Source

admin-bro/src/backend/actions/action.interface.ts

import AdminBro from '../../admin-bro'
import { CurrentAdmin } from '../../current-admin.interface'
import ViewHelpers from '../utils/view-helpers'
import BaseRecord from '../adapters/base-record'
import BaseResource from '../adapters/base-resource'
import ActionDecorator from '../decorators/action-decorator'
import RecordJSON from '../decorators/record-json.interface'
import { NoticeMessage } from '../../frontend/store/with-notice'

/**
 * Execution context for an action. It is passed to the {@link Action#handler},
 * {@link Action#before} and {@link Action#after} functions.
 *
 * @memberof Action
 * @alias ActionContext
 */
export type ActionContext = {
  /**
   * current instance of AdminBro. You may use it to fetch other Resources by their names:
   */
  _admin: AdminBro;
  /**
   * Resource on which action has been invoked. Null for dashboard handler.
   */
  resource: BaseResource;
  /**
   * Record on which action has been invoked (only for {@link actionType} === 'record')
   */
  record?: BaseRecord;
  /**
   * Records on which action has been invoked (only for {@link actionType} === 'bulk')
   */
  records?: Array<BaseRecord>;
  /**
   * view helpers
   */
  h: ViewHelpers;
  /**
   * Object of currently invoked function. Not present for dashboard action
   */
  action: ActionDecorator;
  /**
   * Currently logged in admin
   */
  currentAdmin?: CurrentAdmin;
}

/**
 * Context object passed to a PageHandler
 *
 * @alias PageContext
 * @memberof AdminBroOptions
 */
export type PageContext = {
  /**
   * current instance of AdminBro. You may use it to fetch other Resources by their names:
   */
  _admin: AdminBro;
    /**
   * Currently logged in admin
   */
  currentAdmin?: CurrentAdmin;
    /**
   * view helpers
   */
  h: ViewHelpers;
}

/**
 * ActionRequest
 * @memberof Action
 * @alias ActionRequest
 */
export type ActionRequest = {
  /**
   * parameters passed in an URL
   */
  params: {
    /**
     * Id of current resource
     */
    resourceId: string;
    /**
     * Id of current record (in case of record action)
     */
    recordId?: string;
    /**
     * Id of selected records (in case of bulk action) divided by commas
     */
    recordIds?: string;
    /**
     * Name of an action
     */
    action: string;

    [key: string]: any;
  };
  /**
   * POST data passed to the backend
   */
  payload?: Record<string, any>;
  /**
   * Elements of query string
   */
  query?: Record<string, any>;
  /**
   * HTTP method
   */
  method: 'post' | 'get';
}

/**
 * Base response for all actions
 * @memberof Action
 * @alias ActionResponse
 */
export type ActionResponse = {
  /**
   * Notice message which should be presented to the end user after showing the action
   */
  notice?: NoticeMessage;
  /**
   * redirect path
   */
  redirectUrl?: string;
  /**
   * Any other custom parameter
   */
  [key: string]: any;
}

/**
 * @description
 * Defines the type of {@link Action#isAccessible} and {@link Action#isVisible} functions
 * @alias IsFunction
 * @memberof Action
 */
export type IsFunction = (context: ActionContext) => boolean

/**
 * Required response of a Record action. Extends {@link ActionResponse}
 *
 * @memberof Action
 * @alias RecordActionResponse
 */
export type RecordActionResponse = ActionResponse & {
  /**
   * Record object.
   */
  record: RecordJSON;
}

/**
 * Required response of a Record action. Extends {@link ActionResponse}
 *
 * @memberof Action
 * @alias RecordActionResponse
 */
export type BulkActionResponse = ActionResponse & {
  /**
   * Array of RecordJSON objects.
   */
  records: Array<RecordJSON>;
}

/**
 * Type of a handler function. It has to return response compatible
 * with {@link ActionResponse}, {@link BulkActionResponse} or {@link RecordActionResponse}
 *
 * @alias ActionHandler
 * @async
 * @memberof Action
 * @returns {Promise<T>}
 */
export type ActionHandler<T> = (
  request: ActionRequest,
  response: any,
  context: ActionContext
) => Promise<T>

/**
 * Before action hook. When it is given - it is performed before the {@link ActionHandler}
 * method.
 * @alias Before
 * @returns {Promise<ActionRequest>}
 * @memberof Action
 * @async
 */
export type Before = (
  /**
   * Request object
   */
  request: ActionRequest,
    /**
   * Invocation context
   */
  context: ActionContext,
) => Promise<ActionRequest>

/**
 * Type of an after hook action.
 *
 * @memberof Action
 * @alias After
 * @async
 */
