Now, that you have all the fundaments, it's time to create use case-specific features. With Forest Admin it only takes a few moments, as we facilitate the process thanks to smart actions.
Creating smart actions requires coding, and having an admin or a developer role on Forest Admin.
All the code snippets in this tutorial are written in TypeScript, and whenever we refer to the technical documentation, all links go to the Node.js Developer Guide.
In the demo project used for the purpose of this tutorial, we have created a couple of smart actions, specific to common tasks of Customer Support teams. We will now describe each of them to help you get started with your own smart action. You can find the general and comprehensive guide to smart actions in the documentation. Here we only focus on the steps needed to create the CS-specific ones.
This smart action allows CS team members to apply a promo code, link it with the existing order, and to display the amount with the discount once the coupon is applied.
In order to allow CS team members to apply promo codes, display their names and values, and link them with orders, you need to follow these instructions.
After creating a project, it's time to work on the scaffolding. We created a "customizations" folder, where the code will live every time we want to customize something. These customization files look like this:
import { CollectionCustomizer } from"@forestadmin/agent";import { Schema } from"../typings";exportdefault (coupons:CollectionCustomizer<Schema,'coupons'>) => {// Here, you'll write your customization on the "coupons" collection};
Notice the CollectionCustomizer<Schema, 'coupons'>. This is what allows us to easily customize a collection.
Then, modify the index.ts (which is generated by the toolbelt) to include these customizations:
// // ...import couponsCustomization from'./customizations/coupons';// ...agent// Create the datasource - this should be available in the onboarding code.addDataSource(createSqlDataSource({ uri:process.env.DATABASE_URL, schema:process.env.DATABASE_SCHEMA, sslMode:process.env.DATABASE_SSL_MODEasSslMode, }), )// Customize the collection "coupons".customizeCollection('coupons', couponsCustomization);// ...
Then, it's time to add two new features regarding the Orders collection:
We want an action that links an existing coupon with an order.
We want to display the amount with the discount applied directly in the table view.
import { CollectionCustomizer } from"@forestadmin/agent";import { Schema } from"../typings";// Customize the "orders" collectionexportdefault (orders:CollectionCustomizer<Schema,'orders'>) => { orders// Add a new action name "Apply a coupon".addAction('Apply a coupon', {// We're using scope: "Single" here to allow this action to be triggerable only on a single "Order" scope:'Single',// We declare a form, to allow the user to select a coupon to apply form: [{// In this form, we will have a single field "Coupon" label:'Coupon',// And we want to search in the existing "coupons" table type:'Collection', collectionName:'coupons', }],// The actual code that will be triggered when the action is ranexecute:async (context, resultBuilder) => {try {// Retrieve the selected couponconst [couponId] =context.formValues['Coupon'];// Update the order to add the couponId await context.collection.update({ conditionTree: { field: 'id', operator: 'Equal', value: await context.getRecordId() } }, { coupon_id: couponId });
// Send back a nice success message, and force refetch the record to retrieve the couponreturnresultBuilder.success(`Successfully applied coupon`, { invalidated: ['coupon']}); } catch (error) {// If any error happened, display an error toastrreturnresultBuilder.error(`Failed to apply coupon: ${error.message}.`); } } })// Then, we want to display the "real" amount of the order, taking discount into account// So, let's create a new field.addField('amount_with_discount', {// The amount is a number so ... columnType:'Number',// The real amount depends on the initial amount, and discount percent & amount dependencies: ['coupon:discount_percent','coupon:discount_amount','initial_amount'],// The code related to computing the "real" amountgetValues: (records) => {// For each record, apply the formulae// amountWithDiscount = initialAmount - (initialAmount * (discountPercent / 100)) - discountAmount;returnrecords.map((record) => {// Since records depends on "coupon", we can retrieve all values// And compute the real amount synchronouslyconstdiscountPercent=Number(record.coupon?.discount_percent ||0);constdiscountAmount=Number(record.coupon?.discount_amount ||0);constinitialAmount= record['initial_amount'];constamountWithDiscount= initialAmount - (initialAmount * (discountPercent /100)) - discountAmount;returnMath.floor((amountWithDiscount >0? amountWithDiscount :0)*100)/100; }); } });};
User Management System
Change a plan, Reset a password, Anonymize, Block a user.
Now we're going to add smart actions to the Users collection. Their aim is to allow CS team members to perform the most common actions like changing a plan, resetting passwords, and blocking (moderating) users. In addition, we will create a smart field that will display users' full names, when your database stores first and last names separately. Let's dive in.
import { CollectionCustomizer } from"@forestadmin/agent";import { randomBytes } from'crypto';import { Schema } from"../typings";exportdefault (users:CollectionCustomizer<Schema,'users'>) => {// Create a new fullname fieldusers.addField('fullname', {// As this will be a concatenation of firstname and lastname, type is String columnType:'String',// fullname will depend on firstname & lastname dependencies: ['firstname','lastname'],// Then, simply compute the value for each recordgetValues: (records) => {returnrecords.map((record) =>`${record.firstname}${record.lastname}`); }, })// We want to use the "contains" filter on the frontend, so we'll have to implement it.replaceFieldOperator('fullname','Contains', (value) => ({// "Contains" on fullname is equivalent to "Or Contains" on firstname & lastname aggregator:'Or', conditions: [{ field:'firstname', operator:'Contains', value }, { field:'lastname', operator:'Contains', value }], })) // We define a way to handle "writing" the fullname virtual field. So here, we're splitting the input to firstname and lastname
.replaceFieldWriting('fullname', (value) => {const [firstname,lastname] =value.split(' ');return { firstname, lastname, }; })// We also want to sort on fullname, so here is the equivalent sort.replaceFieldSorting('fullname', [ { field:'firstname', ascending:true }, { field:'lastname', ascending:true }, ])// Add an action that anonymize a specific set of user// Changes their name, email, picture, cellphone, password, set them as blocked and also unlink their address.addAction('Anonymize user', { scope:'Bulk',execute:async (context, resultBuilder) => {try {constrecords=awaitcontext.getRecords(['id']);constuserIds=records.map((record) =>record.id);awaitcontext.collection.update({ conditionTree: { field:'id', operator:'In', value: userIds, } }, { firstname:'Anonymous', lastname:'Anonymous', email:'anonymous@anonymous.anonymous', identity_picture:null, cellphone:'Unknown', password:'', is_blocked:true, signup_date:null, });awaitcontext.dataSource.getCollection('addresses').update({ conditionTree: { field:'user_id', operator:'In', value: userIds, } }, { user_id:null, country:'Unknown', city:'Unknown', street:'Unknown', number:'0', });returnresultBuilder.success('User(s) anonymized!'); } catch(error) {returnresultBuilder.error(`Failed to anonymize user(s) ${error.message}.`); } }, })// Change a user's plan by updating it's subscription.addAction('Change a plan', { scope:'Single', form: [{ label:'plan', collectionName:'plans', type:'Collection', isRequired:true,defaultValue:async (context) => [(awaitcontext.getRecord(['subscription:plan_id'])).subscription?.plan_id], }],execute:async (context, resultBuilder) => {const [newPlanId] =context.formValues.plan;constrecord=awaitcontext.getRecord(['subscription:id']);const { subscription } = record; if (!subscription.id) return resultBuilder.error(`You can not change the plan, the user does not have subscriptions yet.`);
try {awaitcontext.dataSource.getCollection('subscriptions').update({ conditionTree: { field:'id', operator:'Equal', value:subscription.id, }, }, { plan_id: newPlanId });returnresultBuilder.success('Plan successfully updated.'); } catch(error) {returnresultBuilder.error(`Failed to change plan ${error.message}.`); } }, })// Fake simulate a password reset sending action.addAction('Reset password', { scope:'Single',execute:async (context, resultBuilder) => {constuserId=awaitcontext.getRecordId();try {awaitcontext.dataSource.getCollection('users').update({ conditionTree: { field:'id', operator:'Equal', value: userId, }, }, { password:randomBytes(16).toString('hex') });// We do not encourage sending raw password as email, this is just for the example :) return resultBuilder.success('Password successfully updated, a mail has sended to the user with his new password.');
} catch(error) {returnresultBuilder.error(`Failed to reset password ${error.message}.`); } }, })// Moderate a user.addAction('Moderate', { scope:'Single', form: [{ label:'User Name', type:'String', isReadOnly:true, description:'You will block the following user',defaultValue:async (context) => (awaitcontext.getRecord(['fullname'])).fullname, }, { label:'reason', type:'Enum', enumValues: ['resignation','dismissal','long-term illness','other'], isRequired:true, }, { label:'explanation', type:'String', description:'Fill in the reason',if: (context) =>context.formValues.reason ==='other', }],execute:async (context, resultBuilder) => {constuser=awaitcontext.getRecord(['id','is_blocked']);if (user.is_blocked) returnresultBuilder.success('User already blocked.');try {awaitcontext.dataSource.getCollection('users').update({ conditionTree: { field:'id', operator:'Equal', value:user.id, }, }, { is_blocked:true });returnresultBuilder.success('User successfully blocked.'); } catch(error) {returnresultBuilder.error(`Failed block user ${error.message}.`); } }, });};
Impersonate a user
Customer impersonation, often called a 'login as' feature is a very common functionality used by Customer Success teams. In order to troubleshoot customers' issues safely and without compromising data privacy and security, apps often have a feature that allows their managers to take a peek into what their customers see, and be able to identify the problem more easily.
Therefore, it is a must-have feature of every tool used by the CS teams. On Forest Admin, it is possible to add it via a smart action, and it is, in fact, one of the most popular ones. This code should help you create your own.
collection.addAction('Impersonate this user', {// We can only impersonate one user at a time, so it'll be a Single action scope:'Single',execute:async (context, resultBuilder) => {// Retrieve the user idconstuserId=awaitcontext.getRecordId();returnresultBuilder.webhook('https://my-app-url/login',// The url of the company providing the service.'POST',// The method you would like to use (typically a POST). {},// You can add some headers if needed (you can remove it) { adminToken:'your-admin-token',// A body to send to the url (only JSON supported). } ); }, });