Source

adminjs/src/backend/actions/action.interface.ts

import { VariantType } from '@adminjs/design-system'
import AdminJS from '../../adminjs'
import { CurrentAdmin } from '../../current-admin.interface'
import ViewHelpers from '../utils/view-helpers/view-helpers'
import BaseRecord from '../adapters/record/base-record'
import BaseResource from '../adapters/resource/base-resource'
import ActionDecorator from '../decorators/action/action-decorator'
import { LayoutElement, LayoutElementFunction } from '../utils/layout-element-parser'
import { RecordJSON } from '../../frontend/interfaces'
import { NoticeMessage } from '../../frontend/hoc/with-notice'
import { TranslateFunctions } from '../../utils/translate-functions.factory'

export type ActionType = 'resource' | 'record' | 'bulk'

/**
 * Execution context for an action. It is passed to the {@link Action#handler},
 * {@link Action#before} and {@link Action#after} functions.
 *
 * Apart from the properties defined below it also extends {@link TranslateFunctions}.
 * So you can use i.e. context.translateMessage(...) and others...
 *
 * @property {TranslateFunction} {...}      all functions from {@link TranslateFunctions}
 *                                          interface.
 *
 * @memberof Action
 * @alias ActionContext
 */
export type ActionContext = TranslateFunctions & {
  /**
   * current instance of AdminJS. You may use it to fetch other Resources by their names:
   */
  _admin: AdminJS;
  /**
   * 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;
  /**
   * Any custom property which you can add to context
   */
  [key: string]: any;
}

/**
 * Context object passed to a PageHandler
 *
 * @alias PageContext
 * @memberof AdminJSOptions
 */