export type After<T> = (
  /**
   * Response returned by the default ActionHandler
   */
  response: T,
  /**
   * Original request which has been sent to ActionHandler
   */
  request: ActionRequest,
  /**
   * Invocation context
   */
  context: ActionContext,
) => Promise<T>

/**
 * @classdesc
 * Interface representing an Action in AdminBro.
 * Look at {@tutorial 05-actions} to see where you can use this interface.
 *
 * #### Example Action
 *
 * ```
 * const action = {
 *   actionType: 'record',
 *   label: 'Publish',
 *   icon: 'fas fa-eye',
 *   isVisible: true,
 *   handler: async () => {...},
 *   component: AdminBro.bundle('./my-action-component'),
 * }
 * ```
 *
 * There are 3 kinds of actions:
 *
 * 1. Resource action, which is performed for an entire resource.
 * 2. Record action, invoked for an record in a resource
 * 2. Bulk action, invoked for an set of records in a resource
 *
 * ...and there are 6 actions predefined in AdminBro
 *
 * 1. {@link module:NewAction new} (resource action) - create new records in a resource
 * 1. {@link module:ListAction list} (resource action) - list all records within a resource
 * 2. {@link module:EditAction edit} (record action) - update records in a resource
 * 3. {@link module:ShowAction show} (record action) - show details of given record
 * 3. {@link module:DeleteAction delete} (record action) - delete given record
 * 3. {@link module:BulkDeleteAction bulkDelete} (bulk action) - delete given records
 *
 * Users can also create their own actions or override those already existing by using
 * {@link ResourceOptions}
 *
 * ```javascript
 * const AdminBroOptions = {
 *   resources: [{
 *     resource: User,
 *     options: {
 *       actions: {
 *         // example of overriding existing 'new' action for
 *         // User resource.
 *         new: {
 *           label: 'Create new record'
 *         },
 *         // Example of creating a new 'myNewAction' which will be
 *         // a resource action available for User model
 *         myNewAction: {
 *           actionType: 'resource',
 *           handler: async (request, response, context) => {...}
 *         }
 *       }
 *     }
 *   }]
 * }
 *
 * const { ACTIONS } = require('admin-bro')
 * // example of adding after filter for 'show' action for all resources
 * ACTIONS.show.after = async () => {...}
 * ```
 */
