# Add business logic
Source: https://docs.forest.app/get-started/add-business-logic
Create actions and computed fields to go beyond CRUD
Forest's real power comes from the business logic you add on top of your data. This step covers the two most common building blocks: actions and computed fields.
## Create your first action
Actions are custom operations your team can trigger from the UI, refunding an order, sending an email, flagging a record for review. You define them in your back-end code, so they can call external APIs, update multiple tables, or trigger any side effect you need.
Add an action to a collection in your back-end:
```javascript theme={null}
agent.customizeCollection('orders', collection =>
collection.addAction('Mark as reviewed', {
scope: 'Single',
execute: async (context, resultBuilder) => {
await context.collection.update(context.filter, { status: 'reviewed' });
return resultBuilder.success('Order marked as reviewed');
},
}),
);
```
Restart your back-end and the action appears in the collection's **Actions** menu.
Actions can have forms with dynamic fields, approval workflows, and granular permissions. See [Code-based actions](/product/process/actions/custom-actions/overview) for the full reference.
## Create a computed field
Computed fields let you display values that don't exist directly in your database, a full name from first and last name, a status derived from multiple columns, a formatted price.
Add a computed field the same way:
```javascript theme={null}
agent.customizeCollection('customers', collection =>
collection.addField('fullName', {
columnType: 'String',
dependencies: ['firstName', 'lastName'],
getValues: records => records.map(r => `${r.firstName} ${r.lastName}`),
}),
);
```
Restart your back-end and the field appears in your back-office like any other field.
Computed fields can also be made writable, filterable, and sortable. See [Fields](/product/process/fields/computed).
## What's next
With actions and computed fields in place, you're ready to build higher-level operational tools: workspaces, workflows, and inboxes.
Build workspaces, workflows, and inboxes for your team
# Architecture
Source: https://docs.forest.app/get-started/connect/architectures/self-hosted
Deploy the Forest back-end in your infrastructure while Forest hosts the UI - the most popular deployment option
The self-hosted architecture gives you full control over your data and code by hosting the Forest back-end in your own infrastructure. This is the **most popular deployment option** for production applications.
## Architecture overview
### How it works
1. **Your users** access the Forest UI hosted at `app.forestadmin.com`
2. **Forest UI** makes API calls to your back-end
3. **Your back-end** runs in your infrastructure (AWS, GCP, Heroku, etc.)
4. **Your back-end** queries your database directly
5. **All data** stays within your infrastructure
**Key benefit**: This architecture follows our privacy by design motto. Your data never leaves your infrastructure.
## Components
| Component | Hosted by | Location | Responsibilities |
| ----------------- | --------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Your Back-end** | You | Your infrastructure | • Runs in Node.js or Ruby • Direct access to your database • Executes business logic (actions, computed fields, hooks) • Handles authentication and authorization • Exposes REST API for Forest UI |
| **Forest UI** | Forest | `app.forestadmin.com` | • Back-office interface for your users • Communicates with your back-end via HTTPS • Receives only schema metadata (no data) • Managed and updated by Forest |
| **Your Database** | You | Your infrastructure | • PostgreSQL, MySQL, MongoDB, etc. • Only accessible to your back-end • No direct connection from Forest • Your data never leaves your infrastructure |
## Back-end deployment
You can deploy the Forest back-end in two ways, depending on your application architecture:
### In-app deployment
With in-app deployment, the Forest back-end becomes part of your existing application. It runs in the same Node.js or Ruby process, sharing the same database connection pool and resources. When you deploy your application, the back-end deploys with it as a single unit.
This approach is particularly valuable when you want to leverage your existing ORM models. Whether you're using Sequelize, Mongoose, or ActiveRecord, Forest can read directly from your model definitions, no need to redefine your schema. You can also reuse your existing business logic, sharing code, services, and utilities between your application and Forest without duplication.
### Standalone deployment
Standalone deployment runs the Forest back-end as a completely separate service. It operates in its own process or container with dedicated resources and its own database connection pool. Your application and the back-office are deployed and scaled independently.
This architecture provides a cleaner separation of concerns. Your application code stays focused on your business logic, while Forest responsibilities are isolated in their own service. The two services communicate through well-defined boundaries, making your overall system architecture simpler to understand and maintain. This is particularly beneficial as your team grows, developers working on the application don't need to think about back-office concerns, and vice versa.
**Note**: Standalone deployment is currently available for Node.js only. If you need standalone deployment with Ruby, please [contact our sales team](https://www.forestadmin.com/contact).
**Recommendation**: We recommend **standalone deployment** for cleaner architecture and better separation of concerns. Use **in-app** if you need to reuse your ORM models or existing business logic.
## Privacy by design
The self-hosted architecture is built with privacy as a core principle. Here's how data flows when a user accesses Forest:
**The key principle:** Forest's servers (`app.forestadmin.com`, `api.forestadmin.com`) only send configuration and UI code to your browser. All data requests go directly from your browser to your back-end behind your firewall, and your data never leaves your infrastructure.
**Looking for a different setup?** Forest also offers **on-premise** deployments where even the Forest UI runs in your own infrastructure. [Contact us](https://www.forestadmin.com/contact) to find the right fit for your organization.
# ActiveRecord datasource
Source: https://docs.forest.app/get-started/connect/data-sources/active-record
Connect Forest to your ActiveRecord models with support for relationships and Live Query
The ActiveRecord datasource enables importing collections from all model classes extending `ActiveRecord::Base`. It automatically respects your ActiveRecord relationships and configurations.
ActiveRecord datasource is only available for Ruby. For Node.js, check the [Sequelize datasource](/get-started/connect/data-sources/sequelize) or [SQL datasource](/get-started/connect/data-sources/sql).
## Basic usage
```ruby theme={null}
require 'forest_admin_datasource_active_record'
def self.setup!
ForestAdminDatasourceActiveRecord::Datasource.new(
{
'adapter' => ENV['DB_ADAPTER'],
'host' => ENV['DB_HOST'],
'username' => ENV['DB_USERNAME'],
'password' => ENV['DB_PASSWORD'],
'database' => ENV['DB_DATABASE'],
}
)
end
```
## Features
The ActiveRecord datasource automatically preserves your ORM configuration:
* **ActiveRecord relationships** - Relationships will be respected
* **Live Query support** - Users with proper permissions can create Live Query components for charts, analytics, and segments using SQL
## Live Query support
Enable SQL-based reporting by setting a connection name identifier when creating the datasource:
```ruby theme={null}
ForestAdminDatasourceActiveRecord::Datasource.new(
database_config,
live_query_connections: 'main_database'
)
```
This allows authorized users to create Live Query charts, analytics, and segments that execute custom SQL directly against your database.
Live Queries execute raw SQL. Ensure proper access controls and review queries before deploying to production.
## Multi-database configuration
For applications using multiple databases, provide a hash mapping display names to Rails connection identifiers:
```ruby theme={null}
ForestAdminDatasourceActiveRecord::Datasource.new(
database_config,
live_query_connections: {
'main_database' => 'primary',
'replica_database' => 'primary_replica'
}
)
```
The configuration keys display in Forest's UI, while the values reference connection names from your `config/database.yml` file.
## Polymorphic relationships
Forest supports polymorphic relationships in ActiveRecord by enabling a specific configuration option on the datasource instance.
### Configuration
To activate polymorphic relationship support, set the second parameter to `true` when instantiating the datasource:
```ruby theme={null}
def self.setup!
database_configuration = Rails.configuration.database_configuration
# Enable polymorphic relations with true parameter
datasource = ForestAdminDatasourceActiveRecord::Datasource.new(
database_configuration[Rails.env],
true
)
@create_agent = ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource)
customize
@create_agent.build
end
```
### Tips
The `id` and `type` fields of polymorphic relationships remain visible in the interface. Hide them through back-end customization or frontend configuration if desired.
### Limitations
#### Collections
* **Removal**: Cannot delete collections that serve as polymorphic relationship targets; attempting this raises: "Cannot remove because it's a potential target of polymorphic relation"
* **Renaming**: Cannot rename collections involved in polymorphic relationships; error states: "Cannot rename collection because it's a target of a polymorphic relation"
#### Fields
* **Removal**: Cannot delete fields (`foreign_key_type`, `foreign_key_id`) used in polymorphic relationships
* **Renaming**: Cannot rename fields involved in these relationships
* **Computed Fields**: Cannot use polymorphic relationships as dependencies for computed fields due to unpredictable target collections
#### Search
Extended search functionality skips polymorphic relationships to prevent excessive resource consumption, generating debug logs about this behavior. Implement custom search behavior via documentation.
## Source code
This connector is open source. Browse the code or contribute on GitHub: [`forest_admin_datasource_active_record`](https://github.com/ForestAdmin/agent-ruby/tree/main/packages/forest_admin_datasource_active_record).
# Airtable
Source: https://docs.forest.app/get-started/connect/data-sources/airtable
Connect Airtable bases to Forest
Only available for Node.js.
The Airtable datasource connects your Airtable bases to Forest, allowing you to browse, search, and manage your Airtable data directly from your back-office.
## Installation
```bash theme={null}
npm install @forestadmin-experimental/datasource-airtable
```
## Basic usage
```javascript theme={null}
import { createAgent } from '@forestadmin/agent';
import { createAirtableDataSource } from '@forestadmin-experimental/datasource-airtable';
const agent = createAgent(options);
agent.addDataSource(
createAirtableDataSource({
apiKey: process.env.AIRTABLE_API_KEY,
baseId: process.env.AIRTABLE_BASE_ID
})
);
```
## Source code
[github.com/ForestAdmin/forestadmin-experimental, datasource-airtable](https://github.com/ForestAdmin/forestadmin-experimental/tree/main/packages/datasource-airtable)
## Source code
This connector is open source. Browse the code or contribute on GitHub: [`@forestadmin-experimental/datasource-airtable`](https://github.com/ForestAdmin/forestadmin-experimental/tree/main/packages/datasource-airtable).
# CosmosDB
Source: https://docs.forest.app/get-started/connect/data-sources/cosmosdb
Connect Azure CosmosDB to Forest
Only available for Node.js.
The CosmosDB datasource connects your Azure CosmosDB instance to Forest, allowing you to browse, search, and manage your CosmosDB data directly from your back-office.
## Installation
```bash theme={null}
npm install @forestadmin-experimental/datasource-cosmos
```
## Basic usage
```javascript theme={null}
import { createAgent } from '@forestadmin/agent';
import { createCosmosDataSource } from '@forestadmin-experimental/datasource-cosmos';
const agent = createAgent(options);
agent.addDataSource(
createCosmosDataSource({
endpoint: process.env.COSMOS_ENDPOINT,
key: process.env.COSMOS_KEY,
databaseId: process.env.COSMOS_DATABASE_ID
})
);
```
## Source code
[github.com/ForestAdmin/forestadmin-experimental, datasource-cosmos](https://github.com/ForestAdmin/forestadmin-experimental/tree/main/packages/datasource-cosmos)
## Source code
This connector is open source. Browse the code or contribute on GitHub: [`@forestadmin-experimental/datasource-cosmos`](https://github.com/ForestAdmin/forestadmin-experimental/tree/main/packages/datasource-cosmos).
# Overview
Source: https://docs.forest.app/get-started/connect/data-sources/custom-datasources/overview
Build a custom datasource for any system without an existing connector, using the translation or replication strategy
When Forest has no built-in connector for your system (a proprietary database, a legacy service, a third-party REST API), you can build your own datasource. There are two strategies, and the right one depends on what your source can do and how fresh the data needs to be.
## Translation vs. Replication
| | [Translation](/get-started/connect/data-sources/custom-datasources/translation) | [Replication](/get-started/connect/data-sources/custom-datasources/replication) |
| --------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| **How it works** | Translates Forest's query interface into your API's query language and calls the source live on every request | Maintains a local copy (replica) of the source data in a cache controlled by the back-end, and queries that copy |
| **Data freshness** | Always live: every request hits the source | Eventually consistent: refreshed on a schedule, by polling, or via push/webhooks |
| **Query translation** | You implement it (filters, sort, pagination, aggregation) | None: filtering, sorting, search, and charts work out of the box against the cache |
| **Effort** | High: a full-featured translation layer often exceeds 1000 lines | Lower: you mostly implement how data is fetched and (optionally) written back |
| **Best for** | Sources that already support rich querying and where data must always be live | Slow, rate-limited, or query-poor APIs where a cached copy is acceptable |
| **Availability** | Node.js and Ruby | Node.js only |
## Choosing a strategy
* Reach for **Translation** when your source is a capable database or API that can filter, sort, and paginate efficiently, and you need every read to reflect the latest state.
* Reach for **Replication** when the source API is slow, rate-limited, or hard to query, and a periodically synced copy is good enough. It is the simpler path and gives you Forest's full feature set with no query translation.
**Don't see the datasource you need?** [Contact us](https://www.forestadmin.com/contact) to request a connector, or share your own through the [Forest experimental repository](https://github.com/ForestAdmin/forestadmin-experimental).
# Replication
Source: https://docs.forest.app/get-started/connect/data-sources/custom-datasources/replication
Build custom datasources that maintain a local cache of external data for better performance
Replication datasources are only available for Node.js.
The replication strategy maintains a copy of target API data in an internal cache controlled by your back-end, rather than querying the API in real-time.
## Overview
### Key advantages
* **No query translation**: No query translation logic required
* **Performant**: Eliminates synchronous network calls to the target API
* **Feature-complete**: Charts, filtering, and search work out of the box
* **Flexible**: Implement custom logic for fetching target API data
* **Robust**: Recover bad states by reconstructing the replica from scratch
### Minimal implementation
```javascript Node.js theme={null}
const { createReplicaDataSource } = require('@forestadmin/datasource-replica');
const axios = require('axios');
const myCustomDataSource = createReplicaDataSource({
pullDumpHandler: async () => {
const url = 'https://jsonplaceholder.typicode.com';
const collections = ['posts', 'comments', 'albums', 'photos', 'users'];
const entries = [];
for (const collection of collections) {
const response = await axios.get(`${url}/${collection}`);
entries.push(...response.data.map(record => ({ collection, record })));
}
return { more: false, entries };
},
});
agent.addDatasource(myCustomDataSource);
```
This basic implementation fetches all records at startup but doesn't update them afterward.
### Known limitations & solutions
| Limitation | Solution |
| ---------------------------------------------------- | ---------------------------------- |
| Full data dump required at each startup | Implement persistent cache |
| Empty collections and foreign keys not auto-detected | Provide explicit schema definition |
| Data never updates after initial import | Implement update handlers |
| Read-only data | Implement write handlers |
| Nested fields and arrays in API responses | Use record flattener utility |
## Persistent cache
The Forest Node.js back-end uses a SQL database as its underlying cache mechanism. By default, an in-memory SQLite database is used.
### Limitations of in-memory cache
The default in-memory approach presents two main challenges:
1. **Extended startup time**: The back-end must re-fetch all data from the target API on each restart
2. **High memory consumption**: All data remains in memory, which becomes problematic for large datasets
### When to use persistent cache
Depending on which API you are targeting, it may be absolutely fine to use an in-memory cache for smaller datasets. However, larger systems like CRMs or databases containing millions of records benefit significantly from persistent storage.
### Cache initialization
Forest will automatically detect when the schema of the tables in the caching database does not match the schema of the target API. When mismatches occur, tables and indexes are dropped, recreated, and repopulated from the target API.
### Configuration options
* **`cacheInto`**: Accepts a connection string or configuration object for the SQL connector
* **`cacheNamespace`**: Prefixes table names, useful for sharing databases or running multiple replicas
**Important:** No locking mechanism currently exists for concurrent writes when multiple back-end instances share the same cache configuration.
### SQLite file example
```javascript Node.js theme={null}
const myCustomDataSource = createReplicaDataSource({
cacheInto: 'sqlite:/tmp/my-cache.db',
pullDumpHandler: async () => {
return { more: false, entries: [] };
},
});
```
### PostgreSQL example
```javascript Node.js theme={null}
const myCustomDataSource = createReplicaDataSource({
cacheInto: {
uri: 'postgres://xxxx:[email protected]/neondb',
sslMode: 'verify',
},
cacheNamespace: 'my-custom-data-source',
pullDumpHandler: async () => {
return { more: false, entries: [] };
},
});
```
## Updating the replica
Real-world scenarios require keeping the Forest back-end to display up-to-date data.
### Three update methods
Use these approaches independently or combine them:
1. **Scheduled rebuilds** - Refetch all records periodically
2. **Change polling** - Uses Forest events to detect modifications
3. **Change pushing** - Leverages target API events via webhooks
## Scheduled rebuilds
Scheduled rebuilds represent the simplest approach for updating replica data by fetching all records from a target API at regular intervals. This method works with any API but is less efficient for large datasets since it requires fetching all records regardless of changes.
### Configuration options
**`pullDumpOnRestart`**: When set to `true`, data fetches on each back-end startup. This is always enabled for default in-memory cache implementations.
**`pullDumpOnSchedule`**: Accepts cron-like schedule patterns for periodic updates. For example: `['0 0 0 * * *', '0 30 18 * * *']` triggers daily at midnight and 6:30 PM.
### Schedule syntax
The system uses the croner NPM package for schedule parsing with this format:
```
┌─ second (0-59)
│ ┌─ minute (0-59)
│ │ ┌─ hour (0-23)
│ │ │ ┌─ day of month (1-31)
│ │ │ │ ┌─ month (1-12)
│ │ │ │ │ ┌─ day of week (0-6)
* * * * * *
```
Common examples:
* `* * * * * *` - Every second
* `0 * * * * *` - Every minute
* `0 0 9 * * 1` - Mondays at 9am
### Handler implementation
The `pullDumpHandler` returns entries for import and supports pagination. The request object provides `previousDumpState` (for change detection), `cache` access, and `reasons` (startup/schedule triggers).
The response object specifies entries to import, pagination via `more` flag, and state persistence through `nextDumpState` and `nextDeltaState` fields.
**Key advantage:** Old data remains available to users until new data processing completes, preventing service disruption.
## Change polling
Change polling is a strategy for updating replica data sources by fetching only records that have changed, rather than pulling all data from the target API on each update.
### When to poll for changes
Four triggering events are available:
1. **pullDeltaOnRestart**: Handler executes when the back-end restarts
2. **pullDeltaOnSchedule**: Handler runs on a cron-like schedule (same syntax as pullDumpOnSchedule)
3. **pullDeltaOnBeforeAccess**: Handler executes before each datasource access; GUI blocks until completion
4. **pullDeltaOnAfterWrite**: Handler executes after each write operation; GUI blocks until completion
**Optional delay feature:** `pullDeltaOnBeforeAccessDelay` (milliseconds) groups multiple requests sent during the delay period, reducing calls to your target API. Set to 0 to disable.
### Handler implementation
Implement a `pullDeltaHandler` function that receives a request object containing:
* `previousDeltaState`: Persisted state from previous calls
* `affectedCollections`: Collections being accessed or written to
* `cache`: Interface for reading cached data
* `reasons`: Array explaining why the handler was invoked
The handler should return a response object with:
* `more`: Boolean indicating if additional changes exist (triggers immediate re-call)
* `nextDeltaState`: State persisted for subsequent handler invocations
* `newOrUpdatedEntries`: Records created or modified since last call
* `deletedEntries`: Records removed since last call
## Push & webhooks
The push strategy keeps replicas up-to-date when APIs expose change-following capabilities through webhooks, WebSockets, long polling, or similar mechanisms.
### Handler programming
Unlike the pull strategy, developers are responsible for setting up subscriptions to the target API. The back-end calls your handler during startup to establish these subscriptions, and you send changes to the back-end for replica updates.
### Request object structure
The request provides:
* `getPreviousDeltaState()`: Fetches delta state asynchronously, useful when mixing push and pull strategies
* `cache`: Interface for reading from the cache
### onChange payload structure
The payload includes:
* `nextDeltaState` (optional): Updated delta state for recovery on back-end restart
* `newOrUpdatedEntries`: Array of created/updated records with collection and record data
* `deletedEntries`: Array of deleted records (full record not required)
### Example: CouchDB change feed
Using the nano library to subscribe to CouchDB's changes stream:
```javascript Node.js theme={null}
const { createReplicaDataSource } = require('@forestadmin/datasource-replica');
const nano = require('nano');
const myCustomDataSource = createReplicaDataSource({
pushDeltaHandler: async (request, onChanges) => {
const stream = nano.db.changesAsStream('books', {
include_docs: true,
since: await request.getPreviousDeltaState(),
});
stream.on('data', change => {
onChanges({
nextDeltaState: change.seq,
newOrUpdatedEntries: !change.deleted
? [{ collection: 'books', record: { _id: change.id, ...change.doc } }]
: [],
deletedEntries: change.deleted
? [{ collection: 'books', record: { _id: change.id } }]
: [],
});
});
},
});
```
### Example: webhook implementation
Using Express to receive webhooks on a separate port:
```javascript Node.js theme={null}
const { createReplicaDataSource } = require('@forestadmin/datasource-replica');
const myCustomDataSource = createReplicaDataSource({
pushDeltaHandler: async (request, onChanges) => {
const app = express();
app.use(express.json());
app.post('/webhooks/on-book-:type(created|change|deleted)', (req, res) => {
onChanges({
newOrUpdatedEntries:
req.params.type === 'created' || req.params.type === 'change'
? [{ collection: 'book', record: req.body }]
: [],
deletedEntries:
req.params.type === 'deleted'
? [{ collection: 'book', record: { id: req.body.id } }]
: [],
});
res.status(204).send();
});
app.listen(3000);
},
});
```
## Schema & references
### Schema auto-discovery
When no explicit schema is provided, the back-end attempts to auto-discover structure from imported data. However, this approach has limitations:
* Empty collections cannot be imported
* Performance overhead from sampling data
* Primary keys must be named `id`
* Composite primary keys unsupported
* Foreign keys aren't automatically detected
### Providing a schema
Supply a schema via the `createReplicaDataSource` function to avoid auto-discovery limitations. The schema can be static or dynamically generated through Promises or async functions.
### Schema syntax
**Collection definition** includes:
* `name`: Collection identifier
* `fields`: Object containing field definitions, supporting nested objects and arrays
**Field definition** properties (type required):
* Type options: Boolean, Integer, Number, String, Date, Dateonly, Timeonly, Binary, Enum, Json, Point, Uuid
* `defaultValue`: Initial value for new records
* `enumValues`: Possible values for Enum types
* `isPrimaryKey`: Marks primary key fields
* `isReadOnly`: Read-only designation
* `unique`: Uniqueness constraint
* `validation`: Array of validation rules
* `reference`: Defines foreign key relationships with target collection details
### Handling complex data
**Flatten mode** addresses limitations with nested structures and arrays. Options include `auto` or `manual` modes, similar to Mongoose driver configuration.
When enabled, flatten mode:
* Automatically transforms nested records
* Creates virtual collections for arrays
* Uses `@@@` as field separator in flattened output
* Generates synthetic IDs and foreign keys for relationships
**Important:** Original records in handlers remain unflattened; transformation occurs during cache import only.
## Write handlers
### Implementation requirements
Three optional handlers can be implemented: `createRecordHandler`, `updateRecordHandler`, and `deleteRecordHandler`. Omit any handler for operations not needed.
The `createRecordHandler` function uniquely supports return values, which proves useful when the target API auto-generates record IDs.
### Code example
```javascript Node.js theme={null}
const axios = require('axios');
const { createReplicaDataSource } = require('@forestadmin/datasource-replica');
const url = 'https://jsonplaceholder.typicode.com';
const myCustomDataSource = createReplicaDataSource({
// Record synchronization implementation...
createRecordHandler: async (collectionName, record) => {
const response = await axios.post(`${url}/${collectionName}`, record);
return response.data;
},
updateRecordHandler: async (collectionName, record) => {
await axios.put(`${url}/${collectionName}/${record.id}`, record);
},
deleteRecordHandler: async (collectionName, record) => {
await axios.delete(`${url}/${collectionName}/${record.id}`);
},
});
```
### Key takeaways
* All three write handlers remain optional
* Create handlers can return newly generated IDs from the API
* Update and delete handlers perform remote operations without returning values
* The handlers abstract the communication layer between Forest and external APIs
Want to share your custom datasource with the community? Check out the [Forest experimental repository](https://github.com/ForestAdmin/forestadmin-experimental) to contribute.
# Translation
Source: https://docs.forest.app/get-started/connect/data-sources/custom-datasources/translation
Build your own datasource by translating Forest queries into your API's query language
Translation datasources require advanced knowledge of Forest's query interface.
The translation strategy is an advanced approach for creating your own datasources that involves translating Forest's query interface into the target API's query language.
## Overview
A full-featured query translation module typically exceeds 1000 lines of code. This approach suits full-featured databases and requires deep understanding of Forest's internals.
### Key steps
Implementing this strategy requires completing three main phases:
1. **Structure declaration** - Define the data structure
2. **Capabilities declaration** - Specify API capabilities
3. **Translation layer implementation** - Code the actual query translation
### Minimal example
```javascript Node.js theme={null}
class MyCollection extends BaseCollection {
constructor(dataSource) {
super('myCollection', dataSource);
// Add fields with type, filtering, and sorting capabilities
}
async list(caller, filter, projection) {
// Translate Forest query to API format
const params = QueryGenerator.generateListQueryString(filter, projection);
const response = axios.get('https://my-api/my-collection', { params });
return response.body.items;
}
}
```
```ruby Ruby theme={null}
class MyCollection < ForestAdminDatasourceToolkit::Collection
def initialize(datasource)
super(datasource, 'MyCollection')
# Add fields with type, filtering, and sorting capabilities
end
def list(caller, filter, projection)
# Translate Forest query to API format
params = QueryGenerator.generate_list_query_string(filter, projection)
response = HTTParty.get('https://my-api/my-collection', query: params)
response.parsed_response['items']
end
end
```
## Structure declaration
### Columns
Define fields with types, validation, and default values:
```javascript Node.js theme={null}
const { BaseCollection } = require('@forestadmin/datasource-toolkit');
class MovieCollection extends BaseCollection {
constructor() {
// [...]
this.addField('id', {
type: 'Column',
columnType: 'Number',
isPrimaryKey: true,
});
this.addField('title', {
type: 'Column',
columnType: 'String',
validation: [{ operator: 'Present' }],
});
this.addField('mpa_rating', {
type: 'Column',
columnType: 'Enum',
enumValues: ['G', 'PG', 'PG-13', 'R', 'NC-17'],
defaultValue: 'G',
});
this.addField('stars', {
type: 'Column',
columnType: [{ firstName: 'String', lastName: 'String' }],
});
}
}
```
```ruby Ruby theme={null}
class MovieCollection < ForestAdminDatasourceToolkit::Collection
include ForestAdminDatasourceToolkit::Schema
def initialize(datasource)
super(datasource, 'Movie')
add_field('id', ColumnSchema.new(
column_type: 'Number',
is_primary_key: true
))
add_field('title', ColumnSchema.new(
column_type: 'String',
filter_operators: [Operators::PRESENT]
))
add_field('mpa_rating', ColumnSchema.new(
column_type: 'Enum',
enum_values: %w[G PG PG-13 R NC-17],
default_value: 'G'
))
add_field('stars', ColumnSchema.new(
column_type: [
{
'firstName' => 'String',
'lastName' => 'String'
}
]
))
end
end
```
### Typing
The typing system for columns is the same as the one used when declaring fields in the back-end customization step.
### Validation
Forest permits declaring validation rules on primitive-type fields. These rules validate records during creation/updating in the back-office interface.
The validation API mirrors the condition tree structure but excludes a "field" entry. Example validation clause:
```json theme={null}
{
"aggregator": "and",
"conditions": [
{ "operator": "present" },
{ "operator": "like", "value": "found%" },
{ "operator": "today" }
]
}
```
### Relationships
**Important:** Only intra-datasource relationships belong at the collection level. For inter-datasource relationships, use jointures during customization.
Data sources using the query translation strategy require careful implementation for relationships.
```javascript Node.js theme={null}
const { BaseCollection } = require('@forestadmin/datasource-toolkit');
class MovieCollection extends BaseCollection {
constructor() {
// [...]
this.addField('director', {
type: 'ManyToOne',
foreignCollection: 'people',
foreignKey: 'directorId',
foreignKeyTarget: 'id',
});
this.addField('actors', {
type: 'ManyToMany',
foreignCollection: 'people',
throughCollection: 'actorsOnMovies',
originKey: 'movieId',
originKeyTarget: 'id',
foreignKey: 'actorId',
foreignKeyTarget: 'id',
});
}
}
```
```ruby Ruby theme={null}
class MovieCollection < ForestAdminDatasourceToolkit::Collection
def initialize(datasource)
super(datasource, 'Movie')
add_field('director', ManyToOneSchema.new(
foreign_key: 'director_id',
foreign_key_target: 'id',
foreign_collection: 'People'
))
add_field('actors', ManyToManySchema.new(
origin_key: 'movie_id',
origin_key_target: 'id',
foreign_key: 'actor_id',
foreign_key_target: 'id',
foreign_collection: 'People',
through_collection: 'ActorsOnMovies'
))
end
end
```
## Capabilities declaration
Data source implementers don't need to translate every possible query type. Forest ensures only supported query features are available by having collections declare capabilities on construction.
### Required features
All datasources must support:
* Listing records
* `And` nodes in condition trees
* `Or` nodes in condition trees
* `Equal` operator on primary keys
* Paging (`skip`, `limit`)
**Note:** Translating the `Or` node is a strong constraint, as many backends will not allow it: providing a working implementation may require making multiple queries and recombining the results.
### Optional features (opt-in)
| Unlocked feature | Required capabilities |
| ---------------------------------- | ----------------------------------------------------- |
| Pagination page count display | Count |
| Charts | All field support in Aggregation |
| Relations | `In` on primary/foreign keys |
| Select all for actions/delete | `In` and `NotIn` on primary key |
| Frontend filters, scopes, segments | Per-field operator support |
| Operator emulation | `In` on primary keys |
| Search emulation | `Contains` on strings; `Equal` on numbers/UUIDs/enums |
### UI filter requirements by field type
To unlock GUI filtering:
* **Boolean:** `Equal`, `NotEqual`, `Present`, `Blank`
* **Date:** All date operators
* **Enum:** `Equal`, `NotEqual`, `Present`, `Blank`, `In`
* **Number:** `Equal`, `NotEqual`, `Present`, `Blank`, `In`, `GreaterThan`, `LessThan`
* **String:** `Equal`, `NotEqual`, `Present`, `Blank`, `In`, `StartsWith`, `EndsWith`, `Contains`, `NotContains`
* **UUID:** `Equal`, `NotEqual`, `Present`, `Blank`
### Collection-level capabilities
#### Count
Enables pagination widget to display total page count. Requires implementing the `aggregate` method:
```javascript Node.js theme={null}
class MyCollection extends BaseCollection {
constructor() {
this.enableCount();
}
}
```
```ruby Ruby theme={null}
class MyCollection < ForestAdminDatasourceToolkit::Collection
def initialize
# [...]
enable_count
end
end
```
#### Search
Allows custom search implementation instead of default condition tree approach. Useful for full-text search (ElasticSearch, etc.):
```javascript Node.js theme={null}
class MyCollection extends BaseCollection {
constructor() {
this.enableSearch();
}
}
```
```ruby Ruby theme={null}
class MyCollection < ForestAdminDatasourceToolkit::Collection
def initialize
# [...]
enable_search
end
end
```
#### Segments
Define segments at datasource level when condition trees are insufficient or segments are shared across projects:
```javascript Node.js theme={null}
class MyCollection extends BaseCollection {
constructor() {
this.addSegments(['Active records', 'Deleted records']);
// All filter-accepting methods MUST handle segment fields
}
}
```
```ruby Ruby theme={null}
class MyCollection < ForestAdminDatasourceToolkit::Collection
def initialize
# [...]
add_segments(['Active records', 'Deleted records'])
# All filter-accepting methods MUST handle segment fields
end
end
```
### Field-level capabilities
#### Write support
Mark fields as read-only:
```javascript Node.js theme={null}
this.addField('id', {
isReadOnly: true,
});
```
```ruby Ruby theme={null}
add_field('id', {
is_read_only: true
})
```
#### Filtering operators
Declare supported operators per field:
```javascript Node.js theme={null}
this.addField('id', {
filterOperators: new Set([
'Equal',
// additional operators
]),
});
```
```ruby Ruby theme={null}
add_field('id', {
filter_operators: ['Equal']
})
```
#### Sort support
Flag sortable fields:
```javascript Node.js theme={null}
this.addField('id', {
isSortable: true,
});
```
```ruby Ruby theme={null}
add_field('id', {
is_sortable: true
})
```
## Read implementation
### Emulation strategy
Emulation enables rapid development by allowing features to be tested in Node.js before optimization. This approach trades performance for faster iteration.
### Basic list implementation
```javascript Node.js theme={null}
const { BaseCollection } = require('@forestadmin/datasource-toolkit');
const axios = require('axios');
class MyCollection extends BaseCollection {
async list(caller, filter, projection) {
// Fetch all records
const response = await axios.get('https://my-api/my-collection');
const result = response.data.items;
// Apply in-process emulation
if (filter.conditionTree)
result = filter.conditionTree.apply(result, this, caller.timezone);
if (filter.sort) result = filter.sort.apply(result);
if (filter.page) result = filter.page.apply(result);
return projection.apply(result);
}
}
```
```ruby Ruby theme={null}
class MyCollection < ForestAdminDatasourceToolkit::Collection
def list(caller, filter, projection)
# Fetch all records on all requests (inefficient approach)
response = HTTParty.get('https://my-api/my-collection')
result = response.parsed_response['items']
# Apply filtering, sorting, pagination sequentially
result = filter.condition_tree.apply(result, self, caller.timezone) if filter.condition_tree
result = filter.sort.apply(result) if filter.sort
result = filter.page.apply(result) if filter.page
# Handle unsupported operations
raise 'Unsupported feature' if filter.segment || filter.search
projection.apply(result)
end
end
```
### Aggregate method
The `aggregate` method handles both record counting and chart data generation:
```javascript Node.js theme={null}
async aggregate(caller, filter, aggregation, limit) {
const records = await this.list(caller, filter, aggregation.projection);
return aggregation.apply(records, caller.timezone, limit);
}
```
```ruby Ruby theme={null}
def aggregate(caller, filter, aggregation, limit = nil)
records = list(caller, filter, aggregation.projection)
aggregation.apply(records, caller.timezone, limit)
end
```
### Optimization: count queries
Handle count operations separately if your API supports efficient counting:
```javascript Node.js theme={null}
async aggregate(caller, filter, aggregation, limit) {
if (aggregation.operation === 'Count' && aggregation.groups.length === 0) {
return [{ value: await this.count(caller, filter) }];
}
// Handle general case
}
```
```ruby Ruby theme={null}
def aggregate(caller, filter, aggregation, limit = nil)
# Optimize count-only queries
if aggregation.operation == 'Count' && aggregation.groups.empty? && !aggregation.field
return [{ 'value' => count(caller, filter) }]
end
# Handle general aggregation case
end
```
## Write implementation
Making your records editable is achieved by implementing the `create`, `update` and `delete` methods.
**Important:** The three write methods accept filter parameters, but unlike the `list` method, pagination support is unnecessary.
```javascript Node.js theme={null}
const { BaseCollection } = require('@forestadmin/datasource-toolkit');
const axios = require('axios'); // client for the target API
/** Naive implementation of create, update and delete on a REST API */
class MyCollection extends BaseCollection {
constructor() {
this.addField('id', { /* ... */ isReadOnly: true });
this.addField('title', { /* ... */ isReadOnly: false });
}
async create(caller, records) {
const promises = records.map(async record => {
const response = await axios.post('https://my-api/my-collection', record);
return response.data;
});
return Promise.all(promises); // Must return newly created records
}
async update(caller, filter, patch) {
const recordIds = await this.list(caller, filter, ['id']); // Retrieve ids
const promises = recordIds.map(async ({ id }) => {
await axios.patch(`https://my-api/my-collection/${id}`, patch);
});
await Promise.all(promises);
}
async delete(caller, filter) {
const recordIds = await this.list(caller, filter, ['id']); // Retrieve ids
const promises = recordIds.map(async ({ id }) => {
await axios.delete(`https://my-api/my-collection/${id}`);
});
await Promise.all(promises);
}
}
```
```ruby Ruby theme={null}
class MyCollection < ForestAdminDatasourceToolkit::Collection
include ForestAdminDatasourceToolkit::Schema
def initialize(datasource)
super(datasource, 'MyCollection')
add_field('id', ColumnSchema.new(is_read_only: true))
add_field('title', ColumnSchema.new(is_read_only: false))
end
def create(caller, records)
records.map do |record|
response = HTTParty.post('https://my-api/my-collection', body: record.to_json)
response.parsed_response
end
end
def update(caller, filter, patch)
record_ids = list(caller, filter, ForestAdminDatasourceToolkit::Components::Query::Projection.new(['id']))
record_ids.each do |record|
HTTParty.patch("https://my-api/my-collection/#{record['id']}", body: patch.to_json)
end
end
def delete(caller, filter)
record_ids = list(caller, filter, ForestAdminDatasourceToolkit::Components::Query::Projection.new(['id']))
record_ids.each do |record|
HTTParty.delete("https://my-api/my-collection/#{record['id']}")
end
end
end
```
### Method details
* **create()**: Must return the newly created records with all fields populated
* **update()**: Receives filter and patch object; updates matching records
* **delete()**: Receives filter; deletes all matching records
## Intra-datasource relationships
When building your own datasources using the translation strategy, collections must handle intra-datasource relationships that are declared in their structure.
### Relationship types and requirements
**Automatic handling:**
* `one-to-many` relationships
* `many-to-many` relationships
For these types, Forest will automatically call the destination collection with a valid filter, requiring no additional implementation work.
**Manual implementation required:**
* `many-to-one` relationships
* `one-to-one` relationships
These require developers to make all fields from the target collection available on the source collection (under a prefix).
### Handling prefixed fields
When a `many-to-one` relationship exists, the collection must accept references using dot notation throughout its operations.
#### Structure declaration example
```javascript Node.js theme={null}
class MovieCollection extends BaseCollection {
constructor() {
super('movies', null);
this.addField('director', {
type: 'ManyToOne',
foreignCollection: 'people',
foreignKey: 'directorId',
foreignKeyTarget: 'id',
});
}
}
```
```ruby Ruby theme={null}
class MovieCollection < ForestAdminDatasourceToolkit::Collection
def initialize(datasource)
super(datasource, 'Movie')
add_field('director', ManyToOneSchema.new(
foreign_key: 'director_id',
foreign_key_target: 'id',
foreign_collection: 'People'
))
end
end
```
#### Query example
The system can execute calls using both source and target collection fields:
```javascript Node.js theme={null}
await dataSource.getCollection('movies').list(
caller,
{
conditionTree: {
aggregator: 'And',
conditions: [
{ field: 'title', operator: 'Equal', value: 'E.T.' },
{ field: 'director:firstName', operator: 'Equal', value: 'Steven' },
]
},
sort: [{ field: 'director:birthDate', ascending: true }]
},
['id', 'title', 'director:firstName', 'director:lastName']
);
```
```ruby Ruby theme={null}
datasource.collection('Movie').list(
caller,
ForestAdminDatasourceToolkit::Components::Query::Filter.new(
condition_tree: ForestAdminDatasourceToolkit::Components::Query::ConditionTree::ConditionTreeBranch.new(
aggregator: 'and',
conditions: [
ForestAdminDatasourceToolkit::Components::Query::ConditionTree::ConditionTreeLeaf.new(
field: 'title',
operator: 'Equal',
value: 'E.T.'
),
ForestAdminDatasourceToolkit::Components::Query::ConditionTree::ConditionTreeLeaf.new(
field: 'director:firstName',
operator: 'Equal',
value: 'Steven'
)
]
),
sort: ForestAdminDatasourceToolkit::Components::Query::Sort.new(
[{ field: 'director:birthDate', ascending: true }]
)
),
ForestAdminDatasourceToolkit::Components::Query::Projection.new(
['id', 'title', 'director:firstName', 'director:lastName']
)
)
```
#### Expected response structure
```json theme={null}
{
"id": 34,
"title": "E.T",
"director": { "firstName": "Steven", "lastName": "Spielberg" }
}
```
### Implementation scope
Developers implementing your own datasources must handle prefixed field references in:
* **Filters** (condition trees)
* **Projections** (field selections)
* **Aggregations** (calculation operations)
Want to share your datasource with the community? Check out the [Forest experimental repository](https://github.com/ForestAdmin/forestadmin-experimental) to contribute.
# Dummy datasource
Source: https://docs.forest.app/get-started/connect/data-sources/dummy
In-memory test datasource for development, testing, and demonstrations
The Dummy datasource is an in-memory test datasource for **testing and learning Forest back-end functionality only**. It is not intended for production use.
This datasource is strictly for development, testing, and learning purposes. All data is stored in memory and resets on application restart.
## Basic usage
```javascript theme={null}
import { createAgent } from '@forestadmin/agent';
import { createDummyDataSource } from '@forestadmin/datasource-dummy';
const agent = createAgent(options);
agent.addDataSource(createDummyDataSource());
```
## Sample data
The dummy datasource provides 4 pre-configured collections with realistic relationships:
### Collections
**persons** (Authors)
* `id` (Number, Primary Key)
* `firstName` (String)
* `lastName` (String)
Sample: Edward O. Thorp, Isaac Asimov, Roberto Saviano, Stephen King
**books**
* `id` (Number, Primary Key)
* `title` (String)
* `publication` (Date)
* `authorId` (Number)
Sample: Beat the dealer, Foundation, Gomorrah, Misery, Christine, Running Man
**libraries**
* `id` (Number, Primary Key)
* `name` (String)
Sample: Mollat, Cultura, Amazon
**librariesBooks** (Junction table)
* `bookId` (Number, Primary Key)
* `libraryId` (Number, Primary Key)
### Relationships
The datasource demonstrates all common relationship types:
* **One-to-Many**: persons → books (one author has many books)
* **Many-to-One**: books → persons (many books have one author)
* **Many-to-Many**: books ↔ libraries (via librariesBooks junction table)
## Features
* **Full CRUD operations** - Create, read, update, and delete records
* **Filtering** - 16+ filter operators (Contains, StartsWith, Equal, In, etc.)
* **Sorting** - All columns are sortable
* **Pagination** - Full pagination support
* **Relationships** - Demonstrates 1-to-Many, Many-to-One, and Many-to-Many relationships
## Source code
This connector is open source. Browse the code or contribute on GitHub: [`@forestadmin/datasource-dummy`](https://github.com/ForestAdmin/agent-nodejs/tree/main/packages/datasource-dummy).
# Elasticsearch
Source: https://docs.forest.app/get-started/connect/data-sources/elasticsearch
Connect to Elasticsearch indices with query and aggregation support
Only available for Node.js.
The Elasticsearch datasource connects to Elasticsearch indices, allowing you to query, filter, and aggregate your data through Forest.
## Basic usage
```javascript theme={null}
import { createAgent } from '@forestadmin/agent';
import { createElasticsearchDataSource } from '@forestadmin-experimental/datasource-elasticsearch';
const agent = createAgent(options);
agent.addDataSource(
createElasticsearchDataSource({
node: 'http://localhost:9200',
auth: {
username: 'elastic',
password: 'your-password'
}
})
);
```
## Configuration options
### Index-based collections
Map each Elasticsearch index to a Forest collection:
```javascript theme={null}
createElasticsearchDataSource({
node: 'http://localhost:9200',
indices: [
{
name: 'products',
fields: {
id: { type: 'Number' },
name: { type: 'String' },
price: { type: 'Number' },
category: { type: 'String' },
tags: { type: 'StringList' },
created_at: { type: 'Date' }
}
}
]
})
```
### Template-based collections
Use Elasticsearch index templates to automatically generate collections:
```javascript theme={null}
createElasticsearchDataSource({
node: 'http://localhost:9200',
templates: [
{
name: 'logs-*',
fields: {
timestamp: { type: 'Date' },
level: { type: 'String' },
message: { type: 'String' }
}
}
]
})
```
### Authentication
The datasource supports multiple authentication methods:
```javascript theme={null}
// Basic authentication
createElasticsearchDataSource({
node: 'https://elastic.example.com:9200',
auth: {
username: 'elastic',
password: 'password'
}
})
// API key authentication
createElasticsearchDataSource({
node: 'https://elastic.example.com:9200',
auth: {
apiKey: 'your-api-key'
}
})
// Bearer token
createElasticsearchDataSource({
node: 'https://elastic.example.com:9200',
auth: {
bearer: 'your-bearer-token'
}
})
```
### SSL/TLS configuration
```javascript theme={null}
createElasticsearchDataSource({
node: 'https://elastic.example.com:9200',
tls: {
ca: fs.readFileSync('./ca.crt'),
rejectUnauthorized: true
}
})
```
## Field types
The datasource supports the following field types:
* `String` - Text fields
* `Number` - Numeric fields
* `Boolean` - Boolean fields
* `Date` - Date/datetime fields
* `StringList` - Array of strings
* `Json` - Nested objects (stored as JSON)
Arrays must be explicitly specified using the `List` suffix (e.g., `StringList`).
## Query capabilities
### Filtering
The datasource supports 16+ filter operators:
* `Equal`, `NotEqual`
* `GreaterThan`, `LessThan`
* `In`, `NotIn`
* `Contains`, `StartsWith`, `EndsWith`
* `Present`, `Blank`
* `Match` (full-text search)
* And more
### Sorting
Sort records by any field:
```javascript theme={null}
// Forest UI automatically generates queries with sorting
// Example: GET /products?sort=-price (descending by price)
```
### Aggregations
The datasource supports Elasticsearch aggregations for charts and analytics:
* Count
* Sum
* Average
* Min/Max
* Terms aggregation (group by)
### Native SQL queries
Execute Elasticsearch SQL queries directly:
```javascript theme={null}
agent.customizeCollection('products', collection => {
collection.addAction('Run SQL Query', {
scope: 'Global',
execute: async (context) => {
const result = await context.dataSource.executeNativeQuery(
'SELECT category, AVG(price) FROM products GROUP BY category'
);
return result;
}
});
});
```
## Live Query
This datasource supports Live Query for advanced filtering and segmentation. [Learn more about Live Queries](/product/process/segments/smart-segments/overview)
## Limitations
* **No joins** - Relationships between indices are not supported
* **No object sub-models** - Nested objects are flattened to JSON fields
* **No geospatial types** - Point fields are not supported
* **Array specification** - Arrays must be manually specified with `List` suffix
* **Read-heavy** - Optimized for search and analytics, not transactional workloads
## Elasticsearch 8.x support
This datasource is built for Elasticsearch 8.x and uses the official `@elastic/elasticsearch` client. It supports:
* Index templates
* Data streams
* Modern query DSL
* SQL API
* Security features (authentication, TLS)
## Source code
This connector is open source. Browse the code or contribute on GitHub: [`@forestadmin-experimental/datasource-elasticsearch`](https://github.com/ForestAdmin/forestadmin-experimental/tree/main/packages/datasource-elasticsearch).
# GraphQL / Hasura
Source: https://docs.forest.app/get-started/connect/data-sources/graphql-hasura
Connect GraphQL or Hasura to Forest
Only available for Node.js.
The GraphQL / Hasura datasource connects your GraphQL API or Hasura instance to Forest, allowing you to browse and manage your data through your back-office without writing custom integration code.
## Installation
```bash theme={null}
npm install @forestadmin-experimental/datasource-graphql-hasura
```
## Basic usage
```javascript theme={null}
import { createAgent } from '@forestadmin/agent';
import { createGraphqlHasuraDataSource } from '@forestadmin-experimental/datasource-graphql-hasura';
const agent = createAgent(options);
agent.addDataSource(
createGraphqlHasuraDataSource({
url: process.env.GRAPHQL_ENDPOINT,
headers: {
'x-hasura-admin-secret': process.env.HASURA_ADMIN_SECRET
}
})
);
```
## Source code
[github.com/ForestAdmin/forestadmin-experimental, datasource-graphql-hasura](https://github.com/ForestAdmin/forestadmin-experimental/tree/main/packages/datasource-graphql-hasura)
## Source code
This connector is open source. Browse the code or contribute on GitHub: [`@forestadmin-experimental/datasource-graphql-hasura`](https://github.com/ForestAdmin/forestadmin-experimental/tree/main/packages/datasource-graphql-hasura).
# Hubspot
Source: https://docs.forest.app/get-started/connect/data-sources/hubspot
Connect to Hubspot CRM data including contacts, companies, deals, and tickets
Only available for Node.js. This is a read-only datasource.
The Hubspot datasource connects to your Hubspot CRM through the HubSpot API, allowing you to browse and search contacts, companies, deals, tickets, and other CRM objects in Forest.
## Basic usage
```javascript theme={null}
import { createAgent } from '@forestadmin/agent';
import { createHubSpotDataSource } from '@forestadmin-experimental/datasource-hubspot-translation';
const agent = createAgent(options);
agent.addDataSource(
createHubSpotDataSource({
accessToken: process.env.HUBSPOT_ACCESS_TOKEN
})
);
```
## Configuration options
### Access token
Create a Private App in HubSpot to get an access token:
1. Go to Settings → Integrations → Private Apps in your HubSpot account
2. Create a new Private App
3. Grant necessary scopes (contacts, companies, deals, tickets, etc.)
4. Copy the access token
```javascript theme={null}
createHubSpotDataSource({
accessToken: process.env.HUBSPOT_ACCESS_TOKEN
})
```
### Rate limiting
The datasource includes built-in rate limiting using Bottleneck:
```javascript theme={null}
createHubSpotDataSource({
accessToken: process.env.HUBSPOT_ACCESS_TOKEN,
rateLimiting: {
maxConcurrent: 10, // Maximum concurrent requests
minTime: 100 // Minimum time between requests (ms)
}
})
```
Default rate limits respect HubSpot API constraints:
* Search API: 4 requests per second
* Standard API: 100 requests per 10 seconds
### Custom object configuration
Include custom HubSpot objects in your Forest:
```javascript theme={null}
createHubSpotDataSource({
accessToken: process.env.HUBSPOT_ACCESS_TOKEN,
customObjects: ['2-12345678'] // Custom object IDs
})
```
## Supported objects
The datasource automatically includes these standard HubSpot objects:
* **companies** - Company records
* **contacts** - Contact records
* **deals** - Deal pipeline records
* **line\_items** - Product line items
* **products** - Product catalog
* **quotes** - Quote records
* **tickets** - Support ticket records
* **owners** - HubSpot user accounts
Custom objects can be added via configuration.
## Read-only datasource
This datasource is **read-only**. You can:
* Browse records
* Search and filter
* View relationships
* Export data
You cannot:
* Create new records
* Update existing records
* Delete records
For write operations, contact us for more info.
## Filtering capabilities
The datasource supports filtering with these operators:
* `Equal` - Exact match
* `NotEqual` - Exclude matches
* `In` - Match any value in list
* `LessThan`, `GreaterThan` - Numeric/date comparisons
* `Contains` - Text contains (limited support)
**Limitation:** Maximum 5 filter criteria per query due to HubSpot Search API constraints.
## Relationships
The datasource attempts to map HubSpot associations to Forest relationships:
```javascript theme={null}
// Example: Companies associated with Contacts
// Automatically exposed as relationships in Forest
```
However, relationships are limited:
* **No native relation support** - Relationships are flattened or require manual configuration
* **Association API constraints** - Complex associations may not work correctly
## Pagination
Browse large datasets with automatic pagination:
* Default page size: 100 records
* Maximum page size: 100 records (HubSpot API limit)
* Automatic "Load More" in Forest UI
## Rate limiting and performance
The datasource handles HubSpot rate limits automatically:
**Search API limits:**
* 4 requests per second
* Used for filtering and searching
**Standard API limits:**
* 100 requests per 10 seconds
* Used for basic CRUD operations
If you exceed rate limits, the datasource will queue requests and retry automatically.
## Live Query
This datasource supports Live Query for advanced filtering and segmentation. [Learn more about Live Queries](/product/process/segments/smart-segments/overview)
## Limitations
* **Read-only** - No create, update, or delete operations
* **No native relations** - Relationships require manual configuration
* **Search constraints** - Maximum 5 filter criteria per query
* **Rate limiting** - Subject to HubSpot API rate limits
* **No real-time sync** - Data is fetched on-demand, not cached
* **Association complexity** - Complex multi-level associations may not work
## HubSpot API token scopes
Your Private App token needs these scopes:
* `crm.objects.companies.read`
* `crm.objects.contacts.read`
* `crm.objects.deals.read`
* `crm.objects.line_items.read`
* `crm.objects.quotes.read`
* `tickets`
* `crm.objects.owners.read`
For custom objects, add:
* `crm.objects.custom.read`
## Source code
This connector is open source. Browse the code or contribute on GitHub: [`@forestadmin-experimental/datasource-hubspot-translation`](https://github.com/ForestAdmin/forestadmin-experimental/tree/main/packages/datasource-hubspot-translation).
# MongoDB datasource
Source: https://docs.forest.app/get-started/connect/data-sources/mongodb
Connect directly to MongoDB without an ORM using automatic schema introspection
The MongoDB datasource connects directly to MongoDB without requiring Mongoose or another ORM. It automatically introspects your collections by sampling documents to infer the schema.
MongoDB datasource is only available for Node.js. For Ruby, check the [Mongoid datasource](/get-started/connect/data-sources/mongoid).
## Basic usage
```javascript theme={null}
import { createAgent } from '@forestadmin/agent';
import { createMongoDataSource } from '@forestadmin/datasource-mongo';
const agent = createAgent(options);
agent.addDataSource(
createMongoDataSource({
uri: 'mongodb://localhost:27017',
dataSource: { flattenMode: 'auto' }
})
);
```
## Schema introspection
Since MongoDB lacks a predefined schema, the back-end samples documents from each collection during startup to infer the structure. You can control this behavior:
```javascript theme={null}
createMongoDataSource({
uri: 'mongodb://localhost:27017',
introspection: {
collectionSampleSize: 100, // Documents sampled per collection
referenceSampleSize: 10, // References analyzed for relationships
maxPropertiesPerObject: 30 // Maximum properties extracted as columns
}
})
```
Setting sample sizes too high may slow back-end startup times.
## Filtering collections
Exclude specific collections from Forest:
```javascript theme={null}
const agent = createAgent(options);
agent.addDataSource(
createMongoDataSource({ uri: connectionString }),
{ exclude: ['accounts', 'accounts_bills', 'accounts_bills_items'] }
);
```
## Flattening nested data
MongoDB stores nested BSON documents. Configure flattening to convert nested structures into columns or separate collections:
```javascript theme={null}
createMongoDataSource({
uri: 'mongodb://localhost:27017',
dataSource: {
flattenMode: 'manual',
flattenOptions: {
persons: {
asModels: ['bills'], // Convert to separate collections
asFields: ['address'] // Move to root level
// or: asFields: ['address.city', 'address.country']
// or: asFields: [{ field: 'address', level: 1 }]
}
}
}
})
```
## Data navigation
When customizing your back-end, navigate paths using these separators:
* **Nested fields**: Use `@@@` to access nested properties
* **Related data**: Use `:` to navigate relationships
**Example**: `address:city@@@name` accesses the name field within city within the address relation.
## Source code
This connector is open source. Browse the code or contribute on GitHub: [`@forestadmin/datasource-mongo`](https://github.com/ForestAdmin/agent-nodejs/tree/main/packages/datasource-mongo).
# Mongoid datasource
Source: https://docs.forest.app/get-started/connect/data-sources/mongoid
Connect Forest to MongoDB using Mongoid models with flexible nested document handling
The Mongoid datasource imports collections from a Mongoid instance into Forest, with configurable strategies for handling deeply nested embedded documents.
Mongoid datasource is only available for Ruby. For Node.js, check the [Mongoose datasource](/get-started/connect/data-sources/mongoose) or [MongoDB datasource](/get-started/connect/data-sources/mongodb).
## Basic usage
```ruby theme={null}
require 'forest_admin_datasource_mongoid'
datasource = ForestAdminDatasourceMongoid::Datasource.new(
options: {
flatten_mode: 'auto',
}
)
```
## Flatten modes
The Mongoid datasource provides three modes for handling embedded documents:
### `flatten_mode: 'auto'`
Embedded documents (embeds\_one, embeds\_many) are automatically converted into separate Forest collections.
```ruby theme={null}
datasource = ForestAdminDatasourceMongoid::Datasource.new(
options: {
flatten_mode: 'auto',
}
)
```
### `flatten_mode: 'manual'`
You control which virtual collections are created and which fields are moved to the root level.
```ruby theme={null}
datasource = ForestAdminDatasourceMongoid::Datasource.new(
options: {
flatten_mode: 'manual',
# Configure which paths become collections vs fields
# Contact Forest support for manual mode configuration details
}
)
```
### `flatten_mode: 'none'`
Mongoid models are displayed as-is, with embedded objects appearing as raw JSON.
```ruby theme={null}
datasource = ForestAdminDatasourceMongoid::Datasource.new(
options: {
flatten_mode: 'none',
}
)
```
## Data navigation
When working with nested or related data, use specific separators:
* **Nested fields**: Use `@@@` to access nested properties
* **Related data**: Use `:` to navigate relationships
**Example**: `address:city@@@name` accesses the name field within city within the address relation.
## Source code
This connector is open source. Browse the code or contribute on GitHub: [`forest_admin_datasource_mongoid`](https://github.com/ForestAdmin/agent-ruby/tree/main/packages/forest_admin_datasource_mongoid).
# Mongoose datasource
Source: https://docs.forest.app/get-started/connect/data-sources/mongoose
Connect to MongoDB using Mongoose ODM with flexible data transformation strategies
The Mongoose datasource connects to MongoDB through Mongoose models, automatically importing your collections into Forest with support for nested objects, arrays, and relationships.
Mongoose datasource is only available for Node.js. For Ruby, check the [Mongoid datasource](/get-started/connect/data-sources/mongoid).
## Basic usage
```javascript theme={null}
import { createAgent } from '@forestadmin/agent';
import { createMongooseDataSource } from '@forestadmin/datasource-mongoose';
import connection from './mongoose-models';
const agent = createAgent(options);
agent.addDataSource(
createMongooseDataSource(connection, {
flattenMode: 'none'
})
);
```
## Example schema
Here's an example Mongoose schema with nested data:
```javascript theme={null}
import mongoose from 'mongoose';
const personSchema = new mongoose.Schema({
name: String,
age: Number,
address: {
streetName: String,
city: String,
country: String
},
bills: [{
title: String,
amount: Number,
issueDate: Date,
paidBy: [String]
}]
});
const Person = mongoose.model('persons', personSchema);
```
## Flatten modes
The Mongoose datasource offers four transformation strategies:
### `flattenMode: 'auto'`
Arrays of objects and references are converted to independent collections. Other nested fields are moved to the root level.
```javascript theme={null}
createMongooseDataSource(connection, {
flattenMode: 'auto'
})
```
### `flattenMode: 'none'`
No transformations are made. Forest collections use the exact same structure as your Mongoose models.
```javascript theme={null}
createMongooseDataSource(connection, {
flattenMode: 'none'
})
```
### `flattenMode: 'manual'`
You are in full control of which virtual collections are created and which fields are moved to the root level.
```javascript theme={null}
createMongooseDataSource(connection, {
flattenMode: 'manual',
asModels: {
'persons': ['bills'] // Convert bills array to separate collection
},
asFields: {
'persons': ['address'] // Flatten address to root level
}
})
```
### `flattenMode: 'legacy'`
Maintains backward compatibility with previous datasource versions.
```javascript theme={null}
createMongooseDataSource(connection, {
flattenMode: 'legacy'
})
```
## Data navigation
When working with nested or related data, use specific separators:
* **Nested fields**: Use `@@@` to access nested properties
* **Related data**: Use `:` to navigate relationships
**Example**: `address:city@@@name` accesses the name field within city within the address relation.
## Virtual collections
In `auto` mode, the datasource may create virtual collections to represent relationships. If you use a collection whitelist, make sure to include these virtual collections in your configuration.
## Source code
This connector is open source. Browse the code or contribute on GitHub: [`@forestadmin/datasource-mongoose`](https://github.com/ForestAdmin/agent-nodejs/tree/main/packages/datasource-mongoose).
# Add datasources to your project
Source: https://docs.forest.app/get-started/connect/data-sources/overview
Connect one or multiple data sources - SQL databases, MongoDB, APIs, or custom connectors
Forest connects to your data through datasources. You can add one or multiple datasources to your project, each representing a database, an API, or any data source you need to manage.
## Adding a datasource
Use your back-end's method to add a datasource:
```javascript Node.js theme={null}
import { createAgent } from '@forestadmin/agent';
import { createSqlDataSource } from '@forestadmin/datasource-sql';
const agent = createAgent(options);
agent.addDataSource(
createSqlDataSource('postgresql://user:pass@localhost:5432/mydb')
);
```
```ruby Ruby theme={null}
# app/lib/forest_admin_rails/create_agent.rb
module ForestAdminRails
class CreateAgent
def self.setup!
datasource = ForestAdminDatasourceActiveRecord::Datasource.new(
Rails.configuration.database_configuration[Rails.env]
)
@create_agent = ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource)
customize
@create_agent.build
end
def self.customize; end
end
end
```
Your data is now surfaced to Forest, allowing you to browse, search, edit, and manage your records.
## Adding multiple datasources
You can connect multiple datasources to create a unified back-office across different databases, APIs, or systems:
```javascript Node.js theme={null}
const agent = createAgent(options);
// Primary PostgreSQL database
agent.addDataSource(
createSqlDataSource('postgresql://user:pass@localhost:5432/main')
);
// MongoDB for analytics
agent.addDataSource(
createMongooseDataSource(mongoConnection)
);
// External API
agent.addDataSource(
createCustomDataSource(apiConfig)
);
```
```ruby Ruby theme={null}
# app/lib/forest_admin_rails/create_agent.rb, inside CreateAgent.setup!
factory = ForestAdminAgent::Builder::AgentFactory.instance
# Primary PostgreSQL database
factory.add_datasource(
ForestAdminDatasourceActiveRecord::Datasource.new(
Rails.configuration.database_configuration[Rails.env]
)
)
# MongoDB for analytics
factory.add_datasource(ForestAdminDatasourceMongoid::Datasource.new)
@create_agent = factory
```
All collections from all datasources will appear in your Forest interface, giving you a unified view across your entire data landscape.
### Cross-database relationships
You can define relationships between collections from different datasources:
```javascript Node.js theme={null}
agent.customizeCollection('orders', collection => {
collection.addManyToOneRelation('user', 'analytics_users', {
foreignKey: 'user_id'
});
});
```
```ruby Ruby theme={null}
agent.customize_collection('orders') do |collection|
collection.add_many_to_one_relation('user', 'analytics_users',
foreign_key: 'user_id'
)
end
```
This allows you to navigate between related data even when it lives in different databases or systems.
[Learn more about relationships →](/product/process/relationships/overview)
## Available datasources
* **[SQL](/get-started/connect/data-sources/sql)** - PostgreSQL, MySQL, MariaDB, SQL Server with automatic schema introspection
* **[Sequelize](/get-started/connect/data-sources/sequelize)** - Connect through your existing Sequelize ORM models
* **[Mongoose](/get-started/connect/data-sources/mongoose)** - MongoDB via Mongoose ODM with schema support
* **[Dummy](/get-started/connect/data-sources/dummy)** - Generate fake data for testing and prototyping
* **[Hubspot](/get-started/connect/data-sources/hubspot)** - Connect to Hubspot CRM (Contacts, Companies, Deals, Tickets)
* **[Elasticsearch](/get-started/connect/data-sources/elasticsearch)** - Query Elasticsearch indices
* **[Airtable](/get-started/connect/data-sources/airtable)** - Connect Airtable bases
* **[Stripe](/get-started/connect/data-sources/stripe)** - Connect Stripe account data
* **[RPC](/get-started/connect/data-sources/rpc)** - Connect remote data sources via RPC
* **[GraphQL / Hasura](/get-started/connect/data-sources/graphql-hasura)** - Connect GraphQL APIs or Hasura instances
* **[CosmosDB](/get-started/connect/data-sources/cosmosdb)** - Connect Azure CosmosDB
### Custom datasources
Build custom datasources for proprietary systems, legacy databases, REST APIs, or any data source without an existing connector:
* **[Replication](/get-started/connect/data-sources/custom-datasources/replication)** - Replicate external data into Forest
* **[Translation](/get-started/connect/data-sources/custom-datasources/translation)** - Transform and adapt data from any source
**Don't see the datasource you need?** [Contact us](https://www.forestadmin.com/contact) to request a datasource, and we'll help you connect it.
# RPC
Source: https://docs.forest.app/get-started/connect/data-sources/rpc
Connect back-ends over RPC: expose a remote data source, or distribute one back-end across microservices
The RPC datasource connects Forest back-ends to each other over RPC (Remote Procedure Call). Use it to expose a remote data source in your back-office, or to split one back-end across multiple services (microservices) that a main back-end aggregates into a single admin panel.
## Node.js
In Node.js, the RPC datasource connects to a remote data source and exposes its data and operations in your back-office.
```bash theme={null}
npm install @forestadmin-experimental/datasource-rpc
```
```javascript theme={null}
import { createAgent } from '@forestadmin/agent';
import { createRpcDataSource } from '@forestadmin-experimental/datasource-rpc';
const agent = createAgent(options);
agent.addDataSource(
createRpcDataSource({
url: process.env.RPC_SERVER_URL,
}),
);
```
## Ruby (distributed back-ends)
In Ruby, the RPC datasource lets you distribute your Forest back-end across multiple microservices. Each service runs its own RPC back-end, and a **main back-end** aggregates them into a unified admin panel.
**Why use RPC?**
* **Microservices architecture**: your application is split into multiple services, each owning its data.
* **Team isolation**: each team manages their own Forest configuration.
* **Independent deployment**: update one service without redeploying the entire admin panel.
* **Scalability**: distribute load across multiple agents.
### Main back-end
The main back-end aggregates multiple RPC back-ends into a single Forest interface. It behaves exactly like a classic back-end with multiple datasources: once connected, you can define relations across datasources transparently.
```ruby theme={null}
# Gemfile
gem 'forest_admin_datasource_rpc'
```
```ruby theme={null}
# app/lib/forest_admin_rails/create_agent.rb
module ForestAdminRails
class CreateAgent
def self.setup!
@agent = ForestAdminAgent::Builder::AgentFactory.instance
# Add RPC datasources
@agent.add_datasource(
ForestAdminDatasourceRpc.build(uri: 'http://customers-app:3002')
)
@agent.add_datasource(
ForestAdminDatasourceRpc.build(
uri: 'http://billing-app:3003',
auth_secret: 'YOUR-SHARED-AUTH-SECRET'
)
)
# You can also add local datasources
@agent.add_datasource(ForestAdminDatasourceMongoid.build)
@agent.use(ForestAdminDatasourceRpc::ReconciliateRpc)
@agent.build
end
end
end
```
The `auth_secret` option is optional. If not specified, it defaults to your Forest project's `auth_secret`. Override it to use a different shared secret for RPC communication.
The `ReconciliateRpc` plugin must be added **in the main back-end**. It relies on collection names matching across all back-ends, so if you rename collections in your RPC back-ends, keep all back-ends synchronized.
#### Introspection caching
By default, the main back-end introspects each RPC back-end at startup. Caching the introspection schema makes startup faster, lets the main back-end boot even if an RPC back-end is temporarily unavailable, and supports asynchronous deployments. RPC back-ends automatically generate a `.forestadmin-rpc-schema.json` file in development mode; pass it when adding a datasource:
```ruby theme={null}
schema = JSON.parse(
File.read(Rails.root.join('.forestadmin-rpc-schema.json')),
symbolize_names: true
)
@agent.add_datasource(
ForestAdminDatasourceRpc.build(
uri: 'http://customers-app:3002',
introspection: schema
)
)
```
Regenerate the cached schema when an RPC back-end's collections change.
### RPC back-end
An RPC back-end exposes its collections to the main back-end via the RPC protocol.
```ruby theme={null}
# Gemfile
gem 'forest_admin_rpc_agent'
```
Run the installation command with the `auth_secret` from your main Forest project:
```bash theme={null}
forest_admin_rpc_agent install YOUR_AUTH_SECRET
```
This creates `config/initializers/forest_admin_rpc_agent.rb`, `lib/forest_admin_rpc_agent/create_rpc_agent.rb`, and mounts the RPC routes in `config/routes.rb`. The generated back-end looks like:
```ruby theme={null}
module ForestAdminRpcAgent
class CreateRpcAgent
def self.setup!
datasource = ForestAdminDatasourceActiveRecord::Datasource.new(Rails.env.to_sym)
@agent = ForestAdminRpcAgent::Agent.instance.add_datasource(datasource)
@agent.build
end
end
end
```
You can use all standard customization methods (computed fields, actions, segments, etc.) on your RPC back-end collections.
### Cross-RPC relations
When an RPC back-end needs to reference collections from another RPC back-end, import them by adding an RPC datasource that references the other back-end, then use `mark_collections_as_rpc: true` to indicate those collections are provisioned elsewhere.
```ruby theme={null}
module ForestAdminRpcAgent
class CreateRpcAgent
def self.setup!
# 1. Local datasource
datasource = ForestAdminDatasourceActiveRecord::Datasource.new(Rails.env.to_sym)
@agent = ForestAdminRpcAgent::Agent.instance.add_datasource(datasource)
# 2. Remote RPC datasource, marked as RPC
@agent.add_datasource(
ForestAdminDatasourceRpc.build(uri: 'http://customers-app:3002'),
mark_collections_as_rpc: true
)
# 3. Cross-RPC relations (here in the Billing agent:
# 'Invoice' is local, 'User' is imported from the Customers agent)
@agent.customize_collection('Invoice') do |collection|
collection.add_many_to_one_relation(
'user', 'User', { foreign_key: 'user_id', foreign_key_target: 'id' }
)
end
@agent.customize_collection('User') do |collection|
collection.add_one_to_many_relation(
'invoices', 'Invoice', { origin_key: 'user_id', origin_key_target: 'id' }
)
end
@agent.build
end
end
end
```
For finer control over which collections are marked, call `mark_collections_as_rpc` manually:
```ruby theme={null}
# Mark specific collections
@agent.mark_collections_as_rpc('User', 'Address')
# Or use a regex to match a pattern of collections
@agent.mark_collections_as_rpc(/^admin_/)
```
Supported cross-RPC relation types: `add_many_to_one_relation`, `add_one_to_many_relation`, `add_one_to_one_relation`, and `add_many_to_many_relation`.
## Source code
This connector is open source. Browse the code or contribute on GitHub:
* Node.js: [`@forestadmin-experimental/datasource-rpc`](https://github.com/ForestAdmin/forestadmin-experimental/tree/main/packages/datasource-rpc)
* Ruby: [`forest_admin_datasource_rpc`](https://github.com/ForestAdmin/agent-ruby/tree/main/packages/forest_admin_datasource_rpc)
# Sequelize datasource
Source: https://docs.forest.app/get-started/connect/data-sources/sequelize
Connect Forest to your existing Sequelize models with support for scopes, hooks, and associations
The Sequelize datasource allows importing collections from a Sequelize instance, preserving your ORM configuration including scopes, hooks, associations, and validations.
Sequelize datasource is only available for Node.js. For Ruby, check the [ActiveRecord datasource](/get-started/connect/data-sources/active-record).
## Basic usage
```javascript theme={null}
import { createAgent } from '@forestadmin/agent';
import { createSequelizeDataSource } from '@forestadmin/datasource-sequelize';
import { Sequelize, Model, DataTypes } from 'sequelize';
// Initialize Sequelize
const sequelize = new Sequelize('postgresql://user:pass@localhost:5432/mydb');
// Define your models
class User extends Model {}
User.init(
{
username: DataTypes.STRING,
birthday: DataTypes.DATE,
},
{ sequelize, modelName: 'user' }
);
// Add to agent
const agent = createAgent(options);
agent.addDataSource(createSequelizeDataSource(sequelize));
```
## Features
The Sequelize datasource automatically preserves your ORM configuration:
* **Sequelize scopes** - Mapped to Forest segments
* **Sequelize hooks** - Continue to execute as configured
* **Associations** - Relationships are automatically recognized
* **Validations** - Model validations are enforced
## Live Query support
Enable SQL-based reporting by setting a connection identifier:
```javascript theme={null}
agent.addDataSource(
createSequelizeDataSource(sequelize, {
liveQueryConnections: 'main_database'
})
);
```
This allows authorized users to create Live Query charts, analytics, and segments that execute custom SQL directly against your database.
Live Queries execute raw SQL. Ensure proper access controls and review queries before deploying to production.
## Source code
This connector is open source. Browse the code or contribute on GitHub: [`@forestadmin/datasource-sequelize`](https://github.com/ForestAdmin/agent-nodejs/tree/main/packages/datasource-sequelize).
# Snowflake
Source: https://docs.forest.app/get-started/connect/data-sources/snowflake
Connect Snowflake tables and views to Forest (read-only, via ODBC)
The Snowflake data source allows importing tables and views from a Snowflake account into Forest via ODBC. It does **not** rely on ActiveRecord, Forest connects to Snowflake directly through the `ruby-odbc` driver, translates Forest filters/projections/aggregations into parameterised SQL, and streams the results back as plain Ruby objects.
This data source is **read-only**. Every Forest column is emitted with `is_read_only: true`, so the schema emitter sets the collection-level `isReadOnly: true` and the UI hides create/edit/delete actions. Direct calls to `create`, `update`, or `delete` raise a `ForestException` with an explicit read-only message as a defence-in-depth guard.
## Installation
To make everything work as expected, you need to:
* install the gem `forest_admin_datasource_snowflake`.
* have the **unixODBC** system library and the **Snowflake ODBC driver** installed on the host running the agent. On Ubuntu/Debian: `apt-get install unixodbc-dev`, then install [Snowflake's ODBC driver](https://docs.snowflake.com/en/developer-guide/odbc/odbc) and reference it from your `odbcinst.ini`.
## Usage
Register the datasource from inside the back-end's setup hook (`ForestAdminRails::CreateAgent.setup!` for Rails apps), the same place where you would register an Active Record or Mongoid source:
```ruby theme={null}
module ForestAdminRails
class CreateAgent
def self.setup!
datasource = ForestAdminDatasourceSnowflake::Datasource.new(
conn_str: "DRIVER={Snowflake};" \
"Server=#{ENV.fetch('SNOWFLAKE_ACCOUNT')}.snowflakecomputing.com;" \
"UID=#{ENV.fetch('SNOWFLAKE_USER')};" \
"PWD=#{ENV.fetch('SNOWFLAKE_PASSWORD')};" \
"Warehouse=#{ENV.fetch('SNOWFLAKE_WAREHOUSE')};" \
"Database=#{ENV.fetch('SNOWFLAKE_DATABASE')};" \
"Schema=#{ENV.fetch('SNOWFLAKE_SCHEMA', 'PUBLIC')}"
)
@create_agent = ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource, {})
customize
@create_agent.build
end
end
end
```
The snippets below that pass options to `add_datasource` (e.g. `include:` / `exclude:`) drop into the same `setup!` body in place of the `add_datasource(datasource, {})` call.
### Authentication
The data source doesn't interpret authentication options, `conn_str` is parsed as `key=value;...` and the resulting attributes are handed straight to the Snowflake ODBC driver. Any [parameter the driver accepts](https://docs.snowflake.com/en/developer-guide/odbc/odbc-parameters) works, including the production-friendly key-pair / JWT flow:
```ruby theme={null}
conn_str: "DRIVER={Snowflake};" \
"Server=#{ENV.fetch('SNOWFLAKE_ACCOUNT')}.snowflakecomputing.com;" \
"UID=#{ENV.fetch('SNOWFLAKE_USER')};" \
"AUTHENTICATOR=SNOWFLAKE_JWT;" \
"PRIV_KEY_FILE=#{ENV.fetch('SNOWFLAKE_PRIV_KEY_FILE')};" \
# Optional, only when the private key is encrypted:
"PRIV_KEY_FILE_PWD=#{ENV.fetch('SNOWFLAKE_PRIV_KEY_PWD')};" \
"Warehouse=#{ENV.fetch('SNOWFLAKE_WAREHOUSE')};" \
"Database=#{ENV.fetch('SNOWFLAKE_DATABASE')};" \
"Schema=#{ENV.fetch('SNOWFLAKE_SCHEMA', 'PUBLIC')}"
```
The back-end process must have read access to the `PRIV_KEY_FILE` path. `EXTERNALBROWSER` (SSO) and `OAUTH` flows work the same way, set the relevant `AUTHENTICATOR=` and supporting parameters per the driver docs.
## Automatic schema discovery
By default, every user-schema, non-system table reachable by the configured Snowflake user is exposed as a Forest collection. System tables and the `INFORMATION_SCHEMA` views are filtered out automatically.
At boot, the data source issues a small fixed set of metadata queries, independent of how many tables you expose:
* one ODBC `tables` call to enumerate the readable tables and views.
* one bulk `INFORMATION_SCHEMA.COLUMNS` query that returns every column for the schema in a single round-trip. Each Forest collection reads its slice from the pre-fetched result, so introspection cost no longer scales with table count.
* one `SHOW PRIMARY KEYS IN SCHEMA` query to recover declared primary keys (composite keys preserved, ordered by `key_sequence`).
* one `SHOW IMPORTED KEYS IN SCHEMA` query to recover declared foreign keys (see [Foreign-key auto-discovery](#foreign-key-auto-discovery)).
The primary key for each collection is resolved in the following order:
1. an operator-supplied [`primary_keys:`](#overriding-the-primary-key) override,
2. any Snowflake-declared primary key (via `SHOW PRIMARY KEYS IN SCHEMA`),
3. a column literally named `id` (case-insensitive),
4. the first column as a last resort.
Snowflake doesn't expose primary key information through ODBC's standard column metadata, hence the multi-step resolution.
If any of the metadata queries fail (typically because the connecting role lacks the privilege), the failure is logged to stderr with a `[forest_admin_datasource_snowflake]` prefix and skipped. The result is cached so the broken query isn't re-issued on every collection lookup.
### Restricting the imported tables
Use the standard agent-level `include:` / `exclude:` options when registering the datasource. The data source itself exposes every readable user-schema table; the back-end decides which ones to publish.
```ruby theme={null}
ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(
ForestAdminDatasourceSnowflake::Datasource.new(conn_str: ENV.fetch('SNOWFLAKE_CONN_STR')),
include: ['BILLING_USAGE', 'USAGE_ANOMALIES', 'CURRENCY_RATES']
)
# or, equivalently:
ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(
ForestAdminDatasourceSnowflake::Datasource.new(conn_str: ENV.fetch('SNOWFLAKE_CONN_STR')),
exclude: ['INTERNAL_LOG']
)
```
This is the same pattern used by the other Forest data sources, so collection filtering stays consistent across your agent.
### Targeting a specific schema
A datasource instance always represents a **single Snowflake schema**, Forest collection names are unqualified, so two tables with the same name in different schemas would collide. To expose tables from multiple schemas, instantiate one datasource per schema.
The active schema is resolved as follows:
* if `Schema=` is set in the connection string, it wins. The datasource parses it (case-insensitive) at construction time and issues `USE SCHEMA ""` on every new connection so the session, the table-list filter, and all introspection queries stay aligned.
* if `Schema=` is omitted, the datasource snapshots `CURRENT_SCHEMA()` once at boot (whatever default the Snowflake user/role exposes) and uses that as the active schema for the rest of its lifetime.
* if `Schema=` is omitted **and** `CURRENT_SCHEMA()` is null (the role has no default), the datasource raises `ForestAdminDatasourceSnowflake::Error` at boot with a message asking you to set `Schema=` explicitly.
```ruby theme={null}
ForestAdminDatasourceSnowflake::Datasource.new(
conn_str: "DRIVER={Snowflake};...;Schema=ANALYTICS"
)
```
### Overriding the primary key
For tables where the primary key cannot be auto-resolved, for example, a table without a Snowflake-declared PK, no `id` column, and where the first column isn't really the key, pass an explicit `primary_keys:` mapping. The lookup is case-insensitive on the table name. Pass an array to declare a composite key.
```ruby theme={null}
ForestAdminDatasourceSnowflake::Datasource.new(
conn_str: ENV.fetch('SNOWFLAKE_CONN_STR'),
primary_keys: {
'CUSTOMER_EVENTS' => 'EVENT_UUID',
'orders' => 'ORDER_ID',
'usage_quotas' => %w[CUSTOMER_ID EVENT_TYPE]
}
)
```
This override sits at the top of the resolution chain and takes precedence over any Snowflake-declared PK or fallback. If a declared column name does not match any column on the target table, the data source raises `ForestAdminDatasourceSnowflake::Error` at boot, silent fallback would otherwise mask configuration typos.
## Type mapping
Column types are resolved from the Snowflake-native `DATA_TYPE` returned by `INFORMATION_SCHEMA.COLUMNS`.
| Snowflake type | Forest type |
| -------------------------------------------------------------------------------------------- | ----------- |
| `BOOLEAN` | `Boolean` |
| `NUMBER`, `DECIMAL`, `NUMERIC`, `INT`, `INTEGER`, `BIGINT`, `SMALLINT`, `TINYINT`, `BYTEINT` | `Number` |
| `FLOAT`, `FLOAT4`, `FLOAT8`, `DOUBLE`, `DOUBLE PRECISION`, `REAL` | `Number` |
| `VARCHAR`, `CHAR`, `CHARACTER`, `STRING`, `TEXT` | `String` |
| `DATE` | `Dateonly` |
| `TIME` | `Time` |
| `DATETIME`, `TIMESTAMP`, `TIMESTAMP_NTZ`, `TIMESTAMP_LTZ`, `TIMESTAMP_TZ` | `Date` |
| `VARIANT`, `OBJECT`, `ARRAY` | `Json` |
| `BINARY`, `VARBINARY` | `Binary` |
| `GEOGRAPHY`, `GEOMETRY`, `VECTOR` | `String` |
Any type not in the table above falls back to `String`.
`VARIANT` / `OBJECT` / `ARRAY` columns are JSON-parsed at projection time so the Forest UI receives structured data, not raw strings.
`TIMESTAMP_LTZ` and `TIMESTAMP_TZ` are normalised at the session level via `ALTER SESSION SET TIMEZONE = 'UTC'`, so all three TIMESTAMP variants serialise consistently as UTC. This avoids subtle bugs where rows render with different offsets depending on the column variant.
## Foreign-key auto-discovery
Snowflake foreign keys are not enforced by the engine, they exist purely as documentation. If you have defined them in your warehouse, the data source picks them up automatically at boot and exposes them as Forest `ManyToOne` relations.
Discovery runs unconditionally: a `SHOW IMPORTED KEYS IN SCHEMA` query at boot adds a relation field on the source collection for each FK it returns. The relation name is `{source_column}_{target_table}` (downcased).
If the introspection query fails (typically because the connecting role lacks the privilege), the failure is logged and skipped, the rest of the data source remains usable.
Auto-discovery only handles relations defined **inside Snowflake**. Cross-data-source relations, for example a Snowflake `BILLING_USAGE.CUSTOMER_ID` pointing at a Postgres `customers.id`, cannot be discovered (Snowflake has no concept of an FK to another database). Wire those manually in the back-end layer with `add_many_to_one_relation` / `add_one_to_many_relation`.
## Connection pool
The data source uses [`connection_pool`](https://github.com/mperham/connection_pool) under the hood. By default the pool is sized at 5 with a 5-second checkout timeout. Tune via `pool_size:` and `pool_timeout:` if you expect concurrent traffic.
```ruby theme={null}
ForestAdminDatasourceSnowflake::Datasource.new(
conn_str: ENV.fetch('SNOWFLAKE_CONN_STR'),
pool_size: 10,
pool_timeout: 10
)
```
`with_connection` automatically retries the block once after a connection-lost ODBC error (communication failures, expired sessions, expired Snowflake auth tokens, etc.), cycling the pool between attempts so stale handles get closed before re-checkout. Persistent failures bubble up to the caller.
## Statement timeout
To cap any single Forest-driven Snowflake query, pass `statement_timeout:` (in seconds). The data source issues `ALTER SESSION SET STATEMENT_TIMEOUT_IN_SECONDS = N` on each new connection.
```ruby theme={null}
ForestAdminDatasourceSnowflake::Datasource.new(
conn_str: ENV.fetch('SNOWFLAKE_CONN_STR'),
statement_timeout: 60
)
```
This is recommended in production: a runaway aggregate query won't be able to pin a pool slot indefinitely.
## Full reference
```ruby theme={null}
ForestAdminDatasourceSnowflake::Datasource.new(
# Required: ODBC connection string, DRIVER, Server, UID, PWD, Warehouse,
# Database, Schema, plus any Snowflake-specific options.
conn_str: ENV.fetch('SNOWFLAKE_CONN_STR'),
# Optional: explicit primary key per table when auto-resolution can't find the
# right one. Case-insensitive on the table name. Pass an array for composite
# keys. Sits at the top of the resolution chain (above SHOW PRIMARY KEYS,
# the 'id' column, and the first-column fallback). Raises at boot if a
# declared column name doesn't match any column on the table.
primary_keys: { 'orders' => 'ORDER_ID', 'usage_quotas' => %w[CUSTOMER_ID EVENT_TYPE] },
# Optional: connection pool tuning. Defaults: 5 connections, 5s checkout timeout.
pool_size: 5,
pool_timeout: 5,
# Optional: cap any one query at N seconds via ALTER SESSION.
statement_timeout: 60
)
```
## Source code
This connector is open source. Browse the code or contribute on GitHub: [`forest_admin_datasource_snowflake`](https://github.com/ForestAdmin/agent-ruby/tree/main/packages/forest_admin_datasource_snowflake).
# SQL datasource
Source: https://docs.forest.app/get-started/connect/data-sources/sql
Connect to PostgreSQL, MySQL, MariaDB, or SQL Server with automatic schema introspection
This datasource is only available for Node.js agents.
The SQL datasource connects directly to SQL databases with automatic schema introspection. Each database table or view maps to a Forest collection - tables offer full read-write capabilities while views are read-only.
## Basic usage
```javascript theme={null}
import { createAgent } from '@forestadmin/agent';
import { createSqlDataSource } from '@forestadmin/datasource-sql';
const agent = createAgent(options);
agent.addDataSource(
createSqlDataSource({
uri: 'postgresql://user:pass@localhost:5432/mydb',
sslMode: 'preferred'
})
);
```
## Automatic schema introspection
By default, the SQL datasource automatically discovers your database structure when the back-end starts. It extracts:
* **Tables and views** - Each becomes a collection
* **Columns** - With automatic type detection
* **Primary keys** - For record identification
* **Foreign keys** - Converted to relationships
* **Indexes** - Used for query optimization
This requires database credentials with access to `information_schema`. Ownership roles are recommended.
Introspection is supported for PostgreSQL, MySQL, MariaDB, and Microsoft SQL Server.
## Configuration options
### Connection URI
The connection URI format varies by database:
```javascript theme={null}
createSqlDataSource({
uri: 'postgresql://user:password@host:5432/database'
})
```
```javascript theme={null}
createSqlDataSource({
uri: 'mysql://user:password@host:3306/database'
})
```
```javascript theme={null}
createSqlDataSource({
uri: 'mssql://user:password@host:1433/database'
})
```
### SSL configuration
Control SSL/TLS connection behavior:
```javascript theme={null}
createSqlDataSource({
uri: 'postgresql://user:pass@host:5432/db',
sslMode: 'preferred' // or 'verify', 'required', 'disabled', 'manual'
})
```
**SSL modes:**
* `preferred` - Use SSL if available, otherwise unencrypted (default)
* `required` - Require SSL, fail if unavailable
* `verify` - Require SSL with certificate verification
* `disabled` - Never use SSL
* `manual` - Custom SSL configuration (advanced)
### Schema selection
Specify which database schema to use (PostgreSQL, SQL Server):
```javascript theme={null}
createSqlDataSource({
uri: 'postgresql://user:pass@host:5432/db',
schema: 'my_schema' // default: 'public'
})
```
### Connection pooling
Configure connection pool for optimal performance:
```javascript theme={null}
createSqlDataSource({
uri: 'postgresql://user:pass@host:5432/db',
pool: {
max: 20, // Maximum connections
min: 5, // Minimum connections
acquire: 30000, // Max time (ms) to get connection
idle: 10000 // Close idle connections after 10s
}
})
```
### Read replicas
Distribute read operations across replica databases:
```javascript theme={null}
createSqlDataSource({
uri: 'postgresql://user:pass@host:5432/db',
replication: {
write: { host: 'primary.example.com' },
read: [
{ host: 'replica1.example.com' },
{ host: 'replica2.example.com' }
]
}
})
```
### SSH tunnel
Connect through an SSH tunnel:
```javascript theme={null}
createSqlDataSource({
uri: 'postgresql://user:pass@localhost:5432/db',
ssh: {
host: 'ssh-host.example.com',
port: 22,
username: 'ssh-user',
privateKey: require('fs').readFileSync('/path/to/private-key')
}
})
```
### SOCKS5 proxy
Route connections through a SOCKS5 proxy:
```javascript theme={null}
createSqlDataSource({
uri: 'postgresql://user:pass@host:5432/db',
proxySocks: {
host: 'proxy.example.com',
port: 1080,
username: 'proxy-user',
password: 'proxy-pass'
}
})
```
### Connection timeout
Set maximum time to establish connection:
```javascript theme={null}
createSqlDataSource({
uri: 'postgresql://user:pass@host:5432/db',
connectionTimeoutInMs: 5000 // 5 seconds
})
```
## Soft-deleted records
Display records marked as deleted (soft deletes):
```javascript theme={null}
// Show soft-deleted for specific collections
createSqlDataSource({
uri: 'postgresql://user:pass@host:5432/db',
displaySoftDeleted: ['users', 'projects']
})
// Show soft-deleted for all collections
createSqlDataSource({
uri: 'postgresql://user:pass@host:5432/db',
displaySoftDeleted: true
})
```
This is useful when your application uses soft deletes (e.g., `deleted_at` column) and you want to manage deleted records in Forest.
## Caching introspection
The schema introspection is JSON serializable. Cache it to a file:
```javascript theme={null}
import { createSqlDataSource } from '@forestadmin/datasource-sql';
import { writeFileSync, readFileSync } from 'fs';
// Option 1: Generate and cache schema
const dataSource = await createSqlDataSource({
uri: 'postgresql://user:pass@host:5432/db'
});
writeFileSync('./schema-cache.json', JSON.stringify(dataSource.schema));
// Option 2: Load from cache (faster startup, works offline)
const schema = JSON.parse(readFileSync('./schema-cache.json', 'utf8'));
const dataSource = createSqlDataSource({
uri: 'postgresql://user:pass@host:5432/db',
schema: schema
});
```
**Benefits:**
* Faster back-end startup (no introspection delay)
* Work offline or with restricted credentials
* Separate introspection and runtime credentials
* Version control your schema
## Live Query support
Enable SQL-based reporting by setting a connection identifier:
```javascript theme={null}
createSqlDataSource({
uri: process.env.DATABASE_URL,
liveQueryConnections: 'main_database'
})
```
This allows authorized users to create Live Query charts and segments that execute custom SQL directly against your database.
Live Queries execute raw SQL. Ensure proper access controls and review queries before deploying to production.
## Supported databases
| Database | Versions | Driver Package | Status |
| -------------- | -------- | ------------------ | ---------------------------- |
| **PostgreSQL** | 10+ | `pg` + `pg-hstore` | ✅ Full support (recommended) |
| **MySQL** | 5.7+ | `mysql2` | ✅ Production-ready |
| **MariaDB** | 10+ | `mariadb` | ✅ Production-ready |
| **SQL Server** | 2017+ | `tedious` | ✅ Enterprise support |
## Source code
This connector is open source. Browse the code or contribute on GitHub: [`@forestadmin/datasource-sql`](https://github.com/ForestAdmin/agent-nodejs/tree/main/packages/datasource-sql).
# Stripe
Source: https://docs.forest.app/get-started/connect/data-sources/stripe
Connect Stripe to Forest
Only available for Node.js.
The Stripe datasource connects your Stripe account to Forest, allowing you to browse and manage your Stripe data (customers, payments, subscriptions, and more) directly from your back-office.
## Installation
```bash theme={null}
npm install @forestadmin-experimental/datasource-stripe
```
## Basic usage
```javascript theme={null}
import { createAgent } from '@forestadmin/agent';
import { createStripeDataSource } from '@forestadmin-experimental/datasource-stripe';
const agent = createAgent(options);
agent.addDataSource(
createStripeDataSource({
secretKey: process.env.STRIPE_SECRET_KEY
})
);
```
## Source code
[github.com/ForestAdmin/forestadmin-experimental, datasource-stripe](https://github.com/ForestAdmin/forestadmin-experimental/tree/main/packages/datasource-stripe)
## Source code
This connector is open source. Browse the code or contribute on GitHub: [`@forestadmin-experimental/datasource-stripe`](https://github.com/ForestAdmin/forestadmin-experimental/tree/main/packages/datasource-stripe).
# Zendesk
Source: https://docs.forest.app/get-started/connect/data-sources/zendesk
Surface a Zendesk Support account (tickets, users, organizations) as Forest collections
The Zendesk data source surfaces a Zendesk Support account as Forest collections. It exposes tickets, users and organizations (with each ticket carrying its comment thread inline) on top of the [Zendesk REST API](https://developer.zendesk.com/api-reference/ticketing/introduction/), so you can browse and edit them from your Forest project alongside your other data sources.
This is the Zendesk **data source** (Zendesk data inside Forest). If you want to embed Forest data and actions inside Zendesk tickets instead, see the [Zendesk app](/product/embed/zendesk).
## Installation
Install the package `@forestadmin/datasource-zendesk`.
```bash theme={null}
yarn add @forestadmin/datasource-zendesk
```
```javascript theme={null}
import { createAgent } from '@forestadmin/agent';
import {
createZendeskClient,
createZendeskDataSource,
} from '@forestadmin/datasource-zendesk';
const zendeskClient = createZendeskClient({
subdomain: process.env.ZENDESK_SUBDOMAIN,
email: process.env.ZENDESK_EMAIL,
apiToken: process.env.ZENDESK_API_TOKEN,
});
const agent = createAgent(options).addDataSource(
createZendeskDataSource({ client: zendeskClient }),
);
```
Sharing the same `zendeskClient` instance with the [Zendesk plugins](/product/process/advanced-concepts/plugins/zendesk) keeps auth, base URL and best-effort logger consistent across calls. It is recommended, but not required — the plugins can also be configured with raw credentials directly.
Install the gem `forest_admin_datasource_zendesk`.
```ruby theme={null}
module ForestAdminRails
class CreateAgent
def self.setup!
datasource = ForestAdminDatasourceZendesk::Datasource.new(
subdomain: ENV['ZENDESK_SUBDOMAIN'],
username: ENV['ZENDESK_USERNAME'],
token: ENV['ZENDESK_API_TOKEN']
)
@create_agent = ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource, {})
customize
@create_agent.build
end
end
end
```
## Configuration
The datasource authenticates against Zendesk using an [API token](https://support.zendesk.com/hc/en-us/articles/4408889192858-Managing-access-to-the-Zendesk-API).
`createZendeskDataSource` accepts a `ZendeskClientProvider` — either a pre-built `client`, or the three raw credentials below (which the factory then uses to build one). The two shapes are mutually exclusive. When the credentials are passed directly, the client constructor throws `ZendeskConfigurationError` (a `ValidationError`) if any of them is missing or blank.
| Option | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `client` | A `ZendeskClient` instance built with `createZendeskClient`. **Required when `subdomain` / `email` / `apiToken` are not provided.** |
| `subdomain` | The subdomain of your Zendesk account (e.g. `acme` for `https://acme.zendesk.com`). **Required when `client` is not provided.** |
| `email` | The email address associated with the API token (typically a Zendesk admin/agent account). Required alongside `subdomain` and `apiToken`. |
| `apiToken` | A Zendesk API token generated from `Admin Center → Apps and integrations → APIs → Zendesk API`. Required alongside `subdomain` and `email`. |
Sharing one pre-built client with the plugins is the recommended pattern (single auth setup, single logger); letting the factory build it for you is convenient when the plugins use raw credentials too:
```javascript theme={null}
// Recommended — share the same client with the plugins
createZendeskDataSource({
client: createZendeskClient({ subdomain, email, apiToken }),
});
// Or let the factory build the client for you
createZendeskDataSource({ subdomain, email, apiToken });
```
All three options are mandatory; the back-end fails fast with a `ForestAdminDatasourceZendesk::ConfigurationError` if any of them is missing or blank.
| Option | Description |
| ----------- | ----------------------------------------------------------------------------------------------- |
| `subdomain` | The subdomain of your Zendesk account (e.g. `acme` for `https://acme.zendesk.com`). |
| `username` | The email address associated with the API token (typically a Zendesk admin/agent account). |
| `token` | A Zendesk API token generated from `Admin Center → Apps and integrations → APIs → Zendesk API`. |
The `forest_admin_datasource_zendesk` package also ships two action plugins (`CreateTicketWithNotification` and `CloseTicket`) that you can attach to any host collection. See [the Zendesk plugins page](/product/process/advanced-concepts/plugins/zendesk) for details.
## Provided collections
Once the data source is registered, three collections are added to your Forest project:
| Collection | Primary endpoint | Notes |
| ---------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `zendesk_ticket` | Search API (`/api/v2/search.json?query=type:ticket`) | Full read/write. Embeds `requester`, `assignee`, `organization` and an inline `comments` thread. |
| `zendesk_user` | Search API (`/api/v2/search.json?query=type:user`) | Full read/write. |
| `zendesk_organization` | Search API (`/api/v2/search.json?query=type:organization`) | Full read/write. |
The exact collection names are exported as the `COLLECTION_NAMES` constant — use it instead of hard-coding the strings when you customize the collections:
```javascript theme={null}
import { COLLECTION_NAMES } from '@forestadmin/datasource-zendesk';
agent.customizeCollection(COLLECTION_NAMES.ticket, collection => {
/* ... */
});
```
| Collection | Primary endpoint | Notes |
| --------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `ZendeskTicket` | Search API (`/api/v2/search?query=type:ticket`) | Full read/write. Embeds `requester`, `assignee`, `organization` and an inline `comments` thread. |
| `ZendeskUser` | Search API (`/api/v2/search?query=type:user`) | Full read/write. |
| `ZendeskOrganization` | Search API (`/api/v2/search?query=type:organization`) | Full read/write. |
### Relationships
The following relationships are exposed automatically:
* `zendesk_ticket.requester` → `zendesk_user` (foreign key `requester_id`)
* `zendesk_ticket.assignee` → `zendesk_user` (foreign key `assignee_id`)
* `zendesk_ticket.organization` → `zendesk_organization` (foreign key `organization_id`)
* `zendesk_user.organization` → `zendesk_organization`
* `zendesk_user.requested_tickets` → `zendesk_ticket`
* `zendesk_organization.users` → `zendesk_user`
* `zendesk_organization.tickets` → `zendesk_ticket`
* `ZendeskTicket.requester` → `ZendeskUser` (foreign key `requester_id`)
* `ZendeskTicket.assignee` → `ZendeskUser` (foreign key `assignee_id`)
* `ZendeskTicket.organization` → `ZendeskOrganization` (foreign key `organization_id`)
* `ZendeskUser.organization` → `ZendeskOrganization`
* `ZendeskUser.requested_tickets` → `ZendeskTicket`
* `ZendeskOrganization.users` → `ZendeskUser`
* `ZendeskOrganization.tickets` → `ZendeskTicket`
### Comments
Zendesk has no standalone `/comments/{id}` endpoint, so comments are not exposed as their own collection. Instead, each ticket carries a structured `comments` array column that is fetched lazily from `/api/v2/tickets/{id}/comments` only when the projection asks for it (i.e. when `comments` is rendered on the detail view or referenced in a custom action).
Each entry has the following shape:
| Field | Type | Source |
| -------------- | --------- | ------------------------------------------------------------------------------------------- |
| `id` | `Number` | Zendesk comment id |
| `body` | `String` | Plain-text body |
| `html_body` | `String` | HTML-formatted body |
| `public` | `Boolean` | `true` for public replies, `false` for internal notes |
| `author_email` | `String` | Resolved through batched `users/show_many` calls (chunks of 100) across all visible authors |
| `author_name` | `String` | Same |
| `created_at` | `Date` | Comment creation timestamp |
| Field | Type | Source |
| -------------- | --------- | ------------------------------------------------------------------------------------------- |
| `id` | `Number` | Zendesk comment id |
| `body` | `String` | Plain-text body |
| `html_body` | `String` | HTML-formatted body |
| `public` | `Boolean` | `true` for public replies, `false` for internal notes |
| `author_email` | `String` | Resolved through batched `users/show_many` calls (chunks of 100) across all visible authors |
| `author_name` | `String` | Same |
| `created_at` | `Date` | Comment creation timestamp |
The column is read-only — comments are added by writing to the ticket's `description` on creation (Zendesk converts the description into the first comment).
### Custom fields
Custom fields configured in your Zendesk account are introspected at boot and added to the matching collection's schema:
* Ticket custom fields are exposed as `custom_` columns on `zendesk_ticket`.
* User and organization custom fields are exposed using their Zendesk `key` (or `custom_` if no key is set) on `zendesk_user` / `zendesk_organization`.
The Forest column type is derived from the Zendesk field type:
| Zendesk field type | Forest column type |
| ------------------------------------------------- | ------------------------------------------------- |
| `text`, `textarea`, `regexp`, `partialcreditcard` | `String` |
| `integer`, `decimal`, `lookup` | `Number` |
| `date` | `Dateonly` |
| `checkbox` | `Boolean` |
| `dropdown`, `tagger` | `Enum` (or `String` if no options are configured) |
| `multiselect` | `Json` |
Unrecognized field types are skipped and logged with a warning; non-user-created (i.e. non-`removable`) ticket fields — which include every system ticket field — and inactive fields on any resource are dropped silently. Column-name collisions with a native column are also skipped with a warning.
The initial introspection calls (`GET /ticket_fields.json`, `/user_fields.json`, `/organization_fields.json`) are not wrapped in a best-effort guard: a transport error during boot will fail `createZendeskDataSource` loudly. Make sure the API token's role has read access to the field definitions.
* Ticket custom fields are exposed as `custom_` columns on `ZendeskTicket`.
* User and organization custom fields are exposed using their Zendesk `key` (or `custom_` if no key is set) on `ZendeskUser` / `ZendeskOrganization`.
The Forest column type is derived from the Zendesk field type:
| Zendesk field type | Forest column type |
| ------------------------------------------------- | ------------------ |
| `text`, `textarea`, `regexp`, `partialcreditcard` | `String` |
| `integer`, `decimal`, `lookup` | `Number` |
| `date` | `Dateonly` |
| `checkbox` | `Boolean` |
| `dropdown`, `tagger` | `Enum` |
| `multiselect` | `Json` |
Inactive fields, system ticket fields (which already exist as native columns) and unrecognized types are skipped.
If introspection fails (network error, missing scope, …) the datasource degrades gracefully: the corresponding collection is still registered without the custom fields, and a warning is logged.
## Capabilities
### Filters
The condition tree is translated into a Zendesk Search API query.
The following operators are supported per column type:
| Column type | Supported operators |
| ------------------ | --------------------------------------------------------------------------------- |
| Primary key (`id`) | `Equal`, `In` |
| `String`, `Enum` | `Equal`, `NotEqual`, `In`, `NotIn`, `Present`, `Blank` |
| `Number` | `Equal`, `NotEqual`, `In`, `NotIn`, `Present`, `Blank`, `GreaterThan`, `LessThan` |
| `Date`, `Dateonly` | `Equal`, `Before`, `After`, `Present`, `Blank` |
| `Boolean` | `Equal`, `NotEqual` |
Translations:
| Operator | Zendesk Search syntax |
| ---------------------- | ------------------------------------------------------------------- |
| `Equal`, `NotEqual` | `field:value` / `-field:value` |
| `In`, `NotIn` | repeated `field:value` clauses (Zendesk ANDs them — see note below) |
| `GreaterThan`, `After` | `field>value` |
| `LessThan`, `Before` | `field.json` (and friends); sibling conditions in the branch are then re-applied in memory.
* **An empty `In` / `NotIn` raises `UnsupportedOperatorError`** rather than matching everything.
* A handful of columns advertise no filter operators at all (so the UI offers no filter widget on them): `description`, `tags`, `url`, `comments` on tickets; `domain_names`, `details`, `notes`, `shared_tickets` on organizations; `time_zone`, `locale` on users.
* A `null`/`undefined` value passed with `Equal` / `NotEqual` / `In` raises explicitly — use `Present` / `Blank` to filter for absence.
* Filtering `zendesk_ticket.requester_email = "x@y.z"` is rewritten to Zendesk's `requester:x@y.z` operator. Only `Equal` is supported on `requester_email`.
* String values containing whitespace, double quotes, parentheses, colons or hyphens are wrapped in double quotes (with embedded quotes escaped) before being sent to the Search API.
The following operators are supported:
| Operator | Translation |
| ---------------------------------------------- | ------------------------------ |
| `EQUAL`, `NOT_EQUAL` | `field:value` / `-field:value` |
| `IN`, `NOT_IN` | repeated `field:value` clauses |
| `GREATER_THAN`, `LESS_THAN`, `BEFORE`, `AFTER` | `field>value` / `field
### Sorting
Only fields that the Zendesk Search API can sort on are honored. Other sort directives are silently ignored:
* `zendesk_ticket`: `created_at`, `updated_at`, `priority`, `status`, `ticket_type`
* `zendesk_user`: `created_at`, `updated_at`, `name`
* `zendesk_organization`: `created_at`, `updated_at`, `name`
* `ZendeskTicket`: `created_at`, `updated_at`, `priority`, `status`, `ticket_type`
* `ZendeskUser`: `created_at`, `updated_at`, `name`
* `ZendeskOrganization`: `created_at`, `updated_at`, `name`
### Pagination
Forest's offset/limit pagination is translated to Zendesk's `page` / `per_page`. The Search API caps `per_page` at 100 (clamped automatically) **and caps the total result window at 1000 records** (`MAX_TOTAL_RESULTS`). A request with `skip + limit > 1000` raises `UnsupportedOperatorError` rather than silently returning a truncated set.
A bulk `update` or `delete` that matches more than 1000 records will affect only the first 1000 and emit a `Warn` log — narrow the filter when working on larger sets.
Forest's offset/limit pagination is translated to Zendesk's `page` / `per_page`. The Search API caps `per_page` at 100; larger limits are clamped.
### Aggregations
Only `Count` aggregation without grouping is supported. Any other aggregation raises `UnsupportedOperatorError` — the Zendesk Search API has no group-by primitive. Count uses Zendesk's `/search/count.json` for filtered counts, and verifies record existence for the id-lookup path so it never over-counts.
Only `Count` aggregation without grouping is supported. Any other aggregation raises a `ForestException`, the Zendesk Search API has no group-by primitive.
### Search
The Forest search bar is shown by default on every collection (the agent's search decorator advertises `searchable: true`), but the default Forest search rewrites the user's query into an `Or` of `Contains` predicates — operators that the Zendesk Search translator does not support, so typing in the bar surfaces an `UnsupportedOperatorError`.
You have two options:
```javascript theme={null}
// Hide the bar
agent.customizeCollection('zendesk_ticket', collection =>
collection.disableSearch(),
);
// Or implement search yourself with a translator-compatible condition tree
agent.customizeCollection('zendesk_ticket', collection =>
collection.replaceSearch(query => ({
field: 'subject',
operator: 'Equal',
value: query,
})),
);
```
Filtering through the column filters keeps working as documented above either way.
The free-text search bar is enabled on `ZendeskTicket`, `ZendeskUser` and `ZendeskOrganization`. The search term is appended to the query that is built from the active filters, so the count badge and the rendered list always agree.
### Writes
Create, update and delete are supported on all three collections. Custom fields are folded into the appropriate Zendesk payload structure (`custom_fields` for tickets, `user_fields` / `organization_fields` for users and organizations).
A few fields are intentionally read-only:
* `id`, `url`, `created_at` and `updated_at` are ignored on writes.
* On `zendesk_ticket`, `description` is only written on creation (Zendesk turns it into the first comment) — on update the value is dropped with a `Warn` log because Zendesk exposes no write endpoint for it.
* `zendesk_ticket.requester_email` is computed at read time from the requester's profile and cannot be written directly.
* The id-lookup short-circuit re-checks the caller's scopes/segments in memory, so a scoped `update` / `delete` never escapes its perimeter.
* `id`, `url`, `created_at` and `updated_at` are ignored on writes.
* On `ZendeskTicket`, `description` is only written on creation (where Zendesk turns it into the first comment); it is silently dropped on update because Zendesk exposes no write endpoint for it.
* `ZendeskTicket.requester_email` is computed at read time from the requester's profile and cannot be written directly.
## Errors
All exceptions raised by the datasource are subclasses of Forest's `BusinessError` / `ValidationError`:
| Class | Parent | Raised by |
| --------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------- |
| `ZendeskConfigurationError` | `ValidationError` | The client constructor when `subdomain` / `email` / `apiToken` is missing or empty. |
| `ZendeskApiError` | `BusinessError` | Every HTTP call. Carries `operation`, HTTP `status` and the raw response `body`. |
| `UnsupportedOperatorError` | `BusinessError` | The condition-tree translator (unsupported operator, `Or` aggregator, empty `In`/`NotIn`, …) and the pagination cap. |
All three error classes are exported from `@forestadmin/datasource-zendesk` for use in your own catch blocks.
Critical paths (search, count, ticket bulk fetch, ticket comment fetch, writes) raise a `ForestAdminDatasourceZendesk::APIError` that wraps the underlying Zendesk error. Configuration problems raise `ForestAdminDatasourceZendesk::ConfigurationError`.
## Logging
Best-effort enrichment paths (bulk user/organization lookups, comment-author resolution, per-ticket comment fetches) log a `Warn` via the agent logger and degrade to a safe default (typically an empty `Map` or `null` fields) rather than failing the whole page render. Critical paths (search, count, ticket bulk fetch, ticket comment fetch, writes, custom-field introspection) raise `ZendeskApiError`.
No retry / backoff is performed on Zendesk responses — if you need to absorb transient 429s or 502s, wrap your own retry layer around the `ZendeskHttpClient`.
The datasource uses `Rails.logger` when available, and falls back to `Logger.new($stderr)`. You can override it explicitly:
```ruby theme={null}
ForestAdminDatasourceZendesk.logger = MyLogger.new
```
Best-effort enrichment paths (bulk user/organization lookups, comment-author resolution, schema introspection) log a warning and degrade to a safe default rather than failing the whole page render. Critical paths (search, count, ticket bulk fetch, ticket comment fetch, writes) raise a `ForestAdminDatasourceZendesk::APIError` that wraps the underlying Zendesk error.
## Source code
This connector is open source. Browse the code or contribute on GitHub:
[`@forestadmin/datasource-zendesk`](https://github.com/ForestAdmin/agent-nodejs/tree/main/packages/datasource-zendesk)
[`forest_admin_datasource_zendesk`](https://github.com/ForestAdmin/agent-ruby/tree/main/packages/forest_admin_datasource_zendesk)
# Data Types
Source: https://docs.forest.app/get-started/connect/data-types
Reference of data types available in Forest
Fields on Forest can either use `Primitive Types` or `Composite Types`.
## Primitive types
The primitive types which are supported by Forest are the following:
| Forest Type | Language Type |
| ----------- | ---------------------------------------------------------- |
| Boolean | Boolean |
| Date | String with format "1985-10-26T01:22:00-08:00Z" (ISO-8601) |
| Dateonly | String with format "1985-10-26" |
| Enum | String |
| JSON | Any JSON compatible value |
| Number | Number |
| Point | Array of 2 Numbers |
| String | String |
| Timeonly | String with format "01:22:00" |
| Uuid | String with uuid v4 format |
## Composite types
* Fields using composite types are not sortable and do not implement validation.
* Fields that are an array of a primitive type **only** are filterable (depending on the data source).
```js theme={null}
// Object containing 2 strings
const typeOfObjectWithTwoStrings = { firstName: 'String', lastName: 'String' };
// Array of strings
const typeOfArrayOfStrings = ['String'];
// Array of objects
const typeOfArrayOfObjects = [{ content: 'String' }];
// Object containing a 2d-grid of numbers
const typeOfObjectContainingAGridOfNumbers = { content: [['Number']] };
```
When using composite types, the data in the UI may not be displayed as you expect!
| Composite Type | Example | How it gets displayed |
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------- |
| Array of primitive type | `[ 'array', 'of', 'strings']` | As a custom widget in the edition form |
| Object | `{ title: "the godfather"}` | As a nested form in the edition form |
| Array of object | `[{ title: "the shawshank redemption"}]` | As a new collection in Related Data section |
| Array of object (with nested objects) | `[{ rating: { kind: 'MPA", value: "PG-13" } }]` | JSON editor in the edition form |
| Anything else | | JSON editor in the edition form |
If you want to force displaying your data as a new Collection in the Related Data section, but can't because your data model contains nested objects, you may consider typing all nested objects as `'JSON'`.
# Environment Variables
Source: https://docs.forest.app/get-started/connect/environment-variables
Configure your Forest back-end with required environment variables
Environment variables are used to configure your Forest back-end securely, keeping sensitive information separate from your codebase.
## Required variables
### FOREST\_ENV\_SECRET
Your unique environment secret provided by Forest.
```bash Node.js theme={null}
FOREST_ENV_SECRET=1234567890abcdef1234567890abcdef1234567890abcdef
```
```bash Ruby theme={null}
FOREST_ENV_SECRET=1234567890abcdef1234567890abcdef1234567890abcdef
```
**Purpose:**
* Authenticates your back-end with Forest
* Links your back-end to your Forest project
* Required for all architectures (Cloud, Self-Hosted, On-Premise)
**Where to find it:**
1. Go to [app.forestadmin.com](https://app.forestadmin.com)
2. Select your project
3. Go to Settings → Environments
4. Copy the environment secret
Never commit `FOREST_ENV_SECRET` to version control. Always use environment variables or secret management tools.
### FOREST\_AUTH\_SECRET
Secret key used to sign authentication tokens (Self-Hosted and On-Premise only).
```bash Node.js theme={null}
FOREST_AUTH_SECRET=your-secure-random-string-at-least-32-characters-long
```
```bash Ruby theme={null}
FOREST_AUTH_SECRET=your-secure-random-string-at-least-32-characters-long
```
**Purpose:**
* Signs JWT tokens for user authentication
* Required for Self-Hosted and On-Premise architectures
* Not needed for Cloud architecture
**Generate a secure secret:**
```bash theme={null}
# Generate a random 32-character string
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Or use OpenSSL
openssl rand -hex 32
```
### NODE\_ENV (Node.js only)
Environment mode for Node.js applications.
```bash theme={null}
NODE_ENV=production # Options: development, production, test
```
**Purpose:**
* `production`: Optimized performance, minimal logging
* `development`: Detailed logging, development mode
* `test`: Testing mode
```javascript Node.js theme={null}
const agent = createAgent({
envSecret: process.env.FOREST_ENV_SECRET,
authSecret: process.env.FOREST_AUTH_SECRET,
isProduction: process.env.NODE_ENV === 'production'
});
```
```ruby Ruby theme={null}
# config/initializers/forest_admin_rails.rb
ForestAdminRails.configure do |config|
config.env_secret = ENV['FOREST_ENV_SECRET']
config.auth_secret = ENV['FOREST_AUTH_SECRET']
end
```
**Security reminder:** These environment variables contain sensitive secrets that authenticate your back-end with Forest. Never commit them to version control, share them publicly, or reuse them across different environments (development, staging, production). Always use separate secrets for each environment and store them securely using environment variables or secret management tools.
# MCP Connectors
Source: https://docs.forest.app/get-started/connect/integrations/mcp-servers
Connect external tools to Forest using the Model Context Protocol: native connectors and custom servers.
**MCP (Model Context Protocol)** is a standardized way to connect external tools, APIs, and data sources to Forest. Forest ships **native connectors** for popular tools, and lets you plug in **any custom MCP server**. MCP connectors are used inside workflows to trigger external actions, fetch data from external systems, and build multi-step processes that span several tools.
## What is MCP?
The Model Context Protocol is an open standard for connecting AI assistants and applications to external tools and data sources. In Forest, an MCP connector exposes a third-party tool's **tools** (its callable operations) so you can use them in your workflows.
Each native connector wraps the tool's **official MCP server**. Forest handles the connection and authentication, and the operations available are the ones that vendor's MCP server exposes. For the exhaustive list of operations a connector provides, follow the vendor reference linked in each connector below.
## Native MCP Connectors
Connect any of these from **Project Settings → Integrations → Add MCP Server**, then pick the connector instead of entering a URL.
### Payments & billing
Payments, customers, subscriptions, refunds.Payments, orders, catalog, customers.
### CRM & support
Contacts, companies, deals, tickets.Conversations, contacts, articles.Tickets, users, organizations.
### Productivity & docs
Mail, calendar, files, Teams.Pages, databases, search.Boards, items, updates.
### Communication
Messages, channels, users.
### Analytics & data
Events, charts, cohorts.Query tables and views.
### Automation
Trigger and run n8n workflows.Trigger Zaps.Trigger recipes.
### Other
Kolar operations.Orias registry lookups.
The exact operations available come from each vendor's MCP server and can change without notice, which is why Forest doesn't duplicate the full tool list here. After connecting, the **"Confirm the exposed tools"** step shows you exactly what's available for your account and plan.
### Example: Stripe
Each native connector follows the same flow. For Stripe:
1. Go to **Project Settings → Integrations → Add MCP Server**.
2. Select **Stripe**.
3. Complete the connector's authentication.
4. Confirm the exposed tools, then save.
**What it unlocks**: read and act on customers, charges, subscriptions, invoices and refunds from your workflows. For the full operation reference, see the [Stripe MCP documentation](https://docs.stripe.com/mcp).
## Connect a custom MCP server
If a tool isn't in the list above, connect any MCP server by URL:
1. Navigate to **Project Settings → Integrations**.
2. Click **Add MCP Server**.
3. Enter your MCP server URL and configuration:
```json theme={null}
{
"url": "https://your-mcp-server.com",
"name": "My Custom MCP Server",
"apiKey": "your-api-key"
}
```
4. Check that all the expected tools are exposed.
5. Save your configuration.
Learn more about building and hosting MCP servers in the [Claude MCP documentation](https://docs.claude.com/en/docs/agents-and-tools/remote-mcp-servers).
# All integrations
Source: https://docs.forest.app/get-started/connect/integrations/overview
Every third-party tool Forest connects to: data sources, action plugins, embeds, and AI assistants.
Forest integrates with third-party tools in several ways: as a **data source** (browse and edit the tool's data inside Forest), as an **action plugin** (trigger the tool from a Forest action), as an **embed** (surface Forest inside another product), as an **MCP connector** (call the tool from a Forest workflow), or as an **AI assistant** connection. A tool can offer more than one path. See the [MCP Connectors](/get-started/connect/integrations/mcp-servers) catalog for the full list of workflow connectors.
This page lists everything Forest connects to today and where to find it. Looking for a tool that isn't here? [Contact us](mailto:support@forestadmin.com), most APIs can be connected with a [custom data source](/get-started/connect/data-sources/custom-datasources/overview).
## Support & ticketing
* **Browse & edit** tickets, users and organizations: [Zendesk data source](/get-started/connect/data-sources/zendesk)
* **Create & close tickets** from a Forest action: [Zendesk plugins](/product/process/advanced-concepts/plugins/zendesk)
* **Embed Forest** inside Zendesk tickets: [Zendesk app](/product/embed/zendesk)
* **Call Zendesk from workflows**: [Zendesk MCP Connector](/get-started/connect/integrations/mcp-servers)
## Payments & billing
* **Browse & edit** customers, charges and subscriptions as collections: [Stripe data source](/get-started/connect/data-sources/stripe)
* **Call Stripe from workflows**: [Stripe MCP Connector](/get-started/connect/integrations/mcp-servers)
## CRM & marketing
* **Browse & edit** objects (contacts, companies, deals) as collections: [HubSpot data source](/get-started/connect/data-sources/hubspot)
* **Call HubSpot from workflows**: [HubSpot MCP Connector](/get-started/connect/integrations/mcp-servers)
## Data, search & storage
Import tables and views (read-only, via ODBC) as a [data source](/get-started/connect/data-sources/snowflake), or query Snowflake from workflows with the [MCP Connector](/get-started/connect/integrations/mcp-servers).
Expose Elasticsearch indices as searchable collections.
Manage file attachments backed by S3 (and other backends) with the Active Storage plugin, or the AWS S3 plugin (see [Plugins](/product/process/advanced-concepts/plugins/overview)).
Connect Airtable bases as collections.
Connect a Cosmos DB account as collections.
Connect a GraphQL or Hasura endpoint as collections.
## Automation
Read and act on Forest data from n8n with the [Forest node](/product/embed/n8n), or trigger n8n workflows from Forest with the [MCP Connector](/get-started/connect/integrations/mcp-servers).
## AI assistants
Expose your data and actions to AI assistants over the Model Context Protocol.
Route workflow AI steps through your own OpenAI or Anthropic keys with Forest Runtime.
## Databases & ORMs
Forest connects natively to most SQL and NoSQL databases, as well as to your existing ORM models (Sequelize, Mongoose, Active Record, Mongoid). See the [data sources overview](/get-started/connect/data-sources/overview) for the full list.
## Build your own
Need a tool that isn't listed here? Wrap any REST or internal API as a Forest collection with a [custom data source](/get-started/connect/data-sources/custom-datasources/overview), or factor a repeated customization into a [plugin](/product/process/advanced-concepts/plugins/overview).
# Overview
Source: https://docs.forest.app/get-started/connect/overview
Load your data into Forest and enrich it with additional sources
Forest connects to your databases and external services to expose your data and make it actionable.
## How data flows into Forest
Forest loads data through **datasources**, connections to your databases, APIs, or any system that holds data.
```javascript Node.js theme={null}
const agent = createAgent(options);
// Connect your primary database
agent.addDataSource(
createSqlDataSource('postgresql://localhost/mydb')
);
// Add a second datasource (e.g. MongoDB)
agent.addDataSource(
createMongooseDataSource(mongoConnection)
);
// Forest automatically:
// - Discovers all tables
// - Detects column types
// - Identifies relationships (foreign keys)
// - Creates collections for each table
```
```ruby Ruby theme={null}
agent = Forestadmin::Agent.new(options)
# Connect your primary database
agent.add_datasource(
Forestadmin::Datasource::ActiveRecord.new
)
# Add a second datasource
agent.add_datasource(
Forestadmin::Datasource::Mongoid.new
)
# Forest automatically:
# - Discovers all tables
# - Detects column types
# - Identifies relationships (foreign keys)
# - Creates collections for each table
```
## Forest datasources
Forest loads data through **datasources**, connections to your databases, APIs, or any system that holds data. For some datasources, Forest performs introspection to discover the schema automatically. For others (like ORM-based connections), the schema is already known from your model definitions.
**ORM-based connections** (Sequelize, Mongoose):
* Forest reads your ORM model definitions
* Uses the schemas you've already defined in your application code
* Detects relationships from model associations
* No database introspection needed - everything comes from your models
**Direct database connections** (SQL, MongoDB):
* **SQL databases**: Forest queries the database metadata (information\_schema or system catalogs) to discover tables, columns, data types, constraints, foreign keys, and indexes
* **MongoDB**: Forest samples a subset of documents (default: 100 per collection) and analyzes them to infer the schema structure, including nested fields and references
**API-based connections** (REST, GraphQL): Forest uses the API schema or a custom mapping to expose data.
Once introspection is complete, Forest automatically:
1. **Discovers schema** - all tables/collections and their structure
2. **Maps data types** - converts database types to Forest types
3. **Detects relationships** - foreign keys or references become navigable relationships
4. **Creates collections** - each table/collection becomes a collection in the UI
5. **Enables CRUD operations** - browse, create, edit, delete records
## Loading more data: multi-datasources
Connect multiple databases or APIs in the same back-end:
```javascript Node.js theme={null}
// Primary PostgreSQL database
agent.addDataSource(
createSqlDataSource('postgresql://localhost/main'),
{ name: 'main' }
);
// MongoDB for analytics
agent.addDataSource(
createMongooseDataSource(mongoConnection),
{ name: 'analytics' }
);
```
```ruby Ruby theme={null}
# Primary PostgreSQL database
agent.add_datasource(
Forestadmin::Datasource::ActiveRecord.new,
name: 'main'
)
# MongoDB for analytics
agent.add_datasource(
Forestadmin::Datasource::Mongoid.new,
name: 'analytics'
)
```
**Looking to enrich a single record?**
If you want to add computed fields or fetch external data to enrich individual records (not load entire collections), use **Smart Fields** instead of adding a datasource.
[Learn how to enrich data with computed fields →](/product/process/fields/computed)
[Browse available datasources →](/get-started/connect/data-sources/overview)
# Relationships
Source: https://docs.forest.app/get-started/connect/relationships-schema
Define and understand relationships between collections in your Forest schema
A join is used to combine rows from two or more tables, based on a related column between them.
## Declaration
In Forest, relations are defined as fields and are traversable in only one direction.
## Join types
Four join types are available: `ManyToOne`, `ManyToMany`, `OneToMany`, and `OneToOne`.
| Type | Where are the common keys? |
| ---------- | -------------------------------------------------------------------------------------------------- |
| ManyToOne | `origin[foreignKey] == foreign[foreignKeyTarget]` |
| OneToMany | `origin[originKeyTarget] == foreign[originKey]` |
| ManyToMany | `origin[originKeyTarget] == through[originKey] && though[foreignKey] == foreign[foreignKeyTarget]` |
| OneToOne | `origin[originKeyTarget] == foreign[originKey]` |
# TypeScript Autocompletion
Source: https://docs.forest.app/get-started/connect/typescript-autocompletion
Enable TypeScript autocompletion and type safety for your Forest Node.js back-end
**Node.js only** - This page covers TypeScript autocompletion setup for the Node.js agent. For information about Forest data types, see [Data Types](/get-started/connect/data-types).
The Forest Node.js back-end, built entirely in TypeScript, provides comprehensive autocompletion capabilities for collection names, field names, and handler parameters.
## Generating a Typing File
The back-end can generate a typing file based on your data models. This file is auto-generated and should not be manually edited.
### Configuration Options
Two configuration options control typing file generation:
* **`typingsPath`**: Specifies the location where the typing file will be created
* **`typingsMaxDepth`**: Controls the maximum introspection depth for relationships
## TypeScript Usage
In TypeScript projects, import and template the generated schema:
```typescript theme={null}
import { createAgent } from '@forestadmin/agent';
import { Schema } from './typings';
import transactions from './customization/transactions';
await createAgent({
// ...
typingsPath: './typings.ts',
typingsMaxDepth: 5,
})
.customizeCollection('transactions', transactions)
.mountOnStandaloneServer(3000)
.start();
```
The `customizeCollection` method and handler parameters receive strong typing through the Schema template.
## JavaScript Usage
JavaScript developers can leverage JSDoc syntax to maintain autocomplete capabilities:
### Main Back-end File
```javascript theme={null}
const { createAgent } = require('@forestadmin/agent');
/**
* @typedef {import('@forestadmin/agent').Agent} Agent
* @typedef {import('../typings').Schema} Schema
*/
/**
* @type {Agent}
*/
const agent = createAgent({
typingsPath: './typings.ts',
typingsMaxDepth: 5,
});
```
### Customization Files
In separate customization files, JSDoc type annotations preserve autocompletion:
```javascript theme={null}
/**
* @param {CollectionCustomizer} transactions
*/
module.exports = transactions => {
transactions.removeField('amountInEur');
};
```
# Audit & Activity Logs
Source: https://docs.forest.app/get-started/control/audit
Track and monitor all user actions in Forest
## Overview
The Activity tab provides a comprehensive view of team activities within Forest. Every action performed by users is tracked and logged with complete context for accountability and compliance.
## What gets logged
Forest tracks all user activities across different areas:
| Category | Activities |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Data Operations** | `create` - Record creation `update` - Record updates `delete` - Record deletion |
| **Actions** | `action` - Action execution `createApproval` - Approval request created `approveApproval` - Approval granted `rejectApproval` - Approval rejected `markApprovalAsFailed` - Approval failed `ignoreApprovalWarning` - Warning bypassed `sendApprovalBackInReview` - Sent back for review |
| **Inbox Management** | `startProcessingInboxTask` - Task processing started `assignUserToInboxTask` - Task assigned `unassignUserFromInboxTask` - Task unassigned `cancelInboxTask` - Task cancelled `autoCancelInboxTask` - Task auto-cancelled |
| **Workflows** | `triggerWorkflow` - Workflow initiated `completeWorkflowStep` - Step completed `selectWorkflowOption` - Option selected `reviseWorkflowStep` - Step revised `resumeWorkflow` - Workflow resumed `completeWorkflow` - Workflow finished `abortWorkflow` - Workflow aborted |
| **Collaboration** | `createNote` - Note added `deleteNote` - Note removed `createMessage` - Message sent |
For each action, the system logs the **user, targeted record(s), and timestamp**. Forest tracks and stores these activities **without records' sensitive data** except the ID/primary key.
**Known Limitation**: When collections share identical names with different capitalization (e.g., `myCollection` and `MyCollection`), activity tracking may incorrectly attribute actions between these collections.
## Viewing activity logs
### Global activity tab
Access the Activity tab to see all team activities across your Forest project. The logs show all actions with user information, affected records, and timestamps.
### Record-level activity
Individual records display their own activity history through a dedicated Activity tab on each record's detail page, enabling team members to track specific record changes over time.
## Export all activities
Admin users can export comprehensive activity data including read operations:
* Search activities
* Filter usage
* Record detail views
* Dashboard access
* Record exports
* All write operations listed above
To export activity logs:
1. Click the "Export" button in the Activity tab
2. Select a date range
3. Initiate the export
You will receive download links via email shortly after the export request.
**API Access**: Activity logs are also available programmatically through the Forest API. See the [Activity Logs API reference](/reference/api/endpoints/activity-logs) for more information.
## Admin Logs
While Activity Logs track what operators do with your **data**, **Admin Logs** track changes to your Forest project's **configuration**, such as permission and access-management changes. They are kept separately from the data activity logs.
Admin Logs are available through the Forest public API, which lists the admin logs for a project, most recent first. This endpoint is not self-service: [contact us](https://www.forestadmin.com/contact) to enable access.
See the [Admin Logs API reference](/reference/api/endpoints/admin-logs) for details.
# Two-Factor Authentication (2FA)
Source: https://docs.forest.app/get-started/control/authentication/2fa
Enable and enforce two-factor authentication to add an extra layer of security to user accounts
## Overview
Two-Factor Authentication (2FA) adds an additional security layer beyond username and password. Users must provide a second factor - typically a time-based one-time password (TOTP) from an authenticator app - to access Forest.
**Recommended for All Users**: 2FA significantly reduces the risk of unauthorized access, even if passwords are compromised.
## Supported 2FA methods
### Authenticator apps (recommended)
Time-based One-Time Password (TOTP) apps generate 6-digit codes that change every 30 seconds:
Free app for iOS and Android
Supports backup and cloud sync
Multi-device support with encrypted backups
Password manager with built-in TOTP
Open-source with TOTP support
One-tap push notifications
### Backup codes
Recovery codes to use if you lose access to your authenticator app:
* Generated during 2FA setup
* One-time use only
* Store securely (password manager or printed copy)
* Can regenerate if needed
**Save Your Backup Codes**: Without backup codes or access to your authenticator, you'll be locked out if your device is lost.
## Enabling 2FA (for users)
### Setup process
Click your profile picture > **Account Settings** > **Security**
Click **Enable 2FA** button
1. Open your authenticator app
2. Tap "Add account" or "+" button
3. Scan the QR code displayed in Forest
**Or** enter the setup key manually if you can't scan
Enter the 6-digit code from your authenticator app to confirm it's working correctly
1. Download or copy your backup codes
2. Store them securely (password manager recommended)
3. Check the box to confirm you've saved them
You'll now be prompted for a code each time you log in
### Manual setup key
If you can't scan the QR code, use the manual setup key:
```
Setup Key: JBSWY3DPEHPK3PXP
Account: your-email@example.com
Type: Time-based
```
## Using 2FA to log in
### Login flow
Enter your email and password as usual
Open your authenticator app and enter the current 6-digit code
Check "Trust this device for 30 days" to skip 2FA on this device
You're logged in to Forest
### Using backup codes
If you don't have access to your authenticator app:
On the 2FA prompt, click the link to use a backup code
Enter one of your saved backup codes (case-insensitive)
Each backup code can only be used once. Generate new ones if running low.
### Trusted devices
Mark devices as trusted to skip 2FA for 30 days:
* **Use Case**: Your primary work computer
* **Security**: A secure cookie identifies the device
* **Removal**: Go to Account Settings > Security > Trusted Devices to revoke
**Public Computers**: Never mark public or shared computers as trusted.
## Enforcing 2FA (for admins)
Administrators can require 2FA for all users or specific roles.
### Project-wide enforcement
Require 2FA for everyone:
Go to **Project Settings** > **Security** > **Authentication**
Toggle **Require Two-Factor Authentication** to ON
Choose how long users have to enable 2FA:
* 24 hours (urgent)
* 7 days (recommended)
* 30 days (gradual rollout)
Forest automatically emails users about the requirement
Track which users have enabled 2FA in **Project Settings** > **Teams** > **Users**
### Role-based enforcement
Require 2FA only for specific roles:
```javascript theme={null}
// Example: Require 2FA for Admins and Editors
{
"roles": {
"admin": {
"require2FA": true
},
"editor": {
"require2FA": true
},
"viewer": {
"require2FA": false // Optional for viewers
}
}
}
```
**Configuration**:
1. Go to **Project Settings** > **Roles**
2. Edit each role
3. Check **Require 2FA for this role**
### Exceptions
Allow specific users to bypass 2FA requirement:
* **Use Case**: Emergency access accounts, service accounts, external contractors
* **Configuration**: Edit user profile > **Security** > **Exempt from 2FA requirement**
**Best Practice**: Minimize exceptions. If a user truly needs access, they should enable 2FA.
## Managing 2FA
### Regenerating backup codes
If you've used all your backup codes or lost them:
Go to **Account Settings** > **Security**
Click **Regenerate Backup Codes**
Verify with current authenticator code
Old codes are invalidated. Save the new ones securely.
### Resetting your own 2FA
If you need to switch authenticator apps or devices:
Go to **Account Settings** > **Security** > **Disable 2FA**
Enter your current 2FA code or a backup code
Follow the setup process again with your new device/app
### Admin: resetting user's 2FA
If a user loses access to their authenticator and backup codes:
**Security Risk**: Only reset 2FA after verifying the user's identity through alternate means (video call, ID verification, etc.).
Confirm the user's identity (don't rely solely on email, which could be compromised)
Go to **Project Settings** > **Teams** > **Users**
Search for the user who needs 2FA reset
Click user menu (...) > **Reset Two-Factor Authentication**
Confirm the reset. User must set up 2FA again at next login (if required).
This action is automatically logged in audit logs for security tracking
## 2FA + SSO
### How they work together
2FA and SSO can be used simultaneously for defense in depth:
**MFA enforced at identity provider**
* User authenticates with IdP (e.g., Okta, Azure AD)
* IdP requires MFA (push notification, TOTP, etc.)
* Forest trusts the IdP's authentication
**Advantages**:
* Centralized MFA management
* One MFA prompt for all applications
* Better user experience
**Configuration**: Enable MFA in your IdP settings
**Additional 2FA layer in Forest**
* User authenticates via SSO
* Forest requires its own 2FA
**Advantages**:
* Extra layer of security
* Works even if IdP MFA is disabled
* Forest-specific second factor
**Use Case**: Very high security environments, compliance requirements
**Rely entirely on IdP security**
* User authenticates via SSO
* No additional 2FA in Forest
* IdP should enforce MFA
**Advantages**:
* Simplified user experience
* Consistent with other applications
**Requirement**: IdP must have strong MFA policies
### Recommended configuration
Enforce MFA at the IdP level
Disable Forest 2FA for SSO users to avoid double-prompting
Require Forest 2FA
These users don't benefit from IdP security
## Troubleshooting
### Code not working
**Problem**: Authenticator app time is out of sync
**Symptoms**: Code is always rejected, even when entered correctly
**Solution**:
1. Check your phone's time settings
2. Enable automatic time/date
3. Try the next code (they change every 30 seconds)
**For Google Authenticator**:
* Go to Settings > Time correction for codes > Sync now
**Problem**: Entering an old or incorrect code
**Solution**:
* Wait for the code to refresh in your app
* Ensure you're using the correct account (if you have multiple)
* Check for typos (0 vs O, 1 vs l)
**Problem**: Removed app or factory reset phone without backing up
**Solution**:
* Use a backup code if you have one
* Contact your admin for 2FA reset
* Admin must verify your identity before resetting
### Can't scan QR code
**Solutions**:
1. **Use Manual Entry**: Copy the setup key and enter it manually in your authenticator app
2. **Try Different Device**: Use a tablet or another phone to scan
3. **Check Camera Permissions**: Ensure authenticator app has camera access
4. **Screenshot**: Take a screenshot (secure it afterwards) and scan from photos
### Lost backup codes
**If you still have authenticator access**:
1. Log in with your authenticator code
2. Regenerate new backup codes
3. Save them securely
**If you don't have authenticator or backup codes**:
1. Contact your administrator
2. Admin will verify your identity
3. Admin can reset your 2FA
4. Set up 2FA again immediately
### Can't log in after 2FA enforcement
**Problem**: 2FA was enforced but user hasn't set it up
**Solution**:
1. Users receive grace period to enable 2FA
2. During grace period, they're prompted to set up 2FA
3. After grace period, they must set up 2FA before accessing
**Admin Override**:
* Admin can temporarily exempt user from 2FA requirement
* User can then log in and set up 2FA properly
# SCIM Integration with Okta
Source: https://docs.forest.app/get-started/control/authentication/scim-okta
Automate user management by integrating Okta's SCIM provisioning with Forest
You need administrator access to the Forest project.
## Supported Features
The Okta SCIM integration enables:
* User provisioning from Okta to Forest
* Updating user roles, permission levels, and tags
* Deleting users when removed from the Forest app in Okta
* SCIM Groups for team assignment
* Read-only `userName` (email format) and name fields post-creation
## Setup Process
### Step 1: Add Forest App
Navigate to Okta Applications tab, browse the app catalog, and select Forest. Assign a descriptive label.
### Step 2: Authenticate Okta
Enable the User provisioning feature in Forest project settings. This generates an API token to paste into Okta's Integration tab.
### Step 3: Configure Mapping Rules
Create rules for mandatory fields: `teams`, `role`, `permissionLevel`, and optional `tags`. Values must match existing Forest configurations. Ensure mapping direction flows from Okta to Forest.
**Required Parameters:**
* `permissionLevel`: Must be `admin`, `editor`, `user`, or `developer`
* `role`: Must match existing Forest roles
* `teams`: Team names for user assignment
* `tags`: Optional key/value pairs for user tagging
### Step 4: Manage Groups
In Okta's Directory section, create groups matching Forest teams. Use the "Push groups" tab to link Okta groups with Forest teams. Optionally disable group renaming to prevent Okta from overwriting team names.
Removing a group in Okta that was created from, or linked to, a Forest team will **delete** that Forest team.
When you link an Okta group to a Forest team, the team is renamed to match the group name, unless you disable that option.
## Custom Attributes
Add custom user attributes via Directory > Profile Editor for enhanced mapping flexibility.
## Troubleshooting
* Verify `permissionLevel` values: admin, editor, user, or developer
* Confirm `role` matches existing Forest roles
* Allow time for synchronization
* Note that team updates may trigger back-end restarts
# Manual SCIM Integration with Okta
Source: https://docs.forest.app/get-started/control/authentication/scim-okta-manual
Manually configure Okta's SCIM provisioning with Forest using the SCIMForest 2.0 Test App
Enabling SCIM disables Forest user editing. All user management must be done through Okta.
## Supported Features
The manual Okta SCIM integration enables:
* User provisioning from Okta to Forest
* Updating user role, permission level, and tags
* User deletion when removed from the Okta app
* Team assignment via groups
## Setup Steps
### 1. Add Forest App
Navigate to Applications > Browse App Catalog, then select "SCIMForest 2.0 Test App (Header Auth)". Name the application, keeping in mind each app links to one Forest project.
### 2. Authentication
Generate a provisioning token in Forest project settings. In Okta's Integration tab, enter the token prefixed with "Bearer" (format: "Bearer \[token]").
### 3. Configuration
Keep "Sync Password" disabled as it's unsupported.
### 4. Custom Parameters
Four parameters require configuration:
* `permissionLevel`: Admin, Developer, Editor, or User
* `teams`: comma-separated team names (e.g., "Operators,Support")
* `role`: must match existing project roles
* `tags`: optional key/value pairs separated by semicolons
### 5. Attribute Setup
In Profile Editor, set external namespace to `urn:ietf:params:scim:schemas:extension:forest:2.0:User`
### 6. Mapping Rules
Create rules directing Okta to Forest for automatic `role`, `permissionLevel`, and `tags` assignment.
### 7. Group Management
Configure Directory groups for team mapping, then use "Push Groups" to link Okta groups with Forest teams. Optional: disable automatic team renaming in app settings.
# SCIM Integration with OneLogin
Source: https://docs.forest.app/get-started/control/authentication/scim-onelogin
Automate user management by integrating OneLogin's SCIM provisioning with Forest
Enabling SCIM disables Forest user editing. All user management must be done through OneLogin.
## Supported Features
The OneLogin SCIM integration enables:
* User provisioning from OneLogin to Forest
* Updating user role, permission level, and tags
* Deleting users when removed from the Forest app in OneLogin
* SCIM Groups for team assignment
## Configuration Steps
### 1. Adding the Forest App
Navigate to OneLogin's Application tab, select "Add App," then search for and select "SCIM Provisioner with SAML (SCIM v2 Core)."
### 2. Authentication Setup
Name your app, then enable User provisioning in Forest project settings. This generates a token to paste into OneLogin.
### 3. SCIM Base URL
Add this endpoint: `https://api.forestadmin.com/scim`
### 4. JSON Template Configuration
The SCIM template includes user schemas with custom Forest parameters for permissionLevel, role, tags, and teams.
### 5. Custom Parameters
* **permissionLevel**: Must match existing Forest permission level exactly
* **role**: Must match existing project role exactly
* **teams**: Comma-separated team names (e.g., "Operators,Support")
* **tags**: Key/value pairs separated by semicolons (e.g., "regions:France,Italie;job:developer")
### 6. Mapping Rules
Create rules to automatically provide mandatory parameters (role, permissionLevel) and optional tags.
### 7. Custom User Attributes
Add custom fields in the Users tab under "Custom User Fields" to base mapping rules on.
### 8. SCIM Groups Management
Refresh entitlements to fetch OneLogin roles, then create mapping rules between OneLogin roles and Forest teams.
# Single Sign-On (SSO)
Source: https://docs.forest.app/get-started/control/authentication/sso
Authenticate users through your identity provider using SAML 2.0
Single Sign-On lets your users access Forest with their existing corporate credentials, managed by your identity provider (IdP). Forest supports the **SAML 2.0** specification, so any SAML 2.0-compliant IdP can be used.
SSO is configured once, at the **organization** level, by an **organization owner**. A single identity provider is configured per organization.
## How SSO works
Forest uses a standard SP-initiated SAML 2.0 flow: the user starts at Forest, authenticates against your IdP, and is redirected back with a signed assertion.
## Forest SAML settings
When you declare Forest as an application in your IdP, use these values (the audience / Entity ID is shown in your organization settings):
| Setting | Value |
| ------------------ | -------------------------------------------------------------- |
| Callback / ACS URL | `https://api.forestadmin.com/api/saml/callback` |
| Sign-on URL | `https://api.forestadmin.com/api/saml/callback` |
| Logout URL | `https://app.forestadmin.com/login` |
| `NameID` | The user's **email address** (must match their Forest account) |
## Configuring SSO
As an organization owner, go to **Organization settings → Security** and open the SSO configuration.
Create a SAML 2.0 application in your IdP using the Forest SAML settings above. Make sure the `NameID` it returns is the user's email address.
Give Forest your IdP's metadata in one of these ways:
* **XML metadata endpoint URL** (recommended): paste the metadata URL exposed by your IdP.
* **XML metadata file**: upload the metadata file downloaded from your IdP.
* **Manual entry**: enter the login endpoint, the logout endpoint, and a valid signing certificate.
Test the configuration, then enable it. Once SSO is enabled, all users must log in again.
Users must already exist in Forest (or be provisioned through [SCIM](/get-started/control/authentication/scim-okta)) with the same email address used by your IdP. The `NameID` returned in the SAML assertion must equal that email.
## How users log in with SSO
On the Forest login page, click **"Login with SSO"**, enter your organization name, and click **"Login"**. The user is redirected to your IdP and back to Forest once authenticated.
## IdP-initiated login (optional)
Forest also accepts IdP-initiated logins, where the user starts from your IdP's portal and opens Forest from there.
IdP-initiated login introduces a security risk associated with CSRF in the SAML protocol. Prefer SP-initiated login (starting from Forest) unless you specifically need the IdP-initiated flow.
## Provider guides
Forest works with any SAML 2.0 identity provider. Step-by-step guides are available for the most common ones:
# Azure AD / Entra ID SSO
Source: https://docs.forest.app/get-started/control/authentication/sso-providers/azure
Configure SSO with Azure Active Directory / Microsoft Entra ID
## Configuration
1. In the Azure Active Directory admin center, go to **Enterprise applications → New application**
2. Select **Create your own application** (Forest is not in the gallery)
3. Choose **Integrate any other application you don't find in the gallery (Non-gallery)**
4. Configure SAML settings:
| Setting | Value |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| Reply URL (ACS URL) | `https://api.forestadmin.com/api/saml/callback` |
| Sign on URL | `https://api.forestadmin.com/api/saml/callback` |
| Identifier (Entity ID) | `forestadmin-YourOrganizationName` |
| Logout URL (optional) | `https://app.forestadmin.com/login` |
| Relay State (optional) | `{"organizationName": "YourOrganizationName", "destinationUrl": "organization.projects"}` |
5. In the **SAML Signing Certificate** section, copy the **App Federation Metadata Url**
6. In Forest Organization settings, select **XML file endpoint** and paste the URL
## Troubleshooting
* Double-check all endpoints and certificate expiration dates
* Ensure `nameID` is configured to use the **email address used on Forest accounts**
# Generic SAML 2.0 SSO
Source: https://docs.forest.app/get-started/control/authentication/sso-providers/generic-saml
Configure SSO with any SAML 2.0-compatible Identity Provider
Forest supports any Identity Provider that implements the SAML 2.0 specification.
You must be an Organization Owner.
## Step 1: Configure your Identity Provider
Declare Forest as a Service Provider in your IdP using these values:
| Setting | Value |
| ---------------------- | ----------------------------------------------- |
| Callback URL / ACS URL | `https://api.forestadmin.com/api/saml/callback` |
| Sign on URL | `https://api.forestadmin.com/api/saml/callback` |
| Logout URL | `https://app.forestadmin.com/login` |
| Audience (EntityID) | Displayed in your Forest Organization settings |
## Step 2: Configure Forest
In your Organization settings → **Security** tab, configure Forest with your IdP's information.
**Option 1: XML metadata endpoint (recommended)**
Provide the URL to your IdP's metadata XML endpoint. This supports automatic certificate rotation without service interruption.
**Option 2: XML file upload**
Upload the metadata XML file generated by your IdP.
**Option 3: Manual input**
Enter manually:
* Login endpoint
* Logout endpoint
* Valid certificate
## Step 3: Test and enable
Click **"Test configuration"** to verify authentication works. Once confirmed, enable SSO for all users.
After enabling SSO, all users will be required to log in again.
## IdP-initiated login (optional)
Enable **IdP-initiated login** to allow users to be redirected to Forest directly from your IdP dashboard. Set this Relay State on your IdP:
```json theme={null}
{
"organizationName": "YourOrganizationName",
"destinationUrl": "organization.projects"
}
```
## Troubleshooting
* Double-check all endpoints and certificate expiration dates
* Ensure the `NameID` in your IdP is set to the **email address used on Forest accounts**
* Ensure your IdP is configured for **SAML 2.0**
If you can't resolve the issue, ask for help on the [Forest Community Forum](https://community.forestadmin.com).
# Google Workspace SSO
Source: https://docs.forest.app/get-started/control/authentication/sso-providers/google
Configure SSO with Google Workspace
## Configuration
1. Log in to your Google account and navigate to the **Admin console**
2. Go to **Menu → Apps → Web and mobile apps**
3. Click **Add App → Add custom SAML app** and follow the wizard
4. In the **Service Provider Details** window, enter:
| Setting | Value |
| -------------------- | ------------------------------------------------------------------------------------------------------------------ |
| ACS URL | `https://api.forestadmin.com/api/saml/callback` |
| Entity ID | Displayed in your Forest Organization settings |
| Start URL (optional) | For [IdP-initiated login](/get-started/control/authentication/sso-providers/overview#idp-initiated-login-optional) |
5. Download or copy the IdP metadata and paste it into Forest (see [SSO configuration guide](/get-started/control/authentication/sso-providers/overview))
See the [Google documentation on custom SAML applications](https://support.google.com/a/answer/6087519) for more details.
## Troubleshooting
* Double-check all endpoints and certificate expiration dates
* Ensure the `Name ID` (primary email) in your IdP matches the **email address used on Forest accounts**
# Okta SSO
Source: https://docs.forest.app/get-started/control/authentication/sso-providers/okta
Configure SSO with Okta
## Configuration
1. In your Okta admin dashboard, click **Create a new app integration**
2. Select **SAML 2.0** and follow the wizard
3. Configure the app with these settings:
| Setting | Value |
| ------------------------------ | ----------------------------------------------------------------------------------------- |
| ACS URL | `https://api.forestadmin.com/api/saml/callback` |
| Audience URI (EntityID) | `forestadmin-YourOrganizationName` |
| Name ID format | **EmailAddress** |
| Application username | **Email** |
| Update application username on | **Create and update** |
| Relay State (optional) | `{"organizationName": "YourOrganizationName", "destinationUrl": "organization.projects"}` |
4. Go to the **Sign On** tab → **Metadata details** and copy the **Metadata URL**
5. In Forest Organization settings, select **XML file endpoint** and paste the Metadata URL
## Troubleshooting
* Double-check all endpoints and certificate expiration dates
* Ensure `Name ID format` is set to **EmailAddress** and matches the email used on Forest accounts
# SSO Provider Guides
Source: https://docs.forest.app/get-started/control/authentication/sso-providers/overview
Configure Single Sign-On with your Identity Provider
Forest supports SAML 2.0 SSO. The configuration is done in two steps:
1. **Declare Forest in your Identity Provider** using the values below
2. **Configure Forest** with your IdP metadata
You must be an Organization Owner to configure it.
## Forest SAML settings
Use these values when configuring Forest as a Service Provider in your IdP:
| Setting | Value |
| ---------------------- | ----------------------------------------------- |
| Callback URL (ACS URL) | `https://api.forestadmin.com/api/saml/callback` |
| Sign on URL | `https://api.forestadmin.com/api/saml/callback` |
| Logout URL | `https://app.forestadmin.com/login` |
| Audience (EntityID) | Displayed in your Forest Organization settings |
## Configuration methods
### Option 1: XML metadata (recommended)
Provide either a URL to your IdP's metadata XML endpoint, or upload the metadata XML file. This method supports automatic certificate rotation without service interruption.
### Option 2: Manual input
Enter the following fields manually:
* Login endpoint
* Logout endpoint
* Valid certificate
## Enabling SSO
After configuring and testing your SSO setup, enable it for all users in your Organization settings.
After enabling SSO, all users will be required to log in again.
## IdP-initiated login (optional)
To allow users to be automatically redirected to Forest from your IdP dashboard, enable **IdP-initiated login** and set a default Relay State on your IdP:
```json theme={null}
{
"organizationName": "YourOrganizationName",
"destinationUrl": "organization.projects"
}
```
## Troubleshooting
* Double-check all endpoints and certificate expiration dates
* Ensure the `NameID` configured on your IdP matches the **email address used on Forest accounts**
* Ensure you selected **SAML 2.0** on your IdP
## Provider guides
# Roles & Permissions
Source: https://docs.forest.app/get-started/control/roles-permissions
Create and manage custom roles with granular permission controls in Forest
## Overview
Forest enables admins to create and manage custom roles with granular permission controls. The roles system allows organizations to define what actions users can perform within the platform.
Only users with Admin permission level can create and manage roles. Roles are configured in the Roles tab within project settings, where permissions apply to all users assigned to that role.
## Permission levels
Forest defines five user permission levels with increasing administrative capabilities:
| Capability | User | Manager | Editor | Developer | Admin |
| -------------------------------------- | ---- | ------- | ------ | --------- | ----- |
| **Data Management** | ✓ | ✓ | ✓ | ✓ | ✓ |
| Browse collections | ✓ | ✓ | ✓ | ✓ | ✓ |
| View, create, update, delete records\* | ✓ | ✓ | ✓ | ✓ | ✓ |
| Execute actions\* | ✓ | ✓ | ✓ | ✓ | ✓ |
| **Inbox Management** | | ✓ | ✓ | ✓ | ✓ |
| Manage inbox and notifications | | ✓ | ✓ | ✓ | ✓ |
| **UI Customization** | | | ✓ | ✓ | ✓ |
| Customize layouts and views | | | ✓ | ✓ | ✓ |
| Configure collection displays | | | ✓ | ✓ | ✓ |
| Create and edit Smart Views | | | ✓ | ✓ | ✓ |
| Create workspaces | | | ✓ | ✓ | ✓ |
| Create dashboards | | | ✓ | ✓ | ✓ |
| Create workflows | | | ✓ | ✓ | ✓ |
| **Environment Management** | | | | ✓ | ✓ |
| Manage environments | | | | ✓ | ✓ |
| Configure environment settings | | | | ✓ | ✓ |
| Deploy between environments | | | | ✓ | ✓ |
| **Team & Role Management** | | | | | ✓ |
| Manage teams and users | | | | | ✓ |
| Create and manage roles | | | | | ✓ |
| Configure project settings | | | | | ✓ |
| Access all administrative features | | | | | ✓ |
\*Based on collection and action permissions configured for the role
## Roles
Roles are configured in **Project Settings → Roles**. Each role defines granular permissions for collections and actions.
### Collection permissions
Control what users can do with data in each collection:
| Permission | Description |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Read (List)** | Access to table view data - allows users to see the list of records in a collection |
| **Read (Details)** | Access to details and summary view data for individual records - allows users to open and view a specific record's complete information |
| **Create** | Record creation capability, including the ability to duplicate existing records |
| **Update** | Modify existing records |
| **Delete** | Remove records permanently |
| **Export** | Export data from the collection in various formats (CSV, JSON, etc.) |
### Action permissions
Control user ability to trigger and approve actions:
| Permission | Description |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Trigger** | Allow users assigned to this role to trigger this action - the basic execution permission |
| **Require Approval** | Actions won't execute without manual approval - creates an approval workflow where actions are queued and must be reviewed before execution |
| **Approve** | Permits role members to approve trigger requests submitted by other users - can review and authorize pending action requests |
| **Self Approve** | Allows users to approve their own action requests - bypasses the need for another user to review the action, useful for trusted users who need faster execution |
### Approval workflows
When "Require Approval" is enabled for an action:
1. User triggers the action
2. Action enters pending state in the approval queue
3. Users with "Approve" permission review the request
4. Action executes once approved (or is cancelled if rejected)
This workflow ensures sensitive operations (like refunds, data exports, or account deletions) go through proper review before execution.
[Learn more about action approval workflows →](/product/collaborate/approval-workflows)
## Conditional permissions
Restrict permissions based on data conditions using filters. For example, operators might trigger refunds under \$1,000 without approval, while higher amounts require authorization.
Conditional permissions allow you to:
* Set data-based filters on any permission
* Create dynamic permission rules based on field values
* Combine multiple conditions with AND/OR logic
* Apply different permission levels based on record data
**Example:** Allow the "Support" role to trigger refunds only when `amount < 1000`, otherwise require approval.
## Default permissions
Configure default permissions that are automatically applied when new collections or actions are created. This ensures consistent permission settings across your project without having to manually configure each new collection or action.
Default permissions are configured in **Project Settings → Roles** and apply to:
* New collections added to your datasources
* New actions created in the back-end
* New fields added to existing collections
This saves time and ensures security by establishing baseline permissions that new elements inherit automatically.
# Scopes
Source: https://docs.forest.app/get-started/control/scopes-and-visibility
Control which records users can see with dynamic data filtering
## What is a Scope?
A scope functions as "a filter which applies to a collection and all its segments." Scopes enable dynamic data filtering based on the current user, allowing organizations to control which data different users can access throughout the application.
**Scope Limitations**: Scopes apply to the entire application except for global actions, API & SQL charts, and Collaboration & Activities sections.
## Setup process
To configure scopes, navigate to a collection's settings page using the Layout editor mode, then access the Scopes tab. Users can establish a filter and save it to restrict visible data.
For example, filtering for customers whose email addresses end with "@forestadmin.com" would limit collection display to only those matching records.
## Dynamic scopes with user variables
Scopes support dynamic filters based on user attributes. Available dynamic variables include:
* `$currentUser.id`, user identifier
* `$currentUser.firstName` / `lastName` / `fullName`, user name data
* `$currentUser.email`, user email address
* `$currentUser.team.id` / `team.name`, team information
* `$currentUser.tags.your-tag`, custom user tag values
### Practical example
An organization with regional operations teams (France team, Germany team) can filter by team name. When Marc from the France team logs in, he sees only French customer data. Louis from the Germany team simultaneously sees only German customer data.
## User Tags
When user data doesn't directly match database records, user tags provide a solution. Administrators assign tags to individual users through their details pages, then reference those tags in scope filters using `$currentUser.tags.your-tag` syntax. This creates flexible mappings between users and accessible data.
# Two-Factor Authentication Enforcement
Source: https://docs.forest.app/get-started/control/security/2fa-enforcement
Enforce 2FA for specific environments
## Overview
Two-Factor Authentication (2FA) enforcement allows organizations to enforce a second authentication factor while login to specific environments.
## How 2FA Enforcement Works
While 2FA is available platform-wide in Forest, the Security tab allows you to make 2FA mandatory before users can access specific environments.
## Third-Party Login Providers
For users who log in through third-party providers, ensure that 2FA is enabled on that provider to maintain security parity.
When using third-party login providers (such as Google, Microsoft, etc.), the 2FA security must be configured at the provider level to ensure consistent protection.
## Configuration
To enforce 2FA for specific environments:
Go to **Project Settings** > **Security Tab**
Select the environments where 2FA should be mandatory
Save your configuration to enforce 2FA requirements
Once configured, users will be required to complete two-factor authentication before accessing the specified environments.
# Security & Privacy Architecture
Source: https://docs.forest.app/get-started/control/security/architecture
Understand how Forest protects your data with privacy-first architecture and robust security measures
Forest is built with security and privacy at its core. Your data never transits through Forest servers, and you maintain complete control over your infrastructure and access policies.
## Data privacy
### Private by design
Forest implements a **privacy-first architecture** where your data flows directly between your Back-end and user browsers, never passing through Forest servers.
**How it works:**
When users access the Forest UI, their browser establishes two separate connections:
1. **Forest servers:** Retrieves layout configuration, UI settings, and metadata
2. **Your Back-end:** Retrieves actual data from your database
**What Forest sees:**
* UI layouts and configurations
* User authentication metadata (email, role, permissions)
* API request logs (endpoints called, timestamps)
**What Forest never sees:**
* Your actual data (customer records, transactions, etc.)
* Database credentials
* Your `FOREST_AUTH_SECRET`
This architecture ensures your data remains within your infrastructure at all times.
### No third-party tracking
Forest guarantees data privacy across all plan levels:
* **No data sharing:** Your data is never sold or shared with third parties
* **No third-party analytics on data:** Forest doesn't track or analyze your business data
* **Optional tracking control:** Organizations can disable third-party vendors that might track activity metadata from browsers
## Security measures
### Token-based authentication
Forest uses a **dual-token authentication system** to secure both UI access and Back-end communication.
#### FOREST\_ENV\_SECRET
Authenticates requests between your Back-end and Forest servers.
**Purpose:**
* Links your Back-end to your Forest project
* Authenticates layout and configuration requests
* Required for all architectures (Cloud, Self-Hosted, On-Premise)
**Security notes:**
* Generated by Forest
* Unique per environment (development, staging, production)
* Should be stored as an environment variable
```bash .env theme={null}
FOREST_ENV_SECRET=1234567890abcdef1234567890abcdef1234567890abcdef
```
Never commit `FOREST_ENV_SECRET` to version control. Always use environment variables or secret management tools.
#### FOREST\_AUTH\_SECRET
Authenticates requests between user browsers and your Back-end (Self-Hosted and On-Premise only).
**Purpose:**
* Signs JWT tokens for user authentication
* Validates requests to your Back-end
* **Your choice** - Forest never knows this secret
**Security notes:**
* Generated by you (not Forest)
* Should be at least 32 characters long
* Unique per environment
* Used only in Self-Hosted and On-Premise architectures
```bash Generate a secure secret theme={null}
# Using Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Using OpenSSL
openssl rand -hex 32
```
```bash .env theme={null}
FOREST_AUTH_SECRET=your-secure-random-string-at-least-32-characters-long
```
**Cloud architecture:** `FOREST_AUTH_SECRET` is not needed because authentication is handled by Forest servers. Your data still flows directly from your Back-end to browsers without passing through Forest.
### JWT token structure
Both tokens are JSON Web Tokens (JWT) containing user context:
**Token payload includes:**
* User ID
* Email
* Full name
* Role
* Team
* Tags
* Permissions
**Use cases for token data:**
* Custom authorization logic in your Back-end
* Audit logging
* Dynamic filtering based on user context
* Integration with your internal systems
```javascript Node.js - Access user context theme={null}
agent.customizeCollection('orders', collection => {
collection.addHook('Before', 'List', async (context) => {
const { email, role } = context.caller;
// Custom logic based on user context
if (role !== 'admin') {
context.filter = { user_email: email };
}
});
});
```
```ruby Ruby - Access user context theme={null}
collection.add_hook(:Before, :List) do |context|
email = context.caller.email
role = context.caller.role
# Custom logic based on user context
unless role == 'admin'
context.filter = { user_email: email }
end
end
```
### Infrastructure flexibility
You maintain **complete control** over your Back-end deployment:
**Deployment options:**
* **DMZ (Demilitarized Zone):** Deploy Back-end in isolated network segment
* **VPN:** Require VPN connection to access Back-end
* **Private Cloud:** Deploy within your private cloud infrastructure
* **On-Premise:** Keep everything within your data center
**Network security:**
* Configure firewall rules
* Set up network segmentation
* Implement reverse proxies
* Use TLS/SSL for all connections
**Best practice:** Deploy your Back-end behind a VPN or firewall to add an additional layer of security. Even if an attacker obtains valid credentials, they would still need network access to reach your Agent.
### HTTPS/TLS encryption
All communication is encrypted:
* **Browser ↔ Forest:** HTTPS with TLS 1.2+
* **Browser ↔ Your Back-end:** HTTPS (you configure)
* **Back-end ↔ Forest:** HTTPS with TLS 1.2+
Always deploy your Back-end with HTTPS enabled in production. Never use HTTP for sensitive data.
# Auto Logout
Source: https://docs.forest.app/get-started/control/security/auto-logout
Force user session logout after inactivity
## Overview
Auto Logout allows organizations to force your users session logout after a few minutes of inactivity.
## Available Timeout Options
You can configure the following inactivity timeout periods:
* 1 min
* 2 min
* 3 min
* 10 min
* 30 min
* 1 hour (default)
* 2 hours
* 3 hours
* 4 hours
* 5 hours
* 10 hours
* 24 hours
## Configuration
To configure Auto Logout settings:
Go to **Project Settings** > **Security Tab**
Select your desired timeout period from the available options
Save your configuration to apply the auto logout policy
# IP Whitelisting
Source: https://docs.forest.app/get-started/control/security/ip-whitelisting
Control which IP addresses can access your back-office
## Overview
IP Whitelisting enables admins to control which IP addresses can access your back-office. This feature restricts back-office access to approved IP addresses only.
## How it works
When IP Whitelisting is enabled, Forest restricts access to your back-office to only those IP addresses that have been explicitly whitelisted. Users attempting to access from non-whitelisted IP addresses will be blocked.
## Configuration
To configure IP Whitelisting:
Go to **Project Settings** > **Security Tab** > **IP Whitelisting**
Turn on the IP Whitelisting feature
Add the IP addresses that should have access to your back-office
Save your configuration to enforce IP restrictions
# Teams
Source: https://docs.forest.app/get-started/control/teams
Give each group its own layout: a tailored interface, dashboards, and data visibility per team
## Overview
Teams allow you to create different layouts for different groups within your organization. Each team can have its own customized interface, optimized for their specific workflows and priorities.
**Example use cases:**
* **Customer Support** might prioritize displaying transaction IDs in the first column and default to the Data tab
* **Sales team** would prefer showing company names first and default to the Dashboard tab
* **Operations** could have a layout focused on order fulfillment metrics and status tracking
Teams enable you to mirror your internal organizational structure within Forest, ensuring each department sees the most relevant information for their daily work.
When combined with scopes, teams enable row-level security where users only see data relevant to their team context.
Teams are managed in **Project Settings → Teams**.
## Create a team
To establish a new team, navigate to the Teams tab within your project settings and select "+ New team". When creating a team, you have the option to "optionally copy another team's layout".
## Team layout management
A team's layout encompasses all visible UI aspects, customizable per team:
* **Workflows** - Custom workflows specific to the team
* **Inbox** - Team-specific inbox configuration
* **Workspaces** - Organized collections and navigation
* **Collection settings and widgets** - Including Smart Views, columns, and table configurations
* **Actions visibility** - Which actions are visible to the team
* **Dashboards** - Custom dashboards with team-relevant metrics
* **Record settings** - Summary views, analytics per record, related data tabs
To apply an existing team's configuration to another team, access the team's settings page and use the "Copy" feature.
This action is irreversible, so review carefully before proceeding.
## Delete a team
To remove a team, go to its settings page, scroll beneath the user list, and click "Delete this team". You'll be asked to retype the team name as a confirmation step.
This is a permanent action that cannot be undone.
## Advanced use cases
Teams unlock advanced use cases for different organizational needs:
* **Customer portals** - Create dedicated teams for external customers with limited, customized access to their own data
* **Multi-tenant applications** - Isolate data and interfaces for different clients or business units
* **Partner access** - Provide partners with tailored views into relevant data without exposing everything
# Browse & customize your data
Source: https://docs.forest.app/get-started/customize-your-back-office
Explore your records, then shape collections and fields into a back-office your team can actually use
Out of the box, every table in your database is surfaced as a collection with full browse, search, and edit. This step is about exploring what you have, then making it useful for the people who'll work in it every day.
## Browse and search your data
Open any collection from the left sidebar to get a table view of its records. From there you can:
* **Search** across the collection's main fields
* **Filter** on any field (status, date ranges, related records) and combine conditions
* **Sort** by any column
* Click a row to open the **record detail**, with its fields, related data, and available actions
This is the default operator experience, no configuration required. Everything below is about tailoring it.
For the full set of browsing controls, filters, sorting, and exports, see [Browse](/product/execute/browse).
## Organize your navigation bar and collection table view
Open the **Layout Editor** to enter edit mode. From there you can:
* **Hide** & **reorder** collections in the left navigation bar view to only surface the most important ones
* **Hide & reorder** fields that are irrelevant for your team's daily work
Changes in the Layout Editor are saved as configuration, they don't affect your database schema.
The Layout Editor is available to users with the **Editor** permission level or higher. [Learn more about permission levels](/get-started/control/roles-permissions).
## Configure a collection
Beyond the layout, each collection has settings that shape how it behaves:
* **Display name** and **reference field** (the value shown when the record is referenced elsewhere)
* Default **sort** and **search** behavior
* Which **fields** appear, and the **widget** used to display or edit each one (text, date picker, image, JSON, related-record selector…)
See [Collection configuration](/product/build/collection-configuration) and [Fields & widgets](/product/build/fields-and-widgets/overview) for the full list of options.
## Create a segment
Segments are saved filters that let your team quickly access specific subsets of data, for example "Pending orders" or "Enterprise customers".
To create a segment:
1. Open a collection
2. Apply the filters you want (e.g. `status = pending`)
3. Click **Save as segment** and give it a name
The segment will appear in the left sidebar for that collection.
You can also create **smart segments** with custom backend logic for more complex filtering. See [Segments](/product/process/segments/creating-segments).
## What's next
You now have a clean, readable back-office your team can navigate. The next step is adding custom business logic, actions your team can trigger directly from the UI.
Create actions and computed fields
# Deploy
Source: https://docs.forest.app/get-started/deploy
Move your Forest back-end to production and set up your development workflow
So far you've been working locally. This step covers deploying your Forest back-end to production and understanding how Forest's development workflow keeps your environments in sync.
## Understand environments and branches
Forest distinguishes between **environments** (your local dev, staging, production) and **branches** (layout and configuration changes in progress).
* Your **production environment** is the live back-office your team uses
* Your **development environment** is your local Forest back-end
* **Branches** let you make layout changes without affecting production until you're ready to deploy
This means you can iterate on your back-office configuration safely, then push changes to production when they're ready.
## Deploy your back-end
Host your Forest back-end on any platform that can run a Node.js application (AWS, Heroku, GCP, your own servers, etc.).
Make sure to set the environment variables (`FOREST_ENV_SECRET`, `FOREST_AUTH_SECRET`, `DATABASE_URL`) in your hosting platform.
In Forest, go to **Project Settings** → **Environments** → **Add environment**.
Give it a name (e.g. "Production") and enter the URL where your back-end is running.
In the Forest UI, go to **Environments** and click **Deploy to production** to push your layout configuration (segments, UI customizations, workspaces) to production.
Your team can now access the production back-office.
For a full explanation of the development workflow, branches, schema updates, environment management, see [Developer Workflow](/product/process/advanced-concepts/developer-workflow/environments-and-branches).
## Keep the production schema up to date
[`.forestadmin-schema.json`](/reference/schema/forestadmin-schema) describes your data model to Forest. In development it's regenerated every time your agent boots.
**We recommend committing it** with your code: schema changes then show up in pull-request diffs, can be reviewed, and can be reverted in Git.
### Fail CI when the committed schema drifts
Because the file is generated locally, someone can change a data source or customization and forget to commit the regenerated schema, shipping a stale one. Add a CI step that regenerates it and fails when it differs from what's committed, so a forgotten commit turns the build red:
Call [`agent.generateSchemaOnly()`](/reference/agent-api/nodejs) (available since `@forestadmin/agent` 1.83.0) from a one-shot script that reuses your agent setup, then diff the result. Extract your agent into a shared factory that does **not** call `.start()`:
```typescript theme={null}
// generate-schema.ts
import makeAgent from './agent'; // export default () => createAgent({...}).addDataSource(...), without .start()
await makeAgent().generateSchemaOnly(); // writes to the schemaPath set in createAgent
process.exit(0); // an open connection pool would otherwise keep the process alive
```
```bash theme={null}
# the database must be reachable and migrated: generation introspects your tables
npx tsx generate-schema.ts
git diff --exit-code .forestadmin-schema.json
```
If `typingsPath` is set, `generateSchemaOnly()` also rewrites the typings file. Add it to the diff when you commit it (`git diff --exit-code .forestadmin-schema.json typings.ts`) so a stale one fails CI too; if you don't commit the typings, leave them out of the diff so a regenerated local copy doesn't break the build.
Your CI job needs the same environment variables as your back-end (`FOREST_ENV_SECRET`, `FOREST_AUTH_SECRET`, `DATABASE_URL`): `createAgent` requires the secrets even though generation is offline by default. Experimental no-code customizations also fetch their configuration from Forest, which requires connectivity.
The `forest_admin:schema:generate` rake task writes the file without booting the server or sending it to Forest. Regenerate, then diff:
```bash theme={null}
# the database must be reachable and migrated: generation introspects your tables
rails db:migrate
rails forest_admin:schema:generate
git diff --exit-code .forestadmin-schema.json
```
### Generate the schema at build time
Alternatively, you can generate the schema in your CI/CD pipeline and bake it into your build artifact instead of committing it. This isn't the default, but some teams prefer it when merge conflicts on `.forestadmin-schema.json` or forgotten regenerations are a recurring pain: you trade the reviewable Git diff for a conflict-free, always-fresh file, and no longer commit it.
Run the same generation command, then bake the file into your image instead of diffing it:
```bash theme={null}
npx tsx generate-schema.ts
# bake the generated .forestadmin-schema.json into your image, then deploy/promote it
```
```bash theme={null}
rails db:migrate
rails forest_admin:schema:generate
# bake the generated .forestadmin-schema.json into your image, then deploy/promote it
```
Generate the schema against a database whose structure matches production. If your build database is behind or ahead of production (a pending migration), the shipped schema can diverge from what production expects — the same risk as deploying an out-of-date committed file.
## What's next
Production is live. Time to invite your team.
Add users, create teams, and set up permissions
# Expose to AI agents
Source: https://docs.forest.app/get-started/expose-to-ai-agents
Turn on the MCP server so AI agents can act on your data under the same governance
The data, actions, and workflows you've built aren't just for the Forest UI. Forest ships an **MCP server** that exposes the same capabilities to AI agents like Claude, enforcing the exact same permissions and writing the same audit trail, regardless of whether a human or an AI agent is acting.
## What the MCP server exposes
Through the Model Context Protocol, connected AI agents can:
* Browse your collections and their schemas
* Query and filter records across collections
* Execute actions on records, including approval workflows
Every operation runs with the permissions of the authenticated Forest user and is logged just like a UI action.
## Turn on the MCP server
With the Node.js back-end, mount the MCP server when you create the back-end in your `index.js`:
```javascript theme={null}
const agent = createAgent(options)
.addDataSource(/* ... */)
.mountAiMcpServer();
```
Restart your back-end. You should see:
```
info: [MCP] Server initialized successfully
```
Your MCP endpoint is now available at `/mcp`.
You can find your back-end URL in **Project Settings → Environments**. Each environment has its own back-end URL, and therefore its own MCP endpoint.
## Restrict which tools are exposed
By default all tools are enabled. To expose only a subset, pass `enabledTools`, new tools added in future releases stay off until you opt in:
```javascript theme={null}
agent.mountAiMcpServer({
enabledTools: ['describeCollection', 'list', 'listRelated'],
});
```
`describeCollection` is always enabled, it's required for the server to function.
## Connect Claude
Point your AI assistant at the MCP endpoint. On first connection, a browser window opens for you to log in with your Forest credentials, the assistant then operates with that user's permissions.
```bash theme={null}
claude mcp add --transport http forest-admin /mcp
```
Use the transport type `http` (the Forest MCP server uses Streamable HTTP). For other clients (Claude Desktop, Cursor, VS Code, Codex) and the standalone deployment option, see the [Forest MCP Server reference](/product/embed/mcp-server).
Only grant MCP access to trusted AI tools and users. The server can perform any operation the authenticated user can perform.
## What's next
Your operations are now available to your team and your AI agents. Time to move everything to production.
Deploy your Forest back-end to production
# Introduction
Source: https://docs.forest.app/get-started/intro-to-forest-admin
What Forest is, how it works, and what you'll build in this guide.
Forest is the operational infrastructure for regulated operations. It runs alongside your existing systems and gives your team, and your AI agents, one place to execute workflows with full governance and decision traces.
You keep control of your data and infrastructure: the Forest back-end runs in your environment and your data never leaves your network. Forest hosts only the UI and configuration.
This guide covers the **self-hosted Node.js back-end**, the recommended path for most teams.
## How it works
Forest runs through a **back-end**, a lightweight service you deploy alongside your application. The Forest back-end connects to your databases, exposes your business logic and workflows, and serves the API that the Forest UI (and any AI agents you connect) consume.
The Forest back-end does three things:
1. **Connects to your data**, SQL, MongoDB, ActiveRecord, Mongoose, Sequelize, APIs, custom datasources. Multiple sources in one back-end are supported by default.
2. **Runs your business logic**, custom actions, computed fields, hooks, validations. Defined in code, executed where your data lives.
3. **Exposes everything safely**, to your operations team via the Forest UI, and to AI agents via the built-in MCP server. Same permissions, same audit trail, regardless of who or what is asking.
## What you'll build in this guide
By the end of this guide you'll have:
* A self-hosted Forest back-end running in production, connected to your database
* A configured Forest UI your team can use daily
* Custom actions and computed fields tied to your business logic
* Workflows and workspaces formalizing your operations
* An MCP server exposing your data and actions to AI agents under the same governance
* Your team invited with the right permissions
## Prerequisites
Before you start:
* Node.js 18+ installed
* A database with a connection URI ready (PostgreSQL, MySQL, MongoDB, SQL Server, etc.)
* A Forest account, [sign up here](https://app.forestadmin.com/signup) if you don't have one
* Comfortable with a terminal
Get your Forest back-end running in 15 minutes.
# Invite a developer
Source: https://docs.forest.app/get-started/invite-a-developer
Onboard a new developer onto the project with their own development environment
When a new developer joins the project, they need their own local environment connected to Forest. This keeps their work isolated from production while they develop.
## Invite them to the project
1. Go to **Project Settings** → **Users**
2. Invite the developer with the **Developer** or **Admin** permission level so they can manage their environment
## They set up their local back-end
The developer clones the back-end repository and installs dependencies:
```bash theme={null}
git clone
cd
npm install
```
## Create a development environment
Each developer works in their own environment, which has its own `FOREST_ENV_SECRET`.
1. In Forest, go to **Project Settings** → **Environments**
2. Click **Add environment** → **Development**
3. Give it a name (e.g. "Alice's dev")
4. Copy the generated `FOREST_ENV_SECRET`
The developer adds their environment variables to their local `.env`:
```bash theme={null}
FOREST_ENV_SECRET=
FOREST_AUTH_SECRET=
DATABASE_URL=
```
## Start the back-end
```bash theme={null}
npm start
```
Their local back-end is now connected to their own Forest environment, isolated from production.
## Work with branches
Layout changes (collections display, segments, workspaces) are managed through branches, just like code. The developer creates a branch to work on UI changes without affecting production:
```bash theme={null}
forest branch my-feature-branch
```
When the changes are ready, they push the branch:
```bash theme={null}
forest push
```
An Admin can then review and deploy the branch to production from the Forest UI.
For a full explanation of the branch and deploy workflow, see [Developer Workflow](/product/process/advanced-concepts/developer-workflow/environments-and-branches).
***
You're all set. Your back-office is live, your team has access, and your development workflow is in place.
**Explore [Product](/product/overview)** to go deeper on any feature, Connect, Build, Customize, Control, and more.
# Invite your team
Source: https://docs.forest.app/get-started/invite-your-team
Add users, create teams, and assign permissions
Your back-office is in production. This step is about giving your team access, with the right level of permissions for each person.
## Invite a user
1. Go to **Project Settings** → **Users**
2. Click **Invite user**
3. Enter their email address and select a **permission level**
The user will receive an invitation email and can access the back-office immediately after signing up.
## Permission levels
A user's **permission level** sets what they can do with the Forest platform itself, configuration, environments, administration. Forest has five built-in levels, each adding capabilities on top of the previous:
| Level | Adds the ability to |
| ------------- | --------------------------------------------------------------------- |
| **User** | Browse data, view/create/update/delete records, execute actions |
| **Manager** | Manage the inbox and notifications |
| **Editor** | Customize layouts, Smart Views, workspaces, dashboards, and workflows |
| **Developer** | Manage environments and deploy between them |
| **Admin** | Manage teams, users, roles, and project settings |
## Roles and teams
Permission levels are separate from **roles**. Where a permission level governs platform capabilities, a **role** governs *data access*, which collections a user can read or edit, which actions they can trigger, down to field level and conditional rules.
Roles are assigned through teams: group users into a team (e.g. Support, Finance), then attach a role that scopes exactly what that team can touch.
1. Go to **Project Settings** → **Teams**
2. Click **New team** and give it a name
3. Add users to the team
4. Attach a role defining which collections, actions, and fields the team can access
For the full breakdown of roles, collection and action permissions, conditional rules, and approval workflows, see [Roles & Permissions](/get-started/control/roles-permissions).
## What's next
Your team is in. If another developer needs to contribute to the project, the next step covers the developer onboarding workflow.
Set up a development environment for a new developer
# Billing & Invoices
Source: https://docs.forest.app/get-started/project-settings/billing
View your subscription details and access invoice history
The Billing tab provides access to your Forest subscription information and invoice history.
## Accessing billing settings
1. Click your **project logo** in the top-left corner
2. Select **Project Settings**
3. Navigate to the **Billing** tab
## Billing details
View your current Forest plan information, including:
* **Current plan** - Your active subscription tier
* **Monthly fee estimate** - Based on your current number of users
* **Commitment details** - Contract length and terms
* **Payment frequency** - Monthly or annual billing
* **Total user count** - Number of active users in your organization
* **Activated features** - List of features available on your plan
The monthly fee is calculated based on your current number of users. Adding or removing users will affect your next invoice.
## Managing your plan
To modify your subscription or upgrade your plan:
1. Click **Manage your plan** in the Billing tab
2. You'll be directed to Forest's pricing page
3. Review available plans and features
4. Contact the sales team or modify your subscription as needed
## Invoices
The invoices section displays a historical record of your billing activity:
| Information | Description |
| ------------------ | ----------------------------- |
| **Invoice date** | Month and year of the invoice |
| **Amount** | Total amount charged |
| **Payment status** | Confirmation of payment |
You can download invoices for accounting and expense tracking purposes.
## Payment methods
Update your payment information to ensure uninterrupted service:
* Credit or debit card details
* Billing address
* Tax information (if applicable)
Keep your payment information up to date to avoid service interruptions. Failed payments may result in restricted access to your Forest project.
# General Settings
Source: https://docs.forest.app/get-started/project-settings/general
Configure core project settings including name, timezone, and locale
The General tab allows you to manage core project configuration and ownership settings.
## Accessing general settings
1. Click your **project logo** in the top-left corner
2. Select **Project Settings**
3. Navigate to the **General** tab
## Available settings
### Project name
Update your project's name to help identify it across your organization.
### Timezone
Set your default timezone to ensure all timestamps and scheduling reflect the appropriate time zone for your organization. This affects:
* Activity log timestamps
* Scheduled workflow executions
* Report generation times
### Locale
The locale controls how numbers and dates display throughout Forest. Available options:
| Locale | Number Format | Date Format |
| ---------------- | ------------------------------------ | ------------------------------------ |
| **Auto** | Adapts to each user's browser locale | Adapts to each user's browser locale |
| **French** | 29 567,13 | 20/03/21 |
| **English (US)** | 29,567.13 | 03/20/21 |
The **Auto** setting provides the best experience for international teams, as each user sees formats matching their browser preferences.
## Ownership transfer
Transfer the ownership of your project to another user when needed. The new owner will have full administrative access to the project.
Only the current project owner can transfer ownership. This action should be performed carefully as it grants full control to the new owner.
## Project deletion
You can permanently remove your project from this tab.
**Deleting your project cannot be undone.** All configurations, layouts, and settings will be permanently lost.
# Interface & Branding
Source: https://docs.forest.app/get-started/project-settings/interface
Customize your Forest interface with white-label branding and custom domains
The Interface tab provides white-label customization capabilities to align Forest with your company's brand identity.
## Accessing interface settings
1. Click your **project logo** in the top-left corner
2. Select **Project Settings**
3. Navigate to the **Interface** tab
## Available customization options
### Project logo
Set your own project logo which will appear in the top-left corner of your Forest interface, replacing the default Forest branding.
**Requirements:**
* Recommended format: PNG or SVG
* Recommended dimensions: 120x40 pixels for optimal display
* File size: Maximum 2MB
### Primary color
Customize the primary color applied throughout the application interface, including:
* Buttons
* Links
* Active states
* Navigation highlights
This allows you to match Forest's color scheme to your company's brand guidelines.
### Custom domain
Instead of accessing your Forest instance through the default `app.forestadmin.com/12345` URL, configure a custom domain.
**Examples:**
* `forestadmin.my-company.com`
* `admin.my-company.com`
* Any custom domain you own
#### Setting up a custom domain
1. Choose your desired subdomain or domain
2. Configure DNS settings with your domain provider:
* Add a CNAME record pointing to the Forest server
* Specific DNS instructions are provided in the Interface tab
3. Enter your custom domain in the Interface settings
4. Wait for DNS propagation (usually 24-48 hours)
5. Access your Forest instance via your custom domain
SSL certificates are automatically provisioned for custom domains to ensure secure connections.
# User Management
Source: https://docs.forest.app/get-started/project-settings/users
Add, manage, and remove users in your Forest project
The Users tab allows you to invite new users, manage which teams they're part of, and control access to your Forest project.
## Accessing user management
1. Click your **project logo** in the top-left corner
2. Select **Project Settings**
3. Navigate to the **Users** tab
## Inviting new users
To add a user to your Forest project:
1. In the "Invite users" section, provide:
* **Email address** - The user's work email
* **Team** - The team they will join
* **Role** - Their role for granular permissions
* **Permission level** - User, Manager, Editor, Developer, or Admin
2. Click **Invite**
Pending invitations are listed below the user list to track who hasn't accepted yet.
## Managing user teams
Access a user's details page to manage which teams they're part of:
* Add them to a new team
* Modify their teams
* Remove them from a team
A user must be assigned to at least one team at all times. Make sure to add them to a new team before removing them from their current one.
Remember to save changes after making adjustments.
## User tags
Tags organize users into groups for the [Scopes](/get-started/control/scopes-and-visibility) feature, controlling data visibility based on user attributes.
Each tag consists of a `key` and `value` pair:
* **Key** - Must be unique per user (e.g., `region`, `department`, `office_id`)
* **Value** - The specific value for that user (e.g., `US-West`, `Sales`, `42`)
### Example use cases
* **Regional access** - Tag users with `region: EU` or `region: US` to show only relevant data
* **Department filtering** - Tag with `department: Sales` to restrict to sales records
* **Multi-tenant applications** - Tag with `client_id: 123` for client-specific data access
## Removing users
To delete a user account:
1. Open their details page from the Users tab
2. Scroll to the **Danger zone** section
3. Click **Remove user**
4. Confirm by typing `CONFIRM REMOVE`
Removing a user is permanent and will immediately revoke their access to Forest.
# Quickstart
Source: https://docs.forest.app/get-started/quickstart
Get your Forest back-end running locally in 15 minutes.
You'll have a working Forest back-end connected to your database, with the Forest UI open and ready to configure, in about 15 minutes.
## Prerequisites
* Node.js 18+ installed
* Your database URI ready (PostgreSQL, MySQL, MongoDB, SQL Server, etc.)
* A Forest account ([sign up](https://app.forestadmin.com/signup) if needed)
## Steps
Go to [app.forestadmin.com](https://app.forestadmin.com), create a new project, and choose **Self-Hosted**.
The onboarding flow asks for your database URI and generates your back-end project. Before starting it, you'll set its environment variables in a `.env` file:
* `FOREST_ENV_SECRET`: identifies your Forest environment
* `FOREST_AUTH_SECRET`: signs your users' authentication tokens
* `DATABASE_URL`: your database connection URI
Never commit your `.env` file to version control. Add it to `.gitignore`.
In the generated project directory, run:
```bash theme={null}
npm start
```
You should see:
```
[Forest] 🌳 Your agent is running at http://localhost:3310
```
Forest automatically opens in your browser. Your database schema has been detected, collections, relationships, and CRUD operations are ready out of the box.
## What you have now
A connected Forest back-end with every table from your database surfaced as a collection. Operators can browse, search, edit, and delete records out of the box.
From here, the guide walks you through it step by step, starting by shaping these collections into a back-office your team can actually use.
## Troubleshooting
* Check that your `.env` file is present and loaded
* Ensure port 3310 is not already in use
* Run `curl http://localhost:3310/forest`, should return Forest metadata
* Verify your `DATABASE_URL` is correct and the database is reachable from your machine
* Check that the database user has read permissions on the relevant tables
* For SQL databases, the back-end needs access to `information_schema` for introspection
## Need a hand?
Stuck on setup? Reach out, we'll help you get your Forest back-end running.
Organize your collections, fields, and create your first segment.
# Set up operations
Source: https://docs.forest.app/get-started/set-up-operations
Build workspaces, workflows, and inboxes for your team's daily operations
Collections and actions are the foundation. Workspaces, workflows, and inboxes are how you turn them into a tool your team actually uses to get work done.
## Create a workspace
Workspaces are custom pages that combine data from multiple collections into a single view. They're useful for operational processes that span several tables, a KYC review, an incident response, a supplier onboarding.
To create a workspace:
1. Click **+** next to Workspaces in the left sidebar
2. Use the drag-and-drop editor to add components: tables, detail views, charts, action buttons
3. Connect components together, selecting a row in one table can filter another
See [Workspaces](/product/build/workspaces) for layout patterns and advanced configuration.
## Set up a workflow
Workflows automate multi-step processes that require human approval or sequential actions, for example, a refund request that goes through a manager approval before being executed.
To create a workflow:
1. Go to **Workflows** in the left sidebar
2. Click **New workflow**
3. Define the manual trigger
4. Add steps: actions, approvals, notifications
Scheduled and event-based triggers are on the roadmap, workflows are currently triggered manually.
See [Workflows](/product/process/workflows/overview) for the full reference.
## Set up an inbox
Inboxes let your team manage tasks and review queues, records that need attention, approvals waiting, issues to resolve. They bring a task-management layer on top of your data.
To create an inbox:
1. Go to **Inbox** in the left sidebar
2. Click **Configure**
3. Select the collection and filters that define what appears in the inbox
4. Assign it to a team
See [Inbox](/product/manage/inbox) for configuration options.
## What's next
Your operators have everything they need. The same data and actions can now be exposed to AI agents, under the same permissions and audit trail.
Turn on the MCP server and connect Claude
# Layout maintenance
Source: https://docs.forest.app/guides/best-practices/layout-maintenance
Best practices for keeping your Forest layouts clean, fast, and up to date over time.
A Forest project evolves along with your product. New fields get added, old workflows get replaced, and teams change. Without periodic maintenance, layouts accumulate unused components, obsolete segments, and hidden fields that slow things down.
This page covers practical habits for keeping your back-office healthy.
## Identifying layout issues
Before cleaning up, audit what exists. Common issues to look for:
* **Hidden fields that haven't been used in months**: they still load but no one sees them
* **Duplicate segments**: multiple segments with the same or overlapping filters
* **Orphaned Smart Actions**: actions whose backend logic was removed but the button still appears
* **Outdated collection names**: labels that no longer reflect what the data represents
* **Unused relationships**: related collections shown in detail views that operators never scroll to
To audit your layout, enter Layout Editor mode and review each collection systematically.
## Cleanup tasks
### Removing unused fields
Fields hidden in the Layout Editor still exist in the schema and are returned by queries in some cases. If a field is no longer relevant:
1. Hide it in the Layout Editor (for operator-facing cleanup)
2. If it's a computed/smart field defined in code, remove it from your agent configuration
3. Deploy your changes
Don't remove fields that are still referenced in segments, Smart Actions, or custom views, even if hidden from the main layout.
### Cleaning up segments
Too many segments make navigation confusing. Review your segments periodically:
* Archive segments that are no longer used by any team
* Merge overlapping segments into a single one with multiple filter options
* Rename segments to reflect current workflows (e.g. "New signups" → "Onboarding queue")
### Archiving old actions
Smart Actions that were useful in the past may no longer apply. To clean up:
1. Identify actions with zero recent usage (check the [Activity log](/get-started/control/audit))
2. Hide them from the collection's action menu in collection settings
3. Remove the underlying code from your agent if the feature is truly deprecated
### Collection organization
As your project grows, the sidebar navigation can become cluttered:
* **Reorder collections** in the sidebar by dragging them in Layout Editor mode
* **Hide collections** that are only relevant for technical debugging
* **Group related collections** using Workspaces so operators access them in context
## Performance optimization
Layout issues can cause slow page loads even when the database is fast.
### Widget optimization
Some widget types are more expensive than others. For table views on large collections:
* Prefer lightweight display widgets (plain text, badge, date) over complex ones (file viewer, rich text renderer)
* Avoid showing relationship fields in table view columns, as they trigger additional queries per row
### Relationship panel limits
If a detail view shows HasMany relationships, each panel loads additional records. Limit the number of visible relationship panels to what operators actually use.
### Segment query performance
Segments with complex filters can slow down collection loading. Ensure that fields used in segment filters have appropriate database indexes. Work with your technical team to add indexes if needed.
## Regular maintenance schedule
A lightweight recurring review prevents layout debt from accumulating:
| Frequency | Task |
| --------------- | ------------------------------------------------------------------------------- |
| **Monthly** | Review new fields added since last deployment; configure widgets and visibility |
| **Quarterly** | Audit segments and remove unused ones |
| **Per release** | Check if any removed features left orphaned actions or smart fields |
| **Annually** | Full layout audit across all collections and teams |
## Handling deprecations and breaking changes
When Forest releases updates that affect layout behavior:
* Check the [changelog](https://www.forestadmin.com/changelog) for deprecated features
* Test layout compatibility in a development branch before deploying
* Update any Smart Actions or custom views that rely on deprecated APIs
## Permission audits
Layouts interact with permissions. A field visible in the layout but restricted by permissions creates confusion ("why can't I see this?"). Periodically review:
* Fields visible in layouts but restricted for most roles
* Actions shown in the UI but denied at the permission level
* Segments visible to teams who shouldn't filter by those criteria
See [Roles & Permissions](/get-started/control/roles-permissions) for how to align layout visibility with access control.
# Performance
Source: https://docs.forest.app/guides/best-practices/performance
Tips and patterns to optimize your Forest agent
## Computed fields
### Use the `dependencies` option instead of inline queries
A common pattern in legacy agents was to make database queries inside the `get` function. In the new agent, the `dependencies` option lets you declare which related fields you need, and the agent will fetch them automatically via JOIN, which is much faster.
```javascript theme={null}
agent.customizeCollection('post', postCollection => {
postCollection.addField('authorFullName', {
columnType: 'String',
dependencies: ['authorId'],
getValues: posts =>
posts.map(async post => {
// One query per post, very slow with many records
const author = await models.authors.findOne({ where: { id: post.authorId } });
return `${author.firstName} ${author.lastName}`;
}),
});
});
```
```javascript theme={null}
agent.customizeCollection('post', postCollection => {
postCollection.addField('authorFullName', {
columnType: 'String',
// Agent performs a single JOIN, much faster
dependencies: ['author:firstName', 'author:lastName'],
getValues: posts =>
posts.map(post => `${post.author.firstName} ${post.author.lastName}`),
});
});
```
```javascript theme={null}
// Define the field once on the author collection
agent.customizeCollection('author', authorCollection => {
authorCollection.addField('fullName', {
columnType: 'String',
dependencies: ['firstName', 'lastName'],
getValues: authors => authors.map(a => `${a.firstName} ${a.lastName}`),
});
});
// Import it on the post collection
agent.customizeCollection('post', postCollection => {
postCollection.importField('authorFullName', { path: 'author:fullName' });
});
```
### Move async calls outside the hot loop
The new agent works in batch mode. If you have external service calls (APIs, etc.), fetch all records in a single request rather than one per record:
```javascript theme={null}
getValues: users =>
users.map(async user => {
// One API call per user
const address = await geoWebService.getAddress(user.address_id);
return [address.line_1, address.city].join(', ');
}),
```
```javascript theme={null}
getValues: async users => {
// One API call for all users
const addresses = await geoWebService.getAddresses(users.map(u => u.address_id));
return users.map(user => {
const addr = addresses.find(a => a.id === user.address_id);
return [addr.line1, addr.city].join(', ');
});
},
```
### Avoid duplicate queries across computed fields
If multiple computed fields depend on the same external data source, have one field fetch the data and let others depend on it:
```javascript theme={null}
agent.customizeCollection('users', users => {
users.addField('userInfo', {
columnType: { firstName: 'String', lastName: 'String' },
dependencies: ['id'],
getValues: async users => {
const ids = users.map(u => u.id);
return await authService.getUserInfo(ids); // single request
},
});
users.importField('firstName', { path: 'userInfo:firstName' });
users.importField('lastName', { path: 'userInfo:lastName' });
});
```
## Segments
### Use condition trees when possible
If your segment logic maps to a Forest condition tree, use it instead of running a raw query and filtering by IDs. The agent can push the condition tree down to the database as a native query, which is much faster.
```javascript theme={null}
// Fast: agent translates this to a native query
products.addSegment('InStock', () => ({
field: 'stock_count',
operator: 'GreaterThan',
value: 0,
}));
// Slower: fetches all IDs first, then filters
products.addSegment('InStock', async () => {
const ids = await db.query('SELECT id FROM products WHERE stock_count > 0');
return { field: 'id', operator: 'In', value: ids };
});
```
## Filtering emulation
`emulateFieldFiltering` and `emulateFieldSorting` force the agent to retrieve **all** records to compute values. Use them only for collections with a low number of records (a few thousand at most). Prefer `replaceFieldOperator` for large collections.
# Troubleshooting
Source: https://docs.forest.app/guides/best-practices/troubleshooting
Common issues and how to fix them
## CORS errors
CORS is the most common issue when setting up the Forest agent. Open your browser's Developer Console (Network tab) to detect it.
**Check:**
* The Forest agent is mounted **before** any other middleware. In NestJS specifically, the Nest App Factory's CORS configuration can interfere with `@forestadmin/agent`. Configure it after mounting the agent.
* If you cannot mount the agent first and your own CORS middleware answers the preflight requests, its `allowedHeaders` list must include every header the Forest UI sends that requires allow-listing: `Authorization`, `Content-Type`, `Forest-Context-Url`, and `Forest-Projection` — this last one is sent to agents that announce the `canUseProjectionViaHeader` capability, and a missing entry breaks the record details view. The `OPTIONS` request still returns `204` in that case: the browser rejects the preflight response and cancels the actual request, with a console error naming the header (`Request header field forest-projection is not allowed by Access-Control-Allow-Headers in preflight response`). To identify which middleware answered the preflight, read the `Access-Control-Allow-Headers` response header: a fixed list comes from your middleware, an echo of the requested headers comes from the agent.
* Your server is up and running. Test it by calling the `/forest` endpoint: `curl http://localhost:3310/forest`
## 403 Forbidden errors after permission changes
If you add users, create collections, or change permissions in Forest and start getting `403` errors that only go away after restarting your agent, this is likely caused by **SSE (Server-Sent Events) buffering** in a reverse proxy.
Forest uses SSE to push permission updates to your agent in real time. Some reverse proxies buffer SSE connections and prevent the agent from receiving updates.
**To confirm:** Restart your agent. If the 403 errors disappear, SSE buffering is the cause.
**Fix option 1: Disable buffering in your reverse proxy:**
For nginx:
```nginx theme={null}
fastcgi_buffering off;
proxy_buffering off;
```
**Fix option 2: Fall back to TTL-based cache:**
Set `instantCacheRefresh: false` in your agent initialization options. This disables SSE and uses a periodic cache refresh instead.
```javascript theme={null}
const agent = createAgent({
// ...
instantCacheRefresh: false,
});
```
## Agent health check
Test that your agent is reachable:
```bash theme={null}
curl http://localhost:3310/forest
# Should return: {"meta":{"name":"@forestadmin/agent",...}}
```
In production, test with HTTPS:
```bash theme={null}
curl https://your-agent.yourcompany.com/forest
```
## Schema not updating
If your database schema changes are not reflected in Forest:
1. Make sure your agent is restarted after schema changes
2. Check that `isProduction` is set correctly. In production mode, the schema is not re-introspected on startup
3. Delete `.forestadmin-schema.json` and restart to force a full schema refresh (development only)
## Need more help?
Post your question on the [Forest Community Forum](https://community.forestadmin.com). The team responds quickly.
# Migrating from Agent v1 to v2
Source: https://docs.forest.app/guides/migration/from-v1/overview
Complete guide to moving your setup from a legacy agent to the current generation.
This guide covers migrating from any v1 agent (`forest-express-sequelize`, `forest-express-mongoose`, `forest-rails`, `django-forestadmin` v1) to the current generation.
The migration unlocks workflows, the MCP server for AI agents, multi-datasource, decision traces, and everything Forest has shipped since the original admin-panel era. Most teams complete the migration in 1-2 days with zero downtime.
## What v2 changes
The new generation is a rebuilt agent with a cleaner, more composable customization API. The biggest changes you'll see during migration:
* **Multi-datasource is native.** A single agent can connect to multiple databases and APIs.
* **Customization happens via a fluent API on collections.** `agent.customizeCollection('users', users => users.addField(...))`.
* **Computed fields declare their dependencies explicitly.** `dependencies: ['firstName', 'lastName']`.
* **Relationships are first-class.** Many-to-one, one-to-many, many-to-many, and external relations replace most legacy "smart relationships".
* **Routes are no longer overridden directly.** Most v1 route overrides become hooks or computed fields.
## Before you start
List every Smart Action, Smart Field, Smart Relationship, Smart Segment, and route override in your current project. Each step page below covers one of these surfaces.
Create a Remote test environment in Forest (Project Settings → Environments → Add). The new agent will run there before you flip production.
Export your current Forest project layout. While the layout is preserved across the agent swap, having a baseline is good practice.
Both agents can run side by side during migration.
## Migration steps
Install the new packages alongside your existing ones. The legacy agent keeps running on its current port; the new one runs on a different port.
```bash theme={null}
npm install @forestadmin/agent
npm install @forestadmin/datasource-sql
# or @forestadmin/datasource-mongo, @forestadmin/datasource-mongoose, etc.
```
```bash theme={null}
# Gemfile
gem 'forest_admin_agent'
gem 'forest_admin_rails'
gem 'forest_admin_datasource_toolkit'
gem 'forest_admin_datasource_customizer'
gem 'forest_admin_datasource_active_record'
```
Re-declare your data sources using the new datasource API. See [Datasources](/guides/migration/from-v1/steps/datasources).
Convert each Smart Action to the new `addAction` API. See [Smart Actions](/guides/migration/from-v1/steps/smart-actions).
Convert computed fields to `addField` with explicit dependencies. See [Smart Fields](/guides/migration/from-v1/steps/smart-fields).
Convert legacy smart relationships to native relations or external relations. See [Smart Relationships](/guides/migration/from-v1/steps/smart-relationships).
Update segments to use condition trees instead of ORM-specific queries. See [Smart Segments](/guides/migration/from-v1/steps/smart-segments).
Run both agents simultaneously. Point a test environment at the new agent and verify behavior matches v1, especially actions, computed fields, and segments.
Once you're confident, update your Forest environment URL to point at the new agent. The UI configuration is preserved.
Once production has been stable on the new agent for a few days, remove the legacy packages and clean up the old code.
## What's preserved
Your Forest UI configuration is **not** affected by the agent migration:
* Layouts, segments, dashboards, charts
* Roles, permissions, scopes, teams
* Workspaces, workflows, inboxes
* Approval workflow configurations
* Smart Action visibility settings
These all live in Forest's UI configuration and continue to work as long as the new agent exposes the same collection and field names.
## What needs updating
| Surface | Where it changes | Step page |
| ------------------------ | ------------------------------------------------------- | -------------------------------------------------------------------------- |
| Datasource configuration | Code | [Datasources](/guides/migration/from-v1/steps/datasources) |
| Smart Actions | Code | [Smart Actions](/guides/migration/from-v1/steps/smart-actions) |
| Smart Fields | Code | [Smart Fields](/guides/migration/from-v1/steps/smart-fields) |
| Smart Relationships | Code | [Smart Relationships](/guides/migration/from-v1/steps/smart-relationships) |
| Smart Segments | Code | [Smart Segments](/guides/migration/from-v1/steps/smart-segments) |
| Route overrides | Reimplemented as hooks | See [Hooks](/product/process/advanced-concepts/hooks/overview) |
| Environment variables | `FOREST_ENV_SECRET` and `FOREST_AUTH_SECRET` are reused | No change |
| Database | No change | No change |
## Common issues
The new agent treats field names as case-sensitive and prefers camelCase by convention. If your legacy code used snake\_case, you can either rename the fields or use the `rename` option on the datasource to keep the original names exposed.
The Smart Action form API is more structured in v2. Fields are declared explicitly. See [Smart Actions](/guides/migration/from-v1/steps/smart-actions) for the new form syntax.
The new agent supports batched computed fields: `getValues` receives an array of records. If you naively port a v1 `get` function inside a `.map(async ...)`, you'll keep the same N+1 problem. Aggregate the underlying query.
The new relationship API requires you to declare both sides. If you only declared one direction, the UI may not surface it. See [Smart Relationships](/guides/migration/from-v1/steps/smart-relationships).
## Get help
Per-feature migration instructions with side-by-side examples.
Migration questions and patterns from other teams.
Direct help from the Forest team.
# Migrating datasources
Source: https://docs.forest.app/guides/migration/from-v1/steps/datasources
Move your datasource configuration from a legacy agent to the current generation.
In legacy agents, the datasource was tied to a single ORM (Sequelize, Mongoose, ActiveRecord, Mongoid). The new agent introduces an explicit datasource layer that supports multiple databases and APIs in the same agent, and decouples the connection from the framework.
## API cheatsheet
| Legacy agent | New agent |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `forest-express-sequelize` | `@forestadmin/agent` + `@forestadmin/datasource-sql` |
| `forest-express-sequelize` (with Sequelize models) | `@forestadmin/agent` + `@forestadmin/datasource-sequelize` |
| `forest-express-mongoose` | `@forestadmin/agent` + `@forestadmin/datasource-mongoose` |
| `forest-rails` (ActiveRecord) | `forest_admin_agent` + `forest_admin_rails` + `forest_admin_datasource_active_record` (+ toolkit, customizer) |
| `forest-rails` (Mongoid) | `forest_admin_agent` + `forest_admin_rails` + `forest_admin_datasource_mongoid` (+ toolkit, customizer) |
Pick `datasource-sql` or `datasource-mongo` if you want Forest to introspect the database directly. Pick `datasource-sequelize`, `datasource-mongoose`, or `datasource-active-record` if you want to reuse your existing ORM models (recommended when your application already defines them).
## Before (Node.js, forest-express-sequelize)
```javascript theme={null}
const Liana = require('forest-express-sequelize');
const models = require('./models');
app.use(Liana.init({
modelsDir: __dirname + '/models',
envSecret: process.env.FOREST_ENV_SECRET,
authSecret: process.env.FOREST_AUTH_SECRET,
sequelize: models.sequelize,
}));
```
## After (Node.js, reusing Sequelize models)
```javascript theme={null}
import { createAgent } from '@forestadmin/agent';
import { createSequelizeDataSource } from '@forestadmin/datasource-sequelize';
import { sequelize } from './models';
const agent = createAgent({
authSecret: process.env.FOREST_AUTH_SECRET,
envSecret: process.env.FOREST_ENV_SECRET,
isProduction: process.env.NODE_ENV === 'production',
});
agent.addDataSource(createSequelizeDataSource(sequelize));
agent.mountOnExpress(app).start();
```
## After (Node.js, direct SQL connection)
If you don't want to reuse your ORM models, connect directly to the database. Forest introspects the schema automatically.
```javascript theme={null}
import { createAgent } from '@forestadmin/agent';
import { createSqlDataSource } from '@forestadmin/datasource-sql';
const agent = createAgent({
authSecret: process.env.FOREST_AUTH_SECRET,
envSecret: process.env.FOREST_ENV_SECRET,
isProduction: process.env.NODE_ENV === 'production',
});
agent.addDataSource(
createSqlDataSource({
uri: process.env.DATABASE_URL,
sslMode: 'preferred',
})
);
agent.mountOnExpress(app).start();
```
## Before (Ruby, forest-rails with ActiveRecord)
```ruby theme={null}
# Gemfile
gem 'forest_liana'
# config/initializers/forest_liana.rb
ForestLiana.env_secret = ENV['FOREST_ENV_SECRET']
ForestLiana.auth_secret = ENV['FOREST_AUTH_SECRET']
```
## After (Ruby, ActiveRecord)
```ruby theme={null}
# Gemfile
gem 'forest_admin_agent'
gem 'forest_admin_rails'
gem 'forest_admin_datasource_toolkit'
gem 'forest_admin_datasource_customizer'
gem 'forest_admin_datasource_active_record'
```
Run `rails generate forest_admin_rails:install` to scaffold the two configuration files.
```ruby theme={null}
# config/initializers/forest_admin_rails.rb
ForestAdminRails.configure do |config|
config.auth_secret = ENV.fetch('FOREST_AUTH_SECRET')
config.env_secret = ENV.fetch('FOREST_ENV_SECRET')
end
```
```ruby theme={null}
# app/lib/forest_admin_rails/create_agent.rb
module ForestAdminRails
class CreateAgent
def self.setup!
database_configuration = Rails.configuration.database_configuration
datasource = ForestAdminDatasourceActiveRecord::Datasource.new(database_configuration[Rails.env])
@create_agent = ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource)
customize
@create_agent.build
end
def self.customize
# Collection customizations (Smart Actions, computed fields, segments) go here.
end
end
end
```
## Multi-datasource
The biggest payoff of migrating: the new agent can connect to multiple data sources at once.
```javascript theme={null}
const agent = createAgent({ /* ... */ });
// Primary database (Sequelize models reused)
agent.addDataSource(createSequelizeDataSource(sequelize));
// Analytics database (direct SQL)
agent.addDataSource(
createSqlDataSource(process.env.ANALYTICS_DATABASE_URL),
{ name: 'analytics' }
);
// Stripe data
agent.addDataSource(
createStripeDataSource({ secretKey: process.env.STRIPE_SECRET_KEY })
);
```
Cross-datasource relationships are first-class. See [Relationships](/product/process/relationships/overview).
## Configuration changes
| Concept | Legacy | New |
| ---------------------------- | ---------------------- | ---------------------------------------------------------------------- |
| `envSecret` | `FOREST_ENV_SECRET` | `FOREST_ENV_SECRET` (unchanged) |
| `authSecret` | `FOREST_AUTH_SECRET` | `FOREST_AUTH_SECRET` (unchanged) |
| Models directory | `modelsDir` option | Provided implicitly via the datasource (Sequelize / ActiveRecord) |
| Including / excluding tables | Manual model filtering | `{ include: [...], exclude: [...] }` on `addDataSource` |
| Schema generation | Forest CLI / runtime | `.forestadmin-schema.json` written on agent start in development |
| Custom routes | Express routes | Hooks (see [Hooks](/product/process/advanced-concepts/hooks/overview)) |
## Including or excluding collections
In v1, you'd manually filter the models passed in. In v2:
```javascript theme={null}
agent.addDataSource(
createSequelizeDataSource(sequelize),
{ exclude: ['internal_logs', 'session_data'] }
);
// Or only include specific collections
agent.addDataSource(
createSequelizeDataSource(sequelize),
{ include: ['users', 'orders', 'products'] }
);
```
## Validate the migration
After swapping the datasource:
Run on a different port from the legacy agent (e.g. `3001`).
On first start in development, the agent writes a `.forestadmin-schema.json` file. Confirm every collection you expect is listed.
Hit `http://localhost:3001/forest`, which should return Forest metadata.
Update the agent URL in your Forest test environment. Browse collections. Every collection should appear with the same fields.
Once data flows correctly, move on to migrating Smart Actions, Smart Fields, and the rest.
## Common issues
Some legacy agents auto-converted snake\_case to camelCase. The new agent preserves database column names by default. Use the `rename` option or the field-rename customization API to align names.
Foreign keys defined in the database are detected automatically. Relationships defined only in your ORM models (`belongsTo`, `hasMany`) are detected when you use the ORM-backed datasources (`datasource-sequelize`, `datasource-mongoose`, `datasource-active-record`). Relationships that exist only in code paths the agent doesn't see won't be detected. Declare them explicitly with `addManyToOneRelation`, `addOneToManyRelation`, etc.
Verify that `DATABASE_URL` is set on the agent server, that the user has read permissions, and that SSL is configured if your database requires it. For SQL datasources, check `sslMode`.
## Next step
Convert each Smart Action to the new `addAction` API.
# Migrating Smart Actions
Source: https://docs.forest.app/guides/migration/from-v1/steps/smart-actions
Convert legacy Smart Actions to the current agent's action API.
The Smart Actions API was redesigned to be more composable and explicit. The legacy API mixed declaration, form definition, and execution in a single object; the new agent splits these concerns and ties everything to a fluent collection-customization API.
## API cheatsheet
| Legacy agent | New agent |
| -------------------------------------- | ---------------------------------------------------------------- |
| `collection(name, { actions: [...] })` | `agent.customizeCollection(name, c => c.addAction('Name', ...))` |
| `name` | First argument of `addAction` |
| `type: 'single' \| 'bulk' \| 'global'` | `scope: 'Single' \| 'Bulk' \| 'Global'` |
| `fields: [...]` (form definition) | `form: [...]` |
| `download: true` | `generateFile: true` |
| Express handler at custom route | `execute: (context, resultBuilder) => ...` |
| `req.body.data.attributes.values` | `context.formValues` |
| `req.body.data.attributes.ids` | `context.getRecordIds()` |
| `res.send({ success: '...' })` | `resultBuilder.success('...')` |
| `res.send({ error: '...' })` | `resultBuilder.error('...')` |
| `res.redirect(url)` | `resultBuilder.redirectTo(url)` |
## Before (Node.js, forest-express-sequelize)
```javascript theme={null}
// forest/users.js
const Liana = require('forest-express-sequelize');
Liana.collection('users', {
actions: [{
name: 'Send welcome email',
type: 'single',
fields: [
{ field: 'subject', type: 'String', isRequired: true },
{ field: 'message', type: 'String', widget: 'text area' },
],
}],
});
// routes/users.js
const express = require('express');
const router = express.Router();
router.post('/actions/send-welcome-email', Liana.ensureAuthenticated, async (req, res) => {
const { subject, message } = req.body.data.attributes.values;
const userId = req.body.data.attributes.ids[0];
const user = await models.users.findByPk(userId);
await sendEmail(user.email, subject, message);
res.send({ success: 'Email sent' });
});
module.exports = router;
```
## After (Node.js, @forestadmin/agent)
```javascript theme={null}
agent.customizeCollection('users', users => {
users.addAction('Send welcome email', {
scope: 'Single',
form: [
{ label: 'Subject', type: 'String', isRequired: true },
{ label: 'Message', type: 'String', widget: 'TextArea' },
],
execute: async (context, resultBuilder) => {
const user = await context.getRecord(['email']);
const { Subject, Message } = context.formValues;
await sendEmail(user.email, Subject, Message);
return resultBuilder.success('Email sent');
},
});
});
```
## Before (Ruby, forest-rails)
```ruby theme={null}
# app/services/forest_liana/actions/send_welcome_email.rb
class ForestLiana::Actions::SendWelcomeEmail < ForestLiana::SmartAction
type 'single'
fields([
{ field: 'subject', type: 'String', is_required: true },
{ field: 'message', type: 'String', widget: 'text area' },
])
end
# app/controllers/forest/users_controller.rb
class Forest::UsersController < ForestLiana::SmartActionsController
def send_welcome_email
user = User.find(params['data']['attributes']['ids'].first)
values = params['data']['attributes']['values']
UserMailer.welcome(user, values['subject'], values['message']).deliver_now
render serializer: nil, json: { success: 'Email sent' }
end
end
```
## After (Ruby, forest\_admin\_rails)
Customizations live inside `ForestAdminRails::CreateAgent.customize` in `app/lib/forest_admin_rails/create_agent.rb`:
```ruby theme={null}
def self.customize
@create_agent.customize_collection('User') do |collection|
collection.add_action('Send welcome email', {
scope: 'Single',
form: [
{ label: 'Subject', type: 'String', is_required: true },
{ label: 'Message', type: 'String', widget: 'TextArea' },
],
execute: ->(context, result_builder) {
user = context.get_record(['email'])
values = context.form_values
UserMailer.welcome(user, values['Subject'], values['Message']).deliver_now
result_builder.success('Email sent')
},
})
end
end
```
## Result types
The new agent supports the same result types as v1, plus a few new ones, all returned via `resultBuilder`:
| Result | API | Use case |
| --------------- | ------------------------------------------------ | ---------------------------- |
| Success message | `resultBuilder.success(message)` | Confirmation toast |
| Error message | `resultBuilder.error(message)` | Failure toast |
| Redirect | `resultBuilder.redirectTo(url)` | Open external URL |
| File download | `resultBuilder.file(buffer, filename, mimeType)` | Generate and download a file |
| HTML response | `resultBuilder.webhookSuccess(message, html)` | Show custom HTML |
See [Action result types](/product/process/actions/custom-actions/result-types) for details.
## Form fields
Forms in the new agent are typed and support dynamic behavior more cleanly:
```javascript theme={null}
agent.customizeCollection('orders', orders => {
orders.addAction('Refund', {
scope: 'Single',
form: [
{
label: 'Amount',
type: 'Number',
isRequired: true,
defaultValue: async context => {
const record = await context.getRecord(['total']);
return record.total;
},
},
{
label: 'Reason',
type: 'Enum',
enumValues: ['Customer request', 'Defective product', 'Other'],
isRequired: true,
},
{
label: 'Note',
type: 'String',
widget: 'TextArea',
if: context => context.formValues.Reason === 'Other',
},
],
execute: async (context, resultBuilder) => {
// ...
},
});
});
```
See [Action forms](/product/process/actions/custom-actions/forms) for the full API.
## Approval workflows
If your legacy action used the approval system, the configuration moves from the action declaration to **Project Settings → Roles**. The action code itself doesn't need changes. Forest's UI handles the approval gating around your `execute` function.
## Bulk and global actions
| Legacy `type` | New `scope` |
| ------------- | ----------- |
| `'single'` | `'Single'` |
| `'bulk'` | `'Bulk'` |
| `'global'` | `'Global'` |
Bulk actions can use `context.getRecordIds()` to retrieve every selected record's primary key, or `context.getRecords(fields)` to fetch the full records.
## Common conversions
v1 used `download: true` and the route returned a file via `res`. v2 returns the file via `resultBuilder.file(buffer, filename, mimeType)` and sets `generateFile: true` in the action declaration.
v1 supported `change` hooks on form fields. v2 uses the `if` and `defaultValue` properties, which receive a context and the current form values. See [Action forms](/product/process/actions/custom-actions/forms).
v1 used `Liana.ensureAuthenticated` middleware and custom permission checks. v2 uses the standard role and team permission system configured in the UI. For dynamic checks (e.g. only the assigned rep can run an action), use action visibility conditions or check inside `execute` and return `resultBuilder.error(...)`.
Direct port: `execute` calls your webhook URL the same way the v1 route handler did. The `axios.post(...)` (or `Net::HTTP.post(...)`) line is unchanged.
## Migration checklist
Pull from your `forest/` (Node.js) or `app/services/forest_liana/actions/` (Ruby) directory.
Replace `Liana.collection(...).actions = [...]` with `agent.customizeCollection(...).addAction(...)`.
Convert each `field` to a `form` entry. Update widget names (camelCase → PascalCase: `'text area'` → `'TextArea'`).
Move the route handler body into the `execute` function. Replace `req.body.data.attributes.values` with `context.formValues`.
Run both agents and confirm each action behaves the same.
## Next step
Convert computed fields to the new `addField` API with explicit dependencies.
# Migrating Smart Fields
Source: https://docs.forest.app/guides/migration/from-v1/steps/smart-fields
How to migrate smart fields from Agent v1 to the new agent
In the legacy agent, declaring a smart field was done in one big step. In the new agent, the process is split into multiple steps depending on the capabilities of the field (writing, filtering, sorting, etc.).
This allows reuse of the same API when customizing normal fields, reducing the API surface you need to learn.
## API cheatsheet
| Legacy agent | New agent |
| ------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `get: (record) => { ... }` | `getValues: (records) => { ... }` |
| `set: (record, value) => { ... }` | `.replaceFieldWriting(...)` |
| `filter: ({ condition, where }) => { ... }` | `.replaceFieldOperator(...)` / `.emulateFieldOperator(...)` / `.emulateFieldFiltering(...)` |
| `type: 'String'` | `columnType: 'String'` |
| `enums: ['foo', 'bar']` | `columnType: 'Enum', enumValues: ['foo', 'bar']` |
| `reference: 'otherCollection.id'` | [Use a relationship](/guides/migration/from-v1/steps/smart-relationships) |
## Do you still need a computed field?
Smart fields were flexible but often a performance bottleneck. Before migrating, consider whether to replace them with simpler alternatives:
* If you were moving a field from one collection to another → use [import field](/product/process/fields/import-rename-remove)
* If you were creating a link to another record → use [relationships](/product/process/relationships/overview)
## Step 1: Implement a read-only field
### Dependencies are explicit
You now need to declare a `dependencies` array: the field names that your `getValues` function needs. Unlike the legacy agent, the new agent will not automatically fetch the whole record.
### Fields work in batches
The `get` function is now called `getValues`: it takes an **array of records** and must return an **array of values** in the same order.
### Other API changes
* `type` was renamed to `columnType`
* The `field` property no longer exists. The field name is the first argument of `addField`
* `reference` no longer exists. Use [smart relationships](/guides/migration/from-v1/steps/smart-relationships)
* `enums` was renamed to `enumValues`
### Example
```javascript theme={null}
collection('users', {
fields: [
{
field: 'full_address',
type: 'String',
get: async user => {
const addr = await geoWebService.getAddress(user.address_id);
return [addr.line_1, addr.line_2, addr.city, addr.country].join('\n');
},
},
],
});
```
```javascript theme={null}
agent.customizeCollection('users', users => {
users.addField('full_address', {
columnType: 'String',
dependencies: ['address_id'],
getValues: users =>
users.map(async user => {
const addr = await geoWebService.getAddress(user.address_id);
return [addr.line_1, addr.line_2, addr.city, addr.country].join('\n');
}),
});
});
```
```ruby theme={null}
class Forest::User
collection :User
field :full_address, type: 'String' do
addr = GeoWebService.get_address(user.address_id)
[addr.line_1, addr.line_2, addr.city, addr.country].join("\n")
end
end
```
```ruby theme={null}
@create_agent.customize_collection('customer') do |collection|
collection.add_field(
'full_address',
ComputedDefinition.new(
column_type: 'String',
dependencies: ['address_line_1', 'address_line_2', 'address_city', 'address_country'],
values: proc { |records|
records.map { |r|
"#{r['address_line_1']} #{r['address_line_2']} #{r['address_city']} #{r['address_country']}"
}
}
)
)
end
```
## Step 2: Implement write handler
If your smart field was writable, use `replaceFieldWriting`:
```javascript theme={null}
collection('users', {
fields: [{
field: 'full_address',
type: 'String',
get: /* ... */,
set: async (user, value) => {
const parts = value.split('\n');
// update address...
return {};
},
}],
});
```
```javascript theme={null}
agent.customizeCollection('users', users => {
users
.addField('full_address', { /* ... same as before ... */ })
.replaceFieldWriting('full_address', (value) => {
const [line1, line2, city, country] = value.split('\n');
return { address_line_1: line1, address_line_2: line2, address_city: city, address_country: country };
});
});
```
```ruby theme={null}
field :full_address, type: 'String', set: lambda { |params, value|
parts = value.split("\n")
params[:line_1] = parts[0]
params[:line_2] = parts[1]
params
} do
# ...
end
```
```ruby theme={null}
collection.replace_field_writing('full_address') do |value, context|
{
address_line_1: value.split("\n")[0],
address_line_2: value.split("\n")[1],
address_city: value.split("\n")[2],
address_country: value.split("\n")[3]
}
end
```
## Step 3: Implement filters
Filtering is now done operator by operator instead of using a single function. This allows more fine-grained control and means you only need to implement the operators you actually use.
```javascript theme={null}
agent.customizeCollection('users', users => {
users
.addField('full_address', { /* ... */ })
// Implement only the operators you need
.replaceFieldOperator('full_address', 'Equal', (value, context) => ({
aggregator: 'And',
conditions: [
{ field: 'address_city', operator: 'Equal', value: value.split('\n')[2] },
],
}))
// Emulate all other operators (slower, but works automatically)
.emulateFieldFiltering('full_address');
});
```
Emulation forces the agent to retrieve all records and compute values for each one. Use it sparingly for collections with many records.
# Migrating Smart Relationships
Source: https://docs.forest.app/guides/migration/from-v1/steps/smart-relationships
How to migrate smart relationships from Agent v1 to the new agent
Smart relationships work very differently between v1 and v2.
In legacy agents, smart relationships were declared as smart fields with a `reference` property:
* **Many-to-one / one-to-one**: implemented via the `get` function returning a single record
* **One-to-many / many-to-many**: implemented by creating all CRUD routes on a router file
The new system is completely different: it is based on **primary keys and foreign keys**.
## When the foreign key is accessible
If the foreign key already exists in your data:
```javascript theme={null}
// Many-to-one
collection('order', {
fields: [{
field: 'delivery_address',
type: 'String',
reference: 'Address._id',
get: async order => models.addresses.find({ id: order.delivery_address_id }),
}],
});
// Reverse relationship
collection('address', {
fields: [{ field: 'orders', type: ['String'], reference: 'Order.id' }],
});
router.get('/address/:id/relationships/orders', (req, res) => { /* ... */ });
```
```javascript theme={null}
// Many-to-one
agent.customizeCollection('order', orders => {
orders.addManyToOneRelation('deliveryAddress', 'address', {
foreignKey: 'deliveryAddressId',
});
});
// Reverse relationship
agent.customizeCollection('address', addresses => {
addresses.addOneToManyRelation('orders', 'order', {
originKey: 'deliveryAddressId',
});
});
```
```ruby theme={null}
class Forest::Product
include ForestLiana::Collection
collection :Product
has_many :buyers, type: ['String'], reference: 'Customer.id'
end
# routes.rb
namespace :forest do
get '/Product/:product_id/buyers' => 'orders#buyers'
end
```
```ruby theme={null}
@create_agent.customize_collection('Product') do |collection|
collection.add_many_to_one_relation('buyers', 'Customer', { foreign_key: 'country_id' })
end
@create_agent.customize_collection('Customer') do |collection|
collection.add_one_to_many_relation('products', 'Product', { origin_key: 'country_id' })
end
```
## When you need complex logic to get the foreign key
If the foreign key doesn't exist in your database and requires custom logic:
1. Create a **computed field** that contains the foreign key value
2. Make that field filterable with the `In` operator (required for relationships to work)
3. Declare the relationship using the computed field as the foreign key
If the foreign key exists in a related table but not the current one, use [import field](/product/process/fields/import-rename-remove) instead. It's faster and natively filterable.
```javascript theme={null}
collection('order', {
fields: [{
field: 'delivery_address',
type: 'String',
reference: 'Address._id',
get: async order => models.addresses.find(/* complex query */),
}],
});
```
```javascript theme={null}
agent.customizeCollection('order', orders => {
// 1. Computed field containing the FK
orders.addField('deliveryAddressId', {
columnType: 'Number',
dependencies: ['id'],
getValues: async orders => {
const addressByOrderId = await models.addresses.find(/* complex query */);
return orders.map(order => addressByOrderId[order.id].id);
},
});
// 2. Make it filterable (required)
orders.replaceFieldOperator('deliveryAddressId', 'In', (value, context) => {
// reverse-lookup logic
});
// 3. Declare the relationship
orders.addManyToOneRelation('deliveryAddress', 'address', {
foreignKey: 'deliveryAddressId',
});
});
```
```ruby theme={null}
class Forest::ProductsController < ForestLiana::ApplicationController
def buyers
query = # complex query
render json: serialize_models(query, meta: {count: query.count})
end
end
```
```ruby theme={null}
@create_agent.customize_collection('Product') do |collection|
collection.add_field(
'customerId',
ComputedDefinition.new(
column_type: 'Number',
dependencies: ['id'],
values: proc { |customers, context| ... }
)
)
.replace_field_operator('customerId', Operators::IN) { |customer_ids, context|
...
}
.add_many_to_one_relation('buyers', 'Customer', { foreign_key: 'customerId' })
end
```
# Migrating Smart Segments
Source: https://docs.forest.app/guides/migration/from-v1/steps/smart-segments
How to migrate smart segments from Agent v1 to the new agent
Smart segments migrate quickly. The syntax is very similar to the legacy agent. The main difference is in the **return value**.
## What changed
Because the new agent is designed to work with multiple databases, the return value of the segment handler is no longer a Sequelize/Mongoose condition. Instead, you build a **condition tree** that the agent translates to the appropriate database syntax.
## API cheatsheet (Node.js)
| Legacy agent | New agent |
| ---------------------- | ------------------------------------------- |
| `where:` | handler body (return value) |
| `sequelize.where(...)` | condition tree `{ field, operator, value }` |
## Performance tip
Many queries map directly to Forest condition trees, giving much better performance than performing the query yourself and then building a naive `id IN (...)` condition.
## Example
```javascript theme={null}
collection('products', {
segments: [{
name: 'Bestsellers',
where: async product => {
const query = `
SELECT products.id, COUNT(orders.*)
FROM products
JOIN orders ON orders.product_id = products.id
GROUP BY products.id
ORDER BY count DESC
LIMIT 5;
`;
const products = await models.connections.default.query(query, {
type: QueryTypes.SELECT,
});
return { id: { [Op.in]: products.map(p => p.id) } };
},
}],
});
```
```javascript theme={null}
agent.customizeCollection('products', products => {
products.addSegment('Bestsellers', async () => {
const query = `
SELECT products.id, COUNT(orders.*)
FROM products
JOIN orders ON orders.product_id = products.id
GROUP BY products.id
ORDER BY count DESC
LIMIT 5;
`;
const products = await models.connections.default.query(query, {
type: QueryTypes.SELECT,
});
return { field: 'id', operator: 'In', value: products.map(p => p.id) };
});
});
```
```ruby theme={null}
class Forest::Product
include ForestLiana::Collection
collection :Product
segment 'Bestsellers' do
query = <<~SQL
SELECT products.id, COUNT(orders.*)
FROM products
JOIN orders ON orders.product_id = products.id
GROUP BY products.id
ORDER BY count DESC
LIMIT 5;
SQL
products = ActiveRecord::Base.connection.execute(query)
{ id: products.map { |p| p['id'] } }
end
end
```
```ruby theme={null}
@create_agent.customize_collection('product') do |collection|
collection.add_segment('best_sellers') do |context|
rows = ActiveRecord::Base.connection.execute(
'SELECT products.id as product_id, COUNT(orders.id) as nb
FROM products
INNER JOIN orders ON orders.product_id = products.id
GROUP BY products.id
ORDER BY nb DESC
LIMIT 5;'
)
{
field: 'id',
operator: 'In',
value: rows.map { |r| r['product_id'] }
}
end
end
```
# Forest documentation
Source: https://docs.forest.app/index
Operational infrastructure for regulated operations.
Forest is where regulated companies run operations end-to-end across internal teams, AI agents, suppliers, and partners. Same data model, same workflows, same audit trail, regardless of who or what executes.
Use Forest to coordinate humans, AI agents, and systems with full governance. Your data stays in your infrastructure; only the UI and configuration are hosted by Forest.
## Start here
Connect your data and run your first workflow in under 15 minutes.
What Forest is, the architecture, and what you'll build.
## Pick your path
The full onboarding: connect your data, build operations workflows, expose them via MCP to AI agents, deploy, invite your team.
Browse what the platform does, workflows, actions, governance, MCP server, decision traces, embeds, and dig into the parts you need.
## What you can build with Forest
KYC reviews, AML alerts, dispute handling, supplier onboarding, orchestrated end-to-end with the data and actions inline.
Expose your data and workflows to AI agents (Claude, Dust, Decagon, internal builds) via MCP, with permissions and audit trails preserved.
Run the same operation across internal teams, BPOs, partner banks, and AI agents, with one operating model and one audit trail.
Every action carries its full context: who or what executed, what changed, why, and from where. Audit-ready.
## Browse by section
Connect a data source, customize your operations UI, build workflows, deploy. Covers Cloud, Self-Hosted, and On-Premise architectures.
Every feature available to operators and developers, collections, actions, workflows, dashboards, approval workflows, embeds.
Agent SDK reference (Node.js, Ruby), public API, CLI, and schema format.
## Deployment architecture
Forest hosts the UI; you run the agent in your own infrastructure, so your data never leaves your network. Fully-managed Cloud and on-premise options are also available, [contact us](https://www.forestadmin.com/contact) to find the right fit.
## Looking for v1 docs?
If you're maintaining a project on a legacy agent (`forest-express-sequelize`, `forest-express-mongoose`, `forest-rails`, or `django-forestadmin v1`), the v1 reference is preserved here:
Reference for the v1 generation of agents. Maintained for migration purposes only, do not start new projects here.
## Get help
Ask questions, share patterns, and learn from other Forest users.
Reach the Forest support team directly.
Browse source, examples, and the experimental community packages.
# Legacy agents
Source: https://docs.forest.app/legacy/agents-overview
Reference documentation for legacy Forest agents, maintained for migration purposes only.
**Do not start new projects with legacy agents.** This section is preserved for teams maintaining existing v1 deployments and as a reference during migration to the current generation.
## What's a legacy agent?
A legacy agent is any Forest agent prior to the current generation: the era when the product was branded as Forest and centered on building admin panels. The legacy agents are:
| Agent | Language | Status |
| -------------------------- | ------------------- | ---------------- |
| `forest-express-sequelize` | Node.js + Sequelize | Maintenance only |
| `forest-express-mongoose` | Node.js + Mongoose | Maintenance only |
| `forest-rails` | Ruby on Rails | Maintenance only |
These have been replaced by the current agent generation:
Node.js: TypeScript-first, multi-datasource, modern API.
Ruby: ActiveRecord and Mongoid support, Rails integration.
## Why we recommend migrating
The current agent generation is the foundation Forest is built on today. Legacy agents only expose the original admin-panel capabilities; the current agents add everything Forest has shipped since:
* **Multi-datasource support**: combine SQL, MongoDB, APIs, and custom sources in a single agent.
* **Workflows**: orchestrate multi-step operational processes across humans, AI agents, and external systems.
* **MCP server**: expose your data, actions, and workflows to AI agents under governance.
* **Decision traces**: every action carries its full context, audit-ready by default.
* **Better performance**: query optimization and reduced overhead.
* **Active development**: new features, regular security updates, full support.
* **Cleaner customization API**: fewer concepts to learn, more composable.
* **TypeScript** (Node.js): full type inference for your collections and actions.
## Where to start
Step-by-step migration from any legacy agent. Run v1 and v2 in parallel with zero downtime.
Reference for the current agents.
## Legacy reference
If you need to keep working in a legacy agent while you plan a migration:
Reference for `forest-express-sequelize` and `forest-express-mongoose`. Setup, customization, and how-tos for v1 Node.js agents.
Reference for `forest-rails`. Setup, customization, and how-tos for the v1 Ruby agent.
Step-by-step v1 → v2 migration paths per agent.
## Need help migrating?
Full step-by-step instructions.
Migration questions and patterns shared by other teams.
Reach out to the Forest team directly.
# Readme
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/databases/README
# Add new databases
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/databases/add-new-databases
It's becoming quite common to have multiple databases when building a web application. Especially when designing your app with micro services. Here you'll learn how to add new databases.
### Add new database
To connect a new database on your project, you need to follow the following steps:
* Stop your agent. The following process will generate files and using nodemon while following this process can cause mis-generation of the `.forestadmin-schema.json` file.
* Add a new environment variable, inside your `.env` file (It will be `ANOTHER_DB_URL` in this example), which represents the connection url string of the database you want to add.
* Edit the database config file located to `config/databases.js` to add a new object with the following syntax in the array:
```javascript theme={null}
[
{
name: 'your_first_database_connection',
// Models associated to a connection should be in a dedicated folder.
// If your setup already works, you'll need to update the modelsDir associated to your existing connection
// by changing this variable value
modelsDir: path.resolve(
__dirname,
'./models/your_first_database_connection'
),
connection: {
url: process.env.DATABASE_URL,
options: {
/* Database options can be empty, but should match with your requirements */
},
},
},
{
name: 'name_of_the_connection',
modelsDir: path.resolve(__dirname, './models/name_of_the_connection'),
connection: {
url: process.env.ANOTHER_DB_URL,
options: {
/* Database options can be empty, but should match with your requirements */
},
},
},
];
```
* Run `forest schema:update` [command](/legacy/javascript-agents/reference-guide/models/overview#updating-your-models-automatically) and follow instructions.
* It should generate all the required files. ⚠️ Be aware that existing files will remain untouched when switching from a single database to a multi-database setup. If you made any modifications in the models of your existing connection.\
In this example, you may want to check the freshly generated models that will be located in the `./models/your_first_database_connection` folder.
* As stated on the `forest schema:update` documentation, when switching from a single to a multiple database setup, existing models in the `./models` folder will remain untouched, and you'll need to move them to the correct location (According to you `config/databases.js` file) or simply remove them if you never made any modifications on the models themselves.
* Start the agent, and display the added models with the layout editor. Everything should work as expected
# Connect to a read replica database
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/databases/connect-to-a-read-replica-database
⚠️ This tutorial is for SQL databases only.
A read replica is a copy of the master that reflects changes to the master instance in almost real time.\
\
For performance reasons, you can specify one or more servers to act as read replicas, and one server to act as the write master, which handles all writes and updates and propagates them to the replicas.\
\
For example, your read replica will be used while displaying the table view of your records or accessing your Forest dashboard.
As your Admin Backend relies on the Sequelize ORM, it's quite easy to configure a[ read replication](https://sequelize.org/master/manual/read-replication.html).
Those code snippets are an example. It is strongly advised to use environment variables for your database connection credentials.
```javascript theme={null}
const path = require('path');
let databaseOptions = {
logging: process.env.NODE_ENV === 'development' ? console.log : false,
dialect: 'postgresql',
port: 5435,
replication: {
read: [
{
host: 'ec2-52-219-116-175.us-west-1.compute.amazonaws.com',
username: 'userRead',
password: 'passwordUserRead',
database: 'databaseReplicate',
},
],
write: {
host: 'ec2-52-219-125-185.eu-west-1.compute.amazonaws.com',
username: 'userWrite',
password: 'passwordUserWrite',
database: 'databaseMaster',
},
},
pool: {
maxConnections: 10,
minConnections: 1,
},
dialectOptions: {},
};
module.exports = [
{
name: 'default',
modelsDir: path.resolve(__dirname, '../models'),
connection: {
options: { ...databaseOptions },
},
},
];
```
```javascript theme={null}
...
databasesConfiguration.forEach((databaseInfo) => {
let connection;
if(databaseInfo.connection.options.replication) {
connection = new Sequelize(databaseInfo.connection.options);
} else {
connection = new Sequelize(databaseInfo.connection.url, databaseInfo.connection.options);
}
connections[databaseInfo.name] = connection;
...
});
...
```
# Manage SQL views
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/databases/manage-sql-views
In SQL, a view is a virtual table based on the result-set of an SQL statement. Views can provide advantages over tables, such as:
* represent a subset of the data contained in a table (see also[ segments](https://docs.forestadmin.com/user-guide/collections/segments)).
* join and simplify many tables into a single virtual table.
* act as aggregated tables, where the database engine aggregates data (sum, average etc.) and presents the calculated results as part of the data.
Forest natively supports SQL views. If you have already implemented views, simply add [the associated models](https://docs.forestadmin.com/documentation/reference-guide/models/enrich-your-models#declaring-a-new-model) to display them on your interface.
## Creating the SQL View
To create a view, we use `CREATE VIEW` statement.
In the following example, we look for the **user's email**, **the number of orders** and **the total amount spent**.
```sql theme={null}
CREATE VIEW customer_stats AS
SELECT customers.id,
customers.email,
count(orders.*) AS nb_orders,
sum(products.price) AS amount_spent,
customers.created_at,
customers.updated_at
FROM customers
JOIN orders ON customers.id = orders.customer_id
JOIN products ON orders.product_id = products.id
GROUP BY customers.id;
```
## Adding the model
To display the SQL view on your Forest interface, you must add the associated Sequelize model in your application.
```javascript theme={null}
'use strict';
module.exports = (sequelize, DataTypes) => {
const { Sequelize } = sequelize;
const CustomerStats = sequelize.define(
'customer_stats',
{
amount_spent: {
type: DataTypes.INTEGER,
},
nb_orders: {
type: DataTypes.STRING,
},
email: {
type: DataTypes.STRING,
},
},
{
tableName: 'customer_stats',
underscored: true,
schema: process.env.DATABASE_SCHEMA,
}
);
return CustomerStats;
};
```
You must restart your server to see the changes on your interface.
## Managing the view
Once your SQL view is implemented, you'll be able to filter, search, export and change the order of your fields.
Most of the time SQL views are used as **read-only**. If this is the case, we recommend changing the CRUD permission in your [roles's settings](https://docs.forestadmin.com/user-guide/project-settings/teams-and-users/manage-roles).
# Plug multiple schemas
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/databases/plug-multiple-schemas
A **schema** is an organizational layer to better structure your SQL database.
At installation, you may only choose 1 schema:
If you're not using specific schemas, you don't have to fill this advanced option.
### Forest can display collections from multiple schemas
To achieve this, proceed to install using 1 of your schemas. Only the models of this schema will be generated in your `models` directory.
Let's take a model example:
```javascript theme={null}
// This model was generated by Lumber. However, you remain in control of your models.
// Learn how here: https://docs.forestadmin.com/documentation/v/v5/reference-guide/models/enrich-your-models
module.exports = (sequelize, DataTypes) => {
const { Sequelize } = sequelize;
// This section contains the fields of your model, mapped to your table's columns.
// Learn more here: https://docs.forestadmin.com/documentation/v/v5/reference-guide/models/enrich-your-models#declaring-a-new-field-in-a-model
const Addresses = sequelize.define(
'addresses',
{
addressLine: {
type: DataTypes.STRING,
field: 'address',
},
addressCity: {
type: DataTypes.STRING,
},
country: {
type: DataTypes.STRING,
},
createdAt: {
type: DataTypes.DATE,
},
},
{
tableName: 'addresses',
underscored: true,
schema: process.env.DATABASE_SCHEMA,
}
);
return Addresses;
};
```
On **line 24**, you'll notice `schema: process.env.DATABASE_SCHEMA`.
It uses the environment variable `DATABASE_SCHEMA` set in your **.env** file. \
You'll have to edit this to match your schemas. For instance, if you have 2 schemas:
```javascript theme={null}
DATABASE_SCHEMA_1: name_of_the_first_schema;
DATABASE_SCHEMA_2: name_of_the_second_schema;
```
Once this is done, follow those steps:
#### Step 1: Edit your current models
Because you have changed your environment variable name from `DATABASE_SCHEMA` to `DATABASE_SCHEMA_1`, you need to update it in all your models' file in the `models` directory (same line as line 24 in the above example).
#### Step 2: Create new models
For each of your other schemas' models, you'll need to create a file in `models`. This must be done **manually** and the schema line must be set to `DATABASE_SCHEMA_2` as per above example.
If your other schemas have a lot of models, a quick way to generate the models is to create a another project using those other schemas (1 project for each schema).
# Populate a postgreSQL database on Heroku
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/databases/populate-a-postgresql-database-on-heroku
Learn how to populate a remote postgreSQL database on Heroku from an existing database dump.
If you don't have a Heroku application yet, take a look at [this how-to](/legacy/javascript-agents/how-tos/setup/deploy-to-production-on-heroku).
We recommend adding on your Heroku application the free add-on “Heroku Postgres” **(1)(2)**.
Once installed, your Heroku application will contain an environment variable `DATABASE_URL` with the credentials of your new created database **(1)** in the “Config Vars” section **(2)**. Here you can add more if necessary **(3)**.
Your new database has no data yet, so you will need to import your local data to this new one.
To do that, you first need to create a dump of your local database.
```
PGPASSWORD=secret pg_dump -h localhost -p 5416 -U forest forest_demo --no-owner --no-acl -f database.dump
```
Then, you need to import it to the Heroku database.
```
heroku pg:psql DATABASE_URL --app name_of_your_app < database.dump
```
At this stage, your Heroku application is entirely running with its own database.
That's it! Your local database is now available as your **production database** on Heroku. 🎉
N.B: You can make sure by checking the Heroku logs using the command :
```
heroku logs -t -a name_of_your_app
```
# Use a demo SQL database
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/databases/use-a-demo-database
**Pre-requisite**: Docker
To import the demo database using our [forestadmin/meals-database](https://hub.docker.com/r/forestadmin/meals-database) image, simply run:
```
docker run -p 5432:5432 --name forest_demo_database forestadmin/meals-database
```
That's all! Your database is running locally in a docker container.
To check if the database is correctly setup, you can use the following command to connect to your freshly created database.
```sql theme={null}
docker exec -it forest_demo_database psql meals lumber
```
You should get a prompt where you can type SQL queries or PostgreSQL command line `\d` to see the available list of tables.
```sql theme={null}
meals=# \d
List of relations
Schema | Name | Type | Owner
--------+----------------------------+----------+--------
public | ar_internal_metadata | table | lumber
public | chef_availabilities | table | lumber
public | chef_availabilities_id_seq | sequence | lumber
public | chefs | table | lumber
public | chefs_id_seq | sequence | lumber
public | customers | table | lumber
public | customers_id_seq | sequence | lumber
public | delivery_men | table | lumber
public | delivery_men_id_seq | sequence | lumber
public | menus | table | lumber
public | menus_id_seq | sequence | lumber
public | menus_products | table | lumber
public | menus_products_id_seq | sequence | lumber
public | orders | table | lumber
public | orders_id_seq | sequence | lumber
public | orders_products | table | lumber
public | orders_products_id_seq | sequence | lumber
public | product_images | table | lumber
public | product_images_id_seq | sequence | lumber
public | products | table | lumber
public | products_id_seq | sequence | lumber
public | schema_migrations | table | lumber
(22 rows)
```
To use this database for a new Forest project, you'll need:
| Property | Value |
| ------------- | ------ |
| User | lumber |
| Password | secret |
| Database name | meals |
# Upgrade
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/maintain/README
# Changing your domain name
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/maintain/changing-your-domain-name
To change your domain name, you'll have to change your application URL in 2 places:
* in your `.env` file, change the **APPLICATION\_URL** variable to the new URL
* in the details page of your environment (Project settings > Environments), change the **Admin backend URL**
Don't forget to restart your agent.
# Manage your Forest environments programmatically
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/maintain/manage-your-forest-admin-programmatically
For continuous integration and automatization, we have developed a [CLI](https://github.com/ForestAdmin/toolbelt) which makes it easy to manage your Forest environments.
This can be used for Q\&A and testing purposes.
#### Install
```
$ npm install -g forest-cli
```
#### Commands
```
$ forest [command]
```
**General**
* `user` display the current logged in user.
* `login` sign in to your Forest account.
* `logout` sign out of your Forest account.
* `help [cmd]` display help for \[cmd].
**Projects**
Manage Forest projects.
* `projects` list your projects.
* `projects:get` get the configuration of a project.
**Environments**
Manage Forest environments.
* `environments` list your environments.
* `environments:get` get the configuration of an environment.
* `environments:create` create a new environment.
* `environments:delete` delete an environment.
* `environments:copy-layout` copy the layout from one environment to another.
#### Schema
Manage Forest schema.
`schema:apply` apply the current schema of your repository to the specified environment (using your `.forestadmin-schema.json` file).
This option is available only on [agents version >+ 3](https://app.gitbook.com/@forestadmin/s/documentation/~/drafts/-LcaGvIb-WdMOABgHOTu/primary/reference-guide/upgrade-to-v3).
# Migrate to the new role system
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/maintain/migrate-to-the-new-role-system
If you still have access to your project today, you are using the new role system already, read more about **Roles** in our [User Guide](https://docs.forestadmin.com/user-guide/project-settings/teams-and-users/manage-roles).
The old role system has been deprecated the 1st of June 2023, has reached its end of life the 1st of December 2023, and support has been dropped entirely the 4th of December 2024. Please do note that the new Role permissions system requires that you use **version 6.6+** of your agent (**version 5.4+** for Rails) on **all** your environments. If you are running proper versions and urgently need to migrate to the new Roles system please contact our [support](mailto:support@forestadmin.com).
The new role system allows you to control all the permissions of your roles from a single details page, which will look like this:

# Monitor your Forest's status
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/maintain/monitor-your-forests-status
For **healthchecks**, you can query your app at:
* `/forest` : it returns a **204** status code if your app is up and running
* `/forest/healthcheck` : it returns a **200** status code if your app is up and running
# Push your new version to production
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/maintain/push-your-new-version-to-production
Forest is using the [`.forestadmin-schema.json`](https://docs.forestadmin.com/developer-guide-agents-nodejs/under-the-hood/forestadmin-schema) file that is present beside your agent to reflect your model definition as well as the agent version.
When upgrading your agent version, it will only be taken into account if the `.forestadmin-schema.json` with the latest version has been pushed.
## Recommended procedure
At Forest, we advise you to start your migration in your development environment:
1. Upgrade your agent in development following the upgrade notes
2. Start the agent locally
3. You should notice that your `.forestadmin-schema.json` has been updated
4. Commit your source code, dependency manager file as well as the `.forestadmin-schema.json` file
5. Push your commit to Production/Staging/Test
6. Pull code in your server; install, build and restart
## Upgrade without development environment (Not recommended)
If you only have one single remote environment and not bothered by the possibility that it can remain down for a period of time you can upgrade your agent version directly on it.
1. Upgrade the agent following the migration notes
2. Set your `NODE_ENV` or `FOREST_ENVIRONMENT` to `dev`
3. Restart with this new configuration, it should update the content of `.forestadmin-schema.json`
4. Once you have confirmed that the file has been updated and sent to Forest servers, you can restore your environment variables and restart the server
# Update your models' definition
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/maintain/update-your-models-definition
Your database schema will evolve over time. Any changes can (and probably should) be applied to your admin backend's models.
To upgrade your models definition in your code you need to be at least in the V7 version of `forest-express-sequelize` or `forest-express-mongoose` package. If this is not the case please follow our [migration note](/legacy/javascript-agents/how-tos/maintain/upgrade-notes-sql-mongodb/upgrade-to-v7).
Now you can use the `forest schema:update` command to achieve your goal.
This command is able to create all the missing file for a newly added table in your database. However it will not automatically modify existing files. So if you just added a new field inside an existing table, please just remove the corresponding model file inside your models folder and run the command.
### Examples
In the following example, we added a new table `customers` on an existing project. This is the output of the `forest schema:update` command.
```
$ forest schema:update
✓ Connecting to your database(s)
✓ Analyzing the database(s)
create forest/customers.js
skip forest/staffs.js - already exist.
skip forest/stores.js - already exist.
create models/customers.js
skip models/staffs.js - already exist.
skip models/stores.js - already exist.
create routes/customers.js
skip routes/staffs.js - already exist.
skip routes/stores.js - already exist.
✓ Generating your files
```
In the next example we just removed a field from the previous added table. After removing the model file from the models folder. This is the output of the `forest schema:update` command.
```
$ forest schema:update
✓ Connecting to your database(s)
✓ Analyzing the database(s)
skip forest/customers.js - already exist.
skip forest/staffs.js - already exist.
skip forest/stores.js - already exist.
create models/customers.js
skip models/staffs.js - already exist.
skip models/stores.js - already exist.
skip routes/customers.js - already exist.
skip routes/staffs.js - already exist.
skip routes/stores.js - already exist.
✓ Generating your files
```
# Upgrade notes (SQL, Mongodb)
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/maintain/upgrade-notes-sql-mongodb/README
# Upgrade to v3
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/maintain/upgrade-notes-sql-mongodb/upgrade-to-v3
Help developers to move from v2 to v3. Please read carefully and integrate the following breaking changes to ensure a smooth update.
## Breaking changes
### Cors configuration
Set CORS `credentials: true` if you're using custom CORS configuration.
```javascript theme={null}
var express = require('express');
var cors = require('cors');
var app = express();
// ...
app.use(
cors({
origin: [/\.forestadmin\.com$/],
allowedHeaders: ['Authorization', 'X-Requested-With', 'Content-Type'],
credentials: true,
})
);
// ...
module.exports = app;
```
### Global smart action
Smart actions defined as follows `global: true` will no longer be considered as global.
Please now use `type: 'global'`.SQLMongodb
**Before**
```javascript theme={null}
const Liana = require('forest-express-sequelize');
const models = require('../models');
Liana.collection('products', {
actions: [
{
name: 'Import data',
global: true,
},
],
});
```
**After**
```javascript theme={null}
const Liana = require('forest-express-sequelize');
const models = require('../models');
Liana.collection('products', {
actions: [
{
name: 'Import data',
type: 'global',
},
],
});
```
### Schema versioning
On server start - *only in development environments* - the agent will generate a `.forestadmin-schema.json` file reflecting your **Forest models**.
If you change your models, Forest will automatically load a new schema to keep the layout up to date. However, note that changes in your database will not be reflected in your models nor in your UI, unless you use `lumber update` (for Lumber) or update your models manually otherwise.
**Do not edit this file**. It will be automatically generated on server start **only in development environments**.
This file **must be deployed** for any remote environment (staging, production, etc.), as it will be used to generate your Forest UI.
**Version this file.** It will give you more visibility on the changes detected by Forest.
In the following example, we have added two fields on the `invoices` table:
* `emailSent`
* `quadernoId`
Versioning the`.forestadmin-schema.json` file allows you to easily visualize the changes..forestadmin-schema.json versioning example
## Changelogs
This release note covers only the major changes. To learn more, please refer to the changelogs in our different repositories:
* [Express-sequelize changelog](https://github.com/ForestAdmin/forest-express-sequelize/blob/master/CHANGELOG.md#release-300---2019-04-22)
* [Express-mongoose changelog](https://github.com/ForestAdmin/forest-express-mongoose/blob/master/CHANGELOG.md#release-300---2019-04-22)
# Upgrade to v4
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/maintain/upgrade-notes-sql-mongodb/upgrade-to-v4
The purpose of this note is to help developers to upgrade their agent from v3 to v4. Please read carefully and integrate the following breaking changes to ensure a smooth update.
## Upgrading to v4
Before upgrading to v4, consider the below **breaking changes**.
As for any dependency upgrade, it's very important to **test this upgrade** **in your testing environments**. Not doing so could result in your admin panel being unusable.
To upgrade to v4, simply run:
```javascript theme={null}
npm install forest-express-sequelize@4.0.2
```
```javascript theme={null}
npm install forest-express-mongoose@4.1.2
```
In case of a regression introduced in Production after the upgrade, a rollback to your previous agent version 3 is the fastest way to restore your admin panel.
## Breaking changes
### New JWT authentication token
The information format of the *session token* have changed in v4.
You could be impacted if you use the *user session* in Smart Action controllers or Smart Routes
**Calling `req.user` in v3**
```javascript theme={null}
{
"id": "172",
"type": "users",
"data": {
"email": "angelicabengtsson@doha2019.com",
"first_name": "Angelica",
"last_name": "Bengtsson",
"teams": ["Pole Vault"],
},
"relationships": {
"renderings": {
"data": [{
"type": "renderings",
"id": "4998",
}],
},
},
"iat": 1569913709,
"exp": 1571123309
}
```
**Calling `req.user` in v4**
```javascript theme={null}
{
"id": "172",
"email": "angelicabengtsson@doha2019.com",
"firstName": "Angelica",
"lastName": "Bengtsson",
"team": "Pole Vault",
"renderingId": "4998",
"iat": 1569913709,
"exp": 1571123309
}
```
Consequently, the user information is now accessible as described below:
| Property | v3 | v4 |
| ------------ | ---------------------------------------------- | ---------------------- |
| email | `req.user.data.email` | `req.user.email` |
| first name | `req.user.data.first_name` | `req.user.firstName` |
| last name | `req.user.data.last_name` | `req.user.lastName` |
| team | `req.user.data.teams[0]` | `req.user.team` |
| rendering id | `req.user.relationships.renderings.data[0].id` | `req.user.renderingId` |
### New filters query parameters format
The **query parameters** sent for **filtering** purposes have changed in v4.
You could be impacted if you have custom filter implementations.
Below are a few example of the new filter conditions format you can access using`req.params.filters`:
```javascript theme={null}
{
"field": "planLimitationReachedAt",
"operator": "previous_year_to_date",
"value": null
}
```
```javascript theme={null}
{
"aggregator": "and",
"conditions": [{
"field": "planLimitationReachedAt",
"operator": "previous_year_to_date",
"value": null
}, {
"field": "planLimitationStatus",
"operator": "equal",
"value": "warning"
}]
}
```
### MongoDB
This section is dedicated to breaking changes on projects using MongoDB connections.
#### MongoDB version support
The minimal version supported by the agent v4 is **MongoDB v3.2** (December 2015).
If your project uses an older MongoDB version, **you should not upgrade to v4**.
The way the agent implements the resources filtering changed and this new implementation uses features that does not exist in MongoDB versions older than 3.2.
#### Smart Field search implementation
The Smart Field search implementation has changed:
* The function signature now has only one `search` parameter.
* The expected value to be returned is a hash of the conditions (instead of the `query` object).
See the following implementation migration example:
```javascript theme={null}
search(query, search) {
let names = search.split(' ');
query._conditions.$or.push({
firstname: names[0],
lastname: names[1]
});
return query;
}
```
```javascript theme={null}
search(search) {
let names = search.split(' ');
return {
firstname: names[0],
lastname: names[1]
};
}
```
#### Condition operator changes
For consistency reasons, `contains`, `starts with` and `ends with` operators are now **case sensitive**.
#### Smart relationships reference syntax
If you had a `reference` property in a [Smart relationship](/legacy/javascript-agents/reference-guide/models/relationships/create-a-smart-relationship/overview#creating-a-belongsto-smart-relationship) you implemented, the syntax has changed:
```javascript theme={null}
reference: 'Address';
```
```javascript theme={null}
reference: 'Address._id';
```
## Important Notice
### Agent logout
A consequence of the new session token format is:
Once an agent v4 deployed, **all users of your project will be automatically logged out** and be forced to re-authenticate to generate a newly formatted token.
### Changelogs
This release note covers only the major changes. To learn more, please refer to the changelogs in our different repositories:
* [Express-sequelize changelog](https://github.com/ForestAdmin/forest-express-sequelize/blob/master/CHANGELOG.md#release-400---2019-10-04)
* [Express-mongoose changelog](https://github.com/ForestAdmin/forest-express-mongoose/blob/master/CHANGELOG.md#release-400---2019-10-04)
# Upgrade to v5
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/maintain/upgrade-notes-sql-mongodb/upgrade-to-v5
The purpose of this note is to help developers to upgrade their agent from v4 to v5. Please read carefully and integrate the following breaking changes to ensure a smooth upgrade.
## Upgrading to v5
Before upgrading to v5, consider the below **breaking changes**.
As for any dependency upgrade, it's very important to **test this upgrade** **in your testing environments**. Not doing so could result in your admin panel being unusable.
To upgrade to v5, simply run:
```bash theme={null}
npm install forest-express-sequelize@^5.0.0
```
```bash theme={null}
npm install forest-express-mongoose@^5.0.0
```
In case of a regression introduced in Production after the upgrade, a rollback to your previous agent is the fastest way to restore your admin panel.
## Breaking changes
### Smart Actions defined with custom routes
If your Forest configuration contains Smart Actions using POST or PUT method with a custom `endpoint`, you'll have to adapt your Smart Action code.
Here is an example of Smart Action to adapt:
```javascript theme={null}
const Liana = require('forest-express-sequelize');
Liana.collection('companies', {
actions: [
{
name: 'Mark as live',
httpMethod: 'POST',
endpoint: 'my-custom-route/mark-as-live', // custom route
},
],
});
```
```javascript theme={null}
const Liana = require('forest-express-mongoose');
Liana.collection('companies', {
actions: [
{
name: 'Mark as live',
httpMethod: 'POST',
endpoint: 'my-custom-route/mark-as-live', // custom route
},
],
});
```
```javascript theme={null}
const express = require('express');
const router = express.Router();
const Liana = require('forest-express-sequelize');
const models = require('../models');
router.post(
'/my-custom-route/mark-as-live',
Liana.ensureAuthenticated,
(req, res) => {
let companyId = req.body.data.attributes.ids[0];
return models.companies
.update({ status: 'live' }, { where: { id: companyId } })
.then(() => {
res.send({ success: 'Company is now live!' });
});
}
);
module.exports = router;
```
```javascript theme={null}
const express = require('express');
const router = express.Router();
const Liana = require('forest-express-mongoose');
const models = require('../models');
router.post(
'/my-custom-route/mark-as-live',
Liana.ensureAuthenticated,
(req, res) => {
let companyId = req.body.data.attributes.ids[0];
return models.companies
.update({ status: 'live' }, { where: { id: companyId } })
.then(() => {
res.send({ success: 'Company is now live!' });
});
}
);
module.exports = router;
```
To make sure it doesn't break when you upgrade to v5, you must use the`bodyParser.json()` middleware in the route configuration:
```javascript theme={null}
router.post('/my-custom-route/mark-as-live', Liana.ensureAuthenticated, bodyParser.json(),
```
The v5-compatible result would look like this:
```javascript theme={null}
const express = require('express');
const router = express.Router();
const Liana = require('forest-express-sequelize');
const models = require('../models');
const bodyParser = require('body-parser'); // NOTICE: Require the body-parser dependency.
router.post(
'/my-custom-route/mark-as-live',
Liana.ensureAuthenticated,
bodyParser.json(),
(req, res) => {
let companyId = req.body.data.attributes.ids[0];
return models.companies
.update({ status: 'live' }, { where: { id: companyId } })
.then(() => {
res.send({ success: 'Company is now live!' });
});
}
);
module.exports = router;
```
```javascript theme={null}
const express = require('express');
const router = express.Router();
const Liana = require('forest-express-mongoose');
const models = require('../models');
const bodyParser = require('body-parser'); // NOTICE: Require the body-parser dependency.
router.post(
'/my-custom-route/mark-as-live',
Liana.ensureAuthenticated,
bodyParser.json(),
(req, res) => {
let companyId = req.body.data.attributes.ids[0];
return models.companies
.update({ status: 'live' }, { where: { id: companyId } })
.then(() => {
res.send({ success: 'Company is now live!' });
});
}
);
module.exports = router;
```
## Important Notice
### Changelogs
This release note covers only the major changes. To learn more, please refer to the changelogs in our different repositories:
* [Express-sequelize changelog](https://github.com/ForestAdmin/forest-express-sequelize/blob/master/CHANGELOG.md#release-500---2019-10-31)
* [Express-mongoose changelog](https://github.com/ForestAdmin/forest-express-mongoose/blob/master/CHANGELOG.md#release-500---2019-10-31)
# Upgrade to v6
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/maintain/upgrade-notes-sql-mongodb/upgrade-to-v6
The purpose of this note is to help developers to upgrade their agent from v5 to v6. Please read carefully and integrate the following breaking changes to ensure a smooth upgrade.
## Upgrading to v6
Before upgrading to v6, consider the below **breaking changes**.
As for any dependency upgrade, it's very important to **test this upgrade** **in your testing environments**. Not doing so could result in your admin panel being unusable.
To upgrade to v6, simply run:
```bash theme={null}
npm install forest-express-sequelize@^6.0.0
```
```bash theme={null}
npm install forest-express-mongoose@^6.0.0
```
In case of a regression introduced in Production after the upgrade, a rollback to your previous agent is the fastest way to restore your admin panel.
## Breaking changes
### Agent initialization
The agent initialization now **returns a promise**. This solves an issue wherein exposed agents were not yet initialized and thus returning 404s.
You must update the following 2 files:
```javascript theme={null}
// BEFORE
module.exports = function (app) {
app.use(Liana.init({
// AFTER
module.exports = async function (app) {
app.use(await Liana.init({
```
```javascript theme={null}
// BEFORE
resolve: Module => new Module(app),
// AFTER
resolve: Module => Module(app),
```
### Select all feature
This version also introduces the new Select all behavior. Once you've updated your **bulk** Smart Actions according to the below changes, you'll be able to choose between selecting **all** the records or only those displayed on the current page.
```javascript theme={null}
// BEFORE
router.post('/actions/mark-as-live', permissionMiddlewareCreator.smartAction(), (req, res) => {
let companyId = req.body.data.attributes.ids[0];
return companies
.update({ status: 'live' }, { where: { id: companyId \}\})
.then(() => {
res.send({ success: 'Company is now live!' });
});
});
// AFTER
import { RecordsGetter } from "forest-express-sequelize";
...
router.post('/actions/mark-as-live', permissionMiddlewareCreator.smartAction(), (req, res) => {
return new RecordsGetter(companies).getIdsFromRequest(req)
.then((companyIds) => {
return companies
.update({ status: 'live' }, { where: { id: companyIds \}\})
.then(() => {
res.send({ success: 'Company is now live!' });
});
});
});
```
If you altered the default DELETE behavior by overriding or extending it, you'll have to do so as well with the new [BULK DELETE route](/legacy/javascript-agents/reference-guide/routes/default-routes#delete-a-list-of-records).
## Important Notice
### Changelogs
This release note covers only the major changes. To learn more, please refer to the changelogs in our different repositories:
* [Express-sequelize changelog](https://github.com/ForestAdmin/forest-express-sequelize/blob/master/CHANGELOG.md#release-600---2020-03-17)
* [Express-mongoose changelog](https://github.com/ForestAdmin/forest-express-mongoose/blob/master/CHANGELOG.md#release-600---2020-03-17)
# Upgrade to v7
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/maintain/upgrade-notes-sql-mongodb/upgrade-to-v7
The purpose of this note is to help developers to upgrade their agent from v6 to v7. Please read carefully and integrate the following breaking changes to ensure a smooth upgrade.
Please follow the recommended procedure to upgrade your agent version by following [this note](/legacy/javascript-agents/how-tos/maintain/push-your-new-version-to-production).
## Upgrading to v7
This upgrade unlocks the following feature:
* easier addition of additional databases
* no need to re-authenticate when switching between projects/environments/team
* dynamic smart action forms
* automatic model update
Before upgrading to v7, please take note of the following requirement:
* `express` must be **version 4.17** or higher
As for any dependency upgrade, it's very important to **test this upgrade** **in your testing environments**. Not doing so could result in your admin panel being unusable.
To upgrade to v7, simply run:
```bash theme={null}
npm install forest-express-sequelize@^7.12.3
```
```bash theme={null}
npm install forest-express-mongoose@^7.9.2
```
In case of a regression introduced in Production after the upgrade, a rollback to your previous agent is the fastest way to restore your admin panel.
## Breaking changes
### Agent initialization
In the file `middlewares/forestadmin.js`, the parameters of `Liana.init` have been updated. A few parameters have been deprecated and will either be ignored or throw an error.
Two new parameters have also been introduced to ease the addition and management of multiple databases.
The below tables list all these parameters:
| Deprecated parameters | Behavior | Replace by |
| --------------------- | -------- | ------------ |
| `onlyCrudModule` | Ignored | |
| `modelsDir` | Ignored | |
| `sequelize` | Ignored | |
| `mongoose` | Ignored | |
| `secretKey` | Error | `envSecret` |
| `authKey` | Error | `authSecret` |
| New parameters | Description |
| --------------- | --------------------------------------------------------------------------------------- |
| `objectMapping` | static instance of your object mapper (`require('sequelize')` or `require('mongoose')`) |
| `connections` | map of your existing connections, indexed by a unique name for each connections |
Here is an example of an updated `middlewares/forestadmin.js` file after the migration:
```javascript theme={null}
const { objectMapping, connections } = require('../models');
module.exports = async function forestadmin(app) {
app.use(
await Liana.init({
configDir: path.join(__dirname, '../forest'),
envSecret: process.env.FOREST_ENV_SECRET,
authSecret: process.env.FOREST_AUTH_SECRET,
objectMapping,
connections,
})
);
console.log(
chalk.cyan(
'Your admin panel is available here: https://app.forestadmin.com/projects'
)
);
};
```
#### Models index
The `models/index.js` file should be updated as well, in order to export `objectMapping` & `connections`
```javascript theme={null}
const fs = require('fs');
const path = require('path');
const Sequelize = require('sequelize');
const databasesConfiguration = require('../config/databases');
const connections = {};
const db = {};
databasesConfiguration.forEach((databaseInfo) => {
const connection = new Sequelize(
databaseInfo.connection.url,
databaseInfo.connection.options
);
connections[databaseInfo.name] = connection;
const modelsDir =
databaseInfo.modelsDir || path.join(__dirname, databaseInfo.name);
fs.readdirSync(modelsDir)
.filter((file) => file.indexOf('.') !== 0 && file !== 'index.js')
.forEach((file) => {
try {
const model = connection.import(path.join(modelsDir, file))(
connection,
Sequelize.DataTypes
);
db[model.name] = model;
} catch (error) {
console.error('Model creation error: ' + error);
}
});
});
Object.keys(db).forEach((modelName) => {
if ('associate' in db[modelName]) {
db[modelName].associate(db);
}
});
db.objectMapping = Sequelize;
db.connections = connections;
module.exports = db;
```
```javascript theme={null}
const fs = require('fs');
const path = require('path');
const Mongoose = require('mongoose');
const databasesConfiguration = require('../config/databases');
const connections = {};
const db = {};
databasesConfiguration.forEach((databaseInfo) => {
const connection = Mongoose.createConnection(
databaseInfo.connection.url,
databaseInfo.connection.options
);
connections[databaseInfo.name] = connection;
const modelsDir =
databaseInfo.modelsDir || path.join(__dirname, databaseInfo.name);
fs.readdirSync(modelsDir)
.filter((file) => file.indexOf('.') !== 0 && file !== 'index.js')
.forEach((file) => {
try {
const model = require(path.join(modelsDir, file))(connection, Mongoose);
db[model.modelName] = model;
} catch (error) {
console.error(`Model creation error: ${error}`);
}
});
});
db.objectMapping = Mongoose;
db.connections = connections;
module.exports = db;
```
**Introducing database configuration**
A `config/databases.js` file should be added as follows in order to declare the different database connections:
```javascript theme={null}
const path = require('path');
const databaseOptions = {
logging:
!process.env.NODE_ENV || process.env.NODE_ENV === 'development'
? console.log
: false,
pool: { maxConnections: 10, minConnections: 1 },
dialectOptions: {},
};
if (
process.env.DATABASE_SSL &&
JSON.parse(process.env.DATABASE_SSL.toLowerCase())
) {
const rejectUnauthorized = process.env.DATABASE_REJECT_UNAUTHORIZED;
if (
rejectUnauthorized &&
JSON.parse(rejectUnauthorized.toLowerCase()) === false
) {
databaseOptions.dialectOptions.ssl = { rejectUnauthorized: false };
} else {
databaseOptions.dialectOptions.ssl = true;
}
}
module.exports = [
{
name: 'default',
modelsDir: path.resolve(__dirname, '../models'),
connection: {
url: process.env.DATABASE_URL,
options: { ...databaseOptions },
},
},
];
```
```javascript theme={null}
const path = require('path');
const databaseOptions = {
useNewUrlParser: true,
useUnifiedTopology: true,
};
module.exports = [
{
name: 'default',
modelsDir: path.resolve(__dirname, '../models'),
connection: {
url: process.env.DATABASE_URL,
options: { ...databaseOptions },
},
},
];
```
Calling `sequelize` with one of the 2 following syntaxes will not work anymore:
`const { sequelize } = require('../models');` ❌
`const models = require('../models');`\
`const sequelize = models.sequelize; ❌`
Instead, you should now use
`const sequelize = require('../models').connections.default;`
#### Mongoose specific changes
If you made the above recommended changes in your `models/index.js` file, your Mongoose model files should now be written this way:
```javascript theme={null}
// Models are now returned from a function
module.exports = (mongoose, Mongoose) => {
const schema = Mongoose.Schema({
'country': String,
...
});
return mongoose.model('companies', schema, 'companies');
};
```
### Authentication
One of the changes introduced by the v7 is that you no longer need to re-authenticate when switching between projects/environments/team. In order to support this easier authentication flow, the changes described below need to be made.
#### New environment variable
A new environment variable called `APPLICATION_URL` is required and must be added to your `.env` file.
`http://localhost:3310` is the default value to be set for the `APPLICATION_URL`. If you specified a specific url for your application in place of the default one (for example for an install on a remote machine), this url should be the value set.
#### New CORS condition
A change in your `app.js` is required to modify how CORS are handled. The value `'null'` must be accepted for authentication endpoints (**lines 11-17**).
```javascript theme={null}
let allowedOrigins = [/\.forestadmin\.com$/, /localhost:\d{4}$/];
if (process.env.CORS_ORIGINS) {
allowedOrigins = allowedOrigins.concat(process.env.CORS_ORIGINS.split(','));
}
const corsConfig = {
origin: allowedOrigins,
allowedHeaders: ['Authorization', 'X-Requested-With', 'Content-Type'],
maxAge: 86400, // NOTICE: 1 day
credentials: true,
};
app.use(
'/forest/authentication',
cors({
...corsConfig,
// The null origin is sent by browsers for redirected AJAX calls
// we need to support this in authentication routes because OIDC
// redirects to the callback route
origin: corsConfig.origin.concat('null'),
})
);
app.use(cors(corsConfig));
```
#### Running up multiple server instances
If you're running multiple instances of your agent (with a load balancer for example), you will need to set up a static client id.
**Without a static client id, authentication will fail whenever a user makes a request to a different instance than the one he logged into.**
First you will need to obtain a client id for your environment by running the following command:
```
curl -H "Content-Type: application/json" \
-H "Authorization: Bearer FOREST_ENV_SECRET" \
-X POST \
-d '{"token_endpoint_auth_method": "none", "redirect_uris": ["APPLICATION_URL/forest/authentication/callback"]}' \
https://api.forestadmin.com/oidc/reg
```
Then assign the `client_id` value from the response (it's a JWT) to a `FOREST_CLIENT_ID` variable in your **.env** file.
## Important Notice
### Changelogs
This release note covers only the major changes. To learn more, please refer to the changelogs in our different repositories:
* [Express-sequelize changelog](https://github.com/ForestAdmin/forest-express-sequelize/blob/master/CHANGELOG.md#release-600---2020-03-17)
* [Express-mongoose changelog](https://github.com/ForestAdmin/forest-express-mongoose/blob/master/CHANGELOG.md#release-600---2020-03-17)
# Upgrade to v8
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/maintain/upgrade-notes-sql-mongodb/upgrade-to-v8
The purpose of this note is to help developers to upgrade their agent from v7 to v8. Please read carefully and integrate the following breaking changes to ensure a smooth upgrade.
Please follow the recommended procedure to upgrade your agent version by following [this note](/legacy/javascript-agents/how-tos/maintain/push-your-new-version-to-production).
This upgrade unlocks the following features:
* [Add or remove Smart action form fields dynamically](/legacy/javascript-agents/reference-guide/actions/create-and-manage-smart-actions/use-a-smart-action-form#add-remove-fields-dynamically)
* [Use hooks for bulk/global Smart actions](/legacy/javascript-agents/reference-guide/actions/create-and-manage-smart-actions/use-a-smart-action-form#get-selected-records-with-bulk-action)
* [Scopes are now enforced on all pages of the application](/legacy/javascript-agents/reference-guide/scopes/overview)
* Names of uploaded files are persisted and displayed, even when using the default handlers
## Upgrading to v8
Before upgrading to v8, consider the below [**breaking changes**](#breaking-changes).
As for any dependency upgrade, it's very important to **test this upgrade** **in your testing environments**. Not doing so could result in your admin panel being unusable.
To upgrade to v8, run the following and then update your project as shown in the [*Breaking Changes*](#breaking-changes) section below.
```
npm install "forest-express-sequelize@^8.0.0"
```
```
npm install "forest-express-mongoose@^8.0.0"
```
In case of a regression introduced in Production after the upgrade, a rollback to your previous agent is the fastest way to restore your admin panel.
## Breaking changes
#### CORS allowed headers
Every collection calls (CRUD operations) to your agent will now be performed with a new header called `Forest-Context-Url` . This header contains the current URL of the user performing requests. This can be handy if you need information on the context this user is working on.
If you don't have any restriction on headers within your CORS configuration, nothing needs to be changed, you can move on to the next section.
If you have configured a header whitelist (`allowedHeaders` in express for instance) in your CORS configuration, you need to **add this new header to the whitelist**, otherwise browsers won't trigger request anymore due to CORS policy:
Before
```javascript theme={null}
const corsConfig = {
origin: ...,
allowedHeaders: ['Authorization', 'X-Requested-With', 'Content-Type', ...],
maxAge: ...,
credentials: ...,
};
```
After
```javascript theme={null}
const corsConfig = {
origin: ...,
allowedHeaders: ['Forest-Context-Url', 'Authorization', 'X-Requested-With', 'Content-Type', ...],
maxAge: ...,
credentials: ...,
};
```
#### File Upload
Until now, once you had submitted a file for upload, the file name wasn't persisted. We have now made so that it is possible to save and display it.
**If you use a regex to parse data** before sending it for upload (like we originally suggested in this [Woodshop tutorial](https://docs.forestadmin.com/woodshop/how-tos/upload-files-to-s3#directory-services)), there is a breaking change: you need to use the output of the `parseDataUri` method.
Before
```javascript theme={null}
function S3Helper() {
this.upload = (rawData, filename) => new P((resolve, reject) => {
...
const parsed = parseDataUri(rawData);
const base64Image = rawData.replace(/^data:([-\w.]+\/[-\w.]+);base64,/, '');
const data = {
Body: new Buffer(base64Image, 'base64'),
ContentEncoding: 'base64',
...
};
}
```
After
```javascript theme={null}
function S3Helper() {
this.upload = (rawData, filename) => new P((resolve, reject) => {
...
const parsed = parseDataUri(rawData);
const data = {
Body: parsed.data,
...
};
}
```
#### Scopes
Scopes have been revamped, from a convenient alternative to segments, to a security feature. They are now enforced by the agent (server-side).
This update comes with many breaking changes in the prototype of helpers which are provided to access and modify data.
All occurrences of calls to `RecordsGetter`, `RecordCounter`, `RecordsExporter`, `RecordsRemover`, `RecordCreator`, `RecordGetter`, `RecordUpdater`, `RecordRemover` , `RecordsCounter`, must be updated.
Note that `RecordSerializer` was not modified, and can be used to serialize and deserialize models.
Before
```javascript theme={null}
router.post(
'/actions/do-something',
permissionMiddlewareCreator.smartAction()
(req, res) => {
const { query } = req;
// List helpers
new RecordsGetter(MyModel).getAll(query);
new RecordsGetter(MyModel).getIdsFromRequest(req);
new RecordCounter(MyModel).count(query);
new RecordsExporter(MyModel).streamExport(res, query);
new RecordsRemover(MyModel).remove([1, 2, 3])
// Single item helpers
new RecordCreator(MyModel).create({title: 'One');
new RecordGetter(MyModel).get(37);
new RecordUpdater(MyModel).update({title: 'Two'}, 37);
new RecordRemover(MyModel).remove(37);
}
);
```
After
```javascript theme={null}
router.post(
'/actions/do-something',
permissionMiddlewareCreator.smartAction()
(req, res) => {
const { query, user } = req;
// List helpers
new RecordsGetter(MyModel, user, query).getAll();
new RecordsGetter(MyModel, user, query).getIdsFromRequest(req);
new RecordCounter(MyModel, user, query).count();
new RecordsExporter(MyModel, user, query).streamExport(res);
new RecordsRemover(MyModel, user, query).remove([1, 2, 3])
// Single item helpers
new RecordCreator(MyModel, user, query).create({title: 'One');
new RecordGetter(MyModel, user, query).get(37);
new RecordUpdater(MyModel, user, query).update({title: Two'}, 37);
new RecordRemover(MyModel, user, query).remove(37);
}
);
```
#### Smart actions
The `values` endpoint is no longer supported. Hooks now replaces the `values` endpoint since they are now available for single, bulk & global smart action types.
*1st change:*
The Smart action `change` hook method name is no longer the `fieldName`. You are now required to declare the `hook` name as a property inside the `field`.
Before
```javascript theme={null}
{
name: 'Test action',
type: 'single',
fields: [{
field: 'a field',
type: 'String',
}],
hooks: {
change: {
'a field': ({ fields }) => {
// Do something ...
return fields;
},
}
},
}
```
After
```javascript theme={null}
{
name: 'Test action',
type: 'single',
fields: [{
field: 'a field',
type: 'String',
hook: 'onFieldChanged',
}],
hooks: {
change: {
onFieldChanged: ({ fields, changedField }) => {
// Do something ...
return fields;
},
}
},
}
```
*2nd change:*
The signature of `hooks` functions has changed. `fields` is now an array. You must change the way you access fields.
Before
```javascript theme={null}
[...]
hooks: {
load: ({ fields }) => {
const field = fields['a field'];
field.value = 'init your field';
return fields;
},
change: {
onFieldChanged: ({ fields }) => {
const field = fields['a field'];
field.value = 'what you want';
return fields;
}
}
}
[...]
```
After
```javascript theme={null}
[...]
hooks: {
load: ({ fields }) => {
const field = fields.find(field => field.field === 'a field');
field.value = 'init your field';
return fields;
},
change: {
onFieldChanged: ({ fields, changedField }) => {
const field = fields.find(field => field.field === 'a field');
field.value = 'what you want';
return fields;
}
}
}
[...]
```
*3rd change:*
The signature of `hooks` functions has changed. In order to support hooks for **global** and **bulk** smart actions, `record` is no longer sent to the hook. You must change the way you get the record information. This change also prevents unnecessary computation in case you don't need to access the record(s) inside the hooks.
Before
```javascript theme={null}
[...]
hooks: {
load: ({ fields, record }) => {
const field = fields['a field'];
field.value = record.aProps;
return fields;
},
}
[...]
```
After
```javascript theme={null}
const { model } = require('../models');
[...]
hooks: {
load: async ({ fields, request }) => {
const [id] = await new RecordsGetter(model, request.user, request.query)
.getIdsFromRequest(request);
// or
const id = request.body.data.attributes.ids[0];
const record = await model.findByPk(id);
const field = fields.find(field => field.field === 'a field');
field.value = record.aProps;
return fields;
},
}
[...]
```
```javascript theme={null}
const { model } = require('../models');
[...]
hooks: {
load: async ({ fields, request }) => {
const [id] = await new RecordsGetter(model, request.user, request.query)
.getIdsFromRequest(request);
// or
const id = request.body.data.attributes.ids[0];
const record = await model.findById(id);
const field = fields.find(field => field.field === 'a field');
field.value = record.aProps;
return fields;
},
}
[...]
```
# Upgrade to v9
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/maintain/upgrade-notes-sql-mongodb/upgrade-to-v9
The purpose of this note is to help developers to upgrade their agent from v8 to v9. Please read carefully and integrate the following breaking changes to ensure a smooth upgrade.
Please follow the recommended procedure to upgrade your agent version by following [this note](/legacy/javascript-agents/how-tos/maintain/push-your-new-version-to-production).
This upgrade unlocks the following features:
* Use templating in the filters of Chart components
* Add conditions to your role permissions
## Upgrading to v9
As for any dependency upgrade, it's very important to **test this upgrade** **in your testing environments**. Not doing so could result in your admin panel being unusable.
To upgrade to v9, first update your project according to the [*Breaking Changes*](/legacy/javascript-agents/how-tos/maintain/upgrade-notes-sql-mongodb/upgrade-to-v9#breaking-changes) section below.
If you're upgrading from an older version, please make sure you've also read the previous upgrade notes ([v8](/legacy/javascript-agents/how-tos/maintain/upgrade-notes-sql-mongodb/upgrade-to-v8), [v7](/legacy/javascript-agents/how-tos/maintain/upgrade-notes-sql-mongodb/upgrade-to-v7),..)
Once you're done with the above steps, run the following:
```
npm install "forest-express-sequelize@^9.0.0"
```
```
npm install "forest-express-mongoose@^9.0.0"
```
In case of a regression introduced in Production after the upgrade, a rollback to your previous agent is the fastest way to restore your admin panel.
## Breaking changes
### Roles v2.0
This new version (v9) drops the support of the legacy Roles system (v1.0). If you are in this legacy configuration, please follow [this procedure](/legacy/javascript-agents/how-tos/maintain/migrate-to-the-new-role-system) in order to migrate to the new Roles system (v2.0) **before** you attempt to upgrade to version 9.
**How do I know if I'm using the legacy or new Roles system?**
If you have access to Roles (Project settings > Roles) as designed below\...\
\
\
\
then you are using the new Role system.
### Approval Workflow
This new major version makes the configuration, described below, mandatory to ensure that actions are not triggered directly and approvals requests are properly created for the reviewers.
**Whether or not** your project currently uses the Approval Workflow feature,
you must ensure that all your Smart Actions routes are configured with the Smart Action middleware:
`permissionMiddlewareCreator.smartAction()`.
```javascript theme={null}
// BEFORE v9, this configuration, although unsecured, was working.
router.post('/actions/mark-as-live', (req, res) => {
// ...
});
// NOW in v9, this configuration is mandatory to make approvals work as expected.
const { PermissionMiddlewareCreator } = require('forest-express-xxx');
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
'companies'
);
router.post(
'/actions/mark-as-live',
permissionMiddlewareCreator.smartAction(),
(req, res) => {
// ...
}
);
```
# Releases Support
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/releases-support/overview
In order to give more visibility to our developers community, about agent usability and support in the future, you will find, in this page, the important lifecycle dates per agent stack and versions.
## Introduction
Each project using Forest needs a minimal maintenance of the agent dependency installed since day one.
Such maintenance will ensure the best possible experience with the platform: new features, performance improvements, reactive support, and so on.
In order to give more visibility to our developers community, about agent usability and support in the future, you will find, in the section below, the important lifecycle dates per agent stack and versions.
## Definitions
Before jumping into your favorite stack tab, you’ll find below the detailed definition of the agent lifecycle dates:
| Date | Definition |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Initial Release | It marks the date of the official release of a new agent major version, and will, as a consequence, engage Forest on a minimal 18 months period guaranty to keep the agent features 100% compatible with the platform, to provide maintenance, troubleshooting or support, to fix defects, bugs and vulnerabilities. |
| End-of-Support | At this date, Forest will no longer provide maintenance, troubleshooting or support, and will no longer fix defects, bugs or vulnerabilities. |
| End-of-Life | At this date, Forest will not guarantee the agent will be usable. It is very likely that your agent integration will break your admin panel, as we will start removing some legacy features support on our platform. |
## Releases information per stack
### Express Sequelize
A new agent generation has been released to integrate with Node.js projects.
The only way to keep your Forest project running in the future will be to migrate from the [agent v9](https://github.com/ForestAdmin/forest-express-sequelize) to the new [Node.js agent](https://github.com/ForestAdmin/agent-nodejs).
Please follow the dedicated [migration guide](https://docs.forestadmin.com/developer-guide-agents-nodejs/getting-started/migrating).
| Version | Initial Release | Alive | Active Support | End-of-Support | End-of-Life | Lifetime |
| ------- | --------------- | ----- | -------------- | -------------- | ----------- | ----------- |
| v9 | 2022-10-09 | 🟢 | 🟢 | - | - | - |
| v8 | 2021-07-19 | 🔴 | 🔴 | 2023-12-31 | 2024-06-30 | \~3 years |
| v7 | 2021-02-22 | 🔴 | 🔴 | 2023-06-30 | 2023-12-31 | \~3 years |
| v6 | 2020-03-17 | 🔴 | 🔴 | 2022-12-31 | 2023-06-30 | \~3.5 years |
| v5 | 2019-10-31 | 🔴 | 🔴 | 2022-06-30 | 2023-06-30 | \~3.5 years |
| v4 | 2019-10-04 | 🔴 | 🔴 | 2022-06-30 | 2022-12-31 | \~3 years |
| v3 | 2019-04-22 | 🔴 | 🔴 | 2022-06-30 | 2022-12-31 | \~3.5 years |
| v2 | 2017-11-30 | 🔴 | 🔴 | 2022-06-30 | 2022-12-31 | \~5 years |
| v1 | 2016-02-06 | 🔴 | 🔴 | 2022-06-30 | 2022-12-31 | \~7 years |
### Express Mongoose
A new agent generation has been released to integrate with Node.js projects.
The only way to keep your Forest project running in the future will be to migrate from the [agent v9](https://github.com/ForestAdmin/forest-express-mongoose) to the new [Node.js agent](https://github.com/ForestAdmin/agent-nodejs).
Please follow the dedicated [migration guide](https://docs.forestadmin.com/developer-guide-agents-nodejs/getting-started/migrating).
| Version | Initial Release | Alive | Active Support | End-of-Support | End-of-Life | Lifetime |
| ------- | --------------- | ----- | -------------- | -------------- | ----------- | ----------- |
| v9 | 2022-10-09 | 🟢 | 🟢 | - | - | - |
| v8 | 2021-07-19 | 🔴 | 🔴 | 2023-12-31 | 2024-06-30 | \~3 years |
| v7 | 2021-02-22 | 🔴 | 🔴 | 2023-06-30 | 2023-12-31 | \~3 years |
| v6 | 2020-03-17 | 🔴 | 🔴 | 2022-12-31 | 2023-06-30 | \~3.5 years |
| v5 | 2019-10-31 | 🔴 | 🔴 | 2022-06-30 | 2023-06-30 | \~3.5 years |
| v4 | 2019-10-04 | 🔴 | 🔴 | 2022-06-30 | 2022-12-31 | \~3 years |
| v3 | 2019-04-22 | 🔴 | 🔴 | 2022-06-30 | 2022-12-31 | \~3.5 years |
| v2 | 2017-11-30 | 🔴 | 🔴 | 2022-06-30 | 2022-12-31 | \~5 years |
| v1 | 2016-02-06 | 🔴 | 🔴 | 2022-06-30 | 2022-12-31 | \~7 years |
### Django
A new agent generation has been released to integrate with Django projects.
The only way to keep your Forest project running in 2025 will be to migrate from the [legacy agent](https://github.com/ForestAdmin/django-forestadmin) to the new [Python agent](https://github.com/ForestAdmin/agent-python).
Please follow the dedicated [migration guide](https://docs.forestadmin.com/developer-guide-agents-python/getting-started/migrating).
| Version | Initial Release | Alive | Active Support | End-of-Support | End-of-Life | Lifetime |
| ------- | --------------- | ----- | -------------- | -------------- | ----------- | ----------- |
| v1 | 2021-08-13 | 🟠 | 🔴 | 2024-06-30 | 2024-12-31 | \~3.5 years |
### Laravel
A new agent generation has been released to integrate with Laravel projects.
The only way to keep your Forest project running in 2025 will be to migrate from the [legacy agent](https://github.com/ForestAdmin/laravel-forestadmin) to the new [PHP agent](https://github.com/ForestAdmin/agent-php).
Please follow the dedicated [migration guide](https://docs.forestadmin.com/developer-guide-agents-php/getting-started/migrating).
| Version | Initial Release | Alive | Active Support | End-of-Support | End-of-Life | Lifetime |
| ------- | --------------- | ----- | -------------- | -------------- | ----------- | ----------- |
| v1 | 2022-04-15 | 🟠 | 🔴 | 2024-06-30 | 2024-12-31 | \~2.5 years |
# Settings
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/settings/README
# Customize your /forest folder
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/settings/customize-your-forest-folder
By default, all your **Smart** features will be located in a `/forest` folder.
However you can change it using:
```
configDir: 'my/path'
```
in your Forest initialization middleware.
# Disable automatic Forest schema update
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/settings/disable-automatic-forest-admin-schema-update
On server start, Forest automatically loads a new Forest schema if changes are detected.
For better control, you can disable the automatic schema synchronization by adding the following environment variable: `FOREST_DISABLE_AUTO_SCHEMA_APPLY=true`(ex: for QA and testing purposes)
By doing so, you will need to manually synchronize your Forest schema [using our CLI.](/legacy/javascript-agents/how-tos/maintain/manage-your-forest-admin-programmatically)
The command line `forest schema:apply --secret YOUR_FOREST_ENV_SECRET`apply the current schema of your repository to the specified environment (using your `.forestadmin-schema.json` file).
# Display extensive logs
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/settings/display-extensive-logs
For debugging purposes your might want to display extensive logs from your Admin Backend API.\
\
To do so, simply add the following in your code:
```javascript theme={null}
...
NODE_ENV=development
```
```javascript theme={null}
...
mongoose.set('debug', true);
```
This can be useful to understand how queries are executed to display your collections or relationships.
# Include/exclude models
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/settings/include-exclude-models
By default, all models declared in your app are analyzed by the Forest agent in order to display them as collections in your admin panel.
You can exclude some of them from the analysis to never send their metadata to Forest. By doing this, these models will therefore never be available in your admin panel.
To do so, add the following code to **either** define which models are included **or** excluded.
#### Include models
```javascript theme={null}
...
app.use(require('forest-express-sequelize').init({
envSecret: process.env.FOREST_ENV_SECRET,
authSecret: process.env.FOREST_AUTH_SECRET,
objectMapping,
connections,
includedModels: ['customers']
}));
...
```
#### Exclude models
```javascript theme={null}
...
app.use(require('forest-express-sequelize').init({
envSecret: process.env.FOREST_ENV_SECRET,
authSecret: process.env.FOREST_AUTH_SECRET,
objectMapping,
connections,
excludedModels: ['documents', 'transactions']
}));
...
```
#### Include models
```javascript theme={null}
...
app.use(require('forest-express-mongoose').init({
modelsDir: __dirname + '/models',
envSecret: process.env.FOREST_ENV_SECRET,
authSecret: process.env.FOREST_AUTH_SECRET,
sequelize: require('./models').sequelize,
includedModels: ['customers']
}));
...
```
#### Exclude models
```javascript theme={null}
...
app.use(require('forest-express-mongoose').init({
modelsDir: __dirname + '/models',
envSecret: process.env.FOREST_ENV_SECRET,
authSecret: process.env.FOREST_AUTH_SECRET,
sequelize: require('./models').sequelize,
excludedModels: ['documents', 'transactions']
}));
...
```
```ruby theme={null}
ForestLiana.env_secret = Rails.application.secrets.forest_env_secret
ForestLiana.auth_secret = Rails.application.secrets.forest_auth_secret
# ...
# in the [] you may add the precise list of all models you want to see in Forest
ForestLiana.included_models = ['Customer'];
# or second possibility below :
# in the [] you may add the precise list of all models you do not want to see in Forest
ForestLiana.excluded_models = ['Document', 'Transaction'];
```
```python theme={null}
# ...
FOREST = {
'FOREST_ENV_SECRET': os.environ.get('FOREST_ENV_SECRET'),
'FOREST_AUTH_SECRET': os.environ.get('FOREST_AUTH_SECRET'),
# in the [] you may add the precise list of all models you want to see in Forest
'INCLUDED_MODELS': ['Customer']
# in the [] you may add the precise list of all models you do not want to see in Forest
'EXCLUDED_MODELS': ['Customer', 'Transaction'],
}
```
# Setup
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/setup/README
# Configuring CORS headers
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/setup/configuring-cors-headers
Depending on how you've setup your app, you may encounter a [CORS](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing) error. It will look like this in your browser console:
In this case, you need to configure the right CORS headers to **allow the domain** `app.forestadmin.com` to trigger an API call on your Application URL, which is a different domain name (e.g. localhost:3000 on development).
### Rails
We use the [Rack CORS](https://github.com/cyu/rack-cors) Gem for this purpose.
```ruby theme={null}
module YourApp
class Application < Rails::Application
# ...
# For Rails 5, use the class Rack::Cors. For Rails 4, you MUST use the string 'Rack::Cors'.
null_regex = Regexp.new(/\Anull\z/)
config.middleware.insert_before 0, Rack::Cors do
allow do
hostnames = [null_regex, 'localhost:4200', 'app.forestadmin.com', 'localhost:3001']
hostnames += ENV['CORS_ORIGINS'].split(',') if ENV['CORS_ORIGINS']
origins hostnames
resource '*',
headers: :any,
methods: :any,
expose: ['Content-Disposition'],
credentials: true
end
end
end
end
```
# Connecting Forest to Your Database (Forest Cloud)
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/setup/connecting-forest-admin-to-your-database-forest-cloud
### Introduction
Before you can use Forest to manage your data, you need to connect it to your database. This guide will walk you through the necessary steps to establish a connection between Forest and your database by providing the correct credentials, configuring firewall rules, and using tunneling software when required.
### Provide database credentials
To connect Forest to your database, you must enter the following authentication credentials:
* Hostname
* Port
* Username
* Password
* Database name
Make sure to have this information at hand before proceeding.
### Set up tunneling for local databases
If your database is running locally (e.g., 127.0.0.1), you will need to use tunneling software to expose your local database to the internet. This will enable Forest to connect to it. Some popular tunneling software options include:
* Ngrok
* Bastion
* Localtunnel
Choose a tunneling software that suits your needs and follow its documentation to set up the connection.
# Deploy your admin backend on Heroku
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/setup/deploy-to-production-on-heroku
This tutorial is designed to assist people who want to have a step-by-step guide to deploy the Lumber-generated admin backend to Heroku.
If you don’t have a Heroku account yet, [sign up here](https://signup.heroku.com/). Then, create your first Heroku application **(1)** **(2)**.
After creating your application, simply follow the Heroku guide “Deploy using Heroku Git” to push the lumber-generated admin backend code to the Heroku application.
Push your code using the following command:
### Command line
```bash theme={null}
git push heroku master
```
### Output
```
Counting objects: 25, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (20/20), done.
Writing objects: 100% (25/25), 21.56 KiB | 5.39 MiB/s, done.
Total 25 (delta 9), reused 0 (delta 0)
remote: Compressing source files... done.
remote: Building source:
remote:
remote: -----> Node.js app detected
remote:
remote: -----> Creating runtime environment
remote:
remote: NPM_CONFIG_LOGLEVEL=error
remote: NODE_VERBOSE=false
remote: NODE_ENV=production
remote: NODE_MODULES_CACHE=true
remote:
remote: -----> Installing binaries
remote: engines.node (package.json): unspecified
remote: engines.npm (package.json): unspecified (use default)
remote:
remote: Resolving node version 8.x...
remote: Downloading and installing node 8.11.4...
remote: Using default npm version: 5.6.0
remote:
remote: -----> Restoring cache
remote: Skipping cache restore (not-found)
remote:
remote: -----> Building dependencies
remote: Installing node modules (package.json + package-lock)
remote: added 246 packages in 7.72s
remote:
remote: -----> Caching build
remote: Clearing previous node cache
remote: Saving 2 cacheDirectories (default):
remote: - node_modules
remote: - bower_components (nothing to cache)
remote:
remote: -----> Pruning devDependencies
remote: Skipping because npm 5.6.0 sometimes fails when running 'npm prune' due to a known issue
remote: https://github.com/npm/npm/issues/19356
remote:
remote: You can silence this warning by updating to at least npm 5.7.1 in your package.json
remote: https://devcenter.heroku.com/articles/nodejs-support#specifying-an-npm-version
remote:
remote: -----> Build succeeded!
remote: -----> Discovering process types
remote: Procfile declares types -> (none)
remote: Default types for buildpack -> web
remote:
remote: -----> Compressing...
remote: Done: 24.2M
remote: -----> Launching...
remote: Released v3
remote: https://lumber-deploy-to-production.herokuapp.com/ deployed to Heroku
remote:
remote: Verifying deploy... done.
To https://git.heroku.com/lumber-deploy-to-production.git
* [new branch] master -> master
```
Your admin backend is now deployed in a remote Heroku application. 🎉
The last step to have a complete running application is to deploy a database remotely.
For this, you can follow our [Populate a remote database](/legacy/javascript-agents/how-tos/databases/populate-a-postgresql-database-on-heroku) how-to.
This does **not** mean your project is deployed to production on Forest. To deploy to production, check out [Environments](/product/process/advanced-concepts/developer-workflow/environments-and-branches) after you've completed the above steps.
# Deploy your admin backend to Ubuntu server
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/setup/deploy-to-production-to-ubuntu-server
The goal of this tutorial is to help people deploy their admin backend to Ubuntu server.
### Connect to your Ubuntu server using SSH
Before starting anything, you have to make sure you're able to connect to your server using SSH.
### Command line
```bash theme={null}
ssh -i ~/.ssh/aws.pem ubuntu@ec2-18-204-18-81.compute-1.amazonaws.com
```
### Output
```
Warning: Permanently added 'ec2-18-204-18-81.compute-1.amazonaws.com,18.204.18.81' (ECDSA) to the list of known hosts.
Welcome to Ubuntu 18.04.1 LTS (GNU/Linux 4.15.0-1021-aws x86_64)
...
ubuntu@ip-172-31-83-152:~$
```
### Copy the code of your admin backend to your remote server
There are many ways to copy the code of your admin backend to a remote server. For example, you can use `rsync` command, or use a versioning system like `git`.
We **strongly advise** to version the code of your admin backend using **git** and host it to a **private repository** on Github, Bitbucket, Gitlab or other providers.
#### rsync
> **rsync** is a utility for efficiently transferring and synchronizing files across computer systems, by checking the timestamp and size of files. It is *commonly* found on Unix-like systems and functions as both a file synchronization and file transfer program.
>
> Rsync is typically used for synchronizing files and directories between two different systems.\
> (source: [wikipedia](https://en.wikipedia.org/wiki/Rsync))
The syntax used is `rsync OPTIONS SOURCE TARGET`.
```bash theme={null}
rsync -avz -e "ssh -i ~/.ssh/aws.pem -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" --exclude=node_modules --exclude=.git --progress QuickStart ubuntu@ec2-18-204-18-81.compute-1.amazonaws.com:~/
```
In the example above, we use a SSH connection to transfer the file and we connect to the remote server using an identity\_file (a private key).
| Option | Description |
| ----------------- | -------------------------------------- |
| -a | archive mode; same as -rlptgoD (no -H) |
| -v | increase verbosity |
| -z | compress file data during the transfer |
| -e | specify the remote shell to use |
| --exclude=PATTERN | exclude files matching PATTERN |
| --progress | show progress during transfer |
Once done, you can find the code of your admin backend on the home directory of your remote server.
### Command line
```bash theme={null}
ssh -i ~/.ssh/aws.pem ubuntu@ec2-18-204-18-81.compute-1.amazonaws.com
ubuntu@ip-172-31-83-152:~$ cd Quickstart/
ubuntu@ip-172-31-83-152:~/QuickStart$ ls -l
```
### Output
```bash theme={null}
total 5116
-rw-r--r-- 1 ubuntu ubuntu 1386 Oct 22 08:11 app.js
-r-------- 1 ubuntu ubuntu 1692 Oct 23 12:51 aws.pem
drwxr-xr-x 2 ubuntu ubuntu 4096 Oct 12 10:19 bin
-rw-r--r-- 1 ubuntu ubuntu 5126311 Oct 12 11:20 database.dump
drwxr-xr-x 2 ubuntu ubuntu 4096 Oct 19 11:49 forest
drwxr-xr-x 2 ubuntu ubuntu 4096 Oct 19 12:22 models
-rw-r--r-- 1 ubuntu ubuntu 69568 Oct 22 07:30 package-lock.json
-rw-r--r-- 1 ubuntu ubuntu 717 Oct 22 07:30 package.json
drwxr-xr-x 2 ubuntu ubuntu 4096 Oct 12 10:19 public
drwxr-xr-x 2 ubuntu ubuntu 4096 Oct 22 07:48 routes
drwxr-xr-x 2 ubuntu ubuntu 4096 Oct 19 11:53 serializers
drwxr-xr-x 2 ubuntu ubuntu 4096 Oct 19 11:55 services
ubuntu@ip-172-31-83-152:~/QuickStart$
```
#### git
First, you need to initialize a git repository for the code of your admin backend. From the directory of your admin backend, simply run:
```bash theme={null}
git init
```
Then, you can add all the files and create your first commit.
```bash theme={null}
git add .
git commit -am "First commit"
```
Finally, you can add your git remote and push the code on your favorite platform. To do so, **first** create a new QuickStart repository on your github account. **Then** run the following command after changing `YourAccount` to your account name:
```bash theme={null}
git remote add origin git@github.com:YourAccount/QuickStart.git
git push -u origin master
```
Now, you can connect to your remote server using SSH and clone the repository using the HTTPS method.
### Command line
```bash theme={null}
ssh -i ~/.ssh/aws.pem ubuntu@ec2-18-204-18-81.compute-1.amazonaws.com
git clone https://github.com/YourAccount/QuickStart.git
```
### Output
```bash theme={null}
Cloning into 'QuickStart'...
remote: Enumerating objects: 34, done.
remote: Counting objects: 100% (34/34), done.
remote: Compressing objects: 100% (21/21), done.
remote: Total 34 (delta 7), reused 34 (delta 7), pack-reused 0
Unpacking objects: 100% (34/34), done.
```
That's it. Your admin backend's code is available on your remote server.
### Command line
```bash theme={null}
ubuntu@ip-172-31-83-152:~$ cd QuickStart/
ubuntu@ip-172-31-83-152:~/QuickStart$ ls -l
```
### Output
```
total 5112
-rw-rw-r-- 1 ubuntu ubuntu 1386 Oct 23 13:42 app.js
drwxrwxr-x 2 ubuntu ubuntu 4096 Oct 23 13:42 bin
-rw-rw-r-- 1 ubuntu ubuntu 5126311 Oct 23 13:42 database.dump
drwxrwxr-x 2 ubuntu ubuntu 4096 Oct 23 13:42 forest
drwxrwxr-x 2 ubuntu ubuntu 4096 Oct 23 13:42 models
-rw-rw-r-- 1 ubuntu ubuntu 69568 Oct 23 13:42 package-lock.json
-rw-rw-r-- 1 ubuntu ubuntu 717 Oct 23 13:42 package.json
drwxrwxr-x 2 ubuntu ubuntu 4096 Oct 23 13:42 public
drwxrwxr-x 2 ubuntu ubuntu 4096 Oct 23 13:42 routes
drwxrwxr-x 2 ubuntu ubuntu 4096 Oct 23 13:42 serializers
drwxrwxr-x 2 ubuntu ubuntu 4096 Oct 23 13:42 services
ubuntu@ip-172-31-83-152:~/QuickStart$
```
### Install dependencies
First, you have to make sure you have Node.js and NPM correctly installed on your server.
```bash theme={null}
sudo apt update
sudo apt install nodejs npm
```
Then, you will be able to install all the dependencies listed on the package.json file.
```bash theme={null}
npm install
```
### Create the database
#### PostgreSQL
This step is **optional** if you already have a running database.
First, you need to install PostgreSQL:
```bash theme={null}
sudo apt-get install postgresql postgresql-contrib
```
Then, you will be able to connect to the database server:
### Command line
```bash theme={null}
sudo -u postgres psql
```
### Output
```bash theme={null}
psql (10.5 (Ubuntu 10.5-0ubuntu0.18.04))
Type "help" for help.
postgres=
```
Now, we can export the database from your local environment (your computer) to import it to your Ubuntu server.
For security reason, we will not allow remote connections to this database. This is why transfer the database dump to the remote server using `rsync.`
From your computer:
```bash theme={null}
PGPASSWORD=secret pg_dump -h localhost -p 5416 -U forest forest_demo --no-owner --no-acl -f database.dump
rsync -avz -e "ssh -i ~/.ssh/aws.pem -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" --progress database.dump ubuntu@ec2-18-204-18-81.compute-1.amazonaws.com:~/
```
Then, we will create a new DB user and schema from the remote server:
```bash theme={null}
sudo -u postgres psql
postgres=# CREATE USER forest WITH ENCRYPTED PASSWORD 'secret';
postgres=# CREATE DATABASE forest_demo;
postgres=# GRANT ALL PRIVILEGES ON DATABASE forest_demo TO forest;
postgres=# \q
```
And finally import the dump:
```bash theme={null}
PGPASSWORD=secret psql -U forest -h 127.0.0.1 forest_demo < database.dump
```
That's it, your database is now fully imported.
### Command line
```
PGPASSWORD=secret psql -U forest -h 127.0.0.1 forest_demo
```
### Output
```bash theme={null}
psql (10.5 (Ubuntu 10.5-0ubuntu0.18.04))
Type "help" for help.
forest_demo=>
```
### Command line
```
forest_demo=> \d
```
### Output
```sql theme={null}
List of relations
Schema | Name | Type | Owner
--------+---------------------+----------+----------
public | Companies_id_seq | sequence | postgres
public | addresses | table | postgres
public | addresses_id_seq | sequence | postgres
public | appointments | table | postgres
public | appointments_id_seq | sequence | postgres
public | companies | table | postgres
public | customers | table | postgres
public | customers_id_seq | sequence | postgres
public | deliveries | table | postgres
public | deliveries_id_seq | sequence | postgres
public | documents | table | postgres
public | documents_id_seq | sequence | postgres
public | orders | table | postgres
public | orders_id_seq | sequence | postgres
public | products | table | postgres
public | products_id_seq | sequence | postgres
public | transactions | table | postgres
public | transactions_id_seq | sequence | postgres
(18 rows)
```
### Export the environment variables
You must export the environment variables `FOREST_ENV_SECRET` `FOREST_AUTH_SECRET` and `DATABASE_URL`. To do so, open and edit the file `/etc/environment`:
The `FOREST_ENV_SECRET` and `FOREST_AUTH_SECRET` environment variables will be given by Forest after creating a production environment from the interface. [See how to create a production environment](/product/process/advanced-concepts/developer-workflow/environments-and-branches).
```bash theme={null}
sudo vim /etc/environment
```
```bash theme={null}
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games"
FOREST_ENV_SECRET=2417520743be37a9c5af198c018e0ddee9b7c41de1ccb8e76c9d027faa74059e
FOREST_AUTH_SECRET=Piq7a9Kv5anLbK4gj81rirsLhfaJ0pdL
DATABASE_URL=postgres://forest:secret@127.0.0.1/forest_demo
```
Then, you can restart your server to take these new variables into account or simply type:
```bash theme={null}
for env in $( cat /etc/environment ); do export $(echo $env | sed -e 's/"//g'); done
```
### Run your admin backend
From your admin backend's directory, simply type:
### Command line
```bash theme={null}
npm start
```
### Output
```
> QuickStart@0.0.1 start /home/ubuntu/QuickStart
> node ./bin/www
🌳 Your back office API is listening on port 3000 🌳
🌳 Access the UI: http://app.forestadmin.com 🌳
```
Congrats, your admin backend is now running on production. But we strongly advise you to continue following the next steps. If you chose not to do it, you can go back to your Forest interface to create a production environment. [Check out here how to do it](https://docs.forestadmin.com/documentation/getting-started/setup-guide#step-3-deploy-to-production).
The admin backend is by default listening on port **3310**. Be sure you authorized the inbound traffic on this port or set up a web server (like NGINX) as a [Reverse Proxy Server](/legacy/javascript-agents/how-tos/setup/deploy-to-production-to-ubuntu-server#set-up-nginx-as-a-reverse-proxy-server) to use the port **80.**
### Manage Application with PM2
> PM2 is a Production Runtime and Process Manager for Node.js applications with a built-in Load Balancer. It allows you to keep applications alive forever, to reload them without downtime and facilitate common Devops tasks. source: [npmjs/pm2](https://www.npmjs.com/package/pm2)
#### Install PM2
```bash theme={null}
sudo npm install pm2 -g
```
#### Run your admin backend using PM2
```bash theme={null}
pm2 start bin/www
```
### (Optional) Set Up Nginx as a Reverse Proxy Server
Now that your admin backend is running and listening on localhost:3310, we will set up the Nginx web server as a reserve proxy to allow your admin panel's users access it.
```bash theme={null}
sudo apt install nginx
```
To do so, edit (with sudo access) the file located `/etc/nginx/sites-available/default` and replace the existing section `location /` by this one:
```
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
```
Then, restart nginx:
```bash theme={null}
sudo systemctl restart nginx
```
That's it, your admin backend is now listening on the port **80**. Make sure your firewall allows inbound traffic from this port.
We now require that you configure **HTTPS** (port 443) on your admin backend service for **security reasons.** [http://nginx.org/en/docs/http/configuring\_https\_servers.html](http://nginx.org/en/docs/http/configuring_https_servers.html)
Once you've completed the above steps, it does **not** mean your project is deployed to production on Forest. To deploy to production, check out [Environments](/product/process/advanced-concepts/developer-workflow/environments-and-branches).
# Deploy Your Admin Backend With Aws
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/setup/deploy-your-admin-backend-with-aws
This tutorial is designed to assist you with a step-by-step guide to deploy the admin backend to Amazon Web Services, using EC2, ELB, ACM and Route53.
First, please ensure you have an AWS account. You can sign up [here](https://aws.amazon.com/).
### 1. Launch an EC2 Instance:
* Navigate to the EC2 dashboard and click on `Launch Instance`.
* Choose an Amazon Machine Image (AMI) such as `Amazon Linux 2023 AMI`.
* Select `t2.micro` (part of the AWS Free Tier).
* Select `Proceed without a key pair`
* On the `Configure Security Group` step, create a new security group:
* allow `ssh traffic`.
* allow `HTTPS traffic`.
* allow `HTTP traffic`.
* Review and launch the instance.
### 2. Connect to the EC2 instance:
* Navigate to your EC2 instance and click on `Connect`.
* Leave the default parameters and click on `Connect` again.
* Your are now connected to your instance.
### 3. Set up your instance:
The command lines in this step demonstrate how to install a Node.js agent. If you are running Forest on another agent, please adapt the following to your specific stack.
* Update the instance:
```bash theme={null}
sudo yum update -y
```
* Install Git:
```bash theme={null}
sudo yum install git -y
```
* Clone your repo:
```bash theme={null}
git clone your-repo-link
```
* Install Node.js and npm:
```bash theme={null}
sudo yum install npm -y
```
* Navigate to your project directory and install the necessary packages:
```bash theme={null}
cd your-repo-directory
npm install
```
* Set up all the necessary environment variables provided by the Forest environment creation wizard.
* Add the `APPLICATION_PORT` environment variable to be able to contact the server from outside. In this example, we will choose `APPLICATION_PORT=3310`. If you choose another port, please adapt the next steps accordingly.
* Start the agent
```bash theme={null}
npm run start:watch
```
### 4. Adjust security group rules:
* Navigate to your EC2 instance's security group.
* Click on `Edit inbound rules`.
* Add a Custom TCP inbound rule to allow on port `3310`.
### 5. Create a target group:
* In the AWS Management Console, navigate to the EC2 service.
* Under "Target Groups", click `Create Target Groups`.
* Ensure target type is instance.
* Choose HTTP to `3310`.
* Ensure VPC is set to the same VPC as your EC2 instance.
* Setup the health checks as set to `/forest`.
* On the next step, select instance and click on `Include as pending below`.
* Finally create the target group.
### 6. Request a certificate using AWS Certificate Manager (ACM):
* Navigate to ACM and click on `Request a certificate`.
* Enter your domain name and validate the domain ownership using DNS validation.
* After viewing the new created certificate, click on `Create records in Route 53`.
* Wait for the certificate to be validated (this can take some time \< 1mn).
### 7. Set up an Application Load Balancer (ALB):
* In the AWS Management Console, navigate to the EC2 service.
* Under "Load Balancers", click `Create Load Balancer`.
* Choose `Application Load Balancer` and follow the setup.
* Ensure the ALB is set to the same VPC as your EC2 instance.
* Select all regions.
* Remove default security group and select the group associated to the newly created instance.
* Add an HTTPS listener and choose previously created target group and certificate.
* After creating the ALB copy the `DNS name`.
### 8. Add CNAME to Route53:
* Navigate to Route53 and choose your hosted zone (domain).
* Create a `CNAME` record with the domain name filled in the certificate and the `DNS name` of the ALB.
### 9. Finalize:
Check your domain. You should be able to access your Forest panel environment hosted on AWS. 🎉
This is a basic setup, and there are many optimizations and security enhancements (like using RDS, tightening security groups, etc.) that can be done for a production-ready deployment. Please refer to the [AWS documentation](https://docs.aws.amazon.com/index.html) to go deeper.
# Deploy your admin backend to Google Cloud Platform
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/setup/deploy-your-admin-backend-with-google-cloud-platform
This tutorial is designed to assist you with a step-by-step guide to deploy the Lumber-generated admin backend to Google Cloud Platform, using Google's App Engine.
If you don’t have a Google Cloud Platform account yet, [sign up here](https://cloud.google.com/free). Then, [create a billing account](https://cloud.google.com/billing/docs/how-to/manage-billing-account#create_a_new_billing_account) if you haven't already. You will need it to be able to use App Engine.
### **Install the Google Cloud SDK CLI**
You first need to install the [Cloud SDK CLI](https://cloud.google.com/sdk/docs/downloads-interactive) as you will need it to execute the commands listed below.
### Create a new project on your Google Cloud Platform
To create a new project, run the following command in your terminal:
```
gcloud projects create [YOUR_PROJECT_ID]
```
Replace `[YOUR_PROJECT_ID]` with a string of characters that uniquely identifies your project.
To check if your project has been successfully created, run
```
gcloud projects describe [YOUR_PROJECT_ID]
```
### Create an app within your Project using App Engine
The next step is to initialize App Engine for your newly created project. This will create an app attached to the project.
Choose carefully your application's region when prompted, you will not be able to change this setting later.
```
gcloud app create --project=[YOUR_PROJECT_ID]
```
Your App Engine application in your project has been created 🎊.
The last steps needed before you can deploy your Forest backend are to:
* [ensure the billing](https://cloud.google.com/apis/docs/getting-started#enabling_billing) account linked to your new project is the correct one
* [enable the Cloud Build API](https://cloud.google.com/apis/docs/getting-started#enabling_apis) on your project
GCP offers a free tier for the use of Google App Engine. However, it may not be sufficient for your usage in production. You can check the free plan limitations [here](https://cloud.google.com/free/). Note that you will get a USD 300 free credit when you register to App Engine.
### Deploy your application
Now back to your terminal and run the following command in the Forest backend's project directory.
```
touch app.yaml && echo 'runtime: nodejs12' > app.yaml
```
This will create an `app.yaml` config file in your admin backend directory. This file acts as a deployment descriptor for your service, it generally contains CPU, memory, network and disk resources, scaling, and other general settings including environment variables.
For a complete list of all the supported elements in this configuration file, please refer to Google Cloud Platform documentation's [`app.yaml`](https://cloud.google.com/appengine/docs/flexible/nodejs/reference/app-yaml)[ reference](https://cloud.google.com/appengine/docs/flexible/nodejs/reference/app-yaml). We chose to keep it very simple here.
Now, you are ready to deploy, please run:
```
gcloud app deploy
```
Congratulations, your admin backend has been deployed 🎊. You can run the following command to make sure it is up and running.
```
gcloud app browse
```
This does **not** mean your project is deployed to production on Forest. To deploy to production, check out [Environments](/product/process/advanced-concepts/developer-workflow/environments-and-branches) after you've completed the above steps.
### Adding environment variables
When required to add the environment variables to configure your production environment, you need to add them to the `app.yaml` file of your admin backend repository. The file should look like this:
```yaml theme={null}
runtime: nodejs12
env_variables:
FOREST_ENV_SECRET: '63f51525814bdfec9dd99690a656757e251770c34549c5f383d909f5cce41eb9'
FOREST_AUTH_SECRET: '93d33e1b2a9f9b03aeac687d5a811ac872bf145e9f2c4b28'
DATABASE_URL: 'postgres://user:password@remotehost:5432/db_name'
NODE_ENV: 'production'
```
Once the environment variables are added, you can deploy the code base again to sync your production app with your Forest Production environment.
```
gcloud app deploy
```
Having problems deploying? Check out [troubleshooting common problems](https://community.forestadmin.com/t/deploying-on-google-cloud-platform-forestadmin-schema-json-file-does-not-exist/4406) in our community.
# Forest IP white-listing (Forest Cloud)
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/setup/forest-admin-ip-white-listing-forest-cloud
Authorizing Forest IP Addresses for Enhanced Security
In this documentation article, we will guide you through the process of authorizing Forest IP addresses in your database to enhance security, when using our Forest Cloud solution.
This will ensure that only approved IP addresses can access your database, safeguarding your data and minimizing potential vulnerabilities.
#### Step 1: Forest IP Address to Whitelist
For the proper functioning of our services, it's essential to whitelist the following Forest IP address: **35.180.175.97**
#### Step 2: Access Your Database Configuration
Log in to your database management system and navigate to the configuration settings. The process may vary depending on your database provider, so refer to your provider's documentation if needed.
#### Step 3: Update IP Whitelist
Locate the IP whitelisting or firewall settings in your database configuration. Add the Forest IP addresses you obtained in Step 1 to the list of authorized IP addresses.
#### Step 4: Apply Changes and Test Connection
Save the changes to your database configuration and restart your database if necessary. To confirm that the IP addresses have been successfully authorized, try accessing your database using Forest. If you encounter any issues, double-check the authorized IP addresses in your database settings.
By authorizing Forest IP addresses in your database, you can significantly improve the security of your data and adhere to the best practices of your organization. For additional assistance or questions, please refer to our support resources or contact our team.
# Install
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/setup/install
## Requirements
* A local or remote working database (non empty)
or
* An existing app (Django, Rails or Express with Sequelize and Mongoose)
* NPM or Docker installed
* Browser Support: we highly recommend Google Chrome or Firefox
Once you start [creating a project](https://app.forestadmin.com/new-project), you will be able to choose a datasource, the source of the data your admin panel will use.
Forest can be implemented in two very different ways :
* Using an existing app: integrate Forest into your Ruby on Rails, Django, Node.js app with Express (and Sequelize ORM or Mongoose ORM).
* As a dedicated app: create a dedicated app directly linked to your PostgreSQL, MySQL / MariaDB, Microsoft SQL Server or MongoDB database.
At Forest, if you have the choice, we recommend integrating in an existing app as it is easier to maintain.
### Install Forest using an existing app
At the moment, we are supporting:
* Ruby on Rails app
* Django project
* Node.js app with Express and Sequelize ORM
* Node.js app with Express and Mongoose ORM
#### Install Forest using an existing Ruby on Rails app
Requirements: Your Rails app must be version 4 or above.
You are asked to provide the URL of your application that runs locally. When you follow the steps and integrate the gems, you should automatically be redirected to your admin panel!
#### Install Forest using an existing Django app
Requirements:
* Python version should be between 3.6 and 3.10.
* Django version must be 3.2 or higher.
You are asked to provide the URL of your project that runs locally. When you follow the steps, add our app to your installed apps, and set up your agent, you should automatically be redirected to your admin panel!
#### Install Forest using an existing Node.js app with Express
Requirements:
* Using Sequelize or Mongoose ORM
* Sequelize version must be 5.21 or higher
* Mongoose version must be 5 or higher
* Express version must be 4.17.3 or higher
You are asked to provide the URL of your application that runs locally. When you follow the steps, you should automatically be redirected to your admin panel!
### Troubleshooting
In case of an error, you can consult the [troubleshooting page](/legacy/javascript-agents/how-tos/setup/troubleshooting) or ask in the Community forum.
### Install using a database as your datasource
At the moment, we are supporting:
* PostgreSQL
* MySQL / MariaDB
* Microsoft SQL Server
* MongoDB
When choosing one of these databases, you will be prompted to enter your database credentials. Your database credentials never leave the browser, they are only used to generate the environment variables in the setup instructions for the next step.
It is possible to use a local or remote database, but note that this database will be used with your Development environment.
It is possible to skip the authentication in the browser and use directly the CLI to authenticate.
Then, you will be able to create and connect your admin backend, with the following options.
### NPM / Yarn
| Option | Description |
| ------------------------ | ------------------------------------------------ |
| `-c, --connection-url` | The database credentials with a connection URL. |
| `-S, --ssl` | Use SSL for database connection (true \| false). |
| `-s, --schema` | Your database schema. |
| `-H, --application-host` | Hostname of your admin backend application. |
| `-p, --application-port` | Port of your admin backend application. |
| `-h, --help` | Output usage information. |
### Docker
| Option | Description |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `APPLICATION_HOST` | Hostname of your admin backend application. |
| `APPLICATION_PORT` | Port of your admin backend application. |
| `DATABASE_SSL` | Use SSL for database connection (true \| false). |
| `DATABASE_SCHEMA` | Your database schema. |
| `DATABASE_URL` | The database credentials with a connection URL. |
| `FOREST_EMAIL` | Your Forest account email. |
| `FOREST_TOKEN` | Your Forest account token. |
| `FOREST_PASSWORD` | Your Forest account password. Although not recommended, you can use this instead of `FOREST_TOKEN`. Wrap it in double quotes if it contains special characters. |
### Help us get better!
Finally, when your local server is started, you should be automatically redirected to a satisfaction form. Rate us so we can improve, then **go to your newly created admin panel** 🎉
If you installed using a local database, your generated admin backend will have[`http://localhost:3310`](http://localhost:3310/) as an endpoint (Notice the HTTP protocol).\
This explains why, if you try to visit \*\*https\://\*\*app.forestadmin.com, you will be *redirected* to \*\*http\://\*\*app.forestadmin.com as this is the only way it can communicate with your local admin backend.
# Install Forest on a remote machine
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/setup/install-forest-admin-on-a-remote-machine
In this short tutorial, we'll cover how to install Forest on a remote environment instead of locally.
This is **not** the recommended way of using Forest.
When you install Forest, on the last step you are asked to run some commands:
The recommended way of installing Forest is to run those commands **locally**, which will generate files in your current local directory.
**However**, you may require to install Forest **on a remote server**: in this case, you must:
1. Edit the second command (`lumber generate`):
* change `--application-host` to the **URL** of your remote server
2. Run those commands **on that remote server** instead of locally.
All remote environments must use **HTTPS** (port 443) for security reasons. Choosing to install this way will require that you set up SSL certificates on your server yourself.
Remember that the database credentials provided on the previous should reflect where the command will be run (i.e: the host and port might be different).
### Using Docker
When you install Forest, on the last step you are asked to run some commands:
The recommended way of installing Forest is to run those commands **locally**, which will generate files in your current local directory.
**However**, you may require to install Forest **on a remote server**: in this case, you must:
1. Edit the first command (`docker run`):
* change `APPLICATION_HOST` to the **URL** of your remote server
2. Run those commands **on that remote server** instead of locally.
All remote environments must use **HTTPS** (port 443) for security reasons. Choosing to install this way will require that you set up SSL certificates on your server yourself.
Remember that the database credentials provided on the previous should reflect where the command will be run (i.e: the host and port might be different).
# Prevent permission errors at installation
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/setup/prevent-permission-errors-at-installation
If you see an EACCES error when you try to install a lumber-cli globally, follow this tutorial.
Depending on how you've installed Node.js on your system, you could encounter a permissions error **EACCES** similar to the following output.
In this case, I got the error on a EC2 instance running on Ubuntu 10.04 with Node v8.10.0 and NPM v.3.5.2. But you can have this similar problem on another system and node version.
```bash theme={null}
npm ERR! Linux 4.15.0-1021-aws
npm ERR! argv "/usr/bin/node" "/usr/bin/npm" "install" "-g" "lumber-cli" "--save"
npm ERR! node v8.10.0
npm ERR! npm v3.5.2
npm ERR! path /usr/local/lib
npm ERR! code EACCES
npm ERR! errno -13
npm ERR! syscall access
npm ERR! Error: EACCES: permission denied, access '/usr/local/lib'
npm ERR! { Error: EACCES: permission denied, access '/usr/local/lib'
npm ERR! errno: -13,
npm ERR! code: 'EACCES',
npm ERR! syscall: 'access',
npm ERR! path: '/usr/local/lib' }
npm ERR!
npm ERR! Please try running this command again as root/Administrator.
npm ERR! Please include the following file with any support request:
npm ERR! /home/ubuntu/npm-debug.log
```
The problem is because NPM does not have the **write access** to the directory that will contain the package you want to install (here `lumber-cli`).
To solve this issue, we recommend to override the default directory where your global NPM packages will be stored.
```bash theme={null}
mkdir ~/.npm-global
```
Then, configure NPM to use this directory instead of the default one:
```bash theme={null}
npm config set prefix '~/.npm-global'
```
Then, make the node executables accessible from your *PATH.* To do so, export the environment variable PATH by opening or creating the file `~/.profile` and add this line at the end:
```bash theme={null}
export PATH=~/.npm-global/bin:$PATH
```
Finally, reload the `~/.profile` file:
```bash theme={null}
source ~/.profile
```
That's it, now you should be able to install lumber without any error 🎉
```bash theme={null}
npm install -g lumber-cli
```
# Running Forest on multiple servers
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/setup/running-forest-admin-on-multiple-servers
If you're running multiple instances of your agent (with a load balancer for example), you will need to set up a static client id.
**Without a static client id, authentication will fail whenever a user makes a request to a different instance than the one he logged into.**
First you will need to obtain a client id for your environment by running the following command:
```
curl -H "Content-Type: application/json" \
-H "Authorization: Bearer FOREST_ENV_SECRET" \
-X POST \
-d '{"token_endpoint_auth_method": "none", "redirect_uris": ["APPLICATION_URL/forest/authentication/callback"]}' \
https://api.forestadmin.com/oidc/reg
```
Then assign the `client_id` value from the response (it's a JWT) to a `FOREST_CLIENT_ID` variable in your **.env** file.
# Troubleshooting
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/setup/troubleshooting
#### ❓ Don't you see an answer to your problem? Describe it on our [Developer Community Forum](https://community.forestadmin.com/) and we will answer quickly.
## Error messages
### Installation
#### Docker
🙋♂️I can’t connect to Postgres DB inside another docker container. I'm trying to install Forest using docker but my database is running inside a different container and I'm using a custom port. I can access it without any problems via `psql` but then I get an error.
✅ Such an issue has been solved on our community forum. [Check it out.](https://community.forestadmin.com/t/cant-connect-to-postgres-db-inside-another-docker-container/725)
🙋🏾♂️ After installing Forest with Docker, I expect to see my visual data. Instead, I'm getting such error:
✅ Such an issue has been solved on our community forum. [Check it out](https://community.forestadmin.com/t/your-server-encountered-an-error-getaddrinfo-enotfound-postgres-postgres-5432/1798).
🙋🏻 When I want to pull data from my MongoDB database when installing Forest with Docker, I keep getting an error even when I changed to all access.
✅ Such an issue has been solved on our community forum. [Check it out](https://community.forestadmin.com/t/getting-error-mongoserverselectionerror-connection-monitor-to-54-71-237-255-27017-closed/3146).
🙋♂️ When I try to deploy lumber-admin via Docker with a remote database, I am getting an error `Error: Unprocessable Entity`
I suspect a problem on DB, but I cannot find any details or logs about this event. So, my main question is: where I can find any logs?
✅ Such an issue has been solved on our community forum. [Check it out](https://community.forestadmin.com/t/getting-error-mongoserverselectionerror-connection-monitor-to-54-71-237-255-27017-closed/3146).
🙋🏾 I was able to link my data to Forest admin (with docker, on port 5433). When I run [http://localhost:3310](http://localhost:3310) it says my app is running but when I want to log to Forest on [http://app.forestadmin.com/](http://app.forestadmin.com/) I first have to log in and it then says *Your server encountered an error*.
✅ Such an issue has been solved on our community forum. [Check it out](https://community.forestadmin.com/t/new-postgres-db-cant-reach-forest-admin-panel/1378).
#### npm
🙋🏼♀️ When installing via npm, everything worked well up to the “npm start” command when I received an error.
✅ A similar issue has been solved on our community forum. [Check it out.](https://community.forestadmin.com/t/npm-start-error/1520)
#### Nodejs app with Express and Sequelize
🙋🏼 Once I create an account, a project, and then try to install Forest with either an npm or a Docker, the setup fails with the error `SequelizeAssociationError`
✅ A similar issue has been solved on our community forum. [Check it out](https://community.forestadmin.com/t/setup-fails-with-sequelizeassociationerror/519).
#### Deployment
🙋🏽♀️ When I try to deploy Forest to Heroku, it tells me the app crashed after running either `npm start` or `docker compose` up in the project directory.
✅ Such an issue has been solved on our community forum. [Check it out](https://community.forestadmin.com/t/h10-error-when-deploying-to-heroku/547).
# Use Forest with a read-only database
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/setup/use-forest-admin-with-a-read-only-database
Although you'd be denying yourself some native features of Forest (CRUD), this may be mandatory for you because of your project's architecture or security requirements.
If you only want *some* fields to be read-only, check out [this section](https://docs.forestadmin.com/user-guide/collections/customize-your-fields#basic-settings).
To set up Forest with a read-only database, follow those steps:
### Step 1: set all your collections as read-only
A collection can be set as read-only from its settings, accessible using the Layout Editor mode:
You must **disable all permissions** there, as described in [this section](https://docs.forestadmin.com/user-guide/project-settings/teams-and-users/manage-roles#collection-permissions-1).
Repeat this for each of your collections.
### Step 2 (optional): interact with your data using Smart Actions
At this point, your Forest interface allows you only to browse your data and not interact with it.
You still have the opportunity to interact with your data according to your processes with a little coding:
# Why HTTPS is necessary even locally
Source: https://docs.forest.app/legacy/javascript-agents/how-tos/setup/why-https-is-necessary-even-locally
### Overview
When embedding Forest in your app, you'll be asked for the local application URL during the installation process. This URL must be in HTTPS, except for `localhost`.
This article explains why HTTPS is necessary and provides step-by-step guidance on how to set up a secure connection.
### Importance of HTTPS for Forest
Forest's architecture relies on secure communication between the front-end and the agent. Modern browsers enforce strict security measures to ensure data privacy and integrity. As a result, HTTPS is required when connecting to the agent.
As shown in the architecture schema, the front-end of Forest is in HTTPS. To make calls to the agent, modern browsers require the agent endpoint to be in HTTPS as well.
This ensures that data transmitted between the front-end and the agent is encrypted and secure.
### Setting Up a HTTPS Address: Step-by-Step Guide
If your app URL is in HTTP, you can use a tunneling software to access it through HTTPS. This enables Forest to establish a secure connection with your app. Follow these steps to set up a HTTPS address:
1. Choose a tunneling software: Some popular options include:
* [Ngrok](https://ngrok.com/)
* [Bastion](https://github.com/bastion-rs/bastion)
* [Localtunnel](https://localtunnel.github.io/www/)
1. Download and install the tunneling software according to its documentation.
2. Configure the tunneling software to point to your app's HTTP address. This usually involves specifying the local HTTP address and the desired HTTPS address or port number.
3. Start the tunneling software. This will create a secure connection between your app's HTTP address and the new HTTPS address.
4. Test the HTTPS address by accessing it through your browser or another tool. Ensure that the connection is secure and that your app functions correctly.
5. Provide the HTTPS address during the Forest installation process. Forest will now be able to securely connect with your app.
By following these steps and ensuring HTTPS is used for local connections, Forest maintains high security standards and offers a robust admin panel solution that protects both user data and application integrity.
# Create and manage Smart Actions
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/create-and-manage-smart-actions/overview
### What is a Smart Action?
Sooner or later, you will need to perform actions on your data that are specific to your business. Moderating comments, generating an invoice, logging into a customer’s account or banning a user are exactly the kind of important tasks to unlock in order to manage your day-to-day operations.
On our Live Demo example, our `companies` collection has many examples of Smart Action. The simplest one is `Mark as live`.
If you're looking for information on native actions (CRUD), check out [this page](/legacy/javascript-agents/reference-guide/actions/overview).
### Creating a Smart action
In order to create a Smart action, you will first need to **declare it in your code** for a specific collection. Here we declare a *Mark as Live* Smart action for the `companies` collection.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('companies', {
actions: [
{
name: 'Mark as Live',
},
],
});
```
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
collection('companies', {
actions: [
{
name: 'Mark as Live',
},
],
});
```
#### req.user
```javascript theme={null}
req.user content example
{
"id": "172",
"email": "angelicabengtsson@doha2019.com",
"firstName": "Angelica",
"lastName": "Bengtsson",
"team": "Pole Vault",
"role": "Manager",
"tags": [{ key: "country", value: "Canada" }],
"renderingId": "4998",
"iat": 1569913709,
"exp": 1571123309
}
```
#### req.body
You can find important information in the body of the request.
This is particularly useful to find the context in which an action was performed via a relationship.
```javascript theme={null}
{
data: {
attributes: {
collection_name: 'users', //collection on which the action has been triggered
values: {},
ids: [Array], //IDs of selected records
parent_collection_name: 'companies', //Parent collection name
parent_collection_id: '1', //Parent collection id
parent_association_name: 'users', //Name of the association
all_records: false,
all_records_subset_query: {},
all_records_ids_excluded: [],
smart_action_id: 'users-reset-password'
},
type: 'custom-action-requests'
}
}
```
### Customizing response
#### Default success notification
Returning a 204 status code to the HTTP request of the Smart Action shows the default notification message in the browser.
On our Live Demo example, if our Smart Action `Mark as Live` route is implemented like this:
```javascript theme={null}
...
router.post('/actions/mark-as-live', permissionMiddlewareCreator.smartAction(), (req, res) => {
// ...
res.status(204).send();
});
...
```
We will see a success message in the browser:
#### Custom success notification
If we return a 200 status code with an object `{ success: '...' }` as the payload like this…
```javascript theme={null}
...
router.post('/actions/mark-as-live', permissionMiddlewareCreator.smartAction(), (req, res) => {
// ...
res.send({ success: 'Company is now live!' });
});
...
```
```javascript theme={null}
...
router.post('/actions/mark-as-live', permissionMiddlewareCreator.smartAction(), (req, res) => {
// ...
res.send({ success: 'Company is now live!' });
});
...
```
```ruby theme={null}
class Forest::CompaniesController < ForestLiana::SmartActionsController
def mark_as_live
# ...
render json: { success: 'Company is now live!' }
end
end
```
… the success notification will look like this:
#### Custom error notification
Finally, returning a 400 status code allows you to return errors properly.
```javascript theme={null}
...
router.post('/actions/mark-as-live', permissionMiddlewareCreator.smartAction(), (req, res) => {
// ...
res.status(400).send({ error: 'The company was already live!' });
});
...
```
```javascript theme={null}
...
router.post('/actions/mark-as-live', permissionMiddlewareCreator.smartAction(), (req, res) => {
// ...
res.status(400).send({ error: 'The company was already live!' });
});
...
```
```ruby theme={null}
class Forest::CompaniesController < ForestLiana::SmartActionsController
def mark_as_live
# ...
render status: 400, json: { error: 'The company was already live!' }
end
end
```
#### Custom HTML response
You can also return a HTML page as a response to give more feedback to the admin user who has triggered your Smart Action. To do this, you just need to return a 200 status code with an object `{ html: '...' }`.
On our Live Demo example, we’ve created a `Charge credit card` Smart Action on the Collection `customers`that returns a custom HTML response.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('customers', {
actions: [
{
name: 'Charge credit card',
type: 'single',
fields: [
{
field: 'amount',
isRequired: true,
description:
'The amount (USD) to charge the credit card. Example: 42.50',
type: 'Number',
},
{
field: 'description',
isRequired: true,
description:
'Explain the reason why you want to charge manually the customer here',
type: 'String',
},
],
},
],
});
```
```javascript theme={null}
...
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
router.post('/actions/charge-credit-card', permissionMiddlewareCreator.smartAction(), (req, res) => {
let customerId = req.body.data.attributes.ids[0];
let amount = req.body.data.attributes.values.amount * 100;
let description = req.body.data.attributes.values.description;
return customers
.findByPk(customerId)
.then((customer) => {
return stripe.charges.create({
amount: amount,
currency: 'usd',
customer: customer.stripe_id,
description: description
});
})
.then((response) => {
res.send({
html: `
\$${response.amount / 100} USD has been successfully charged.
EOF
}
end
end
```
You can either respond with an HTML page in case of error. The user will be able to go back to his smart action's form by using the cross icon at the top right of the panel.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('customers', {
actions: [
{
name: 'Charge credit card',
type: 'single',
fields: [
{
field: 'amount',
isRequired: true,
description:
'The amount (USD) to charge the credit card. Example: 42.50',
type: 'Number',
},
{
field: 'description',
isRequired: true,
description:
'Explain the reason why you want to charge manually the customer here',
type: 'String',
},
],
},
],
});
```
```javascript theme={null}
...
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
router.post('/actions/charge-credit-card', permissionMiddlewareCreator.smartAction(), (req, res) => {
let customerId = req.body.data.attributes.ids[0];
let amount = req.body.data.attributes.values.amount * 100;
let description = req.body.data.attributes.values.description;
return customers
.findByPk(customerId)
.then((customer) => {
return stripe.charges.create({
amount: amount,
currency: 'usd',
customer: customer.stripe_id,
description: description
});
})
.then((response) => {
res.status(400).send({
html: `
$${response.amount / 100} USD has not been charged.
Credit card
**** **** **** ${response.source.last4}
Reason
You can not charge this credit card. The card is marked as blocked
\$${response.amount / 100} USD has not been charged.
Credit card
**** **** **** ${record.source.last4}
Reason
You can not charge this credit card. The card is marked as blocked
`
});
});
});
...
module.exports = router;
```
```ruby theme={null}
class Forest::Customer
include ForestLiana::Collection
collection :Customer
action 'Charge credit card', type: 'single', fields: [{
field: 'amount',
is_required: true,
description: 'The amount (USD) to charge the credit card. Example: 42.50',
type: 'Number'
}, {
field: 'description',
is_required: true,
description: 'Explain the reason why you want to charge manually the customer here',
type: 'String'
}]
end
```
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
post '/actions/charge-credit-card' => 'customers#charge_credit_card'
end
mount ForestLiana::Engine => '/forest'
end
```
```ruby title="/app/controllers/forest/customers_controller.rb" theme={null}
class Forest::CustomersController < ForestLiana::SmartActionsController
def charge_credit_card
customer_id = ForestLiana::ResourcesGetter.get_ids_from_request(params, forest_user).first
amount = params.dig('data', 'attributes', 'values', 'amount').to_i
description = params.dig('data', 'attributes', 'values', 'description')
customer = Customer.find(customer_id)
response = Stripe::Charge.create(
amount: amount * 100,
currency: 'usd',
customer: customer.stripe_id,
description: description
)
render status: 400, json: {
html: <<EOF
<p class="c-clr-1-4 l-mt l-mb">\$#{record.amount / 100} USD has not been charged.</p>
<strong class="c-form__label--read c-clr-1-2">Credit card</strong>
<p class="c-clr-1-4 l-mb">**** **** **** #{record.source.last4}</p>
<strong class="c-form__label--read c-clr-1-2">Reason</strong>
<p class="c-clr-1-4 l-mb">You can not charge this credit card. The card is marked as blocked</p>
EOF
}
end
end
```
### Setting up a webhook
After a smart action you can set up a HTTP (or HTTPS) callback - a webhook - to forward information to other applications.\
\
To set up a webhook all you have to do is to add a `webhook`object in the response of your action.
```javascript theme={null}
response.send({
webhook: {
// This is the object that will be used to fire http calls.
url: 'http://my-company-name', // The url of the company providing the service.
method: 'POST', // The method you would like to use (typically a POST).
headers: {}, // You can add some headers if needed (you can remove it).
body: {
// A body to send to the url (only JSON supported).
adminToken: 'your-admin-token',
},
},
});
```
```javascript theme={null}
response.send({
webhook: {
// This is the object that will be used to fire http calls.
url: 'http://my-company-name', // The url of the company providing the service.
method: 'POST', // The method you would like to use (typically a POST).
headers: {}, // You can add some headers if needed (you can remove it).
body: {
// A body to send to the url (only JSON supported).
adminToken: 'your-admin-token',
},
},
});
```
```ruby theme={null}
render json: {
webhook: { # This is the object that will be used to fire http calls.
url: 'http://my-company-name', # The url of the company providing the service.
method: 'POST', # The method you would like to use (typically a POST).
headers: {}, # You can add some headers if needed (you can remove it).
body: { # A body to send to the url (only JSON supported).
adminToken: 'your-admin-token',
}
}
}
```
```python theme={null}
from django_forest.utils.collection import Collection
from app.models import Customer
class CustomerForest(Collection):
def load(self):
self.actions = [{
'name': 'Generate invoice',
'download': True
}]
Collection.register(CustomerForest, Customer)
```
On our Live Demo, the collection `Customer` has a Smart Action `Generate invoice`. In this use case, we want to download the generated PDF invoice after clicking on the action. To indicate a Smart Action returns something to download, you have to enable the option `download`.
Want to upload your files to Amazon S3? Check out this this [Woodshop tutorial](https://docs.forestadmin.com/woodshop/how-tos/upload-files-to-s3).
### Refreshing your related data
If you want to create an action accessible from the details or the summary view of a record involving related data, this section may interest you.
In the example below, the “Add new transaction” action is accessible from the summary view. This action creates a new transaction and automatically refreshes the “Emitted transactions” related data section to see the new transaction.
Below is the sample code. We use faker to generate random data in our example. Remember to install it if you wish to use it (`npm install faker`).
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('companies', {
actions: [
{
name: 'Add new transaction',
description: 'Name of the company who will receive the transaction.',
fields: [
{
field: 'Beneficiary company',
description: 'Name of the company who will receive the transaction.',
reference: 'companies.id',
},
{
field: 'Amount',
type: 'Number',
},
],
},
],
});
```
```javascript theme={null}
...
const faker = require('faker');
router.post('/actions/add-new-transaction', permissionMiddlewareCreator.smartAction(),
(req, res) => {
let emitterCompanyId = req.body.data.attributes.ids[0]
let beneficiaryCompanyId = req.body.data.attributes.values['Beneficiary company']
let amount = req.body.data.attributes.values['Amount']
return transactions
.create({
emitter_company_id: emitterCompanyId,
beneficiary_company_id: beneficiaryCompanyId,
beneficiary_iban: faker.finance.iban(),
emitter_iban: faker.finance.iban(),
vat_amount: faker.finance.amount(500, 10000, 0),
fee_amount: faker.finance.amount(500, 10000, 0),
status: ['to_validate', 'validated', 'rejected'].sample,
note: faker.lorem.sentences(),
amount: amount,
emitter_bic: faker.finance.bic(),
beneficiary_bic: faker.finance.bic()
})
.then(() => {
// the code below automatically refresh the related data
// 'emitted_transactions' on the Companies' Summary View
// after submitting the Smart action form.
res.send({
success: 'New transaction emitted',
refresh: { relationships: ['emitted_transactions'] },
});
});
});
```
Below is the sample code. We use faker to generate random data in our example. Remember to install it if you wish to use it (`npm install faker`).
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
collection('Company', {
actions: [
{
name: 'Add new transaction',
description: 'Name of the company who will receive the transaction.',
fields: [
{
field: 'Beneficiary company',
description: 'Name of the company who will receive the transaction.',
reference: 'Company',
},
{
field: 'Amount',
type: 'Number',
},
],
},
],
});
```
```javascript theme={null}
const express = require('express');
const router = express.Router();
const Liana = require('forest-express-mongoose');
const Transaction = require('../models/transactions');
const faker = require('faker');
// ...
router.post(
'/actions/add-new-transaction',
Liana.ensureAuthenticated,
(req, res) => {
let emitterCompanyId = req.body.data.attributes.ids[0];
let beneficiaryCompanyId =
req.body.data.attributes.values['Beneficiary company'];
let amount = req.body.data.attributes.values['Amount'];
return Transaction.create({
emitter_company_id: emitterCompanyId,
beneficiary_company_id: beneficiaryCompanyId,
beneficiary_iban: faker.finance.iban(),
emitter_iban: faker.finance.iban(),
vat_amount: faker.finance.amount(500, 10000, 0),
fee_amount: faker.finance.amount(500, 10000, 0),
status: ['to_validate', 'validated', 'rejected'].sample,
note: faker.lorem.sentences(),
amount: amount,
emitter_bic: faker.finance.bic(),
beneficiary_bic: faker.finance.bic(),
}).then(() => {
// the code below automatically refresh the related data
// 'emitted_transactions' on the Companies' Summary View
// after submitting the Smart action form.
res.send({
success: 'New transaction emitted',
refresh: { relationships: ['emitted_transactions'] },
});
});
}
);
```
Below is the sample code. We use the `gem 'faker'` to easily generate fake data. Remember to add this gem to your `Gemfile` and install it (`bundle install`) if you wish to use it.
```ruby theme={null}
class Forest::Company
include ForestLiana::Collection
collection :Company
# ...
action 'Add new transaction', fields: [{
field: 'Beneficiary company',
description: 'Name of the company who will receive the transaction.',
reference: 'Company.id'
}, {
field: 'Amount',
type: 'Number'
}]
# ...
end
```
```ruby theme={null}
class Forest::CompaniesController < ForestLiana::SmartActionsController
# ...
def add_new_transaction
attrs = params.dig('data','attributes', 'values')
beneficiary_company_id = attrs['Beneficiary company']
emitter_company_id = ForestLiana::ResourcesGetter.get_ids_from_request(params, forest_user).first
amount = attrs['Amount']
Transaction.create!(
emitter_company_id: emitter_company_id,
beneficiary_company_id: beneficiary_company_id,
beneficiary_iban: Faker::Code.imei,
emitter_iban: Faker::Code.imei,
vat_amount: Faker::Number.number(4),
fee_amount: Faker::Number.number(4),
status: ['to_validate', 'validated', 'rejected'].sample,
note: Faker::Lorem.paragraph,
amount: amount,
emitter_bic: Faker::Code.nric,
beneficiary_bic: Faker::Code.nric
)
# the code below automatically refresh the related data
# 'emitted_transactions' on the Companies' Summary View
# after submitting the Smart action form.
render json: {
success: 'New transaction emitted',
refresh: { relationships: ['emitted_transactions'] },
}
end
end
```
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
# ...
post '/actions/add-new-transaction' => 'companies#add_new_transaction'
# ...
end
mount ForestLiana::Engine => '/forest'
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end
```
Below is the sample code. We use the python Faker package to easily generate fake data. Remember to add this package to your `requirements.txt` and install it if you wish to use it.
Below is the sample code. We use the Faker package to easily generate fake data. Remember to add this package to your `composer.json` and install it if you wish to use it.
### Redirecting to a different page on success
To streamline your operation workflow, it could make sense to redirect to another page after a Smart action was successfully executed.\
\
It is possible using the `redirectTo` property.\
\
The redirection works both for **internal** (`*.forestadmin.com` pages) and **external** links.
**External** links will open in a new tab.
Here's a working example for both cases:
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('models', {
actions: [
{
name: 'Return and track',
},
{
name: 'Show some activity',
},
],
});
```
```javascript theme={null}
...
// External redirection
router.post('/actions/return-and-track', permissionMiddlewareCreator.smartAction(),
(req, res) => {
res.send({
success: 'Return initiated successfully.',
redirectTo: 'https://www.royalmail.com/portal/rm/track?trackNumber=ZW924750388GB',
});
}
);
// Internal redirection
router.post('/actions/show-some-activity', permissionMiddlewareCreator.smartAction(),
(req, res) => {
res.send({
success: 'Navigated to the activity view.',
redirectTo: '/MyProject/MyEnvironment/MyTeam/data/20/index/record/20/108/activity',
});
}
);
...
module.exports = router;
```
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
collection('models', {
actions: [
{
name: 'Initiate return and display tracking',
},
{
name: 'Show some activity',
},
],
});
```
```javascript theme={null}
...
// External redirection
router.post('/actions/return-and-track', Liana.ensureAuthenticated,
(req, res) => {
res.send({
success: 'Return initiated successfully.',
redirectTo: 'https://www.royalmail.com/portal/rm/track?trackNumber=ZW924750388GB',
});
}
);
// Internal redirection
router.post('/actions/show-some-activity', Liana.ensureAuthenticated,
(req, res) => {
res.send({
success: 'Navigated to the activity view.',
redirectTo: '/1/data/20/index/record/20/108/activity/preview',
});
}
);
...
module.exports = router;
```
```ruby theme={null}
class Forest::Company
include ForestLiana::Collection
collection :Company
action 'Return and track'
action 'Show some activity'
end
```
```ruby theme={null}
...
namespace :forest do
post '/actions/return-and-track' => 'company#redirect_externally'
post '/actions/show-some-activity' => 'company#redirect_internally'
end
...
```
```ruby theme={null}
...
def redirect_externally
# External redirection
render json: {
success: 'Return initiated successfully.',
redirectTo: 'https://www.royalmail.com/portal/rm/track?trackNumber=ZW924750388GB',
}
end
def redirect_internally
# Internal redirection
render json: {
success: 'Return initiated successfully.',
redirectTo: '/MyProject/MyEnvironment/MyTeam/data/20/index/record/20/108/activity',
}
end
...
```
Your **external** links must use the `http` or `https` protocol.
### Enable/Disable a Smart Action according to the state of a record
Sometimes, your Smart Action only makes sense depending on the state of your records. On our Live Demo, it does not make any sense to enable the `Mark as Live` Smart Action on the `companies` collection if the company is already live, right? This is configured from the collection's Smart Action settings.
### Restrict a smart action to specific roles
When using Forest collaboratively with clear roles defined it becomes relevant to restrict a smart action only to a select few. This functionality is accessible through Smart Actions Permissions in the Role section of your Project Settings.
### Require approval for a Smart action
Critical actions for your business may need approval before being processed. You can require approval per role from the *Roles* tab of your Project Settings; approval requests are then reviewed from the Collaboration menu.
Want to go further with Smart Actions? Read the next page to discover how to make your Smart Actions even more powerful with **Forms**!
# Use a Smart Action Form
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/create-and-manage-smart-actions/use-a-smart-action-form
We've just introduced Smart actions: they're great because you can execute virtually any business logic. However, there is one big part missing: how do you let your users provide more information or have interaction when they trigger the Smart action? In short, you need to open a **Smart Action Form**.
## Opening a **Smart Action Form**
Very often, you will need to ask user inputs before triggering the logic behind a Smart Action.\
For example, you might want to specify a reason if you want to block a user account. Or set the amount to charge a user’s credit card.
On our Live Demo example, we’ve defined 4 input fields on the Smart Action `Upload Legal Docs` on the collection `companies`.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('companies', {
actions: [
{
name: 'Upload Legal Docs',
type: 'single',
fields: [
{
field: 'Certificate of Incorporation',
description:
'The legal document relating to the formation of a company or corporation.',
type: 'File',
isRequired: true,
},
{
field: 'Proof of address',
description:
'(Electricity, Gas, Water, Internet, Landline & Mobile Phone Invoice / Payment Schedule) no older than 3 months of the legal representative of your company',
type: 'File',
isRequired: true,
},
{
field: 'Company bank statement',
description: 'PDF including company name as well as IBAN',
type: 'File',
isRequired: true,
},
{
field: 'Valid proof of ID',
description:
'ID card or passport if the document has been issued in the EU, EFTA, or EEA / ID card or passport + resident permit or driving license if the document has been issued outside the EU, EFTA, or EEA of the legal representative of your company',
type: 'File',
isRequired: true,
},
],
},
],
});
```
```javascript theme={null}
const { PermissionMiddlewareCreator } = require('forest-express-sequelize');
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('companies');
...
router.post('/actions/upload-legal-docs', permissionMiddlewareCreator.smartAction(),
(req, res) => {
// Get the current company id
let companyId = req.body.data.attributes.ids[0];
// Get the values of the input fields entered by the admin user.
let attrs = req.body.data.attributes.values;
let certificate_of_incorporation = attrs['Certificate of Incorporation'];
let proof_of_address = attrs['Proof of address'];
let company_bank_statement = attrs['Company bank statement'];
let passport_id = attrs['Valid proof of id'];
// The business logic of the Smart Action. We use the function
// UploadLegalDoc to upload them to our S3 repository. You can see the full
// implementation on our Forest Live Demo repository on Github.
return P.all([
uploadLegalDoc(companyId, certificate_of_incorporation, 'certificate_of_incorporation_id'),
uploadLegalDoc(companyId, proof_of_address, 'proof_of_address_id'),
uploadLegalDoc(companyId, company_bank_statement,'bank_statement_id'),
uploadLegalDoc(companyId, passport_id, 'passport_id'),
])
.then(() => {
// Once the upload is finished, send a success message to the admin user in the UI.
res.send({ success: 'Legal documents are successfully uploaded.' });
});
});
...
module.exports = router;
```
On our Live Demo example, we’ve defined 4 input fields on the Smart Action `Upload Legal Docs` on the collection `companies`.
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
collection('companies', {
actions: [{
name: 'Upload Legal Docs',
type: 'single',
fields: [{
field: 'Certificate of Incorporation',
description: 'The legal document relating to the formation of a company or corporation.',
type: 'File',
isRequired: true
}, {
field: 'Proof of address',
description: '(Electricity, Gas, Water, Internet, Landline & Mobile Phone Invoice / Payment Schedule) no older than 3 months of the legal representative of your company',
type: 'File',
isRequired: true
}, {
field: 'Company bank statement',
description: 'PDF including company name as well as IBAN',
type: 'File',
isRequired: true
}, {
field: 'Valid proof of ID',
description: 'ID card or passport if the document has been issued in the EU, EFTA, or EEA / ID card or passport + resident permit or driving license if the document has been issued outside the EU, EFTA, or EEA of the legal representative of your company',
type: 'File',
isRequired: true
}],
});
```
```javascript theme={null}
...
router.post('/actions/upload-legal-docs',
(req, res) => {
// Get the current company id
let companyId = req.body.data.attributes.ids[0];
// Get the values of the input fields entered by the admin user.
let attrs = req.body.data.attributes.values;
let certificate_of_incorporation = attrs['Certificate of Incorporation'];
let proof_of_address = attrs['Proof of address'];
let company_bank_statement = attrs['Company bank statement'];
let passport_id = attrs['Valid proof of id'];
// The business logic of the Smart Action. We use the function
// UploadLegalDoc to upload them to our S3 repository. You can see the full
// implementation on our Forest Live Demo repository on Github.
return P.all([
uploadLegalDoc(companyId, certificate_of_incorporation, 'certificate_of_incorporation_id'),
uploadLegalDoc(companyId, proof_of_address, 'proof_of_address_id'),
uploadLegalDoc(companyId, company_bank_statement,'bank_statement_id'),
uploadLegalDoc(companyId, passport_id, 'passport_id'),
])
.then(() => {
// Once the upload is finished, send a success message to the admin user in the UI.
res.send({ success: 'Legal documents are successfully uploaded.' });
});
});
...
module.exports = router;
```
On our Live Demo example, we’ve defined 4 input fields on the Smart Action `Upload Legal Docs` on the collection `Company`.
```ruby theme={null}
class Forest::Company
include ForestLiana::Collection
collection :Company
action 'Upload Legal Docs', type: 'single', fields: [{
field: 'Certificate of Incorporation',
description: 'The legal document relating to the formation of a company or corporation.',
type: 'File',
is_required: true
}, {
field: 'Proof of address',
description: '(Electricity, Gas, Water, Internet, Landline & Mobile Phone Invoice / Payment Schedule) no older than 3 months of the legal representative of your company',
type: 'File',
is_required: true
}, {
field: 'Company bank statement',
description: 'PDF including company name as well as IBAN',
type: 'File',
is_required: true
}, {
field: 'Valid proof of ID',
description: 'ID card or passport if the document has been issued in the EU, EFTA, or EEA / ID card or passport + resident permit or driving license if the document has been issued outside the EU, EFTA, or EEA of the legal representative of your company',
type: 'File',
is_required: true
}]
end
```
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
post '/actions/upload-legal-docs' => 'companies#upload_legal_docs'
end
mount ForestLiana::Engine => '/forest'
end
```
```ruby theme={null}
class Forest::CompaniesController < ForestLiana::SmartActionsController
def upload_legal_doc(company_id, doc, field)
id = SecureRandom.uuid
Forest::S3Helper.new.upload(doc, "livedemo/legal/#{id}")
company = Company.find(company_id)
company[field] = id
company.save
Document.create({
file_id: company[field],
is_verified: true
})
end
def upload_legal_docs
# Get the current company id
company_id = ForestLiana::ResourcesGetter.get_ids_from_request(params, forest_user).first
# Get the values of the input fields entered by the admin user.
attrs = params.dig('data', 'attributes', 'values')
certificate_of_incorporation = attrs['Certificate of Incorporation'];
proof_of_address = attrs['Proof of address'];
company_bank_statement = attrs['Company bank statement'];
passport_id = attrs['Valid proof of ID'];
# The business logic of the Smart Action. We use the function
# upload_legal_doc to upload them to our S3 repository. You can see the
# full implementation on our Forest Live Demo repository on Github.
upload_legal_doc(company_id, certificate_of_incorporation, 'certificate_of_incorporation_id')
upload_legal_doc(company_id, proof_of_address, 'proof_of_address_id')
upload_legal_doc(company_id, company_bank_statement, 'bank_statement_id')
upload_legal_doc(company_id, passport_id, 'passport_id')
# Once the upload is finished, send a success message to the admin user in the UI.
render json: { success: 'Legal documents are successfully uploaded.' }
end
end
```
On our Live Demo example, we’ve defined 4 input fields on the Smart Action `Upload Legal Docs` on the collection `Company`.
Ensure the file app/forest/\_\_init\_\_.py exists and contains the import of the previous defined class :
On our Live Demo example, we’ve defined 4 input fields on the Smart Action `Upload Legal Docs` on the collection `Company`.
The 2nd parameter of the `SmartAction` method is not required. If you don't fill it, the name of your smartAction will be the name of your method that wrap it.
### Handling input values
Here is the list of available options to customize your input form.
| Name | Type | Description |
| ----------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| field | string | Label of the input field. |
| type | string or array |
|
| reference | string | (optional) Specify that the input is a reference to another collection. You must specify the primary key (ex: `category.id`). |
| enums | array of strings | (optional) Required only for the `Enum` type. This is where you list all the possible values for your input field. |
| description | string | (optional) Add a description for your admin users to help them fill correctly your form |
| isRequired | boolean | (optional) If `true`, your input field will be set as required in the browser. Default is `false`. |
| hook | string | (optional) Specify the change hook. If specified the corresponding hook is called when the input change |
| widget | string | (optional) The following widgets are available to your smart action fields (`text area`, `date`, `boolean`, `file,` `dateonly`) |
The `widget` property is only partially supported.
If you want to use a custom widget via a Smart Action Hook, you'll need to use the syntax mentioned in the next section.
## Use components to better layout your form
This feature is only available from **version 9.4.0** (`forest-express-sequelize` and `forest-express-mongoose`) / **version 9.4.0** (`forest-rails`) .
you must define your layout in a `load` hook at minima, and repeat it in each `change` hook.
This feature is useful when dealing with long/complex forms, with many fields. It will let you organize them and add useful information to guide the end user.
The layout must contain the fields as they should be rendered on the form.
### List of supported layout components
### Node.js
```javascript theme={null}
// Page
{
type: 'Layout',
component: 'Page',
elements: [] // An array of fields or other layout elements (except other pages)
},
// Row
{
type: 'Layout',
component: 'Row',
fields: [] // An array of one or two fields
}
// Separator
{
type: 'Layout',
component: 'Separator',
}
// Html bloc
{
type: 'Layout',
component: 'HtmlBlock',
content: '...' // A text content, which supports html tags
}
```
### Example
Here's an example of an action form with many fields, that we want to improve with some layout components, to make it easier for the end user to fill in.
### Node.js
```javascript theme={null}
const applyLayout = (fields) => {
const fieldByName = (name) => fields.find((field) => field.field === name);
return [
{
type: 'Layout',
component: 'Page',
elements: [
{
type: 'Layout',
component: 'HtmlBlock',
content: '
Please fill in the customer details first, following this guide
'
},
{
type: 'Layout',
component: 'Row',
fields: [fieldByName('firstname'), fieldByName('lastname')]
},
{ type: 'Layout', component: 'Separator' },
fieldByName('username'),
fieldByName('email'),
]
},
{
type: 'Layout',
component: 'Page',
elements: [
{
type: 'Layout',
component: 'HtmlBlock',
content: 'You may now enter his address details'
},
{
type: 'Layout',
component: 'Row',
fields: [fieldByName('city'), fieldByName('zip code')]
},
fieldByName('country'),
]
}
]
}
collection('customers', {
actions: [
{
name: 'Send invoice',
type: 'single',
fields: [
{
field: 'firstname',
type: 'String',
isRequired: true,
},
{
field: 'lastname',
type: 'String',
isRequired: true,
},
{
field: 'username',
type: 'String',
},
{
field: 'email',
type: 'String',
isRequired: true,
},
{
field: 'country',
type: 'Enum',
enums: [],
},
{
field: 'city',
type: 'String',
hook: 'onCityChange',
},
{
field: 'zip code',
type: 'String',
hook: 'onZipCodeChange',
},
],
hooks: {
load: async ({ fields }) => {
return applyLayout(fields);
},
change: {
onCityChange: async ({ fields }) => {
return applyLayout(fields);
},
onZipCodeChange: async ({ fields }) => {
return applyLayout(fields);
},
},
},
},
],
fields: [],
segments: [],
});
```
### Prefill a form with default values
Forest allows you to set default values of your form. In this example, we will prefill the form with data coming from the record itself **(1)**, with just a few extra lines of code.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
const { customers } = require('../models');
collection('Customers', {
actions: [{
name: 'Charge credit card',
type: 'single',
fields: [{
field: 'amount',
isRequired: true,
description: 'The amount (USD) to charge the credit card. Example: 42.50',
type: 'Number'
}, {
field: 'description',
isRequired: true,
description: 'Explain the reason why you want to charge manually the customer here',
type: 'String'
}, {
// we added a field to show the full potential of prefilled values in this example
field: 'stripe_id',
isRequired: true,
type: 'String'
}],
hooks: {
load: async ({ fields, request }) => {
const amount = fields.find(field => field.field === 'amount');
const stripeId = fields.find(field => field.field === 'stripe_id');
amount.value = 4520;
const id = request.body.data.attributes.ids[0];
const customer = await customers.findByPk(id);
stripeId.value = customer.stripe_id;
return fields;
},
},
}],
...
});
```
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
const { customers } = require('../models');
collection('Customers', {
actions: [{
name: 'Charge credit card',
type: 'single',
fields: [{
field: 'amount',
isRequired: true,
description: 'The amount (USD) to charge the credit card. Example: 42.50',
type: 'Number'
}, {
field: 'description',
isRequired: true,
description: 'Explain the reason why you want to charge manually the customer here',
type: 'String'
}, {
// we added a field to show the full potential of prefilled values in this example
field: 'stripe_id',
isRequired: true,
type: 'String'
}],
hooks: {
load: async ({ fields, request }) => {
const amount = fields.find(field => field.field === 'amount');
const stripeId = fields.find(field => field.field === 'stripe_id');
amount.value = 4520;
const id = request.body.data.attributes.ids[0];
const customer = await customers.findByPk(id);
stripeId.value = customer.stripe_id;
return fields;
},
},
}],
...
});
```
```ruby theme={null}
class Forest::Customers
include ForestLiana::Collection
collection :Customers
action 'Charge credit card',
type: 'single',
fields: [{
field: 'amount',
isRequired: true,
description: 'The amount (USD) to charge the credit card. Example: 42.50',
type: 'Number'
}, {
field: 'description',
isRequired: true,
description: 'Explain the reason why you want to charge manually the customer here',
type: 'String'
}, {
# we added a field to show the full potential of prefilled values in this example
field: 'stripe_id',
isRequired: true,
type: 'String'
}],
:hooks => {
:load => -> (context) {
amount = context[:fields].find{|field| field[:field] == 'amount'}
stripeId = context[:fields].find{|field| field[:field] == 'stripe_id'}
amount[:value] = 4520;
id = context[:params][:data][:attributes][:ids][0];
customer = Customers.find(id);
stripeId[:value] = customer['stripe_id'];
return context[:fields];
}
}
...
end
```
### Making a field read-only
To make a field read only, you can use the `isReadOnly` property:
| Name | Type | Description |
| ------------ | ------- | ---------------------------------------------------------------------------------------------- |
| `isReadOnly` | boolean | (optional) If `true`, the Smart action field won’t be editable in the form. Default is `false` |
Combined with the **load** [hook](/legacy/javascript-agents/reference-guide/actions/create-and-manage-smart-actions/overview#making-a-form-dynamic) feature, this can be used to make a field read-only dynamically:
```javascript theme={null}
const { customers } = require('../models');
collection('customers', {
actions: [
{
name: 'Some action',
type: 'single',
fields: [
{
field: 'country',
type: 'String',
isReadOnly: true,
},
{
field: 'city',
type: 'String',
},
],
hooks: {
load: async ({ fields, request }) => {
const country = fields.find((field) => field.field === 'country');
country.value = 'France';
const id = request.body.data.attributes.ids[0];
const customer = await customers.findById(id);
// If customer country is not France, empty field and make it editable
if (customer.country !== 'France') {
country.value = '';
country.isReadOnly = false;
}
return fields;
},
},
},
],
fields: [],
segments: [],
});
```
### Change your form's data based on previous field values
This feature is only available from **version 8.0.0** (`forest-express-sequelize` and `forest-express-mongoose`) / **version 7.0.0** (`forest-rails`) .
Here's a typical example: Selecting a **City** within a list of cities from the **Country** you just selected. Then selecting a **Zip code** within a list of zip codes located in the **City** you just selected.
```javascript theme={null}
const { getEnumsFromDatabaseForThisRecord } = require('./my-own-helper');
const { getZipCodeFromCity } = require('...');
const { collection } = require('forest-express-sequelize');
const { customers } = require('../models');
collection('customers', {
actions: [
{
name: 'Send invoice',
type: 'single',
fields: [
{
field: 'country',
type: 'Enum',
enums: [],
},
{
field: 'city',
type: 'String',
hook: 'onCityChange',
},
{
field: 'zip code',
type: 'String',
hook: 'onZipCodeChange',
},
],
hooks: {
load: async ({ fields, request }) => {
const country = fields.find((field) => field.field === 'country');
const id = request.body.data.attributes.ids[0];
const customer = await customers.findByPk(id);
country.enums = getEnumsFromDatabaseForThisRecord(customer);
return fields;
},
change: {
onCityChange: async ({ fields, request, changedField }) => {
const zipCode = fields.find((field) => field.field === 'zip code');
const id = request.body.data.attributes.ids[0];
const customer = await customers.findByPk(id);
zipCode.value = getZipCodeFromCity(customer, changedField.value);
return fields;
},
onZipCodeChange: async ({ fields, request, changedField }) => {
const city = fields.find((field) => field.field === 'city');
const id = request.body.data.attributes.ids[0];
const customer = await customers.findByPk(id);
city.value = getCityFromZipCode(customer, changedField.value);
return fields;
},
},
},
},
],
fields: [],
segments: [],
});
```
```javascript theme={null}
const { getEnumsFromDatabaseForThisRecord } = require('./my-own-helper');
const { getZipCodeFromCity } = require('...');
const { collection } = require('forest-express-mongoose');
const { customers } = require('../models');
collection('customers', {
actions: [
{
name: 'Send invoice',
type: 'single',
fields: [
{
field: 'country',
type: 'Enum',
enums: [],
},
{
field: 'city',
type: 'String',
hook: 'onCityChange',
},
{
field: 'zip code',
type: 'String',
hook: 'onZipCodeChange',
},
],
hooks: {
load: async ({ fields, request }) => {
const country = fields.find((field) => field.field === 'country');
const id = request.body.data.attributes.ids[0];
const customer = await customers.findById(id);
country.enums = getEnumsFromDatabaseForThisRecord(customer);
return fields;
},
change: {
onCityChange: async ({ fields, request, changedField }) => {
const zipCode = fields.find((field) => field.field === 'zip code');
const id = request.body.data.attributes.ids[0];
const customer = await customers.findById(id);
zipCode.value = getZipCodeFromCity(customer, changedField.value);
return fields;
},
onZipCodeChange: async ({ fields, request, changedField }) => {
const city = fields.find((field) => field.field === 'city');
const id = request.body.data.attributes.ids[0];
const customer = await customers.findById(id);
city.value = getCityFromZipCode(customer, changedField.value);
return fields;
},
},
},
},
],
fields: [],
segments: [],
});
```
```javascript theme={null}
actions 'Send invoice',
type: 'single',
fields: [
{
field: 'country',
type: 'Enum',
enums: []
},
{
field: 'city',
type: 'String',
hook: 'oncityChange'
},
{
field: 'zip code',
type: 'String',
hook: 'onZipCodeChange'
},
],
hooks: {
:load => -> (context){
country = context[:fields].find{|field| field[:field] == 'country'}
id = context[:params][:data][:attributes][:ids][0];
customer = Customers.find(id);
country[:enums] = getEnumsFromDatabaseForThisRecord(customer)
return context[:fields]
},
:change => {
'oncityChange'=> -> (context){
zipCode = context[:fields].find{|field| field[:field] == 'zip code'}
id = context[:params][:data][:attributes][:ids][0];
customer = Customers.find(id);
zipCode[:value] = getZipCodeFromCity(
context[:record],
context[:context][:changed_field][:value]
)
return context[:fields]
},
'onZipCodeChange'=> -> (context) {
city = context[:fields].find{|field| field[:field] == 'city'}
id = context[:params][:data][:attributes][:ids][0];
customer = Customers.find(id);
city[:value] = getCityFromZipCode(
context[:record],
context[:context][:changed_field][:value]
)
return context[:fields]
},
},
}
```
#### How does it work?
The `hooks` property receives a *context* object containing:
* the `fields` array in its current state (containing also the current values)
* the `request` object containing all the information related to the records selection. Explained [here](/legacy/javascript-agents/reference-guide/actions/create-and-manage-smart-actions/overview#available-smart-action-properties).
* the `changedField` is the current field who trigger the hook (only for change hook)
`fields` **must** be returned. Note that `fields` is an array containing existing fields with properties described in [this section](/legacy/javascript-agents/reference-guide/actions/create-and-manage-smart-actions/overview#handling-input-values).
If you want to use a widget inside of a hook, you'll need to use the following syntax on your field:
* For a `text area`, use `{ widgetEdit: 'text area editor', parameters: {} }`
* For a `boolean`, use `{ widgetEdit: 'boolean editor', parameters: {} }`
* For a `date` or a `dateonly`, use `{ widgetEdit: 'date editor', parameters: {} }`
* For a `file`, use `{ widgetEdit: 'file picker', parameters: {} }`
To dynamically change a property within a `load` or `change` [hook](/legacy/javascript-agents/reference-guide/actions/create-and-manage-smart-actions/use-a-smart-action-form#making-a-form-dynamic-with-hooks), just set it! For instance, setting a new *description* for the field `city`:
```javascript theme={null}
const city = fields.find((field) => field.field === 'city');
city.description = 'Please enter the name of your favorite city';
```
```javascript theme={null}
const city = fields.find((field) => field.field === 'city');
city.description = 'Please enter the name of your favorite city';
```
```javascript theme={null}
city = context[:fields].find{|field| field[:field] == 'city'}
city[:description] = "Please enter the name of your favorite city"
```
```python theme={null}
'hooks': {
'load': self.send_invoice_load,
}
...
def send_invoice_load(fields, request, *args, **kwargs):
country = next((x for x in fields if x['field'] == 'country'), None)
country['value'] = 'France'
return fields
```
### Add/remove fields dynamically
This feature is only available from [**version 8.0.0**](/legacy/javascript-agents/how-tos/maintain/upgrade-notes-sql-mongodb/upgrade-to-v8) (`forest-express-sequelize` and `forest-express-mongoose`) / [**version 7.0.0**](/legacy/javascript-agents/how-tos/maintain/upgrade-notes-sql-mongodb/upgrade-to-v7) (`forest-rails`).
You can add a `field` dynamically inside the `fields` array, like so:
```javascript theme={null}
[...]
hooks: {
change: {
onFieldChanged: ({ fields, request, changedField }) => {
[...]
fields.push({
field: 'another field',
type: 'Boolean',
});
return fields;
}
}
}
[...]
```
```javascript theme={null}
[...]
hooks: {
change: {
onFieldChanged: ({ fields, request, changedField }) => {
[...]
fields.push({
field: 'another field',
type: 'Boolean',
});
return fields;
}
}
}
[...]
```
```ruby theme={null}
:hooks => {
:change => {
'onFieldChanged' => -> (context) {
[...]
context[:fields].push({
field: 'another field',
type: 'Boolean',
});
return context[:fields];
}
}
}
```
```python theme={null}
'hooks': {
'change': {
'onFieldChanged': self.on_field_change,
'onAnotherFieldChanged': self.on_another_field_change,
}
}
...
def on_field_change(self, fields, request, changed_field, *args, **kwargs):
fields.append({
'field': 'another field',
'type': 'Boolean',
'hook': 'onAnotherFieldChanged',
})
return fields
def on_another_field_change(self, fields, request, changed_field, *args, **kwargs):
// Do what you want
return fields
```
### Get selected records with bulk action
When using hooks with a bulk Smart action, you'll probably need te get the values or ids of the selected records. See below how this can be achieved.
```javascript theme={null}
const { collection, RecordsGetter } = require('forest-express-sequelize');
const { customers } = require('../models');
const customersHaveSameCountry = require('../services/customers-have-same-country');
collection('customers', {
actions: [
{
name: 'Some action',
type: 'bulk',
fields: [
{
field: 'country',
type: 'String',
isReadOnly: true,
},
{
field: 'city',
type: 'String',
},
],
hooks: {
load: async ({ fields, request }) => {
const country = fields.find((field) => field.field === 'country');
const ids = await new RecordsGetter(
customers,
request.user,
request.query
).getIdsFromRequest(request);
const customers = await customers.findAll({ where: { id } });
country.value = '';
country.isReadOnly = false;
// If customers have the same country, set field to this country and make it not editable
if (customersHaveSameCountry(customers)) {
country.value = customers.country;
country.isReadOnly = true;
}
return fields;
},
},
},
],
fields: [],
segments: [],
});
```
```javascript theme={null}
const { collection, RecordsGetter } = require('forest-express-mongoose');
const { customers } = require('../models');
const customersHaveSameCountry = require('../services/customers-have-same-country');
collection('customers', {
actions: [
{
name: 'Some action',
type: 'bulk',
fields: [
{
field: 'country',
type: 'String',
isReadOnly: true,
},
{
field: 'city',
type: 'String',
},
],
hooks: {
load: async ({ fields, request }) => {
const country = fields.find((field) => field.field === 'country');
const ids = await new RecordsGetter(
customers,
request.user,
request.query
).getIdsFromRequest(request);
const customers = await customers.findAll({ _id: { $in: ids } });
country.value = '';
country.isReadOnly = false;
// If customers have the same country, set field to this country and make it not editable
if (customersHaveSameCountry(customers)) {
country.value = customers.country;
country.isReadOnly = true;
}
return fields;
},
},
},
],
fields: [],
segments: [],
});
```
```ruby theme={null}
class Forest::Customers
include ForestLiana::Collection
collection :Customers
action 'Some action',
type: 'bulk',
fields: [
{
field: 'country',
type: 'String',
is_read_only: true
},
{
field: 'city',
type: 'String'
},
],
:hooks => {
:load => -> (context) {
country = context[:fields].find{|field| field[:field] == 'country'}
ids = ForestLiana::ResourcesGetter.get_ids_from_request(context[:params], context[:user]);
customers = Customers.find(ids);
country[:value] = '';
country[:is_read_only] = false;
# If customers have the same country, set field to this country and make it not editable
if customers_have_same_country(customers)
country[:value] = customers.country;
country[:is_read_only] = true;
end
return context[:fields];
},
},
end
```
# Smart Action Intents
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/create-and-manage-smart-actions/use-action-intent
### What are smart action intents ?
Action intents allows you to redirect your operators from the outside worlds directly to a specific action, by using an url.
This means that your actions can now be accessed directly using a link (saving many clicks), link for which you can specify few parameters so can pre-compute your form with custom values for instance.
All of our action types are supported (Global, Bulk and Single)
### Building a smart action intent
Get to the index of the collection you want to share an action from, and retrieve its URl.
For instance, given a project `aProject`, an environment `anEnvironment`, a team `aTeam` and a collection `aCollection`, the url should look similar to this:
`https://app.forestadmin.com/aProject/anEnvironment/aTeam/data/aCollection/index`
Base on that url, you can configure the action intent with 3 parameters:
* `actionIntent` of type string, being the name of the action you want to redirect to.
* `actionIntentIds` of type array of string, being the IDs of the records you want to execute the action for.
* `actionIntentParams` of type JSON object, being the params you want to send along your action intent
Please do note that `actionIntentIds` and `actionIntentParams` should be a valid JSON structure
Here is an example of all of these parameters combined:
`https://app.forestadmin.com/aProject/anEnvironment/aTeam/data/aCollection/index?actionIntent=anActionName&actionIntentIds=[1,2]&actionIntentParams={"firstParam":"firstValue","secondParam":"secondValue"}`
### How to use actionIntentParams
`actionIntentParams` should be a valid JSON object
Your parameters provided to the action intent will be passed to your agent over change and load hooks, allowing you to compute any value for your fields based on the provided parameters. You can access those parameters like such:
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
const { customers } = require('../models');
collection('aCollection', {
actions: [{
name: 'anAction',
type: 'single',
fields: [{
field: 'aField',
type: 'String',
hook: 'onValueChange',
}],
hooks: {
change: {
onValueChange: ({ fields, request }) => {
const actionIntentParams = request.body.data.attributes.action_intent_params;
...
return fields;
}
},
load: async ({ fields, request }) => {
const actionIntentParams = request.body.data.attibutes.action_intent_params;
...
return fields;
},
},
}],
...
});
```
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
const { customers } = require('../models');
collection('aCollection', {
actions: [{
name: 'anAction',
type: 'single',
fields: [{
field: 'aField',
type: 'String',
hook: 'onValueChange',
}],
hooks: {
change: {
onValueChange: ({ fields, request }) => {
const actionIntentParams = request.body.data.attributes.action_intent_params;
...
return fields;
}
},
load: async ({ fields, request }) => {
const actionIntentParams = request.body.data.attibutes.action_intent_params;
...
return fields;
},
},
}],
...
});
```
```ruby theme={null}
class Forest::ACollection
include ForestLiana::Collection
collection :ACollection
action 'an_action',
type: 'single',
fields: [{
field: 'a_field',
type: 'String',
hook: 'on_value_change',
}],
:hooks => {
:change => {
'on_value_change' => -> (context) {
action_intent_params = context[:params][:data][:attributes][:action_intent_params];
...
return context[:fields];
}
}
:load => -> (context, request) {
action_intent_params = context[:params][:data][:attributes][:action_intent_params];
...
return context[:fields];
}
}
...
end
```
### How to use actionIntentIds
`actionIntentIds` should be a valid JSON array, or a single id. It is also worth noting that for global action, any provided ids will be skipped. Also, action of type single should be having a single id provided, and bulk action should be passed having many provided
Ids configured in the action intent will be provided as usual within your context. Please refer to this [documentation](/legacy/javascript-agents/reference-guide/actions/create-and-manage-smart-actions/overview#creating-a-smart-action) for more details.
# Actions
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/overview
Visualizing data is great, but at some point you're going to want to interact with it.
### What is an action?
An action is a button that triggers server-side logic through an API call. Without a single line of code, Forest natively supports all common actions required on an admin interface such as CRUD (Create, Read, Update, Delete), sort, search, data export, and more.
### Native actions vs Smart Actions
In Forest, all the available actions can fall into 2 categories.
#### Native actions
Those actions come out-of-the-box. We've covered them in details *from a route perspective* in [Routes](/legacy/javascript-agents/reference-guide/routes/overview). The most common ones are:
* **Create**: create a new record in a given collection
* **Duplicate**: create a new record from an existing one
* **Update**: edit a record's data
* **Delete**: remove a record
Some actions are only available when 1+ record(s) are selected. This depends on [their type](/legacy/javascript-agents/reference-guide/actions/overview#triggering-different-types-of-actions).
Native actions' **permissions** are set from the Roles section of the Project settings.
#### Smart Actions
Smart actions are your own business-related actions, built with your own code. You'll learn how to use them in the [following page](/legacy/javascript-agents/reference-guide/actions/create-and-manage-smart-actions/overview#what-is-a-smart-action).
Smart actions can be triggered from the *Actions* button or directly from a Summary view.
### Triggering different types of actions
Triggering an action is very simple, but the behavior can differ according to the type of action.
There are 3 types of actions :
* **Bulk** actions: the action will be available when you click on one or several desired records
* **Single** actions: the action is only available for one selected record at a time
* **Global** actions: the action is always available and will be executed on all records
In the following pages, we'll cover everything you need to know about interacting with your data through actions.
# Smart Action Examples
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/smart-action-examples/README
# Add many existing records at the same time (hasMany-belongsTo relationship)
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/smart-action-examples/add-many-existing-records-at-the-same-time-hasmany-belongsto-relationship
This example shows how to associate multiple existing records at once to a record using a simple smart action.
### Requirements
* An admin backend running on `forest-express-sequelize`
* Relationship **One-To-Many** between two collections (in this example an organization **hasMany** companies \<-> a company **belongsTo** an organization)
## How it works
### Directory: **/forest**
Create a new smart action in the forest file of the collection with the **hasMany relationship** (organizations in this example).
This smart action will be usable on a single record (`type: 'single'`). We will create two fields in the smart action form, one will be used for the **search** on the referenced collection and the second will be used to see the **selection** made by the operator.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
const { companies } = require('../models');
collection('organizations', {
actions: [
{
name: 'Associate companies',
type: 'single',
fields: [
{
field: 'search',
type: 'String',
reference: 'companies',
isRequired: false,
hook: 'onSearchChange',
},
{
field: 'selection',
type: ['String'],
isReadOnly: true,
isRequired: true,
hook: 'onSelectionChange',
},
],
hooks: {
change: {
onSearchChange: async ({ fields }) => {
// Retrieve fields
const selection = fields.find(
(field) => field.field === 'selection'
);
const search = fields.find((field) => field.field === 'search');
if (!!search.value) {
// Retrieve the company name by querying the DB
const { name: searchValue } =
(await companies.findByPk(search.value)) || {};
// Adding company names when searching matches
if (searchValue) {
const allAddedValues = [
...(selection.previousValue || []),
searchValue,
]; // ...() spread the array
// Unique array values using a set
selection.value = [...new Set(allAddedValues)];
// Allow user to interact with selection field
selection.isReadOnly = false;
// Reset search value
search.value = '';
}
}
return fields;
},
onSelectionChange: async ({ fields }) => {
// This hooks is needed to allow company removal from selection
const selectionField = fields.find(
(field) => field.field === 'selection'
);
// Enable or disable user interactions
if (selectionField.value?.length > 0) {
selectionField.isReadOnly = false;
} else {
selectionField.isReadOnly = true;
}
return fields;
},
},
},
},
],
fields: [],
segments: [],
});
```
### **Directory: /routes**
When the user validates the action, this route is called. We will use the **selection** to retrieve all companies' ids and then updates all companies `organizationId` field to create the associations.\
\
*In addition, once the smart action has been successfully run, it refreshes the relationship to properly display newly added associations.*
```javascript theme={null}
const express = require('express');
const { PermissionMiddlewareCreator } = require('forest-express-sequelize');
const {
companies,
objectMapping: { Op },
} = require('../models');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
'organizations'
);
// Associate companies smart action route
router.post(
'/actions/associate-companies',
permissionMiddlewareCreator.smartAction(),
async (req, res) => {
const {
body: {
data: { attributes },
},
} = req;
const companyNames = attributes.values['selection'];
// Retrieve all companies ids using the company names sent by the action form
const companyIds = (
await companies.findAll({
where: { name: { [Op.in]: companyNames } },
attributes: ['id'],
})
).map((company) => company.id);
// Retrieve organization id from the request
const organizationId = attributes.ids[0];
// Update the companies to add the belongsTo association
await companies.update(
{ organizationId: organizationId },
{ where: { id: companyIds } }
);
// Send success toasted and refresh the related data in the Summary
res.send({
success: 'Companies have been added!',
refresh: { relationships: ['companies'] },
});
}
);
module.exports = router;
```
# BelongsToMany edition through smart collection
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/smart-action-examples/belongstomany-edition-through-smart-collection
**Context:** *A customer success team has to onboard “experts”, and those “experts” can have multiple “skills”, modelled via a belongsToMany relationship between “experts” and “skills” tables through an “experts\_skills” table; the skills table has \~200 records and experts usually have between 5 to 30 of them.*
*Unfortunately this is quite painful to edit in forest admin right now since when you want to add a new item in a belongToMany relationship in forest admin you have to:*
* *click on “add an existing …”*
* *Remember and search for the item using a single search bar*
* *select the desired item*
### Intro
In the following we will see how the choice of `skills` to be added to an expert can be materialized through a searchable smart collection named `otherSkills` displayed as related data of an `expert`. An action applicable on the selected records of this collection will allow to associate new skills to an expert.
**Data models**
The data models we have been working with here (`experts` and `skills`) are the following:
```jsx theme={null}
// This model was generated by Lumber. However, you remain in control of your models.
// Learn how here:
module.exports = (sequelize, DataTypes) => {
const { Sequelize } = sequelize;
// This section contains the fields of your model, mapped to your table's columns.
// Learn more here:
const Experts = sequelize.define(
'experts',
{
username: {
type: DataTypes.STRING,
},
},
{
tableName: 'experts',
timestamps: false,
schema: process.env.DATABASE_SCHEMA,
}
);
// This section contains the relationships for this model. See: .
Experts.associate = (models) => {
Experts.belongsToMany(models.skills, {
through: 'experts_skills',
foreignKey: 'expert_id',
otherKey: 'skill_id',
as: 'expertSkills',
});
};
return Experts;
};
```
```jsx theme={null}
// This model was generated by Lumber. However, you remain in control of your models.
// Learn how here:
module.exports = (sequelize, DataTypes) => {
const { Sequelize } = sequelize;
// This section contains the fields of your model, mapped to your table's columns.
// Learn more here:
const Skills = sequelize.define(
'skills',
{
description: {
type: DataTypes.STRING,
},
},
{
tableName: 'skills',
timestamps: false,
schema: process.env.DATABASE_SCHEMA,
}
);
// This section contains the relationships for this model. See: .
Skills.associate = (models) => {
Skills.belongsToMany(models.experts, {
through: 'experts_skills',
foreignKey: 'skill_id',
otherKey: 'expert_id',
as: 'skillExperts',
});
};
return Skills;
};
```
### Step 1: create a smart collection 'other skills'
As we already have the skills assigned to an expert as related data when viewing an expert, we'd like to see the skills that have not been assigned and could be by the user.
For this we need to create a smart collection called `otherSkills`. This smart collection is defined in a file `other-skills.js` inside the `forest` folder.
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
collection('otherSkills', {
fields: [
{
field: 'description',
type: 'String',
},
],
});
```
### Step 2: declare a smart relationship between experts and otherSkills
In order to display records from the collection `otherSkills` as related data of an expert, we need to declare a smart relationship between these collections. This is done in the file `experts.js` of the `forest` folder.
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
collection('experts', {
actions: [],
fields: [
{
field: 'otherSkills',
type: ['String'],
reference: 'otherSkills.id',
},
],
segments: [],
});
```
### Step 3: implement the logic to retrieve records from the smart relationship
We want to display as related data the `skills` that are not already assigned to an `expert` so we can add them. Therefore when implementing the route called to retrieve records from the collection `otherSkills` through the smart relationship, we need to add this logic. This is done in the file `experts.js` of the `routes` folder.
\
```jsx theme={null}
const express = require('express');
const {
PermissionMiddlewareCreator,
RecordSerializer,
} = require('forest-express-sequelize');
const { Op } = require('sequelize');
const { otherSkills, experts, skills } = require('../models');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('experts');
const recordSerializer = new RecordSerializer({ name: 'otherSkills' });
router.get(
'/experts/:id/relationships/otherSkills',
permissionMiddlewareCreator.list(),
(request, response, next) => {
const expert = experts.findByPk(request.params.id, {
include: [
{
model: skills,
as: 'expertSkills',
},
],
});
let queryParams = {};
if (request.query.search) {
queryParams = {
where: {
description: {
[Op.iLike]: `%${request.query.search}%`,
},
},
};
}
const skillsList = skills.findAll(queryParams);
Promise.all([expert, skillsList])
.then((results) => {
const { expertSkills } = results[0];
const allSkills = results[1];
const expertSkillsIds = expertSkills.map((record) => record.id);
const records = [];
allSkills.forEach((skillListed) => {
if (
!expertSkillsIds.includes(skillListed.id) &&
skillListed.description
) {
const skill = {
id: skillListed.id,
description: skillListed.description,
};
records.push(skill);
}
});
return records;
})
.then((records) => recordSerializer.serialize(records))
.then((recordsSerialized) =>
response.send({
...recordsSerialized,
meta: { count: recordsSerialized.data.length },
})
);
}
);
module.exports = router;
```
### Step 4: create the smart action to add skills to an expert
Next step is to declare a smart action that will allow a user to select several records of the `otherSkills` smart collection and associate them to an `expert`. This action is declared in the file `other-skills.js` of the `forest` folder.
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
collection('otherSkills', {
actions: [{
name: 'add',
type: 'bulk',
}],
...
});
```
The logic to be triggered when a call is made to the route is implemented as follows in the `other-skills.js` file of the `routes` folder.
```javascript theme={null}
const express = require('express');
const { PermissionMiddlewareCreator } = require('forest-express-sequelize');
const { experts } = require('../models');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
'otherSkills'
);
router.post(
'/actions/add',
permissionMiddlewareCreator.smartAction(),
(request, response) => {
const expertId = request.body.data.attributes.parent_collection_id;
const selectedIds = request.body.data.attributes.ids;
experts
.findByPk(expertId)
.then((user) => {
selectedIds.forEach((skillId) => {
user.addExpertSkills(skillId);
});
})
.then(() =>
response.send({
success: `${selectedIds.length} new skills have been added`,
})
);
}
);
module.exports = router;
```
# Calculate the distance between two string addresses
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/smart-action-examples/calculate-the-distance-between-two-string-addresses
**Context**: As a user I want to be able to obtain the distance between two objects that have address information as a string.
**Example**: I have a collection `places` that has `lineAddress1`, `addressCity` and `country` fields.
In a smart action called `get distance to another place` called from a specific place, I want to be able to select another place, choosing the locomotion mode and get the distance between the two and duration of trip.
### Implementation
First you need to declare the action and the content of the form.
`forest/places.js`
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
collection('places', {
actions: [
{
name: 'get distance to other place',
fields: [
{
field: 'destination',
reference: 'places.id',
},
{
field: 'mode',
type: 'Enum',
enums: ['driving', 'bicycling', 'walking'],
},
],
},
],
fields: [],
segments: [],
});
```
Then you need to implement the logic of the action. Here we use the service `superagent` to handle api calls.
The process has two main steps:
* call to the places api to retrieve the place\_id identifier corresponding to the string address of the origin and destination (that is computed as a complete address based on the separate `addressLine1`, `addressCity` and `country` fields)
* call to the distance matrix api to retrieve the distance information based on the origin and destination's place\_ids
The result returned to the UI is formatted in html to enable a good display to the user.
`routes/places.js`
```javascript theme={null}
const express = require('express');
const superagent = require('superagent');
const { PermissionMiddlewareCreator } = require('forest-express-sequelize');
const { places } = require('../models');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('places');
router.post(
'/actions/get-distance-to-other-place',
permissionMiddlewareCreator.smartAction(),
async (request, response, next) => {
let origin = {};
let destination = {};
const attr = request.body.data.attributes;
const where = {
id: [attr.ids[0], attr.values.destination],
};
function computeFullAddress(address) {
return `${address.addressLine1},${address.addressCity}, ${address.country}`;
}
function setOriginDestination(addressesArray) {
addressesArray.forEach((address) => {
if (`${address.id}` === attr.ids[0]) {
origin = { address: computeFullAddress(address) };
} else {
destination = { address: computeFullAddress(address) };
}
});
}
function getPlaceId(place) {
return superagent
.get(
'https://maps.googleapis.com/maps/api/place/findplacefromtext/json?'
)
.query({
input: place.address,
inputtype: 'textquery',
fields: 'place_id,geometry/location',
key: process.env.GOOGLE_API_KEY,
})
.then((res) => {
const data = JSON.parse(res.text);
return data.candidates[0].place_id;
});
}
//get the addresses records based on the current record id and the selected destination id
const addressesRecords = await places.findAll({ where });
//add the full address to the empty objects origin and destination
setOriginDestination(addressesRecords);
// retrieve the place_id for the origin and destination
const googlePlaceIds = await Promise.all([
getPlaceId(origin),
getPlaceId(destination),
]);
[origin.placeId, destination.placeId] = googlePlaceIds;
//perform call to the distance matrix api
return superagent
.get('https://maps.googleapis.com/maps/api/distancematrix/json?')
.query({
origins: `place_id:${origin.placeId}`,
destinations: `place_id:${destination.placeId}`,
key: process.env.GOOGLE_API_KEY,
// the mode here corresponds to the one selected in the action form
mode: attr.values.mode,
})
.then((res) => {
console.log(res.text);
return JSON.parse(res.text);
})
.then((results) => {
response.send({
html: `
Distance between
${results.origin_addresses[0]}
and
${results.destination_addresses[0]}
Distance
${results.rows[0].elements[0].distance.text}
Duration
${results.rows[0].elements[0].duration.text}
`,
});
})
.catch((e) => console.log(e.response.error));
}
);
module.exports = router;
```
# Call a n8n webhook
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/smart-action-examples/call-a-webhook-with-record-ids
***
description: >-
This example shows how to call a third party webhook/automation tool like n8n, make or zapier…
***
# Call a n8n webhook
You need to declare the new action with its scope in the `users.js` model
```javascript theme={null}
// forest/users.js
const Liana = require('forest-express-sequelize');
Liana.collection('users', {
actions: [
{
name: 'Notify with slack',
type: 'single',
},
],
});
```
Then implement the action as needed in the route route action:
```javascript theme={null}
// routes/users.js
const express = require('express');
const router = express.Router();
const Liana = require('forest-express-sequelize');
const models = require('../models');
const Liana = require('forest-express-sequelize');
const superagent = require('superagent');
router.post(
'/actions/notify-with-slack',
Liana.ensureAuthenticated,
async (request, response) => {
const { query, user } = request;
const [userId] = await new RecordsGetter(
models.user,
user,
query
).getIdsFromRequest(request);
try {
await superagent
.post('https://user.app.nn.cloud/webhook/123456/abcde/')
.send({ userId });
response.send({ success: 'Called webhook' });
} catch (e) {
return response
.status(400)
.send({ error: `Failure calling webhook: ${e.message}` });
}
}
);
module.exports = router;
```
# Create a record with a multiselect through a many-to-many relationship
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/smart-action-examples/create-a-record-with-a-multiselect-through-a-many-to-many-relationship
**Context:** In this case, a card has many expense categories through a many to many relationships, using a join table (card expense categories). We want to be able to create a card, selecting the categories, and creating the card expense categories at the same time.
**Implementation:**
We will use a [smart action](/legacy/javascript-agents/reference-guide/actions/create-and-manage-smart-actions/overview) form with a hook to retrieve the categories as values for the multi select.
Then we implement the creation of cards and expenseCategories in the form.
`forest/cards.js`
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
const { expenseCategories } = require('../models');
// This file allows you to add to your Forest UI:
// - Smart actions:
// - Smart fields:
// - Smart relationships:
// - Smart segments:
collection('cards', {
actions: [
{
name: 'Create card',
type: 'global',
fields: [
{
field: 'name',
type: 'String',
isRequired: true,
},
{
field: 'user',
type: 'Number',
reference: 'users.id',
isRequired: true,
},
{
field: 'categories',
type: ['Enum'],
},
],
hooks: {
load: async ({ fields, request }) => {
const categories = fields.find(
(field) => field.field === 'categories'
);
categories.enums = await expenseCategories
.findAll({ raw: true })
.map((category) => category.title);
return fields;
},
},
},
],
fields: [],
segments: [],
});
```
`routes/cards.js`
```jsx theme={null}
const express = require('express');
const { PermissionMiddlewareCreator } = require('forest-express-sequelize');
const {
cards,
cardExpenseCategories,
expenseCategories,
} = require('../models');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('cards');
// This file contains the logic of every route in Forest for the collection cards:
// - Native routes are already generated but can be extended/overridden - Learn how to extend a route here:
// - Smart action routes will need to be added as you create new Smart Actions - Learn how to create a Smart Action here:
//...
//Smart action - Create a card
router.post(
'/actions/create-card',
permissionMiddlewareCreator.smartAction(),
(req, res) => {
let attrs = req.body.data.attributes.values;
categories_attrs = attrs['categories'];
attrs = { name: attrs['name'], userId: attrs['user'] };
return cards
.create(attrs)
.then((card) => {
categories_attrs.forEach((category) => {
return expenseCategories
.findOne({ where: { title: category } })
.then((expenseCategory) =>
cardExpenseCategories.create({
cardId: card.id,
expenseCategoryId: expenseCategory.id,
})
);
});
})
.then(() => {
res.send({
success: 'Your card is created!',
refresh: { relationships: ['cardExpensesCategories'] },
});
});
}
);
```
### Rails version:
`lib/forest_liana/collections/card.rb`
```jsx theme={null}
class Forest::Card
include ForestLiana::Collection
collection :Card
action 'Create Card',
type: 'global',
fields: [{
field: "name",
type: "String",
isRequired: true,
},
{
field: "user",
type: "Number",
reference: "User.id",
isRequired: true,
},
{
field: "company",
type: "Number",
reference: "Company.id",
isRequired: true,
},
{
field: "vendor",
type: "Number",
reference: "Vendor.id",
isRequired: true,
},
{
field: "categories",
type: ['Enum'],
}
],
:hooks => {
:load => -> (context) {
categories = context[:fields].find{|field| field[:field] == 'categories'}
categories[:enums] = ExpenseCategory.all.pluck(:title)
return context[:fields]
}
}
end
```
`config/routes.rb`
```jsx theme={null}
Rails.application.routes.draw do
...
namespace :forest do
post '/actions/create-card' => 'cards#create_card'
end
mount ForestLiana::Engine => '/forest'
end
```
`controllers/forest/cards_controller.rb`
```jsx theme={null}
class Forest::CardsController < ForestLiana::SmartActionsController
def create_card
attrs = params.dig('data', 'attributes', 'values')
categories_attrs = attrs['categories'];
attrs = { name: attrs['name'], user_id: attrs['user'], company_id: attrs['company'], vendor_id: attrs['vendor'] };
card = Card.create(attrs)
categories_attrs.each do|category|
expense_category = ExpenseCategory.find_by(title: category)
card_expense_category = CardExpenseCategory.create(card_id: card.id, expense_category_id: expense_category.id)
end
render json: { success: 'Your card has been created.' }
end
end
```
# Custom dynamic dropdown in a form using smart collections
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/smart-action-examples/custom-dynamic-dropdown-in-a-form-using-smart-collections
**Context**: I want my users to be able to select an input within a list computed dynamically depending on the current record.
In this example I have a custom action called `report transaction` applicable to records from a `companies` model. I want to allow users to select some information coming from the `transaction` table from a dropdown. The information should be computed from transactions that belong to the current company.
This cannot be handled properly with the current features of custom action forms. However, you can add an input field that points to a virtual collection. As users can perform a dynamic search on this collection, you can catch the search input and use to build the virtual collection records returned.
In our example, the user needs to enter the id of the record on which the action is triggered to build the selection.
### Custom action definition
Within the custom action, we add a field referencing the custom collection `transactionsInfo`.
`forest/companies.js`
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
collection('companies', {
actions: [
{
name: 'Report transaction',
type: 'single',
fields: [
{
field: 'transaction info',
description: 'enter company id',
reference: 'transactionsInfo',
},
],
},
],
fields: [],
segments: [],
});
```
### Virtual collection definition
The custom collection `transactionsInfo` includes an `id` field and an `info` field which includes the information we want the users to be able to select and that will be used in the custom action logic.
`forest/transaction-info.js`
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
collection('transactionsInfo', {
fields: [
{
field: 'id',
type: 'Number',
},
{
field: 'info',
type: 'String',
},
],
});
```
### Virtual collection implementation
`routes/transactions-info.js`
```javascript theme={null}
const express = require('express');
const {
PermissionMiddlewareCreator,
RecordSerializer,
} = require('forest-express-sequelize');
const { companies, transactions } = require('../models');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
'transactionsInfo'
);
const recordSerializer = new RecordSerializer({ name: 'transactionsInfo' });
router.get(
'/transactionsInfo',
permissionMiddlewareCreator.list(),
async (request, response, next) => {
// get the current record from the id entered as an input
let company = null;
try {
company = await companies.findByPk(request.query.search);
} catch (error) {
return {};
}
// based on the record, trigger the logic to build the selection to be proposed
// here we get info from the related transactions and build transactionsInfo records from them
const companyTransactions = await transactions.findAll({
where: { beneficiary_company_id: company.id },
});
const selection = [];
companyTransactions.forEach((transaction) => {
const record = {
id: transaction.id,
info: `ref ${transaction.reference} - amount ${transaction.amount} USD`,
};
selection.push(record);
});
return recordSerializer.serialize(selection).then((recordsSerialized) => {
response.send(recordsSerialized);
});
}
);
module.exports = router;
```
# Dropdown with list of values in smart action form
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/smart-action-examples/dropdown-with-list-of-values-in-smart-action-form
**Context**: Within a smart action form, I want to enable my users to choose the value of an input field within a set of predefined values.
Here I have a smart action called `change status` for the collection `companies`. I want users to be able to only select the new status from a list of possible options.
`forest/companies.js`
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('companies', {
actions: [
{
name: 'Change status',
type: 'single',
fields: [
{
field: 'New status',
type: 'Enum',
isRequired: true,
enums: ['Pending', 'Live'],
},
],
},
],
fields: [],
segments: [],
});
```
# Handle enums with alias labels in a smart action
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/smart-action-examples/handle-enums-with-alias-labels-in-a-smart-action
**Context**: As a user to choose the input for a smart action field from a list of labels and I want a label to be pre-selected depending on the record's information. The labels do not correspond to the value to be updated in the database.
**Example**: I have a collection `companies` that has a `status` field. The status value in the database can be `rejected` or `live`.
In a smart action called update company status I want users to be able to select an alias value (i.e. `'rejeté'` for `rejected` and `'validé'` for `live`).
### Implementation
In order not to duplicate the matching to be made between the different values from the UI to the database and the other way around, I create a `company-status-handler` file that will allow me to handle the conversion.
`services/companies-status-handler.js`
```jsx theme={null}
exports.statusValueMatching = {
rejected: 'rejeté',
live: 'validé',
};
function getKeyByValue(object, value) {
return Object.keys(object).find((key) => object[key] === value);
}
exports.convertStatusValue = (status, source) => {
if (source === 'database') {
return this.statusValueMatching[status];
}
return getKeyByValue(this.statusValueMatching, status);
};
```
`forest/companies.js`
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
const {
convertStatusValue,
statusValueMatching,
} = require('../services/companies-status-handler');
collection('companies', {
actions: [
{
name: 'Update company status',
type: 'single',
fields: [
{
field: 'Statut',
type: 'Enum',
enums: Object.values(statusValueMatching),
},
],
values: (company) => {
company.Statut = convertStatusValue(company.status, 'database');
return company;
},
},
],
fields: [],
segments: [],
});
```
`routes/companies.js`
```javascript theme={null}
const express = require('express');
const { PermissionMiddlewareCreator } = require('forest-express-sequelize');
const { companies } = require('../models');
const { convertStatusValue } = require('../services/companies-status-handler');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
'companies'
);
router.post('/actions/update-company-status', (request, response, next) => {
// Learn what this route does here: https://docs.forestadmin.com/documentation/v/v6/reference-guide/routes/default-routes#create-a-record
const attr = request.body.data.attributes;
companies
.update(
{ status: convertStatusValue(attr.values.Statut, 'front') },
{ where: { id: attr.ids[0] } }
)
.then(() => response.send({ success: 'company updated' }));
});
module.exports = router;
```
# Impersonate a user
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/smart-action-examples/impersonate-a-user
This example shows you how to create a Smart Action `"Impersonate"` to login as one of your customers.
It can be useful to help your customers debug an issue or to get a better understanding of what they see on their account (in your app).
## Requirements
* An admin backend running on forest-express-sequelize/forest-express-mongoose
## How it works
### Directory: /models
This directory contains the `users.js` file where the model is declared.
```javascript theme={null}
module.exports = (sequelize, DataTypes) => {
const { Sequelize } = sequelize;
const Users = sequelize.define('users', {
email: {
type: DataTypes.STRING,
},
createdAt: {
type: DataTypes.DATE,
},
//...
}, {
tableName: 'users',
timestamps: false,
schema: process.env.DATABASE_SCHEMA,
});
Users.associate = (models) => {
};
return Users;
};
```
```javascript theme={null}
const mongoose = require('mongoose');
const schema = mongoose.Schema({
'email': String,
'createdAt': Date,
...
}, {
timestamps: false,
});
module.exports = mongoose.model('users', schema, 'users');
```
### **Directory: /forest**
This directory contains the `users.js` file where the Smart Action `Impersonate`is declared.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('users', {
actions: [
{
name: 'Impersonate',
type: 'single',
},
],
});
```
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
collection('users', {
actions: [
{
name: 'Impersonate',
type: 'single',
},
],
});
```
### **Directory: /routes**
This directory contains the `users.js` file where the implementation of the route is handled. The `POST /forest/actions/impersonate` API call is triggered when you click on the Smart Action in the Forest UI.
```javascript theme={null}
router.post('/actions/impersonate',
(req, res) => {
let userId = req.body.data.attributes.ids[0];
response.send({
webhook: { // This is the object that will be used to fire http calls.
url: 'https://my-app-url/login', // The url of the company providing the service.
method: 'POST', // The method you would like to use (typically a POST).
headers: { }, // You can add some headers if needed (you can remove it).
body: { // A body to send to the url (only JSON supported).
adminToken: 'your-admin-token',
},
},
success: `Impersonating user ${userId}`, // The success message that will be toasted.
redirectTo: 'https://my-app-url/', // Force the redirection to your app if needed.
});
});
module.exports = router;
```
```javascript theme={null}
router.post('/actions/impersonate', (req, res) => {
let userId = req.body.data.attributes.ids[0];
response.send({
webhook: {
// This is the object that will be used to fire http calls.
url: 'https://my-app-url/login', // The url of the company providing the service.
method: 'POST', // The method you would like to use (typically a POST).
headers: {}, // You can add some headers if needed (you can remove it).
body: {
// A body to send to the url (only JSON supported).
adminToken: 'your-admin-token',
},
},
success: `Impersonating user ${userId}`, // The success message that will be toasted.
redirectTo: 'https://my-app-url/', // Force the redirection to your app if needed.
});
});
module.exports = router;
```
This is useful for authentication using cookies. By using this example, you're performing the login request directly from the browser. Thus, the cookies will be automatically sent from your own service to the browser (as you'd normally do with your own app).
# Refresh hasMany relationship in smart action
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/smart-action-examples/refresh-hasmany-relationship-in-smart-action
**Context**: In this example I have a model `tenants` that hasMany records from a model `ssoProviders`. I want to create a new ssoProvider from a smart action accessible at the level of a tenant and refresh the list of ssoProviders shown in the summary view.
## Models
```jsx theme={null}
// This model was generated by Lumber. However, you remain in control of your models.
// Learn how here:
module.exports = (sequelize, DataTypes) => {
const { Sequelize } = sequelize;
// This section contains the fields of your model, mapped to your table's columns.
// Learn more here:
const Tenants = sequelize.define(
'tenants',
{},
{
tableName: 'tenants',
underscored: true,
timestamps: false,
schema: process.env.DATABASE_SCHEMA,
}
);
// This section contains the relationships for this model. See: .
Tenants.associate = (models) => {
Tenants.hasMany(models.ssoProviders, {
foreignKey: {
name: 'tenantIdKey',
field: 'tenant_id',
},
as: 'ssoProviders',
});
};
return Tenants;
};
```
```jsx theme={null}
// This model was generated by Lumber. However, you remain in control of your models.
// Learn how here:
module.exports = (sequelize, DataTypes) => {
const { Sequelize } = sequelize;
// This section contains the fields of your model, mapped to your table's columns.
// Learn more here:
const SsoProviders = sequelize.define(
'ssoProviders',
{
description: {
type: DataTypes.STRING,
},
},
{
tableName: 'sso_providers',
underscored: true,
timestamps: false,
schema: process.env.DATABASE_SCHEMA,
}
);
// This section contains the relationships for this model. See: .
SsoProviders.associate = (models) => {
SsoProviders.belongsTo(models.tenants, {
foreignKey: {
name: 'tenantIdKey',
field: 'tenant_id',
},
as: 'tenant',
});
};
return SsoProviders;
};
```
## Smart action definition
in the file `forest/tenants.js`
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
// This file allows you to add to your Forest UI:
// - Smart actions:
// - Smart fields:
// - Smart relationships:
// - Smart segments:
collection('tenants', {
actions: [
{
name: 'add provider',
type: 'single',
},
],
fields: [],
segments: [],
});
```
in the file `routes/tenants.js`
```javascript theme={null}
const express = require('express');
const { PermissionMiddlewareCreator } = require('forest-express-sequelize');
const { tenants, ssoProviders } = require('../models');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('tenants');
...
router.post('/actions/add-provider', permissionMiddlewareCreator.smartAction(), (req, res) => {
const tenantIdKey = req.body.data.attributes.ids[0];
const description = `test ${Math.floor(Math.random() * 100)}`;
return ssoProviders
.create({
description,
tenantIdKey,
})
.then(() => {
res.send({
success: 'Added new provider',
refresh: { relationships: ['ssoProviders'] },
});
});
});
module.exports = router;
```
# Retrieve smart field info in a smart action
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/smart-action-examples/retrieve-smart-field-info-in-a-smart-action
Example of retrieving a Smart field into a Smart action
```javascript theme={null}
const Liana = require('forest-express-sequelize');
Liana.collection('users', {
fields: [
{
field: 'fullemail',
type: 'String',
get: (user) => {
return user.email + ' + ' + 'hello';
},
},
],
actions: [
{
name: 'test',
type: 'single',
},
],
});
// routes/users.js
const express = require('express');
const router = express.Router();
const Liana = require('forest-express-sequelize');
const models = require('../models');
router.post('/actions/test', Liana.ensureAuthenticated, (req, res, next) => {
const userId = req.body.data.attributes.ids[0];
return models.users
.findByPk(userId)
.then((user) =>
new Liana.ResourceSerializer(
Liana,
models.users,
user,
null,
{},
{}
).perform()
)
.then((userSerialized) => {
// NOTICE: Liana.ResourceSerializer will compute all Smart Field values of the record.
return res.send({
success: `Top Top ${userSerialized.data.attributes.fullemail}`,
});
})
.catch(next);
});
module.exports = router;
```
# Smart action to create several records from the input of a single smart action form
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/smart-action-examples/smart-action-to-create-several-records-from-the-input-of-a-single-smart-action-form
**Description**: From a smart action form which asks input for 3 new products at a time (picture + description), catch the posted payload and create 3 products
```ruby theme={null}
require 'data_uri'
require 'base64'
class Forest::ProductsController < ForestLiana::ApplicationController
def split_product
attrs = params.dig('data', 'attributes', 'values')
created_items = 0
(1..3).each do |i|
new_product_picture = attrs["product_#{i}_picture"];
new_product_description = attrs["product_#{i}_description"];
if new_product_picture && new_product_description
# if you are storing your pictures in a cloud and your DB stores the pictures url -> include here a function to send the base64 image to your cloud and fetch back the corresponding url
Product.new({
label: product_description,
picture: product_picture,
})
created_items += 1 if Product.save
end
end
success_message = 'Successfully created ' + created_items.to_s + ' item(s)'
puts success_message
render json: { success: success_message }
end
def split_product_values
context = get_smart_action_context
picture_url = context[:picture]
render serializer: nil, json: { product_1_picture: picture_url, product_2_picture: picture_url, product_3_picture: picture_url}, status: :ok
end
end
```
# Smart segment to restrict access to an action on a record details view
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/smart-action-examples/smart-segment-to-restrict-access-to-an-action-on-a-record-details-view
**Context**: As a user, I want to enable or not a smart action for a record depending on the value of a smart field.
In this example, the user wants to enable the access to a smart action called `restricted action` for a collection `customers` solely for customers that have registered `orders`. In our data models a customer hasMany orders.
The behavior observed above corresponds to this implementation in the file `customers.js`
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
const models = require('../models');
const { Op } = models.Sequelize;
collection('customers', {
actions: [
{
name: 'Restricted action',
},
],
fields: [
{
field: 'ordersNumber',
type: 'Number',
get(customer) {
return models.orders
.count({ where: { customer_id: customer.id } })
.then((nb) => nb);
},
},
],
segments: [
{
name: 'Customers with orders',
where: (query) => {
const recordId = JSON.parse(query.filters).value;
return models.orders
.count({ where: { customer_id: recordId } })
.then((ordersNumber) => {
if (ordersNumber > 0) {
return { id: { [Op.in]: [recordId] } };
}
return { id: { [Op.in]: [null] } };
});
},
},
],
});
```
This works only at the level of a records details view as we are looking to catch the query made to ensure that the action should be visible. This query is structured this way and allows us to implement the logic above by retrieving the record id present in the filter:
```javascript theme={null}
{
segment: 'Customers with orders',
filters: '{"field":"id","operator":"equal","value":"67573"}',
timezone: 'Europe/Paris'
}
```
# Anonymize users in bulk
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/smart-action-examples/update-users-in-bulk
This example shows how to bulk update users
As usual, you must declare the action on your collection.
```javascript theme={null}
// forest/users.js
const Liana = require('forest-express-sequelize');
Liana.collection('users', {
actions: [
{
name: 'Anonymize',
type: 'single',
},
],
});
```
ou can then implement the post action as you need. Here the records are simply updated in bulk through the `sequelize` ORM.
```javascript theme={null}
// routes/users.js
const express = require('express');
const router = express.Router();
const models = require('../models');
const {
ensureAuthenticated,
RecordsGetter,
} = require('forest-express-sequelize');
router.post(
'/actions/anonymize',
ensureAuthenticated,
parseRequestBody,
async (request, response) => {
const { query, user } = request;
const recordsGetter = new RecordsGetter(models.user, user, query);
const records = await recordsGetter.getAll();
try {
await models.user.update(
{
firstName: '*** Anonymized First Name ***',
lastName: '*** Anonymized Last Name ***',
},
{ where: { id: records.map((record) => record.id) } }
);
response.send({ success: 'User(s) anonymized' });
} catch (e) {
return response
.status(400)
.send({ error: `Failure during user anonymization: ${e.message}` });
}
}
);
module.exports = router;
```
# Upload files to amazon s3
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/smart-action-examples/upload-files-to-amazon-s3
In this example we want to upload files (legal docs) for the companies collection that will be stored in Amazon S3 through a smart action. To do so we need to perform the following steps:
### Declare the smart action
In the companies.js file of the Forest folder, add the following to enable the user to access the action in the UI (by declaring the name and type of the action) and open an input form when triggering the action (by declaring fields).
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
collection('companies', {
actions: [{
name: 'Upload Legal Docs',
type: 'single',
fields: [{
field: 'Certificate of Incorporation',
description: 'The legal document relating to the formation of a company or corporation.',
type: 'File',
isRequired: true
}, {
field: 'Proof of address',
description: '(Electricity, Gas, Water, Internet, Landline & Mobile Phone Invoice / Payment Schedule) no older than 3 months of the legal representative of your company',
type: 'File',
isRequired: true
}, {
field: 'Company bank statement',
description: 'PDF including company name as well as IBAN',
type: 'File',
isRequired: true
}, {
field: 'Valid proof of ID',
description: 'ID card or passport if the document has been issued in the EU, EFTA, or EEA / ID card or passport + resident permit or driving license if the document has been issued outside the EU, EFTA, or EEA of the legal representative of your company',
type: 'File',
isRequired: true
}],
});
```
### Implement the logic of the smart action
To implement the logic that will be called upon when the action is triggered and the corresponding endpoint is called by the browser, the following has been added to the file companies.js in the routes folder.
```jsx theme={null}
const express = require('express');
const S3Helper = require('../services/s3-helper');
const router = express.Router();
function uploadLegalDoc(companyId, doc, field) {
const id = uuid();
return new S3Helper().upload(doc, `livedemo/legal/${id}`)
.then(() => models.companies.findById(companyId))
.then((company) => {
company[field] = id;
return company.save();
})
.then((company) => models.documents.create({
file_id: company[field],
is_verified: true,
}));
}
router.post('/actions/upload-legal-docs',
(req, res) => {
// Get the current company id
let companyId = req.body.data.attributes.ids[0];
// Get the values of the input fields entered by the admin user.
let attrs = req.body.data.attributes.values;
let certificate_of_incorporation = attrs['Certificate of Incorporation'];
let proof_of_address = attrs['Proof of address'];
let company_bank_statement = attrs['Company bank statement'];
let passport_id = attrs['Valid proof of id'];
// The business logic of the Smart Action. We use the function
// UploadLegalDoc to upload them to our S3 repository. You can see the full
// implementation on our Forest Live Demo repository on Github.
return P.all([
uploadLegalDoc(companyId, certificate_of_incorporation, 'certificate_of_incorporation_id'),
uploadLegalDoc(companyId, proof_of_address, 'proof_of_address_id'),
uploadLegalDoc(companyId, company_bank_statement,'bank_statement_id'),
uploadLegalDoc(companyId, passport_id, 'passport_id'),
])
.then(() => {
// Once the upload is finished, send a success message to the admin user in the UI.
res.send({ success: 'Legal documents are successfully uploaded.' });
});
});
...
module.exports = router;
```
The file required where the S3 helper is defined has been added to a services folder, as `services/s3-helper.js`.
```javascript theme={null}
const P = require('bluebird');
const parseDataUri = require('parse-data-uri');
const AWS = require('aws-sdk');
const filesize = require('filesize');
function S3Helper() {
function mapAttrs(file) {
return {
id: file.Key.replace('livedemo/legal/', ''),
url: `https://s3-eu-west-1.amazonaws.com/${process.env.S3_BUCKET}/${file.Key}`,
last_modified: file.LastModified,
size: filesize(file.Size),
};
}
this.upload = (rawData, filename) => {
return new P((resolve, reject) => {
// Create the S3 client.
let s3Bucket = new AWS.S3({ params: { Bucket: process.env.S3_BUCKET } });
let parsed = parseDataUri(rawData);
let base64Image = rawData.replace(
/^data:(image|application)\/\w+;base64,/,
''
);
let data = {
Key: filename,
Body: new Buffer(base64Image, 'base64'),
ContentEncoding: 'base64',
ContentDisposition: 'inline',
ContentType: parsed.mimeType,
ACL: 'public-read',
};
// Upload the image.
s3Bucket.upload(data, function (err, response) {
if (err) {
return reject(err);
}
return resolve(response);
return models.companies
.findById(companyId)
.then((company) => {
company.certificate_of_incorporation_id = certificateId;
return company.save();
})
.then(() => {
res.send({ success: 'Legal documents are successfully uploaded.' });
});
});
});
};
this.files = (prefix) => {
const s3 = new AWS.S3();
let files = [];
return new P((resolve, reject) => {
return s3
.listObjects({
Bucket: process.env.S3_BUCKET,
Prefix: prefix,
})
.on('success', function handlePage(r) {
files.push(...r.data.Contents);
if (r.hasNextPage()) {
r.nextPage().on('success', handlePage).send();
} else {
return resolve(files.map((f) => mapAttrs(f)));
}
})
.on('error', (err) => {
reject(err);
})
.send();
});
};
this.file = (key) => {
const s3 = new AWS.S3();
let files = [];
return new P((resolve, reject) => {
return s3
.listObjects({
Bucket: process.env.S3_BUCKET,
Prefix: key,
})
.on('success', (file) => {
return resolve(mapAttrs(file.data.Contents[0]));
})
.on('error', (err) => {
reject(err);
})
.send();
});
};
this.deleteFile = (key) => {
const s3 = new AWS.S3();
return new P((resolve, reject) => {
return s3
.deleteObjects({
Bucket: process.env.S3_BUCKET,
Delete: {
Objects: [{ Key: key }],
},
})
.on('success', () => resolve())
.on('error', (err) => reject(err))
.send();
});
};
this.updateFile = (key, newKey) => {
const s3 = new AWS.S3();
return new P((resolve, reject) => {
return s3
.copyObject({
Bucket: process.env.S3_BUCKET,
CopySource: process.env.S3_BUCKET + '/' + key,
Key: newKey,
MetadataDirective: 'REPLACE',
})
.on('success', (file) => {
return this.deleteFile(key).then(() => {
return resolve(this.file(newKey));
});
})
.on('error', (err) => reject(err))
.send();
});
};
}
module.exports = S3Helper;
```
# Upload several files with the File Picker
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/actions/smart-action-examples/upload-several-files-with-the-file-picker
**Smart action**
If you set an input field as an array of strings (\['String']), you can use the file picker to upload several files at once.
The following example shows you how to define an action allowing for the upload of several files.
In your forest/your-model.js file, add the following:
```jsx theme={null}
actions: [{
name: 'Upload files',
type: 'single',
fields: [{
field: 'files',
type: ['String'],
widget: 'file picker',
description: 'upload your files'
}]
```
**Native edit**
If a field corresponds to a column/field in your database set as an array of strings, you can upload several files when you use the `file picker` edit widget in the collection's settings. The field needs to be defined as an array in the sequelize / mongoose model definition (like so):
```jsx theme={null}
multipleDocumentPath: {
type: DataTypes.ARRAY(DataTypes.STRING),
},
```
```jsx theme={null}
multipleDocumentPath: [String];
```
💡 In order to be able to load several files that may be heavy, you will need to edit your app.js file as explained [here](https://community.forestadmin.com/t/maximum-file-size-in-a-smart-action-field-file/173/4?u=philippeg).
# Create a Smart Chart
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/charts/create-a-smart-chart
On the previous page, we learned how API-based charts allow you to fetch any dataset from a custom endpoint. But using the finite list of predefined charts (Single, Distribution, Time-based, etc.), you are still constrained by how that data is displayed. With **Smart Charts**, you can code exactly what data you want and how you want it displayed!
You need a **Starter plan** or above to create Smart charts
### Creating a Smart Chart
To create a chart and access the *Smart Chart Editor*, click on the **Edit Smart Chart** button:
Next, use the *Template*, *Component,* and *Style* tabs to create your customized chart. At any point, you can render your chart by clicking on the **Run code** button.
Don't forget to click on **Create Chart** (or **Save** if the chart is already created) once you're done!
If you are creating a **record-specific** smart chart (in the record Analytics tab), the **`record`** object is directly accessible (either through `this.args.record` in the component or `@record` in the template).
### Creating a Table Chart
Our first Smart Chart example will be a simple table: however you may choose to make it as complex and customized as you wish.
```markup theme={null}
\{\{user.username\}\}\{\{user.points\}\}
```
Using a trivial set of hardcoded data for example's sake:
```javascript theme={null}
import Component from '@glimmer/component';
import {
loadExternalStyle,
loadExternalJavascript,
} from 'client/utils/smart-view-utils';
export default class extends Component {
users = [
{
username: 'Darth Vador',
points: 1500000,
},
{
username: 'Luke Skywalker',
points: 2,
},
];
}
```
To query a custom route of your Forest server as your datasource, you may use this syntax instead:
```javascript theme={null}
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class extends Component {
@service lianaServerFetch;
@tracked users;
constructor(...args) {
super(...args);
this.fetchData();
}
async fetchData() {
const response = await this.lianaServerFetch.fetch(
'/forest/custom-data',
{}
);
this.users = await response.json();
}
}
```
### Creating a Bar Chart
This second example shows how you can achieve any format of charts, as you can benefit from external libraries like D3js.
```markup theme={null}
\{\{this.chart\}\}
```
```javascript theme={null}
import Component from '@glimmer/component';
import {
loadExternalStyle,
loadExternalJavascript,
} from 'client/utils/smart-view-utils';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
export default class extends Component {
constructor(...args) {
super(...args);
this.loadPlugin();
}
@tracked chart;
@tracked loaded = false;
async loadPlugin() {
await loadExternalJavascript('https://d3js.org/d3.v6.min.js');
this.loaded = true;
this.renderChart();
}
async fetchData() {
const response = await this.lianaServerFetch.fetch(
'/forest/custom-data',
{}
);
const data = await response.json();
return data;
}
@action
async renderChart() {
if (!this.loaded) {
return;
}
const color = 'steelblue';
// Don't comment the lines below if you want to fetch data from your Forest server
// const usersData = await this.fetchData()
// const data = Object.assign(usersData.sort((a, b) => d3.descending(a.points, b.points)), {format: "%", y: "↑ Frequency"})
// To remove if you're using data from your Forest server
const alphabet = await d3.csv(
'https://static.observableusercontent.com/files/09f63bb9ff086fef80717e2ea8c974f918a996d2bfa3d8773d3ae12753942c002d0dfab833d7bee1e0c9cd358cd3578c1cd0f9435595e76901508adc3964bbdc?response-content-disposition=attachment%3Bfilename*%3DUTF-8%27%27alphabet.csv',
function (d) {
return {
name: d.letter,
value: +d.frequency,
};
}
);
const data = Object.assign(
alphabet.sort((a, b) => d3.descending(a.value, b.value)),
{ format: '%', y: '↑ Frequency' }
);
const height = 500;
const width = 800;
const margin = { top: 30, right: 0, bottom: 30, left: 40 };
const x = d3
.scaleBand()
.domain(d3.range(data.length))
.range([margin.left, width - margin.right])
.padding(0.1);
const y = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.value)])
.nice()
.range([height - margin.bottom, margin.top]);
const xAxis = (g) =>
g.attr('transform', `translate(0,${height - margin.bottom})`).call(
d3
.axisBottom(x)
.tickFormat((i) => data[i].username)
.tickSizeOuter(0)
);
const yAxis = (g) =>
g
.attr('transform', `translate(${margin.left},0)`)
.call(d3.axisLeft(y).ticks(null, data.format))
.call((g) => g.select('.domain').remove())
.call((g) =>
g
.append('text')
.attr('x', -margin.left)
.attr('y', 10)
.attr('fill', 'currentColor')
.attr('text-anchor', 'start')
.text(data.y)
);
const svg = d3.create('svg').attr('viewBox', [0, 0, width, height]);
svg
.append('g')
.attr('fill', color)
.selectAll('rect')
.data(data)
.join('rect')
.attr('x', (d, i) => x(i))
.attr('y', (d) => y(d.value))
.attr('height', (d) => y(0) - y(d.value))
.attr('width', x.bandwidth());
svg.append('g').call(xAxis);
svg.append('g').call(yAxis);
this.chart = svg.node();
}
}
```
In the above snippet, notice how we import the **D3js** library. Of course, you can choose to use any other library of your choice.
This bar chart is inspired by [this one](https://observablehq.com/@d3/bar-chart).
The resulting chart can be resized to fit your use:
### Creating a density map
This last example shows how you can achieve virtually anything, since you are basically coding in a sandbox. There's no limit to what you can do with Smart charts.
```markup theme={null}
\{\{this.chart\}\}
```
```javascript theme={null}
import Component from '@glimmer/component';
import {
loadExternalStyle,
loadExternalJavascript,
} from 'client/utils/smart-view-utils';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
export default class extends Component {
constructor(...args) {
super(...args);
this.loadPlugin();
}
@tracked chart;
@tracked loaded = false;
async loadPlugin() {
await loadExternalJavascript('https://d3js.org/d3.v6.min.js');
await loadExternalJavascript('https://unpkg.com/topojson-client@3');
this.loaded = true;
this.renderChart();
}
@action
async renderChart() {
if (!this.loaded) {
return;
}
const height = 610;
const width = 975;
const format = d3.format(',.0f');
const path = d3.geoPath();
// This is the JSON for drawing the contours of the map
// Ref.: https://github.com/d3/d3-fetch/blob/v2.0.0/README.md#json
const us = await d3.json(
'https://static.observableusercontent.com/files/6b1776f5a0a0e76e6428805c0074a8f262e3f34b1b50944da27903e014b409958dc29b03a1c9cc331949d6a2a404c19dfd0d9d36d9c32274e6ffbc07c11350ee?response-content-disposition=attachment%3Bfilename*%3DUTF-8%27%27counties-albers-10m.json'
);
const features = new Map(
topojson.feature(us, us.objects.counties).features.map((d) => [d.id, d])
);
// Population should contain data about the density
const population = await d3.json(
'https://static.observableusercontent.com/files/beb56a2d9534662123fa352ffff2db8472e481776fcc1608ee4adbd532ea9ccf2f1decc004d57adc76735478ee68c0fd18931ba01fc859ee4901deb1bee2ed1b?response-content-disposition=attachment%3Bfilename*%3DUTF-8%27%27population.json'
);
const data = population.slice(1).map(([population, state, county]) => {
const id = state + county;
const feature = features.get(id);
return {
id,
position: feature && path.centroid(feature),
title: feature && feature.properties.name,
value: +population,
};
});
const radius = d3.scaleSqrt([0, d3.max(data, (d) => d.value)], [0, 40]);
const svg = d3.create('svg').attr('viewBox', [0, 0, width, height]);
svg
.append('path')
.datum(topojson.feature(us, us.objects.nation))
.attr('fill', '#ddd')
.attr('d', path);
svg
.append('path')
.datum(topojson.mesh(us, us.objects.states, (a, b) => a !== b))
.attr('fill', 'none')
.attr('stroke', 'white')
.attr('stroke-linejoin', 'round')
.attr('d', path);
const legend = svg
.append('g')
.attr('fill', '#777')
.attr('transform', 'translate(915,608)')
.attr('text-anchor', 'middle')
.style('font', '10px sans-serif')
.selectAll('g')
.data(radius.ticks(4).slice(1))
.join('g');
legend
.append('circle')
.attr('fill', 'none')
.attr('stroke', '#ccc')
.attr('cy', (d) => -radius(d))
.attr('r', radius);
legend
.append('text')
.attr('y', (d) => -2 * radius(d))
.attr('dy', '1.3em')
.text(radius.tickFormat(4, 's'));
svg
.append('g')
.attr('fill', 'brown')
.attr('fill-opacity', 0.5)
.attr('stroke', '#fff')
.attr('stroke-width', 0.5)
.selectAll('circle')
.data(
data
.filter((d) => d.position)
.sort((a, b) => d3.descending(a.value, b.value))
)
.join('circle')
.attr('transform', (d) => `translate(${d.position})`)
.attr('r', (d) => radius(d.value))
.append('title')
.text((d) => `${d.title} ${format(d.value)}`);
this.chart = svg.node();
}
}
```
In the above snippet, notice how we import the **D3js** library. Of course, you can choose to use any other library of your choice.
This density map chart is inspired from [this one](https://observablehq.com/@d3/bubble-map).
The resulting chart can be resized to fit your use:
### Creating a Cohort Chart
This is another example to help you build a Cohort Chart.
```markup theme={null}
```
```javascript theme={null}
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import {
loadExternalStyle,
loadExternalJavascript,
} from 'client/utils/smart-view-utils';
function isValidHex(color) {
return /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(color);
}
function shadeColor(color, percent) {
//#
color = isValidHex(color) ? color : '#3f83a3'; //handling null color;
percent = 1.0 - Math.ceil(percent / 10) / 10;
var f = parseInt(color.slice(1), 16),
t = percent < 0 ? 0 : 255,
p = percent < 0 ? percent * -1 : percent,
R = f >> 16,
G = (f >> 8) & 0x00ff,
B = f & 0x0000ff;
return (
'#' +
(
0x1000000 +
(Math.round((t - R) * p) + R) * 0x10000 +
(Math.round((t - G) * p) + G) * 0x100 +
(Math.round((t - B) * p) + B)
)
.toString(16)
.slice(1)
);
}
export default class extends Component {
@service lianaServerFetch;
@tracked loaging = true;
constructor(...args) {
super(...args);
this.loadPlugin();
}
async loadPlugin() {
await loadExternalJavascript('https://d3js.org/d3.v6.min.js');
this.loaging = false;
this.renderChart();
}
getRows(data) {
var rows = [];
var keys = Object.keys(data);
var days = [];
var percentDays = [];
for (var key in keys) {
if (data.hasOwnProperty(keys[key])) {
days = data[keys[key]];
percentDays.push(keys[key]);
for (var i = 0; i < days.length; i++) {
percentDays.push(
i > 0 ? Math.round((days[i] / days[0]) * 100 * 100) / 100 : days[i]
);
}
rows.push(percentDays);
percentDays = [];
}
}
return rows;
}
@action
async renderChart() {
// To fetch data from the backend
// const data = await this.lianaServerFetch.fetch('/forest/custom-route', {});
const options = {
data: {
// You can use any data format, just change the getRows logic
'May 3, 2021': [79, 18, 16, 12, 16, 11, 7, 5],
'May 10, 2021': [168, 35, 28, 30, 24, 12, 10],
'May 17, 2021': [188, 42, 32, 34, 25, 18],
'May 24, 2021': [191, 42, 32, 28, 12],
'May 31, 2021': [191, 45, 34, 30],
'June 7, 2021': [184, 42, 32],
'June 14, 2021': [182, 44],
},
title: 'Retention rates by weeks after sign-up',
};
var graphTitle = options.title || 'Retention Graph';
var data = options.data || null;
const container = d3.select('#demo').append('div').attr('class', 'box');
var header = container
.append('div')
.attr('class', 'box-header with-border');
var title = header.append('p').attr('class', 'box-title').text(graphTitle);
var body = container.append('div').attr('class', 'box-body');
var table = body
.append('table')
.attr('class', 'table table-bordered text-center');
var headData = ['Cohort', 'New users', '1', '2', '3', '4', '5', '6', '7'];
var tHead = table
.append('thead')
.append('tr')
.attr('class', 'retention-thead')
.selectAll('td')
.data(headData)
.enter()
.append('td')
.attr('class', function (d, i) {
if (i == 0) return 'retention-date';
else return 'days';
})
.text(function (d) {
return d;
});
var rowsData = this.getRows(data);
var tBody = table.append('tbody');
var rows = tBody.selectAll('tr').data(rowsData).enter().append('tr');
var cells = rows
.selectAll('td')
.data(function (row, i) {
return row;
})
.enter()
.append('td')
.attr('class', function (d, i) {
if (i == 0) return 'retention-date';
else return 'days';
})
.attr('style', function (d, i) {
if (i > 1) return 'background-color :' + shadeColor('#00c4b4', d);
})
.append('div')
.attr('data-toggle', 'tooltip')
.text(function (d, i) {
return d + (i > 1 ? '%' : '');
});
}
}
```
In the above snippet, notice how we import the **D3js** library. Of course, you can choose to use any other library of your choice.
```css theme={null}
.c-smart-chart {
display: flex;
white-space: normal;
bottom: 0;
left: 0;
right: 0;
top: 0;
background-color: var(--color-beta-surface);
}
.box {
position: relative;
border-radius: 3px;
background: #ffffff;
width: 100%;
}
.box-body {
max-height: 500px;
overflow: auto;
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px;
}
.box-header {
color: #444;
display: block;
padding: 10px;
position: relative;
}
.box-header .box-title {
display: inline-block;
font-size: 18px;
margin: 0;
line-height: 1;
}
.box-title {
display: inline-block;
font-size: 18px;
margin: 0;
line-height: 1;
}
.retention-thead,
.retention-date {
background-color: #cfcfcf;
font-weight: 700;
padding: 8px;
}
.days {
cursor: pointer;
padding: 8px;
text-align: center;
}
```
The resulting chart can be resized to fit your use:
# Create an API-based Chart
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/charts/create-an-api-based-chart
### Creating an API-based Chart
Sometimes, charts data are complicated and closely tied to your business. Forest allows you to code how the chart is computed. Choose **API** as the data source when configuring your chart.
Forest will make the HTTP call to Smart Chart URL when retrieving the chart values for the rendering.
### Value API-based Chart
On our Live Demo, we have a `MRR` value chart which computes our Monthly Recurring Revenue. This chart queries the Stripe API to get all charges made in the current month (in March for this example).
When serializing the data, we use the `Liana.StatSerializer()` serializer. Check the `value` syntax below.
```
{ value: }
```
```javascript theme={null}
const P = require('bluebird');
const express = require('express');
const router = express.Router();
const Liana = require('forest-express-sequelize');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const moment = require('moment');
...
router.post('/stats/mrr', (req, res) => {
let mrr = 0;
let from = moment.utc('2018-03-01').unix();
let to = moment.utc('2018-03-31').unix();
return stripe.charges
.list({
created: { gte: from, lte: to }
})
.then((response) => {
return P.each(response.data, (charge) => {
mrr += charge.amount;
});
})
.then(() => {
let json = new Liana.StatSerializer({
value: mrr
}).perform();
res.send(json);
});
});
...
module.exports = router;
```
When serializing the data, we use the `Liana.StatSerializer()` serializer. Check the `value` syntax below.
```
{ value: }
```
```javascript theme={null}
const P = require('bluebird');
const express = require('express');
const router = express.Router();
const Liana = require('forest-express-mongoose');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const moment = require('moment');
...
router.post('/stats/mrr', (req, res) => {
let mrr = 0;
let from = moment.utc('2018-03-01').unix();
let to = moment.utc('2018-03-31').unix();
return stripe.charges
.list({
created: { gte: from, lte: to }
})
.then((response) => {
return P.each(response.data, (charge) => {
mrr += charge.amount;
});
})
.then(() => {
let json = new Liana.StatSerializer({
value: mrr
}).perform();
res.send(json);
});
});
...
module.exports = router;
```
When serializing the data, we use the `serialize_model()` method. Check the `value` syntax below.
```
{ value: }
```
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
post '/stats/mrr' => 'charts#mrr'
end
mount ForestLiana::Engine => '/forest'
end
```
```ruby theme={null}
class Forest::ChartsController < ForestLiana::ApplicationController
def mrr
mrr = 0
from = Date.parse('2018-03-01').to_time(:utc).to_i
to = Date.parse('2018-03-31').to_time(:utc).to_i
Stripe::Charge.list({
created: { gte: from, lte: to },
limit: 100
}).each do |charge|
mrr += charge.amount / 100
end
stat = ForestLiana::Model::Stat.new({ value: mrr })
render json: serialize_model(stat)
end
end
```
### Repartition API-based Chart
On our Live Demo, we have a `Charges` repartition chart which shows a repartition chart distributed by credit card country. This chart queries the Stripe API to get all charges made in the current month (in March for this example) and check the credit card country.
When serializing the data, we use the `Liana.StatSerializer()` serializer. Check the `value` syntax below.
```
{
value: [{
key: ,
value:
}, {
key: ,
value:
}, …]
}
```
```javascript theme={null}
const _ = require('lodash');
const P = require('bluebird');
const express = require('express');
const router = express.Router();
const Liana = require('forest-express-sequelize');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const moment = require('moment');
router.post(
'/stats/credit-card-country-repartition',
Liana.ensureAuthenticated,
(req, res) => {
let repartition = [];
let from = moment.utc('2018-03-01').unix();
let to = moment.utc('2018-03-20').unix();
return stripe.charges
.list({
created: { gte: from, lte: to },
})
.then((response) => {
return P.each(response.data, (charge) => {
let country = charge.source.country || 'Others';
let entry = _.find(repartition, { key: country });
if (!entry) {
repartition.push({ key: country, value: 1 });
} else {
entry.value++;
}
});
})
.then(() => {
let json = new Liana.StatSerializer({
value: repartition,
}).perform();
res.send(json);
});
}
);
module.exports = router;
```
When serializing the data, we use the `Liana.StatSerializer()` serializer. Check the `value` syntax below.
```
{
value: [{
key: ,
value:
}, {
key: ,
value:
}, …]
}
```
```javascript theme={null}
const _ = require('lodash');
const P = require('bluebird');
const express = require('express');
const router = express.Router();
const Liana = require('forest-express-mongoose');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const moment = require('moment');
router.post(
'/stats/credit-card-country-repartition',
Liana.ensureAuthenticated,
(req, res) => {
let repartition = [];
let from = moment.utc('2018-03-01').unix();
let to = moment.utc('2018-03-20').unix();
return stripe.charges
.list({
created: { gte: from, lte: to },
})
.then((response) => {
return P.each(response.data, (charge) => {
console.log(charge.source);
let country = charge.source.country || 'Others';
let entry = _.find(repartition, { key: country });
if (!entry) {
repartition.push({ key: country, value: 1 });
} else {
entry.value++;
}
});
})
.then(() => {
let json = new Liana.StatSerializer({
value: repartition,
}).perform();
res.send(json);
});
}
);
module.exports = router;
```
When serializing the data, we use the `serialize_model()` method. Check the `value` syntax below.
```
{
value: [{
key: ,
value:
}, {
key: ,
value:
}, …]
}
```
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
post '/stats/credit-card-country-repartition' => 'charts#credit_card_country_repartition'
end
mount ForestLiana::Engine => '/forest'
end
```
```ruby theme={null}
class Forest::ChartsController < ForestLiana::ApplicationController
def credit_card_country_repartition
repartition = []
from = Date.parse('2018-03-01').to_time(:utc).to_i
to = Date.parse('2018-03-20').to_time(:utc).to_i
Stripe::Charge.list({
created: { gte: from, lte: to },
limit: 100
}).each do |charge|
country = charge.source.country || 'Others'
entry = repartition.find { |e| e[:key] == country }
if !entry
repartition << { key: country, value: 1 }
else
++entry[:value]
end
end
stat = ForestLiana::Model::Stat.new({ value: repartition })
render json: serialize_model(stat)
end
end
```
```
{
value: [{
key: ,
value:
}, {
key: ,
value:
}, …]
}
```
### Time-based API-based Chart
On our Live Demo, we have a `Charges` time-based chart which shows the number of charges per day. This chart queries the Stripe API to get all charges made in the current month (in March for this example) and group data by day.
When serializing the data, we use the `Liana.StatSerializer()` serializer. Check the `value` syntax below.
```
{
value: [{
label: ,
values: { value: }
}, {
label: ,
values: { value: }
}, …]
}
```
```javascript theme={null}
const _ = require('lodash');
const P = require('bluebird');
const express = require('express');
const router = express.Router();
const Liana = require('forest-express-sequelize');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const moment = require('moment');
router.post('/stats/charges-per-day', (req, res) => {
let values = [];
let from = moment.utc('2018-03-01').unix();
let to = moment.utc('2018-03-31').unix();
return stripe.charges
.list({
created: { gte: from, lte: to },
})
.then((response) => {
return P.each(response.data, (charge) => {
let date = moment.unix(charge.created).startOf('day').format('LLL');
let entry = _.find(values, { label: date });
if (!entry) {
values.push({ label: date, values: { value: 1 } });
} else {
entry.values.value++;
}
});
})
.then(() => {
let json = new Liana.StatSerializer({
value: values,
}).perform();
res.send(json);
});
});
module.exports = router;
```
When serializing the data, we use the `Liana.StatSerializer()` serializer. Check the `value` syntax below.
```
{
value: [{
label: ,
values: { value: }
}, {
label: ,
values: { value: }
}, …]
}
```
```javascript theme={null}
const _ = require('lodash');
const P = require('bluebird');
const express = require('express');
const router = express.Router();
const Liana = require('forest-express-mongoose');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const moment = require('moment');
router.post('/stats/charges-per-day', (req, res) => {
let values = [];
let from = moment.utc('2018-03-01').unix();
let to = moment.utc('2018-03-31').unix();
return stripe.charges
.list({
created: { gte: from, lte: to },
})
.then((response) => {
return P.each(response.data, (charge) => {
let date = moment.unix(charge.created).startOf('day').format('LLL');
let entry = _.find(values, { label: date });
if (!entry) {
values.push({ label: date, values: { value: 1 } });
} else {
entry.values.value++;
}
});
})
.then(() => {
let json = new Liana.StatSerializer({
value: values,
}).perform();
res.send(json);
});
});
module.exports = router;
```
When serializing the data, we use the `serialize_model()` method. Check the `value` syntax below.
```
{
value: [{
label: ,
values: { value: }
}, {
label: ,
values: { value: }
}, …]
}
```
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
post '/stats/charges-per-day' => 'charts#charges_per_day'
end
mount ForestLiana::Engine => '/forest'
end
```
```ruby theme={null}
class Forest::ChartsController < ForestLiana::ApplicationController
def charges_per_day
values = []
from = Date.parse('2018-03-01').to_time(:utc).to_i
to = Date.parse('2018-03-31').to_time(:utc).to_i
Stripe::Charge.list({
created: { gte: from, lte: to },
limit: 100
}).each do |charge|
date = Time.at(charge.created).beginning_of_day.strftime("%d/%m/%Y")
entry = values.find { |e| e[:label] == date }
if !entry
values << { label: date, values: { value: 1 } }
else
++entry[:values][:value]
end
end
stat = ForestLiana::Model::Stat.new({ value: values })
render json: serialize_model(stat)
end
end
```
```
{
value: [{
label: ,
values: { value: }
}, {
label: ,
values: { value: }
}, …]
}
```
### Objective API-based Chart
Creating an Objective Smart Chart means you'll be fetching your data from an external API endpoint:
This endpoint must return data with the following format:
```
{
value: {
value: xxxx,
objective: yyyy
}
}
```
Here's how you could implement it:
```javascript theme={null}
// [...]
const Liana = require('forest-express-sequelize');
// [...]
router.post('/stats/some-objective', (req, res) => {
// fetch your data here (a promise must be returned)
.then(() => {
let json = new Liana.StatSerializer({
value: {
value: fetchedValue,
objective: fetchedObjective
}
}).perform();
res.send(json);
}
}
```
```javascript theme={null}
// [...]
const Liana = require('forest-express-mongoose');
// [...]
router.post('/stats/some-objective', (req, res) => {
// fetch your data here (a promise must be returned)
.then(() => {
let json = new Liana.StatSerializer({
value: {
value: fetchedValue,
objective: fetchedObjective
}
}).perform();
res.send(json);
}
}
```
```ruby theme={null}
...
namespace :forest do
post '/stats/some-objective' => 'customers#some_objective'
end
...
```
```ruby theme={null}
...
def some_objective
# fetch your data here
stat = ForestLiana::Model::Stat.new({
value: {
value: 10, # the fetched value
objective: 678 # the fetched objective
}
})
render json: serialize_model(stat)
end
...
```
```
{
value: {
value: xxxx,
objective: yyyy
}
}
```
# Create Charts with AWS Redshift
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/charts/create-charts-with-aws-redshift
This example shows you how to create a graph based on AWS Redshift.
This could be useful if you want to avoid making graphs directly from your production database.
This tutorial is based on [this database sample](https://docs.aws.amazon.com/redshift/latest/gsg/rs-gsg-create-sample-db.html).
We'll create 2 charts:
1. Number of users (*single value chart*)
2. Top 5 buyers (*leaderboard chart*)
## Connect to a Redshift Database
Install the [NodeJS package](https://www.npmjs.com/package/node-redshift) for your Forest project
```bash theme={null}
node install node-redshift --save
```
Create the database client and set up the credentials variables cf. package documentation: [https://www.npmjs.com/package/node-redshift](https://www.npmjs.com/package/node-redshift).
```javascript theme={null}
var Redshift = require('node-redshift');
var clientCredentials = {
host: process.env.REDSHIFT_HOST,
port: process.env.REDSHIFT_PORT,
database: process.env.REDSHIFT_DATABASE,
user: process.env.REDSHIFT_DB_USER,
password: process.env.REDSHIFT_DB_PASSWORD,
};
const redshiftClient = new Redshift(clientCredentials);
```
Configure your database credentials in your env variables
## Create the Single Value Chart
Step 1 - Create a Single Value Smart Chart in the Forest Project Dashboard.
[Learn more about Smart Chart](/legacy/javascript-agents/reference-guide/charts/create-a-smart-chart)
Step 2 - Create the route to handle the Smart Chart
```javascript theme={null}
const express = require('express');
const router = express.Router();
const Liana = require('forest-express');
...
router.post('/stats/nb-users', Liana.ensureAuthenticated, async (request, response) => {
const query = `
SELECT count(*) as nb
FROM users
`;
const data = await redshiftClient.query(query);
let json = new Liana.StatSerializer({
value: data.rows[0].nb
}).perform();
response.send(json);
});
```
## Create the Leaderboard Chart
Step 1 - Create a Leaderboard Smart Chart in the Forest Project Dashboard.
Learn more about [Smart charts](/legacy/javascript-agents/reference-guide/charts/create-a-smart-chart)
Step 2 - Create the route to handle the Smart Chart
```javascript theme={null}
const express = require('express');
const router = express.Router();
const Liana = require('forest-express');
...
router.post('/stats/top-5-buyers', Liana.ensureAuthenticated, async (request, response) => {
const query = `
SELECT firstname || ' ' || lastname AS key, total_quantity AS value
FROM (SELECT buyerid, sum(qtysold) total_quantity
FROM sales
GROUP BY buyerid
ORDER BY total_quantity desc limit 5) Q, users
WHERE Q.buyerid = userid
ORDER BY Q.total_quantity desc
`;
const data = await redshiftClient.query(query);
let leaderboard = data.rows;
let json = new Liana.StatSerializer({
value: leaderboard
}).perform();
response.send(json);
});
```
## Result
# Charts
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/charts/overview
As an admin user, KPIs are paramount to follow day by day. Your customers’ growth, Monthly Recurring Revenue (MRR), Paid VS Free accounts are some common examples.
### What types of charts exist in Forest?
Forest can render six types of charts:
* Single value (Number of customers, MRR, …)
* Repartition (Number of customers by countries, Paid VS Free, …)
Only the 5 biggest categories will be displayed separately. All the others will go into a 6th "Other" category.
* Time-based (Number of sign-ups per month, …)
* Percentage (% of paying customers, …)
* Objective (Orders passed per year VS objective, …)
* Leaderboard (Companies who emitted the most transactions, …)
Ensure you’ve enabled the `Layout Editor` mode to add, edit or delete a chart.
### Where can you add charts?
Charts can be added in 2 places:
* In your **Dashboard** tab
* In the **Analytics** tab of every record of a collection
In the following pages, you'll learn how to create all types of charts.
# Integrations
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/README
## Integrations
Forest is able to leverage data from third party services by reconciliating it with your application’s data, providing it directly to your admin. All your admin actions can be performed at the same place, bringing additional intelligence to your admin and ensuring consistency.
# Algolia
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/algolia/README
# Geocode an address with Algolia
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/algolia/geocode-an-address-with-algolia
This example shows you how to use an autocomplete address smart field to update a PostreSQL geography point (lat, long).
## Requirements
* An admin backend running on forest-express-sequelize
* An algolia account
* [algoliasearch](https://www.npmjs.com/package/algoliasearch) npm package
## How it works
### Directory: /models
This directory contains the `events.js` file where the model is declared.
```javascript theme={null}
module.exports = (sequelize, DataTypes) => {
const Model = sequelize.define(
'events',
{
name: {
type: DataTypes.STRING,
primaryKey: true,
},
locationGeo: {
type: DataTypes.GEOMETRY('POINT', 4326),
},
address: {
type: DataTypes.STRING,
},
},
{
tableName: 'events',
underscored: true,
timestamps: false,
schema: process.env.DATABASE_SCHEMA,
}
);
Model.removeAttribute('id');
Model.associate = () => {};
return Model;
};
```
### Directory: /forest
This directory contains the `events.js` file where the Smart Field `Location setter`is declared.\
\
This smart field will be used to update the value of the `address`and `locationGeo` fields.
```javascript theme={null}
const algoliasearch = require('algoliasearch');
const places = algoliasearch.initPlaces(
process.env.PLACES_APP_ID,
process.env.PLACES_API_KEY
);
async function getLocationCoordinates(query) {
try {
const location = await places.search({ query, type: 'address' });
console.log('search location coordinates result', location.hits[0]._geoloc);
return location.hits[0]._geoloc;
} catch (err) {
console.log(err);
return null;
}
}
async function setEvent(event, query) {
const coordinates = await getLocationCoordinates(query);
event.address = query;
event.locationGeo = `{"type": "Point", "coordinates": [${coordinates.lat}, ${coordinates.lng}]}`;
console.log('new address', event.address);
console.log('new location', event.locationGeo);
return event;
}
collection('events', {
fields: [
{
field: 'Location setter',
type: 'String',
// Get the data to be displayed.
get: (event) => event.address,
// Update using Algolia.
set: (event, query) => setEvent(event, query),
},
],
});
```
The field `Location setter` should use the [address edit widget](https://docs.forestadmin.com/user-guide/collections/customize-your-fields/edit-widgets#address) to enable address autocomplete.
# Azure Table Storage
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/azure-table-storage
This How to is based on the [Medium article](https://avarnon.medium.com/exposing-azure-table-storage-through-forest-admin-2d601752f9b1) by [Andrew Varnon](https://avarnon.medium.com/)
The implementation is done using a [Smart Collection](https://docs.forestadmin.com/documentation/reference-guide/collections/create-a-smart-collection) and a CRUD service that will wrap the [Azure Table Storage API](https://docs.microsoft.com/en-us/rest/api/storageservices/table-service-rest-api).
### The Table Storage Definition
You can use the new [Azure Data Explorer](https://azure.microsoft.com/en-us/services/data-explorer/) to create and populate a Table Storage in your [Azure Storage account](https://docs.microsoft.com/en-us/azure/storage/common/storage-account-overview).
In our example, we are going to use the Table Customers with the fields:
* **Id**: PartitionKey + RowKey
* **Timestamp** (updated at)
* **Email** as String
* **FirstName** as String
* **LastName** as String
### Install Azure `data-tables` package
```haskell theme={null}
npm install @azure/data-tables --save
```
### Smart Collection definition
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('customers', {
fields: [
{
field: 'id',
type: 'String',
get: (customer) => `${customer.partitionKey}|${customer.rowKey}`,
},
{
field: 'partitionKey',
type: 'String',
},
{
field: 'rowKey',
type: 'String',
},
{
field: 'timestamp',
type: 'Date',
},
{
field: 'Email',
type: 'String',
},
{
field: 'LastName',
type: 'String',
},
{
field: 'FirstName',
type: 'String',
},
],
});
```
### The Azure Data Tables Service Wrapper
```javascript theme={null}
const { TableClient } = require('@azure/data-tables');
const getClient = (tableName) => {
const client = TableClient.fromConnectionString(
process.env.AZURE_STORAGE_CONNECTION_STRING,
tableName
);
return client;
};
const azureTableStorageService = {
deleteEntityAsync: async (tableName, partitionKey, rowKey) => {
const client = getClient(tableName);
await client.deleteEntity(partitionKey, rowKey);
},
getEntityAsync: async (tableName, partitionKey, rowKey) => {
const client = getClient(tableName);
return client.getEntity(partitionKey, rowKey);
},
listEntitiesAsync: async (tableName, options) => {
const client = getClient(tableName);
var azureResponse = await client.listEntities();
let iterator = await azureResponse.byPage({
maxPageSize: options.pageSize,
});
for (let i = 1; i < options.pageNumber; i++) iterator.next(); // Skip pages
let entities = await iterator.next();
let records = entities.value.filter((entity) => entity.etag);
// Load an extra page if we need to allow (Next Page)
const entitiesNextPage = await iterator.next();
let nbNextPage = 0;
if (entitiesNextPage && entitiesNextPage.value) {
nbNextPage = entitiesNextPage.value.filter(
(entity) => entity.etag
).length;
}
// Azure Data Tables does not provide a row count.
// We just inform the user there is a new page with at least x items
const minimumRowEstimated =
(options.pageNumber - 1) * options.pageSize + records.length + nbNextPage;
return { records, count: minimumRowEstimated };
},
createEntityAsync: async (tableName, entity) => {
const client = getClient(tableName);
delete entity['__meta__'];
await client.createEntity(entity);
return client.getEntity(entity.partitionKey, entity.rowKey);
},
updateEntityAsync: async (tableName, entity) => {
const client = getClient(tableName);
await client.updateEntity(entity, 'Replace');
return client.getEntity(entity.partitionKey, entity.rowKey);
},
};
module.exports = azureTableStorageService;
```
### Routes definition
```javascript theme={null}
const express = require('express');
const {
PermissionMiddlewareCreator,
RecordCreator,
RecordUpdater,
} = require('forest-express');
const { RecordSerializer } = require('forest-express');
const router = express.Router();
const COLLECTION_NAME = 'customers';
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
COLLECTION_NAME
);
const recordSerializer = new RecordSerializer({ name: COLLECTION_NAME });
const azureTableStorageService = require('../services/azure-table-storage-service');
// Get a list of Customers
router.get(
`/${COLLECTION_NAME}`,
permissionMiddlewareCreator.list(),
async (request, response, next) => {
const pageSize = parseInt(request.query.page.size) || 15;
const pageNumber = parseInt(request.query.page.number);
azureTableStorageService
.listEntitiesAsync(COLLECTION_NAME, { pageSize, pageNumber })
.then(async ({ records, count }) => {
const recordsSerialized = await recordSerializer.serialize(records);
response.send({ ...recordsSerialized, meta: { count } });
})
.catch((e) => {
console.error(e);
next(e);
});
}
);
// Get a Customer
router.get(
`/${COLLECTION_NAME}/:recordId`,
permissionMiddlewareCreator.details(),
async (request, response, next) => {
const parts = request.params.recordId.split('|');
azureTableStorageService
.getEntityAsync(COLLECTION_NAME, parts[0], parts[1])
.then((record) => recordSerializer.serialize(record))
.then((recordSerialized) => response.send(recordSerialized))
.catch((e) => {
console.error(e);
next(e);
});
}
);
// Create a Customer
router.post(
`/${COLLECTION_NAME}`,
permissionMiddlewareCreator.create(),
async (request, response, next) => {
const recordCreator = new RecordCreator(
{ name: COLLECTION_NAME },
request.user,
request.query
);
recordCreator
.deserialize(request.body)
.then((recordToCreate) => {
return azureTableStorageService.createEntityAsync(
COLLECTION_NAME,
recordToCreate
);
})
.then((record) => recordSerializer.serialize(record))
.then((recordSerialized) => response.send(recordSerialized))
.catch((e) => {
console.error(e);
next(e);
});
}
);
// Update a Customer
router.put(
`/${COLLECTION_NAME}/:recordId`,
permissionMiddlewareCreator.update(),
async (request, response, next) => {
const parts = request.params.recordId.split('|');
const recordUpdater = new RecordUpdater(
{ name: COLLECTION_NAME },
request.user,
request.query
);
recordUpdater
.deserialize(request.body)
.then((recordToUpdate) => {
recordToUpdate.partitionKey = parts[0];
recordToUpdate.rowKey = parts[1];
return azureTableStorageService.updateEntityAsync(
COLLECTION_NAME,
recordToUpdate
);
})
.then((record) => recordSerializer.serialize(record))
.then((recordSerialized) => response.send(recordSerialized))
.catch((e) => {
console.error(e);
next(e);
});
}
);
// Delete a list of Customers
router.delete(
`/${COLLECTION_NAME}`,
permissionMiddlewareCreator.delete(),
async (request, response, next) => {
try {
for (const key of request.body.data.attributes.ids) {
const parts = key.split('|');
await azureTableStorageService.deleteEntityAsync(
COLLECTION_NAME,
parts[0],
parts[1]
);
}
response.status(204).send();
} catch (e) {
console.error(e);
next(e);
}
}
);
module.exports = router;
```
# Dwolla
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/dwolla/README
The following section will provide you with a set of examples to implement a custom integration of [Dwolla](https://www.dwolla.com/)
# Display Dwolla customers
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/dwolla/display-dwolla-customers
This example shows you how to create a smart collection to list the customers of your [Dwolla](https://www.dwolla.com/) account.
## 1. Define the smart collection
Filterable fields are flagged using `isFilterable: true`. You will need to enable this option using the collection settings in the [Layout Editor](https://docs.forestadmin.com/user-guide/getting-started/master-your-ui/using-the-layout-editor-mode).
Customers have `isSearchable` flag enabled: it means the search input field will be activated on the collection UI.
```javascript theme={null}
// forest/dwolla-customers.js
const { collection } = require('forest-express-sequelize');
collection('dwollaCustomers', {
isSearchable: true,
actions: [],
fields: [
{
field: 'id',
type: 'String',
},
{
field: 'firstName',
type: 'String',
},
{
field: 'lastName',
type: 'String',
},
{
field: 'fullName',
type: 'String',
get: (customer) => {
return customer.firstName + ' ' + customer.lastName;
},
},
{
field: 'type',
type: 'Enum',
enums: ['unverified', 'personal', 'business', 'receive-only'],
},
{
field: 'email',
type: 'String',
isFilterable: true,
},
{
field: 'businessName',
type: 'String',
isFilterable: true,
},
{
field: 'created', //created_at
type: 'Date',
},
{
field: 'status',
type: 'Enum',
enums: ['unverified', 'suspended', 'retry', 'document', 'verified'],
isFilterable: true,
},
{
field: 'fundingSources',
type: ['String'],
reference: 'dwollaFundingSources.id',
},
{
field: 'transfers',
type: ['String'],
reference: 'dwollaTransfers.id',
},
],
segments: [],
});
```
## 2. Implement the route
The Customers routes implement the Get List and Get One, plus the [smart relationships (HasMany)](https://docs.forestadmin.com/documentation/reference-guide/relationships/create-a-smart-relationship#creating-a-hasmany-smart-relationship):
* Funding Sources
* Transfers
These routes use the Dwolla service described in [another section](https://docs.forestadmin.com/woodshop/how-tos/dwolla-integration/dwolla-servive).
```javascript theme={null}
// routes/customers.js
const express = require('express');
const {
PermissionMiddlewareCreator,
RecordSerializer,
} = require('forest-express-sequelize');
const DwollaService = require('../services/dwolla-service');
let dwollaService = new DwollaService(
process.env.DWOLLA_APP_KEY,
process.env.DWOLLA_APP_SECRET,
process.env.DWOLLA_ENVIRONMENT
);
const MODEL_NAME = 'dwollaCustomers';
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
`${MODEL_NAME}`
);
// Get a list of Customers
router.get(
`/${MODEL_NAME}`,
permissionMiddlewareCreator.list(),
(request, response, next) => {
dwollaService
.getCustomers(request.query)
.then(async (result) => {
const recordSerializer = new RecordSerializer({ name: MODEL_NAME });
const recordsSerialized = await recordSerializer.serialize(result.list);
response.send({ ...recordsSerialized, meta: { count: result.count } });
})
.catch(next);
}
);
// Get a Customer
router.get(
`/${MODEL_NAME}/:recordId`,
permissionMiddlewareCreator.details(),
(request, response, next) => {
const recordId = request.params.recordId;
dwollaService
.getCustomer(recordId)
.then(async (record) => {
const recordSerializer = new RecordSerializer({ name: MODEL_NAME });
const recordSerialized = await recordSerializer.serialize(record);
response.send(recordSerialized);
})
.catch(next);
}
);
router.get(
`/${MODEL_NAME}/:recordId/relationships/fundingSources`,
(request, response, next) => {
const recordId = request.params.recordId;
dwollaService
.getCustomerFundingSources(recordId, request.query)
.then(async (result) => {
const recordSerializer = new RecordSerializer({
name: 'dwollaFundingSources',
});
const recordsSerialized = await recordSerializer.serialize(result.list);
response.send({ ...recordsSerialized, meta: { count: result.count } });
})
.catch(next);
}
);
router.get(
`/${MODEL_NAME}/:recordId/relationships/transfers`,
(request, response, next) => {
const recordId = request.params.recordId;
dwollaService
.getCustomerTransfers(recordId, request.query)
.then(async (result) => {
const recordSerializer = new RecordSerializer({
name: 'dwollaTransfers',
});
const recordsSerialized = await recordSerializer.serialize(result.list);
response.send({ ...recordsSerialized, meta: { count: result.count } });
})
.catch(next);
}
);
module.exports = router;
```
# Display Dwolla funding sources
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/dwolla/display-dwolla-funding-sources
## 1. Define the smart collection
Filterable fields are flagged using `isFilterable: true`. You will need to enable this option using the collection settings in the [Layout Editor](https://docs.forestadmin.com/user-guide/getting-started/master-your-ui/using-the-layout-editor-mode).
Funding Sources have the `onlyForRelationships` enabled: it means that these 2 collections are only accessible via the Dwolla customer relationships.
```javascript theme={null}
// forest/dwolla-funding-sources.js
const { collection } = require('forest-express-sequelize');
collection('dwollaFundingSources', {
onlyForRelationships: true,
actions: [],
fields: [
{
field: 'id',
type: 'String',
},
{
field: 'status',
type: 'Enum',
enums: ['unverified', 'verified'],
},
{
field: 'type',
type: 'Enum',
enums: ['bank', 'balance'],
},
{
field: 'bankAccountType',
type: 'Enum',
enums: ['checking', 'savings', 'general-ledger', 'loan'],
},
{
field: 'name',
type: 'String',
},
{
field: 'balance',
type: 'Json',
},
{
field: 'balanceReadable',
type: 'String',
get: (fundingSource) => {
if (!fundingSource.balance) return null;
var formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: fundingSource.balance.currency,
});
return formatter.format(fundingSource.balance.value);
},
},
{
field: 'removed',
type: 'Boolean',
},
{
field: 'channels',
type: ['String'],
},
{
field: 'bankName',
type: 'String',
},
{
field: 'fingerprint',
type: 'String',
},
{
field: 'created', //created_at
type: 'Date',
},
],
segments: [],
});
```
## 2. Implement the route
This route use the Dwolla service described in [another section](/legacy/javascript-agents/reference-guide/integrations/dwolla/dwolla-service).
```javascript theme={null}
// routes/funding-sources.js
const express = require('express');
const {
PermissionMiddlewareCreator,
RecordSerializer,
} = require('forest-express-sequelize');
const DwollaService = require('../services/dwolla-service');
let dwollaService = new DwollaService(
process.env.DWOLLA_APP_KEY,
process.env.DWOLLA_APP_SECRET,
process.env.DWOLLA_ENVIRONMENT
);
const MODEL_NAME = 'dwollaFundingSources';
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
`${MODEL_NAME}`
);
// Get a FundingSource
router.get(
`/${MODEL_NAME}/:recordId`,
permissionMiddlewareCreator.details(),
(request, response, next) => {
const recordId = request.params.recordId;
dwollaService
.getFundingSource(recordId)
.then(async (record) => {
const recordSerializer = new RecordSerializer({ name: MODEL_NAME });
const recordSerialized = await recordSerializer.serialize(record);
response.send(recordSerialized);
})
.catch(next);
}
);
module.exports = router;
```
# Display Dwolla transfers
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/dwolla/display-dwolla-transfers
This example shows you how to create a smart collection to list the transfers of your [Dwolla](https://www.dwolla.com) account.
## 1. Define the smart collection
Filterable fields are flagged using `isFilterable: true`. You will need to enable this option using the collection settings in the [Layout Editor](https://docs.forestadmin.com/user-guide/getting-started/master-your-ui/using-the-layout-editor-mode).
Transfers have the `onlyForRelationships` enabled: it means that these 2 collections are only accessible via the Dwolla customer relationships.
```javascript theme={null}
// forest/dwolla-transfers.js
const { collection } = require('forest-express-sequelize');
collection('dwollaTransfers', {
onlyForRelationships: true,
isSearchable: true,
actions: [],
fields: [
{
field: 'id',
type: 'String',
isSortable: true,
},
{
field: 'status',
type: 'Enum',
enums: ['processed', 'pending', 'cancelled', 'failed'],
isFilterable: true,
},
{
field: 'amount',
type: 'Json',
},
{
field: 'amountReadable',
type: 'String',
get: (transfer) => {
if (!transfer.amount) return null;
var formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: transfer.amount.currency,
// These options are needed to round to whole numbers if that's what you want.
//minimumFractionDigits: 0, // (this suffices for whole numbers, but will print 2500.10 as $2,500.1)
//maximumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501)
});
return formatter.format(transfer.amount.value);
},
},
{
field: 'metadata',
type: 'Json',
},
{
field: 'clearing',
type: 'Json',
},
{
field: 'clearing',
type: 'Json',
},
{
field: 'achDetails',
type: 'Json',
},
{
field: 'correlationId',
type: 'String',
isFilterable: true,
},
{
field: 'individualAchId',
type: 'String',
},
{
field: 'bankName',
type: 'String',
},
{
field: 'fingerprint',
type: 'String',
},
{
field: 'created', //created_at
type: 'Date',
},
],
segments: [],
});
```
## 2. Implement the route
This route use the Dwolla service described in [another section](/legacy/javascript-agents/reference-guide/integrations/dwolla/dwolla-service).
```javascript theme={null}
// routes/transfers.js
const express = require('express');
const {
PermissionMiddlewareCreator,
RecordSerializer,
} = require('forest-express-sequelize');
const DwollaService = require('../services/dwolla-service');
let dwollaService = new DwollaService(
process.env.DWOLLA_APP_KEY,
process.env.DWOLLA_APP_SECRET,
process.env.DWOLLA_ENVIRONMENT
);
const MODEL_NAME = 'dwollaTransfers';
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
`${MODEL_NAME}`
);
// Get a Transfer
router.get(
`/${MODEL_NAME}/:recordId`,
permissionMiddlewareCreator.details(),
(request, response, next) => {
const recordId = request.params.recordId;
dwollaService
.getTransfer(recordId)
.then(async (record) => {
const recordSerializer = new RecordSerializer({ name: MODEL_NAME });
const recordSerialized = await recordSerializer.serialize(record);
response.send(recordSerialized);
})
.catch(next);
}
);
module.exports = router;
```
# Dwolla Service
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/dwolla/dwolla-service
This service wraps the [Dwolla SDK ](https://developers.dwolla.com/sdks-tools#sdks--tools)and provides the following implementation:
* Pagination (on Customers & Transfers)
* Fields to be displayed on the UI (select)
* Search (on Customers & Transfers)
* Filters (on Customers, cf `isFilterable` flag)
### Prototype
```javascript theme={null}
"use strict";
const dwolla = require('dwolla-v2');
var _ = require('lodash');
class DwollaService {
// Allow to create a Dwolla Client based on the App Key a Secret
constructor(appKey, appSecret, environment);
// Get a List of Customers based on the query (page, filter, search, sort)
getCustomers (query);
// Get a Customer by Id
getCustomer (recordId);
// Get a Customer for a local database user (by email)
getCustomerSmartRelationship (user);
// Get a list of Funding Sources for a customer Id
getCustomerFundingSources (recordId, query);
// Get a Funding Source by Id
getFundingSource (recordId);
// Get a list of Transfers for a customer Id
getCustomerTransfers (recordId, query);
// Get a Transfer by Id
getTransfer (recordId);
}
module.exports = DwollaService;
```
# Link users and Dwolla customers
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/dwolla/link-users-and-dwolla-customers
The implementation of this [smart relationship (belongsTo](/legacy/javascript-agents/reference-guide/models/relationships/create-a-smart-relationship/overview#creating-a-belongsto-smart-relationship)) relies on a Dwolla service that will retrieve the Dwolla customer based on the user's email. The Dwolla service is described in [another section](/legacy/javascript-agents/reference-guide/integrations/dwolla/dwolla-service).
```javascript theme={null}
// forest/users.js
const { collection } = require('forest-express-sequelize');
const DwollaService = require('../services/dwolla-service');
let dwollaService = new DwollaService(
process.env.DWOLLA_APP_KEY,
process.env.DWOLLA_APP_SECRET,
process.env.DWOLLA_ENVIRONMENT
);
collection('users', {
actions: [],
fields: [
{
field: 'dwollaCustomer',
type: 'String',
reference: 'dwollaCustomers.id',
get: function (user) {
return dwollaService.getCustomerSmartRelationship(user);
},
},
],
segments: [],
});
```
# Readme
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/elasticsearch/README
# Another example
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/elasticsearch/another-example
For the purpose of this example let's say we have an `activity-logs` index in Elasticsearch with the following mapping.
```javascript theme={null}
{
"mappings": {
"_doc": {
"dynamic": "strict",
"properties": {
"action": {
"type": "keyword"
},
"label": {
"type": "text",
"index_options": "docs",
"norms": false
},
"userId": {
"type": "keyword"
},
"collectionId": {
"type": "keyword"
},
"createdAt": {
"type": "date"
}
}
}
}
}
```
## Creating the Smart Collection with related data
First, we declare the `activity-logs` collection in the `forest/` directory.
In this Smart Collection, we want to display for each activity log its action, the label (in a field description), the **related user** that made the activity, the collectionId on which the activity was made and the date the activity was made by the user.
You can check out the list of [available field options](https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields#available-field-options) if you need them for your own case.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
const models = require('../models');
collection('activity-logs', {
isSearchable: false,
fields: [
{
field: 'id',
type: 'string',
},
{
field: 'action',
type: 'Enum',
isFilterable: true,
enums: [
'create',
'read',
'update',
'delete',
'action',
'search',
'filter',
],
},
{
field: 'label',
type: 'String',
isFilterable: true,
},
{
field: 'collectionId',
type: 'String',
isFilterable: true,
},
{
field: 'userId',
type: 'String',
isFilterable: true,
},
{
field: 'createdAt',
type: 'Date',
isFilterable: true,
},
{
field: 'user',
type: 'Number',
reference: 'users.id',
get: async (activityLog) => {
// For search queries, the user is already loaded for performance reasons
if (activityLog.user) {
return activityLog.user;
}
if (!activityLog.userId) {
return null;
}
return models.users.findOne({
attributes: ['id', 'firstName', 'lastName', 'email'],
paranoid: false,
where: {
id: activityLog.userId,
},
});
},
},
{
field: 'user_email',
type: 'String',
isFilterable: true,
get: (activityLog) => {
// The field is declared after, when processed, the user has already been retrieved
return activityLog.user.email;
},
},
],
});
```
## Implementing the GET (all records with a filter on related data)
This is a complex use case: How to handle filters on related data. We want to be able to filter using the `user.mail` field.\
\
To accommodate you we already provide you a simple service [`ElasticsearchHelper`](https://docs.forestadmin.com/woodshop/how-tos/create-a-smart-collection-with-elasticsearch/elasticsearch-service-utils) that handles all the logic to connect with your Elasticsearch data.
```javascript theme={null}
const express = require('express');
const router = express.Router();
const models = require('../models');
// We need parseFilter utils to create the where clause for sequelize
const {
RecordSerializer,
Schemas,
parseFilter,
} = require('forest-express-sequelize');
const ElasticsearchHelper = require('../service/elasticsearch-helper');
const { FIELD_DEFINITIONS } = require('../utils/filter-translator');
const serializer = new RecordSerializer({ name: 'es-activity-logs' });
// Custom mapping function
function mapActivityLog(id, source) {
const { createdAt, ...simpleProperties } = source;
return {
id,
...simpleProperties,
createdAt: source.createdAt ? new Date(source.createdAt) : null,
};
}
const configuration = {
index: 'activity-logs-*',
filterDefinition: {
action: FIELD_DEFINITIONS.keyword,
label: FIELD_DEFINITIONS.text,
collectionId: FIELD_DEFINITIONS.keyword,
userId: FIELD_DEFINITIONS.keyword,
createdAt: FIELD_DEFINITIONS.date,
},
mappingFunction: mapActivityLog,
sort: [{ createdAt: { order: 'desc' } }],
};
const elasticsearchHelper = new ElasticsearchHelper(configuration);
// Specific implementation to handle related data
async function computeUserFilter(models, filter, options) {
const where = await parseFilter(
{
...filter,
field: filter.field.replace('user_', ''),
},
Schemas.schemas.users,
options.timezone
);
const users = await models.users.findAll({
where,
attributes: ['id'],
paranoid: false,
});
return {
operator: 'equal',
field: 'userId',
value: users.map((user) => user.id),
};
}
async function computeFilterOnRelatedEntity(models, options, filter) {
if (filter.field === 'user_email') {
return computeUserFilter(models, filter, options);
}
return filter;
}
async function computeFiltersOnRelatedEntities(models, filters, options) {
if (!filters) {
return undefined;
}
if (!filters.aggregator) {
return computeFilterOnRelatedEntity(models, options, filters);
}
return {
...filters,
conditions: await Promise.all(
filters.conditions.map(
computeFilterOnRelatedEntity.bind(undefined, models, options)
)
),
};
}
router.get('/es-activity-logs', async (request, response, next) => {
try {
const pageSize = Number(request?.query?.page?.size) || 20;
const page = Number(request?.query?.page?.number) || 1;
const options = { timezone: request.query?.timezone };
let filters;
try {
filters = request.query?.filters && JSON.parse(request.query.filters);
} catch (e) {
filters = undefined;
}
const filtersWithRelatedEntities = await computeFiltersOnRelatedEntities(
models,
filters,
options
);
const result = await elasticsearchHelper.functionSearch({
page,
pageSize,
filters: filtersWithRelatedEntities || undefined,
options,
});
response.send({
...(await serializer.serialize(result.results)),
meta: {
count: result.count,
},
});
} catch (e) {
next(e);
}
});
module.exports = router;
```
## Implementing the GET (all records with the search)
Another way to search through related data is to implement your own search logic.
```javascript theme={null}
const express = require('express');
const router = express.Router();
const models = require('../models');
const Sequelize = require('sequelize');
const { RecordSerializer } = require('forest-express-sequelize');
const ElasticsearchHelper = require('../service/elasticsearch-helper');
const { FIELD_DEFINITIONS } = require('../utils/filter-translator');
const serializer = new RecordSerializer({ name: 'es-activity-logs' });
// Custom mapping function
function mapActivityLog(id, source) {
const { createdAt, ...simpleProperties } = source;
return {
id,
...simpleProperties,
createdAt: source.createdAt ? new Date(source.createdAt) : null,
};
}
const configuration = {
index: 'activity-logs-*',
filterDefinition: {
action: FIELD_DEFINITIONS.keyword,
label: FIELD_DEFINITIONS.text,
collectionId: FIELD_DEFINITIONS.keyword,
userId: FIELD_DEFINITIONS.keyword,
createdAt: FIELD_DEFINITIONS.date,
},
mappingFunction: mapActivityLog,
sort: [{ createdAt: { order: 'desc' } }],
};
const elasticsearchHelper = new ElasticsearchHelper(configuration);
router.get('/es-activity-logs', async (request, response, next) => {
try {
const pageSize = Number(request?.query?.page?.size) || 20;
const page = Number(request?.query?.page?.number) || 1;
const search = request.query?.search;
// NOTICE: search all user ids whom firstName or lastName or email match %search%
const { Op } = Sequelize;
const where = {};
const searchCondition = { [Op.iLike]: `%${search}%` };
where[Op.or] = [
{ firstName: searchCondition },
{ lastName: searchCondition },
{ email: searchCondition },
];
const userIdsFromSearch = await models.users.findAll({
where,
attributes: ['id'],
paranoid: false,
});
// NOTICE: Create a custom boolean query for Elasticsearch
const booleanQuery = {
should: [
{
terms: {
userId: userIdsFromSearch.map((user) => user.id),
},
},
],
minimum_should_match: 1,
};
// NOTICE: Use the elasticsearchHelper to query Elasticsearch
const [results, count] = await Promise.all([
elasticsearchHelper.esSearch({ page, pageSize }, booleanQuery),
elasticsearchHelper.esCount(booleanQuery),
]);
response.send({
...(await serializer.serialize(results)),
meta: {
count: count,
},
});
} catch (e) {
next(e);
}
});
module.exports = router;
```
# Elasticsearch service/utils
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/elasticsearch/elasticsearch-service-utils
## Connecting to Elasticsearch with a Custom Service
This service wraps the [Elasticsearch Node.js client](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html) and provides the following implementation:
* Get a list of records (with Pagination and Filters handling)
* Get a simple record
* Create a record
* Update an existing record
* Delete a record
### Prototype
```javascript theme={null}
const { Client } = require('@elastic/elasticsearch');
// Our own utils that transform ForestAdmin filters to Elasticsearch one
const { esTranslateFilter } = require('../utils/filter-translator');
class ElasticsearchHelper {
// Allow to create a ElasticsearchHelper on your elastic index
constructor({
index,
filterDefinition,
mappingFunction,
sort,
});
// Get a List of Records based on the query (page, filter, search, sort)
functionSearch ({
pageSize, page, filters, options,
});
// Get a Record by Id
getRecord(recordId);
// Create a Record in your Elasticsearch index
createRecord(recordToCreate);
// Update a Record in from your Elasticsearch index
updateRecord(recordToUpdate);
// Remove a by Id
removeRecord(recordId);
// Remove multiple Ids
removeRecords(recordsIdsToDelete);
}
module.exports = ElasticsearchHelper;
```
### Full implementation
```javascript theme={null}
const { Client } = require('@elastic/elasticsearch');
const { esTranslateFilter } = require('../utils/filter-translator');
function baseMappingFunction(id, source) {
return {
id,
...source,
};
}
class ElasticsearchHelper {
constructor({
index,
filterDefinition,
mappingFunction = baseMappingFunction,
sort,
}) {
if (!index) {
throw new Error(
'Your elasticsearch index for this collection is required !'
);
}
this.index = index;
this.filterDefinition = filterDefinition;
this.mappingFunction = mappingFunction;
this.sort = sort;
this.elasticsearchClient = new Client({ node: 'http://localhost:9200' });
}
/**
* @param \{\{
* pageSize?: number
* page?: number
* \}\} params
* @param {any} booleanQuery Elasticsearch bool query
* @returns {Promise>}
*/
async esSearch({ pageSize, page }, booleanQuery) {
const size = pageSize || 20;
const from = ((page || 1) - 1) * size;
const response = await this.elasticsearchClient.search({
index: this.index,
body: {
query: {
bool: {
...booleanQuery,
},
},
sort: this.sort,
},
size,
from,
});
return response.body.hits.hits.map((hit) =>
this.mappingFunction(hit._id, hit._source)
);
}
/**
* @param {any} booleanQuery Elasticsearch bool query
* @returns {Promise}
*/
async esCount(booleanQuery) {
const response = await this.elasticsearchClient.count({
index: this.index,
body: {
query: {
bool: {
...booleanQuery,
},
},
},
});
return Number(response.body.count);
}
/**
* @param \{\{
* pageSize?: number
* page?: number
* filters: any
* options: any
* \}\} params
* @returns {Promise<{
* count: number;
* results: Array
* }>}
*/
async functionSearch({ pageSize, page, filters, options }) {
const esFilter = esTranslateFilter(this.filterDefinition, filters, options);
const [results, count] = await Promise.all([
this.esSearch({ page, pageSize }, { filter: esFilter }),
this.esCount({ filter: esFilter }),
]);
return {
results,
count,
};
}
/**
* @param {string} id
* @returns {Promise}
*/
async getRecord(id) {
const response = await this.elasticsearchClient.search({
index: this.index,
body: {
query: {
bool: {
filter: {
term: {
_id: id,
},
},
},
},
},
});
const hit = response.body.hits.hits[0];
if (!hit) {
return null;
}
return this.mappingFunction(hit._id, hit._source);
}
/**
* @param {any} recordToCreate
* @returns {Promise}
*/
async createRecord(recordToCreate) {
const response = await this.elasticsearchClient.index({
index: this.index,
body: recordToCreate,
op_type: 'create',
refresh: true,
});
return this.getRecord(response.body._id);
}
/**
* @param {any} recordToUpdate
* @returns {Promise}
*/
async updateRecord(recordToUpdate) {
const { id, ...propertiesToUpdate } = recordToUpdate;
await this.elasticsearchClient.update({
id,
index: this.index,
body: {
doc: {
...propertiesToUpdate,
},
},
refresh: true,
});
return this.getRecord(id);
}
/**
* @param {any} id
* @returns {Promise}
*/
async removeRecord(id) {
await this.elasticsearchClient.delete({
id,
index: this.index,
refresh: true,
});
}
/**
* @param {Array} idsToDelete
* @returns {Promise}
*/
async removeRecords(idsToDelete) {
const body = idsToDelete.map((id) => {
return {
delete: {
_index: this.index,
_id: id,
},
};
});
await this.elasticsearchClient.bulk({
body,
refresh: true,
});
}
}
module.exports = ElasticsearchHelper;
```
You need to add Elasticsearch Node.js client to your project`npm install @elastic/elasticsearch`
## Creating utils to convert Express query filters to Elasticsearch one
This utils takes an object representing a filter from the ForestAdmin UI and transforms it into a filter for Elasticsearch.
* Date filters
* Number filters
* Text filters
* Enum filters
### Full implementation
```javascript theme={null}
const { BaseOperatorDateParser } = require('forest-express-sequelize');
const moment = require('moment');
/**
* @enum {string}
*/
const DATE_OPERATORS = {
today: 'today',
yesterday: 'yesterday',
previous_week: 'previous_week',
previous_month: 'previous_month',
previous_quarter: 'previous_quarter',
previous_year: 'previous_year',
previous_week_to_date: 'previous_week_to_date',
previous_month_to_date: 'previous_month_to_date',
previous_quarter_to_date: 'previous_quarter_to_date',
previous_year_to_date: 'previous_year_to_date',
previous_x_days: 'previous_x_days',
previous_x_days_to_date: 'previous_x_days_to_date',
past: 'past',
future: 'future',
before_x_hours_ago: 'before_x_hours_ago',
after_x_hours_ago: 'after_x_hours_ago',
};
/**
* @typedef \{\{
* field: string;
* operator: 'before' | 'after' | 'equal' | 'not_equal' | 'present' | 'blank' | 'today'
* | 'yesterday' | 'previous_x_days' | 'previous_week' | 'previous_month' | 'previous_quarter'
* | 'previous_year' | 'previous_x_days_to_date' | 'previous_week_to_date'
* | 'previous_month_to_date' | 'previous_quarter_to_date' | 'previous_year_to_date'
* | 'past' | 'future' | 'before_x_hours_ago' | 'after_x_hours_ago'
* value: string | null;
* \}\} OneFilter
*
* @typedef \{\{
* aggregator: 'and' | 'or'
* conditions: OneFilter[]
* \}\} FilterCombination
*
* @typedef \{\{
* timezone: string
* \}\} FilterOptions
*/
/**
* @enum {string}
*/
const FIELD_DEFINITIONS = {
date: 'date',
keyword: 'keyword',
text: 'text',
number: 'number',
};
/**
* @param {FieldDefinition} fieldDefinition
* @param {OneFilter} oneFilter
* @param {FilterOptions} options
*/
function equal(fieldDefinition, oneFilter, options) {
switch (fieldDefinition) {
case FIELD_DEFINITIONS.date: {
const date = moment.tz(oneFilter.value, options.timezone);
return {
term: {
[oneFilter.field]: date.toISOString(),
},
};
}
case FIELD_DEFINITIONS.keyword:
case FIELD_DEFINITIONS.text:
case FIELD_DEFINITIONS.number: {
return {
terms: {
[oneFilter.field]: Array.isArray(oneFilter.value)
? oneFilter.value
: [oneFilter.value],
},
};
}
default:
throw new Error('Invalid field type for operator equal');
}
}
/**
* @param {(fieldDefinition: FieldDefinition, oneFilter: OneFilter, options: FilterOptions) => any}
* @param {FieldDefinition} fieldDefinition
* @param {OneFilter} oneFilter
* @param {FilterOptions} options
*/
function not(mapper, fieldDefinition, oneFilter, options) {
return {
bool: {
must_not: mapper(fieldDefinition, oneFilter, options),
},
};
}
/**
* @param {FieldDefinition} fieldDefinition
* @param {OneFilter} oneFilter
*/
function present(fieldDefinition, oneFilter) {
return {
exists: {
field: oneFilter.field,
},
};
}
/**
* @param {'gt' | 'lt'} rangeOperator
* @param {FieldDefinition} fieldDefinition
* @param {OneFilter} oneFilter
* @param {FilterOptions} options
*/
function dateInRange(rangeOperator, fieldDefinition, oneFilter, options) {
if (fieldDefinition !== FIELD_DEFINITIONS.date) {
throw new Error('Invalid field type for operator after');
}
return {
range: {
[oneFilter.field]: {
[rangeOperator]: oneFilter.value,
time_zone: options.timezone,
},
},
};
}
/**
* @param {FieldDefinition} fieldDefinition
* @param {OneFilter} oneFilter
*/
function startsWith(fieldDefinition, oneFilter) {
if (
![FIELD_DEFINITIONS.keyword, FIELD_DEFINITIONS.text].includes(
fieldDefinition
)
) {
throw new Error('Unsupported operator starts_with');
}
return {
wildcard: {
[oneFilter.field]: {
value: `${oneFilter.value}*`,
case_insensitive: true,
},
},
};
}
/**
* @param {FieldDefinition} fieldDefinition
* @param {OneFilter} oneFilter
*/
function endsWith(fieldDefinition, oneFilter) {
if (
![FIELD_DEFINITIONS.keyword, FIELD_DEFINITIONS.text].includes(
fieldDefinition
)
) {
throw new Error('Unsupported operator ends_with');
}
return {
wildcard: {
[oneFilter.field]: {
value: `*${oneFilter.value}`,
case_insensitive: true,
},
},
};
}
/**
* @param {FieldDefinition} fieldDefinition
* @param {OneFilter} oneFilter
*/
function contains(fieldDefinition, oneFilter) {
if (
![FIELD_DEFINITIONS.keyword, FIELD_DEFINITIONS.text].includes(
fieldDefinition
)
) {
throw new Error('Unsupported operator contains');
}
return {
wildcard: {
[oneFilter.field]: {
value: `*${oneFilter.value}*`,
case_insensitive: true,
},
},
};
}
/**
* @param {FieldDefinition} fieldDefinition
* @param {OneFilter} oneFilter
*/
function numberInRange(rangeOperator, fieldDefinition, oneFilter) {
if (![FIELD_DEFINITIONS.number].includes(fieldDefinition)) {
throw new Error(`Unsupported operator ${rangeOperator}`);
}
return {
range: {
[oneFilter.field]: {
[rangeOperator]: oneFilter.value,
},
},
};
}
/**
* @param {BaseOperatorDateParser} operatorDateParser
* @param {OneFilter} filter
* @param {FilterOptions} options
* @returns { range: any}
*/
function mapDateOperator(operatorDateParser, filter, options) {
return {
range: {
[filter.field]: {
...operatorDateParser.getDateFilter(filter.operator, filter.value),
time_zone: options.timezone,
},
},
};
}
const MAPPING = {
equal,
not_equal: not.bind(undefined, equal),
present,
blank: not.bind(undefined, present),
before: dateInRange.bind(undefined, 'lt'),
after: dateInRange.bind(undefined, 'gt'),
starts_with: startsWith,
ends_with: endsWith,
contains,
not_contains: not.bind(undefined, contains),
greater_than: numberInRange.bind(undefined, 'gt'),
less_than: numberInRange.bind(undefined, 'lt'),
};
/**
* @param {Record} fieldDefinitions
* @param {BaseOperatorDateParser} operatorDateParser
* @param {FilterOptions} options
* @param {OneFilter} oneFilter
*/
function mapFilter(fieldDefinitions, operatorDateParser, options, oneFilter) {
if (
fieldDefinitions[oneFilter.field] === FIELD_DEFINITIONS.date &&
Object.values(DATE_OPERATORS).includes(oneFilter.operator)
) {
return mapDateOperator(operatorDateParser, oneFilter, options);
}
const mapper = MAPPING[oneFilter.operator];
const fieldDefinition = fieldDefinitions[oneFilter.field];
if (!mapper) {
throw new Error(
`Unknown operator ${oneFilter.operator}, you need to define it !`
);
}
if (!fieldDefinition) {
throw new Error(
`Unknown field ${oneFilter.field}, your field hasn't any field definition. Please check your ElasticsearchHelper configuration`
);
}
return mapper(fieldDefinition, oneFilter, options);
}
/**
* Takes an object representing a filter from the UI
* and transforms it into a filter for elasticSearch
* @param {Record} fieldDefinitions
* @param {FilterCombination | OneFilter} filters
* @param {FilterOptions} options
* @returns {any} A valid ES filter
*/
function esTranslateFilter(fieldDefinitions, filters, options) {
if (!filters) {
return [];
}
const operatorDateParser = new BaseOperatorDateParser({
timezone: options.timezone,
operators: {
GTE: 'gte',
LTE: 'lte',
GT: 'gt',
LT: 'lt',
},
});
if (!filters.aggregator) {
return [mapFilter(fieldDefinitions, operatorDateParser, options, filters)];
}
const mapped = filters.conditions.map(
mapFilter.bind(undefined, fieldDefinitions, operatorDateParser, options)
);
if (filters.aggregator === 'and') {
return mapped;
}
return {
bool: {
should: mapped,
minimum_should_match: 1,
},
};
}
exports.esTranslateFilter = esTranslateFilter;
exports.FIELD_DEFINITIONS = FIELD_DEFINITIONS;
```
We expose utils to parse filters through **forest-express-sequelize** since version **7.6.0**
# Interact with your Elasticsearch data
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/elasticsearch/interact-with-your-elasticsearch-data
### Creating the Smart Collection
Let's take a simple example from Kibana, we will use [a set of fictitious accounts with randomly generated data.](https://download.elastic.co/demos/kibana/gettingstarted/accounts.zip) You can easily import the data using Kibana Home page section **Ingest your data**.
When it's done we can start looking at how to play with those data in Forest.
### forest-express-sequelize
First, we declare the `bank-accounts` collection in the `forest/` directory. In this Smart Collection, all fields are related to document mapping attributes except the field `id` that is computed using the document `_id`.
You can check out the list of [available field options](https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields#available-field-options) if you need them.
You **MUST** declare an `id` field when creating a Smart Collection. The value of this field for each record **MUST** be unique. On the following example, we simply use the UUID provided on every Elasticsearch documents.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('bank-accounts', {
isSearchable: false,
fields: [
{
field: 'id',
type: 'string',
},
{
field: 'account_number',
type: 'Number',
isFilterable: true,
},
{
field: 'address',
type: 'String',
isFilterable: true,
},
{
field: 'firstname',
type: 'String',
isFilterable: true,
},
{
field: 'lastname',
type: 'String',
isFilterable: true,
},
{
field: 'age',
type: 'Number',
isFilterable: true,
},
{
field: 'balance',
type: 'Number',
isFilterable: true,
},
{
field: 'city',
type: 'String',
isFilterable: true,
},
{
field: 'employer',
type: 'String',
isFilterable: true,
},
{
field: 'email',
type: 'String',
isFilterable: true,
},
{
field: 'gender',
type: 'Enum',
isFilterable: true,
enums: ['M', 'F'],
},
{
field: 'state',
type: 'String',
isFilterable: true,
},
],
});
```
You can add the option `isSearchable: true` to your collection to display the search bar. Note that you will have to implement the search yourself by including it into your own `GET` logic.
### Implementing the routes
It's not an easy job to connect several data sources in the same structure. To accommodate you in this journey we already provide you a simple service [`ElasticsearchHelper`](https://docs.forestadmin.com/woodshop/how-tos/create-a-smart-collection-with-elasticsearch/elasticsearch-service-utils) that handles all the logic to connect with your Elasticsearch data.
\
Before getting further, in order to search your data using filters, we need to define the Elasticsearch configuration.
| Name | Type | Description |
| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| index | string | The name of your Elasticsearch index. |
| filterDefinition | string | Type of your Elasticsearch fields. Can be `number`, `date`, `text`,`keyword` |
| sort | array of objects | (optional) Required only to sort your data. [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/7.12/sort-search-results.html) `Example: [ { createdAt: { order: 'desc' } }]` |
| mappingFunction | function | (optional) Required only to modify the data retrieved from Elasticsearch. `Example: (id, source) => { id, ...source}` |
```javascript theme={null}
const express = require('express');
const router = express.Router();
const {
RecordSerializer,
RecordCreator,
RecordsGetter,
RecordUpdater,
PermissionMiddlewareCreator,
} = require('forest-express-sequelize');
const ElasticsearchHelper = require('../service/elasticsearch-helper');
const { FIELD_DEFINITIONS } = require('../utils/filter-translator');
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
'bank-accounts'
);
const configuration = {
index: 'bank-accounts',
filterDefinition: {
account_number: FIELD_DEFINITIONS.number,
address: FIELD_DEFINITIONS.keyword,
age: FIELD_DEFINITIONS.number,
balance: FIELD_DEFINITIONS.number,
city: FIELD_DEFINITIONS.keyword,
email: FIELD_DEFINITIONS.keyword,
employer: FIELD_DEFINITIONS.keyword,
firstname: FIELD_DEFINITIONS.keyword,
lastname: FIELD_DEFINITIONS.keyword,
employer: FIELD_DEFINITIONS.keyword,
state: FIELD_DEFINITIONS.keyword,
gender: FIELD_DEFINITIONS.keyword,
},
};
const elasticsearchHelper = new ElasticsearchHelper(configuration);
// Routes implementation
module.exports = router;
```
Our custom filter translator only support `number`, `keyword`, `text`, `date` data types. Nonetheless, you can implement more filter mapper type in the`utils/filter-translator.js`
### Implementing the GET (all records)
In the file `routes/bank-accounts.js`, we’ve created a new route to implement the API behind the Smart Collection.
The logic here is to list all the BankAccount records. We use a custom service `service/elasticsearch-helper.js` for this example. The implementation code of this service is available here.
Finally, the last step is to serialize the response data in the expected format which is simply a standard [JSON API](http://jsonapi.org/) document. You are lucky `forest-express-sequelize` already does this for you using the RecordSerializer.
```javascript theme={null}
// Imports and ElasticsearchHelper base definition ...
router.get('/bank-accounts', async (request, response, next) => {
try {
const pageSize = Number(request?.query?.page?.size) || 20;
const page = Number(request?.query?.page?.number) || 1;
// search is not handle in this example
// const search = request.query?.search;
const options = { timezone: request.query?.timezone };
let filters;
try {
filters = request.query?.filters && JSON.parse(request.query.filters);
} catch (e) {
filters = undefined;
}
const result = await elasticsearchHelper.functionSearch({
page,
pageSize,
filters: filters || undefined,
options,
});
const serializer = new RecordSerializer({ name: 'bank-accounts' });
response.send({
...(await serializer.serialize(result.results)),
meta: {
count: result.count,
},
});
} catch (e) {
next(e);
}
});
module.exports = router;
```
### Implementing the GET (a specific record)
To access the details view of a Smart Collection record, you have to catch the GET API call on a specific record. One more time, we use a custom service that encapsulates the Elasticsearch business logic for this example.
```javascript theme={null}
// Imports and ElasticsearchHelper base definition ...
router.get('/bank-accounts/:id', async (request, response, next) => {
try {
const bankAccount = await elasticsearchHelper.getRecord(request.params.id);
const serializer = new RecordSerializer({ name: 'bank-accounts' });
response.send(await serializer.serialize(bankAccount));
} catch (e) {
next(e);
}
});
module.exports = router;
```
### Implementing the PUT
To handle the update of a record we have to catch the PUT API call.
```javascript theme={null}
// Imports and ElasticsearchHelper base definition ...
router.put(
'/bank-accounts/:id',
permissionMiddlewareCreator.update(),
(request, response, next) => {
const updater = new RecordUpdater(
{ name: 'bank-accounts' },
req.user,
req.query
);
updater
.deserialize(request.body)
.then((recordToUpdate) =>
elasticsearchHelper.updateRecord(recordToUpdate)
)
.then((record) => updater.serialize(record))
.then((recordSerialized) => response.send(recordSerialized))
.catch(next);
}
);
module.exports = router;
```
### Implementing the DELETE
Now we are able to see all the bank accounts on Forest, it’s time to implement the DELETE HTTP method in order to remove the documents on Elasticsearch when the authorized user needs it.
#### Delete a list a single record
### forest-express-sequelize
```javascript theme={null}
// Imports and ElasticsearchHelper base definition ...
router.delete(
'/bank-accounts/:id',
permissionMiddlewareCreator.delete(),
async (request, response, next) => {
try {
await elasticsearchHelper.removeRecord(request.params.id);
response.status(204).send();
} catch (e) {
next(e);
}
}
);
module.exports = router;
```
#### Delete a list of records
### forest-express-sequelize
```javascript theme={null}
// Imports and ElasticsearchHelper base definition ...
router.delete(
'/bank-accounts',
permissionMiddlewareCreator.delete(),
async (request, response, next) => {
const getter = new RecordsGetter(
{ name: 'bank-accounts' },
request.user,
request.query
);
const ids = await getter.getIdsFromRequest(request);
try {
await elasticsearchHelper.removeRecords(ids);
response.status(204).send();
} catch (e) {
next(e);
}
}
);
module.exports = router;
```
### Implementing the POST
To create a record we have to catch the POST API call.
### forest-express-sequelize
```javascript theme={null}
// Imports and ElasticsearchHelper base definition ...
router.post(
'/bank-accounts',
permissionMiddlewareCreator.create(),
(request, response, next) => {
const recordCreator = new RecordCreator(
{ name: 'bank-accounts' },
request.user,
request.query
);
recordCreator
.deserialize(request.body)
.then((recordToCreate) =>
elasticsearchHelper.createRecord(recordToCreate)
)
.then((record) => recordCreator.serialize(record))
.then((recordSerialized) => response.send(recordSerialized))
.catch(next);
}
);
module.exports = router;
```
# Readme
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/hubspot/README
# Create a Hubspot company
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/hubspot/create-a-hubspot-company
This example shows you how to create a Smart Action `"Create company in Hubspot"` that generates a company in Hubspot based on information from your database.
## Requirements
* An admin backend running on forest-express-sequelize
* [superagent](https://www.npmjs.com/package/superagent) npm package
* a Hubspot account
## How it works
### Directory: /models
This directory contains the `companies.js` file where the collection is declared.
```javascript theme={null}
module.exports = (sequelize, DataTypes) => {
const { Sequelize } = sequelize;
const Companies = sequelize.define(
'companies',
{
description: {
type: DataTypes.STRING,
},
industry: {
type: DataTypes.STRING,
},
headquarters: {
type: DataTypes.STRING,
},
name: {
type: DataTypes.STRING,
},
status: {
type: DataTypes.ENUM,
values: ['lead', 'customer', 'churn'],
},
crmId: {
type: DataTypes.BIGINT,
},
},
{
tableName: 'companies',
underscored: true,
timestamps: false,
schema: process.env.DATABASE_SCHEMA,
paranoid: true,
}
);
return Companies;
};
```
### Directory: /forest
This directory contains the `companies.js` file where the smart action is declared. A smart field has also been added to add a link to the company's Hubspot profile if the company's `crmId` field is not `null`.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('companies', {
actions: [{
name: 'Create company in Hubspot',
type: 'single',
}],
fields: [{
// adding a field that will allow to be directed on click to the company's profile in hubspot
field: 'crm link',
type: 'String',
get: (company) => company.crmId ?
'https://app.hubspot.com/contacts/6332498/company/' + company.dataValues.crmId : null
}],
segments: [],
});
```
### Directory: /routes
This directory contains the `companies.js` file where the smart action logic is implemented.
In this logic a Hubspot company instance is created through a /post create company call to the Hubspot API.
The Hubspot API key is defined in the `.env` file and requested through the expression `process.env.HUBSPOT_API`.
```javascript theme={null}
const express = require('express');
const { PermissionMiddlewareCreator } = require('forest-express-sequelize');
const { companies } = require('../models');
const superagent = require('superagent');
const router = express.Router();
// function that returns a sequelize object
function getRecord(collection, recordId) {
return collection.findOne({ where: { id: recordId } });
}
// function that update a company record crmId with the hubspot companyId
function setCrmId(record, hubspotId) {
record.crmId = hubspotId;
return record.save();
}
// function that creates a company in Hubspot through the hubspot API
function createHubspotCompany(company) {
return superagent
.post(
`https://api.hubapi.com/companies/v2/companies?hapikey=${process.env.HUBSPOT_API}`
)
.send({
properties: [
{
name: 'name',
value: company.name,
},
{
name: 'description',
value: company.description,
},
{
name: 'city',
value: company.headquarters,
},
{
name: 'industry',
value: company.industry,
},
],
})
.then((response) => JSON.parse(response.res.text));
}
router.post('/actions/create-company-in-Hubspot', async (req, res) => {
const companyId = req.body.data.attributes.ids[0];
const company = await getRecord(companies, companyId);
if (company.crmId) {
return res
.status(400)
.send({
error: 'A lead from Hubspot is already assigned to this company',
});
}
try {
const hubspotCompany = await createHubspotCompany(company);
await setCrmId(company, hubspotCompany.companyId);
} catch (err) {
console.log('error => ', err);
res.status(400).send({ error: 'could not create lead' });
}
return res.send({ success: 'Lead has been created in Hubspot!' });
});
```
# Display Hubspot companies
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/hubspot/display-hubspot-companies
This example shows you how to create a smart collection to list the companies of your Hubspot account.
## Requirements
* An admin backend running on forest-express-sequelize
* [superagent](https://www.npmjs.com/package/superagent) npm package
* a Hubspot account
## How it works
### Directory: /forest
This directory contains the `hubspot-companies.js` file where the collection is declared.
```javascript theme={null}
const collection = require('forest-express-sequelize');
collection('hubspot_companies', {
isSearchable: true,
fields: [
{
field: 'id',
type: 'Number',
},
{
field: 'name',
type: 'String',
},
{
field: 'hubspot_link',
type: 'String',
},
],
});
```
### Directory: /routes
This directory contains the `hubspot-companies.js` file where the serializer for the collection and logic to get records is defined.
Companies information are obtained by making a [get all companies](https://developers.hubspot.com/docs/methods/companies/get-all-companies) call to the Hubspot API.
The Hubspot API key is defined in the `.env` file and requested through the expression `process.env.HUBSPOT_API`.
```javascript theme={null}
const Liana = require('forest-express-sequelize');
const express = require('express');
const superagent = require('superagent');
const JSONAPISerializer = require('jsonapi-serializer').Serializer;
const router = express.Router();
// define the serializer used to format the payload
const hubspotCompaniesSerializer = new JSONAPISerializer('hubspotCompanies', {
attributes: ['name', 'hubspotLink'],
keyForAttribute: 'underscore_case',
id: 'companyId',
transform(record) {
record.name = record.properties.name.value;
record.hubspotLink = `https://app.hubspot.com/contacts/6332498/company/${record.companyId}`;
return record;
},
});
function getHubspotCompaniesList(limit, offset) {
return superagent
.get(
`https://api.hubapi.com/companies/v2/companies/paged?hapikey=${process.env.HUBSPOT_API}&properties=name&limit=${limit}&offset=${offset}`
)
.then((response) => JSON.parse(response.res.text));
}
async function getAllHubspotCompanies() {
let allHubspotCompanies = [];
let hasMore = true;
let offset = '';
while (hasMore) {
let getCompaniesResponse = await getHubspotCompaniesList(250, offset);
allHubspotCompanies = allHubspotCompanies.concat(
getCompaniesResponse.companies
);
offset = getCompaniesResponse.offset;
hasMore = getCompaniesResponse['has-more'];
}
return allHubspotCompanies;
}
function searchHubspotCompanies(companies, search) {
return companies.filter((item) => {
return item.properties.name.value
.toUpperCase()
.includes(search.toUpperCase());
});
}
router.get(
'/hubspot_companies',
Liana.ensureAuthenticated,
async (req, res, next) => {
// set pagination parameters when exist (default limit is 250 as it is the max allowed by Hubspot)
let limit = req.query.page ? parseInt(req.query.page.size) : 20;
let offset = req.query.page
? (parseInt(req.query.page.number) - 1) * limit
: 0;
// set search terms when exist
let search = null;
search = req.query.search ? req.query.search : search;
let hubspotCompanies = await getAllHubspotCompanies();
if (search) {
hubspotCompanies = searchHubspotCompanies(hubspotCompanies, search);
}
const count = hubspotCompanies ? hubspotCompanies.length : null;
const paginateHubspotCompanies = hubspotCompanies.slice(
offset,
offset + limit
);
const serializedCompanies = hubspotCompaniesSerializer.serialize(
paginateHubspotCompanies
);
return res.send({ ...serializedCompanies, meta: { count } });
}
);
module.exports = router;
```
# Intercom
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/intercom
Configuring the Intercom integration allows you to display your user’s session data (location, browser type, …) and conversations.
In order for your intercom integration to work properly, you will have to use the version 2 of intercom API. To do so, you'll need go to the intercom developer hub and ensure that the app registered to retrieve your API key uses the intercom API version 2.0.
First, add the intercom client as a dependency to your project:
```bash theme={null}
npm install intercom-client@2.11
```
```
npm install intercom-client@2.11
```
```ruby theme={null}
gem 'intercom'
```
Then, you need to add the intercom integration:
```javascript theme={null}
...
const intercomClient = require('intercom-client');
const { objectMapping, connections } = require('../models');
module.exports = async function (app) {
app.use(await Liana.init({
configDir: path.join(__dirname, '../forest'),
envSecret: process.env.FOREST_ENV_SECRET,
authSecret: process.env.FOREST_AUTH_SECRET,
objectMapping,
connections,
integrations: {
intercom: {
accessToken: process.env.INTERCOM_ACCESS_TOKEN,
intercom: intercomClient,
mapping: ['users.email'],
},
},
}));
console.log(chalk.cyan('Your admin panel is available here: https://app.forestadmin.com/projects'));
};
```
```javascript theme={null}
...
const intercomClient = require('intercom-client');
const { objectMapping, connections } = require('../models');
module.exports = async function (app) {
app.use(await Liana.init({
configDir: path.join(__dirname, '../forest'),
envSecret: process.env.FOREST_ENV_SECRET,
authSecret: process.env.FOREST_AUTH_SECRET,
objectMapping,
connections,
integrations: {
intercom: {
accessToken: process.env.INTERCOM_ACCESS_TOKEN,
intercom: intercomClient,
mapping: ['users.email'],
},
},
}));
console.log(chalk.cyan('Your admin panel is available here: https://app.forestadmin.com/projects'));
};
```
```ruby theme={null}
ForestLiana.integrations = {
# ...
intercom: {
access_token: ENV['INTERCOM_ACCESS_TOKEN'],
mapping: ['Customer']
}
}
```
* `intercom` is used to pass the intercom client version. To do so, you have to require the previously installed client, as in the example.
* `accessToken` should be defined in your environment variable and is provided by intercom.
* `mapping` refers to the collection and field name you want to map to intercom data. It can either be a field that contain emails that refer to intercom users or a field that contain ids mapping the `external_id` in Intercom API.
You will have to restart your server to see Intercom plugged to your project.
### Others
# Mixpanel
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/mixpanel
The Mixpanel integration allows you to fetch Mixpanel’s events and display them at a record level into Forest.
To benefit from Mixpanel integration, you need to add the package `mixpanel-data-export` before going further.
Then, add the following code to your `app.js` file. In our example we will map the `customers.email` with the data coming from Mixpanel. You may replace by your own relevant collection(s).
By default, Mixpanel is sending the following fields: id, event, date, city, region, country, timezone, os, osVersion, browser, browserVersion. If you want to add other fields from Mixpanel, you have to add them in `customProperties`:
```javascript theme={null}
...
const { objectMapping, connections } = require('../models');
module.exports = function (app) {
app.use(Liana.init({
configDir: path.join(__dirname, '../forest'),
envSecret: process.env.FOREST_ENV_SECRET,
authSecret: process.env.FOREST_AUTH_SECRET,
objectMapping,
connections,
integrations: {
mixpanel: {
apiKey: process.env.MIXPANEL_API_KEY,
apiSecret: process.env.MIXPANEL_SECRET_KEY,
mapping: ['customers.email'],
customProperties: ['Campaign Source', 'plan', 'tutorial complete'],
mixpanel: require('mixpanel-data-export')
},
},
}));
console.log(chalk.cyan('Your admin panel is available here: https://app.forestadmin.com/projects'));
};
```
To benefit from Mixpanel integration, you need to add the package `mixpanel-data-export` before going further.
Then, add the following code to your `app.js` file. In our example we will map the `customers.email` with the data coming from Mixpanel. You may replace by your own relevant collection(s).
By default, Mixpanel is sending the following fields: id, event, date, city, region, country, timezone, os, osVersion, browser, browserVersion. If you want to add other fields from Mixpanel, you have to add them in `customProperties`:
```javascript theme={null}
...
const { objectMapping, connections } = require('../models');
module.exports = function (app) {
app.use(Liana.init({
configDir: path.join(__dirname, '../forest'),
envSecret: process.env.FOREST_ENV_SECRET,
authSecret: process.env.FOREST_AUTH_SECRET,
objectMapping,
connections,
integrations: {
mixpanel: {
apiKey: process.env.MIXPANEL_API_KEY,
apiSecret: process.env.MIXPANEL_SECRET_KEY,
mapping: ['customers.email'],
customProperties: ['Campaign Source', 'plan', 'tutorial complete'],
mixpanel: require('mixpanel-data-export')
},
},
}));
console.log(chalk.cyan('Your admin panel is available here: https://app.forestadmin.com/projects'));
};
```
To benefit from Mixpanel integration, you need to add the `gem 'mixpanel_client'` to your Gemfile.
Then, add the following code to your initializer. In our example we will map the `Customer.email` with the data coming from Mixpanel. You may replace by your own relevant collection(s).
By default, Mixpanel is sending the following fields: id, event, date, city, region, country, timezone, os, osVersion, browser, browserVersion. If you want to add other fields from Mixpanel, you have to add them in `customProperties`:
```ruby theme={null}
ForestLiana.env_secret = Rails.application.secrets.forest_env_secret
ForestLiana.auth_secret = Rails.application.secrets.forest_auth_secret
ForestLiana.integrations = {
mixpanel: {
api_key: 'YOUR MIXPANEL API KEY',
api_secret: 'YOUR MIXPANEL SECRET KEY',
mapping: ['Customer.email'],
custom_properties: ['Campaign Source', 'plan', 'tutorial complete'],
}
}
```
You will then be able to see the Mixpanel events on a record, a `Customer` in our example.
You'll need to install the [Mixpanel Data Export](https://www.npmjs.com/package/mixpanel-data-export) package to run the Mixpanel integration
# Razorpay
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/razorpay
**Context**: As a user I want to be able to see all payments and orders related to a customer from Razorpay.
**Example**: I have a collection `users` and a collection `orders` in the database. An order belongs to a customer through a field `user`. An order has a field `order_reference` and `payment_reference` that are ids of objects from Razorpay.
### Models
`models/users.js`
```jsx theme={null}
const mongoose = require('mongoose');
const schema = mongoose.Schema(
{
Age: Number,
Interests: [String],
Name: String,
createdAt: Date,
updatedAt: Date,
},
{
timestamps: false,
}
);
module.exports = mongoose.model('users', schema, 'users');
```
`models/orders.js`
```jsx theme={null}
const mongoose = require('mongoose');
const schema = mongoose.Schema(
{
reference: String,
payment_ref: String,
order_ref: String,
user: { type: mongoose.Schema.Types.ObjectId, ref: 'users' },
},
{
timestamps: false,
}
);
module.exports = mongoose.model('orders', schema, 'orders');
```
### Implementation
#### Declare virtual collections
`forest/razorpay-payments`
```jsx theme={null}
const { collection } = require('forest-express-mongoose');
collection('razorpayPayments', {
actions: [],
fields: [
{
field: 'amount',
type: 'Number',
},
{
field: 'entity',
type: 'String',
},
{
field: 'method',
type: 'String',
},
{
field: 'international',
type: 'Boolean',
},
{
field: 'currency',
type: 'String',
},
{
field: 'method',
type: 'String',
},
{
field: 'amount_refunded',
type: 'Number',
},
{
field: 'refund_status',
type: 'String',
},
{
field: 'captured',
type: 'Boolean',
},
{
field: 'description',
type: 'String',
},
{
field: 'card_id',
type: 'String',
},
{
field: 'bank',
type: 'String',
},
{
field: 'wallet',
type: 'String',
},
{
field: 'vpa',
type: 'String',
},
{
field: 'email',
type: 'String',
},
{
field: 'contact',
type: 'String',
},
{
field: 'fee',
type: 'Number',
},
{
field: 'tax',
type: 'Number',
},
{
field: 'error_code',
type: 'String',
},
{
field: 'error_description',
type: 'String',
},
{
field: 'status',
type: 'String',
},
{
field: 'created_at',
type: 'Date',
},
],
segments: [],
});
```
`forest/razorpay-orders.js`
```jsx theme={null}
const { collection } = require('forest-express-mongoose');
collection('razorpayOrders', {
actions: [],
fields: [
{
field: 'amount',
type: 'Number',
},
{
field: 'entity',
type: 'String',
},
{
field: 'amount_paid',
type: 'String',
},
{
field: 'amount_due',
type: 'String',
},
{
field: 'currency',
type: 'INR',
},
{
field: 'receipt',
type: 'String',
},
{
field: 'status',
type: 'String',
},
{
field: 'created_at',
type: 'Date',
},
],
segments: [],
});
```
#### Add relationships to virtual collections
You need to declare a relationship between the `users` collection and the virtual `razorpayPayments` and `razorpayOrders` collections in the `forest/users.js` file.
```jsx theme={null}
const { collection } = require('forest-express-mongoose');
collection('users', {
actions: [],
fields: [
{
field: 'razorpayPayments',
type: ['String'],
reference: 'razorpayPayments.id',
},
],
segments: [],
});
```
### Define route logic for the relationship
You now have to implement the logic to be executed to retrieve and send the information from Razorpay to the UI when the corresponding route is called.
This is done in the file `routes/users.js.` Remember that you need to properly serialize the objects in order for the UI to correctly display them, using the `RecordsSerializer`.
```javascript theme={null}
const express = require('express');
const {
PermissionMiddlewareCreator,
RecordSerializer,
} = require('forest-express-mongoose');
const superagent = require('superagent');
const { orders } = require('../models');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('users');
router.get(
'/users/:recordId/relationships/razorpayPayments',
permissionMiddlewareCreator.details(),
async (request, response, next) => {
const razorpayPaymentSerializer = new RecordSerializer({
modelName: 'razorpayPayments',
});
const { recordId } = request.params;
const userOrders = await orders.find({ user: recordId });
const fetchPaymentsFromApi = [];
userOrders.forEach((record) => {
let fetchPayment = superagent
.get(
`https://${process.env.RAZORPAY_KEY_ID}:${process.env.RAZORPAY_KEY_SECRET}@api.razorpay.com/v1/payments/${record.payment_ref}`
)
.then((res) => JSON.parse(res.text));
fetchPaymentsFromApi.push(fetchPayment);
});
return Promise.all(fetchPaymentsFromApi)
.then((res) => razorpayPaymentSerializer.serialize(res))
.then((recordsSerialized) =>
response.send({
...recordsSerialized,
meta: { count: recordsSerialized.data.length },
})
)
.catch((e) => {
console.log(
chalk.red('error with razorpay api call =>', e.response.res.text)
);
return response.send({});
});
}
);
router.get(
'/users/:recordId/relationships/razorpayOrders',
permissionMiddlewareCreator.details(),
async (request, response, next) => {
const razorpayOrderSerializer = new RecordSerializer({
modelName: 'razorpayOrders',
});
const { recordId } = request.params;
const userOrders = await orders.find({ user: recordId });
const fetchPaymentsFromApi = [];
userOrders.forEach((record) => {
let fetchPayment = superagent
.get(
`https://${process.env.RAZORPAY_KEY_ID}:${process.env.RAZORPAY_KEY_SECRET}@api.razorpay.com/v1/orders/${record.order_ref}`
)
.then((res) => JSON.parse(res.text));
fetchPaymentsFromApi.push(fetchPayment);
});
return Promise.all(fetchPaymentsFromApi)
.then((res) => razorpayOrderSerializer.serialize(res))
.then((recordsSerialized) =>
response.send({
...recordsSerialized,
meta: { count: recordsSerialized.data.length },
})
)
.catch((e) => {
console.log(
chalk.red('error with razorpay api call =>', e.response.res.text)
);
return response.send({});
});
}
);
module.exports = router;
```
# Readme
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/slack/README
# Send Smart Action notifications to Slack
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/slack/send-smart-action-notifications-to-slack
This example shows you how to integrate [Slack incoming webhooks](https://api.slack.com/messaging/webhooks) to receive notifications in your workspace when a Smart Action e.g `"Reject application"` is triggered.
Demo
## Create your Forest slack app
Follow Slack's guide to [create a new app](https://api.slack.com/messaging/webhooks) in your workspace and start sending messages using Incoming Webhooks.
At the end of this guide, make sure your Slack app has the following features activated:
* Incoming Webhooks
* Interactive Components
* Bots
* Permissions
Once your Slack app has its shiny Incoming Webhook URL, you will be able to send your [message](https://api.slack.com/messages) in JSON as the body of an `application/json` POST request.
```
https://hooks.slack.com/services/YOUR_WORKSPACE_ID/YOUR_CHANNEL_ID/YOUR_SECRET_TOKEN
```
## Connect your app from a Slack channel of your choice
## Set up the webhook from your admin backend
### Install the [node.js Slack SDK](https://slack.dev/node-slack-sdk)
From your project's directory, simply run
```bash theme={null}
$ npm install --save @slack/webhook
```
### Add the Incoming Webhook URL to your .env
```bash theme={null}
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR_WORKSPACE_ID/YOUR_CHANNEL_ID/YOUR_SECRET_TOKEN
```
This Incoming Webhook URL contains a secret key, please make sure it does not appear in your code.
### Create the Smart Action and initialize the Incoming Webhook
#### Smart Action declaration
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
// This file allows you to add to your Forest UI:
// - Smart actions: https://docs.forestadmin.com/documentation/reference-guide/actions/create-and-manage-smart-actions
// - Smart fields: https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields
// - Smart relationships: https://docs.forestadmin.com/documentation/reference-guide/relationships/create-a-smart-relationship
// - Smart segments: https://docs.forestadmin.com/documentation/reference-guide/segments/smart-segments
collection('companies', {
actions: [
{
name: 'Reject application',
type: 'single',
fields: [
{
field: 'Reason(s) for rejection',
description: 'Please provide a reason for this decision',
type: ['Enum'],
enums: [
'Certificate of Incorporation',
'Proof of Address ID',
'Bank Statement ID',
],
required: true,
},
{
field: 'Comment',
description:
'This comment will only be displayed in your slack workspace message',
type: 'String',
widget: 'text area',
},
],
},
],
// ...
});
```
#### Smart Action logic
```javascript theme={null}
const express = require('express');
const {
PermissionMiddlewareCreator,
RecordsGetter,
} = require('forest-express-sequelize');
const { IncomingWebhook } = require('@slack/webhook');
const { companies } = require('../models');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
'companies'
);
const url = process.env.SLACK_WEBHOOK_URL;
// This file contains the logic of every route in Forest for the collection companies:
// - Native routes are already generated but can be extended/overridden - Learn how to extend a route here: https://docs.forestadmin.com/documentation/v/v6/reference-guide/routes/extend-a-route
// - Smart action routes will need to be added as you create new Smart Actions - Learn how to create a Smart Action here: https://docs.forestadmin.com/documentation/v/v6/reference-guide/actions/create-and-manage-smart-actions
// ...
// Initialize webhook
const webhook = new IncomingWebhook(url);
router.post(
'/actions/reject-application',
permissionMiddlewareCreator.smartAction(),
async (request, response) => {
// Get input form attributes
const attributes = await request.body.data.attributes.values;
const rejectionReason = attributes['Reason(s) for rejection'];
const comment = attributes['Comment'];
// Get selected company
const [selectedCompanyId] = await new RecordsGetter(
companies,
request.user,
request.query
).getIdsFromRequest(request);
const selectedCompany = await companies.findByPk(selectedCompanyId);
// Change company status to rejected
await companies.update(
{ status: 'rejected' },
{ where: { id: selectedCompanyId } }
);
response.send({ success: "Company's request to go live rejected!" });
// Trigger Slack webhook
await webhook.send({
text: 'An action has been triggered from Forest',
channel: 'C01CFGCADGF', // replace by your Slack channel ID
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: 'Action triggered - Company application rejected :x:',
emoji: true,
},
},
{
type: 'divider',
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `${request.user.firstName} ${request.user.lastName} just rejected 's request to go live!\n\n • *Reason for rejection:* ${rejectionReason[0]}\n • *Comment:* ${comment}`,
},
accessory: {
type: 'image',
image_url: `${selectedCompany.logoUrl}`,
alt_text: 'company logo',
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: 'For more details on the company',
},
accessory: {
type: 'button',
text: {
type: 'plain_text',
text: 'View Notes',
emoji: true,
},
value: 'null',
url: `https://app.forestadmin.com/Live-demo/Production/Operations/data/companies/index/record/companies/${selectedCompanyId}/collaboration`,
action_id: 'button-action',
},
},
],
});
}
);
module.exports = router;
```
To learn more about composing messages using the Slack API, please visit the
* Slack Interactive messages [guide](https://api.slack.com/messaging/interactivity)
* Slack Block Kit [visual builder](https://api.slack.com/tools/block-kit-builder)
To learn more about error handling of Slack Interactive Webhooks, please visit the Slack [changelog](https://api.slack.com/changelog/2016-05-17-changes-to-errors-for-incoming-webhooks).
# Stripe
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/stripe
Configuring the Stripe integration for Forest allows you to have your **customer’s payments, invoices, cards and subscriptions** **(1)** alongside the corresponding customer from your application. A `Refund` Smart Action **(2,3)** is also implemented out-of-the-box.
On our Live Demo, we’ve configured the Stripe integration on the `customers` collection. The Stripe Customer ID is already stored on the database under the field `stripe_id`.
```javascript theme={null}
...
const { objectMapping, connections } = require('../models');
module.exports = function (app) {
app.use(Liana.init({
configDir: path.join(__dirname, '../forest'),
envSecret: process.env.FOREST_ENV_SECRET,
authSecret: process.env.FOREST_AUTH_SECRET,
objectMapping,
connections,
integrations: {
stripe: {
apiKey: process.env.STRIPE_SECRET_KEY,
mapping: 'customers.stripe_id',
stripe: require('stripe')
}
}
}));
console.log(chalk.cyan('Your admin panel is available here: https://app.forestadmin.com/projects'));
};
```
On our Live Demo, we’ve configured the Stripe integration on the `customers` collection. The Stripe Customer ID is already stored on the database under the field `stripe_id`.
```javascript theme={null}
...
const { objectMapping, connections } = require('../models');
module.exports = function (app) {
app.use(Liana.init({
configDir: path.join(__dirname, '../forest'),
envSecret: process.env.FOREST_ENV_SECRET,
authSecret: process.env.FOREST_AUTH_SECRET,
objectMapping,
connections,
integrations: {
stripe: {
apiKey: process.env.STRIPE_SECRET_KEY,
mapping: 'customers.stripe_id',
stripe: require('stripe')
}
}
}));
console.log(chalk.cyan('Your admin panel is available here: https://app.forestadmin.com/projects'));
};
```
On our Live Demo, we’ve configured the Stripe integration on the `Customer` collection. The Stripe Customer ID is already stored on the database under the field `stripe_id`.
```ruby theme={null}
ForestLiana.env_secret = Rails.application.secrets.forest_env_secret
ForestLiana.auth_secret = Rails.application.secrets.forest_auth_secret
ForestLiana.integrations = {
stripe: {
api_key: ENV['STRIPE_SECRET_KEY'],
mapping: 'Customer.stripe_id'
}
}
```
#### Available options
Here are the complete list of available options to customize your Stripe integration.
| Name | Type | Description |
| -------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| api\_key | string | The API Secret key of your Stripe account. Should normally starts with `sk_`. |
| mapping | string | Indicates how to reconcile your Customer data from your Stripe account and your collection/field from your database. Format must be `model_name.stripe_customer_id_field` |
A `stripe` option is also available to use the official [Node.js Stripe library](https://github.com/stripe/stripe-node) NPM package.
# Readme
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/twilio/README
# Send an SMS with Twilio and Zapier
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/twilio/send-an-sms-with-twilio-and-zapier
This example shows you how to create a Smart Action `"Send SMS"` that triggers a [Zapier webhook](https://zapier.com/zapbook/webhook/) to send an SMS message with Twilio.
## Requirements
* An admin backend running on forest-express-sequelize
* A Zapier account
* [node-fetch](https://www.npmjs.com/package/node-fetch) npm package
## How it works
### Directory: /models
This directory contains the `users.js` file where the model is declared.
```javascript theme={null}
module.exports = (sequelize, DataTypes) => {
const { Sequelize } = sequelize;
const Users = sequelize.define(
'users',
{
email: {
type: DataTypes.STRING,
},
phoneNumber: {
type: DataTypes.STRING,
},
},
{
tableName: 'users',
timestamps: false,
schema: process.env.DATABASE_SCHEMA,
}
);
Users.associate = (models) => {};
return Users;
};
```
### **Directory: /forest**
This directory contains the `users.js` file where the Smart Action `Send SMS`is declared.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('users', {
actions: [
{
name: 'Send SMS',
type: 'single',
},
],
});
```
### **Directory: /routes**
This directory contains the `users.js` file where the implementation of the route is handled. The `POST /forest/actions/send-sms` API call is triggered when you click on the Smart Action in the Forest UI. The route implementation retrieves all the necessary data and triggers another API call directly to a [Zapier hook](https://zapier.com/zapbook/webhook/).
```javascript theme={null}
const fetch = require('node-fetch');
//...
// Send SMS
router.post('/actions/send-sms', (request, response) => {
let userId = request.body.data.attributes.ids[0];
return users
.findByPk(userId)
.then((user) => {
user = user.toJSON();
return fetch(
'https://hooks.zapier.com/hooks/catch/4760242/o1uqz0r/silent',
{
method: 'POST',
body: JSON.stringify({
phoneNumber: user.phoneNumber,
}),
headers: { 'Content-Type': 'application/json' },
}
);
})
.then(() => {
response.status(204).send();
});
});
//...
module.exports = router;
```
# Authentication, Filtering & Sorting
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/zendesk/authentication-filtering-and-sorting
## Get authenticated to the Zendesk API
You first need to generate an authentication token to access the Zendesk API. We are going to use the basic authentication mechanism. [More details provided here](https://developer.zendesk.com/rest_api/docs/support/introduction#security-and-authentication). \
\
The 2 parameters required are: a user email (agent) that is allowed to access Zendesk, and the API Key that you can retrieve from the Zendesk console:
These 2 parameters can be environment variables like this;
```javascript theme={null}
function getToken() {
const authEmail = process.env.ZENDESK_AUTH_EMAIL;
const apiKey = process.env.ZENDESK_API_TOKEN;
return Buffer.from(`${authEmail}/token:${apiKey}`).toString('base64');
}
```
## Filtering and Sorting using the API
Zendesk API allows you to filter and sort the tickets/users, plus paginate the result.
What we need first, is to implement a way to transform the Forest filtering, sorting and pagination convention to the Zendesk API format.
Learn more about how to [authenticate, filter and sort with the Zendesk API](https://docs.forestadmin.com/woodshop/how-tos/zendesk-integration/authentication-filtering-and-sorting).
### Filtering
```javascript theme={null}
function getFilterConditions(params) {
let filters = [];
if (params.filters) {
let filtersJson = JSON.parse(params.filters);
if (filtersJson.aggregator) {
filters = filtersJson.conditions;
} else {
filters = [filtersJson];
}
}
let filterConditions = [];
if (params.search) {
filterConditions.push(params.search);
}
for (let filter of filters) {
if (filter.field === 'id') {
filterConditions.push(`${filter.value}`);
} else {
// This example shows the equals, greater than and lower than conditions
// cf. Search operators => https://support.zendesk.com/hc/en-us/articles/203663226-Zendesk-Support-search-reference#topic_lhr_wsc_3v
let operator = ':';
switch (filter.operator) {
case 'before':
operator = '<';
break;
case 'after':
operator = '>';
break;
}
filterConditions.push(`${filter.field}${operator}${filter.value}`);
}
}
return filterConditions;
}
```
### Sorting
```javascript theme={null}
function getSort(params, options) {
let sort_by = options.default_sort_by || '';
let sort_order = options.default_sort_order || '';
let sort = params.sort;
if (sort) {
let asc = true;
if (sort.startsWith('-')) {
asc = false;
sort = sort.substring(1);
}
const collectionName = options.collection_name;
const authorized_fields = Liana.Schemas.schemas[collectionName].fields
.filter((field) => field.isSortable)
.map((field) => field.field);
if (authorized_fields.includes(sort)) {
sort_by = sort;
sort_order = asc ? 'asc' : 'desc';
}
}
return { sort_by, sort_order };
}
```
# Bonus: Direct link to Zendesk + change priority of a ticket
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/zendesk/bonus-direct-link-to-zendesk--and--change-priority-of-a-ticket
## Create a Direct Link to Zendesk
The next step is to build a direct link to the Zendesk Ticket using a URL. We are going to implement a smart field for this. To build the URL, we simply use Zendesk's convention: `ZENDESK_URL_PREFIX/agent/tickets/ticketId`
```javascript theme={null}
const ZENDESK_URL_PREFIX = `https://${process.env.ZENDESK_SUBDOMAIN}.zendesk.com`;
collection('zendesk_tickets', {
actions: [],
fields: [{
field: 'direct_url',
type: 'String',
get: (ticket) => {
return `${ZENDESK_URL_PREFIX}/agent/tickets/${ticket.id}`;
},
},
...
],
segments: [],
});
```
Once the smart field is added, just set up the Display Widget in Forest UI to allow the display of the URL as a Link:
## Change the priority of a ticket
Let's say your operations team wants to change the priority of Zendesk tickets directly from Forest.
For doing so, let's create a simple [Smart Action](https://docs.forestadmin.com/documentation/reference-guide/actions/create-and-manage-smart-actions) like this:
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
const ZENDESK_URL_PREFIX = `https://${process.env.ZENDESK_SUBDOMAIN}.zendesk.com`;
// Search on tickets => https://support.zendesk.com/hc/en-us/articles/203663206-Searching-tickets
collection('zendesk_tickets', {
actions: [{
name: 'Change Priority',
type: 'single',
endpoint: '/forest/actions/zendesk-ticket-change-priority',
fields: [
{
field: 'New Ticket Priority',
description: 'What is the new priority?',
type: 'Enum',
enums: ['urgent', 'high', 'normal', 'low'],
isRequired: true
},
],
}],
fields: [
...
],
segments:[]
}
```
Implement the `updateTicket` service according to the [Zendesk API](https://developer.zendesk.com/rest_api/docs/support/tickets#update-ticket):
```javascript theme={null}
async function updateTicket(ticketId, newValues) {
const body = {
ticket: newValues,
};
return axios
.put(`${ZENDESK_URL_PREFIX}/api/v2/tickets/${ticketId}`, body, {
headers: {
Authorization: `Basic ${getToken()}`,
},
})
.then(async (resp) => {
let record = resp.data.ticket;
return record;
});
}
```
And now, we need to implement the route to handle this Smart Action:
```javascript theme={null}
const { PermissionMiddlewareCreator } = require('forest-express-sequelize');
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
'companies'
);
const {
getTickets,
getTicket,
updateTicket,
} = require('../services/zendesk-tickets-service');
router.post(
'/actions/zendesk-ticket-change-priority',
permissionMiddlewareCreator.smartAction(),
(request, response, next) => {
const ticketId = request.body.data.attributes.ids[0];
const newValues = {
priority: request.body.data.attributes.values['New Ticket Priority'],
};
updateTicket(ticketId, newValues)
// eslint-disable-next-line no-unused-vars
.then(async function (recordUpdated) {
response.send({
success: 'Ticket Priority changed!',
});
})
.catch(next);
}
);
```
You now have full integration with Zendesk!\
\
To go further, please [check our Github repository and explore how to](https://github.com/existenz31/forest-zendesk):
* Get the Assignee, Submitter & Requester users for a Zendesk Ticket
* Get the Zendesk User for a User
* Get the requested tickets for a Zendesk User
* and more...
# Display Zendesk tickets
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/zendesk/display-zendesk-tickets
This section shows you how to create a smart collection to list the tickets of your Zendesk account.
### Declare the Smart Collection Zendesk Tickets
First, we need to declare the smart collection in your project based on the API documentation. As an example, here the smart collection definition for Users:
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
// Search on tickets => https://support.zendesk.com/hc/en-us/articles/203663206-Searching-tickets
collection('zendesk_tickets', {
actions: [],
fields: [
{
field: 'id',
type: 'Number',
isFilterable: true,
},
{
field: 'created_at',
type: 'Date',
isSortable: true,
},
{
field: 'updated_at',
type: 'Date',
isSortable: true,
},
{
field: 'type',
type: 'Enum',
enums: ['problem', 'incident', 'question', 'task'],
isFilterable: true,
isSortable: true,
},
{
field: 'priority',
type: 'Enum',
enums: ['urgent', 'high', 'normal', 'low'],
isFilterable: true,
isSortable: true,
},
{
field: 'status',
type: 'Enum',
enums: ['new', 'open', 'pending', 'hold', 'solved', 'closed'],
isFilterable: true,
isSortable: true,
},
{
field: 'subject',
type: 'String',
isFilterable: true,
},
{
field: 'description',
type: 'String',
isFilterable: true,
},
{
field: 'comment_count',
type: 'Number',
},
{
field: 'is_public',
type: 'Boolean',
},
{
field: 'satisfaction_rating',
type: 'Json',
},
{
field: 'tags',
type: ['String'],
isFilterable: true, // not => filtering on array is not yet possible
},
],
segments: [],
});
```
Some fields are available for filtering or sorting using the Zendesk API. To allow this on the Forest UI, simply add the keywords `isFilterable` and `isSortable` in your field definition.
### Implement the Smart Collection route
In the file `routes/zendesk-tickets.js`, we’ve created a new route to implement the API behind the Smart Collection.
The logic here is to list all the users of your Zendesk account.
* Learn more about how to [authenticate, filter and sort with the Zendesk API](https://docs.forestadmin.com/woodshop/how-tos/zendesk-integration/authentication-filtering-and-sorting).
* Find more information about `getTickets` variable definition in [the Github repository](https://github.com/existenz31/forest-zendesk/blob/master/services/zendesk-tickets-service.js).
```javascript theme={null}
const express = require('express');
const { PermissionMiddlewareCreator } = require('forest-express-sequelize');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
'zendesk_tickets'
);
const { getTickets } = require('../services/zendesk-tickets-service');
// Get a list of Zendesk Tickets
router.get(
'/zendesk_tickets',
permissionMiddlewareCreator.list(),
(request, response, next) => {
getTickets(request, response, next);
}
);
```
### Implement the get Route
The section above help you display the list of all Zendesk tickets. But you'll need to implement also the logic to display the information of a specific ticket.
This is going to be very similar. We just need to implement a new endpoint to get an individual ticket from the Zendesk API.
```javascript theme={null}
async function getTicket(request, response, next) {
return axios
.get(
`${ZENDESK_URL_PREFIX}/api/v2/tickets/${request.params.ticketId}?include=comment_count`,
{
headers: {
Authorization: `Basic ${getToken()}`,
},
}
)
.then(async (resp) => {
let record = resp.data.ticket;
// Serialize the result using the Forest format
const recordSerializer = new RecordSerializer({
name: 'zendesk_tickets',
});
const recordSerialized = await recordSerializer.serialize(record);
response.send(recordSerialized);
})
.catch(next);
}
```
```javascript theme={null}
const {
getTickets,
getTicket,
} = require('../services/zendesk-tickets-service');
// Get a Zendesk Ticket
router.get(
'/zendesk_tickets/:ticketId',
permissionMiddlewareCreator.details(),
(request, response, next) => {
getTicket(request, response, next);
}
);
```
# Display Zendesk users
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/zendesk/display-zendesk-users
This section shows you how to create a smart collection to list the users of your Zendesk account.
### Declare the Smart Collection Zendesk Users
Zendesk API allows to access different data:
* [Users](https://developer.zendesk.com/rest_api/docs/support/users)
* [Tickets & Comments](https://developer.zendesk.com/rest_api/docs/support/tickets)
* [Organizations](https://developer.zendesk.com/rest_api/docs/support/organizations) and [Groups](https://developer.zendesk.com/rest_api/docs/support/groups)
First, we need to declare the smart collection in your project based on the API documentation. As an example, here the smart collection definition for Users:
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
// Search on users => https://support.zendesk.com/hc/en-us/articles/203663216-Searching-users-groups-and-organizations#topic_duj_sbb_vc
collection('zendesk_users', {
isSearchable: true,
actions: [],
fields: [
{
field: 'id',
type: 'String',
isFilterable: false, // Zendesk API does not provide such capacity with the API
},
{
field: 'name',
type: 'String',
isFilterable: true,
},
{
field: 'alias',
type: 'String',
},
{
field: 'email',
type: 'String',
isFilterable: true,
},
{
field: 'role',
type: 'Enum',
enums: ['end-user', 'agent', 'admin'],
isFilterable: true,
},
{
field: 'role_type',
type: 'Number',
},
{
field: 'phone',
type: 'String',
isFilterable: true,
},
{
field: 'whatsapp',
type: 'String',
isFilterable: true,
},
{
field: 'last_login_at',
type: 'Date',
},
{
field: 'verified',
type: 'Boolean',
},
{
field: 'active',
type: 'Boolean',
},
{
field: 'suspended',
type: 'Boolean',
isFilterable: true,
},
{
field: 'created_at',
type: 'Date',
isSortable: true,
},
{
field: 'updated_at',
type: 'Date',
isSortable: true,
},
{
field: 'last_login_at',
type: 'Date',
},
{
field: 'notes',
type: 'String',
isFilterable: true,
},
{
field: 'details',
type: 'String',
isFilterable: true,
},
{
field: 'tags',
type: ['String'],
isFilterable: true, // is it possible? => no arrays are not yet filterable
},
{
field: 'time_zone',
type: 'String',
},
{
field: 'moderator',
type: 'Boolean',
},
{
field: 'external_id',
type: 'String',
isFilterable: true,
},
{
field: 'only_private_comments',
type: 'Boolean',
},
{
field: 'photo_url',
type: 'File',
get: (user) => {
return user.photo ? user.photo.content_url : null;
},
},
],
segments: [],
});
```
Some fields are available for filtering or sorting using the Zendesk API. To allow this on the Forest UI, simply add the keywords `isFilterable` and `isSortable` in your field definition.
### Implement the Smart Collection route
In the file `routes/zendesk-users.js`, we’ve created a new route to implement the API behind the Smart Collection.
The logic here is to list all the users of your Zendesk account.
* Learn more about how to [authenticate, filter and sort with the Zendesk API](https://docs.forestadmin.com/woodshop/how-tos/zendesk-integration/authentication-filtering-and-sorting).
* Find more information about `getUsers` variable definition in [the Github repository](https://github.com/existenz31/forest-zendesk/blob/master/services/zendesk-users-service.js).
```javascript theme={null}
const express = require('express');
const { PermissionMiddlewareCreator } = require('forest-express-sequelize');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
'zendesk_tickets'
);
const { getUsers } = require('../services/zendesk-users-service');
// Get a list of Zendesk Users
router.get(
'/zendesk_users',
permissionMiddlewareCreator.list(),
(request, response, next) => {
getUsers(request, response, next);
}
);
```
### Implement the get Route
The section above help you display the list of all Zendesk users. But you'll need to implement also the logic to display the information of a specific user.
We just need to implement a new endpoint to get an individual user from the Zendesk API.
```javascript theme={null}
async function getUser(request, response, next) {
return axios
.get(
`${ZENDESK_URL_PREFIX}/api/v2/users/${request.params.userId}?include=comment_count`,
{
headers: {
Authorization: `Basic ${getToken()}`,
},
}
)
.then(async (resp) => {
let record = resp.data.user;
// Serialize the result using the Forest format
const recordSerializer = new RecordSerializer({ name: 'zendesk_users' });
const recordSerialized = await recordSerializer.serialize(record);
response.send(recordSerialized);
})
.catch(next);
}
```
```javascript theme={null}
const { getUsers, getUser } = require('../services/zendesk-tickets-service');
// Get a Zendesk Ticket
router.get(
'/zendesk_users/:userId',
permissionMiddlewareCreator.details(),
(request, response, next) => {
getUser(request, response, next);
}
);
```
# Zendesk
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/zendesk/overview
For this example we will use the Zendesk API described [here](https://developer.zendesk.com/rest_api/docs/support/introduction).
We are going to use [Smart Collections](/legacy/javascript-agents/reference-guide/smart-collections/overview), [Smart Relationships](/legacy/javascript-agents/reference-guide/models/relationships/create-a-smart-relationship/overview), and [Smart Fields](/legacy/javascript-agents/reference-guide/smart-fields/overview) to implement such integration.
The full implementation of this integration is available [here](https://github.com/existenz31/forest-zendesk) on GitHub.
### Live Demo
### Build your basic Admin Panel with Forest
Let's start with a basic admin panel on top of a SQL database that has a table `Users` that holds an email address field.
Now, let's build the Admin Panel as usual with Forest. You will get something like this:
# View tickets related to a user
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/integrations/zendesk/view-tickets-related-to-a-user
Now, let's say we want to access the tickets for a user of my database. We are going to use the email address as the foreign key between the database model (`Users` table) and Zendesk tickets.
First, we need to create the [Smart Relationship](https://docs.forestadmin.com/documentation/reference-guide/relationships/create-a-smart-relationship) between `Users` and `zendesk_tickets` as follows:
```javascript theme={null}
collection('users', {
actions: [],
fields: [
{
field: 'ze_requested_tickets',
type: ['String'],
reference: 'zendesk_tickets.id',
},
],
segments: [],
});
```
Then, we need to implement the Smart Relationship route. This route will query the Zendesk tickets related to the user's email (requested field on `zendesk_tickets`).
```javascript theme={null}
const { getTickets } = require('../services/zendesk-tickets-service');
router.get(
'/users/:userId/relationships/ze_requested_tickets',
async (request, response, next) => {
// Get the user email for filtering on requester
const user = await users.findByPk(request.params.userId);
const additionalFilter = `requester:${user.email}`;
getTickets(request, response, next, additionalFilter);
}
);
```
Now, you should see the requested tickets for a user:
# Enrich your models
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/models/enrich-your-models
⚠️ This page is relevant only if you installed Forest directly on a database (SQL/Mongodb). If you installed in a Rails/Django/Laravel app, you manage your models like you normally would.
### Declaring a new model
Whenever you have a new table/collection in your database, you will have to create file to declare it. Here is a **template example** for a `companies` table:
```javascript theme={null}
module.exports = (sequelize, DataTypes) => {
const { Sequelize } = sequelize;
const Company = sequelize.define('companies', {
name: {
type: DataTypes.STRING,
},
createdAt: {
type: DataTypes.DATE,
},
...
}, {
tableName: 'companies',
underscored: true,
schema: process.env.DATABASE_SCHEMA,
});
Company.associate = (models) => {
};
return Company;
};
```
**Fields** within that model should match your table's fields as shown in next section.
New **relationships** may be added there:
```javascript theme={null}
Company.associate = (models) => {};
```
You can learn more about relationships on this [dedicated page](/legacy/javascript-agents/reference-guide/models/relationships/overview).
```javascript theme={null}
module.exports = (mongoose, Mongoose) => {
const schema = Mongoose.Schema({
'name': String,
'createdAt': Date,
...
}, {
timestamps: false,
});
return mongoose.model('companies', schema, 'companies');
};
```
**Fields** within that model should match your collection's fields as shown in next section.
New **relationships** are to be added as properties:
```javascript theme={null}
'orders': [{ type: mongoose.Schema.Types.ObjectId, ref: 'orders' }],
'customer_id': { type: mongoose.Schema.Types.ObjectId, ref: 'customers' },
```
You can learn more about relationships on this [dedicated page](/legacy/javascript-agents/reference-guide/models/relationships/overview).
When you manually add a new model, you need to configure the permissions for the corresponding collection in the UI (allow record details view, record creation, record edit, etc). By default a new collection is not visible and all permissions are disabled. You can set permissions by going to the [Roles settings](https://docs.forestadmin.com/user-guide/project-settings/teams-and-users/manage-roles).
### Declaring a new field in a model
Any new field must be added **manually** within the corresponding model of your `/models` folder.
Fields are declared as follows:
```javascript theme={null}
createdAt: {
type: DataTypes.DATE,
},
```
An exhaustive list of **DataTypes** can be found in [Sequelize documentation](https://sequelize.org/master/manual/data-types.html).
You can see how that snippet fits into your code in the [model example](/legacy/javascript-agents/reference-guide/models/enrich-your-models#declaring-a-new-model) above.
Fields are declared as follows:
```javascript theme={null}
'createdAt': Date,
```
An exhaustive list of **SchemaTypes** can be found in [Mongoose documentation](https://mongoosejs.com/docs/schematypes.html#what-is-a-schematype).
You can see how that snippet fits into your code in the [model example](/legacy/javascript-agents/reference-guide/models/enrich-your-models#declaring-a-new-model) above.
### Managing nested documents in Mongoose
For a better user experience, flatten nested fields. In v2 see the [Flattener plugin](/product/process/advanced-concepts/plugins/overview).
Lumber introspects your data structure recursively, so ***nested fields*** (object in object) are detected any level deep. Your **sub-documents** (array of nested fields) are detected as well.
Conflicting data types will result in the generation of a [mixed](https://mongoosejs.com/docs/schematypes.html#mixed) type field.
The following model...
```javascript theme={null}
module.exports = (mongoose, Mongoose) => {
const schema = Mongoose.Schema({
// Level 0
'age': Number,
'id': Number,
'name': String,
// Level 1
'address':{
'addressDetail': String,
'area': String,
'city': String,
'pincode': Number,
},
// Level 2
'contactDetails':{
'phone':{
'homePhone': String,
'mobilePhone': String,
},
'email': String,
},
// Related data
'booksRead':[{
'name': String,
'authorName': String,
'publishedBy': String,
}],
}, {
timestamps: false,
});
return mongoose.model('testCollection', schema, 'testCollection');
};
```
...will result in the following interface:
### Removing a model
By default **all** tables/collections in your database are analyzed by Lumber to generate your models. If you want to exclude some of them to prevent them from appearing in your Forest, check out [this how-to](/legacy/javascript-agents/how-tos/settings/include-exclude-models).
### Adding validation to your models
Validation allows you to keep control over your data's quality and integrity.
If your existing app already has validation conditions, you may - or may not - want to reproduce the same validation conditions in your admin backend's models.
If so, you'll have to do it **manually**, using the below examples.
Depending on your database type, your models will have been generated in *Sequelize* (for SQL databases) or *Mongoose* (for Mongo databases).
In Sequelize, you add validation using the `validate` property:
```javascript theme={null}
module.exports = (sequelize, DataTypes) => {
const Customer = sequelize.define('customers', {
...
'email': {
type: DataTypes.STRING,
validate: {
isEmail: true,
len: [10,25]
}
},
...
},
...
return Customer;
};
```
The 2 validators above will have the following effect on your email field:
For an exhaustive list of available validators, check out the [Sequelize documentation](https://sequelize.org/master/manual/models-definition.html#validations).
In Mongoose, you add validators alongside the `type` property:
```javascript theme={null}
module.exports = (mongoose, Mongoose) => {
const schema = Mongoose.Schema({
'createdAt': Date,
'email': {
'type': String,
'minlength': 10,
'maxlength': 25
},
'firstname': String,
...
}
return mongoose.model('customer', schema, 'customer');
};
```
This is the effect on your field:
Mongoose has no build-in validators to check whether a string is an email. Should you want to validate that a content is an email, you have several solutions:
```javascript theme={null}
module.exports = (mongoose, Mongoose) => {
const schema = Mongoose.Schema({
'createdAt': Date,
'email': {
'type': String,
'match': [/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/, 'Invalid email']
},
'firstname': String,
...
}
return mongoose.model('customer', schema, 'customer');
};
```
A better yet solution would be to rely on an external library called [validator.js](https://www.npmjs.com/package/validator) which provides many [build-in validators](https://www.npmjs.com/package/validator#validators):
```javascript theme={null}
import { isEmail } from 'validator';
module.exports = (mongoose, Mongoose) => {
const schema = Mongoose.Schema({
'createdAt': Date,
'email': {
'type': String,
'validate': [isEmail, 'Invalid email']
},
'firstname': String,
...
}
return mongoose.model('customer', schema, 'customer');
};
```
You then that any invalid email is refused:
For further details on validators in Mongoose, check out the [Mongoose documentation](https://mongoosejs.com/docs/validation.html#built-in-validators).
###
### Adding a default value to your models
You can choose to add a default value for some fields in your models. As a result, the corresponding fields will be prefilled with their default value in the creation form:
```javascript theme={null}
module.exports = (sequelize, DataTypes) => {
const Customer = sequelize.define('customers', {
...
'firstname': {
'type': DataTypes.STRING,
'defaultValue': 'Marc'
},
...
},
...
return Customer;
};
```
```javascript theme={null}
module.exports = (mongoose, Mongoose) => {
const schema = Mongoose.Schema({
'createdAt': Date,
'email': {
'type': String,
'default': 'Marc'
},
'firstname': String,
...
}
return mongoose.model('customer', schema, 'customer');
};
```
### Adding a hook
Hooks are a powerful mechanism which allow you to automatically **trigger an event** at specific moments in your records lifecycle.
In our case, let's pretend we want to update a `update_count` field every time a record is updated:
To add a `beforeSave` hook in Sequelize, use the following syntax:
```javascript theme={null}
module.exports = (sequelize, DataTypes) => {
var Order = sequelize.define('orders', {
...
'update_count': {
'type': DataTypes.INTEGER,
'defaultValue': 0
},
...
},
...
Order.beforeSave((order, options) => {
order.update_count += 1;
}
);
return Order;
};
```
Every time the order is updated, the updateCount field will be incremented by 1:
The exhaustive list of available hooks in Sequelize are available [here](https://sequelize.org/master/manual/hooks.html).
To add a hook in Mongoose on `save` event, you may use the following snippet:
```javascript theme={null}
module.exports = (mongoose, Mongoose) => {
const schema = Mongoose.Schema({
'update_count': {
'type': Number,
'default': 0
},
...
}
schema.pre('save', async function() {
const newCount = this.update_count + 1;
const incrementCount = () => {
this.set('update_count', newCount);
};
await incrementCount();
});
return mongoose.model('order', schema, 'order');
};
```
As mentioned in [their documentation](https://mongoosejs.com/docs/middleware.html#notes)
*Pre and post `save()` hooks are **not** executed on `update()`, `findOneAndUpdate()`, etc.*
This would only work if you specifically call `save` in your update method.
# Models
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/models/overview
⚠️ This page and sub-pages are relevant only if you installed Forest directly on a database (SQL/Mongodb). If you installed in a Rails/Django/Laravel app, you manage your models like you normal
Your models are located in `/models`. They control a big part of your Forest UI.
### Reflecting your database changes in your UI
When you install for the first time, Lumber introspects your database and generates your models accordingly.
Afterwards, here's how your database changes can be rendered in your Forest UI:
### Updating your models automatically
If you made many changes or even added a new table/collection, we recently reintroduced a programmatic way to help you manage the associated file changes:
This feature requires an agent **version** 7 or higher.
Version 2.2+ of [Forest CLI](https://www.npmjs.com/package/forest-cli) allows you via its `schema:update` command to:
* Generate files which, after introspecting your database, appear to be missing in your folders (`models` , `routes` & `forest`). Eg. Adding a new table and launching `schema:update` within your project directory should generate the associated models/routes & forest files
* Generate a correct project architecture to easily manage multiple databases. After your onboarding (on a single database), update the `config/databases.js` file to add a new connection, launch `schema:update` and your models should be set correctly
`forest schema:update` will **never** modify your code base (remove files, move files, change file content). It's up to you to copy some (or all) of the generated contents into your existing files/folders.
Note that `forest schema:update` options are as follows:
* `-c` or `--config` , allowing to specify a path for the config file to user (Default to `./config/databases.js`)
* `-o` or `--output-directory` : Create a directory named after the config parameter provided. It will also redump all the `models/routes/forest` file in a specific directory, allowing the end-user to pick code modification.
This command need to be launched at the root of the project directory, where the `.env` should be, since it is required by `config/databases.js` file.
Have any models that will always stay hidden? Find out how [you can exclude them](/legacy/javascript-agents/how-tos/settings/include-exclude-models) and gain on performance.
### Enriching your models
Lumber does some of the work for you. However, **you remain in control of your models**.
On the following page, we'll cover how you can enrich your models:
### The `.forestadmin-schema.json` file
On server start, a `.forestadmin-schema.json` file will be auto-generated in **local (development) environments only.** It reflects:
* the **state of your models** (in `/models`)**.**
* your **Forest customization** (in `/forest`).
This file **must be versioned and deployed** for any remote environment (staging, production, etc.), as it will be used to generate your Forest UI.
We use the environment variable ***NODE\_ENV*** to detect if an environment is in development. Setting this variable to either nothing or ***development*** will regenerate a new *.forestadmin-schema.json* file every time your app restarts. Using another value will not regenerate the file.
A consequence of the above is, **in Production** the `.forestadmin-schema.json` file does **not** update according to your schema changes.
**Do not edit this file,** as it could break your interface if the wrong syntax is used.
Versioning the`.forestadmin-schema.json` file will also help you visualize your changes.
To **disable automatic** Forest schema updates and do it **manually**, follow this [how-to](https://docs.forestadmin.com/documentation/v/v4/how-tos/disable-automatic-forest-admin-schema-update).
# GetIdsFromRequest
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/models/relationships/create-a-smart-relationship/getidsfromrequest
In recent versions of our agents, you may have noticed a new helper, which is `getIdsFromRequest`. This helper comes alongside the ['Select All' feature](https://docs.forestadmin.com/documentation/how-tos/maintain/upgrade-notes-sql-mongodb/upgrade-to-v6#select-all-feature), allowing you to trigger a Smart Action on more records than those displayed in the UI.
Unfortunately, this helper is not compatible with Smart Actions triggered on Smart Relationships. This is due to the Smart Relationship concept. When you create a [HasMany Smart Relationship](https://docs.forestadmin.com/documentation/reference-guide/relationships/create-a-smart-relationship#creating-a-hasmany-smart-relationship), you become the owner of the way your data are linked together by overriding the routes. Forest can't retrieve the logic to link the data, this is why you also need to code your own `getIdsFromRequest` helper. This documentation will guide you through the steps you need to create your own helper.
Let's take an example to illustrate what we want to achieve:
In this case, with have a HasMany Smart Relationship between `owners` and `articles` called `Liked articles`. As you can see, we are about to trigger the `Unlike` Smart Action on every article the owner liked that corresponds to the filter and the search we configured.
### What is the getIdsFromRequest about?
This helper simply takes a query as a parameter (containing your filters, your search, and some other configuration) and then returns the ids corresponding to this query. In other words, based on what the user selects ('select all', 'select current page', ...) this helper is able to return the exact ids the user wants to operate on. With these ids, your will then be able to perform operations related to your smart actions.
4 cases need to be handled there:
* Select all: each of the related records should be impacted
* Select all, minus some: each of the related records should be impacted, except specific ones
* Select current page: each of the listed records should be impacted
* Select some: only some specific records should be impacted
Only the two first cases need to be handled, because the last two cases consist of a simple list of the ids selected by the user directly in the request. So nothing special to do here.
In conjunction with the previous 4 cases, we also need to handle the filters and the search set up before executing the smart action.
### Code Snippet
Please find in the following snippet every of the requirement listed above fulfilled to make the Smart Action work with the Select All feature.
```javascript theme={null}
const { PermissionMiddlewareCreator } = require('forest-express-sequelize');
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('articles');
...
// In this function, we want to mimic the getIdsFromRequest behavior,
// Used in conjunction with the "select all record" feature on a smart relationship
async function customGetIdsFromRequest(request) {
const {
all_records,
all_records_ids_excluded,
parent_collection_id: parentRecordId,
all_records_subset_query,
ids,
} = request.body.data.attributes;
if (all_records) {
// In this case, the "select all records" option has been selected.
// This means that the action we want to trigger needs to be performed
// On every record
const options = {
where: {
owners_id: parentRecordId,
},
attributes: ['id'],
raw: true,
}
// Handle filters on records if any
if (all_records_subset_query && all_records_subset_query.filters) {
const filter = await parseFilter(
JSON.parse(all_records_subset_query.filters),
Schemas.schemas.articles,
request.query.timezone
);
options.where = { ...options.where, ...filter };
}
// Handle the search if any
if (all_records_subset_query.search) {
if (options.where.body) {
options.where.body[Op.like] = all_records_subset_query.search;
} else {
options.where.body = { [Op.like]: all_records_subset_query.search };
}
}
if (all_records_ids_excluded && all_records_ids_excluded.length) {
// In this case, the "select all records" option has been selected but some records
// has been unselected right after. This means that the action we want to trigger
// needs to be performed on every relationship, except some specific one
options['where']['id'] = {
[Op.notIn]: all_records_ids_excluded
};
}
return (await articles.findAll(options)).map((record) => record.id);
}
// In any other cases, the ids selected to perform the
// smart action on are listed in the "ids" attribute
return ids;
}
router.post('/actions/Unlike', permissionMiddlewareCreator.smartAction(), async (request, response, next) => {
const ids = await customGetIdsFromRequest(request);
// Do whatever you want with the ids here
response.status(200).send();
})
```
Explanation of the code:
* Line 1: If the Select All feature has been used, we need to build a query to concatenate the filter, the search, and the Select All configuration. Otherwise, the ids are already present in the query (see line 58)
* Line 25: Here is an example to show you how to quickly handle filters, if any
* Line 36: Here is an example to show you how to handle the search, if any
* Line 44: Finally, this snippet of code removes any ids that have been unselected by the user after using the Select All feature.
# Create a Smart relationship
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/models/relationships/create-a-smart-relationship/overview
### What is a Smart Relationship?
Sometimes, you want to create a virtual relationship between two set of data that does not exist in your database. A concrete example could be creating a relationship between two collections available in two different databases. Creating a Smart Relationship allows you to customize with code how your collections are linked together.
### Create a BelongsTo Smart Relationship
On the Live Demo example, we have an **order** which `belongsTo` a **customer** which `belongsTo` a **delivery address**. We’ve created here a BelongsTo Smart Relationship that acts like a shortcut between the **order** and the **delivery address**.
A BelongsTo Smart Relationship is created like a [Smart Field](/legacy/javascript-agents/reference-guide/smart-fields/overview#what-is-a-smart-field) with the `reference` option to indicate on which collection the Smart Relationship points to. You will also need to code the logic of the search query.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
const models = require('../models');
collection('orders', {
fields: [{
field: 'delivery_address',
type: 'String',
reference: 'addresses.id',
get: function (order) {
return models.addresses
.findAll({
include: [{
model: models.customers,
where: { id: order.customer_id },
include: [{
model: models.orders,
where: { ref: order.ref }
}]
}],
})
.then((addresses) => {
if (addresses) { return addresses[0]; }
});
}
}]
});
```
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
const Address = require('../models/addresses');
collection('Order', {
fields: [
{
field: 'delivery_address',
type: 'String',
reference: 'Address._id',
get: function (order) {
return Address.aggregate([
{
$lookup: {
from: 'orders',
localField: 'customer_id',
foreignField: 'customer_id',
as: 'orders_docs',
},
},
{
$match: {
'orders_docs._id': order._id,
},
},
]).then((addresses) => {
if (addresses) {
return addresses[0]._id;
}
});
},
},
],
});
```
```ruby theme={null}
class Forest::Order
include ForestLiana::Collection
collection :Order
search_delivery_address = lambda do |query, search|
query.joins(customer: :address).or(Order.joins(customer: :address).where("addresses.country ILIKE ?", "%#{search}%"))
end
belongs_to :delivery_address, reference: 'Address.id', search: search_delivery_address do
object.customer.address
end
end
```
Ensure the file app/forest/\_\_init\_\_.py exists and contains the import of the previous defined class :
```python theme={null}
from django_forest.utils.collection import Collection
from app.models import Product
class ProductForest(Collection):
def load(self):
self.fields = [
{
'field': 'buyers',
'reference': 'app_customer.id',
'type': ['String'],
}
]
Collection.register(ProductForest, Product)
```
Upon browsing, an API call is triggered when accessing the data of the HasMany relationships in order to fetch them asynchronously. In the following example, the API call is a GET on `/products/:product_id/relationships/buyers`.
**Option 1: using Sequelize ORM**
We’ll use the **findAll** and **count** methods provided by [Sequelize](https://sequelize.org/v5/manual/querying.html) to find and count all customers who bought the current product (*buyers*).
Then, you should handle pagination in order to avoid performance issue. The API call has a query string available which gives you all the necessary parameters you need to enable pagination.
Finally, you don’t have to serialize the data yourself. The Forest agent already knows how to serialize your collection (`customers` in this example). You can access to the serializer through the `recordsGetter.serialize` function.
```javascript theme={null}
const express = require('express');
const {
PermissionMiddlewareCreator,
RecordSerializer,
} = require('forest-express-sequelize');
const { products, customers, orders } = require('../models');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('products');
router.get(
'/products/:product_id/relationships/buyers',
(request, response, next) => {
const productId = request.params.product_id;
const limit = parseInt(request.query.page.size, 10) || 20;
const offset = (parseInt(request.query.page.number, 10) - 1) * limit;
const include = [
{
model: orders,
as: 'orders',
where: { product_id: productId },
},
];
// find the customers for the requested page and page size
const findAll = customers.findAll({
include,
offset,
limit,
});
// count all customers for pagination
const count = customers.count({ include });
// resolve the two promises and serialize the response
const serializer = new RecordSerializer(customers);
Promise.all([findAll, count])
.then(([customersFound, customersCount]) =>
serializer.serialize(customersFound, { count: customersCount })
)
.then((recordsSerialized) => response.send(recordsSerialized))
.catch(next);
}
);
```
**Option2: using raw SQL**
We’ll use raw SQL query and [Sequelize](http://docs.sequelizejs.com) to **count** and **find all** customers who bought the current product (*buyers*).
Then, you should handle pagination in order to avoid performance issue. The API call has a query string available which gives you all the necessary parameters you need to enable pagination.
Finally, you don’t have to serialize the data yourself. The Forest agent already knows how to serialize your collection (`customers` in this example). You can access to the serializer through the `recordsGetter.serialize` function.
```javascript theme={null}
const express = require('express');
const router = express.Router();
const models = require('../models');
router.get('/products/:product_id/relationships/buyers', (req, res, next) => {
let limit = parseInt(req.query.page.size) || 10;
let offset = (parseInt(req.query.page.number) - 1) * limit;
let queryType = models.sequelize.QueryTypes.SELECT;
let countQuery = `
SELECT COUNT(*)
FROM customers
JOIN orders ON orders.customer_id = customers.id
JOIN products ON orders.product_id = products.id
WHERE product_id = ${req.params.product_id};
`;
let dataQuery = `
SELECT customers.*
FROM customers
JOIN orders ON orders.customer_id = customers.id
JOIN products ON orders.product_id = products.id
WHERE product_id = ${req.params.product_id}
LIMIT ${limit}
OFFSET ${offset}
`;
const serializer = new RecordSerializer(customers);
Promise.all([
// Since support to multiple db connections was added you have to use the connection name defined in config/databases.js
// here using default
models.connections.default.query(countQuery, { type: queryType }),
models.connections.default.query(dataQuery, { type: queryType }),
])
.then(([count, queryResult]) =>
serializer.serialize(queryResult[0], { count: count[0].count })
)
.then((serializedResult) => res.send(serializedResult))
.catch((err) => next(err));
});
module.exports = router;
```
If your primary key column name (`customer_id`) is different than the model field name (`customerId`), you must alias the primary key column with the name of the model field in the **dataQuery**.\
\
Ex: `SELECT customers.*, customers.customer_id AS “customerId”`
Upon browsing, an API call is triggered when accessing the data of the HasMany relationships in order to fetch them asynchronously. In the following example, the API call is a GET on `/Product/:product_id/relationships/buyers`.
We use the `$lookup` operator of the **aggregate** pipeline. Since there's a many-to-many relationship between `Product` and `Customer`, the `$lookup` operator needs to look into orders which is an array we have to flatten first using `$unwind`.
Finally, you don’t have to serialize the data yourself. The Forest agent already knows how to serialize your collection (`Customer` in this example). You can access to the serializer through the `Liana.ResourceSerializer` object.
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
collection('products', {
fields: [
{
field: 'buyers',
type: ['String'],
reference: 'Customer._id',
},
],
});
```
```javascript theme={null}
const P = require('bluebird');
const express = require('express');
const router = express.Router();
const Liana = require('forest-express-mongoose');
const { Customers } = require('../models');
const mongoose = require('mongoose');
router.get('/Product/:product_id/relationships/buyers', (req, res, next) => {
let limit = parseInt(req.query.page.size) || 10;
let offset = (parseInt(req.query.page.number) - 1) * limit;
let countQuery = Customers.aggregate([
{
$lookup: {
from: 'orders',
localField: 'orders',
foreignField: '_id',
as: 'orders_docs',
},
},
{
$unwind: '$orders_docs',
},
{
$lookup: {
from: 'products',
localField: 'orders_docs._id',
foreignField: 'orders',
as: 'products_docs',
},
},
{
$match: {
'products_docs._id': mongoose.Types.ObjectId(req.params.product_id),
},
},
{
$count: 'products_docs',
},
]);
let dataQuery = Customers.aggregate([
{
$lookup: {
from: 'orders',
localField: 'orders',
foreignField: '_id',
as: 'orders_docs',
},
},
{
$unwind: '$orders_docs',
},
{
$lookup: {
from: 'products',
localField: 'orders_docs._id',
foreignField: 'orders',
as: 'products_docs',
},
},
{
$match: {
'products_docs._id': mongoose.Types.ObjectId(req.params.product_id),
},
},
]);
return P.all([countQuery, dataQuery])
.spread((count, customers) => {
const serializer = new Liana.RecordSerializer(Customers);
return serializer.serialize(customers, { count: count.orders_count });
})
.then((products) => {
res.send(products);
})
.catch((err) => next(err));
});
module.exports = router;
```
Upon browsing, an API call is triggered when accessing the data of the HasMany relationships in order to fetch them asynchronously. In the following example, the API call is a GET on `/Product/:product_id/buyers`.
We’ve built the right SQL query using [Active Record](http://guides.rubyonrails.org/active_record_basics.html) to **count** and **find all** customers who bought the current product.
Then, you should handle pagination in order to avoid performance issue. The API call has a querystring available which gives you all the necessary parameters you need to enable pagination.
Finally, you don’t have to serialize the data yourself. The Forest agent already knows how to serialize your collection (`Customer` in this example). You can access to the serializer through the `serialize_models()` function.
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
get '/Product/:product_id/buyers' => 'orders#buyers'
end
mount ForestLiana::Engine => '/forest'
end
```
```ruby theme={null}
class Forest::ProductsController < ForestLiana::ApplicationController
def buyers
limit = params['page']['size'].to_i
offset = (params['page']['number'].to_i - 1) * limit
product = Product.find(params['product_id'])
customers = Customer.where(order_id: product.orders.ids)
render json: serialize_models(customers.limit(limit).offset(offset), meta: {count: customers.count})
end
end
```
Upon browsing, an API call is triggered when accessing the data of the HasMany relationships in order to fetch them asynchronously. In the following example, the API call is a GET on `/app_product/:product_pk/relationships/buyers`.\
\
You will have to declare this route in your app **urls.py** file
Then create the pertained view
We’ve built the right SQL query using [Django ORM](https://docs.djangoproject.com/en/3.2/topics/db/queries/) to **find all** customers who bought the current product.
Then, you should handle pagination in order to avoid performance issue. The API call has a querystring available which gives you all the necessary parameters you need to enable pagination.
Finally, you don’t have to serialize the data yourself. The Forest agent already knows how to serialize your collection (`Customer` in this example, with the table name `app_customer`). You can access to the serializer through the `Schema().dump` function (using [marshmallow-jsonapi](https://marshmallow-jsonapi.readthedocs.io/en/latest/) internally).
Upon browsing, an API call is triggered when accessing the data of the HasMany relationships in order to fetch them asynchronously. In the following example, the API call is a GET on `/product/{id}/relationships/buyers`.
We’ve built the right SQL query using [Active Record](http://guides.rubyonrails.org/active_record_basics.html) to **count** and **find all** customers who bought the current product.
Then, you should handle pagination in order to avoid performance issue. The API call has a querystring available which gives you all the necessary parameters you need to enable pagination.
Finally, you don’t have to serialize the data yourself. The Forest agent already knows how to serialize your collection (`Customer` in this example). You can access to the serializer through the `render()` function of JsonApi facade.
# Relationships
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/models/relationships/overview
## What is a relationship?
A relationship is a connection between two collections.
Relationships are visible and actionable in Forest:
* `hasMany` **(1)**
* `belongsTo` or `hasOne`**(2)**
If you installed Forest within a **Rails** app, then all the relationships defined in your ActiveRecord models are supported out of the box. Check the official [Rails documentation](https://guides.rubyonrails.org/association_basics.html) to create new ones.
If you installed Forest directly on a database, then most relationships should have been [automatically generated](/legacy/javascript-agents/reference-guide/models/relationships/overview#lumber-relationship-generation-rules). However, depending on your database nature and structure, you may have to add some manually.
## Adding relationships (databases only)
Depending on your database type, your models will have been generated in Sequelize (for SQL databases) or Mongoose (for Mongo databases).
Below are some simple snippets showing you how to add relationships. However, should you want to dig deeper, please refer to the appropriate framework's documentations:
* [Sequelize's documentation](https://sequelize.org/master/manual/assocs.html) on adding relationships in your models (SQL)
* [Mongoose's documentation](https://mongoosejs.com/docs/guide.html) on adding relationships in your models (Mongodb)
### Adding a `hasMany` relationship
In our [Live demo](https://app.forestadmin.com/Live%20Demo/Production/Operations/data/806052/index), a **customer** can have multiple **orders**. In that case, we have to use a `hasMany` relationship.
```javascript theme={null}
module.exports = (sequelize, DataTypes) => {
const Customer = sequelize.define('customers',
...
);
Customer.associate = (models) => {
Customer.hasMany(models.orders);
};
return Customer;
};
```
```javascript theme={null}
module.exports = (mongoose, Mongoose) => {
const schema = Mongoose.Schema({
...
'orders': [{ type: Mongoose.Schema.Types.ObjectId, ref: 'orders' }],
...
}, {
timestamps: true,
});
return mongoose.model('customers', schema, 'customers');
};
```
Note that for orders to be displayed within the related data section of your customer, they have to be populated in your database. For instance:
Once you've added your relationship(s) in your model(s), they will only be taken into account **after you restart your server**.

### Adding a `hasOne` relationship
In case of a one-to-one relationship between 2 collections, the opposite of a `belongsTo` relationship is a `hasOne` relationship. Taking the same example as before, the opposite of "an **address** `belongsTo` a **customer**" is simply "a **customer**`hasOne` **address"**.
```javascript theme={null}
module.exports = (sequelize, DataTypes) => {
const Customer = sequelize.define('customers',
...
);
Customer.associate = (models) => {
Customer.hasOne(models.addresses);
};
return Customer;
};
```
```javascript theme={null}
module.exports = (mongoose, Mongoose) => {
const schema = Mongoose.Schema({
...
'address': { type: Mongoose.Schema.Types.ObjectId, ref: 'addresses' },
...
}, {
timestamps: true,
});
return mongoose.model('customers', schema, 'customers');
};
```

Don't forget to **restart your server** for your newly added relationships to be taken into account.
### Adding a `belongsTo` relationship
On our Live Demo example, the Address model has a foreignKey customer\_id that points to the Customer. In other words, an **address**`belongsTo` a **customer**.
```javascript theme={null}
module.exports = (sequelize, DataTypes) => {
const Address = sequelize.define('addresses',
...
);
Address.associate = (models) => {
Address.belongsTo(models.customers);
};
return Address;
};
```
```javascript theme={null}
module.exports = (mongoose, Mongoose) => {
const schema = Mongoose.Schema({
...
'customer_id': { type: Mongoose.Schema.Types.ObjectId, ref: 'customers' },
...
}, {
timestamps: true,
});
return mongoose.model('addresses', schema, 'addresses');
};
```
This will work if your foreign keys are correctly named:\
For a collection `collectionName`, the foreign key should be `collection_name_id`.\
\
If this is not the case, check out the [section below](/legacy/javascript-agents/reference-guide/models/relationships/overview#declaring-a-foreign-key-sql-only).
Don't forget to **restart your server** for your newly added relationships to be taken into account.
#### Declaring a foreign key (SQL only)
It's possible that your tables are linked in an unusual way (using *names* instead of *ids* for instance).\
\
In that case, adding the above code will not suffice to add the `belongsTo` relationship. Even though we recommend you modify your database structure to stay within foreign key conventions (pointing to an id), there is a way to **specify how your tables are linked**.
If the field `fk_customername` of a table **Address** points to the field `name` of a table **Customer**, add the following:
```javascript theme={null}
...
Address.associate = (models) => {
Address.belongsTo(models.customers, {
foreignKey: 'fk_companyname'
targetKey: 'name'
});
};
...
```
This is explained in [Sequelize's documentation](https://sequelize.org/master/manual/associations.html#target-keys).
### Adding a `belongsToMany` relationship (SQL only)
`belongsToMany` association is often used to set up a many-to-many relationship with another model. For this example, we will consider the models `Projects` and `Users`. A user can be part of many projects, and one project has many users. The junction table that will keep track of the associations will be called `userProjects`, which will contain the foreign keys projectId and userId.
```javascript theme={null}
...
UserProjects.associate = (models) => {
UserProjects.belongsTo(models.projects, {
foreignKey: {
name: 'projectIdKey',
field: 'projectId',
},
as: 'project',
});
UserProjects.belongsTo(models.users, {
foreignKey: {
name: 'userIdKey',
field: 'userId',
},
as: 'user',
});
};
...
```
```javascript theme={null}
...
Users.associate = (models) => {
Users.belongsToMany(models.projects, {
through: 'userProjects',
foreignKey: 'userId',
otherKey: 'projectId',
});
};
...
```
```javascript theme={null}
...
Projects.associate = (models) => {
Projects.belongsToMany(models.users, {
through: 'userProjects',
foreignKey: 'projectId',
otherKey: 'userId',
});
};
...
```
## Relationship generation rules
Forest automatically generates most relationships, according to the below rules:
**BelongsTo**
Detecting `belongsTo` is straight forward, we check if the referenced table of the foreign key is unique (unique constraint or primary key), then a `belongsTo` association can be set between the two tables.
**HasMany**
If the foreign key doesn't have a uniqueness constraint, then we can define a `hasMany` association.
**HasOne**
If the foreign key also have a unique constraint or is used as the primary key of its table, then we can define a `hasOne` association.
**BelongsToMany**
We detect Many-to-Many relationships when we detect a simple **junction table**. We are able to detect a junction table when it contains 2 foreign keys. It can optionally contain additional fields like a primary key and technical timestamps.
**BelongsTo**
When a document contains an ObjectID referring to another document, we create a `belongsTo` relationship to the corresponding collection.
**HasMany**
When a document contains an array of ObjectIDs referring to other documents, we create a `hasMany` relationship to the corresponding collection.
**HasOne**
Not automatically generated.
**BelongsToMany**
Not automatically generated.
# Smart Relationship Examples
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/models/relationships/smart-relationship-examples/README
# Smart hasMany relationship in mongoDB
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/models/relationships/smart-relationship-examples/smart-hasmany-relationship-in-mongodb
**Context**: As a user I want to display records that have a belongsTo relationship to another record as related data of this record.
Parent collection: `user`
Child collection: `visualization`
## Models definition
`models/user.js`
```jsx theme={null}
// This model was generated by Lumber. However, you remain in control of your models.
// Learn how here:
const mongoose = require('mongoose');
// This section contains the properties of your model, mapped to your collection's properties.
// Learn more here:
const schema = mongoose.Schema(
{
avatar_link: String,
client: { type: mongoose.Schema.Types.ObjectId, ref: 'client' },
date_added: Date,
email: String,
first_name: String,
last_name: String,
user_type: String,
},
{
timestamps: false,
}
);
module.exports = mongoose.model('user', schema, 'user');
```
`models/visualization.js`
```jsx theme={null}
// This model was generated by Lumber. However, you remain in control of your models.
// Learn how here:
const mongoose = require('mongoose');
// This section contains the properties of your model, mapped to your collection's properties.
// Learn more here:
const schema = mongoose.Schema(
{
description: String,
name: String,
user: { type: mongoose.Schema.Types.ObjectId, ref: 'user' },
visualization_type: String,
},
{
timestamps: false,
}
);
module.exports = mongoose.model('visualization', schema, 'visualization');
```
## Declaration of the relationship
As the relationship that is not present in your database structure, declare it at the level of the forest folder.
`forest/user.js`
```jsx theme={null}
const { collection } = require('forest-express-mongoose');
const { customFieldsStyles } = require('../style/fields-style.js');
// This file allows you to add to your Forest UI:
// - Smart actions:
// - Smart fields:
// - Smart relationships:
// - Smart segments:
collection('user', {
actions: [],
fields: [
{
field: 'visualizations',
type: ['String'],
reference: 'visualization._id',
},
],
segments: [],
});
```
## Implementation of the get route for the relationship
The route to get the related visualizations when you are on a user page needs to be implemented in the routes folder.
`routes/user.js`
```javascript theme={null}
const express = require('express');
const { PermissionMiddlewareCreator, RecordSerializer } = require('forest-express-mongoose');
const mongoose = require('mongoose');
const { visualization } = require('../models');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('user');
...
router.get('/user/:recordId/relationships/visualizations', permissionMiddlewareCreator.details(), async (req, res, next) => {
const limit = parseInt(req.query.page.size) || 10;
const offset = (parseInt(req.query.page.number) - 1) * limit;
const userObjectId = mongoose.Types.ObjectId(req.params.recordId);
const visualizationSerializer = new RecordSerializer({ modelName: 'visualization' });
const count = await visualization.countDocuments({ user: userObjectId });
const data = await visualization.find({ user: userObjectId }, null, { skip: offset, limit });
const dataSerialized = await visualizationSerializer.serialize(data, { count });
res.send(dataSerialized);
});
module.exports = router;j
```
# Performance
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/performance
Loading performance is key to streamlining your operations. Here are a few steps we recommend taking to ensure your Forest is optimized.
Please find here all the hands-on best practices to keep your admin panel performant. Depending on your user's needs, you might either hide or optimize some fields to limit the number of components, avoid a large datasets display or rework complex logic.
You can display bellow performances improvement tricks in [this video](https://www.youtube.com/watch?v=UC5nH8q5YUI). For any further help to improve admin panel performances, get in touch with [the community](https://community.forestadmin.com).
### Layout optimization
1\. Show only [Smart fields](https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields) you absolutely need.
As you can see in the [Loading time benchmark](/legacy/javascript-agents/reference-guide/performance#loading-time-benchmark) below, Smart fields can be quite **costly** in terms of loading performance. Limiting them to those you need is key.
2\. Reduce the number of records per page
3\. Reduce the number of fields displayed
You can hide some fields in your table view; this will not prevent you from seeing them in the record details view.
Relationship fields are links to other collection records within your table view:
Having Relationship fields can decrease your performance, especially if your tables have a lot of records. Therefore you should display only those you need and use!
### Optimize smart fields performance
To optimize your smart field performances, please check out [this section](/legacy/javascript-agents/reference-guide/smart-fields/overview#createadvancedsmartfield).
### Restrict search on specific fields
Sometimes, searching in all fields is not relevant and may even result in big performance issues. You can restrict your search to specific fields only using the `searchFields` option.
In this example, we configure Forest to only search on the fields `name` and `industry` of our collection `companies`.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('companies', {
searchFields: ['name', 'industry'],
});
```
In this example, we configure Forest to only search on the fields `name` and `industry` of our collection `companies`.
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
collection('companies', {
searchFields: ['name', 'industry'],
});
```
In this example, we configure Forest to only search on the fields `name` and `industry` of our collection `Company`.
```ruby theme={null}
class Forest::BooksController < ForestLiana::ResourcesController
def count
deactivate_count_response
end
end
```
* adding a route in `app/config/routes.rb` before `mount ForestLiana::Engine => '/forest'`
```ruby theme={null}
namespace :forest do
get '/Book/count' , to: 'books#count'
end
```
..adding the following middleware in settings.py and set the collection(s) to deactivate.
adding a route in `app/routes/web.php`
To disable the count request in the table of a relationship (Related data section):
```javascript theme={null}
router.get(
'/books/:recordId/relationships/companies/count',
deactivateCountMiddleware
);
```
```javascript theme={null}
router.get(
'/books/:recordId/relationships/companies/count',
deactivateCountMiddleware
);
```
```ruby theme={null}
class Forest::BookCompaniesController < ForestLiana::AssociationsController
def count
if (params[:search])
params[:collection] = 'Book'
params[:association_name] = 'company'
super
else
deactivate_count_response
end
end
end
```
```ruby theme={null}
namespace :forest do
get '/Book/:id/relationships/companies/count' , to: 'book_companies#count'
end
```
Furthermore, if you want to disable on all relationships at once:
```python theme={null}
class CustomDeactivateCountMiddleware(DeactivateCountMiddleware):
def is_deactivated(self, request, view_func, *args, **kwargs):
is_deactivated = super().is_deactivated(request, view_func, *args, **kwargs)
return is_deactivated and 'search' not in request.GET
```
```python theme={null}
MIDDLEWARE = [
'myproject.myapp.middlewares.CustomDeactivateCountMiddleware',
# ...
]
# To deactivate the count on /apps_books/count if there is no search argument
FOREST = {
# ...,
DEACTIVATED_COUNT = [
'apps_books', # apps_model
],
# ...
}
```
One more example: you may want to deactivate the pagination count request for a specific team:
```javascript theme={null}
router.get('/books/count', (request, response, next) => {
// Count is deactivated for the Operations team
if (request.user.team === 'Operations') {
deactivateCountMiddleware(request, response);
// Count is made for all other teams
} else {
next();
}
});
```
```javascript theme={null}
router.get('/books/count', (request, response, next) => {
// Count is deactivated for the Operations team
if (request.user.team === 'Operations') {
deactivateCountMiddleware(request, response);
// Count is made for all other teams
} else {
next();
}
});
```
```ruby theme={null}
class Forest::BooksController < ForestLiana::ResourcesController
def count
if forest_user['team'] == 'Operations'
deactivate_count_response
else
params[:collection] = 'Book'
super
end
end
end
```
### Database Indexing
**Indexes** are a powerful tool used in the background of a database to speed up querying. It power queries by providing a method to quickly lookup the requested data. As Forest generates SQL queries to fetch your data, creating indexes can improve the query response time.
5\. Index the Primary and Unique Key Columns
\
The syntax for creating an index will vary depending on the database. However, the syntax typically includes a `CREATE` keyword followed by the `INDEX` keyword and the name we’d like to use for the index. Next should come the `ON` keyword followed by the name of the table that has the data we’d like to quickly access. Finally, the last part of the statement should be the name(s) of the columns to be indexed.
```
CREATE INDEX ON (column1, column2, ...)
```
For example, if we would like to index phone numbers from a `customers` table, we could use the following statement:
```
CREATE INDEX customers_by_phoneON customers (phone_number)
```
The users cannot see the indexes, they are just used to speed up searches/queries.
6\. Index the Foreign Key Columns
Foreign key columns should be indexed if they are used intensively in Smart fields. In the table below, you can see how drastically it reduces the loading time of the page.
Updating a table with indexes takes more time than updating a table without (because the indexes also need an update). So, only create indexes on columns that will be frequently searched against.
### Loading time benchmark
Below is the outcome of a performance test on page load time of the Table view. It highlights the *importance* of **using indexes** and **limiting the number of columns and lines**.
# Default routes
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/routes/default-routes
⚠️ This page is relevant only if you installed Forest directly on a database (SQL/Mongodb). If you installed in a Rails app, the default routes are managed within your Rails app.
Forest's default routes are generated in the `routes` folder at installation.
Below we've detailed what the `next()` statement does. Those snippets can be used when overriding those routes, as explained [here](/legacy/javascript-agents/reference-guide/routes/override-a-route).
### Create a record
```javascript theme={null}
...
const {
PermissionMiddlewareCreator,
RecordCreator,
} = require('forest-express-sequelize');
const { companies } = require('../models');
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('companies');
...
// Create a Company - Check out our documentation for more details: https://docs.forestadmin.com/documentation/v/v6/reference-guide/routes/default-routes#create-a-record
router.post('/companies', permissionMiddlewareCreator.create(), (request, response, next) => {
const { body, query, user } = request;
const recordCreator = new RecordCreator(companies, user, query);
recordCreator.deserialize(body)
.then(recordToCreate => recordCreator.create(recordToCreate))
.then(record => recordCreator.serialize(record))
.then(recordSerialized => response.send(recordSerialized))
.catch(next);
});
...
```
### Update a record
```javascript theme={null}
...
const {
PermissionMiddlewareCreator,
RecordUpdater,
} = require('forest-express-sequelize');
const { companies } = require('../models');
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('companies');
...
// Update a Company - Check out our documentation for more details: https://docs.forestadmin.com/documentation/v/v6/reference-guide/routes/default-routes#update-a-record
router.put('/companies/:recordId', permissionMiddlewareCreator.update(), (request, response, next) => {
const { body, params, query, user } = request;
const recordUpdater = new RecordUpdater(companies, user, query);
recordUpdater.deserialize(body)
.then(recordToUpdate => recordUpdater.update(recordToUpdate, params.recordId))
.then(record => recordUpdater.serialize(record))
.then(recordSerialized => response.send(recordSerialized))
.catch(next);
});
...
```
Note that the **update** of `belongsTo` fields is managed by [another route](/legacy/javascript-agents/reference-guide/routes/default-routes#relationship-routes).
### Delete a record
```javascript theme={null}
...
const {
PermissionMiddlewareCreator,
RecordRemover,
} = require('forest-express-sequelize');
const { companies } = require('../models');
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('companies');
...
// Delete a Company - Check out our documentation for more details: https://docs.forestadmin.com/documentation/v/v6/reference-guide/routes/default-routes#delete-a-record
router.delete('/companies/:recordId', permissionMiddlewareCreator.delete(), (request, response, next) => {
const { params, query, user } = request;
const recordRemover = new RecordRemover(companies, user, query);
recordRemover.remove(params.recordId)
.then(() => response.status(204).send())
.catch(next);
});
...
```
### Get a list of records
```javascript theme={null}
...
const {
PermissionMiddlewareCreator,
RecordsGetter,
} = require('forest-express-sequelize');
const { companies } = require('../models');
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('companies');
...
// Get a list of Companies - Check out our documentation for more details: https://docs.forestadmin.com/documentation/v/v6/reference-guide/routes/default-routes#get-a-list-of-records
router.get('/companies', permissionMiddlewareCreator.list(), (request, response, next) => {
const { query, user } = request;
const recordsGetter = new RecordsGetter(companies, user, query);
recordsGetter.getAll()
.then(records => recordsGetter.serialize(records))
.then(recordsSerialized => response.send(recordsSerialized))
.catch(next);
});
...
```
### Get a number of records
```javascript theme={null}
...
const {
PermissionMiddlewareCreator,
RecordsCounter,
} = require('forest-express-sequelize');
const { companies } = require('../models');
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('companies');
...
// Get a number of Companies - Check out our documentation for more details: https://docs.forestadmin.com/documentation/v/v6/reference-guide/routes/default-routes#get-a-number-of-records
router.get('/companies/count', permissionMiddlewareCreator.list(), (request, response, next) => {
const { query, user } = request;
const recordsCounter = new RecordsCounter(companies, user, query);
recordsCounter.count(request.query)
.then(count => response.send({ count }))
.catch(next);
});
...
```
### Get a record
```javascript theme={null}
...
const {
PermissionMiddlewareCreator,
RecordGetter,
} = require('forest-express-sequelize');
const { companies } = require('../models');
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('companies');
...
// Get a Company - Check out our documentation for more details: https://docs.forestadmin.com/documentation/v/v6/reference-guide/routes/default-routes#get-a-record
router.get('/companies/:recordId', permissionMiddlewareCreator.details(), (request, response, next) => {
const { params, query, user } = request;
const recordGetter = new RecordGetter(companies, user, query);
recordGetter.get(params.recordId)
.then(record => recordGetter.serialize(record))
.then(recordSerialized => response.send(recordSerialized))
.catch(next);
});
```
### Export a list of records
```javascript theme={null}
...
const {
PermissionMiddlewareCreator,
RecordsExporter,
} = require('forest-express-sequelize');
const { companies } = require('../models');
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('companies');
...
// Export a list of Companies - Check out our documentation for more details: https://docs.forestadmin.com/documentation/v/v6/reference-guide/routes/default-routes#export-a-list-of-records
router.get('/companies.csv', permissionMiddlewareCreator.export(), (request, response, next) => {
const { query, user } = request;
const recordsExporter = new RecordsExporter(companies, user, query);
recordsExporter
.streamExport(response)
.catch(next);
});
```
### Delete a list of records
```javascript theme={null}
...
const {
PermissionMiddlewareCreator,
RecordsRemover,
} = require('forest-express-sequelize');
const { companies } = require('../models');
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('companies');
...
// Delete a list of Companies - Check out our documentation for more details: https://docs.forestadmin.com/documentation/v/v6/reference-guide/routes/default-routes#delete-a-record
router.delete('/companies', permissionMiddlewareCreator.delete(), (request, response, next) => {
const { query, user } = request;
const recordsGetter = new RecordsGetter(companies, user, query);
const recordsRemover = new RecordsRemover(companies, user, query);
recordsGetter.getIdsFromRequest(request)
.then((ids) => recordsRemover.remove(ids))
.then(() => response.status(204).send())
.catch(next);
});
```
### Other available routes
Some other routes exist but are not generated automatically because it's less likely that you'll need to extend or override them.
Here is the list:
#### Relationship routes
**GET** /forest///relationships/\
⟶ **List** has many relationships
**GET** /forest///relationships//count\
⟶ **Count** has many relationships
**PUT** /forest///relationships/\
⟶ **Update** a belongs to field
**POST** /forest///relationships/\
⟶ **Add** existing records to has many relationship
**GET** /forest///relationships/.csv\
⟶ **Export** all has many relationships
**PUT** /forest///relationships//\
⟶ **Update** an embedded document (inside a list)
**DELETE** /forest///relationships/\
⟶ **Dissociate** records from relations
#### Action routes
**POST** /forest/actions//values\
⟶ **Get** the default values for this action
# Extend a route
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/routes/extend-a-route
⚠️ This page is relevant only if you installed Forest directly on a database (SQL/Mongodb). If you installed in a Rails app, check the "Override a route" page.
Extending a route is a clean way to achieve more by building on top of Forest's existing routes.
To extend a route, simply **add** **your own logic before the `next()` statement:**
```javascript theme={null}
const express = require('express');
const { PermissionMiddlewareCreator } = require('forest-express-sequelize');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('companies');
...
// Create a Action Approval - Check out our documentation for more details: https://docs.forestadmin.com/documentation/reference-guide/routes/default-routes#create-a-record
router.post('/companies', permissionMiddlewareCreator.create(), (req, res, next) => {
// >> Add your logic here <<
next();
});
...
module.exports = router;
```
```javascript theme={null}
const express = require('express');
const { PermissionMiddlewareCreator } = require('forest-express-mongoose');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('companies');
...
// Create a Action Approval - Check out our documentation for more details: https://docs.forestadmin.com/documentation/reference-guide/routes/default-routes#create-a-record
router.post('/companies', permissionMiddlewareCreator.create(), (req, res, next) => {
// >> Add your logic here <<
next();
});
...
module.exports = router;
```
### Adding logic with an API call
The most simple way to trigger your business app's (or any external app's) logic is with an API call!
In the following example, we override the `CREATE` route so that a credit card is created whenever a new customer is created in Forest:
```javascript theme={null}
...
// Require superagent once you've installed it (npm install superagent)
const superagent = require('superagent');
...
router.post('/customers', permissionMiddlewareCreator.create(), (req, res, next) => {
// Prepare the API call using the Forest's posted data
superagent
.post('https://my-company/create-card')
// Don't forget to authenticate your request using the relevant authentication method
.set('X-API-Key', '**********')
.end((err, res) => {
// Call next() to execute Forest's default behavior
next();
});
});
...
module.exports = router;
```
### Adding logic with a message broker
Using a message broker - such as RabbitMQ or Kafka - to broadcast events is current practice.
Here is how you could be using [RabbitMQ](https://www.rabbitmq.com/tutorials/tutorial-one-javascript.html) to handle `orders` synchronization across multiple channels:
```javascript theme={null}
...
const amqp = require('amqplib/callback_api');
...
router.put('/orders/:orderId', permissionMiddlewareCreator.update(), (req, res, next) => {
// Prepare your message from Forest's updated data
var orderId = req.body.data.id;
var orderStatus = req.body.data.attributes.shipping_status;
var message = 'Order ' + orderId + ' shipping status is now: ' + orderStatus;
var queue = 'orders_sync_queue';
// Connect to your Rabbitmq remote instance and publish your message
amqp.connect('amqp://{your_rabbitmq_host}', function(error0, connection) {
if (error0) {
throw error0;
}
connection.createChannel(function(error1, channel) {
if (error1) {
throw error1;
}
channel.assertQueue(queue, {
durable: false
});
channel.sendToQueue(queue, Buffer.from(message));
});
setTimeout(function() {
connection.close();
}, 500);
});
// Call next() to execute Forest's default behavior
next();
});
...
module.exports = router;
```
### Adding logic after Forest's default behavior
At some point, you may want to trigger your remote logic **after** Forest's logic.
To achieve this, you can manually recreate `next()`'s behavior by using the snippets of [default routes](/legacy/javascript-agents/reference-guide/routes/default-routes), then append your own logic.
# Override a route
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/routes/override-a-route
Overriding a route allows you to change or completely replace a Forest's route behavior.
### Changing Forest's behavior
To achieve this, use existing snippets of [default routes](/legacy/javascript-agents/reference-guide/routes/default-routes) and modify them according to your needs.
Here are a few examples:
#### Use extended search by default
```javascript theme={null}
const express = require('express');
const {
PermissionMiddlewareCreator,
RecordsGetter,
RecordsCounter,
} = require('forest-express-sequelize');
const { companies } = require('../models');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
'companies'
);
//...
// Get a list of Companies - Check out our documentation for more details: https://docs.forestadmin.com/documentation/reference-guide/routes/default-routes#get-a-list-of-records
router.get(
'/companies',
permissionMiddlewareCreator.list(),
(request, response, next) => {
const { query, user } = request;
query.searchExtended = '1';
const recordsGetter = new RecordsGetter(companies, user, query);
recordsGetter
.getAll()
.then((records) => recordsGetter.serialize(records))
.then((recordsSerialized) => response.send(recordsSerialized))
.catch(next);
}
);
// Get a number of Companies - Check out our documentation for more details: https://docs.forestadmin.com/documentation/reference-guide/routes/default-routes#get-a-list-of-records
router.get(
'/companies/count',
permissionMiddlewareCreator.list(),
(request, response, next) => {
const { query, user } = request;
query.searchExtended = '1';
const recordsCounter = new RecordsCounter(companies, user, query);
recordsCounter
.count()
.then((count) => response.send({ count }))
.catch(next);
}
);
//...
```
```javascript theme={null}
const express = require('express');
const {
PermissionMiddlewareCreator,
RecordsGetter,
RecordsCounter,
} = require('forest-express-mongoose');
const { companies } = require('../models');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
'companies'
);
//...
// Get a list of Companies - Check out our documentation for more details: https://docs.forestadmin.com/documentation/reference-guide/routes/default-routes#get-a-list-of-records
router.get(
'/companies',
permissionMiddlewareCreator.list(),
(request, response, next) => {
const { query, user } = request;
query.searchExtended = '1';
const recordsGetter = new RecordsGetter(companies, user, query);
recordsGetter
.getAll()
.then((records) => recordsGetter.serialize(records))
.then((recordsSerialized) => response.send(recordsSerialized))
.catch(next);
}
);
// Get a number of Companies - Check out our documentation for more details: https://docs.forestadmin.com/documentation/reference-guide/routes/default-routes#get-a-list-of-records
router.get(
'/companies/count',
permissionMiddlewareCreator.list(),
(request, response, next) => {
const { query, user } = request;
query.searchExtended = '1';
const recordsCounter = new RecordsCounter(companies, user, query);
recordsCounter
.count()
.then((count) => response.send({ count }))
.catch(next);
}
);
//...
```
```ruby theme={null}
if ForestLiana::UserSpace.const_defined?('CompanyController')
ForestLiana::UserSpace::CompanyController.class_eval do
alias_method :default_index, :index
alias_method :default_count, :count
# Get a list of Companies
def index
params['searchExtended'] = '1'
default_index
end
# Get a number of Companies
def count
params['searchExtended'] = '1'
default_count
end
end
end
```
With this snippet, only the `companies` collection would use extended search by default.
Using extended search is less performant than default search. Use this wisely.
#### Protect a specific record
```javascript theme={null}
router.delete(
'/companies/:recordId',
permissionMiddlewareCreator.delete(),
(request, response, next) => {
const { params, query, user } = request;
if (Number(params.recordId) === 82) {
response
.status(403)
.send('This record is protected, you cannot remove it.');
return;
}
const recordRemover = new RecordRemover(companies, user, query);
recordRemover
.remove(params.recordId)
.then(() => response.status(204).send())
.catch(next);
}
);
```
```javascript theme={null}
router.delete(
'/companies/:recordId',
permissionMiddlewareCreator.delete(),
(request, response, next) => {
const { params, query, user } = request;
if (Number(params.recordId) === 82) {
response
.status(403)
.send('This record is protected, you cannot remove it.');
return;
}
const recordRemover = new RecordRemover(companies, user, query);
recordRemover
.remove(params.recordId)
.then(() => response.status(204).send())
.catch(next);
}
);
```
```ruby theme={null}
if ForestLiana::UserSpace.const_defined?('CompanyController')
ForestLiana::UserSpace::CompanyController.class_eval do
alias_method :default_destroy, :destroy
def destroy
if params["id"] == "50"
render status: 403, plain: 'This record is protected, you cannot remove it.'
else
default_destroy
end
end
end
end
```
### Replacing Forest's behavior
To achieve this, simply remove the `next()` statement of any route:
```javascript theme={null}
...
// Create a Company - Check out our documentation for more details: https://docs.forestadmin.com/documentation/reference-guide/routes/default-routes#create-a-record
router.post('/companies', permissionMiddlewareCreator.create(), (req, res, next) => {
// >> Add your own logic here <<
});
...
```
```javascript theme={null}
...
// Create a Company - Check out our documentation for more details: https://docs.forestadmin.com/documentation/reference-guide/routes/default-routes#create-a-record
router.post('/companies', permissionMiddlewareCreator.create(), (req, res, next) => {
// >> Add your own logic here <<
});
...
```
```ruby theme={null}
if ForestLiana::UserSpace.const_defined?('CompanyController')
ForestLiana::UserSpace::CompanyController.class_eval do
# Create a Company
def create
# >> Add your own logic here <<
end
end
end
```
For instance, if you have a `Users` collection, you might want to create your users via your own api:
```javascript theme={null}
...
const axios = require('axios');
const { RecordSerializer } = require('forest-express-sequelize');
const { users } = require('../models');
...
router.post('/users', permissionMiddlewareCreator.create(), (request, response, next) => {
const recordSerializer = new RecordSerializer(users);
const axiosRequest = {
url: 'https:///users',
method: 'post',
data: request.body.data.attributes,
};
axios(axiosRequest)
.then(result => recordSerializer.serialize(result.data))
.then(resultSerialized => response.send(resultSerialized))
.catch(error => {
console.log('error:', error);
next(error);
});
});
```
```javascript theme={null}
...
const axios = require('axios');
const { RecordSerializer } = require('forest-express-mongoose');
const { users } = require('../models');
...
router.post('/users', permissionMiddlewareCreator.create(), (request, response, next) => {
const recordSerializer = new RecordSerializer(users);
const axiosRequest = {
url: 'https:///users',
method: 'post',
data: request.body.data.attributes,
};
axios(axiosRequest)
.then(result => recordSerializer.serialize(result.data))
.then(resultSerialized => response.send(resultSerialized))
.catch(error => {
console.log('error:', error);
next(error);
});
});
```
```ruby theme={null}
require 'net/http'
require 'uri'
if ForestLiana::UserSpace.const_defined?('UserController')
ForestLiana::UserSpace::UserController.class_eval do
# Create a User
def create
forest_authorize!('add', forest_user, @resource)
begin
response = Net::HTTP.post URI('https:///users'), params.to_json, "Content-Type" => "application/json"
render serializer: nil, json: render_record_jsonapi(response.body)
rescue => errors
render serializer: nil, json: JSONAPI::Serializer.serialize_errors(errors), status: 400
end
end
end
end
```
# Routes
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/routes/overview
### What is a route?
A route is simply the mapping between an API endpoint and the business logic behind this endpoint.
### Default routes
Forest comes packaged with a set of existing routes, which execute Forest's default logic. The most common ones are :
| Route | Default behavior |
| ------------------------------------------ | ----------------------------- |
| `router.post('/companies', …` | Create a company |
| `router.put('/companies/:companyId', …` | Update a company |
| `router.delete('/companies/:companyId', …` | Delete a company |
| `router.get('/companies/:companyId', …` | Get a company |
| `router.get('/companies', …` | List all companies |
| `router.get('/companies/count', …` | Count the number of companies |
| `router.get('/companies.csv', …` | Export all companies |
Very often, you’ll need to call business logic from another backend application. This is why in Forest, **all your admin backend's routes are extendable**.
At installation, they are generated in `/routes`.
Note that for any collection added **after** installation, you will have to create a new `your_collection_name.js` file in `/routes`.
The generated routes use `next()` to call Forest's default behavior.
If you need more details on what each default route does, check out this page:
To learn **how to extend a route's behavior**, read this page:
To learn **how to override a route's behavior**, read this page:
If you want to trigger logic unrelated to Forest's basic routes (create, update, etc), head over to our [Smart actions](/legacy/javascript-agents/reference-guide/actions/create-and-manage-smart-actions/overview#what-is-a-smart-action) page.
# Create a scope more than one level away based on a Smart field
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/scopes/create-a-scope-more-than-one-level-away-based-on-a-smart-field
**Context:** As a user I want to create a scope on a table that does not have the tag column in the table.
As a user I want to create a scope on related tables more than one level away
**Example:**
The objective is to implement scopes on all tables, filtering on`companies` to make sure that companies can only see their own data. In this example, `companies` has many `departments`, `departments` has many `users`. The company id is not in `users` table but in the `departments` table. We want to scope `users` according to a company value.
### **Step 1: Create a smart field and the filter for the `users` table**
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
const { users, departments, companies } = require('../models');
const models = require('../models');
const { Op } = models.objectMapping;
collection('users', {
actions: [],
fields: [
{
field: 'company name',
isFilterable: true,
type: 'String',
get: async (user) => {
//We are looking for the company name of the user (user belongs to a department that belongs to a company)
const company = await companies.findOne({
attributes: ['name'],
include: {
required: true,
model: departments,
where: { id: user.departmentId },
},
});
return company.name;
},
filter: async ({ condition: { value, operator } }) => {
switch (operator) {
case 'equal':
//We are looking for all the users ids that have a company name equal to the condition value
const queryToFindUsers = await users.findAll({
attributes: ['id'],
include: [
{
required: true,
model: departments,
include: [
{
required: true,
model: companies,
where: { name: { [Op.eq]: value } },
},
],
},
],
});
//We map this array of objects to retrieve the user ids
const userIds = queryToFindUsers.map((user) => user.id);
return { id: { [Op.in]: userIds } };
default:
return null;
}
},
},
],
segments: [],
});
```
```ruby theme={null}
class Forest::Customer
include ForestLiana::Collection
collection :User
filter_company = lambda do |condition, where|
company_value = condition['value']
case condition['operator']
when 'equal'
"users.id IN (SELECT users.id
FROM users
JOIN departments ON departments.id = users.department_id
JOIN companies ON companies.id = departments.company_id
WHERE companies.name = '#{company_value}')"
end
end
field :company, type: 'String', is_filterable: true, filter: filter_company do
company = User.find(object.id).department.company
"#{company.name}"
end
end
```
### **Step 2: Configure the scope in the UI**
In project settings:
In the table `users`
# Scopes
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/scopes/overview
### What is a scope?
A scope is a filter which applies to a collection and all its segments.
It is useful in that it can be used to control what data is available to users. More specifically, scopes can be set up to filter data dynamically on the current user.
**Scopes** are applied to the entire application excluding global smart actions, API & SQL charts and Collaboration & Activities.
### Using a dynamic scope
Imagine a situation where you have several Operations teams each specialized in a specific country's operations:
* *France* team handles customers from France
* *Germany* team handles customers from Germany
* ...
By scoping the collection on `$currentUser.team.name`, Marc who belongs to the *France* team will only see customers from France, while Louis who belongs to the *Germany* team will only see customers from Germany.
#### Dynamic variables
In the example above, we used the team name to filter out what the user sees: `$currentUser.team.name`
Here the exhaustive list of available dynamic variables:
| Syntax | Result |
| ---------------------------- | ---------------------------------------------------------------------- |
| `$currentUser.id` | The id of the current user |
| `$currentUser.firstName` | The first name of the current user |
| `$currentUser.lastName` | The last name of the current user |
| `$currentUser.fullName` | The full name of the current user |
| `$currentUser.email` | The email of the current user |
| `$currentUser.team.id` | The id of the team of the current user |
| `$currentUser.team.name` | The name of the team of the current user |
| `$currentUser.tags.your-tag` | The value associated with key `your-tag` for the current user, if any. |
#### Using user tags
The above example is only possible if your data matches your users' details (email, team, etc). It's likely that it won't always be the case. This is why we've introduced user tags.
User tags are set from each user's details page and allow you to freely associate your users to a value which will match against your data using the `$currentUser.tags.your-tag` dynamic variable.
# Scope on a smart field extracting a json's column attribute
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/scopes/scope-on-a-smart-field-extracting-a-jsons-column-attribute
**Context**: As a user, I want to scope a table's records based on the value of an attribute nested within a json column.
**Example**: I have a table `users` that includes a JSONB column named `contact`. The `contact` json can include a `phone`, `email` or `country` attribute. Since I want to scope my collection by `country`, I created a smart field called `country` that returns the value of the country attribute and I implemented a filter feature for this field.
### Implementation
The smart field definition and the filtering logic are defined as follows in the `forest/users.js` file of my admin backend.
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
const models = require('../models');
const { Op } = models.objectMapping;
collection('users', {
actions: [],
fields: [
{
field: 'country',
isFilterable: true,
type: 'String',
get: (record) => record.contact.country,
filter({ condition, where }) {
switch (condition.operator) {
case 'equal':
return {
'contact.country': { [Op.eq]: condition.value },
};
// ... And so on with the other operators not_equal, starts_with, etc.
default:
return null;
}
},
},
],
segments: [],
});
```
In order to make your smart field filterable in the UI, you both need to add the `isFilterable: true` option in the field's declaration and to enable filtering on this field in the field settings in the UI.
# Examples
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-collections/examples/README
# Create a Smart Collection with Amazon S3
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-collections/examples/amazon-s3-integration-example
### Creating the Smart Collection
On our Live Demo, we’ve stored the `Legal Documents` of a `Company` on Amazon S3. In the following example, we show you how to create the Smart Collection to see and manipulate them in your Forest admin.
First, we declare the `legal_docs` collection in the `forest/` directory. In this Smart Collection, all fields are related to S3 attributes except the field `is_verified` that is stored on our database in the collection `documents`.
You can check out the list of [available field options ](/legacy/javascript-agents/reference-guide/smart-fields/overview#available-field-options)if you need it.
You **MUST** declare an `id` field when creating a Smart Collection. The value of this field for each record **MUST** be unique. On the following example, we simply generate a random UUID.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
const models = require('../models');
collection('legal_docs', {
fields: [
{
field: 'id',
type: 'String',
},
{
field: 'url',
type: 'String',
widget: 'link',
isReadOnly: true,
},
{
field: 'last_modified',
type: 'Date',
isReadOnly: true,
},
{
field: 'size',
type: 'String',
isReadOnly: true,
},
{
field: 'is_verified',
type: 'Boolean',
isReadOnly: false,
},
],
});
```
First, we declare the `legal_docs` collection in the `forest/` directory. In this Smart Collection, all fields are related to S3 attributes except the field `is_verified` that is stored on our database in the collection `documents`.
You can check out the list of [available field options here ](/legacy/javascript-agents/reference-guide/smart-fields/overview#available-field-options)if you need it.
You **MUST** declare an `id` field when creating a Smart Collection. The value of this field for each record **MUST** be unique. On the following example, we simply generate a random UUID.
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
const models = require('../models');
collection('legal_docs', {
fields: [
{
field: 'id',
type: 'String',
},
{
field: 'url',
type: 'String',
widget: 'link',
isReadOnly: true,
},
{
field: 'last_modified',
type: 'Date',
isReadOnly: true,
},
{
field: 'size',
type: 'String',
isReadOnly: true,
},
{
field: 'is_verified',
type: 'Boolean',
isReadOnly: false,
},
],
});
```
You can add the option `isSearchable: true` to your collection to display the search bar. Note that you will have to implement the search yourself by including it into your own `get` logic.
### Implementing the GET (all records)
At this time, there’s no Smart Collection Implementation because no route in your admin backend handles the API call yet.
In the file `routes/legal_docs.js`, we’ve created a new route to implement the API behind the Smart Collection.
The logic here is to list all the files uploaded on a specific S3 Bucket. We use a custom service `services/s3-helper.js` for this example. The implementation code of this service is [available on Github](https://github.com/ForestAdmin/forest-live-demo-lumber/blob/master/services/s3-helper.js).
Finally, the last step is to serialize the response data in the expected format which is simply a standard [JSON API](http://jsonapi.org/) document. We use the very simple [JSON API Serializer](https://github.com/SeyZ/jsonapi-serializer) library for this task.
```javascript theme={null}
const P = require('bluebird');
const express = require('express');
const router = express.Router();
const models = require('../models');
const S3Helper = require('../services/s3-helper');
const Serializer = require('../serializers/legal_docs');
function reconcileData(file) {
return models.documents
.findOne({ where: { file_id: file.id } })
.then((doc) => {
file.is_verified = doc ? doc.is_verified : false;
return file;
});
}
router.get('/legal_docs', (req, res, next) => {
return new S3Helper()
.files('livedemo/legal')
.then((files) => P.mapSeries(files, (file) => reconcileData(file)))
.then((files) => Serializer.serialize(files))
.then((files) => res.send(files))
.catch((err) => next(err));
});
module.exports = router;
```
```javascript theme={null}
const JSONAPISerializer = require('jsonapi-serializer').Serializer;
module.exports = new JSONAPISerializer('legal_docs', {
attributes: ['url', 'last_modified', 'size', 'is_verified'],
keyForAttribute: 'underscore_case',
});
```
In the file `routes/legal_docs.js`, we’ve created a new route to implement the API behind the Smart Collection.
The logic here is to list all the files uploaded on a specific S3 Bucket. We use a custom service `services/s3-helper.js` for this example. The implementation code of this service is [available on Github](https://github.com/ForestAdmin/forest-live-demo-lumber/blob/master/services/s3-helper.js).
Finally, the last step is to serialize the response data in the expected format which is simply a standard [JSON API](http://jsonapi.org/) document. We use the very simple [JSON API Serializer](https://github.com/SeyZ/jsonapi-serializer) library for this task.
```javascript theme={null}
const P = require('bluebird');
const express = require('express');
const router = express.Router();
const models = require('../models');
const S3Helper = require('../services/s3-helper');
const Serializer = require('../serializers/legal_docs');
function reconcileData(file) {
return models.documents
.findOne({ where: { file_id: file.id } })
.then((doc) => {
file.is_verified = doc ? doc.is_verified : false;
return file;
});
}
router.get('/legal_docs', (req, res, next) => {
return new S3Helper()
.files('livedemo/legal')
.then((files) => P.mapSeries(files, (file) => reconcileData(file)))
.then((files) => Serializer.serialize(files))
.then((files) => res.send(files))
.catch((err) => next(err));
});
module.exports = router;
```
```javascript theme={null}
const JSONAPISerializer = require('jsonapi-serializer').Serializer;
module.exports = new JSONAPISerializer('legal_docs', {
attributes: ['url', 'last_modified', 'size', 'is_verified'],
keyForAttribute: 'underscore_case',
});
```
### Implementing the GET (specific record)
To access the details view of a Smart Collection record, you have to catch the GET API call on a specific record. One more time, we use a custom service `services/s3-helper.js` that encapsulates the S3 business logic for this example.
The implementation of the `reconcileData()` and `Serializer.serialize()` functions are already described in the [Implementing the GET (all records)](/legacy/javascript-agents/reference-guide/smart-collections/overview#implementing-the-get-all-records) section.
```javascript theme={null}
const P = require('bluebird');
const express = require('express');
const router = express.Router();
const models = require('../models');
const S3Helper = require('../services/s3-helper');
const Serializer = require('../serializers/legal_docs');
// ...
router.get('/legal_docs/:doc_id', (req, res, next) => {
return new S3Helper()
.file(`livedemo/legal/${req.params.doc_id}`)
.then((file) => reconcileData(file))
.then((file) => Serializer.serialize(file))
.then((file) => res.send(file))
.catch((err) => next(err));
});
module.exports = router;
```
To access the details view of a Smart Collection record, you have to catch the GET API call on a specific record. One more time, we use a custom service `services/s3-helper.js` that encapsulates the S3 business logic for this example.
The implementation of the `reconcileData()` and `Serializer.serialize()` functions are already described in the [Implementing the GET (all records)](/legacy/javascript-agents/reference-guide/smart-collections/overview#implementing-the-get-all-records) section.
```javascript theme={null}
const P = require('bluebird');
const express = require('express');
const router = express.Router();
const models = require('../models');
const S3Helper = require('../services/s3-helper');
const Serializer = require('../serializers/legal_docs');
// ...
router.get('/legal_docs/:doc_id', (req, res, next) => {
return new S3Helper()
.file(`livedemo/legal/${req.params.doc_id}`)
.then((file) => reconcileData(file))
.then((file) => Serializer.serialize(file))
.then((file) => res.send(file))
.catch((err) => next(err));
});
module.exports = router;
```
### Implementing the PUT
To handle the update of a record we have to catch the PUT API call. In our example, all S3-related fields are set as read-only and only `is_verified` can be updated.
The implementation of the `reconcileData()` and `Serializer.serialize()` functions are already explained in the [Implementing the GET (all records)](/legacy/javascript-agents/reference-guide/smart-collections/overview#implementing-the-get-all-records) section.
```javascript theme={null}
const P = require('bluebird');
const express = require('express');
const router = express.Router();
const models = require('../models');
const S3Helper = require('../services/s3-helper');
const Serializer = require('../serializers/legal_docs');
// ...
router.put('/legal_docs/:doc_id', (req, res, next) => {
return models.documents
.findOne({ where: { file_id: req.params.doc_id } })
.then((doc) => {
doc.is_verified = req.body.data.attributes.is_verified;
return doc.save();
})
.then(() => new S3Helper().file(`livedemo/legal/${req.params.doc_id}`))
.then((file) => reconcileData(file))
.then((file) => Serializer.serialize(file))
.then((file) => res.send(file))
.catch((err) => next(err));
});
module.exports = router;
```
The implementation of the `reconcileData()` and `Serializer.serialize()` functions are already explained in the [Implementing the GET (all records)](/legacy/javascript-agents/reference-guide/smart-collections/overview#implementing-the-get-all-records) section.
```javascript theme={null}
const P = require('bluebird');
const express = require('express');
const router = express.Router();
const models = require('../models');
const S3Helper = require('../services/s3-helper');
const Serializer = require('../serializers/legal_docs');
// ...
router.put('/legal_docs/:doc_id', (req, res, next) => {
return models.documents
.findOne({ where: { file_id: req.params.doc_id } })
.then((doc) => {
doc.is_verified = req.body.data.attributes.is_verified;
return doc.save();
})
.then(() => new S3Helper().file(`livedemo/legal/${req.params.doc_id}`))
.then((file) => reconcileData(file))
.then((file) => Serializer.serialize(file))
.then((file) => res.send(file))
.catch((err) => next(err));
});
module.exports = router;
```
### Implementing the DELETE
Now we are able to see all the legal documents on Forest, it’s time to implement the DELETE HTTP method in order to remove the documents on S3 when the admin user needs it.
```javascript theme={null}
const express = require('express');
const router = express.Router();
const S3Helper = require('../services/s3-helper');
const Serializer = require('../serializers/legal_docs');
// ...
router.delete('/legal_docs/:doc_id', (req, res, next) => {
return new S3Helper()
.deleteFile(`livedemo/legal/${req.params.doc_id}`)
.then(() => res.status(204).send())
.catch((err) => next(err));
});
module.exports = router;
```
```javascript theme={null}
const express = require('express');
const router = express.Router();
const S3Helper = require('../services/s3-helper');
const Serializer = require('../serializers/legal_docs');
// ...
router.delete('/legal_docs/:doc_id', (req, res, next) => {
return new S3Helper()
.deleteFile(`livedemo/legal/${req.params.doc_id}`)
.then(() => res.status(204).send())
.catch((err) => next(err));
});
module.exports = router;
```
### Implementing the POST
On our Live Demo example, creating a record directly from this Smart Collection does not make any sense because the admin user will upload the legal docs in the company details view. For the documentation purpose, we catch the call and returns an appropriate error message to the admin user.
```javascript theme={null}
...
router.post('/legal_docs', permissionMiddlewareCreator.create(), (request, response) => {
response.status(400).send('You cannot create legal documents from here. Please, upload them directly in the details view of a Company');
});
...
module.exports = router;
```
```javascript theme={null}
const P = require('bluebird');
const express = require('express');
const router = express.Router();
const models = require('../models');
const S3Helper = require('../services/s3-helper');
const Serializer = require('../serializers/legal_docs');
// ...
router.post('/legal_docs', (req, res, next) => {
res
.status(400)
.send(
'You cannot create legal documents from here. Please, upload them directly in the details view of a Company'
);
});
module.exports = router;
```
# Create records from a Smart collection
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-collections/examples/create-records-from-a-smart-collection
**Context**: As a user I want to be able to add new records to a number of collections based on the input made in a smart collection creation form
Example: In this example I have the following data model:
Property ← building ← lot → owner
The smart collection called `ownerProperties` features records including:
* the firstName and lastName of the owner
* the reference of the property
In my use case I want to be able to create a new lot, owner, building and property based on the input of the form.
### Definition of the smart collection
The smart collection is declared this way in a `forest/owner-properties.js` file.
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
collection('ownerProperties', {
actions: [],
fields: [
{
field: 'ownerFirstName',
type: 'String',
},
{
field: 'ownerLastName',
type: 'String',
},
{
field: 'ownerProperty',
type: 'String',
},
],
segments: [],
});
```
### Definition of the routes
Below is the `routes/owner-properties.js` file that includes the logic for the `GET` and `POST` calls made on the smart collection.
```jsx theme={null}
const express = require('express');
const {
PermissionMiddlewareCreator,
RecordSerializer,
} = require('forest-express-sequelize');
const models = require('../models');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
'ownerProperties'
);
const recordsSerializer = new RecordSerializer({ name: 'ownerProperties' });
const include = [
{
model: models.owners,
as: 'owner',
},
{
model: models.buildings,
as: 'building',
include: [
{
model: models.properties,
as: 'property',
},
],
},
];
function ownerPropertyGetter(id) {
return models.lots.findByPk(id, { include }).then(async (lot) => {
let ownerProperty = {
id: lot.id,
ownerFirstName: lot.owner.firstName,
ownerLastName: lot.owner.lastName,
ownerProperty: lot.building.property.name,
};
return recordsSerializer.serialize(ownerProperty);
});
}
// Create records from the owner, lot, building and property collections from create form
router.post(
'/ownerProperties',
permissionMiddlewareCreator.create(),
(request, response, next) => {
const { attributes } = request.body.data;
const fields = {
owner: {
firstName: attributes.ownerFirstName,
lastName: attributes.ownerLastName,
},
building: {
property: {
name: attributes.ownerProperty,
},
},
};
return models.lots.create(fields, { include }).then(async (lot) => {
// don't forget to return the object newly created to ensure a smooth redirection
const serializedRecord = await ownerPropertyGetter(lot.id);
return response.send(serializedRecord);
});
}
);
// Get a list of owner-properties
router.get(
'/ownerProperties',
permissionMiddlewareCreator.list(),
(request, response, next) => {
const limit = parseInt(request.query.page.size) || 20;
const offset = (parseInt(request.query.page.number) - 1) * limit;
const findAllQuery = models.lots.findAll({
include,
limit,
offset,
});
const countQuery = models.lots.count({ include });
Promise.all([findAllQuery, countQuery])
.then(async ([lotsList, count]) => {
const records = [];
lotsList.forEach((lot) => {
let ownerProperty = {
id: lot.id,
ownerFirstName: lot.owner.firstName,
ownerLastName: lot.owner.lastName,
ownerProperty: lot.building.property.name,
};
records.push(ownerProperty);
});
const serializedRecords = await recordsSerializer.serialize(records);
response.send({ ...serializedRecords, meta: { count } });
})
.catch((err) => next(err));
}
);
// Get an owner property
router.get(
'/ownerProperties/:recordId',
permissionMiddlewareCreator.details(),
(request, response, next) => {
return ownerPropertyGetter(request.params.recordId).then(
(serializedRecord) => response.send(serializedRecord)
);
}
);
module.exports = router;
```
The objects that are serialized to be returned to the UI are constructed as such:
```jsx theme={null}
lots {
dataValues: {
id: 1,
lotNumber: 1,
buildingIdKey: 1,
ownerIdKey: 1,
owner: owners {
dataValues: {
id: 1,
firstName: 'Pete',
lastName: 'Maravich',
email: 'user@example.com'
},
...
building: buildings {
dataValues: {
id: 1,
name: 'Sevres',
addressLine1: '80 rue de Sevres',
number: 1,
centralHeating: true,
propertyIdKey: 1,
property: properties {
dataValues: {
id: 1,
name: 'Laennec',
addressCity: 'Paris',
addressLine1: '102 rue de Sevres',
numberOfBuildings: 6,
status: null
},
...
}
},
...
}
```
### Make the smart collection visible and enable the create form
By default, a smart collection newly created is hidden in the UI, does not enable create, update and delete operations and all its fields are set as read only.
To make the collection fully functional in the UI you need to following these steps:
# Searchable smart collection with records fetched from hubspot API
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-collections/examples/searchable-smart-collection-with-records-fetched-from-hubspot-api
**Context:** Create a smart collection fetching the 10 first companies records from hubspot or the ones matching a search criteria
First step is to declare the collection and the fields that should be expected to be found for this collection.
```jsx theme={null}
const Liana = require('forest-express-sequelize');
const models = require('../models');
Liana.collection('hubspot_companies', {
isSearchable: true,
fields: [
{
field: 'id',
type: 'Number',
},
{
field: 'name',
type: 'String',
},
],
});
```
Next step is to define the logic to retrieve the data of the smart collection in a `routes/your-model.js` file.
You first need to set variables according to the context to ensure the query follows the UX (nb of records per page, index of the page you're on, search performed or not)
You then need to define a serializer adapted to the format of the data that will be passed and the expected fields of the collection.
Finally you need to implement the API call, serialize the data obtained, filter depending on the search performed and return the payload.
NB: I used the `superagent` module for the API call
```javascript theme={null}
const Liana = require('forest-express-sequelize');
const express = require('express');
const router = express.Router();
const models = require('../models');
const P = require('bluebird');
const JSONAPISerializer = require('jsonapi-serializer').Serializer;
const superagent = require('superagent');
router.get(
'/hubspot_companies',
Liana.ensureAuthenticated,
(req, res, next) => {
// set pagination parameters when exist (default limit is 250 as it is the max allowed by Hubspot)
let limit = 250;
let offset = 0;
req.query.page ? (limit = parseInt(req.query.page.size)) : limit;
req.query.page
? (offset = (parseInt(req.query.page.number) - 1) * limit)
: offset;
// set search terms when exist
let search = null;
req.query.search ? (search = req.query.search) : search;
// define the serializer used to format the payload
const hubspotCompaniesSerializer = new JSONAPISerializer(
'hubspotCompanies',
{
attributes: ['name'],
keyForAttribute: 'underscore_case',
id: 'companyId',
transform: function (record) {
record.name = record['properties']['name']['value'];
return record;
},
}
);
// implement function to call hubspot API and return companies
async function getCompanies() {
return (hubspot_companies = await superagent
.get(
`https://api.hubapi.com/companies/v2/companies/paged?hapikey=${process.env.HUBSPOT_API}&properties=name&limit=${limit}&offset=${limit}`
)
.then((response) => {
// parsing the answer from the API
companiesJSON = JSON.parse(response.res.text).companies;
// serializing the companies to comply with the format expected by the Forest server
serializedCompanies =
hubspotCompaniesSerializer.serialize(companiesJSON);
// return all data or data with a name containing the searched terms from the companies fetched
if (search) {
serializedCompanies.data = serializedCompanies.data.filter(
function (item) {
return item.attributes.name
.toUpperCase()
.includes(search.toUpperCase());
}
);
return serializedCompanies;
} else {
return serializedCompanies;
}
}));
}
async function sendCompaniesPayload() {
let hubspotCompanies = await getCompanies();
// defining the count of companies fetched
let count = hubspotCompanies.data.length;
return res.send({ ...hubspotCompanies, meta: { count: count } });
}
sendCompaniesPayload();
}
);
module.exports = router;
```
# Smart relationship between model and stripe cards
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-collections/examples/smart-relationship-between-model-and-stripe-cards
**Context**: as a user I want to display stripe cards associated to a user using the Stripe API.
### Implementation
First step is to declare the smart collection user\_stripe\_cards in a `user-stripe-cards.js` file in the forest folder.
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
const models = require('../models');
collection('users_stripe_cards', {
isSearchable: true,
fields: [
{
field: 'id',
type: 'String',
},
{
field: 'country',
type: 'String',
},
{
field: 'brand',
type: 'String',
},
{
field: 'exp_month',
type: 'Number',
},
{
field: 'exp_year',
type: 'Number',
},
{
field: 'last4',
type: 'Number',
},
],
});
```
Next step is to add the smart relationship between users and stripe cards in the `forest/users.js` file.
```jsx theme={null}
collection('users', {
actions: [],
fields: [
{
field: 'stripe-cards',
type: ['String'],
reference: 'users_stripe_cards.id',
},
],
segments: [],
});
```
Final step is to implement the route for the relationship for the cards to be displayed as related data of a user. This is done in the `routes/users.js` file.
```javascript theme={null}
const express = require('express');
const { PermissionMiddlewareCreator } = require('forest-express-sequelize');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { users, usersData } = require('../models');
var JSONAPISerializer = require('jsonapi-serializer').Serializer;
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('users');
router.get(
'/users/:userId/relationships/stripe-cards',
async (request, response, next) => {
const UserStripeCardSerializer = new JSONAPISerializer(
'users_stripe_cards',
{
attributes: ['country', 'brand', 'exp_year', 'exp_month', 'last4'],
}
);
const { userId } = request.params;
const stripeId = await usersData
.findOne({
where: { userId },
})
.then((userData) => userData.stripeId)
.catch(() => null);
const cardsInfo = await stripe.customers.listSources(stripeId, {
object: 'card',
limit: 3,
});
const data = UserStripeCardSerializer.serialize(cardsInfo.data);
return response.send(data);
}
);
module.exports = router;
```
# Smart Collections
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-collections/overview
### What is a Smart Collection?
A Smart Collection is a Forest Collection based on your API implementation. It allows you to reconcile fields of data coming from different or external sources in a single tabular view (by default), without having to physically store them into your database.
Fields of data could be coming from many other sources such as other B2B SaaS (e.g. Zendesk, Salesforce, Stripe), in-memory database, message broker, etc.
This is an **advanced** notion. If you're just starting with Forest, you should skip this for now.
In the following example, we have created a **Smart Collection** called `customer_stats`allowing us to see all customers who have placed orders, the number of order placed and the total amount of those orders.
**For an example of advanced customization and featuring an Amazon S3 integration,** you can see [here](/legacy/javascript-agents/reference-guide/smart-collections/examples/amazon-s3-integration-example) how we've stored in our live demo the companies' legal documents on Amazon S3 and how we've implemented a **Smart Collection** to access and manipulate them.
### Creating a Smart Collection
First, we declare the `customer_stats` collection in the `forest/` directory.
In this Smart Collection, we want to display for each customer its email address, the number of orders made (in a field `orders_count`) and the sum of the price of all those orders (in a field `total_amount`).
You can check out the list of [available field options ](/legacy/javascript-agents/reference-guide/smart-fields/overview#available-field-options)if you need it.
You **MUST** declare an `id` field when creating a Smart Collection. The value of this field for each record **MUST** be unique.
As we are using the *customer id* in this example, we do not need to declare an `id` manually.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
const models = require('../models');
collection('customer_stats', {
isSearchable: true,
fields: [
{
field: 'email',
type: 'String',
},
{
field: 'orders_count',
type: 'Number',
},
{
field: 'total_amount',
type: 'Number',
},
],
});
```
The option`isSearchable: true` added to your collection allows to display the search bar. Note that you will have to implement the search yourself by including it into your own `get` logic.
*Work in progress - this section will soon be released*
First, we declare the `CustomerStat` collection in the `lib/forest-liana/collections/` directory.
In this Smart Collection, we want to display for each customer its email address, the number of orders made (in a field `orders_count`) and the sum of the price of all those orders (in a field `total_amount`).
You can check out the list of [available field options ](/legacy/javascript-agents/reference-guide/smart-fields/overview#available-field-options)if you need it.
You **MUST** declare an `id` field when creating a Smart Collection. The value of this field for each record **MUST** be unique.
As we are using the *customer id* in this example, we do not need to declare an `id` manually.
```ruby theme={null}
class Forest::CustomerStatsController < ForestLiana::ApplicationController
require 'jsonapi-serializers'
before_action :set_params, only: [:index]
class BaseSerializer
include JSONAPI::Serializer
def type
'customerStat'
end
def format_name(attribute_name)
attribute_name.to_s.underscore
end
def unformat_name(attribute_name)
attribute_name.to_s.dasherize
end
end
class CustomerStatSerializer < BaseSerializer
attribute :email
attribute :total_amount
attribute :orders_count
end
def index
customers_count = Customer.count_by_sql("
SELECT COUNT(*)
FROM customers
WHERE
EXISTS (
SELECT *
FROM orders
WHERE orders.customer_id = customers.id
)
AND email LIKE '%#{@search}%'
")
customer_stats = Customer.find_by_sql("
SELECT customers.id,
customers.email,
count(orders.*) AS orders_count,
sum(products.price) AS total_amount,
customers.created_at,
customers.updated_at
FROM customers
JOIN orders ON customers.id = orders.customer_id
JOIN products ON orders.product_id = products.id
WHERE email LIKE '%#{@search}%'
GROUP BY customers.id
ORDER BY customers.id
LIMIT #{@limit}
OFFSET #{@offset}
")
customer_stats_json = CustomerStatSerializer.serialize(customer_stats, is_collection: true, meta: {count: customers_count})
render json: customer_stats_json
end
private
def set_params
@limit = params[:page][:size].to_i
@offset = (params[:page][:number].to_i - 1) * @limit
@search = sanitize_sql_like(params[:search]? params[:search] : "")
end
def sanitize_sql_like(string, escape_character = "\\")
pattern = Regexp.union(escape_character, "%", "_")
string.gsub(pattern) { |x| [escape_character, x].join }
end
end
```
You then need to create a route pointing to your collection's index action to get all your collection's records.
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
get '/CustomerStat' => 'customer_stats#index'
end
mount ForestLiana::Engine => '/forest'
end
```
First we will add the right path to the **urls.py** file
Then we will create the pertained view
Create a controller `CustomerStatsController`
Then add the route.
Now we are all set, we can access the Smart Collection as any other collection.
In this example we have only implemented the **GET all records** action but you can also add the following actions: **GET specific records**, **PUT, DELETE** and **POST**. These are shown in the next page explaining how a Smart Collection can be used to access and manipulate data stored in Amazon S3.
# Serializing your records
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-collections/serializing-your-records
To be interpreted correctly by the ForestAdmin UI, the data must be sent from your admin backend using a particular structure.\
\
This structure needs to comply to the JSON API standard. The JSON API standard is used to ensure a standardized way to format JSON responses returned to clients. You can find some more information directly from their [website](https://jsonapi.org/).\
\
Most of the time, your admin backend will handle this for you, and you will not have to play with serialization. However you might encounter specific use cases that will require you to serialize data yourself, such as smart collections for example.
In order to help you do so, the helper `RecordSerializer` is made available through the packages built-in your admin panel.
### Initializing the record serializer
```javascript theme={null}
const { RecordSerializer } = require('forest-express-sequelize');
const recordSerializer = new RecordSerializer({ name: 'customer_stats' });
```
```javascript theme={null}
const { RecordSerializer } = require('forest-express-mongoose');
const recordsSerializer = new RecordSerializer({ modelName: 'customer_stats' });
```
To make use of the serializer, simply get it from your agent package, and initialize it with a collection of yours. The serializer will retrieve the structure of the collection, and thus, will know which attributes it needs to take in to perform the serialization.1
### Example 1 - Smart collection with simple fields
Let's take a look at the collection defined in the documentation's [smart collection example](/legacy/javascript-agents/reference-guide/smart-collections/overview):
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('customer_stats', {
isSearchable: true,
fields: [
{
field: 'email',
type: 'String',
},
{
field: 'orders_count',
type: 'Number',
},
{
field: 'total_amount',
type: 'Number',
},
],
});
```
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
collection('customer_stats', {
isSearchable: true,
fields: [
{
field: 'email',
type: 'String',
},
{
field: 'orders_count',
type: 'Number',
},
{
field: 'total_amount',
type: 'Number',
},
],
});
```
The serializer exposes a `.serialize()` method that takes as an argument an array of objects (or a single object). In the smart collection example, this array would be as such:
```javascript theme={null}
const records = [
{
id: 67427,
email: 'janessa_langosh@example.net',
orders_count: '4',
total_amount: 93800,
created_at: 2018-03-19T14:59:59.440Z,
updated_at: 2018-03-19T15:00:00.443Z
},
{
id: 67429,
email: 'dortha90@example.net',
orders_count: '3',
total_amount: 106700,
created_at: 2018-03-19T15:00:08.430Z,
updated_at: 2018-03-19T15:00:09.134Z
},
...
]
```
```javascript theme={null}
const records = [
{
_id: 5eebcb6bb9faba06df0cd7a9,
email: 'janessa_langosh@example.net',
orders_count: '4',
total_amount: 93800,
created_at: 2018-03-19T14:59:59.440Z,
updated_at: 2018-03-19T15:00:00.443Z
},
{
_id: 5eec5c30b9faba06df0cd917,
email: 'dortha90@example.net',
orders_count: '3',
total_amount: 106700,
created_at: 2018-03-19T15:00:08.430Z,
updated_at: 2018-03-19T15:00:09.134Z
}
...
]
```
To perform the serialization just use the `.serialize()` method like this:
```javascript theme={null}
const serializedRecords = recordSerializer.serialize(records);
```
The serialized records are formatted as follows:
```javascript theme={null}
{
data: [
{
type: 'customer_stats',
id: '67427',
attributes: {
email: 'janessa_langosh@example.net',
orders_count: '4',
total_amount: 93800
}
},
{
type: 'customer_stats',
id: '67429',
attributes: {
email: 'dortha90@example.net',
orders_count: '3',
total_amount: 106700
},
},
...
]
}
```
```javascript theme={null}
{
data: [
{
type: 'customer_stats',
id: '5eebcb6bb9faba06df0cd7a9',
attributes: {
email: 'janessa_langosh@example.net',
orders_count: '4',
total_amount: 93800
}
},
{
type: 'customer_stats',
id: '5eec5c30b9faba06df0cd917',
attributes: {
email: 'dortha90@example.net',
orders_count: '3',
total_amount: 106700
},
},
...
]
}
```
This is the proper format expected by the UI to correctly display the records.
### Example 2 - Smart collection example with an added belongsTo relationship
Now let's say we want to reference the customer related to a stat instead of just displaying its `email`. We would then adapt the smart collection definition to include a field `customer` referencing the `customers` collection:
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('customer_stats', {
isSearchable: true,
fields: [
{
field: 'orders_count',
type: 'Number',
},
{
field: 'total_amount',
type: 'Number',
},
{
field: 'customer',
type: 'String',
reference: 'customers.id',
},
],
});
```
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
collection('customer_stats', {
isSearchable: true,
fields: [
{
field: 'orders_count',
type: 'Number',
},
{
field: 'total_amount',
type: 'Number',
},
{
field: 'customer',
type: 'String',
reference: 'customers._id',
},
],
});
```
For the belongsTo relationship to be properly serialized, the records passed on to the serializer should include the related object (here `customer`), following this structure:
```javascript theme={null}
const records = [
{
id: 67427,
customer: {
id: 27048
},
orders_count: '4',
total_amount: 93800,
created_at: 2018-03-19T14:59:59.440Z,
updated_at: 2018-03-19T15:00:00.443Z
},
{
id: 67429,
customer: {
id: 27049
},
orders_count: '3',
total_amount: 106700,
created_at: 2018-03-19T15:00:08.430Z,
updated_at: 2018-03-19T15:00:09.134Z
},
...
]
```
```javascript theme={null}
const records = [
{
id: 5eebcb6bb9faba06df0cd7a9,
customer: {
id: 5eebcb6bb9faba06df0cd7a9
},
orders_count: '4',
total_amount: 93800,
created_at: 2018-03-19T14:59:59.440Z,
updated_at: 2018-03-19T15:00:00.443Z
},
...
]
```
Now if we try to serialize this data, the serializer will automatically detect that the records to be serialized include another record (customer in this case), based on the collection definition.
The included records will then be picked up and wrapped to comply to the JSON API relationships format.
```javascript theme={null}
const serializedRecords = recordsSerializer.serialize(records);
```
The serialized records are formatted as follows:
```javascript theme={null}
{
data: [
{
type: 'customer_stats',
id: '67427',
attributes: {
orders_count: '4',
total_amount: 93800
},
relationships: {
customer: {
data: {type: "customers", id: "27048"}
links: {related: {href: "/forest/customer_stats/67427/relationships/customer"\}\}
}
}
},
...
],
included: [
{
type: "customers"
id: "27048"
attributes: {
id: 27048
}
},
...
]
}
```
```javascript theme={null}
{
data: [
{
type: 'customer_stats',
id: '5eebcb6bb9faba06df0cd7a9',
attributes: {
orders_count: '4',
total_amount: 93800
},
relationships: {
customer: {
data: {type: "customers", id: "5eebcb6bb9faba06df0cd7a9"}
links: {related: {href: "/forest/customer_stats/5eebcb6bb9faba06df0cd7a9/relationships/customer"\}\}
}
}
},
...
],
included: [
{
type: "customers"
id: "5eebcb6bb9faba06df0cd7a9"
attributes: {
id: "5eebcb6bb9faba06df0cd7a9"
}
},
...
]
}
```
Note that the `customer` relationship is clearly indicated under the `relationships` attribute. Also note that the customer is automatically wrapped in the `included` section, with its attributes if you specified some (only `id` in this case).
# Smart Fields
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-fields/overview
### What is a Smart Field?
A field that displays a computed value in your collection.
A Smart Field is a column that displays processed-on-the-fly data. It can be as simple as concatenating attributes to make them human friendly, or more complex (e.g. total of orders).
### Creating a Smart Field
On our Live Demo, the very simple Smart Field `fullname` is available on the `customers` collection.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('customers', {
fields: [
{
field: 'fullname',
type: 'String',
get: (customer) => {
return customer.firstname + ' ' + customer.lastname;
},
},
],
});
```
\
Very often, the business logic behind the Smart Field is more complex and must be asynchronous. To do that, please have a look at [this section](/legacy/javascript-agents/reference-guide/smart-fields/overview#createadvancedsmartfield).
On our Live Demo, the very simple Smart Field `fullname` is available on the `customers` collection.
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
collection('customers', {
fields: [
{
field: 'fullname',
type: 'String',
get: (customer) => {
return customer.firstname + ' ' + customer.lastname;
},
},
],
});
```
\
Very often, the business logic behind the Smart Field is more complex and must be asynchronous. To do that, please have a look at [this section](/legacy/javascript-agents/reference-guide/smart-fields/overview#createadvancedsmartfield).
On our Live Demo, the very simple Smart Field `fullname` is available on the `Customer` collection.
```ruby theme={null}
class Forest::Customer
include ForestLiana::Collection
collection :Customer
field :fullname, type: 'String' do
"#{object.firstname} #{object.lastname}"
end
end
```
Very often, the business logic behind the Smart Field is more complex and must interact with the database. Here’s an example with the Smart Field `full_address` on the `Customer` collection.
```ruby theme={null}
class Forest::Customer
include ForestLiana::Collection
collection :Customer
field :full_address, type: 'String' do
address = Address.find_by(customer_id: object.id)
"#{address[:address_line_1]} #{address[:address_line_2]} #{address[:address_city]} #{address[:country]}"
end
end
```
On our Live Demo, the very simple Smart Field `fullname` is available on the `Customer` collection.
Ensure the file app/forest/\_\_init\_\_.py exists and contains the import of the previous defined class :
On our Live Demo, the very simple Smart Field `fullname` is available on the `Customer` model.
Very often, the business logic behind the Smart Field is more complex and must interact with the database. Here’s an example with the Smart Field `full_address` on the `Customer` model.
The collection name must be the same as the **model name**.
### Updating a Smart Field
By default, your Smart Field is considered as read-only. If you want to update a Smart Field, you just need to write the logic to “unzip” the data. **Note that the `set` method should always return the object it’s working on**. In the example hereunder, the `customer` object is returned including only the modified data.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('customers', {
fields: [
{
field: 'fullname',
type: 'String',
get: (customer) => {
return customer.firstname + ' ' + customer.lastname;
},
set: (customer, fullname) => {
let names = fullname.split(' ');
customer.firstname = names[0];
customer.lastname = names[1];
// Don't forget to return the customer.
return customer;
},
},
],
});
```
Working with the actual record can be done this way:
```javascript theme={null}
const { collection, ResourceGetter } = require('forest-express-sequelize');
const { customers } = require('../models');
collection('customers', {
fields: [
{
field: 'fullname',
type: 'String',
get: (customer) => {
return customer.firstname + ' ' + customer.lastname;
},
set: async (customer, fullname) => {
const customerBeforeUpdate = await customers.findOne({
where: { id: customer.id },
});
const names = fullname.split(' ');
customer.firstname = `${names[0]} ${customerBeforeUpdate.pseudo}`;
return customer;
},
},
],
});
```
For security reasons, the `fullname` Smart field will remain **read-only**, even after you implement the `set` method. To edit it, disable read-only mode in the field settings.
By default, your Smart Field is considered as read-only. If you want to update a Smart Field, you just need to write the logic to “unzip” the data. **Note that the `set` method should always return the object it’s working on**. In the example hereunder, the `customer` record is returned.
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
collection('customers', {
fields: [
{
field: 'fullname',
type: 'String',
get: (customer) => {
return customer.firstname + ' ' + customer.lastname;
},
set: (customer, fullname) => {
let names = fullname.split(' ');
customer.firstname = names[0];
customer.lastname = names[1];
// Don't forget to return the customer.
return customer;
},
},
],
});
```
Working with the actual record can be done this way:
```javascript theme={null}
const { collection, ResourceGetter } = require('forest-express-mongoose');
const { customers } = require('../models');
collection('customers', {
fields: [
{
field: 'fullname',
type: 'String',
get: (customer) => {
return customer.firstname + ' ' + customer.lastname;
},
set: async (customer, fullname) => {
const customerBeforeUpdate = await customers.findById(customer.id);
const names = fullname.split(' ');
customer.firstname = `${names[0]} ${customerBeforeUpdate.pseudo}`;
return customer;
},
},
],
});
```
For security reasons, the `fullname` Smart field will remain **read-only**, even after you implement the `set` method. To edit it, disable read-only mode in the field settings.
By default, your Smart Field is considered as read-only. If you want to update a Smart Field, you just need to write the logic to “unzip” the data. **Note that the set method should always return the object it’s working on**. In the example hereunder, the `user_params` is returned is returned including only the modified data.
```ruby theme={null}
class Forest::Customer
include ForestLiana::Collection
collection :Customer
set_fullname = lambda do |user_params, fullname|
fullname = fullname.split
user_params[:firstname] = fullname.first
user_params[:lastname] = fullname.last
# Returns a hash of the updated values you want to persist.
user_params
end
field :fullname, type: 'String', set: set_fullname do
"#{object.firstname} #{object.lastname}"
end
end
```
For security reasons, the `fullname` Smart field will remain **read-only**, even after you implement the `set` method. To edit it, disable read-only mode in the field settings.
By default, your Smart Field is considered as read-only. If you want to update a Smart Field, you just need to write the logic to “unzip” the data. **Note that the `set` method should always return the object it’s working on**. In the example hereunder, the `customer` object is returned including only the modified data.
### Searching, Sorting and Filtering on a Smart Field
To perform a search on a Smart Field, you also need to write the logic to “unzip” the data, then the search query which is specific to your zipping. In the example hereunder, the `firstname` and `lastname` are searched separately after having been unzipped.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
const models = require('../models/');
const _ = require('lodash');
const Op = models.objectMapping.Op;
collection('customers', {
fields: [
{
field: 'fullname',
type: 'String',
get: (customer) => {
return customer.firstname + ' ' + customer.lastname;
},
search: function (query, search) {
let split = search.split(' ');
var searchCondition = {
[Op.and]: [
{ firstname: { [Op.like]: `%${split[0]}%` } },
{ lastname: { [Op.like]: `%${split[1]}%` } },
],
};
query.where[Op.and][0][Op.or].push(searchCondition);
return query;
},
},
],
});
```
For **case insensitive** search using PostgreSQL database use `iLike` operator. See [Sequelize operators documentation](https://sequelize.org/docs/v6/core-concepts/model-querying-basics/#operators).
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
const models = require('../models/');
const _ = require('lodash');
collection('customers', {
fields: [{
field: 'fullname',
type: 'String',
get: (customer) => {
return customer.firstname + ' ' + customer.lastname;
},
search(search) {
let names = search.split(' ');
return {
firstname: names[0],
lastname: names[1]
};
}
}]
});
```
```ruby theme={null}
class Forest::Customer
include ForestLiana::Collection
collection :Customer
search_fullname = lambda do |query, search|
firstname, lastname = search.split
# Injects your new filter into the WHERE clause.
query.where_clause.send(:predicates)[0] << " OR (firstname = '#{firstname}' AND lastname = '#{lastname}')"
query
end
field :fullname, type: 'String', set: set_fullname, search: search_fullname do
"#{object.firstname} #{object.lastname}"
end
end
```
#### Filtering
This feature is only available on agents version **6.7+** (version **6.2+** for Rails).
To perform a filter on a Smart Field, you need to write the filter query logic, which is specific to your use case.
In the example hereunder, the `fullname` is filtered by checking conditions on the `firstname` and `lastname` depending on the filter operator selected.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
const models = require('../models/');
const { Op } = models.Sequelize;
collection('customers', {
fields: [
{
field: 'fullname',
isFilterable: true,
type: 'String',
get: (customer) => {
return customer.firstname + ' ' + customer.lastname;
},
filter({ condition, where }) {
const firstWord = !!condition.value && condition.value.split(' ')[0];
const secondWord = !!condition.value && condition.value.split(' ')[1];
switch (condition.operator) {
case 'equal':
return {
[Op.and]: [
{ firstname: firstWord },
{ lastname: secondWord || '' },
],
};
case 'ends_with':
if (!secondWord) {
return {
lastName: { [Op.like]: `%${firstWord}` },
};
}
return {
[Op.and]: [
{ firstName: { [Op.like]: `%${firstWord}` } },
{ lastName: secondWord },
],
};
// ... And so on with the other operators not_equal, starts_with, etc.
default:
return null;
}
},
},
],
segments: [],
});
```
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
const models = require('../models');
collection('customer', {
actions: [],
fields: [
{
field: 'fullName',
type: 'String',
isFilterable: true,
get: (customer) => {
return customer.firstname + ' ' + customer.lastname;
},
filter({ condition, where }) {
const firstWord = !!condition.value && condition.value.split(' ')[0];
const secondWord = !!condition.value && condition.value.split(' ')[1];
switch (condition.operator) {
case 'equal':
return {
$and: [{ firstname: firstWord }, { lastname: secondWord || '' }],
};
case 'ends_with':
if (!secondWord) {
return {
lastname: { $regex: `.*${firstWord}` },
};
}
return {
$and: [
{ firstname: { $regex: `.*${firstWord}` } },
{ lastname: secondWord },
],
};
// ... And so on with the other operators not_equal, starts_with, etc.
default:
return null;
}
},
},
],
segments: [],
});
```
```ruby theme={null}
class Forest::Customer
include ForestLiana::Collection
collection :Customer
filter_fullname = lambda do |condition, where|
first_word = condition['value'] && condition['value'].split[0]
second_word = condition['value'] && condition['value'].split[1]
case condition['operator']
when 'equal'
"firstname = '#{first_word}' AND lastname = '#{second_word}'"
when 'ends_with'
if second_word.nil?
"lastname LIKE '%#{first_word}'"
else
"firstname LIKE '%#{first_word}' AND lastname = '#{second_word}'"
end
# ... And so on with the other operators not_equal, starts_with, etc.
end
end
field :fullname, type: 'String', is_read_only: false, is_required: true, is_filterable: true, filter: filter_fullname do
"#{object.firstname} #{object.lastname}"
end
end
```
Make sure you set the option `isFilterable: true` in the field definition of your code. Then, you will be able to toggle the "Filtering enabled" option in the browser, in your **Fields Settings**.
#### Sorting
**Sorting** on a Smart Field is not *natively supported* in Forest. However you can check out those guides:
* [Sort by Smart field](/legacy/javascript-agents/reference-guide/smart-fields/smart-field-examples/sort-by-smart-field)
* [Sort by Smart field that includes value from a belongsTo relationship](/legacy/javascript-agents/reference-guide/smart-fields/smart-field-examples/sort-by-smart-field-that-includes-value-from-a-belongsto-relationship)
### Available Field Options
Here are the list of available options to customize your Smart Field:
| Name | Type | Description |
| ----------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| field | string | The name of your Smart Field. |
| type | string | Type of your field. Can be `Boolean`, `Date`, `Json`,`Dateonly`, `Enum`, `File`, `Number, ['String']` or `String` . |
| enums | array of strings | (optional) Required only for the `Enum` type. This is where you list all the possible values for your input field. |
| description | string | (optional) Add a description to your field. |
| reference | string | (optional) Configure the Smart Field as a [Smart Relationship](/legacy/javascript-agents/reference-guide/models/relationships/overview#what-is-a-smart-relationship). |
| isReadOnly | boolean | (optional) If `true`, the Smart Field won’t be editable in the browser. Default is `true` if there’s no `set` option declared. |
| isRequired | boolean | (optional) If true, your Smart Field will be set as required in the browser. Default is false. |
You can define a widget for a smart field from the [settings of your collection](https://docs.forestadmin.com/user-guide/collections/customize-your-fields).
### Building Performant Smart Fields
To optimize your smart field performance, we recommend using a mechanism of batching and caching data requests.
Implement them using the DataLoader which is a generic utility to be used as part of your application's data fetching layer to provide a simplified and consistent API over various remote data sources.
#### Smart field declaration
```javascript theme={null}
const DataLoader = require('dataloader');
const authorLoader = new DataLoader(async (authorKeys) => {
const authors = await users.findAll({
where: { id: authorKeys },
});
const authorsById = new Map(authors.map((user) => [user.id, user]));
return authorKeys.map((authorKey) => authorsById.get(authorKey));
});
collection('posts', {
actions: [],
fields: [
{
field: 'author_name',
type: 'String',
get: async (record) => {
const author = await authorLoader.load(record.authorKey);
return author.name;
},
},
],
segments: [],
});
```
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
const { Address } = require('../models');
const Dataloader = require('dataloader');
const addressLoader = new Dataloader((customerIds) => {
const addresses = await models.addresses.find({
customer_id: {
$in: customerIds
}
});
const addressesByCustomerId = new Map(addresses.map(
address => [address.customer_id, address]
));
return customerIds.map(customerId => addressesByCustomerId.get(customerId));
})
collection('customers', {
fields: [{
field: 'full_address',
type: 'String',
get: (customer) => {
return addressLoader.load(customer.id)
.then((address) => {
return address.address_line_1 + '\n' +
address.address_line_2 + '\n' +
address.address_city + ' ' + address.country;
});
}
}]
});
```
####
# Smart Field Examples
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-fields/smart-field-examples/README
# Add an HTML credit card as a smart field in a summary view
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-fields/smart-field-examples/add-an-html-credit-card-as-a-smart-field-in-a-summary-view
**Context:** As a user I want to display the credit card infos of a client in a nice and visual way
`forest/companies.js`
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
const { companies, documents } = require('../models');
// This file allows you to add to your Forest UI:
// - Smart actions:
// - Smart fields:
// - Smart relationships:
// - Smart segments:
collection('companies', {
actions: [],
fields: [
{
field: 'Creditcard',
type: 'String',
get: (company) => {
if (company.creditCard) {
return `
`;
}
},
},
],
segments: [],
});
```
Use the rich text editor widget in order to interpret HTML in your field.
# Add fields destined to the create form
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-fields/smart-field-examples/add-fields-destined-to-the-create-form
**Context**: As a user I want to be able to pass information to a create form that concerns other collections than the current one.
The use case would be for the creation of a given record to add the information needed to create a parent record if it doesn't exist yet.
**Example**: I have a collection `lots` that belongsTo a collection `buildings` and a collection `owners`. If when I create a lot, the owner and building record it should belong to do not exist yet, I want to have input fields available in the lot create form so I can create them along with the lot in a single API call.
### Add smart fields that will be used as input fields in the form
You can declare smart fields that will not be meant to display any information but solely to serve as input fields.
In my example the fields are declared as follows in the `forest/lots.js` file:
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
collection('lots', {
actions: [],
fields: [
{
field: 'newOwnerFirstName',
type: 'String',
},
{
field: 'newOwnerLastName',
type: 'String',
},
{
field: 'newOwnerEmail',
type: 'String',
},
{
field: 'newBuildingName',
type: 'String',
},
{
field: 'newBuildingNumber',
type: 'String',
},
{
field: 'newBuildingAddressLine1',
type: 'String',
},
{
field: 'newBuildingCentralHeating',
type: 'Boolean',
},
],
segments: [],
});
```
When you add the fields, you can hide them in the UI and make them visible only in the create form. As you want the user to be able to search within the existing records of the parent collection you can keep the reference fields natively generated. But if the records don't exist they can fill in the input fields.
\
Demo video available here ⇒[https://www.loom.com/share/da44ee3c886e4f90a7768fdbfe4b462d?from\_recorder=1](https://www.loom.com/share/da44ee3c886e4f90a7768fdbfe4b462d?from_recorder=1)
## Catch the input at the route level
Now you can check if an input has been provided and use it following your own custom logic.
```jsx theme={null}
router.post(
'/lots',
permissionMiddlewareCreator.create(),
(request, response, next) => {
const attributes = request.body.data.attributes;
// do what you want with the user input
}
);
```
Reprising the form shown in the video above, the attributes object looks like this:
```javascript theme={null}
{
newBuildingAddressLine1: '2 street test',
newBuildingName: 'New building',
newBuildingNumber: '2',
newOwnerEmail: 'toto@mail.com'
}
```
# Add validation to a smart field edition
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-fields/smart-field-examples/add-validation-to-a-smart-field-edition
**Context**: I want to make sure that my users can only enter a value satisfying a certain set of conditions when editing a smart field.
Here I'm working on a collection `customers` and the smart field `'must-be-kuku'` should only accept the value `kuku`.
`forest/customers.js`
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
const models = require('../models');
// This file allows you to add to your Forest UI:
// - Smart actions:
// - Smart fields:
// - Smart relationships:
// - Smart segments:
collection('customers', {
actions: [],
fields: [
{
field: 'must-be-kuku',
type: 'String',
get(customer) {
return 'kuku';
},
set(customer, value) {},
},
],
segments: [],
});
```
At the route level I need to check the user input from the edit form in the UI and check the value that has been entered into the field `'must-be-kuku'`.
`routes/customers.js`
```javascript theme={null}
const express = require('express');
const { PermissionMiddlewareCreator } = require('forest-express-sequelize');
const { customers } = require('../models');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
'customers'
);
// Update a Customer
router.put(
'/customers/:recordId',
permissionMiddlewareCreator.update(),
(request, response, next) => {
if (request.body.data.attributes['must-be-kuku'] !== 'kuku') {
return response.status(403).send('should have been kuku!');
}
next();
}
);
module.exports = router;
```
# Display field with complex info in html format (rich text editor)
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-fields/smart-field-examples/display-field-with-complex-info-in-html-format-rich-text-editor
## First step: Display through html
Create a smart field that will return a string containing the html formatted data (here the features name and if they are enabled or not).
This smart field will be declared at the level of the account collection (as we want features status to be visible for each account). The file where the smart field should be declared is contained in a folder forest and should be `forest/accounts.js`
The logic is to add for each feature a new div which includes an element containing the name and an element conditionally formatted (green or red) containing the value true of false.
In order to do that you need to list the fields to iterate on to add the html elements.
```jsx theme={null}
fields: [{
field: 'display rights',
type: 'String',
get: (account) => {
//check if the movie has a related characteristics record to return smtg or not
if (account.right) {
// list all your fields from the movie_characteristics collection you want to display
const rightsNameList = ["feature1", "feature2"];
// create empty string which will be filled with a div per field listed above - this string will be the value returned
let rightsList = ""
// add style that will be used to display the movie_characteristics info
const rightsDivStyle = 'margin: 24px 0px; color: #415574'
const rightsNameStyle = 'padding: 6px 16px; margin: 12px; background-color:#b5c8d05e; border-radius: 6px'
const rightsValueStyleRed = 'padding: 6px 12px; background-color:#ff7f7f87; border-radius: 6px'
const rightsValueStyleGreen = 'padding: 6px 12px; background-color:#7FFF7F; border-radius: 6px'
// iterate over the list of movie characteristics fields
for (index = 0; index < rightsNameList.length; index++) {
const fieldName = rightsNameList[index]
let rightsValueStyle = rightsValueStyleRed
if (account.right[fieldName] === true) {
rightsValueStyle = rightsValueStyleGreen
}
// insert the div with the field info to the string that will be returned
rightsList += `
${fieldName}${account.right[fieldName]}
`
}
return rightsList
}
}
}],
```
## Second step: Update through a smart action
Then to edit, create a smart action (in the same file) that will open a form with an input for each feature to update (prefilled with the current value)
```jsx theme={null}
actions:[{
name: 'update rights',
type: 'single',
fields: [{
field: 'feature1',
type: 'Boolean',
description: 'insert value to update field',
},{
field: 'feature2',
type: 'Boolean',
description: 'insert value to update field',
}],
values: (context) => {
async function getRights(context){
// wait until you fetch the movie record - IMPORTANT do not forget to include the hasone relationship with the movie characteristics table
let account = await models.accounts.findByPk(context.id, {include:[{model: models.rights}]})
return account.right
}
// Forest will automatically match all of the form fields that have the same name as your movie characteristics fields and prefill with their value
return getRights(context)
}
}],
```
Then define what happens when the form is sent and the route is called. The route needs to be defined in a file `routes/accounts.js`.
You need to get account object and update each field for which a new value has been passed with the relevant value.
The refresh relationship part is needed to refresh the data displayed without having to refresh manually
```javascript theme={null}
const P = require('bluebird');
const express = require('express');
const router = express.Router();
const Liana = require('forest-express-sequelize');
const models = require('../models');
router.post('/actions/update-rights', Liana.ensureAuthenticated, (req, res) => {
let accountId = req.body.data.attributes.ids[0];
console.log(accountId);
let fieldList = req.body.data.attributes.values;
console.log(fieldList);
return models.accounts
.findByPk(accountId, { include: [{ model: models.rights }] })
.then((account) => {
for (var key in fieldList) {
let fieldValue = fieldList[key];
account.right.update({ [key]: fieldValue });
}
res.send({
success: 'Characteristics updated',
refresh: { relationships: ['accounts', 'rights'] },
});
});
});
module.exports = router;
```
# Display smart field as progress bar using rich text editor
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-fields/smart-field-examples/display-smart-field-as-progress-bar-using-rich-text-editor
```javascript theme={null}
const Liana = require('forest-express-sequelize');
const models = require('../models/');
const express = require('express');
const router = express.Router();
Liana.collection('orders', {
fields: [
{
field: 'progressBar',
type: 'String',
get: (order) => {
//set your value and max value
var progressValue = yourProgressValue;
var maxValue = yourMaxValue;
var percentage = (progressValue / maxValue) * 100;
return `
0${maxValue} ${progressValue}
`;
},
},
],
});
```
# Generate signed urls to display S3 files in a smart field
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-fields/smart-field-examples/generate-signed-urls-to-display-s3-files-in-a-smart-field
**Context**: As a user I want to be able to preview files from an S3 bucket thanks to secure signed urls.
**Example**: I have a collection `places` that has a `pictures` field which is an array of strings containing the file name of files stored on a s3 bucket.
In a smart field called `s3pictures` I return the value of calls made to S3 to get signed urls for the files whose name is present in the `pictures` field.
### Implementation
First you need to implement the function to get the signed urls from s3. We use the `aws-sdk` npm package to connect to the bucket storing the pictures.
`services/s3-helper.js`
```jsx theme={null}
const AWS = require('aws-sdk');
AWS.config.update({
region: process.env.AWS_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
});
function getS3SignedUrlById(fileId) {
const s3Bucket = new AWS.S3({ params: { Bucket: process.env.S3_BUCKET } });
return s3Bucket.getSignedUrl('getObject', {
Key: fileId,
Expires: 60 * process.env.AWS_S3_URL_EXPIRE_MINS,
});
}
module.exports = getS3SignedUrlById;
```
Then you need to declare the `s3Pictures` smart field and implement the get logic to populate it. In the get function you iterate on the pictures array to get the signed url for each file name then return an array with the signed urls.
`forest/places.js`
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
const getSignedUrlById = require('../services/s3-helper');
collection('places', {
actions: [],
fields: [
{
field: 's3Pictures',
type: ['String'],
get: async (place) => {
if (place.pictures) {
const s3pictures = [];
for (const picture of place.pictures) {
const url = await getSignedUrlById(picture);
s3pictures.push(url);
}
return s3pictures;
}
return null;
},
},
],
segments: [],
});
```
You can then use the default file viewer widget settings to preview the pictures.
# Print a status object in a single line field
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-fields/smart-field-examples/print-a-status-object-in-a-single-line-field
**Context**: as a user I want to display in a single field all the lines from a status object from a user's record.
Example of a user document:
```jsx theme={null}
{
"_id" : ObjectId("5ed65502ade8bf99a79a0be5"),
"date_added" : ISODate("2020-06-02T14:32:50.360Z"),
"email" : "demo@emitwise.com",
"client" : ObjectId("5ec5146d4bd6a122bd5dee25"),
"first_name" : "Eduardo",
"last_name" : "Gomez",
"avatar_link" : "",
"has_consented_to_cookies" : false,
"has_seen_reportwise_welcome" : false,
"user_type" : "pro",
"is_in_demo_mode" : true,
"onboarding_progress" : {
"registered" : true,
"payment_complete" : false,
"data_uploaded" : false,
"location_data_added" : false,
"data_processed" : false,
"complete" : false
}
}
```
`forest/companies.js`
```jsx theme={null}
const { collection } = require('forest-express-mongoose');
const { customFieldsStyles } = require('../style/fields-style.js');
// This file allows you to add to your Forest UI:
// - Smart actions:
// - Smart fields:
// - Smart relationships:
// - Smart segments:
collection('user', {
actions: [],
fields: [
{
field: 'status',
type: 'String',
get: (user) => {
// check if the user has a subdocument to return
if (user.onboarding_progress) {
// list all your fields from the subdocument you want to display
const fieldsNameList = [
'registered',
'payment_complete',
'data_uploaded',
'location_data_added',
'data_processed',
'complete',
];
// create empty string which will be filled with a div per field listed above - this string will be the value returned
let fieldValueList = '';
//
// iterate over the list of fields and add style that will be used to display the subdocument fields
for (index = 0; index < fieldsNameList.length; index++) {
const fieldName = fieldsNameList[index];
let fieldValueStyle = customFieldsStyles.fieldValueStyleRed;
if (user.onboarding_progress[fieldName] === true) {
fieldValueStyle = customFieldsStyles.fieldValueStyleGreen;
}
// insert the div with the field info to the string that will be returned
fieldValueList += `
`;
}
return fieldValueList;
}
},
},
{
field: 'visualizations',
type: ['String'],
reference: 'visualization._id',
},
],
segments: [],
});
```
`style/fields-style.js`
```javascript theme={null}
exports.customFieldsStyles = {
fieldDivStyle: 'margin: 24px 0px; color: #415574',
fieldNameStyle:
'padding: 6px 16px; margin: 12px; background-color:#b5c8d05e; border-radius: 6px',
fieldValueStyle: 'padding: 6px 16px; margin: 12px; border-radius: 6px',
fieldValueStyleRed:
'padding: 6px 12px; background-color:#ff7f7f87; border-radius: 6px',
fieldValueStyleGreen:
'padding: 6px 12px; background-color:#7FFF7F; border-radius: 6px',
};
```
# Sort by smart field
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-fields/smart-field-examples/sort-by-smart-field
Context: as a user, I want to be able to sort a collection based on a smart field. This example is based on [the one provided in the documentation](https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields#creating-a-smart-field) with a simple concatenation of 2 fields existing in the collection.
We have a `customers` collection with a field `firstname` and field `lastname`. We create a smart field `fullname` that is a concatenation of the two fields.
#### **Smart field definition**
In order to make the field sortable, you need to add the `isSortable` attribute.
`forest/customers.js`
```jsx theme={null}
{
field: 'fullname',
type: 'String',
isSortable: true,
get: (customer) => {
return customer.firstname + ' ' + customer.lastname;
},
},
```
#### **Route definition**
At the level of the route, you need to catch the query and redirect the sort field from one that does not exist in the database (`fullname`) to the relevant one (`firstname`)
`routes/customers.js`
```javascript theme={null}
router.get(
'/customers',
permissionMiddlewareCreator.list(),
(request, response, next) => {
// Learn what this route does here: https://docs.forestadmin.com/documentation/v/v6/reference-guide/routes/default-routes#get-a-list-of-records
let sort;
switch (request.query.sort) {
case '-fullname':
sort = '-firstname';
break;
case 'fullname':
sort = 'firstname';
break;
default:
sort = request.query.sort;
}
request.query.sort = sort;
next();
}
);
```
# Sort by smart field that includes value from a belongsTo relationship
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-fields/smart-field-examples/sort-by-smart-field-that-includes-value-from-a-belongsto-relationship
**Context**: As a user I want to be able to sort records based on a smart field where the smart field includes data from the current record's parent.
**Example**: Here I have a model `orders` that has a belongsTo relationship with the `customers` model.
I have a smart field in the `orders` model called `customer email` that returns the value of the parent customer's email field. I want to sort the orders by the `customer email` smart field.
### Implementation
`forest/orders.js`
```jsx theme={null}
const { collection } = require('forest-express-sequelize');
const models = require('../models');
collection('orders', {
actions: [],
fields: [
{
field: 'customer email',
get: (order) =>
models.customers
.findByPk(order.customer.dataValues.id)
.then((customer) => customer.email),
isSortable: true,
},
],
segments: [],
});
```
`routes/orders.js`
```javascript theme={null}
router.get(
'/orders',
permissionMiddlewareCreator.list(),
(request, response, next) => {
if (request.query.sort.includes('customer email')) {
request.query.sort = request.query.sort.includes('-')
? '-customer.email'
: 'customer.email';
}
next();
}
);
```
# Update point geometry field using a smart field and algolia api
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-fields/smart-field-examples/update-point-geometry-field-using-a-smart-field-and-algolia-api
Algolia is sunsetting its Place services. We recommend that you use the Google service instead. [Learn more](https://www.algolia.com/blog/product/sunsetting-our-places-feature/).
**Description**: I need to fill in 2 fields in my db for a location: address 1 (a string) and location (a postresql geography point). Although a [widget ](https://docs.forestadmin.com/user-guide/collections/customize-your-fields/edit-widgets)with autocomplete exists to fill an address string in the UI, the location coordinates can only be obtained manually by looking up the address in our search engine which is not optimal.create smart field to edit point field using the address widget and algolia API.
**Approach chosen**: Create a smart field in your Forest backend app that will serve as the input field.
```javascript theme={null}
const Liana = require('forest-express-sequelize');
const algoliasearch = require('algoliasearch');
const places = algoliasearch.initPlaces(
process.env.PLACES_APP_ID,
process.env.PLACES_API_KEY
);
Liana.collection('events', {
fields: [
{
field: 'Location setter',
type: 'String',
get: (event) => {
return event.address;
},
set: (event, query) => {
async function getLocationCoordinates(query) {
try {
const location = await places.search({
query: query,
type: 'address',
});
console.log(
'search location coordinates result',
location.hits[0]._geoloc
);
return location.hits[0]._geoloc;
} catch (err) {
console.log(err);
console.log(err.debugData);
}
}
async function setEvent(event, query) {
const coordinates = await getLocationCoordinates(query);
event.address = query;
console.log('new address', event.address);
event.locationGeo = `{"type": "Point", "coordinates": [${coordinates.lat}, ${coordinates.lng}]}`;
console.log('new location', event.locationGeo);
return event;
}
return setEvent(event, query);
},
},
],
});
```
# Smart Segments
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-segments
### What is a Smart Segment?
A **Segment** is a subset of a collection: it's basically a saved filter of your collection.
Segments are designed for those who want to *systematically* visualize data according to specific sets of filters. It allows you to save your filters configuration so you don’t have to compute the same actions every day.
A **Smart Segments** is useful when you want to use a complex filter, which you'll add as code in your backend.
### Creating a Smart Segment
Sometimes, segment filters are complicated and closely tied to your business. Forest allows you to code how the segment is computed.
On our Live Demo example, we’ve implemented a Smart Segment on the collection `products` to allow admin users to see the bestsellers at a glance.
You’re free to implement the business logic you need. The only requirement is to return a valid Sequelize condition. Most of the time, your Smart Segment should return something like `{ id: { in: [ 1,2,3,4,5 ] } }`.
On our implementation, we use a raw SQL query to filter and sort the product that was sold the most.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
const models = require('../models');
const { Op, QueryTypes } = models.objectMapping;
collection('products', {
segments: [
{
name: 'Bestsellers',
where: (product) => {
return models.connections.default
.query(
`
SELECT products.id, COUNT(orders.*)
FROM products
JOIN orders ON orders.product_id = products.id
GROUP BY products.id
ORDER BY count DESC
LIMIT 5;
`,
{ type: QueryTypes.SELECT }
)
.then((products) => {
let productIds = products.map((product) => product.id);
return { id: { [Op.in]: productIds } };
});
},
},
],
});
```
You’re free to implement the business logic you need. Your Smart Segment should return something like `{ _id: { $in: [ 1,2,3,4,5 ] } }`.
```javascript theme={null}
const { collection } = require('forest-express-mongoose');
const { Product } = require('../models');
collection('Product', {
fields: [
{
field: 'buyers',
type: ['String'],
reference: 'Customer',
},
],
segments: [
{
name: 'Bestsellers',
where: (product) => {
return Product.aggregate([
{
$project: { orders_count: { $size: { $ifNull: ['$orders', []] } } },
},
{
$sort: { orders_count: -1 },
},
{
$limit: 5,
},
]).then((products) => {
let productIds = [];
products
.filter((product) => {
if (product._id.length === 0) {
return false;
}
return true;
})
.forEach((product) => {
productIds.push(product._id);
});
return { _id: { $in: productIds } };
});
},
},
],
});
```
```ruby theme={null}
class Forest::Product
include ForestLiana::Collection
collection :Product
segment 'Bestsellers' do
productIds = Product.joins(:orders).group('products.id').order('count(orders.id)').limit(10).pluck('products.id')
{ id: productIds }
end
end
```
Ensure the file app/forest/\_\_init\_\_.py exists and contains the import of the previous defined class :
```python theme={null}
from app.forest.product import ProductForest
```
The 2nd parameter of the `SmartSegment` method is not required. If you don't fill it, the name of your SmartSegment will be the name of your method that wrap it.
### Setting up independent columns visibility
By default, Forest applies the same configuration to all segments of the same collection.
However, the *Independent columns configuration* option allows you to display different columns on your different segments.
# Create a Calendar view
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-views/create-a-calendar-view
The example below shows how to display a calendar view:
```javascript theme={null}
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { guidFor } from '@ember/object/internals';
import {
triggerSmartAction,
deleteRecords,
getCollectionId,
loadExternalStyle,
loadExternalJavascript,
} from 'client/utils/smart-view-utils';
export default class extends Component {
@service() router;
@service() store;
@tracked conditionAfter = null;
@tracked conditionBefore = null;
@tracked loaded = false;
constructor(...args) {
super(...args);
this.loadPlugin();
}
get calendarId() {
return `${guidFor(this)}-calendar`;
}
async loadPlugin() {
loadExternalStyle(
'https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.css'
);
await loadExternalJavascript(
'https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.js'
);
this.loaded = true;
this.onInsert();
}
@action
onInsert() {
if (!this.loaded || !document.getElementById(this.calendarId)) return;
this.calendar = new FullCalendar.Calendar(
document.getElementById(this.calendarId),
{
allDaySlot: false,
minTime: '00:00:00',
initialDate: new Date(2018, 2, 1),
eventClick: ({ event, jsEvent, view }) => {
this.router.transitionTo(
'project.rendering.data.collection.list.view-edit.details',
this.args.collection.id,
// This is not a mistake, you have to specify the collection twice
this.args.collection.id,
event.id
);
},
events: async (info, successCallback, failureCallback) => {
const field = this.args.collection.fields.findBy(
'fieldName',
'start_date'
);
if (this.conditionAfter) {
this.args.removeCondition(this.conditionAfter, true);
this.conditionAfter.unloadRecord();
}
if (this.conditionBefore) {
this.args.removeCondition(this.conditionBefore, true);
this.conditionBefore.unloadRecord();
}
const conditionAfter =
this.store.createFragment('fragment-condition');
conditionAfter.set('field', field);
conditionAfter.set('operator', 'is after');
conditionAfter.set('value', info.start);
conditionAfter.set('smartView', this.args.viewList);
this.conditionAfter = conditionAfter;
const conditionBefore =
this.store.createFragment('fragment-condition');
conditionBefore.set('field', field);
conditionBefore.set('operator', 'is before');
conditionBefore.set('value', info.end);
conditionBefore.set('smartView', this.args.viewList);
this.conditionBefore = conditionBefore;
this.args.addCondition(conditionAfter, true);
this.args.addCondition(conditionBefore, true);
await this.args.fetchRecords({ page: 1 });
successCallback(
this.args.records?.map((appointment) => {
return {
id: appointment.get('id'),
title: appointment.get('forest-name'),
start: appointment.get('forest-start_date'),
end: appointment.get('forest-end_date'),
};
})
);
},
}
);
this.calendar.render();
}
@action
triggerSmartAction(...args) {
return triggerSmartAction(this, ...args);
}
@action
deleteRecords(...args) {
return deleteRecords(this, ...args);
}
}
```
```css theme={null}
.calendar {
padding: 20px;
background: var(--color-beta-surface);
height: 100%;
overflow: scroll;
}
.calendar .fc-toolbar.fc-header-toolbar .fc-left {
font-size: 14px;
font-weight: bold;
}
.calendar .fc-day-header {
padding: 10px 0;
background-color: var(--color-beta-secondary);
color: var(--color-beta-on-secondary_dark);
}
.calendar .fc-event {
background-color: var(--color-beta-secondary);
border: 1px solid var(--color-beta-on-secondary_border);
color: var(--color-beta-on-secondary_medium);
font-size: 14px;
}
.calendar .fc-day-grid-event {
background-color: var(--color-beta-info);
color: var(--color-beta-on-info);
font-size: 10px;
border: none;
padding: 2px;
}
.calendar .fc-day-number {
color: var(--color-beta-on-surface_medium);
}
.calendar .fc-other-month .fc-day-number {
color: var(--color-beta-on-surface_disabled);
}
.fc-left {
color: var(--color-beta-on-surface_dark);
}
.c-smart-view {
display: flex;
white-space: normal;
position: absolute;
bottom: 0;
left: 0;
right: 0;
top: 0;
background-color: var(--color-beta-surface);
}
.c-smart-view__content {
margin: auto;
text-align: center;
color: var(--color-beta-on-surface_medium);
}
.c-smart-view_icon {
margin-bottom: 32px;
font-size: 32px;
}
```
```html theme={null}
```
# Create a custom moderation view
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-views/create-a-custom-moderation-view
This example shows you how you can implement a moderation view with a custom Approve/Reject workflow.
In our example, we want to Approve or Reject products to moderate content on our website:
* We want to preview products images
* We want to bulk Approve/Reject products
## How it works
### Smart view definition
Learn more about [smart views](/legacy/javascript-agents/reference-guide/smart-views/overview).\
\
**File template.hbs**
This file contains the HTML and CSS needed to build the view.
### Template
```css theme={null}
Product details
Images
\{\{#each this.formattedRecords as |record|\}\}
\{\{record.forest-name\}\}
\{\{record.forest-state\}\}
\{\{#each record.forest-imagesSF as |image|\}\}
\{\{/each\}\}
\{\{/each\}\}
```
# Create a custom tinder-like validation view
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-views/create-a-custom-tinder-like-validation-view
This example shows you how you can implement a time-saving profile validation view using keyboard keys to trigger approve/reject actions.
In our example, we want to Approve or Reject new customers profiles and more specifically:
* We want to preview information from the user's profile
* We want to approve a customer by pressing the ArrowRight key
* We want to reject a customer by pressing the ArrowLeft key
## How it works
### Models definition
Here is the definition of the underlying model for this view
```javascript theme={null}
module.exports = (sequelize, DataTypes) => {
const { Sequelize } = sequelize;
const customerValidations = sequelize.define(
'customerValidations',
{
firstname: {
type: DataTypes.STRING,
},
lastname: {
type: DataTypes.STRING,
},
email: {
type: DataTypes.STRING,
},
createdAt: {
type: DataTypes.DATE,
},
status: {
type: DataTypes.STRING,
},
avatar: {
type: DataTypes.STRING,
},
},
{
tableName: 'customers',
underscored: true,
schema: process.env.DATABASE_SCHEMA,
}
);
return customerValidations;
};
```
### Smart view definition
Learn more about [smart views](/legacy/javascript-agents/reference-guide/smart-views/overview).\
\
This file contains the HTML, JS and CSS needed to build the view.
### Template
```css theme={null}
\{\{#if (eq @recordsCount 0)\}\}
\{\{@collection.pluralizedDisplayName\}\}
There are no items to process.
\{\{/if\}\}
\{\{#unless (eq @recordsCount 0)\}\}
\{\{#each @records as |record|\}\}
name : \{\{record.forest-firstname\}\} \{\{record.forest-lastname\}\}
```
# Create a dynamic calendar view for an event-booking use case
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-views/create-a-dynamic-calendar-view-for-an-event-booking-use-case
This example shows you how you can implement a calendar view with a custom workflow involving dynamic API calls.
In our example, we want to manage the bookings for a sports court where:
* We have a list of court opening dates. Each date can be subject to a price increase if the period is busy. These dates [come from a collection](https://docs.forestadmin.com/woodshop/how-tos/create-a-custom-view#available-dates-model) called `availableDates`
* A list of available slots appears after selecting a date and duration. These available slots [come from a smart collection](https://docs.forestadmin.com/woodshop/how-tos/create-a-custom-view#available-slots-smart-collection) called `availableSlots`
* The user can book a specific slot [using a smart action](https://docs.forestadmin.com/woodshop/how-tos/create-a-custom-view#book-smart-action) called`book`.
## How it works
### Smart view definition
Learn more about [smart views](https://docs.forestadmin.com/documentation/reference-guide/views/create-and-manage-smart-views#creating-a-smart-view).\
\
**File template.hbs**
This file contains the HTML and CSS needed to build the view.
```markup theme={null}
\{\{else\}\}
\{\{#if (not this.selectedDate)\}\}
Please select a date to see slots available.
\{\{else\}\}
No slots available, please try another duration or another date.
\{\{/if\}\}
\{\{/if\}\}
```
**File template.js**
This file contains all the logic needed to handle events and actions.
```javascript theme={null}
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { scheduleOnce } from '@ember/runloop';
import { observer } from '@ember/object';
import $ from 'jquery';
import SmartViewMixin from 'client/mixins/smart-view-mixin';
export default Component.extend(SmartViewMixin, {
store: service(),
conditionAfter: null,
conditionBefore: null,
loaded: false,
calendarId: null,
selectedAvailability: null,
selectedDate: null,
selectedDuration: 1,
availableSlots: null,
availableSlotsCollection: null,
_calendar: null,
init(...args) {
this._super(...args);
this.loadPlugin();
this.initConditions();
this.set('durations', [
{
label: '1 hour',
value: 1,
},
{
label: '2 hours',
value: 2,
},
{
label: '3 hours',
value: 3,
},
]);
},
didInsertElement() {
this.set(
'availableSlotsCollection',
this.store.peekAll('collection').findBy('name', 'availableSlots')
);
},
// update displayed events when new records are retrieved
onRecordsChange: observer('records.[]', function () {
this.setEvent();
}),
onConfigurationChange: observer(
'selectedDate',
'selectedDuration',
function () {
this.searchAvailabilities();
}
),
initConditions() {
if (this.filters) {
this.filters.forEach((condition) => {
if (condition.operator === 'is after') {
this.set('conditionAfter', condition);
} else if (condition.operator === 'is before') {
this.set('conditionBefore', condition);
}
});
}
},
loadPlugin() {
scheduleOnce('afterRender', this, function () {
this.set('calendarId', `${this.elementId}-calendar`);
// retrieve fullCalendar script to build the calendar view
$.getScript(
'https://cdn.jsdelivr.net/npm/fullcalendar@5.3.0/main.min.js',
() => {
this.setEvent();
const calendarEl = document.getElementById(this.calendarId);
const calendar = new FullCalendar.Calendar(calendarEl, {
height: 600,
allDaySlot: true,
eventClick: (event, jsEvent, view) => {
// persist the selected event information when an event is clicked
this.set('selectedAvailability', event.event);
const eventStart = event.event.start;
const selectedDate = `${eventStart.getDate().toString()}/${(
eventStart.getMonth() + 1
).toString()}/${eventStart.getFullYear().toString()}`;
// persist the selected event's date to be displayed in the view
this.set('selectedDate', selectedDate);
},
// define logic to be triggered when the user navigates between date ranges
datesSet: (view) => {
// define params to query the relevant records from the database based on the date range
const params = {
filters: JSON.stringify({
aggregator: 'and',
conditions: [
{
field: 'date',
operator: 'before',
value: view.end,
},
{
field: 'date',
operator: 'after',
value: view.start,
},
],
}),
'page[number]': 1,
'page[size]': 31,
timezone: 'Europe/Paris',
};
// query the records from the availableDates collection
return this.store
.query('forest-available-date', params)
.then((records) => {
this.set('records', records);
})
.catch((error) => {
this.set('records', null);
alert('We could not retrieve the available dates');
console.error(error);
});
},
});
this.set('_calendar', calendar);
calendar.render();
this.set('loaded', true);
}
);
const headElement = document.getElementsByTagName('head')[0];
const cssLink = document.createElement('link');
cssLink.type = 'text/css';
cssLink.rel = 'stylesheet';
cssLink.href =
'https://cdn.jsdelivr.net/npm/fullcalendar@5.3.0/main.min.css';
headElement.appendChild(cssLink);
});
},
// create calendar event objects for each availableDates record
setEvent() {
if (!this.records || !this.loaded) {
return;
}
this._calendar.getEvents().forEach((event) => event.remove());
this.records.forEach((availability) => {
if (availability.get('forest-opened') === true) {
const event = {
id: availability.get('id'),
title: 'Available',
start: availability.get('forest-date'),
allDay: true,
};
if (availability.get('forest-pricingPremium') === 'high') {
event.textColor = 'white';
event.backgroundColor = '#FB6669';
event.title = 'Available';
}
this._calendar.addEvent(event);
}
});
},
// retrieve record from the availableSlots collection when an event has been selected
searchAvailabilities() {
if (this.selectedAvailability) {
return this.store
.query('forest-available-slot', {
date: this.selectedAvailability.start,
duration: this.selectedDuration,
})
.then((slots) => {
this.set('availableSlots', slots);
})
.catch((error) => {
this.set('availableSlots', null);
alert('We could not retrieve the available slots');
console.error(error);
});
}
},
});
```
### Available dates model
**File models/available-dates.js**
This file contains the model definition for the collection `availableDates`. It is located in the `models` folder, at the root of the admin backend.
```javascript theme={null}
module.exports = (sequelize, DataTypes) => {
const { Sequelize } = sequelize;
const AvailableDates = sequelize.define(
'availableDates',
{
date: {
type: DataTypes.DATE,
},
opened: {
type: DataTypes.BOOLEAN,
},
pricingPremium: {
type: DataTypes.STRING,
},
},
{
tableName: 'available_dates',
underscored: true,
timestamps: false,
schema: process.env.DATABASE_SCHEMA,
}
);
return AvailableDates;
};
```
### Available slots smart collection
To create a smart collection that returns records built from an API call, two files need to be created:
* a file `available-slots.js` inside the folder `forest` to declare the collection
* a file `available-slots.js` inside the `routes` folder to implement the `GET` logic for the collection
**File forest/available-slots.js**
This file includes the smart collection definition.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('availableSlots', {
fields: [
{
field: 'startDate',
type: 'Date',
},
{
field: 'endDate',
type: 'Date',
},
{
field: 'time',
type: 'String',
},
{
field: 'maxTimeSlot',
type: 'Number',
},
],
segments: [],
});
```
**File routes/available-slots.js**
This file includes the logic implemented to retrieve the available slots from an API call and return them serialized to the UI.
```javascript theme={null}
const express = require('express');
const {
PermissionMiddlewareCreator,
RecordSerializer,
} = require('forest-express-sequelize');
const { availableSlots } = require('../models');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
'availableSlots'
);
const recordSerializer = new RecordSerializer({ name: 'availableSlots' });
// Get a list of Available slots
router.get(
'/availableSlots',
permissionMiddlewareCreator.list(),
(request, response, next) => {
const { date } = request.query;
const { duration } = request.query;
return fetch(
`https://apicallplaceholder/slots/?date=${date}&duration=${duration}`
)
.then((response) => JSON.parse(response))
.then((matchingSlots) => {
return recordSerializer
.serialize(matchingSlots)
.then((recordsSerialized) => response.send(recordsSerialized));
})
.catch((error) => {
console.error(error);
});
}
);
module.exports = router;
```
### Book smart action
To create the action to book a slot, two files need to be updated:
* the file `available-slots.js` inside the folder `forest` to declare the action
* the file `available-slots.js` inside the `routes` folder to implement the logic for the action
**File forest/available-slots.js**
This file includes the smart action definition. The action form is pre-filled with the start and end date. The last step is to select the user associated with this booking.
```javascript theme={null}
const { collection } = require('forest-express-sequelize');
collection('availableSlots', {
actions: [
{
name: 'book',
type: 'single',
fields: [{
field: 'start date',
type: 'Date',
}, {
field: 'end date',
type: 'Date',
}, {
field: 'user',
reference: 'users.id',
}],
values: (context) => {
return {
'start date': context.startDate,
'end date': context.endDate,
};
},
},
],
...
});
```
**File routes/available-slots.js**
This file includes the logic of the smart action. It basically creates a record from the `bookings` collection with the information passed on by the user input form.
```javascript theme={null}
const express = require('express');
const { PermissionMiddlewareCreator } = require('forest-express-sequelize');
const { bookings } = require('../models');
const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('availableSlots');
...
router.post('/actions/book', permissionMiddlewareCreator.smartAction(), (request, response) => {
const attr = request.body.data.attributes.values;
const startDate = attr['start date'];
const endDate = attr['end date'];
bookings.create({
startDate,
endDate,
userIdKey: attr.user,
}).then(() => response.send({ success: 'successfully created booking' }));
});
module.exports = router;
```
# Create a Gallery view
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-views/create-a-gallery-view
### Ember
```javascript theme={null}
import Component from '@glimmer/component';
import { action } from '@ember/object';
import {
triggerSmartAction,
deleteRecords,
getCollectionId,
loadExternalStyle,
loadExternalJavascript,
} from 'client/utils/smart-view-utils';
export default class extends Component {
@action
triggerSmartAction(...args) {
return triggerSmartAction(this, ...args);
}
@action
deleteRecords(...args) {
return deleteRecords(this, ...args);
}
}
```
### React
```jsx theme={null}
import React from 'react';
import WithEmberSupport from 'ember-react-components';
import { inject as service } from '@ember/service';
@WithEmberSupport
export default class extends React.Component {
@service router;
render() {
const {
records,
collection,
numberOfPages,
recordsCount,
currentPage,
searchValue,
isLoading,
fetchRecords,
} = this.props;
const goBack = () => {
if (currentPage > 1) {
return fetchRecords({ page: currentPage - 1 })
}
};
const goNext = () => {
if (currentPage < numberOfPages) {
return fetchRecords({ page: currentPage + 1 })
}
};
const redirectToRecord = (record) => this.transitionTo(
'project.rendering.data.collection.list.view-edit.details',
collection.id,
record.id,
);
return (
```
# Smart Views
Source: https://docs.forest.app/legacy/javascript-agents/reference-guide/smart-views/overview
## What is a Smart View?
Smart Views lets you code your view using JS, HTML, and CSS. They are taking data visualization to the next level. Ditch the table view and display your orders on a Map, your events in a Calendar, your movies, pictures and profiles in a Gallery. All of that with the easiness of Forest.
## Creating a Smart View
Forest provides an online editor to inject your Smart View code. The editor is available on the collection’s settings, then in the “Smart views” tab.
The code of a Smart View is a [Glimmer Component](https://guides.emberjs.com/release/upgrading/current-edition/glimmer-components/) and simply consists of a Template and Javascript code.
You don’t need to know the **Ember.js** framework to create a Smart View. We will guide you here on all the basic requirements. For more advanced usage, you can still refer to the [Glimmer Component](https://guides.emberjs.com/release/upgrading/current-edition/glimmer-components/) documentations.
Your code must be compatible with Ember 4.12.
### Getting your records
The records of your collection are accessible from the records property. Here’s how to iterate over them in the template section:
```markup theme={null}
\{\{#each @records as |record|\}\}
\{\{/each\}\}
```
### Accessing a specific record
For each record, you will access its attributes through the `forest-attribute` property. The `forest-` preceding the field name **is required**.
```markup theme={null}
\{\{#each @records as |record|\}\}
status: \{\{record.forest-shipping_status\}\}
\{\{/each\}\}
```
### Accessing belongsTo relationships
Accessing a `belongsTo` relationship works in exactly the same way as accessing a simple field. Forest triggers automatically an API call to retrieve the data from your Admin API only if it’s necessary.
On the `Shipping` Smart View (in the collection named `Order`) defined on our Live Demo example, we’ve displayed the full name of the customer related to an order.
```markup theme={null}
\{\{#each @records as |record|\}\}
Order to \{\{record.forest-customer.forest-firstname\}\} \{\{record.forest-customer.forest-lastname\}\}
\{\{/each\}\}
```
### Accessing hasMany relationships
Accessing a `hasMany` relationship works in exactly the same way as accessing a simple field.. Forest triggers automatically an API call to retrieve the data from your Admin API only if it’s necessary.
```markup theme={null}
\{\{#each @records as |record|\}\}
\{\{#each @record.forest-comments as |comment|\}\}
\{\{comment.forest-text\}\}
\{\{/each\}\}
\{\{/each\}\}
```
### Refreshing data
Trigger the `fetchRecords` action in order to refresh the records on the page.
```markup theme={null}
```
### Fetching data
Trigger an API call to your Admin API in order to fetch records from any collection and with any filters you want.
We will use the `store` service for that purpose. Check out the list of all available services from your Smart View.
In our Live Demo example, the collection `appointments` has a `Calendar` Smart View. When you click on the previous or next month, the Smart View fetches the new events in the selected month. The result here is set to the property`appointments`. You can access it directly from your template.
```javascript theme={null}
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class extends Component {
@service store;
@tracked appointments;
async fetchData(startDate, endDate) {
const params = {
filters: JSON.stringify({
aggregator: 'and',
conditions: [{
field: 'start_date',
operator: 'greater_than'
value: startDate,
}, {
field: 'start_date',
operator: 'less_than'
value: endDate,
}],
}),
timezone: 'America/Los_Angeles',
'page[number]': 1,
'page[size]': 50
};
this.appointments = await this.store.query('forest_appointment', params);
}
// ...
};
```
```markup theme={null}
\{\{#each this.appointments as |appointment|\}\}
\{\{appointment.id\}\}
\{\{appointment.forest-name\}\}
\{\{/each\}\}
```
#### Available parameters
| Parameter | Type | Description |
| ------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| filters | Object | A stringified JSON object containing either a filter or an aggregation of several filters. A filter has: `field`, `operator`, `value`. An aggregation has: `aggregator` (and/or), `conditions` (array). Available operators: `less_than`, `greater_than`, `equal`, `after`, `before`, `contains`, `starts_with`, `ends_with`, `not_contains`, `present`, `not_equal`, `blank` |
| timezone | String | The timezone string. Example: `America/Los_Angeles`. |
| page\[number] | Number | The page number you want to fetch. |
| page\[size] | Number | The number of records per page you want to fetch. |
### Deleting records
The `deleteRecords` action lets you delete one or multiple records. A pop-up will automatically ask for a confirmation when a user triggers the delete action.
```markup theme={null}
\{\{#each @records as |record|\}\}
\{\{/each\}\}
```
### Triggering a Smart Action
Please note that the smart action triggering in the context of the smart view editor can be broken as you might not have access to all the required information. We advise you to test the smart action execution from the smart view applied to the collection view.
Here’s how to trigger your [Smart Actions](/legacy/javascript-agents/reference-guide/actions/create-and-manage-smart-actions/overview#what-is-a-smart-action) directly from your Smart Views.
### template.hbs
```markup theme={null}
```
### component.js
```javascript theme={null}
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { triggerSmartAction } from 'client/utils/smart-view-utils';
export default class extends Component {
@action
triggerSmartAction(...args) {
return triggerSmartAction(this, ...args);
}
@action
deleteRecords(...args) {
return deleteRecords(this, ...args);
}
}
```
`triggerSmartAction` function imported from `'client/utils/smart-view-utils'`has the following signature:
```javascript theme={null}
function triggerSmartAction(
context, collection, actionName, records, callback = () => {}, values = null,
)
```
| Argument name | Description |
| ------------- | ----------------------------------------------------------------------------------------------------------------------- |
| context | Context is the reference to the component, in the smart view it is accessible through the keyword `this` |
| collection | The `collection` that has the Smart Action |
| actionName | The Smart Action name |
| records | An array of records or a single one |
| callback | A function executed after the smart action that takes as the single parameter the result of the smart action execution. |
| values | An object containing the values to be passed for the smart action fields |
Here is an example of how to trigger the smart action with the values passed from the code, you only need to do it if you **don't** want to use the built-in [smart action form](/legacy/javascript-agents/reference-guide/actions/create-and-manage-smart-actions/use-a-smart-action-form)
### template.hbs
```markup theme={null}
```
### component.js
```javascript theme={null}
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { triggerSmartAction } from 'client/utils/smart-view-utils';
import { tracked } from '@glimmer/tracking';
export default class extends Component {
@tracked newTime = '11:00';
@action
triggerSmartAction(actionName, records, values) {
return triggerSmartAction(
this,
this.args.collection,
actionName,
records,
() => {},
values
);
}
@action
rescheduleToNewTime(record) {
this.triggerSmartAction('Reschedule', record, { newTime });
}
@action
deleteRecords(...args) {
return deleteRecords(this, ...args);
}
}
```
### Available properties
Forest automatically injects into your Smart View some properties to help you display your data like you want.
| Property | Type | Description |
| --------------- | ------- | ------------------------------------------------------ |
| `collection` | Model | The current collection. |
| `currentPage` | Number | The current page. |
| `isLoading` | Boolean | Indicates if the UI is currently loading your records. |
| `numberOfPages` | Number | The total number of available pages |
| `records` | array | Your data entries. |
| `searchValue` | String | The current search. |
### Available actions
Forest automatically injects into your Smart View some actions to trigger the logic you want.
| Action | Description |
| ---------------------------------------------------- | ----------------------------------------------------------------------- |
| `deleteRecords(records)` | Delete one or multiple records. |
| `triggerSmartAction(collection, actionName, record)` | Trigger a Smart Action defined on the specified collection on a record. |
## Applying a Smart View
To apply a Smart view you created, turn on the Layout Editor mode **(1)**, click on the table button **(2)** and drag & drop your Smart View's name in first position inside the dropdown **(3)**:
Your view will refresh automatically. You can now turn off the Layout Editor mode **(4)**.
### Impact on related data
Once your Smart view is applied, it will also be displayed in your record's related data.
#### In the related data section
#### In the summary view
As of today, it's **not** possible to set different views for your table/summary/related data views.
# Readme
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/databases/README
# Add new databases
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/databases/add-new-databases
It's becoming quite common to have multiple databases when building a web application. Especially when designing your app with micro services. Here you'll learn how to add new databases.
### Add new database
To connect a new database on your project, you need to follow the following steps:
* Stop your agent. The following process will generate files and using nodemon while following this process can cause mis-generation of the `.forestadmin-schema.json` file.
* Add a new environment variable, inside your `.env` file (It will be `ANOTHER_DB_URL` in this example), which represents the connection url string of the database you want to add.
* Edit the database config file located to `config/databases.js` to add a new object with the following syntax in the array:
```javascript theme={null}
[
{
name: 'your_first_database_connection',
// Models associated to a connection should be in a dedicated folder.
// If your setup already works, you'll need to update the modelsDir associated to your existing connection
// by changing this variable value
modelsDir: path.resolve(
__dirname,
'./models/your_first_database_connection'
),
connection: {
url: process.env.DATABASE_URL,
options: {
/* Database options can be empty, but should match with your requirements */
},
},
},
{
name: 'name_of_the_connection',
modelsDir: path.resolve(__dirname, './models/name_of_the_connection'),
connection: {
url: process.env.ANOTHER_DB_URL,
options: {
/* Database options can be empty, but should match with your requirements */
},
},
},
];
```
* Run `forest schema:update` [command](/legacy/ruby-agent/reference-guide/models/overview#updating-your-models-automatically) and follow instructions.
* It should generate all the required files. ⚠️ Be aware that existing files will remain untouched when switching from a single database to a multi-database setup. If you made any modifications in the models of your existing connection.\
In this example, you may want to check the freshly generated models that will be located in the `./models/your_first_database_connection` folder.
* As stated on the `forest schema:update` documentation, when switching from a single to a multiple database setup, existing models in the `./models` folder will remain untouched, and you'll need to move them to the correct location (According to you `config/databases.js` file) or simply remove them if you never made any modifications on the models themselves.
* Start the agent, and display the added models with the layout editor. Everything should work as expected
# Connect to a read replica database
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/databases/connect-to-a-read-replica-database
⚠️ This tutorial is for SQL databases only.
A read replica is a copy of the master that reflects changes to the master instance in almost real time.\
\
For performance reasons, you can specify one or more servers to act as read replicas, and one server to act as the write master, which handles all writes and updates and propagates them to the replicas.\
\
For example, your read replica will be used while displaying the table view of your records or accessing your Forest dashboard.
As your Admin Backend relies on the Sequelize ORM, it's quite easy to configure a[ read replication](https://sequelize.org/master/manual/read-replication.html).
Those code snippets are an example. It is strongly advised to use environment variables for your database connection credentials.
# Manage SQL views
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/databases/manage-sql-views
In SQL, a view is a virtual table based on the result-set of an SQL statement. Views can provide advantages over tables, such as:
* represent a subset of the data contained in a table (see also[ segments](https://docs.forestadmin.com/user-guide/collections/segments)).
* join and simplify many tables into a single virtual table.
* act as aggregated tables, where the database engine aggregates data (sum, average etc.) and presents the calculated results as part of the data.
Forest natively supports SQL views. If you have already implemented views, simply add [the associated models](https://docs.forestadmin.com/documentation/reference-guide/models/enrich-your-models#declaring-a-new-model) to display them on your interface.
## Creating the SQL View
To create a view, we use `CREATE VIEW` statement.
In the following example, we look for the **user's email**, **the number of orders** and **the total amount spent**.
```sql theme={null}
CREATE VIEW customer_stats AS
SELECT customers.id,
customers.email,
count(orders.*) AS nb_orders,
sum(products.price) AS amount_spent,
customers.created_at,
customers.updated_at
FROM customers
JOIN orders ON customers.id = orders.customer_id
JOIN products ON orders.product_id = products.id
GROUP BY customers.id;
```
## Adding the model
To display the SQL view on your Forest interface, you must add the associated Sequelize model in your application.
You must restart your server to see the changes on your interface.
## Managing the view
Once your SQL view is implemented, you'll be able to filter, search, export and change the order of your fields.
Most of the time SQL views are used as **read-only**. If this is the case, we recommend changing the CRUD permission in your [roles's settings](https://docs.forestadmin.com/user-guide/project-settings/teams-and-users/manage-roles).
# Plug multiple schemas
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/databases/plug-multiple-schemas
A **schema** is an organizational layer to better structure your SQL database.
At installation, you may only choose 1 schema:
If you're not using specific schemas, you don't have to fill this advanced option.
### Forest can display collections from multiple schemas
To achieve this, proceed to install using 1 of your schemas. Only the models of this schema will be generated in your `models` directory.
Let's take a model example:
On **line 24**, you'll notice `schema: process.env.DATABASE_SCHEMA`.
It uses the environment variable `DATABASE_SCHEMA` set in your **.env** file. \
You'll have to edit this to match your schemas. For instance, if you have 2 schemas:
Once this is done, follow those steps:
#### Step 1: Edit your current models
Because you have changed your environment variable name from `DATABASE_SCHEMA` to `DATABASE_SCHEMA_1`, you need to update it in all your models' file in the `models` directory (same line as line 24 in the above example).
#### Step 2: Create new models
For each of your other schemas' models, you'll need to create a file in `models`. This must be done **manually** and the schema line must be set to `DATABASE_SCHEMA_2` as per above example.
If your other schemas have a lot of models, a quick way to generate the models is to create a another project using those other schemas (1 project for each schema).
# Use a demo SQL database
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/databases/use-a-demo-database
**Pre-requisite**: Docker
To import the demo database using our [forestadmin/meals-database](https://hub.docker.com/r/forestadmin/meals-database) image, simply run:
```
docker run -p 5432:5432 --name forest_demo_database forestadmin/meals-database
```
That's all! Your database is running locally in a docker container.
To check if the database is correctly setup, you can use the following command to connect to your freshly created database.
```sql theme={null}
docker exec -it forest_demo_database psql meals lumber
```
You should get a prompt where you can type SQL queries or PostgreSQL command line `\d` to see the available list of tables.
```sql theme={null}
meals=# \d
List of relations
Schema | Name | Type | Owner
--------+----------------------------+----------+--------
public | ar_internal_metadata | table | lumber
public | chef_availabilities | table | lumber
public | chef_availabilities_id_seq | sequence | lumber
public | chefs | table | lumber
public | chefs_id_seq | sequence | lumber
public | customers | table | lumber
public | customers_id_seq | sequence | lumber
public | delivery_men | table | lumber
public | delivery_men_id_seq | sequence | lumber
public | menus | table | lumber
public | menus_id_seq | sequence | lumber
public | menus_products | table | lumber
public | menus_products_id_seq | sequence | lumber
public | orders | table | lumber
public | orders_id_seq | sequence | lumber
public | orders_products | table | lumber
public | orders_products_id_seq | sequence | lumber
public | product_images | table | lumber
public | product_images_id_seq | sequence | lumber
public | products | table | lumber
public | products_id_seq | sequence | lumber
public | schema_migrations | table | lumber
(22 rows)
```
To use this database for a new Forest project, you'll need:
| Property | Value |
| ------------- | ------ |
| User | lumber |
| Password | secret |
| Database name | meals |
# Upgrade
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/maintain/README
# Changing your domain name
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/maintain/changing-your-domain-name
To change your domain name, you'll have to change your application URL in 2 places:
* in your `.env` file, change the **APPLICATION\_URL** variable to the new URL
* in the details page of your environment (Project settings > Environments), change the **Admin backend URL**
Don't forget to restart your agent.
# Manage your Forest environments programmatically
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/maintain/manage-your-forest-admin-programmatically
For continuous integration and automatization, we have developed a [CLI](https://github.com/ForestAdmin/toolbelt) which makes it easy to manage your Forest environments.
This can be used for Q\&A and testing purposes.
#### Install
```
$ npm install -g forest-cli
```
#### Commands
```
$ forest [command]
```
**General**
* `user` display the current logged in user.
* `login` sign in to your Forest account.
* `logout` sign out of your Forest account.
* `help [cmd]` display help for \[cmd].
**Projects**
Manage Forest projects.
* `projects` list your projects.
* `projects:get` get the configuration of a project.
**Environments**
Manage Forest environments.
* `environments` list your environments.
* `environments:get` get the configuration of an environment.
* `environments:create` create a new environment.
* `environments:delete` delete an environment.
* `environments:copy-layout` copy the layout from one environment to another.
#### Schema
Manage Forest schema.
`schema:apply` apply the current schema of your repository to the specified environment (using your `.forestadmin-schema.json` file).
This option is available only on [agents version >+ 3](https://app.gitbook.com/@forestadmin/s/documentation/~/drafts/-LcaGvIb-WdMOABgHOTu/primary/reference-guide/upgrade-to-v3).
# Migrate to the new role system
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/maintain/migrate-to-the-new-role-system
If you still have access to your project today, you are using the new role system already, read more about **Roles** in our [User Guide](https://docs.forestadmin.com/user-guide/project-settings/teams-and-users/manage-roles).
The old role system has been deprecated the 1st of June 2023, has reached its end of life the 1st of December 2023, and support has been dropped entirely the 4th of December 2024. Please do note that the new Role permissions system requires that you use **version 6.6+** of your agent (**version 5.4+** for Rails) on **all** your environments. If you are running proper versions and urgently need to migrate to the new Roles system please contact our [support](mailto:support@forestadmin.com).
The new role system allows you to control all the permissions of your roles from a single details page, which will look like this:

# Monitor your Forest's status
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/maintain/monitor-your-forests-status
For **healthchecks**, you can query your app at:
* `/forest` : it returns a **204** status code if your app is up and running
* `/forest/healthcheck` : it returns a **200** status code if your app is up and running
# Push your new version to production
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/maintain/push-your-new-version-to-production
Forest is using the [`.forestadmin-schema.json`](https://docs.forestadmin.com/developer-guide-agents-nodejs/under-the-hood/forestadmin-schema) file that is present beside your agent to reflect your model definition as well as the agent version.
When upgrading your agent version, it will only be taken into account if the `.forestadmin-schema.json` with the latest version has been pushed.
## Recommended procedure
At Forest, we advise you to start your migration in your development environment:
1. Upgrade your agent in development following the upgrade notes
2. Start the agent locally
3. You should notice that your `.forestadmin-schema.json` has been updated
4. Commit your source code, dependency manager file as well as the `.forestadmin-schema.json` file
5. Push your commit to Production/Staging/Test
6. Pull code in your server; install, build and restart
## Upgrade without development environment (Not recommended)
If you only have one single remote environment and not bothered by the possibility that it can remain down for a period of time you can upgrade your agent version directly on it.
1. Upgrade the agent following the migration notes
2. Set your `NODE_ENV` or `FOREST_ENVIRONMENT` to `dev`
3. Restart with this new configuration, it should update the content of `.forestadmin-schema.json`
4. Once you have confirmed that the file has been updated and sent to Forest servers, you can restore your environment variables and restart the server
# Update your models' definition
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/maintain/update-your-models-definition
Your database schema will evolve over time. Any changes can (and probably should) be applied to your admin backend's models.
To upgrade your models definition in your code you need to be at least in the V7 version of `forest-express-sequelize` or `forest-express-mongoose` package. If this is not the case, you need to upgrade to V7 first. See the v1 upgrade notes (SQL, MongoDB) in this legacy documentation, or jump directly to the [v1 to v2 migration guide](/guides/migration/from-v1/overview).
Now you can use the `forest schema:update` command to achieve your goal.
This command is able to create all the missing file for a newly added table in your database. However it will not automatically modify existing files. So if you just added a new field inside an existing table, please just remove the corresponding model file inside your models folder and run the command.
### Examples
In the following example, we added a new table `customers` on an existing project. This is the output of the `forest schema:update` command.
```
$ forest schema:update
✓ Connecting to your database(s)
✓ Analyzing the database(s)
create forest/customers.js
skip forest/staffs.js - already exist.
skip forest/stores.js - already exist.
create models/customers.js
skip models/staffs.js - already exist.
skip models/stores.js - already exist.
create routes/customers.js
skip routes/staffs.js - already exist.
skip routes/stores.js - already exist.
✓ Generating your files
```
In the next example we just removed a field from the previous added table. After removing the model file from the models folder. This is the output of the `forest schema:update` command.
```
$ forest schema:update
✓ Connecting to your database(s)
✓ Analyzing the database(s)
skip forest/customers.js - already exist.
skip forest/staffs.js - already exist.
skip forest/stores.js - already exist.
create models/customers.js
skip models/staffs.js - already exist.
skip models/stores.js - already exist.
skip routes/customers.js - already exist.
skip routes/staffs.js - already exist.
skip routes/stores.js - already exist.
✓ Generating your files
```
# Upgrade notes (Rails)
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/maintain/upgrade-notes-rails/README
# Upgrade to v5
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/maintain/upgrade-notes-rails/untitled
The purpose of this note is to help developers to upgrade their agent from v4 to v5. Please read carefully and integrate the following breaking changes to ensure a smooth update.
Please be aware that while Forest make every effort to ensure that our platform updates are broadly compatible and offer detailed instructions for upgrading, Forest cannot guarantee that custom code developed by the developers will always be compatible with new versions of our software. This includes any custom modifications or extensions to core functionalities, such as method overrides or custom integrations. It is the responsibility of the developers to review and test their custom code to ensure compatibility with each new version. Our team provides comprehensive upgrade guides to assist in this process, but these cannot encompass the unique customizations that may be present in each customer's environment. Therefore, Forest strongly recommend establishing a thorough testing protocol for your specific customizations to safeguard against potential issues during the upgrade process.
## Upgrading to v5
Before upgrading to v5, consider the below **breaking changes**.
As for any dependency upgrade, it's very important to **test this upgrade** **in your testing environments**. Not doing so could result in your admin panel being unusable.
To upgrade to v5, **update the version in your Gemfile**, then run:
```javascript theme={null}
bundle install
```
In case of a regression introduced in Production after the upgrade, a rollback to your previous agent version 4 is the fastest way to restore your admin panel.
## Breaking changes
### Select all feature
This version also introduces the new Select all behavior. Once you've updated your **bulk** Smart Actions according to the below changes, you'll be able to choose between selecting **all** the records or only those displayed on the current page.
```javascript theme={null}
# BEFORE
class Forest::CompaniesController < ForestLiana::SmartActionsController
def mark_as_live
company_ids = params.dig('data', 'attributes', 'ids')
# ...
render json: { success: 'Companies are now live!' }
end
end
# AFTER
class Forest::CompaniesController < ForestLiana::SmartActionsController
def mark_as_live
company_ids = ForestLiana::ResourcesGetter.get_ids_from_request(params)
# ...
render json: { success: 'Companies are now live!' }
end
end
```
If you altered the default DELETE behavior by overriding or extending it, you'll have to do so as well with the new BULK DELETE route.
## Important Notice
### Changelogs
This release note covers only the major changes. To learn more, please refer to the changelogs in our different repositories:
* [Rails changelog](https://github.com/ForestAdmin/forest-rails/blob/master/CHANGELOG.md#release-500---2020-03-20)
# Upgrade to v3
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/maintain/upgrade-notes-rails/upgrade-to-v3
Help developers to move from v2 to v3. Please read carefully and integrate the following breaking changes to ensure a smooth update.
Please be aware that while Forest make every effort to ensure that our platform updates are broadly compatible and offer detailed instructions for upgrading, Forest cannot guarantee that custom code developed by the developers will always be compatible with new versions of our software. This includes any custom modifications or extensions to core functionalities, such as method overrides or custom integrations. It is the responsibility of the developers to review and test their custom code to ensure compatibility with each new version. Our team provides comprehensive upgrade guides to assist in this process, but these cannot encompass the unique customizations that may be present in each customer's environment. Therefore, Forest strongly recommend establishing a thorough testing protocol for your specific customizations to safeguard against potential issues during the upgrade process.
## Breaking changes
### Cors configuration
Set CORS `credentials: true` if you're using custom CORS configuration. See [how to configure CORS headers](/legacy/ruby-agent/how-tos/setup/configuring-cors-headers).
### Rails
We use the [Rack CORS](https://github.com/cyu/rack-cors) Gem for this purpose.
```ruby theme={null}
# Gemfile
source 'https://rubygems.org'
# ...
gem 'forest_liana'
gem 'rack-cors'
```
```ruby theme={null}
module LiveDemoRails
class Application < Rails::Application
# ...
# For Rails 5, use the class Rack::Cors. For Rails 4, you MUST use the string 'Rack::Cors'.
config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'app.forestadmin.com'
resource '*',
headers: :any,
methods: :any,
expose: ['Content-Disposition'],
credentials: true
end
end
end
end
```
### Global smart action
Smart actions defined as follows `global: true` will no longer be considered as global.
Please now use `type: 'global'`.
### Rails
**Before**
```ruby theme={null}
class Forest::Product
include ForestLiana::Collection
collection :Product
action 'Import data',
global: true
# ...
end
```
**After**
```ruby theme={null}
class Forest::Product
include ForestLiana::Collection
collection :Product
action 'Import data',
type: 'global'
# ...
end
```
### Schema versioning
On server start - *only in development environments* - the agent will generate a `.forestadmin-schema.json` file reflecting your **Forest schema**.
If you change your models or database, Forest will automatically load a new schema to keep the layout up to date.
**Do not edit this file**. It will be automatically generated on server start **only in development environments**.
This file **must be deployed** for any remote environment (staging, production, etc.), as it will be used to generate your Forest UI.
**Version this file.** It will give you more visibility on the changes detected by Forest.
In the following example, we have added two fields on the `invoices` table:
* `emailSent`
* `quadernoId`
Versioning the`.forestadmin-schema.json` file allows you to easily visualize the changes.
## Changelogs
This release note covers only the major changes. To learn more, please refer to the changelogs in our different repositories:
* [Rails changelog](https://github.com/ForestAdmin/forest-rails/blob/master/CHANGELOG.md#release-300---2019-04-22)
# Upgrade to v4
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/maintain/upgrade-notes-rails/upgrade-to-v4
The purpose of this note is to help developers to upgrade their agent from v3 to v4. Please read carefully and integrate the following breaking changes to ensure a smooth update.
Please be aware that while Forest make every effort to ensure that our platform updates are broadly compatible and offer detailed instructions for upgrading, Forest cannot guarantee that custom code developed by the developers will always be compatible with new versions of our software. This includes any custom modifications or extensions to core functionalities, such as method overrides or custom integrations. It is the responsibility of the developers to review and test their custom code to ensure compatibility with each new version. Our team provides comprehensive upgrade guides to assist in this process, but these cannot encompass the unique customizations that may be present in each customer's environment. Therefore, Forest strongly recommend establishing a thorough testing protocol for your specific customizations to safeguard against potential issues during the upgrade process.
## Upgrading to v4
Before upgrading to v4, consider the below **breaking changes**.
As for any dependency upgrade, it's very important to **test this upgrade** **in your testing environments**. Not doing so could result in your admin panel being unusable.
To upgrade to v4, **update the version in your Gemfile**, then run:
```javascript theme={null}
bundle install
```
In case of a regression introduced in Production after the upgrade, a rollback to your previous agent version 3 is the fastest way to restore your admin panel.
## Breaking changes
### New JWT authentication token
The information format of the *session token* have changed in v4.
You could be impacted if you use the *user session* in Smart Action controllers or Smart Routes
**Calling `forest_user` in v3**
```javascript theme={null}
{
"id": "172",
"type": "users",
"data": {
"email": "angelicabengtsson@doha2019.com",
"first_name": "Angelica",
"last_name": "Bengtsson",
"teams": ["Pole Vault"],
},
"relationships": {
"renderings": {
"data": [{
"type": "renderings",
"id": "4998",
}],
},
},
"iat": 1569913709,
"exp": 1571123309
}
```
**Calling `forest_user` in v4**
```javascript theme={null}
{
"id": "172",
"email": "angelicabengtsson@doha2019.com",
"firstName": "Angelica",
"lastName": "Bengtsson",
"team": "Pole Vault",
"renderingId": "4998",
"iat": 1569913709,
"exp": 1571123309
}
```
Consequently, the user information is now accessible as described below:
| Property | v3 | v4 |
| ------------ | ------------------------------------------------- | ------------------------- |
| email | `forest_user.data.email` | `forest_user.email` |
| first name | `forest_user.data.first_name` | `forest_user.firstName` |
| last name | `forest_user.data.last_name` | `forest_user.lastName` |
| team | `forest_user.data.teams[0]` | `forest_user.team` |
| rendering id | `forest_user.relationships.renderings.data[0].id` | `forest_user.renderingId` |
### New filters query parameters format
The **query parameters** sent for **filtering** purposes have changed in v4.
You could be impacted if you have custom filter implementations.
Below are a few example of the new filter conditions format you can access using`params[:filters]`:
```javascript theme={null}
{
"field": "planLimitationReachedAt",
"operator": "previous_year_to_date",
"value": null
}
```
```javascript theme={null}
{
"aggregator": "and",
"conditions": [{
"field": "planLimitationReachedAt",
"operator": "previous_year_to_date",
"value": null
}, {
"field": "planLimitationStatus",
"operator": "equal",
"value": "warning"
}]
}
```
## Important Notice
### Agent logout
A consequence of the new session token format is:
Once an agent v4 deployed, **all users of your project will be automatically logged out** and be forced to re-authenticate to generate a newly formatted token.
### Changelogs
This release note covers only the major changes. To learn more, please refer to the changelogs in our different repositories:
* [Rails changelog](https://github.com/ForestAdmin/forest-rails/blob/master/CHANGELOG.md#release-400---2019-10-04)
# Upgrade to v6
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/maintain/upgrade-notes-rails/upgrade-to-v6
The purpose of this note is to help developers to upgrade their agent from v5 to v6. Please read carefully and integrate the following breaking changes to ensure a smooth update.
Please be aware that while Forest make every effort to ensure that our platform updates are broadly compatible and offer detailed instructions for upgrading, Forest cannot guarantee that custom code developed by the developers will always be compatible with new versions of our software. This includes any custom modifications or extensions to core functionalities, such as method overrides or custom integrations. It is the responsibility of the developers to review and test their custom code to ensure compatibility with each new version. Our team provides comprehensive upgrade guides to assist in this process, but these cannot encompass the unique customizations that may be present in each customer's environment. Therefore, Forest strongly recommend establishing a thorough testing protocol for your specific customizations to safeguard against potential issues during the upgrade process.
Please follow the recommended procedure to upgrade your agent version by following [this note](/legacy/ruby-agent/how-tos/maintain/push-your-new-version-to-production).
## Upgrading to v6
Before upgrading to v5, consider the below **breaking changes**.
As for any dependency upgrade, it's very important to **test this upgrade** **in your testing environments**. Not doing so could result in your admin panel being unusable.
To upgrade to v6, **update the version in your Gemfile**, then run the following and update your project as shown in the *Breaking Changes* section below.:
```javascript theme={null}
bundle install
```
In case of a regression introduced in Production after the upgrade, a rollback to your previous agent version 5 is the fastest way to restore your admin panel.
## Breaking changes
### Easier authentication
The agent version introduces an improved authentication mechanism. The following changes are required:
#### New environment variable
In your `secrets.yml` file, set a `forest_application_url` variable: it must contain your Rails app URL for that environment. Then add the following:
```ruby theme={null}
ForestLiana.application_url = Rails.application.secrets.forest_application_url
```
#### New CORS condition
Add `null_regex = Regexp.new(/\Anull\z/)` as a variable and use it in your cors configuration. When using `rack cors`, it should look like this:
```ruby theme={null}
null_regex = Regexp.new(/\Anull\z/)
config.middleware.insert_before 0, Rack::Cors do
allow do
hostnames = [null_regex, 'localhost:4200', 'app.forestadmin.com', 'localhost:3001']
hostnames += ENV['CORS_ORIGINS'].split(',') if ENV['CORS_ORIGINS']
origins hostnames
resource '*',
headers: :any,
methods: :any,
expose: ['Content-Disposition'],
credentials: true
end
end
```
#### Enable caching
You need to enable caching on your environment to be able to authenticate to Forest. You can do it by running the following command:
```bash theme={null}
rails dev:cache
```
You can either enable caching or setup a static clientId as shown in the next step.
#### Setup a static clientId
This is required if you're running multiple instances of your agent (with a load balancer for exemple) or if you don't want to enable caching on your environment.
First, you will need to obtain a Client ID for your environment by running the following command:
```bash theme={null}
curl -H "Content-Type: application/json" \
-H "Authorization: Bearer FOREST_ENV_SECRET" \
-X POST \
-d '{"token_endpoint_auth_method": "none", "redirect_uris": ["APPLICATION_URL/forest/authentication/callback"]}' \
https://api.forestadmin.com/oidc/reg
```
Then assign the `client_id` value from the response (it's a JWT) to a `forest_client_id` variable in your `secret.yml` file.
Lastly, add the following:
```ruby theme={null}
ForestLiana.forest_client_id = Rails.application.secrets.forest_client_id
```
## Important Notice
### Changelogs
This release note covers only the major changes. To learn more, please refer to the changelogs in our different repositories:
* [Rails changelog](https://github.com/ForestAdmin/forest-rails/blob/master/CHANGELOG.md#600-2021-02-22)
# Upgrade to v7
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/maintain/upgrade-notes-rails/upgrade-to-v7
The purpose of this note is to help developers to upgrade their agent from v6 to v7. Please read carefully and integrate the following breaking changes to ensure a smooth update.
Please be aware that while Forest make every effort to ensure that our platform updates are broadly compatible and offer detailed instructions for upgrading, Forest cannot guarantee that custom code developed by the developers will always be compatible with new versions of our software. This includes any custom modifications or extensions to core functionalities, such as method overrides or custom integrations. It is the responsibility of the developers to review and test their custom code to ensure compatibility with each new version. Our team provides comprehensive upgrade guides to assist in this process, but these cannot encompass the unique customizations that may be present in each customer's environment. Therefore, Forest strongly recommend establishing a thorough testing protocol for your specific customizations to safeguard against potential issues during the upgrade process.
Please follow the recommended procedure to upgrade your agent version by following [this note](/legacy/ruby-agent/how-tos/maintain/push-your-new-version-to-production).
## Upgrading to v7
Before upgrading to v7, consider the below [**breaking changes**](/legacy/ruby-agent/how-tos/maintain/upgrade-notes-rails/upgrade-to-v7#breaking-change).
This upgrade unlocks the following feature:
* [Add/remove Smart action form fields dynamically](/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/use-a-smart-action-form#add-remove-fields-dynamically)
* [Use hooks for bulk/global Smart actions](/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/use-a-smart-action-form#get-selected-records-with-bulk-action)
To upgrade to v7, **update the version in your Gemfile**, then run the following and update your project as shown in the *Breaking Changes* section below.:
```javascript theme={null}
bundle install
```
In case of a regression introduced in Production after the upgrade, a rollback to your previous agent version 6 is the fastest way to restore your admin panel.
## Breaking change
#### Smart actions
The `values` endpoint is no longer supported.
The smart action `change` hook is no longer linked to `fieldName`. Now it need to set a `hook` property inside field definition.
Before
```ruby theme={null}
action 'Test action',
type: 'single',
fields: [{
field: 'a field',
type: 'String',
}],
:hooks => {
:change => {
'a field' => -> (context) {
# Do something ...
return context[:fields];
}
}
}
```
After
```ruby theme={null}
action 'Test action',
type: 'single',
fields: [{
field: 'a field',
type: 'String',
hook: 'onFieldChanged',
}],
:hooks => {
:change => {
'onFieldChanged' => -> (context) {
# Do something ...
return context[:fields];
}
}
}
```
The signature of `hooks` function has changed.`fields` is now an array. You must change the way you access fields.
Before
```ruby theme={null}
[...]
:hooks => {
:load => -> (context) {
field = context[:fields]['a field'];
field[:value] = 'init your field';
return context[:fields];
},
:change => {
'onFieldChanged' => -> (context) {
field = context[:fields]['a field'];
field[:value] = 'what you want';
return context[:fields];
}
}
}
[...]
```
After
```ruby theme={null}
[...]
:hooks => {
:load => -> (context) {
field = context[:fields].find{|field| field[:field] == 'a field'}
field[:value] = 'init your field';
return context[:fields];
},
:change => {
'onFieldChanged' => -> (context) {
field = context[:fields].find{|field| field[:field] == 'a field'}
field[:value] = 'what you want';
return context[:fields];
}
}
}
[...]
```
The signature of `hooks` functions has changed. In order to support the hooks for **global** and **bulk** smart action, `record` is no longer sent to the hook. You must change the way you get the record information.
Before
```ruby theme={null}
[...]
:hooks => {
:load => -> (context) {
field = context[:fields]['a field'];
field[:value] = context[:record].a_props;
return context[:fields];
}
}
[...]
```
After
```ruby theme={null}
[...]
:hooks => {
:load => -> (context) {
id = ForestLiana::ResourcesGetter.get_ids_from_request(context[:params])[0];
# or
id = context[:params][:data][:attributes][:ids][0];
record = model.find(id)
field = context[:fields].find{|field| field[:field] == 'a field'}
field[:value] = record.a_props;
return context[:fields];
}
}
[...]
```
#### Scopes
Scopes have been revamped, from a convenient alternative to segments, to a security feature. They are now enforced by the agent (server-side).
This update comes with breaking changes in the prototype of helpers which are provided to access and modify data.
All occurrences of calls to `ResourcesGetter`, `ResourceGetter`, `ResourceUpdater` must be updated and now require the `forest_user` property to retrieve the relevant scope. The `forest_user` property is made accessible in your smart action controllers by inheriting from our controller: `ForestLiana::SmartActionsController`
Before
```ruby theme={null}
ForestLiana::ResourcesGetter.new(resource, params).perform
ForestLiana::ResourcesGetter.get_ids_from_request(params)
ForestLiana::ResourceGetter.new(resource, params).perform
ForestLiana::ResourceUpdater.new(resource, params).perform
```
After
```ruby theme={null}
ForestLiana::ResourcesGetter.new(resource, params, forest_user).perform
ForestLiana::ResourcesGetter.get_ids_from_request(params, forest_user)
ForestLiana::ResourceGetter.new(resource, params, forest_user).perform
ForestLiana::ResourceUpdater.new(resource, params, forest_user).perform
```
# Upgrade to v8
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/maintain/upgrade-notes-rails/upgrade-to-v8
The purpose of this note is to help developers to upgrade their agent from v7 to v8. Please read carefully and integrate the following breaking changes to ensure a smooth update.
Please be aware that while Forest make every effort to ensure that our platform updates are broadly compatible and offer detailed instructions for upgrading, Forest cannot guarantee that custom code developed by the developers will always be compatible with new versions of our software. This includes any custom modifications or extensions to core functionalities, such as method overrides or custom integrations. It is the responsibility of the developers to review and test their custom code to ensure compatibility with each new version. Our team provides comprehensive upgrade guides to assist in this process, but these cannot encompass the unique customizations that may be present in each customer's environment. Therefore, Forest strongly recommend establishing a thorough testing protocol for your specific customizations to safeguard against potential issues during the upgrade process.
Please follow the recommended procedure to upgrade your agent version by following [this note](/legacy/ruby-agent/how-tos/maintain/push-your-new-version-to-production).
This upgrade unlocks the following features:
* Use templating in the filters of Chart components
* Add conditions to your role permissions
## Upgrading to v8
As for any dependency upgrade, it's very important to **test this upgrade** **in your testing environments**. Not doing so could result in your admin panel being unusable.
To upgrade to v8, first update your project according to the [*Breaking Changes*](/legacy/ruby-agent/how-tos/maintain/upgrade-notes-rails/upgrade-to-v8#breaking-changes) section below.
If you're upgrading from an older version, please make sure you've also read the previous upgrade notes ([v7](/legacy/ruby-agent/how-tos/maintain/upgrade-notes-rails/upgrade-to-v7), [v6](/legacy/ruby-agent/how-tos/maintain/upgrade-notes-rails/upgrade-to-v6),..)
To upgrade to v8, **update the version in your Gemfile**, then run the following and update your project as shown in the *Breaking Changes* section below.
```javascript theme={null}
bundle install
```
In case of a regression introduced in Production after the upgrade, a rollback to your previous agent version 7 is the fastest way to restore your admin panel.
## Breaking changes
### Roles v2.0
This new version (v8) drops the support of the legacy Roles system (v1.0). If you are this legacy configuration, please follow [this procedure](/legacy/ruby-agent/how-tos/maintain/migrate-to-the-new-role-system) in order to migrate to the new Roles system (v2.0) **before** you attempt to upgrade to version 8.
**How do I know if I'm using the legacy or new Roles system?**
If you have access to Roles (Project settings > Roles) as designed below\...\
\
\
\
then you are using the new Role system.
### Approval Workflow
This new major version makes the configuration, described below, mandatory to ensure that actions are not triggered directly and approvals requests are properly created for the reviewers.
**Whether or not** your project currently uses the Approval Workflow feature,
you must ensure that all your Smart Actions controllers extend from the `ForestLiana::SmartActionsController` controller.
```ruby theme={null}
# NOW in v9, this configuration is mandatory to make approvals work as expected.
class Forest::CompaniesController < ForestLiana::SmartActionsController
def mark_as_live
# ...
end
end
```
### Routes override
If your project overrides routes, using the `ForestLiana::PermissionsChecker` to check the permissions, you must replace `PermissionsChecker.new(...)` by `ForestLiana::Ability::forest_authorize!(action, forest_user, @resource)`.
An example can be found [here](/legacy/ruby-agent/reference-guide/routes/override-a-route).
# Upgrade to v9
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/maintain/upgrade-notes-rails/upgrade-to-v9
The purpose of this note is to help developers to upgrade their agent from v8 to v9. Please read carefully and integrate the following breaking changes to ensure a smooth update.
Please be aware that while Forest make every effort to ensure that our platform updates are broadly compatible and offer detailed instructions for upgrading, Forest cannot guarantee that custom code developed by the developers will always be compatible with new versions of our software. This includes any custom modifications or extensions to core functionalities, such as method overrides or custom integrations. It is the responsibility of the developers to review and test their custom code to ensure compatibility with each new version. Our team provides comprehensive upgrade guides to assist in this process, but these cannot encompass the unique customizations that may be present in each customer's environment. Therefore, Forest strongly recommend establishing a thorough testing protocol for your specific customizations to safeguard against potential issues during the upgrade process.
This upgrade unlocks the following feature:
* Support polymorphic associations
## Upgrading to v9
As for any dependency upgrade, it's very important to **test this upgrade** **in your testing environments**. Not doing so could result in your admin panel being unusable.
To upgrade to v9, first update your project according to the [*Breaking Changes*](/legacy/ruby-agent/how-tos/maintain/upgrade-notes-rails/upgrade-to-v9#breaking-changes) section below.
If you're upgrading from an older version, please make sure you've also read the previous upgrade notes ([v8](/legacy/ruby-agent/how-tos/maintain/upgrade-notes-rails/upgrade-to-v8), [v7](/legacy/ruby-agent/how-tos/maintain/upgrade-notes-rails/upgrade-to-v7), [v6](/legacy/ruby-agent/how-tos/maintain/upgrade-notes-rails/upgrade-to-v6),..)
To upgrade to v9, **update the version in your Gemfile**, then run the following and update your project as shown in the *Breaking Changes* section below.
```bash theme={null}
bundle install
```
In case of a regression introduced in Production after the upgrade, a rollback to your previous agent version 8 is the fastest way to restore your admin panel.
## Breaking changes
This new version introduces support for polymorphic associations.
It's now easier to create or update polymorphic associations using the polymorphic record selection component.
You can now navigate between related records through the related link.
The `_type` and `_id` fields are no longer returned by the API. As a result, if you have set up a segment, scope, smart action or any others features that uses these fields, they will no longer work.
# Settings
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/settings/README
# Customize your /forest folder
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/settings/customize-your-forest-folder
By default, all your **Smart** features will be located in a `/forest` folder.
However you can change it using:
```
configDir: 'my/path'
```
in your Forest initialization middleware.
# Disable automatic Forest schema update
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/settings/disable-automatic-forest-admin-schema-update
On server start, Forest automatically loads a new Forest schema if changes are detected.
For better control, you can disable the automatic schema synchronization by adding the following environment variable: `FOREST_DISABLE_AUTO_SCHEMA_APPLY=true`(ex: for QA and testing purposes)
By doing so, you will need to manually synchronize your Forest schema [using our CLI.](/legacy/ruby-agent/how-tos/maintain/manage-your-forest-admin-programmatically)
The command line `forest schema:apply --secret YOUR_FOREST_ENV_SECRET`apply the current schema of your repository to the specified environment (using your `.forestadmin-schema.json` file).
# Display extensive logs
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/settings/display-extensive-logs
For debugging purposes your might want to display extensive logs from your Admin Backend API.\
\
To do so, simply add the following in your code:
# Include/exclude models
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/settings/include-exclude-models
By default, all models declared in your app are analyzed by the Forest agent in order to display them as collections in your admin panel.
You can exclude some of them from the analysis to never send their metadata to Forest. By doing this, these models will therefore never be available in your admin panel.
To do so, add the following code to **either** define which models are included **or** excluded.
#### Include models
#### Exclude models
#### Include models
#### Exclude models
```ruby theme={null}
ForestLiana.env_secret = Rails.application.secrets.forest_env_secret
ForestLiana.auth_secret = Rails.application.secrets.forest_auth_secret
# ...
# in the [] you may add the precise list of all models you want to see in Forest
ForestLiana.included_models = ['Customer'];
# or second possibility below :
# in the [] you may add the precise list of all models you do not want to see in Forest
ForestLiana.excluded_models = ['Document', 'Transaction'];
```
# Setup
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/setup/README
# Configuring CORS headers
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/setup/configuring-cors-headers
Depending on how you've setup your app, you may encounter a [CORS](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing) error. It will look like this in your browser console:
In this case, you need to configure the right CORS headers to **allow the domain** `app.forestadmin.com` to trigger an API call on your Application URL, which is a different domain name (e.g. localhost:3000 on development).
### Rails
We use the [Rack CORS](https://github.com/cyu/rack-cors) Gem for this purpose.
```ruby theme={null}
# Gemfile
source 'https://rubygems.org'
# ...
gem 'forest_liana'
gem 'rack-cors'
```
```ruby theme={null}
module YourApp
class Application < Rails::Application
# ...
# For Rails 5, use the class Rack::Cors. For Rails 4, you MUST use the string 'Rack::Cors'.
null_regex = Regexp.new(/\Anull\z/)
config.middleware.insert_before 0, Rack::Cors do
allow do
hostnames = [null_regex, 'localhost:4200', 'app.forestadmin.com', 'localhost:3001']
hostnames += ENV['CORS_ORIGINS'].split(',') if ENV['CORS_ORIGINS']
origins hostnames
resource '*',
headers: :any,
methods: :any,
expose: ['Content-Disposition'],
credentials: true
end
end
end
end
```
# Connecting Forest to Your Database (Forest Cloud)
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/setup/connecting-forest-admin-to-your-database-forest-cloud
### Introduction
Before you can use Forest to manage your data, you need to connect it to your database. This guide will walk you through the necessary steps to establish a connection between Forest and your database by providing the correct credentials, configuring firewall rules, and using tunneling software when required.
### Provide database credentials
To connect Forest to your database, you must enter the following authentication credentials:
* Hostname
* Port
* Username
* Password
* Database name
Make sure to have this information at hand before proceeding.
### Set up tunneling for local databases
If your database is running locally (e.g., 127.0.0.1), you will need to use tunneling software to expose your local database to the internet. This will enable Forest to connect to it. Some popular tunneling software options include:
* Ngrok
* Bastion
* Localtunnel
Choose a tunneling software that suits your needs and follow its documentation to set up the connection.
# Deploy your admin backend on Heroku
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/setup/deploy-to-production-on-heroku
This tutorial is designed to assist people who want to have a step-by-step guide to deploy the Lumber-generated admin backend to Heroku.
If you don’t have a Heroku account yet, [sign up here](https://signup.heroku.com/). Then, create your first Heroku application **(1)** **(2)**.
After creating your application, simply follow the Heroku guide “Deploy using Heroku Git” to push the lumber-generated admin backend code to the Heroku application.
Push your code using the following command:
### Command line
```bash theme={null}
git push heroku master
```
### Output
```
Counting objects: 25, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (20/20), done.
Writing objects: 100% (25/25), 21.56 KiB | 5.39 MiB/s, done.
Total 25 (delta 9), reused 0 (delta 0)
remote: Compressing source files... done.
remote: Building source:
remote:
remote: -----> Node.js app detected
remote:
remote: -----> Creating runtime environment
remote:
remote: NPM_CONFIG_LOGLEVEL=error
remote: NODE_VERBOSE=false
remote: NODE_ENV=production
remote: NODE_MODULES_CACHE=true
remote:
remote: -----> Installing binaries
remote: engines.node (package.json): unspecified
remote: engines.npm (package.json): unspecified (use default)
remote:
remote: Resolving node version 8.x...
remote: Downloading and installing node 8.11.4...
remote: Using default npm version: 5.6.0
remote:
remote: -----> Restoring cache
remote: Skipping cache restore (not-found)
remote:
remote: -----> Building dependencies
remote: Installing node modules (package.json + package-lock)
remote: added 246 packages in 7.72s
remote:
remote: -----> Caching build
remote: Clearing previous node cache
remote: Saving 2 cacheDirectories (default):
remote: - node_modules
remote: - bower_components (nothing to cache)
remote:
remote: -----> Pruning devDependencies
remote: Skipping because npm 5.6.0 sometimes fails when running 'npm prune' due to a known issue
remote: https://github.com/npm/npm/issues/19356
remote:
remote: You can silence this warning by updating to at least npm 5.7.1 in your package.json
remote: https://devcenter.heroku.com/articles/nodejs-support#specifying-an-npm-version
remote:
remote: -----> Build succeeded!
remote: -----> Discovering process types
remote: Procfile declares types -> (none)
remote: Default types for buildpack -> web
remote:
remote: -----> Compressing...
remote: Done: 24.2M
remote: -----> Launching...
remote: Released v3
remote: https://lumber-deploy-to-production.herokuapp.com/ deployed to Heroku
remote:
remote: Verifying deploy... done.
To https://git.heroku.com/lumber-deploy-to-production.git
* [new branch] master -> master
```
Your admin backend is now deployed in a remote Heroku application. 🎉
The last step to have a complete running application is to deploy a database remotely.
For this, see the **How-tos → Databases → Populate a postgreSQL database on Heroku** entry in this legacy v1 documentation.
This does **not** mean your project is deployed to production on Forest. To deploy to production, check out [Environments](/product/process/advanced-concepts/developer-workflow/environments-and-branches) after you've completed the above steps.
# Deploy your admin backend to Ubuntu server
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/setup/deploy-to-production-to-ubuntu-server
The goal of this tutorial is to help people deploy their admin backend to Ubuntu server.
### Connect to your Ubuntu server using SSH
Before starting anything, you have to make sure you're able to connect to your server using SSH.
### Command line
```bash theme={null}
ssh -i ~/.ssh/aws.pem ubuntu@ec2-18-204-18-81.compute-1.amazonaws.com
```
### Output
```
Warning: Permanently added 'ec2-18-204-18-81.compute-1.amazonaws.com,18.204.18.81' (ECDSA) to the list of known hosts.
Welcome to Ubuntu 18.04.1 LTS (GNU/Linux 4.15.0-1021-aws x86_64)
...
ubuntu@ip-172-31-83-152:~$
```
### Copy the code of your admin backend to your remote server
There are many ways to copy the code of your admin backend to a remote server. For example, you can use `rsync` command, or use a versioning system like `git`.
We **strongly advise** to version the code of your admin backend using **git** and host it to a **private repository** on Github, Bitbucket, Gitlab or other providers.
#### rsync
> **rsync** is a utility for efficiently transferring and synchronizing files across computer systems, by checking the timestamp and size of files. It is *commonly* found on Unix-like systems and functions as both a file synchronization and file transfer program.
>
> Rsync is typically used for synchronizing files and directories between two different systems.\
> (source: [wikipedia](https://en.wikipedia.org/wiki/Rsync))
The syntax used is `rsync OPTIONS SOURCE TARGET`.
```bash theme={null}
rsync -avz -e "ssh -i ~/.ssh/aws.pem -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" --exclude=node_modules --exclude=.git --progress QuickStart ubuntu@ec2-18-204-18-81.compute-1.amazonaws.com:~/
```
In the example above, we use a SSH connection to transfer the file and we connect to the remote server using an identity\_file (a private key).
| Option | Description |
| ----------------- | -------------------------------------- |
| -a | archive mode; same as -rlptgoD (no -H) |
| -v | increase verbosity |
| -z | compress file data during the transfer |
| -e | specify the remote shell to use |
| --exclude=PATTERN | exclude files matching PATTERN |
| --progress | show progress during transfer |
Once done, you can find the code of your admin backend on the home directory of your remote server.
### Command line
```bash theme={null}
ssh -i ~/.ssh/aws.pem ubuntu@ec2-18-204-18-81.compute-1.amazonaws.com
ubuntu@ip-172-31-83-152:~$ cd Quickstart/
ubuntu@ip-172-31-83-152:~/QuickStart$ ls -l
```
### Output
```bash theme={null}
total 5116
-rw-r--r-- 1 ubuntu ubuntu 1386 Oct 22 08:11 app.js
-r-------- 1 ubuntu ubuntu 1692 Oct 23 12:51 aws.pem
drwxr-xr-x 2 ubuntu ubuntu 4096 Oct 12 10:19 bin
-rw-r--r-- 1 ubuntu ubuntu 5126311 Oct 12 11:20 database.dump
drwxr-xr-x 2 ubuntu ubuntu 4096 Oct 19 11:49 forest
drwxr-xr-x 2 ubuntu ubuntu 4096 Oct 19 12:22 models
-rw-r--r-- 1 ubuntu ubuntu 69568 Oct 22 07:30 package-lock.json
-rw-r--r-- 1 ubuntu ubuntu 717 Oct 22 07:30 package.json
drwxr-xr-x 2 ubuntu ubuntu 4096 Oct 12 10:19 public
drwxr-xr-x 2 ubuntu ubuntu 4096 Oct 22 07:48 routes
drwxr-xr-x 2 ubuntu ubuntu 4096 Oct 19 11:53 serializers
drwxr-xr-x 2 ubuntu ubuntu 4096 Oct 19 11:55 services
ubuntu@ip-172-31-83-152:~/QuickStart$
```
#### git
First, you need to initialize a git repository for the code of your admin backend. From the directory of your admin backend, simply run:
```bash theme={null}
git init
```
Then, you can add all the files and create your first commit.
```bash theme={null}
git add .
git commit -am "First commit"
```
Finally, you can add your git remote and push the code on your favorite platform. To do so, **first** create a new QuickStart repository on your github account. **Then** run the following command after changing `YourAccount` to your account name:
```bash theme={null}
git remote add origin git@github.com:YourAccount/QuickStart.git
git push -u origin master
```
Now, you can connect to your remote server using SSH and clone the repository using the HTTPS method.
### Command line
```bash theme={null}
ssh -i ~/.ssh/aws.pem ubuntu@ec2-18-204-18-81.compute-1.amazonaws.com
git clone https://github.com/YourAccount/QuickStart.git
```
### Output
```bash theme={null}
Cloning into 'QuickStart'...
remote: Enumerating objects: 34, done.
remote: Counting objects: 100% (34/34), done.
remote: Compressing objects: 100% (21/21), done.
remote: Total 34 (delta 7), reused 34 (delta 7), pack-reused 0
Unpacking objects: 100% (34/34), done.
```
That's it. Your admin backend's code is available on your remote server.
### Command line
```bash theme={null}
ubuntu@ip-172-31-83-152:~$ cd QuickStart/
ubuntu@ip-172-31-83-152:~/QuickStart$ ls -l
```
### Output
```
total 5112
-rw-rw-r-- 1 ubuntu ubuntu 1386 Oct 23 13:42 app.js
drwxrwxr-x 2 ubuntu ubuntu 4096 Oct 23 13:42 bin
-rw-rw-r-- 1 ubuntu ubuntu 5126311 Oct 23 13:42 database.dump
drwxrwxr-x 2 ubuntu ubuntu 4096 Oct 23 13:42 forest
drwxrwxr-x 2 ubuntu ubuntu 4096 Oct 23 13:42 models
-rw-rw-r-- 1 ubuntu ubuntu 69568 Oct 23 13:42 package-lock.json
-rw-rw-r-- 1 ubuntu ubuntu 717 Oct 23 13:42 package.json
drwxrwxr-x 2 ubuntu ubuntu 4096 Oct 23 13:42 public
drwxrwxr-x 2 ubuntu ubuntu 4096 Oct 23 13:42 routes
drwxrwxr-x 2 ubuntu ubuntu 4096 Oct 23 13:42 serializers
drwxrwxr-x 2 ubuntu ubuntu 4096 Oct 23 13:42 services
ubuntu@ip-172-31-83-152:~/QuickStart$
```
### Install dependencies
First, you have to make sure you have Node.js and NPM correctly installed on your server.
```bash theme={null}
sudo apt update
sudo apt install nodejs npm
```
Then, you will be able to install all the dependencies listed on the package.json file.
```bash theme={null}
npm install
```
### Create the database
#### PostgreSQL
This step is **optional** if you already have a running database.
First, you need to install PostgreSQL:
```bash theme={null}
sudo apt-get install postgresql postgresql-contrib
```
Then, you will be able to connect to the database server:
### Command line
```bash theme={null}
sudo -u postgres psql
```
### Output
```bash theme={null}
psql (10.5 (Ubuntu 10.5-0ubuntu0.18.04))
Type "help" for help.
postgres=
```
Now, we can export the database from your local environment (your computer) to import it to your Ubuntu server.
For security reason, we will not allow remote connections to this database. This is why transfer the database dump to the remote server using `rsync.`
From your computer:
```bash theme={null}
PGPASSWORD=secret pg_dump -h localhost -p 5416 -U forest forest_demo --no-owner --no-acl -f database.dump
rsync -avz -e "ssh -i ~/.ssh/aws.pem -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" --progress database.dump ubuntu@ec2-18-204-18-81.compute-1.amazonaws.com:~/
```
Then, we will create a new DB user and schema from the remote server:
```bash theme={null}
sudo -u postgres psql
postgres=# CREATE USER forest WITH ENCRYPTED PASSWORD 'secret';
postgres=# CREATE DATABASE forest_demo;
postgres=# GRANT ALL PRIVILEGES ON DATABASE forest_demo TO forest;
postgres=# \q
```
And finally import the dump:
```bash theme={null}
PGPASSWORD=secret psql -U forest -h 127.0.0.1 forest_demo < database.dump
```
That's it, your database is now fully imported.
### Command line
```
PGPASSWORD=secret psql -U forest -h 127.0.0.1 forest_demo
```
### Output
```bash theme={null}
psql (10.5 (Ubuntu 10.5-0ubuntu0.18.04))
Type "help" for help.
forest_demo=>
```
### Command line
```
forest_demo=> \d
```
### Output
```sql theme={null}
List of relations
Schema | Name | Type | Owner
--------+---------------------+----------+----------
public | Companies_id_seq | sequence | postgres
public | addresses | table | postgres
public | addresses_id_seq | sequence | postgres
public | appointments | table | postgres
public | appointments_id_seq | sequence | postgres
public | companies | table | postgres
public | customers | table | postgres
public | customers_id_seq | sequence | postgres
public | deliveries | table | postgres
public | deliveries_id_seq | sequence | postgres
public | documents | table | postgres
public | documents_id_seq | sequence | postgres
public | orders | table | postgres
public | orders_id_seq | sequence | postgres
public | products | table | postgres
public | products_id_seq | sequence | postgres
public | transactions | table | postgres
public | transactions_id_seq | sequence | postgres
(18 rows)
```
### Export the environment variables
You must export the environment variables `FOREST_ENV_SECRET` `FOREST_AUTH_SECRET` and `DATABASE_URL`. To do so, open and edit the file `/etc/environment`:
The `FOREST_ENV_SECRET` and `FOREST_AUTH_SECRET` environment variables will be given by Forest after creating a production environment from the interface. [See how to create a production environment](/product/process/advanced-concepts/developer-workflow/environments-and-branches).
```bash theme={null}
sudo vim /etc/environment
```
```bash theme={null}
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games"
FOREST_ENV_SECRET=2417520743be37a9c5af198c018e0ddee9b7c41de1ccb8e76c9d027faa74059e
FOREST_AUTH_SECRET=Piq7a9Kv5anLbK4gj81rirsLhfaJ0pdL
DATABASE_URL=postgres://forest:secret@127.0.0.1/forest_demo
```
Then, you can restart your server to take these new variables into account or simply type:
```bash theme={null}
for env in $( cat /etc/environment ); do export $(echo $env | sed -e 's/"//g'); done
```
### Run your admin backend
From your admin backend's directory, simply type:
### Command line
```bash theme={null}
npm start
```
### Output
```
> QuickStart@0.0.1 start /home/ubuntu/QuickStart
> node ./bin/www
🌳 Your back office API is listening on port 3000 🌳
🌳 Access the UI: http://app.forestadmin.com 🌳
```
Congrats, your admin backend is now running on production. But we strongly advise you to continue following the next steps. If you chose not to do it, you can go back to your Forest interface to create a production environment. [Check out here how to do it](https://docs.forestadmin.com/documentation/getting-started/setup-guide#step-3-deploy-to-production).
The admin backend is by default listening on port **3310**. Be sure you authorized the inbound traffic on this port or set up a web server (like NGINX) as a [Reverse Proxy Server](/legacy/ruby-agent/how-tos/setup/deploy-to-production-to-ubuntu-server#set-up-nginx-as-a-reverse-proxy-server) to use the port **80.**
### Manage Application with PM2
> PM2 is a Production Runtime and Process Manager for Node.js applications with a built-in Load Balancer. It allows you to keep applications alive forever, to reload them without downtime and facilitate common Devops tasks. source: [npmjs/pm2](https://www.npmjs.com/package/pm2)
#### Install PM2
```bash theme={null}
sudo npm install pm2 -g
```
#### Run your admin backend using PM2
```bash theme={null}
pm2 start bin/www
```
### (Optional) Set Up Nginx as a Reverse Proxy Server
Now that your admin backend is running and listening on localhost:3310, we will set up the Nginx web server as a reserve proxy to allow your admin panel's users access it.
```bash theme={null}
sudo apt install nginx
```
To do so, edit (with sudo access) the file located `/etc/nginx/sites-available/default` and replace the existing section `location /` by this one:
```
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
```
Then, restart nginx:
```bash theme={null}
sudo systemctl restart nginx
```
That's it, your admin backend is now listening on the port **80**. Make sure your firewall allows inbound traffic from this port.
We now require that you configure **HTTPS** (port 443) on your admin backend service for **security reasons.** [http://nginx.org/en/docs/http/configuring\_https\_servers.html](http://nginx.org/en/docs/http/configuring_https_servers.html)
Once you've completed the above steps, it does **not** mean your project is deployed to production on Forest. To deploy to production, check out [Environments](/product/process/advanced-concepts/developer-workflow/environments-and-branches).
# Deploy Your Admin Backend With Aws
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/setup/deploy-your-admin-backend-with-aws
This tutorial is designed to assist you with a step-by-step guide to deploy the admin backend to Amazon Web Services, using EC2, ELB, ACM and Route53.
First, please ensure you have an AWS account. You can sign up [here](https://aws.amazon.com/).
### 1. Launch an EC2 Instance:
* Navigate to the EC2 dashboard and click on `Launch Instance`.
* Choose an Amazon Machine Image (AMI) such as `Amazon Linux 2023 AMI`.
* Select `t2.micro` (part of the AWS Free Tier).
* Select `Proceed without a key pair`
* On the `Configure Security Group` step, create a new security group:
* allow `ssh traffic`.
* allow `HTTPS traffic`.
* allow `HTTP traffic`.
* Review and launch the instance.
### 2. Connect to the EC2 instance:
* Navigate to your EC2 instance and click on `Connect`.
* Leave the default parameters and click on `Connect` again.
* Your are now connected to your instance.
### 3. Set up your instance:
The command lines in this step demonstrate how to install a Node.js agent. If you are running Forest on another agent, please adapt the following to your specific stack.
* Update the instance:
```bash theme={null}
sudo yum update -y
```
* Install Git:
```bash theme={null}
sudo yum install git -y
```
* Clone your repo:
```bash theme={null}
git clone your-repo-link
```
* Install Node.js and npm:
```bash theme={null}
sudo yum install npm -y
```
* Navigate to your project directory and install the necessary packages:
```bash theme={null}
cd your-repo-directory
npm install
```
* Set up all the necessary environment variables provided by the Forest environment creation wizard.
* Add the `APPLICATION_PORT` environment variable to be able to contact the server from outside. In this example, we will choose `APPLICATION_PORT=3310`. If you choose another port, please adapt the next steps accordingly.
* Start the agent
```bash theme={null}
npm run start:watch
```
### 4. Adjust security group rules:
* Navigate to your EC2 instance's security group.
* Click on `Edit inbound rules`.
* Add a Custom TCP inbound rule to allow on port `3310`.
### 5. Create a target group:
* In the AWS Management Console, navigate to the EC2 service.
* Under "Target Groups", click `Create Target Groups`.
* Ensure target type is instance.
* Choose HTTP to `3310`.
* Ensure VPC is set to the same VPC as your EC2 instance.
* Setup the health checks as set to `/forest`.
* On the next step, select instance and click on `Include as pending below`.
* Finally create the target group.
### 6. Request a certificate using AWS Certificate Manager (ACM):
* Navigate to ACM and click on `Request a certificate`.
* Enter your domain name and validate the domain ownership using DNS validation.
* After viewing the new created certificate, click on `Create records in Route 53`.
* Wait for the certificate to be validated (this can take some time \< 1mn).
### 7. Set up an Application Load Balancer (ALB):
* In the AWS Management Console, navigate to the EC2 service.
* Under "Load Balancers", click `Create Load Balancer`.
* Choose `Application Load Balancer` and follow the setup.
* Ensure the ALB is set to the same VPC as your EC2 instance.
* Select all regions.
* Remove default security group and select the group associated to the newly created instance.
* Add an HTTPS listener and choose previously created target group and certificate.
* After creating the ALB copy the `DNS name`.
### 8. Add CNAME to Route53:
* Navigate to Route53 and choose your hosted zone (domain).
* Create a `CNAME` record with the domain name filled in the certificate and the `DNS name` of the ALB.
### 9. Finalize:
Check your domain. You should be able to access your Forest panel environment hosted on AWS. 🎉
This is a basic setup, and there are many optimizations and security enhancements (like using RDS, tightening security groups, etc.) that can be done for a production-ready deployment. Please refer to the [AWS documentation](https://docs.aws.amazon.com/index.html) to go deeper.
# Deploy your admin backend to Google Cloud Platform
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/setup/deploy-your-admin-backend-with-google-cloud-platform
This tutorial is designed to assist you with a step-by-step guide to deploy the Lumber-generated admin backend to Google Cloud Platform, using Google's App Engine.
If you don’t have a Google Cloud Platform account yet, [sign up here](https://cloud.google.com/free). Then, [create a billing account](https://cloud.google.com/billing/docs/how-to/manage-billing-account#create_a_new_billing_account) if you haven't already. You will need it to be able to use App Engine.
### **Install the Google Cloud SDK CLI**
You first need to install the [Cloud SDK CLI](https://cloud.google.com/sdk/docs/downloads-interactive) as you will need it to execute the commands listed below.
### Create a new project on your Google Cloud Platform
To create a new project, run the following command in your terminal:
```
gcloud projects create [YOUR_PROJECT_ID]
```
Replace `[YOUR_PROJECT_ID]` with a string of characters that uniquely identifies your project.
To check if your project has been successfully created, run
```
gcloud projects describe [YOUR_PROJECT_ID]
```
### Create an app within your Project using App Engine
The next step is to initialize App Engine for your newly created project. This will create an app attached to the project.
Choose carefully your application's region when prompted, you will not be able to change this setting later.
```
gcloud app create --project=[YOUR_PROJECT_ID]
```
Your App Engine application in your project has been created 🎊.
The last steps needed before you can deploy your Forest backend are to:
* [ensure the billing](https://cloud.google.com/apis/docs/getting-started#enabling_billing) account linked to your new project is the correct one
* [enable the Cloud Build API](https://cloud.google.com/apis/docs/getting-started#enabling_apis) on your project
GCP offers a free tier for the use of Google App Engine. However, it may not be sufficient for your usage in production. You can check the free plan limitations [here](https://cloud.google.com/free/). Note that you will get a USD 300 free credit when you register to App Engine.
### Deploy your application
Now back to your terminal and run the following command in the Forest backend's project directory.
```
touch app.yaml && echo 'runtime: nodejs12' > app.yaml
```
This will create an `app.yaml` config file in your admin backend directory. This file acts as a deployment descriptor for your service, it generally contains CPU, memory, network and disk resources, scaling, and other general settings including environment variables.
For a complete list of all the supported elements in this configuration file, please refer to Google Cloud Platform documentation's [`app.yaml`](https://cloud.google.com/appengine/docs/flexible/nodejs/reference/app-yaml)[ reference](https://cloud.google.com/appengine/docs/flexible/nodejs/reference/app-yaml). We chose to keep it very simple here.
Now, you are ready to deploy, please run:
```
gcloud app deploy
```
Congratulations, your admin backend has been deployed 🎊. You can run the following command to make sure it is up and running.
```
gcloud app browse
```
This does **not** mean your project is deployed to production on Forest. To deploy to production, check out [Environments](/product/process/advanced-concepts/developer-workflow/environments-and-branches) after you've completed the above steps.
### Adding environment variables
When required to add the environment variables to configure your production environment, you need to add them to the `app.yaml` file of your admin backend repository. The file should look like this:
```yaml theme={null}
runtime: nodejs12
env_variables:
FOREST_ENV_SECRET: '63f51525814bdfec9dd99690a656757e251770c34549c5f383d909f5cce41eb9'
FOREST_AUTH_SECRET: '93d33e1b2a9f9b03aeac687d5a811ac872bf145e9f2c4b28'
DATABASE_URL: 'postgres://user:password@remotehost:5432/db_name'
NODE_ENV: 'production'
```
Once the environment variables are added, you can deploy the code base again to sync your production app with your Forest Production environment.
```
gcloud app deploy
```
Having problems deploying? Check out [troubleshooting common problems](https://community.forestadmin.com/t/deploying-on-google-cloud-platform-forestadmin-schema-json-file-does-not-exist/4406) in our community.
# Forest IP white-listing (Forest Cloud)
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/setup/forest-admin-ip-white-listing-forest-cloud
Authorizing Forest IP Addresses for Enhanced Security
In this documentation article, we will guide you through the process of authorizing Forest IP addresses in your database to enhance security, when using our Forest Cloud solution.
This will ensure that only approved IP addresses can access your database, safeguarding your data and minimizing potential vulnerabilities.
#### Step 1: Forest IP Address to Whitelist
For the proper functioning of our services, it's essential to whitelist the following Forest IP address: **35.180.175.97**
#### Step 2: Access Your Database Configuration
Log in to your database management system and navigate to the configuration settings. The process may vary depending on your database provider, so refer to your provider's documentation if needed.
#### Step 3: Update IP Whitelist
Locate the IP whitelisting or firewall settings in your database configuration. Add the Forest IP addresses you obtained in Step 1 to the list of authorized IP addresses.
#### Step 4: Apply Changes and Test Connection
Save the changes to your database configuration and restart your database if necessary. To confirm that the IP addresses have been successfully authorized, try accessing your database using Forest. If you encounter any issues, double-check the authorized IP addresses in your database settings.
By authorizing Forest IP addresses in your database, you can significantly improve the security of your data and adhere to the best practices of your organization. For additional assistance or questions, please refer to our support resources or contact our team.
# Install
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/setup/install
## Requirements
* A local or remote working database (non empty)
or
* An existing app (Django, Rails or Express with Sequelize and Mongoose)
* NPM or Docker installed
* Browser Support: we highly recommend Google Chrome or Firefox
Once you start [creating a project](https://app.forestadmin.com/new-project), you will be able to choose a datasource, the source of the data your admin panel will use.
Forest can be implemented in two very different ways :
* Using an existing app: integrate Forest into your Ruby on Rails, Django, Node.js app with Express (and Sequelize ORM or Mongoose ORM).
* As a dedicated app: create a dedicated app directly linked to your PostgreSQL, MySQL / MariaDB, Microsoft SQL Server or MongoDB database.
At Forest, if you have the choice, we recommend integrating in an existing app as it is easier to maintain.
### Install Forest using an existing app
At the moment, we are supporting:
* Ruby on Rails app
* Django project
* Node.js app with Express and Sequelize ORM
* Node.js app with Express and Mongoose ORM
#### Install Forest using an existing Ruby on Rails app
Requirements: Your Rails app must be version 4 or above.
You are asked to provide the URL of your application that runs locally. When you follow the steps and integrate the gems, you should automatically be redirected to your admin panel!
#### Install Forest using an existing Django app
Requirements:
* Python version should be between 3.6 and 3.10.
* Django version must be 3.2 or higher.
You are asked to provide the URL of your project that runs locally. When you follow the steps, add our app to your installed apps, and set up your agent, you should automatically be redirected to your admin panel!
#### Install Forest using an existing Node.js app with Express
Requirements:
* Using Sequelize or Mongoose ORM
* Sequelize version must be 5.21 or higher
* Mongoose version must be 5 or higher
* Express version must be 4.17.3 or higher
You are asked to provide the URL of your application that runs locally. When you follow the steps, you should automatically be redirected to your admin panel!
### Troubleshooting
In case of an error, you can consult the [troubleshooting page](/legacy/ruby-agent/how-tos/setup/troubleshooting) or ask in the Community forum.
### Install using a database as your datasource
At the moment, we are supporting:
* PostgreSQL
* MySQL / MariaDB
* Microsoft SQL Server
* MongoDB
When choosing one of these databases, you will be prompted to enter your database credentials. Your database credentials never leave the browser, they are only used to generate the environment variables in the setup instructions for the next step.
It is possible to use a local or remote database, but note that this database will be used with your Development environment.
It is possible to skip the authentication in the browser and use directly the CLI to authenticate.
Then, you will be able to create and connect your admin backend, with the following options.
### NPM / Yarn
| Option | Description |
| ------------------------ | ------------------------------------------------ |
| `-c, --connection-url` | The database credentials with a connection URL. |
| `-S, --ssl` | Use SSL for database connection (true \| false). |
| `-s, --schema` | Your database schema. |
| `-H, --application-host` | Hostname of your admin backend application. |
| `-p, --application-port` | Port of your admin backend application. |
| `-h, --help` | Output usage information. |
### Docker
| Option | Description |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `APPLICATION_HOST` | Hostname of your admin backend application. |
| `APPLICATION_PORT` | Port of your admin backend application. |
| `DATABASE_SSL` | Use SSL for database connection (true \| false). |
| `DATABASE_SCHEMA` | Your database schema. |
| `DATABASE_URL` | The database credentials with a connection URL. |
| `FOREST_EMAIL` | Your Forest account email. |
| `FOREST_TOKEN` | Your Forest account token. |
| `FOREST_PASSWORD` | Your Forest account password. Although not recommended, you can use this instead of `FOREST_TOKEN`. Wrap it in double quotes if it contains special characters. |
### Help us get better!
Finally, when your local server is started, you should be automatically redirected to a satisfaction form. Rate us so we can improve, then **go to your newly created admin panel** 🎉
If you installed using a local database, your generated admin backend will have[`http://localhost:3310`](http://localhost:3310/) as an endpoint (Notice the HTTP protocol).\
This explains why, if you try to visit \*\*https\://\*\*app.forestadmin.com, you will be *redirected* to \*\*http\://\*\*app.forestadmin.com as this is the only way it can communicate with your local admin backend.
# Install Forest on a remote machine
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/setup/install-forest-admin-on-a-remote-machine
In this short tutorial, we'll cover how to install Forest on a remote environment instead of locally.
This is **not** the recommended way of using Forest.
When you install Forest, on the last step you are asked to run some commands:
The recommended way of installing Forest is to run those commands **locally**, which will generate files in your current local directory.
**However**, you may require to install Forest **on a remote server**: in this case, you must:
1. Edit the second command (`lumber generate`):
* change `--application-host` to the **URL** of your remote server
2. Run those commands **on that remote server** instead of locally.
All remote environments must use **HTTPS** (port 443) for security reasons. Choosing to install this way will require that you set up SSL certificates on your server yourself.
Remember that the database credentials provided on the previous should reflect where the command will be run (i.e: the host and port might be different).
### Using Docker
When you install Forest, on the last step you are asked to run some commands:
The recommended way of installing Forest is to run those commands **locally**, which will generate files in your current local directory.
**However**, you may require to install Forest **on a remote server**: in this case, you must:
1. Edit the first command (`docker run`):
* change `APPLICATION_HOST` to the **URL** of your remote server
2. Run those commands **on that remote server** instead of locally.
All remote environments must use **HTTPS** (port 443) for security reasons. Choosing to install this way will require that you set up SSL certificates on your server yourself.
Remember that the database credentials provided on the previous should reflect where the command will be run (i.e: the host and port might be different).
# Prevent permission errors at installation
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/setup/prevent-permission-errors-at-installation
If you see an EACCES error when you try to install a lumber-cli globally, follow this tutorial.
Depending on how you've installed Node.js on your system, you could encounter a permissions error **EACCES** similar to the following output.
In this case, I got the error on a EC2 instance running on Ubuntu 10.04 with Node v8.10.0 and NPM v.3.5.2. But you can have this similar problem on another system and node version.
```bash theme={null}
npm ERR! Linux 4.15.0-1021-aws
npm ERR! argv "/usr/bin/node" "/usr/bin/npm" "install" "-g" "lumber-cli" "--save"
npm ERR! node v8.10.0
npm ERR! npm v3.5.2
npm ERR! path /usr/local/lib
npm ERR! code EACCES
npm ERR! errno -13
npm ERR! syscall access
npm ERR! Error: EACCES: permission denied, access '/usr/local/lib'
npm ERR! { Error: EACCES: permission denied, access '/usr/local/lib'
npm ERR! errno: -13,
npm ERR! code: 'EACCES',
npm ERR! syscall: 'access',
npm ERR! path: '/usr/local/lib' }
npm ERR!
npm ERR! Please try running this command again as root/Administrator.
npm ERR! Please include the following file with any support request:
npm ERR! /home/ubuntu/npm-debug.log
```
The problem is because NPM does not have the **write access** to the directory that will contain the package you want to install (here `lumber-cli`).
To solve this issue, we recommend to override the default directory where your global NPM packages will be stored.
```bash theme={null}
mkdir ~/.npm-global
```
Then, configure NPM to use this directory instead of the default one:
```bash theme={null}
npm config set prefix '~/.npm-global'
```
Then, make the node executables accessible from your *PATH.* To do so, export the environment variable PATH by opening or creating the file `~/.profile` and add this line at the end:
```bash theme={null}
export PATH=~/.npm-global/bin:$PATH
```
Finally, reload the `~/.profile` file:
```bash theme={null}
source ~/.profile
```
That's it, now you should be able to install lumber without any error 🎉
```bash theme={null}
npm install -g lumber-cli
```
# Running Forest on multiple servers
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/setup/running-forest-admin-on-multiple-servers
If you're running multiple instances of your agent (with a load balancer for example), you will need to set up a static client id.
**Without a static client id, authentication will fail whenever a user makes a request to a different instance than the one he logged into.**
First you will need to obtain a client id for your environment by running the following command:
```
curl -H "Content-Type: application/json" \
-H "Authorization: Bearer FOREST_ENV_SECRET" \
-X POST \
-d '{"token_endpoint_auth_method": "none", "redirect_uris": ["APPLICATION_URL/forest/authentication/callback"]}' \
https://api.forestadmin.com/oidc/reg
```
Then assign the `client_id` value from the response (it's a JWT) to a `FOREST_CLIENT_ID` variable in your **.env** file.
# Troubleshooting
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/setup/troubleshooting
#### ❓ Don't you see an answer to your problem? Describe it on our [Developer Community Forum](https://community.forestadmin.com/) and we will answer quickly.
## Error messages
### Installation
#### Docker
🙋♂️I can’t connect to Postgres DB inside another docker container. I'm trying to install Forest using docker but my database is running inside a different container and I'm using a custom port. I can access it without any problems via `psql` but then I get an error.
✅ Such an issue has been solved on our community forum. [Check it out.](https://community.forestadmin.com/t/cant-connect-to-postgres-db-inside-another-docker-container/725)
🙋🏾♂️ After installing Forest with Docker, I expect to see my visual data. Instead, I'm getting such error:
✅ Such an issue has been solved on our community forum. [Check it out](https://community.forestadmin.com/t/your-server-encountered-an-error-getaddrinfo-enotfound-postgres-postgres-5432/1798).
🙋🏻 When I want to pull data from my MongoDB database when installing Forest with Docker, I keep getting an error even when I changed to all access.
✅ Such an issue has been solved on our community forum. [Check it out](https://community.forestadmin.com/t/getting-error-mongoserverselectionerror-connection-monitor-to-54-71-237-255-27017-closed/3146).
🙋♂️ When I try to deploy lumber-admin via Docker with a remote database, I am getting an error `Error: Unprocessable Entity`
I suspect a problem on DB, but I cannot find any details or logs about this event. So, my main question is: where I can find any logs?
✅ Such an issue has been solved on our community forum. [Check it out](https://community.forestadmin.com/t/getting-error-mongoserverselectionerror-connection-monitor-to-54-71-237-255-27017-closed/3146).
🙋🏾 I was able to link my data to Forest admin (with docker, on port 5433). When I run [http://localhost:3310](http://localhost:3310) it says my app is running but when I want to log to Forest on [http://app.forestadmin.com/](http://app.forestadmin.com/) I first have to log in and it then says *Your server encountered an error*.
✅ Such an issue has been solved on our community forum. [Check it out](https://community.forestadmin.com/t/new-postgres-db-cant-reach-forest-admin-panel/1378).
#### npm
🙋🏼♀️ When installing via npm, everything worked well up to the “npm start” command when I received an error.
✅ A similar issue has been solved on our community forum. [Check it out.](https://community.forestadmin.com/t/npm-start-error/1520)
#### Ruby on Rails
🙋🏻♀️ I created a new project using Ruby on Rails as the datasource. I followed instructions, added gem, migrated, `dev:cache`, and started the server. Everything went smoothly, but once on the admin panel, I am getting the following message:
*Unable to authenticate you*
*Please verify that your admin backend is correctly configured and running.*
✅ Such an issue has been solved on our community forum. [Check it out](https://community.forestadmin.com/t/unable-to-authenticate-you-please-verify-that-your-admin-backend-is-correctly-configured-and-running/2017).
### Deployment
🙋🏽♀️ When I try to deploy Forest to Heroku, it tells me the app crashed after running either `npm start` or `docker compose` up in the project directory.
✅ Such an issue has been solved on our community forum. [Check it out](https://community.forestadmin.com/t/h10-error-when-deploying-to-heroku/547).
# Use Forest with a read-only database
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/setup/use-forest-admin-with-a-read-only-database
Although you'd be denying yourself some native features of Forest (CRUD), this may be mandatory for you because of your project's architecture or security requirements.
If you only want *some* fields to be read-only, check out [this section](https://docs.forestadmin.com/user-guide/collections/customize-your-fields#basic-settings).
To set up Forest with a read-only database, follow those steps:
### Step 1: set all your collections as read-only
A collection can be set as read-only from its settings, accessible using the Layout Editor mode:
You must **disable all permissions** there, as described in [this section](https://docs.forestadmin.com/user-guide/project-settings/teams-and-users/manage-roles#collection-permissions-1).
Repeat this for each of your collections.
### Step 2 (optional): interact with your data using Smart Actions
At this point, your Forest interface allows you only to browse your data and not interact with it.
You still have the opportunity to interact with your data according to your processes with a little coding:
# Why HTTPS is necessary even locally
Source: https://docs.forest.app/legacy/ruby-agent/how-tos/setup/why-https-is-necessary-even-locally
### Overview
When embedding Forest in your app, you'll be asked for the local application URL during the installation process. This URL must be in HTTPS, except for `localhost`.
This article explains why HTTPS is necessary and provides step-by-step guidance on how to set up a secure connection.
### Importance of HTTPS for Forest
Forest's architecture relies on secure communication between the front-end and the agent. Modern browsers enforce strict security measures to ensure data privacy and integrity. As a result, HTTPS is required when connecting to the agent.
As shown in the architecture schema, the front-end of Forest is in HTTPS. To make calls to the agent, modern browsers require the agent endpoint to be in HTTPS as well.
This ensures that data transmitted between the front-end and the agent is encrypted and secure.
### Setting Up a HTTPS Address: Step-by-Step Guide
If your app URL is in HTTP, you can use a tunneling software to access it through HTTPS. This enables Forest to establish a secure connection with your app. Follow these steps to set up a HTTPS address:
1. Choose a tunneling software: Some popular options include:
* [Ngrok](https://ngrok.com/)
* [Bastion](https://github.com/bastion-rs/bastion)
* [Localtunnel](https://localtunnel.github.io/www/)
1. Download and install the tunneling software according to its documentation.
2. Configure the tunneling software to point to your app's HTTP address. This usually involves specifying the local HTTP address and the desired HTTPS address or port number.
3. Start the tunneling software. This will create a secure connection between your app's HTTP address and the new HTTPS address.
4. Test the HTTPS address by accessing it through your browser or another tool. Ensure that the connection is secure and that your app functions correctly.
5. Provide the HTTPS address during the Forest installation process. Forest will now be able to securely connect with your app.
By following these steps and ensuring HTTPS is used for local connections, Forest maintains high security standards and offers a robust admin panel solution that protects both user data and application integrity.
# Create and manage Smart Actions
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/overview
### What is a Smart Action?
Sooner or later, you will need to perform actions on your data that are specific to your business. Moderating comments, generating an invoice, logging into a customer’s account or banning a user are exactly the kind of important tasks to unlock in order to manage your day-to-day operations.
On our Live Demo example, our `companies` collection has many examples of Smart Action. The simplest one is `Mark as live`.
If you're looking for information on native actions (CRUD), check out [this page](/legacy/ruby-agent/reference-guide/actions/overview).
### Creating a Smart action
In order to create a Smart action, you will first need to **declare it in your code** for a specific collection. Here we declare a *Mark as Live* Smart action for the `companies` collection.
```ruby theme={null}
class Forest::Company
include ForestLiana::Collection
collection :Company
action 'Mark as Live'
end
```
Ensure the file app/forest/\_\_init\_\_.py exists and contains the import of the previous defined class :
After declaring it, your Smart action will appear in the Smart actions tab within your collection settings.
A Smart action is displayed in the UI only if:
* it is set as "visible" in the collection settings\
AND
* in non-development environments, the user's role must grant the "trigger" permission
At this point, the Smart Action does *nothing*, because no route in your Admin backend handles the API call yet.
The **Smart Action behavior** is implemented separately from the declaration.
In the following example, we've implemented the *Mark as live* Smart Action, which simply changes a company's status to `live`.
The route declaration takes place in `config/routes.rb`.
```javascript theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
post '/actions/mark-as-live' => 'companies#mark_as_live'
end
mount ForestLiana::Engine => '/forest'
end
```
The business logic in this Smart Action is extremely simple. We only update here the attribute `status` of the companies to the value `live`:
```ruby theme={null}
class Forest::CompaniesController < ForestLiana::SmartActionsController
def mark_as_live
company_id = ForestLiana::ResourcesGetter.get_ids_from_request(params, forest_user).first
Company.update(company_id, status: 'live')
head :no_content
end
end
```
You must make sure that all your Smart Actions controllers extend from the `ForestLiana::SmartActionsController`. This is mandatory to ensure that all features built on top of Smart Actions work as expected (authentication, permissions, approval workflows,...)
You may have to [add CORS headers](/legacy/ruby-agent/how-tos/setup/configuring-cors-headers) to enable the domain `app.forestadmin.com` to trigger API call on your Application URL, which is on a different domain name (e.g. *localhost:3000*).
Make sure your **project** `urls.py` file include you app urls with the `forest` prefix.
```javascript theme={null}
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('forest', include('app.urls')),
path('forest', include('django_forest.urls')),
path('admin/', admin.site.urls),
]
```
The route declaration takes place in `app/urls.py`.
```javascript theme={null}
from django.urls import path
from django.views.decorators.csrf import csrf_exempt
from . import views
app_name = 'app'
urlpatterns = [
path('/actions/mark-as-live', csrf_exempt(views.MarkAsLiveView.as_view()), name='mark-as-live'),
]
```
The business logic in this Smart Action is extremely simple. We only update here the attribute `status` of the companies to the value `live`:
Note that Forest takes care of the authentication thanks to the `ActionView` parent class view.
You may have to [add CORS headers](/legacy/ruby-agent/how-tos/setup/configuring-cors-headers) to enable the domain `app.forestadmin.com` to trigger API call on your Application URL, which is on a different domain name (e.g. *localhost:8000*).
The route declaration takes place in `routes/web.php`.
The business logic in this Smart Action is extremely simple. We only update here the attribute `status` of the companies to the value `live`:
#### What's happening under the hood?
When you trigger the Smart Action from the UI, your browser will make an API call: `POST /forest/actions/mark-as-live`.
If you want to customize the API call, check the list of [available options](https://docs.forestadmin.com/documentation/reference-guide/actions/create-and-manage-smart-actions#available-smart-action-options).
The payload of the HTTP request is based on a [JSON API](http://jsonapi.org) document.\
The `data.attributes.ids` key allows you to retrieve easily the selected records from the UI.\
The `data.attributes.values` key contains all the values of your input fields ([handling input values](/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/use-a-smart-action-form#handling-input-values)).\
Other properties of `data.attributes` are used to manage the *select all* behavior.
```javascript theme={null}
{
"data": {
"attributes": {
"ids": ["1985"],
"values": {},
"collection_name": "companies",
...
},
"type": "custom-action-requests"
}
}
```
Should you want not to use the `RecordsGetter` and use request attributes directly instead, be very careful about edge cases (related data view, etc).
### Available Smart Action options
Here is the list of available options to customize your Smart Action:
### Rails
| Name | Type | Description |
| --------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| name | string | Label of the action displayed in Forest. |
| type | string | (optional) [Type](/legacy/ruby-agent/reference-guide/actions/overview#triggering-different-types-of-actions) of the action. Can be `bulk`, `global` or `single`. Default is `bulk`. |
| fields | array of objects | (optional) Check the [handling input values](/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/use-a-smart-action-form#handling-input-values) section. |
| download | boolean | (optional) If `true`, the action triggers a file download in the Browser. Default is `false` |
| endpoint | string | (optional) Set the API route to call when clicking on the Smart Action. Default is `'/forest/actions/name-of-the-action-dasherized'` |
| http\_method | string | (optional) Set the HTTP method to use when clicking on the Smart Action. Default is `POST`. |
| description | string | (optional) Add a description shown in the smart action form. This supports html tags. ⚠️ only available in `forest_liana` **9.4.0** |
| submit\_button\_label | string | (optional) Sets the text written on the submit button at the end of the form. Default value is the Smart Action name. ⚠️ only available in `forest_liana` **9.4.0** |
Want to go further with Smart Actions? Read the [next page](/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/use-a-smart-action-form) to discover how to make your Smart Actions even more powerful with **Forms**!
### Available Smart Action properties
#### req.user
The JWT Data Token contains all the details of the requesting user. On any authenticated request to your Admin Backend, you can access them with the variable `req.user`.
```javascript theme={null}
req.user content example
{
"id": "172",
"email": "angelicabengtsson@doha2019.com",
"firstName": "Angelica",
"lastName": "Bengtsson",
"team": "Pole Vault",
"role": "Manager",
"tags": [{ key: "country", value: "Canada" }],
"renderingId": "4998",
"iat": 1569913709,
"exp": 1571123309
}
```
#### req.body
You can find important information in the body of the request.
This is particularly useful to find the context in which an action was performed via a relationship.
```javascript theme={null}
{
data: {
attributes: {
collection_name: 'users', //collection on which the action has been triggered
values: {},
ids: [Array], //IDs of selected records
parent_collection_name: 'companies', //Parent collection name
parent_collection_id: '1', //Parent collection id
parent_association_name: 'users', //Name of the association
all_records: false,
all_records_subset_query: {},
all_records_ids_excluded: [],
smart_action_id: 'users-reset-password'
},
type: 'custom-action-requests'
}
}
```
### Customizing response
#### Default success notification
Returning a 204 status code to the HTTP request of the Smart Action shows the default notification message in the browser.
On our Live Demo example, if our Smart Action `Mark as Live` route is implemented like this:
```javascript theme={null}
...
router.post('/actions/mark-as-live', permissionMiddlewareCreator.smartAction(), (req, res) => {
// ...
res.status(204).send();
});
...
```
We will see a success message in the browser:
#### Custom success notification
If we return a 200 status code with an object `{ success: '...' }` as the payload like this…
```ruby theme={null}
class Forest::CompaniesController < ForestLiana::SmartActionsController
def mark_as_live
# ...
render json: { success: 'Company is now live!' }
end
end
```
… the success notification will look like this:
#### Custom error notification
Finally, returning a 400 status code allows you to return errors properly.
```ruby theme={null}
class Forest::CompaniesController < ForestLiana::SmartActionsController
def mark_as_live
# ...
render status: 400, json: { error: 'The company was already live!' }
end
end
```
#### Custom HTML response
You can also return a HTML page as a response to give more feedback to the admin user who has triggered your Smart Action. To do this, you just need to return a 200 status code with an object `{ html: '...' }`.
On our Live Demo example, we’ve created a `Charge credit card` Smart Action on the Collection `customers`that returns a custom HTML response.
```ruby theme={null}
class Forest::Customer
include ForestLiana::Collection
collection :Customer
action 'Charge credit card', type: 'single', fields: [{
field: 'amount',
is_required: true,
description: 'The amount (USD) to charge the credit card. Example: 42.50',
type: 'Number'
}, {
field: 'description',
is_required: true,
description: 'Explain the reason why you want to charge manually the customer here',
type: 'String'
}]
end
```
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
post '/actions/charge-credit-card' => 'customers#charge_credit_card'
end
mount ForestLiana::Engine => '/forest'
end
```
```ruby theme={null}
class Forest::CustomersController < ForestLiana::SmartActionsController
def charge_credit_card
customer_id = ForestLiana::ResourcesGetter.get_ids_from_request(params).first
amount = params.dig('data', 'attributes', 'values', 'amount').to_i
description = params.dig('data', 'attributes', 'values', 'description')
customer = Customer.find(customer_id)
response = Stripe::Charge.create(
amount: amount * 100,
currency: 'usd',
customer: customer.stripe_id,
description: description
)
render json: { html: <$#{response.amount / 100.0} USD has been successfully charged.
Credit card
EOF
}
end
end
```
You can either respond with an HTML page in case of error. The user will be able to go back to his smart action's form by using the cross icon at the top right of the panel.
```ruby theme={null}
class Forest::Customer
include ForestLiana::Collection
collection :Customer
action 'Charge credit card', type: 'single', fields: [{
field: 'amount',
is_required: true,
description: 'The amount (USD) to charge the credit card. Example: 42.50',
type: 'Number'
}, {
field: 'description',
is_required: true,
description: 'Explain the reason why you want to charge manually the customer here',
type: 'String'
}]
end
```
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
post '/actions/charge-credit-card' => 'customers#charge_credit_card'
end
mount ForestLiana::Engine => '/forest'
end
```
```ruby title="/app/controllers/forest/customers_controller.rb" theme={null}
class Forest::CustomersController < ForestLiana::SmartActionsController
def charge_credit_card
customer_id = ForestLiana::ResourcesGetter.get_ids_from_request(params, forest_user).first
amount = params.dig('data', 'attributes', 'values', 'amount').to_i
description = params.dig('data', 'attributes', 'values', 'description')
customer = Customer.find(customer_id)
response = Stripe::Charge.create(
amount: amount * 100,
currency: 'usd',
customer: customer.stripe_id,
description: description
)
render status: 400, json: {
html: <<EOF
<p class="c-clr-1-4 l-mt l-mb">\$#{record.amount / 100} USD has not been charged.</p>
<strong class="c-form__label--read c-clr-1-2">Credit card</strong>
<p class="c-clr-1-4 l-mb">**** **** **** #{record.source.last4}</p>
<strong class="c-form__label--read c-clr-1-2">Reason</strong>
<p class="c-clr-1-4 l-mb">You can not charge this credit card. The card is marked as blocked</p>
EOF
}
end
end
```
### Setting up a webhook
After a smart action you can set up a HTTP (or HTTPS) callback - a webhook - to forward information to other applications.\
\
To set up a webhook all you have to do is to add a `webhook`object in the response of your action.
```ruby theme={null}
render json: {
webhook: { # This is the object that will be used to fire http calls.
url: 'http://my-company-name', # The url of the company providing the service.
method: 'POST', # The method you would like to use (typically a POST).
headers: {}, # You can add some headers if needed (you can remove it).
body: { # A body to send to the url (only JSON supported).
adminToken: 'your-admin-token',
}
}
}
```
Webhooks are commonly used to perform smaller requests and tasks, like sending emails or [impersonating a user](https://docs.forestadmin.com/woodshop/how-tos/impersonate-a-user).
Another interesting use of this is automating SSO authentication into your external apps.
### Downloading a file
On our Live Demo, the collection `Customer` has a Smart Action `Generate invoice`. In this use case, we want to download the generated PDF invoice after clicking on the action. To indicate a Smart Action returns something to download, you have to enable the option `download`.
Don’t forget to expose the `Content-Disposition` header in the CORS configuration (as shown in the code below) to be able to customize the filename to download.
```ruby theme={null}
class Forest::Customer
include ForestLiana::Collection
collection :Customer
action 'Generate invoice', download: true
end
```
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
post '/actions/generate-invoice' => 'customers#generate_invoice'
end
mount ForestLiana::Engine => '/forest'
end
```
```ruby theme={null}
module LiveDemoRails
class Application < Rails::Application
config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'
resource '*', :headers => :any, :methods => [:get, :post, :options],
# you MUST expose the Content-Disposition header to customize the file to download.
expose: ['Content-Disposition']
end
end
end
end
```
```ruby theme={null}
class Forest::CustomersController < ForestLiana::SmartActionsController
def generate_invoice
data = open("#{File.dirname(__FILE__)}/../../../public/invoice-2342.pdf" )
send_data data.read, filename: 'invoice-2342.pdf', type: 'application/pdf', disposition: 'attachment'
end
end
```
On our Live Demo, the collection `Customer` has a Smart Action `Generate invoice`. In this use case, we want to download the generated PDF invoice after clicking on the action. To indicate a Smart Action returns something to download, you have to enable the option `download`.
Don’t forget to expose the `Content-Disposition` header in the CORS configuration (as shown in the code below) to be able to customize the filename to download.
On our Live Demo, the collection `Customer` has a Smart Action `Generate invoice`. In this use case, we want to download the generated PDF invoice after clicking on the action. To indicate a Smart Action returns something to download, you have to enable the option `download`.
Want to upload your files to Amazon S3? Check out this this [Woodshop tutorial](https://docs.forestadmin.com/woodshop/how-tos/upload-files-to-s3).
### Refreshing your related data
If you want to create an action accessible from the details or the summary view of a record involving related data, this section may interest you.
In the example below, the “Add new transaction” action is accessible from the summary view. This action creates a new transaction and automatically refreshes the “Emitted transactions” related data section to see the new transaction.
Below is the sample code. We use the `gem 'faker'` to easily generate fake data. Remember to add this gem to your `Gemfile` and install it (`bundle install`) if you wish to use it.
```ruby theme={null}
class Forest::Company
include ForestLiana::Collection
collection :Company
# ...
action 'Add new transaction', fields: [{
field: 'Beneficiary company',
description: 'Name of the company who will receive the transaction.',
reference: 'Company.id'
}, {
field: 'Amount',
type: 'Number'
}]
# ...
end
```
```ruby theme={null}
class Forest::CompaniesController < ForestLiana::SmartActionsController
# ...
def add_new_transaction
attrs = params.dig('data','attributes', 'values')
beneficiary_company_id = attrs['Beneficiary company']
emitter_company_id = ForestLiana::ResourcesGetter.get_ids_from_request(params, forest_user).first
amount = attrs['Amount']
Transaction.create!(
emitter_company_id: emitter_company_id,
beneficiary_company_id: beneficiary_company_id,
beneficiary_iban: Faker::Code.imei,
emitter_iban: Faker::Code.imei,
vat_amount: Faker::Number.number(4),
fee_amount: Faker::Number.number(4),
status: ['to_validate', 'validated', 'rejected'].sample,
note: Faker::Lorem.paragraph,
amount: amount,
emitter_bic: Faker::Code.nric,
beneficiary_bic: Faker::Code.nric
)
# the code below automatically refresh the related data
# 'emitted_transactions' on the Companies' Summary View
# after submitting the Smart action form.
render json: {
success: 'New transaction emitted',
refresh: { relationships: ['emitted_transactions'] },
}
end
end
```
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
# ...
post '/actions/add-new-transaction' => 'companies#add_new_transaction'
# ...
end
mount ForestLiana::Engine => '/forest'
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end
```
Below is the sample code. We use the python Faker package to easily generate fake data. Remember to add this package to your `requirements.txt` and install it if you wish to use it.
Below is the sample code. We use the Faker package to easily generate fake data. Remember to add this package to your `composer.json` and install it if you wish to use it.
### Redirecting to a different page on success
To streamline your operation workflow, it could make sense to redirect to another page after a Smart action was successfully executed.\
\
It is possible using the `redirectTo` property.\
\
The redirection works both for **internal** (`*.forestadmin.com` pages) and **external** links.
**External** links will open in a new tab.
Here's a working example for both cases:
```ruby theme={null}
class Forest::Company
include ForestLiana::Collection
collection :Company
action 'Return and track'
action 'Show some activity'
end
```
```ruby theme={null}
...
namespace :forest do
post '/actions/return-and-track' => 'company#redirect_externally'
post '/actions/show-some-activity' => 'company#redirect_internally'
end
...
```
```ruby theme={null}
...
def redirect_externally
# External redirection
render json: {
success: 'Return initiated successfully.',
redirectTo: 'https://www.royalmail.com/portal/rm/track?trackNumber=ZW924750388GB',
}
end
def redirect_internally
# Internal redirection
render json: {
success: 'Return initiated successfully.',
redirectTo: '/MyProject/MyEnvironment/MyTeam/data/20/index/record/20/108/activity',
}
end
...
```
Your **external** links must use the `http` or `https` protocol.
### Enable/Disable a Smart Action according to the state of a record
Sometimes, your Smart Action only makes sense depending on the state of your records. On our Live Demo, it does not make any sense to enable the `Mark as Live` Smart Action on the `companies` collection if the company is already live, right? This is configured from the collection's Smart Action settings.
### Restrict a smart action to specific roles
When using Forest collaboratively with clear roles defined it becomes relevant to restrict a smart action only to a select few. This functionality is accessible through Smart Actions Permissions in the Role section of your Project Settings.
### Require approval for a Smart action
Critical actions for your business may need approval before being processed. You can require approval per role from the *Roles* tab of your Project Settings; approval requests are then reviewed from the Collaboration menu.
Want to go further with Smart Actions? Read the next page to discover how to make your Smart Actions even more powerful with **Forms**!
# Use a Smart Action Form
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/use-a-smart-action-form
We've just introduced Smart actions: they're great because you can execute virtually any business logic. However, there is one big part missing: how do you let your users provide more information or have interaction when they trigger the Smart action? In short, you need to open a **Smart Action Form**.
## Opening a **Smart Action Form**
Very often, you will need to ask user inputs before triggering the logic behind a Smart Action.\
For example, you might want to specify a reason if you want to block a user account. Or set the amount to charge a user’s credit card.
On our Live Demo example, we’ve defined 4 input fields on the Smart Action `Upload Legal Docs` on the collection `Company`.
```ruby theme={null}
class Forest::Company
include ForestLiana::Collection
collection :Company
action 'Upload Legal Docs', type: 'single', fields: [{
field: 'Certificate of Incorporation',
description: 'The legal document relating to the formation of a company or corporation.',
type: 'File',
is_required: true
}, {
field: 'Proof of address',
description: '(Electricity, Gas, Water, Internet, Landline & Mobile Phone Invoice / Payment Schedule) no older than 3 months of the legal representative of your company',
type: 'File',
is_required: true
}, {
field: 'Company bank statement',
description: 'PDF including company name as well as IBAN',
type: 'File',
is_required: true
}, {
field: 'Valid proof of ID',
description: 'ID card or passport if the document has been issued in the EU, EFTA, or EEA / ID card or passport + resident permit or driving license if the document has been issued outside the EU, EFTA, or EEA of the legal representative of your company',
type: 'File',
is_required: true
}]
end
```
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
post '/actions/upload-legal-docs' => 'companies#upload_legal_docs'
end
mount ForestLiana::Engine => '/forest'
end
```
```ruby theme={null}
class Forest::CompaniesController < ForestLiana::SmartActionsController
def upload_legal_doc(company_id, doc, field)
id = SecureRandom.uuid
Forest::S3Helper.new.upload(doc, "livedemo/legal/#{id}")
company = Company.find(company_id)
company[field] = id
company.save
Document.create({
file_id: company[field],
is_verified: true
})
end
def upload_legal_docs
# Get the current company id
company_id = ForestLiana::ResourcesGetter.get_ids_from_request(params, forest_user).first
# Get the values of the input fields entered by the admin user.
attrs = params.dig('data', 'attributes', 'values')
certificate_of_incorporation = attrs['Certificate of Incorporation'];
proof_of_address = attrs['Proof of address'];
company_bank_statement = attrs['Company bank statement'];
passport_id = attrs['Valid proof of ID'];
# The business logic of the Smart Action. We use the function
# upload_legal_doc to upload them to our S3 repository. You can see the
# full implementation on our Forest Live Demo repository on Github.
upload_legal_doc(company_id, certificate_of_incorporation, 'certificate_of_incorporation_id')
upload_legal_doc(company_id, proof_of_address, 'proof_of_address_id')
upload_legal_doc(company_id, company_bank_statement, 'bank_statement_id')
upload_legal_doc(company_id, passport_id, 'passport_id')
# Once the upload is finished, send a success message to the admin user in the UI.
render json: { success: 'Legal documents are successfully uploaded.' }
end
end
```
On our Live Demo example, we’ve defined 4 input fields on the Smart Action `Upload Legal Docs` on the collection `Company`.
Ensure the file app/forest/\_\_init\_\_.py exists and contains the import of the previous defined class :
On our Live Demo example, we’ve defined 4 input fields on the Smart Action `Upload Legal Docs` on the collection `Company`.
The 2nd parameter of the `SmartAction` method is not required. If you don't fill it, the name of your smartAction will be the name of your method that wrap it.
### Handling input values
Here is the list of available options to customize your input form.
| Name | Type | Description |
| ----------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| field | string | Label of the input field. |
| type | string or array |
|
| reference | string | (optional) Specify that the input is a reference to another collection. You must specify the primary key (ex: `category.id`). |
| enums | array of strings | (optional) Required only for the `Enum` type. This is where you list all the possible values for your input field. |
| description | string | (optional) Add a description for your admin users to help them fill correctly your form |
| isRequired | boolean | (optional) If `true`, your input field will be set as required in the browser. Default is `false`. |
| hook | string | (optional) Specify the change hook. If specified the corresponding hook is called when the input change |
| widget | string | (optional) The following widgets are available to your smart action fields (`text area`, `date`, `boolean`, `file,` `dateonly`) |
The `widget` property is only partially supported.
If you want to use a custom widget via a Smart Action Hook, you'll need to use the syntax mentioned in the next section.
## Use components to better layout your form
This feature is only available from **version 9.4.0** (`forest-express-sequelize` and `forest-express-mongoose`) / **version 9.4.0** (`forest-rails`) .
you must define your layout in a `load` hook at minima, and repeat it in each `change` hook.
This feature is useful when dealing with long/complex forms, with many fields. It will let you organize them and add useful information to guide the end user.
The layout must contain the fields as they should be rendered on the form.
### List of supported layout components
### Ruby on Rails
```ruby theme={null}
# Page
{
type: 'Layout',
component: 'Page',
elements: [] # An array of fields or other layout elements (except other pages)
},
# Row
{
type: 'Layout',
component: 'Row',
fields: [] # An array of one or two fields
}
# Separator
{
type: 'Layout',
component: 'Separator',
}
# Html bloc
{
type: 'Layout',
component: 'HtmlBlock',
content: '...' # A text content, which supports html tags
}
```
### Example
Here's an example of an action form with many fields, that we want to improve with some layout components, to make it easier for the end user to fill in.
### Ruby on Rails
```ruby theme={null}
class Forest::Customers
include ForestLiana::Collection
collection :Customers
def self.apply_layout(fields)
find_field_by_name = proc { |field_name| fields.find { |field| field[:field] == field_name } }
[
{
type: 'Layout',
component: 'Page',
elements: [
{
type: 'Layout',
component: 'HtmlBlock',
content: '
Please fill in the customer details first, following this guide
'
},
{
type: 'Layout',
component: 'Row',
fields: [find_field_by_name.call('firstname'), find_field_by_name.call('lastname')]
},
{ type: 'Layout', component: 'Separator' },
find_field_by_name.call('username'),
find_field_by_name.call('email'),
]
},
{
type: 'Layout',
component: 'Page',
elements: [
{
type: 'Layout',
component: 'HtmlBlock',
content: 'You may now enter his address details'
},
{
type: 'Layout',
component: 'Row',
fields: [find_field_by_name.call('city'), find_field_by_name.call('zip code')]
},
find_field_by_name.call('country'),
]
}
]
end
action 'Send invoice',
type: 'single',
fields: [
{
field: 'firstname',
type: 'String',
is_required: true,
},
{
field: 'lastname',
type: 'String',
is_required: true,
},
{
field: 'username',
type: 'String',
},
{
field: 'email',
type: 'String',
is_required: true,
},
{
field: 'country',
type: 'Enum',
enums: [],
},
{
field: 'city',
type: 'String',
hook: 'on_city_change',
},
{
field: 'zip code',
type: 'String',
hook: 'on_zip_code_change',
},
],
hooks: {
load: proc { |context| apply_layout(context[:fields]) },
change: {
'on_city_change' => proc { |context| apply_layout(context[:fields]) },
'on_zip_code_change' => proc { |context| apply_layout(context[:fields]) },
}
}
end
```
The resulting action form will be:
## Making a form dynamic with hooks
Business logic often requires your forms to adapt to its context. Forest makes this possible through a powerful way to extend your form's logic.
To make Smart Action Forms dynamic, we've introduced the concept of **hooks:** hooks allow you to run some logic upon a specific event.
The `load` **hook** is called when the form loads, allowing you to change its properties upon load.
The `change` **hook** is called whenever you interact with a field of the form.
### Prefill a form with default values
Forest allows you to set default values of your form. In this example, we will prefill the form with data coming from the record itself **(1)**, with just a few extra lines of code.
```ruby theme={null}
class Forest::Customers
include ForestLiana::Collection
collection :Customers
action 'Charge credit card',
type: 'single',
fields: [{
field: 'amount',
isRequired: true,
description: 'The amount (USD) to charge the credit card. Example: 42.50',
type: 'Number'
}, {
field: 'description',
isRequired: true,
description: 'Explain the reason why you want to charge manually the customer here',
type: 'String'
}, {
# we added a field to show the full potential of prefilled values in this example
field: 'stripe_id',
isRequired: true,
type: 'String'
}],
:hooks => {
:load => -> (context) {
amount = context[:fields].find{|field| field[:field] == 'amount'}
stripeId = context[:fields].find{|field| field[:field] == 'stripe_id'}
amount[:value] = 4520;
id = context[:params][:data][:attributes][:ids][0];
customer = Customers.find(id);
stripeId[:value] = customer['stripe_id'];
return context[:fields];
}
}
...
end
```
### Making a field read-only
To make a field read only, you can use the `isReadOnly` property:
| Name | Type | Description |
| ------------ | ------- | ---------------------------------------------------------------------------------------------- |
| `isReadOnly` | boolean | (optional) If `true`, the Smart action field won’t be editable in the form. Default is `false` |
Combined with the **load** [hook](/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/overview#making-a-form-dynamic) feature, this can be used to make a field read-only dynamically:
```javascript theme={null}
actions 'Send invoice',
type: 'single',
fields: [
{
field: 'country',
type: 'Enum',
enums: []
},
{
field: 'city',
type: 'String',
hook: 'oncityChange'
},
{
field: 'zip code',
type: 'String',
hook: 'onZipCodeChange'
},
],
hooks: {
:load => -> (context){
country = context[:fields].find{|field| field[:field] == 'country'}
id = context[:params][:data][:attributes][:ids][0];
customer = Customers.find(id);
country[:enums] = getEnumsFromDatabaseForThisRecord(customer)
return context[:fields]
},
:change => {
'oncityChange'=> -> (context){
zipCode = context[:fields].find{|field| field[:field] == 'zip code'}
id = context[:params][:data][:attributes][:ids][0];
customer = Customers.find(id);
zipCode[:value] = getZipCodeFromCity(
context[:record],
context[:context][:changed_field][:value]
)
return context[:fields]
},
'onZipCodeChange'=> -> (context) {
city = context[:fields].find{|field| field[:field] == 'city'}
id = context[:params][:data][:attributes][:ids][0];
customer = Customers.find(id);
city[:value] = getCityFromZipCode(
context[:record],
context[:context][:changed_field][:value]
)
return context[:fields]
},
},
}
```
#### How does it work?
The `hooks` property receives a *context* object containing:
* the `fields` array in its current state (containing also the current values)
* the `request` object containing all the information related to the records selection. Explained [here](/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/overview#available-smart-action-properties).
* the `changedField` is the current field who trigger the hook (only for change hook)
`fields` **must** be returned. Note that `fields` is an array containing existing fields with properties described in [this section](/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/overview#handling-input-values).
If you want to use a widget inside of a hook, you'll need to use the following syntax on your field:
* For a `text area`, use `{ widgetEdit: 'text area editor', parameters: {} }`
* For a `boolean`, use `{ widgetEdit: 'boolean editor', parameters: {} }`
* For a `date` or a `dateonly`, use `{ widgetEdit: 'date editor', parameters: {} }`
* For a `file`, use `{ widgetEdit: 'file picker', parameters: {} }`
To dynamically change a property within a `load` or `change` [hook](/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/use-a-smart-action-form#making-a-form-dynamic-with-hooks), just set it! For instance, setting a new *description* for the field `city`:
```ruby theme={null}
:hooks => {
:change => {
'onFieldChanged' => -> (context) {
[...]
context[:fields].push({
field: 'another field',
type: 'Boolean',
});
return context[:fields];
}
}
}
```
We added the `changedField` attribute so that you can easily know what changed.
Note that you may add a `change` hook on a dynamically-added field. Simply use the following syntax:
```ruby theme={null}
:hooks => {
:change => {
'onFieldChanged' => -> (context) {
[...]
context[:fields].push({
field: 'another field',
type: 'Boolean',
hook: 'onAnotherFiledChanged',
});
return context[:fields];
},
'onAnotherFiledChanged' => -> (context) {
# Do what you want
return context[:fields];
}
}
}
```
### Get selected records with bulk action
When using hooks with a bulk Smart action, you'll probably need te get the values or ids of the selected records. See below how this can be achieved.
```ruby theme={null}
class Forest::Customers
include ForestLiana::Collection
collection :Customers
action 'Some action',
type: 'bulk',
fields: [
{
field: 'country',
type: 'String',
is_read_only: true
},
{
field: 'city',
type: 'String'
},
],
:hooks => {
:load => -> (context) {
country = context[:fields].find{|field| field[:field] == 'country'}
ids = ForestLiana::ResourcesGetter.get_ids_from_request(context[:params], context[:user]);
customers = Customers.find(ids);
country[:value] = '';
country[:is_read_only] = false;
# If customers have the same country, set field to this country and make it not editable
if customers_have_same_country(customers)
country[:value] = customers.country;
country[:is_read_only] = true;
end
return context[:fields];
},
},
end
```
# Smart Action Intents
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/use-action-intent
### What are smart action intents ?
Action intents allows you to redirect your operators from the outside worlds directly to a specific action, by using an url.
This means that your actions can now be accessed directly using a link (saving many clicks), link for which you can specify few parameters so can pre-compute your form with custom values for instance.
All of our action types are supported (Global, Bulk and Single)
### Building a smart action intent
Get to the index of the collection you want to share an action from, and retrieve its URl.
For instance, given a project `aProject`, an environment `anEnvironment`, a team `aTeam` and a collection `aCollection`, the url should look similar to this:
`https://app.forestadmin.com/aProject/anEnvironment/aTeam/data/aCollection/index`
Base on that url, you can configure the action intent with 3 parameters:
* `actionIntent` of type string, being the name of the action you want to redirect to.
* `actionIntentIds` of type array of string, being the IDs of the records you want to execute the action for.
* `actionIntentParams` of type JSON object, being the params you want to send along your action intent
Please do note that `actionIntentIds` and `actionIntentParams` should be a valid JSON structure
Here is an example of all of these parameters combined:
`https://app.forestadmin.com/aProject/anEnvironment/aTeam/data/aCollection/index?actionIntent=anActionName&actionIntentIds=[1,2]&actionIntentParams={"firstParam":"firstValue","secondParam":"secondValue"}`
### How to use actionIntentParams
`actionIntentParams` should be a valid JSON object
Your parameters provided to the action intent will be passed to your agent over change and load hooks, allowing you to compute any value for your fields based on the provided parameters. You can access those parameters like such:
### Rails
```ruby theme={null}
class Forest::ACollection
include ForestLiana::Collection
collection :ACollection
action 'an_action',
type: 'single',
fields: [{
field: 'a_field',
type: 'String',
hook: 'on_value_change',
}],
:hooks => {
:change => {
'on_value_change' => -> (context) {
action_intent_params = context[:params][:data][:attributes][:action_intent_params];
...
return context[:fields];
}
}
:load => -> (context, request) {
action_intent_params = context[:params][:data][:attributes][:action_intent_params];
...
return context[:fields];
}
}
...
end
```
### How to use actionIntentIds
`actionIntentIds` should be a valid JSON array, or a single id. It is also worth noting that for global action, any provided ids will be skipped. Also, action of type single should be having a single id provided, and bulk action should be passed having many provided
Ids configured in the action intent will be provided as usual within your context. Please refer to this [documentation](/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/overview#creating-a-smart-action) for more details.
# Actions
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/overview
Visualizing data is great, but at some point you're going to want to interact with it.
### What is an action?
An action is a button that triggers server-side logic through an API call. Without a single line of code, Forest natively supports all common actions required on an admin interface such as CRUD (Create, Read, Update, Delete), sort, search, data export, and more.
### Native actions vs Smart Actions
In Forest, all the available actions can fall into 2 categories.
#### Native actions
Those actions come out-of-the-box. We've covered them in details *from a route perspective* in [Routes](/legacy/ruby-agent/reference-guide/routes/overview). The most common ones are:
* **Create**: create a new record in a given collection
* **Duplicate**: create a new record from an existing one
* **Update**: edit a record's data
* **Delete**: remove a record
Some actions are only available when 1+ record(s) are selected. This depends on [their type](/legacy/ruby-agent/reference-guide/actions/overview#triggering-different-types-of-actions).
Native actions' **permissions** are set from the Roles section of the Project settings.
#### Smart Actions
Smart actions are your own business-related actions, built with your own code. You'll learn how to use them in the [following page](/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/overview#what-is-a-smart-action).
Smart actions can be triggered from the *Actions* button or directly from a Summary view.
### Triggering different types of actions
Triggering an action is very simple, but the behavior can differ according to the type of action.
There are 3 types of actions :
* **Bulk** actions: the action will be available when you click on one or several desired records
* **Single** actions: the action is only available for one selected record at a time
* **Global** actions: the action is always available and will be executed on all records
In the following pages, we'll cover everything you need to know about interacting with your data through actions.
# Smart Action Examples
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/smart-action-examples/README
# Add many existing records at the same time (hasMany-belongsTo relationship)
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/smart-action-examples/add-many-existing-records-at-the-same-time-hasmany-belongsto-relationship
This example shows how to associate multiple existing records at once to a record using a simple smart action.
### Requirements
* An admin backend running on `forest-express-sequelize`
* Relationship **One-To-Many** between two collections (in this example an organization **hasMany** companies \<-> a company **belongsTo** an organization)
## How it works
### Directory: **/forest**
Create a new smart action in the forest file of the collection with the **hasMany relationship** (organizations in this example).
This smart action will be usable on a single record (`type: 'single'`). We will create two fields in the smart action form, one will be used for the **search** on the referenced collection and the second will be used to see the **selection** made by the operator.
### **Directory: /routes**
When the user validates the action, this route is called. We will use the **selection** to retrieve all companies' ids and then updates all companies `organizationId` field to create the associations.\
\
*In addition, once the smart action has been successfully run, it refreshes the relationship to properly display newly added associations.*
# BelongsToMany edition through smart collection
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/smart-action-examples/belongstomany-edition-through-smart-collection
**Context:** *A customer success team has to onboard “experts”, and those “experts” can have multiple “skills”, modelled via a belongsToMany relationship between “experts” and “skills” tables through an “experts\_skills” table; the skills table has \~200 records and experts usually have between 5 to 30 of them.*
*Unfortunately this is quite painful to edit in forest admin right now since when you want to add a new item in a belongToMany relationship in forest admin you have to:*
* *click on “add an existing …”*
* *Remember and search for the item using a single search bar*
* *select the desired item*
### Intro
In the following we will see how the choice of `skills` to be added to an expert can be materialized through a searchable smart collection named `otherSkills` displayed as related data of an `expert`. An action applicable on the selected records of this collection will allow to associate new skills to an expert.
**Data models**
The data models we have been working with here (`experts` and `skills`) are the following:
### Step 2: declare a smart relationship between experts and otherSkills
In order to display records from the collection `otherSkills` as related data of an expert, we need to declare a smart relationship between these collections. This is done in the file `experts.js` of the `forest` folder.
### Step 3: implement the logic to retrieve records from the smart relationship
We want to display as related data the `skills` that are not already assigned to an `expert` so we can add them. Therefore when implementing the route called to retrieve records from the collection `otherSkills` through the smart relationship, we need to add this logic. This is done in the file `experts.js` of the `routes` folder.
\
### Step 4: create the smart action to add skills to an expert
Next step is to declare a smart action that will allow a user to select several records of the `otherSkills` smart collection and associate them to an `expert`. This action is declared in the file `other-skills.js` of the `forest` folder.
The logic to be triggered when a call is made to the route is implemented as follows in the `other-skills.js` file of the `routes` folder.
# Calculate the distance between two string addresses
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/smart-action-examples/calculate-the-distance-between-two-string-addresses
**Context**: As a user I want to be able to obtain the distance between two objects that have address information as a string.
**Example**: I have a collection `places` that has `lineAddress1`, `addressCity` and `country` fields.
In a smart action called `get distance to another place` called from a specific place, I want to be able to select another place, choosing the locomotion mode and get the distance between the two and duration of trip.
### Implementation
First you need to declare the action and the content of the form.
`forest/places.js`
Then you need to implement the logic of the action. Here we use the service `superagent` to handle api calls.
The process has two main steps:
* call to the places api to retrieve the place\_id identifier corresponding to the string address of the origin and destination (that is computed as a complete address based on the separate `addressLine1`, `addressCity` and `country` fields)
* call to the distance matrix api to retrieve the distance information based on the origin and destination's place\_ids
The result returned to the UI is formatted in html to enable a good display to the user.
`routes/places.js`
# Call a n8n webhook
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/smart-action-examples/call-a-webhook-with-record-ids
***
description: >-
This example shows how to call a third party webhook/automation tool like n8n, make or zapier…
***
# Call a n8n webhook
You need to declare the new action with its scope in the `users.js` model
Then implement the action as needed in the route route action:
# Create a record with a multiselect through a many-to-many relationship
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/smart-action-examples/create-a-record-with-a-multiselect-through-a-many-to-many-relationship
**Context:** In this case, a card has many expense categories through a many to many relationships, using a join table (card expense categories). We want to be able to create a card, selecting the categories, and creating the card expense categories at the same time.
**Implementation:**
We will use a [smart action](/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/overview) form with a hook to retrieve the categories as values for the multi select.
Then we implement the creation of cards and expenseCategories in the form.
`forest/cards.js`
`routes/cards.js`
### Rails version:
`lib/forest_liana/collections/card.rb`
```jsx theme={null}
class Forest::Card
include ForestLiana::Collection
collection :Card
action 'Create Card',
type: 'global',
fields: [{
field: "name",
type: "String",
isRequired: true,
},
{
field: "user",
type: "Number",
reference: "User.id",
isRequired: true,
},
{
field: "company",
type: "Number",
reference: "Company.id",
isRequired: true,
},
{
field: "vendor",
type: "Number",
reference: "Vendor.id",
isRequired: true,
},
{
field: "categories",
type: ['Enum'],
}
],
:hooks => {
:load => -> (context) {
categories = context[:fields].find{|field| field[:field] == 'categories'}
categories[:enums] = ExpenseCategory.all.pluck(:title)
return context[:fields]
}
}
end
```
`config/routes.rb`
```jsx theme={null}
Rails.application.routes.draw do
...
namespace :forest do
post '/actions/create-card' => 'cards#create_card'
end
mount ForestLiana::Engine => '/forest'
end
```
`controllers/forest/cards_controller.rb`
```jsx theme={null}
class Forest::CardsController < ForestLiana::SmartActionsController
def create_card
attrs = params.dig('data', 'attributes', 'values')
categories_attrs = attrs['categories'];
attrs = { name: attrs['name'], user_id: attrs['user'], company_id: attrs['company'], vendor_id: attrs['vendor'] };
card = Card.create(attrs)
categories_attrs.each do|category|
expense_category = ExpenseCategory.find_by(title: category)
card_expense_category = CardExpenseCategory.create(card_id: card.id, expense_category_id: expense_category.id)
end
render json: { success: 'Your card has been created.' }
end
end
```
# Custom dynamic dropdown in a form using smart collections
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/smart-action-examples/custom-dynamic-dropdown-in-a-form-using-smart-collections
**Context**: I want my users to be able to select an input within a list computed dynamically depending on the current record.
In this example I have a custom action called `report transaction` applicable to records from a `companies` model. I want to allow users to select some information coming from the `transaction` table from a dropdown. The information should be computed from transactions that belong to the current company.
This cannot be handled properly with the current features of custom action forms. However, you can add an input field that points to a virtual collection. As users can perform a dynamic search on this collection, you can catch the search input and use to build the virtual collection records returned.
In our example, the user needs to enter the id of the record on which the action is triggered to build the selection.
### Custom action definition
Within the custom action, we add a field referencing the custom collection `transactionsInfo`.
`forest/companies.js`
### Virtual collection definition
The custom collection `transactionsInfo` includes an `id` field and an `info` field which includes the information we want the users to be able to select and that will be used in the custom action logic.
`forest/transaction-info.js`
### Virtual collection implementation
`routes/transactions-info.js`
# Dropdown with list of values in smart action form
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/smart-action-examples/dropdown-with-list-of-values-in-smart-action-form
**Context**: Within a smart action form, I want to enable my users to choose the value of an input field within a set of predefined values.
Here I have a smart action called `change status` for the collection `companies`. I want users to be able to only select the new status from a list of possible options.
`forest/companies.js`
# Handle enums with alias labels in a smart action
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/smart-action-examples/handle-enums-with-alias-labels-in-a-smart-action
**Context**: As a user to choose the input for a smart action field from a list of labels and I want a label to be pre-selected depending on the record's information. The labels do not correspond to the value to be updated in the database.
**Example**: I have a collection `companies` that has a `status` field. The status value in the database can be `rejected` or `live`.
In a smart action called update company status I want users to be able to select an alias value (i.e. `'rejeté'` for `rejected` and `'validé'` for `live`).
### Implementation
In order not to duplicate the matching to be made between the different values from the UI to the database and the other way around, I create a `company-status-handler` file that will allow me to handle the conversion.
`services/companies-status-handler.js`
`routes/companies.js`
# Impersonate a user
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/smart-action-examples/impersonate-a-user
This example shows you how to create a Smart Action `"Impersonate"` to login as one of your customers.
It can be useful to help your customers debug an issue or to get a better understanding of what they see on their account (in your app).
## Requirements
* An admin backend running on forest-express-sequelize/forest-express-mongoose
## How it works
### Directory: /models
This directory contains the `users.js` file where the model is declared.
### **Directory: /routes**
This directory contains the `users.js` file where the implementation of the route is handled. The `POST /forest/actions/impersonate` API call is triggered when you click on the Smart Action in the Forest UI.
```javascript theme={null}
router.post('/actions/impersonate', (req, res) => {
let userId = req.body.data.attributes.ids[0];
response.send({
webhook: {
// This is the object that will be used to fire http calls.
url: 'https://my-app-url/login', // The url of the company providing the service.
method: 'POST', // The method you would like to use (typically a POST).
headers: {}, // You can add some headers if needed (you can remove it).
body: {
// A body to send to the url (only JSON supported).
adminToken: 'your-admin-token',
},
},
success: `Impersonating user ${userId}`, // The success message that will be toasted.
redirectTo: 'https://my-app-url/', // Force the redirection to your app if needed.
});
});
module.exports = router;
```
This is useful for authentication using cookies. By using this example, you're performing the login request directly from the browser. Thus, the cookies will be automatically sent from your own service to the browser (as you'd normally do with your own app).
# Refresh hasMany relationship in smart action
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/smart-action-examples/refresh-hasmany-relationship-in-smart-action
**Context**: In this example I have a model `tenants` that hasMany records from a model `ssoProviders`. I want to create a new ssoProvider from a smart action accessible at the level of a tenant and refresh the list of ssoProviders shown in the summary view.
## Models
in the file `routes/tenants.js`
# Retrieve smart field info in a smart action
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/smart-action-examples/retrieve-smart-field-info-in-a-smart-action
Example of retrieving a Smart field into a Smart action
# Smart action to create several records from the input of a single smart action form
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/smart-action-examples/smart-action-to-create-several-records-from-the-input-of-a-single-smart-action-form
**Description**: From a smart action form which asks input for 3 new products at a time (picture + description), catch the posted payload and create 3 products
```ruby theme={null}
require 'data_uri'
require 'base64'
class Forest::ProductsController < ForestLiana::ApplicationController
def split_product
attrs = params.dig('data', 'attributes', 'values')
created_items = 0
(1..3).each do |i|
new_product_picture = attrs["product_#{i}_picture"];
new_product_description = attrs["product_#{i}_description"];
if new_product_picture && new_product_description
# if you are storing your pictures in a cloud and your DB stores the pictures url -> include here a function to send the base64 image to your cloud and fetch back the corresponding url
Product.new({
label: product_description,
picture: product_picture,
})
created_items += 1 if Product.save
end
end
success_message = 'Successfully created ' + created_items.to_s + ' item(s)'
puts success_message
render json: { success: success_message }
end
def split_product_values
context = get_smart_action_context
picture_url = context[:picture]
render serializer: nil, json: { product_1_picture: picture_url, product_2_picture: picture_url, product_3_picture: picture_url}, status: :ok
end
end
```
# Smart segment to restrict access to an action on a record details view
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/smart-action-examples/smart-segment-to-restrict-access-to-an-action-on-a-record-details-view
**Context**: As a user, I want to enable or not a smart action for a record depending on the value of a smart field.
In this example, the user wants to enable the access to a smart action called `restricted action` for a collection `customers` solely for customers that have registered `orders`. In our data models a customer hasMany orders.
The behavior observed above corresponds to this implementation in the file `customers.js`
This works only at the level of a records details view as we are looking to catch the query made to ensure that the action should be visible. This query is structured this way and allows us to implement the logic above by retrieving the record id present in the filter:
```javascript theme={null}
{
segment: 'Customers with orders',
filters: '{"field":"id","operator":"equal","value":"67573"}',
timezone: 'Europe/Paris'
}
```
# Anonymize users in bulk
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/smart-action-examples/update-users-in-bulk
This example shows how to bulk update users
As usual, you must declare the action on your collection.
ou can then implement the post action as you need. Here the records are simply updated in bulk through the `sequelize` ORM.
# Upload files to amazon s3
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/smart-action-examples/upload-files-to-amazon-s3
In this example we want to upload files (legal docs) for the companies collection that will be stored in Amazon S3 through a smart action. To do so we need to perform the following steps:
### Declare the smart action
In the companies.js file of the Forest folder, add the following to enable the user to access the action in the UI (by declaring the name and type of the action) and open an input form when triggering the action (by declaring fields).
### Implement the logic of the smart action
To implement the logic that will be called upon when the action is triggered and the corresponding endpoint is called by the browser, the following has been added to the file companies.js in the routes folder.
```jsx theme={null}
const express = require('express');
const S3Helper = require('../services/s3-helper');
const router = express.Router();
function uploadLegalDoc(companyId, doc, field) {
const id = uuid();
return new S3Helper().upload(doc, `livedemo/legal/${id}`)
.then(() => models.companies.findById(companyId))
.then((company) => {
company[field] = id;
return company.save();
})
.then((company) => models.documents.create({
file_id: company[field],
is_verified: true,
}));
}
router.post('/actions/upload-legal-docs',
(req, res) => {
// Get the current company id
let companyId = req.body.data.attributes.ids[0];
// Get the values of the input fields entered by the admin user.
let attrs = req.body.data.attributes.values;
let certificate_of_incorporation = attrs['Certificate of Incorporation'];
let proof_of_address = attrs['Proof of address'];
let company_bank_statement = attrs['Company bank statement'];
let passport_id = attrs['Valid proof of id'];
// The business logic of the Smart Action. We use the function
// UploadLegalDoc to upload them to our S3 repository. You can see the full
// implementation on our Forest Live Demo repository on Github.
return P.all([
uploadLegalDoc(companyId, certificate_of_incorporation, 'certificate_of_incorporation_id'),
uploadLegalDoc(companyId, proof_of_address, 'proof_of_address_id'),
uploadLegalDoc(companyId, company_bank_statement,'bank_statement_id'),
uploadLegalDoc(companyId, passport_id, 'passport_id'),
])
.then(() => {
// Once the upload is finished, send a success message to the admin user in the UI.
res.send({ success: 'Legal documents are successfully uploaded.' });
});
});
...
module.exports = router;
```
The file required where the S3 helper is defined has been added to a services folder, as `services/s3-helper.js`.
```javascript theme={null}
const P = require('bluebird');
const parseDataUri = require('parse-data-uri');
const AWS = require('aws-sdk');
const filesize = require('filesize');
function S3Helper() {
function mapAttrs(file) {
return {
id: file.Key.replace('livedemo/legal/', ''),
url: `https://s3-eu-west-1.amazonaws.com/${process.env.S3_BUCKET}/${file.Key}`,
last_modified: file.LastModified,
size: filesize(file.Size),
};
}
this.upload = (rawData, filename) => {
return new P((resolve, reject) => {
// Create the S3 client.
let s3Bucket = new AWS.S3({ params: { Bucket: process.env.S3_BUCKET } });
let parsed = parseDataUri(rawData);
let base64Image = rawData.replace(
/^data:(image|application)\/\w+;base64,/,
''
);
let data = {
Key: filename,
Body: new Buffer(base64Image, 'base64'),
ContentEncoding: 'base64',
ContentDisposition: 'inline',
ContentType: parsed.mimeType,
ACL: 'public-read',
};
// Upload the image.
s3Bucket.upload(data, function (err, response) {
if (err) {
return reject(err);
}
return resolve(response);
return models.companies
.findById(companyId)
.then((company) => {
company.certificate_of_incorporation_id = certificateId;
return company.save();
})
.then(() => {
res.send({ success: 'Legal documents are successfully uploaded.' });
});
});
});
};
this.files = (prefix) => {
const s3 = new AWS.S3();
let files = [];
return new P((resolve, reject) => {
return s3
.listObjects({
Bucket: process.env.S3_BUCKET,
Prefix: prefix,
})
.on('success', function handlePage(r) {
files.push(...r.data.Contents);
if (r.hasNextPage()) {
r.nextPage().on('success', handlePage).send();
} else {
return resolve(files.map((f) => mapAttrs(f)));
}
})
.on('error', (err) => {
reject(err);
})
.send();
});
};
this.file = (key) => {
const s3 = new AWS.S3();
let files = [];
return new P((resolve, reject) => {
return s3
.listObjects({
Bucket: process.env.S3_BUCKET,
Prefix: key,
})
.on('success', (file) => {
return resolve(mapAttrs(file.data.Contents[0]));
})
.on('error', (err) => {
reject(err);
})
.send();
});
};
this.deleteFile = (key) => {
const s3 = new AWS.S3();
return new P((resolve, reject) => {
return s3
.deleteObjects({
Bucket: process.env.S3_BUCKET,
Delete: {
Objects: [{ Key: key }],
},
})
.on('success', () => resolve())
.on('error', (err) => reject(err))
.send();
});
};
this.updateFile = (key, newKey) => {
const s3 = new AWS.S3();
return new P((resolve, reject) => {
return s3
.copyObject({
Bucket: process.env.S3_BUCKET,
CopySource: process.env.S3_BUCKET + '/' + key,
Key: newKey,
MetadataDirective: 'REPLACE',
})
.on('success', (file) => {
return this.deleteFile(key).then(() => {
return resolve(this.file(newKey));
});
})
.on('error', (err) => reject(err))
.send();
});
};
}
module.exports = S3Helper;
```
# Upload several files with the File Picker
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/actions/smart-action-examples/upload-several-files-with-the-file-picker
**Smart action**
If you set an input field as an array of strings (\['String']), you can use the file picker to upload several files at once.
The following example shows you how to define an action allowing for the upload of several files.
In your forest/your-model.js file, add the following:
```jsx theme={null}
multipleDocumentPath: {
type: DataTypes.ARRAY(DataTypes.STRING),
},
```
```jsx theme={null}
multipleDocumentPath: [String];
```
💡 In order to be able to load several files that may be heavy, you will need to edit your app.js file as explained [here](https://community.forestadmin.com/t/maximum-file-size-in-a-smart-action-field-file/173/4?u=philippeg).
# Create a Smart Chart
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/charts/create-a-smart-chart
On the previous page, we learned how API-based charts allow you to fetch any dataset from a custom endpoint. But using the finite list of predefined charts (Single, Distribution, Time-based, etc.), you are still constrained by how that data is displayed. With **Smart Charts**, you can code exactly what data you want and how you want it displayed!
You need a **Starter plan** or above to create Smart charts
### Creating a Smart Chart
To create a chart and access the *Smart Chart Editor*, click on the **Edit Smart Chart** button:
Next, use the *Template*, *Component,* and *Style* tabs to create your customized chart. At any point, you can render your chart by clicking on the **Run code** button.
Don't forget to click on **Create Chart** (or **Save** if the chart is already created) once you're done!
If you are creating a **record-specific** smart chart (in the record Analytics tab), the **`record`** object is directly accessible (either through `this.args.record` in the component or `@record` in the template).
### Creating a Table Chart
Our first Smart Chart example will be a simple table: however you may choose to make it as complex and customized as you wish.
```markup theme={null}
\{\{user.username\}\}\{\{user.points\}\}
```
Using a trivial set of hardcoded data for example's sake:
```javascript theme={null}
import Component from '@glimmer/component';
import {
loadExternalStyle,
loadExternalJavascript,
} from 'client/utils/smart-view-utils';
export default class extends Component {
users = [
{
username: 'Darth Vador',
points: 1500000,
},
{
username: 'Luke Skywalker',
points: 2,
},
];
}
```
To query a custom route of your Forest server as your datasource, you may use this syntax instead:
```javascript theme={null}
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class extends Component {
@service lianaServerFetch;
@tracked users;
constructor(...args) {
super(...args);
this.fetchData();
}
async fetchData() {
const response = await this.lianaServerFetch.fetch(
'/forest/custom-data',
{}
);
this.users = await response.json();
}
}
```
### Creating a Bar Chart
This second example shows how you can achieve any format of charts, as you can benefit from external libraries like D3js.
```markup theme={null}
\{\{this.chart\}\}
```
```javascript theme={null}
import Component from '@glimmer/component';
import {
loadExternalStyle,
loadExternalJavascript,
} from 'client/utils/smart-view-utils';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
export default class extends Component {
constructor(...args) {
super(...args);
this.loadPlugin();
}
@tracked chart;
@tracked loaded = false;
async loadPlugin() {
await loadExternalJavascript('https://d3js.org/d3.v6.min.js');
this.loaded = true;
this.renderChart();
}
async fetchData() {
const response = await this.lianaServerFetch.fetch(
'/forest/custom-data',
{}
);
const data = await response.json();
return data;
}
@action
async renderChart() {
if (!this.loaded) {
return;
}
const color = 'steelblue';
// Don't comment the lines below if you want to fetch data from your Forest server
// const usersData = await this.fetchData()
// const data = Object.assign(usersData.sort((a, b) => d3.descending(a.points, b.points)), {format: "%", y: "↑ Frequency"})
// To remove if you're using data from your Forest server
const alphabet = await d3.csv(
'https://static.observableusercontent.com/files/09f63bb9ff086fef80717e2ea8c974f918a996d2bfa3d8773d3ae12753942c002d0dfab833d7bee1e0c9cd358cd3578c1cd0f9435595e76901508adc3964bbdc?response-content-disposition=attachment%3Bfilename*%3DUTF-8%27%27alphabet.csv',
function (d) {
return {
name: d.letter,
value: +d.frequency,
};
}
);
const data = Object.assign(
alphabet.sort((a, b) => d3.descending(a.value, b.value)),
{ format: '%', y: '↑ Frequency' }
);
const height = 500;
const width = 800;
const margin = { top: 30, right: 0, bottom: 30, left: 40 };
const x = d3
.scaleBand()
.domain(d3.range(data.length))
.range([margin.left, width - margin.right])
.padding(0.1);
const y = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.value)])
.nice()
.range([height - margin.bottom, margin.top]);
const xAxis = (g) =>
g.attr('transform', `translate(0,${height - margin.bottom})`).call(
d3
.axisBottom(x)
.tickFormat((i) => data[i].username)
.tickSizeOuter(0)
);
const yAxis = (g) =>
g
.attr('transform', `translate(${margin.left},0)`)
.call(d3.axisLeft(y).ticks(null, data.format))
.call((g) => g.select('.domain').remove())
.call((g) =>
g
.append('text')
.attr('x', -margin.left)
.attr('y', 10)
.attr('fill', 'currentColor')
.attr('text-anchor', 'start')
.text(data.y)
);
const svg = d3.create('svg').attr('viewBox', [0, 0, width, height]);
svg
.append('g')
.attr('fill', color)
.selectAll('rect')
.data(data)
.join('rect')
.attr('x', (d, i) => x(i))
.attr('y', (d) => y(d.value))
.attr('height', (d) => y(0) - y(d.value))
.attr('width', x.bandwidth());
svg.append('g').call(xAxis);
svg.append('g').call(yAxis);
this.chart = svg.node();
}
}
```
In the above snippet, notice how we import the **D3js** library. Of course, you can choose to use any other library of your choice.
This bar chart is inspired by [this one](https://observablehq.com/@d3/bar-chart).
The resulting chart can be resized to fit your use:
### Creating a density map
This last example shows how you can achieve virtually anything, since you are basically coding in a sandbox. There's no limit to what you can do with Smart charts.
```markup theme={null}
\{\{this.chart\}\}
```
```javascript theme={null}
import Component from '@glimmer/component';
import {
loadExternalStyle,
loadExternalJavascript,
} from 'client/utils/smart-view-utils';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
export default class extends Component {
constructor(...args) {
super(...args);
this.loadPlugin();
}
@tracked chart;
@tracked loaded = false;
async loadPlugin() {
await loadExternalJavascript('https://d3js.org/d3.v6.min.js');
await loadExternalJavascript('https://unpkg.com/topojson-client@3');
this.loaded = true;
this.renderChart();
}
@action
async renderChart() {
if (!this.loaded) {
return;
}
const height = 610;
const width = 975;
const format = d3.format(',.0f');
const path = d3.geoPath();
// This is the JSON for drawing the contours of the map
// Ref.: https://github.com/d3/d3-fetch/blob/v2.0.0/README.md#json
const us = await d3.json(
'https://static.observableusercontent.com/files/6b1776f5a0a0e76e6428805c0074a8f262e3f34b1b50944da27903e014b409958dc29b03a1c9cc331949d6a2a404c19dfd0d9d36d9c32274e6ffbc07c11350ee?response-content-disposition=attachment%3Bfilename*%3DUTF-8%27%27counties-albers-10m.json'
);
const features = new Map(
topojson.feature(us, us.objects.counties).features.map((d) => [d.id, d])
);
// Population should contain data about the density
const population = await d3.json(
'https://static.observableusercontent.com/files/beb56a2d9534662123fa352ffff2db8472e481776fcc1608ee4adbd532ea9ccf2f1decc004d57adc76735478ee68c0fd18931ba01fc859ee4901deb1bee2ed1b?response-content-disposition=attachment%3Bfilename*%3DUTF-8%27%27population.json'
);
const data = population.slice(1).map(([population, state, county]) => {
const id = state + county;
const feature = features.get(id);
return {
id,
position: feature && path.centroid(feature),
title: feature && feature.properties.name,
value: +population,
};
});
const radius = d3.scaleSqrt([0, d3.max(data, (d) => d.value)], [0, 40]);
const svg = d3.create('svg').attr('viewBox', [0, 0, width, height]);
svg
.append('path')
.datum(topojson.feature(us, us.objects.nation))
.attr('fill', '#ddd')
.attr('d', path);
svg
.append('path')
.datum(topojson.mesh(us, us.objects.states, (a, b) => a !== b))
.attr('fill', 'none')
.attr('stroke', 'white')
.attr('stroke-linejoin', 'round')
.attr('d', path);
const legend = svg
.append('g')
.attr('fill', '#777')
.attr('transform', 'translate(915,608)')
.attr('text-anchor', 'middle')
.style('font', '10px sans-serif')
.selectAll('g')
.data(radius.ticks(4).slice(1))
.join('g');
legend
.append('circle')
.attr('fill', 'none')
.attr('stroke', '#ccc')
.attr('cy', (d) => -radius(d))
.attr('r', radius);
legend
.append('text')
.attr('y', (d) => -2 * radius(d))
.attr('dy', '1.3em')
.text(radius.tickFormat(4, 's'));
svg
.append('g')
.attr('fill', 'brown')
.attr('fill-opacity', 0.5)
.attr('stroke', '#fff')
.attr('stroke-width', 0.5)
.selectAll('circle')
.data(
data
.filter((d) => d.position)
.sort((a, b) => d3.descending(a.value, b.value))
)
.join('circle')
.attr('transform', (d) => `translate(${d.position})`)
.attr('r', (d) => radius(d.value))
.append('title')
.text((d) => `${d.title} ${format(d.value)}`);
this.chart = svg.node();
}
}
```
In the above snippet, notice how we import the **D3js** library. Of course, you can choose to use any other library of your choice.
This density map chart is inspired from [this one](https://observablehq.com/@d3/bubble-map).
The resulting chart can be resized to fit your use:
### Creating a Cohort Chart
This is another example to help you build a Cohort Chart.
```markup theme={null}
```
```javascript theme={null}
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import {
loadExternalStyle,
loadExternalJavascript,
} from 'client/utils/smart-view-utils';
function isValidHex(color) {
return /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(color);
}
function shadeColor(color, percent) {
//#
color = isValidHex(color) ? color : '#3f83a3'; //handling null color;
percent = 1.0 - Math.ceil(percent / 10) / 10;
var f = parseInt(color.slice(1), 16),
t = percent < 0 ? 0 : 255,
p = percent < 0 ? percent * -1 : percent,
R = f >> 16,
G = (f >> 8) & 0x00ff,
B = f & 0x0000ff;
return (
'#' +
(
0x1000000 +
(Math.round((t - R) * p) + R) * 0x10000 +
(Math.round((t - G) * p) + G) * 0x100 +
(Math.round((t - B) * p) + B)
)
.toString(16)
.slice(1)
);
}
export default class extends Component {
@service lianaServerFetch;
@tracked loaging = true;
constructor(...args) {
super(...args);
this.loadPlugin();
}
async loadPlugin() {
await loadExternalJavascript('https://d3js.org/d3.v6.min.js');
this.loaging = false;
this.renderChart();
}
getRows(data) {
var rows = [];
var keys = Object.keys(data);
var days = [];
var percentDays = [];
for (var key in keys) {
if (data.hasOwnProperty(keys[key])) {
days = data[keys[key]];
percentDays.push(keys[key]);
for (var i = 0; i < days.length; i++) {
percentDays.push(
i > 0 ? Math.round((days[i] / days[0]) * 100 * 100) / 100 : days[i]
);
}
rows.push(percentDays);
percentDays = [];
}
}
return rows;
}
@action
async renderChart() {
// To fetch data from the backend
// const data = await this.lianaServerFetch.fetch('/forest/custom-route', {});
const options = {
data: {
// You can use any data format, just change the getRows logic
'May 3, 2021': [79, 18, 16, 12, 16, 11, 7, 5],
'May 10, 2021': [168, 35, 28, 30, 24, 12, 10],
'May 17, 2021': [188, 42, 32, 34, 25, 18],
'May 24, 2021': [191, 42, 32, 28, 12],
'May 31, 2021': [191, 45, 34, 30],
'June 7, 2021': [184, 42, 32],
'June 14, 2021': [182, 44],
},
title: 'Retention rates by weeks after sign-up',
};
var graphTitle = options.title || 'Retention Graph';
var data = options.data || null;
const container = d3.select('#demo').append('div').attr('class', 'box');
var header = container
.append('div')
.attr('class', 'box-header with-border');
var title = header.append('p').attr('class', 'box-title').text(graphTitle);
var body = container.append('div').attr('class', 'box-body');
var table = body
.append('table')
.attr('class', 'table table-bordered text-center');
var headData = ['Cohort', 'New users', '1', '2', '3', '4', '5', '6', '7'];
var tHead = table
.append('thead')
.append('tr')
.attr('class', 'retention-thead')
.selectAll('td')
.data(headData)
.enter()
.append('td')
.attr('class', function (d, i) {
if (i == 0) return 'retention-date';
else return 'days';
})
.text(function (d) {
return d;
});
var rowsData = this.getRows(data);
var tBody = table.append('tbody');
var rows = tBody.selectAll('tr').data(rowsData).enter().append('tr');
var cells = rows
.selectAll('td')
.data(function (row, i) {
return row;
})
.enter()
.append('td')
.attr('class', function (d, i) {
if (i == 0) return 'retention-date';
else return 'days';
})
.attr('style', function (d, i) {
if (i > 1) return 'background-color :' + shadeColor('#00c4b4', d);
})
.append('div')
.attr('data-toggle', 'tooltip')
.text(function (d, i) {
return d + (i > 1 ? '%' : '');
});
}
}
```
In the above snippet, notice how we import the **D3js** library. Of course, you can choose to use any other library of your choice.
```css theme={null}
.c-smart-chart {
display: flex;
white-space: normal;
bottom: 0;
left: 0;
right: 0;
top: 0;
background-color: var(--color-beta-surface);
}
.box {
position: relative;
border-radius: 3px;
background: #ffffff;
width: 100%;
}
.box-body {
max-height: 500px;
overflow: auto;
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px;
}
.box-header {
color: #444;
display: block;
padding: 10px;
position: relative;
}
.box-header .box-title {
display: inline-block;
font-size: 18px;
margin: 0;
line-height: 1;
}
.box-title {
display: inline-block;
font-size: 18px;
margin: 0;
line-height: 1;
}
.retention-thead,
.retention-date {
background-color: #cfcfcf;
font-weight: 700;
padding: 8px;
}
.days {
cursor: pointer;
padding: 8px;
text-align: center;
}
```
The resulting chart can be resized to fit your use:
# Create an API-based Chart
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/charts/create-an-api-based-chart
### Creating an API-based Chart
Sometimes, charts data are complicated and closely tied to your business. Forest allows you to code how the chart is computed. Choose **API** as the data source when configuring your chart.
Forest will make the HTTP call to Smart Chart URL when retrieving the chart values for the rendering.
### Value API-based Chart
On our Live Demo, we have a `MRR` value chart which computes our Monthly Recurring Revenue. This chart queries the Stripe API to get all charges made in the current month (in March for this example).
When serializing the data, we use the `serialize_model()` method. Check the `value` syntax below.
```
{ value: }
```
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
post '/stats/mrr' => 'charts#mrr'
end
mount ForestLiana::Engine => '/forest'
end
```
```ruby theme={null}
class Forest::ChartsController < ForestLiana::ApplicationController
def mrr
mrr = 0
from = Date.parse('2018-03-01').to_time(:utc).to_i
to = Date.parse('2018-03-31').to_time(:utc).to_i
Stripe::Charge.list({
created: { gte: from, lte: to },
limit: 100
}).each do |charge|
mrr += charge.amount / 100
end
stat = ForestLiana::Model::Stat.new({ value: mrr })
render json: serialize_model(stat)
end
end
```
### Repartition API-based Chart
On our Live Demo, we have a `Charges` repartition chart which shows a repartition chart distributed by credit card country. This chart queries the Stripe API to get all charges made in the current month (in March for this example) and check the credit card country.
When serializing the data, we use the `serialize_model()` method. Check the `value` syntax below.
```
{
value: [{
key: ,
value:
}, {
key: ,
value:
}, …]
}
```
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
post '/stats/credit-card-country-repartition' => 'charts#credit_card_country_repartition'
end
mount ForestLiana::Engine => '/forest'
end
```
```ruby theme={null}
class Forest::ChartsController < ForestLiana::ApplicationController
def credit_card_country_repartition
repartition = []
from = Date.parse('2018-03-01').to_time(:utc).to_i
to = Date.parse('2018-03-20').to_time(:utc).to_i
Stripe::Charge.list({
created: { gte: from, lte: to },
limit: 100
}).each do |charge|
country = charge.source.country || 'Others'
entry = repartition.find { |e| e[:key] == country }
if !entry
repartition << { key: country, value: 1 }
else
++entry[:value]
end
end
stat = ForestLiana::Model::Stat.new({ value: repartition })
render json: serialize_model(stat)
end
end
```
```
{
value: [{
key: ,
value:
}, {
key: ,
value:
}, …]
}
```
### Time-based API-based Chart
On our Live Demo, we have a `Charges` time-based chart which shows the number of charges per day. This chart queries the Stripe API to get all charges made in the current month (in March for this example) and group data by day.
When serializing the data, we use the `serialize_model()` method. Check the `value` syntax below.
```
{
value: [{
label: ,
values: { value: }
}, {
label: ,
values: { value: }
}, …]
}
```
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
post '/stats/charges-per-day' => 'charts#charges_per_day'
end
mount ForestLiana::Engine => '/forest'
end
```
```ruby theme={null}
class Forest::ChartsController < ForestLiana::ApplicationController
def charges_per_day
values = []
from = Date.parse('2018-03-01').to_time(:utc).to_i
to = Date.parse('2018-03-31').to_time(:utc).to_i
Stripe::Charge.list({
created: { gte: from, lte: to },
limit: 100
}).each do |charge|
date = Time.at(charge.created).beginning_of_day.strftime("%d/%m/%Y")
entry = values.find { |e| e[:label] == date }
if !entry
values << { label: date, values: { value: 1 } }
else
++entry[:values][:value]
end
end
stat = ForestLiana::Model::Stat.new({ value: values })
render json: serialize_model(stat)
end
end
```
```
{
value: [{
label: ,
values: { value: }
}, {
label: ,
values: { value: }
}, …]
}
```
### Objective API-based Chart
Creating an Objective Smart Chart means you'll be fetching your data from an external API endpoint:
This endpoint must return data with the following format:
```
{
value: {
value: xxxx,
objective: yyyy
}
}
```
Here's how you could implement it:
```ruby theme={null}
...
namespace :forest do
post '/stats/some-objective' => 'customers#some_objective'
end
...
```
```ruby theme={null}
...
def some_objective
# fetch your data here
stat = ForestLiana::Model::Stat.new({
value: {
value: 10, # the fetched value
objective: 678 # the fetched objective
}
})
render json: serialize_model(stat)
end
...
```
```
{
value: {
value: xxxx,
objective: yyyy
}
}
```
# Create Charts with AWS Redshift
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/charts/create-charts-with-aws-redshift
This example shows you how to create a graph based on AWS Redshift.
This could be useful if you want to avoid making graphs directly from your production database.
This tutorial is based on [this database sample](https://docs.aws.amazon.com/redshift/latest/gsg/rs-gsg-create-sample-db.html).
We'll create 2 charts:
1. Number of users (*single value chart*)
2. Top 5 buyers (*leaderboard chart*)
## Connect to a Redshift Database
Install the [NodeJS package](https://www.npmjs.com/package/node-redshift) for your Forest project
```bash theme={null}
node install node-redshift --save
```
Create the database client and set up the credentials variables cf. package documentation: [https://www.npmjs.com/package/node-redshift](https://www.npmjs.com/package/node-redshift).
```javascript theme={null}
var Redshift = require('node-redshift');
var clientCredentials = {
host: process.env.REDSHIFT_HOST,
port: process.env.REDSHIFT_PORT,
database: process.env.REDSHIFT_DATABASE,
user: process.env.REDSHIFT_DB_USER,
password: process.env.REDSHIFT_DB_PASSWORD,
};
const redshiftClient = new Redshift(clientCredentials);
```
Configure your database credentials in your env variables
## Create the Single Value Chart
Step 1 - Create a Single Value Smart Chart in the Forest Project Dashboard.
[Learn more about Smart Chart](/legacy/ruby-agent/reference-guide/charts/create-a-smart-chart)
Step 2 - Create the route to handle the Smart Chart
```javascript theme={null}
const express = require('express');
const router = express.Router();
const Liana = require('forest-express');
...
router.post('/stats/nb-users', Liana.ensureAuthenticated, async (request, response) => {
const query = `
SELECT count(*) as nb
FROM users
`;
const data = await redshiftClient.query(query);
let json = new Liana.StatSerializer({
value: data.rows[0].nb
}).perform();
response.send(json);
});
```
## Create the Leaderboard Chart
Step 1 - Create a Leaderboard Smart Chart in the Forest Project Dashboard.
Learn more about [Smart charts](/legacy/ruby-agent/reference-guide/charts/create-a-smart-chart)
Step 2 - Create the route to handle the Smart Chart
```javascript theme={null}
const express = require('express');
const router = express.Router();
const Liana = require('forest-express');
...
router.post('/stats/top-5-buyers', Liana.ensureAuthenticated, async (request, response) => {
const query = `
SELECT firstname || ' ' || lastname AS key, total_quantity AS value
FROM (SELECT buyerid, sum(qtysold) total_quantity
FROM sales
GROUP BY buyerid
ORDER BY total_quantity desc limit 5) Q, users
WHERE Q.buyerid = userid
ORDER BY Q.total_quantity desc
`;
const data = await redshiftClient.query(query);
let leaderboard = data.rows;
let json = new Liana.StatSerializer({
value: leaderboard
}).perform();
response.send(json);
});
```
## Result
# Charts
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/charts/overview
As an admin user, KPIs are paramount to follow day by day. Your customers’ growth, Monthly Recurring Revenue (MRR), Paid VS Free accounts are some common examples.
### What types of charts exist in Forest?
Forest can render six types of charts:
* Single value (Number of customers, MRR, …)
* Repartition (Number of customers by countries, Paid VS Free, …)
Only the 5 biggest categories will be displayed separately. All the others will go into a 6th "Other" category.
* Time-based (Number of sign-ups per month, …)
* Percentage (% of paying customers, …)
* Objective (Orders passed per year VS objective, …)
* Leaderboard (Companies who emitted the most transactions, …)
Ensure you’ve enabled the `Layout Editor` mode to add, edit or delete a chart.
### Where can you add charts?
Charts can be added in 2 places:
* In your **Dashboard** tab
* In the **Analytics** tab of every record of a collection
In the following pages, you'll learn how to create all types of charts.
# Integrations
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/README
## Integrations
Forest is able to leverage data from third party services by reconciliating it with your application’s data, providing it directly to your admin. All your admin actions can be performed at the same place, bringing additional intelligence to your admin and ensuring consistency.
# Algolia
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/algolia/README
# Geocode an address with Algolia
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/algolia/geocode-an-address-with-algolia
This example shows you how to use an autocomplete address smart field to update a PostreSQL geography point (lat, long).
## Requirements
* An admin backend running on forest-express-sequelize
* An algolia account
* [algoliasearch](https://www.npmjs.com/package/algoliasearch) npm package
## How it works
### Directory: /models
This directory contains the `events.js` file where the model is declared.
### Directory: /forest
This directory contains the `events.js` file where the Smart Field `Location setter`is declared.\
\
This smart field will be used to update the value of the `address`and `locationGeo` fields.
```javascript theme={null}
const algoliasearch = require('algoliasearch');
const places = algoliasearch.initPlaces(
process.env.PLACES_APP_ID,
process.env.PLACES_API_KEY
);
async function getLocationCoordinates(query) {
try {
const location = await places.search({ query, type: 'address' });
console.log('search location coordinates result', location.hits[0]._geoloc);
return location.hits[0]._geoloc;
} catch (err) {
console.log(err);
return null;
}
}
async function setEvent(event, query) {
const coordinates = await getLocationCoordinates(query);
event.address = query;
event.locationGeo = `{"type": "Point", "coordinates": [${coordinates.lat}, ${coordinates.lng}]}`;
console.log('new address', event.address);
console.log('new location', event.locationGeo);
return event;
}
collection('events', {
fields: [
{
field: 'Location setter',
type: 'String',
// Get the data to be displayed.
get: (event) => event.address,
// Update using Algolia.
set: (event, query) => setEvent(event, query),
},
],
});
```
The field `Location setter` should use the [address edit widget](https://docs.forestadmin.com/user-guide/collections/customize-your-fields/edit-widgets#address) to enable address autocomplete.
# Azure Table Storage
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/azure-table-storage
This How to is based on the [Medium article](https://avarnon.medium.com/exposing-azure-table-storage-through-forest-admin-2d601752f9b1) by [Andrew Varnon](https://avarnon.medium.com/)
The implementation is done using a [Smart Collection](https://docs.forestadmin.com/documentation/reference-guide/collections/create-a-smart-collection) and a CRUD service that will wrap the [Azure Table Storage API](https://docs.microsoft.com/en-us/rest/api/storageservices/table-service-rest-api).
### The Table Storage Definition
You can use the new [Azure Data Explorer](https://azure.microsoft.com/en-us/services/data-explorer/) to create and populate a Table Storage in your [Azure Storage account](https://docs.microsoft.com/en-us/azure/storage/common/storage-account-overview).
In our example, we are going to use the Table Customers with the fields:
* **Id**: PartitionKey + RowKey
* **Timestamp** (updated at)
* **Email** as String
* **FirstName** as String
* **LastName** as String
### Install Azure `data-tables` package
```haskell theme={null}
npm install @azure/data-tables --save
```
### Smart Collection definition
### The Azure Data Tables Service Wrapper
```javascript theme={null}
const { TableClient } = require('@azure/data-tables');
const getClient = (tableName) => {
const client = TableClient.fromConnectionString(
process.env.AZURE_STORAGE_CONNECTION_STRING,
tableName
);
return client;
};
const azureTableStorageService = {
deleteEntityAsync: async (tableName, partitionKey, rowKey) => {
const client = getClient(tableName);
await client.deleteEntity(partitionKey, rowKey);
},
getEntityAsync: async (tableName, partitionKey, rowKey) => {
const client = getClient(tableName);
return client.getEntity(partitionKey, rowKey);
},
listEntitiesAsync: async (tableName, options) => {
const client = getClient(tableName);
var azureResponse = await client.listEntities();
let iterator = await azureResponse.byPage({
maxPageSize: options.pageSize,
});
for (let i = 1; i < options.pageNumber; i++) iterator.next(); // Skip pages
let entities = await iterator.next();
let records = entities.value.filter((entity) => entity.etag);
// Load an extra page if we need to allow (Next Page)
const entitiesNextPage = await iterator.next();
let nbNextPage = 0;
if (entitiesNextPage && entitiesNextPage.value) {
nbNextPage = entitiesNextPage.value.filter(
(entity) => entity.etag
).length;
}
// Azure Data Tables does not provide a row count.
// We just inform the user there is a new page with at least x items
const minimumRowEstimated =
(options.pageNumber - 1) * options.pageSize + records.length + nbNextPage;
return { records, count: minimumRowEstimated };
},
createEntityAsync: async (tableName, entity) => {
const client = getClient(tableName);
delete entity['__meta__'];
await client.createEntity(entity);
return client.getEntity(entity.partitionKey, entity.rowKey);
},
updateEntityAsync: async (tableName, entity) => {
const client = getClient(tableName);
await client.updateEntity(entity, 'Replace');
return client.getEntity(entity.partitionKey, entity.rowKey);
},
};
module.exports = azureTableStorageService;
```
### Routes definition
```javascript theme={null}
const express = require('express');
const {
PermissionMiddlewareCreator,
RecordCreator,
RecordUpdater,
} = require('forest-express');
const { RecordSerializer } = require('forest-express');
const router = express.Router();
const COLLECTION_NAME = 'customers';
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
COLLECTION_NAME
);
const recordSerializer = new RecordSerializer({ name: COLLECTION_NAME });
const azureTableStorageService = require('../services/azure-table-storage-service');
// Get a list of Customers
router.get(
`/${COLLECTION_NAME}`,
permissionMiddlewareCreator.list(),
async (request, response, next) => {
const pageSize = parseInt(request.query.page.size) || 15;
const pageNumber = parseInt(request.query.page.number);
azureTableStorageService
.listEntitiesAsync(COLLECTION_NAME, { pageSize, pageNumber })
.then(async ({ records, count }) => {
const recordsSerialized = await recordSerializer.serialize(records);
response.send({ ...recordsSerialized, meta: { count } });
})
.catch((e) => {
console.error(e);
next(e);
});
}
);
// Get a Customer
router.get(
`/${COLLECTION_NAME}/:recordId`,
permissionMiddlewareCreator.details(),
async (request, response, next) => {
const parts = request.params.recordId.split('|');
azureTableStorageService
.getEntityAsync(COLLECTION_NAME, parts[0], parts[1])
.then((record) => recordSerializer.serialize(record))
.then((recordSerialized) => response.send(recordSerialized))
.catch((e) => {
console.error(e);
next(e);
});
}
);
// Create a Customer
router.post(
`/${COLLECTION_NAME}`,
permissionMiddlewareCreator.create(),
async (request, response, next) => {
const recordCreator = new RecordCreator(
{ name: COLLECTION_NAME },
request.user,
request.query
);
recordCreator
.deserialize(request.body)
.then((recordToCreate) => {
return azureTableStorageService.createEntityAsync(
COLLECTION_NAME,
recordToCreate
);
})
.then((record) => recordSerializer.serialize(record))
.then((recordSerialized) => response.send(recordSerialized))
.catch((e) => {
console.error(e);
next(e);
});
}
);
// Update a Customer
router.put(
`/${COLLECTION_NAME}/:recordId`,
permissionMiddlewareCreator.update(),
async (request, response, next) => {
const parts = request.params.recordId.split('|');
const recordUpdater = new RecordUpdater(
{ name: COLLECTION_NAME },
request.user,
request.query
);
recordUpdater
.deserialize(request.body)
.then((recordToUpdate) => {
recordToUpdate.partitionKey = parts[0];
recordToUpdate.rowKey = parts[1];
return azureTableStorageService.updateEntityAsync(
COLLECTION_NAME,
recordToUpdate
);
})
.then((record) => recordSerializer.serialize(record))
.then((recordSerialized) => response.send(recordSerialized))
.catch((e) => {
console.error(e);
next(e);
});
}
);
// Delete a list of Customers
router.delete(
`/${COLLECTION_NAME}`,
permissionMiddlewareCreator.delete(),
async (request, response, next) => {
try {
for (const key of request.body.data.attributes.ids) {
const parts = key.split('|');
await azureTableStorageService.deleteEntityAsync(
COLLECTION_NAME,
parts[0],
parts[1]
);
}
response.status(204).send();
} catch (e) {
console.error(e);
next(e);
}
}
);
module.exports = router;
```
# Dwolla
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/dwolla/README
The following section will provide you with a set of examples to implement a custom integration of [Dwolla](https://www.dwolla.com/)
# Display Dwolla customers
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/dwolla/display-dwolla-customers
This example shows you how to create a smart collection to list the customers of your [Dwolla](https://www.dwolla.com/) account.
## 1. Define the smart collection
Filterable fields are flagged using `isFilterable: true`. You will need to enable this option using the collection settings in the [Layout Editor](https://docs.forestadmin.com/user-guide/getting-started/master-your-ui/using-the-layout-editor-mode).
Customers have `isSearchable` flag enabled: it means the search input field will be activated on the collection UI.
## 2. Implement the route
The Customers routes implement the Get List and Get One, plus the [smart relationships (HasMany)](https://docs.forestadmin.com/documentation/reference-guide/relationships/create-a-smart-relationship#creating-a-hasmany-smart-relationship):
* Funding Sources
* Transfers
These routes use the Dwolla service described in [another section](https://docs.forestadmin.com/woodshop/how-tos/dwolla-integration/dwolla-servive).
# Display Dwolla funding sources
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/dwolla/display-dwolla-funding-sources
## 1. Define the smart collection
Filterable fields are flagged using `isFilterable: true`. You will need to enable this option using the collection settings in the [Layout Editor](https://docs.forestadmin.com/user-guide/getting-started/master-your-ui/using-the-layout-editor-mode).
Funding Sources have the `onlyForRelationships` enabled: it means that these 2 collections are only accessible via the Dwolla customer relationships.
## 2. Implement the route
This route use the Dwolla service described in [another section](/legacy/ruby-agent/reference-guide/integrations/dwolla/dwolla-service).
# Display Dwolla transfers
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/dwolla/display-dwolla-transfers
This example shows you how to create a smart collection to list the transfers of your [Dwolla](https://www.dwolla.com) account.
## 1. Define the smart collection
Filterable fields are flagged using `isFilterable: true`. You will need to enable this option using the collection settings in the [Layout Editor](https://docs.forestadmin.com/user-guide/getting-started/master-your-ui/using-the-layout-editor-mode).
Transfers have the `onlyForRelationships` enabled: it means that these 2 collections are only accessible via the Dwolla customer relationships.
## 2. Implement the route
This route use the Dwolla service described in [another section](/legacy/ruby-agent/reference-guide/integrations/dwolla/dwolla-service).
# Dwolla Service
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/dwolla/dwolla-service
This service wraps the [Dwolla SDK ](https://developers.dwolla.com/sdks-tools#sdks--tools)and provides the following implementation:
* Pagination (on Customers & Transfers)
* Fields to be displayed on the UI (select)
* Search (on Customers & Transfers)
* Filters (on Customers, cf `isFilterable` flag)
### Prototype
```javascript theme={null}
"use strict";
const dwolla = require('dwolla-v2');
var _ = require('lodash');
class DwollaService {
// Allow to create a Dwolla Client based on the App Key a Secret
constructor(appKey, appSecret, environment);
// Get a List of Customers based on the query (page, filter, search, sort)
getCustomers (query);
// Get a Customer by Id
getCustomer (recordId);
// Get a Customer for a local database user (by email)
getCustomerSmartRelationship (user);
// Get a list of Funding Sources for a customer Id
getCustomerFundingSources (recordId, query);
// Get a Funding Source by Id
getFundingSource (recordId);
// Get a list of Transfers for a customer Id
getCustomerTransfers (recordId, query);
// Get a Transfer by Id
getTransfer (recordId);
}
module.exports = DwollaService;
```
# Link users and Dwolla customers
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/dwolla/link-users-and-dwolla-customers
The implementation of this [smart relationship (belongsTo](/legacy/ruby-agent/reference-guide/models/relationships/create-a-smart-relationship/overview#creating-a-belongsto-smart-relationship)) relies on a Dwolla service that will retrieve the Dwolla customer based on the user's email. The Dwolla service is described in [another section](/legacy/ruby-agent/reference-guide/integrations/dwolla/dwolla-service).
# Readme
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/elasticsearch/README
# Another example
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/elasticsearch/another-example
For the purpose of this example let's say we have an `activity-logs` index in Elasticsearch with the following mapping.
## Implementing the GET (all records with a filter on related data)
This is a complex use case: How to handle filters on related data. We want to be able to filter using the `user.mail` field.\
\
To accommodate you we already provide you a simple service [`ElasticsearchHelper`](https://docs.forestadmin.com/woodshop/how-tos/create-a-smart-collection-with-elasticsearch/elasticsearch-service-utils) that handles all the logic to connect with your Elasticsearch data.
## Implementing the GET (all records with the search)
Another way to search through related data is to implement your own search logic.
# Elasticsearch service/utils
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/elasticsearch/elasticsearch-service-utils
## Connecting to Elasticsearch with a Custom Service
This service wraps the [Elasticsearch Node.js client](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html) and provides the following implementation:
* Get a list of records (with Pagination and Filters handling)
* Get a simple record
* Create a record
* Update an existing record
* Delete a record
### Prototype
We expose utils to parse filters through **forest-express-sequelize** since version **7.6.0**
# Interact with your Elasticsearch data
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/elasticsearch/interact-with-your-elasticsearch-data
### Creating the Smart Collection
Let's take a simple example from Kibana, we will use [a set of fictitious accounts with randomly generated data.](https://download.elastic.co/demos/kibana/gettingstarted/accounts.zip) You can easily import the data using Kibana Home page section **Ingest your data**.
When it's done we can start looking at how to play with those data in Forest.
### forest-express-sequelize
First, we declare the `bank-accounts` collection in the `forest/` directory. In this Smart Collection, all fields are related to document mapping attributes except the field `id` that is computed using the document `_id`.
You can check out the list of [available field options](https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields#available-field-options) if you need them.
You **MUST** declare an `id` field when creating a Smart Collection. The value of this field for each record **MUST** be unique. On the following example, we simply use the UUID provided on every Elasticsearch documents.
You can add the option `isSearchable: true` to your collection to display the search bar. Note that you will have to implement the search yourself by including it into your own `GET` logic.
### Implementing the routes
It's not an easy job to connect several data sources in the same structure. To accommodate you in this journey we already provide you a simple service [`ElasticsearchHelper`](https://docs.forestadmin.com/woodshop/how-tos/create-a-smart-collection-with-elasticsearch/elasticsearch-service-utils) that handles all the logic to connect with your Elasticsearch data.
\
Before getting further, in order to search your data using filters, we need to define the Elasticsearch configuration.
| Name | Type | Description |
| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| index | string | The name of your Elasticsearch index. |
| filterDefinition | string | Type of your Elasticsearch fields. Can be `number`, `date`, `text`,`keyword` |
| sort | array of objects | (optional) Required only to sort your data. [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/7.12/sort-search-results.html) `Example: [ { createdAt: { order: 'desc' } }]` |
| mappingFunction | function | (optional) Required only to modify the data retrieved from Elasticsearch. `Example: (id, source) => { id, ...source}` |
Our custom filter translator only support `number`, `keyword`, `text`, `date` data types. Nonetheless, you can implement more filter mapper type in the`utils/filter-translator.js`
### Implementing the GET (all records)
In the file `routes/bank-accounts.js`, we’ve created a new route to implement the API behind the Smart Collection.
The logic here is to list all the BankAccount records. We use a custom service `service/elasticsearch-helper.js` for this example. The implementation code of this service is available here.
Finally, the last step is to serialize the response data in the expected format which is simply a standard [JSON API](http://jsonapi.org/) document. You are lucky `forest-express-sequelize` already does this for you using the RecordSerializer.
```javascript theme={null}
// Imports and ElasticsearchHelper base definition ...
router.delete(
'/bank-accounts/:id',
permissionMiddlewareCreator.delete(),
async (request, response, next) => {
try {
await elasticsearchHelper.removeRecord(request.params.id);
response.status(204).send();
} catch (e) {
next(e);
}
}
);
module.exports = router;
```
#### Delete a list of records
### forest-express-sequelize
```javascript theme={null}
// Imports and ElasticsearchHelper base definition ...
router.post(
'/bank-accounts',
permissionMiddlewareCreator.create(),
(request, response, next) => {
const recordCreator = new RecordCreator(
{ name: 'bank-accounts' },
request.user,
request.query
);
recordCreator
.deserialize(request.body)
.then((recordToCreate) =>
elasticsearchHelper.createRecord(recordToCreate)
)
.then((record) => recordCreator.serialize(record))
.then((recordSerialized) => response.send(recordSerialized))
.catch(next);
}
);
module.exports = router;
```
# Readme
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/hubspot/README
# Create a Hubspot company
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/hubspot/create-a-hubspot-company
This example shows you how to create a Smart Action `"Create company in Hubspot"` that generates a company in Hubspot based on information from your database.
## Requirements
* An admin backend running on forest-express-sequelize
* [superagent](https://www.npmjs.com/package/superagent) npm package
* a Hubspot account
## How it works
### Directory: /models
This directory contains the `companies.js` file where the collection is declared.
### Directory: /routes
This directory contains the `companies.js` file where the smart action logic is implemented.
In this logic a Hubspot company instance is created through a /post create company call to the Hubspot API.
The Hubspot API key is defined in the `.env` file and requested through the expression `process.env.HUBSPOT_API`.
# Display Hubspot companies
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/hubspot/display-hubspot-companies
This example shows you how to create a smart collection to list the companies of your Hubspot account.
## Requirements
* An admin backend running on forest-express-sequelize
* [superagent](https://www.npmjs.com/package/superagent) npm package
* a Hubspot account
## How it works
### Directory: /forest
This directory contains the `hubspot-companies.js` file where the collection is declared.
### Directory: /routes
This directory contains the `hubspot-companies.js` file where the serializer for the collection and logic to get records is defined.
Companies information are obtained by making a [get all companies](https://developers.hubspot.com/docs/methods/companies/get-all-companies) call to the Hubspot API.
The Hubspot API key is defined in the `.env` file and requested through the expression `process.env.HUBSPOT_API`.
# Intercom
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/intercom
Configuring the Intercom integration allows you to display your user’s session data (location, browser type, …) and conversations.
In order for your intercom integration to work properly, you will have to use the version 2 of intercom API. To do so, you'll need go to the intercom developer hub and ensure that the app registered to retrieve your API key uses the intercom API version 2.0.
First, add the intercom client as a dependency to your project:
```ruby theme={null}
gem 'intercom'
```
Then, you need to add the intercom integration:
```ruby theme={null}
ForestLiana.integrations = {
# ...
intercom: {
access_token: ENV['INTERCOM_ACCESS_TOKEN'],
mapping: ['Customer']
}
}
```
* `intercom` is used to pass the intercom client version. To do so, you have to require the previously installed client, as in the example.
* `accessToken` should be defined in your environment variable and is provided by intercom.
* `mapping` refers to the collection and field name you want to map to intercom data. It can either be a field that contain emails that refer to intercom users or a field that contain ids mapping the `external_id` in Intercom API.
You will have to restart your server to see Intercom plugged to your project.
### Others
# Mixpanel
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/mixpanel
The Mixpanel integration allows you to fetch Mixpanel’s events and display them at a record level into Forest.
### Rails
To benefit from Mixpanel integration, you need to add the `gem 'mixpanel_client'` to your Gemfile.
Then, add the following code to your initializer. In our example we will map the `Customer.email` with the data coming from Mixpanel. You may replace by your own relevant collection(s).
By default, Mixpanel is sending the following fields: id, event, date, city, region, country, timezone, os, osVersion, browser, browserVersion. If you want to add other fields from Mixpanel, you have to add them in `customProperties`:
```ruby theme={null}
ForestLiana.env_secret = Rails.application.secrets.forest_env_secret
ForestLiana.auth_secret = Rails.application.secrets.forest_auth_secret
ForestLiana.integrations = {
mixpanel: {
api_key: 'YOUR MIXPANEL API KEY',
api_secret: 'YOUR MIXPANEL SECRET KEY',
mapping: ['Customer.email'],
custom_properties: ['Campaign Source', 'plan', 'tutorial complete'],
}
}
```
You will then be able to see the Mixpanel events on a record, a `Customer` in our example.
You'll need to install the [Mixpanel Data Export](https://www.npmjs.com/package/mixpanel-data-export) package to run the Mixpanel integration
# Razorpay
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/razorpay
**Context**: As a user I want to be able to see all payments and orders related to a customer from Razorpay.
**Example**: I have a collection `users` and a collection `orders` in the database. An order belongs to a customer through a field `user`. An order has a field `order_reference` and `payment_reference` that are ids of objects from Razorpay.
### Models
`models/users.js`
`forest/razorpay-orders.js`
#### Add relationships to virtual collections
You need to declare a relationship between the `users` collection and the virtual `razorpayPayments` and `razorpayOrders` collections in the `forest/users.js` file.
### Define route logic for the relationship
You now have to implement the logic to be executed to retrieve and send the information from Razorpay to the UI when the corresponding route is called.
This is done in the file `routes/users.js.` Remember that you need to properly serialize the objects in order for the UI to correctly display them, using the `RecordsSerializer`.
# Readme
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/slack/README
# Send Smart Action notifications to Slack
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/slack/send-smart-action-notifications-to-slack
This example shows you how to integrate [Slack incoming webhooks](https://api.slack.com/messaging/webhooks) to receive notifications in your workspace when a Smart Action e.g `"Reject application"` is triggered.
Demo
## Create your Forest slack app
Follow Slack's guide to [create a new app](https://api.slack.com/messaging/webhooks) in your workspace and start sending messages using Incoming Webhooks.
At the end of this guide, make sure your Slack app has the following features activated:
* Incoming Webhooks
* Interactive Components
* Bots
* Permissions
Once your Slack app has its shiny Incoming Webhook URL, you will be able to send your [message](https://api.slack.com/messages) in JSON as the body of an `application/json` POST request.
```
https://hooks.slack.com/services/YOUR_WORKSPACE_ID/YOUR_CHANNEL_ID/YOUR_SECRET_TOKEN
```
## Connect your app from a Slack channel of your choice
## Set up the webhook from your admin backend
### Install the [node.js Slack SDK](https://slack.dev/node-slack-sdk)
From your project's directory, simply run
```bash theme={null}
$ npm install --save @slack/webhook
```
### Add the Incoming Webhook URL to your .env
```bash theme={null}
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR_WORKSPACE_ID/YOUR_CHANNEL_ID/YOUR_SECRET_TOKEN
```
This Incoming Webhook URL contains a secret key, please make sure it does not appear in your code.
### Create the Smart Action and initialize the Incoming Webhook
#### Smart Action declaration
#### Smart Action logic
To learn more about composing messages using the Slack API, please visit the
* Slack Interactive messages [guide](https://api.slack.com/messaging/interactivity)
* Slack Block Kit [visual builder](https://api.slack.com/tools/block-kit-builder)
To learn more about error handling of Slack Interactive Webhooks, please visit the Slack [changelog](https://api.slack.com/changelog/2016-05-17-changes-to-errors-for-incoming-webhooks).
# Stripe
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/stripe
Configuring the Stripe integration for Forest allows you to have your **customer’s payments, invoices, cards and subscriptions** **(1)** alongside the corresponding customer from your application. A `Refund` Smart Action **(2,3)** is also implemented out-of-the-box.
### Rails
On our Live Demo, we’ve configured the Stripe integration on the `Customer` collection. The Stripe Customer ID is already stored on the database under the field `stripe_id`.
```ruby theme={null}
ForestLiana.env_secret = Rails.application.secrets.forest_env_secret
ForestLiana.auth_secret = Rails.application.secrets.forest_auth_secret
ForestLiana.integrations = {
stripe: {
api_key: ENV['STRIPE_SECRET_KEY'],
mapping: 'Customer.stripe_id'
}
}
```
#### Available options
Here are the complete list of available options to customize your Stripe integration.
| Name | Type | Description |
| -------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| api\_key | string | The API Secret key of your Stripe account. Should normally starts with `sk_`. |
| mapping | string | Indicates how to reconcile your Customer data from your Stripe account and your collection/field from your database. Format must be `model_name.stripe_customer_id_field` |
A `stripe` option is also available to use the official [Node.js Stripe library](https://github.com/stripe/stripe-node) NPM package.
# Readme
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/twilio/README
# Send an SMS with Twilio and Zapier
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/twilio/send-an-sms-with-twilio-and-zapier
This example shows you how to create a Smart Action `"Send SMS"` that triggers a [Zapier webhook](https://zapier.com/zapbook/webhook/) to send an SMS message with Twilio.
## Requirements
* An admin backend running on forest-express-sequelize
* A Zapier account
* [node-fetch](https://www.npmjs.com/package/node-fetch) npm package
## How it works
### Directory: /models
This directory contains the `users.js` file where the model is declared.
### **Directory: /routes**
This directory contains the `users.js` file where the implementation of the route is handled. The `POST /forest/actions/send-sms` API call is triggered when you click on the Smart Action in the Forest UI. The route implementation retrieves all the necessary data and triggers another API call directly to a [Zapier hook](https://zapier.com/zapbook/webhook/).
```javascript theme={null}
const fetch = require('node-fetch');
//...
// Send SMS
router.post('/actions/send-sms', (request, response) => {
let userId = request.body.data.attributes.ids[0];
return users
.findByPk(userId)
.then((user) => {
user = user.toJSON();
return fetch(
'https://hooks.zapier.com/hooks/catch/4760242/o1uqz0r/silent',
{
method: 'POST',
body: JSON.stringify({
phoneNumber: user.phoneNumber,
}),
headers: { 'Content-Type': 'application/json' },
}
);
})
.then(() => {
response.status(204).send();
});
});
//...
module.exports = router;
```
# Authentication, Filtering & Sorting
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/zendesk/authentication-filtering-and-sorting
## Get authenticated to the Zendesk API
You first need to generate an authentication token to access the Zendesk API. We are going to use the basic authentication mechanism. [More details provided here](https://developer.zendesk.com/rest_api/docs/support/introduction#security-and-authentication). \
\
The 2 parameters required are: a user email (agent) that is allowed to access Zendesk, and the API Key that you can retrieve from the Zendesk console:
These 2 parameters can be environment variables like this;
# Bonus: Direct link to Zendesk + change priority of a ticket
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/zendesk/bonus-direct-link-to-zendesk--and--change-priority-of-a-ticket
## Create a Direct Link to Zendesk
The next step is to build a direct link to the Zendesk Ticket using a URL. We are going to implement a smart field for this. To build the URL, we simply use Zendesk's convention: `ZENDESK_URL_PREFIX/agent/tickets/ticketId`
Implement the `updateTicket` service according to the [Zendesk API](https://developer.zendesk.com/rest_api/docs/support/tickets#update-ticket):
You now have full integration with Zendesk!\
\
To go further, please [check our Github repository and explore how to](https://github.com/existenz31/forest-zendesk):
* Get the Assignee, Submitter & Requester users for a Zendesk Ticket
* Get the Zendesk User for a User
* Get the requested tickets for a Zendesk User
* and more...
# Display Zendesk tickets
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/zendesk/display-zendesk-tickets
This section shows you how to create a smart collection to list the tickets of your Zendesk account.
### Declare the Smart Collection Zendesk Tickets
First, we need to declare the smart collection in your project based on the API documentation. As an example, here the smart collection definition for Users:
Some fields are available for filtering or sorting using the Zendesk API. To allow this on the Forest UI, simply add the keywords `isFilterable` and `isSortable` in your field definition.
### Implement the Smart Collection route
In the file `routes/zendesk-tickets.js`, we’ve created a new route to implement the API behind the Smart Collection.
The logic here is to list all the users of your Zendesk account.
* Learn more about how to [authenticate, filter and sort with the Zendesk API](https://docs.forestadmin.com/woodshop/how-tos/zendesk-integration/authentication-filtering-and-sorting).
* Find more information about `getTickets` variable definition in [the Github repository](https://github.com/existenz31/forest-zendesk/blob/master/services/zendesk-tickets-service.js).
### Implement the get Route
The section above help you display the list of all Zendesk tickets. But you'll need to implement also the logic to display the information of a specific ticket.
This is going to be very similar. We just need to implement a new endpoint to get an individual ticket from the Zendesk API.
```javascript theme={null}
async function getTicket(request, response, next) {
return axios
.get(
`${ZENDESK_URL_PREFIX}/api/v2/tickets/${request.params.ticketId}?include=comment_count`,
{
headers: {
Authorization: `Basic ${getToken()}`,
},
}
)
.then(async (resp) => {
let record = resp.data.ticket;
// Serialize the result using the Forest format
const recordSerializer = new RecordSerializer({
name: 'zendesk_tickets',
});
const recordSerialized = await recordSerializer.serialize(record);
response.send(recordSerialized);
})
.catch(next);
}
```
```javascript theme={null}
const {
getTickets,
getTicket,
} = require('../services/zendesk-tickets-service');
// Get a Zendesk Ticket
router.get(
'/zendesk_tickets/:ticketId',
permissionMiddlewareCreator.details(),
(request, response, next) => {
getTicket(request, response, next);
}
);
```
# Display Zendesk users
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/zendesk/display-zendesk-users
This section shows you how to create a smart collection to list the users of your Zendesk account.
### Declare the Smart Collection Zendesk Users
Zendesk API allows to access different data:
* [Users](https://developer.zendesk.com/rest_api/docs/support/users)
* [Tickets & Comments](https://developer.zendesk.com/rest_api/docs/support/tickets)
* [Organizations](https://developer.zendesk.com/rest_api/docs/support/organizations) and [Groups](https://developer.zendesk.com/rest_api/docs/support/groups)
First, we need to declare the smart collection in your project based on the API documentation. As an example, here the smart collection definition for Users:
Some fields are available for filtering or sorting using the Zendesk API. To allow this on the Forest UI, simply add the keywords `isFilterable` and `isSortable` in your field definition.
### Implement the Smart Collection route
In the file `routes/zendesk-users.js`, we’ve created a new route to implement the API behind the Smart Collection.
The logic here is to list all the users of your Zendesk account.
* Learn more about how to [authenticate, filter and sort with the Zendesk API](https://docs.forestadmin.com/woodshop/how-tos/zendesk-integration/authentication-filtering-and-sorting).
* Find more information about `getUsers` variable definition in [the Github repository](https://github.com/existenz31/forest-zendesk/blob/master/services/zendesk-users-service.js).
### Implement the get Route
The section above help you display the list of all Zendesk users. But you'll need to implement also the logic to display the information of a specific user.
We just need to implement a new endpoint to get an individual user from the Zendesk API.
```javascript theme={null}
async function getUser(request, response, next) {
return axios
.get(
`${ZENDESK_URL_PREFIX}/api/v2/users/${request.params.userId}?include=comment_count`,
{
headers: {
Authorization: `Basic ${getToken()}`,
},
}
)
.then(async (resp) => {
let record = resp.data.user;
// Serialize the result using the Forest format
const recordSerializer = new RecordSerializer({ name: 'zendesk_users' });
const recordSerialized = await recordSerializer.serialize(record);
response.send(recordSerialized);
})
.catch(next);
}
```
```javascript theme={null}
const { getUsers, getUser } = require('../services/zendesk-tickets-service');
// Get a Zendesk Ticket
router.get(
'/zendesk_users/:userId',
permissionMiddlewareCreator.details(),
(request, response, next) => {
getUser(request, response, next);
}
);
```
# Zendesk
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/zendesk/overview
For this example we will use the Zendesk API described [here](https://developer.zendesk.com/rest_api/docs/support/introduction).
We are going to use [Smart Collections](/legacy/ruby-agent/reference-guide/smart-collections/overview), [Smart Relationships](/legacy/ruby-agent/reference-guide/models/relationships/create-a-smart-relationship/overview), and [Smart Fields](/legacy/ruby-agent/reference-guide/smart-fields/overview) to implement such integration.
The full implementation of this integration is available [here](https://github.com/existenz31/forest-zendesk) on GitHub.
### Live Demo
### Build your basic Admin Panel with Forest
Let's start with a basic admin panel on top of a SQL database that has a table `Users` that holds an email address field.
Now, let's build the Admin Panel as usual with Forest. You will get something like this:
# View tickets related to a user
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/integrations/zendesk/view-tickets-related-to-a-user
Now, let's say we want to access the tickets for a user of my database. We are going to use the email address as the foreign key between the database model (`Users` table) and Zendesk tickets.
First, we need to create the [Smart Relationship](https://docs.forestadmin.com/documentation/reference-guide/relationships/create-a-smart-relationship) between `Users` and `zendesk_tickets` as follows:
```javascript theme={null}
collection('users', {
actions: [],
fields: [
{
field: 'ze_requested_tickets',
type: ['String'],
reference: 'zendesk_tickets.id',
},
],
segments: [],
});
```
Then, we need to implement the Smart Relationship route. This route will query the Zendesk tickets related to the user's email (requested field on `zendesk_tickets`).
```javascript theme={null}
const { getTickets } = require('../services/zendesk-tickets-service');
router.get(
'/users/:userId/relationships/ze_requested_tickets',
async (request, response, next) => {
// Get the user email for filtering on requester
const user = await users.findByPk(request.params.userId);
const additionalFilter = `requester:${user.email}`;
getTickets(request, response, next, additionalFilter);
}
);
```
Now, you should see the requested tickets for a user:
# Enrich your models
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/models/enrich-your-models
⚠️ This page is relevant only if you installed Forest directly on a database (SQL/Mongodb). If you installed in a Rails/Django/Laravel app, you manage your models like you normally would.
### Declaring a new model
Whenever you have a new table/collection in your database, you will have to create file to declare it. Here is a **template example** for a `companies` table:
### Declaring a new field in a model
Any new field must be added **manually** within the corresponding model of your `/models` folder.
### Managing nested documents in Mongoose
For a better user experience, flatten nested fields. In v2 see the [Flattener plugin](/product/process/advanced-concepts/plugins/overview).
Lumber introspects your data structure recursively, so ***nested fields*** (object in object) are detected any level deep. Your **sub-documents** (array of nested fields) are detected as well.
Conflicting data types will result in the generation of a [mixed](https://mongoosejs.com/docs/schematypes.html#mixed) type field.
The following model...
...will result in the following interface:
### Removing a model
By default **all** tables/collections in your database are analyzed by Lumber to generate your models. If you want to exclude some of them to prevent them from appearing in your Forest, check out [this how-to](/legacy/ruby-agent/how-tos/settings/include-exclude-models).
### Adding validation to your models
Validation allows you to keep control over your data's quality and integrity.
If your existing app already has validation conditions, you may - or may not - want to reproduce the same validation conditions in your admin backend's models.
If so, you'll have to do it **manually**, using the below examples.
Depending on your database type, your models will have been generated in *Sequelize* (for SQL databases) or *Mongoose* (for Mongo databases).
###
### Adding a default value to your models
You can choose to add a default value for some fields in your models. As a result, the corresponding fields will be prefilled with their default value in the creation form:
### Adding a hook
Hooks are a powerful mechanism which allow you to automatically **trigger an event** at specific moments in your records lifecycle.
In our case, let's pretend we want to update a `update_count` field every time a record is updated:
# Models
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/models/overview
⚠️ This page and sub-pages are relevant only if you installed Forest directly on a database (SQL/Mongodb). If you installed in a Rails/Django/Laravel app, you manage your models like you normal
Your models are located in `/models`. They control a big part of your Forest UI.
### Reflecting your database changes in your UI
When you install for the first time, Lumber introspects your database and generates your models accordingly.
Afterwards, here's how your database changes can be rendered in your Forest UI:
### Updating your models automatically
If you made many changes or even added a new table/collection, we recently reintroduced a programmatic way to help you manage the associated file changes:
This feature requires an agent **version** 7 or higher.
Version 2.2+ of [Forest CLI](https://www.npmjs.com/package/forest-cli) allows you via its `schema:update` command to:
* Generate files which, after introspecting your database, appear to be missing in your folders (`models` , `routes` & `forest`). Eg. Adding a new table and launching `schema:update` within your project directory should generate the associated models/routes & forest files
* Generate a correct project architecture to easily manage multiple databases. After your onboarding (on a single database), update the `config/databases.js` file to add a new connection, launch `schema:update` and your models should be set correctly
`forest schema:update` will **never** modify your code base (remove files, move files, change file content). It's up to you to copy some (or all) of the generated contents into your existing files/folders.
Note that `forest schema:update` options are as follows:
* `-c` or `--config` , allowing to specify a path for the config file to user (Default to `./config/databases.js`)
* `-o` or `--output-directory` : Create a directory named after the config parameter provided. It will also redump all the `models/routes/forest` file in a specific directory, allowing the end-user to pick code modification.
This command need to be launched at the root of the project directory, where the `.env` should be, since it is required by `config/databases.js` file.
Have any models that will always stay hidden? Find out how [you can exclude them](/legacy/ruby-agent/how-tos/settings/include-exclude-models) and gain on performance.
### Enriching your models
Lumber does some of the work for you. However, **you remain in control of your models**.
On the following page, we'll cover how you can enrich your models:
### The `.forestadmin-schema.json` file
On server start, a `.forestadmin-schema.json` file will be auto-generated in **local (development) environments only.** It reflects:
* the **state of your models** (in `/models`)**.**
* your **Forest customization** (in `/forest`).
This file **must be versioned and deployed** for any remote environment (staging, production, etc.), as it will be used to generate your Forest UI.
We use the environment variable ***NODE\_ENV*** to detect if an environment is in development. Setting this variable to either nothing or ***development*** will regenerate a new *.forestadmin-schema.json* file every time your app restarts. Using another value will not regenerate the file.
A consequence of the above is, **in Production** the `.forestadmin-schema.json` file does **not** update according to your schema changes.
**Do not edit this file,** as it could break your interface if the wrong syntax is used.
Versioning the`.forestadmin-schema.json` file will also help you visualize your changes.
To **disable automatic** Forest schema updates and do it **manually**, follow this [how-to](https://docs.forestadmin.com/documentation/v/v4/how-tos/disable-automatic-forest-admin-schema-update).
# GetIdsFromRequest
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/models/relationships/create-a-smart-relationship/getidsfromrequest
In recent versions of our agents, you may have noticed a new helper, which is `getIdsFromRequest`. This helper comes alongside the ['Select All' feature](https://docs.forestadmin.com/documentation/how-tos/maintain/upgrade-notes-sql-mongodb/upgrade-to-v6#select-all-feature), allowing you to trigger a Smart Action on more records than those displayed in the UI.
Unfortunately, this helper is not compatible with Smart Actions triggered on Smart Relationships. This is due to the Smart Relationship concept. When you create a [HasMany Smart Relationship](https://docs.forestadmin.com/documentation/reference-guide/relationships/create-a-smart-relationship#creating-a-hasmany-smart-relationship), you become the owner of the way your data are linked together by overriding the routes. Forest can't retrieve the logic to link the data, this is why you also need to code your own `getIdsFromRequest` helper. This documentation will guide you through the steps you need to create your own helper.
Let's take an example to illustrate what we want to achieve:
In this case, with have a HasMany Smart Relationship between `owners` and `articles` called `Liked articles`. As you can see, we are about to trigger the `Unlike` Smart Action on every article the owner liked that corresponds to the filter and the search we configured.
### What is the getIdsFromRequest about?
This helper simply takes a query as a parameter (containing your filters, your search, and some other configuration) and then returns the ids corresponding to this query. In other words, based on what the user selects ('select all', 'select current page', ...) this helper is able to return the exact ids the user wants to operate on. With these ids, your will then be able to perform operations related to your smart actions.
4 cases need to be handled there:
* Select all: each of the related records should be impacted
* Select all, minus some: each of the related records should be impacted, except specific ones
* Select current page: each of the listed records should be impacted
* Select some: only some specific records should be impacted
Only the two first cases need to be handled, because the last two cases consist of a simple list of the ids selected by the user directly in the request. So nothing special to do here.
In conjunction with the previous 4 cases, we also need to handle the filters and the search set up before executing the smart action.
### Code Snippet
Please find in the following snippet every of the requirement listed above fulfilled to make the Smart Action work with the Select All feature.
Explanation of the code:
* Line 1: If the Select All feature has been used, we need to build a query to concatenate the filter, the search, and the Select All configuration. Otherwise, the ids are already present in the query (see line 58)
* Line 25: Here is an example to show you how to quickly handle filters, if any
* Line 36: Here is an example to show you how to handle the search, if any
* Line 44: Finally, this snippet of code removes any ids that have been unselected by the user after using the Select All feature.
# Create a Smart relationship
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/models/relationships/create-a-smart-relationship/overview
### What is a Smart Relationship?
Sometimes, you want to create a virtual relationship between two set of data that does not exist in your database. A concrete example could be creating a relationship between two collections available in two different databases. Creating a Smart Relationship allows you to customize with code how your collections are linked together.
### Create a BelongsTo Smart Relationship
On the Live Demo example, we have an **order** which `belongsTo` a **customer** which `belongsTo` a **delivery address**. We’ve created here a BelongsTo Smart Relationship that acts like a shortcut between the **order** and the **delivery address**.
A BelongsTo Smart Relationship is created like a [Smart Field](/legacy/ruby-agent/reference-guide/smart-fields/overview#what-is-a-smart-field) with the `reference` option to indicate on which collection the Smart Relationship points to. You will also need to code the logic of the search query.
```ruby theme={null}
class Forest::Order
include ForestLiana::Collection
collection :Order
search_delivery_address = lambda do |query, search|
query.joins(customer: :address).or(Order.joins(customer: :address).where("addresses.country ILIKE ?", "%#{search}%"))
end
belongs_to :delivery_address, reference: 'Address.id', search: search_delivery_address do
object.customer.address
end
end
```
Ensure the file app/forest/\_\_init\_\_.py exists and contains the import of the previous defined class :
### Create a HasMany Smart Relationship
On the Live Demo example, we have a **product** `hasMany` **orders** and an **order** `belongsTo` **customer**. We’ve created a Smart Relationship that acts like a shortcut: **product** `hasMany` **customers**.
A HasMany Smart Relationship is created like a [Smart Field](https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields) with the `reference` option to indicates on which collection the Smart Relationship points to.
```ruby theme={null}
class Forest::Product
include ForestLiana::Collection
collection :Product
has_many :buyers, type: ['String'], reference: 'Customer.id'
end
```
Upon browsing, an API call is triggered when accessing the data of the HasMany relationships in order to fetch them asynchronously. In the following example, the API call is a GET on `/Product/:product_id/buyers`.
We’ve built the right SQL query using [Active Record](http://guides.rubyonrails.org/active_record_basics.html) to **count** and **find all** customers who bought the current product.
Then, you should handle pagination in order to avoid performance issue. The API call has a querystring available which gives you all the necessary parameters you need to enable pagination.
Finally, you don’t have to serialize the data yourself. The Forest agent already knows how to serialize your collection (`Customer` in this example). You can access to the serializer through the `serialize_models()` function.
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
get '/Product/:product_id/buyers' => 'orders#buyers'
end
mount ForestLiana::Engine => '/forest'
end
```
```ruby theme={null}
class Forest::ProductsController < ForestLiana::ApplicationController
def buyers
limit = params['page']['size'].to_i
offset = (params['page']['number'].to_i - 1) * limit
product = Product.find(params['product_id'])
customers = Customer.where(order_id: product.orders.ids)
render json: serialize_models(customers.limit(limit).offset(offset), meta: {count: customers.count})
end
end
```
Upon browsing, an API call is triggered when accessing the data of the HasMany relationships in order to fetch them asynchronously. In the following example, the API call is a GET on `/app_product/:product_pk/relationships/buyers`.\
\
You will have to declare this route in your app **urls.py** file
Then create the pertained view
We’ve built the right SQL query using [Django ORM](https://docs.djangoproject.com/en/3.2/topics/db/queries/) to **find all** customers who bought the current product.
Then, you should handle pagination in order to avoid performance issue. The API call has a querystring available which gives you all the necessary parameters you need to enable pagination.
Finally, you don’t have to serialize the data yourself. The Forest agent already knows how to serialize your collection (`Customer` in this example, with the table name `app_customer`). You can access to the serializer through the `Schema().dump` function (using [marshmallow-jsonapi](https://marshmallow-jsonapi.readthedocs.io/en/latest/) internally).
Upon browsing, an API call is triggered when accessing the data of the HasMany relationships in order to fetch them asynchronously. In the following example, the API call is a GET on `/product/{id}/relationships/buyers`.
We’ve built the right SQL query using [Active Record](http://guides.rubyonrails.org/active_record_basics.html) to **count** and **find all** customers who bought the current product.
Then, you should handle pagination in order to avoid performance issue. The API call has a querystring available which gives you all the necessary parameters you need to enable pagination.
Finally, you don’t have to serialize the data yourself. The Forest agent already knows how to serialize your collection (`Customer` in this example). You can access to the serializer through the `render()` function of JsonApi facade.
# Relationships
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/models/relationships/overview
## What is a relationship?
A relationship is a connection between two collections.
Relationships are visible and actionable in Forest:
* `hasMany` **(1)**
* `belongsTo` or `hasOne`**(2)**
If you installed Forest within a **Rails** app, then all the relationships defined in your ActiveRecord models are supported out of the box. Check the official [Rails documentation](https://guides.rubyonrails.org/association_basics.html) to create new ones.
If you installed Forest directly on a database, then most relationships should have been [automatically generated](/legacy/ruby-agent/reference-guide/models/relationships/overview#lumber-relationship-generation-rules). However, depending on your database nature and structure, you may have to add some manually.
## Adding relationships (databases only)
Depending on your database type, your models will have been generated in Sequelize (for SQL databases) or Mongoose (for Mongo databases).
Below are some simple snippets showing you how to add relationships. However, should you want to dig deeper, please refer to the appropriate framework's documentations:
* [Sequelize's documentation](https://sequelize.org/master/manual/assocs.html) on adding relationships in your models (SQL)
* [Mongoose's documentation](https://mongoosejs.com/docs/guide.html) on adding relationships in your models (Mongodb)
### Adding a `hasMany` relationship
In our [Live demo](https://app.forestadmin.com/Live%20Demo/Production/Operations/data/806052/index), a **customer** can have multiple **orders**. In that case, we have to use a `hasMany` relationship.
### Adding a `hasOne` relationship
In case of a one-to-one relationship between 2 collections, the opposite of a `belongsTo` relationship is a `hasOne` relationship. Taking the same example as before, the opposite of "an **address** `belongsTo` a **customer**" is simply "a **customer**`hasOne` **address"**.
### Adding a `belongsTo` relationship
On our Live Demo example, the Address model has a foreignKey customer\_id that points to the Customer. In other words, an **address**`belongsTo` a **customer**.
#### Declaring a foreign key (SQL only)
It's possible that your tables are linked in an unusual way (using *names* instead of *ids* for instance).\
\
In that case, adding the above code will not suffice to add the `belongsTo` relationship. Even though we recommend you modify your database structure to stay within foreign key conventions (pointing to an id), there is a way to **specify how your tables are linked**.
If the field `fk_customername` of a table **Address** points to the field `name` of a table **Customer**, add the following:
```javascript theme={null}
...
UserProjects.associate = (models) => {
UserProjects.belongsTo(models.projects, {
foreignKey: {
name: 'projectIdKey',
field: 'projectId',
},
as: 'project',
});
UserProjects.belongsTo(models.users, {
foreignKey: {
name: 'userIdKey',
field: 'userId',
},
as: 'user',
});
};
...
```
```javascript theme={null}
...
Users.associate = (models) => {
Users.belongsToMany(models.projects, {
through: 'userProjects',
foreignKey: 'userId',
otherKey: 'projectId',
});
};
...
```
```javascript theme={null}
...
Projects.associate = (models) => {
Projects.belongsToMany(models.users, {
through: 'userProjects',
foreignKey: 'projectId',
otherKey: 'userId',
});
};
...
```
## Relationship generation rules
Forest automatically generates most relationships, according to the below rules:
# Smart Relationship Examples
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/models/relationships/smart-relationship-examples/README
# Performance
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/performance
Loading performance is key to streamlining your operations. Here are a few steps we recommend taking to ensure your Forest is optimized.
Please find here all the hands-on best practices to keep your admin panel performant. Depending on your user's needs, you might either hide or optimize some fields to limit the number of components, avoid a large datasets display or rework complex logic.
You can display bellow performances improvement tricks in [this video](https://www.youtube.com/watch?v=UC5nH8q5YUI). For any further help to improve admin panel performances, get in touch with [the community](https://community.forestadmin.com).
### Layout optimization
1\. Show only [Smart fields](https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields) you absolutely need.
As you can see in the [Loading time benchmark](/legacy/ruby-agent/reference-guide/performance#loading-time-benchmark) below, Smart fields can be quite **costly** in terms of loading performance. Limiting them to those you need is key.
2\. Reduce the number of records per page
3\. Reduce the number of fields displayed
You can hide some fields in your table view; this will not prevent you from seeing them in the record details view.
Relationship fields are links to other collection records within your table view:
Having Relationship fields can decrease your performance, especially if your tables have a lot of records. Therefore you should display only those you need and use!
### Optimize smart fields performance
To optimize your smart field performances, please check out [this section](/legacy/ruby-agent/reference-guide/smart-fields/overview#createadvancedsmartfield).
### Restrict search on specific fields
Sometimes, searching in all fields is not relevant and may even result in big performance issues. You can restrict your search to specific fields only using the `searchFields` option.
### Rails
In this example, we configure Forest to only search on the fields `name` and `industry` of our collection `Company`.
```ruby theme={null}
class Forest::Company
include ForestLiana::Collection
collection :Company
search_fields ['name', 'industry']
action 'Mark as Live'
# ...
end
```
### Disable pagination count
This feature is only available if you're using the `forest-express-sequelize` (v8.5.3+)`,` `forest-express-mongoose` (v8.6.5+), `forest-rails` (v7.5.0+) or `django-forestadmin` (v1.2.0+) agent.
To paginate tables properly, Forest triggers a separate request to fetch the number of records.
In certain conditions, usually, when your database reaches a point where it has a lot of records, this request can decrease your loading performance. In this case, you can choose to disable it...
* creating a controller in the repository `lib/forest_liana/controllers` for override the count action
```ruby theme={null}
class Forest::BooksController < ForestLiana::ResourcesController
def count
deactivate_count_response
end
end
```
* adding a route in `app/config/routes.rb` before `mount ForestLiana::Engine => '/forest'`
```ruby theme={null}
namespace :forest do
get '/Book/count' , to: 'books#count'
end
```
..adding the following middleware in settings.py and set the collection(s) to deactivate.
adding a route in `app/routes/web.php`
To disable the count request in the table of a relationship (Related data section):
```ruby theme={null}
class Forest::BookCompaniesController < ForestLiana::AssociationsController
def count
if (params[:search])
params[:collection] = 'Book'
params[:association_name] = 'company'
super
else
deactivate_count_response
end
end
end
```
```ruby theme={null}
namespace :forest do
get '/Book/:id/relationships/companies/count' , to: 'book_companies#count'
end
```
Furthermore, if you want to disable on all relationships at once:
You can also disable the count request in a collection only in certain conditions. For instance, you can disable the count if you're using a filter:
```ruby theme={null}
class Forest::BooksController < ForestLiana::ResourcesController
def count
if (params[:filters])
params[:collection] = 'Book'
super
else
deactivate_count_response
end
end
end
```
One more example: you may want to deactivate the pagination count request for a specific team:
```ruby theme={null}
class Forest::BooksController < ForestLiana::ResourcesController
def count
if forest_user['team'] == 'Operations'
deactivate_count_response
else
params[:collection] = 'Book'
super
end
end
end
```
### Database Indexing
**Indexes** are a powerful tool used in the background of a database to speed up querying. It power queries by providing a method to quickly lookup the requested data. As Forest generates SQL queries to fetch your data, creating indexes can improve the query response time.
5\. Index the Primary and Unique Key Columns
\
The syntax for creating an index will vary depending on the database. However, the syntax typically includes a `CREATE` keyword followed by the `INDEX` keyword and the name we’d like to use for the index. Next should come the `ON` keyword followed by the name of the table that has the data we’d like to quickly access. Finally, the last part of the statement should be the name(s) of the columns to be indexed.
```
CREATE INDEX ON (column1, column2, ...)
```
For example, if we would like to index phone numbers from a `customers` table, we could use the following statement:
```
CREATE INDEX customers_by_phoneON customers (phone_number)
```
The users cannot see the indexes, they are just used to speed up searches/queries.
6\. Index the Foreign Key Columns
Foreign key columns should be indexed if they are used intensively in Smart fields. In the table below, you can see how drastically it reduces the loading time of the page.
Updating a table with indexes takes more time than updating a table without (because the indexes also need an update). So, only create indexes on columns that will be frequently searched against.
### Loading time benchmark
Below is the outcome of a performance test on page load time of the Table view. It highlights the *importance* of **using indexes** and **limiting the number of columns and lines**.
# Default routes
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/routes/default-routes
⚠️ This page is relevant only if you installed Forest directly on a database (SQL/Mongodb). If you installed in a Rails app, the default routes are managed within your Rails app.
Forest's default routes are generated in the `routes` folder at installation.
Below we've detailed what the `next()` statement does. Those snippets can be used when overriding those routes, as explained [here](/legacy/ruby-agent/reference-guide/routes/override-a-route).
### Create a record
### Update a record
Note that the **update** of `belongsTo` fields is managed by [another route](/legacy/ruby-agent/reference-guide/routes/default-routes#relationship-routes).
### Delete a record
### Get a list of records
### Get a number of records
### Get a record
### Export a list of records
### Delete a list of records
### Other available routes
Some other routes exist but are not generated automatically because it's less likely that you'll need to extend or override them.
Here is the list:
#### Relationship routes
**GET** /forest///relationships/\
⟶ **List** has many relationships
**GET** /forest///relationships//count\
⟶ **Count** has many relationships
**PUT** /forest///relationships/\
⟶ **Update** a belongs to field
**POST** /forest///relationships/\
⟶ **Add** existing records to has many relationship
**GET** /forest///relationships/.csv\
⟶ **Export** all has many relationships
**PUT** /forest///relationships//\
⟶ **Update** an embedded document (inside a list)
**DELETE** /forest///relationships/\
⟶ **Dissociate** records from relations
#### Action routes
**POST** /forest/actions//values\
⟶ **Get** the default values for this action
# Extend a route
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/routes/extend-a-route
⚠️ This page is relevant only if you installed Forest directly on a database (SQL/Mongodb). If you installed in a Rails app, check the "Override a route" page.
Extending a route is a clean way to achieve more by building on top of Forest's existing routes.
To extend a route, simply **add** **your own logic before the `next()` statement:**
### Adding logic with an API call
The most simple way to trigger your business app's (or any external app's) logic is with an API call!
In the following example, we override the `CREATE` route so that a credit card is created whenever a new customer is created in Forest:
```javascript theme={null}
...
// Require superagent once you've installed it (npm install superagent)
const superagent = require('superagent');
...
router.post('/customers', permissionMiddlewareCreator.create(), (req, res, next) => {
// Prepare the API call using the Forest's posted data
superagent
.post('https://my-company/create-card')
// Don't forget to authenticate your request using the relevant authentication method
.set('X-API-Key', '**********')
.end((err, res) => {
// Call next() to execute Forest's default behavior
next();
});
});
...
module.exports = router;
```
### Adding logic with a message broker
Using a message broker - such as RabbitMQ or Kafka - to broadcast events is current practice.
Here is how you could be using [RabbitMQ](https://www.rabbitmq.com/tutorials/tutorial-one-javascript.html) to handle `orders` synchronization across multiple channels:
```javascript theme={null}
...
const amqp = require('amqplib/callback_api');
...
router.put('/orders/:orderId', permissionMiddlewareCreator.update(), (req, res, next) => {
// Prepare your message from Forest's updated data
var orderId = req.body.data.id;
var orderStatus = req.body.data.attributes.shipping_status;
var message = 'Order ' + orderId + ' shipping status is now: ' + orderStatus;
var queue = 'orders_sync_queue';
// Connect to your Rabbitmq remote instance and publish your message
amqp.connect('amqp://{your_rabbitmq_host}', function(error0, connection) {
if (error0) {
throw error0;
}
connection.createChannel(function(error1, channel) {
if (error1) {
throw error1;
}
channel.assertQueue(queue, {
durable: false
});
channel.sendToQueue(queue, Buffer.from(message));
});
setTimeout(function() {
connection.close();
}, 500);
});
// Call next() to execute Forest's default behavior
next();
});
...
module.exports = router;
```
### Adding logic after Forest's default behavior
At some point, you may want to trigger your remote logic **after** Forest's logic.
To achieve this, you can manually recreate `next()`'s behavior by using the snippets of [default routes](/legacy/ruby-agent/reference-guide/routes/default-routes), then append your own logic.
# Override a route
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/routes/override-a-route
Overriding a route allows you to change or completely replace a Forest's route behavior.
### Changing Forest's behavior
To achieve this, use existing snippets of [default routes](/legacy/ruby-agent/reference-guide/routes/default-routes) and modify them according to your needs.
Here are a few examples:
#### Use extended search by default
```ruby theme={null}
if ForestLiana::UserSpace.const_defined?('CompanyController')
ForestLiana::UserSpace::CompanyController.class_eval do
alias_method :default_index, :index
alias_method :default_count, :count
# Get a list of Companies
def index
params['searchExtended'] = '1'
default_index
end
# Get a number of Companies
def count
params['searchExtended'] = '1'
default_count
end
end
end
```
With this snippet, only the `companies` collection would use extended search by default.
Using extended search is less performant than default search. Use this wisely.
#### Protect a specific record
```ruby theme={null}
if ForestLiana::UserSpace.const_defined?('CompanyController')
ForestLiana::UserSpace::CompanyController.class_eval do
alias_method :default_destroy, :destroy
def destroy
if params["id"] == "50"
render status: 403, plain: 'This record is protected, you cannot remove it.'
else
default_destroy
end
end
end
end
```
### Replacing Forest's behavior
To achieve this, simply remove the `next()` statement of any route:
```ruby theme={null}
if ForestLiana::UserSpace.const_defined?('CompanyController')
ForestLiana::UserSpace::CompanyController.class_eval do
# Create a Company
def create
# >> Add your own logic here <<
end
end
end
```
For instance, if you have a `Users` collection, you might want to create your users via your own api:
```ruby theme={null}
require 'net/http'
require 'uri'
if ForestLiana::UserSpace.const_defined?('UserController')
ForestLiana::UserSpace::UserController.class_eval do
# Create a User
def create
forest_authorize!('add', forest_user, @resource)
begin
response = Net::HTTP.post URI('https:///users'), params.to_json, "Content-Type" => "application/json"
render serializer: nil, json: render_record_jsonapi(response.body)
rescue => errors
render serializer: nil, json: JSONAPI::Serializer.serialize_errors(errors), status: 400
end
end
end
end
```
# Routes
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/routes/overview
### What is a route?
A route is simply the mapping between an API endpoint and the business logic behind this endpoint.
### Default routes
Forest comes packaged with a set of existing routes, which execute Forest's default logic. The most common ones are :
| Route | Default behavior |
| ------------------------------------------ | ----------------------------- |
| `router.post('/companies', …` | Create a company |
| `router.put('/companies/:companyId', …` | Update a company |
| `router.delete('/companies/:companyId', …` | Delete a company |
| `router.get('/companies/:companyId', …` | Get a company |
| `router.get('/companies', …` | List all companies |
| `router.get('/companies/count', …` | Count the number of companies |
| `router.get('/companies.csv', …` | Export all companies |
Very often, you’ll need to call business logic from another backend application. This is why in Forest, **all your admin backend's routes are extendable**.
At installation, they are generated in `/routes`.
Note that for any collection added **after** installation, you will have to create a new `your_collection_name.js` file in `/routes`.
The generated routes use `next()` to call Forest's default behavior.
If you need more details on what each default route does, check out this page:
To learn **how to extend a route's behavior**, read this page:
To learn **how to override a route's behavior**, read this page:
If you want to trigger logic unrelated to Forest's basic routes (create, update, etc), head over to our [Smart actions](/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/overview#what-is-a-smart-action) page.
# Create a scope more than one level away based on a Smart field
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/scopes/create-a-scope-more-than-one-level-away-based-on-a-smart-field
**Context:** As a user I want to create a scope on a table that does not have the tag column in the table.
As a user I want to create a scope on related tables more than one level away
**Example:**
The objective is to implement scopes on all tables, filtering on`companies` to make sure that companies can only see their own data. In this example, `companies` has many `departments`, `departments` has many `users`. The company id is not in `users` table but in the `departments` table. We want to scope `users` according to a company value.
### **Step 1: Create a smart field and the filter for the `users` table**
```ruby theme={null}
class Forest::Customer
include ForestLiana::Collection
collection :User
filter_company = lambda do |condition, where|
company_value = condition['value']
case condition['operator']
when 'equal'
"users.id IN (SELECT users.id
FROM users
JOIN departments ON departments.id = users.department_id
JOIN companies ON companies.id = departments.company_id
WHERE companies.name = '#{company_value}')"
end
end
field :company, type: 'String', is_filterable: true, filter: filter_company do
company = User.find(object.id).department.company
"#{company.name}"
end
end
```
### **Step 2: Configure the scope in the UI**
In project settings:
In the table `users`
# Scopes
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/scopes/overview
### What is a scope?
A scope is a filter which applies to a collection and all its segments.
It is useful in that it can be used to control what data is available to users. More specifically, scopes can be set up to filter data dynamically on the current user.
**Scopes** are applied to the entire application excluding global smart actions, API & SQL charts and Collaboration & Activities.
### Using a dynamic scope
Imagine a situation where you have several Operations teams each specialized in a specific country's operations:
* *France* team handles customers from France
* *Germany* team handles customers from Germany
* ...
By scoping the collection on `$currentUser.team.name`, Marc who belongs to the *France* team will only see customers from France, while Louis who belongs to the *Germany* team will only see customers from Germany.
#### Dynamic variables
In the example above, we used the team name to filter out what the user sees: `$currentUser.team.name`
Here the exhaustive list of available dynamic variables:
| Syntax | Result |
| ---------------------------- | ---------------------------------------------------------------------- |
| `$currentUser.id` | The id of the current user |
| `$currentUser.firstName` | The first name of the current user |
| `$currentUser.lastName` | The last name of the current user |
| `$currentUser.fullName` | The full name of the current user |
| `$currentUser.email` | The email of the current user |
| `$currentUser.team.id` | The id of the team of the current user |
| `$currentUser.team.name` | The name of the team of the current user |
| `$currentUser.tags.your-tag` | The value associated with key `your-tag` for the current user, if any. |
#### Using user tags
The above example is only possible if your data matches your users' details (email, team, etc). It's likely that it won't always be the case. This is why we've introduced user tags.
User tags are set from each user's details page and allow you to freely associate your users to a value which will match against your data using the `$currentUser.tags.your-tag` dynamic variable.
# Scope on a smart field extracting a json's column attribute
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/scopes/scope-on-a-smart-field-extracting-a-jsons-column-attribute
**Context**: As a user, I want to scope a table's records based on the value of an attribute nested within a json column.
**Example**: I have a table `users` that includes a JSONB column named `contact`. The `contact` json can include a `phone`, `email` or `country` attribute. Since I want to scope my collection by `country`, I created a smart field called `country` that returns the value of the country attribute and I implemented a filter feature for this field.
### Implementation
The smart field definition and the filtering logic are defined as follows in the `forest/users.js` file of my admin backend.
In order to make your smart field filterable in the UI, you both need to add the `isFilterable: true` option in the field's declaration and to enable filtering on this field in the field settings in the UI.
# Examples
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-collections/examples/README
# Create a Smart Collection with Amazon S3
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-collections/examples/amazon-s3-integration-example
### Creating the Smart Collection
On our Live Demo, we’ve stored the `Legal Documents` of a `Company` on Amazon S3. In the following example, we show you how to create the Smart Collection to see and manipulate them in your Forest admin.
### Implementing the GET (all records)
At this time, there’s no Smart Collection Implementation because no route in your admin backend handles the API call yet.
### Implementing the GET (specific record)
### Implementing the PUT
To handle the update of a record we have to catch the PUT API call. In our example, all S3-related fields are set as read-only and only `is_verified` can be updated.
### Implementing the DELETE
Now we are able to see all the legal documents on Forest, it’s time to implement the DELETE HTTP method in order to remove the documents on S3 when the admin user needs it.
### Implementing the POST
On our Live Demo example, creating a record directly from this Smart Collection does not make any sense because the admin user will upload the legal docs in the company details view. For the documentation purpose, we catch the call and returns an appropriate error message to the admin user.
# Create records from a Smart collection
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-collections/examples/create-records-from-a-smart-collection
**Context**: As a user I want to be able to add new records to a number of collections based on the input made in a smart collection creation form
Example: In this example I have the following data model:
Property ← building ← lot → owner
The smart collection called `ownerProperties` features records including:
* the firstName and lastName of the owner
* the reference of the property
In my use case I want to be able to create a new lot, owner, building and property based on the input of the form.
### Definition of the smart collection
The smart collection is declared this way in a `forest/owner-properties.js` file.
### Definition of the routes
Below is the `routes/owner-properties.js` file that includes the logic for the `GET` and `POST` calls made on the smart collection.
The objects that are serialized to be returned to the UI are constructed as such:
```jsx theme={null}
lots {
dataValues: {
id: 1,
lotNumber: 1,
buildingIdKey: 1,
ownerIdKey: 1,
owner: owners {
dataValues: {
id: 1,
firstName: 'Pete',
lastName: 'Maravich',
email: 'user@example.com'
},
...
building: buildings {
dataValues: {
id: 1,
name: 'Sevres',
addressLine1: '80 rue de Sevres',
number: 1,
centralHeating: true,
propertyIdKey: 1,
property: properties {
dataValues: {
id: 1,
name: 'Laennec',
addressCity: 'Paris',
addressLine1: '102 rue de Sevres',
numberOfBuildings: 6,
status: null
},
...
}
},
...
}
```
### Make the smart collection visible and enable the create form
By default, a smart collection newly created is hidden in the UI, does not enable create, update and delete operations and all its fields are set as read only.
To make the collection fully functional in the UI you need to following these steps:
# Searchable smart collection with records fetched from hubspot API
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-collections/examples/searchable-smart-collection-with-records-fetched-from-hubspot-api
**Context:** Create a smart collection fetching the 10 first companies records from hubspot or the ones matching a search criteria
First step is to declare the collection and the fields that should be expected to be found for this collection.
Next step is to define the logic to retrieve the data of the smart collection in a `routes/your-model.js` file.
You first need to set variables according to the context to ensure the query follows the UX (nb of records per page, index of the page you're on, search performed or not)
You then need to define a serializer adapted to the format of the data that will be passed and the expected fields of the collection.
Finally you need to implement the API call, serialize the data obtained, filter depending on the search performed and return the payload.
NB: I used the `superagent` module for the API call
# Smart relationship between model and stripe cards
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-collections/examples/smart-relationship-between-model-and-stripe-cards
**Context**: as a user I want to display stripe cards associated to a user using the Stripe API.
### Implementation
First step is to declare the smart collection user\_stripe\_cards in a `user-stripe-cards.js` file in the forest folder.
Next step is to add the smart relationship between users and stripe cards in the `forest/users.js` file.
# Smart Collections
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-collections/overview
### What is a Smart Collection?
A Smart Collection is a Forest Collection based on your API implementation. It allows you to reconcile fields of data coming from different or external sources in a single tabular view (by default), without having to physically store them into your database.
Fields of data could be coming from many other sources such as other B2B SaaS (e.g. Zendesk, Salesforce, Stripe), in-memory database, message broker, etc.
This is an **advanced** notion. If you're just starting with Forest, you should skip this for now.
In the following example, we have created a **Smart Collection** called `customer_stats`allowing us to see all customers who have placed orders, the number of order placed and the total amount of those orders.
**For an example of advanced customization and featuring an Amazon S3 integration,** you can see [here](/legacy/ruby-agent/reference-guide/smart-collections/examples/amazon-s3-integration-example) how we've stored in our live demo the companies' legal documents on Amazon S3 and how we've implemented a **Smart Collection** to access and manipulate them.
### Creating a Smart Collection
First, we declare the `CustomerStat` collection in the `lib/forest-liana/collections/` directory.
In this Smart Collection, we want to display for each customer its email address, the number of orders made (in a field `orders_count`) and the sum of the price of all those orders (in a field `total_amount`).
You can check out the list of [available field options ](/legacy/ruby-agent/reference-guide/smart-fields/overview#available-field-options)if you need it.
You **MUST** declare an `id` field when creating a Smart Collection. The value of this field for each record **MUST** be unique.
As we are using the *customer id* in this example, we do not need to declare an `id` manually.
```ruby theme={null}
class Forest::CustomerStat
include ForestLiana::Collection
collection :CustomerStat, is_searchable: true
field :id, type: 'Number', is_read_only: true
field :email, type: 'String', is_read_only: true
field :orders_count, type: 'Number', is_read_only: true
field :total_amount, type: 'Number', is_read_only: true
end
```
The option`is_searchable: true` added to your collection allows to display the search bar. Note that you will have to implement the search yourself by including it into your own `get` logic in your collection controller.
First, we declare the `CustomerStat` collection in the `app/forest/customer_stat.py` file.
In this Smart Collection, we want to display for each customer its email address, the number of orders made (in a field `orders_count`) and the sum of the price of all those orders (in a field `total_amount`).
You can check out the list of [available field options ](/legacy/ruby-agent/reference-guide/smart-fields/overview#available-field-options)if you need it.
You **MUST** declare an `id` field when creating a Smart Collection. The value of this field for each record **MUST** be unique.
As we are using the *customer id* in this example, we do not need to declare an `id`
Ensure the file app/forest/\_\_init\_\_.py exists and contains the import of the previous defined class :
The option`is_searchable = True` added to your collection allows to display the search bar. Note that you will have to implement the search yourself by including it into your own `get` logic in your collection controller.
First, we declare the `CustomerStat` collection in the `app/Models/SmartCollections/CustomerStat.php` file.
In this Smart Collection, we want to display for each customer its email address, the number of orders made (in a field `orders_count`) and the sum of the price of all those orders (in a field `total_amount`).
You can check out the list of [available field options ](/legacy/ruby-agent/reference-guide/smart-fields/overview#available-field-options)if you need it.
You **MUST** declare an `id` field when creating a Smart Collection. The value of this field for each record **MUST** be unique.
As we are using the *customer id* in this example, we do not need to declare an `id`
The option`is_searchable = True` added to your collection allows to display the search bar. Note that you will have to implement the search yourself by including it into your own `get` logic in your collection controller.
### Implementing the GET (all records)
At this time, there’s no Smart Collection Implementation because no route in your app handles the API call yet.
In the repository `lib/forest_liana/controllers/`, we’ve created a controller file `customer_stats.rb` to implement API behind the Smart Collection.
The logic here is to index all the customers that have made orders (with their email), to count the number of orders made and to sum up the price of all the orders.
The `limit` and `offset` variables are used to paginate your collection according to the number of records per page set in your UI.
We have implemented a **search logic** to catch if a search query (accessible through `params[:search]`) has been performed and to return all records for which the `email` field matches the search.
Finally, the last step is to serialize the response data in the expected format which is simply a standard [JSON API](http://jsonapi.org) document. We use the [JSON API Serializer](https://github.com/fotinakis/jsonapi-serializers) library for this task.
```ruby theme={null}
class Forest::CustomerStatsController < ForestLiana::ApplicationController
require 'jsonapi-serializers'
before_action :set_params, only: [:index]
class BaseSerializer
include JSONAPI::Serializer
def type
'customerStat'
end
def format_name(attribute_name)
attribute_name.to_s.underscore
end
def unformat_name(attribute_name)
attribute_name.to_s.dasherize
end
end
class CustomerStatSerializer < BaseSerializer
attribute :email
attribute :total_amount
attribute :orders_count
end
def index
customers_count = Customer.count_by_sql("
SELECT COUNT(*)
FROM customers
WHERE
EXISTS (
SELECT *
FROM orders
WHERE orders.customer_id = customers.id
)
AND email LIKE '%#{@search}%'
")
customer_stats = Customer.find_by_sql("
SELECT customers.id,
customers.email,
count(orders.*) AS orders_count,
sum(products.price) AS total_amount,
customers.created_at,
customers.updated_at
FROM customers
JOIN orders ON customers.id = orders.customer_id
JOIN products ON orders.product_id = products.id
WHERE email LIKE '%#{@search}%'
GROUP BY customers.id
ORDER BY customers.id
LIMIT #{@limit}
OFFSET #{@offset}
")
customer_stats_json = CustomerStatSerializer.serialize(customer_stats, is_collection: true, meta: {count: customers_count})
render json: customer_stats_json
end
private
def set_params
@limit = params[:page][:size].to_i
@offset = (params[:page][:number].to_i - 1) * @limit
@search = sanitize_sql_like(params[:search]? params[:search] : "")
end
def sanitize_sql_like(string, escape_character = "\\")
pattern = Regexp.union(escape_character, "%", "_")
string.gsub(pattern) { |x| [escape_character, x].join }
end
end
```
You then need to create a route pointing to your collection's index action to get all your collection's records.
```ruby theme={null}
Rails.application.routes.draw do
# MUST be declared before the mount ForestLiana::Engine.
namespace :forest do
get '/CustomerStat' => 'customer_stats#index'
end
mount ForestLiana::Engine => '/forest'
end
```
First we will add the right path to the **urls.py** file
Then we will create the pertained view
Create a controller `CustomerStatsController`
Then add the route.
Now we are all set, we can access the Smart Collection as any other collection.
In this example we have only implemented the **GET all records** action but you can also add the following actions: **GET specific records**, **PUT, DELETE** and **POST**. These are shown in the next page explaining how a Smart Collection can be used to access and manipulate data stored in Amazon S3.
# Serializing your records
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-collections/serializing-your-records
To be interpreted correctly by the ForestAdmin UI, the data must be sent from your admin backend using a particular structure.\
\
This structure needs to comply to the JSON API standard. The JSON API standard is used to ensure a standardized way to format JSON responses returned to clients. You can find some more information directly from their [website](https://jsonapi.org/).\
\
Most of the time, your admin backend will handle this for you, and you will not have to play with serialization. However you might encounter specific use cases that will require you to serialize data yourself, such as smart collections for example.
In order to help you do so, the helper `RecordSerializer` is made available through the packages built-in your admin panel.
### Initializing the record serializer
### Example 1 - Smart collection with simple fields
Let's take a look at the collection defined in the documentation's [smart collection example](/legacy/ruby-agent/reference-guide/smart-collections/overview):
### Example 2 - Smart collection example with an added belongsTo relationship
Now let's say we want to reference the customer related to a stat instead of just displaying its `email`. We would then adapt the smart collection definition to include a field `customer` referencing the `customers` collection:
# Smart Fields
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-fields/overview
### What is a Smart Field?
A field that displays a computed value in your collection.
A Smart Field is a column that displays processed-on-the-fly data. It can be as simple as concatenating attributes to make them human friendly, or more complex (e.g. total of orders).
### Creating a Smart Field
On our Live Demo, the very simple Smart Field `fullname` is available on the `Customer` collection.
```ruby theme={null}
class Forest::Customer
include ForestLiana::Collection
collection :Customer
field :fullname, type: 'String' do
"#{object.firstname} #{object.lastname}"
end
end
```
Very often, the business logic behind the Smart Field is more complex and must interact with the database. Here’s an example with the Smart Field `full_address` on the `Customer` collection.
```ruby theme={null}
class Forest::Customer
include ForestLiana::Collection
collection :Customer
field :full_address, type: 'String' do
address = Address.find_by(customer_id: object.id)
"#{address[:address_line_1]} #{address[:address_line_2]} #{address[:address_city]} #{address[:country]}"
end
end
```
On our Live Demo, the very simple Smart Field `fullname` is available on the `Customer` collection.
Ensure the file app/forest/\_\_init\_\_.py exists and contains the import of the previous defined class :
Very often, the business logic behind the Smart Field is more complex and must interact with the database. Here’s an example with the Smart Field `full_address` on the `Customer` collection.
On our Live Demo, the very simple Smart Field `fullname` is available on the `Customer` model.
Very often, the business logic behind the Smart Field is more complex and must interact with the database. Here’s an example with the Smart Field `full_address` on the `Customer` model.
The collection name must be the same as the **model name**.
### Updating a Smart Field
By default, your Smart Field is considered as read-only. If you want to update a Smart Field, you just need to write the logic to “unzip” the data. **Note that the set method should always return the object it’s working on**. In the example hereunder, the `user_params` is returned is returned including only the modified data.
```ruby theme={null}
class Forest::Customer
include ForestLiana::Collection
collection :Customer
set_fullname = lambda do |user_params, fullname|
fullname = fullname.split
user_params[:firstname] = fullname.first
user_params[:lastname] = fullname.last
# Returns a hash of the updated values you want to persist.
user_params
end
field :fullname, type: 'String', set: set_fullname do
"#{object.firstname} #{object.lastname}"
end
end
```
For security reasons, the `fullname` Smart field will remain **read-only**, even after you implement the `set` method. To edit it, disable read-only mode in the field settings.
By default, your Smart Field is considered as read-only. If you want to update a Smart Field, you just need to write the logic to “unzip” the data. **Note that the `set` method should always return the object it’s working on**. In the example hereunder, the `customer` object is returned including only the modified data.
### Searching, Sorting and Filtering on a Smart Field
To perform a search on a Smart Field, you also need to write the logic to “unzip” the data, then the search query which is specific to your zipping. In the example hereunder, the `firstname` and `lastname` are searched separately after having been unzipped.
```ruby theme={null}
class Forest::Customer
include ForestLiana::Collection
collection :Customer
search_fullname = lambda do |query, search|
firstname, lastname = search.split
# Injects your new filter into the WHERE clause.
query.where_clause.send(:predicates)[0] << " OR (firstname = '#{firstname}' AND lastname = '#{lastname}')"
query
end
field :fullname, type: 'String', set: set_fullname, search: search_fullname do
"#{object.firstname} #{object.lastname}"
end
end
```
#### Filtering
This feature is only available on agents version **6.7+** (version **6.2+** for Rails).
To perform a filter on a Smart Field, you need to write the filter query logic, which is specific to your use case.
In the example hereunder, the `fullname` is filtered by checking conditions on the `firstname` and `lastname` depending on the filter operator selected.
```ruby theme={null}
class Forest::Customer
include ForestLiana::Collection
collection :Customer
filter_fullname = lambda do |condition, where|
first_word = condition['value'] && condition['value'].split[0]
second_word = condition['value'] && condition['value'].split[1]
case condition['operator']
when 'equal'
"firstname = '#{first_word}' AND lastname = '#{second_word}'"
when 'ends_with'
if second_word.nil?
"lastname LIKE '%#{first_word}'"
else
"firstname LIKE '%#{first_word}' AND lastname = '#{second_word}'"
end
# ... And so on with the other operators not_equal, starts_with, etc.
end
end
field :fullname, type: 'String', is_read_only: false, is_required: true, is_filterable: true, filter: filter_fullname do
"#{object.firstname} #{object.lastname}"
end
end
```
Make sure you set the option `isFilterable: true` in the field definition of your code. Then, you will be able to toggle the "Filtering enabled" option in the browser, in your **Fields Settings**.
#### Sorting
**Sorting** on a Smart Field is not *natively supported* in Forest. However you can check out those guides:
* [Sort by Smart field](/legacy/ruby-agent/reference-guide/smart-fields/smart-field-examples/sort-by-smart-field)
* [Sort by Smart field that includes value from a belongsTo relationship](/legacy/ruby-agent/reference-guide/smart-fields/smart-field-examples/sort-by-smart-field-that-includes-value-from-a-belongsto-relationship)
### Available Field Options
Here are the list of available options to customize your Smart Field:
| Name | Type | Description |
| ----------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| field | string | The name of your Smart Field. |
| type | string | Type of your field. Can be `Boolean`, `Date`, `Json`,`Dateonly`, `Enum`, `File`, `Number, ['String']` or `String` . |
| enums | array of strings | (optional) Required only for the `Enum` type. This is where you list all the possible values for your input field. |
| description | string | (optional) Add a description to your field. |
| reference | string | (optional) Configure the Smart Field as a [Smart Relationship](/legacy/ruby-agent/reference-guide/models/relationships/overview#what-is-a-smart-relationship). |
| isReadOnly | boolean | (optional) If `true`, the Smart Field won’t be editable in the browser. Default is `true` if there’s no `set` option declared. |
| isRequired | boolean | (optional) If true, your Smart Field will be set as required in the browser. Default is false. |
You can define a widget for a smart field from the [settings of your collection](https://docs.forestadmin.com/user-guide/collections/customize-your-fields).
### Building Performant Smart Fields
To optimize your smart field performance, we recommend using a mechanism of batching and caching data requests.
Implement them using the DataLoader which is a generic utility to be used as part of your application's data fetching layer to provide a simplified and consistent API over various remote data sources.
#### Smart field declaration
####
# Smart Field Examples
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-fields/smart-field-examples/README
# Add an HTML credit card as a smart field in a summary view
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-fields/smart-field-examples/add-an-html-credit-card-as-a-smart-field-in-a-summary-view
**Context:** As a user I want to display the credit card infos of a client in a nice and visual way
`forest/companies.js`
Use the rich text editor widget in order to interpret HTML in your field.
# Add fields destined to the create form
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-fields/smart-field-examples/add-fields-destined-to-the-create-form
**Context**: As a user I want to be able to pass information to a create form that concerns other collections than the current one.
The use case would be for the creation of a given record to add the information needed to create a parent record if it doesn't exist yet.
**Example**: I have a collection `lots` that belongsTo a collection `buildings` and a collection `owners`. If when I create a lot, the owner and building record it should belong to do not exist yet, I want to have input fields available in the lot create form so I can create them along with the lot in a single API call.
### Add smart fields that will be used as input fields in the form
You can declare smart fields that will not be meant to display any information but solely to serve as input fields.
In my example the fields are declared as follows in the `forest/lots.js` file:
When you add the fields, you can hide them in the UI and make them visible only in the create form. As you want the user to be able to search within the existing records of the parent collection you can keep the reference fields natively generated. But if the records don't exist they can fill in the input fields.
\
Demo video available here ⇒[https://www.loom.com/share/da44ee3c886e4f90a7768fdbfe4b462d?from\_recorder=1](https://www.loom.com/share/da44ee3c886e4f90a7768fdbfe4b462d?from_recorder=1)
## Catch the input at the route level
Now you can check if an input has been provided and use it following your own custom logic.
```jsx theme={null}
router.post(
'/lots',
permissionMiddlewareCreator.create(),
(request, response, next) => {
const attributes = request.body.data.attributes;
// do what you want with the user input
}
);
```
Reprising the form shown in the video above, the attributes object looks like this:
```javascript theme={null}
{
newBuildingAddressLine1: '2 street test',
newBuildingName: 'New building',
newBuildingNumber: '2',
newOwnerEmail: 'toto@mail.com'
}
```
# Add validation to a smart field edition
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-fields/smart-field-examples/add-validation-to-a-smart-field-edition
**Context**: I want to make sure that my users can only enter a value satisfying a certain set of conditions when editing a smart field.
Here I'm working on a collection `customers` and the smart field `'must-be-kuku'` should only accept the value `kuku`.
`forest/customers.js`
At the route level I need to check the user input from the edit form in the UI and check the value that has been entered into the field `'must-be-kuku'`.
`routes/customers.js`
# Display field with complex info in html format (rich text editor)
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-fields/smart-field-examples/display-field-with-complex-info-in-html-format-rich-text-editor
## First step: Display through html
Create a smart field that will return a string containing the html formatted data (here the features name and if they are enabled or not).
This smart field will be declared at the level of the account collection (as we want features status to be visible for each account). The file where the smart field should be declared is contained in a folder forest and should be `forest/accounts.js`
The logic is to add for each feature a new div which includes an element containing the name and an element conditionally formatted (green or red) containing the value true of false.
In order to do that you need to list the fields to iterate on to add the html elements.
# Display smart field as progress bar using rich text editor
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-fields/smart-field-examples/display-smart-field-as-progress-bar-using-rich-text-editor
# Generate signed urls to display S3 files in a smart field
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-fields/smart-field-examples/generate-signed-urls-to-display-s3-files-in-a-smart-field
**Context**: As a user I want to be able to preview files from an S3 bucket thanks to secure signed urls.
**Example**: I have a collection `places` that has a `pictures` field which is an array of strings containing the file name of files stored on a s3 bucket.
In a smart field called `s3pictures` I return the value of calls made to S3 to get signed urls for the files whose name is present in the `pictures` field.
### Implementation
First you need to implement the function to get the signed urls from s3. We use the `aws-sdk` npm package to connect to the bucket storing the pictures.
`services/s3-helper.js`
You can then use the default file viewer widget settings to preview the pictures.
# Print a status object in a single line field
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-fields/smart-field-examples/print-a-status-object-in-a-single-line-field
**Context**: as a user I want to display in a single field all the lines from a status object from a user's record.
Example of a user document:
`style/fields-style.js`
```javascript theme={null}
exports.customFieldsStyles = {
fieldDivStyle: 'margin: 24px 0px; color: #415574',
fieldNameStyle:
'padding: 6px 16px; margin: 12px; background-color:#b5c8d05e; border-radius: 6px',
fieldValueStyle: 'padding: 6px 16px; margin: 12px; border-radius: 6px',
fieldValueStyleRed:
'padding: 6px 12px; background-color:#ff7f7f87; border-radius: 6px',
fieldValueStyleGreen:
'padding: 6px 12px; background-color:#7FFF7F; border-radius: 6px',
};
```
# Sort by smart field
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-fields/smart-field-examples/sort-by-smart-field
Context: as a user, I want to be able to sort a collection based on a smart field. This example is based on [the one provided in the documentation](https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields#creating-a-smart-field) with a simple concatenation of 2 fields existing in the collection.
We have a `customers` collection with a field `firstname` and field `lastname`. We create a smart field `fullname` that is a concatenation of the two fields.
#### **Smart field definition**
In order to make the field sortable, you need to add the `isSortable` attribute.
`forest/customers.js`
```jsx theme={null}
{
field: 'fullname',
type: 'String',
isSortable: true,
get: (customer) => {
return customer.firstname + ' ' + customer.lastname;
},
},
```
#### **Route definition**
At the level of the route, you need to catch the query and redirect the sort field from one that does not exist in the database (`fullname`) to the relevant one (`firstname`)
`routes/customers.js`
```javascript theme={null}
router.get(
'/customers',
permissionMiddlewareCreator.list(),
(request, response, next) => {
// Learn what this route does here: https://docs.forestadmin.com/documentation/v/v6/reference-guide/routes/default-routes#get-a-list-of-records
let sort;
switch (request.query.sort) {
case '-fullname':
sort = '-firstname';
break;
case 'fullname':
sort = 'firstname';
break;
default:
sort = request.query.sort;
}
request.query.sort = sort;
next();
}
);
```
# Sort by smart field that includes value from a belongsTo relationship
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-fields/smart-field-examples/sort-by-smart-field-that-includes-value-from-a-belongsto-relationship
**Context**: As a user I want to be able to sort records based on a smart field where the smart field includes data from the current record's parent.
**Example**: Here I have a model `orders` that has a belongsTo relationship with the `customers` model.
I have a smart field in the `orders` model called `customer email` that returns the value of the parent customer's email field. I want to sort the orders by the `customer email` smart field.
### Implementation
`forest/orders.js`
`routes/orders.js`
```javascript theme={null}
router.get(
'/orders',
permissionMiddlewareCreator.list(),
(request, response, next) => {
if (request.query.sort.includes('customer email')) {
request.query.sort = request.query.sort.includes('-')
? '-customer.email'
: 'customer.email';
}
next();
}
);
```
# Update point geometry field using a smart field and algolia api
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-fields/smart-field-examples/update-point-geometry-field-using-a-smart-field-and-algolia-api
Algolia is sunsetting its Place services. We recommend that you use the Google service instead. [Learn more](https://www.algolia.com/blog/product/sunsetting-our-places-feature/).
**Description**: I need to fill in 2 fields in my db for a location: address 1 (a string) and location (a postresql geography point). Although a [widget ](https://docs.forestadmin.com/user-guide/collections/customize-your-fields/edit-widgets)with autocomplete exists to fill an address string in the UI, the location coordinates can only be obtained manually by looking up the address in our search engine which is not optimal.create smart field to edit point field using the address widget and algolia API.
**Approach chosen**: Create a smart field in your Forest backend app that will serve as the input field.
# Smart Segments
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-segments
### What is a Smart Segment?
A **Segment** is a subset of a collection: it's basically a saved filter of your collection.
Segments are designed for those who want to *systematically* visualize data according to specific sets of filters. It allows you to save your filters configuration so you don’t have to compute the same actions every day.
A **Smart Segments** is useful when you want to use a complex filter, which you'll add as code in your backend.
### Creating a Smart Segment
Sometimes, segment filters are complicated and closely tied to your business. Forest allows you to code how the segment is computed.
On our Live Demo example, we’ve implemented a Smart Segment on the collection `products` to allow admin users to see the bestsellers at a glance.
```ruby theme={null}
class Forest::Product
include ForestLiana::Collection
collection :Product
segment 'Bestsellers' do
productIds = Product.joins(:orders).group('products.id').order('count(orders.id)').limit(10).pluck('products.id')
{ id: productIds }
end
end
```
Ensure the file app/forest/\_\_init\_\_.py exists and contains the import of the previous defined class :
The 2nd parameter of the `SmartSegment` method is not required. If you don't fill it, the name of your SmartSegment will be the name of your method that wrap it.
### Setting up independent columns visibility
By default, Forest applies the same configuration to all segments of the same collection.
However, the *Independent columns configuration* option allows you to display different columns on your different segments.
# Create a Calendar view
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-views/create-a-calendar-view
The example below shows how to display a calendar view:
```javascript theme={null}
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { guidFor } from '@ember/object/internals';
import {
triggerSmartAction,
deleteRecords,
getCollectionId,
loadExternalStyle,
loadExternalJavascript,
} from 'client/utils/smart-view-utils';
export default class extends Component {
@service() router;
@service() store;
@tracked conditionAfter = null;
@tracked conditionBefore = null;
@tracked loaded = false;
constructor(...args) {
super(...args);
this.loadPlugin();
}
get calendarId() {
return `${guidFor(this)}-calendar`;
}
async loadPlugin() {
loadExternalStyle(
'https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.css'
);
await loadExternalJavascript(
'https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.js'
);
this.loaded = true;
this.onInsert();
}
@action
onInsert() {
if (!this.loaded || !document.getElementById(this.calendarId)) return;
this.calendar = new FullCalendar.Calendar(
document.getElementById(this.calendarId),
{
allDaySlot: false,
minTime: '00:00:00',
initialDate: new Date(2018, 2, 1),
eventClick: ({ event, jsEvent, view }) => {
this.router.transitionTo(
'project.rendering.data.collection.list.view-edit.details',
this.args.collection.id,
// This is not a mistake, you have to specify the collection twice
this.args.collection.id,
event.id
);
},
events: async (info, successCallback, failureCallback) => {
const field = this.args.collection.fields.findBy(
'fieldName',
'start_date'
);
if (this.conditionAfter) {
this.args.removeCondition(this.conditionAfter, true);
this.conditionAfter.unloadRecord();
}
if (this.conditionBefore) {
this.args.removeCondition(this.conditionBefore, true);
this.conditionBefore.unloadRecord();
}
const conditionAfter =
this.store.createFragment('fragment-condition');
conditionAfter.set('field', field);
conditionAfter.set('operator', 'is after');
conditionAfter.set('value', info.start);
conditionAfter.set('smartView', this.args.viewList);
this.conditionAfter = conditionAfter;
const conditionBefore =
this.store.createFragment('fragment-condition');
conditionBefore.set('field', field);
conditionBefore.set('operator', 'is before');
conditionBefore.set('value', info.end);
conditionBefore.set('smartView', this.args.viewList);
this.conditionBefore = conditionBefore;
this.args.addCondition(conditionAfter, true);
this.args.addCondition(conditionBefore, true);
await this.args.fetchRecords({ page: 1 });
successCallback(
this.args.records?.map((appointment) => {
return {
id: appointment.get('id'),
title: appointment.get('forest-name'),
start: appointment.get('forest-start_date'),
end: appointment.get('forest-end_date'),
};
})
);
},
}
);
this.calendar.render();
}
@action
triggerSmartAction(...args) {
return triggerSmartAction(this, ...args);
}
@action
deleteRecords(...args) {
return deleteRecords(this, ...args);
}
}
```
```css theme={null}
.calendar {
padding: 20px;
background: var(--color-beta-surface);
height: 100%;
overflow: scroll;
}
.calendar .fc-toolbar.fc-header-toolbar .fc-left {
font-size: 14px;
font-weight: bold;
}
.calendar .fc-day-header {
padding: 10px 0;
background-color: var(--color-beta-secondary);
color: var(--color-beta-on-secondary_dark);
}
.calendar .fc-event {
background-color: var(--color-beta-secondary);
border: 1px solid var(--color-beta-on-secondary_border);
color: var(--color-beta-on-secondary_medium);
font-size: 14px;
}
.calendar .fc-day-grid-event {
background-color: var(--color-beta-info);
color: var(--color-beta-on-info);
font-size: 10px;
border: none;
padding: 2px;
}
.calendar .fc-day-number {
color: var(--color-beta-on-surface_medium);
}
.calendar .fc-other-month .fc-day-number {
color: var(--color-beta-on-surface_disabled);
}
.fc-left {
color: var(--color-beta-on-surface_dark);
}
.c-smart-view {
display: flex;
white-space: normal;
position: absolute;
bottom: 0;
left: 0;
right: 0;
top: 0;
background-color: var(--color-beta-surface);
}
.c-smart-view__content {
margin: auto;
text-align: center;
color: var(--color-beta-on-surface_medium);
}
.c-smart-view_icon {
margin-bottom: 32px;
font-size: 32px;
}
```
```html theme={null}
```
# Create a custom moderation view
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-views/create-a-custom-moderation-view
This example shows you how you can implement a moderation view with a custom Approve/Reject workflow.
In our example, we want to Approve or Reject products to moderate content on our website:
* We want to preview products images
* We want to bulk Approve/Reject products
## How it works
### Smart view definition
Learn more about [smart views](/legacy/ruby-agent/reference-guide/smart-views/overview).\
\
**File template.hbs**
This file contains the HTML and CSS needed to build the view.
### Template
```css theme={null}
Product details
Images
\{\{#each this.formattedRecords as |record|\}\}
\{\{record.forest-name\}\}
\{\{record.forest-state\}\}
\{\{#each record.forest-imagesSF as |image|\}\}
\{\{/each\}\}
\{\{/each\}\}
```
# Create a custom tinder-like validation view
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-views/create-a-custom-tinder-like-validation-view
This example shows you how you can implement a time-saving profile validation view using keyboard keys to trigger approve/reject actions.
In our example, we want to Approve or Reject new customers profiles and more specifically:
* We want to preview information from the user's profile
* We want to approve a customer by pressing the ArrowRight key
* We want to reject a customer by pressing the ArrowLeft key
## How it works
### Models definition
Here is the definition of the underlying model for this view
### Smart view definition
Learn more about [smart views](/legacy/ruby-agent/reference-guide/smart-views/overview).\
\
This file contains the HTML, JS and CSS needed to build the view.
### Template
```css theme={null}
\{\{#if (eq @recordsCount 0)\}\}
\{\{@collection.pluralizedDisplayName\}\}
There are no items to process.
\{\{/if\}\}
\{\{#unless (eq @recordsCount 0)\}\}
\{\{#each @records as |record|\}\}
name : \{\{record.forest-firstname\}\} \{\{record.forest-lastname\}\}
```
# Create a dynamic calendar view for an event-booking use case
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-views/create-a-dynamic-calendar-view-for-an-event-booking-use-case
This example shows you how you can implement a calendar view with a custom workflow involving dynamic API calls.
In our example, we want to manage the bookings for a sports court where:
* We have a list of court opening dates. Each date can be subject to a price increase if the period is busy. These dates [come from a collection](https://docs.forestadmin.com/woodshop/how-tos/create-a-custom-view#available-dates-model) called `availableDates`
* A list of available slots appears after selecting a date and duration. These available slots [come from a smart collection](https://docs.forestadmin.com/woodshop/how-tos/create-a-custom-view#available-slots-smart-collection) called `availableSlots`
* The user can book a specific slot [using a smart action](https://docs.forestadmin.com/woodshop/how-tos/create-a-custom-view#book-smart-action) called`book`.
## How it works
### Smart view definition
Learn more about [smart views](https://docs.forestadmin.com/documentation/reference-guide/views/create-and-manage-smart-views#creating-a-smart-view).\
\
**File template.hbs**
This file contains the HTML and CSS needed to build the view.
```markup theme={null}
\{\{else\}\}
\{\{#if (not this.selectedDate)\}\}
Please select a date to see slots available.
\{\{else\}\}
No slots available, please try another duration or another date.
\{\{/if\}\}
\{\{/if\}\}
```
**File template.js**
This file contains all the logic needed to handle events and actions.
**File routes/available-slots.js**
This file includes the logic implemented to retrieve the available slots from an API call and return them serialized to the UI.
### Book smart action
To create the action to book a slot, two files need to be updated:
* the file `available-slots.js` inside the folder `forest` to declare the action
* the file `available-slots.js` inside the `routes` folder to implement the logic for the action
**File forest/available-slots.js**
This file includes the smart action definition. The action form is pre-filled with the start and end date. The last step is to select the user associated with this booking.
**File routes/available-slots.js**
This file includes the logic of the smart action. It basically creates a record from the `bookings` collection with the information passed on by the user input form.
# Create a Gallery view
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-views/create-a-gallery-view
### Ember
```javascript theme={null}
import Component from '@glimmer/component';
import { action } from '@ember/object';
import {
triggerSmartAction,
deleteRecords,
getCollectionId,
loadExternalStyle,
loadExternalJavascript,
} from 'client/utils/smart-view-utils';
export default class extends Component {
@action
triggerSmartAction(...args) {
return triggerSmartAction(this, ...args);
}
@action
deleteRecords(...args) {
return deleteRecords(this, ...args);
}
}
```
### React
```jsx theme={null}
import React from 'react';
import WithEmberSupport from 'ember-react-components';
import { inject as service } from '@ember/service';
@WithEmberSupport
export default class extends React.Component {
@service router;
render() {
const {
records,
collection,
numberOfPages,
recordsCount,
currentPage,
searchValue,
isLoading,
fetchRecords,
} = this.props;
const goBack = () => {
if (currentPage > 1) {
return fetchRecords({ page: currentPage - 1 })
}
};
const goNext = () => {
if (currentPage < numberOfPages) {
return fetchRecords({ page: currentPage + 1 })
}
};
const redirectToRecord = (record) => this.transitionTo(
'project.rendering.data.collection.list.view-edit.details',
collection.id,
record.id,
);
return (
```
# Smart Views
Source: https://docs.forest.app/legacy/ruby-agent/reference-guide/smart-views/overview
## What is a Smart View?
Smart Views lets you code your view using JS, HTML, and CSS. They are taking data visualization to the next level. Ditch the table view and display your orders on a Map, your events in a Calendar, your movies, pictures and profiles in a Gallery. All of that with the easiness of Forest.
## Creating a Smart View
Forest provides an online editor to inject your Smart View code. The editor is available on the collection’s settings, then in the “Smart views” tab.
The code of a Smart View is a [Glimmer Component](https://guides.emberjs.com/release/upgrading/current-edition/glimmer-components/) and simply consists of a Template and Javascript code.
You don’t need to know the **Ember.js** framework to create a Smart View. We will guide you here on all the basic requirements. For more advanced usage, you can still refer to the [Glimmer Component](https://guides.emberjs.com/release/upgrading/current-edition/glimmer-components/) documentations.
Your code must be compatible with Ember 4.12.
### Getting your records
The records of your collection are accessible from the records property. Here’s how to iterate over them in the template section:
```markup theme={null}
\{\{#each @records as |record|\}\}
\{\{/each\}\}
```
### Accessing a specific record
For each record, you will access its attributes through the `forest-attribute` property. The `forest-` preceding the field name **is required**.
```markup theme={null}
\{\{#each @records as |record|\}\}
status: \{\{record.forest-shipping_status\}\}
\{\{/each\}\}
```
### Accessing belongsTo relationships
Accessing a `belongsTo` relationship works in exactly the same way as accessing a simple field. Forest triggers automatically an API call to retrieve the data from your Admin API only if it’s necessary.
On the `Shipping` Smart View (in the collection named `Order`) defined on our Live Demo example, we’ve displayed the full name of the customer related to an order.
```markup theme={null}
\{\{#each @records as |record|\}\}
Order to \{\{record.forest-customer.forest-firstname\}\} \{\{record.forest-customer.forest-lastname\}\}
\{\{/each\}\}
```
### Accessing hasMany relationships
Accessing a `hasMany` relationship works in exactly the same way as accessing a simple field.. Forest triggers automatically an API call to retrieve the data from your Admin API only if it’s necessary.
```markup theme={null}
\{\{#each @records as |record|\}\}
\{\{#each @record.forest-comments as |comment|\}\}
\{\{comment.forest-text\}\}
\{\{/each\}\}
\{\{/each\}\}
```
### Refreshing data
Trigger the `fetchRecords` action in order to refresh the records on the page.
```markup theme={null}
```
### Fetching data
Trigger an API call to your Admin API in order to fetch records from any collection and with any filters you want.
We will use the `store` service for that purpose. Check out the list of all available services from your Smart View.
In our Live Demo example, the collection `appointments` has a `Calendar` Smart View. When you click on the previous or next month, the Smart View fetches the new events in the selected month. The result here is set to the property`appointments`. You can access it directly from your template.
```javascript theme={null}
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class extends Component {
@service store;
@tracked appointments;
async fetchData(startDate, endDate) {
const params = {
filters: JSON.stringify({
aggregator: 'and',
conditions: [{
field: 'start_date',
operator: 'greater_than'
value: startDate,
}, {
field: 'start_date',
operator: 'less_than'
value: endDate,
}],
}),
timezone: 'America/Los_Angeles',
'page[number]': 1,
'page[size]': 50
};
this.appointments = await this.store.query('forest_appointment', params);
}
// ...
};
```
```markup theme={null}
\{\{#each this.appointments as |appointment|\}\}
\{\{appointment.id\}\}
\{\{appointment.forest-name\}\}
\{\{/each\}\}
```
#### Available parameters
| Parameter | Type | Description |
| ------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| filters | Object | A stringified JSON object containing either a filter or an aggregation of several filters. A filter has: `field`, `operator`, `value`. An aggregation has: `aggregator` (and/or), `conditions` (array). Available operators: `less_than`, `greater_than`, `equal`, `after`, `before`, `contains`, `starts_with`, `ends_with`, `not_contains`, `present`, `not_equal`, `blank` |
| timezone | String | The timezone string. Example: `America/Los_Angeles`. |
| page\[number] | Number | The page number you want to fetch. |
| page\[size] | Number | The number of records per page you want to fetch. |
### Deleting records
The `deleteRecords` action lets you delete one or multiple records. A pop-up will automatically ask for a confirmation when a user triggers the delete action.
```markup theme={null}
\{\{#each @records as |record|\}\}
\{\{/each\}\}
```
### Triggering a Smart Action
Please note that the smart action triggering in the context of the smart view editor can be broken as you might not have access to all the required information. We advise you to test the smart action execution from the smart view applied to the collection view.
Here’s how to trigger your [Smart Actions](/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/overview#what-is-a-smart-action) directly from your Smart Views.
### template.hbs
```markup theme={null}
```
### component.js
```javascript theme={null}
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { triggerSmartAction } from 'client/utils/smart-view-utils';
export default class extends Component {
@action
triggerSmartAction(...args) {
return triggerSmartAction(this, ...args);
}
@action
deleteRecords(...args) {
return deleteRecords(this, ...args);
}
}
```
`triggerSmartAction` function imported from `'client/utils/smart-view-utils'`has the following signature:
```javascript theme={null}
function triggerSmartAction(
context, collection, actionName, records, callback = () => {}, values = null,
)
```
| Argument name | Description |
| ------------- | ----------------------------------------------------------------------------------------------------------------------- |
| context | Context is the reference to the component, in the smart view it is accessible through the keyword `this` |
| collection | The `collection` that has the Smart Action |
| actionName | The Smart Action name |
| records | An array of records or a single one |
| callback | A function executed after the smart action that takes as the single parameter the result of the smart action execution. |
| values | An object containing the values to be passed for the smart action fields |
Here is an example of how to trigger the smart action with the values passed from the code, you only need to do it if you **don't** want to use the built-in [smart action form](/legacy/ruby-agent/reference-guide/actions/create-and-manage-smart-actions/use-a-smart-action-form)
### template.hbs
```markup theme={null}
```
### component.js
```javascript theme={null}
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { triggerSmartAction } from 'client/utils/smart-view-utils';
import { tracked } from '@glimmer/tracking';
export default class extends Component {
@tracked newTime = '11:00';
@action
triggerSmartAction(actionName, records, values) {
return triggerSmartAction(
this,
this.args.collection,
actionName,
records,
() => {},
values
);
}
@action
rescheduleToNewTime(record) {
this.triggerSmartAction('Reschedule', record, { newTime });
}
@action
deleteRecords(...args) {
return deleteRecords(this, ...args);
}
}
```
### Available properties
Forest automatically injects into your Smart View some properties to help you display your data like you want.
| Property | Type | Description |
| --------------- | ------- | ------------------------------------------------------ |
| `collection` | Model | The current collection. |
| `currentPage` | Number | The current page. |
| `isLoading` | Boolean | Indicates if the UI is currently loading your records. |
| `numberOfPages` | Number | The total number of available pages |
| `records` | array | Your data entries. |
| `searchValue` | String | The current search. |
### Available actions
Forest automatically injects into your Smart View some actions to trigger the logic you want.
| Action | Description |
| ---------------------------------------------------- | ----------------------------------------------------------------------- |
| `deleteRecords(records)` | Delete one or multiple records. |
| `triggerSmartAction(collection, actionName, record)` | Trigger a Smart Action defined on the specified collection on a record. |
## Applying a Smart View
To apply a Smart view you created, turn on the Layout Editor mode **(1)**, click on the table button **(2)** and drag & drop your Smart View's name in first position inside the dropdown **(3)**:
Your view will refresh automatically. You can now turn off the Layout Editor mode **(4)**.
### Impact on related data
Once your Smart view is applied, it will also be displayed in your record's related data.
#### In the related data section
#### In the summary view
As of today, it's **not** possible to set different views for your table/summary/related data views.
# Collection configuration
Source: https://docs.forest.app/product/build/collection-configuration
Configure how collections behave, display, and interact with your operators.
Collection configuration controls everything about how a collection appears and behaves in Forest, from its name and icon to which fields are searchable, how records are sorted by default, and what actions are available.
Most settings are configured visually through the **Layout Editor** or the collection's **Settings** panel.
## Collection settings
Access collection settings by entering Layout Editor mode and clicking the **⚙️ gear icon** next to a collection name in the sidebar.
| Setting | Description |
| ---------------- | ------------------------------------------------------------------------ |
| **Display name** | The label shown in the navigation and throughout the UI |
| **Icon** | An icon to identify the collection visually |
| **Description** | A short note shown to operators to explain what this collection contains |
| **Visibility** | Control whether the collection appears in the navigation |
## Table view configuration
The table view is the primary way operators browse records. Configure it to show the right information at a glance:
* **Column selection**, choose which fields appear as columns
* **Column ordering**, set the left-to-right order of columns
* **Column width**, adjust how much space each column takes
* **Default sort**, define which field is sorted by default and in which direction
* **View density**, switch between compact and comfortable row height
These settings are managed in the [Layout Editor](/product/build/layout-editor).
## Record display
When an operator clicks a record to open it, Forest shows a detail view. Configure:
* **Summary field**, which field value appears in breadcrumbs and relationship pickers (typically a name or identifier)
* **Related collections**, which HasMany relationships appear in the detail view and in what order
To set the summary field, go to collection settings and select a field from the **"Record title"** dropdown.
## Segments
Segments are pre-filtered views of a collection, like "Active users", "Pending orders", or "High-risk transactions". Operators can switch between segments from the collection's navigation bar.
Configure segments in the **Segments** tab of the collection settings:
* **Create a segment**, define a filter condition (no-code) or write a Smart Segment (code)
* **Reorder segments**, drag to set the display order
* **Set a default segment**, the segment that loads when the collection is opened
* **Segment permissions**, control which teams can see each segment
See [Creating Segments](/product/process/segments/creating-segments) for details on building segment conditions.
## Filters
Operators can filter collection records using the filter panel. Configure:
* **Available filters**, which fields appear as filter options
* **Filter presets**, saved filter combinations that operators can apply in one click
Filter presets are shared across the team. Create them by applying a filter in the collection view and clicking **Save filter**.
## Actions configuration
Actions are buttons that operators can trigger on records. In collection settings, control:
* **Action visibility**, show or hide specific actions for this collection
* **Action ordering**, set the display order in the action menu
* **Action permissions**, managed through [Roles & Permissions](/get-started/control/roles-permissions)
Both built-in actions (create, edit, delete, export) and actions appear here.
# Smart views
Source: https://docs.forest.app/product/build/custom-views/smart-views
Build fully custom collection views using JavaScript, HTML, and CSS, beyond what the table layout offers.
Smart Views let you replace the default table view with any UI you can code. Display orders on a map, events in a calendar, pictures in a gallery, all powered by your real data and integrated with Forest actions.
## What is a smart view?
A Smart View is a [Glimmer Component](https://api.emberjs.com/ember/3.28/modules/@glimmer%2Fcomponent) composed of three files: a JavaScript component, an HTML/Handlebars template, and a CSS stylesheet. Forest hosts and runs this code directly in your back-office.
You don't need to know Ember.js to write a Smart View. The examples below cover all the patterns you'll need. For advanced use, refer to the [Glimmer Component](https://api.emberjs.com/ember/3.28/modules/@glimmer%2Fcomponent) and [Handlebars Template](https://guides.emberjs.com/v3.28.0/components/) documentation.
Your code must be compatible with Ember 4.12.
## Creating a smart view
Forest provides an online editor to write your Smart View code. Access it from the collection's **Settings**, then the **Smart Views** tab.
## Available properties
Forest automatically injects the following properties into your Smart View:
| Property | Type | Description |
| --------------- | ------- | ------------------------------------------------ |
| `collection` | Model | The current collection |
| `currentPage` | Number | The current page |
| `isLoading` | Boolean | Indicates if the UI is currently loading records |
| `numberOfPages` | Number | The total number of available pages |
| `records` | Array | Your data entries |
| `searchValue` | String | The current search value |
## Available actions
Forest also injects the following actions:
| Action | Description |
| ---------------------------------------------------- | ----------------------------------------------------------------- |
| `deleteRecords(records)` | Delete one or multiple records |
| `triggerSmartAction(collection, actionName, record)` | Trigger an action defined on the specified collection on a record |
## Working with records
### Iterating over records
Access all records from the `@records` property and iterate in your template:
```handlebars theme={null}
{{#each @records as |record|}}
{{/each}}
```
### Accessing field values
Access field values using the `forest-` prefix before the field name:
```handlebars theme={null}
{{#each @records as |record|}}
Status: {{record.forest-shipping_status}}
{{/each}}
```
### Accessing belongsTo relationships
Accessing a `belongsTo` relationship works the same as a field. Forest automatically fetches the related data via API when needed:
```handlebars theme={null}
{{#each @records as |record|}}
Order to
{{record.forest-customer.forest-firstname}}
{{record.forest-customer.forest-lastname}}
{{/each}}
```
### Accessing hasMany relationships
Same behavior applies for `hasMany` relationships:
```handlebars theme={null}
{{#each @records as |record|}}
{{#each record.forest-comments as |comment|}}
{{comment.forest-text}}
{{/each}}
{{/each}}
```
### Refreshing records
Call `@fetchRecords` to reload the current page of records:
```handlebars theme={null}
```
### Fetching records with custom filters
Use the `store` service to query any collection with custom filters. In the example below, a calendar view fetches appointments within a date range:
```js title="component.js" theme={null}
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class extends Component {
@service store;
@tracked appointments;
async fetchData(startDate, endDate) {
const params = {
filters: JSON.stringify({
aggregator: 'And',
conditions: [
{ field: 'start_date', operator: 'GreaterThan', value: startDate },
{ field: 'start_date', operator: 'LessThan', value: endDate },
],
}),
timezone: 'America/Los_Angeles',
'page[number]': 1,
'page[size]': 50,
};
this.appointments = await this.store.query('forest_appointment', params);
}
}
```
```handlebars title="template.hbs" theme={null}
{{#each this.appointments as |appointment|}}
```
### Shipping status view
Displays a master-detail layout: a scrollable list of orders on the left and a progress bar card on the right showing the shipping status of the selected order.
```js title="component.js" theme={null}
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { triggerSmartAction, deleteRecords } from 'client/utils/smart-view-utils';
export default class extends Component {
@tracked currentRecord = null;
get status() {
switch (this.currentRecord?.get('forest-shipping_status')) {
case 'Being processed': return 'one';
case 'Ready for shipping': return 'two';
case 'In transit': return 'three';
case 'Shipped': return 'four';
default: return null;
}
}
@action
setDefaultCurrentRecord() {
if (!this.currentRecord) {
this.currentRecord = this.args.records.firstObject;
}
}
@action
selectRecord(record) {
this.currentRecord = record;
}
@action
triggerSmartAction(...args) {
return triggerSmartAction(this, ...args);
}
@action
deleteRecords(...args) {
return deleteRecords(this, ...args);
}
}
```
```handlebars title="template.hbs" theme={null}
Order to
{{this.currentRecord.forest-customer.forest-firstname}}
{{this.currentRecord.forest-customer.forest-lastname}}
ID: {{this.currentRecord.id}}
```
# Fields & widgets
Source: https://docs.forest.app/product/build/fields-and-widgets/overview
Understand how fields and widgets work together to display and edit your data in Forest.
Every piece of data in Forest is represented by a **field**. Every field is rendered using a **widget**. Understanding this distinction helps you configure exactly how your data looks and behaves for operators.
A **field** is a data definition, it comes from your database schema or is computed by your agent. Fields have a name, a type (`String`, `Number`, `Date`, `Boolean`, etc.), and a value.
A **widget** is the UI component used to display or edit that field. The same field can be rendered differently depending on where it appears and what you want operators to do with it. Widgets come in two categories: **display widgets** (used in table views, detail views, and Workspaces) and **edit widgets** (used in create and edit forms).
A field can have different widgets for display and edit. For example, a date field might display as a relative time ("3 days ago") but edit with a date picker calendar.
## Field types and their widgets
| Field type | Display widgets | Edit widgets |
| -------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| **Text** | Text, Long Text, Rich Text, Code, Email, URL, Phone | Text Input, Textarea, Rich Text Editor, Code Editor, Email Input, URL Input, Phone Input, Password Input |
| **Number** | Number, Currency, Percentage, Duration, Progress Bar | Number Input, Currency Input, Slider, Stepper |
| **Date & Time** | Date, DateTime, Time, Relative Time | Date Picker, DateTime Picker, Time Picker, Date Range Picker |
| **Boolean** | Checkbox (read-only), Badge, Icon | Checkbox, Toggle Switch |
| **Enum / Selection** | Text, Status Badge | Dropdown, Radio Buttons, Autocomplete |
| **Multi-value** | Text | Multi-select, Tags Input |
| **File / Image** | Image, File, Video, Audio | File Uploader, Image Uploader, Drag & Drop Uploader |
| **Relationship** | BelongsTo, HasMany Count, Link | BelongsTo Selector, HasMany Editor, Search & Select |
| **JSON** | JSON Viewer | JSON Editor |
| **Special** | Color, Rating | Color Picker, Rating Input, Address Input, Location Picker |
## Widget reference
### Text
**Display widgets:**
* **Text**, the default for string fields. Renders the raw value as plain text. Best for identifiers, names, and short labels.
* **Long Text**, for multi-line text content. Displays the first line in table view with a "show more" toggle in the detail view.
* **Rich Text**, renders HTML or markdown with formatting (bold, lists, headings). Use for fields that store formatted content like descriptions or notes.
* **Code**, displays the value in a monospace code block with syntax highlighting. Useful for JSON snippets, SQL queries, or scripts.
* **Email**, renders the value as a clickable `mailto:` link.
* **URL**, renders the value as a clickable external link that opens in a new tab.
* **Phone**, formats and displays phone numbers. On mobile, renders as a `tel:` link for direct dialing.
**Edit widgets:**
* **Text Input**, the default for short string fields. A single-line input.
* **Textarea**, a multi-line text input for longer content. Configure the number of visible rows.
* **Rich Text Editor**, a WYSIWYG editor with formatting controls. Stores content as HTML.
* **Code Editor**, a syntax-highlighted code editor with line numbers. Select the language mode for appropriate highlighting.
* **Email Input**, a text input with email format validation.
* **URL Input**, a text input with URL format validation.
* **Phone Input**, a text input with phone number formatting and a country code selector.
* **Password Input**, a masked text input. Value is never pre-filled in edit mode.
### Number
**Display widgets:**
* **Number**, displays the raw number with optional thousands separators.
* **Currency**, displays a number with a currency symbol and appropriate decimal places (e.g. `$1,234.56`).
* **Percentage**, displays a number as a percentage (e.g. `73.5%`). Assumes the raw value is 0–100 or 0–1 (configurable).
* **Duration**, converts a number (in seconds, minutes, or milliseconds) to a human-readable duration like `2h 34m`.
* **Progress Bar**, renders a visual progress bar. Requires a min and max value to calculate the fill.
**Edit widgets:**
* **Number Input**, the default for numeric fields. Shows increment/decrement buttons. Configure min, max, and step values.
* **Currency Input**, a number input with a currency symbol prefix.
* **Slider**, a draggable slider for selecting a value within a defined range.
* **Stepper**, increment/decrement buttons without a text input. Best for small integer values.
### Date & Time
**Display widgets:**
* **Date**, displays a date value formatted according to your locale setting. Time portion is hidden.
* **DateTime**, displays both date and time (e.g. `Mar 15, 2024 at 14:32`).
* **Time**, displays only the time portion of a timestamp.
* **Relative Time**, displays how long ago (or how far in the future) the value is, relative to now (e.g. `3 days ago`). Automatically updates as time passes.
Hover over a relative time widget to see the full absolute date and time.
**Edit widgets:**
* **Date Picker**, a calendar popup for selecting a date. Supports min/max date constraints.
* **DateTime Picker**, combines a calendar with a time selector. Configure timezone handling.
* **Time Picker**, a time-only input for time-of-day fields not associated with a specific date.
* **Date Range Picker**, two date pickers for selecting a start and end date. Best used in filters and action forms.
### Boolean
**Display widgets:**
* **Checkbox (read-only)**, displays a checked or unchecked checkbox.
* **Badge**, renders the boolean as a colored badge, green for `true`, red for `false`. Labels are customizable.
* **Icon**, displays a checkmark (✓) or cross (✗) icon. More compact for table views with many boolean columns.
**Edit widgets:**
* **Checkbox**, a simple checkbox, checked means `true`.
* **Toggle Switch**, a toggle that switches between on and off states. More prominent than a checkbox.
### Enum and selection
**Display widgets:**
* **Text**, renders the raw enum value as plain text.
* **Status Badge**, a flexible badge widget for enum/string fields. Map specific values to colors (e.g. `active → green`, `pending → yellow`, `blocked → red`). Configure value-to-color mappings in the widget settings panel.
**Edit widgets:**
* **Dropdown**, a select menu with predefined options. Options can be static (defined manually) or dynamic (loaded from another collection via an action).
* **Radio Buttons**, shows all options at once as radio buttons. Best when there are 2–5 options.
* **Multi-select**, like Dropdown but allows selecting multiple values. Stores as an array.
* **Autocomplete**, a text input that searches and filters options as you type. Best for large option lists.
* **Tags Input**, lets operators type values and press Enter to create tags. Values are stored as an array of strings. No predefined list required.
### File and image
**Display widgets:**
* **Image**, renders the field value (a URL or base64 string) as an inline image thumbnail. Clicking the thumbnail opens the full-size image.
* **File**, renders a download link for a file URL. Displays the filename and a download icon.
* **Video**, embeds a video player inline.
* **Audio**, embeds an audio player with play/pause controls.
**Edit widgets:**
* **File Uploader**, a file upload input. Operators can browse or drag and drop. Configure accepted file types and max file size.
* **Image Uploader**, like File Uploader but restricted to image types, with a preview before saving. Supports cropping if configured.
* **Drag & Drop Uploader**, a large drop zone that accepts one or multiple files.
### Relationship
**Display widgets:**
* **BelongsTo**, displays the related record as a clickable link that opens the record's detail view. Shows the related record's summary field.
* **HasMany Count**, displays the number of related records as a badge (e.g. `12 orders`). Clicking opens the filtered list.
* **Link**, a custom link widget for manually constructed URLs using field values (e.g. `https://app.example.com/users/{{id}}`).
**Edit widgets:**
* **BelongsTo Selector**, a searchable dropdown that lets operators pick a related record. Searches by the related collection's summary field.
* **HasMany Editor**, displays existing related records in a list, with options to add new records or remove existing associations.
* **Search & Select**, an autocomplete input that searches across a related collection as the operator types. Better suited for large related collections with many records.
### JSON
**Display widgets:**
* **JSON Viewer**, renders a JSON value as a collapsible, syntax-highlighted tree structure.
**Edit widgets:**
* **JSON Editor**, a code editor pre-configured for JSON, with syntax validation.
### Special
**Display widgets:**
* **Color**, displays a color swatch alongside the hex or RGB value.
* **Rating**, displays a star rating (e.g. ★★★☆☆) based on a numeric value.
**Edit widgets:**
* **Color Picker**, a color selector with a swatch palette and hex input. Stores the value as a hex code or RGB string.
* **Rating Input**, star rating input where operators click to set a value. Configure max stars and whether half-stars are allowed.
* **Address Input**, a structured address form with separate inputs for street, city, zip code, country, etc.
* **Location Picker**, an embedded map that lets operators pin a location. Stores latitude and longitude coordinates.
## Configuring widgets
To change a widget for a field:
1. Enter **Layout Editor** mode
2. Navigate to the view you want to change (table, detail, or form)
3. Click on a field to open its configuration panel
4. Select a new widget from the **Widget** dropdown
5. Adjust widget-specific options (format, color, validation, etc.)
6. Save your changes
Widget availability depends on the field's data type. A Boolean field can't use a text input widget, for example.
## Validation
Most edit widgets support built-in validation options:
| Validation | Description |
| ------------------------ | ------------------------------------------------------------ |
| **Required** | Field must be filled before saving |
| **Min / Max** | For numbers: value bounds. For text: character length limits |
| **Pattern** | Regex validation for custom formats |
| **Custom error message** | Override the default validation error text |
Validation runs client-side for immediate feedback, and action hooks can also enforce it server-side.
# Layout editor
Source: https://docs.forest.app/product/build/layout-editor
Customize the appearance and structure of your collections without writing code.
The Layout Editor is Forest's visual customization tool. It lets you control exactly what your operators see and how data is presented, which columns appear in tables, how detail views are organized, and how create/edit forms are structured.
Each environment and team has its own layout. Changes you make in development don't affect production until you deploy them.
## Accessing the layout editor
To enter Layout Editor mode, click the **Layout Editor** toggle in the top navigation bar of your back-office. A purple banner appears when the mode is active, any changes you make are saved automatically as a draft.
## Showing and ordering collections
With Layout Editor mode on, you control which collections appear in the navigation and in what order:
* **Show/hide collections**, click the eye icon next to a collection to toggle it. Hide the collections that aren't relevant to your operational team.
* **Reorder collections**, editable elements are surrounded by dotted lines; drag and drop them to change the order.
* **Reorder tabs / change the default tab**, by default Forest opens on the Dashboard tab. Drag the tabs (for example move the "Data" tab) to change their order and the default landing tab.
## Customizing the table view
The table view is what operators see when browsing a collection. In Layout Editor mode:
* **Show/hide columns**, click the eye icon next to any field to toggle its visibility
* **Reorder columns**, drag and drop column headers to rearrange them
* **Resize columns**, drag column borders to adjust width
* **Set default sort**, click a column header to define the default sort order and direction
Changes apply immediately in preview. Other team members continue seeing the previous layout until you publish.
## Customizing the detail view
The detail view shows when an operator opens a single record. Reorganize fields to match your team's workflow:
* **Drag fields** to reorder them on the page
* **Create sections** to group related fields under a heading
* **Add tabs** to separate different areas of information (e.g. "Profile", "Billing", "Activity")
* **Hide fields** that operators don't need to see on this view
* **Configure related data panels**, choose which relationships appear and how they're displayed
### Sections and tabs
Sections help organize long detail views. To add a section:
1. Click **Add section** in the Layout Editor sidebar
2. Give it a label
3. Drag fields into the section
Tabs are useful when a record has many distinct categories of information. Each tab becomes a separate panel in the detail view.
## Summary view
The Summary view is a curated, presentation-oriented view of a single record. Once configured for a collection, it becomes the default entry point when an operator opens one of its records.
To create one, open a record, go to the **Summary** tab and click **Add one** (if a Summary view already exists, reconfigure it from Layout Editor mode).
You then build it with a drag-and-drop **visual builder** organized into four categories:
* **Formatting**, add a **Section** and drop fields into it to structure the layout.
* **Fields**, the record's own fields.
* **Related data**, the record's relationships (for example a customer's `Orders`); drag them in like any other module.
* **Actions**, insert any of the collection's [actions](/product/process/actions/overview) directly into a section, so operators can act on the record straight from the Summary.
To remove a section or module, drag it from its upper-right corner to the trash icon.
## Explorer view
The **Explorer** tab lets operators browse linked collections, even across relationships that are several levels deep, and edit, add, or associate records from there.
To configure it, turn on Layout Editor mode while on the **Explorer** tab. You choose which related collections to display and up to **3** fields to show for each record.
## Customizing forms
Create and edit forms are configured separately from the detail view, giving you full control over the data entry experience.
In the Layout Editor, switch to **"New record"** or **"Edit record"** view using the selector at the top, then:
* **Reorder fields** to match the natural flow of data entry
* **Group fields** into logical sections (e.g. "Contact info", "Address")
* **Set field visibility**, hide fields that should never appear in forms
* **Mark fields as read-only** in edit mode when they shouldn't be changed after creation
Hiding a required field from a form will prevent operators from creating records. Make sure required fields remain visible.
# Layout versioning
Source: https://docs.forest.app/product/build/layout-versioning
Manage, version, and deploy your Forest layout configurations across environments using branches.
Every layout change you make in Forest, column ordering, field visibility, segments, custom views, is versioned. This lets you iterate safely in development, test in staging, and promote to production without disrupting your operators.
## How layout versioning works
A Forest project has two independently versioned parts:
* **Back-end code**, your back-end customizations (datasources, actions, computed fields, hooks). Versioned with Git.
* **Layout**, your UI configuration (field visibility, columns, segments, forms, dashboards). Versioned with Forest branches.
Branches are isolated copies of a layout. You create one, make changes, test them, then deploy when ready. The production layout is never touched until you explicitly deploy.
## Environments and layouts
Each environment has its own layout:
| Environment | Typical use |
| ---------------- | ---------------------------------------------------- |
| Development | Your personal branch, safe to experiment |
| Remote (staging) | Test layout changes with your team before production |
| Production | Live layout seen by all operators |
When you enter Layout Editor mode in your development environment, changes are saved to your current branch, not immediately deployed anywhere.
## Creating a branch
A branch is created from an existing environment's layout. Use the Forest CLI:
```bash theme={null}
forest branch my-feature-layout --origin production
```
This creates a branch called `my-feature-layout` that starts from the production layout. Switch to it:
```bash theme={null}
forest switch my-feature-layout
```
## Making layout changes
With your branch active, open the Forest UI in your browser. Enter Layout Editor mode and make your changes, reorder columns, create segments, configure forms. All changes are saved automatically to your branch.
## Comparing layouts
Before deploying, review what changed:
```bash theme={null}
forest schema:diff
```
This shows the difference between your branch and the target environment's layout.
## Deploying changes
To push your branch to a remote (staging) environment:
```bash theme={null}
forest push --environment staging
```
To deploy to production:
```bash theme={null}
forest deploy
```
Deploying overwrites the target environment's layout. Review your changes with `forest schema:diff` before deploying to production.
## Rolling back
If a layout change causes issues in production, roll back by deploying a previous branch or resetting the environment:
```bash theme={null}
forest environments:reset --environment staging --from production
```
Layout rollbacks only affect the UI configuration, your back-end code and database are not touched.
## Team collaboration
When multiple people work on the same project:
* Each developer works on their own named branch
* Branches are isolated, one developer's changes don't affect another's view
* Changes are only visible to others after a push or deploy
* The CLI `forest branch` command lists all active branches
## Avoiding conflicts
Layout branches don't support merging, deploying one branch's changes overwrites the target. To collaborate safely:
* Communicate which collections or features each person is working on
* Work on distinct parts of the layout to avoid overlap
* Use short-lived branches and deploy frequently
## Best practices
`feature/add-order-segments` is clearer than `dev-branch-1`. Use kebab-case.
The longer a branch lives, the more it diverges from production and the harder it is to deploy cleanly.
Before deploying to production, push to staging and verify the layout renders as expected for at least one operator role.
Read every change before pushing. The diff is the last review gate before operators see the change.
# Pagination Configuration
Source: https://docs.forest.app/product/build/pagination
Configure page size and pagination behavior for collections, and optimize performance on large datasets.
Pagination controls how many records are loaded at once when operators browse a collection. Getting it right balances user experience, showing enough records to be useful, with query performance on large tables.
## Default Behavior
When an operator opens a collection, Forest loads records in pages. Two requests are sent to your back-end for each page load:
1. A query to fetch the records for the current page
2. A query to count the total number of records (used to display page numbers and totals)
By default, collections show **15 records per page**.
## Changing the Page Size
The page size is part of the collection's layout, so the value you set applies to **all operators**, not just you. It is not a personal, per-operator preference.
To change it:
1. Enter **Layout Editor** mode
2. Open the collection's table view
3. Use the page-size selector at the **bottom-right** of the table and pick a value
4. The new page size is saved to the layout and applies to everyone
Increasing the page size on slow queries significantly impacts load time. Always test with realistic data volumes before changing the default for large tables.
## Disabling the Record Count
The total record count query can be expensive on large tables, especially if the query involves complex filters, joins, or a database without good index coverage.
If the total count isn't needed, disable it per collection:
```javascript Node.js / Cloud theme={null}
agent.customizeCollection('orders', collection => {
collection.disableCount();
});
```
```ruby Ruby theme={null}
@create_agent.customize_collection('orders') do |collection|
collection.disable_count
end
```
```ruby Ruby DSL theme={null}
@create_agent.collection :orders do |collection|
collection.disable_count
end
```
```python Python theme={null}
agent.customize_collection("orders").disable_count()
```
```php PHP theme={null}
use ForestAdmin\AgentPHP\DatasourceCustomizer\CollectionCustomizer;
$forestAgent->customizeCollection(
'Orders',
function (CollectionCustomizer $builder) {
$builder->disableCount();
}
);
```
When count is disabled, the total number of records and page count are not displayed in the table view. Operators can still paginate forward and backward through records.
Disabling count is especially useful for collections backed by large analytical tables, external APIs, or data sources where a COUNT query is particularly slow.
## Performance Recommendations
| Scenario | Recommendation |
| ----------------------------------- | --------------------------------------------------------------------------------- |
| Table has millions of rows | Disable count, use smaller page sizes (10–25) |
| Frequent segment or filter use | Add database indexes on filtered fields |
| Table view has relationship columns | Remove relationship columns from the table view, each row triggers an extra query |
| Smart Fields in table view | Hide Smart Fields not needed at a glance, they compute per row |
For more optimization strategies, see [Layout Maintenance](/guides/best-practices/layout-maintenance).
# Search Configuration
Source: https://docs.forest.app/product/build/search-configuration
Configure which fields are searchable in your collections, how search behaves, and how to extend or replace the default search logic.
Forest includes a free-text search bar on every collection's table view. By default it searches across text, enum, number, and UUID fields. You can configure exactly which fields are searched, what operators are used, and even replace the default behavior entirely with custom logic.
## How Search Works
When an operator types in the search bar, Forest sends a query to your back-end with the search string. The back-end applies it as a filter against your data source and returns matching records.
Two search modes exist:
* **Normal search**, searches fields in the current collection
* **Extended search**, also searches fields in directly related collections. Operators can trigger extended search from the footer when normal results are empty.
## Default Search Behavior
By default, Forest searches only specific field types:
| Field type | Default behavior |
| ----------- | --------------------------------------------------- |
| `String` | Field contains the search string (case-insensitive) |
| `Enum` | Field equals the search string (case-insensitive) |
| `Number` | Field equals the search string (if numeric) |
| `UUID` | Field equals the search string |
| Other types | Field is ignored |
## Replacing the Search Handler
Use `replaceSearch` in your back-end configuration to define exactly how search strings are translated into filters.
For large datasets, limit searchable fields to columns with database indexes. Searching unindexed fields causes full table scans.
In Node.js and Python, the handler receives a `context` with the `generateSearchFilter` helper. In Ruby, the `replace_search` block receives `(search_string, extended_search)` and returns a [condition tree](/get-started/connect/relationships-schema) directly: there is no `generate_search_filter` helper, so you build the tree yourself.
### Restricting Which Fields Are Searched
```javascript Node.js / Cloud theme={null}
agent.customizeCollection('people', collection => {
collection.replaceSearch((searchString, extendedMode, context) => {
return context.generateSearchFilter(searchString, {
extended: extendedMode,
onlyFields: ['firstName', 'lastName', 'email'],
});
});
});
```
```ruby Ruby theme={null}
include ForestAdmin::Types
@create_agent.customize_collection('people') do |collection|
collection.replace_search do |search_string, extended_search|
{
aggregator: 'Or',
conditions: ['firstName', 'lastName', 'email'].map do |field|
{ field: field, operator: Operators::I_CONTAINS, value: search_string }
end
}
end
end
```
```ruby Ruby DSL theme={null}
include ForestAdmin::Types
@create_agent.collection :people do |collection|
collection.replace_search do |search_string, extended_search|
{
aggregator: 'Or',
conditions: ['firstName', 'lastName', 'email'].map do |field|
{ field: field, operator: Operators::I_CONTAINS, value: search_string }
end
}
end
end
```
```python Python theme={null}
def search_in_people(search_string, extended_search, context):
return context.generate_search_filter(
search_string,
extended=extended_search,
only_fields=["firstName", "lastName", "email"],
)
agent.customize_collection("people").replace_search(search_in_people)
```
### Excluding Fields from Default Search
```javascript theme={null}
agent.customizeCollection('people', collection => {
collection.replaceSearch((searchString, extendedMode, context) => {
return context.generateSearchFilter(searchString, {
extended: extendedMode,
excludeFields: ['internalNotes', 'legacyId'],
});
});
});
```
### Context-Dependent Search
Different search logic depending on what the operator is searching for:
```javascript Node.js / Cloud theme={null}
const referenceRegexp = /^[a-f]{16}$/i;
const barcodeRegexp = /^[0-9]{10}$/;
agent.customizeCollection('products', collection => {
collection.replaceSearch(async (searchString, extendedMode, context) => {
if (referenceRegexp.test(searchString))
return { field: 'reference', operator: 'Equal', value: searchString };
if (barcodeRegexp.test(searchString))
return { field: 'barCode', operator: 'Equal', value: searchString };
if (!extendedMode)
return context.generateSearchFilter(searchString, { onlyFields: ['name'] });
return context.generateSearchFilter(searchString, {
onlyFields: ['name', 'description', 'brand:name'],
});
});
});
```
```ruby Ruby theme={null}
include ForestAdmin::Types
REFERENCE_REGEXP = /\A[a-f]{16}\z/i
BARCODE_REGEXP = /\A[0-9]{10}\z/
@create_agent.customize_collection('products') do |collection|
collection.replace_search do |search_string, extended_search|
next { field: 'reference', operator: Operators::EQUAL, value: search_string } if REFERENCE_REGEXP.match?(search_string)
next { field: 'barCode', operator: Operators::EQUAL, value: search_string } if BARCODE_REGEXP.match?(search_string)
fields = extended_search ? ['name', 'description', 'brand:name'] : ['name']
{
aggregator: 'Or',
conditions: fields.map do |field|
{ field: field, operator: Operators::I_CONTAINS, value: search_string }
end
}
end
end
```
```ruby Ruby DSL theme={null}
include ForestAdmin::Types
REFERENCE_REGEXP = /\A[a-f]{16}\z/i
BARCODE_REGEXP = /\A[0-9]{10}\z/
@create_agent.collection :products do |collection|
collection.replace_search do |search_string, extended_search|
next { field: 'reference', operator: Operators::EQUAL, value: search_string } if REFERENCE_REGEXP.match?(search_string)
next { field: 'barCode', operator: Operators::EQUAL, value: search_string } if BARCODE_REGEXP.match?(search_string)
fields = extended_search ? ['name', 'description', 'brand:name'] : ['name']
{
aggregator: 'Or',
conditions: fields.map do |field|
{ field: field, operator: Operators::I_CONTAINS, value: search_string }
end
}
end
end
```
### Integrating an External Search Engine
If your data is indexed in Algolia, Elasticsearch, or another service, call it directly in the search handler:
```javascript Node.js / Cloud theme={null}
const algoliasearch = require('algoliasearch');
const client = algoliasearch('APPLICATION_ID', 'API_KEY');
const index = client.initIndex('products');
agent.customizeCollection('products', collection =>
collection.replaceSearch(async (searchString) => {
const { hits } = await index.search(searchString, {
attributesToRetrieve: ['id'],
hitsPerPage: 50,
});
return { field: 'id', operator: 'In', value: hits.map(h => h.id) };
})
);
```
```ruby Ruby theme={null}
require 'algolia'
client = Algolia::Search::Client.create('APPLICATION_ID', 'API_KEY')
index = client.init_index('products')
@create_agent.customize_collection('products') do |collection|
collection.replace_search do |search_string, extended_search|
hits = index.search(search_string, { attributesToRetrieve: ['id'], hitsPerPage: 50 })['hits']
{ field: 'id', operator: 'In', value: hits.map { |hit| hit['id'] } }
end
end
```
```ruby Ruby DSL theme={null}
require 'algolia'
client = Algolia::Search::Client.create('APPLICATION_ID', 'API_KEY')
index = client.init_index('products')
@create_agent.collection :products do |collection|
collection.replace_search do |search_string, extended_search|
hits = index.search(search_string, { attributesToRetrieve: ['id'], hitsPerPage: 50 })['hits']
{ field: 'id', operator: 'In', value: hits.map { |hit| hit['id'] } }
end
end
```
```python Python theme={null}
from algoliasearch.search_client import SearchClient
client = SearchClient.create("APPLICATION_ID", "API_KEY")
index = client.init_index("products")
async def search_products(search_string, extended_search, context):
results = index.search(search_string, {"attributesToRetrieve": ["id"], "hitsPerPage": 50})
ids = [hit["id"] for hit in results["hits"]]
return ConditionTreeLeaf("id", "in", ids)
agent.customize_collection("products").replace_search(search_products)
```
## Disabling Search
To remove the search bar from a collection entirely:
```javascript Node.js / Cloud theme={null}
agent.customizeCollection('products', collection => {
collection.disableSearch();
});
```
```ruby Ruby theme={null}
@create_agent.customize_collection('products') do |collection|
collection.disable_search
end
```
```ruby Ruby DSL theme={null}
@create_agent.collection :products do |collection|
collection.disable_search
end
```
```python Python theme={null}
agent.customize_collection("products").disable_search()
```
This is useful for collections where free-text search doesn't apply, for example, collections that only display computed or joined data.
# Workspaces
Source: https://docs.forest.app/product/build/workspaces
Build custom, use-case specific interfaces for your operations teams, without writing code.
A **Workspace** is a custom interface you build inside Forest, tailored to a specific use case. Instead of navigating between multiple collections, operators get a single focused view that shows exactly what they need and lets them take action immediately.
Think of a Workspace as the difference between a generic spreadsheet and a purpose-built tool: same data, dramatically better experience.
## Why use workspaces
The default "Data" tab gives operators access to all your collections, but it's generic. Workspaces let you:
* **Focus** - show only the data relevant to a specific task
* **Contextualize** - display related information side by side without navigating away
* **Act faster** - put action buttons directly next to the data that triggers them
* **Guide** - add text, sections, and structure to guide users through a workflow
Workspaces are built with a **drag-and-drop editor**, no code required for most use cases.
## Building a workspace
**Step 1, Create a new workspace**
Click on `+ Add New` in the left sidebar, select **Workspace**, and give it a descriptive name that reflects the use case (e.g. "Customer Onboarding", "KYC Review").
**Step 2, Add a Collection component**
Drag a **Collection** from the sidebar onto the canvas. Select which collection to display and optionally apply a segment. Set the row click behavior to **"Select a record"** to enable dynamic updates across all connected components.
**Step 3, Add context with Field components**
Drag **Field** components onto the canvas. Link them to your Collection and select which fields to display. When an operator clicks a row, these fields update to show the selected record's values.
**Step 4, Add Actions**
Drag an **Action** and link it to your Collection. Now operators can click a row and immediately trigger the right action, no need to open the record detail.
**Step 5, Exit the builder and test it**
Click "Exit builder" to switch to the operator view. Test the workflow by clicking rows and triggering actions.
***
## Components reference
A Workspace is composed of **components** that you drag onto a canvas and configure. They fall into 5 categories.
### Layout & Guidance
Components to structure your workspace and guide operators through it.
| Component | Purpose |
| ----------- | -------------------------------------------------------------------------------- |
| **Text** | Headers, instructions, labels, supports [templating](#templating) |
| **Section** | Groups related components; visibility can be controlled as a unit |
| **Tabs** | Tabbed area holding up to 10 tabs; only the active tab's components are rendered |
| **Link** | Link to a record detail page, external URL, or another workspace |
| **Divider** | Horizontal or vertical visual separator |
### Source
Source components set the data context for the rest of the workspace. Data & Action components subscribe to a Source to know which record to act on.
| Component | Purpose |
| --------------- | ------------------------------------------------------------------------------------ |
| **Search** | Lets operators search for a record by a specified field |
| **Collection** | Displays records in a table; operators can select a row to feed connected components |
| **Inbox** | Surfaces the next task from the operator's [Inbox](/product/manage/inbox) |
| **Dropdown** | A selector that dynamically filters connected components |
| **Date Picker** | A date selector that filters connected components |
| **Toggle** | A boolean switch used to apply or remove a filter |
| **Input** | Free text/number input used to filter connected components |
#### Dropdown modes
The Dropdown component has three modes:
* **Static**, You hardcode a list of values. Reorder them with drag handles.
* **Dynamic > Simple**, Choose a collection and a field; the dropdown is populated from your data, with an optional filter.
* **Dynamic > Smart**, Fetches values from an external endpoint. The expected response format is:
```json theme={null}
{
"data": ["value1", "value2"]
}
```
Enable the **"Search"** option on any mode to add a search input, useful when the list is long.
#### Date Picker
Set minimum and/or maximum selectable dates, hardcoded or using [templating](#templating):
```
Minimum: {{currentDate.subtract.days.1}}
Maximum: {{currentDate.add.days.1}}
```
#### Toggle
Pick a value type (String, Number, Boolean, etc.) and set the value for the "toggled on" state. When toggled off, the component's value is undefined and any filter using it is ignored.
Example: a Boolean toggle with value `true` filtering on `isEmailVerified`, combined with a String toggle with value `"onboarding"` filtering on `status`.
### Data & Actions
| Component | Purpose |
| ------------ | -------------------------------------------------------------- |
| **Field** | Displays the value of a specific field from a selected record |
| **Action** | Triggers a global, single, or bulk action on a selected record |
| **Workflow** | Triggers or resumes a Workflow on a selected record |
The **Field** component supports all [display widgets](/product/build/fields-and-widgets/overview). For nested values, use the code input mode:
```
{{country.headquarter.address}}
```
### Analytics
| Component | Purpose |
| ------------ | -------------------------------------------------------------- |
| **Chart** | Embeds any chart, single value, distribution, time-based, etc. |
| **Metabase** | Embeds a Metabase dashboard |
#### Metabase component setup
| Parameter | Description |
| --------- | ------------------------------------------------------------------------------------------------------------------------------ |
| **URL** | Your Metabase instance URL (e.g. `https://myanalytics.mycompany.com`) |
| **Token** | A JWT signed with your `METABASE_SECRET_KEY`, containing `{"resource": {"dashboard": }, "params": {}}` |
| **Query** | Optional query string parameters passed to the dashboard, supports templating (e.g. `projectId={{search1.selectedRecord.id}}`) |
### Custom
The Custom component lets you write your own component in HTML/JS for advanced display logic not covered by the built-in set.
***
## Templating
Templating lets components reference values from other components dynamically. Type `{{` in any text input, filter, or link field to open the autocomplete.
| Syntax | Result |
| -------------------------------------- | --------------------------------- |
| `{{collection1.selectedRecord.email}}` | Field value from the selected row |
| `{{currentUser.fullName}}` | Logged-in operator's name |
| `{{currentUser.email}}` | Logged-in operator's email |
| `{{currentUser.team}}` | Logged-in operator's team |
| `{{currentUser.tags.some-tag}}` | Value of a specific user tag |
| `{{currentDate}}` | Today's date |
| `{{currentDate.subtract.days.7}}` | 7 days ago |
| `{{currentDate.startOf.months}}` | First day of current month |
When you rename a component, all templating references to it update automatically.
### Filtering charts with templating
Charts in a workspace can be filtered using other components' values. In the chart's filter configuration, reference a source component:
```
company_id = {{collection1.selectedRecord.id}}
```
Templating also works inside **SQL Query** mode charts. You can use it in the **Timeframe** property of time-based charts, for example, a Dropdown with values *Day*, *Week*, *Month*, *Year* can dynamically change a chart's timeframe.
***
## Visibility rules
Every component has a **Visible** option at the bottom of its settings panel.
| Option | Behavior |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| **Always** | Component is always visible. Filters using undefined templating values are silently ignored. |
| **Only when a component is visible** | Component appears only when another specified component is also visible, useful for dividers and section titles. |
| **Only when dynamic variables are defined** | Component appears only when all its templating variables have a value, prevents showing empty states. |
Example: a related Collection filtered on `{{collection1.selectedRecord.id}}` with visibility set to "Only when dynamic variables are defined" will stay hidden until an operator selects a row in `collection1`.
***
## Sharing workspaces
Workspaces are available to all operators in your project by default. Use [Roles & Permissions](/get-started/control/roles-permissions) to limit visibility to specific teams.
Operators can share a Workspace URL with a pre-applied filter state, letting them hand off specific work items directly.
For complete, step-by-step examples, see [Example 1: KYC](/product/build/workspaces/kyc) and [Example 2: Incident Management](/product/build/workspaces/incident-management).
# Example 2: Incident Management
Source: https://docs.forest.app/product/build/workspaces/incident-management
Build a support triage workspace that lets back-ends review, resolve, and escalate incidents in one place.
This guide walks through building an Incident Management Workspace, a focused interface where support representatives can see pending incidents, review the context, and resolve or escalate them without switching between views.
**What operators will be able to do:**
* See a live breakdown of incident types at a glance
* Browse pending incidents and click to review details
* View all relevant fields for the selected incident
* Resolve, escalate, or reassign incidents from a single screen
This example applies to ride-sharing support, but the same pattern works for any incident management workflow: customer complaints, technical outages, fraud reports, etc.
## 1. Create the workspace
Click the **🧩 icon** in the left sidebar, then **"Create your first Workspace"**.
Name it something clear: **"Incident Queue"** or **"Support Triage"**. Rename it by clicking the edit icon next to "My Workspace".
## 2. Add a header with context
Drag a **Text** component onto the canvas. Use Templating to personalize it:
```
Incident Queue - {{currentUser.firstName}}'s shift
```
This helps orient the operator and makes handoffs easier when team members are reviewing the same workspace.
## 3. Add a distribution chart
Add a **Chart** component to give operators a quick overview of what's in the queue:
* **Chart type:** Distribution
* **Collection:** Issue
* **Group by:** category (or type)
* **Filter:** `status = "To Review"`
This shows the mix of incident types, billing issues vs. driver complaints vs. app errors, so operators can prioritize their work.
## 4. Add the Issues Collection
Drag a **Collection** component onto the main canvas area. Configure it:
* **Collection:** Issue
* **Segment:** "To Review" (filter: `status = "To Review"`)
* **On row click:** "Select a record"
Add a **Text** label above it: "Pending Incidents"
Setting "Select a record" on row click is what connects the collection to the detail panel you'll build next.
## 5. Build the detail panel
Create a section to the right of the issue list. Add a **Text** component with Templating:
```
Reviewing: {{collection1.selectedRecord.category}} - {{collection1.selectedRecord.id}}
```
Then add **Field** components for the key information operators need:
* **Description**, what happened
* **Reported by**, user or driver
* **Created at**, when it was reported
* **Priority**, urgency level
* **Assigned to**, current owner
For each field:
1. Drag a **Field** component from the sidebar's "Data" section
2. Set **Source** to the Issue collection (Collection 1)
3. Set **Field** to the appropriate field
4. Choose the right display widget (relative time for dates, badge for priority, etc.)
## 6. Add a link to the full record
Drag a **Link** component:
* **URL type:** Redirect to record
* **Source:** Collection 1 (Issue)
This gives operators an escape hatch when they need more context than the workspace shows.
## 7. Show related information
If incidents are linked to user accounts or trips, add a second **Collection** with a dynamic filter:
* **Collection:** User (or Trip)
* **Filter:** `id = {{collection1.selectedRecord.user_id}}`
This second panel updates automatically when the operator selects an issue, showing the user's history, other recent reports, or relevant context without any navigation.
## 8. Add action buttons
Drag **Button** components for the core actions:
| Button label | Action | Purpose |
| ------------ | ------------------- | -------------------------- |
| ✓ Resolve | `resolve_incident` | Marks incident as resolved |
| ↑ Escalate | `escalate_to_tier2` | Moves to escalation queue |
| → Reassign | `reassign_incident` | Sends to another back-end |
Configure each button:
* **Source:** Collection 1 (Issue)
* **Action:** Select the corresponding action
The buttons appear active only when a row is selected, preventing accidental actions.
## 9. Exit the builder and test
Click **"Exit builder"** to switch to operator view. Walk through the workflow:
1. Click an incident row, the detail panel should update
2. Review the related user/trip information
3. Click "Resolve", the action form should open
4. Submit the form, the incident should disappear from the queue
If the resolved incident still appears, verify your segment filter is based on a status field that the action updates.
## The result
Operators get a complete triage interface:
* **Top:** Distribution chart showing the incident mix
* **Left:** List of pending incidents
* **Right:** Incident details + related context + action buttons
Incidents resolve in seconds instead of minutes. No tab-switching, no hunting for context, no missed actions.
## Common customizations
**Color-code priority**, Use the Status Badge widget for the priority field and map `high → red`, `medium → yellow`, `low → green`.
**Add an SLA timer**, Use the Relative Time widget on `created_at` to show how long the incident has been open. Add a warning badge for incidents older than your SLA threshold.
**Show back-end notes**, Add a Field component for a `notes` text field so back-ends can see what previous back-ends have done before acting.
**Add a search box**, Drag a **Search** component linked to the Issues collection so operators can find a specific incident by ID or keyword.
# Example 1: KYC
Source: https://docs.forest.app/product/build/workspaces/kyc
Step-by-step guide to building a document review and applicant approval workspace.
This guide walks through building a KYC (Know Your Customer) Workspace, a focused interface where your compliance team can review company applications, check submitted documents, and approve or reject applicants without switching between multiple views.
**What operators will be able to do:**
* See all companies in "Signed up" status at a glance
* View the documents submitted by a specific company
* Upload missing documents on behalf of the company
* Approve or reject applications directly from the workspace
## 1. Create the workspace
In your Forest project (start in a staging environment), click the **🧩 icon** in the left sidebar, then click **"Create your first Workspace"**.
Rename it by clicking the edit icon next to "My Workspace". Choose a clear name like **"KYC Review"**, operators will see this label every day.
## 2. Add a title with templating
Drag a **Text** component onto the canvas. Add a title like:
```
KYC Review - Welcome, {{currentUser.firstName}}
```
Type `{{` to see the available template variables. Dynamic text makes the workspace feel personalized and lets you reference selected record data as you build more components.
## 3. Add a summary chart
Drag a **Chart** component from the sidebar. Configure it:
* **Chart type:** Single value
* **Collection:** Company
* **Metric:** Count
* **Filter:** `status = "Signed up"`
This gives operators a live count of pending applications, useful context before they dive into the queue.
## 4. Add the Companies Collection
Drag a **Collection** component onto the canvas. In the sidebar:
* **Collection:** Company
* **Segment:** Select or create a "Signed up" segment that filters `status = "Signed up"`
* **On row click:** "Select a record"
Setting "Select a record" on click is what makes the workspace dynamic, other components will react to whichever company the operator clicks.
Add a **Text** component above the collection as a label: "Companies awaiting review"
## 5. Display company details with Field components
Create a new section to the right of the companies list. Add a **Text** component:
```
Reviewing: {{collection1.selectedRecord.name}}
```
Then drag **Field** components from the "Data" section in the sidebar. For each field you want to display (e.g. registration number, risk level, country):
1. Drag a **Field** component onto the canvas
2. Set **Source** to the Company collection
3. Set **Field** to the relevant field
4. Choose the appropriate display widget (text, badge, image, etc.)
For document fields (e.g. passport scan, proof of address), use the **File viewer** widget to show an inline preview.
## 6. Add a link to the full record
Drag a **Link** component and configure it:
* **URL type:** Redirect to record
* **Source:** Collection 1 (Company)
The link dynamically points to the currently selected company's detail view, useful when the operator needs to see the full record.
## 7. Show related documents with a second Collection
Drag a second **Collection** component. Configure it:
* **Collection:** Document
* **Filter:** `company_id = {{collection1.selectedRecord.id}}`
This filter uses Templating to automatically show only the documents that belong to the selected company. As the operator clicks different rows in the companies list, this collection updates instantly.
## 8. Add action buttons
Drag a **Button** component onto the canvas. Configure it:
* **Source:** Collection 1 (Company)
* **Action:** "Upload Legal Docs" (an action defined in your back-end)
Repeat for other actions:
* "Approve Application"
* "Reject Application"
* "Request More Documents"
The buttons appear next to the selected company's details, so operators can review and act in one motion.
## 9. Add a Dropdown for document filtering
To let operators filter documents by type, drag a **Dropdown** component:
* **Type:** Dynamic
* **Collection:** Document
* **Field:** type
Then add a filter to the Documents collection: `type = {{dropdown1.value}}`
Now operators can select "Passport" from the dropdown and the document list filters automatically.
## 10. Test the workspace
Click **"Exit builder"** to switch to the operator view. Test the full workflow:
1. Click a company row, details and documents should update
2. Trigger an action, the action form should appear
3. Submit the form, the collection should refresh and show the updated status
## The result
When fully built, operators get a single-screen workflow:
* **Left panel:** List of companies awaiting KYC review
* **Right panel:** Details, documents, and action buttons for the selected company
* **Top:** Count of pending applications for context
Companies move out of the queue automatically once their status changes, operators never need to navigate elsewhere.
# Approval workflows
Source: https://docs.forest.app/product/collaborate/approval-workflows
Review, approve, or reject actions that require team validation before execution.
The **Approval Workflow** feature lets your team ensure that sensitive or high-impact actions are reviewed before they execute. When an action requires approval, it is held in a queue until a qualified team member approves or rejects it.
This is especially valuable for large teams that need structured, controlled decision-making, ensuring every critical action is reviewed and audited.
## How it works
1. An operator triggers an action that requires approval.
2. Instead of executing immediately, an **approval request** is created.
3. Team members with approval permission are notified and can review the request.
4. Once approved (or rejected), the action either executes or is cancelled.
5. The request moves to **History** for audit purposes.
## The approval workflow screen
Navigate to the **Collaboration** tab → **Approval Workflow** to access the approval queue. Requests are divided into three lists:
| List | Description |
| ------------- | ----------------------------------------------------------- |
| **Created** | Approval requests you have submitted and are pending review |
| **To review** | Requests you have permission to approve or reject |
| **History** | All processed requests (approved or rejected) |
## Reviewing a request
Click on any approval request to open a modal showing:
* The **action** that was triggered and its name.
* The **target record** the action applies to.
* The **form values** entered by the requester (if the action has a form).
After reviewing, you can:
* **Approve**, the action will execute immediately.
* **Reject**, the action is cancelled; the requester is notified.
* **Comment**, leave a note explaining your decision.
Once processed, the request moves to **History**.
## Filtering approval requests
For large teams with high request volumes, use the **filter panel** to narrow the list:
| Filter | Description |
| ----------------- | --------------------------------------------------- |
| **Action** | Filter by the name of the action |
| **Requester** | Filter by the team member who submitted the request |
| **Team** | Filter by the team associated with the request |
| **Creation date** | Select a date interval |
Multiple filters can be combined for precise results.
## Exporting approval history
Click the **Export** button in the History section to download a CSV of your approval requests over a given time period. The file will be sent to your email.
## Configuring approval workflows
Approval workflows are configured in **Project Settings → Roles**. For each role, configure each action's approval behavior:
| Permission | Effect |
| -------------------- | ----------------------------------------------------------------------- |
| **Require approval** | Operators in this role must request approval before the action executes |
| **Approve** | Operators in this role can approve requests submitted by others |
| **Self Approve** | Operators in this role can approve their own requests |
Approval workflow configuration is available to admins in Project Settings → Roles. Operators cannot modify these settings.
# Notes
Source: https://docs.forest.app/product/collaborate/notes
Leave notes and communicate with your team directly on records in Forest.
The **Notes** feature lets operators leave contextual notes on any record, making it easy to share insights, flag issues, or communicate without leaving Forest.
Notes are attached to a specific record and visible to all team members who have access to it.
## Leaving a note
1. Open a record by clicking on it in the table view.
2. Navigate to the **Collaboration** tab on the record detail view.
3. Click **Create a note**.
4. Type your message. Use `@mention` to notify a specific team member.
5. Submit the note.
The note is immediately visible to other operators viewing the same record.
## Mentions and notifications
Use `@username` inside a note to mention a specific teammate. When mentioned:
* They receive a **red dot** notification on their Collaboration tab.
* The notification persists until they view the note.
Mentions let you request input or assign attention to a specific person.
## Viewing your notes
From the **Collaboration** tab (top navigation), the view shows:
* All notes **you have created** across all records.
* All notes **where you have been mentioned**.
This gives you a single place to track your conversations and outstanding items.
# Forest MCP Server
Source: https://docs.forest.app/product/embed/mcp-server
Securely access your Forest data and actions from AI-enabled third party apps using the Model Context Protocol.
# What is the Model Context Protocol (MCP)?
The Model Context Protocol (MCP) is an open standard for connecting AI assistants to external data and tools through natural language.
# What can the Forest MCP do?
The Forest MCP server lets AI tools like Claude, Dust, and others to:
* Access collection schemas
* Securely query and browse your data
* Execute actions on records
All of this while respecting the Roles & Permissions of your Forest project, and logging every activity, just like if they were performed through the UI.
The Forest MCP Server also enables other third party apps to embed and access Forest data and actions, for example in [Zendesk](/product/embed/zendesk), or [n8n](/product/embed/n8n).
# Enabling the Forest MCP Server
There are 2 ways to configure the Forest MCP Server:
* **Standalone**: the Forest MCP Server runs as an standalone service, pointing to your existing node.js or ruby back-end
* **Mounted**: the Forest MCP Server runs as part of your node.js back-end
## Standalone Forest MCP Server
To run your Forest MCP Server as a standalone service, you will first need to download the mcp-server package:
```text theme={null}
npm install @forestadmin/mcp-server
```
You will then need to provide your FOREST\_ENV\_SECRET and FOREST\_AUTH\_SECRET variables to start the Forest MCP Server, to ensure it can authenticate and access the right back-end, corresponding to your project and environment of choice:
```text theme={null}
FOREST_ENV_SECRET=xxx FOREST_AUTH_SECRET=xxx npx forest-mcp-server
```
Follow [this guide](/get-started/connect/environment-variables) to retrieve your AUTH and ENV secrets for the relevant environment.
### Standalone configuration
The standalone Forest MCP Server is configured entirely through environment variables:
| Variable | Required | Default | Description |
| -------------------------------------- | -------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `FOREST_ENV_SECRET` | Yes | — | Your environment secret, used to authenticate and reach the right back-end. |
| `FOREST_AUTH_SECRET` | Yes | — | Your authentication secret. Must match the one of the corresponding back-end. |
| `MCP_SERVER_PORT` | No | `3931` | Port the standalone server listens on. |
| `FOREST_MCP_ENABLED_TOOLS` | No | all tools | Comma-separated allowlist of tools to expose (see [Restrict tools](#restrict-tools)). |
| `FOREST_AGENT_URL` | No | your environment's back-end URL | URL the MCP Server uses to reach your back-end's data layer. |
| `FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS` | No | `3600` (1 hour) | Shortens the OAuth access token lifetime (see [Token lifetimes](#token-lifetimes)). Minimum `60`. |
| `FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS` | No | unbounded | Shortens the time between two interactive logins (see [Token lifetimes](#token-lifetimes)). Minimum `60`. |
| `FOREST_MCP_ALLOWED_OAUTH_CLIENTS` | No | any registered client | Comma-separated domains of the OAuth clients allowed to connect (see [Restrict which AI clients can connect](#restrict-which-ai-clients-can-connect)). |
Set `FOREST_AGENT_URL` when the MCP Server runs next to a self-hosted back-end reachable at an internal address (e.g. `http://localhost:3310`), so tool calls hit it directly instead of the public back-end URL registered in Forest.
Your Forest MCP Server will be accessible at this URL: `{your-standalone-server-url}/mcp`
## Mounted Forest MCP Server
This is only available with the node.js back-end. For other back-ends, refer to the Standalone method further down.
In your node.js's `index.js` file, simply call the `mountAiMcpServer()` method when creating the back-end, for example:
```text theme={null}
const agent = createAgent(options).addDataSource(/* ... */).mountAiMcpServer();
```
Upon restarting your back-end, the Forest MCP Server will automatically start, as confirmed by the following console log:
```text theme={null}
info: [MCP] Server initialized successfully
```
Your Forest MCP Server URL will be `{your-agent-url}/mcp`
Your back-end URL can be found in the Forest UI's Project Settings, under the Environments tab.
Note that each Environment has its own Back-end URL, and therefore its own Forest MCP Server URL.
When mounted, the MCP server intercepts the **entire** `/oauth/*` and `/.well-known/*` namespaces plus `/mcp` at your back-end's root. Any request in those namespaces is captured by the MCP server — if it doesn't serve that exact route (or your back-end already does), the request gets a 404/405 **instead of reaching your back-end**. So your own `/oauth/callback` or `/.well-known/apple-app-site-association` would break, not just OAuth.
Pass a `basePath` to narrow the MCP server to a dedicated prefix so your routes are left untouched. The OAuth and protocol routes move under the prefix; the `.well-known` discovery documents stay at the root (as OAuth discovery requires) but are served at prefix-suffixed paths such as `/.well-known/oauth-authorization-server/ai`, narrowing the `.well-known` claim to just those two paths:
```text theme={null}
const agent = createAgent(options).addDataSource(/* ... */).mountAiMcpServer({ basePath: '/ai' });
```
Your Forest MCP Server URL then becomes `{your-agent-url}/ai/mcp`. Because OAuth discovery must stay at the origin root, `basePath` requires your agent to be served at the domain root (it throws at startup if the agent URL already includes a path), and root `/.well-known/*` requests must still reach the agent.
The prefix applies to every route, including the protocol endpoint — so `basePath: '/mcp'` would make the endpoint `/mcp/mcp`. Prefer a distinct prefix such as `/ai` to avoid the repetition.
# Available tools
The Forest MCP server exposes the following capabilities:
### Read
| Tool | Description |
| -------------------- | ----------------------------------------------------- |
| `describeCollection` | Get schema of a collection (fields, types, relations) |
| `list` | Search and list records with filters and pagination |
| `listRelated` | List related records |
### Write
| Tool | Description |
| ------------ | ------------------------------- |
| `create` | Create a new record |
| `update` | Update an existing record |
| `delete` | Delete one or more records |
| `associate` | Link records through a relation |
| `dissociate` | Unlink records from a relation |
### Actions
| Tool | Description |
| --------------- | ---------------------------------- |
| `getActionForm` | Get form fields for a smart action |
| `executeAction` | Execute a smart action |
## Restrict tools
You can restrict which tools the MCP server exposes using `enabledTools`. Only the tools you list will be available, and **new tools added in future releases will NOT be automatically enabled**, so your configuration stays safe over time.
```javascript Mounted on agent theme={null}
agent.mountAiMcpServer({
enabledTools: ['describeCollection', 'list', 'listRelated'],
});
```
```bash Standalone theme={null}
FOREST_MCP_ENABLED_TOOLS="describeCollection,list,listRelated" \
FOREST_ENV_SECRET=xxx FOREST_AUTH_SECRET=xxx npx forest-mcp-server
```
When `enabledTools` is not set, all tools are enabled by default.
`describeCollection` is always enabled, even if omitted from the list, as it is required for the MCP server to function properly.
## Restrict which AI clients can connect
By default, any OAuth client application can register against the MCP server through Dynamic Client Registration and, once one of your users signs in, obtain tokens. Use `allowedOAuthClients` (`@forestadmin/agent` ≥ 1.92.0, `@forestadmin/mcp-server` ≥ 1.21.0) to accept only approved client applications:
```javascript Mounted on agent theme={null}
agent.mountAiMcpServer({
allowedOAuthClients: ['dust.tt'],
});
```
```bash Standalone theme={null}
FOREST_MCP_ALLOWED_OAUTH_CLIENTS="dust.tt" \
FOREST_ENV_SECRET=xxx FOREST_AUTH_SECRET=xxx npx forest-mcp-server
```
A client is allowed only when **every** redirect URI it registered is an `http(s)` URI on a listed domain or one of its subdomains (`dust.tt` matches `eu.dust.tt`). Matching uses redirect URIs because they are the one piece of registration metadata an impostor cannot benefit from — the authorization code is only ever delivered there. Self-declared fields such as the client name are ignored, and custom (non-`http(s)`) scheme URIs are rejected even on an allowed domain, because they deliver the callback to whatever local application registered the scheme.
Every other client is rejected with a standard OAuth `invalid_client` error telling the user to contact their administrator; the response does not reveal the allowed domains. Registration itself still succeeds — it happens on the Forest server — the client just cannot use it against your MCP server. Access tokens issued before you enabled the option stay valid until they expire (1 hour at most); refreshes are blocked immediately.
Native desktop clients (Claude Desktop, MCP Inspector, ...) register `localhost` redirect URIs, so they are always rejected when the allowlist is set. There is deliberately no loopback exemption: allowing `localhost` would allow every local application. Omit the option in environments that need native clients (e.g. development).
## Token lifetimes
The MCP server issues OAuth tokens whose lifetimes come from Forest: **1 hour** (3600s) for an access token, **8 days** (691200s) for a refresh token. Forest re-grants those 8 days on *every* refresh, so without `refreshTokenSeconds` an assistant that keeps working is never asked to sign in again. You can shorten them with `tokenTtl`, to reduce how long a leaked token stays usable and to force users to log in again periodically.
```javascript Mounted on agent theme={null}
agent.mountAiMcpServer({
tokenTtl: {
accessTokenSeconds: 900,
refreshTokenSeconds: 86400,
},
});
```
```bash Standalone theme={null}
FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS=900 \
FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS=86400 \
FOREST_ENV_SECRET=xxx FOREST_AUTH_SECRET=xxx npx forest-mcp-server
```
The two settings differ in what your users notice:
| Setting | Forest default | What it bounds | Effect on the user |
| --------------------- | -------------- | ----------------------------------------------------- | ----------------------------------------------------------- |
| `accessTokenSeconds` | 1 hour | How long an issued access token drives the MCP server | None — the AI assistant silently obtains a new one |
| `refreshTokenSeconds` | unbounded | The time between two **interactive logins** | Once it elapses, the user logs in through the browser again |
`refreshTokenSeconds` is measured from the login itself, not from the last refresh, so an assistant that keeps working cannot keep extending its own session. Refresh tokens issued before you enabled the option carry no login timestamp, so their window is measured from their last refresh instead — one longer session each, then bounded.
Both values are upper bounds: they can only **shorten** what Forest granted, never extend it. For `accessTokenSeconds`, a value above Forest's own token lifetime has no effect. `refreshTokenSeconds` bounds the whole session, which Forest otherwise re-extends on every refresh, so any value shortens it however large it is.
`accessTokenSeconds` bounds what a leaked token can do through the MCP server — its scopes stop applying and its calls stop being audited. It does **not** shorten the Forest token carried inside that JWT, which is signed rather than encrypted: treat a leak as a Forest token leak and revoke at the source.
The minimum for either value is 60 seconds; a lower value is raised to it. An invalid value (zero, negative or fractional) stops the server at startup rather than silently leaving your tokens uncapped.
## Connect your AI assistant
Your MCP endpoint is available at `/mcp` (`/mcp` when mounted, `/mcp` when standalone). On first connection, a browser window opens for you to log in with your Forest credentials; the assistant then operates with that user's permissions.
```bash Claude Code theme={null}
claude mcp add --transport http forest-admin
```
```json Claude Desktop theme={null}
{
"mcpServers": {
"forest-admin": {
"command": "npx",
"args": ["-y", "mcp-remote", ""]
}
}
}
```
```json Cursor theme={null}
{
"mcpServers": {
"forest-admin": {
"url": ""
}
}
}
```
```json VS Code theme={null}
{
"servers": {
"forest-admin": {
"type": "http",
"url": ""
}
}
}
```
```toml Codex (OpenAI) theme={null}
[mcp_servers.forest-admin]
url = ""
```
Use the MCP transport type `"http"` (not `"sse"` or `"url"`): the Forest MCP server uses Streamable HTTP. Your URL should still use `https://`. Clients that rely on `mcp-remote` (Claude Desktop, Windsurf, JetBrains) require Node.js 18+ (some versions need 20+).
## Use cases
### AI-assisted operations
Use Claude or other AI assistants to:
* Answer questions about your data
* Generate reports and insights
* Automate routine tasks
* Perform data analysis
### Example prompts
> "Show me all pending orders from the last 24 hours"
> "What customers have the highest lifetime value?"
> "Execute the 'Send Invoice' action on order #12345"
## Security
The Forest MCP server:
* Respects all Forest permissions and roles
* Uses your environment's authentication
* Logs all operations for audit purposes
* Never exposes sensitive data without proper access
* Lets you restrict which AI client applications can connect (see [Restrict which AI clients can connect](#restrict-which-ai-clients-can-connect))
* Lets you shorten the OAuth token lifetimes (see [Token lifetimes](#token-lifetimes))
Only provide MCP server access to trusted AI tools and users. The server can perform any operation that the authenticated user can perform.
# n8n Node
Source: https://docs.forest.app/product/embed/n8n
Trigger Forest actions and access your data from n8n workflows.
The Forest n8n node turns Forest into a node in your automation stack. Read records, trigger custom actions, and react to Forest events directly from n8n workflows, without writing API calls by hand.
## What you can do
* **Read records**, query any collection and pipe the results into n8n nodes (Slack, Sheets, HubSpot, etc.).
* **Trigger custom actions**, fire any [Forest action](/product/process/actions/overview) on one or many records, with parameters.
* **Use Forest as a trigger**, react to events on your collections (record created, action executed) and kick off an n8n workflow.
* **Combine with AI**, pass Forest records into AI nodes (OpenAI, Claude, etc.) and write the result back through a custom action.
## When to use n8n vs. native Forest workflows
Use Forest [workflows](/product/process/workflows/overview) when the orchestration lives close to your data and your operators, multi-step, human approvals, audit trail.
Use n8n when the orchestration spans many tools (Slack, Notion, HubSpot, Stripe…) and Forest is one of several systems involved. The Forest n8n node gives you the same record access and action execution, but inside n8n's broader automation graph.
## Setup
From the n8n node library, install the official Forest community node.
Create an [API key](/reference/api/authentication) in your Forest project settings, then paste it into the Forest credentials node in n8n.
Pick a collection and the operation: **Get records**, **Get one record**, or **Trigger action**. The node will discover your collections and actions automatically.
## Example: post a Slack notification when a high-value record is created
A typical n8n workflow using Forest as the data source:
1. **Forest trigger**, fires when a new record is created in `orders`.
2. **Filter**, keep only orders where `amount > 10000`.
3. **Slack node**, post a message to `#sales-ops` with the customer name and amount.
Every Forest record fetched through n8n still respects the permissions of the API key: workspace scopes, role-based access, and the audit trail all apply.
# Zendesk App
Source: https://docs.forest.app/product/embed/zendesk
Embed Forest data and actions inside Zendesk tickets and customer profiles.
The Forest Zendesk app lets you view and act on your data without leaving Zendesk. Access customer records, trigger actions, and see related data inline in ticket sidebars.
# How it works
The app uses the Forest MCP to surface your data and actions inside Zendesk. Install the app from the Zendesk marketplace, connect it to your Forest project, and configure which collections to display.
# Install Guide
## Pre-requisites
The Forest Zendesk app uses the Forest MCP Server to securely access your Forest data and actions, it is therefore required to have it enabled to proceed with the installation of the Zendesk app.
1. Ensure the Forest MCP Server has been enabled on your Forest project
2. Make a note of your Forest MCP Server URL; you will need it when installing the Zendesk app
If you're not sure whether the Forest MCP Server has been enabled or which URL to access it on, ask your Tech team - they can simply follow [this guide](/product/embed/mcp-server) to set it up.
## Installing the Zendesk app
1. Find the [Forest app in the Zendesk Marketplace](https://www.zendesk.fr/marketplace/apps/support/1231469/forest-admin/?queryID=507953d20df1906a17dbad7341966557)
2. Click on Install
3. Enter your Zendesk URL and click on Next; you will be re-directed to your Zendesk Admin Center
You will need to be an Admin in Zendesk to access the Admin Center.
4. In the Installation form, you will need to provide:
1. The forestAgentUrl: this is the URL of your Forest MCP Server (as mentioned in the Pre-requisites section)
2. The contextField: this is the Zendesk Ticket Field that will be used to match the Ticket Sender with a record from your Forest project data
3. Optionally, you can set restrictions on which Zendesk Roles and Groups to grant access to the Forest app
5. Click on Install
The Forest Zendesk app is now installed! It can now be added to your ticket sidebar, from the Apps tab.
## Configuring the Zendesk App
Now that the Forest app has been installed in your Zendesk project, your back-ends will simply need to configure it:
1. In the Forest app, click on "Connect with Forest"
2. Enter your Forest credentials, as you usually would to access your Forest project
3. Select your Organization, Project, Environment, and Team, and click on Save changes
It will now automatically analyze the customer request from the ticket, find the relevant Customer, User, or Partner in your Forest data, and recommend relevant Data and Actions to solve the ticket.
# Executing actions
Source: https://docs.forest.app/product/execute/actions
Trigger actions and perform operations on your data directly from Forest.
**Actions** in Forest let operators trigger custom business logic on one or more records, sending emails, updating statuses, charging payments, generating reports, and more. This page explains how to find and execute actions as an operator.
## Types of actions
| Type | Description |
| ----------------- | --------------------------------------------------------------- |
| **Single record** | Triggered from a specific record's detail view or row menu |
| **Bulk** | Triggered on multiple selected records at once |
| **Global** | Triggered from the collection level, without selecting a record |
## Triggering a single record action
1. Open a record by clicking on a row in the table view.
2. In the detail view, look for the **Actions** button (top-right area).
3. Click the action you want to execute.
4. If the action has a form, fill in the required fields and click **Submit**.
Trigger actions directly from the row using the **context menu** (three-dot icon) without opening the detail view.
## Triggering bulk actions
1. [Select multiple records](/product/execute/browse#bulk-selection) using the checkboxes in the table view.
2. The **Actions** toolbar appears at the top of the table.
3. Click the action you want to run on the selected records.
4. Fill in the form (if required) and confirm.
Bulk actions apply to all selected records. Some actions may have a maximum number of records they can process at once.
## Triggering global actions
Global actions are not tied to any specific record. They appear in the **Actions** menu at the top of the collection view, regardless of whether records are selected.
Use global actions for operations like generating a report, triggering a sync, or exporting data.
## Action forms
Many actions open a **form** before execution. The form lets you provide additional input required by the action, for example, a reason for rejection, an amount to refund, or a target email address.
* **Required fields** are marked and must be filled before the form can be submitted.
* **Dynamic fields** may appear or change based on values you enter in other fields.
* **Dropdowns and pickers** are common field types for structured input.
## Actions requiring approval
Some actions are configured to require approval from another team member before they execute. When you trigger such an action:
1. A request is submitted to the **Approval Workflow**.
2. The action is not executed immediately, it waits for approval.
3. You can track the status in the [Approval Workflows](/product/collaborate/approval-workflows) tab.
Approval workflows are configured by admins in Project Settings → Roles. See [Approval Workflows](/product/collaborate/approval-workflows).
## Actions requiring confirmation
Some actions display a **confirmation dialog** before executing, to prevent accidental triggers. Read the confirmation message carefully before proceeding.
## Permissions
Which actions you can see and execute depends on your **role permissions**, configured by your admin. If an action is not visible, you may not have permission to access it.
# Browsing & searching data
Source: https://docs.forest.app/product/execute/browse
Navigate, search, filter, and explore your data effectively in Forest.
Forest gives you a broad set of tools to navigate your data: a flexible table view, full-text search, advanced filters, segments, and bulk selection. This page covers everything you need to find and explore records efficiently.
## Table view
When you open a collection, records are displayed in a **table view** with one row per record and one column per field. Customize this view using the [Layout Editor](/product/build/layout-editor).
* **Sort** by any column by clicking its header. Click again to reverse the order.
* **Resize** columns by dragging the column separator.
* **Reorder** columns by dragging the column header to a new position.
* **Show/hide** columns using the column picker button in the top-right of the table.
## Searching
The **search bar** at the top of any collection lets you filter records by keyword. Matching text is highlighted within the results.
By default, Forest searches across the fields configured as searchable for that collection (configured by your developers in the back-end), and matches on **contains**.
Developers can configure which fields are included in search using the `searchable` option in the back-end configuration.
### Extended search
By default, only the collection's own fields are searched. Reference fields of `belongsTo` records are ignored. **Extended search** also looks inside the reference fields of related records. It is not the default because it is slower.
### Advanced search syntax
Advanced search syntax is supported on Node.js back-ends (`@forestadmin/agent`) from version 1.36.18.
Combine these operators to search precisely:
| Syntax | Matches |
| ----------------------------- | ----------------------------------------------------------------- |
| `-term` | records that do **not** contain `term` |
| `property:term` | records with `term` in the `property` field |
| `relation.childProperty:term` | records with `term` in `childProperty` of the `relation` relation |
| `term1 OR term2` | records with `term1` or `term2` |
| `term1 AND term2` | records with both (same as `term1 term2`) |
| `property:NULL` | records whose `property` is the technical value `NULL` |
| `"multiple quoted words"` | the exact phrase, without splitting into separate terms |
All of these can be nested, for example `(property:term OR -term2) AND (property1:NULL OR relation.childProperty:term3)`.
`NULL`, `OR` and `AND` must be written in capital letters to be read as operators.
### Focused search on one property
`property:searchedTerm` searches only inside `property`; `relation.childProperty:searchedTerm` searches inside a relation's field.
The property name matches the technical name in the database. Forest ignores casing and separators like `-` and `_`, so `property_name` matches a technical `propertyName`.
Focused search combines with the operators below, for example `property:>20`.
**Text fields** match with the first operator your database supports, in order: contains (case insensitive), contains (case sensitive), then equal. So searching `Term` matches `TERM` and `abcTERMdef` on a case-insensitive database.
**Number fields** support `>42`, `>=42`, `<42`, `<=42` (for example `property:>42`).
**Date fields** support partial dates (`2020`, `2020-01`, `2020-01-01`) and the comparison operators (for example `property:>2020-01-01`, `property:<=2020`). Dates use the timezone configured in Forest.
**Boolean fields** accept `true`/`1` and `false`/`0` (case insensitive).
## Filtering
The **filter panel** lets you apply precise conditions to narrow down the list of records.
Click the **Filter** button (funnel icon) to open the filter panel, then:
1. Select a **field** to filter on.
2. Choose an **operator** (equals, contains, greater than, is null, etc.).
3. Enter a **value**.
4. Click **Apply**.
Add multiple filters and combine them with **AND** or **OR** logic. Filters are applied on top of any active segment.
### Saving filters
Frequently-used filters can be saved as **segments** by your developers. See [Segments](/product/process/segments/creating-segments) for more information.
## Segments
**Segments** are predefined subsets of a collection, equivalent to a saved filter. They appear as **tabs** above the record list.
Switch between segments by clicking the tabs. The record count next to each tab shows how many records match that segment.
Segments are configured by developers in the back-end code or by admins in the Layout Editor. See [Segments](/product/process/segments/creating-segments).
## Pagination
Large collections are split across multiple pages. Use the pagination controls at the bottom of the table to:
* Navigate **Previous / Next** page.
* Jump to a **specific page** by entering the page number.
* Change the **page size** (number of records per page).
The total record count is displayed next to the pagination controls.
## Detail view
Click any row in the table to open the **detail view** for that record. The detail view shows:
* All fields for the record, organized in the layout defined by your admin.
* **Related data**, linked records from related collections (one-to-many, many-to-many).
* **Action buttons**, actions available for this record.
* **Collaboration tab**, notes, approval requests.
Use the **Edit** button to modify field values directly from the detail view.
## Bulk selection
To perform an action on multiple records at once:
1. Check the **checkbox** on the left of each row you want to select.
2. To select all records on the current page, check the **header checkbox**.
3. To select all records across all pages (matching the current filter), use the **"Select all N records"** option that appears after selecting the page.
Once records are selected, **bulk actions** appear in the toolbar above the table.
Some bulk actions may have a maximum record limit. Check the action documentation or contact your developers for details.
# Export data
Source: https://docs.forest.app/product/execute/export
Export your data to CSV for reporting, analysis, and integration with other tools.
Forest lets you export collection data to **CSV** directly from the UI. Exports respect your active filters and segments, so you can export exactly the records you need.
## Exporting records
1. Open the collection you want to export.
2. Optionally, apply **filters** or switch to a **segment** to narrow the records.
3. Click the **Export** button (download icon) in the top-right toolbar.
4. Forest will generate and download a CSV file with the current record set.
The export includes all records matching your current filter, not just the records on the current page. Pagination does not affect exports.
## What gets exported
The CSV export includes:
* All **visible columns** in the current table view (as configured in the Layout Editor).
* All records matching the **active filter and segment**.
* Field values formatted as plain text.
Relationships (linked records) are exported as their identifier or display field, depending on your configuration.
## Export limits
For very large datasets, exports may take a moment to generate. Forest processes the export in the background and downloads the file when ready.
Extremely large exports (hundreds of thousands of records) may be slow. Consider applying filters to reduce the dataset before exporting.
For very large datasets, consider using a custom action to trigger an asynchronous export instead. This avoids timeout issues and lets you send the result by email or generate a downloadable file.
## Exporting approval request history
From the **Approval Workflows** tab, export the history of approval requests over a given period. Click the **Export** button in that tab and select the desired time interval, you'll receive a CSV file by email.
# Executing workflows
Source: https://docs.forest.app/product/execute/workflows
Trigger and monitor multi-step workflows from Forest.
Workflows in Forest let operators run multi-step operational processes, KYC reviews, refunds, customer onboarding, supplier onboarding, incident response, directly from the operations UI, without scripts or developer involvement. Each workflow combines data, actions, AI-powered steps, and decisions into a single guided sequence with full audit trail.
For developers building or configuring workflows, see [Workflows (process)](/product/process/workflows/overview). This page is for operators executing them.
## What a workflow does
A workflow chains multiple operations into a single automated sequence. Examples:
* **KYC review**: load customer record → fetch KYB documents → AI summarizes red flags → operator decision → trigger compliance action → log result
* **Customer refund**: validate eligibility → check balance → trigger Stripe refund → notify customer → close ticket
* **Supplier onboarding**: create account → request documents → AI validates submission → escalate exceptions → finalize contract
Each step is recorded, every decision, every input, every action triggered, producing a full decision trace operators (and auditors) can replay.
## Where workflows are triggered
Workflows can be executed from:
* The **List View** (on a single record)
* The **Summary** or **Details view** of a record
* A **Workspace**
The workflow opens in a guided panel. The user works through each step; the workflow context accumulates as they go.
Workflows can also be started automatically by an external system through a [webhook trigger](/product/process/workflows/triggers), without a user in the interface.
## AI-powered steps
The **Get Data**, **Update Data**, **Trigger Action**, and **MCP Task** steps surface the relevant data or actions automatically based on the workflow's context. Each completed step adds to that context, helping the AI suggest better defaults and pre-fills for subsequent steps, for example, pre-filling fields when updating a record, or composing an email with the right context when notifying a customer through a tool like Zendesk.
Every AI-powered step provides a **Handle Manually** option, letting users bypass the AI suggestion if it isn't relevant or if the AI hits an error. Subsequent steps then lack that context, which may affect downstream steps.
## Automated steps
Some steps run without user input, typically AI-powered steps and certain Decisions. If the AI lacks context to make a confident decision, or if a third-party system is unresponsive, the user is prompted to take over (same as **Handle Manually**).
## Saving and resuming
A workflow can be paused at any time. The user is prompted to **Save** or **Abort** it. Saved workflows can be resumed by another user, after any length of time:
* They resume automatically when triggered from a record's Summary View or a Workspace
* They appear under **To Continue** when the workflow is accessed from the List View or Record View
This is how queue handoffs work, one operator starts a case, leaves it for review, another picks it up.
## Revising steps
Workflows reduce mistakes in complex processes, but errors still happen. To correct something, go back to a completed step and click **Revise** to redo it.
Since workflows can branch based on user choices, revising a step **cancels any steps completed after it**, ensuring the workflow follows the correct path based on the updated input.
## Audit trail
Every workflow execution is recorded. The full sequence of steps, decisions, inputs, and outputs is replayable in read-only mode from the workflow list on the record. Every interaction is also logged in your **Activity Logs**, accessible from the Reports tab or via the [public API](/reference/api/endpoints/activity-logs) for audit and compliance reporting.
## Permissions
Workflow execution is governed by the standard role and team permission system. A user can only:
* See workflows their team has access to
* Trigger steps for which their role has the required permissions
* Access data and actions inside steps based on their scopes
See [Roles & permissions](/get-started/control/roles-permissions) for configuration.
# Charts
Source: https://docs.forest.app/product/manage/charts/overview
Visualize your data with KPI cards, time-based trends, distributions, and more.
**Charts** in Forest let you build visual data representations directly from your collections, without writing custom code. Use them to monitor KPIs, track trends, and create dashboards your operations team can act on.
Charts can be displayed in two places:
* The **Dashboard** tab, a shared dashboard visible to your team.
* The **Analytics** tab of a specific record, charts scoped to a single record's data.
## Chart types
Forest supports six chart types:
| Type | Description | Example |
| ---------------- | --------------------------------------------------------- | ---------------------------------- |
| **Single value** | Displays one key metric as a large number | Total customers, MRR |
| **Repartition** | Pie or donut chart showing distribution across categories | Customers by country, Paid vs Free |
| **Time-based** | Line or bar chart showing a metric over time | Signups per month |
| **Percentage** | Displays a metric as a percentage | % paying customers |
| **Objective** | Progress bar showing actual vs target | Orders vs monthly goal |
| **Leaderboard** | Ranked list showing top N items | Companies by transaction volume |
For Repartition charts, only the top 5 categories are shown individually. All others are grouped into a 6th "Other" category.
## Creating a chart
1. Enable **Layout Editor** mode by clicking the toggle in the top navigation.
2. Navigate to the **Dashboard** tab (or the Analytics tab of a record).
3. Click **Add a new chart**.
4. Enter a **name** and optional **description**.
5. Select a **chart type**.
6. Configure the chart data source (see below).
7. Save and exit Layout Editor mode.
### Simple mode (UI-based)
In **Simple mode**, configure your chart by selecting:
* **Collection**, which data source to query.
* **Aggregate function**, `count`, `sum`, `average`, `min`, `max`.
* **Group by field**, the dimension to group results by.
* **Time frame**, for time-based charts: day, week, month, or year.
* **Filters**, optional conditions to restrict which records are included.
### Query mode (SQL)
For advanced analytics, use **Query mode** to write custom SQL directly.
Query mode is only available for SQL databases. For security reasons, only `SELECT` queries are allowed.
Each chart type expects specific column names in the query result:
| Chart type | Required columns |
| ------------------------ | ----------------------------- |
| Single value | `value` |
| Single value with growth | `value`, `previous` |
| Repartition | `key`, `value` |
| Time-based | `key`, `value` |
| Objective | `value`, `objective` |
| Leaderboard | `key`, `value` (with `LIMIT`) |
**Example, Single value:**
```sql theme={null}
SELECT COUNT(*) AS value
FROM customers;
```
**Example, Repartition:**
```sql theme={null}
SELECT status AS key, COUNT(*) AS value
FROM orders
GROUP BY status;
```
**Example, Time-based:**
```sql theme={null}
SELECT DATE_TRUNC('month', created_at) AS key, COUNT(*) AS value
FROM signups
GROUP BY key
ORDER BY key;
```
**Example, Leaderboard:**
```sql theme={null}
SELECT companies.name AS key, SUM(transactions.amount) AS value
FROM transactions
JOIN companies ON transactions.beneficiary_company_id = companies.id
GROUP BY key
ORDER BY value DESC
LIMIT 10;
```
## Record-specific analytics
Charts can also be scoped to a single record. When placed in the **Analytics** tab of a collection:
* In **Query mode**, use `{{recordId}}` to inject the current record's ID into your SQL query.
* In **API mode**, the `record_id` is automatically passed in the HTTP body.
**Example:**
```sql theme={null}
SELECT DATE_TRUNC('month', transactions.created_at) AS key, SUM(transactions.amount) AS value
FROM transactions
WHERE transactions.company_id = {{recordId}}
GROUP BY key
ORDER BY key;
```
A chart configured this way works for all records in the collection.
## Managing charts
In **Layout Editor** mode:
* **Move** charts by dragging them on the dashboard.
* **Resize** charts to fit your layout.
* **Edit** a chart's configuration by clicking the pencil icon.
* **Delete** a chart by clicking the trash icon.
# Smart Charts
Source: https://docs.forest.app/product/manage/charts/smart-charts
Code your own custom charts when no-code chart types aren't enough.
The no-code chart types (Single, Distribution, Time-based, Cohort, Leaderboard, Objective) cover most needs. When you want full control over how data is displayed, custom visualizations, external libraries, complex layouts, use **Smart Charts**.
With Smart Charts you write the data source on the back-end and the rendering component in the UI editor. There's no limit to what you can render: D3.js, custom SVG, embedded iframes, anything you can write in JavaScript.
## Creating a Smart Chart
From any dashboard or record analytics tab, click **Edit Smart Chart** to open the editor.
The editor exposes three tabs:
* **Template**, the Handlebars template that renders your chart
* **Component**, the JavaScript component that loads data and orchestrates rendering
* **Style**, optional CSS scoped to this chart
Click **Run code** at any time to preview the chart. When you're done, click **Create Chart** (or **Save** if already created).
When creating a Smart Chart on a specific record (record's Analytics tab), the `record` object is directly accessible via `this.args.record` in the component or `@record` in the template.
## Defining the data source on the back-end
A Smart Chart pulls its data from a custom endpoint you expose on the back-end with `addChart`. The handler returns any shape your component expects.
```javascript Node.js theme={null}
agent.addChart('mytablechart', async (context, resultBuilder) => {
// Load data from anywhere: your database, an external API, etc.
return resultBuilder.smart([
{ username: 'Darth Vader', points: 1500000 },
{ username: 'Luke Skywalker', points: 2 },
]);
});
```
```ruby Ruby theme={null}
@create_agent.add_chart('mytablechart') do |context, result_builder|
result_builder.smart(
[
{ username: 'Darth Vader', points: 1_500_000 },
{ username: 'Luke Skywalker', points: 2 },
]
)
end
```
```ruby Ruby DSL theme={null}
@create_agent.chart :mytablechart do
# `context` is available implicitly
smart(
[
{ username: 'Darth Vader', points: 1_500_000 },
{ username: 'Luke Skywalker', points: 2 },
]
)
end
```
The component then fetches `/forest/_charts/mytablechart` and passes the data to the template.
### Passing extra parameters
The chart handler's `context` exposes any custom query-string or body parameters sent to the chart endpoint via `context.parameters`, alongside `context.caller` (the current user). Use them to make a chart depend on a filter, a date range, or the current record:
```javascript theme={null}
agent.addChart('example', async (context, resultBuilder) => {
// Current user
const { email, timezone } = context.caller;
// Custom parameters from the request
const { startDate } = context.parameters;
const rows = await context.dataSource
.getCollection('orders')
.aggregate({}, { operation: 'Count' });
return resultBuilder.value(rows[0]?.value ?? 0);
});
```
For collection charts, `context` also exposes `context.recordId`, `context.compositeRecordId`, and `context.getRecord(fields)`.
## Example: table chart
A minimal table chart in three steps: back-end endpoint, component fetch, template.
**Component:**
```js theme={null}
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class extends Component {
@service lianaServerFetch;
@tracked users;
constructor(...args) {
super(...args);
this.fetchData();
}
async fetchData() {
const response = await this.lianaServerFetch.fetch(
'/forest/_charts/mytablechart',
{},
);
this.users = await response.json();
}
}
```
**Template:**
```handlebars theme={null}
{{user.username}}{{user.points}}
```
## Example: bar chart with D3.js
Smart Charts can load any external library. This bar chart uses [D3.js](https://d3js.org), inspired by [this example](https://observablehq.com/@d3/bar-chart).
**Back-end:**
```javascript theme={null}
agent.addChart('alphabetfrequency', async (context, resultBuilder) => {
return resultBuilder.smart([
{ name: 'E', value: 0.12702 },
{ name: 'T', value: 0.09056 },
{ name: 'A', value: 0.08167 },
// ...
]);
});
```
**Component (excerpt):**
```js theme={null}
import Component from '@glimmer/component';
import { loadExternalJavascript } from 'client/utils/smart-view-utils';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
export default class extends Component {
@service lianaServerFetch;
@tracked chart;
async load() {
await loadExternalJavascript('https://d3js.org/d3.v6.min.js');
const response = await this.lianaServerFetch.fetch(
'/forest/_charts/alphabetfrequency',
{},
);
const alphabet = await response.json();
this.renderChart(alphabet);
}
renderChart(alphabet) {
// d3 rendering logic: produce an SVG node
this.chart = svg.node();
}
}
```
**Template:**
```handlebars theme={null}
{{this.chart}}
```
## Example: cohort retention chart
A retention table with shaded cells, also using D3.js.
The back-end returns the cohort matrix; the component computes percentages and renders shaded cells; the template wraps the result.
```javascript theme={null}
agent.addChart('cohort', async (context, resultBuilder) => {
return resultBuilder.smart({
title: 'Retention rates by weeks after signup',
head: ['Cohort', 'New users', '1', '2', '3', '4', '5', '6', '7'],
data: {
'May 3, 2021': [79, 18, 16, 12, 16, 11, 7, 5],
'May 10, 2021': [168, 35, 28, 30, 24, 12, 10],
'May 17, 2021': [188, 42, 32, 34, 25, 18],
'May 24, 2021': [191, 42, 32, 28, 12],
'May 31, 2021': [191, 45, 34, 30],
'June 7, 2021': [184, 42, 32],
'June 14, 2021': [182, 44],
},
});
});
```
## Example: density map
A geographic density map, fetching contour data and population statistics, rendering with D3 + topojson.
```javascript theme={null}
agent.addChart('densitymap', async (context, resultBuilder) => {
const contours = await fetch('https://example.com/counties-albers-10m.json').then(r => r.json());
const population = await fetch('https://example.com/population.json').then(r => r.json());
return resultBuilder.smart({ contours, population });
});
```
The component loads D3, topojson, then renders the SVG. See [this Observable notebook](https://observablehq.com/@d3/bubble-map) for the rendering logic.
# Dashboards
Source: https://docs.forest.app/product/manage/dashboards
Create and organize dashboards to monitor KPIs and metrics across your team.
A **dashboard** is a tab in your Forest project where you group charts to track KPIs and operational metrics. You can create as many dashboards as needed, one per team, use case, or business domain.
## Creating a dashboard
1. Enable **Layout Editor** mode from the top navigation.
2. Click **+ New** next to the dashboard tabs.
3. Give the dashboard a name.
4. Add charts to it (see [Charts](./charts/overview)).
## Managing dashboards
In Layout Editor mode, you can:
* **Rename** a dashboard by clicking its tab name.
* **Reorder** dashboards by dragging the tabs.
* **Delete** a dashboard by clicking the trash icon next to its name.
Deleting a dashboard also deletes all charts it contains.
## Adding charts
Charts are the building blocks of a dashboard. Forest supports two ways to populate chart data:
* **Simple mode**, configure charts from the UI by selecting a collection, an aggregate function, and optional filters. No code required.
* **Query mode**, write a raw SQL query directly in the UI for advanced analytics.
See [Charts](./charts/overview) for the full reference on chart types and configuration.
## API-powered charts
When chart data requires custom business logic, calling an external API, joining data across sources, or applying transformations not possible in SQL, you can implement the data retrieval in your agent.
Use `agent.addChart()` to register a named chart handler:
```javascript theme={null}
agent.addChart('monthlyRecurringRevenue', async (context, resultBuilder) => {
const rows = await context.dataSource
.getCollection('payments')
.aggregate(
{ conditionTree: { field: 'status', operator: 'equal', value: 'paid' } },
{ operation: 'Sum', field: 'amount' }
);
return resultBuilder.value(rows[0].value);
});
```
```ruby theme={null}
@agent.add_chart('monthlyRecurringRevenue') do |context, result_builder|
result = context.datasource.get_collection('payment').aggregate(
Filter.new(condition_tree: Nodes::ConditionTreeLeaf.new('status', Operators::EQUAL, 'paid')),
Aggregation.new(operation: 'Sum', field: 'amount')
)
result_builder.value(result[0]['value'])
end
```
Then in the UI, create a chart on the dashboard, select **API** as the data source, and enter the chart URL:
```
/forest/_charts/monthlyRecurringRevenue
```
The chart type selected in the UI must match the `resultBuilder` method used in your back-end (`value`, `timeBased`, `distribution`, `percentage`, `objective`, `leaderboard`). The chart name must be URL-safe.
### Record-specific API charts
To scope a chart to a specific record, register it on the collection instead:
```javascript theme={null}
agent.customizeCollection('customers', collection => {
collection.addChart('revenueByCustomer', async (context, resultBuilder) => {
const rows = await context.dataSource
.getCollection('payments')
.aggregate(
{
conditionTree: {
aggregator: 'And',
conditions: [
{ field: 'status', operator: 'equal', value: 'paid' },
{ field: 'customer:id', operator: 'equal', value: context.recordId },
],
},
},
{ operation: 'Sum', field: 'amount' }
);
return resultBuilder.value(rows[0].value);
});
});
```
```ruby theme={null}
@agent.customize_collection('customer') do |collection|
collection.add_chart('revenueByCustomer') do |context, result_builder|
result = context.datasource.get_collection('payment').aggregate(
Filter.new(
condition_tree: Nodes::ConditionTreeBranch.new('And', [
Nodes::ConditionTreeLeaf.new('status', Operators::EQUAL, 'paid'),
Nodes::ConditionTreeLeaf.new('customer:id', Operators::EQUAL, context.get_record_id)
])
),
Aggregation.new(operation: 'Sum', field: 'amount')
)
result_builder.value(result[0]['value'])
end
end
```
The chart URL for collection-scoped charts follows this pattern:
```
/forest/_charts/customers/revenueByCustomer
```
## Access control
Dashboard visibility follows your project's role permissions. Users only see the charts they have access to based on their role's collection permissions.
# Inbox
Source: https://docs.forest.app/product/manage/inbox
Automatically distribute tasks across your team with Inboxes.
The **Inbox** feature automatically assigns tasks to team members based on workload and dispatch rules, improving efficiency and ensuring no record falls through the cracks.
An inbox is either tied to:
* A **Segment**, for example the `Waiting for Validation` segment of a `Documents` collection. Every record in that segment becomes a task for operators to process.
* A **Workflow**, as an escalation mechanism for a user to pick up a process where a previous user left off.
## Creating an inbox
To create an inbox, navigate to the **Collaboration** tab and enable **Layout Editor** mode. In the left navbar, find the **Inboxes** section and click **Add New**.
An inbox is always based on a segment of a collection. In this example, the inbox is based on the `Documents` collection and the `Waiting for Validation` segment, all documents that have not been verified yet.
## Processing tasks
Once an inbox is created, operators can access it from the main tab, or any Workspace on which the inbox has been configured as a source.
Click **Start processing** to be automatically assigned to the next task and redirected to the record.
A bar appears at the top indicating you are processing a task. Click **Next ticket** to be automatically assigned a new task.
### To do
The **To do** section shows the number of unassigned records, and provides the **Start Processing** button to be automatically assigned to the next task and redirected to the record.
After completing the work on that record (e.g. triggering a `Validate Document` action), click **Next ticket** in the top bar to be assigned a new task automatically.
### Doing
The **Doing** section shows tasks currently assigned to you. Navigate back to an in-progress record from here.
### Backlog
The **Backlog** is visible to operators with a **Manager, Editor, Developer, or Administrator** permission level. It provides an overview of all records in the inbox:
* Which tasks are unassigned, in progress, or completed.
* Which operator is assigned to each task.
* For in-progress tasks: when the task was started.
* For completed tasks: the total handling time (including approval time if applicable).
## Manual task assignment
Managers can manually assign a specific record to an operator from:
* The **Backlog** section of the inbox.
* The **record detail view**, using the native **"Assign to..."** action, which also lets you assign records to operators outside your current team.
Manual assignments bypass the sorting rules and become the next task for that operator.
## Inbox settings
Access inbox settings from **Layout Editor mode** → the menu next to the inbox name. From there:
* **Rename** the inbox or update its icon.
* Edit its **dispatch rules**.
* **Prevent operators from un-assigning themselves** from tasks.
By default, operators can un-assign themselves from a task, and assign someone else in their place.
* **Automatically un-assign tasks** after a configurable idle time, making them available to other operators.
* **Set a maximum number of concurrent tasks per operator**, when reached, clicking "Next ticket" redirects the operator to one of their in-progress tasks instead of assigning a new one.
Maximum concurrent tasks and automatic unassignment are disabled by default.
# Team performance
Source: https://docs.forest.app/product/manage/team-performance
Monitor team productivity and operational efficiency.
This page is a work in progress. Team Performance metrics are part of an upcoming feature set. Check back for updates, or contact your Forest account manager for the latest information.
The **Team Performance** section will provide managers and team leads with aggregated metrics and analytics about how their team is performing, including task throughput, response times, and workload distribution.
In the meantime, track team activity using:
* **[Activity Logs](/get-started/control/audit)**, a complete audit trail of all actions and events.
* **[Inbox](/product/manage/inbox)**, the Backlog section shows task assignment and completion data per operator.
* **[Charts](/product/manage/charts/overview)**, build custom dashboards using your operational data.
# Product
Source: https://docs.forest.app/product/overview
What Forest does, workflows, governance, MCP, decision traces, and the surfaces that build them.
Forest is the operational infrastructure where regulated companies run end-to-end workflows across humans, AI agents, suppliers, and partners. The product is organized around six surfaces, what you build, how operators execute work, who collaborates, where the platform shows up, what gets managed, and how developers customize the back-end in code.
## The six surfaces
Shape what your operators see. Layout editor, collection settings, fields and widgets, custom Smart Views, workspaces, search, pagination, layout versioning.
Operations your team performs daily, browsing records, triggering actions, exporting data, running workflows.
See what's happening, dashboards, charts, activity logs, inboxes, and team performance views.
Coordinate on records, notes, mentions, and approval workflows.
Bring Forest into the tools your team and AI agents already use, MCP server, n8n node, Zendesk app.
Customize the back-end in code, fields, actions, relationships, segments, hooks, and plugins.
## Build, shape the interface
The Build surface is where you configure what operators see. Most of it is no-code.
Visually customize collection views, detail pages, and forms.
Display name, summary field, segments, filters, and per-collection action visibility.
Map field types to display and edit widgets, from text and numbers to images, JSON, and relationships.
Replace the default table with any UI, maps, calendars, galleries, kanban, using JavaScript, HTML, and CSS.
Combine data and actions from multiple collections into a single page for an operational workflow.
Configure how search behaves and how large collections paginate.
## Execute, get work done
Filter, sort, and inspect records across your collections.
Trigger custom and built-in actions on single records, bulk selections, or at the collection level.
Run multi-step operational processes, KYC reviews, refunds, onboarding, with a no-code editor.
Export collection data to CSV.
## Manage, see what's happening
Combine charts to monitor KPIs and operational metrics.
Single value, repartition, time-based, percentage, objective, and leaderboard charts, with simple or SQL-based configuration.
Every action taken in Forest, with full audit context.
Distribute work to your team, review queues, escalations, and assigned records.
Track team productivity and bottlenecks across operations.
## Collaborate, work as a team
Leave contextual notes on records and mention teammates.
Require sign-off from another team member before sensitive actions execute.
## Embed, bring Forest into the tools your operators and back-ends use
Expose Forest data and workflows to AI agents via the Model Context Protocol, with permissions and audit trails preserved.
Trigger Forest actions and access data from n8n workflows.
Surface Forest records inside Zendesk tickets.
## Process, customize the back-end in code
This is where developers extend Forest's behavior beyond the no-code surface.
Computed fields, validation, write behavior, filtering and sorting on any field.
Custom actions in code, dynamic forms, multi-step logic, file generation, custom result types.
Cross-database relationships, computed foreign keys, external API-backed relationships.
No-code and code-based segments to slice your collections.
Configure no-code workflow steps that combine data, actions, and AI.
Intercept CRUD operations and package reusable customizations.
## Looking for the API or CLI?
Configuration happens here. Reference documentation lives in **[Reference](/reference/overview)**, the Node.js and Ruby back-end SDKs, the public API, the Forest CLI, and the `.forestadmin-schema.json` format.
# Context & scope
Source: https://docs.forest.app/product/process/actions/custom-actions/context-scope
Understanding action scopes and the context object
Actions have three scopes that determine how they trigger and which records they target. The context object provides access to form values, selected records, and user information.
## Action scopes
| Scope | Targets | Triggered from list view | Triggered from detail view |
| ---------- | ----------------------------- | ------------------------------------- | -------------------------- |
| **Single** | One record at a time | When one record is selected | ✅ Yes |
| **Bulk** | Multiple selected records | When one or more records are selected | ✅ Yes |
| **Global** | Your choice among all records | ✅ Always available | ❌ No |
### Single scope
Execute on one specific record.
```javascript Node.js / Cloud theme={null}
collection.addAction('Send email', {
scope: 'Single',
execute: async (context, resultBuilder) => {
const user = await context.getRecord(['email', 'name']);
// Process single user
},
});
```
```ruby Ruby theme={null}
collection.add_action(
'Send email',
BaseAction.new(scope: ActionScope::SINGLE) do |context, result_builder|
user = context.get_record(['email', 'name'])
# Process single user
end
)
```
```ruby Ruby DSL theme={null}
collection.action 'Send email', scope: :single do
execute do
user = record(['email', 'name'])
# Process single user
end
end
```
**Use when:**
* Sending an email to a specific user
* Generating an invoice for one order
* Viewing details of a single item
* Resetting a user's password
**Context methods:**
* `context.getRecord(fieldNames)` - Get the selected record
* `context.getRecordId()` - Get the record ID
### Bulk scope
Execute on multiple selected records simultaneously.
```javascript Node.js / Cloud theme={null}
collection.addAction('Archive selected', {
scope: 'Bulk',
execute: async (context, resultBuilder) => {
const orders = await context.getRecords(['id']);
// Process all selected orders
},
});
```
```ruby Ruby theme={null}
collection.add_action(
'Archive selected',
BaseAction.new(scope: ActionScope::BULK) do |context, result_builder|
orders = context.get_records(['id'])
# Process all selected orders
end
)
```
```ruby Ruby DSL theme={null}
collection.action 'Archive selected', scope: :bulk do
execute do
orders = records(['id'])
# Process all selected orders
end
end
```
**Use when:**
* Archiving multiple items
* Updating status of several records
* Sending mass emails
* Bulk deleting records
**Context methods:**
* `context.getRecords(fieldNames)` - Get all selected records
* `context.getRecordIds()` - Get array of record IDs
Handle failures gracefully in bulk actions. Decide whether to stop on first error or continue processing remaining records.
### Global scope
Execute at collection level without selecting specific records.
```javascript Node.js / Cloud theme={null}
collection.addAction('Import data', {
scope: 'Global',
execute: async (context, resultBuilder) => {
const { file } = context.formValues;
// Process import for entire collection
},
});
```
```ruby Ruby theme={null}
collection.add_action(
'Import data',
BaseAction.new(scope: ActionScope::GLOBAL) do |context, result_builder|
file = context.form_values['file']
# Process import for entire collection
end
)
```
```ruby Ruby DSL theme={null}
collection.action 'Import data', scope: :global do
execute do
file = form_value(:file)
# Process import for entire collection
end
end
```
**Use when:**
* Importing data from CSV
* Generating collection-wide reports
* Syncing with external services
* Running maintenance tasks
**Context methods:**
* `context.filter` - Access current filters and search
* `context.collection` - Query the collection
* No specific records are pre-selected
## The context object
The context object is passed as the first argument to the execute handler and provides access to all action data.
### Form values
Access values entered by the user in the form:
```javascript theme={null}
const { Amount, Description } = context.formValues;
// With spaces in label
const firstName = context.formValues['First Name'];
// By field id (if specified)
const email = context.formValues['email'];
```
### Selected records
Get data from the records the action is running on:
```javascript Single action theme={null}
const user = await context.getRecord(['id', 'email', 'name']);
console.log(user.id, user.email, user.name);
// Get just the ID
const userId = await context.getRecordId();
```
```javascript Bulk action theme={null}
const users = await context.getRecords(['id', 'email']);
// Returns array of records
const ids = await context.getRecordIds();
// Returns array of IDs: [1, 2, 3, ...]
```
### Current user
Access information about who triggered the action:
```javascript theme={null}
const userId = context.caller.id;
const userEmail = context.caller.email;
const userRole = context.caller.role;
const userTeam = context.caller.team;
const userTimezone = context.caller.timezone;
```
### Collection metadata
Access the collection schema and query interface:
```javascript theme={null}
const collectionName = context.collection.name;
const fields = context.collection.schema.fields;
// Query the collection
const records = await context.collection.list(filter, projection);
```
### Filters
For Bulk and Global actions, access current filters from the UI:
```javascript theme={null}
const filter = context.filter;
// Use filter to query matching records
const matchingRecords = await context.collection.list(filter, projection);
```
This filter represents the current segment, search, and filters applied in the Forest interface.
### Change detection
Check if a form field value has changed (useful for dynamic forms):
```javascript theme={null}
if (context.hasFieldChanged('Status')) {
// Status field was modified by user
const newStatus = context.formValues.Status;
}
```
## Examples
### Example: Access record data
```javascript Node.js / Cloud theme={null}
collection.addAction('Display customer info', {
scope: 'Single',
execute: async (context, resultBuilder) => {
// Get specific fields
const customer = await context.getRecord([
'firstName',
'lastName',
'email',
'company:name' // Relation field
]);
return resultBuilder.success('Customer info', {
html: `
Name: ${customer.firstName} ${customer.lastName}
Email: ${customer.email}
Company: ${customer.company.name}
`,
});
},
});
```
```ruby Ruby theme={null}
collection.add_action(
'Display customer info',
BaseAction.new(scope: ActionScope::SINGLE) do |context, result_builder|
# Get specific fields
customer = context.get_record([
'firstName',
'lastName',
'email',
'company:name' # Relation field
])
result_builder.success(
'Customer info',
html: "
Transaction ##{transaction_id} created successfully
Amount: $#{amount}
",
invalidated: ['transactions', 'balance']
)
```
## With error result
Invalidation works with success results only. Errors don't refresh data:
```javascript Node.js / Cloud theme={null}
try {
await createTransaction(data);
return resultBuilder.success('Success', {
invalidated: ['transactions'],
});
} catch (error) {
// No invalidation on error
return resultBuilder.error(`Failed: ${error.message}`);
}
```
```ruby Ruby theme={null}
begin
create_transaction(data)
result_builder.success('Success', invalidated: ['transactions'])
rescue => error
# No invalidation on error
result_builder.error("Failed: #{error.message}")
end
```
## Finding relationship names
The relationship name in `invalidated` must match the field name in your schema:
1. Open the Summary View in Layout Editor
2. Find the Related Data section you want to refresh
3. Note the relationship field name (e.g., `emitted_transactions`, `comments`, `order_items`)
4. Use that exact name in the `invalidated` array
## Limitations
* Invalidation only works with `success()` result type
* Only affects Summary Views with Related Data sections
* Cannot invalidate data in other collections
* Requires exact match of relationship field names
## Alternative: Full page reload
If you need to refresh all data on the page, redirect to the current page:
```javascript theme={null}
const currentUrl = context.request.url; // If available
return resultBuilder.redirectTo(currentUrl);
```
However, this is less efficient than targeted invalidation.
# Result types
Source: https://docs.forest.app/product/process/actions/custom-actions/result-types
Return different types of feedback from your actions
Actions can return different types of results to provide feedback to users. Use the result builder to control what happens after an action executes.
## Default behavior
If you don't return anything and no exception is thrown, Forest displays a generic success notification.
```javascript theme={null}
execute: async (context, resultBuilder) => {
// Perform your logic
// No return = generic success message
}
```
## Success notification
Display a custom success message.
```javascript Node.js / Cloud theme={null}
return resultBuilder.success('Company is now live!');
```
```ruby Ruby theme={null}
result_builder.success(message: 'Company is now live!')
```
## Error notification
Display an error message when something goes wrong.
```javascript Node.js / Cloud theme={null}
if (!isValid) {
return resultBuilder.error('The company was already live!');
}
```
```ruby Ruby theme={null}
if !is_valid
result_builder.error(message: 'The company was already live!')
end
```
Always handle errors gracefully and return meaningful error messages to help users understand what went wrong.
## HTML result
Return rich formatted content displayed in a side panel. Perfect for showing detailed operation results.
```javascript Node.js / Cloud theme={null}
return resultBuilder.success('Charge successful', {
html: `
"
}
)
```
### HTML with error
You can also return HTML content with an error:
```javascript Node.js / Cloud theme={null}
return resultBuilder.error('Charge failed', {
html: `
"
)
end
}
```
### Conditional redirect
```javascript Node.js / Cloud theme={null}
execute: async (context, resultBuilder) => {
const order = await context.getRecord(['status', 'id']);
if (order.status === 'pending') {
// Update and redirect to details
await updateOrder(order.id, { status: 'approved' });
return resultBuilder.redirectTo(`/orders/${order.id}`);
} else {
// Already processed
return resultBuilder.error('Order was already processed');
}
}
```
```ruby Ruby theme={null}
execute: ->(context, result_builder) {
order = context.get_record(['status', 'id'])
if order['status'] == 'pending'
# Update and redirect to details
update_order(order['id'], { status: 'approved' })
result_builder.redirect_to("/orders/#{order['id']}")
else
# Already processed
result_builder.error('Order was already processed')
end
}
```
# Overview
Source: https://docs.forest.app/product/process/actions/overview
Trigger custom operations on your data across Forest and 3rd party apps
Actions are interactive buttons in Forest that execute operations on your data. They let you implement business logic, provide a secure way for users to update your data, and integrate with external services directly from your back-office.
## What are actions?
An action is a button that appears in the Forest interface. When clicked, it triggers custom logic - from simple field updates to more complex business logic.
Actions let you:
* Execute business operations (approve orders, ban users, send invoices)
* Integrate with external services (Stripe, SendGrid, Slack)
* Process data (bulk updates, exports, transformations)
* Implement approval processes
* Trigger webhooks to third-party tools
## Where actions appear
Actions are available throughout Forest:
* **Collections**
* **Table view**: Actions on records in table and detail views (single, bulk, or global actions)
* **Details tab**: Trigger an Action on the selected record, from its Details tab
* **Summary tab**: Trigger an Action from a record's summary view, when configured
* **Workspaces** - learn more [here](/product/build/workspaces)
* **Workflows** - learn more [here](/product/execute/workflows)
## Built-in actions
Forest provides native actions out-of-the-box:
| Action | Description | Scope |
| ------------- | ------------------------------ | ------------- |
| **Create** | Add a new record | Global |
| **Update** | Edit record fields | Single |
| **Delete** | Delete a record | Single / Bulk |
| **Duplicate** | Copy an existing record | Single |
| **Export** | Download all records as a CSV | Global |
| **Assign to** | Assign records to team members | Single |
These work automatically with your data structure and require no configuration.
The **Assign to** action is part of Forest's [Inbox](/product/manage/inbox) feature for task management.
## Custom actions
Beyond built-in actions, create your own custom actions with custom code in your back-end:
* **Complex business logic**
* **Dynamic forms**: Forms that adapt to user input
* **External integrations**: Deep API integrations
* **File generation**: Create PDFs, CSVs, reports
* **Custom result types**: HTML responses, redirects, downloads
For developers who need full control and flexibility.
Build actions with code
## Action scopes
Every action has a **scope** that defines when it's available:
| Scope | Description | Example |
| ---------- | ------------------------------------------------------ | ------------------------------------- |
| **Single** | Operates on one specific record | Send a password reset email to a user |
| **Bulk** | Operates on multiple selected records | Archive 50 selected orders at once |
| **Global** | Operates at collection level without selecting records | Import customers from a CSV file |
## Action types
You can customize the appearance of action buttons to match their purpose, via the Collection settings:
| Type | Color | Use case |
| ----------- | --------------- | ----------------------------------- |
| **Default** | Interface color | Standard operations |
| **Info** | Blue | Informational actions |
| **Success** | Green | Positive actions (approve, confirm) |
| **Warning** | Orange | Actions requiring attention |
| **Danger** | Red | Destructive actions (delete, ban) |
| **Neutral** | Light gray | Secondary actions |
The button color helps users quickly identify the nature of an action before executing it from a Workspace or Summary View.
## Action visibility
Control when and where actions appear in your interface.
**Role-based permissions** determine which users can see and execute specific actions. Configure permissions per team and role to restrict sensitive operations. See [Roles & permissions](/get-started/control/roles-permissions) for configuration.
**Approval workflows** require validation from another authorized user before execution. This adds a review step for critical operations. See [Approval workflows](https://forest.mintlify.app/product/collaborate/approval-workflows) for more information.
**Conditional visibility** allows actions to appear only when certain conditions are met, i.e. when the selected record belong to one or more specific segments.
## What actions enable
### Approval workflows
Actions integrate with Forest's approval system. Require manager approval before execution, track approval history, and maintain an audit trail of who approved what.
Learn about approval workflows
### Role-based permissions
Control who can execute actions by configuring permissions per team and role. Show actions only when conditions are met and restrict sensitive operations.
Configure action permissions
# Environments & branches
Source: https://docs.forest.app/product/process/advanced-concepts/developer-workflow/environments-and-branches
One of the goals of Forest is enabling technical teams to achieve more in less time. Your Forest back-office is composed of 2 parts, the frontend (UI) and the back-end, and for each one, you need the right tools:
The **Admin back-end** is part of your codebase. You'll be using your favorite tools to customize it:
* Editing: your favorite IDE
* Versioning: your favorite versioning tool (git, svn, mercurial, etc.)
Your **Forest UI** is **not** part of your codebase: it is managed on Forest servers. Here's what we've built for you:
* Editing: use the Layout Editor mode to intuitively manage your layout (UI)
* Versioning: use [Forest CLI](/reference/cli/overview) to manage your layout versions
## Environments
### Deploying to production
Forest is meant to help you manage your operations: this can only happen if your team operates on your Production data. To do so, you need to **create your Production Environment**.
Click "Deploy to production" on the top banner or in the "Environments" tab of your Project settings.
#### Deploy your Back-end
In the first step, you need to input your Back-end's URL. This is the URL of the server onto which you have deployed (or will soon deploy) your Back-end's codebase.
For security reasons, your back-end must use the **HTTPS** protocol. The URL must not end with a trailing `/`.
#### Connect to your database
In the next step, you need to fill out your Production database credentials.
Your **database credentials** never leave your browser and are solely used to generate environment variables on the next step, so they are **never exposed**.
#### Set your environment variables
The final step requires that you add environment variables to your server. Follow the on-screen instructions. Once your server is successfully detected and running with the indicated environment variables, a "Finish" button will appear.
### Creating a remote environment
Now that your back-office is live in production, you might want to add an extra step for testing purposes. Forest lets you create Remote Environments (for test, qa, staging, pre-production, etc.).
To create a new Remote Environment, go to your Project settings, then from the "Environments" tab, click on "Add a new environment".
### Change environment origin
Change the origins of your Environments to create complex workflows, for instance, `dev > staging > preprod > production`. All the layouts of an environment will be generated based on its parent's layout.
All child Environments will be refreshed based on the new hierarchy.
### Set an environment as production
To set another Environment as your Production Environment (also known as "reference"), click on the Environment you wish to set as production, and from its details page, click "Set as production".
To set an Environment as production it should have the actual reference as its origin.
The actual reference will take the new production as the origin. All children layouts will be refreshed. Any layout change that is not applicable will be ignored.
### Delete an environment
You may also delete an Environment. **Be very careful** as there is no going back.
***
## Branches
### What is a layout?
A **layout** is all the configuration that **defines your user interface (UI)**. In Forest, there is 1 layout per environment and per team.
The [Forest CLI](/reference/cli/overview) will help you manage layouts across environments.
### What is a branch?
A Branch is a fork (i.e. copy) of the layout of the Environment it is attached to. A Branch can only be created in your own Development Environment.
The **origin** of a branch is either specified using the `--origin` option or selected when prompted. You should choose the environment you want to make some layout changes on.
Once you've created a Branch, your layout will look exactly like the layout of its origin Environment.
### How do branches work?
Any **layout change** you make on your current Branch using the Layout Editor will be **saved on your current Branch** and will not affect its origin Environment.
Any changes made to the origin of your Branch will instantly reflect on your Branch.
For those familiar with git's *rebase*, this means you will **never have to rebase** your Branch on its origin, as it is done automatically.
### How do you create a branch?
To create a branch, use [Forest CLI](/reference/cli/overview). Make sure you've created your local Development Environment using the `init` command. Then, to create a Branch named `my-branch` based on your `production` Environment:
```
forest branch my-branch --origin production
```
Using kebab-case is recommended. However, if you prefer to use spaces in your Branch names, surround them with quotes: `forest branch "my branch" --origin production`.
### Checking your branch information
The interface shows at all times what is your current Branch and how many layout changes were made on it. These information appear in the top banner of your back-office.
To switch your *current* branch to another existing branch, use the `forest switch` command.
***
## Deploying your changes
### Applying your changes to production: `deploy`
`deploy` means applying your branch's changes to your reference environment definitively.
```
forest deploy
```
As all your environments' layouts depend on your **reference** environment, the `deploy` command will apply the layout changes to all your project environments.
Deploy with care as such action cannot be reverted.
Don't forget to **deploy your back-end changes** (if any) before the `deploy` command.
### Testing your changes on a remote environment: `push`
`push` means moving your Branch's changes to a Remote Environment set as the origin of your Branch.
```
forest push
```
Pushing your changes from your local Branch will automatically **delete** it.
Note that you'll be pushing your **current** Branch. To select another Branch, use `forest switch`. If the origin of your Branch is not the Remote you want, change it with `forest set-origin`.
#### Deploying the layout of a remote environment
Once you have tested your new layout on a Remote Environment, to deploy it to Production **click on "Deploy to production"** in the top banner of that Environment's layout.
#### Making changes directly from a remote environment
Apply final touches using the Layout Editor from the Remote Environment. Any changes you make on that Remote Environment will also be deployed when you run `forest deploy`.
# Schema updates
Source: https://docs.forest.app/product/process/advanced-concepts/developer-workflow/schema-updates
Keep your Forest back-office in sync when your database schema changes.
This page needs to be completed with best practices from the Forest team.
When you add a table, rename a column, or change a relationship in your database, Forest needs to learn about those changes. This page explains how schema synchronization works and how to handle common scenarios.
## What is the schema?
Forest introspects your database at startup and builds an internal representation of your data model, the **schema**. This schema drives everything: which collections appear, what fields are available, and how relationships are represented.
For self-hosted back-ends, the schema is also saved to a local file called `.forestadmin-schema.json`. This file acts as a cache and allows Forest to detect changes between restarts.
## Automatic schema detection
For self-hosted back-ends, schema updates are detected **automatically on back-end restart**. When your back-end starts up:
1. It connects to your database and introspects the current schema
2. It compares the result with `.forestadmin-schema.json` (if it exists)
3. If changes are detected, it updates the file and notifies the Forest cloud
This means the typical workflow for a database change is:
```bash theme={null}
# 1. Make your database change (migration, ALTER TABLE, etc.)
# 2. Restart your agent
npm run start
# 3. The new fields/tables appear in Forest automatically
```
During development, back-ends often restart on file change (via nodemon or similar). Schema changes show up immediately.
## Cloud Back-end schema sync
For **Forest Cloud** (where Forest manages the back-end), you trigger schema synchronization manually from the UI:
1. Go to **Project Settings** → **Environments**
2. Click on the environment you want to sync
3. In the **Back-end** section, click **Synchronize schema**
Forest will connect to your database and pull the latest schema. New tables and columns will appear in your back-office.
You can also trigger a sync from the **quick access icon** in the left navigation bar.
## The `.forestadmin-schema.json` file
This file is auto-generated and lives in your project root. It contains a snapshot of your data model as Forest sees it.
**Do:**
* Commit it to your repository, it ensures schema consistency across team members and environments
* Review changes in pull requests to catch unexpected schema drift
**Don't:**
* Edit it manually, changes will be overwritten on the next back-end start
* Delete it unless troubleshooting, the back-end will regenerate it, but this forces a full re-introspection
## Common scenarios
### Adding a new table
After running your database migration:
1. Restart your back-end
2. The new table appears as a new collection in Forest
3. Configure its layout and permissions as needed
### Adding a new column
After adding a column to an existing table:
1. Restart your back-end
2. The new field appears in the collection's configuration
3. It's hidden by default, enable it in the [Layout Editor](/product/build/layout-editor) if needed
### Renaming a column
Renaming a column is treated as **removing the old field and adding a new one**. This means:
* Any widget configuration, segments, or actions referencing the old name will break
* You'll need to update your Forest configuration to reference the new name
To minimize disruption, consider adding a database view or alias that preserves the old column name temporarily.
### Removing a column
After dropping a column from your database and restarting your back-end, the field disappears from Forest. If it was referenced in segments, actions, or custom code, you'll see errors, clean those up first.
### Changing a column type
Type changes may cause widget incompatibilities (e.g. a field that was a `String` and is now a `JSON` object). After the schema update, review the field's widget configuration and update it if needed.
## Troubleshooting
### New fields aren't appearing
* Confirm your database migration ran successfully
* Restart your back-end (a running back-end doesn't auto-detect database changes)
* Check your back-end logs for introspection errors
### Schema conflict errors
If the back-end reports a schema conflict, it means the `.forestadmin-schema.json` file is out of sync with the current database. Delete the file and restart the back-end to regenerate it from scratch.
### Missing relationships
Relationships are inferred from foreign keys in the database. If a relationship isn't showing up:
* Verify the foreign key constraint exists in the database
* If you're using a non-standard naming convention, you may need to declare the relationship explicitly in your back-end configuration
See [Relationships](/product/process/relationships/overview) for how to define relationships in code.
## Best practices
* **Run migrations before deploying back-end changes**, the back-end should start against the already-migrated database
* **Test schema changes in a development branch**, use [Environments & branches](/product/process/advanced-concepts/developer-workflow/environments-and-branches) to validate layout impact before hitting production
* **Commit `.forestadmin-schema.json`**, keeps all team members on the same schema version
* **Review the schema diff in PRs**, catching an unintended table rename in review is much easier than debugging it in production
# TypeScript setup & autocompletion
Source: https://docs.forest.app/product/process/advanced-concepts/developer-workflow/typescript-setup
Enable full TypeScript support and IDE autocompletion for your Forest agent.
The `@forestadmin/agent` package is written in TypeScript and ships with complete type definitions. With a small amount of setup, you get autocompletion for collection names, field names, and all customization APIs, in both TypeScript and JavaScript projects.
## How it works
Forest generates a **typings file** from your data model. This file contains TypeScript interfaces that describe each collection and its fields. Once generated, you pass this schema as a generic type parameter to `createAgent`, which propagates the types throughout all your customization code.
The result: your IDE knows that the `orders` collection has a `total_amount` field of type `number`, and will autocomplete accordingly.
## Generating the typings file
Add two options to your `createAgent` call:
* `typingsPath`, where to write the generated file (e.g. `'./typings.ts'`)
* `typingsMaxDepth`, how deep to introspect relationships (default `5` is usually sufficient)
The file is generated automatically on back-end startup. **Do not edit it manually**, it will be overwritten on the next restart.
## TypeScript setup
```typescript theme={null}
// agent.ts
import { createAgent } from '@forestadmin/agent';
import { Schema } from './typings';
import customizeOrders from './customization/orders';
await createAgent({
authSecret: process.env.FOREST_AUTH_SECRET!,
envSecret: process.env.FOREST_ENV_SECRET!,
isProduction: process.env.NODE_ENV === 'production',
typingsPath: './typings.ts',
typingsMaxDepth: 5,
})
.customizeCollection('orders', customizeOrders)
.mountOnStandaloneServer(Number(process.env.PORT))
.start();
```
In your customization files, import both `CollectionCustomizer` and your `Schema`:
```typescript theme={null}
// customization/orders.ts
import { CollectionCustomizer } from '@forestadmin/agent';
import { Schema } from '../typings';
export default (orders: CollectionCustomizer) => {
// Full autocompletion: field names, types, action context, etc.
orders.addField('display_name', {
columnType: 'String',
dependencies: ['first_name', 'last_name'],
getValues: (records) =>
records.map((r) => `${r.first_name} ${r.last_name}`),
});
};
```
The `customizeCollection` call is strongly typed, the second argument (`'orders'`) is validated against your actual collection names, and the handler receives a fully-typed `CollectionCustomizer`.
## JavaScript setup (with JSDoc)
If your project uses JavaScript, get autocompletion using JSDoc type annotations. The pattern uses `@typedef` to import types from the generated typings file.
```javascript theme={null}
// agent.js
const { createAgent } = require('@forestadmin/agent');
const customizeOrders = require('./customization/orders');
/**
* @typedef {import('@forestadmin/agent').Agent} Agent
* @typedef {import('./typings').Schema} Schema
* @type {Agent}
*/
const agent = createAgent({
authSecret: process.env.FOREST_AUTH_SECRET,
envSecret: process.env.FOREST_ENV_SECRET,
isProduction: process.env.NODE_ENV === 'production',
typingsPath: './typings.ts',
typingsMaxDepth: 5,
});
await agent
.customizeCollection('orders', customizeOrders)
.mountOnStandaloneServer(Number(process.env.PORT))
.start();
```
In customization files:
```javascript theme={null}
// customization/orders.js
/**
* @typedef {import('@forestadmin/agent').CollectionCustomizer} CollectionCustomizer
* @typedef {import('../typings').Schema} Schema
* @param {CollectionCustomizer} orders
*/
module.exports = (orders) => {
// Autocompletion works here in VS Code and WebStorm
orders.addField('display_name', {
columnType: 'String',
dependencies: ['first_name', 'last_name'],
getValues: (records) =>
records.map((r) => `${r.first_name} ${r.last_name}`),
});
};
```
## What gets autocompleted
Once the schema is typed, your IDE provides autocompletion for:
| Context | What's autocompleted |
| ---------------------------------------- | ----------------------------------------------- |
| `customizeCollection('...')` | Collection names from your database |
| `addField`, `removeField`, `renameField` | Field names within the collection |
| `dependencies: [...]` | Field names available as dependencies |
| `getValues` record parameter | Field values with correct types |
| Action context (`.form`, `.record`) | Field names and types in action handlers |
| Filter conditions | Field names for Smart Segment and scope filters |
## IDE configuration
### VS Code
TypeScript and JSDoc autocompletion work out of the box with the built-in TypeScript language server. No additional extensions are required.
To verify it's working: open a customization file, type `orders.` and you should see a list of available methods. Inside a `dependencies` array, type a quote and you should see field name suggestions.
### WebStorm / IntelliJ
WebStorm has built-in TypeScript support. Open your project and the IDE will automatically pick up the typings file. JSDoc-based autocompletion also works without additional configuration.
## Troubleshooting
### The typings file isn't being generated
* Make sure `typingsPath` is set in your `createAgent` call
* Check that the back-end starts without errors, introspection failures prevent typings generation
* Verify the directory in `typingsPath` exists (e.g. if you set `'./src/typings.ts'`, the `src/` directory must exist)
### Collection names aren't being suggested
* Confirm the typings file exists and isn't empty
* Make sure you're passing `` as the generic to `createAgent`
* Try restarting your TypeScript language server in your IDE (VS Code: Cmd+Shift+P → "Restart TS Server")
### Type errors after a schema change
The typings file is regenerated on back-end restart. If you added or removed fields and see type errors, restart your back-end and the typings will update automatically.
# Hooks
Source: https://docs.forest.app/product/process/advanced-concepts/hooks/overview
Forest provides extensive customization options for your collections, allowing you to tailor CRUD operations, implement business logic, and enhance data integrity through two features:
* **Collection hooks**, execute custom code before or after CRUD operations
* **Collection overrides**, completely replace the default behavior of CUD operations
These two features are almost identical, but they are executed at very different stages of the customizations.
This means that Collection hooks will be executed even if you choose to use Collection override.
## Collection hooks
Collection hooks allow you to execute custom code before or after CRUD operations, giving you the ability to enforce business rules, or integrate with external services.
### Features
* **Pre** and **Post** operation execution: Execute custom logic before or after a specific collection operation.
* **Flexible trigger points**: Hook into any of the standard CRUD operations (`list`, `create`, `update`, `delete`, `aggregate`).
* **Contextual information**: Access to a rich high-level context providing details about the operation, enabling precise and informed logic execution.
### How it works
Any given Collection should implement all of the following functions:
* `list`
* `create`
* `update`
* `delete`
* `aggregate`
The Collection hooks feature allows executing code before and/or after any of these functions, providing a way to interact with your Collections.
To declare a hook on a Collection, the following information is required:
* A lifecycle position (`Before` | `After`)
* An action type (`List` | `Create` | `Update` | `Delete` | `Aggregate`)
* A callback, that will receive a context matching the provided hook position and hook definition.
A single Collection can have multiple hooks with the same position and the same type. They will run in their declaration order.
Collection hooks are only called when the Collection function is contacted by the UI. This means that any usage of the Forest query interface will not trigger them.
### Basic use cases
In the following example, we want to prevent a set of users from updating any records of the `Transactions` table. We want to check if the user email is allowed to update a record via an external API call.
```javascript Node.js / Cloud theme={null}
transaction.addHook('Before', 'Update', async context => {
// context.caller contains information about the current user, the defined
// timezone, etc.
// In this case, context.caller.email is the email used in Forest by the user
// that initiated the call
const isAllowed = await myFunctionToCheckIfUserIsAllowed(context.caller.email);
if (!isAllowed) {
// Raising an error here will prevent the execution of the update function,
// as well as any other hooks that may be defined afterwards.
context.throwForbiddenError(`${context.caller.email} is not allowed!`);
}
});
```
```ruby Ruby theme={null}
transaction.add_hook('Before', 'Update') do |context|
# context.caller contains information about the current user, the defined
# timezone, etc.
# In this case, context.caller.email is the email used in Forest by the user
# that initiated the call
is_allowed = my_function_to_check_if_user_is_allowed(context.caller.email)
unless is_allowed
# Raising an error here will prevent the execution of the update function,
# as well as any other hooks that may be defined afterwards.
context.throw_forbidden_error("#{context.caller.email} is not allowed!")
end
end
```
```ruby Ruby DSL theme={null}
@create_agent.collection :transactions do |collection|
collection.before :update do |context|
# context.caller contains information about the current user, the defined
# timezone, etc.
# In this case, context.caller.email is the email used in Forest by the user
# that initiated the call
is_allowed = my_function_to_check_if_user_is_allowed(context.caller.email)
unless is_allowed
# Raising an error here will prevent the execution of the update function,
# as well as any other hooks that may be defined afterwards.
context.raise_forbidden_error("#{context.caller.email} is not allowed!")
end
end
end
```
Another good example: each time a new `User` is created in the database, send them a welcome email.
```javascript Node.js / Cloud theme={null}
user.addHook('After', 'Create', async (context, responseBuilder) => {
// The result of the create function always returns an array of records
const userEmail = context.records[0]?.email;
await MyEmailSender.sendEmail({
from: 'erlich@bachman.com',
to: userEmail,
message: 'Hey, a new account was created with this email.',
});
});
```
```ruby Ruby theme={null}
user.add_hook('After', 'Create') do |context|
# The result of the create function always returns an array of records
user_email = context.records[0]&.dig('email')
MyEmailSender.send_email(
from: 'erlich@bachman.com',
to: user_email,
message: 'Hey, a new account was created with this email.'
)
end
```
```ruby Ruby DSL theme={null}
@create_agent.collection :users do |collection|
collection.after :create do |context|
# The result of the create function always returns an array of records
user_email = context.records[0]&.dig('email')
MyEmailSender.send_email(
from: 'erlich@bachman.com',
to: user_email,
message: 'Hey, a new account was created with this email.'
)
end
end
```
***
## Collection overrides
Collection overrides provide the ability to completely replace the default behavior of CUD operations. This feature allows for custom implementations of `create`, `update`, and `delete` operations, giving you full control over data handling.
### Features
* **Complete control over CUD**: Directly replace the standard behavior of `create`, `update`, and `delete` operations with custom logic.
* **Custom operation logic**: Implement entirely custom workflows or integrate external services directly into your CUD operations.
* **Full operation context**: Receive detailed low-level context about the operation, enabling complex logic and integrations.
### How it works
Collection overrides allow you to define custom behavior that will entirely replace the default implementation of the `create`, `update`, and `delete` operations.
To define an override for a Collection, you must specify the handler function that will be executed instead of the default operation. The custom handler function will receive a context object containing relevant information for the operation.
### Setting up overrides
#### Custom Create operation
Unknown properties in returned records will be removed.
```javascript Node.js / Cloud theme={null}
collection.overrideCreate(async context => {
// Custom logic to handle creation
// context.data contains the data intended for creation
// Return an array of created records
});
```
```ruby Ruby theme={null}
collection.override_create do |context|
# Custom logic to handle creation
# context.data contains the data intended for creation
# Return an array of created records
end
```
#### Custom Update operation
```javascript Node.js / Cloud theme={null}
collection.overrideUpdate(async context => {
// Custom logic to handle update
// context.filter to determine which records are targeted
// context.patch contains the data for update
// Perform update operation
});
```
```ruby Ruby theme={null}
collection.override_update do |context|
# Custom logic to handle update
# context.filter to determine which records are targeted
# context.patch contains the data for update
# Perform update operation
end
```
#### Custom Delete operation
```javascript Node.js / Cloud theme={null}
collection.overrideDelete(async context => {
// Custom logic to handle deletion
// context.filter to determine which records are targeted
// Perform deletion operation
});
```
```ruby Ruby theme={null}
collection.override_delete do |context|
# Custom logic to handle deletion
# context.filter to determine which records are targeted
# Perform deletion operation
end
```
Overrides take precedence over the default operation. Ensure your custom handlers properly manage all necessary logic for the operation, as the default behavior will not be executed.
### Basic use cases
#### Create over API
You might want to create the record with your custom API:
```javascript Node.js / Cloud theme={null}
const { MissingFieldError } = require('@forestadmin/datasource-toolkit');
product.overrideCreate(async context => {
const { data } = context;
if (data.some(product => !product.name)) {
throw new MissingFieldError('name', 'products');
}
const response = await fetch('https://my-product-api.com/products', {
method: 'POST',
body: data,
});
const products = await response.json();
// structure is an array of Partial
// [ { name: 'CoffeeMaker3000, price: "$300" } ]
return products;
});
```
```ruby Ruby theme={null}
product.override_create do |context|
data = context.data
if data.any? { |p| p['name'].nil? }
raise ForestAdminDatasourceToolkit::Exceptions::ForestException, 'name is required for products'
end
response = Faraday.post('https://my-product-api.com/products', data.to_json,
'Content-Type' => 'application/json'
)
# structure is an array of Partial
# [ { 'name' => 'CoffeeMaker3000', 'price' => '$300' } ]
JSON.parse(response.body)
end
```
#### Modify data before update
You might want to modify payload data before updating your record:
```javascript Node.js / Cloud theme={null}
product.overrideUpdate(async context => {
const { patch } = context;
// Execute data modification and validation only if one of name or slug was edited
if (patch.name || patch.slug) {
const name = patch.name || patch.slug.split('-')[0];
const uuid = await fetch('https://my-product-api.com/slug', {
method: 'GET',
body: { name, slug },
});
patch.name = name;
patch.slug = `${name}-${uuid}`;
}
await context.collection.update(context.filter, context.patch);
});
```
```ruby Ruby theme={null}
product.override_update do |context|
patch = context.patch
# Execute data modification and validation only if one of name or slug was edited
if patch['name'] || patch['slug']
name = patch['name'] || patch['slug'].split('-')[0]
response = Faraday.get('https://my-product-api.com/slug', { name: name })
uuid = JSON.parse(response.body)
patch['name'] = name
patch['slug'] = "#{name}-#{uuid}"
end
context.collection.update(context.filter, context.patch)
end
```
# Active Storage
Source: https://docs.forest.app/product/process/advanced-concepts/plugins/active-storage
Expose Rails Active Storage attachments as File fields in Forest
The Active Storage plugin automatically detects `has_one_attached` declarations on your Rails models and exposes them as File fields in Forest.
It handles file upload, download, preview, and deletion out of the box.
## Usage
Add the plugin in your `lib/forest_admin_rails/create_agent.rb` file, before `@agent.build`:
```ruby theme={null}
@agent.use(ForestAdminRails::Plugins::ActiveStorage)
```
That's it. The plugin will scan all your collections for `has_one_attached` fields and create a File field for each one.
## Options
| Option | Type | Default | Description |
| --------------------------- | ------- | ------- | -------------------------------------------------------------------------------- |
| `only` | Array | `nil` | Only process these collections (whitelist) |
| `except` | Array | `nil` | Skip these collections (blacklist) |
| `hide_internal_collections` | Boolean | `true` | Hide Active Storage internal collections (`Attachment`, `Blob`, `VariantRecord`) |
| `download_images_on_list` | Boolean | `false` | Download image content on list view for thumbnail preview |
### Example with options
```ruby theme={null}
@agent.use(ForestAdminRails::Plugins::ActiveStorage, {
only: ['Order', 'Product'],
download_images_on_list: true
})
```
## Image preview on list view
By default, file content is only downloaded on the detail view (single record) to avoid performance issues. On the list view, only file metadata is returned (file icon and name).
If you want image thumbnails to appear on the list view, enable the `download_images_on_list` option. Only images (`image/png`, `image/jpeg`, etc.) will be downloaded. Other file types (PDF, ZIP, etc.) will still show just the file icon.
```ruby theme={null}
@agent.use(ForestAdminRails::Plugins::ActiveStorage, { download_images_on_list: true })
```
## Hiding internal collections
Active Storage creates internal tables (`active_storage_attachments`, `active_storage_blobs`, `active_storage_variant_records`) that are automatically exposed by the ActiveRecord data source. These tables are not useful in the admin panel.
By default, the plugin hides these collections. If you need them visible (for example, if you use `has_many_attached` and want to browse attachments via related data), you can disable this behavior:
```ruby theme={null}
@agent.use(ForestAdminRails::Plugins::ActiveStorage, { hide_internal_collections: false })
```
## Limitations
* Only `has_one_attached` is supported. `has_many_attached` is not currently handled by this plugin.
* Works with any Active Storage back-end (local disk, Amazon S3, Google Cloud Storage, Azure Storage, etc.).
# Plugins
Source: https://docs.forest.app/product/process/advanced-concepts/plugins/overview
When customizing your Back-end behavior, it is quite common to have to perform the same tasks on multiple Fields and Collections.
Plugins are the answer to that need, and you are strongly encouraged to use them everywhere you notice that your customization files could benefit from code factorization.
## Using plugins
Plugins are used by either importing a module, or installing the relevant package, and then calling the `use` method.
Depending on the plugin, options may be provided.
```javascript theme={null}
import { createAgent } from '@forestadmin/agent';
import { createFileField } from '@forestadmin/plugin-aws-s3';
import { removeTimestamps } from './plugins/remove-timestamps';
// The .use() method can be called both on the agent and on collections.
createAgent()
// Some plugins do not require options
.use(removeTimestamps)
// Others do
.customizeCollection('accounts', collection =>
collection.use(createFileField, { fieldname: 'avatar' }),
);
```
## Writing plugins
A plugin is nothing more than an `async function` that performs customizations.
```javascript theme={null}
export async function removeTimestamps(dataSource, collection) {
// Allow the plugin to be used both on the dataSource or on individual collections
const collections = collection ? [collection] : dataSource.collections;
// Remove fields
for (const currentCollection of collections) {
currentCollection.removeField('createdAt');
currentCollection.removeField('updatedAt');
}
}
```
## Write your own plugin
Each plugin is nothing more than an `async function` that can perform customizations at either Back-end level, Collection level, or both.
```javascript theme={null}
export async function removeTimestamps(dataSource, collection, options) {
// ... call customization methods here
}
```
3 parameters are provided:
| Name | Description |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `dataSource` | An object that allows customizing the whole agent. It has the same interface as the `Agent` you manipulate outside of plugins |
| `collection` | An object that allows customizing the collection that the plugin was called from (`null` if the plugin was called on the Back-end). It is the same object passed when you call `customizeCollection` |
| `options` | Options that are provided to the plugin. There is no set structure for this parameter, as each plugin will provide specific mandatory or optional options |
### Making your plugin act differently depending on the collection
When making a plugin, you may want it to generalize to many different Collections.
This can be achieved by adopting different behavior depending on the `schema` of the Collection being targeted.
```javascript theme={null}
export async function removeTimestamps(dataSource, collection, options) {
for (const currentCollection of dataSource.collections) {
if (currentCollection.schema.fields.createdAt) {
currentCollection.removeField('createdAt');
}
if (currentCollection.schema.fields.updatedAt) {
currentCollection.removeField('updatedAt');
}
}
}
```
# Zendesk plugins
Source: https://docs.forest.app/product/process/advanced-concepts/plugins/zendesk
Surface Zendesk operations as actions on any collection (create and close tickets)
The Zendesk connector ships two plugins that surface Zendesk operations as actions on any host collection of your back-end, typically a collection that already carries the Zendesk requester's identity (an `email` column) or the Zendesk ticket id (a column like `last_zendesk_ticket_id`).
Both plugins need a way to reach the Zendesk API. They accept the same `ZendeskClientProvider` contract as the datasource factory: pass either an already-built `client`, **or** raw credentials (`subdomain`, `email`, `apiToken`) and the plugin builds one for you on the fly. Sharing the same `client` across the [Zendesk datasource](/get-started/connect/data-sources/zendesk) and the plugins is recommended (single auth setup, single logger), but not required.
Both plugins require the [Zendesk datasource](/get-started/connect/data-sources/zendesk) to be registered on your back-end: they need the `Datasource` instance to reach the Zendesk API client.
## Usage
Nothing is registered automatically. Opt each plugin in per collection:
```javascript theme={null}
import {
createZendeskClient,
createZendeskDataSource,
closeTicketPlugin,
createTicketWithNotificationPlugin,
} from '@forestadmin/datasource-zendesk';
const zendeskClient = createZendeskClient({
subdomain: process.env.ZENDESK_SUBDOMAIN,
email: process.env.ZENDESK_EMAIL,
apiToken: process.env.ZENDESK_API_TOKEN,
});
const agent = createAgent(options)
.addDataSource(createZendeskDataSource({ client: zendeskClient }))
.customizeCollection('customers', collection => {
collection.use(createTicketWithNotificationPlugin, {
client: zendeskClient,
requesterEmailDefault: record => String(record.email ?? ''),
});
})
.customizeCollection('orders', collection => {
collection.use(closeTicketPlugin, {
client: zendeskClient,
ticketIdField: 'last_zendesk_ticket_id',
});
});
```
If you'd rather not build a client up-front (for example when you install a single plugin on a project that does not register the Zendesk datasource), pass the credentials straight to the plugin instead:
```javascript theme={null}
collection.use(closeTicketPlugin, {
subdomain: process.env.ZENDESK_SUBDOMAIN,
email: process.env.ZENDESK_EMAIL,
apiToken: process.env.ZENDESK_API_TOKEN,
ticketIdField: 'last_zendesk_ticket_id',
});
```
You can attach the same plugin to multiple collections (e.g. `customers` and `orders`) with different option sets. Each plugin throws explicitly if installed at the datasource level — they only work on a collection.
```ruby theme={null}
@agent.collection :Customer do |collection|
collection.use(
ForestAdminDatasourceZendesk::Plugins::CreateTicketWithNotification,
datasource: zendesk_datasource
)
collection.use(
ForestAdminDatasourceZendesk::Plugins::CloseTicket,
datasource: zendesk_datasource,
ticket_id_field: 'last_zendesk_ticket_id'
)
end
```
Both plugins take the `Datasource` instance you registered earlier as the required `datasource:` option. You can attach the same plugin to multiple collections (e.g. `Customer` and `Order`) with different option sets.
## Create a ticket with notification
A `Single`-scope action that opens a Zendesk ticket from the selected host record. The host record does not need to be related to Zendesk — the requester is identified by an email entered (or pre-filled) in the form, and Zendesk creates the user record on the fly if it does not already exist (the action derives the user's name from the email's local-part, e.g. `john.doe@acme.com → john.doe`, to satisfy Zendesk's non-empty-name validation).
```javascript theme={null}
collection.use(createTicketWithNotificationPlugin, {
client: zendeskClient,
actionName: 'Open a support ticket',
defaultSubject: 'Refund for {{ record.email }}',
defaultMessage: '
Hi {{ record.name }},
',
requesterEmailDefault: record => String(record.email ?? ''),
senderEmail: 'support@acme.com',
priorityOverride: 'high',
ticketIdField: 'last_zendesk_ticket_id',
});
```
| Option | Type | Description |
| ----------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `client` | `ZendeskClient` | The Zendesk client instance. **Required when `subdomain` / `email` / `apiToken` are not provided.** |
| `subdomain` | `string` | Zendesk subdomain. **Required when `client` is not provided** (the plugin then builds a client from these credentials). |
| `email` | `string` | Email associated with the API token. Required alongside `subdomain` and `apiToken`. |
| `apiToken` | `string` | Zendesk API token. Required alongside `subdomain` and `email`. |
| `actionName` | `string` | Overrides the action label. Defaults to `'Create ticket and notify'`. |
| `defaultSubject` | `string \| (record) => string` | Default value for the "Subject" field. As a string, supports `{{ record. }}` tokens (dotted paths work). As a function, receives the loaded record and returns a string. |
| `defaultMessage` | `string \| (record) => string` | Default value for the "Message" field. Same token / function syntax. Rendered through a RichText widget and shipped as `html_body`. **Ignored when a non-empty `emailTemplates` is provided AND a real template is selected** (see wizard). |
| `emailTemplates` | `Array<{ title: string; content: string }>` | When non-empty, the form becomes a two-page wizard (see below). `content` supports the same `{{ record. }}` token syntax. |
| `requesterEmailDefault` | `string \| (record) => string` | Default for the "Requester email" form field. Supports the same token / function syntax as Subject / Message. |
| `senderEmail` | `string` | Maps to Zendesk's `recipient` on the created ticket — the support address replies are sent FROM. When unset, Zendesk uses the account's default support address. |
| `priorityOverride` | `'low' \| 'normal' \| 'high' \| 'urgent'` | When set, the "Priority" dropdown is removed from the form and this value is forced in the payload. |
| `typeOverride` | `'problem' \| 'incident' \| 'question' \| 'task'` | Same idea for the "Type" dropdown. |
| `showInternalNote` | `boolean` | When `true`, adds the "Send as internal note" checkbox to the form. Hidden by default — tickets are public unless this is opt-in. |
| `ticketIdField` | `string` | Writable column on the host collection that receives the freshly-created ticket id. Best-effort: a writeback failure is logged and surfaced in the success message without rolling back the ticket. |
The form exposes the following fields by default:
| Field | Type / widget | Notes |
| --------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Requester email | `String` | Required. Pre-filled by `requesterEmailDefault`. |
| Subject | `String` | Required. Default supports `{{ record. }}` tokens. |
| Message | `String` / `RichText` | Required. Sent as the ticket's first comment (`html_body`). |
| Priority | `Enum` | Defaults to `normal`. Values: `low`, `normal`, `high`, `urgent`. **Hidden when `priorityOverride` is set.** |
| Type | `Enum` | Optional. Values: `problem`, `incident`, `question`, `task`. **Hidden when `typeOverride` is set.** |
| Send as internal note | `Boolean` | **Hidden by default.** Surfaces only when `showInternalNote: true` is set. When checked, the first comment is private and no notification email is sent to the requester. |
The default form always creates a public comment, which triggers Zendesk's default notification email to the requester. When `senderEmail` is set, that address is used as the support recipient (the From address of the outbound email).
```ruby theme={null}
@agent.collection :Customer do |collection|
collection.use(
ForestAdminDatasourceZendesk::Plugins::CreateTicketWithNotification,
datasource: zendesk_datasource,
action_name: 'Open a support ticket',
default_subject: 'Refund for {{record.email}}',
default_message: '
Hi {{record.name}},
',
requester_email_default: ->(record) { record['email'] },
sender_email: 'support@acme.com',
priority_override: 'high',
ticket_id_field: 'last_zendesk_ticket_id'
)
end
```
| Option | Description |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `datasource` | **Required.** The `ForestAdminDatasourceZendesk::Datasource` instance. |
| `action_name` | Overrides the action label. Defaults to `'Create ticket and notify'`. |
| `default_subject` | String used to pre-fill the "Subject" field. Supports `{{record.}}` tokens resolved against the selected record when the form opens. |
| `default_message` | String used to pre-fill the "Message" field. Same token syntax; rendered through a RichText widget and shipped as `html_body`. Token *values* are HTML-escaped. **Ignored when `email_templates` is set** (the wizard takes over). |
| `email_templates` | Array of `{ title:, content: }` hashes. When non-empty, the form becomes a two-page wizard (see below). |
| `requester_email_default` | Default for the "Requester email" form field. Accepts a String (supports the same `{{record.}}` tokens as Subject/Message) or a `record -> email_string` Proc evaluated against the selected record when the form opens. |
| `sender_email` | Maps to Zendesk's `recipient` on the created ticket, the support address replies are sent FROM. When unset, Zendesk uses the account's default support address. |
| `priority_override` | When set, the "Priority" dropdown is removed from the form and this value is forced in the payload (e.g. `'high'`). |
| `type_override` | Same idea for the "Type" dropdown (e.g. `'incident'`). |
| `show_internal_note` | When truthy, adds the "Send as internal note" checkbox to the form. Hidden by default, tickets are public unless this is opt-in. |
| `ticket_id_field` | Writable column on the host collection that receives the freshly-created ticket id. Best-effort: a writeback failure is logged and surfaced in the success message without rolling back the ticket. |
The form exposes the following fields by default:
| Field | Type | Notes |
| --------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Requester email | String | Required. Pre-filled by `requester_email_default`. |
| Subject | String | Required. Default supports `{{record.}}` tokens. |
| Message | RichText | Required. Sent as the ticket's first comment (`html_body`). Token *values* inside the default are HTML-escaped. |
| Priority | Enum | Defaults to `normal`. Values: `low`, `normal`, `high`, `urgent`. **Removed from the form when `priority_override` is set.** |
| Type | Enum | Optional. Values: `problem`, `incident`, `question`, `task`. **Removed from the form when `type_override` is set.** |
| Send as internal note | Boolean | **Hidden by default.** Surfaces only when `show_internal_note: true` is set. When checked, the first comment is private and no notification email is sent to the requester. |
The default form always creates a public comment, which triggers Zendesk's default notification email to the requester. When `sender_email` is set, that address is used as the support recipient (the From address of the outbound email).
### Email-templates wizard
When the `email_templates` / `emailTemplates` option is set, the form becomes a two-page wizard:
1. **Page 1 — Template.** A `Template` field lists each template's `title` plus a sentinel `"No template"` entry. Selecting an entry drives the Message default on page 2.
2. **Page 2 — Body.** The same fields as above, with the Message field recomputed from the page 1 selection.
* A real template selected → `interpolate(template.content, record)`.
* `"No template"` selected → `defaultMessage` is honored (if set), otherwise the field is empty.
Token interpolation supports `{{ record. }}` with dotted paths (e.g. `{{ record.org.name }}`). Tokens that resolve to `null`/`undefined` become an empty string. **Token values are not HTML-escaped** — the form is RichText/HTML, so do not interpolate untrusted data into the message body.
This is an intentional cross-runtime difference: the Ruby plugin escapes token values, and its wizard ignores `default_message` in template mode, whereas the Node.js plugin honors `defaultMessage` when "No template" is selected.
```javascript theme={null}
collection.use(createTicketWithNotificationPlugin, {
client: zendeskClient,
emailTemplates: [
{
title: 'Refund confirmation',
content: '
Hi {{ record.first_name }}, your refund has been processed.
',
},
{
title: 'Shipping delay',
content:
'
Hi {{ record.first_name }}, we apologise for the delay shipping order #{{ record.order_id }}.
',
},
],
});
```
Picking a different template (then clicking back) re-fills the Message; typing into Message in between is preserved across re-fetches of the same template selection.
Template `content` supports the same `{{record.}}` token syntax as `default_message`, and the interpolated values are HTML-escaped before being injected into the RichText editor. Picking `"No template"` yields an empty Message, `default_message` is intentionally ignored in wizard mode so the strict opt-in stays predictable.
```ruby theme={null}
collection.use(
ForestAdminDatasourceZendesk::Plugins::CreateTicketWithNotification,
datasource: zendesk_datasource,
email_templates: [
{ title: 'Refund confirmation',
content: '
Hi {{record.first_name}}, your refund has been processed.
' },
{ title: 'Shipping delay',
content: '
Hi {{record.first_name}}, we apologise for the delay shipping order #{{record.order_id}}.
' }
]
)
```
## Close a ticket
Registers actions that transition a Zendesk ticket to `solved` or `closed`. The plugin reads the ticket id from a configurable column on the host record(s) — so you can close a Zendesk ticket directly from a host row that stores `last_zendesk_ticket_id`, without having to navigate to the ticket collection.
```javascript theme={null}
collection.use(closeTicketPlugin, {
client: zendeskClient,
ticketIdField: 'last_zendesk_ticket_id',
});
```
| Option | Type | Description |
| --------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `client` | `ZendeskClient` | The Zendesk client instance. **Required when `subdomain` / `email` / `apiToken` are not provided.** |
| `subdomain` | `string` | Zendesk subdomain. **Required when `client` is not provided** (the plugin then builds a client from these credentials). |
| `email` | `string` | Email associated with the API token. Required alongside `subdomain` and `apiToken`. |
| `apiToken` | `string` | Zendesk API token. Required alongside `subdomain` and `email`. |
| `ticketIdField` | `string` | **Required.** Name of the column on the host record that holds the Zendesk ticket id. |
| `statuses` | `Array<'solved' \| 'closed'>` | Subset of the targeted statuses. Defaults to both (`['solved', 'closed']`). |
| `scopes` | `Array<'Single' \| 'Bulk'>` | Subset of registered scopes. Defaults to both (`['Single', 'Bulk']`). |
`statuses` and `scopes` are orthogonal — the plugin registers one action per `(status, scope)` pair, so the full default registers four actions on the host collection:
| Status | Single-scope label | Bulk-scope label |
| -------- | ------------------------------- | ----------------------------------------- |
| `solved` | "Mark Zendesk ticket as solved" | "Mark selected Zendesk tickets as solved" |
| `closed` | "Mark Zendesk ticket as closed" | "Mark selected Zendesk tickets as closed" |
Pick subsets to register fewer variants, e.g. `{ statuses: ['closed'], scopes: ['Bulk'] }` registers a single bulk-close action. You can also point the plugin directly at `zendesk_ticket` with `ticketIdField: 'id'` to get a native "close from the ticket detail" action.
```ruby theme={null}
@agent.collection :Customer do |collection|
collection.use(
ForestAdminDatasourceZendesk::Plugins::CloseTicket,
datasource: zendesk_datasource,
ticket_id_field: 'last_zendesk_ticket_id'
)
end
```
| Option | Description |
| ----------------- | ----------------------------------------------------------------------------------------------------------- |
| `datasource` | **Required.** The `ForestAdminDatasourceZendesk::Datasource` instance. |
| `ticket_id_field` | **Required.** Name of the column on the host record that holds the Zendesk ticket id. |
| `statuses` | Subset of `%w[solved closed]`. Defaults to both. Accepts symbols (`%i[solved]`) or strings interchangeably. |
| `scopes` | Subset of `%i[single bulk]`. Defaults to both. Accepts symbols or strings interchangeably. |
`statuses` and `scopes` are orthogonal, the plugin registers one action per `(status, scope)` pair, so the full default registers four actions on the host collection:
| Status | Single-scope label | Bulk-scope label |
| -------- | ------------------------------- | ----------------------------------------- |
| `solved` | "Mark Zendesk ticket as solved" | "Mark selected Zendesk tickets as solved" |
| `closed` | "Mark Zendesk ticket as closed" | "Mark selected Zendesk tickets as closed" |
Pick subsets to register fewer variants, e.g. `statuses: %w[closed], scopes: %i[bulk]` registers a single bulk-close action.
### Status semantics
* **`solved`** is the standard "resolved" workflow; the requester can still reopen the ticket during Zendesk's reopen window.
* **`closed`** is terminal. Zendesk rejects further updates to a closed ticket and sometimes rejects the direct `open → closed` transition.
The plugin recognises Zendesk's "closed prevents ticket update" error (HTTP 422 with a matching detail message) and translates it to a clean outcome:
* Targeting `closed` on an already-closed ticket → **success** (counted as "was already closed").
* Targeting `solved` on an already-closed ticket → **failure** (Zendesk does not allow editing a closed ticket).
### Bulk behaviour
Each id is processed independently — a single rejected transition does not abort the rest of the run. The success message reports succeeded, already-closed and failed ids so partial successes are visible. If every id fails the action surfaces as an error rather than a partial success.
If no usable id can be read from the selected record(s) (e.g. `ticketIdField` is empty), the action returns an error with `No ticket id available on the selected record(s).` rather than calling Zendesk.
If no usable id can be read from the selected record(s) (e.g. `ticket_id_field` is empty), the action returns an error rather than calling Zendesk.
# Testing your back-end
Source: https://docs.forest.app/product/process/advanced-concepts/testing
Test your Forest customizations locally without connecting to Forest servers
Test your Forest customizations locally without connecting to Forest servers.
The testing library is available for the `@forestadmin/agent` Node.js agent.
## Installation
```bash theme={null}
npm install --save-dev @forestadmin/agent-testing
```
## Quick start
The testing library provides two main functions:
* **`createForestServerSandbox(port)`** starts a local mock server that simulates Forest servers.
* **`createAgentTestClient(options)`** creates a test client to interact with your agent.
```typescript theme={null}
import {
createAgentTestClient,
createForestServerSandbox,
} from '@forestadmin/agent-testing';
describe('My Agent', () => {
let sandbox, client;
beforeAll(async () => {
// Start mock server
sandbox = await createForestServerSandbox(3001);
// Connect test client
client = await createAgentTestClient({
serverUrl: 'http://localhost:3001',
agentUrl: 'http://localhost:3310',
agentSchemaPath: './.forestadmin-schema.json',
agentForestEnvSecret: process.env.FOREST_ENV_SECRET,
agentForestAuthSecret: process.env.FOREST_AUTH_SECRET,
});
});
afterAll(async () => {
await sandbox?.stop();
});
it('should list users', async () => {
const users = await client.collection('users').list();
expect(users.length).toBeGreaterThan(0);
});
it('should execute action', async () => {
const action = await client
.collection('orders')
.action('Apply discount', { recordId: 1 });
await action.getFieldNumber('discount').fill(10);
const result = await action.execute();
expect(result.success).toBe(true);
});
});
```
## Collections
Test CRUD operations and hooks.
### List
Test that filters return the correct records.
```typescript theme={null}
// Fetch admins
const admins = await client.collection('users').list({
filters: { field: 'role', operator: 'Equal', value: 'admin' },
});
// Check all have role 'admin'
expect(admins.every(u => u.role === 'admin')).toBe(true);
```
### Create
```typescript theme={null}
// Create user
const user = await client.collection('users').create({ email: 'john@example.com' });
// Fetch to verify
const [created] = await client.collection('users').list({
filters: { field: 'id', operator: 'Equal', value: user.id },
});
expect(created.email).toBe('john@example.com');
```
### Update
```typescript theme={null}
// Update email
await client.collection('users').update(userId, { email: 'new@example.com' });
const [user] = await client.collection('users').list({
filters: { field: 'id', operator: 'Equal', value: userId },
});
expect(user.email).toBe('new@example.com');
```
### Delete
```typescript theme={null}
// Delete user
await client.collection('users').delete(userId);
const users = await client.collection('users').list({
filters: { field: 'id', operator: 'Equal', value: userId },
});
expect(users).toHaveLength(0);
```
## Actions
Test actions and form behavior.
```typescript theme={null}
// Get action
const action = await client.collection('orders').action('Refund', { recordId: 1 });
// Fill form
await action.getFieldNumber('amount').fill(100);
await action.getFieldString('reason').fill('Customer request');
// Execute
const result = await action.execute();
expect(result.success).toBe(true);
```
### Dynamic forms
Test fields that appear based on other field values.
```typescript theme={null}
const action = await client.collection('orders').action('Refund', { recordId: 1 });
// Check field is hidden
expect(action.doesFieldExist('manager_approval')).toBe(false);
// Fill amount > threshold
await action.getFieldNumber('amount').fill(500);
// Check field now appears
expect(action.doesFieldExist('manager_approval')).toBe(true);
```
### Field properties
Test field validation rules.
```typescript theme={null}
const action = await client.collection('users').action('Update', { recordId: 1 });
// Check required
expect(action.getFieldString('email').isRequired()).toBe(true);
// Check read-only
expect(action.getFieldNumber('age').isReadOnly()).toBe(false);
```
## Computed fields
```typescript theme={null}
// Fetch user
const [user] = await client.collection('users').list<{ fullName: string }>();
// Check computed value
expect(user.fullName).toBe('John Doe');
```
## Segments
```typescript theme={null}
// Fetch from segment
const minors = await client.collection('users').segment('minors').list();
// Check filter works
expect(minors.every(u => u.age < 18)).toBe(true);
```
## Charts
```typescript theme={null}
// Fetch value chart
const chart = await client.valueChart('totalRevenue');
expect(chart.countCurrent).toBeGreaterThan(0);
// Fetch distribution chart
const distribution = await client.distributionChart('ordersByStatus');
expect(distribution).toContainEqual({ key: 'pending', value: expect.any(Number) });
```
# Binary fields
Source: https://docs.forest.app/product/process/fields/binary
In Forest, binary fields are included in the payloads that transit between your back-end and the UI like any other field.
To achieve that, they are either encoded using the [data-URI scheme](https://en.wikipedia.org/wiki/Data_URI_scheme) or in [hexadecimal representation](https://en.wikipedia.org/wiki/Hexadecimal).
Binary fields in databases are usually either used to store compact data (like a hash or an identifier) or large data (like an image).
To handle both cases, Forest has two distinct modes available.
## Summary
| | `hex` mode | `datauri` mode |
| --------------- | -------------------------------------------------------- | ------------------------------------------------------ |
| Best suited for | Compact data (identifiers, hashes, ...) | Large data (files) |
| Description | The binary data is encoded in hexadecimal representation | The binary data is encoded using the data-URI scheme |
| Example | `0xdeadbeef` | `data:image/png;base64,...` |
| UI widget | Textual representation | File picker / viewer |
| Default mode | Field is used as either a primary or foreign key | Field is *not* used as either a primary or foreign key |
## Switching between modes
Note that as both modes result in a textual representation of the binary data, changing the mode will not affect the widget used in the UI.
You will need to update the widget manually using the UI customization feature.
## Using the hexadecimal mode
The hexadecimal mode is suitable for all data that you would not save in a file.
It is the default mode for all binary fields that are used as either a primary or foreign key.
To use the hexadecimal mode for another field, use the `replaceFieldBinaryMode` method:
```javascript theme={null}
agent.customizeCollection('people', collection =>
collection.replaceFieldBinaryMode('avatar', 'hex'),
);
```
## Using the data-URI mode
The data-URI mode is suitable for all data that you would save in a file (images, PDFs, ...).
When using that mode, both the File Viewer and the File Picker widgets are available in the UI to respectively preview and upload files.
If the automatic detection based on the field type is not working for you, force the `datauri` mode using the `replaceFieldBinaryMode` method:
```javascript theme={null}
agent.customizeCollection('people', collection =>
collection.replaceFieldBinaryMode('avatar', 'datauri'),
);
```
# Computed fields
Source: https://docs.forest.app/product/process/fields/computed
Forest allows creating new fields on any collection, either computationally, by fetching data on an external API, or based on other data available on the connected data sources.
By default, the fields that you create will be read-only, but make them [filterable and sortable](/product/process/fields/filter), and [writable](/product/process/fields/write) by using the relevant methods.
## How does it work?
When creating a new field you will need to provide:
| Field | Description |
| ----------------------- | ------------------------------------------------------------------------------------ |
| `columnType` | Type of the new field (any primitive or composite type) |
| `dependencies` | List of fields needed from the source records and linked records to run the handler |
| `getValues` | Handler which computes the new value **for a batch of records** |
| `enumValues` (optional) | When `columnType` is `Enum`, you must specify the values that the field will support |
## Examples
### Adding a field by concatenating other fields
This example adds a `user.displayName` field, which is computed by concatenating the first and last names.
```javascript Node.js / Cloud theme={null}
// "user" Collection has the following structure: { id, firstName, lastName }
agent.customizeCollection('user', collection => {
collection.addField('displayName', {
// Type of the new field
columnType: 'String',
// Dependencies which are needed to compute the new field (must not be empty)
dependencies: ['firstName', 'lastName'],
// Compute function for the new field
// Note that the function computes the new values in batches: the return value
// must be an array which contains the new values in the same order than the
// provided records.
getValues: (records, context) =>
records.map(r => `${r.firstName} ${r.lastName}`),
});
});
```
```ruby Ruby theme={null}
# "user" Collection has the following structure: { id, firstName, lastName }
ForestAdmin.customize do
customize_collection('user') do |collection|
collection.add_field('displayName', ComputedDefinition.new(
column_type: 'String',
dependencies: ['firstName', 'lastName'],
values: proc { |records| records.map { |r| "#{r['firstName']} #{r['lastName']}" } }
))
end
end
```
```ruby Ruby DSL theme={null}
# "user" Collection has the following structure: { id, firstName, lastName }
@create_agent.collection :user do |collection|
collection.computed_field :displayName,
type: 'String',
depends_on: [:firstName, :lastName] do |records|
records.map { |r| "#{r['firstName']} #{r['lastName']}" }
end
end
```
### Adding a field that depends on another computed field
This example adds a `user.displayName` field, then another that capitalizes it.
```javascript Node.js / Cloud theme={null}
// "user" Collection has the following structure: { id, firstName, lastName }
agent.customizeCollection('user', collection => {
collection
// Create a field which is computed by concatenating the first and last names
.addField('displayName', {
columnType: 'String',
dependencies: ['firstName', 'lastName'],
getValues: (records, context) =>
records.map(r => `${r.firstName} ${r.lastName}`),
})
// Create another field which is computed by uppercasing the first field
.addField('displayNameCaps', {
columnType: 'String',
dependencies: ['displayName'], // You can depend on other computed fields
getValues: (records, context) => records.map(r => r.displayName.toUpperCase()),
});
});
```
```ruby Ruby theme={null}
# "user" Collection has the following structure: { id, firstName, lastName }
ForestAdmin.customize do
customize_collection('user') do |collection|
collection
# Create a field which is computed by concatenating the first and last names
.add_field('displayName', ComputedDefinition.new(
column_type: 'String',
dependencies: ['firstName', 'lastName'],
values: proc { |records| records.map { |r| "#{r['firstName']} #{r['lastName']}" } }
))
# Create another field which is computed by uppercasing the first field
.add_field('displayNameCaps', ComputedDefinition.new(
column_type: 'String',
dependencies: ['displayName'], # You can depend on other computed fields
values: proc { |records| records.map { |r| r['displayName'].upcase } }
))
end
end
```
```ruby Ruby DSL theme={null}
# "user" Collection has the following structure: { id, firstName, lastName }
@create_agent.collection :user do |collection|
# Create a field which is computed by concatenating the first and last names
collection.computed_field :displayName,
type: 'String',
depends_on: [:firstName, :lastName] do |records|
records.map { |r| "#{r['firstName']} #{r['lastName']}" }
end
# Create another field which is computed by uppercasing the first field
collection.computed_field :displayNameCaps,
type: 'String',
depends_on: [:displayName] do |records| # You can depend on other computed fields
records.map { |r| r['displayName'].upcase }
end
end
```
### Adding a field that depends on a many-to-one relationship
We can improve the previous example by adding the city of the user to the display name.
```javascript Node.js / Cloud theme={null}
// Structure:
// User { id, addressId, firstName, lastName }
// Address { id, city }
agent.customizeCollection('user', collection => {
collection.addField('displayName', {
columnType: 'String',
// We added 'address:city' in the list of dependencies,
// which tells forest to fetch the related record
dependencies: ['firstName', 'lastName', 'address:city'],
// The address is now available in the parameters
getValues: (records, context) =>
records.map(r => `${r.firstName} ${r.lastName} (from ${r.address.city})`),
});
});
```
```ruby Ruby theme={null}
# Structure:
# User { id, addressId, firstName, lastName }
# Address { id, city }
ForestAdmin.customize do
customize_collection('user') do |collection|
collection.add_field('displayName', ComputedDefinition.new(
column_type: 'String',
# We added 'address:city' in the list of dependencies,
# which tells forest to fetch the related record
dependencies: ['firstName', 'lastName', 'address:city'],
# The address is now available in the parameters
values: proc { |records|
records.map { |r| "#{r['firstName']} #{r['lastName']} (from #{r['address']['city']})" }
}
))
end
end
```
```ruby Ruby DSL theme={null}
# Structure:
# User { id, addressId, firstName, lastName }
# Address { id, city }
@create_agent.collection :user do |collection|
collection.computed_field :displayName,
type: 'String',
# We added 'address:city' in the list of dependencies,
# which tells forest to fetch the related record
depends_on: [:firstName, :lastName, 'address:city'] do |records|
# The address is now available in the parameters
records.map { |r| "#{r['firstName']} #{r['lastName']} (from #{r['address']['city']})" }
end
end
```
### Adding a field that depends on a one-to-many relationship
Let's add a `user.totalSpending` field by summing the amount of all `orders`.
```javascript Node.js / Cloud theme={null}
// Structure
// User { id }
// Order { id, customer_id, amount }
agent.customizeCollection('user', collection => {
collection.addField('totalSpending', {
columnType: 'Number',
dependencies: ['id'],
getValues: async (records, context) => {
const recordIds = records.map(r => r.id);
// We're using Forest's query interface
// (use an ORM or a plain SQL query)
const filter = {
conditionTree: { field: 'customer_id', operator: 'In', value: recordIds },
};
const aggregation = {
operation: 'Sum',
field: 'amount',
groups: [{ field: 'customer_id' }],
};
const rows = await context.dataSource
.getCollection('order')
.aggregate(filter, aggregation);
return records.map(record => {
const row = rows.find(r => r.group.customer_id === record.id);
return row?.value ?? 0;
});
},
});
});
```
```ruby Ruby theme={null}
# Structure
# User { id }
# Order { id, customer_id, amount }
ForestAdmin.customize do
customize_collection('user') do |collection|
collection.add_field('totalSpending', ComputedDefinition.new(
column_type: 'Number',
dependencies: ['id'],
values: proc { |records, context|
record_ids = records.map { |r| r['id'] }
# We're using Forest's query interface
# (use an ORM or a plain SQL query)
filter = { condition_tree: { field: 'customer_id', operator: 'In', value: record_ids } }
aggregation = { operation: 'Sum', field: 'amount', groups: [{ field: 'customer_id' }] }
rows = context.datasource.get_collection('order').aggregate(filter, aggregation)
records.map do |record|
row = rows.find { |r| r[:group]['customer_id'] == record['id'] }
row ? row[:value] : 0
end
}
))
end
end
```
```ruby Ruby DSL theme={null}
# Structure
# User { id }
# Order { id, customer_id, amount }
@create_agent.collection :user do |collection|
collection.computed_field :totalSpending,
type: 'Number',
depends_on: [:id] do |records, context|
record_ids = records.map { |r| r['id'] }
# We're using Forest's query interface
# (use an ORM or a plain SQL query)
filter = { condition_tree: { field: 'customer_id', operator: 'In', value: record_ids } }
aggregation = { operation: 'Sum', field: 'amount', groups: [{ field: 'customer_id' }] }
rows = context.datasource.get_collection('order').aggregate(filter, aggregation)
records.map do |record|
row = rows.find { |r| r[:group]['customer_id'] == record['id'] }
row ? row[:value] : 0
end
end
end
```
### Adding a field fetching data from an API
Let's imagine that we want to check if the email address of our users is deliverable.
We can use a verification API to perform that work.
```javascript Node.js / Cloud theme={null}
const emailVerificationClient = require('@sendchimplio/client');
emailVerificationClient.setApiKey(process.env.SENDCHIMPLIO_API_KEY);
// "User" Collection has the following structure: { id, email }
agent.customizeCollection('user', collection => {
collection.addField('emailDeliverable', {
columnType: 'Boolean',
dependencies: ['email'],
getValues: async (records, context) => {
// Call the API to verify emails
const response = await emailVerificationClient.verifyEmails(
records.map(r => r.email),
);
// Return values in the same order than the source records
return records.map(r => {
const check = response[r.email];
return check.domainValid && (!usernameChecked || usernameValid);
});
},
});
});
```
```ruby Ruby theme={null}
# "User" Collection has the following structure: { id, email }
ForestAdmin.customize do
customize_collection('user') do |collection|
collection.add_field('emailDeliverable', ComputedDefinition.new(
column_type: 'Boolean',
dependencies: ['email'],
values: proc { |records, context|
# Call the API to verify emails
response = EmailVerificationClient.verify_emails(records.map { |r| r['email'] })
# Return values in the same order than the source records
records.map do |r|
check = response[r['email']]
check[:domain_valid] && (!check[:username_checked] || check[:username_valid])
end
}
))
end
end
```
```ruby Ruby DSL theme={null}
# "User" Collection has the following structure: { id, email }
@create_agent.collection :user do |collection|
collection.computed_field :emailDeliverable,
type: 'Boolean',
depends_on: [:email] do |records, context|
# Call the API to verify emails
response = EmailVerificationClient.verify_emails(records.map { |r| r['email'] })
# Return values in the same order than the source records
records.map do |r|
check = response[r['email']]
check[:domain_valid] && (!check[:username_checked] || check[:username_valid])
end
end
end
```
## Performance
When adding many fields, keep in mind that:
* You should refrain from making queries to external services
* Use relationships in the `dependencies` array when that is possible
* Use batch API calls instead of performing requests one by one inside of the `records.map` handler
* Only add fields you need in the `dependencies` list
* This will reduce the pressure on your data sources (fewer columns to fetch)
* And increase the probability of reducing the number of records passed to your handler (records are deduplicated)
* Do not duplicate code between handlers of different fields: fields can depend on each other (no cycles allowed)
# Filtering & Sorting
Source: https://docs.forest.app/product/process/fields/filter
## Filtering
### Disabling operators
Disable filtering without any code in the field settings in the Forest UI.
### Substitution
Operation substitution serves two purposes:
* **Performance**: provide a more efficient way to perform a given filtering operation
* **Capabilities**: enable filtering on a computed field or other non-filterable fields
```javascript theme={null}
collection.replaceFieldOperator('fullName', 'Equal', (value, context) => {
const [firstName, ...lastNames] = value.split(' ');
return {
aggregator: 'And',
conditions: [
{ field: 'firstName', operator: 'Equal', value: firstName },
{ field: 'lastName', operator: 'Equal', value: lastNames.join(' ') },
],
};
});
```
### Operators to support to enable search
| Column Type | Operator to support |
| ----------- | ------------------------------ |
| Number | Equal |
| Enum | Equal |
| String | IContains OR Contains OR Equal |
| Uuid | Equal |
Use the `replaceFieldOperator` method to unlock the operators.
### Emulation
Filtering emulation allows making fields filterable automatically.
It is a convenient way to get things working quickly for collections that have a low number of records (in the thousands at most).
This emulation forces the back-end to retrieve all the collection records and compute the field values for each one of them.
As a consequence, filtering emulation performance cost is **linear** with the number of records in the collection, so **activate it sparingly and with great care**.
```javascript theme={null}
// Add support for all operators
collection.emulateFieldFiltering('fullName');
// Add support for a single operator
collection.emulateFieldOperator('fullName', 'Equal');
```
***
## Sorting
Depending on the data source, not all fields may be sortable, or you may want to change how the native sorting works.
Use the `replaceFieldSorting` and `emulateFieldSorting` methods to change a single column's sorting behavior.
### Substitution
Provide replacement sort clauses. In this example, we're telling Forest "When a user sorts by full name, I want to sort by the last name, and then by the first name".
```javascript theme={null}
collection.replaceFieldSorting('fullName', [
{ field: 'lastName', ascending: true },
{ field: 'firstName', ascending: true },
]);
```
Another very common reason is performance. For instance, with auto-incrementing ids, sorting by `creationDate` is equivalent to sorting by the primary key in reverse order.
Using sort substitution where needed can save you from adding many indexes to your database.
```javascript theme={null}
// Sorting by creationDate ascending <=> Sorting by id descending
collection.replaceFieldSorting('creationDate', [{ field: 'id', ascending: false }]);
```
### Emulation
Sorting emulation allows making any field automatically sortable. It will sort records by lexicographical order.
It is a convenient way to get things working quickly for collections that have a low number of records (in the thousands at most).
This emulation forces the back-end to retrieve all the collection records and compute the field values for each one of them.
As a consequence, sorting emulation performance cost is **linear** with the number of records in the collection, so **activate it sparingly and with great care**.
```javascript theme={null}
collection.emulateFieldSorting('fullName');
```
# Import, rename & remove fields
Source: https://docs.forest.app/product/process/fields/import-rename-remove
When building your back-office, you will probably want to hide as much complexity from your users as you can.
This includes:
* Hiding technical and confidential fields
* Using naming conventions that the final user understands
## Moving fields
Import fields from single record relationships into your collections.
The imported fields will behave as if they were on that collection.
```javascript theme={null}
// Assuming the following structure:
// User { id, firstName, lastName, addressId }
// Address { id, streetName, streetNumber, city, countryId }
// Country { id, name }
userCollection
.importField('city', { path: 'address:city', readonly: true })
.importField('country', { path: 'address:country:name', readonly: true });
```
When using `readonly: false`, the referenced record fields can be edited.
## Renaming and removing fields and relations
Rename and remove fields or relations by calling the `renameField` and `removeField` methods.
```javascript theme={null}
collection.renameField('account_v3_uuid_new', 'account').removeField('password');
```
Renamed and removed fields are renamed and removed **only in the back-office**.
In your code:
* Removed fields are still accessible (for instance, as dependencies to compute new fields)
* Renamed fields must still be referred to by using their original name
# Overview
Source: https://docs.forest.app/product/process/fields/overview
Customize how fields behave in your Forest back-office
Fields are the individual data points displayed and edited across your collections in Forest. By default, Forest exposes the fields that exist in your data source. You can then extend and customize them to match your business needs.
## What are fields?
A field in Forest maps to a column, property, or attribute in your data source. Forest lets you go beyond that default mapping by:
* **Creating computed fields**: derive new values from existing data or external APIs
* **Importing fields**: pull in fields from related records directly into a collection
* **Renaming and removing fields**: hide technical details and use business-friendly names
* **Adding write behavior**: make computed or read-only fields editable
* **Adding validation rules**: enforce stricter constraints beyond what your data source provides
* **Enabling filtering and sorting**: make any field filterable or sortable
* **Handling binary data**: configure how binary fields are displayed and uploaded
## How field customization works
Field customization is applied in your back-end code using a fluent API on the collection object. Changes are applied at the back-end level and reflected in the Forest UI.
```javascript theme={null}
collection
.addField('fullName', {
columnType: 'String',
dependencies: ['firstName', 'lastName'],
getValues: (records, context) =>
records.map(r => `${r.firstName} ${r.lastName}`),
})
.replaceFieldWriting('fullName', (value, context) => {
const [firstName, lastName] = value.split(' ');
return { firstName, lastName };
})
.addFieldValidation('fullName', 'Present')
.addFieldValidation('fullName', 'ShorterThan', 30)
.emulateFieldFiltering('fullName')
.emulateFieldSorting('fullName')
.removeField('firstName', 'lastName');
```
## Explore field customization
Create new fields derived from existing data or external APIs
Import fields from relationships, rename for clarity, or hide technical fields
Add validation rules beyond what your data source enforces
Make fields writable and control how writes are applied
Enable filtering and sorting on any field, including computed ones
Configure how binary data is displayed and uploaded
# Field validation
Source: https://docs.forest.app/product/process/fields/validation
Most data sources can import validation rules from their target.
For instance, if you are using the SQL data source:
* Columns of type `VARCHAR(15)` will automatically carry a `less than 15 chars` validator
* Non-nullable columns will automatically carry a `Present` validator
However, you may want to enforce stricter restrictions than the ones implemented in your data source.
## Adding validation rules
The list of operators (`Present`, `LongerThan`, ...) available when adding validators is the same as the filter operators.
```javascript theme={null}
collection
.addFieldValidation('firstName', 'Present')
.addFieldValidation('firstName', 'LongerThan', 2)
.addFieldValidation('firstName', 'ShorterThan', 13)
.addFieldValidation('firstName', 'Match', /^[a-z]+$/i);
```
## Custom validators
If you need to implement custom validators or validation over multiple fields you may use [change hooks](/product/process/advanced-concepts/hooks/overview).
## Make a field optional
If the introspection marks a field as required, and you would like to make it optional, use the `setFieldNullable` function on your collection.
Be wary that if your database system does not allow empty values on the specified field, updating that field on records with an empty value will result in an error.
```javascript theme={null}
collection.setFieldNullable('firstName');
```
# Writing behavior
Source: https://docs.forest.app/product/process/fields/write
Forest allows replacing the default field writing behavior with your own custom logic.
This is useful when you want to change how a given field behaves, but also to make [computed fields](/product/process/fields/computed) writable.
## How does it work
The `replaceFieldWriting` function allows changing the behavior of any change by creating a new patch that will be applied to the record.
You should refrain from using handlers that have side effects (to perform error handling, validation, ...) and [use hooks instead](/product/process/advanced-concepts/hooks/overview).
## Making a field read-only
Achieve this without any code in the field settings in the Forest UI.
## Examples
### Changing other fields in the same record
In the following example, editing or creating a `fullName` will update both `firstName` and `lastName` fields of the record.
```javascript Node.js / Cloud theme={null}
collection.replaceFieldWriting('fullName', value => {
const [firstName, lastName] = value.split(' ');
return { firstName, lastName };
});
```
```ruby Ruby theme={null}
collection.replace_field_writing('fullName') do |value|
first_name, last_name = value.split(' ')
{ 'firstName' => first_name, 'lastName' => last_name }
end
```
### Having specific behavior only for updates
Define different behavior for `creations` and `updates`.
In this example, each time the `firstName` field is edited, we also want to update a timestamp field.
```javascript Node.js / Cloud theme={null}
collection.replaceFieldWriting('firstName', async (value, context) => {
switch (context.action) {
case 'create':
return { firstName, firstNameLastEdited: null };
case 'update':
return { firstName, firstNameLastEdited: new Date().toISOString() };
default:
throw new Error('Unexpected value');
}
});
```
```ruby Ruby theme={null}
collection.replace_field_writing('firstName') do |value, context|
case context.action
when 'create'
{ 'firstName' => value, 'firstNameLastEdited' => nil }
when 'update'
{ 'firstName' => value, 'firstNameLastEdited' => Time.now.iso8601 }
else
raise 'Unexpected value'
end
end
```
### Changing fields in related records
Handling relationships inside a `replaceFieldWriting` will only work for `ManyToOne` and `OneToOne` relationships.
In this simple example, we have two collections that are linked together:
* The `Users` collection has a `job` and a `portfolioId` as foreignKey
* The `Portfolios` collection has a `title`
When the user updates his `job` field we want also to update the `title` of the portfolio by the `job` name.
```javascript Node.js / Cloud theme={null}
collection.replaceFieldWriting('job', (job, { action }) => {
return { job, portfolio: { title: job } };
});
```
```ruby Ruby theme={null}
collection.replace_field_writing('job') do |job, context|
{ 'job' => job, 'portfolio' => { 'title' => job } }
end
```
If the relationships do not exist, they will be created with the given field values.
Provide another `portfolioId` to update the relationships and their fields:
```javascript Node.js / Cloud theme={null}
collection.replaceFieldWriting('job', (job, { action }) => {
return { job, portfolioId: 8, portfolio: { title: job } };
});
```
```ruby Ruby theme={null}
collection.replace_field_writing('job') do |job, context|
{ 'job' => job, 'portfolioId' => 8, 'portfolio' => { 'title' => job } }
end
```
Chain the relationships. For example, if a portfolio has a `one-to-one` relationship with the `formats` collection, you can update it by writing the right path.
```javascript Node.js / Cloud theme={null}
collection.replaceFieldWriting('job', (job, { action }) => {
return { job, portfolioId: 8, portfolio: { title: job, format: { name: 'pdf' } } };
});
```
```ruby Ruby theme={null}
collection.replace_field_writing('job') do |job, context|
{ 'job' => job, 'portfolioId' => 8, 'portfolio' => { 'title' => job, 'format' => { 'name' => 'pdf' } } }
end
```
# Computed foreign keys
Source: https://docs.forest.app/product/process/relationships/computed-fks
You may want to create a relationship between 2 Collections, but you don't have a foreign key that is ready to use to connect them.
To solve that use case, you should use both [computed fields](/product/process/fields/computed) and relationships.
This is done with the following steps:
1. Create a new field containing a foreign key
2. Make the field filterable for the `In` operator (required, see [Under the hood](/product/process/relationships/under-the-hood))
3. Create a relationship using it
## Displaying a link to the last message sent by a customer
We have 2 Collections: `Customers` and `Messages`, linked together by a `one-to-many` relationship.
We want to create a `ManyToOne` relationship with the last message sent by a given customer.
```javascript theme={null}
agent.customizeCollection('customers', collection => {
// Create foreign key
collection.addField('lastMessageId', {
columnType: 'Number',
dependencies: ['id'],
getValues: async (customers, context) => {
// We're using Forest's Query Interface (you can use an ORM or plain SQL)
const messages = context.dataSource.getCollection('messages');
const conditionTree = {
field: 'customer_id',
operator: 'In',
value: customers.map(c => c.id),
};
const rows = await messages.aggregate(
{ conditionTree },
{ operation: 'Max', field: 'id', groups: [{ field: 'customer_id' }] },
);
return customers.map(record => {
return rows.find(row => row.group.customer_id === record.id)?.value ?? null;
});
},
});
// Implement the 'In' operator.
collection.replaceFieldOperator(
'lastMessageId',
'In',
async (lastMessageIds, context) => {
const records = await context.dataSource
.getCollection('messages')
.list(
{ conditionTree: { field: 'id', operator: 'In', value: lastMessageIds } },
['customer_id'],
);
return { field: 'id', operator: 'In', value: records.map(r => r.customer_id) };
},
);
// Create relationships using the foreign key we just added.
collection.addManyToOneRelation('lastMessage', 'messages', {
foreignKey: 'lastMessageId',
});
});
```
## Connecting collections without a shared identifier
You have 2 Collections both containing users: one from your database, one from your CRM.
There is no common id between them, however both have `firstName`, `lastName`, and `birthDate` fields, which taken together are unique enough.
```javascript theme={null}
agent
.customizeCollection('databaseUsers', createFilterableIdentityField)
.customizeCollection('crmUsers', createFilterableIdentityField)
.customizeCollection('databaseUsers', createRelationship)
.customizeCollection('crmUsers', createInverseRelationship);
/**
* Concatenate firstname, lastname and birthData to make a unique identifier
* and ensure that the new field is filterable
*/
function createFilterableIdentityField(collection) {
// Create foreign key on the collection from the database
collection.addField('userIdentifier', {
columnType: 'String',
dependencies: ['firstName', 'lastName', 'birthDate'],
getValues: user => user.map(u => `${u.firstName}/${u.lastName}/${u.birthDate}`),
});
// Implement 'In' filtering operator (required)
collection.replaceFieldOperator('userIdentifier', 'In', values => ({
aggregator: 'Or',
conditions: values.map(value => ({
aggregator: 'And',
conditions: [
{ field: 'firstName', operator: 'Equal', value: value.split('/')[0] },
{ field: 'lastName', operator: 'Equal', value: value.split('/')[1] },
{ field: 'birthDate', operator: 'Equal', value: value.split('/')[2] },
],
})),
}));
}
/** Create relationship between databaseUsers and crmUsers */
function createRelationship(databaseUsers) {
databaseUsers.addOneToOneRelation('userFromCrm', 'crmUsers', {
originKey: 'userIdentifier',
originKeyTarget: 'userIdentifier',
});
}
/** Create relationship between crmUsers and databaseUsers */
function createInverseRelationship(crmUsers) {
crmUsers.addManyToOneRelation('userFromDatabase', 'databaseUsers', {
foreignKey: 'userIdentifier',
foreignKeyTarget: 'userIdentifier',
});
}
```
# Multiple-records relationships
Source: https://docs.forest.app/product/process/relationships/multiple-records
Relationships that point to multiple records are displayed in the frontend in the "Related Data" and "Explorer" tabs.
## One-to-Many relationships
In a one-to-many relationship, one record from a Collection is attached to multiple records of another Collection.
Think about countries and towns: a country has multiple towns, and each town belongs to a country.
```javascript Node.js / Cloud theme={null}
// Link 'countries' to 'towns'
agent.customizeCollection('countries', collection => {
collection.addOneToManyRelation('myTowns', 'towns', {
originKey: 'country_id',
originKeyTarget: 'id', // Optional (uses primary key of countries by default)
});
});
```
```ruby Ruby theme={null}
ForestAdmin.customize do
# Link 'countries' to 'towns'
customize_collection('countries') do |collection|
collection.add_one_to_many_relation('myTowns', 'towns',
origin_key: 'country_id',
origin_key_target: 'id' # Optional (uses primary key of countries by default)
)
end
end
```
## Many-to-Many relationships
In a many-to-many relationship, 3 Collections are used instead of 2 to build the relationship.
This allows multiple records from one Collection to be attached to multiple records from another Collection.
For instance, on a movie recommendation website, each user can rate many movies, and each movie can be rated by many users. The 3 Collections used are `users` (the "origin"), `ratings` (the "through"), and `movies` (the "foreign" Collection).
```javascript Node.js / Cloud theme={null}
// Create one side of the relationship ...
agent.customizeCollection('users', collection => {
collection.addManyToManyRelation('ratedMovies', 'movies', 'ratings', {
originKey: 'user_id',
foreignKey: 'movie_id',
});
});
// ... and the other one
agent.customizeCollection('movies', collection => {
collection.addManyToManyRelation('whoRatedThisMovie', 'users', 'ratings', {
originKeyTarget: 'id', // Optional (uses primary key of movies by default)
originKey: 'movie_id',
foreignKey: 'user_id',
foreignKeyTarget: 'id', // Optional (uses primary key of users by default)
});
});
```
```ruby Ruby theme={null}
ForestAdmin.customize do
# Create one side of the relationship ...
customize_collection('users') do |collection|
collection.add_many_to_many_relation('ratedMovies', 'movies', 'ratings',
origin_key: 'user_id',
foreign_key: 'movie_id'
)
end
# ... and the other one
customize_collection('movies') do |collection|
collection.add_many_to_many_relation('whoRatedThisMovie', 'users', 'ratings',
origin_key_target: 'id', # Optional (uses primary key of movies by default)
origin_key: 'movie_id',
foreign_key: 'user_id',
foreign_key_target: 'id' # Optional (uses primary key of users by default)
)
end
end
```
## External relationships
External relationships allow defining Collections which will only be available through the "Related Data" section of a given model.
External relationships do not support pagination.
```javascript Node.js / Cloud theme={null}
const states = [
{ code: 'AK', name: 'Alaska', zip: [99501, 99950], closeTo: [] },
{ code: 'AL', name: 'Alabama', zip: [35004, 36925], closeTo: ['TE', 'MI', 'GE'] },
{ code: 'AR', name: 'Arkansas', zip: [71601, 72959], closeTo: ['OK', 'TX', 'LO'] },
{ code: 'AZ', name: 'Arizona', zip: [85001, 86556], closeTo: ['NM', 'CO', 'NE'] },
{ code: 'CA', name: 'California', zip: [90001, 96162], closeTo: ['OR', 'NE'] },
// ....
];
agent.customizeCollection('address', collection => {
collection.addExternalRelation('nearStates', {
// Define schema of the records in the relationship.
schema: { code: 'Number', name: 'String' },
// Which fields are needed from the parent record to run the handler?
// Dependencies are optional: by default only the primary key of address would be
// provided.
dependencies: ['country', 'zipCode'],
// Compute list of records from the parent record
listRecords: async ({ country, zipCode }) => {
if (country === 'USA') {
const state = states.find(s => s.zip[0] < zipCode && zipCode < s.zip[1]);
return states.filter(s => state.closeTo.includes(s.code));
}
return [];
},
});
});
```
```ruby Ruby theme={null}
STATES = [
{ 'code' => 'AK', 'name' => 'Alaska', 'zip' => [99501, 99950], 'closeTo' => [] },
{ 'code' => 'AL', 'name' => 'Alabama', 'zip' => [35004, 36925], 'closeTo' => ['TE', 'MI', 'GE'] },
{ 'code' => 'AR', 'name' => 'Arkansas', 'zip' => [71601, 72959], 'closeTo' => ['OK', 'TX', 'LO'] },
{ 'code' => 'AZ', 'name' => 'Arizona', 'zip' => [85001, 86556], 'closeTo' => ['NM', 'CO', 'NE'] },
{ 'code' => 'CA', 'name' => 'California', 'zip' => [90001, 96162], 'closeTo' => ['OR', 'NE'] },
# ....
]
ForestAdmin.customize do
customize_collection('address') do |collection|
collection.add_external_relation('nearStates',
# Define schema of the records in the relationship.
schema: { 'code' => 'Number', 'name' => 'String' },
# Which fields are needed from the parent record to run the handler?
# Dependencies are optional: by default only the primary key of address would be provided.
dependencies: ['country', 'zipCode'],
# Compute list of records from the parent record
list_records: ->(record) {
if record['country'] == 'USA'
state = STATES.find { |s| s['zip'][0] < record['zipCode'].to_i && record['zipCode'].to_i < s['zip'][1] }
STATES.select { |s| state['closeTo'].include?(s['code']) }
else
[]
end
}
)
end
end
```
# Overview
Source: https://docs.forest.app/product/process/relationships/overview
When relationships are defined during the customization step, Forest Collections act as if the 2 Collections were natively linked at the data source level.
You may have noticed that relationships within a data source are configured out of the box, so you won't need to define those.
However, you may want to create additional intra and cross data source relationships to:
* help users navigate within your back-office
* create charts that use data from multiple data sources
* let users filter, use scopes, or segment with conditions that cross data source boundaries
## Minimal example
```javascript Node.js / Cloud theme={null}
agent.customizeCollection('towns', collection =>
collection
// Towns belong to 1 country
.addManyToOneRelation('country', 'countries', { foreignKey: 'country_id' })
// Towns have 1 mayor
.addOneToOneRelation('mayor', 'mayors', { originKey: 'town_id' })
// Towns have multiple inhabitants
.addOneToManyRelation('inhabitants', 'people', { originKey: 'town_id' })
// Towns electricity is supplied by power plants that are shared with other towns
.addManyToManyRelation('energyProviders', 'powerPlants', 'utilityContracts', {
originKey: 'town_id',
foreignKey: 'power_plant_id',
})
// Towns have a list of honorary citizen that is retrievable through a public API
.addExternalRelation('honoraryCitizen', {
schema: { firstName: 'String', lastName: 'String' },
listRecords: async ({ id }) => {
const response = await axios.get(
`https://api.mytown.com/cities/${id}/honorary-citizen`,
);
return response.body;
},
}),
);
```
```ruby Ruby theme={null}
ForestAdmin.customize do
customize_collection('towns') do |collection|
collection
# Towns belong to 1 country
.add_many_to_one_relation('country', 'countries', foreign_key: 'country_id')
# Towns have 1 mayor
.add_one_to_one_relation('mayor', 'mayors', origin_key: 'town_id')
# Towns have multiple inhabitants
.add_one_to_many_relation('inhabitants', 'people', origin_key: 'town_id')
# Towns electricity is supplied by power plants that are shared with other towns
.add_many_to_many_relation('energyProviders', 'powerPlants', 'utilityContracts',
origin_key: 'town_id',
foreign_key: 'power_plant_id'
)
# Towns have a list of honorary citizen that is retrievable through a public API
.add_external_relation('honoraryCitizen',
schema: { 'firstName' => 'String', 'lastName' => 'String' },
list_records: ->(record) {
response = Faraday.get("https://api.mytown.com/cities/#{record['id']}/honorary-citizen")
JSON.parse(response.body)
}
)
end
end
```
# Single-record relationships
Source: https://docs.forest.app/product/process/relationships/single-record
Relationships that point to a single record are displayed in your back-office as links.
Once configured, they appear in charts, filters, scopes, and segments.
For performance reasons when sorting a Table View on customizer-defined relationships, Forest will always sort on the primary key of the related collection.
## Many-to-One relationships
Many-to-One relationships are by far the most common type of relationship: many records from a Collection are attached to another Collection record.
Think about countries and towns: a town belongs to a single country, but each country can have multiple towns.
```javascript Node.js / Cloud theme={null}
agent.customizeCollection('towns', collection =>
collection.addManyToOneRelation('country', 'countries', {
foreignKey: 'country_id',
foreignKeyTarget: 'id', // Optional (uses `country` primary key by default)
}),
);
```
```ruby Ruby theme={null}
ForestAdmin.customize do
customize_collection('towns') do |collection|
collection.add_many_to_one_relation('country', 'countries',
foreign_key: 'country_id',
foreign_key_target: 'id' # Optional (uses `country` primary key by default)
)
end
end
```
## One-to-One relationships
In a one-to-one relationship, there is a one-to-one mapping between records in 2 Collections. The relationship can be unset for some records, but no record from the first Collection can be linked to more than one record in the other Collection.
Think about cities and mayors: A city can have at most one mayor, and each mayor belongs to a single city.
Take note that the inverse of a `one-to-one` is a `many-to-one`.
This may seem counter-intuitive: the side of the relationship which should be configured as `many-to-one` is the one that carries the foreign key.
```javascript Node.js / Cloud theme={null}
// Configure one side of the relationship ...
agent.customizeCollection('mayors', collection => {
collection.addOneToOneRelation('city', 'cities', {
originKey: 'mayor_id',
originKeyTarget: 'id', // Optional (uses `mayors` primary key by default)
});
});
// ... and the other one.
agent.customizeCollection('cities', collection => {
// ⚠️ Not 'OneToOne'
collection.addManyToOneRelation('mayor', 'mayors', {
foreignKey: 'mayor_id',
foreignKeyTarget: 'id', // Optional (uses `mayors` primary key by default)
});
});
```
```ruby Ruby theme={null}
ForestAdmin.customize do
# Configure one side of the relationship ...
customize_collection('mayors') do |collection|
collection.add_one_to_one_relation('city', 'cities',
origin_key: 'mayor_id',
origin_key_target: 'id' # Optional (uses `mayors` primary key by default)
)
end
# ... and the other one.
customize_collection('cities') do |collection|
# ⚠️ Not 'one_to_one'
collection.add_many_to_one_relation('mayor', 'mayors',
foreign_key: 'mayor_id',
foreign_key_target: 'id' # Optional (uses `mayors` primary key by default)
)
end
end
```
# Under the hood
Source: https://docs.forest.app/product/process/relationships/under-the-hood
Join emulation works by transparently analyzing the requests that are performed by the frontend and customer API in your Back-end, and translating them into multiple requests to the relevant data sources.
For instance, assuming that:
* you defined a jointure between 2 Collections: `books` and `authors`
* both Collections are hosted on different SQL databases
* you display the `books` list in your back-office
```sql theme={null}
-- The frontend needs the result of that query to display the 'list view'
-- which cannot be performed, because books and authors are on different databases
SELECT books.title, authors.firstName, authors.lastName
FROM books
INNER JOIN authors ON authors.id = books.id
WHERE books.title LIKE 'Found%'
```
The request will be transparently split and its result merged to produce the same output as if the original query was run.
```sql theme={null}
-- Step 1: Query database containing books (including foreign key)
SELECT books.title, books.authorId FROM books WHERE books.title LIKE 'Found%';
> | title | authorId |
> | Foundation | 83948934 |
-- Step 2: Query database containing authors (including pk)
SELECT authors.id, authors.firstName, authors.lastName FROM authors WHERE id IN (83948934);
> | id | firstName | lastName |
> | 83948934 | Isaac | Asimov |
-- Step 3: Merge results (using books.authorId === authors.id)
> | title | authorId | firstName | lastName |
> | Foundation | 83948934 | Isaac | Asimov |
```
Automatic query splitting handles complex cross-datasource queries, however not all queries are created equal.
In this simple example, it is a straightforward three-step process, but the feature can come at the cost of performance on more complex queries.
# Creating segments
Source: https://docs.forest.app/product/process/segments/creating-segments
Create and manage segments through the Forest UI, no code required
A **segment** is a saved filter that gives you a dedicated tab on a collection, showing only the records that match specific conditions.
For code-based segments, see [Code-based segments](/product/process/segments/smart-segments/overview).
## Create a segment
1. Enable **Layout Editor** mode (top right of your screen)
2. Click the **⚙️ cog icon** next to the collection you want to edit
3. Go to the **Segments** tab
4. Click **"Create your first segment"** (or **"+ New segment"** if you already have some)
5. Give your segment a **name**
6. Add one or more **filters**
7. Optionally adjust the **sort field and order**
8. Save
Newly created segments are **disabled** by default. Enable them from the Segments tab in Layout Editor mode.
## Reorder segments
Use the Layout Editor to drag and drop segments into the order that matches your workflow.
## SQL Query segments
For advanced filtering, create a segment using a raw SQL query.
To enable SQL Query segments in back-end v2, your developers need to add a connection name to the datasources on which you want to run live queries.
SQL Query mode is only available for SQL databases. For security reasons, only `SELECT` queries are allowed.
Switch to **Query** mode, type your SQL query, and save. The query must return the **primary key** column of the collection.
```sql theme={null}
SELECT t.id FROM transactions t
JOIN companies beneficiary ON t.beneficiary_company_id = beneficiary.id
JOIN companies emitter ON t.emitter_company_id = emitter.id
WHERE beneficiary.headquarter ILIKE '%United States%'
AND emitter.headquarter ILIKE '%United States%'
```
To query across multiple databases in PostgreSQL, you can use the [dblink function](https://www.postgresql.org/docs/current/contrib-dblink-function.html).
# Code-based segments
Source: https://docs.forest.app/product/process/segments/smart-segments/overview
A Segment is a subset of a Collection: it's a saved filter of your Collection.
Segments are designed for those who want to *systematically* visualize data according to specific sets of filters. It allows you to save filter configurations so users don't have to do repetitive actions every day.
## From your back-end
Sometimes, Segment filters are complicated and closely tied to your business. Forest allows you to code how the Segment is computed.
For instance, you might implement a Segment on the `products` collection to allow admin users to see the bestsellers at a glance.
### Example
In the following example, we are making queries using the Forest Query Interface.
As Forest does not impose any restriction on the handler, you are free to call external APIs or query your database directly instead.
The only requirement when implementing a Segment from your back-end is to return a valid `ConditionTree`.
```javascript Node.js / Cloud theme={null}
agent.customizeCollection('products', collection =>
collection.addSegment('mostPurchased', async context => {
// Query the ids of the 10 most ordered products.
const rows = await context.dataSource
.getCollection('orders')
.aggregate({}, { operation: 'Count', groups: [{ field: 'product_id' }] }, 10);
// Return a condition tree which matches those records
return { field: 'id', operator: 'In', value: rows.map(r => r['product_id']) };
});
);
```
```ruby Ruby theme={null}
ForestAdmin.customize do
customize_collection('products') do |collection|
collection.add_segment('mostPurchased') do |context|
# Query the ids of the 10 most ordered products.
rows = context.datasource
.get_collection('orders')
.aggregate({}, { operation: 'Count', groups: [{ field: 'product_id' }] }, 10)
# Return a condition tree which matches those records
{ field: 'id', operator: 'In', value: rows.map { |r| r['product_id'] } }
end
end
end
```
```ruby Ruby DSL theme={null}
@create_agent.collection :products do |collection|
collection.segment 'mostPurchased' do |context|
# Query the ids of the 10 most ordered products.
rows = context.datasource
.get_collection('orders')
.aggregate({}, { operation: 'Count', groups: [{ field: 'product_id' }] }, 10)
# Return a condition tree which matches those records
{ field: 'id', operator: 'In', value: rows.map { |r| r['product_id'] } }
end
end
```
# Forest Runtime
Source: https://docs.forest.app/product/process/workflows/forest-runtime
Run your workflow steps on your own infrastructure — the Forest orchestrator never sees your records.
Forest's orchestrator coordinates your workflows but never executes the steps itself — that runs on **Forest Runtime**, deployed on your own infrastructure. Forest Runtime polls the orchestrator for pending steps, runs them locally, and reports only the results back.
Setting it up is a **one-time addition, not a migration**: you deploy Forest Runtime and either point your agent at it or embed it in the agent — your collections and existing workflows are untouched.
Because it talks to your data through your own Forest agent, **the Forest orchestrator never sees your records** — data read and written by data steps stays within your infrastructure.
One exception: **AI steps and MCP Tasks** send prompt content, which can include record data, to an LLM provider and remote tools — so workflows aren't fully air-gapped. See [AI provider](#ai-provider).
## Do I need Forest Runtime?
Yes — Forest Runtime is what executes your workflow steps. **[Webhook-triggered workflows](/product/process/workflows/triggers) especially**: a webhook can fire at any time, with no guarantee anyone has Forest open in a browser, so they can only run server-side.
Running on infrastructure you control also keeps the records handled by data steps out of Forest's infrastructure — decisive for compliance or data-residency requirements, or when a step needs access to systems reachable only from within your network.
## How it works
1. A workflow is triggered — by a user or a [webhook](/product/process/workflows/triggers). The Forest orchestrator queues the pending steps.
2. Forest Runtime polls the orchestrator and pulls the steps assigned to it.
3. Each step runs locally, reaching your data and actions through your Forest agent.
4. It reports the step outcome back to the orchestrator, which advances the workflow.
```mermaid theme={null}
flowchart LR
O["Forest orchestrator (coordinates)"] -- "pending steps" --> R["Forest Runtime (your infrastructure)"]
R -- "data & actions" --> A["Your Forest agent"] --> DB[("Your business database")]
R -. "state (DATABASE_URL)" .-> P[("Forest Runtime's Postgres")]
R -- "outcomes only, never records" --> O
```
## Prerequisites
* A recent **Forest Admin agent** — the minimum version depends on how you run Forest Runtime (below).
* A **PostgreSQL** database for production: Forest Runtime persists its run state there. A database-free mode exists for testing only.
## Running Forest Runtime
Run Forest Runtime one of two ways:
* **[Embedded in your Node.js agent](#embedded-in-the-nodejs-agent)** — one line in your agent, nothing separate to deploy. The simplest option.
* **[Standalone](#standalone-docker-or-cli)** — a separate process (Docker or CLI), to scale it independently of your agent or to use it with a Ruby agent.
### Embedded in the Node.js agent
Requirements:
* **`@forestadmin/agent`** — embedded mode arrived in **1.84.0**, but the options shown below (`schema`, `ai`, `encryptionKey`, the tuning knobs) landed in later releases, so use a **recent** version.
* **Node.js ≥ 22.12.0** — required by the executor package.
* **The executor package**, installed at the **exact version your agent pins**. It's an *optional peer dependency* the agent loads dynamically at runtime, so `tsc` won't flag it when it's missing — but `agent.start()` throws *"The embedded workflow executor requires the `@forestadmin/workflow-executor` package"*. The pin is exact (a `^` range conflicts), so install that specific version — find it in your lockfile or with `npm info @forestadmin/agent@ peerDependencies` (pin the version you actually run — without it, npm answers for `latest`, whose pin may differ):
```bash theme={null}
npm install @forestadmin/workflow-executor@
```
* **TypeScript 5.5+** — the package ships zod 4, which is only tested against TypeScript 5.5 and later. On TypeScript 4.x its typings can't even be parsed, and `skipLibCheck` won't help (it suppresses type errors, not the syntax errors these typings trigger).
Then add one line — the executor runs inside the agent process, so there's nothing else to deploy:
```js theme={null}
createAgent(options)
.addDataSource(/* ... */)
.addWorkflowExecutor({ database: { uri: process.env.DATABASE_URL } })
.start();
```
Safe to reuse the database your agent already reads: the executor keeps its tables in a dedicated **`forest`** Postgres schema, so they stay out of the `public` schema your datasource introspects and never show up as collections in your panel. Pass a `schema` if you'd rather name it differently.
Creating that schema needs the `CREATE` privilege on the database. If your role doesn't have it, have an administrator create the schema and grant the role access to it — the executor checks whether the schema already exists before trying to create one, so a pre-created schema boots fine with schema-level privileges only.
It inherits your agent's secrets and Forest connection, so you only configure:
| Option | Description |
| ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `database` | Postgres connection — a URI or Sequelize options. Persists run state. Its tables live in the `forest` schema unless you pass a different `schema` here. Required unless `inMemory`. |
| `inMemory` | `true` runs without a database (testing only — runs are lost on restart). |
| `agentUrl` | How the executor reaches your agent. Auto-derived when the agent runs on its own server; **required** when the agent is mounted on Express, Fastify or NestJS. |
| `port` | Loopback port the executor listens on internally (default `3400`). |
| `ai` | Bring your own LLM instead of Forest's AI server: `{ provider: 'anthropic' \| 'openai', model, apiKey }` (all three required together). Omit to keep using Forest's server. |
| `encryptionKey` | At-rest key (AES-256-GCM) for [OAuth-protected MCP connector](#oauth-protected-mcp-connectors) credentials. Generate with `openssl rand -hex 32`. Omit if you don't use them. |
| `pollingIntervalS`, `stepTimeoutS`, `aiInvokeTimeoutS`, `stopTimeoutS`, `maxChainDepth`, `schemaCacheTtlS` | The same tuning knobs as [standalone](#tuning), in camelCase — same defaults. Log verbosity follows your agent's own logger, so there's no separate log-level option here. |
Embedded has full configuration parity with standalone: your own AI provider (`ai`), the encryption key, and every tuning knob are all settable here. It only inherits your agent's secrets and Forest connection; set nothing and AI steps use Forest's AI server.
### Standalone (Docker or CLI)
Run Forest Runtime as its own service — the way to run it with any agent other than the v2 Node.js one (which can also [embed](#embedded-in-the-nodejs-agent) it), and to scale or deploy it separately.
**1. Point your agent at it** with the workflow executor URL, so the agent mounts the route that forwards workflow requests to Forest Runtime (relaying the JWT). It's supported across both agent generations:
| Agent | Config | Min version |
| ----------------------------------------------------------------- | -------------------------------------- | ----------- |
| `@forestadmin/agent` — Node.js (v2) | `createAgent({ workflowExecutorUrl })` | 1.79.0 |
| `forest-express` — Node.js (v1, incl. `-sequelize` / `-mongoose`) | `Liana.init({ workflowExecutorUrl })` | 10.7.0 |
| `forest_admin_rails` — Ruby (v2) | `config.workflow_executor_url` | 1.31.0 |
| `forest-rails` — Ruby (v1) | `ForestLiana.workflow_executor_url` | 9.18.0 |
```js Node.js (v2) theme={null}
createAgent({
// ...
workflowExecutorUrl: process.env.WORKFLOW_EXECUTOR_URL, // e.g. http://localhost:3400
})
```
```js forest-express (v1) theme={null}
Liana.init({
// ...
workflowExecutorUrl: process.env.WORKFLOW_EXECUTOR_URL,
})
```
```ruby forest_admin_rails (v2) theme={null}
# config/initializers/forest_admin_rails.rb
ForestAdminRails.configure do |config|
# ...
config.workflow_executor_url = ENV['WORKFLOW_EXECUTOR_URL']
end
```
```ruby forest-rails (v1) theme={null}
# config/initializers/forest_liana.rb
ForestLiana.workflow_executor_url = ENV['WORKFLOW_EXECUTOR_URL']
```
The Python agents (`agent-python`, `django-forestadmin`) don't support the workflow executor yet.
If the workflow executor URL is left unset, the agent returns `404` on those routes and Forest Runtime never receives any work. When the agent and Forest Runtime run on separate hosts, use an internal address the agent can reach on Forest Runtime's HTTP port (default `3400`).
**2. Run the executor** as a Docker image or via the CLI:
```bash Docker theme={null}
docker run -d \
--add-host=host.docker.internal:host-gateway \
-e FOREST_ENV_SECRET="your-env-secret" \
-e FOREST_AUTH_SECRET="your-auth-secret" \
-e AGENT_URL="http://host.docker.internal:3351" \
-e DATABASE_URL="postgres://user:pass@host.docker.internal:5432/mydb" \
-p 3400:3400 \
ghcr.io/forestadmin/workflow-executor:latest
```
```bash npx theme={null}
FOREST_ENV_SECRET="your-env-secret" \
FOREST_AUTH_SECRET="your-auth-secret" \
AGENT_URL="https://your-agent-url" \
DATABASE_URL="postgres://user:pass@localhost:5432/mydb" \
npx @forestadmin/workflow-executor
```
| Variable | Required | Description |
| -------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `FOREST_ENV_SECRET` | yes | Your environment secret — same value as your agent. Also in [app.forestadmin.com](https://app.forestadmin.com) → Settings → Environments. |
| `FOREST_AUTH_SECRET` | yes | The same `authSecret` value your agent is configured with — ask whoever operates the agent. |
| `AGENT_URL` | yes | URL where your Forest Admin agent is running (e.g. `http://localhost:3351`). |
| `DATABASE_URL` | yes | Postgres connection string. Required unless you pass the `--in-memory` flag (testing only — state is lost on restart). |
When Forest Runtime runs in Docker and your agent runs on the host machine, use `host.docker.internal` instead of `localhost` in `AGENT_URL` and `DATABASE_URL`. On Linux Docker Engine that hostname doesn't exist by default, so the `--add-host=host.docker.internal:host-gateway` flag above is required to resolve it (on Docker Desktop it's already provided and the flag is harmless).
### Network requirements
A standalone Forest Runtime opens these connections (an embedded executor makes the same outbound calls from the agent process, with no extra inbound port):
| Direction | Flow | Purpose |
| --------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| Inbound | Agent → Forest Runtime `:3400` | The agent forwards workflow requests. |
| Outbound | Forest Runtime → `api.forestadmin.com` (HTTPS) | Polling the orchestrator for pending steps. |
| Outbound | Forest Runtime → `AGENT_URL` | Data steps read/write through your agent. |
| Outbound | Forest Runtime → your Postgres | State persistence. |
| Outbound | Forest Runtime → LLM provider & remote MCP tools | AI steps and MCP Tasks (Forest's AI server by default, or your own provider — see [AI provider](#ai-provider)). |
On first boot Forest Runtime auto-creates its `workflow_step_executions` table, then polls the orchestrator every 30 seconds (`POLLING_INTERVAL_S`) for work.
Forest Runtime is stateless apart from its Postgres database: you can run several instances against the same database for high availability — each pending step is claimed by exactly one instance. If you use [OAuth-protected MCP connectors](#oauth-protected-mcp-connectors), give every instance the same encryption key.
### Health check
`GET /health` is public (no auth) and returns the runtime's current state:
```bash theme={null}
curl http://localhost:3400/health
# {"state":"running"}
```
| State | HTTP | Meaning |
| ---------- | ----- | ------------------------------------------------------------------------------------------------------------------ |
| `running` | `200` | Started and polling for work. |
| `draining` | `200` | Graceful shutdown — finishing in-flight steps (up to `STOP_TIMEOUT_S`, 30s by default), no longer taking new runs. |
| `idle` | `503` | Not started yet. |
| `stopped` | `503` | Shut down. |
For **liveness** probes, treat any `200` as healthy. For **readiness** probes, route traffic only on `{"state":"running"}` so a draining instance stops receiving new work while it finishes in-flight steps.
## AI provider
Several step types rely on an LLM: guidance, decisions, MCP Tasks, and AI-assisted data steps. By default Forest Runtime uses **Forest's AI server** — no configuration required, AI steps work out of the box.
To keep AI calls off Forest's server and use your **own provider and key** instead, set all three variables together:
| Variable | Description |
| ------------- | ----------------------------------------------------------------- |
| `AI_PROVIDER` | `anthropic` or `openai`. |
| `AI_MODEL` | Model name for that provider (e.g. `gpt-4.1`, `claude-sonnet-5`). |
| `AI_API_KEY` | Your API key for that provider. |
This is **all-or-nothing**: set the three together to use your own provider, or leave all three unset to fall back to Forest's AI server. Setting only some of them fails at startup.
## OAuth-protected MCP connectors
If your workflows include [MCP Tasks](/product/process/workflows/overview) backed by OAuth-protected connectors, Forest Runtime stores each user's OAuth credentials in its database, encrypted at rest. Provide the encryption key:
| Variable | Description |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FOREST_EXECUTOR_ENCRYPTION_KEY` | At-rest encryption key (AES-256-GCM) for stored OAuth credentials. Generate with `openssl rand -hex 32`. Use a **separate** secret from `FOREST_AUTH_SECRET`. |
* Required **only** for OAuth-protected MCP connectors, and read lazily — an instance that stores no such credentials runs fine without it.
* Use the **same value on every instance** that shares a database, or an instance won't decrypt credentials written by another.
* Treat it as permanent: there is no managed rotation. Changing it forces every affected user to reconnect their connectors.
## Observability
The Docker image ships with [OpenTelemetry](https://opentelemetry.io/) APM built in, compatible with any OTLP backend (Datadog, Grafana Tempo, Jaeger, Honeycomb…). It is **off by default** and turns on as soon as you set `OTEL_EXPORTER_OTLP_ENDPOINT`. OpenTelemetry is bundled only in the Docker image, not the npm package.
### When an MCP Task can't load its tools
Forest Runtime logs the reason at `Error`, so it is in your logs without changing `LOG_LEVEL`. This is the JSON the Docker image writes to stdout; running in a terminal you get the same fields in the pretty single-line format instead.
```json theme={null}
{
"level": "Error",
"message": "MCP servers failed to load tools",
"timestamp": "2026-08-10T09:14:22.031Z",
"requestedMcpServerId": "39",
"mcpServerName": "acme-crm",
"failures": [
{ "server": "acme-crm", "kind": "connection", "error": "connect ECONNREFUSED 10.0.4.12:8080" }
]
}
```
`kind` tells you where to look:
* `auth` — the server rejected the credential (HTTP 401). For an OAuth connector the runtime refreshes the token and retries once on its own, so act only if the failure repeats without a follow-up `MCP tools loaded after refreshing the credential` line. For a static credential, renew it in the connector's configuration.
* `connection` — unreachable, refused, or slower than the 15-second per-server load timeout.
* `unknown` — the server answered but the load failed anyway, including HTTP 403 permission or scope errors that no token refresh can fix; `error` carries the reason.
A server that answers but exposes no tools is not a failure: you get an empty tool list and no error.
If your logs show a `failedConfigNames` list instead of `failures`, your runtime predates this change: it names which server failed but not why, and reports a healthy server exposing no tools as a failure. Upgrade to get the cause.
## Tuning
Beyond the required variables, these optional knobs have sensible defaults and rarely need changing:
| Variable | Default | Description |
| --------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `HTTP_PORT` | `3400` | Port Forest Runtime's HTTP server listens on. |
| `POLLING_INTERVAL_S` | `30` | How often it polls the orchestrator for pending steps. |
| `LOG_LEVEL` | `Info` | `Debug`, `Info`, `Warn`, or `Error`. `Debug` adds one line per MCP server with its tool count and load time. |
| `STEP_TIMEOUT_S` | `300` | Max duration of a single step. |
| `AI_INVOKE_TIMEOUT_S` | `30` | Max duration of a single AI provider invocation. |
| `STOP_TIMEOUT_S` | `30` | Grace period on shutdown to finish in-flight steps before exiting. |
| `MAX_CHAIN_DEPTH` | `50` | Max steps auto-executed per run before yielding. |
| `SCHEMA_CACHE_TTL_S` | `600` | Collection schema cache TTL. |
| `DATABASE_SCHEMA` | `forest` | Postgres schema Forest Runtime creates its tables in — already isolated from `public` by default; set it only to use a different name. |
For the remaining variables (individual database parts, `DATABASE_SSL`, in-memory testing mode, full OTel configuration), see the package [README on npm](https://www.npmjs.com/package/@forestadmin/workflow-executor).
## Learn more
Build and manage workflows in the no-code editor
Configure the connectors used by MCP Tasks
# Workflows
Source: https://docs.forest.app/product/process/workflows/overview
Formalize your operational processes in a no-code editor, leveraging your data, actions, and 3rd party apps for reliable, secure, and efficient executions.
Forest Workflows let you formalize your operational processes in a no-code editor, leveraging your data, actions, and 3rd party apps to ensure reliable, secure, and efficient executions, right in your back-office.
## Creating & managing workflows
Like all UI customization features in Forest, Workflows can be created and managed by users with the Admin, Developer, or Editor permission level.
### Getting started
The quickest way to create a new workflow is through the **Customization Center** (Edit Layout → + Add New).
Alternatively, add a new workflow directly from your **Collection Settings**, using the **+ New** button in the Workflows tab.
### The workflow editor
The Workflow Editor is where you define and map your operational process, without worrying about any of the technical aspects.
#### Interface overview
The Workflow Editor is made up of four main elements:
* **Toolbar**, on the left, where you'll find the Steps available to build your workflow
* **Canvas**, in the center, where you build the flow of your process
* **Settings Panel**, on the right, where you configure the step(s) currently selected in the canvas
* **Header**, where you save your workflow, after fixing any issues raised in the **Errors Panel**
#### Steps
A workflow is a series of Steps. Some steps are entirely manual, the end user carries out the instruction provided, while **AI-powered** steps surface the relevant data or actions at runtime to help the user complete the step more efficiently.
The following step types are available (\* denotes AI-powered):
**Guidance** tasks provide instructions to the end user on how to complete the step.
**Trigger Action**\* tasks provide the end user with the action required to complete the step (e.g. "Trigger the Onboard Customer action").
**Get Data**\* tasks retrieve the value of one or multiple fields, display them to the end user, and load them into the workflow context (e.g. "Retrieve the Name, Address, and Email of the customer").
**Update Data**\* tasks provide the end user with a way to update the value of a field (e.g. "Update the value of the customer's Address").
**Load Related Record**\* tasks let you load a related record to access its data and actions in subsequent steps (e.g. "Load the Bank Statement related to the Customer, from the Document collection").
**Decision** steps split your workflow into separate branches based on a condition.
**Go To** steps can loop back to a previous step, jump to another branch, or step into another workflow.
**Groups** let you organize multiple steps together for improved readability.
**End** steps mark the end of a branch and the completion of a workflow.
**Escalations** indicate a point where the workflow run is placed in an Inbox, so that another user can pick it up where the previous one left off.
**MCP Tasks**\* let the end user perform an action in a 3rd party app via the corresponding MCP Server.
MCP Tasks require an MCP Server to be configured in your Forest project. See [MCP Servers](/get-started/connect/integrations/mcp-servers) for details.
#### Step automation
Workflows are designed to be human-first for reliability and compliance, but it is possible to automate certain steps to boost execution efficiency.
At the end of each step, the user clicks the confirmation button (labelled **Done** by default) to proceed. Skip this by setting the **Step Completion** toggle to **Automatic**, so the workflow advances to the next step immediately upon completion.
On **Trigger Action** and **MCP Tasks** steps, the end user must explicitly click Execute by default. Once you're confident in the AI suggesting the correct tool, set the **Execution** toggle to **Automatic** to skip that extra click.
Actions featuring a form still need to be filled out by the end user before the workflow submits them automatically.
**Decisions** can also be delegated to AI: set the **Decision maker** toggle to **AI** on a decision step, and the workflow will automatically guide users through the correct branch, assuming it has sufficient context from previous steps.
### Managing workflows
Existing workflows can be managed from the relevant **Collection Settings**, in the Workflows tab. From there, rename a workflow, restrict its availability to a subset of Segments, and access the Workflow Editor.
By default, workflows are available when selecting a single record from the List View. Toggle their visibility and reorder them in the **Layout Editor**.
Workflows can also be made available in Summary Views and Workspaces.
### Triggering workflows
By default, workflows are started manually by users from the interface. A workflow can also be triggered automatically by an external system through a **webhook**, configured in the **Process** section of the workflow settings.
Enable manual and webhook triggers, and manage the webhook URL and token
***
## Security & auditing
As with everything in Forest, the Workflow feature is built on the **Roles & Permissions** system. Although anyone can trigger a workflow, data and actions are only made available to users whose role permits it.
Every completed and aborted workflow execution is recorded in the workflow list for a given record, and opens in read-only mode to review its execution in detail.
Every interaction within a workflow execution is also recorded in your **Activity Logs**, available in the Reports tab or through the public API.
Control who can access workflows and what data and actions they can interact with
***
## Learn more
Trigger custom operations on your data
Require sign-off before sensitive operations
Learn how end users run workflows
Distribute workflow tasks across your team
# Workflow triggers
Source: https://docs.forest.app/product/process/workflows/triggers
Choose how a workflow starts, manually from the interface, or automatically from an external system via a webhook.
A workflow can be started in two independent ways. Both are configured in the **Process** section of the workflow settings page, beneath the version card. Each trigger type has its own row with an on/off toggle, and the two can be enabled independently.
* **Manual** — users start the workflow from a matching record in the interface (List View, Summary/Details, or a Workspace). This is the default. See [Executing workflows](/product/execute/workflows).
* **Webhook** — external systems start the workflow via an authenticated HTTP POST. **Disabled by default.**
Webhook triggers are available on **all environments**, with no production-only restriction.
## The webhook trigger
When enabled, the webhook lets any external system, an ETL job, a partner service, a CRON in your own infrastructure, start a workflow run on a specific record by calling a stable URL.
Two things are separated by design:
* **The URL** identifies everything *fixed* about the trigger: which workflow runs, and against which rendering. The only per-call input is the target record.
* **The token** carries authentication *and* the identity the run acts as. The run reads and writes data as the token's user, and the activity log attributes it to that user, so a webhook-triggered run can never do more than that user is allowed to.
For the full HTTP contract, request body, response codes, idempotency, and rate limits, see the [Trigger a workflow via webhook](/reference/api/endpoints/trigger-workflow-webhook) API reference.
## Enabling the webhook
1. Open the workflow settings page and go to the **Process** section.
2. Toggle **Webhook** on.
Once enabled:
* the endpoint **URL** is displayed inline with a **copy** button;
* a hint shows the JSON body to send, with the target record's `record_id`;
* a **Generate new URL** button lets you rotate the URL (see [Regenerating the URL](#regenerating-the-url)).
Copy the URL and use it from your external system with a valid application token. The workflow starts on the record you pass in the request body.
## Regenerating the URL
If a URL may have leaked, or you simply want to rotate it, generate a new one from the **Generate new URL** button below the current URL.
Generating a new URL **immediately invalidates the current one**. Any integration still calling the old URL will start failing until you update it with the new URL.
When you confirm:
* the URL is updated inline, and the **copy** button now copies the new one;
* a confirmation toaster briefly appears.
## Revoking access
You have three independent levers to stop a webhook, without necessarily touching the others:
| Lever | Effect | Calls then return |
| --------------------------------- | --------------------------------------------------- | ----------------- |
| **Disable the Webhook toggle** | Turns the trigger off. URL and token are unchanged. | `404` |
| **Generate new URL** | Invalidates the current URL. | `400` (old URL) |
| **Invalidate / expire the token** | Done by the token's user, from account settings. | `401` |
Turning the toggle back on re-enables the *same* URL and token — it is a pause, not a reset.
## Auditing
Every webhook-triggered run is recorded in the workflow run history and in your **Activity Logs**, attributed to the token's user and marked as **webhook-triggered** so you can distinguish automated runs from manual ones.
## Learn more
The HTTP contract: body, response codes, idempotency, rate limits.
Build and manage workflows in the no-code editor.
How operators run workflows from the interface.
Control what a workflow run can access.
# Agent API Reference
Source: https://docs.forest.app/reference/agent-api/nodejs
Complete API reference for the Forest Node.js Agent
Complete API reference for `@forestadmin/agent` (Node.js/TypeScript).
## Agent Class
### createAgent(options)
Create and configure a Forest agent.
```typescript theme={null}
import { createAgent } from '@forestadmin/agent';
const agent = createAgent(options: AgentOptions): Agent;
```
**Parameters:**
| Option | Type | Required | Description |
| ------------------------------------ | -------- | -------- | ------------------------------------------------------------------------- |
| `authSecret` | string | Yes | Your FOREST\_AUTH\_SECRET |
| `envSecret` | string | Yes | Your FOREST\_ENV\_SECRET |
| `isProduction` | boolean | No | Enable production mode |
| `logger` | function | No | Custom logger function |
| `loggerLevel` | string | No | Log level: 'Debug', 'Info', 'Warn', 'Error' |
| `prefix` | string | No | API prefix (default: '/forest') |
| `schemaPath` | string | No | Path to .forestadmin-schema.json |
| `typingsPath` | string | No | Path to the generated TypeScript typings file (written when set) |
| useUnsafeActionEndpoint | boolean | No | Drop the positional index from Smart Action routes. Default `false`. |
**Example:**
```typescript theme={null}
const agent = createAgent({
authSecret: process.env.FOREST_AUTH_SECRET,
envSecret: process.env.FOREST_ENV_SECRET,
isProduction: process.env.NODE_ENV === 'production',
});
```
Only enable `useUnsafeActionEndpoint` if you specifically need index-free Smart
Action routes.
It removes the positional index
(`/_actions/{collection}/{slug}` instead of `/_actions/{collection}/{index}/{slug}`),
which can make two action names collide on the same route.
When enabled, the agent refuses to start if two actions on the same collection
resolve to the same slug.
***
### agent.addDataSource(factory, options?)
Add a datasource to the agent.
```typescript theme={null}
agent.addDataSource(
factory: DataSourceFactory,
options?: DataSourceOptions
): Agent;
```
**Parameters:**
| Option | Type | Description |
| ----------------- | ----------------------- | --------------------------- |
| `factory` | DataSourceFactory | Datasource factory function |
| `options.include` | string\[] | Collections to include |
| `options.exclude` | string\[] | Collections to exclude |
| `options.rename` | Record\ | Rename collections |
**Example:**
```typescript theme={null}
import { createSqlDataSource } from '@forestadmin/datasource-sql';
agent.addDataSource(
createSqlDataSource(process.env.DATABASE_URL),
{ exclude: ['internal_logs'] }
);
```
***
### agent.customizeCollection(name, callback)
Customize a specific collection with the provided callback.
```typescript theme={null}
agent.customizeCollection(
name: string,
callback: (collection: CollectionCustomizer) => void
): Agent;
```
**Example:**
```typescript theme={null}
agent.customizeCollection('users', collection => {
collection.addAction('Send email', {
scope: 'Single',
execute: async (context, resultBuilder) => {
// Action logic
return resultBuilder.success('Email sent!');
},
});
});
```
***
### agent.addChart(name, definition)
Create a datasource-level API chart.
```typescript theme={null}
agent.addChart(
name: string,
definition: DataSourceChartDefinition
): Agent;
```
**Example:**
```typescript theme={null}
agent.addChart('overview', (context, resultBuilder) => {
return resultBuilder.distribution({
'Active': 150,
'Inactive': 50,
});
});
```
***
### agent.removeCollection(...names)
Remove collections from the exported schema (they remain usable within the agent).
```typescript theme={null}
agent.removeCollection(...names: string[]): Agent;
```
**Example:**
```typescript theme={null}
agent.removeCollection('internalLogs', 'debugData');
```
***
### agent.use(plugin, options?)
Load a plugin across all collections.
```typescript theme={null}
agent.use(
plugin: Plugin,
options?: Options
): Agent;
```
**Example:**
```typescript theme={null}
import advancedExportPlugin from '@forestadmin/plugin-export-advanced';
agent.use(advancedExportPlugin, { format: 'xlsx' });
```
***
### agent.start()
Start the agent and connect to Forest servers.
```typescript theme={null}
await agent.start(): Promise;
```
**Example:**
```typescript theme={null}
await agent.start();
console.log('Agent started successfully');
```
***
### agent.stop()
Stop the agent and close all connections.
```typescript theme={null}
await agent.stop(): Promise;
```
***
### agent.restart()
Reconstruct routing and remount routes at runtime. Called when customizations are refreshed externally (e.g. in cloud environments).
```typescript theme={null}
await agent.restart(): Promise;
```
***
### agent.mountAiMcpServer(options?)
Enable a Model Context Protocol (MCP) server on the agent, allowing AI assistants to interact with your data through standardized tools.
```typescript theme={null}
agent.mountAiMcpServer(options?: {
enabledTools?: ToolName[];
basePath?: string;
allowedOAuthClients?: string[];
tokenTtl?: {
accessTokenSeconds?: number;
refreshTokenSeconds?: number;
};
}): Agent;
```
**Parameters:**
| Option | Type | Description |
| --------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `enabledTools` | `ToolName[]` | Restrict which MCP tools are exposed. Defaults to all tools. |
| `basePath` | `string` | Path prefix for the MCP OAuth and protocol routes (e.g. `'/ai'`); the `.well-known` discovery documents stay at the origin root (prefix-suffixed). Requires the agent at the domain root. Defaults to the host root. |
| `allowedOAuthClients` | `string[]` | Accept only OAuth clients whose registered redirect URIs are all `http(s)` URIs on the listed domains or their subdomains; every other client gets a standard `invalid_client` rejection. Must be non-empty when set (an empty array throws at startup). Defaults to accepting any registered client. See [Restrict which AI clients can connect](/product/embed/mcp-server#restrict-which-ai-clients-can-connect). Since `@forestadmin/agent` 1.92.0. |
| `tokenTtl` | `{ accessTokenSeconds?: number; refreshTokenSeconds?: number }` | Shorten the OAuth token lifetimes issued by the MCP server (defaults: 1 hour for access tokens, unbounded session for refresh). Upper bounds only — they can never extend what Forest grants; minimum `60` seconds each. See [Token lifetimes](/product/embed/mcp-server#token-lifetimes). Since `@forestadmin/agent` 1.91.0. |
**Available tool names:** `'describeCollection'`, `'list'`, `'listRelated'`, `'create'`, `'update'`, `'delete'`, `'associate'`, `'dissociate'`, `'getActionForm'`, `'executeAction'`
**Example:**
```typescript theme={null}
agent.mountAiMcpServer({
enabledTools: ['describeCollection', 'list', 'listRelated'],
});
```
By default, the MCP server registers its endpoints at the host root:
| Endpoint | Purpose |
| ----------------------------------------------- | -------------------------------------------------- |
| `POST /mcp` | Main MCP protocol endpoint (Bearer token required) |
| `GET`, `POST /oauth/authorize` | OAuth authorization |
| `POST /oauth/token` | Token exchange |
| `GET /.well-known/oauth-authorization-server` | Authorization server metadata |
| `GET /.well-known/oauth-protected-resource/mcp` | Protected resource metadata |
When mounted inside an existing application, the MCP server intercepts the **entire** `/oauth/*` and `/.well-known/*` namespaces plus `/mcp` at the host root. Any request in those namespaces is captured by the MCP server — if it doesn't serve that exact route (or your app already does), the request gets a 404/405 **instead of reaching your app**. For example, your own `/oauth/callback` or `/.well-known/apple-app-site-association` would break, not just OAuth. Set `basePath` to narrow the MCP server to a dedicated prefix (and just two suffixed `.well-known` paths) so your routes are left untouched.
**Scoping under a prefix:**
```typescript theme={null}
agent.mountAiMcpServer({ basePath: '/ai' });
```
With `basePath: '/ai'`, the OAuth and protocol routes move under the prefix. The `.well-known` discovery documents stay at the host root (as required by OAuth discovery, RFC 8414/9728) but are served at prefix-suffixed paths, so they no longer collide with your application's own root `.well-known` routes:
| Endpoint | Purpose |
| -------------------------------------------------- | ----------------------------------------------------- |
| `POST /ai/mcp` | Main MCP protocol endpoint |
| `GET`, `POST /ai/oauth/authorize` | OAuth authorization |
| `POST /ai/oauth/token` | Token exchange |
| `GET /.well-known/oauth-authorization-server/ai` | Authorization server metadata (root, prefix-suffixed) |
| `GET /.well-known/oauth-protected-resource/ai/mcp` | Protected resource metadata (root, prefix-suffixed) |
MCP clients discover these endpoints automatically from the metadata, so no client-side configuration is needed.
The prefix applies to **all** routes, including the protocol endpoint — so `basePath: '/mcp'` yields `/mcp/mcp`. Use a distinct prefix such as `/ai` to avoid the repetition.
Because the `.well-known` discovery documents must stay at the origin root, `basePath` requires the agent to be served at the **domain root**. If your agent's URL already includes a path (e.g. `https://host/api`), setting `basePath` throws at startup. And root `/.well-known/*` requests must still reach the agent — a reverse proxy that forwards only `//*` will break discovery.
When the MCP server runs standalone (`npx forest-mcp-server`), the same options are set through environment variables: `FOREST_MCP_ENABLED_TOOLS` (comma-separated tool names), `FOREST_MCP_ALLOWED_OAUTH_CLIENTS` (comma-separated domains), `FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS` and `FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS` (seconds), and `MCP_SERVER_PORT` (default: `3931`). A mounted server does not read these variables — pass the options to `mountAiMcpServer` instead.
***
### agent.updateTypesOnFileSystem(typingsPath, typingsMaxDepth)
Update the TypeScript typings file generated from your datasources.
```typescript theme={null}
await agent.updateTypesOnFileSystem(
typingsPath: string,
typingsMaxDepth: number
): Promise;
```
**Example:**
```typescript theme={null}
await agent.updateTypesOnFileSystem('./typings.ts', 5);
```
***
### agent.generateSchemaOnly()
Build the schema (`.forestadmin-schema.json`) and the TypeScript typings and write them to disk **without** starting the agent or sending the schema to Forest. Useful for generating the schema at build time in a CI/CD pipeline, see [Generate the schema at build time](/get-started/deploy#generate-the-schema-at-build-time). Available since `@forestadmin/agent` 1.83.0.
```typescript theme={null}
await agent.generateSchemaOnly(): Promise;
```
It takes no arguments: it reads `schemaPath`, `typingsPath` and `typingsMaxDepth` from the options you passed to `createAgent`. It always writes the schema to `schemaPath`, and **also writes the TypeScript typings to `typingsPath` in the same pass whenever that option is set**. Unlike `agent.start()`, it always rebuilds the schema, even when `isProduction` is `true`, and it never contacts Forest, with one exception: if you enable experimental no-code customizations, it still fetches their configuration from the Forest API, so connectivity is required in that case.
Despite the name, `Only` means it *only generates the files* without starting the agent or sending the schema to Forest, not "the schema only". If you want to regenerate **only** the typings, use `agent.updateTypesOnFileSystem()` instead.
**Example:**
```typescript theme={null}
import { createAgent } from '@forestadmin/agent';
import { createSqlDataSource } from '@forestadmin/datasource-sql';
const agent = createAgent({
authSecret: process.env.FOREST_AUTH_SECRET,
envSecret: process.env.FOREST_ENV_SECRET,
isProduction: true,
schemaPath: '.forestadmin-schema.json',
typingsPath: './typings.ts', // optional
}).addDataSource(createSqlDataSource(process.env.DATABASE_URL));
await agent.generateSchemaOnly();
```
This does not close your data source connections. In a one-shot script, close your data source (the SQL/Sequelize/Mongo client you passed in) or call `process.exit()` once it resolves, otherwise an open connection pool can keep the process alive.
***
## Collection Customizer
Methods available when customizing a collection through `agent.customizeCollection()`.
## Actions
### collection.addAction(name, definition)
Add an action to the collection.
```typescript theme={null}
collection.addAction(
name: string,
definition: ActionDefinition
): CollectionCustomizer;
```
**Definition Properties:**
| Property | Type | Description |
| ------------------- | ------------------------------ | ----------------------------- |
| `scope` | 'Single' \| 'Bulk' \| 'Global' | Action scope |
| `execute` | function | Action execution handler |
| `form` | FormElement\[] | Dynamic form configuration |
| `description` | string | Action description |
| `generateFile` | boolean | Whether action returns a file |
| `submitButtonLabel` | string | Custom button text |
**Execute Function:**
```typescript theme={null}
execute: (
context: ActionContext,
resultBuilder: ResultBuilder
) => Promise
```
**ActionContext Properties:**
* `context.collection` - Collection instance
* `context.filter` - Filter for selected records
* `context.caller` - User who triggered the action
* `context.formValues` - Form values submitted
**ActionContext Methods:**
* `context.getRecords(fields)` - Get multiple records (Bulk/Single scope)
* `context.getRecordIds()` - Get IDs of selected records
* `context.getCompositeRecordIds()` - Get composite IDs of selected records
* `context.hasFieldChanged(fieldName)` - Check if form field changed
* `context.getRecord(fields)` - Get single record (Single scope only)
* `context.getRecordId()` - Get single record ID (Single scope only)
* `context.getCompositeRecordId()` - Get composite ID (Single scope only)
* `context.getField(fieldName)` - Get single field value (Single scope only)
**ResultBuilder Methods:**
* `resultBuilder.success(message?, options?)` - Success response
* `options.html` - Custom HTML to display
* `options.invalidated` - Array of collection names to refresh
* `resultBuilder.error(message?, options?)` - Error response
* `options.html` - Custom HTML to display
* `resultBuilder.webhook(url, method, headers, body)` - Trigger webhook
* `resultBuilder.file(stream, filename, mimeType)` - Return file download
* `resultBuilder.redirectTo(path)` - Redirect to URL
* `resultBuilder.setHeader(name, value)` - Add HTTP header to response
**Example - Simple Action:**
```typescript theme={null}
collection.addAction('Mark as verified', {
scope: 'Single',
execute: async (context, resultBuilder) => {
const user = await context.getRecord(['id']);
await updateUser(user.id, { verified: true });
return resultBuilder.success('User marked as verified');
},
});
```
**Example - Action with Form:**
```typescript theme={null}
collection.addAction('Send notification', {
scope: 'Bulk',
form: [
{
label: 'Message',
type: 'String',
isRequired: true,
},
{
label: 'Channel',
type: 'Enum',
enumValues: ['email', 'sms', 'push'],
isRequired: true,
},
],
execute: async (context, resultBuilder) => {
const { message, channel } = context.formValues;
const users = await context.getRecords(['email']);
for (const user of users) {
await sendNotification(user.email, message, channel);
}
return resultBuilder.success(`Sent ${channel} to ${users.length} users`);
},
});
```
**Example - File Generation:**
```typescript theme={null}
collection.addAction('Export to PDF', {
scope: 'Bulk',
generateFile: true,
execute: async (context, resultBuilder) => {
const records = await context.getRecords(['name', 'email']);
const pdfStream = await generatePDF(records);
return resultBuilder.file(pdfStream, 'export.pdf', 'application/pdf');
},
});
```
***
## Fields
### collection.addField(name, definition)
Add a computed field to the collection.
```typescript theme={null}
collection.addField(
name: string,
definition: ComputedDefinition
): CollectionCustomizer;
```
**Definition Properties:**
| Property | Type | Required | Description |
| -------------- | ---------- | -------- | ------------------------------ |
| `columnType` | ColumnType | Yes | Field data type |
| `dependencies` | string\[] | Yes | Fields needed for computation |
| `getValues` | function | Yes | Value computation function |
| `defaultValue` | any | No | Default value |
| `enumValues` | string\[] | No | Enum options (if type is Enum) |
**Column Types:**
* `'String'` - Text
* `'Number'` - Numeric value
* `'Boolean'` - True/false
* `'Date'` - Date with time
* `'Dateonly'` - Date without time
* `'Time'` - Time only
* `'Enum'` - Enumeration
* `'Json'` - JSON object
* `'Uuid'` - UUID
* `'Point'` - Geographic point
* `'File'` - File reference
**Example - Simple Computed Field:**
```typescript theme={null}
collection.addField('fullName', {
columnType: 'String',
dependencies: ['firstName', 'lastName'],
getValues: (records) =>
records.map(r => `${r.firstName} ${r.lastName}`),
});
```
**Example - Async Computed Field:**
```typescript theme={null}
collection.addField('revenueThisYear', {
columnType: 'Number',
dependencies: ['id'],
getValues: async (records) => {
const ids = records.map(r => r.id);
const revenues = await fetchRevenues(ids);
return revenues;
},
});
```
***
### collection.importField(name, options)
Import a field from a related collection.
```typescript theme={null}
collection.importField(
name: string,
options: { path: string; readonly?: boolean }
): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
// Import author's name into books collection
collection.importField('authorName', {
path: 'author:fullName',
readonly: true,
});
```
***
### collection.renameField(currentName, newName)
Rename a field in the exported schema.
```typescript theme={null}
collection.renameField(
currentName: string,
newName: string
): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
collection.renameField('created_at', 'createdAt');
```
***
### collection.removeField(...names)
Remove fields from the exported schema (they remain usable within the agent).
```typescript theme={null}
collection.removeField(...names: string[]): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
collection.removeField('password', 'internalNotes', 'debugData');
```
***
### collection.addFieldValidation(name, operator, value?)
Add a validation rule to a field.
```typescript theme={null}
collection.addFieldValidation(
name: string,
operator: Operator,
value?: any
): CollectionCustomizer;
```
**Operators:**
`'Present'`, `'LongerThan'`, `'ShorterThan'`, `'Contains'`, `'Like'`, `'Match'`, `'GreaterThan'`, `'LessThan'`, `'Before'`, `'After'`
**Example:**
```typescript theme={null}
collection
.addFieldValidation('email', 'Present')
.addFieldValidation('email', 'Match', /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/)
.addFieldValidation('age', 'GreaterThan', 18)
.addFieldValidation('username', 'LongerThan', 3);
```
***
### collection.setFieldNullable(name)
Mark a field as optional (nullable).
```typescript theme={null}
collection.setFieldNullable(name: string): CollectionCustomizer;
```
Your database might still refuse empty values if it requires one.
**Example:**
```typescript theme={null}
collection.setFieldNullable('middleName');
```
***
### collection.replaceFieldWriting(name, definition)
Replace the write behavior of a field.
```typescript theme={null}
collection.replaceFieldWriting(
name: string,
definition: WriteDefinition
): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
// Write fullName as firstName + lastName
collection.replaceFieldWriting('fullName', fullName => {
const [firstName, lastName] = fullName.split(' ');
return { firstName, lastName };
});
```
***
### collection.replaceFieldBinaryMode(name, mode)
Choose how binary data should be transported to the GUI.
```typescript theme={null}
collection.replaceFieldBinaryMode(
name: string,
mode: 'datauri' | 'hex'
): CollectionCustomizer;
```
**Modes:**
* `'datauri'` - Best for file uploads, uses FilePicker widget
* `'hex'` - Best for short binary data like UUIDs
**Example:**
```typescript theme={null}
collection.replaceFieldBinaryMode('avatar', 'datauri');
collection.replaceFieldBinaryMode('uuid', 'hex');
```
***
## Relationships
### collection.addManyToOneRelation(name, foreignCollection, options)
Add a many-to-one relationship.
```typescript theme={null}
collection.addManyToOneRelation(
name: string,
foreignCollection: string,
options: {
foreignKey: string;
foreignKeyTarget?: string;
}
): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
// books.authorId → persons.id
books.addManyToOneRelation('author', 'persons', {
foreignKey: 'authorId',
});
```
***
### collection.addOneToManyRelation(name, foreignCollection, options)
Add a one-to-many relationship.
```typescript theme={null}
collection.addOneToManyRelation(
name: string,
foreignCollection: string,
options: {
originKey: string;
originKeyTarget?: string;
}
): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
// persons.id ← books.authorId
persons.addOneToManyRelation('writtenBooks', 'books', {
originKey: 'authorId',
});
```
***
### collection.addOneToOneRelation(name, foreignCollection, options)
Add a one-to-one relationship.
```typescript theme={null}
collection.addOneToOneRelation(
name: string,
foreignCollection: string,
options: {
originKey: string;
originKeyTarget?: string;
}
): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
// persons.id ← profiles.personId (unique)
persons.addOneToOneRelation('profile', 'profiles', {
originKey: 'personId',
});
```
***
### collection.addManyToManyRelation(name, foreignCollection, throughCollection, options)
Add a many-to-many relationship.
```typescript theme={null}
collection.addManyToManyRelation(
name: string,
foreignCollection: string,
throughCollection: string,
options: {
originKey: string;
foreignKey: string;
originKeyTarget?: string;
foreignKeyTarget?: string;
}
): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
// students ↔ student_courses ↔ courses
students.addManyToManyRelation('enrolledCourses', 'courses', 'student_courses', {
originKey: 'studentId',
foreignKey: 'courseId',
});
```
***
### collection.addExternalRelation(name, definition)
Add a virtual collection into the related data of a record.
```typescript theme={null}
collection.addExternalRelation(
name: string,
definition: ExternalRelationDefinition
): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
collection.addExternalRelation('relatedProducts', {
schema: { id: 'Number', name: 'String', price: 'Number' },
listRecords: async ({ id }) => {
return await fetchRelatedProducts(id);
},
});
```
***
## Segments
### collection.addSegment(name, definition)
Add a segment (saved filter) to the collection.
```typescript theme={null}
collection.addSegment(
name: string,
definition: SegmentDefinition
): CollectionCustomizer;
```
**Example - Static Segment:**
```typescript theme={null}
collection.addSegment('Premium users', {
field: 'plan',
operator: 'Equal',
value: 'premium',
});
```
**Example - Dynamic Segment:**
```typescript theme={null}
collection.addSegment('Active this month', async (context) => {
const startOfMonth = new Date();
startOfMonth.setDate(1);
return {
field: 'lastActiveAt',
operator: 'After',
value: startOfMonth,
};
});
```
***
## Hooks
### collection.addHook(position, type, handler)
Add a hook to execute code before or after operations.
```typescript theme={null}
collection.addHook(
position: 'Before' | 'After',
type: HookType,
handler: HookHandler
): CollectionCustomizer;
```
**Hook Types:**
`'List'`, `'Create'`, `'Update'`, `'Delete'`, `'Aggregate'`
**Example - Before Hook:**
```typescript theme={null}
collection.addHook('Before', 'Create', async (context) => {
// Validate data before creation
if (!context.data.email) {
throw new Error('Email is required');
}
});
```
**Example - After Hook:**
```typescript theme={null}
collection.addHook('After', 'Update', async (context) => {
// Send notification after update
const records = await context.collection.list(context.filter, ['email']);
for (const record of records) {
await sendUpdateNotification(record.email);
}
});
```
***
## Search
### collection.replaceSearch(definition)
Replace the default search behavior.
```typescript theme={null}
collection.replaceSearch(
definition: SearchDefinition
): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
collection.replaceSearch(async (searchString) => {
// Search in multiple fields
return {
aggregator: 'Or',
conditions: [
{ field: 'firstName', operator: 'Contains', value: searchString },
{ field: 'lastName', operator: 'Contains', value: searchString },
{ field: 'email', operator: 'Contains', value: searchString },
],
};
});
```
***
### collection.disableSearch()
Disable search functionality on the collection.
```typescript theme={null}
collection.disableSearch(): CollectionCustomizer;
```
***
## Sorting
### collection.emulateFieldSorting(name)
Enable in-memory sorting on a field.
```typescript theme={null}
collection.emulateFieldSorting(name: string): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
collection.emulateFieldSorting('fullName');
```
***
### collection.replaceFieldSorting(name, equivalentSort)
Replace sorting implementation for a field.
```typescript theme={null}
collection.replaceFieldSorting(
name: string,
equivalentSort: SortClause[]
): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
collection.replaceFieldSorting('fullName', [
{ field: 'lastName', ascending: true },
{ field: 'firstName', ascending: true },
]);
```
***
### collection.disableFieldSorting(name)
Disable sorting on a specific field.
```typescript theme={null}
collection.disableFieldSorting(name: string): CollectionCustomizer;
```
***
## Filtering
### collection.emulateFieldFiltering(name)
Enable in-memory filtering on all operators for a field.
```typescript theme={null}
collection.emulateFieldFiltering(name: string): CollectionCustomizer;
```
***
### collection.emulateFieldOperator(name, operator)
Enable in-memory filtering for a specific operator on a field.
```typescript theme={null}
collection.emulateFieldOperator(
name: string,
operator: Operator
): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
collection.emulateFieldOperator('fullName', 'Contains');
```
***
### collection.replaceFieldOperator(name, operator, replacer)
Replace the implementation of a filter operator.
```typescript theme={null}
collection.replaceFieldOperator(
name: string,
operator: Operator,
replacer: OperatorDefinition
): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
collection.replaceFieldOperator('fullName', 'Contains', (value) => {
return {
aggregator: 'Or',
conditions: [
{ field: 'firstName', operator: 'Contains', value },
{ field: 'lastName', operator: 'Contains', value },
],
};
});
```
***
## Charts
### collection.addChart(name, definition)
Add a chart to the collection.
```typescript theme={null}
collection.addChart(
name: string,
definition: ChartDefinition
): CollectionCustomizer;
```
**Example - Value Chart:**
```typescript theme={null}
collection.addChart('totalRevenue', async (context, resultBuilder) => {
const total = await calculateTotalRevenue();
return resultBuilder.value(total);
});
```
**Example - Distribution Chart:**
```typescript theme={null}
collection.addChart('usersByPlan', async (context, resultBuilder) => {
const distribution = await getUserDistributionByPlan();
return resultBuilder.distribution(distribution);
});
```
**Example - Time-based Chart:**
```typescript theme={null}
collection.addChart('signupsOverTime', async (context, resultBuilder) => {
const data = await getSignupsOverTime(context.timezone);
return resultBuilder.timeBased(data);
});
```
***
## Collection Overrides
### collection.overrideCreate(handler)
Replace the default create operation.
```typescript theme={null}
collection.overrideCreate(
handler: CreateOverrideHandler
): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
collection.overrideCreate(async (context) => {
const { data } = context;
// Custom creation logic
const record = await customCreateAPI(data);
return [record];
});
```
***
### collection.overrideUpdate(handler)
Replace the default update operation.
```typescript theme={null}
collection.overrideUpdate(
handler: UpdateOverrideHandler
): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
collection.overrideUpdate(async (context) => {
const { filter, patch } = context;
// Custom update logic
await customUpdateAPI(filter, patch);
});
```
***
### collection.overrideDelete(handler)
Replace the default delete operation.
```typescript theme={null}
collection.overrideDelete(
handler: DeleteOverrideHandler
): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
collection.overrideDelete(async (context) => {
const { filter } = context;
// Custom deletion logic (e.g., soft delete)
await customSoftDeleteAPI(filter);
});
```
***
## Other Methods
### collection.disableCount()
Disable count in list view pagination for improved performance.
```typescript theme={null}
collection.disableCount(): CollectionCustomizer;
```
***
### collection.use(plugin, options?)
Load a plugin on a specific collection.
```typescript theme={null}
collection.use(
plugin: Plugin,
options?: any
): CollectionCustomizer;
```
**Example:**
```typescript theme={null}
import { createFileField } from '@forestadmin/plugin-s3';
collection.use(createFileField, {
fieldname: 'avatar',
bucket: 'my-bucket',
});
```
***
## Chart Result Builders
When creating charts with `collection.addChart()` or `agent.addChart()`, the result builder provides methods to format chart data.
### resultBuilder.value(value, previousValue?)
Create a Value chart (single number).
```typescript theme={null}
collection.addChart('totalRevenue', async (context, resultBuilder) => {
const total = await calculateRevenue();
const previous = await calculateRevenue(lastMonth);
return resultBuilder.value(total, previous);
});
```
***
### resultBuilder.distribution(obj)
Create a Distribution/Pie chart.
```typescript theme={null}
collection.addChart('usersByPlan', async (context, resultBuilder) => {
return resultBuilder.distribution({
'Free': 1000,
'Pro': 500,
'Enterprise': 50,
});
});
```
***
### resultBuilder.timeBased(timeRange, values)
Create a Time-based/Line chart.
```typescript theme={null}
collection.addChart('signupsOverTime', async (context, resultBuilder) => {
return resultBuilder.timeBased('Day', [
{ date: new Date('2024-01-01'), value: 10 },
{ date: new Date('2024-01-02'), value: 15 },
{ date: new Date('2024-01-03'), value: null }, // Missing data
]);
});
```
**Time Ranges:** `'Day'`, `'Week'`, `'Month'`, `'Quarter'`, `'Year'`
***
### resultBuilder.multipleTimeBased(timeRange, dates, lines)
Create a Multi-line Time-based chart.
```typescript theme={null}
collection.addChart('comparison', async (context, resultBuilder) => {
const dates = [new Date('2024-01-01'), new Date('2024-01-02'), new Date('2024-01-03')];
return resultBuilder.multipleTimeBased('Day', dates, [
{ label: 'Sales', values: [100, 150, 200] },
{ label: 'Returns', values: [10, 15, null] },
]);
});
```
***
### resultBuilder.percentage(value)
Create a Percentage chart.
```typescript theme={null}
collection.addChart('completionRate', async (context, resultBuilder) => {
const rate = (completed / total) * 100;
return resultBuilder.percentage(rate);
});
```
***
### resultBuilder.objective(value, objective)
Create an Objective chart (progress toward goal).
```typescript theme={null}
collection.addChart('salesGoal', async (context, resultBuilder) => {
const current = await getCurrentSales();
const target = 100000;
return resultBuilder.objective(current, target);
});
```
***
### resultBuilder.leaderboard(obj)
Create a Leaderboard chart (sorted distribution).
```typescript theme={null}
collection.addChart('topSellers', async (context, resultBuilder) => {
return resultBuilder.leaderboard({
'John': 5000,
'Jane': 7500,
'Bob': 3000,
});
// Automatically sorted: Jane (7500), John (5000), Bob (3000)
});
```
***
### resultBuilder.smart(data)
Create a Smart chart (custom format).
```typescript theme={null}
collection.addChart('custom', async (context, resultBuilder) => {
return resultBuilder.smart({
// Custom chart data structure
type: 'custom',
data: [/* your data */],
});
});
```
***
## Form Field Types
Action forms support various field types with different widgets.
### Basic Field Types
```typescript theme={null}
collection.addAction('Example', {
scope: 'Single',
form: [
{
label: 'User Name',
type: 'String',
isRequired: true,
description: 'Enter the user name',
},
{
label: 'Age',
type: 'Number',
isRequired: false,
defaultValue: 18,
},
{
label: 'Is Active',
type: 'Boolean',
defaultValue: true,
},
{
label: 'Birth Date',
type: 'Date',
},
{
label: 'Metadata',
type: 'Json',
},
],
execute: async (context, resultBuilder) => {
const { userName, age, isActive, birthDate, metadata } = context.formValues;
// ... action logic
return resultBuilder.success();
},
});
```
**Available Types:**
* `'String'` - Text input
* `'Number'` - Numeric input
* `'Boolean'` - Checkbox
* `'Date'` - Date picker
* `'Dateonly'` - Date without time
* `'Time'` - Time picker
* `'Enum'` - Single selection
* `'EnumList'` - Multiple selection
* `'File'` - File upload
* `'FileList'` - Multiple file upload
* `'Json'` - JSON editor
* `'Collection'` - Record picker
* `'NumberList'` - Array of numbers
* `'StringList'` - Array of strings
***
### Enum Fields
```typescript theme={null}
{
label: 'Status',
type: 'Enum',
enumValues: ['pending', 'approved', 'rejected'],
isRequired: true,
}
```
***
### Collection Fields
Pick a record from another collection:
```typescript theme={null}
{
label: 'Assign to User',
type: 'Collection',
collectionName: 'users',
}
```
***
### Dropdown Widget
```typescript theme={null}
{
label: 'Country',
type: 'String',
widget: 'Dropdown',
options: [
{ label: 'United States', value: 'US' },
{ label: 'United Kingdom', value: 'UK' },
{ label: 'France', value: 'FR' },
],
placeholder: 'Select a country',
search: 'static', // or 'disabled'
}
```
***
### Dynamic Dropdown
```typescript theme={null}
{
label: 'Product',
type: 'String',
widget: 'Dropdown',
search: 'dynamic',
options: async (context, searchValue) => {
const products = await searchProducts(searchValue);
return products.map(p => ({
label: p.name,
value: p.id,
}));
},
}
```
***
### Conditional Fields
Show/hide fields based on other field values:
```typescript theme={null}
form: [
{
label: 'Notification Type',
type: 'Enum',
enumValues: ['email', 'sms', 'push'],
},
{
label: 'Email Address',
type: 'String',
if: (context) => context.formValues.notificationType === 'email',
},
{
label: 'Phone Number',
type: 'String',
if: (context) => context.formValues.notificationType === 'sms',
},
]
```
***
### Dynamic Field Values
```typescript theme={null}
{
label: 'Department',
type: 'Enum',
enumValues: async (context) => {
// Fetch departments based on selected company
const companyId = context.formValues.companyId;
return await getDepartments(companyId);
},
}
```
***
### Read-Only Fields
```typescript theme={null}
{
label: 'Created At',
type: 'Date',
isReadOnly: true,
value: async (context) => {
const record = await context.getRecord(['createdAt']);
return record.createdAt;
},
}
```
***
## ConditionTree Utilities
Build complex filter conditions programmatically.
### ConditionTreeLeaf
Simple condition on a single field:
```typescript theme={null}
import { ConditionTreeLeaf } from '@forestadmin/agent';
const condition = new ConditionTreeLeaf('status', 'Equal', 'active');
// Equivalent to: { field: 'status', operator: 'Equal', value: 'active' }
```
***
### ConditionTreeBranch
Combine multiple conditions with AND/OR:
```typescript theme={null}
import { ConditionTreeBranch, ConditionTreeLeaf } from '@forestadmin/agent';
const condition = new ConditionTreeBranch('And', [
new ConditionTreeLeaf('status', 'Equal', 'active'),
new ConditionTreeLeaf('age', 'GreaterThan', 18),
]);
```
***
### ConditionTree Factory
```typescript theme={null}
import { ConditionTreeFactory } from '@forestadmin/agent';
// From plain object
const condition = ConditionTreeFactory.fromPlainObject({
field: 'email',
operator: 'Contains',
value: '@example.com',
});
// Combine conditions
const combined = ConditionTreeFactory.intersect([
condition1,
condition2,
]);
// Union (OR)
const union = ConditionTreeFactory.union([
condition1,
condition2,
]);
```
***
### Available Operators
**Comparison:**
* `'Equal'`, `'NotEqual'`
* `'GreaterThan'`, `'LessThan'`
* `'In'`, `'NotIn'`
* `'Present'`, `'Blank'`
**String:**
* `'Contains'`, `'NotContains'`
* `'StartsWith'`, `'EndsWith'`
* `'Like'`, `'ILike'` (case-insensitive)
**Date:**
* `'Before'`, `'After'`
* `'Today'`, `'Yesterday'`, `'PreviousWeek'`, `'PreviousMonth'`, `'PreviousQuarter'`, `'PreviousYear'`
* `'Past'`, `'Future'`
**Array:**
* `'IncludesAll'`, `'IncludesNone'`
***
## Related Types
### Caller
User information available in all contexts:
```typescript theme={null}
interface Caller {
id: number;
email: string;
firstName: string;
lastName: string;
team: string;
role: string;
tags: Record;
timezone: string;
}
```
***
### Filter
```typescript theme={null}
import { Filter, ConditionTreeLeaf } from '@forestadmin/agent';
const filter = new Filter({
conditionTree: new ConditionTreeLeaf('status', 'Equal', 'active'),
search: 'john',
searchExtended: false,
segment: 'premium-users',
});
```
***
### Projection
Specify which fields to retrieve:
```typescript theme={null}
import { Projection } from '@forestadmin/agent';
const projection = new Projection('id', 'name', 'email', 'company:name');
```
***
## Plugin: Flattener
`@forestadmin/plugin-flattener` flattens nested data structures (composite columns, relations, JSON columns) into individual top-level fields.
### flattenColumn(dataSource, collection, options)
Decompose a column with a composite type into individual fields.
```typescript theme={null}
import { flattenColumn } from '@forestadmin/plugin-flattener';
agent.customizeCollection('orders', async (collection) => {
await collection.use(flattenColumn, {
columnName: 'address',
include: ['street', 'city', 'zipCode'],
readonly: false,
});
});
```
**Options:**
| Option | Type | Required | Description |
| ------------ | --------- | -------- | ------------------------------------- |
| `columnName` | string | Yes | Column to flatten |
| `include` | string\[] | No | Fields to import (defaults to all) |
| `exclude` | string\[] | No | Fields to skip |
| `level` | number | No | Maximum nesting depth |
| `readonly` | boolean | No | Whether imported fields are read-only |
| `columnType` | object | No | Custom type mapping for nested fields |
***
### flattenRelation(dataSource, collection, options)
Import fields from a relation directly into the collection.
```typescript theme={null}
import { flattenRelation } from '@forestadmin/plugin-flattener';
agent.customizeCollection('books', async (collection) => {
await collection.use(flattenRelation, {
relationName: 'author',
include: ['firstName', 'lastName', 'email'],
readonly: true,
});
});
```
**Options:**
| Option | Type | Required | Description |
| -------------- | --------- | -------- | ------------------------------------- |
| `relationName` | string | Yes | Relation to flatten |
| `include` | string\[] | No | Fields to import |
| `exclude` | string\[] | No | Fields to skip |
| `readonly` | boolean | No | Whether imported fields are read-only |
***
### flattenJsonColumn(dataSource, collection, options)
Expand a JSON column into individual typed fields.
```typescript theme={null}
import { flattenJsonColumn } from '@forestadmin/plugin-flattener';
agent.customizeCollection('products', (collection) => {
collection.use(flattenJsonColumn, {
columnName: 'metadata',
columnType: {
weight: 'Number',
dimensions: { width: 'Number', height: 'Number' },
tags: ['String'],
},
readonly: false,
keepOriginalColumn: false,
});
});
```
**Options:**
| Option | Type | Required | Description |
| -------------------- | ------- | -------- | -------------------------------------------- |
| `columnName` | string | Yes | JSON column to flatten |
| `columnType` | object | Yes | Type definition for nested fields |
| `level` | number | No | Maximum nesting depth |
| `readonly` | boolean | No | Whether flattened fields are read-only |
| `keepOriginalColumn` | boolean | No | Whether to preserve the original JSON column |
***
## Package: Forest Cloud
`@forestadmin/forest-cloud` is a dev-only package that provides CLI tooling for cloud-hosted customization projects.
### Installation
```bash theme={null}
npm install @forestadmin/forest-cloud --save-dev
```
### CLI Commands
#### bootstrap
Initialize a cloud customization project. Authenticates with Forest, creates a `cloud-customizer` directory, configures credentials, and generates type definitions.
```bash theme={null}
npx forest-cloud bootstrap --env-secret YOUR_FOREST_ENV_SECRET
```
#### update-typings
Regenerate TypeScript type definitions based on your current database structure and customization code.
```bash theme={null}
npx forest-cloud update-typings
```
#### login
Refresh your Forest authentication token.
```bash theme={null}
npx forest-cloud login
```
### Exported Types
```typescript theme={null}
import type { Agent, SqlConnectionParams, MongoConnectionParams } from '@forestadmin/forest-cloud';
```
| Type | Description |
| ----------------------- | -------------------------------------------------------- |
| `Agent` | Re-export of the `Agent` class from `@forestadmin/agent` |
| `SqlConnectionParams` | Connection options for SQL datasources |
| `MongoConnectionParams` | Connection parameters for MongoDB datasources |
***
## Package: Agent Testing
`@forestadmin/agent-testing` provides utilities to test agent customizations locally without connecting to Forest servers.
### createForestServerSandbox(port)
Start a local sandbox that mimics Forest servers.
```typescript theme={null}
import { createForestServerSandbox } from '@forestadmin/agent-testing';
const sandbox = await createForestServerSandbox(3001);
// ... run your tests
await sandbox.close();
```
***
### createAgentTestClient(options)
Connect a test client to a running agent to simulate frontend requests.
```typescript theme={null}
import { createAgentTestClient } from '@forestadmin/agent-testing';
const client = await createAgentTestClient({
agentForestEnvSecret: process.env.FOREST_ENV_SECRET,
agentForestAuthSecret: process.env.FOREST_AUTH_SECRET,
agentUrl: 'http://localhost:3000',
serverUrl: 'http://localhost:3001',
agentSchemaPath: '.forestadmin-schema.json',
});
```
**Parameters:**
| Option | Type | Description |
| ----------------------- | ------ | ---------------------------------- |
| `agentForestEnvSecret` | string | Environment secret |
| `agentForestAuthSecret` | string | Auth secret |
| `agentUrl` | string | URL of your running agent |
| `serverUrl` | string | URL of the sandbox server |
| `agentSchemaPath` | string | Path to `.forestadmin-schema.json` |
### Typical test workflow
```typescript theme={null}
import { createForestServerSandbox, createAgentTestClient } from '@forestadmin/agent-testing';
// 1. Start sandbox
const sandbox = await createForestServerSandbox(3001);
// 2. Start your agent (pointing at sandbox)
// FOREST_SERVER_URL=http://localhost:3001 node index.js
// 3. Connect test client
const client = await createAgentTestClient({
agentForestEnvSecret: 'test-env-secret',
agentForestAuthSecret: 'test-auth-secret',
agentUrl: 'http://localhost:3000',
serverUrl: 'http://localhost:3001',
agentSchemaPath: '.forestadmin-schema.json',
});
// 4. Assert
const users = await client.collection('users').list();
expect(users).toHaveLength(3);
// 5. Cleanup
await sandbox.close();
```
# Agent SDK overview
Source: https://docs.forest.app/reference/agent-api/overview
How Forest agents fit into your stack across supported languages.
A Forest agent is the lightweight backend that connects Forest to your data. It runs in your infrastructure, executes your business logic, and exposes your data and workflows to two consumers:
* **The Forest UI**, what your operations team uses daily.
* **AI agents** (Claude, Dust, Decagon, internal builds), via the built-in MCP server, with the same permissions and audit trail.
The agent is the substrate that makes Forest the operational layer for regulated work, not a thin wrapper around your database.
## Supported languages
`@forestadmin/agent`, TypeScript-first, multi-datasource, plugin ecosystem. The most actively developed agent.
**Features**: full TypeScript support with type inference, multi-datasource, plugin ecosystem, advanced customization API, Cloud and self-hosted deployment.
`forest_admin_agent`, ActiveRecord and Mongoid support, Rails integration.
**Features**: ActiveRecord and Mongoid datasources, Rails integration, custom actions and fields, approval workflows.
## Architecture
All Forest agents follow the same architecture:
```
Your databases & APIs
↓
Forest Agent (your backend)
↓
Forest API
↓
┌────┴────┐
Forest UI MCP server
↓
AI agents
```
The agent is the only component with direct access to your data. Everything else, the UI, the API gateway, AI agents, talks to the agent over HTTPS.
## Core concepts
| Concept | What it is |
| -------------- | --------------------------------------------------------------------------------------------------------- |
| Datasources | Connections to your databases, APIs, or custom sources. Multiple per agent. |
| Collections | Each table or model becomes a collection, what operators browse, search, and act on. |
| Customizations | Actions, computed fields, segments, relationships, hooks, plugins. Defined in code. |
| Hooks | Intercept and modify CRUD operations to enforce business rules. |
| MCP server | Exposes your collections, actions, and workflows to AI agents under the same permissions and audit trail. |
| Authentication | Forest handles SSO, SAML, 2FA, SCIM provisioning, and role-based permissions. |
## Choosing an agent
| Agent | Best for | Deployment |
| ------------------------------ | --------------------------------------------------------- | -------------------- |
| Node.js (`@forestadmin/agent`) | Modern apps, TypeScript projects, multi-datasource setups | Cloud or self-hosted |
| Ruby (`forest_admin_agent`) | Rails applications | Self-hosted |
If you're starting fresh, the Node.js agent gets the most feature work. The Ruby agent is at parity for the core feature set and is the right choice for Rails-native teams.
## Migrating from a v1 agent?
If you're using a legacy agent (`forest-express-sequelize`, `forest-express-mongoose`, `forest-rails`, or `django-forestadmin` v1), the migration path is documented:
Step-by-step migration from v1 to the current generation.
Reference for v1 agents, maintained for migration purposes only.
## Getting started
Node.js or Ruby, pick the one that matches your stack.
Add `@forestadmin/agent` (npm) or `forest_admin_agent` (gem) to your project.
Connect your databases, APIs, or custom sources.
Build actions, computed fields, and segments in code.
Run the agent in your infrastructure (self-hosted) or have Forest host it (Cloud).
## Next steps
Complete API reference for `@forestadmin/agent`.
Complete API reference for `forest_admin_agent`.
Get an agent running in 10 minutes.
## Need help?
Full docs portal.
Ask the community.
Reach the Forest team.
# Ruby Agent API Reference
Source: https://docs.forest.app/reference/agent-api/ruby
Complete API reference for the Forest Ruby Agent
Complete API reference for Forest Ruby agent packages.
## Agent Setup
### Creating an Agent
The Ruby agent is designed for Rails applications and automatically introspects your data models.
```ruby theme={null}
# Gemfile
gem 'forest_admin_agent'
gem 'forest_admin_rails'
gem 'forest_admin_datasource_toolkit'
gem 'forest_admin_datasource_customizer'
gem 'forest_admin_datasource_active_record' # For ActiveRecord
# or
gem 'forest_admin_datasource_mongoid' # For Mongoid
```
**Installation:**
```bash theme={null}
bundle install
rails generate forest_admin_rails:install
```
The generator creates two files:
* `config/initializers/forest_admin_rails.rb`, secrets and configuration
* `app/lib/forest_admin_rails/create_agent.rb`, datasource setup and collection customizations
**Configuration** (`config/initializers/forest_admin_rails.rb`):
```ruby theme={null}
ForestAdminRails.configure do |config|
config.auth_secret = ENV.fetch('FOREST_AUTH_SECRET')
config.env_secret = ENV.fetch('FOREST_ENV_SECRET')
end
```
**Datasource setup and customizations** (`app/lib/forest_admin_rails/create_agent.rb`):
```ruby theme={null}
module ForestAdminRails
class CreateAgent
def self.setup!
database_configuration = Rails.configuration.database_configuration
datasource = ForestAdminDatasourceActiveRecord::Datasource.new(database_configuration[Rails.env])
@create_agent = ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource)
customize
@create_agent.build
end
def self.customize
# All your collection customizations go here, see below.
end
end
end
```
**Configuration Options** (passed to `ForestAdminRails.configure`):
| Option | Type | Required | Description |
| ------------------- | ------ | -------- | --------------------------------------- |
| `auth_secret` | String | Yes | Your FOREST\_AUTH\_SECRET |
| `env_secret` | String | Yes | Your FOREST\_ENV\_SECRET |
| `forest_server_url` | String | No | Forest server URL (default: production) |
***
## Customizing Collections
### agent.customize\_collection(name, \&block)
Customize a specific collection with the provided block.
```ruby theme={null}
# Inside ForestAdminRails::CreateAgent.customize
@create_agent.customize_collection('User') do |collection|
collection.add_action('Send email', {
scope: 'Single',
execute: ->(context, result_builder) {
# Action logic
result_builder.success('Email sent!')
}
})
end
```
**Parameters:**
| Parameter | Type | Description |
| --------- | ------ | ------------------- |
| `name` | String | Collection name |
| `block` | Block | Customization block |
***
## Datasources
### agent.add\_datasource(datasource, options = )
Add a datasource to the agent. Called inside `ForestAdminRails::CreateAgent.setup!`.
```ruby theme={null}
# Inside ForestAdminRails::CreateAgent.setup!
@create_agent = ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(
ForestAdminDatasourceActiveRecord::Datasource.new(database_configuration[Rails.env]),
exclude: ['internal_logs']
)
```
**Options:**
| Option | Type | Description |
| --------- | -------------- | ---------------------- |
| `include` | Array\ | Collections to include |
| `exclude` | Array\ | Collections to exclude |
| `rename` | Hash | Rename collections |
**Example with Mongoid:**
```ruby theme={null}
# Inside ForestAdminRails::CreateAgent.setup!
@create_agent = ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(
ForestAdminDatasourceMongoid::Datasource.new,
rename: { 'old_name' => 'new_name' }
)
```
***
## Actions
### collection.add\_action(name, definition)
Add an action to the collection.
```ruby theme={null}
collection.add_action('Send email', {
scope: 'Single',
execute: ->(context, result_builder) {
user = context.get_record(['id', 'email'])
UserMailer.notification(user['email']).deliver_later
result_builder.success('Email sent!')
}
})
```
**Definition Properties:**
| Property | Type | Description |
| --------------------- | ------- | ---------------------------------------------- |
| `scope` | Symbol | Action scope: `:Single`, `:Bulk`, or `:Global` |
| `execute` | Proc | Action execution handler |
| `form` | Array | Dynamic form configuration |
| `description` | String | Action description |
| `generate_file` | Boolean | Whether action returns a file |
| `submit_button_label` | String | Custom button text |
**Execute Block:**
```ruby theme={null}
execute: ->(context, result_builder) {
# Action logic
}
```
**ActionContext Methods:**
* `context.collection` - Collection instance
* `context.filter` - Filter for selected records
* `context.caller` - User who triggered the action
* `context.form_values` - Form values submitted
* `context.get_records(fields)` - Get multiple records
* `context.get_record(fields)` - Get single record (Single scope)
* `context.get_record_ids` - Get IDs of selected records
* `context.has_field_changed(field_name)` - Check if form field changed
**ResultBuilder Methods:**
* `result_builder.success(message, options = {})` - Success response
* `options[:html]` - Custom HTML to display
* `options[:invalidated]` - Array of collection names to refresh
* `result_builder.error(message, options = {})` - Error response
* `result_builder.webhook(url, method, headers, body)` - Trigger webhook
* `result_builder.file(stream, filename, mime_type)` - Return file download
* `result_builder.redirect_to(path)` - Redirect to URL
* `result_builder.set_header(name, value)` - Add HTTP header
**Example - Action with Form:**
```ruby theme={null}
collection.add_action('Send notification', {
scope: 'Bulk',
form: [
{
label: 'Message',
type: 'String',
is_required: true
},
{
label: 'Channel',
type: 'Enum',
enum_values: ['email', 'sms', 'push'],
is_required: true
}
],
execute: ->(context, result_builder) {
message = context.form_values['message']
channel = context.form_values['channel']
users = context.get_records(['email'])
users.each do |user|
NotificationService.send(user['email'], message, channel)
end
result_builder.success("Sent #{channel} to #{users.length} users")
}
})
```
**Example - File Generation:**
```ruby theme={null}
collection.add_action('Export to CSV', {
scope: 'Bulk',
generate_file: true,
execute: ->(context, result_builder) {
records = context.get_records(['name', 'email'])
csv_stream = CsvGenerator.generate(records)
result_builder.file(csv_stream, 'export.csv', 'text/csv')
}
})
```
***
## Fields
### collection.add\_field(name, definition)
Add a computed field to the collection.
```ruby theme={null}
collection.add_field('full_name', {
column_type: 'String',
dependencies: ['first_name', 'last_name'],
get_values: ->(records) {
records.map { |r| "#{r['first_name']} #{r['last_name']}" }
}
})
```
**Definition Properties:**
| Property | Type | Required | Description |
| --------------- | ------ | -------- | ------------------------------ |
| `column_type` | String | Yes | Field data type |
| `dependencies` | Array | Yes | Fields needed for computation |
| `get_values` | Proc | Yes | Value computation function |
| `default_value` | Any | No | Default value |
| `enum_values` | Array | No | Enum options (if type is Enum) |
**Column Types:**
* `'String'` - Text
* `'Number'` - Numeric value
* `'Boolean'` - True/false
* `'Date'` - Date with time
* `'Dateonly'` - Date without time
* `'Enum'` - Enumeration
* `'Json'` - JSON object
* `'Uuid'` - UUID
**Example - Async Computed Field:**
```ruby theme={null}
collection.add_field('revenue_this_year', {
column_type: 'Number',
dependencies: ['id'],
get_values: ->(records) {
ids = records.map { |r| r['id'] }
RevenueCalculator.fetch_for_ids(ids)
}
})
```
***
### collection.import\_field(name, options)
Import a field from a related collection.
```ruby theme={null}
# Import author's name into books collection
collection.import_field('author_name', {
path: 'author:full_name',
readonly: true
})
```
**Options:**
| Option | Type | Description |
| ---------- | ------- | --------------------------------------------- |
| `path` | String | Relationship path (e.g., 'author:full\_name') |
| `readonly` | Boolean | Whether field is read-only |
***
### collection.rename\_field(current\_name, new\_name)
Rename a field in the exported schema.
```ruby theme={null}
collection.rename_field('created_at', 'createdAt')
```
***
### collection.remove\_field(\*names)
Remove fields from the exported schema.
```ruby theme={null}
collection.remove_field('password', 'internal_notes', 'debug_data')
```
***
### collection.replace\_field\_writing(name, definition)
Replace the write behavior of a field.
```ruby theme={null}
# Write full_name as first_name + last_name
collection.replace_field_writing('full_name', ->(full_name) {
parts = full_name.split(' ', 2)
{ 'first_name' => parts[0], 'last_name' => parts[1] }
})
```
***
## Segments
### collection.add\_segment(name, definition)
Add a segment (saved filter) to the collection.
```ruby theme={null}
collection.add_segment('Premium users', {
field: 'plan',
operator: 'Equal',
value: 'premium'
})
```
**Example - Static Segment:**
```ruby theme={null}
collection.add_segment('Active users', {
field: 'status',
operator: 'Equal',
value: 'active'
})
```
**Example - Dynamic Segment:**
```ruby theme={null}
collection.add_segment('Active this month', ->(context) {
start_of_month = Date.today.beginning_of_month
{
field: 'last_active_at',
operator: 'After',
value: start_of_month
}
})
```
**Example - Complex Segment:**
```ruby theme={null}
collection.add_segment('VIP customers', ->(context) {
{
aggregator: 'And',
conditions: [
{ field: 'status', operator: 'Equal', value: 'active' },
{ field: 'lifetime_value', operator: 'GreaterThan', value: 10000 }
]
}
})
```
***
## Relationships
### collection.add\_many\_to\_one\_relation(name, foreign\_collection, options)
Add a many-to-one relationship.
```ruby theme={null}
# books.author_id → persons.id
collection.add_many_to_one_relation('author', 'Person', {
foreign_key: 'author_id'
})
```
**Options:**
| Option | Type | Description |
| -------------------- | ------ | ---------------------------- |
| `foreign_key` | String | Foreign key field name |
| `foreign_key_target` | String | Target field (default: 'id') |
***
### collection.add\_one\_to\_many\_relation(name, foreign\_collection, options)
Add a one-to-many relationship.
```ruby theme={null}
# persons.id ← books.author_id
collection.add_one_to_many_relation('written_books', 'Book', {
origin_key: 'author_id'
})
```
**Options:**
| Option | Type | Description |
| ------------------- | ------ | --------------------------------- |
| `origin_key` | String | Foreign key in related collection |
| `origin_key_target` | String | Target field (default: 'id') |
***
### collection.add\_one\_to\_one\_relation(name, foreign\_collection, options)
Add a one-to-one relationship.
```ruby theme={null}
# persons.id ← profiles.person_id (unique)
collection.add_one_to_one_relation('profile', 'Profile', {
origin_key: 'person_id'
})
```
***
### collection.add\_many\_to\_many\_relation(name, foreign\_collection, through\_collection, options)
Add a many-to-many relationship.
```ruby theme={null}
# students ↔ student_courses ↔ courses
collection.add_many_to_many_relation('enrolled_courses', 'Course', 'StudentCourse', {
origin_key: 'student_id',
foreign_key: 'course_id'
})
```
**Options:**
| Option | Type | Description |
| -------------------- | ------ | -------------------------------- |
| `origin_key` | String | Foreign key to origin collection |
| `foreign_key` | String | Foreign key to target collection |
| `origin_key_target` | String | Origin target field |
| `foreign_key_target` | String | Foreign target field |
***
## Hooks
### collection.add\_hook(position, type, handler)
Add a hook to execute code before or after operations.
```ruby theme={null}
collection.add_hook('Before', 'Create', ->(context) {
# Validate data before creation
if context.data['email'].nil?
raise 'Email is required'
end
})
```
**Hook Types:**
* `'List'` - Before/after listing records
* `'Create'` - Before/after creating records
* `'Update'` - Before/after updating records
* `'Delete'` - Before/after deleting records
* `'Aggregate'` - Before/after aggregating data
**Example - Before Hook:**
```ruby theme={null}
collection.add_hook('Before', 'Create', ->(context) {
# Set default values
context.data['status'] ||= 'active'
context.data['created_by'] = context.caller.id
})
```
**Example - After Hook:**
```ruby theme={null}
collection.add_hook('After', 'Update', ->(context) {
# Send notification after update
records = context.collection.list(context.filter, ['email'])
records.each do |record|
NotificationService.send_update_email(record['email'])
end
})
```
***
## Charts
### collection.add\_chart(name, definition)
Add a chart to the collection.
```ruby theme={null}
collection.add_chart('total_revenue', ->(context, result_builder) {
total = Order.sum(:total)
result_builder.value(total)
})
```
**Chart Types:**
**Value Chart:**
```ruby theme={null}
collection.add_chart('user_count', ->(context, result_builder) {
count = User.count
result_builder.value(count)
})
```
**Distribution Chart:**
```ruby theme={null}
collection.add_chart('users_by_plan', ->(context, result_builder) {
distribution = User.group(:plan).count
result_builder.distribution(distribution)
})
```
**Time-based Chart:**
```ruby theme={null}
collection.add_chart('signups_over_time', ->(context, result_builder) {
data = User.group_by_day(:created_at).count
result_builder.time_based('Day', data)
})
```
**Percentage Chart:**
```ruby theme={null}
collection.add_chart('completion_rate', ->(context, result_builder) {
completed = Task.where(status: 'completed').count
total = Task.count
rate = (completed.to_f / total * 100).round(2)
result_builder.percentage(rate)
})
```
**Objective Chart:**
```ruby theme={null}
collection.add_chart('sales_goal', ->(context, result_builder) {
current = Order.sum(:total)
target = 100_000
result_builder.objective(current, target)
})
```
**Leaderboard Chart:**
```ruby theme={null}
collection.add_chart('top_sellers', ->(context, result_builder) {
top = User.joins(:orders)
.group('users.name')
.sum('orders.total')
result_builder.leaderboard(top)
})
```
***
## Search & Sorting
### collection.replace\_search(definition)
Replace the default search behavior.
```ruby theme={null}
collection.replace_search(->(search_string) {
{
aggregator: 'Or',
conditions: [
{ field: 'first_name', operator: 'Contains', value: search_string },
{ field: 'last_name', operator: 'Contains', value: search_string },
{ field: 'email', operator: 'Contains', value: search_string }
]
}
})
```
***
### collection.disable\_search
Disable search functionality on the collection.
```ruby theme={null}
collection.disable_search
```
***
### collection.replace\_field\_sorting(name, equivalent\_sort)
Replace sorting implementation for a field.
```ruby theme={null}
collection.replace_field_sorting('full_name', [
{ field: 'last_name', ascending: true },
{ field: 'first_name', ascending: true }
])
```
***
## Form Field Types
Action forms support various field types.
```ruby theme={null}
collection.add_action('Example', {
scope: 'Single',
form: [
{
label: 'User Name',
type: 'String',
is_required: true,
description: 'Enter the user name'
},
{
label: 'Age',
type: 'Number',
default_value: 18
},
{
label: 'Is Active',
type: 'Boolean',
default_value: true
},
{
label: 'Birth Date',
type: 'Date'
},
{
label: 'Status',
type: 'Enum',
enum_values: ['pending', 'approved', 'rejected']
}
],
execute: ->(context, result_builder) {
values = context.form_values
# ... action logic
result_builder.success
}
})
```
**Available Types:**
* `'String'` - Text input
* `'Number'` - Numeric input
* `'Boolean'` - Checkbox
* `'Date'` - Date picker
* `'Dateonly'` - Date without time
* `'Enum'` - Single selection
* `'EnumList'` - Multiple selection
* `'File'` - File upload
* `'Json'` - JSON editor
* `'Collection'` - Record picker
**Collection Field:**
```ruby theme={null}
{
label: 'Assign to User',
type: 'Collection',
collection_name: 'User'
}
```
**Conditional Fields:**
```ruby theme={null}
form: [
{
label: 'Notification Type',
type: 'Enum',
enum_values: ['email', 'sms', 'push']
},
{
label: 'Email Address',
type: 'String',
if: ->(context) { context.form_values['notification_type'] == 'email' }
}
]
```
***
## Complete Example
```ruby theme={null}
# config/initializers/forest_admin_rails.rb
ForestAdminRails.configure do |config|
config.auth_secret = ENV.fetch('FOREST_AUTH_SECRET')
config.env_secret = ENV.fetch('FOREST_ENV_SECRET')
end
```
```ruby theme={null}
# app/lib/forest_admin_rails/create_agent.rb
module ForestAdminRails
class CreateAgent
def self.setup!
database_configuration = Rails.configuration.database_configuration
datasource = ForestAdminDatasourceActiveRecord::Datasource.new(database_configuration[Rails.env])
@create_agent = ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource)
customize
@create_agent.build
end
def self.customize
# Customize User collection
@create_agent.customize_collection('User') do |collection|
# Add computed field
collection.add_field('full_name', {
column_type: 'String',
dependencies: ['first_name', 'last_name'],
get_values: ->(records) {
records.map { |r| "#{r['first_name']} #{r['last_name']}" }
}
})
# Add segment
collection.add_segment('Active users', {
field: 'status',
operator: 'Equal',
value: 'active'
})
# Add action
collection.add_action('Send promotional email', {
scope: 'Bulk',
form: [
{
label: 'Campaign',
type: 'Enum',
enum_values: ['summer', 'winter', 'black_friday']
},
{
label: 'Discount',
type: 'Number',
is_required: true
}
],
execute: ->(context, result_builder) {
campaign = context.form_values['campaign']
discount = context.form_values['discount']
users = context.get_records(['email'])
users.each do |user|
PromotionalMailer.campaign_email(
user['email'],
campaign,
discount
).deliver_later
end
result_builder.success("Email sent to #{users.length} users")
}
})
# Add hook
collection.add_hook('Before', 'Create', ->(context) {
context.data['status'] ||= 'active'
context.data['created_by'] = context.caller.id
})
# Add chart
collection.add_chart('user_count', ->(context, result_builder) {
count = User.count
result_builder.value(count)
})
end
end
end
end
```
***
## Available Operators
**Comparison:**
* `'Equal'`, `'NotEqual'`
* `'GreaterThan'`, `'LessThan'`
* `'In'`, `'NotIn'`
* `'Present'`, `'Blank'`
**String:**
* `'Contains'`, `'NotContains'`
* `'StartsWith'`, `'EndsWith'`
* `'Like'`, `'ILike'` (case-insensitive)
**Date:**
* `'Before'`, `'After'`
* `'Today'`, `'Yesterday'`
* `'PreviousWeek'`, `'PreviousMonth'`, `'PreviousQuarter'`, `'PreviousYear'`
* `'Past'`, `'Future'`
***
## Context Objects
### Caller
User information available in all contexts:
```ruby theme={null}
context.caller.id # User ID
context.caller.email # User email
context.caller.first_name # User first name
context.caller.last_name # User last name
context.caller.team # User team
context.caller.role # User role
context.caller.timezone # User timezone
```
# API Authentication
Source: https://docs.forest.app/reference/api/authentication
Learn how to authenticate requests to the Forest Public API
# API Authentication
All requests to the Forest Public API require authentication using API tokens. This guide explains how to create, manage, and use API tokens securely.
## Authentication Overview
Forest uses **Bearer token authentication** for API requests. Each token:
* Is associated with a specific Forest project
* Can have different permission scopes
* Should be treated as sensitive credentials
* Revocable at any time
## Creating API Tokens
### Step 1: Access Account Settings
1. Log in to your Forest account
2. Click on your User Profile avatar in the bottom left hand corner
3. Click on **Account Settings**
### Step 2: Generate Token
1. From the list of existing tokens, click on **Generate New Token**
2. Give it a name, and click on **Generate Token**
### Step 3: Save Token
**Important:** Copy and save your token immediately. For security reasons, you won't be able to see it again. If you lose the token, you'll need to generate a new one.
## Using API Tokens
Include your API token in the `Authorization` header of all requests using the Bearer scheme:
```bash theme={null}
Authorization: Bearer YOUR_API_TOKEN
```
### Example Requests
**cURL:**
```bash theme={null}
curl -H "Authorization: Bearer fa_your_api_token_here" \
https://public-api.forestadmin.com/v1/project/{projectName}/environment/{environmentName}/activity-logs
```
**JavaScript (Node.js):**
```javascript theme={null}
const axios = require('axios');
const apiToken = process.env.FOREST_API_TOKEN;
const response = await axios.get(
'https://public-api.forestadmin.com/v1/project/{projectName}/environment/{environmentName}/activity-logs',
{
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json'
}
}
);
```
**Python:**
```python theme={null}
import requests
import os
api_token = os.getenv('FOREST_API_TOKEN')
headers = {
'Authorization': f'Bearer {api_token}',
'Content-Type': 'application/json'
}
response = requests.get(
'https://public-api.forestadmin.com/v1/project/{projectName}/environment/{environmentName}/activity-logs',
headers=headers
)
```
**Ruby:**
```ruby theme={null}
require 'net/http'
require 'json'
api_token = ENV['FOREST_API_TOKEN']
uri = URI('https://public-api.forestadmin.com/v1/project/{projectName}/environment/{environmentName}/activity-logs')
request = Net::HTTP::Get.new(uri)
request['Authorization'] = "Bearer #{api_token}"
request['Content-Type'] = 'application/json'
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(request)
end
```
## Token Security
### Storing Tokens Securely
**Best Practice:** Store API tokens in environment variables or secure secrets management systems, never in code or version control.
**Environment Variables:**
```bash theme={null}
# .env file (add to .gitignore!)
FOREST_API_TOKEN=fa_your_api_token_here
```
**Secrets Managers:**
* AWS Secrets Manager
* Google Cloud Secret Manager
* Azure Key Vault
* HashiCorp Vault
* 1Password Secrets Automation
### What NOT to Do
❌ **Never commit tokens to version control:**
```javascript theme={null}
// BAD - Don't do this!
const apiToken = 'fa_live_12345678...';
```
❌ **Never expose tokens in client-side code:**
```html theme={null}
```
❌ **Never log tokens:**
```javascript theme={null}
// BAD - Don't do this!
console.log('Using token:', apiToken);
```
### Secure Practices
✅ **Use environment variables:**
```javascript theme={null}
const apiToken = process.env.FOREST_API_TOKEN;
```
✅ **Use secrets management:**
```javascript theme={null}
const apiToken = await secretsManager.getSecret('forest-api-token');
```
✅ **Rotate tokens regularly:**
```text theme={null}
Schedule: Every 90 days
Process: Generate new → Update integrations → Revoke old
```
## Token Permissions
### Permission Levels
**Read-Only:**
* View activity logs
* View admin logs
* Read notes
* Cannot create or modify data
**Read-Write:**
* All read permissions
* Create and update notes
* Perform write operations (where available)
### Scope Limitations
Limit token access to only the endpoints needed:
| Scope | Access |
| -------------------- | ----------------------- |
| `activity_logs:read` | Read activity logs |
| `admin_logs:read` | Read admin logs |
| `notes:read` | Read notes |
| `notes:write` | Create and update notes |
| `all` | Full API access |
**Example: Audit-only token**
```text theme={null}
Scopes: activity_logs:read, admin_logs:read
Permissions: Read-only
Use case: Compliance reporting
```
## Token Management
### Listing Active Tokens
View all active tokens in **Project Settings** > **API Access**:
* Token name
* Creation date
* Last used
* Expiration date
* Scopes
### Rotating Tokens
Regular token rotation improves security:
1. Generate a new token with same permissions
2. Update all integrations to use new token
3. Verify all integrations working
4. Revoke the old token
**Recommended rotation schedule:**
* Production: Every 90 days
* Development: Every 180 days
* Testing: As needed
### Revoking Tokens
Immediately revoke a token if:
* It may have been compromised
* An integration is decommissioned
* An team member with access leaves
* You detect suspicious activity
**To revoke:**
1. Go to **Project Settings** > **API Access**
2. Find the token
3. Click **Revoke**
4. Confirm revocation
Revoking a token immediately stops all integrations using it. Ensure you update integrations before revoking.
## Authentication Errors
### 401 Unauthorized
**Cause:** Missing, invalid, or expired token
**Response:**
```json theme={null}
{
"error": "Unauthorized",
"message": "Invalid or missing API token"
}
```
**Solutions:**
* Verify token is included in Authorization header
* Check token hasn't expired
* Ensure token format is correct (`Bearer `)
* Regenerate token if necessary
### 403 Forbidden
**Cause:** Token lacks required permissions
**Response:**
```json theme={null}
{
"error": "Forbidden",
"message": "Insufficient permissions for this operation"
}
```
**Solutions:**
* Check token scopes include required permissions
* Generate new token with appropriate scopes
* Verify endpoint matches token permissions
### Token Expired
**Cause:** Token past expiration date
**Response:**
```json theme={null}
{
"error": "Unauthorized",
"message": "API token has expired"
}
```
**Solution:**
* Generate a new token
* Update integration configuration
* Consider longer expiration or no expiration for stable integrations
## Best Practices
### 1. One Token Per Integration
Create separate tokens for each integration:
```text theme={null}
✅ Good:
- data-warehouse-sync (read-only, activity logs)
- slack-notifications (read-write, notes)
- compliance-export (read-only, all logs)
❌ Bad:
- master-token (read-write, all scopes)
```
### 2. Principle of Least Privilege
Grant minimum necessary permissions:
```javascript theme={null}
// Good - specific scopes
const auditToken = {
scopes: ['activity_logs:read'],
permissions: 'read-only'
};
// Bad - excessive permissions
const masterToken = {
scopes: ['all'],
permissions: 'read-write'
};
```
### 3. Monitor Token Usage
Track token activity in **Project Settings** > **API Access**:
* Last used timestamp
* Request frequency
* Error rates
* Unusual patterns
### 4. Implement Error Handling
Handle authentication errors gracefully:
```javascript theme={null}
async function makeAuthenticatedRequest(url) {
try {
const response = await axios.get(url, {
headers: {
'Authorization': `Bearer ${apiToken}`
}
});
return response.data;
} catch (error) {
if (error.response?.status === 401) {
console.error('Authentication failed. Token may be invalid or expired.');
// Trigger token refresh workflow
} else if (error.response?.status === 403) {
console.error('Insufficient permissions for this operation.');
}
throw error;
}
}
```
### 5. Audit Token Access
Regular security reviews:
* Review active tokens monthly
* Revoke unused tokens
* Update token names to reflect current usage
* Document token purpose and owner
## Next Steps
Understand API usage limits
Start using the Activity Logs API
Back to API overview
Learn about Forest security
# Activity Logs
Source: https://docs.forest.app/reference/api/endpoints/activity-logs
List activity logs for a project environment
## List activity logs
Returns activity logs for a specific project and environment, sorted by `createdAt` date in descending order (most recent first).
Use the `createdAt` filter for pagination.
```
GET /v1/project/{projectName}/environment/{environmentName}/activity-logs
```
### Authentication
All requests require a Bearer token in the `Authorization` header. Generate one from [your account settings](https://app.forestadmin.com/user-settings).
```
Authorization: Bearer YOUR_APPLICATION_TOKEN
```
The token must be generated by a user with an **Admin** role on the project. Tokens generated by non-admin users will not have access to this endpoint.
If your project uses SSO, the application token must be generated while logged in with SSO.
### Path parameters
| Parameter | Type | Description |
| ----------------- | ------ | ---------------------------------------------------- |
| `projectName` | string | Your project name |
| `environmentName` | string | The environment name (e.g., `production`, `staging`) |
# Admin Logs
Source: https://docs.forest.app/reference/api/endpoints/admin-logs
List admin logs for a project
## List admin logs
Returns admin logs for a specific project, sorted by `createdAt` date in descending order (most recent first).
Use the `createdAt` filter for pagination.
```
GET /v1/project/{projectName}/admin-logs
```
### Authentication
All requests require a Bearer token in the `Authorization` header. Generate one from [your account settings](https://app.forestadmin.com/user-settings).
```
Authorization: Bearer YOUR_APPLICATION_TOKEN
```
If your project uses SSO, the application token must be generated while logged in with SSO.
### Path parameters
| Parameter | Type | Description |
| ------------- | ------ | ----------------- |
| `projectName` | string | Your project name |
# Notes
Source: https://docs.forest.app/reference/api/endpoints/notes
List notes and their messages for a project environment
## List notes
Returns notes and their messages for a specific project and environment, sorted by `id` in descending order (most recent first).
Use the `createdAt` filters for pagination.
```
GET /v1/project/{projectName}/environment/{environmentName}/notes
```
### Authentication
All requests require a Bearer token in the `Authorization` header. Generate one from [your account settings](https://app.forestadmin.com/user-settings).
```
Authorization: Bearer YOUR_APPLICATION_TOKEN
```
The token must be generated by a user with an **Admin** role on the project. Tokens generated by non-admin users will not have access to this endpoint.
If your project uses SSO, the application token must be generated while logged in with SSO.
### Path parameters
| Parameter | Type | Description |
| ----------------- | ------ | --------------------------------------- |
| `projectName` | string | The **case sensitive** project name |
| `environmentName` | string | The **case sensitive** environment name |
### Query parameters
| Parameter | Type | Description |
| ------------------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------- |
| `limit` | integer | Maximum number of notes to return. Between 1 and 100. Default is 10. |
| `userEmail` | string | Filter by the email of the user who created the note. |
| `userId` | integer | Filter by the id of the user who created the note. |
| `teamName` | string | Filter by team name. |
| `collectionName` | string | Filter by collection name (as defined in your schema). |
| `recordId` | string | Filter by the id of the record the note is attached to. |
| `createdAt.eq` / `.lt` / `.lte` / `.gt` / `.gte` | string (ISO 8601) | Filter by creation date. Use `.lt`/`.lte`/`.gt`/`.gte` for range queries and pagination. |
| `updatedAt.eq` / `.lt` / `.lte` / `.gt` / `.gte` | string (ISO 8601) | Filter by last update date. |
| `archivedAt.eq` / `.lt` / `.lte` / `.gt` / `.gte` | string (ISO 8601) | Filter by archive date. |
### Response
```json theme={null}
{
"hasMore": false,
"parameters": {
"projectName": "Forest",
"environmentName": "Production",
"limit": 10
},
"data": [
{
"object": "note",
"id": 42,
"title": "THE TITLE",
"recordId": "10",
"createdAt": "2024-02-20T10:35:54.685Z",
"updatedAt": "2024-03-10T10:36:54.685Z",
"archivedAt": "2024-05-22T14:01:23.015Z",
"user": {
"object": "user",
"id": 1,
"username": "alice1",
"email": "alice@somewhere.com"
},
"environment": {
"object": "environment",
"name": "Production"
},
"team": {
"object": "team",
"name": "Operations"
},
"collection": {
"object": "collection",
"name": "client"
},
"messages": [
{
"object": "note-message",
"content": "
",
"createdAt": "2024-02-20T11:15:32.461Z",
"user": {
"object": "user",
"email": "bob@somewhere.com",
"id": 1,
"username": "bob"
}
}
]
}
]
}
```
### Response fields
| Field | Type | Description |
| -------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `hasMore` | boolean | Whether more notes are available after this set. |
| `data[].id` | integer | The note id. |
| `data[].title` | string | The note title. |
| `data[].recordId` | string | The id of the record the note is attached to. |
| `data[].createdAt` | string (ISO 8601) | When the note was created. |
| `data[].updatedAt` | string (ISO 8601) | When the note (or one of its messages) was last updated. |
| `data[].archivedAt` | string (ISO 8601) | When the note was archived, if applicable. |
| `data[].user` | object | The user who created the note (`id`, `username`, `email`). |
| `data[].environment` | object | The environment the note is attached to. |
| `data[].team` | object | The team the note is attached to. |
| `data[].collection` | object | The collection the note is attached to. Notes created on collections deleted before 2024-06-11 may be missing this field. |
| `data[].messages` | array | Messages within the note thread. Each has `content` (HTML), `createdAt`, and `user`. |
### Errors
| Status | Meaning |
| ------ | --------------------------------------------------------------------------------------------------------- |
| `429` | Too many requests. The response includes a `Retry-After` header indicating the number of seconds to wait. |
| `4XX` | Client error. Response body contains `code` and `message`. |
| `5XX` | Server error. Response body contains `code` and `message`. |
# Trigger a workflow via webhook
Source: https://docs.forest.app/reference/api/endpoints/trigger-workflow-webhook
Start a workflow run on a specific record from an external system, over authenticated HTTP.
## Trigger a workflow run
Starts a run of a workflow on a specific record. External systems call the workflow's webhook URL to trigger it the same way an operator would from the interface, but over authenticated HTTP.
```
POST
```
You do **not** build this URL yourself. Enable the webhook trigger on the workflow, then **copy the full URL** from its trigger settings — it already contains the signed identifier of the workflow and rendering. See [Workflow triggers](/product/process/workflows/triggers) for how editors enable the webhook, copy the URL, and manage the token.
The run uses the **latest active workflow** for the target rendering. There is no published/draft distinction — "latest active" is the contract.
### Authentication
All requests require a Forest **application token** in the `Authorization` header, presented with the Bearer scheme. Generate one from [your account settings](https://app.forestadmin.com/user-settings).
```
Authorization: Bearer YOUR_APPLICATION_TOKEN
```
The token serves two purposes at once:
* **Authentication** — it identifies the caller and grants (or denies) the call.
* **Execution identity** — the run reads and writes data **as the token's user**, and the activity log attributes it to that user. Anything the user is not allowed to access, the run cannot access either.
The URL alone grants no authority. It carries a signed identifier of the workflow and rendering, but starts nothing without a token whose user can reach that rendering. Authority comes entirely from the token.
If your project uses SSO, the application token must be generated while logged in with SSO.
### The webhook URL
The URL is obtained by copying it from the workflow's trigger settings — you never assemble it from IDs. It is:
* **Stable** — it does not change when the workflow is published again.
* **Signed and tamper-evident** — it embeds the workflow and rendering; a forged or modified URL is rejected.
* **Valid until the editor invalidates it** — regenerating the URL (or disabling the trigger) is how access is revoked. See [Revoking access](#revoking-access).
Treat the URL as a credential and store it alongside the token.
### Request body
The body is JSON and is validated. Only known fields are extracted; unknown fields are ignored.
```json theme={null}
{
"record_id": "42"
}
```
| Field | Type | Description | |
| ----------- | ------ | ------------------------------------------------------------------------------------------------------- | -------- |
| `record_id` | string | The record the workflow runs on. Composite primary keys are supported in their packed form (e.g. \`"123 | 456"\`). |
The `record_id` is **not** verified when the run is created. If the record does not exist or is inaccessible to the token's user, the run is still created and fails at its first data step during execution — observable via the run state.
Execution is **asynchronous**: the endpoint creates and queues the run, then returns immediately. The run is processed by the executor afterwards.
### Example request
Use the URL you copied from the workflow's trigger settings as-is. In the examples below, `FOREST_WEBHOOK_URL` holds that copied URL.
**cURL:**
```bash theme={null}
curl -X POST "$FOREST_WEBHOOK_URL" \
-H "Authorization: Bearer $FOREST_APPLICATION_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "record_id": "42" }'
```
**JavaScript (Node.js):**
```javascript theme={null}
const response = await fetch(process.env.FOREST_WEBHOOK_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FOREST_APPLICATION_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ record_id: '42' }),
});
if (!response.ok) {
throw new Error(`Webhook request failed: ${response.status}`);
}
const { runId } = await response.json();
```
### Response
On success the endpoint returns `202 Accepted` with the id of the created run:
```json theme={null}
{
"runId": "b1e6c2a4-7f3d-4e2a-9c1b-8d5f0a2e3c4d"
}
```
Use `runId` to follow the run's progress and outcome.
### Idempotency
Only one run of a given workflow can be active on a given record at a time. If a run is already ongoing on the target record, the endpoint returns `409 Conflict` and does **not** start or resume a run:
```json theme={null}
{
"error": "A run of this workflow is already ongoing on this record."
}
```
This makes retries safe **while a run is still ongoing**: a caller that retries after a network error either starts the run (`202`) or learns one is already in progress (`409`). Note the `409` only holds for the duration of the active run — once it finishes, a new request on the same record starts a fresh run (`202`). Retry to recover from transient failures on the initial call, not to re-drive a record after its run has completed.
### Rate limiting
The endpoint is rate-limited **per webhook**, so one noisy integration cannot starve other webhooks. Exceeding the limit returns `429 Too Many Requests` with a `Retry-After` header (in seconds).
The starting threshold is roughly **60 requests per minute per webhook**, and is configurable. Duplicate-record bursts are additionally absorbed by the single-run-per-record `409`.
### Errors
| Status | Meaning |
| ------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `400` | Invalid JSON body, missing `record_id`, or a malformed, tampered, or superseded URL (e.g. an old URL after it was regenerated). |
| `401` | Missing, expired, or invalid Bearer token. |
| `403` | Token is valid, but its user is not authorized for the target organization, rendering, or workflow. |
| `404` | Unknown webhook, no active workflow for the rendering, or the webhook trigger is disabled. |
| `409` | A run of this workflow is already ongoing on this record (see [Idempotency](#idempotency)). |
| `429` | Rate limit exceeded. The response includes a `Retry-After` header. |
### Revoking access
The webhook keeps working until an editor intervenes. Two independent levers, both from the workflow's trigger settings:
* **Disable the webhook trigger** — the toggle is the revocation switch. The URL and token are unchanged, but calls return `404` while it is off.
* **Regenerate the URL** — invalidates the current URL immediately; the old URL then returns `400`. Update your integration with the new URL.
Independently, the **application token** can be expired or revoked by its user at any time, which makes calls return `401`.
## Learn more
Enable and configure the webhook trigger from the workflow editor.
Generate and manage application tokens.
# Public API
Source: https://docs.forest.app/reference/api/introduction
Programmatic access to Forest activity logs, admin logs, and notes.
The Forest public API exposes a subset of platform data and operations for programmatic access, useful for compliance reporting, audit log archival, SIEM integration, and external tooling.
## What the API exposes
| Endpoint group | Use case |
| ------------------------------------------------------- | --------------------------------------------------------------------------- |
| [Activity logs](/reference/api/endpoints/activity-logs) | Track every action taken in Forest, exports for audit and compliance |
| [Admin logs](/reference/api/endpoints/admin-logs) | Track configuration and administrative operations across the project |
| [Notes](/reference/api/endpoints/notes) | Read and write [collaboration notes](/product/collaborate/notes) on records |
**Trigger a workflow via webhook** is also available over HTTP, but it does not follow the base URL and path convention below. Its URL is not assembled from your project and environment — you copy the full, signed URL from the workflow's trigger settings. See [Trigger a workflow via webhook](/reference/api/endpoints/trigger-workflow-webhook) for the contract and [Workflow triggers](/product/process/workflows/triggers) for how to generate the URL.
If you need data access beyond what's exposed here, you have two options:
* **Use the Forest Agent directly**, the agent serves your collections via its own REST API. See the [Node.js](/reference/agent-api/nodejs) or [Ruby](/reference/agent-api/ruby) agent reference.
* **Connect via MCP**, the [MCP server](/product/embed/mcp-server) exposes your data, actions, and workflows to AI agents under the same governance.
## Base URL
All public API requests go to:
```
https://public-api.forestadmin.com
```
Specific endpoints use a path scoped to your project and environment:
```
GET /v1/project/{projectName}/environment/{environmentName}/activity-logs
GET /v1/project/{projectName}/admin-logs
```
See each endpoint page for the exact path and parameters.
## Authentication
All requests require a Bearer token in the `Authorization` header:
```
Authorization: Bearer YOUR_APPLICATION_TOKEN
```
Tokens must be generated by a user with an Admin role on the project. See [Authentication](/reference/api/authentication) for how to generate and manage tokens.
## Rate limits
The public API enforces per-token rate limits. See [Rate limits](/reference/api/rate-limits) for current values, response headers, and best practices for handling throttling.
## Common use cases
Export activity logs to a data warehouse for long-term retention. Build custom audit dashboards. Pull decision traces for regulator inquiries.
Stream Forest activity into Splunk, Datadog, ELK, CloudWatch, or your existing SIEM.
Sync notes between Forest and external collaboration tools (Slack, Teams, internal CRMs).
Pull operations data on a schedule and feed it into business reporting tools.
## Getting started
Project Settings → API Access → Generate New Token. The user generating the token must have Admin role on the project.
Use the token in the `Authorization` header. See [Authentication](/reference/api/authentication) for examples in cURL, Node.js, and Python.
Read the `X-RateLimit-Remaining` header in responses. Implement exponential backoff for 429 responses.
## Next steps
Generate and use API tokens.
Understand and handle limits.
The most-used endpoint group.
# API Rate Limits
Source: https://docs.forest.app/reference/api/rate-limits
Understand and work within Forest API rate limits
# API Rate Limits
The Forest Public API implements rate limiting to ensure service stability and fair usage across all customers. This guide explains rate limits, how to work with them, and best practices for building reliable integrations.
## Rate Limit Overview
Rate limits protect the API infrastructure by:
* **Preventing abuse** - Limiting excessive or malicious requests
* **Ensuring availability** - Maintaining service for all users
* **Encouraging efficiency** - Promoting optimized API usage
* **Fair resource allocation** - Distributing capacity equitably
## Current Rate Limits
### Standard Limits
| Time Window | Limit | Applies To |
| ----------- | --------------- | ------------- |
| Per minute | 60 requests | Per API token |
| Per hour | 1,000 requests | Per API token |
| Per day | 10,000 requests | Per API token |
### Enterprise Limits
Enterprise customers may have higher limits based on their plan. Contact your account manager for details.
Rate limits are subject to change. Check the response headers for current limits.
## Rate Limit Headers
Every API response includes headers with rate limit information:
```http theme={null}
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1640995200
```
### Header Definitions
**`X-RateLimit-Limit`**
* Maximum requests allowed in current time window
* Example: `60` (60 requests per minute)
**`X-RateLimit-Remaining`**
* Requests remaining in current time window
* Example: `45` (45 requests left)
**`X-RateLimit-Reset`**
* Unix timestamp when the rate limit resets
* Example: `1640995200` (January 1, 2022, 00:00:00 UTC)
### Reading Headers
**JavaScript:**
```javascript theme={null}
const response = await axios.get(
'https://public-api.forestadmin.com/v1/project/{projectName}/environment/{environmentName}/activity-logs',
{ headers: { 'Authorization': `Bearer ${token}` } }
);
const limit = response.headers['x-ratelimit-limit'];
const remaining = response.headers['x-ratelimit-remaining'];
const reset = response.headers['x-ratelimit-reset'];
console.log(`${remaining}/${limit} requests remaining`);
console.log(`Resets at: ${new Date(reset * 1000).toISOString()}`);
```
**Python:**
```python theme={null}
response = requests.get(
'https://public-api.forestadmin.com/v1/project/{projectName}/environment/{environmentName}/activity-logs',
headers={'Authorization': f'Bearer {token}'}
)
limit = response.headers.get('X-RateLimit-Limit')
remaining = response.headers.get('X-RateLimit-Remaining')
reset = response.headers.get('X-RateLimit-Reset')
print(f'{remaining}/{limit} requests remaining')
print(f'Resets at: {datetime.fromtimestamp(int(reset))}')
```
## 429 Too Many Requests
Exceeding the rate limit causes the API to return a `429 Too Many Requests` error:
**Response:**
```json theme={null}
{
"error": "Too Many Requests",
"message": "Rate limit exceeded. Please retry after 60 seconds.",
"retry_after": 60
}
```
**Headers:**
```http theme={null}
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1640995260
Retry-After: 60
```
### Retry-After Header
The `Retry-After` header indicates how long to wait before retrying:
* Value in seconds until you can retry
* Always respect this value
* Do not retry before this time
## Handling Rate Limits
### Strategy 1: Check Headers Proactively
Monitor rate limit headers and slow down before hitting the limit:
```javascript theme={null}
async function makeRateLimitedRequest(url) {
const response = await axios.get(url, {
headers: { 'Authorization': `Bearer ${token}` }
});
const remaining = parseInt(response.headers['x-ratelimit-remaining']);
const reset = parseInt(response.headers['x-ratelimit-reset']);
// Slow down if close to limit
if (remaining < 10) {
const waitTime = (reset - Date.now() / 1000) * 1000;
console.log(`Approaching rate limit. Waiting ${waitTime}ms...`);
await sleep(waitTime);
}
return response.data;
}
```
### Strategy 2: Exponential Backoff
Implement exponential backoff for 429 errors:
```javascript theme={null}
async function requestWithRetry(url, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await axios.get(url, {
headers: { 'Authorization': `Bearer ${token}` }
});
} catch (error) {
if (error.response?.status === 429) {
const retryAfter = error.response.headers['retry-after'] ||
Math.pow(2, attempt) * 1000;
console.log(`Rate limited. Retrying after ${retryAfter}ms...`);
await sleep(retryAfter);
continue;
}
throw error;
}
}
throw new Error('Max retries exceeded');
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
```
### Strategy 3: Request Queue
Use a queue to control request rate:
```javascript theme={null}
class RateLimitedQueue {
constructor(requestsPerMinute = 60) {
this.queue = [];
this.processing = false;
this.interval = 60000 / requestsPerMinute; // ms between requests
}
async add(requestFn) {
return new Promise((resolve, reject) => {
this.queue.push({ requestFn, resolve, reject });
this.process();
});
}
async process() {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
const { requestFn, resolve, reject } = this.queue.shift();
try {
const result = await requestFn();
resolve(result);
} catch (error) {
reject(error);
}
await sleep(this.interval);
this.processing = false;
this.process();
}
}
// Usage
const queue = new RateLimitedQueue(60);
for (const id of recordIds) {
queue.add(() =>
axios.get(`https://public-api.forestadmin.com/api/v1/records/${id}`, {
headers: { 'Authorization': `Bearer ${token}` }
})
);
}
```
### Strategy 4: Token Bucket
Implement a token bucket algorithm:
```python theme={null}
import time
from threading import Lock
class TokenBucket:
def __init__(self, rate, capacity):
self.rate = rate # tokens per second
self.capacity = capacity # maximum tokens
self.tokens = capacity
self.last_update = time.time()
self.lock = Lock()
def consume(self, tokens=1):
with self.lock:
now = time.time()
elapsed = now - self.last_update
# Add tokens based on elapsed time
self.tokens = min(
self.capacity,
self.tokens + elapsed * self.rate
)
self.last_update = now
if self.tokens >= tokens:
self.tokens -= tokens
return True
return False
def wait_for_token(self):
while not self.consume():
time.sleep(0.1)
# Usage (60 requests per minute)
bucket = TokenBucket(rate=1, capacity=60)
for record_id in record_ids:
bucket.wait_for_token()
response = requests.get(
f'https://public-api.forestadmin.com/api/v1/records/{record_id}',
headers={'Authorization': f'Bearer {token}'}
)
```
## Best Practices
### 1. Respect Rate Limits
Always check and respect rate limit headers:
```javascript theme={null}
✅ Good:
- Monitor X-RateLimit-Remaining
- Slow down when approaching limit
- Respect Retry-After header
- Implement exponential backoff
❌ Bad:
- Ignore rate limit headers
- Retry immediately after 429
- Use multiple tokens to circumvent limits
```
### 2. Implement Caching
Cache responses to reduce API calls:
```javascript theme={null}
const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
async function getCachedData(endpoint) {
const cached = cache.get(endpoint);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data;
}
const response = await axios.get(endpoint, {
headers: { 'Authorization': `Bearer ${token}` }
});
cache.set(endpoint, {
data: response.data,
timestamp: Date.now()
});
return response.data;
}
```
### 3. Batch Operations
Group related requests when possible:
```javascript theme={null}
// Bad - Multiple individual requests
for (const id of userIds) {
await getUser(id); // 100 requests for 100 users
}
// Good - Batch request
const users = await getUsers({ ids: userIds }); // 1 request
```
### 4. Use Pagination Efficiently
Request only the data you need:
```javascript theme={null}
// Bad - Request all data
const allLogs = await getAllActivityLogs(); // Could be thousands
// Good - Paginate and filter
const recentLogs = await getActivityLogs({
start_date: '2024-01-01',
limit: 100,
page: 1
});
```
### 5. Monitor Usage
Track your API usage to identify optimization opportunities:
```javascript theme={null}
class APIMonitor {
constructor() {
this.requests = 0;
this.errors = 0;
this.rateLimits = 0;
}
recordRequest() {
this.requests++;
}
recordError(error) {
this.errors++;
if (error.response?.status === 429) {
this.rateLimits++;
}
}
getStats() {
return {
total_requests: this.requests,
total_errors: this.errors,
rate_limit_errors: this.rateLimits,
error_rate: (this.errors / this.requests * 100).toFixed(2) + '%'
};
}
}
const monitor = new APIMonitor();
// Use in requests
try {
monitor.recordRequest();
const response = await makeRequest();
} catch (error) {
monitor.recordError(error);
throw error;
}
```
### 6. Schedule Heavy Operations
Run intensive operations during off-peak hours:
```javascript theme={null}
// Schedule large exports for off-peak times
const isOffPeak = () => {
const hour = new Date().getUTCHours();
return hour >= 0 && hour < 6; // 00:00-06:00 UTC
};
if (isOffPeak()) {
await exportLargeDataset();
} else {
console.log('Scheduling for off-peak hours...');
scheduleForOffPeak(exportLargeDataset);
}
```
## Increasing Rate Limits
### Enterprise Plans
Enterprise customers can request higher rate limits:
1. **Contact your account manager**
2. **Describe your use case**
3. **Provide expected request volume**
4. **Discuss SLA requirements**
### Custom Limits
For specific high-volume use cases, custom limits may be available:
* Bulk data exports
* Real-time integrations
* Data warehouse syncing
* Compliance reporting
**Contact:** [enterprise@forestadmin.com](mailto:enterprise@forestadmin.com)
## Complete Example
Full implementation with rate limiting, retries, and monitoring:
```javascript theme={null}
const axios = require('axios');
class ForestAPIClient {
constructor(token, requestsPerMinute = 60) {
this.token = token;
this.baseURL = 'https://public-api.forestadmin.com';
this.queue = [];
this.processing = false;
this.interval = 60000 / requestsPerMinute;
this.monitor = {
requests: 0,
errors: 0,
rateLimits: 0
};
}
async request(endpoint, options = {}) {
return new Promise((resolve, reject) => {
this.queue.push({ endpoint, options, resolve, reject });
this.processQueue();
});
}
async processQueue() {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
const { endpoint, options, resolve, reject } = this.queue.shift();
try {
const response = await this.makeRequest(endpoint, options);
this.monitor.requests++;
resolve(response);
} catch (error) {
this.monitor.errors++;
if (error.response?.status === 429) {
this.monitor.rateLimits++;
// Re-queue the request
this.queue.unshift({ endpoint, options, resolve, reject });
const retryAfter = error.response.headers['retry-after'] * 1000 || 60000;
await this.sleep(retryAfter);
} else {
reject(error);
}
}
await this.sleep(this.interval);
this.processing = false;
this.processQueue();
}
async makeRequest(endpoint, options, retries = 3) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
return await axios({
method: options.method || 'GET',
url: `${this.baseURL}${endpoint}`,
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
...options.headers
},
...options
});
} catch (error) {
if (attempt === retries - 1) throw error;
if (error.response?.status === 429) {
const retryAfter = error.response.headers['retry-after'] * 1000 ||
Math.pow(2, attempt) * 1000;
await this.sleep(retryAfter);
} else {
throw error;
}
}
}
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
getStats() {
return {
...this.monitor,
error_rate: (this.monitor.errors / this.monitor.requests * 100).toFixed(2) + '%',
rate_limit_rate: (this.monitor.rateLimits / this.monitor.errors * 100).toFixed(2) + '%'
};
}
}
// Usage
const client = new ForestAPIClient(process.env.FOREST_API_TOKEN, 60);
async function fetchActivityLogs() {
const response = await client.request('/v1/project/{projectName}/environment/{environmentName}/activity-logs', {
params: {
start_date: '2024-01-01',
limit: 100
}
});
return response.data;
}
// Fetch multiple pages
const allLogs = [];
for (let page = 1; page <= 10; page++) {
const response = await client.request('/v1/project/{projectName}/environment/{environmentName}/activity-logs', {
params: { page, limit: 100 }
});
allLogs.push(...response.data);
}
console.log('Stats:', client.getStats());
```
## Next Steps
Learn about API authentication
Start using the Activity Logs API
Back to API overview
Review best practices
# forest branch
Source: https://docs.forest.app/reference/cli/branch
Create a new branch or list and delete your existing branches
# forest branch
Create, list, or delete layout branches. Branches work similarly to Git branches, but for your Forest UI layout, they let you isolate UI changes while you develop a feature, then push or deploy those changes when ready.
## Usage
```bash theme={null}
forest branch [BRANCH_NAME] [options]
```
## Arguments
| Argument | Description |
| ------------- | -------------------------------------------------------------- |
| `BRANCH_NAME` | Name of the branch to create (optional, omit to list branches) |
## Options
| Option | Description |
| ----------------- | --------------------------------------------- |
| `-d, --delete` | Delete a branch |
| `--force` | Skip confirmation when deleting a branch |
| `-o, --origin` | Set the origin environment for the new branch |
| `-p, --projectId` | The ID of the project to work on |
| `--format` | Output format: `table` (default) or `json` |
| `--help` | Display usage information |
## Listing branches
Run `forest branch` without arguments to list all your existing branches:
```bash theme={null}
$ forest branch
NAME ORIGIN IS CURRENT CLOSED AT
feature/new-button production ✅
fix-missing-label staging
feature/remove-tooltip preprod 2022-08-19T08:08:47.678Z
```
The **IS CURRENT** column shows your active branch, the one your Development Environment is currently using.
## Creating a branch
Append a branch name to create a new branch:
```bash theme={null}
$ forest branch feature/new-ops-feature --origin production
✅ Switched to new branch: feature/new-ops-feature
```
Your project must have at least one remote or production environment before you can create branches.
```bash theme={null}
$ forest branch add-refund-action
❌ You cannot create a branch until this project has either a remote or a production environment.
```
### Branch origins
Every branch needs an **origin**, the environment whose layout state the branch starts from. Your layout changes build on top of the origin's layout.
* If you omit `--origin`, the CLI prompts you to select one interactively.
* Branches usually originate from your Production or a Remote Environment.
There are no specific constraints on branch names, but **kebab-case** is the convention. Branch names must be unique within a project.
## Deleting a branch
Use the `-d` flag with a branch name to delete it:
```bash theme={null}
$ forest branch -d hotfix/fix-dropdown-issue
? Delete branch "hotfix/fix-dropdown-issue"? Y
✅ Branch hotfix/fix-dropdown-issue successfully deleted.
```
You will be prompted to confirm deletion. To skip the confirmation:
```bash theme={null}
forest branch -d hotfix/fix-dropdown-issue --force
```
## Examples
```bash theme={null}
# List all branches
forest branch
# Create a new branch with a specific origin
forest branch feature/customer-export --origin production
# Create a branch in a specific project
forest branch feature/new-view --projectId 42
# Delete a branch
forest branch -d feature/old-experiment
# Delete a branch without confirmation
forest branch -d feature/old-experiment --force
# Output branch list as JSON
forest branch --format json
```
# forest deploy
Source: https://docs.forest.app/reference/cli/deploy
Deploy layout changes from your current branch to the production (reference) environment
# forest deploy
Deploy the layout changes of your current branch to the reference environment (typically Production). Unlike [`forest push`](/reference/cli/push), which targets non-production environments, `forest deploy` permanently applies your changes to production.
## Usage
```bash theme={null}
forest deploy [options]
```
## Options
| Option | Description |
| ----------------- | ------------------------------- |
| `-f, --force` | Skip deployment confirmation |
| `-p, --projectId` | The ID of the project to deploy |
| `--help` | Display usage information |
## How it works
When you run `forest deploy`, your branch's layout changes (Δ) are permanently applied to your Production Environment:
```
my-branch (Δ) ──deploy──► Production (reference)
```
Since remote environments have Production as their origin, the deployed changes will automatically appear in all remote environments too.
Deploying to production is **irreversible**. The layout changes will be permanently applied to your Production Environment. Make sure changes have been tested in a staging or remote environment first.
## Prerequisites
To deploy a branch, its **origin must be the reference environment** (Production). If you created your branch from a staging environment, you cannot deploy it directly, you would need to push it to staging first, then deploy from there.
## Deploying changes
Run `forest deploy` from your project directory:
```bash theme={null}
$ forest deploy
? Deploy my-current-branch to Production (Y|n): Y
✅ Deployment successful.
```
You will be prompted to confirm the deployment. To skip the confirmation:
```bash theme={null}
forest deploy --force
```
## Deploy from the UI
You can also deploy layout changes directly from the Forest UI. When a remote environment has changes ready to deploy to production, a banner appears at the top with a **"Deploy to …"** link.
Deploy from the UI is only available for remote environments whose origin **is** the reference environment (Production).
## Push vs. deploy
| Command | Target | Effect |
| ------------------------------------ | ----------------------------------------- | ------------------------------------------------ |
| [`forest push`](/reference/cli/push) | Non-reference environments (e.g. staging) | Applies layout changes to a remote environment |
| `forest deploy` | Reference environment (Production) | Permanently applies layout changes to production |
## Examples
```bash theme={null}
# Deploy with confirmation prompt
forest deploy
# Deploy without confirmation
forest deploy --force
# Deploy in a specific project
forest deploy --projectId 42
```
# forest init
Source: https://docs.forest.app/reference/cli/init
Set up your Forest development environment in your current project directory
# forest init
Set up your development environment in your current project directory. This command configures your local Forest environment, linking your codebase to a project on Forest.
## Usage
```bash theme={null}
forest init [options]
```
## Options
| Option | Description |
| ----------------- | ----------------------------------- |
| `-p, --projectId` | The ID of the project to initialize |
## What it does
`forest init` is an interactive wizard that:
1. Authenticates you if you are not already logged in
2. Selects the Forest project to connect to
3. Configures your local agent endpoint (host and port)
4. Creates your **Development Environment** on Forest
5. Optionally sets up your database connection
6. Creates or updates your `.env` file with the required environment variables
`forest init` is not meant to create a new project from scratch. If you do not have an existing Forest project yet, create one from the UI first.
Run `forest init` from your project's **root directory**, the same directory where your agent code lives.
## Interactive prompts
### Authentication
If you are not already logged in, `forest init` will prompt for your credentials. You can also authenticate beforehand with [`forest login`](/reference/cli/login).
### Project selection
If your account is linked to multiple projects, you will be asked to select the one matching your current codebase:
```
? Select your project:
❯ My CRM Admin
Internal Operations Tool
Customer Support Panel
```
If your account has only one project, this step is skipped automatically.
### Endpoint configuration
Forest needs to know where your local agent is running to set up the Development Environment:
```
? Enter your local admin backend endpoint: (http://localhost:3310)
```
Press **Enter** to accept the default (`http://localhost:3310`) or provide a custom URL.
Your Development Environment is created as soon as you confirm the endpoint. 🎉
### Database configuration (optional)
`forest init` will offer to set up your `DATABASE_URL` if it is not already configured:
```
? You don't have a DATABASE_URL yet. Do you need help setting it? (Y/n)
```
If you accept, you will be guided through entering your database credentials, which will be written to your `.env` file.
## Environment variables
After a successful `forest init`, the following variables are added to your `.env` file:
| Variable | Description |
| ------------------- | --------------------------------------------------- |
| `FOREST_ENV_SECRET` | Secret key identifying your Development Environment |
| `DATABASE_URL` | Your database connection string (if configured) |
The `FOREST_ENV_SECRET` uniquely identifies your Development Environment. Keep this value private and do not share it.
## Example
```bash theme={null}
$ cd my-forest-admin-project
$ forest init
? Select your project: My CRM Admin
? Enter your local admin backend endpoint: (http://localhost:3310)
✅ Development Environment created!
? You don't have a DATABASE_URL yet. Do you need help setting it? Yes
...
✅ Your .env file has been updated.
```
## Troubleshooting
**Not logged in**
Run [`forest login`](/reference/cli/login) first, or let `forest init` handle authentication interactively.
**Project not found**
Ensure your Forest account has access to the project. Contact your project admin if needed.
**Endpoint unreachable**
Make sure your local agent is running before confirming the endpoint. Forest will attempt to reach it during setup.
# forest login
Source: https://docs.forest.app/reference/cli/login
Authenticate the Forest CLI with your Forest account
# forest login
Sign in to your Forest account.
## Usage
```bash theme={null}
forest login [options]
```
## Options
| Option | Description |
| ---------------- | ---------------------------------------------------------- |
| `-e, --email` | Your Forest account email |
| `-P, --password` | Your Forest account password (ignored if `--token` is set) |
| `-t, --token` | Your Forest account token |
## Default behavior
Running `forest login` without arguments opens your browser to authenticate via your Forest account:
```bash theme={null}
$ forest login
# Your browser opens automatically.
# If it doesn't, visit the URL printed in the terminal and enter the confirmation code shown.
```
Once you confirm in the browser, the CLI stores your session and you're ready to use other commands.
## Login with email and password
You can bypass the browser flow by providing credentials directly:
```bash theme={null}
forest login --email you@company.com --password yourpassword
```
## Login with an application token
```bash theme={null}
forest login --token YOUR_APPLICATION_TOKEN
```
Application tokens are useful in CI/CD pipelines or automated environments where opening a browser is not possible.
## Logging out
```bash theme={null}
forest logout
```
## Checking the current user
```bash theme={null}
forest user
```
## Token storage
The CLI stores your session token locally in `~/.forest.d/`. This directory is created automatically on first login.
Do not commit the `~/.forest.d/` directory to version control. It contains your authentication credentials.
## Troubleshooting
**Browser does not open**
The CLI will print a URL and a confirmation code in the terminal. Open the URL manually and enter the code to complete authentication.
**Token expired**
Run `forest login` again to get a fresh session.
**Command requires authentication**
Most CLI commands require you to be logged in. Run `forest login` before using other commands.
# Forest CLI
Source: https://docs.forest.app/reference/cli/overview
Command-line tool for managing your Forest projects, branches, environments, and deployments
# Forest CLI
The Forest CLI (`forest`) is a command-line tool that lets you manage your Forest layout changes throughout your development workflow, from local development to production deployment.
## Installation
```bash theme={null}
npm install -g forest-cli
```
You can verify the installation with:
```bash theme={null}
forest --version
```
The Forest CLI requires **Node.js >= 18.0.0**.
## Getting started
Once installed, authenticate with your Forest account:
```bash theme={null}
forest login
```
Then initialize your development environment from your project directory:
```bash theme={null}
forest init
```
## Commands
### Authentication
| Command | Description |
| -------------------------------------- | ------------------------------------ |
| [`forest login`](/reference/cli/login) | Sign in to your Forest account |
| `forest logout` | Sign out of your account |
| `forest user` | Display the currently logged-in user |
### Branch management
| Command | Description |
| ---------------------------------------- | ------------------------------------------ |
| [`forest branch`](/reference/cli/branch) | Create a branch or list existing branches |
| [`forest switch`](/reference/cli/switch) | Switch to a different branch |
| `forest set-origin` | Set an environment as your branch's origin |
### Deployment
| Command | Description |
| ---------------------------------------- | --------------------------------------------------------------- |
| [`forest push`](/reference/cli/push) | Push layout changes from your branch to its origin environment |
| [`forest deploy`](/reference/cli/deploy) | Deploy layout changes to the production (reference) environment |
### Environment management
| Command | Description |
| ---------------------------- | ------------------------------------------------ |
| `forest environments` | List all environments |
| `forest environments:create` | Create a new environment |
| `forest environments:delete` | Delete an environment |
| `forest environments:update` | Update an environment |
| `forest environments:reset` | Reset all layout changes on a remote environment |
### Project management
| Command | Description |
| --------------------- | ----------------------------- |
| `forest projects` | List all projects |
| `forest projects:get` | Get a project's configuration |
### Schema
| Command | Description |
| ---------------------- | ------------------------------------------------- |
| `forest schema:apply` | Apply your local schema to a specific environment |
| `forest schema:diff` | Compare the schemas of two environments |
| `forest schema:update` | Refresh your schema by generating missing files |
## Development workflow
A typical development workflow with the Forest CLI looks like this:
1. **Create a branch** to isolate your layout changes:
```bash theme={null}
forest branch feature/my-feature --origin production
```
2. **Work locally**, make layout changes in your Forest UI connected to your development environment.
3. **Push your changes** to a staging or test environment for review:
```bash theme={null}
forest push
```
4. **Deploy to production** once changes are validated:
```bash theme={null}
forest deploy
```
## Docker usage
You can also run the CLI via Docker without installing it locally:
```bash theme={null}
docker run --rm --init -it \
-v `pwd`:/usr/src/app \
-v ~/.forest.d:/usr/src/cli/.forest.d \
-e TOKEN_PATH="/usr/src/cli" \
forestadmin/toolbelt:latest [command]
```
## Global options
All commands support the following options:
| Option | Description |
| ----------- | ----------------------------------------- |
| `--help` | Display usage information for the command |
| `--version` | Show the CLI version |
# forest push
Source: https://docs.forest.app/reference/cli/push
Push layout changes from your current branch to its origin environment
# forest push
Push the layout changes of your current branch to its origin environment. This applies your branch's UI changes to a remote environment (such as staging), making them visible to users of that environment.
## Usage
```bash theme={null}
forest push [options]
```
## Options
| Option | Description |
| ----------------- | -------------------------------- |
| `--force` | Skip push confirmation |
| `-p, --projectId` | The ID of the project to push to |
| `--help` | Display usage information |
## How it works
When you run `forest push`, your branch's layout changes (noted Δ) are applied to the branch's **origin environment**:
```
my-branch (Δ) ──push──► Staging (origin)
```
The layout changes are moved from your branch to the origin environment. Other users of that environment will immediately see the updated layout.
Pushing your branch to its origin environment will **delete the branch** automatically. Make sure your changes are ready before pushing.
## Pushing changes
Run `forest push` from your project directory:
```bash theme={null}
$ forest push
? Push branch my-current-branch onto Staging (Y|n): Y
✅ Push successful.
```
You will be prompted to confirm before the push is applied. To skip the confirmation:
```bash theme={null}
forest push --force
```
## Push vs. deploy
`forest push` and `forest deploy` are distinct commands with different purposes:
| Command | Target | Effect |
| --------------- | ----------------------------------------- | ------------------------------------------------------------------ |
| `forest push` | Non-reference environments (e.g. staging) | Applies layout changes to a remote environment; deletes the branch |
| `forest deploy` | Reference environment (e.g. production) | Permanently applies layout changes to production |
You cannot `push` to the Production (reference) environment. Use [`forest deploy`](/reference/cli/deploy) for production changes.
## Pushing from the UI
You can also push layout changes directly from the Forest UI. When a remote environment has unpushed changes, a banner appears at the top with a **"Push to …"** link.
Push from the UI is only available for remote environments whose origin is **not** the reference environment.
## Examples
```bash theme={null}
# Push with confirmation prompt
forest push
# Push without confirmation
forest push --force
# Push in a specific project
forest push --projectId 42
```
# forest switch
Source: https://docs.forest.app/reference/cli/switch
Switch to a different branch in your local Forest development environment
# forest switch
Switch your active branch in your local development environment. The active branch determines which layout configuration your Development Environment uses.
## Usage
```bash theme={null}
forest switch [BRANCH_NAME] [options]
```
## Arguments
| Argument | Description |
| ------------- | ------------------------------------------------------------------------------ |
| `BRANCH_NAME` | The name of the branch to switch to (optional, omit for interactive selection) |
## Options
| Option | Description |
| -------- | ------------------------- |
| `--help` | Display usage information |
## Interactive selection
If you omit the branch name, the CLI presents an interactive list of available branches:
```bash theme={null}
$ forest switch
? Select the branch you want to set as current:
❯ feature/add-new-smart-view
hotfix/fix-dropdown-issue
feature/implement-refund-action
```
## Direct switch
Provide the branch name directly to switch without the interactive prompt:
```bash theme={null}
$ forest switch feature/add-new-smart-view
✅ Switched to branch: feature/add-new-smart-view
```
You cannot switch to the branch that is already active.
## Examples
```bash theme={null}
# Interactive branch selection
forest switch
# Switch to a specific branch
forest switch feature/customer-export
# Switch to main development branch
forest switch main-feature
```
## After switching
After switching branches, your Development Environment will reflect the layout of the new branch. If your local agent is running, **restart it** to ensure it picks up any environment variable changes.
If your Forest UI does not update after switching branches, try refreshing your browser.
# Reference
Source: https://docs.forest.app/reference/overview
Agent SDKs, public API, CLI, and schema format.
Technical reference for everything programmable in Forest, the agent SDKs, the public API, the Forest CLI, and the `.forestadmin-schema.json` format.
Looking for the onboarding path instead? See **[Get started](/get-started/intro-to-forest-admin)**.
## Agent SDK
The Forest Agent is the lightweight backend that connects Forest to your data. It runs in your infrastructure, executes your business logic, and exposes your data to operators and AI agents (via the MCP server).
Complete API reference for `@forestadmin/agent`. TypeScript-first, multi-datasource, plugin ecosystem.
Complete API reference for the Forest Ruby agent. ActiveRecord and Mongoid support, Rails integration.
How agents fit into Forest's architecture, supported languages, and feature parity across SDKs.
## CLI
The Forest CLI (`forest`) manages branches, environments, and deployments throughout your development workflow.
Authentication, branch management, deployment, environment commands, and Docker usage.
Common commands:
Sign in to your Forest account.
Initialize your local development environment.
Create or list branches.
Switch the active branch.
Push layout changes to the branch's origin environment.
Deploy layout changes to production.
## Public API
The Forest public API exposes activity logs, admin logs, and notes for programmatic access, useful for compliance, audit, and external integrations.
Authentication, rate limits, and available endpoints.
List activity logs per project and environment.
List admin operations across a project.
Read and write collaboration notes on records.
## Schema
Reference for the auto-generated schema file that describes your collections and customizations.
## Migrating from a legacy agent?
If you're working with `forest-express-sequelize`, `forest-express-mongoose`, `forest-rails`, or `django-forestadmin v1`, see **[Migrating from v1](/guides/migration/from-v1/overview)**. The legacy agent reference is preserved in **[Legacy](/legacy/agents-overview)** for migration purposes.
# .forestadmin-schema.json
Source: https://docs.forest.app/reference/schema/forestadmin-schema
Reference for the auto-generated schema file describing your collections and customizations.
The `.forestadmin-schema.json` file is the canonical description of your agent's data model, the collections it exposes, the fields and their types, the relationships, and the customizations applied. It's auto-generated, versioned with your code, and read by the Forest API to render the UI for each environment.
The filename is `.forestadmin-schema.json` (the legacy naming is preserved to avoid breaking existing deployments). Treat it as a Forest-managed file.
## When the file is generated
In **development environments only**, the agent regenerates `.forestadmin-schema.json` on every startup. It reflects:
* The state of your data sources (collections, fields, types, relationships)
* Your agent's customizations (actions, computed fields, segments, hooks)
In Node.js, the agent decides whether to regenerate based on the `isProduction` option passed to `createAgent`. In Ruby, generation is tied to the Rails environment, and you can also generate the file explicitly with a rake task.
```typescript Node.js theme={null}
const agent = createAgent({
authSecret: process.env.FOREST_AUTH_SECRET,
envSecret: process.env.FOREST_ENV_SECRET,
isProduction: process.env.NODE_ENV === 'production',
});
```
```bash Ruby theme={null}
# In development, the schema is regenerated when the server boots.
# In production, the agent uses the committed file and never regenerates it.
# Generate it explicitly (without booting the server) with the rake task:
rails forest_admin:schema:generate
# Add debug=true to troubleshoot generation:
rails forest_admin:schema:generate debug=true
```
| Environment | Behavior |
| ------------------- | ------------------------------------------------------------------- |
| Development | Regenerates `.forestadmin-schema.json` on every restart |
| Remote / production | Never regenerates, uses the schema file deployed alongside the code |
## Why versioning matters
In **remote and production environments**, the agent does NOT regenerate the schema file. It uses the version that was deployed with your code. This means:
* You must commit `.forestadmin-schema.json` to version control
* You must deploy it alongside your agent code to every remote environment
* The file is the contract between your agent and the Forest API for that environment
If you change your data sources or customizations and don't redeploy the schema file, the production environment will keep serving the old schema, your changes won't show up in the UI.
Do not edit `.forestadmin-schema.json` manually. Wrong syntax will break the UI.
## What versioning the schema gives you
Beyond keeping environments in sync, committing the schema file lets you:
* **Review schema changes in pull requests**, diffs show every collection, field, and relationship change before merge
* **Roll back schema regressions**, if a bad change breaks production, revert the commit
* **Audit historical schemas**, Git history shows the data model state at any past point