export type PageContext = {
  /**
   * current instance of AdminJS. You may use it to fetch other Resources by their names:
   */
  _admin: AdminJS;
    /**
   * 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;
    /**
     * an optional search query string (for `search` resource action)
     */
    query?: 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>

export type BuildInActions =
  'show' |
  'edit' |
  'list' |
  'delete' |
  'bulkDelete' |
  'new' |
  'search'

/**
 * @classdesc
 * Interface representing an Action in AdminJS.
 * Look at {@tutorial actions} to see where you can use this interface.
 *
 * #### Example Action
 *
 * ```
 * const action = {
 *   actionType: 'record',
 *   icon: 'View',
 *   isVisible: true,
 *   handler: async () => {...},
 *   component: AdminJS.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
 * 3. Bulk action, invoked for an set of records in a resource
 *
 * ...and there are 7 actions predefined in AdminJS
 *
 * 1. {@link module:NewAction new} (resource action) - create new records in a resource
 * 2. {@link module:ListAction list} (resource action) - list all records within a resource
 * 3. {@link module:SearchAction search} (resource action) - search by query string
 * 4. {@link module:EditAction edit} (record action) - update records in a resource
 * 5. {@link module:ShowAction show} (record action) - show details of given record
 * 6. {@link module:DeleteAction delete} (record action) - delete given record
 * 7. {@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 AdminJSOptions = {
 *   resources: [{
 *     resource: User,
 *     options: {
 *       actions: {
 *         // example of overriding existing 'new' action for
 *         // User resource.
 *         new: {
 *           icon: 'Add'
 *         },
 *         // 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('adminjs')
 * // example of adding after filter for 'show' action for all resources
 * ACTIONS.show.after = async () => {...}
 * ```
 */
export interface Action <T extends ActionResponse> {
  /**
   * Name of an action which is its uniq key.
   * If you use one of _list_, _search_, _edit_, _new_, _show_, _delete_ or
   * _bulkDelete_ you override existing actions.
   * For all other keys you create a new action.
   */
  name: BuildInActions | 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 AdminJS({ 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.
   *
   * You can use it as a carrier between the hooks.
   *
   * Example for isVisible function which allows the 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 AdminJS({ 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;
  /**
   * if filter should be visible on the sidebar. Only for _resource_ actions
   *
   * Example of creating new resource action with filter
   *
   * ```javascript
   * new AdminJS({ resources: [{
   *   resource: Car,
   *   options: { actions: {
   *     newAction: {
   *       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: ActionType;
  /**
   * icon name for the action. Take a look {@link Icon} component,
   * because what you put here is passed down to it.
   *
   * ```javascript
   * new AdminJS({ resources: [{
   *   resource: Car,
   *   options: { actions: { edit: { icon: 'Add' } } },
   * }]})
   * ```
   */
  icon?: string;
  /**
   * guard message - user will have to confirm it before executing an action.
   *
   * ```javascript
   * new AdminJS({ resources: [{
   *   resource: Car,
   *   options: { actions: {
   *     delete: {
   *       guard: 'doYouReallyWantToDoThis',
   *     }
   *   }}
   * }]})
   * ```
   *
   * What you enter there goes to {@link TranslateFunctions#translateMessage} function,
   * so in order to define the actual message you will have to specify its
   * translation in {@link AdminJSOptions.Locale}
   */
  guard?: string;
  /**
   * Component which will be used to render the action. To pass the component
   * use {@link AdminJS.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 we suggest using {@link Action#before} and {@link Action#after} hooks.
   */
  handler: ActionHandler<T> | Array<ActionHandler<T>> | null;
  /**
   * 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.password) {
   *         request.payload = {
   *           ...request.payload,
   *           encryptedPassword: await bcrypt.hash(request.payload.password, 10),
   *           password: undefined,
   *         }
   *       }
   *       return request
   *     },
   *   }
   * }
   * ```
   */
  before?: Before | Array<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('adminjs')
   *
   * ACTIONS.edit.after = createLog
   * ACTIONS.delete.after = createLog
   * ACTIONS.new.after = createLog
   * ```
   *
   */
  after?: After<T> | Array<After<T>>;

  /**
   * Indicates if given action should be seen in a drawer or in a full screen. Default to false
   */
  showInDrawer?: boolean;

  /**
   * Indicates if Action Header should be hidden.
   * Action header consist of:
   * - breadcrumbs
   * - action buttons
   * - action title
   */
  hideActionHeader?: boolean;

  /**
   * The max width of action HTML container.
   * You can put here an actual size in px or an array of widths, where different values
   * will be responsible for different breakpoints.
   * It is directly passed to action's wrapping {@link Box} component, to its `width` property.
   *
   * Examples
   * ```javascript
   *
   * // passing regular string
   * containerWidth: '800px'
   *
   * // passing number for 100% width
   * containerWidth: 1
   *
   * // passing values for different {@link breakpoints}
   * containerWidth: [1, 1/2, 1/3]
   * ```
   */
  containerWidth?: string | number | Array<string | number>;
  /**
   * Definition for the layout. Works with the edit and show actions.
   *
   * With the help of {@link LayoutElement} you can put all the properties to whatever
   * layout you like, without knowing React.
   *
   * This is an example of defining a layout
   *
   * ```
   * const layout = [{ width: 1 / 2 }, [
   *     ['@H3', { children: 'Company data' }],
   *     'companyName',
   *     'companySize',
   *   ]],
   *   [
   *     ['@H3', { children: 'Contact Info' }],
   *     [{ flexDirection: 'row', flex: true }, [
   *       ['email', { pr: 'default', flexGrow: 1 }],
   *       ['address', { flexGrow: 1 }],
   *     ]],
   *   ],
   * ]
   * ```
   *
   * Alternatively you can pass a {@link LayoutElementFunction function} taking
   * {@link CurrentAdmin} as an argument. This will allow you to show/hide
   * given property for restricted users.
   *
   * To see entire documentation and more examples visit {@link LayoutElement}
   *
   * @see LayoutElement
   * @see LayoutElementFunction
   */
  layout?: LayoutElementFunction | Array<LayoutElement>;

  /**
   * Defines the variant of the action. based on that it will receive given color.
   * @new in version v3.3
   */
  variant?: VariantType;

  /**
   * Action can be nested. If you give here another action name - it will be nested under it.
   * If parent action doesn't exists - it will be nested under name in the parent.
   * @new in version v3.3
   */
  parent?: string;

  /**
   * Any custom properties you want to pass down to {@link ActionJSON}. They have to
   * be stringified.
   * @new in version v3.3
   */
  custom?: Record<string, any>;
}