export default interface Action <T extends ActionResponse> {
  /**
   * Name of an action which is its uniq key.
   * If use one of _list_, _edit_, _new_, _show_ or _delete_ you override existing actions.
   * For all other keys you create a new action.
   */
  name: string;
  /**
   * indicates if action should be visible for given invocation context.
   * It also can be a simple boolean value.
   * `True` by default.
   * The most common example of usage is to hide resources from the UI.
   * So let say we have 2 resources __User__ and __Cars__:
   *
   * ```javascript
   * const User = mongoose.model('User', mongoose.Schema({
   *   email: String,
   *   encryptedPassword: String,
   * }))
   * const Car = mongoose.model('Car', mongoose.Schema({
   *   name: String,
   *   ownerId: { type: mongoose.Types.ObjectId, ref: 'User' },
   * })
   * ```
   *
   * so if we want to hide Users collection, but allow people to pick user when
   * creating cars. We can do this like this:
   *
   * ```javascript
   * new AdminBro({ resources: [{
   *   resource: User,
   *   options: { actions: { list: { isVisible: false } } }
   * }]})
   * ```
   * In contrast - when we use {@link Action#isAccessible} instead - user wont be able to
   * pick car owner.
   *
   * @see {@link ActionContext}   parameter passed to isAccessible
   * @see {@link IsFunction}      exact type of the function
   */
  isVisible?: boolean | IsFunction;
  /**
   * Indicates if the action can be invoked for given invocation context.
   * You can pass a boolean or function of type {@link IsFunction}, which
   * takes {@link ActionContext} as an argument.
   *
   * Example for isVisible function which allows user to edit cars which belongs only
   * to her:
   *
   * ```javascript
   * const canEditCars = ({ currentAdmin, record }) => {
   *   return currentAdmin && (
   *     currentAdmin.role === 'admin'
   *     || currentAdmin._id === record.param('ownerId')
   *   )
   * }
   *
   * new AdminBro({ resources: [{
   *   resource: Car,
   *   options: { actions: { edit: { isAccessible: canEditCars } } }
   * }]})
   * ```
   *
   * @see {@link ActionContext}   parameter passed to isAccessible
   * @see {@link IsFunction}      exact type of the function
   */
  isAccessible?: boolean | IsFunction;
  /**
   * name of the action which will appear in the UI
   */
  label?: string;
  /**
   * if filter should be visible on the sidebar. Only for _resource_ actions
   *
   * Example of creating new resource action with filter
   *
   * ```javascript
   * new AdminBro({ resources: [{
   *   resource: Car,
   *   options: { actions: {
   *     newAction: {
   *       label: 'New action',
   *       type: 'resource',
   *       showFilter: true,
   *     }
   *   }}
   * }]})
   * ```
   */
  showFilter?: boolean;
  /**
   * Type of an action - could be either _resource_, _record_ or _bulk_.
   *
   * <img src="./images/actions.png">
   *
   * When you define a new action - it is required.
   */
  actionType: 'resource' | 'record' | 'bulk';
  /**
   * icon class of an action
   *
   * ```javascript
   * new AdminBro({ resources: [{
   *   resource: Car,
   *   options: { actions: { edit: { icon: 'fa fa-bomb' } } },
   * }]})
   * ```
   */
  icon?: string;
  /**
   * guard message - user will have to confirm it before executing an action.
   *
   * ```javascript
   * new AdminBro({ resources: [{
   *   resource: Car,
   *   options: { actions: {
   *     delete: {
   *       guard: 'do you really want to delete this amazing element?',
   *     }
   *   }}
   * }]})
   * ```
   */
  guard?: string;
  /**
   * Component which will be used to render the action. To pass the component
   * use {@link AdminBro.bundle} method.
   *
   * Action components accepts {@link ActionProps} and are rendered by the
   * {@link BaseActionComponent}
   *
   * When component is set to `false` then action doesn't have it's own view.
   * Instead after clicking button it is immediately performed. Example of
   * an action without a view is {@link module:DeleteAction}.
   */
  component?: string | false;
  /**
   * handler function which will be invoked by either:
   * - {@link ApiController#resourceAction}
   * - {@link ApiController#recordAction}
   * - or {@link ApiController#bulkAction}
   * when user visits clicks action link.
   *
   * If you are defining this action for a record it has to return:
   * - {@link ActionResponse} for resource action
   * - {@link RecordActionResponse} for record action
   * - {@link BulkActionResponse} for bulk action
   *
   * ```javascript
   * // Handler of a 'record' action
   * handler: async (request, response, context) {
   *   const user = context.record
   *   const Cars = context._admin.findResource('Car')
   *   const userCar = Car.findOne(context.record.param('carId'))
   *   return {
   *     record: user.toJSON(context.currentAdmin),
   *   }
   * }
   * ```
   *
   * Required for new actions. For modifying already defined actions
   * like new and edit please use {@link Action#before} and {@link Action#after} hooks.
   */
  handler: ActionHandler<T>;
  /**
   * Before action hook. When it is given - it is performed before the {@link Action#handler}
   * method.
   *
   * Example of hashing password before creating it:
   *
   * ```javascript
   * actions: {
   *   new: {
   *     before: async (request) => {
   *       if(request.payload.record.password) {
   *         request.payload.record = {
   *           ...request.payload.record,
   *           encryptedPassword: await bcrypt.hash(request.payload.record.password, 10),
   *           password: undefined,
   *         }
   *       }
   *       return request
   *     },
   *   }
   * }
   * ```
   */
  before?: Before;
  /**
   * After action hook. When it is given - it is performed on the returned,
   * by {@link Action#handler handler} function response.
   *
   * You can use it to (just an idea)
   * - create log of changes done in the app
   * - prefetch additional data after original {@link Handler} is being performed
   *
   * Creating a changelog example:
   *
   * ```javascript
   * // example mongoose model
   * const ChangeLog = mongoose.model('ChangeLog', mongoose.Schema({
   *   // what action
   *   action: { type: String },
   *   // who
   *   userId: { type: mongoose.Types.ObjectId, ref: 'User' },
   *   // on which resource
   *   resource: { type: String },
   *   // was record involved (resource and recordId creates to polymorphic relation)
   *   recordId: { type: mongoose.Types.ObjectId },
   * }, { timestamps: true }))
   *
   * // actual after function
   * const createLog = async (originalResponse, request, context) => {
   *   // checking if object doesn't have any errors or is a delete action
   *   if ((request.method === 'post'
   *        && originalResponse.record
   *        && !Object.keys(originalResponse.record.errors).length)
   *        || context.action.name === 'delete') {
   *     await ChangeLog.create({
   *       action: context.action.name,
   *       // assuming in the session we store _id of the current admin
   *       userId: context.currentAdmin && context.currentAdmin._id,
   *       resource: context.resource.id(),
   *       recordId: context.record && context.record.id(),
   *     })
   *   }
   *   return originalResponse
   * }
   *
   * // and attaching this function to actions for all resources
   * const { ACTIONS } = require('admin-bro')
   *
   * ACTIONS.edit.after = createLog
   * ACTIONS.delete.after = createLog
   * ACTIONS.new.after = createLog
   * ```
   *
   */
  after?: After<T>;
}