Source

adminjs/src/adminjs.ts

  1. import * as _ from 'lodash'
  2. import * as path from 'path'
  3. import * as fs from 'fs'
  4. import i18n, { i18n as I18n } from 'i18next'
  5. import { AdminJSOptionsWithDefault, AdminJSOptions } from './adminjs-options.interface'
  6. import BaseResource from './backend/adapters/resource/base-resource'
  7. import BaseDatabase from './backend/adapters/database/base-database'
  8. import ConfigurationError from './backend/utils/errors/configuration-error'
  9. import ResourcesFactory from './backend/utils/resources-factory/resources-factory'
  10. import userComponentsBundler from './backend/bundler/user-components-bundler'
  11. import { RecordActionResponse, Action, BulkActionResponse } from './backend/actions/action.interface'
  12. import { DEFAULT_PATHS } from './constants'
  13. import { ACTIONS } from './backend/actions'
  14. import loginTemplate from './frontend/login-template'
  15. import { ListActionResponse } from './backend/actions/list/list-action'
  16. import { combineTranslations, Locale } from './locale/config'
  17. import { locales } from './locale'
  18. import { TranslateFunctions, createFunctions } from './utils/translate-functions.factory'
  19. import { OverridableComponent } from './frontend/utils/overridable-component'
  20. import { relativeFilePathResolver } from './utils/file-resolver'
  21. const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf-8'))
  22. export const VERSION = pkg.version
  23. export const defaultOptions: AdminJSOptionsWithDefault = {
  24. rootPath: DEFAULT_PATHS.rootPath,
  25. logoutPath: DEFAULT_PATHS.logoutPath,
  26. loginPath: DEFAULT_PATHS.loginPath,
  27. databases: [],
  28. resources: [],
  29. dashboard: {},
  30. pages: {},
  31. bundler: {},
  32. }
  33. type ActionsMap = {
  34. show: Action<RecordActionResponse>;
  35. edit: Action<RecordActionResponse>;
  36. delete: Action<RecordActionResponse>;
  37. bulkDelete: Action<BulkActionResponse>;
  38. new: Action<RecordActionResponse>;
  39. list: Action<ListActionResponse>;
  40. }
  41. export type Adapter = { Database: typeof BaseDatabase; Resource: typeof BaseResource }
  42. /**
  43. * Main class for AdminJS extension. It takes {@link AdminJSOptions} as a
  44. * parameter and creates an admin instance.
  45. *
  46. * Its main responsibility is to fetch all the resources and/or databases given by a
  47. * user. Its instance is a currier - injected in all other classes.
  48. *
  49. * @example
  50. * const AdminJS = require('adminjs')
  51. * const admin = new AdminJS(AdminJSOptions)
  52. */
  53. class AdminJS {
  54. public resources: Array<BaseResource>
  55. public options: AdminJSOptionsWithDefault
  56. public locale!: Locale
  57. public i18n!: I18n
  58. public translateFunctions!: TranslateFunctions
  59. /**
  60. * List of all default actions. If you want to change the behavior for all actions like:
  61. * _list_, _edit_, _show_, _delete_ and _bulkDelete_ you can do this here.
  62. *
  63. * @example <caption>Modifying accessibility rules for all show actions</caption>
  64. * const { ACTIONS } = require('adminjs')
  65. * ACTIONS.show.isAccessible = () => {...}
  66. */
  67. public static ACTIONS: ActionsMap
  68. /**
  69. * AdminJS version
  70. */
  71. public static VERSION: string
  72. /**
  73. * @param {AdminJSOptions} options Options passed to AdminJS
  74. */
  75. constructor(options: AdminJSOptions = {}) {
  76. /**
  77. * @type {BaseResource[]}
  78. * @description List of all resources available for the AdminJS.
  79. * They can be fetched with the {@link AdminJS#findResource} method
  80. */
  81. this.resources = []
  82. /**
  83. * @type {AdminJSOptions}
  84. * @description Options given by a user
  85. */
  86. this.options = _.merge({}, defaultOptions, options)
  87. this.resolveBabelConfigPath()
  88. this.initI18n()
  89. const { databases, resources } = this.options
  90. const resourcesFactory = new ResourcesFactory(this, global.RegisteredAdapters || [])
  91. this.resources = resourcesFactory.buildResources({ databases, resources })
  92. }
  93. initI18n(): void {
  94. const language = this.options.locale?.language || locales.en.language
  95. const defaultTranslations = locales[language]?.translations || locales.en.translations
  96. this.locale = {
  97. translations: combineTranslations(defaultTranslations, this.options.locale?.translations),
  98. language,
  99. }
  100. if (i18n.isInitialized) {
  101. i18n.addResourceBundle(this.locale.language, 'translation', this.locale.translations)
  102. } else {
  103. i18n.init({
  104. lng: this.locale.language,
  105. initImmediate: false, // loads translations immediately
  106. resources: {
  107. [this.locale.language]: {
  108. translation: this.locale.translations,
  109. },
  110. },
  111. })
  112. }
  113. // mixin translate functions to AdminJS instance so users will be able to
  114. // call AdminJS.translateMessage(...)
  115. this.translateFunctions = createFunctions(i18n)
  116. Object.getOwnPropertyNames(this.translateFunctions).forEach((translateFunctionName) => {
  117. this[translateFunctionName] = this.translateFunctions[translateFunctionName]
  118. })
  119. }
  120. /**
  121. * Registers various database adapters written for AdminJS.
  122. *
  123. * @example
  124. * const AdminJS = require('adminjs')
  125. * const MongooseAdapter = require('adminjs-mongoose')
  126. * AdminJS.registerAdapter(MongooseAdapter)
  127. *
  128. * @param {Object} options
  129. * @param {typeof BaseDatabase} options.Database subclass of {@link BaseDatabase}
  130. * @param {typeof BaseResource} options.Resource subclass of {@link BaseResource}
  131. */
  132. static registerAdapter({ Database, Resource }: {
  133. Database: typeof BaseDatabase;
  134. Resource: typeof BaseResource;
  135. }): void {
  136. if (!Database || !Resource) {
  137. throw new Error('Adapter has to have both Database and Resource')
  138. }
  139. // checking if both Database and Resource have at least isAdapterFor method
  140. if (Database.isAdapterFor && Resource.isAdapterFor) {
  141. global.RegisteredAdapters = global.RegisteredAdapters || []
  142. global.RegisteredAdapters.push({ Database, Resource })
  143. } else {
  144. throw new Error('Adapter elements has to be a subclass of AdminJS.BaseResource and AdminJS.BaseDatabase')
  145. }
  146. }
  147. /**
  148. * Initializes AdminJS instance in production. This function should be called by
  149. * all external plugins.
  150. */
  151. async initialize(): Promise<void> {
  152. if (process.env.NODE_ENV === 'production'
  153. && !(process.env.ADMIN_JS_SKIP_BUNDLE === 'true')) {
  154. // eslint-disable-next-line no-console
  155. console.log('AdminJS: bundling user components...')
  156. await userComponentsBundler(this, { write: true })
  157. }
  158. }
  159. /**
  160. * Watches for local changes in files imported via {@link AdminJS.bundle}.
  161. * It doesn't work on production environment.
  162. *
  163. * @return {Promise<never>}
  164. */
  165. async watch(): Promise<string | undefined> {
  166. if (process.env.NODE_ENV !== 'production') {
  167. return userComponentsBundler(this, { write: true, watch: true })
  168. }
  169. return undefined
  170. }
  171. /**
  172. * Renders an entire login page with email and password fields
  173. * using {@link Renderer}.
  174. *
  175. * Used by external plugins
  176. *
  177. * @param {Object} options
  178. * @param {String} options.action Login form action url - it could be
  179. * '/admin/login'
  180. * @param {String} [options.errorMessage] Optional error message. When set,
  181. * renderer will print this message in
  182. * the form
  183. * @return {Promise<string>} HTML of the rendered page
  184. */
  185. async renderLogin({ action, errorMessage }): Promise<string> {
  186. return loginTemplate(this, { action, errorMessage })
  187. }
  188. /**
  189. * Returns resource base on its ID
  190. *
  191. * @example
  192. * const User = admin.findResource('users')
  193. * await User.findOne(userId)
  194. *
  195. * @param {String} resourceId ID of a resource defined under {@link BaseResource#id}
  196. * @return {BaseResource} found resource
  197. * @throws {Error} When resource with given id cannot be found
  198. */
  199. findResource(resourceId): BaseResource {
  200. const resource = this.resources.find(m => m._decorated?.id() === resourceId)
  201. if (!resource) {
  202. throw new Error([
  203. `There are no resources with given id: "${resourceId}"`,
  204. 'This is the list of all registered resources you can use:',
  205. this.resources.map(r => r._decorated?.id() || r.id()).join(', '),
  206. ].join('\n'))
  207. }
  208. return resource
  209. }
  210. /**
  211. * Resolve babel config file path,
  212. * and load configuration to this.options.bundler.babelConfig.
  213. */
  214. resolveBabelConfigPath(): void {
  215. if (typeof this.options?.bundler?.babelConfig !== 'string') {
  216. return
  217. }
  218. let filePath = ''
  219. let config = this.options?.bundler?.babelConfig
  220. if (config[0] === '/') {
  221. filePath = config
  222. } else {
  223. filePath = relativeFilePathResolver(config, /new AdminJS/)
  224. }
  225. if (!fs.existsSync(filePath)) {
  226. throw new ConfigurationError(`Given babel config "${filePath}", doesn't exist.`, 'AdminJS.html')
  227. }
  228. if (path.extname(filePath) === '.js') {
  229. // eslint-disable-next-line
  230. const configModule = require(filePath)
  231. config = configModule && configModule.__esModule
  232. ? configModule.default || undefined
  233. : configModule
  234. if (!config || typeof config !== 'object' || Array.isArray(config)) {
  235. throw new Error(
  236. `${filePath}: Configuration should be an exported JavaScript object.`,
  237. )
  238. }
  239. } else {
  240. try {
  241. config = JSON.parse(fs.readFileSync(filePath, 'utf8'))
  242. } catch (err) {
  243. throw new Error(`${filePath}: Error while parsing config - ${err.message}`)
  244. }
  245. if (!config) throw new Error(`${filePath}: No config detected`)
  246. if (typeof config !== 'object') {
  247. throw new Error(`${filePath}: Config returned typeof ${typeof config}`)
  248. }
  249. if (Array.isArray(config)) {
  250. throw new Error(`${filePath}: Expected config object but found array`)
  251. }
  252. }
  253. this.options.bundler.babelConfig = config
  254. }
  255. /**
  256. * Requires given `.jsx/.tsx` file, that it can be bundled to the frontend.
  257. * It will be available under AdminJS.UserComponents[componentId].
  258. *
  259. * @param {String} src Path to a file containing react component.
  260. *
  261. * @param {OverridableComponent} [componentName] - name of the component which you want
  262. * to override
  263. * @returns {String} componentId - uniq id of a component
  264. *
  265. * @example <caption>Passing custom components in AdminJS options</caption>
  266. * const adminJsOptions = {
  267. * dashboard: {
  268. * component: AdminJS.bundle('./path/to/component'),
  269. * }
  270. * }
  271. * @example <caption>Overriding AdminJS core components</caption>
  272. * // somewhere in the code
  273. * AdminJS.bundle('./path/to/new-sidebar/component', 'SidebarFooter')
  274. */
  275. public static bundle(src: string, componentName?: OverridableComponent): string {
  276. const nextId = Object.keys(global.UserComponents || {}).length + 1
  277. const extensions = ['.jsx', '.js', '.ts', '.tsx']
  278. let filePath = ''
  279. const componentId = componentName || `Component${nextId}`
  280. if (path.isAbsolute(src)) {
  281. filePath = src
  282. } else {
  283. filePath = relativeFilePathResolver(src, /.*\.{1}bundle/)
  284. }
  285. const { ext: originalFileExtension } = path.parse(filePath)
  286. for (const extension of extensions) {
  287. const forcedExt = extensions.includes(originalFileExtension) ? '' : extension
  288. const { root, dir, name, ext } = path.parse(filePath + forcedExt)
  289. const fileName = path.format({ root, dir, name, ext })
  290. if (fs.existsSync(fileName)) {
  291. // We have to put this to the global scope because of the NPM resolution. If we put this to
  292. // let say `AdminJS.UserComponents` (static member) it wont work in a case where user uses
  293. // AdminJS.bundle from a different packages (i.e. from the extension) because there, there
  294. // is an another AdminJS version (npm installs different versions for each package). Also
  295. // putting admin to peerDependencies wont solve this issue, because in the development mode
  296. // we have to install adminjs it as a devDependency, because we want to run test or have
  297. // proper types.
  298. global.UserComponents = global.UserComponents || {}
  299. global.UserComponents[componentId] = path.format({ root, dir, name })
  300. return componentId
  301. }
  302. }
  303. throw new ConfigurationError(`Given file "${src}", doesn't exist.`, 'AdminJS.html')
  304. }
  305. }
  306. AdminJS.VERSION = VERSION
  307. AdminJS.ACTIONS = ACTIONS
  308. // eslint-disable-next-line @typescript-eslint/no-empty-interface
  309. interface AdminJS extends TranslateFunctions {}
  310. export const { registerAdapter } = AdminJS
  311. export const { bundle } = AdminJS
  312. export default AdminJS