Repository: SoftwareBrothers/adminjs Branch: master Commit: 9c2ee7f1b58c Files: 494 Total size: 1.0 MB Directory structure: gitextract_d7cte0cf/ ├── .babelrc.json ├── .cspell.json ├── .dockerignore ├── .eslintrc.cjs ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ └── feature_request.yml │ └── workflows/ │ └── push.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .prettierrc ├── .releaserc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── UPGRADE-6.0.md ├── bin/ │ ├── app.js │ └── globals.js ├── cli.js ├── commitlint.config.cjs ├── cy/ │ ├── commands/ │ │ ├── ab-get-property.js │ │ ├── ab-keep-logged-in.js │ │ ├── ab-login-api.js │ │ └── ab-login.js │ ├── cypress.doc.md │ ├── index.d.ts │ ├── index.js │ └── readme.md ├── index.d.ts ├── index.js ├── package.json ├── project.inlang/ │ ├── project_id │ └── settings.json ├── spec/ │ ├── backend/ │ │ ├── helpers/ │ │ │ ├── helper-stub.ts │ │ │ └── resource-stub.ts │ │ └── index.js │ ├── fixtures/ │ │ ├── action.factory.js │ │ ├── example-component.js │ │ ├── property.factory.js │ │ └── record.factory.js │ ├── index.js │ ├── lib.js │ └── setup.js ├── src/ │ ├── adminjs-options.interface.ts │ ├── adminjs.spec.ts │ ├── adminjs.ts │ ├── babel.test.config.json │ ├── backend/ │ │ ├── actions/ │ │ │ ├── action.interface.ts │ │ │ ├── bulk-delete/ │ │ │ │ ├── bulk-delete-action.spec.ts │ │ │ │ └── bulk-delete-action.ts │ │ │ ├── delete/ │ │ │ │ ├── delete-action.spec.ts │ │ │ │ └── delete-action.ts │ │ │ ├── edit/ │ │ │ │ └── edit-action.ts │ │ │ ├── index.ts │ │ │ ├── list/ │ │ │ │ └── list-action.ts │ │ │ ├── new/ │ │ │ │ └── new-action.ts │ │ │ ├── search/ │ │ │ │ └── search-action.ts │ │ │ └── show/ │ │ │ └── show-action.ts │ │ ├── adapters/ │ │ │ ├── database/ │ │ │ │ ├── base-database.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── property/ │ │ │ │ ├── base-property.ts │ │ │ │ └── index.ts │ │ │ ├── record/ │ │ │ │ ├── base-record.spec.ts │ │ │ │ ├── base-record.ts │ │ │ │ ├── index.ts │ │ │ │ └── params.type.ts │ │ │ └── resource/ │ │ │ ├── base-resource.spec.ts │ │ │ ├── base-resource.ts │ │ │ ├── index.ts │ │ │ └── supported-databases.type.ts │ │ ├── bundler/ │ │ │ ├── app.bundler.ts │ │ │ ├── components.bundler.ts │ │ │ ├── generate-user-component-entry.spec.js │ │ │ ├── generate-user-component-entry.ts │ │ │ ├── globals.bundler.ts │ │ │ ├── index.ts │ │ │ └── utils/ │ │ │ ├── asset-bundler.ts │ │ │ └── constants.ts │ │ ├── controllers/ │ │ │ ├── api-controller.spec.js │ │ │ ├── api-controller.ts │ │ │ ├── app-controller.ts │ │ │ └── index.ts │ │ ├── decorators/ │ │ │ ├── action/ │ │ │ │ ├── action-decorator.spec.ts │ │ │ │ ├── action-decorator.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── property/ │ │ │ │ ├── index.ts │ │ │ │ ├── property-decorator.spec.ts │ │ │ │ ├── property-decorator.ts │ │ │ │ ├── property-options.interface.ts │ │ │ │ └── utils/ │ │ │ │ ├── index.ts │ │ │ │ ├── override-from-options.spec.ts │ │ │ │ └── override-from-options.ts │ │ │ └── resource/ │ │ │ ├── index.ts │ │ │ ├── resource-decorator.spec.ts │ │ │ ├── resource-decorator.ts │ │ │ ├── resource-options.interface.ts │ │ │ └── utils/ │ │ │ ├── decorate-actions.ts │ │ │ ├── decorate-properties.spec.ts │ │ │ ├── decorate-properties.ts │ │ │ ├── find-sub-property.ts │ │ │ ├── flat-sub-properties.ts │ │ │ ├── get-navigation.spec.ts │ │ │ ├── get-navigation.ts │ │ │ ├── get-property-by-key.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── services/ │ │ │ ├── action-error-handler/ │ │ │ │ ├── action-error-handler.spec.ts │ │ │ │ ├── action-error-handler.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ └── sort-setter/ │ │ │ ├── index.ts │ │ │ ├── sort-setter.spec.js │ │ │ └── sort-setter.ts │ │ └── utils/ │ │ ├── auth/ │ │ │ ├── base-auth-provider.ts │ │ │ ├── default-auth-provider.ts │ │ │ └── index.ts │ │ ├── build-feature/ │ │ │ ├── build-feature.spec.ts │ │ │ ├── build-feature.ts │ │ │ └── index.ts │ │ ├── component-loader.ts │ │ ├── errors/ │ │ │ ├── app-error.ts │ │ │ ├── configuration-error.ts │ │ │ ├── forbidden-error.ts │ │ │ ├── index.ts │ │ │ ├── not-found-error.ts │ │ │ ├── not-implemented-error.ts │ │ │ ├── record-error.ts │ │ │ └── validation-error.ts │ │ ├── filter/ │ │ │ ├── filter.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── layout-element-parser/ │ │ │ ├── index.ts │ │ │ ├── layout-element-parser.spec.ts │ │ │ ├── layout-element-parser.ts │ │ │ └── layout-element.doc.md │ │ ├── options-parser/ │ │ │ ├── index.ts │ │ │ └── options-parser.ts │ │ ├── populator/ │ │ │ ├── index.ts │ │ │ ├── populate-property.spec.ts │ │ │ ├── populate-property.ts │ │ │ ├── populator.doc.md │ │ │ ├── populator.spec.ts │ │ │ └── populator.ts │ │ ├── request-parser/ │ │ │ ├── index.ts │ │ │ ├── request-parser.spec.ts │ │ │ └── request-parser.ts │ │ ├── resources-factory/ │ │ │ ├── index.ts │ │ │ ├── resources-factory.spec.js │ │ │ └── resources-factory.ts │ │ ├── router/ │ │ │ ├── index.ts │ │ │ ├── router.doc.md │ │ │ ├── router.spec.ts │ │ │ └── router.ts │ │ ├── uploaded-file.type.ts │ │ └── view-helpers/ │ │ ├── index.ts │ │ ├── view-helpers.spec.ts │ │ └── view-helpers.ts │ ├── constants.ts │ ├── core-scripts.interface.ts │ ├── current-admin.interface.ts │ ├── frontend/ │ │ ├── assets/ │ │ │ └── styles/ │ │ │ └── icomoon.css │ │ ├── bundle-entry.jsx │ │ ├── components/ │ │ │ ├── actions/ │ │ │ │ ├── action.props.ts │ │ │ │ ├── bulk-delete.tsx │ │ │ │ ├── edit.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── list.tsx │ │ │ │ ├── new.tsx │ │ │ │ ├── show.tsx │ │ │ │ └── utils/ │ │ │ │ ├── append-force-refresh.spec.ts │ │ │ │ ├── append-force-refresh.ts │ │ │ │ ├── index.ts │ │ │ │ └── layout-element-renderer.tsx │ │ │ ├── app/ │ │ │ │ ├── action-button/ │ │ │ │ │ ├── action-button.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── action-header/ │ │ │ │ │ ├── action-header-props.tsx │ │ │ │ │ ├── action-header.tsx │ │ │ │ │ ├── actions-to-button-group.spec.ts │ │ │ │ │ ├── actions-to-button-group.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── styled-back-button.tsx │ │ │ │ ├── admin-modal.tsx │ │ │ │ ├── app-loader.tsx │ │ │ │ ├── auth-background-component.tsx │ │ │ │ ├── base-action-component.tsx │ │ │ │ ├── breadcrumbs.tsx │ │ │ │ ├── default-dashboard.tsx │ │ │ │ ├── drawer-portal.tsx │ │ │ │ ├── error-boundary.tsx │ │ │ │ ├── error-message.tsx │ │ │ │ ├── filter-drawer.tsx │ │ │ │ ├── footer.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── language-select/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── language-select.tsx │ │ │ │ ├── logged-in.tsx │ │ │ │ ├── notice.tsx │ │ │ │ ├── records-table/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── no-records.tsx │ │ │ │ │ ├── property-header.spec.tsx │ │ │ │ │ ├── property-header.tsx │ │ │ │ │ ├── record-in-list.tsx │ │ │ │ │ ├── records-table-header.spec.tsx │ │ │ │ │ ├── records-table-header.tsx │ │ │ │ │ ├── records-table.spec.tsx │ │ │ │ │ ├── records-table.tsx │ │ │ │ │ ├── selected-records.tsx │ │ │ │ │ └── utils/ │ │ │ │ │ ├── display.tsx │ │ │ │ │ ├── get-bulk-actions-from-records.spec.ts │ │ │ │ │ └── get-bulk-actions-from-records.ts │ │ │ │ ├── sidebar/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── sidebar-branding.tsx │ │ │ │ │ ├── sidebar-footer.tsx │ │ │ │ │ ├── sidebar-pages.tsx │ │ │ │ │ ├── sidebar-resource-section.tsx │ │ │ │ │ └── sidebar.tsx │ │ │ │ ├── sort-link.tsx │ │ │ │ ├── top-bar.tsx │ │ │ │ ├── utils/ │ │ │ │ │ ├── discord-logo-svg.tsx │ │ │ │ │ └── rocket-svg.tsx │ │ │ │ └── version.tsx │ │ │ ├── application.tsx │ │ │ ├── index.ts │ │ │ ├── login/ │ │ │ │ └── index.tsx │ │ │ ├── property-type/ │ │ │ │ ├── array/ │ │ │ │ │ ├── add-new-item-translation.tsx │ │ │ │ │ ├── convert-to-sub-property.tsx │ │ │ │ │ ├── edit.spec.tsx │ │ │ │ │ ├── edit.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── list.tsx │ │ │ │ │ ├── remove-sub-property.spec.ts │ │ │ │ │ ├── remove-sub-property.ts │ │ │ │ │ └── show.tsx │ │ │ │ ├── base-property-component.doc.md │ │ │ │ ├── base-property-component.tsx │ │ │ │ ├── base-property-props.ts │ │ │ │ ├── boolean/ │ │ │ │ │ ├── boolean-property-value.tsx │ │ │ │ │ ├── edit.tsx │ │ │ │ │ ├── filter.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── list.tsx │ │ │ │ │ ├── map-value.tsx │ │ │ │ │ └── show.tsx │ │ │ │ ├── clean-property-component.tsx │ │ │ │ ├── currency/ │ │ │ │ │ ├── currency-input-wrapper.tsx │ │ │ │ │ ├── edit.tsx │ │ │ │ │ ├── filter.tsx │ │ │ │ │ ├── format-value.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── list.tsx │ │ │ │ │ └── show.tsx │ │ │ │ ├── datetime/ │ │ │ │ │ ├── edit.tsx │ │ │ │ │ ├── filter.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── list.tsx │ │ │ │ │ ├── map-value.ts │ │ │ │ │ ├── show.tsx │ │ │ │ │ └── strip-time-from-iso.ts │ │ │ │ ├── default-type/ │ │ │ │ │ ├── default-property-value.tsx │ │ │ │ │ ├── edit.tsx │ │ │ │ │ ├── filter.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── list.tsx │ │ │ │ │ ├── show.spec.tsx │ │ │ │ │ └── show.tsx │ │ │ │ ├── docs/ │ │ │ │ │ └── on-property-change.doc.md │ │ │ │ ├── index.tsx │ │ │ │ ├── key-value/ │ │ │ │ │ ├── edit.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── show.tsx │ │ │ │ ├── mixed/ │ │ │ │ │ ├── convert-to-sub-property.ts │ │ │ │ │ ├── edit.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── list.tsx │ │ │ │ │ └── show.tsx │ │ │ │ ├── password/ │ │ │ │ │ ├── edit.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── phone/ │ │ │ │ │ ├── edit.tsx │ │ │ │ │ ├── filter.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── list.tsx │ │ │ │ │ └── show.tsx │ │ │ │ ├── record-property-is-equal.ts │ │ │ │ ├── reference/ │ │ │ │ │ ├── edit.tsx │ │ │ │ │ ├── filter.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── list.tsx │ │ │ │ │ ├── reference-value.tsx │ │ │ │ │ └── show.tsx │ │ │ │ ├── richtext/ │ │ │ │ │ ├── edit.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── list.tsx │ │ │ │ │ └── show.tsx │ │ │ │ ├── textarea/ │ │ │ │ │ ├── edit.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── show.tsx │ │ │ │ └── utils/ │ │ │ │ ├── index.ts │ │ │ │ ├── property-description/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── property-description.tsx │ │ │ │ └── property-label/ │ │ │ │ ├── index.ts │ │ │ │ └── property-label.tsx │ │ │ ├── routes/ │ │ │ │ ├── bulk-action.tsx │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── page.tsx │ │ │ │ ├── record-action.spec.tsx │ │ │ │ ├── record-action.tsx │ │ │ │ ├── resource-action.tsx │ │ │ │ ├── resource.tsx │ │ │ │ └── utils/ │ │ │ │ ├── should-action-re-fetch-data.ts │ │ │ │ └── wrapper.tsx │ │ │ └── spec/ │ │ │ ├── action-json.factory.ts │ │ │ ├── factory.ts │ │ │ ├── initialize-translations.ts │ │ │ ├── page-json.factory.ts │ │ │ ├── property-json.factory.ts │ │ │ ├── record-json.factory.ts │ │ │ ├── resource-json.factory.ts │ │ │ └── test-context-provider.tsx │ │ ├── global-entry.js │ │ ├── hoc/ │ │ │ ├── allow-override.tsx │ │ │ ├── index.ts │ │ │ ├── with-no-ssr.tsx │ │ │ └── with-notice.ts │ │ ├── hooks/ │ │ │ ├── index.ts │ │ │ ├── use-action/ │ │ │ │ ├── index.ts │ │ │ │ ├── use-action-response-handler.ts │ │ │ │ ├── use-action.doc.md │ │ │ │ ├── use-action.ts │ │ │ │ └── use-action.types.ts │ │ │ ├── use-current-admin.ts │ │ │ ├── use-filter-drawer.tsx │ │ │ ├── use-history-listen.ts │ │ │ ├── use-local-storage/ │ │ │ │ ├── index.ts │ │ │ │ ├── use-local-storage-result.type.ts │ │ │ │ ├── use-local-storage.doc.md │ │ │ │ └── use-local-storage.ts │ │ │ ├── use-modal.doc.md │ │ │ ├── use-modal.ts │ │ │ ├── use-navigation-resources.ts │ │ │ ├── use-notice.ts │ │ │ ├── use-query-params.ts │ │ │ ├── use-record/ │ │ │ │ ├── filter-record.spec.ts │ │ │ │ ├── filter-record.ts │ │ │ │ ├── index.ts │ │ │ │ ├── is-entire-record-given.ts │ │ │ │ ├── merge-record-response.ts │ │ │ │ ├── params-to-form-data.spec.ts │ │ │ │ ├── params-to-form-data.ts │ │ │ │ ├── update-record.spec.ts │ │ │ │ ├── update-record.ts │ │ │ │ ├── use-record.doc.md │ │ │ │ ├── use-record.tsx │ │ │ │ └── use-record.type.ts │ │ │ ├── use-records/ │ │ │ │ ├── index.ts │ │ │ │ ├── use-records-result.type.ts │ │ │ │ ├── use-records.doc.md │ │ │ │ └── use-records.ts │ │ │ ├── use-resource/ │ │ │ │ ├── index.ts │ │ │ │ ├── use-resource.doc.md │ │ │ │ └── use-resource.ts │ │ │ ├── use-selected-records/ │ │ │ │ ├── index.ts │ │ │ │ ├── use-selected-records-result.type.ts │ │ │ │ ├── use-selected-records.doc.md │ │ │ │ └── use-selected-records.ts │ │ │ └── use-translation.ts │ │ ├── index.ts │ │ ├── interfaces/ │ │ │ ├── action/ │ │ │ │ ├── action-has-component.ts │ │ │ │ ├── action-href.ts │ │ │ │ ├── action-json.interface.ts │ │ │ │ ├── build-action-api-call-trigger.ts │ │ │ │ ├── build-action-click-handler.ts │ │ │ │ ├── build-action-test-id.ts │ │ │ │ ├── call-action-api.ts │ │ │ │ ├── index.ts │ │ │ │ ├── is-bulk-action.ts │ │ │ │ ├── is-record-action.ts │ │ │ │ └── is-resource-action.ts │ │ │ ├── index.ts │ │ │ ├── modal.interface.ts │ │ │ ├── noticeMessage.interface.ts │ │ │ ├── page-json.interface.ts │ │ │ ├── property-json/ │ │ │ │ ├── index.ts │ │ │ │ └── property-json.interface.ts │ │ │ ├── record-json.interface.ts │ │ │ └── resource-json.interface.ts │ │ ├── layout-template.spec.ts │ │ ├── layout-template.tsx │ │ ├── login-template.spec.ts │ │ ├── login-template.tsx │ │ ├── store/ │ │ │ ├── actions/ │ │ │ │ ├── add-notice.ts │ │ │ │ ├── drop-notice.ts │ │ │ │ ├── filter-drawer.ts │ │ │ │ ├── index.ts │ │ │ │ ├── initialize-assets.ts │ │ │ │ ├── initialize-branding.ts │ │ │ │ ├── initialize-dashboard.ts │ │ │ │ ├── initialize-locale.ts │ │ │ │ ├── initialize-pages.ts │ │ │ │ ├── initialize-paths.ts │ │ │ │ ├── initialize-resources.ts │ │ │ │ ├── initialize-theme.ts │ │ │ │ ├── initialize-versions.ts │ │ │ │ ├── modal.ts │ │ │ │ ├── route-changed.ts │ │ │ │ ├── set-current-admin.ts │ │ │ │ ├── set-drawer-preroute.ts │ │ │ │ └── set-notice-progress.ts │ │ │ ├── index.ts │ │ │ ├── initialize-store.ts │ │ │ ├── reducers/ │ │ │ │ ├── assetsReducer.ts │ │ │ │ ├── brandingReducer.ts │ │ │ │ ├── dashboardReducer.ts │ │ │ │ ├── drawerReducer.ts │ │ │ │ ├── filterDrawerReducer.ts │ │ │ │ ├── index.ts │ │ │ │ ├── localesReducer.ts │ │ │ │ ├── modalReducer.ts │ │ │ │ ├── noticesReducer.ts │ │ │ │ ├── pagesReducer.ts │ │ │ │ ├── pathsReducer.ts │ │ │ │ ├── resourcesReducer.ts │ │ │ │ ├── routerReducer.ts │ │ │ │ ├── sessionReducer.ts │ │ │ │ ├── themeReducer.ts │ │ │ │ └── versionsReducer.ts │ │ │ ├── store.ts │ │ │ └── utils/ │ │ │ └── pages-to-store.ts │ │ └── utils/ │ │ ├── adminjs.i18n.ts │ │ ├── api-client.ts │ │ ├── data-css-name.ts │ │ ├── index.ts │ │ └── overridable-component.ts │ ├── index.ts │ ├── locale/ │ │ ├── config.ts │ │ ├── de/ │ │ │ └── translation.json │ │ ├── default-config.ts │ │ ├── en/ │ │ │ └── translation.json │ │ ├── es/ │ │ │ └── translation.json │ │ ├── index.ts │ │ ├── it/ │ │ │ └── translation.json │ │ ├── ja/ │ │ │ └── translation.json │ │ ├── pl/ │ │ │ └── translation.json │ │ ├── pt-BR/ │ │ │ └── translation.json │ │ ├── ua/ │ │ │ └── translation.json │ │ └── zh-CN/ │ │ └── translation.json │ └── utils/ │ ├── error-type.enum.ts │ ├── file-resolver.ts │ ├── flat/ │ │ ├── constants.ts │ │ ├── filter-out-params.doc.md │ │ ├── filter-out-params.spec.ts │ │ ├── filter-out-params.ts │ │ ├── flat-module.ts │ │ ├── flat.doc.md │ │ ├── flat.types.ts │ │ ├── get.doc.md │ │ ├── get.spec.ts │ │ ├── get.ts │ │ ├── index.ts │ │ ├── merge.spec.ts │ │ ├── merge.ts │ │ ├── path-parts.type.ts │ │ ├── path-to-parts.doc.md │ │ ├── path-to-parts.ts │ │ ├── property-key-regex.ts │ │ ├── remove-path.doc.md │ │ ├── remove-path.spec.ts │ │ ├── remove-path.ts │ │ ├── select-params.doc.md │ │ ├── select-params.spec.ts │ │ ├── select-params.ts │ │ ├── set.doc.md │ │ ├── set.spec.ts │ │ └── set.ts │ ├── index.ts │ ├── param-converter/ │ │ ├── constants.ts │ │ ├── convert-nested-param.spec.ts │ │ ├── convert-nested-param.ts │ │ ├── convert-param.spec.ts │ │ ├── convert-param.ts │ │ ├── index.ts │ │ ├── param-converter-module.ts │ │ ├── prepare-params.ts │ │ └── validate-param.ts │ ├── theme-bundler.ts │ └── translate-functions.factory.ts ├── tsconfig.json └── vendor-types/ ├── chai/ │ └── index.d.ts ├── global.ts └── node/ └── node.d.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc.json ================================================ { "presets": [ "@babel/preset-react", ["@babel/preset-env", { "targets": { "node": "18" }, "loose": true, "modules": false }], ["@babel/preset-typescript"] ], "plugins": [ "@babel/plugin-syntax-import-assertions" ], "only": ["src/", "spec/"], "ignore": [ "src/frontend/assets/scripts/app-bundle.development.js", "src/frontend/assets/scripts/app-bundle.production.js", "src/frontend/assets/scripts/global-bundle.development.js", "src/frontend/assets/scripts/global-bundle.production.js" ], "generatorOpts": { "importAttributesKeyword": "with" } } ================================================ FILE: .cspell.json ================================================ { "version": "0.1", "language": "en", "words": [ "crossorigin", "nullish", "envs", "prefetch", "expressjs", "icomoon", "populator", "unflatten", "datetime", "richtext", "promisify", "hoverable", "dropdown", "flatpickr", "codecov", "bulma", "unmount", "testid", "woff", "iife", "sourcemap", "Roboto", "camelize", "datepicker", "camelcase", "fullwidth", "wysiwig", "Helvetica", "Neue", "Arial", "nowrap", "textfield", "scrollable", "flexbox", "treal", "xxxl", "adminjs", "Checkmark", "overridable", "Postgres", "Hana", "Wojtek", "Krysiak", "bigint", "borderless", "metadetaksamosone", "esrever", "jsnext", "Никита" ], "ignorePaths": [ "src/frontend/assets/**/*" ] } ================================================ FILE: .dockerignore ================================================ node_modules npm-debug.log ================================================ FILE: .eslintrc.cjs ================================================ module.exports = { parser: '@typescript-eslint/parser', plugins: ['@typescript-eslint', 'mocha'], env: { es6: true, node: true, mocha: true, }, extends: ['airbnb', 'plugin:@typescript-eslint/recommended', 'plugin:mocha/recommended', 'plugin:import/typescript'], parserOptions: { ecmaVersion: 2020, sourceType: 'module', }, rules: { 'react/jsx-filename-extension': 'off', '@typescript-eslint/no-explicit-any': 'off', indent: ['error', 2], '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/ban-ts-ignore': 'off', 'import/prefer-default-export': 'off', 'linebreak-style': ['error', 'unix'], quotes: ['error', 'single'], semi: ['error', 'never'], 'import/no-unresolved': 'off', 'func-names': 'off', 'no-underscore-dangle': 'off', 'guard-for-in': 'off', 'no-restricted-syntax': 'off', 'no-await-in-loop': 'off', 'object-curly-newline': 'off', 'import/extensions': [2, 'ignorePackages'], 'mocha/no-hooks-for-single-case': 'off', 'no-param-reassign': 'off', 'default-param-last': 'off', 'no-use-before-define': 'off', 'no-restricted-exports': 'off', 'react/require-default-props': 'off', 'react/jsx-props-no-spreading': 'off', 'react/function-component-definition': 'off', 'max-classes-per-file': 'off', '@typescript-eslint/ban-ts-comment': 'off', 'import/no-import-module-exports': 'off', }, ignorePatterns: [ '*/build/**/*', '*.json', '*.txt', '*.md', '*.lock', '*.log', '*.yaml', '**/*/frontend/assets/**/*', '*.d.ts', '*.config.js', ], overrides: [ { files: ['*-test.js', '*.spec.js', '*-test.ts', '*.spec.ts', '*.spec.tsx', '*.factory.ts', '*.factory.js'], rules: { 'no-unused-expressions': 'off', 'func-names': 'off', 'prefer-arrow-callback': 'off', 'import/no-extraneous-dependencies': 'off', 'mocha/no-mocha-arrows': 'off', '@typescript-eslint/explicit-function-return-type': 'off', }, }, { files: ['*.jsx', '*.js'], rules: { '@typescript-eslint/explicit-function-return-type': 'off', }, }, { files: ['*.cjs'], rules: { 'import/no-commonjs': 'off', }, }, { files: ['*.tsx'], rules: { 'react/prop-types': 'off', 'react/function-component-definition': 'off', }, }, { files: ['**/*/cypress/integration/**/*.spec.js', './cy/**/*.js'], rules: { 'mocha/no-mocha-arrows': 'off', 'spaced-comment': 'off', }, }, ], settings: { 'import/resolver': { node: { extensions: ['.js', '.jsx', '.ts', '.tsx'], }, }, }, globals: { expect: true, factory: true, sandbox: true, server: true, window: true, AdminJS: true, flatpickr: true, FormData: true, File: true, cy: true, Cypress: true, }, } ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: File a bug report title: "[Bug]: " labels: ["bug", "triage"] assignees: - octocat body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! - type: input id: contact attributes: label: Contact Details description: How can we get in touch with you if we need more info? placeholder: Please visit our Discord channel [Discord](https://adminjs.page.link/discord) or leave your email. validations: required: false - type: textarea id: what-happened attributes: label: What happened? description: Also tell us, what did you expect to happen? placeholder: Tell us what you see! value: "List what you are trying to do?" validations: required: true - type: input id: prevalence attributes: label: Bug prevalence description: "How often do you or others encounter this bug?" placeholder: "Example: Whenever I visit the personal account page (1-2 times a week)" validations: required: true - type: textarea id: versions attributes: label: AdminJS dependencies version description: "Provide to us a list of dependencies from package.json " placeholder: "Copy exact versions of plugins that you are currently using" validations: required: true - type: dropdown id: browsers attributes: label: What browsers do you see the problem on? multiple: true options: - Firefox - Chrome - Safari - Microsoft Edge - type: textarea id: logs attributes: label: Relevant log output description: Please copy and paste any relevant log output or drag&drop screenshot of code. This will be automatically formatted into code, so no need for backticks. render: bash - type: textarea id: code attributes: label: Relevant code that's giving you issues description: Please copy and paste any relevant code or drag&drop screenshot of code. This will be automatically formatted into code, so no need for backticks. render: TypeScript ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ --- name: 🛠️ Feature Request description: Suggest an idea to help us improve AdminJS title: "[Feature]: " labels: - "feature_request" body: - type: markdown attributes: value: | **Thanks :heart: for taking the time to fill out this feature request report!** We kindly ask that you search to see if an issue [already exists](https://github.com/SoftwareBrothers/adminjs#:~:text=OpenSource%20SoftwareBrothers%20community-,Join%20the%20community,-to%20get%20help) for your feature. We are also happy to accept contributions from our users. For more details see [here](https://github.com/SoftwareBrothers/adminjs/blob/master/CONTRIBUTING.md). - type: textarea attributes: label: Description description: | A clear and concise description of the feature you're interested in. validations: required: true - type: textarea attributes: label: Suggested Solution description: | Describe the solution you'd like. A clear and concise description of what you want to happen. validations: required: true - type: textarea attributes: label: Alternatives description: | Describe alternatives you've considered. A clear and concise description of any alternative solutions or features you've considered. validations: required: false - type: textarea attributes: label: Additional Context description: | Add any other context about the problem here. validations: required: false ================================================ FILE: .github/workflows/push.yml ================================================ name: CI/CD on: [push, pull_request] jobs: setup: name: setup runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 - name: Setup uses: actions/setup-node@v3 with: node-version: '18' cache: 'yarn' - name: Install run: yarn install --frozen-lockfile build: name: build runs-on: ubuntu-latest needs: setup steps: - name: Checkout uses: actions/checkout@v3 - name: Setup uses: actions/setup-node@v3 with: node-version: '18' cache: 'yarn' - name: Install run: yarn install - name: Assets cache uses: actions/cache@v3 id: assets-cache with: path: src/frontend/assets/scripts key: assets-${{ hashFiles('**/src/frontend/global-entry.js') }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**/bin/bundle-globals.js') }} restore-keys: | assets- - name: build run: yarn build - name: types run: yarn types - name: bundle globals production if: steps.assets-cache.outputs.cache-hit != 'true' run: NODE_ENV=production yarn bundle:globals - name: bundle globals dev if: steps.assets-cache.outputs.cache-hit != 'true' run: NODE_ENV=dev yarn bundle:globals - name: bundle production run: NODE_ENV=production ONCE=true yarn bundle - name: bundle dev run: ONCE=true yarn bundle - name: Upload Build if: | contains(github.ref, 'refs/heads/master') || contains(github.ref, 'refs/heads/beta-v7') || contains(github.ref, 'refs/heads/beta') uses: actions/upload-artifact@v4 with: name: lib path: lib - name: Upload Types if: | contains(github.ref, 'refs/heads/master') || contains(github.ref, 'refs/heads/beta-v7') || contains(github.ref, 'refs/heads/beta') uses: actions/upload-artifact@v4 with: name: types path: types - name: Upload Bundle if: | contains(github.ref, 'refs/heads/master') || contains(github.ref, 'refs/heads/beta-v7') || contains(github.ref, 'refs/heads/beta') uses: actions/upload-artifact@v4 with: name: bundle path: lib/frontend/assets/scripts test: name: Test runs-on: ubuntu-latest needs: setup steps: - name: Checkout uses: actions/checkout@v3 - name: Setup uses: actions/setup-node@v3 with: node-version: '18' cache: 'yarn' - name: Install run: yarn install - name: Lint run: yarn lint - name: spell run: yarn cspell - name: install codecov run: yarn global add codecov if: contains(github.ref, 'refs/heads/master') - name: cover if: contains(github.ref, 'refs/heads/master') run: yarn codecov env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - name: test if: "!contains(github.ref, 'refs/heads/master')" run: yarn test publish: name: Publish if: | github.event_name == 'push' && ( contains(github.ref, 'refs/heads/master') || contains(github.ref, 'refs/heads/beta-v7') || contains(github.ref, 'refs/heads/beta') ) needs: - test - build services: mongo: image: mongo:3.4.23 ports: - 27017:27017 runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 - name: Setup uses: actions/setup-node@v3 with: node-version: '18' cache: 'yarn' - name: Install run: yarn install - name: Download Build uses: actions/download-artifact@v4 with: name: lib path: lib - name: Download Types uses: actions/download-artifact@v4 with: name: types path: types - name: Download Bundle uses: actions/download-artifact@v4 with: name: bundle path: bundle - name: Check directories exists uses: andstor/file-existence-action@v2 with: files: "bundle/, lib/, types/" fail: true - name: Release env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }} JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} run: yarn release ================================================ FILE: .gitignore ================================================ .nyc_output .DS_store .vscode .nova .idea .cache lib built types coverage docker-compose.ovverride.yml node_modules src/frontend/assets/scripts .adminjs # cypress output **/*/cypress/videos **/*/cypress/screenshots /packages/app/build .env firebase.env.json yarn-error.log ================================================ FILE: .npmignore ================================================ .nyc_output .DS_store .vscode .idea .travis.yml /coverage docker-compose.ovverride.yml node_modules docs /packages ================================================ FILE: .npmrc ================================================ access=public ================================================ FILE: .prettierrc ================================================ { "printWidth": 100, "singleQuote": true, "trailingComma": "all", "semi": false, "overrides": [ { "files": ["*.ts", "*.tsx"], "options": { "parser": "typescript" } }, { "files": "*.css", "options": { "singleQuote": false, "tabWidth": 2 } } ] } ================================================ FILE: .releaserc ================================================ { "branches": [ "+([0-9])?(.{+([0-9]),x}).x", "master", { "name": "beta", "prerelease": true }, { "name": "beta-v7", "prerelease": true } ], "plugins": [ ["@semantic-release/commit-analyzer", { "preset": "angular", "releaseRules": [ {"type": "feat", "scope": "locale", "release": "patch"}, {"type": "feat", "scope": "small", "release": "patch"}, {"type": "chore", "scope": "deps", "release": "patch"}, {"scope": "no-release", "release": false} ] }], "@semantic-release/release-notes-generator", "@semantic-release/npm", "@semantic-release/github", "@semantic-release/git" ] } ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: - Using welcoming and inclusive language - Being respectful of differing viewpoints and experiences - Gracefully accepting constructive criticism - Attempting collaboration before conflict - Focusing on what is best for the community - Showing empathy towards other community members Examples of unacceptable behavior by participants include: - Violence, threats of violence, or inciting others to commit self-harm - The use of sexualized language or imagery and unwelcome sexual attention or advances - Trolling, intentionally spreading misinformation, insulting/derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or electronic address, without explicit permission - Abuse of the reporting process to intentionally harass or exclude others - Advocating for, or encouraging, any of the above behavior - Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting us through adminjs@adminjs.co. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. If you are unsure whether an incident is a violation, or whether the space where the incident took place is covered by our Code of Conduct, **we encourage you to still report it**. We would prefer to have a few extra reports where we decide to take no action, than to leave an incident go unnoticed and unresolved that may result in an individual or group to feel like they can no longer participate in the community. Reports deemed as not a violation will also allow us to improve our Code of Conduct and processes surrounding it. If you witness a dangerous situation or someone in distress, we encourage you to report even if you are only an observer. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html ================================================ FILE: CONTRIBUTING.md ================================================ # AdminJS Development ## Explanation of AdminJS libraries There are three main terms we use to define AdminJS libraries, which are: * Core - the main library, containing basically all the logic which you can find in `adminjs` repository * Plugin - plugins are wrappers around popular frameworks which allow you to connect AdminJS to your Node.js server and build AdminJS-specific routes. For example `@adminjs/express`. * Adapter - adapters are wrappers around supported ORMs and ODMs. They export classes which extend `BaseDatabase`, `BaseResource` and `BaseProperty` components. Their task is to translate AdminJS specific filters/queries to something understandable by your ORM/ODM of choice in order to communicate with the database. For example `@adminjs/typeorm`. AdminJS also has got it's own design system library (`@adminjs/design-system`) and a set of premade `features` which you can import into your project (e. g. `@adminjs/passwords`). ## Contributing ### Development If you want to contribute to the project, fork repositories you will be working on and after you're done with your changes, open a pull request. AdminJS team uses [semantic-release](https://github.com/semantic-release/semantic-release) library to automatically create `releases` when a pull request is merged to `master` branch or `pre-releases` when a pull request is merged to `beta` branch. Additionally, based on your commit messages, the library automatically generates a changelog. Because of this, there is a strict requirement on how you should name your `branches` and `commits`. Branch names should be prefixed with `fix/`, `chore/`, `feat/` or `test/`. Commit messages should start with `fix:`, `chore:`, `feat:`, `test:` with the following rules: 1. When a commit starting with `chore:` or `test:` is merged, it **will not** create a new release. 2. When a commit starting with `fix:` is merged, a release will be created which upgrades the patch version of the library (example: 6.0.0 -> 6.0.1). 3. When a commit starting with `feat:` is merged, a release will be created which upgrades the minor version of the library (example: 6.0.1 -> 6.1.0). 4. When a commit has `BREAKING CHANGE: xxxxx` inside of it's **description**, a release will be created which upgrades the major version of the library (example: 6.1.0 -> 7.0.0). 5. There are also additional rules defined in `.releaserc`: - commits starting with `feat(locale):` and `feat(small):` will only upgrade the patch version despite being `feat` (feature) commits. This type of commit message should be used when you want to create a new translation (`feat(locale)`) or when you add a small feature that doesn't affect the library much (`feat(small)`). - commits starting with `chore(deps):` will upgrade the patch version despite being `chore` commits. These are commits automatically created by [dependabot](https://github.com/dependabot) but you should also use this message type when you want to only upgrade a package manually. 6. When a pull request is merged which contains multiple commit messages, the higher version upgrades take precedence, for example a pull request with `feat:` and `fix:` messages will upgrade the `minor version` (because of `feat: ...` message) but both commit messages will appear in the changelog. When you work on your bug fixes or new features, make sure they only concern the subject of your pull request. Ideally you would be using `git commit --amend` so that there's only one commit in your pull request. If for some reason you want to make more than one change and you need to have multiple commit messages in a pull request, each commit message should only contain changes it refers to. When a pull request contains a lot of commits without proper messages, we usually `squash` them when merging which can result in a poor release changelog. ### Issues When creating an issue please try to describe your problem with as many details as possible. If your issue is a complex one and would require us to reproduce this to respond, a test repository or a code sample which would allow us to reproduce your problem would be ideal. If possible, try to create issues by using our issues templates. ### Translations You can contribute translations via the [fink localization editor](https://fink.inlang.com/github.com/SoftwareBrothers/adminjs). [![inlang status badge](https://badge.inlang.com/?url=github.com/SoftwareBrothers/adminjs)](https://fink.inlang.com/github.com/SoftwareBrothers/adminjs?ref=badge) ## Developing locally ### Basic example To see your changes in your local environment you will, first of all, need a test application. You can clone our [example app](https://github.com/SoftwareBrothers/adminjs-example-app) or create your own. Next, open terminal in your `adminjs` fork repository, install dependencies and run commands: ```bash $ yarn dev $ yarn bundle:globals # re-run it only after you install new dependencies ``` After this, run: ```bash $ yarn link ``` to register `adminjs` under yarn's linked packages. Now open terminal in your test application's project and link your local `adminjs` package: ```bash $ yarn link "adminjs" ``` You should be able to see your local changes to `adminjs` package in your test application. However, you might have to restart your test application each time you make changes to `adminjs` local package. ### Advanced example The case described above is the easiest one. Now let's assume you want to make changes to `@adminjs/design-system` package. This will require you to chain `yarn link` commands: 1. Fork and clone `adminjs-design-system` repository 2. Install dependencies of `adminjs-design-system` and run `yarn dev` 3. Register `@adminjs/design-system` under yarn's linked packages: `yarn link` 4. Since `@adminjs/design-system` is a dependency of `adminjs`, open `adminjs` project locally and run: ```bash $ yarn link "@adminjs/design-system" ``` and rebuild it. 5. Run `yarn link` to register `adminjs` under yarn's linked packages (if you haven't done it before). 6. Link `adminjs` package in your test application: ```bash $ yarn link "adminjs" ``` If you try to run your test application now, it should build fine, but you might see some errors when you try to open admin panel in your browser concerning multiple React instances. This error occurs because when you install repository's dependencies locally, it will install all of them, meaning you will have `react` installed in your `adminjs-design-system` and `adminjs` folders but you may have only one React instance at a time. The easiest way to solve this would be to use a linked `react` package: 1. In your test application, run: ```bash $ yarn add react@^18.1.0 react-dom@^18.1.0 styled-components@^5.3.5 ``` We're also installing `react-dom` and `styled-components` because these two libraries also cause similar issues to `react`. 2. Navigate to installed `react`, `react-dom` and `styled-components` and register them under yarn linked packages: ```bash $ cd node_modules/react && yarn link $ cd ../../node_modules/react-dom && yarn link $ cd ../../node_modules/react-i18next && yarn link $ cd ../../node_modules/styled-components && yarn link ``` 3. Now link those packages in your local `adminjs` and `adminjs-design-system` projects: ```bash $ yarn link "react" $ yarn link "react-dom" $ yarn link "react-i18next" $ yarn link "styled-components" ``` 4. Your test application should now be working. Having lots of registered packages can bring confusion, you can use the command below to find out which packages you have linked and where they are used: ```bash $ ls -la ~/.config/yarn/link/ ``` `yarn link` documentation: https://classic.yarnpkg.com/en/docs/cli/link ### Alternative to yarn link The alternative to `yarn link` is to clone our [adminjs-dev](https://github.com/SoftwareBrothers/adminjs-dev) repository. This repository has all AdminJS packages added as submodules in one [yarn workspace](https://classic.yarnpkg.com/lang/en/docs/workspaces/). The repository's README should be detailed enough to get you started. Please note that using `yarn workspaces` also has it's issues that we haven't yet resolved. One of them is that all submodules dependencies are installed in the top level (in the root directory of `adminjs-dev`) and installing or updating any new dependency in for example `packages/adminjs` won't update it's `yarn.lock` file and you have to do it manually. The upside is that you don't have to deal with `yarn link` dependency issues plus you're able to see your local changes in other submodules in the same workspace immediately. ================================================ FILE: LICENSE.md ================================================ Copyright 2018 SoftwareBrothers.co Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # AdminJS [AdminJS](https://adminjs.co/) is an automatic admin interface that can be plugged into your application. You, as a developer, provide database models (like posts, comments, stores, products or whatever else your application uses), and AdminJS generates UI which allows you (or other trusted users) to manage content. Inspired by: [django admin](https://docs.djangoproject.com), [rails admin](https://github.com/sferik/rails_admin) and [active admin](https://activeadmin.info/). ## Example application Check out our demo application: - Login: `example@adminjs.co` - Password: `password` https://demo.adminjs.co You can also have a look at our customized AdminJS dashboard which shows various library statistics: https://stats.adminjs.co ## Getting started - Check out the [documentation](https://docs.adminjs.co) - Try the [live demo](https://demo.adminjs.co) as mentioned above ## Our open source community on Discord - [Join the community](https://adminjs.page.link/discord) to get help and be inspired. # What kind of problems it solves So you have a working service built in Node.js. It uses (for example) [Hapi.js](https://hapijs.com/) for rendering a couple of REST routes and [mongoose](https://mongoosejs.com/) as the _connector_ to the database. Everything works fine, but now you would like to: * view all the data in the app, * perform custom _business_ actions on objects in the database, * bootstrap the tables with the _initial_ data, * build custom report pages, * allow other team members (not necessary programmers) to see what is going on in the application. And all these cases can be solved by AdminJS. By adding couple of lines of code you have a running admin interface. # Features * CRUD any data in any resource * Custom actions * Form validation based on schema in your resources * Full featured dashboard with widgets * Custom resource decorators ## Contribute If you would like work on an AdminJS and develop new features please check out our [Contribution Guide](https://github.com/SoftwareBrothers/adminjs/blob/master/CONTRIBUTING.md) There you can find instructions on how to run AdminJS locally for development. If you're searching for tasks you can contribute to, we currently accept contributions to issues in our [Kanban Board](https://github.com/orgs/SoftwareBrothers/projects/5/views/1). Any small or large contribution or any input into discussion is welcome! ## License AdminJS is copyrighted © 2023 rst.software. It is a free software, and may be redistributed under the terms specified in the [LICENSE](LICENSE.md) file. ## About rst.software We’re an open, friendly team that helps clients from all over the world to transform their businesses and create astonishing products. * We are available for [hire](https://www.rst.software/estimate-your-project). * If you want to work for us - check out the [career page](https://www.rst.software/join-us). ================================================ FILE: UPGRADE-6.0.md ================================================ # Migration guide to version v6 ## Updating AdminJS to v6 To update AdminJS package to the sixth wersion please use following npm command ``` npm install adminjs ``` This should update the version of ```adminjs``` and ```adminjs-design-system``` packages to newest beta versions. If you have ```adminjs-design-system``` in your dependencies you should update it accordingly. ## Changes ### :warning: React upgrade to v18.1.0+ **If you don't have react as a dependency in your project you won't have to do anything 😉** AdminJS now uses ```react``` and ```react-dom``` in ```v18.1.0+``` as a dependency. Hence if you're using react outside of AdminJS, please upgrade it to the matching version. Instructions on how to do it are available [here](https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html) Additionally, we upgraded the ```styled-components``` package to ```v5.3.5```, which works well with react v18. ### :warning: Rebranding Branding option `softwareBrothers` is now not supported and replaced with `withMadeWithLove` which shows a tiny heart icon on the bottom sidebar and login page. ### ⚠️ New RichText input library Due to security and support issues, richText implementation has changed from Quill to TipTap. Therefore, in AdminJS v6, **all quill-related configurations will no longer be valid.** ### :white_check_mark: Phone and currency inputs AdminJS gained two new input types. To use phone and currency inputs, use them as a type in your resource option. **Example of use** ```ts const TransactionResource: ResourceWithOptions = { resource: Transaction, options: { properties: { price: { type: 'currency', }, phone: { type: 'phone', }, }, navigation: { name: 'App', icon: 'Settings', } } }; ``` ### :white_check_mark: Select component available in ```adminjs-design-system``` The select component was extracted from the core package to ```adminjs-design-system```. You no longer need to implement such a component on your own. ================================================ FILE: bin/app.js ================================================ import bundler from '../lib/backend/bundler/app.bundler.js' await bundler.build() ================================================ FILE: bin/globals.js ================================================ import bundler from '../lib/backend/bundler/globals.bundler.js' await bundler.build() ================================================ FILE: cli.js ================================================ #!/usr/bin/env node // eslint-disable-next-line import('./lib/cli') ================================================ FILE: commitlint.config.cjs ================================================ module.exports = { extends: [ '@commitlint/config-conventional', ], } ================================================ FILE: cy/commands/ab-get-property.js ================================================ /// /** * @method abGetProperty * @description * ### Usage * * ```javascript * // your property in AdminJS * resourceOptions: { * properties: { * isAdmin: {...} * } * } * ``` * * ```javascript * // accessing it in test. * cy.abGetProperty('isAdmin', 'input[type="checkbox"]') * ``` * * `abGetProperty` returns the wrapper Section for a given property. You can pass inner element * which allows you to select `input`, `label`, `options`, etc. inside it. * * @memberof module:cy * @param {string} path path of the property * @param {string} [selector=null] * @example * it('shows disabled checkBox', () => { * cy.abGetProperty('isAdmin', 'input[type="checkbox"]') * .should('be.disabled') * .should('not.be.checked') * * cy.abGetProperty('isAdmin', 'label') * .click() * .should('not.be.checked') * }) */ Cypress.Commands.add('abGetProperty', (path, selector = null) => { let propertySelector = `[data-testid$="-${path}"]` if (selector) { propertySelector = [propertySelector, selector].join(' ') } return cy.get(propertySelector) }) ================================================ FILE: cy/commands/ab-keep-logged-in.js ================================================ /// /** * @method abKeepLoggedIn * @memberof module:cy * @param {object} [options] * @param {object} [options.cookie] session cookie name: default to Cypress.env('AB_COOKIE_NAME') * @example * before(() => { * cy.abLogin() * }) * * beforeAll(() => { * cy.abKeepLoggedIn({ cookie: 'my-session-cookie' }) * cy.visit('your/path') * }) */ Cypress.Commands.add('abKeepLoggedIn', ({ cookie }) => { Cypress.Cookies.preserveOnce(cookie || Cypress.env('AB_COOKIE_NAME')) }) ================================================ FILE: cy/commands/ab-login-api.js ================================================ /// /** * @method abLoginAPI * @memberof module:cy * @description * Comparing to {@link module:cy.abLogin} it doesn't render page - just performs an API call * and logs you in by storing the cookie. In order to preserve this cookie between the `it()` * calls you have to use {@link module:cy.abKeepLoggedIn} helper. * @param {object} [options] * @param {object} [options.email] login email: default to Cypress.env('AB_EMAIL') * @param {object} [options.password] login password: default to Cypress.env('AB_PASSWORD') * @param {object} [options.loginPath] default to '/login' */ Cypress.Commands.add('abLoginAPI', ({ email, password, loginPath } = {}) => ( cy.request('POST', loginPath || '/login', { email: email || Cypress.env('AB_EMAIL'), password: password || Cypress.env('AB_PASSWORD'), }) )) ================================================ FILE: cy/commands/ab-login.js ================================================ /// /** * @method abLogin * @description * logs you to the AdminJS. Since the system uses cookie for storing the session information, you * can use {@link module:cy.abKeepLoggedIn} helper to keep it between test cases. * @memberof module:cy * @param {object} [options] * @param {object} [options.email] login email: default to Cypress.env('AB_EMAIL') * @param {object} [options.password] login password: default to Cypress.env('AB_PASSWORD') * @param {object} [options.loginPath] default to '/login' */ Cypress.Commands.add('abLogin', ({ email, password, loginPath } = {}) => { cy.visit(loginPath || '/login') cy.get('[name=email]').type(email || Cypress.env('AB_EMAIL')) cy.get('[name=password]').type(password || Cypress.env('AB_PASSWORD')) cy.get('button').click() }) ================================================ FILE: cy/cypress.doc.md ================================================ ### Cypress helpers This module gathers helpers which can be used when you E2E test your AdminJS dashboard with {@link https://www.cypress.io/} as we do. ### Usage First, you have to import helpers to your cypress project. You can do this in: `/support/index.js` or `/support/commands.js` ```javascript require('adminjs/cy') ``` and now you can use our helpers ```javascript /// /// context('resources/Company/actions/new', () => { before(() => { cy.abLoginAPI({ password: Cypress.env('ADMIN_PASSWORD'), email: Cypress.env('ADMIN_EMAIL') }) }) beforeEach(() => { cy.abKeepLoggedIn({ cookie: Cypress.env('COOKIE_NAME') }) cy.visit('resources/Company/actions/new') }) //... }) ``` ### What we have Cypress helpers project is currently in the WIP/POC phase, that is why there are not much helpers here. But you can expect that gradually we will add more. ================================================ FILE: cy/index.d.ts ================================================ /// declare namespace Cypress { type AbLoginParams = { email?: string; password?: string; loginPath?: string; } type AbKeepLoggedInParams = { cookie?: string; } interface Chainable { abLogin(params?: AbLoginParams): Chainable; abLoginAPI(params?: AbLoginParams): Chainable; abGetProperty(propertyPath: string, selector?: string): Chainable; abKeepLoggedIn(params?: AbKeepLoggedInParams): Chainable; } } ================================================ FILE: cy/index.js ================================================ /** * @module cy * @load ./cypress.doc.md */ import './commands/ab-login.js' import './commands/ab-login-api.js' import './commands/ab-keep-logged-in.js' import './commands/ab-get-property.js' ================================================ FILE: cy/readme.md ================================================ ## Test suite: Login page form ### LPF-1: Log in to the app with a valid email and password **Test description:** Verify if a user will be able to log in with a valid email and password **Type:** Functional **Priority:** High **Severity:** Critical **Behavior:** Positive **Automation status:** To be automated **Tags:** login_page, login_form #### Preconditions * Open a login page in a browser([https://demo.adminjs.co/admin](https://demo.adminjs.co/admin)) * User is not already logged and has got an active account
No. Steps Data Expected results
1 Enter a valid email address in the “Email” input element test@example.com The field has been completed
2 Enter a valid password in the “Password” input element password The field has been completed with “*” signs
3 Click on the “Login” button Login should be successful
4 Check the URL address User should be on the “.../admin” page
## Test suite: Mongoose Resources forms ### MRF-1: Create a new element via form on the “Complicated” list **Test description:** Verify if a user will be able to create a new element on the list **Type:** Functional **Priority:** Medium **Severity:** Normal **Behavior:** Positive **Automation status:** To be automated **Tags:** mongoose_resources, complicated_category, complicated_form #### Precondition: * User is already logged into the application
No. Steps Data Expected results
1 Click on the hamburger menu if the navigation panel is not visible Navigation panel is visible
2 Click on the “Mongoose Resources” → Complicated link inside the navigation panel User should be redirected to the “.../admin/resources/Complicated” page
3 Hide the navigation panel if you launch it via hamburger menu Navigation panel is not visible. Hamburger menu is visible
4 Click on the “Create new” button The form is visible. User should be on the “.../admin/resources/Complicated/actions/new” page
5 Fill the “Name” input element with a random value e.g. Alex The field has been completed
6 Click on the “Add New Item” button in the “String Array” section A text input element and bin icon are shown
7 Click on the bin icon The text input element was removed
8 Click on the “Add New Item” button again in the “String Array” section A text input element and bin icon are shown again
9 Fill the text input element with a random value e.g. String Array The field has been completed
10 Click on the “Add New Item” button in the “Authors” section A text input element and bin icon are shown
11 Choose one of the elements from the dropdown list e.g. Books The field has been completed
12 Fill the “Nested Details Age” input element with a random value e.g. 26 The field has been completed
13 Fill the “Nested Details Height” input element with a random value e.g. 187 The field has been completed
14 Fill the “Nested Details Place Of Birth” input element with a random value e.g. Warsaw The field has been completed
15 Fill the “Nested Details Place Of Birth” input element with a random value e.g. Extremely Nested Text The field has been completed
16 Click on the “Add New Item” button in the “Parents” section Two text input elements (“Parents Name”, “Parents Surname”) and the bin icon are shown
17 Fill the “Parents Name” input element with a random value e.g. Harry The field has been completed
18 Fill the “Parents Surname” input element with a random value e.g. Potter The field has been completed
19 Click on the “Add New Item” button in the “Item” section The “Item Image Variants” section with a new button “Add New Item” and the bin icon are shown
20 Click on the “Add New Item” button in the “Item Image Variants” section Two input elements, two checkboxes and a new bin icon are shown
21 Fill the “Item Image Variants Image URL” input element with a random value e.g. www.google.com The field has been completed
22 Check the checkbox “Item Image Variants Is Approved” The checkbox is checked
23 Set the random date and time from the picker in “Item Image Variants Date Created” input element The field has been completed
24 Click on the “Save” button User is redirected to the “.../admin/resources/Complicated” page. The toast message: “Successfully created a new record” is shown
25 Look at the number of the elements on the list The number is increased by 1
26 Look at your new element on the list Your element should be at the top of the list
27 Look at values in each column in your element Users should see provided data in each related column. Columns “String Array”, “Authors”, “Parents” and “Item” show information about quantity. In this case “length: 1”. Column “Id” has a random string. Column “Updated At” shows time and date of creating
## Test suite: Sequelize Resources filters ### SRF-1: Filter elements on the “Favorite Places” list **Test description:** Verify if a user will be able to filter elements on the list **Type:** Functional **Priority:** Medium **Severity:** Normal **Behavior:** Positive **Automation status:** To be automated **Tags:** sequelize_resources, favorite_places_category, favorite_places_filters, filters #### Precondition * User is already logged into the application
No. Steps Data Expected results
1 Click on the hamburger menu if the navigation panel is not visible Navigation panel is visible
2 Click on the “Sequelize Resources” → Favourite Places link inside the navigation panel User should be redirected to the “.../admin/resources/FavouritePlaces” page
3 Hide the navigation panel if you launch it via hamburger menu Navigation panel is not visible. Hamburger menu is visible
4 If there is no any elements on the list, create at least two elements with random data The elements are visible on the list
5 Click on the “Filter” button The filters section with form is visible
6 Fill the “Name” input element with the name from e.g. the first element on the list The field has been completed
7 Click on the “Apply changes” button inside the filters section On the list should be visible only elements with the name inserted in the filter input
8 Click on the “Reset” button inside the filters section Inside the list all elements should be visible
9 Fill the “Id” input element with the Id from e.g. the first element on the list The field has been completed
10 Click on the “Apply changes” button inside the filters section On the list should be visible only elements with the Id inserted in the filter input
11 Click on the “Reset” button inside the filters section Inside the list all elements should be visible
12 Choose one of the elements from the “User Id” dropdown element with the User Id from e.g. the first element on the list The field has been completed
13 Click on the “Apply changes” button inside the filters section On the list should be visible only elements with the User Id inserted in the filter input
14 Click on the “Reset” button inside the filters section Inside the list all elements should be visible
15 Choose the dates “From” and “To” that first element’s “published At” field on the list is in the range of them The fields have been completed
16 Click on the “Apply changes” button inside he filters section On the list should be visible only elements with “Published At” date and time in the range of the filters
17 Click on the “Reset” button inside the filters section Inside the list all elements should be visible
18 Fill the “Description” input element with the word from the column “Description” from e.g. the first element on the list The field has been completed
19 Click on the “Apply changes” button inside the filters section On the list should be visible only elements with “Description” field which include the word from the filter input element
20 Click on the “Reset” button inside the filters section Inside the list all elements should be visible
================================================ FILE: index.d.ts ================================================ import AdminJS from './types/src/index.js' import { ReduxState } from './types/src/frontend/store/store.js' export * from './types/src/index.js' export { AdminJS, ReduxState, } export default AdminJS declare const REDUX_STATE: ReduxState ================================================ FILE: index.js ================================================ import AdminJS from './lib/index.js' export * from './lib/index.js' export { AdminJS, } export default AdminJS ================================================ FILE: package.json ================================================ { "name": "adminjs", "version": "7.8.17", "description": "Admin panel for apps written in node.js", "type": "module", "exports": { ".": { "types": "./index.d.ts", "import": "./index.js", "require": "./index.js" }, "./bundler": "./lib/backend/bundler/index.js" }, "scripts": { "test": "mocha --loader=ts-node/esm ./spec/index.js", "types": "tsc", "clean": "rm -rf lib && mkdir lib && rm -fr types && mkdir types", "build": "babel src --out-dir lib --copy-files --extensions '.ts,.js,.jsx,.tsx'", "lint": "eslint './spec/**/*' './src/**/*' './cy/**/*' './*'", "cover": "NODE_ENV=test nyc --reporter=lcov --reporter=text-lcov npm test", "codecov": "NODE_ENV=test nyc --reporter=text-lcov npm test | codecov --pipe", "bundle": "node bin/app.js", "bundle:globals": "node bin/globals.js", "cspell": "cspell src/**/*.ts src/**/*.js src/**/*.tsx src/**/*.jsx", "check:all": "yarn types && yarn cspell && yarn lint && yarn test && yarn bundle && NODE_ENV=production yarn bundle", "dev": "yarn build && yarn types && yarn bundle:globals && yarn bundle && NODE_ENV=production yarn bundle:globals && NODE_ENV=production yarn bundle", "release": "semantic-release" }, "bin": { "admin": "./cli.js" }, "nyc": { "exclude": [ "spec", "example-app", "src/**/*.spec.ts", "src/**/*.spec.js", "src/**/*.spec.tsx", "src/**/*.spec.jsx", "src/**/*.factory.ts", "src/backend/bundler/user-components-bundler.ts", "docs", "coverage", "types", "src/frontend/assets/scripts", "lib", ".vscode", ".github", "**/*.spec.js" ], "all": true, "extension": [ ".js", ".jsx", ".tsx", ".ts" ] }, "repository": { "type": "git", "url": "https://github.com/SoftwareBrothers/adminjs.git" }, "engines": { "node": ">=16.0.0" }, "keywords": [ "hapi", "express", "mongoose", "admin", "admin-panel" ], "browserslist": [ "last 5 Chrome versions" ], "husky": { "hooks": { "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" } }, "author": "Wojciech Krysiak", "license": "MIT", "bugs": { "url": "https://github.com/SoftwareBrothers/adminjs/issues" }, "homepage": "https://github.com/SoftwareBrothers/adminjs#readme", "dependencies": { "@adminjs/design-system": "^4.1.0", "@babel/core": "^7.23.9", "@babel/parser": "^7.23.9", "@babel/plugin-syntax-import-assertions": "^7.23.3", "@babel/plugin-transform-runtime": "^7.23.9", "@babel/preset-env": "^7.23.9", "@babel/preset-react": "^7.23.3", "@babel/preset-typescript": "^7.23.3", "@babel/register": "^7.23.7", "@hello-pangea/dnd": "^16.2.0", "@redux-devtools/extension": "^3.2.5", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-replace": "^5.0.5", "axios": "^1.3.4", "commander": "^10.0.0", "flat": "^5.0.2", "i18next": "^22.4.13", "i18next-browser-languagedetector": "^7.0.1", "i18next-http-backend": "^2.2.0", "lodash": "^4.17.21", "ora": "^6.2.0", "prop-types": "^15.8.1", "punycode": "^2.3.0", "qs": "^6.11.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-feather": "^2.0.10", "react-i18next": "^12.2.0", "react-is": "^18.2.0", "react-redux": "^8.0.5", "react-router": "^6.9.0", "react-router-dom": "^6.9.0", "redux": "^4.2.1", "regenerator-runtime": "^0.14.1", "rollup": "^4.11.0", "rollup-plugin-esbuild-minify": "^1.1.1", "rollup-plugin-polyfill-node": "^0.13.0", "slash": "^5.0.0", "uuid": "^9.0.0", "xss": "^1.0.14" }, "devDependencies": { "@babel/cli": "^7.23.9", "@commitlint/cli": "^17.5.0", "@commitlint/config-conventional": "^17.4.4", "@semantic-release/git": "^10.0.1", "@testing-library/react": "^14.0.0", "@types/babel-core": "^6.25.10", "@types/chai": "^4.3.4", "@types/chai-as-promised": "^7.1.5", "@types/factory-girl": "^5.0.8", "@types/flat": "^5.0.2", "@types/lodash": "^4.14.194", "@types/mocha": "^10.0.1", "@types/node": "^20.6.0", "@types/qs": "^6.9.7", "@types/react": "^18.0.35", "@types/react-dom": "^18.0.11", "@types/sinon": "^10.0.13", "@types/sinon-chai": "^3.2.9", "@typescript-eslint/eslint-plugin": "^5.56.0", "@typescript-eslint/parser": "^5.56.0", "babel-plugin-module-resolver": "^5.0.0", "chai": "^4.3.7", "chai-as-promised": "^7.1.1", "chai-change": "^2.1.2", "core-js": "^3.36.0", "cspell": "^6.30.2", "eslint": "^8.36.0", "eslint-config-airbnb": "^19.0.4", "eslint-plugin-import": "^2.27.5", "eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-mocha": "^10.1.0", "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "factory-girl": "^5.0.4", "husky": "^8.0.3", "jsdom": "^21.1.1", "jsdom-global": "^3.0.2", "mocha": "^10.2.0", "node-esm-import-all": "^1.0.0", "npm-run-all": "^4.1.5", "semantic-release": "^20.1.3", "sinon": "^15.0.2", "sinon-chai": "^3.7.0", "ts-node": "10.8.1", "typescript": "^5.3.3" }, "resolutions": { "react-redux": "8.0.5", "redux": "4.2.1" } } ================================================ FILE: project.inlang/project_id ================================================ ed92cfe9d9d26d48639c434e4f5ddd645dfbd5ecaac14329f8b042b5546fd3a9 ================================================ FILE: project.inlang/settings.json ================================================ { "$schema": "https://inlang.com/schema/project-settings", "sourceLanguageTag": "en", "languageTags": [ "en", "es", "it", "ja", "pl", "pt-BR", "ua", "zh-CN" ], "modules": [ "https://cdn.jsdelivr.net/npm/@inlang/plugin-i18next@4/dist/index.js", "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-empty-pattern@latest/dist/index.js", "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-identical-pattern@latest/dist/index.js", "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@latest/dist/index.js", "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-without-source@latest/dist/index.js" ], "plugin.inlang.i18next": { "pathPattern": "./src/locale/{languageTag}/translation.json" } } ================================================ FILE: spec/backend/helpers/helper-stub.ts ================================================ import sinon from 'sinon' import ViewHelpers from '../../../src/backend/utils/view-helpers/view-helpers.js' export const expectedResult = { recordActionUrl: '#recordActionUrl', resourceActionUrl: '#resourceActionUrl', bulkActionUrl: '#bulkActionUrl', loginUrl: 'loginUrl', logoutUrl: 'logoutUrl', rootUrl: 'admin', assetPath: 'assetPath', resourceUrl: 'resourceUrl', dashboardUrl: 'dashboardUrl', pageUrl: 'pageUrl', editUrl: 'editUrl', showUrl: 'showUrl', deleteUrl: 'deleteUrl', newUrl: 'newUrl', listUrl: 'listUrl', bulkDeleteUrl: 'bulkDeleteUrl', } export default (): ViewHelpers => ( { options: { loginPath: expectedResult.loginUrl, logoutPath: expectedResult.logoutUrl, rootPath: expectedResult.rootUrl, }, recordActionUrl: sinon.stub().returns(expectedResult.recordActionUrl), resourceActionUrl: sinon.stub().returns(expectedResult.resourceActionUrl), bulkActionUrl: sinon.stub().returns(expectedResult.bulkActionUrl), urlBuilder: sinon.stub(), loginUrl: sinon.stub().returns(expectedResult.loginUrl), logoutUrl: sinon.stub().returns(expectedResult.logoutUrl), assetPath: sinon.stub().returns(expectedResult.assetPath), resourceUrl: sinon.stub().returns(expectedResult.resourceUrl), dashboardUrl: sinon.stub().returns(expectedResult.dashboardUrl), pageUrl: sinon.stub().returns(expectedResult.pageUrl), editUrl: sinon.stub().returns(expectedResult.editUrl), showUrl: sinon.stub().returns(expectedResult.showUrl), deleteUrl: sinon.stub().returns(expectedResult.deleteUrl), newUrl: sinon.stub().returns(expectedResult.newUrl), listUrl: sinon.stub().returns(expectedResult.listUrl), bulkDeleteUrl: sinon.stub().returns(expectedResult.bulkDeleteUrl), } ) ================================================ FILE: spec/backend/helpers/resource-stub.ts ================================================ import sinon from 'sinon' import BaseProperty from '../../../src/backend/adapters/property/base-property.js' import BaseResource from '../../../src/backend/adapters/resource/base-resource.js' import ResourceDecorator from '../../../src/backend/decorators/resource/resource-decorator.js' /** * returns properties with following absolute paths: * - normal: number * - nested: mixed * - nested.normal: string * - nested.nested: mixed * - nested.nested.normalInner: string * - arrayed: string (array) * - arrayedMixed: mixed (array) * - arrayedMixed.arrayParam: string * * @private */ const buildProperties = (): Array => { const normalProperty = new BaseProperty({ path: 'normal', type: 'number' }) as any const nestedProperty = new BaseProperty({ path: 'nested', type: 'mixed' }) as any const nested2Property = new BaseProperty({ path: 'nested', type: 'mixed' }) as any const arrayProperty = new BaseProperty({ path: 'arrayed', type: 'string' }) as any const arrayMixedProperty = new BaseProperty({ path: 'arrayedMixed', type: 'mixed' }) as any arrayProperty.isArray = (): boolean => true arrayMixedProperty.isArray = (): boolean => true nestedProperty.subProperties = (): Array => [ new BaseProperty({ path: 'normal', type: 'string' }), nested2Property, ] nested2Property.subProperties = (): Array => [ new BaseProperty({ path: 'normalInner', type: 'string' }), ] arrayMixedProperty.subProperties = (): Array => [ new BaseProperty({ path: 'arrayParam', type: 'string' }), ] return [normalProperty, nestedProperty, arrayProperty, arrayMixedProperty] } export const expectedResult = { id: 'someID', properties: buildProperties(), resourceName: 'resourceName', databaseName: 'databaseName', databaseType: 'mongodb', parent: { name: 'databaseName', icon: 'icon-mongodb', }, } export default (): BaseResource => ({ _decorated: {} as ResourceDecorator, id: sinon.stub().returns(expectedResult.id), properties: sinon.stub().returns(expectedResult.properties), property: sinon.stub().returns(new BaseProperty({ path: 'prop', type: 'string' })), databaseName: sinon.stub().returns(expectedResult.databaseName), databaseType: sinon.stub().returns(expectedResult.databaseType), count: sinon.stub(), find: sinon.stub(), findOne: sinon.stub(), findMany: sinon.stub(), build: sinon.stub(), create: sinon.stub(), update: sinon.stub(), delete: sinon.stub(), assignDecorator: sinon.stub(), decorate: sinon.stub(), }) ================================================ FILE: spec/backend/index.js ================================================ /* eslint-disable import/first */ import { factory } from 'factory-girl' process.env.MONGO_URL = 'mongodb://mongo/admin-server-test' global.factory = factory import './adapters/base-record.spec.js' import './bundler/generate-user-component-entry.spec.js' import './decorators/property-decorator.spec.js' import './decorators/resource-decorator.spec.js' import './utils/populator.spec.js' import './utils/resources-factory.spec.js' ================================================ FILE: spec/fixtures/action.factory.js ================================================ import { factory } from 'factory-girl' factory.define('action', Object, { name: factory.sequence('action.name', (n) => `action${n}`), actionType: 'record', icon: 'icon', label: factory.sequence('action.label', (n) => `action label ${n}`), guard: null, showFilter: false, component: undefined, }) factory.extend('action', 'recordAction', { actionType: 'record', }) factory.extend('action', 'resourceAction', { actionType: 'resource', }) ================================================ FILE: spec/fixtures/example-component.js ================================================ import React from 'react' function ExampleComponent() { return
Some example text
} export default ExampleComponent ================================================ FILE: spec/fixtures/property.factory.js ================================================ import { factory } from 'factory-girl' factory.define('property', Object, { isId: false, isSortable: true, isTitle: true, label: factory.sequence('property.label', (n) => `some property ${n}`), name: factory.sequence('property.name', (n) => `someProperty${n}`), position: factory.sequence('property.position', (n) => n), type: 'string', }) ================================================ FILE: spec/fixtures/record.factory.js ================================================ import { factory } from 'factory-girl' factory.define('record', Object, { params: { param1: 'value', 'nested.param': 'value2', _id: '5d6165fc1af7720536be0930', }, populated: {}, errors: {}, id: '5d6165fc1af7720536be0930', }) ================================================ FILE: spec/index.js ================================================ /* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable import/first */ /* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable func-names */ import * as url from 'url' import path from 'path' import register from '@babel/register' import { importAll } from 'node-esm-import-all' import presetReact from '@babel/preset-react' import presetEnv from '@babel/preset-env' import presetTs from '@babel/preset-typescript' const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) register({ presets: [ presetReact, [presetEnv, { targets: { node: '18', }, modules: false, loose: true, }], presetTs, ], extensions: ['.jsx', '.js', '.ts', '.tsx'], only: ['src/', 'spec/'], }) import './setup.js' await importAll({ dirname: path.join(__dirname, '/../src'), filter: /spec\.(js|ts|tsx)$/i, recursive: true, }) ================================================ FILE: spec/lib.js ================================================ /* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable func-names */ import { importAll } from 'node-esm-import-all' import './setup.js' await importAll({ dirname: '../lib/', filter: /spec\.js$/i, recursive: true, }) ================================================ FILE: spec/setup.js ================================================ /* eslint-disable func-names */ /* eslint-disable mocha/no-top-level-hooks */ import { URLSearchParams } from 'url' import chai from 'chai' import sinonChai from 'sinon-chai' import sinon from 'sinon' import jsdom from 'jsdom-global' process.env.NODE_ENV = 'test' chai.use(sinonChai) global.expect = chai.expect global.URLSearchParams = URLSearchParams beforeEach(function () { this.sinon = sinon.createSandbox() this.jsdom = jsdom() }) afterEach(function () { this.sinon.restore() }) ================================================ FILE: src/adminjs-options.interface.ts ================================================ import { ThemeOverride } from '@adminjs/design-system' import type { TransformOptions as BabelConfig } from 'babel-core' import AdminJS from './adminjs.js' import BaseResource from './backend/adapters/resource/base-resource.js' import BaseDatabase from './backend/adapters/database/base-database.js' import { PageContext } from './backend/actions/action.interface.js' import { ResourceOptions } from './backend/decorators/resource/resource-options.interface.js' import { Locale } from './locale/config.js' import { CurrentAdmin } from './current-admin.interface.js' import { CoreScripts } from './core-scripts.interface.js' import { ComponentLoader } from './backend/utils/component-loader.js' /** * AdminJSOptions * * This is the heart of entire AdminJS - all options resides here. * * ### Usage with regular javascript * * ```javascript * const AdminJS = require('adminjs') * //... * const adminJS = new AdminJS({ * rootPath: '/xyz-admin', * logoutPath: '/xyz-admin/exit', * loginPath: '/xyz-admin/sign-in', * databases: [mongooseConnection], * resources: [{ resource: ArticleModel, options: {...}}], * branding: { * companyName: 'XYZ c.o.', * }, * }) * ``` * * ### TypeScript * * ``` * import { AdminJSOptions } from 'adminjs' * * const options: AdminJSOptions = { * rootPath: '/xyz-admin', * logoutPath: '/xyz-admin/exit', * loginPath: '/xyz-admin/sign-in', * databases: [mongooseConnection], * resources: [{ resource: ArticleModel, options: {...}}], * branding: { * companyName: 'XYZ c.o.', * }, * } * * const adminJs = new AdminJS(options) * ``` */ export interface AdminJSOptions { /** * path, under which, AdminJS will be available. Default to `/admin` * */ rootPath?: string; /** * url to a logout action, default to `/admin/logout` */ logoutPath?: string; /** * url to a login page, default to `/admin/login` */ loginPath?: string; /** * Array of all Databases which are supported by AdminJS via adapters */ databases?: Array; componentLoader?: ComponentLoader; /** * List of custom pages which will be visible below all resources * @example * pages: { * customPage: { * label: "Custom page", * handler: async (request, response, context) => { * return { * text: 'I am fetched from the backend', * } * }, * component: 'CustomPage', * }, * anotherPage: { * label: "TypeScript page", * component: 'TestComponent', * }, * }, */ pages?: AdminPages; /** * Array of all Resources which are supported by AdminJS via adapters. * You can pass either resource or resource with an options and thus modify it. * @property {any} resources[].resource * @property {ResourceOptions} resources[].options * @property {Array} resources[].features * * @see ResourceOptions */ resources?: Array; /** * Option to modify the dashboard */ dashboard?: { /** * Handler function which can be triggered using {@link ApiClient#getDashboard}. */ handler?: PageHandler; /** * Bundled component name which should be rendered when user opens the dashboard */ component?: string; }; /** * Flag which indicates if version number should be visible on the UI */ version?: VersionSettings; /** * Options which are related to the branding. */ branding?: BrandingOptions | BrandingOptionsFunction; /** * Custom assets you want to pass to AdminJS */ assets?: Assets | AssetsFunction; /** * Indicates is bundled by AdminJS files like: * - components.bundle.js * - global.bundle.js * - design-system.bundle.js * - app.bundle.js * should be taken from the same server as other AdminJS routes (default) * or should be taken from an external CDN. * * If set - bundles will go from given CDN if unset - from the same server. * * When you can use this option? So let's say you want to deploy the app on * serverless environment like Firebase Cloud Functions. In that case you don't * want to serve static files with the same app because your function will be * invoked every time frontend asks for static assets. * * Solution would be to: * - create `public` folder in your app * - generate `bundle.js` file to `.adminjs/` folder by using {@link AdminJS#initialize} * function (with process.env.NODE_ENV set to 'production'). * - copy the before mentioned file to `public` folder and rename it to * components.bundle.js * - copy * './node_modules/adminjs/lib/frontend/assets/scripts/app-bundle.production.js' to * './public/app.bundle.js', * - copy * './node_modules/adminjs/lib/frontend/assets/scripts/global-bundle.production.js' to * './public/global.bundle.js' * * - copy * './node_modules/adminjs/node_modules/@adminjs/design-system/bundle.production.js' to * './public/design-system.bundle.js' * - host entire public folder under some domain (if you use firebase - you can host them * with firebase hosting) * - point {@link AdminJS.assetsCDN} to this domain */ assetsCDN?: string; /** * Environmental variables passed to the frontend. * * So let say you want to pass some _GOOGLE_MAP_API_TOKEN_ to the frontend. * You can do this by adding it here: * * ```javascript * new AdminJS({env: { * GOOGLE_MAP_API_TOKEN: 'my-token', * }}) * ``` * * and this token will be available on the frontend by using: * * ```javascript * AdminJS.env.GOOGLE_MAP_API_TOKEN * ``` */ env?: Record; /** * Translations * * Change it in order to: * - localize admin panel * - change any arbitrary text in the UI * * This is the example for changing name of a couple of resources along with some * properties to Polish. You can also use this technic to change any text even in english. * So to change button label of a "new action" from default "Create new" to "Create new Comment" * only for Comment resource, place it in action section. * * ```javascript * { * locale: { * translations: { * pl: { * labels: { * Comments: "Komentarze", * }, * resources: { * Comments: { * properties: { * name: "Nazwa Komentarza", * content: "Zawartość", * }, * actions: { * new: 'Create new Comment' * } * } * } * } * } * } *} * ``` * * Check out the [i18n tutorial]{@tutorial i18n} to see how * internationalization in AdminJS works. */ locale?: Locale | ((admin?: CurrentAdmin) => Locale | Promise); /** * rollup bundle options; */ bundler?: BundlerOptions; /** * Additional settings. */ settings?: Partial; /** * List of available themes, for example exports of the `@adminjs/themes` npm package. */ availableThemes?: ThemeConfig[]; /** * ID of the default theme. If not provided, the first theme from the `availableThemes` * list will be used. */ defaultTheme?: string; } export type ThemeConfig = { id: string, name: string, overrides: Partial; bundlePath?: string; stylePath?: string; } export type AdminJSSettings = { defaultPerPage: number; }; /* cspell: enable */ /** * @memberof AdminJSOptions * @alias Assets * * Optional assets (stylesheets, and javascript libraries) which can be * appended to the HEAD of the page. * * you can also pass {@link AssetsFunction} instead. */ export type Assets = { /** * List to urls of custom stylesheets. You can pass your font - icons here (as an example) */ styles?: Array; /** * List of urls to custom scripts. If you use some particular js * library - you can pass its url here. */ scripts?: Array; /** * Mapping of core scripts in case you want to version your assets */ coreScripts?: CoreScripts; }; /** * @alias AssetsFunction * @name AssetsFunction * @memberof AdminJSOptions * @returns {Assets | Promise} * @description * Function returning {@link Assets} */ export type AssetsFunction = (admin?: CurrentAdmin) => Assets | Promise; /** * Version Props * @alias VersionProps * @memberof AdminJSOptions */ export type VersionSettings = { /** * if set to true - current admin version will be visible */ admin?: boolean; /** * Here you can pass any arbitrary version text which will be seen in the US. * You can pass here your current API version. */ app?: string; }; export type VersionProps = { admin?: string; app?: string; }; /** * Branding Options * * You can use them to change how AdminJS looks. For instance to change name and * colors (dark theme) run: * * ```javascript * new AdminJS({ * branding: { * companyName: 'John Doe Family Business', * theme, * } * }) * ``` * * @alias BrandingOptions * @memberof AdminJSOptions */ export type BrandingOptions = { /** * URL to a logo, or `false` if you want to hide the default one. */ logo?: string | false; /** * Name of your company, which will replace "AdminJS". */ companyName?: string; /** * CSS theme. */ theme?: Partial; /** * Flag indicates if "made with love" tiny heart icon * should be visible on the bottom sidebar and login page. * @new since 6.0.0 */ withMadeWithLove?: boolean; /** * URL to a favicon */ favicon?: string; }; /** * Branding Options Function * * function returning BrandingOptions. * * @alias BrandingOptionsFunction * @memberof AdminJSOptions * @returns {BrandingOptions | Promise} */ export type BrandingOptionsFunction = ( admin?: CurrentAdmin, ) => BrandingOptions | Promise; /** * Object describing regular page in AdminJS * * @alias AdminPage * @memberof AdminJSOptions */ export type AdminPage = { /** * Handler function */ handler?: PageHandler; /** * Component defined by using {@link ComponentLoader} */ component: string; /** * Page icon */ icon?: string; }; /** * Object describing map of regular pages in AdminJS * * @alias AdminPages * @memberof AdminJSOptions */ export type AdminPages = Record; /** * Default way of passing Options with a Resource * @alias ResourceWithOptions * @memberof AdminJSOptions */ export type ResourceWithOptions = { resource: any; options: ResourceOptions; features?: Array; }; /** * Function taking {@link ResourceOptions} and merging it with all other options * * @alias FeatureType * @type function * @returns {ResourceOptions} * @memberof AdminJSOptions */ export type FeatureType = ( /** * AdminJS instance */ admin: AdminJS, /** * Options returned by the feature added before */ options: ResourceOptions, ) => ResourceOptions; /** * Function which is invoked when user enters given AdminPage * * @alias PageHandler * @memberof AdminJSOptions */ export type PageHandler = (request: any, response: any, context: PageContext) => Promise; /** * Bundle options * * @alias BundlerOptions * @memberof AdminJSOptions * @example * const adminJS = new AdminJS({ resources: [], rootPath: '/admin', babelConfig: './.adminJS.babelrc' }) */ export type BundlerOptions = { /** * The file path to babel config file or json object of babel config. */ babelConfig?: BabelConfig | string; }; export interface AdminJSOptionsWithDefault extends AdminJSOptions { rootPath: string; logoutPath: string; loginPath: string; refreshTokenPath: string; databases?: Array; resources?: Array< | BaseResource | { resource: BaseResource; options: ResourceOptions; } >; dashboard: { handler?: PageHandler; component?: string; }; bundler: BundlerOptions; pages: AdminJSOptions['pages']; } ================================================ FILE: src/adminjs.spec.ts ================================================ import path from 'path' import { expect } from 'chai' import * as url from 'url' import AdminJS from './adminjs.js' import BaseDatabase from './backend/adapters/database/base-database.js' import BaseResource from './backend/adapters/resource/base-resource.js' import { ComponentLoader } from './backend/utils/component-loader.js' const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) describe('AdminJS', function () { beforeEach(function () { global.RegisteredAdapters = [] }) describe('#constructor', function () { it('sets default root path when no given', function () { expect(new AdminJS().options.rootPath).to.equal('/admin') }) }) describe('.AdminJS.registerAdapter', function () { beforeEach(function () { class Database extends BaseDatabase {} class Resource extends BaseResource {} this.DatabaseAdapter = { Database, Resource } }) it('adds given adapter to list off all available adapters', function () { AdminJS.registerAdapter(this.DatabaseAdapter) expect(global.RegisteredAdapters).to.have.lengthOf(1) }) it('throws an error when adapter is not full', function () { expect(() => { AdminJS.registerAdapter({ Resource: BaseResource, Database: null as unknown as typeof BaseDatabase }) }).to.throw('Adapter has to have both Database and Resource') }) it('throws an error when adapter has elements not being subclassed from base adapter', function () { expect(() => { AdminJS.registerAdapter({ Resource: {} as typeof BaseResource, Database: {} as typeof BaseDatabase, }) }).to.throw('Adapter elements have to be a subclass of AdminJS.BaseResource and AdminJS.BaseDatabase') }) }) describe('resolveBabelConfigPath', function () { it('load .babelrc file', function () { const adminJS = new AdminJS({ bundler: { babelConfig: '../.babelrc.json' } }) expect(adminJS.options.bundler.babelConfig).not.to.undefined }) it('load with json object directly', function () { const adminJS = new AdminJS({ bundler: { babelConfig: { presets: [ '@babel/preset-react', ['@babel/preset-env', { targets: { node: '18', }, modules: false, loose: true, }], '@babel/preset-typescript', ], plugins: ['@babel/plugin-syntax-import-assertions'], only: ['src/', 'spec/'], ignore: [ 'src/frontend/assets/scripts/app-bundle.development.js', 'src/frontend/assets/scripts/app-bundle.production.js', 'src/frontend/assets/scripts/global-bundle.development.js', 'src/frontend/assets/scripts/global-bundle.production.js', ], } } }) expect(adminJS.options.bundler.babelConfig).not.to.undefined }) it('load babel.config.cjs file', function () { const adminJS = new AdminJS({ bundler: { babelConfig: './babel.test.config.json' } }) expect(adminJS.options.bundler.babelConfig).not.to.undefined }) }) describe('ComponentLoader', function () { const loader = new ComponentLoader() afterEach(function () { loader.clear() }) context('file exists', function () { beforeEach(function () { this.result = loader.add('ExampleComponent', '../spec/fixtures/example-component') }) it('adds given file to a UserComponents object', function () { expect(Object.keys(loader.getComponents())).to.have.lengthOf(1) }) it('returns uniqe id', function () { expect(loader.getComponents()[this.result]).not.to.be.undefined expect(this.result).to.be.a('string') }) it('converts relative path to absolute path', function () { expect( loader.getComponents()[this.result], ).to.equal(path.join(__dirname, '../spec/fixtures/example-component')) }) }) context('component name given', function () { it('returns the same component name as which was given', function () { const name = loader.add('Dashboard', '../spec/fixtures/example-component') expect(name).to.eq('Dashboard') }) }) it('throws an error when component doesn\'t exist', function () { expect(() => { loader.add('ExampleComponent', './fixtures/example-components') }).to.throw().property('name', 'ConfigurationError') }) }) }) ================================================ FILE: src/adminjs.ts ================================================ import merge from 'lodash/merge.js' import * as path from 'path' import * as fs from 'fs' import * as url from 'url' import { AdminJSOptionsWithDefault, AdminJSOptions } from './adminjs-options.interface.js' import BaseResource from './backend/adapters/resource/base-resource.js' import BaseDatabase from './backend/adapters/database/base-database.js' import ConfigurationError from './backend/utils/errors/configuration-error.js' import ResourcesFactory from './backend/utils/resources-factory/resources-factory.js' import componentsBundler from './backend/bundler/components.bundler.js' import { RecordActionResponse, Action, BulkActionResponse, } from './backend/actions/action.interface.js' import { DEFAULT_PATHS } from './constants.js' import { ACTIONS } from './backend/actions/index.js' import loginTemplate, { LoginTemplateAttributes } from './frontend/login-template.js' import { ListActionResponse } from './backend/actions/list/list-action.js' import { Locale } from './locale/index.js' import { TranslateFunctions } from './utils/translate-functions.factory.js' import { relativeFilePathResolver } from './utils/file-resolver.js' import { Router } from './backend/utils/index.js' import { ComponentLoader } from './backend/utils/component-loader.js' import { bundlePath, stylePath } from './utils/theme-bundler.js' import generateEntry from './backend/bundler/generate-user-component-entry.js' import { ADMIN_JS_TMP_DIR } from './backend/bundler/utils/constants.js' const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf-8')) export const VERSION = pkg.version export const defaultOptions: AdminJSOptionsWithDefault = { rootPath: DEFAULT_PATHS.rootPath, logoutPath: DEFAULT_PATHS.logoutPath, loginPath: DEFAULT_PATHS.loginPath, refreshTokenPath: DEFAULT_PATHS.refreshTokenPath, databases: [], resources: [], dashboard: {}, pages: {}, bundler: {}, } type ActionsMap = { show: Action edit: Action delete: Action bulkDelete: Action new: Action list: Action } export type Adapter = { Database: typeof BaseDatabase; Resource: typeof BaseResource } /** * Main class for AdminJS extension. It takes {@link AdminJSOptions} as a * parameter and creates an admin instance. * * Its main responsibility is to fetch all the resources and/or databases given by a * user. Its instance is a currier - injected in all other classes. * * @example * const AdminJS = require('adminjs') * const admin = new AdminJS(AdminJSOptions) */ class AdminJS { public resources: Array public options: AdminJSOptionsWithDefault public locale!: Locale public translateFunctions!: TranslateFunctions public componentLoader: ComponentLoader /** * List of all default actions. If you want to change the behavior for all actions like: * _list_, _edit_, _show_, _delete_ and _bulkDelete_ you can do this here. * * @example Modifying accessibility rules for all show actions * const { ACTIONS } = require('adminjs') * ACTIONS.show.isAccessible = () => {...} */ public static ACTIONS: ActionsMap /** * AdminJS version */ public static VERSION: string /** * @param {AdminJSOptions} options Options passed to AdminJS */ constructor(options: AdminJSOptions = {}) { /** * @type {BaseResource[]} * @description List of all resources available for the AdminJS. * They can be fetched with the {@link AdminJS#findResource} method */ this.resources = [] /** * @type {AdminJSOptions} * @description Options given by a user */ this.options = merge({}, defaultOptions, options) this.resolveBabelConfigPath() const { databases, resources } = this.options this.componentLoader = options.componentLoader ?? new ComponentLoader() const resourcesFactory = new ResourcesFactory(this, global.RegisteredAdapters || []) this.resources = resourcesFactory.buildResources({ databases, resources }) this.addThemeAssets() } /** * Registers various database adapters written for AdminJS. * * @example * const AdminJS = require('adminjs') * const MongooseAdapter = require('adminjs-mongoose') * AdminJS.registerAdapter(MongooseAdapter) * * @param {Object} options * @param {typeof BaseDatabase} options.Database subclass of {@link BaseDatabase} * @param {typeof BaseResource} options.Resource subclass of {@link BaseResource} */ static registerAdapter({ Database, Resource, }: { Database: typeof BaseDatabase Resource: typeof BaseResource }): void { if (!Database || !Resource) { throw new Error('Adapter has to have both Database and Resource') } // TODO: check if this is actually valid because "isAdapterFor" is always defined. // checking if both Database and Resource have at least isAdapterFor method // @ts-ignore if (Database.isAdapterFor && Resource.isAdapterFor) { global.RegisteredAdapters = global.RegisteredAdapters || [] global.RegisteredAdapters.push({ Database, Resource }) } else { throw new Error( 'Adapter elements have to be a subclass of AdminJS.BaseResource and AdminJS.BaseDatabase', ) } } /** * Initializes AdminJS instance in production. This function should be called by * all external plugins. */ async initialize(): Promise { if (process.env.NODE_ENV === 'production' && !(process.env.ADMIN_JS_SKIP_BUNDLE === 'true')) { // eslint-disable-next-line no-console console.log('AdminJS: bundling user components...') await componentsBundler.createEntry({ content: generateEntry(this, ADMIN_JS_TMP_DIR), }) await componentsBundler.build() } } /** * Watches for local changes in files imported via {@link ComponentLoader}. * It doesn't work on production environment. * * @return {Promise} */ async watch(): Promise { if (process.env.NODE_ENV !== 'production') { await componentsBundler.createEntry({ content: generateEntry(this, ADMIN_JS_TMP_DIR), }) await componentsBundler.watch() } return undefined } /** * Renders an entire login page with email and password fields * using {@link Renderer}. * * Used by external plugins * * @param {Object} options * @param {String} options.action Login form action url - it could be * '/admin/login' * @param {String} [options.errorMessage] Optional error message. When set, * renderer will print this message in * the form * @return {Promise} HTML of the rendered page */ async renderLogin(props: LoginTemplateAttributes): Promise { return loginTemplate(this, props) } /** * Returns resource base on its ID * * @example * const User = admin.findResource('users') * await User.findOne(userId) * * @param {String} resourceId ID of a resource defined under {@link BaseResource#id} * @return {BaseResource} found resource * @throws {Error} When resource with given id cannot be found */ findResource(resourceId): BaseResource { const resource = this.resources.find((m) => m._decorated?.id() === resourceId) if (!resource) { throw new Error( [ `There are no resources with given id: "${resourceId}"`, 'This is the list of all registered resources you can use:', this.resources.map((r) => r._decorated?.id() || r.id()).join(', '), ].join('\n'), ) } return resource } /** * Resolve babel config file path, * and load configuration to this.options.bundler.babelConfig. */ resolveBabelConfigPath(): void { if (typeof this.options?.bundler?.babelConfig !== 'string') { return } let filePath = '' let config = this.options?.bundler?.babelConfig if (config[0] === '/') { filePath = config } else { filePath = relativeFilePathResolver(config, /new AdminJS/) } if (!fs.existsSync(filePath)) { throw new ConfigurationError( `Given babel config "${filePath}", doesn't exist.`, 'AdminJS.html', ) } if (path.extname(filePath) === '.js') { // eslint-disable-next-line const configModule = require(filePath) // eslint-disable-next-line max-len config = configModule && configModule.__esModule ? configModule.default || undefined : configModule if (!config || typeof config !== 'object' || Array.isArray(config)) { throw new Error(`${filePath}: Configuration should be an exported JavaScript object.`) } } else { try { config = JSON.parse(fs.readFileSync(filePath, 'utf8')) } catch (err) { throw new Error(`${filePath}: Error while parsing config - ${err.message}`) } if (!config) throw new Error(`${filePath}: No config detected`) if (typeof config !== 'object') { throw new Error(`${filePath}: Config returned typeof ${typeof config}`) } if (Array.isArray(config)) { throw new Error(`${filePath}: Expected config object but found array`) } } this.options.bundler.babelConfig = config } addThemeAssets() { this.options.availableThemes?.forEach((theme) => { Router.assets.push({ path: `/frontend/assets/themes/${theme.id}/theme.bundle.js`, src: theme.bundlePath ?? bundlePath(theme.id), }) Router.assets.push({ path: `/frontend/assets/themes/${theme.id}/style.css`, src: theme.stylePath ?? stylePath(theme.id), }) }) } private static __unsafe_componentIndex = 0 public static __unsafe_staticComponentLoader = new ComponentLoader() } AdminJS.VERSION = VERSION AdminJS.ACTIONS = ACTIONS // eslint-disable-next-line @typescript-eslint/no-empty-interface interface AdminJS extends TranslateFunctions {} export const { registerAdapter } = AdminJS export default AdminJS ================================================ FILE: src/babel.test.config.json ================================================ { "presets": [ "@babel/preset-react", [ "@babel/preset-env", { "targets": { "node": "18" }, "loose": true, "modules": false } ], "@babel/preset-typescript" ], "plugins": ["@babel/plugin-syntax-import-assertions"], "only": ["src/", "spec/"], "ignore": [ "src/frontend/assets/scripts/app-bundle.development.js", "src/frontend/assets/scripts/app-bundle.production.js", "src/frontend/assets/scripts/global-bundle.development.js", "src/frontend/assets/scripts/global-bundle.production.js" ] } ================================================ FILE: src/backend/actions/action.interface.ts ================================================ import { IconProps, VariantType } from '@adminjs/design-system' import AdminJS from '../../adminjs.js' import { CurrentAdmin } from '../../current-admin.interface.js' import ViewHelpers from '../utils/view-helpers/view-helpers.js' import BaseRecord from '../adapters/record/base-record.js' import BaseResource from '../adapters/resource/base-resource.js' import ActionDecorator from '../decorators/action/action-decorator.js' import { LayoutElement, LayoutElementFunction } from '../utils/layout-element-parser/index.js' import { RecordJSON } from '../../frontend/interfaces/index.js' import { type NoticeMessage } from '../../frontend/interfaces/noticeMessage.interface.js' export type ActionQueryParameters = { sortBy?: string direction?: 'asc' | 'desc' filters?: Record perPage?: number page?: number } 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. * * @memberof Action * @alias ActionContext */ export type ActionContext = { /** * 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; /** * 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; /** * Elements of query string */ query?: Record; /** * 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; } /** * 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 {T | Promise} */ export type ActionHandler = ( request: ActionRequest, response: any, context: ActionContext ) => T | Promise /** * Before action hook. When it is given - it is performed before the {@link ActionHandler} * method. * @alias Before * @returns {ActionRequest | Promise} * @memberof Action * @async */ export type Before = ( /** * Request object */ request: ActionRequest, /** * Invocation context */ context: ActionContext, ) => ActionRequest | Promise /** * Type of an after hook action. * * @memberof Action * @alias After * @async */ export type After = ( /** * Response returned by the default ActionHandler */ response: T, /** * Original request which has been sent to ActionHandler */ request: ActionRequest, /** * Invocation context */ context: ActionContext, ) => T | Promise 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: 'MyAction', * } * ``` * * 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 { /** * 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; /** * If action should have resource actions buttons displayed above action header. * * Defaults to `true` * * @new in version v5.8.1 */ showResourceActions?: boolean; /** * Type of an action - could be either _resource_, _record_ or _bulk_. * * * * 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?: IconProps['icon']; /** * 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 a translate 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 ComponentLoader.add} or {@link ComponentLoader.override} 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 | Array> | 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; /** * 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 | Array>; /** * 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; /** * 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; /** * 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; } ================================================ FILE: src/backend/actions/bulk-delete/bulk-delete-action.spec.ts ================================================ import chai, { expect } from 'chai' import chaiAsPromised from 'chai-as-promised' import sinon from 'sinon' import BulkDeleteAction from './bulk-delete-action.js' import { ActionContext, ActionRequest, ActionHandler, BulkActionResponse } from '../action.interface.js' import BaseRecord from '../../adapters/record/base-record.js' import AdminJS from '../../../adminjs.js' import ViewHelpers from '../../utils/view-helpers/view-helpers.js' import BaseResource from '../../adapters/resource/base-resource.js' import ActionDecorator from '../../decorators/action/action-decorator.js' import NotFoundError from '../../utils/errors/not-found-error.js' import { RecordJSON } from '../../../frontend/interfaces/index.js' import { CurrentAdmin } from '../../../current-admin.interface.js' chai.use(chaiAsPromised) describe('BulkDeleteAction', function () { let data: ActionContext const request = {} as ActionRequest let response: any describe('.handler', function () { afterEach(function () { sinon.restore() }) beforeEach(async function () { data = { _admin: sinon.createStubInstance(AdminJS), translateMessage: sinon.stub().returns('translatedMessage'), h: sinon.createStubInstance(ViewHelpers), resource: sinon.createStubInstance(BaseResource), action: sinon.createStubInstance(ActionDecorator) as unknown as ActionDecorator, } as unknown as ActionContext }) it('throws error when no records are given', async function () { await expect( (BulkDeleteAction.handler as ActionHandler)(request, response, data), ).to.rejectedWith(NotFoundError) }) context('2 records were selected', function () { let record: BaseRecord let recordJSON: RecordJSON beforeEach(function () { recordJSON = { id: 'someId' } as RecordJSON record = sinon.createStubInstance(BaseRecord, { toJSON: sinon.stub<[(CurrentAdmin)?]>().returns(recordJSON), }) as unknown as BaseRecord data.records = [record] }) it('returns all records for get request', async function () { request.method = 'get' await expect( (BulkDeleteAction.handler as ActionHandler)(request, response, data), ).to.eventually.deep.equal({ records: [recordJSON], }) }) it('deletes all records for post request', async function () { request.method = 'post' await ( BulkDeleteAction.handler as ActionHandler )(request, response, data) expect(data.resource.delete).to.have.been.calledOnce }) it('returns deleted records, notice and redirectUrl for post request', async function () { request.method = 'post' const actionResponse = await ( BulkDeleteAction.handler as ActionHandler )(request, response, data) expect(actionResponse).to.have.property('notice') expect(actionResponse).to.have.property('redirectUrl') expect(actionResponse).to.have.property('records') }) }) }) }) ================================================ FILE: src/backend/actions/bulk-delete/bulk-delete-action.ts ================================================ import { Action, BulkActionResponse } from '../action.interface.js' import NotFoundError from '../../utils/errors/not-found-error.js' /** * @implements Action * @category Actions * @module BulkDeleteAction * @description * Removes given records from the database. * @private */ export const BulkDeleteAction: Action = { name: 'bulkDelete', isVisible: true, actionType: 'bulk', icon: 'Trash2', showInDrawer: true, variant: 'danger', /** * Responsible for deleting existing records. * * To invoke this action use {@link ApiClient#bulkAction} * with {actionName: _bulkDelete_} * * @return {Promise} * @implements ActionHandler * @memberof module:BulkDeleteAction */ handler: async (request, response, context) => { const { records, resource, h } = context if (!records || !records.length) { throw new NotFoundError('no records were selected.', 'Action#handler') } if (request.method === 'get') { const recordsInJSON = records.map((record) => record.toJSON(context.currentAdmin)) return { records: recordsInJSON, } } if (request.method === 'post') { await Promise.all(records.map((record) => resource.delete(record.id(), context))) return { records: records.map((record) => record.toJSON(context.currentAdmin)), notice: { message: records.length > 1 ? 'successfullyBulkDeleted_plural' : 'successfullyBulkDeleted', options: { count: records.length }, resourceId: resource.id(), type: 'success', }, redirectUrl: h.resourceUrl({ resourceId: resource._decorated?.id() || resource.id() }), } } throw new Error('method should be either "post" or "get"') }, } export default BulkDeleteAction ================================================ FILE: src/backend/actions/delete/delete-action.spec.ts ================================================ import chai, { expect } from 'chai' import chaiAsPromised from 'chai-as-promised' import sinon from 'sinon' import DeleteAction from './delete-action.js' import { ActionContext, ActionRequest, ActionHandler, RecordActionResponse } from '../action.interface.js' import BaseRecord from '../../adapters/record/base-record.js' import AdminJS from '../../../adminjs.js' import ViewHelpers from '../../utils/view-helpers/view-helpers.js' import BaseResource from '../../adapters/resource/base-resource.js' import ActionDecorator from '../../decorators/action/action-decorator.js' import NotFoundError from '../../utils/errors/not-found-error.js' import { ValidationError } from '../../utils/errors/validation-error.js' import { RecordJSON } from '../../../frontend/interfaces/index.js' import { CurrentAdmin } from '../../../current-admin.interface.js' chai.use(chaiAsPromised) describe('DeleteAction', function () { let data: ActionContext const request = { params: {}, method: 'post', } as ActionRequest let response: any describe('.handler', function () { afterEach(function () { sinon.restore() }) beforeEach(async function () { data = { _admin: sinon.createStubInstance(AdminJS), h: sinon.createStubInstance(ViewHelpers), resource: sinon.createStubInstance(BaseResource), action: sinon.createStubInstance(ActionDecorator) as unknown as ActionDecorator, } as unknown as ActionContext }) it('throws error when no records are given', async function () { await expect( (DeleteAction.handler as ActionHandler)(request, response, data), ).to.rejectedWith(NotFoundError) }) context('A record has been selected', function () { let record: BaseRecord let recordJSON: RecordJSON beforeEach(function () { recordJSON = { id: 'someId' } as RecordJSON record = sinon.createStubInstance(BaseRecord, { toJSON: sinon.stub<[(CurrentAdmin)?]>().returns(recordJSON), }) as unknown as BaseRecord request.params.recordId = recordJSON.id data.record = record }) it('returns deleted record, notice and redirectUrl', async function () { const actionResponse = await ( DeleteAction.handler as ActionHandler )(request, response, data) expect(actionResponse).to.have.property('notice') expect(actionResponse).to.have.property('redirectUrl') expect(actionResponse).to.have.property('record') }) context('ValidationError is thrown by Resource.delete', function () { it('returns error notice', async function () { const errorMessage = 'test validation error' data.resource = sinon.createStubInstance(BaseResource, { delete: sinon.stub().rejects(new ValidationError({}, { message: errorMessage })) as any, }) const actionResponse = await ( DeleteAction.handler as ActionHandler )(request, response, data) expect(actionResponse).to.have.property('notice') expect(actionResponse.notice).to.deep.equal({ message: errorMessage, type: 'error', }) expect(actionResponse).to.have.property('record') }) it('returns error notice with default message when ValidationError has no baseError', async function () { data.resource = sinon.createStubInstance(BaseResource, { delete: sinon.stub().rejects(new ValidationError({})) as any, }) const actionResponse = await ( DeleteAction.handler as ActionHandler )(request, response, data) expect(actionResponse).to.have.property('notice') expect(actionResponse.notice).to.deep.equal({ message: 'thereWereValidationErrors', type: 'error', }) expect(actionResponse).to.have.property('record') }) }) }) }) }) ================================================ FILE: src/backend/actions/delete/delete-action.ts ================================================ import { Action, RecordActionResponse } from '../action.interface.js' import NotFoundError from '../../utils/errors/not-found-error.js' import ValidationError from '../../utils/errors/validation-error.js' /** * @implements Action * @category Actions * @module DeleteAction * @description * Removes given record from the database. Since it doesn't have a * component - it redirects right away after clicking its {@link ActionButton} * @private */ export const DeleteAction: Action = { name: 'delete', isVisible: true, actionType: 'record', icon: 'Trash2', guard: 'confirmDelete', component: false, variant: 'danger', /** * Responsible for deleting existing record. * * To invoke this action use {@link ApiClient#recordAction} * * @return {Promise} * @implements ActionHandler * @memberof module:DeleteAction */ handler: async (request, _response, context) => { const { record, resource, currentAdmin, h } = context if (!request.params.recordId || !record) { throw new NotFoundError([ 'You have to pass "recordId" to Delete Action', ].join('\n'), 'Action#handler') } if (request.method === 'get') { return { record: record.toJSON(context.currentAdmin), } } try { await resource.delete(request.params.recordId, context) } catch (error) { if (error instanceof ValidationError) { const baseMessage = error.baseError?.message || 'thereWereValidationErrors' return { record: record.toJSON(currentAdmin), notice: { message: baseMessage, type: 'error', }, } } throw error } return { record: record.toJSON(currentAdmin), redirectUrl: h.resourceUrl({ resourceId: resource._decorated?.id() || resource.id() }), notice: { message: 'successfullyDeleted', type: 'success', }, } }, } export default DeleteAction ================================================ FILE: src/backend/actions/edit/edit-action.ts ================================================ import { Action, RecordActionResponse } from '../action.interface.js' import NotFoundError from '../../utils/errors/not-found-error.js' import populator from '../../utils/populator/populator.js' import { paramConverter } from '../../../utils/param-converter/index.js' /** * @implements Action * @category Actions * @module EditAction * @description * Shows form for updating existing record * @private * * @classdesc * Uses {@link EditAction} component to render form */ export const EditAction: Action = { name: 'edit', isVisible: true, actionType: 'record', icon: 'Edit', showInDrawer: false, /** * Responsible for updating existing record. * * To invoke this action use {@link ApiClient#recordAction} * * @return {RecordActionResponse} populated record * @implements Action#handler * @memberof module:EditAction */ handler: async (request, response, context) => { const { record, resource, currentAdmin, h } = context if (!record) { throw new NotFoundError([ `Record of given id ("${request.params.recordId}") could not be found`, ].join('\n'), 'Action#handler') } if (request.method === 'get') { return { record: record.toJSON(currentAdmin) } } const params = paramConverter.prepareParams(request.payload ?? {}, resource) const newRecord = await record.update(params, context) const [populatedRecord] = await populator([newRecord], context) // eslint-disable-next-line no-param-reassign context.record = populatedRecord if (record.isValid()) { return { redirectUrl: h.resourceUrl({ resourceId: resource._decorated?.id() || resource.id() }), notice: { message: 'successfullyUpdated', type: 'success', }, record: populatedRecord.toJSON(currentAdmin), } } const baseMessage = populatedRecord.baseError?.message || 'thereWereValidationErrors' return { record: populatedRecord.toJSON(currentAdmin), notice: { message: baseMessage, type: 'error', }, } }, } export default EditAction ================================================ FILE: src/backend/actions/index.ts ================================================ import { DeleteAction } from './delete/delete-action.js' import { ShowAction } from './show/show-action.js' import { NewAction } from './new/new-action.js' import { EditAction } from './edit/edit-action.js' import { SearchAction } from './search/search-action.js' import { ListAction } from './list/list-action.js' import { BulkDeleteAction } from './bulk-delete/bulk-delete-action.js' import { BuildInActions } from './action.interface.js' export * from './delete/delete-action.js' export * from './show/show-action.js' export * from './new/new-action.js' export * from './edit/edit-action.js' export * from './search/search-action.js' export * from './list/list-action.js' export * from './bulk-delete/bulk-delete-action.js' export * from './action.interface.js' export const ACTIONS: {[key in BuildInActions]: any} = { new: NewAction, list: ListAction, show: ShowAction, edit: EditAction, delete: DeleteAction, bulkDelete: BulkDeleteAction, search: SearchAction, } ================================================ FILE: src/backend/actions/list/list-action.ts ================================================ import { flat } from '../../../utils/flat/index.js' import { Action, ActionQueryParameters, ActionResponse } from '../action.interface.js' import sortSetter from '../../services/sort-setter/sort-setter.js' import Filter from '../../utils/filter/filter.js' import populator from '../../utils/populator/populator.js' import { RecordJSON } from '../../../frontend/interfaces/index.js' const PER_PAGE_LIMIT = 500 /** * @implements Action * @category Actions * @module ListAction * @description * Returns selected Records in a list form * @private */ export const ListAction: Action = { name: 'list', isVisible: true, actionType: 'resource', showFilter: true, showInDrawer: false, /** * Responsible for returning data for all records. * * To invoke this action use {@link ApiClient#recordAction} * * @implements Action#handler * @memberof module:ListAction * @return {Promise} records with metadata */ handler: async (request, response, context) => { const { query } = request const { sortBy, direction, filters = {} } = flat.unflatten(query || {}) as ActionQueryParameters const { resource, _admin } = context let { page, perPage } = flat.unflatten(query || {}) as ActionQueryParameters if (perPage) { perPage = +perPage > PER_PAGE_LIMIT ? PER_PAGE_LIMIT : +perPage } else { perPage = _admin.options.settings?.defaultPerPage ?? 10 } page = Number(page) || 1 const listProperties = resource.decorate().getListProperties() const firstProperty = listProperties.find((p) => p.isSortable()) let sort if (firstProperty) { sort = sortSetter( { sortBy, direction }, firstProperty.name(), resource.decorate().options, ) } const filter = await new Filter(filters, resource).populate(context) const { currentAdmin } = context const records = await resource.find(filter, { limit: perPage, offset: (page - 1) * perPage, sort, }, context) const populatedRecords = await populator(records, context) // eslint-disable-next-line no-param-reassign context.records = populatedRecords const total = await resource.count(filter, context) return { meta: { total, perPage, page, direction: sort?.direction, sortBy: sort?.sortBy, }, records: populatedRecords.map((r) => r.toJSON(currentAdmin)), } }, } export default ListAction /** * Response returned by List action * @memberof module:ListAction * @alias ListAction */ export type ListActionResponse = ActionResponse & { /** * Paginated collection of records */ records: Array; /** * Pagination metadata */ meta: { page: number; perPage: number; direction: 'asc' | 'desc'; sortBy: string; total: number; }; } ================================================ FILE: src/backend/actions/new/new-action.ts ================================================ import { populator } from '../../utils/populator/index.js' import { paramConverter } from '../../../utils/param-converter/index.js' import { Action, RecordActionResponse } from '../action.interface.js' /** * @implements Action * @category Actions * @module NewAction * @description * Shows form for creating a new record * Uses {@link NewAction} component to render form * @private */ export const NewAction: Action = { name: 'new', isVisible: true, actionType: 'resource', icon: 'Plus', showInDrawer: false, variant: 'primary', /** * Responsible for creating new record. * * To invoke this action use {@link ApiClient#resourceAction} * * @implements Action#handler * @memberof module:NewAction * @return {Promise} populated records */ handler: async (request, response, context) => { const { resource, h, currentAdmin } = context if (request.method === 'post') { const params = paramConverter.prepareParams(request.payload ?? {}, resource) let record = await resource.build(params) record = await record.create(context) const [populatedRecord] = await populator([record], context) // eslint-disable-next-line no-param-reassign context.record = populatedRecord if (record.isValid()) { return { redirectUrl: h.resourceUrl({ resourceId: resource._decorated?.id() || resource.id() }), notice: { message: 'successfullyCreated', type: 'success', }, record: record.toJSON(currentAdmin), } } const baseMessage = populatedRecord.baseError?.message || 'thereWereValidationErrors' return { record: record.toJSON(currentAdmin), notice: { message: baseMessage, type: 'error', }, } } // TODO: add wrong implementation error throw new Error('new action can be invoked only via `post` http method') }, } export default NewAction ================================================ FILE: src/backend/actions/search/search-action.ts ================================================ import { flat } from '../../../utils/flat/index.js' import { Action, ActionResponse, ActionQueryParameters } from '../action.interface.js' import { RecordJSON } from '../../../frontend/interfaces/index.js' import Filter from '../../utils/filter/filter.js' /** * @implements Action * @category Actions * @module SearchAction * @description * Used to search particular record based on "title" property. It is used by * select fields with autocomplete. * Uses {@link ShowAction} component to render form * @private */ export const SearchAction: Action = { name: 'search', isVisible: false, actionType: 'resource', /** * Search records by query string. * * To invoke this action use {@link ApiClient#resourceAction} * @memberof module:SearchAction * * @return {Promise} populated record * @implements ActionHandler */ handler: async (request, response, context) => { const { currentAdmin, resource } = context const { query } = request const decorated = resource.decorate() const titlePropertyName = request.query?.searchProperty ?? decorated.titleProperty().name() const { sortBy = decorated.options?.sort?.sortBy || titlePropertyName, direction = decorated.options?.sort?.direction || 'asc', filters: customFilters = {}, perPage = 50, page = 1, } = flat.unflatten(query || {}) as ActionQueryParameters const queryString = request.params && request.params.query const queryFilter = queryString ? { [titlePropertyName]: queryString } : {} const filters = { ...customFilters, ...queryFilter, } const filter = new Filter(filters, resource) const records = await resource.find(filter, { limit: perPage, offset: (page - 1) * perPage, sort: { sortBy, direction, }, }, context) return { records: records.map((record) => record.toJSON(currentAdmin)), } }, } export default SearchAction /** * Response of a [Search]{@link ApiController#search} action in the API * @memberof module:SearchAction * @alias SearchResponse */ export type SearchActionResponse = ActionResponse & { /** * List of records */ records: Array; } ================================================ FILE: src/backend/actions/show/show-action.ts ================================================ import { Action, RecordActionResponse } from '../action.interface.js' import NotFoundError from '../../utils/errors/not-found-error.js' /** * @implements Action * @category Actions * @module ShowAction * @description * Returns selected Record * Uses {@link ShowAction} component to render form * @private */ export const ShowAction: Action = { name: 'show', isVisible: true, actionType: 'record', icon: 'Monitor', showInDrawer: false, /** * Responsible for returning data for given record. * * To invoke this action use {@link ApiClient#recordAction} * @memberof module:ShowAction * * @return {Promise} populated record * @implements ActionHandler */ handler: async (request, response, data) => { if (!data.record) { throw new NotFoundError([ `Record of given id ("${request.params.recordId}") could not be found`, ].join('\n'), 'Action#handler') } return { record: data.record.toJSON(data.currentAdmin), } }, } export default ShowAction ================================================ FILE: src/backend/adapters/database/base-database.ts ================================================ /* eslint-disable no-useless-constructor */ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-empty-function */ /* eslint class-methods-use-this: 0 no-unused-vars: 0 */ import BaseResource from '../resource/base-resource.js' import NotImplementedError from '../../utils/errors/not-implemented-error.js' /** * Representation of an ORM database in AdminJS * @category Base * * @mermaid * graph LR * A[BaseDatabase] -->|has many| B(BaseResource) * B --> |has many|C(BaseRecord) * B --> |has many|D(BaseProperty) */ class BaseDatabase { constructor(database: any) {} /** * Checks if given adapter supports database provided by user * * @param {any} database database provided in AdminJSOptions#databases array * @return {Boolean} if given adapter supports this database - returns true */ static isAdapterFor(database: any): boolean { throw new NotImplementedError('BaseDatabase.isAdapterFor') } /** * returns array of all resources (collections/tables) in the database * * @return {BaseResource[]} */ resources(): Array { throw new NotImplementedError('BaseDatabase#resources') } } export default BaseDatabase ================================================ FILE: src/backend/adapters/database/index.ts ================================================ export { default as BaseDatabase } from './base-database.js' ================================================ FILE: src/backend/adapters/index.ts ================================================ export * from './database/index.js' export * from './property/index.js' export * from './record/index.js' export * from './resource/index.js' ================================================ FILE: src/backend/adapters/property/base-property.ts ================================================ /* eslint class-methods-use-this: 0 object-curly-newline: 0 */ /** * @name PropertyType * @typedef {object} PropertyType * @memberof BaseProperty * @alias PropertyType * @property {string} string default property type * @property {string} float type of floating point numbers * @property {string} number regular number * @property {string} boolean boolean value * @property {string} date date * @property {string} datetime date with time * @property {string} mixed type representing an object * @property {string} reference many to one reference * @property {string} richtext wysiwig editor * @property {string} textarea resizable textarea input * @property {string} password password field */ // Spacer const TITLE_COLUMN_NAMES = ['title', 'name', 'subject', 'email'] export type PropertyType = 'string' | 'float' | 'number' | 'boolean' | 'date' | 'datetime' | 'mixed' | 'reference' | 'key-value' | 'richtext' | 'textarea' | 'password' | 'currency' | 'phone' | 'uuid'; // description type BasePropertyAttrs = { path: string; type?: PropertyType; isId?: boolean; isSortable?: boolean; position?: number; } /** * Represents Resource Property * @category Base */ class BaseProperty { private _path: string private _type: PropertyType private _isId: boolean private _isSortable: boolean private _position: number /** * @param {object} options * @param {string} options.path property path: usually it its key but when * property is for an object the path can be * divided to parts by dots: i.e. * 'address.street' * @param {PropertyType} [options.type='string'] * @param {boolean} [options.isId=false] true when field should be treated as an ID * @param {boolean} [options.isSortable=true] if property should be sortable */ constructor({ path, type = 'string', isId = false, isSortable = true, position = 1, }: BasePropertyAttrs) { this._path = path this._type = type this._isId = isId if (!this._path) { throw new Error('you have to give path parameter when creating BaseProperty') } this._isSortable = isSortable this._position = position } /** * Name of the property * @return {string} name of the property */ name(): string { return this._path } path(): string { return this.name() } position(): number { return this._position === undefined ? 1 : this._position } /** * Return type of a property * @return {PropertyType} */ type(): PropertyType { return this._type || 'string' } /** * Return true if given property should be treated as a Record Title. * * @return {boolean} */ isTitle(): boolean { return TITLE_COLUMN_NAMES.includes(this._path.toLowerCase()) } /** * Indicates if given property should be visible * * @return {Boolean} */ isVisible(): boolean { return !this._path || !this._path.match('password') } /** * Indicates if value of given property can be updated * * @return {boolean} */ isEditable(): boolean { return true } /** * Returns true if given property is a uniq key in a table/collection * * @return {boolean} */ isId(): boolean { return !!this._isId } /** * If property is a reference to a record of different resource * it should contain {@link BaseResource.id} of this resource. * * When property is responsible for the field: 'user_id' in SQL database * reference should be the name of the Resource which it refers to: `Users` */ reference(): string | null { return null } /** * Returns all available values which field can accept. It is used in case of * enums * * @return {Array | null} array of all available values or null when field * is not an enum. */ availableValues(): Array | null { return null } /** * Returns true when given property is an array * * @return {boolean} */ isArray(): boolean { return false } /** * Returns true when given property has draggable elements. * Only usable for array properties. * * @return {boolean} */ isDraggable(): boolean { return false } /** * In case of `mixed` type returns all nested properties. * * @return {Array} sub properties */ subProperties(): Array { return [] } /** * Indicates if given property can be sorted * * @return {boolean} */ isSortable(): boolean { return this._isSortable } /** * Indicates if given property is required */ isRequired(): boolean { return false } } export default BaseProperty ================================================ FILE: src/backend/adapters/property/index.ts ================================================ export { default as BaseProperty } from './base-property.js' export type { PropertyType } from './base-property.js' ================================================ FILE: src/backend/adapters/record/base-record.spec.ts ================================================ import chai, { expect } from 'chai' import chaiChange from 'chai-change' import sinon from 'sinon' import sinonChai from 'sinon-chai' import chaiAsPromised from 'chai-as-promised' import { ParamsType } from './params.type.js' import BaseRecord from './base-record.js' import BaseResource from '../resource/base-resource.js' import BaseProperty from '../property/base-property.js' import ValidationError, { PropertyErrors } from '../../utils/errors/validation-error.js' import RecordError from '../../utils/errors/record-error.js' import { ActionDecorator, ResourceDecorator } from '../../decorators/index.js' chai.use(chaiAsPromised) chai.use(chaiChange) chai.use(sinonChai) describe('Record', function () { let record: BaseRecord let params: BaseRecord['params'] = { param1: 'john' } afterEach(function () { sinon.restore() }) describe('#get', function () { context('record with nested parameters', function () { const nested3level = 'value' beforeEach(function () { params = { nested1level: { nested2level: { nested3level } }, } record = new BaseRecord(params, {} as BaseResource) }) it('returns deepest field when all up-level keys are given', function () { expect(record.get('nested1level.nested2level.nested3level')).to.equal(nested3level) }) it('returns object when all up-level keys are given except one', function () { expect(record.get('nested1level.nested2level')).to.deep.equal({ nested3level }) }) it('returns object when only first level key is given', function () { expect(record.get('nested1level')).to.deep.equal({ nested2level: { nested3level }, }) }) it('returns undefined when passing unknown param', function () { expect(record.get('nested1level.nested2')).to.be.undefined }) }) }) describe('#constructor', function () { it('returns empty object if params are not passed to the constructor', function () { record = new BaseRecord({}, {} as BaseResource) expect((record as any).params).to.deep.equal({}) }) it('stores flatten object params', function () { record = new BaseRecord({ auth: { login: 'login' } }, {} as BaseResource) expect((record as any).params).to.deep.equal({ 'auth.login': 'login' }) }) }) describe('#save', function () { const newParams = { param2: 'doe' } const properties = [new BaseProperty({ path: '_id', isId: true })] let resource: BaseResource beforeEach(function () { resource = sinon.createStubInstance(BaseResource, { properties: sinon.stub<[], BaseProperty[]>().returns(properties), create: sinon.stub<[Record], Promise>() .resolves(newParams), update: sinon.stub<[string, Record], Promise>() .resolves(newParams), }) }) it('uses BaseResource#create method when there is no id property', async function () { record = new BaseRecord(newParams, resource) record.save() expect(resource.create).to.have.been.calledWith(newParams) }) it('uses BaseResource#update method when there is a id property', function () { const _id = '1231231313' record = new BaseRecord({ ...newParams, _id }, resource) record.save() expect(resource.update).to.have.been.calledWith(_id, { ...newParams, _id }) }) it('stores validation error when they happen', async function () { const baseError: RecordError = { message: 'test base error', } const propertyErrors: PropertyErrors = { param2: { type: 'required', message: 'Field is required', }, } resource.create = sinon.stub().rejects(new ValidationError(propertyErrors, baseError)) record = new BaseRecord(newParams, resource) await record.save() expect(record.error('param2')).to.deep.equal(propertyErrors.param2) expect(record.baseError).to.deep.equal(baseError) }) it('stores validation error when they happen (even when there is no baseError specified)', async function () { const propertyErrors: PropertyErrors = { param2: { type: 'required', message: 'Field is required', }, } resource.create = sinon.stub().rejects(new ValidationError(propertyErrors)) record = new BaseRecord(newParams, resource) await record.save() expect(record.error('param2')).to.deep.equal(propertyErrors.param2) expect(record.baseError).to.be.null }) }) describe('#update', function () { const newParams = { param2: 'doe' } const properties = [new BaseProperty({ path: '_id', isId: true })] params = { param1: 'john', _id: '1381723981273' } let resource: BaseResource context('resource stores the value', function () { beforeEach(async function () { resource = sinon.createStubInstance(BaseResource, { properties: sinon.stub<[], BaseProperty[]>().returns(properties), update: sinon.stub<[string, Record], Promise>() .resolves(newParams), }) record = new BaseRecord(params, resource) await record.update(newParams) }) it('stores what was returned by BaseResource#update to this.params', function () { expect(record.get('param2')).to.equal(newParams.param2) }) it('resets the baseError when there is none', function () { expect((record as any).baseError).to.deep.equal(null) }) it('resets the errors when there are none', function () { expect((record as any).errors).to.deep.equal({}) }) it('calls the BaseResource#update function with the id and new params', function () { expect(resource.update).to.have.been.calledWith(params._id, newParams) }) }) context('resource throws validation error', function () { const baseError: RecordError = { message: 'test base error', } const propertyErrors: PropertyErrors = { param2: { type: 'required', message: 'Field is required', }, } beforeEach(async function () { resource = sinon.createStubInstance(BaseResource, { properties: sinon.stub<[], BaseProperty[]>().returns(properties), update: sinon.stub<[string, Record], Promise>() .rejects(new ValidationError(propertyErrors, baseError)), }) record = new BaseRecord(params, resource) this.returnedValue = await record.update(newParams) }) it('stores validation baseError', function () { expect(record.baseError).to.deep.equal(baseError) }) it('stores validation errors', function () { expect(record.error('param2')).to.deep.equal(propertyErrors.param2) }) it('returns itself', function () { expect(this.returnedValue).to.equal(record) }) }) }) describe('#isValid', function () { it('returns true when there are no errors', function () { (record as any).errors = {} expect(record.isValid()).to.equal(true) }) it('returns false when there is at least on error', function () { (record as any).errors = { pathWithError: { type: 'required', message: 'I am error' }, } expect(record.isValid()).to.equal(false) }) }) describe('#title', function () { const properties = [new BaseProperty({ path: 'name' })] params = { name: 'john', _id: '1381723981273' } it('returns value in title property', function () { const resource = sinon.createStubInstance(BaseResource, { properties: sinon.stub<[], BaseProperty[]>().returns(properties), }) record = new BaseRecord(params, resource) expect(record.title()).to.equal(params.name) }) }) describe('#populate', function () { it('sets populated field', function () { const populated = { value: new BaseRecord({}, {} as BaseResource) } record = new BaseRecord(params, {} as BaseResource) record.populate('value', populated.value) expect((record as any).populated.value).to.equal(populated.value) }) it('clears populated field when record is null or undefined', () => { record = new BaseRecord(params, {} as BaseResource) record.populate('value', 'something' as any) expect(() => { record.populate('value', null) }).to.alter(() => record.populated.value, { from: 'something', to: undefined }) }) }) describe('#toJSON', () => { const param = 'populatedProperty' let resource: BaseResource beforeEach(() => { resource = sinon.createStubInstance(BaseResource, { properties: sinon.stub<[], BaseProperty[]>().returns([]), decorate: sinon.stub<[], ResourceDecorator>().returns( sinon.createStubInstance(ResourceDecorator, { recordActions: sinon.stub<[BaseRecord], ActionDecorator[]>().returns([]), bulkActions: sinon.stub<[BaseRecord], ActionDecorator[]>().returns([]), }) as unknown as ResourceDecorator, ), }) record = new BaseRecord(params, resource) }) it('changes populated records to JSON', () => { const refRecord = sinon.createStubInstance(BaseRecord, { toJSON: sinon.stub(), }) record.populate(param, refRecord) sinon.stub(record, 'id').returns('1') record.toJSON() expect(refRecord.toJSON).to.have.been.calledOnce }) it('does not changes to JSON when in populated there is something else than BaseRecord', () => { record.populate(param, 'something else' as unknown as BaseRecord) sinon.stub(record, 'id').returns('1') expect(() => { record.toJSON() }).not.to.throw() }) }) }) ================================================ FILE: src/backend/adapters/record/base-record.ts ================================================ import { flat, GetOptions } from '../../../utils/flat/index.js' import { ParamsType } from './params.type.js' import BaseResource from '../resource/base-resource.js' import ValidationError, { PropertyErrors } from '../../utils/errors/validation-error.js' import RecordError from '../../utils/errors/record-error.js' import { RecordJSON } from '../../../frontend/interfaces/index.js' import { CurrentAdmin } from '../../../current-admin.interface.js' import { ActionContext } from '../../actions/index.js' /** * Representation of an particular ORM/ODM Record in given Resource in AdminJS * * @category Base */ class BaseRecord { /** * Resource to which record belongs */ public resource: BaseResource /** * Actual record data stored as a flatten object. You shouldn't access them directly - always * with {@link BaseRecord#get} and {@link BaseRecord#set} property. */ public params: ParamsType /** * Object containing any base/overall validation error messages: * this.baseError = { message: 'errorMessage' } */ public baseError: RecordError | null /** * Object containing all validation errors: this.errors[path] = { message: 'errorMessage' } */ public errors: PropertyErrors /** * Object containing all populated relations. */ public populated: {[key: string]: BaseRecord} /** * @param {ParamsType} params all resource data. I.e. field values * @param {BaseResource} resource resource to which given record belongs */ constructor(params: ParamsType, resource: BaseResource) { this.resource = resource this.params = params ? flat.flatten(params) : {} this.baseError = null this.errors = {} this.populated = {} } /** * Returns value for given field. * @param {string} path path (name) for given field: i.e. 'email' or 'authentication.email' * if email is nested within the authentication object in the data * store * @return {any} value for given field * @deprecated in favour of {@link BaseRecord#get} and {@link BaseRecord#set} methods */ param(path: string): any { return flat.get(this.params, path) } /** * Returns unflatten (regular) value for given field. So if you have in the params following * structure: * ```javascript * params = { * genre.0: 'male', * genre.1: 'female', * } * ``` * * for `get('genre')` function will return ['male', 'female'] * * @param {string} [propertyPath] path for the property. If not set function returns an entire * unflatten object * @param {GetOptions} [options] * @return {any} unflatten data under given path * @new in version 3.3 */ get(propertyPath?: string, options?: GetOptions): any { return flat.get(this.params, propertyPath, options) } /** * Sets given value under the propertyPath. Value is flatten and all previous values under this * path are replaced. When value is `undefined` function just clears the old values * * @param {string} propertyPath * @param {any} value * @returns an entire, updated, params object * @new in version 3.3 */ set(propertyPath: string, value: any): any { this.params = flat.set(this.params, propertyPath, value) return this.params } /** * Returns object containing all params keys starting with prefix * * @param {string} prefix * * @return {object | undefined} * @deprecated in favour of {@link selectParams} */ namespaceParams(prefix: string): Record | void { return flat.selectParams(this.params, prefix) } /** * Returns object containing all params keys starting with prefix * * @param {string} prefix * @param {GetOptions} [options] * * @return {object | undefined} * @new in version 3.3 */ selectParams(prefix: string, options?: GetOptions): Record | void { return flat.selectParams(this.params, prefix, options) } /** * Updates given Record in the data store. Practically it invokes * {@link BaseResource.update} method. * * When validation error occurs it stores that to {@link BaseResource.errors} * * @param {object} params all field with values which has to be updated * @param {ActionContext} [context] * @return {Promise} given record (this) */ async update(params, context?: ActionContext): Promise { try { this.storeParams(params) const returnedParams = await this.resource.update(this.id(), params, context) this.storeParams(returnedParams) } catch (e) { if (e instanceof ValidationError) { this.baseError = e.baseError this.errors = e.propertyErrors return this } throw e } this.baseError = null this.errors = {} return this } /** * Saves the record in the database. When record already exists - it updates, otherwise * it creates new one. * * Practically it invokes * {@link BaseResource#create} or {@link BaseResource#update} methods. * * When validation error occurs it stores that to {@link BaseResource#errors} * @param {ActionContext} [context] * @return {Promise} given record (this) */ async save(context?: ActionContext): Promise { try { let returnedParams if (this.id()) { returnedParams = await this.resource.update(this.id(), this.params, context) } else { returnedParams = await this.resource.create(this.params, context) } this.storeParams(returnedParams) } catch (e) { if (e instanceof ValidationError) { this.baseError = e.baseError this.errors = e.propertyErrors return this } throw e } this.baseError = null this.errors = {} return this } /** * Creates the record in the database * * Practically it invokes * {@link BaseResource#create}. * * When validation error occurs it stores that to {@link BaseResource#errors} * * * @return {Promise} given record (this) * @param {ActionContext} [context] */ async create(context?: ActionContext): Promise { try { const returnedParams = await this.resource.create(this.params, context) this.storeParams(returnedParams) } catch (e) { if (e instanceof ValidationError) { this.baseError = e.baseError this.errors = e.propertyErrors return this } throw e } this.baseError = null this.errors = {} return this } /** * Returns uniq id of the Record. * @return {string | number} id of the Record */ id(): string { const idProperty = this.resource.properties().find((p) => p.isId()) if (!idProperty) { throw new Error(`Resource: "${this.resource.id()}" does not have an id property`) } return this.params[idProperty.name()] } /** * Returns title of the record. Usually title is an value for fields like: email, topic, * title etc. * * Title will be shown in the breadcrumbs for example. * * @return {string} title of the record */ title(): string { const nameProperty = this.resource.properties().find((p) => p.isTitle()) return nameProperty ? this.param(nameProperty.name()) : this.id() } /** * Return state of validation for given record * @return {boolean} if record is valid or not. */ isValid(): boolean { return Object.keys(this.errors).length === 0 } /** * Returns error message for given property path (name) * @param {string} path (name) of property which we want to check if is valid * @return {RecordError | null} validation message of null */ error(path: string): RecordError | null { return this.errors[path] } /** * Populate record relations * * @param {string} propertyPath name of the property which should be populated * @param {BaseRecord | null} [record] record to which property relates. If record is null * or undefined - function clears the previous value */ populate(propertyPath: string, record?: BaseRecord | null): void { if (record === null || typeof record === 'undefined') { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [propertyPath]: oldValue, ...rest } = this.populated this.populated = rest } else { this.populated[propertyPath] = record } } /** * Returns JSON representation of an record * @param {CurrentAdmin} [currentAdmin] * @return {RecordJSON} */ toJSON(currentAdmin?: CurrentAdmin): RecordJSON { const populated = Object.keys(this.populated).reduce((m, key) => { // sometimes user can add some arbitrary element to populated object. In such case // we should omit toJSON call. if ((this.populated[key] as any).toJSON) { m[key] = this.populated[key].toJSON(currentAdmin) } else { m[key] = this.populated[key] } return m }, {}) return { params: this.params, populated, baseError: this.baseError, errors: this.errors, id: this.id(), title: this.resource.decorate().titleOf(this), recordActions: this.resource.decorate().recordActions(this, currentAdmin) .map((recordAction) => recordAction.toJSON(currentAdmin)), bulkActions: this.resource.decorate().bulkActions(this, currentAdmin) .map((recordAction) => recordAction.toJSON(currentAdmin)), } } /** * Stores incoming payloadData in record params * * @param {object} [payloadData] */ storeParams(payloadData?: object): void { this.params = flat.merge(this.params, payloadData) } } export default BaseRecord ================================================ FILE: src/backend/adapters/record/index.ts ================================================ export { default as BaseRecord } from './base-record.js' export * from './params.type.js' ================================================ FILE: src/backend/adapters/record/params.type.ts ================================================ /** * @alias ParamsTypeValue * @memberof BaseRecord */ export type ParamsTypeValue = string | number | boolean | null | undefined | [] | Record | File /** * @alias ParamsType * @memberof BaseRecord */ export type ParamsType = Record // TODO: change ^^^any to ParamsTypeValue ================================================ FILE: src/backend/adapters/resource/base-resource.spec.ts ================================================ import chai, { expect } from 'chai' import sinon from 'sinon' import chaiAsPromised from 'chai-as-promised' import BaseResource from './base-resource.js' import NotImplementedError from '../../utils/errors/not-implemented-error.js' import Filter from '../../utils/filter/filter.js' import BaseRecord from '../record/base-record.js' import AdminJS from '../../../adminjs.js' import ResourceDecorator from '../../decorators/resource/resource-decorator.js' chai.use(chaiAsPromised) describe('BaseResource', function () { let resource: BaseResource beforeEach(function () { resource = new BaseResource({}) }) afterEach(function () { sinon.restore() }) describe('.isAdapterFor', function () { it('throws NotImplementedError', async function () { expect(() => BaseResource.isAdapterFor({})).to.throw(NotImplementedError) }) }) describe('#databaseName', function () { it('throws NotImplementedError', async function () { expect(() => resource.databaseName()).to.throw(NotImplementedError) }) }) describe('#databaseType', function () { it('returns "database" by default', async function () { expect(resource.databaseType()).to.eq('other') }) }) describe('#id', function () { it('throws NotImplementedError', async function () { expect(() => resource.id()).to.throw(NotImplementedError) }) }) describe('#properties', function () { it('throws NotImplementedError', async function () { expect(() => resource.properties()).to.throw(NotImplementedError) }) }) describe('#property', function () { it('throws NotImplementedError', async function () { expect(() => resource.property('someProperty')).to.throw(NotImplementedError) }) }) describe('#count', function () { it('throws NotImplementedError', async function () { expect(resource.count({} as Filter)).to.be.rejectedWith(NotImplementedError) }) }) describe('#find', function () { it('throws NotImplementedError', async function () { expect(resource.find({} as Filter, {})).to.be.rejectedWith(NotImplementedError) }) }) describe('#findOne', function () { it('throws NotImplementedError', async function () { expect(resource.findOne('someId')).to.be.rejectedWith(NotImplementedError) }) }) describe('#build', function () { it('returns new BaseRecord', async function () { const params = { param: 'value' } expect(resource.build(params)).to.be.instanceOf(BaseRecord) }) }) describe('#create', function () { it('throws NotImplementedError', async function () { expect(resource.create({})).to.be.rejectedWith(NotImplementedError) }) }) describe('#update', function () { it('throws NotImplementedError', async function () { expect(resource.update('id', {})).to.be.rejectedWith(NotImplementedError) }) }) describe('#delete', function () { it('throws NotImplementedError', async function () { expect(resource.delete('id')).to.be.rejectedWith(NotImplementedError) }) }) describe('#decorate', function () { it('returns new Decorator when resource has been decorated', function () { sinon.stub(resource, 'properties').returns([]) resource.assignDecorator(new AdminJS(), {}) expect(resource.decorate()).to.be.instanceOf(ResourceDecorator) }) it('throws error when resource has not been decorated', function () { expect(() => resource.decorate()).to.throw('resource does not have any assigned decorator yet') }) }) }) ================================================ FILE: src/backend/adapters/resource/base-resource.ts ================================================ /* eslint-disable @typescript-eslint/no-empty-function */ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint class-methods-use-this: 0 no-unused-vars: 0 */ /* eslint no-useless-constructor: 0 */ import { SupportedDatabasesType } from './supported-databases.type.js' import { BaseProperty, BaseRecord, ParamsType } from '../index.js' import { NotImplementedError, Filter } from '../../utils/index.js' import { ResourceOptions, ResourceDecorator } from '../../decorators/index.js' import AdminJS from '../../../adminjs.js' import { ActionContext } from '../../actions/index.js' /** * Representation of a ORM Resource in AdminJS. Visually resource is a list item in the sidebar. * Each resource has many records and many properties. * * Analogy is REST resource. * * It is an __abstract class__ and all database adapters should implement extend it implement * following methods: * * - (static) {@link BaseResource.isAdapterFor isAdapterFor()} * - {@link BaseResource#databaseName databaseName()} * - {@link BaseResource#name name()} * - {@link BaseResource#id id()} * - {@link BaseResource#properties properties()} * - {@link BaseResource#property property()} * - {@link BaseResource#count count()} * - {@link BaseResource#find find()} * - {@link BaseResource#findOne findOne()} * - {@link BaseResource#findMany findMany()} * - {@link BaseResource#create create()} * - {@link BaseResource#update update()} * - {@link BaseResource#delete delete()} * @category Base * @abstract * @hideconstructor */ class BaseResource { public _decorated: ResourceDecorator | null /** * Checks if given adapter supports resource provided by the user. * This function has to be implemented only if you want to create your custom * database adapter. * * For one time Admin Resource creation - it is not needed. * * @param {any} rawResource resource provided in AdminJSOptions#resources array * @return {Boolean} if given adapter supports this resource - returns true * @abstract */ static isAdapterFor(rawResource): boolean { throw new NotImplementedError('BaseResource.isAdapterFor') } /** * Creates given resource based on the raw resource object * * @param {Object} [resource] */ constructor(resource?: any) { this._decorated = null } /** * The name of the database to which resource belongs. When resource is * a mongoose model it should be database name of the mongo database. * * Visually, by default, all resources are nested in sidebar under their database names. * @return {String} database name * @abstract */ databaseName(): string { throw new NotImplementedError('BaseResource#databaseName') } /** * Returns type of the database. It is used to compute sidebar icon for * given resource. Default: 'database' * @return {String} */ databaseType(): SupportedDatabasesType | string { return 'other' } /** * Each resource has to have uniq id which will be put to an URL of AdminJS routes. * For instance in {@link Router} path for the `new` form is `/resources/{resourceId}/new` * @return {String} uniq resource id * @abstract */ id(): string { throw new NotImplementedError('BaseResource#id') } /** * returns array of all properties which belongs to resource * @return {BaseProperty[]} * @abstract */ properties(): Array { throw new NotImplementedError('BaseResource#properties') } /** * returns property object for given field * @param {String} path path/name of the property. Take a look at * {@link BaseProperty} to learn more about * property paths. * @return {BaseProperty | null} * @abstract */ property(path: string): BaseProperty | null { throw new NotImplementedError('BaseResource#property') } /** * Returns number of elements for given resource by including filters * @param {Filter} filter represents what data should be included * @param {ActionContext} [context] * @return {Promise} * @abstract */ async count(filter: Filter, context?: ActionContext): Promise { throw new NotImplementedError('BaseResource#count') } /** * Returns actual records for given resource * * @param {Filter} filter what data should be included * @param {Object} options * @param {Number} [options.limit] how many records should be taken * @param {Number} [options.offset] offset * @param {Object} [options.sort] sort * @param {Number} [options.sort.sortBy] sortable field * @param {Number} [options.sort.direction] either asc or desc * @param {ActionContext} [context] * @return {Promise} list of records * @abstract * @example * // filters example * { * name: 'Tom', * createdAt: { from: '2019-01-01', to: '2019-01-18' } * } */ async find(filter: Filter, options: { limit?: number; offset?: number; sort?: { sortBy?: string; direction?: 'asc' | 'desc'; }; }, context?: ActionContext): Promise> { throw new NotImplementedError('BaseResource#find') } /** * Finds one Record in the Resource by its id * * @param {String} id uniq id of the Resource Record * @param {ActionContext} [context] * @return {Promise | null} record * @abstract */ async findOne(id: string, context?: ActionContext): Promise { throw new NotImplementedError('BaseResource#findOne') } /** * Finds many records based on the resource ids * * @param {Array} ids list of ids to find * @param {ActionContext} [context] * * @return {Promise>} records */ async findMany(ids: Array, context?: ActionContext): Promise> { throw new NotImplementedError('BaseResource#findMany') } /** * Builds new Record of given Resource. * * Each Record is an representation of the resource item. Before it can be saved, * it has to be instantiated. * * This function has to be implemented if you want to create new records. * * @param {Record} params * @return {BaseRecord} */ build(params: Record): BaseRecord { return new BaseRecord(params, this) } /** * Creates new record * * @param {Record} params * @param {ActionContext} [context] * @return {Promise} created record converted to raw Object which * can be used to initiate new {@link BaseRecord} instance * @throws {ValidationError} If there are validation errors it should be thrown * @abstract */ async create(params: Record, context?: ActionContext): Promise { throw new NotImplementedError('BaseResource#create') } /** * Updates the record. * * @param {String} id uniq id of the Resource Record * @param {Record} params * @param {ActionContext} [context] * @return {Promise} created record converted to raw Object which * can be used to initiate new {@link BaseRecord} instance * @throws {ValidationError} If there are validation errors it should be thrown * @abstract */ async update(id: string, params: Record, context?: ActionContext) : Promise { throw new NotImplementedError('BaseResource#update') } /** * Delete given record by id * * @param {String | Number} id id of the Record * @param {ActionContext} [context] * @throws {ValidationError} If there are validation errors it should be thrown * @abstract */ async delete(id: string, context?: ActionContext): Promise { throw new NotImplementedError('BaseResource#delete') } /** * Assigns given decorator to the Resource. Than it will be available under * resource.decorate() method * * @param {BaseDecorator} Decorator * @param {AdminJS} admin current instance of AdminJS * @param {ResourceOptions} [options] * @private */ assignDecorator(admin: AdminJS, options: ResourceOptions = {}): void { this._decorated = new ResourceDecorator({ resource: this, admin, options }) } /** * Gets decorator object for given resource * @return {BaseDecorator | null} */ decorate(): ResourceDecorator { if (!this._decorated) { throw new Error('resource does not have any assigned decorator yet') } return this._decorated } } export default BaseResource ================================================ FILE: src/backend/adapters/resource/index.ts ================================================ export { default as BaseResource } from './base-resource.js' export * from './supported-databases.type.js' ================================================ FILE: src/backend/adapters/resource/supported-databases.type.ts ================================================ export type SupportedDatabasesType = 'MySQL' | 'MariaDB' | 'Postgres' | 'CockroachDB' | 'SQLite' | 'MicrosoftSQLServer' | 'Oracle' | 'SAPHana' | 'MongoDB' | 'other' ================================================ FILE: src/backend/bundler/app.bundler.ts ================================================ import path from 'path' import * as url from 'url' import { InputOptions, OutputOptions } from 'rollup' import { nodeResolve as resolve } from '@rollup/plugin-node-resolve' import * as commonjs from '@rollup/plugin-commonjs' import * as replace from '@rollup/plugin-replace' import * as json from '@rollup/plugin-json' import { minify } from 'rollup-plugin-esbuild-minify' import { AssetBundler } from './utils/asset-bundler.js' import { NODE_ENV } from './utils/constants.js' const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) const input: InputOptions = { input: path.join(__dirname, '../../frontend/bundle-entry.js'), external: AssetBundler.DEFAULT_EXTERNALS, plugins: [ resolve({ extensions: AssetBundler.DEFAULT_EXTENSIONS, mainFields: ['browser', 'main', 'module', 'jsnext:main'], preferBuiltins: false, }), (json as any).default(), (replace as any).default({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 'process.env.IS_BROWSER': 'true', 'process.env.': 'AdminJS.env.', preventAssignment: true, 'process.browser': true, }), (commonjs as any).default(), ...(NODE_ENV === 'production' ? [minify()] : []), ], } const output: OutputOptions = { name: 'AdminJS', file: path.join(__dirname, `../../frontend/assets/scripts/app-bundle.${NODE_ENV}.js`), inlineDynamicImports: true, globals: AssetBundler.DEFAULT_GLOBALS, } const bundler = new AssetBundler(input, output) export default bundler ================================================ FILE: src/backend/bundler/components.bundler.ts ================================================ import { InputOptions, OutputOptions } from 'rollup' import { nodeResolve as resolve } from '@rollup/plugin-node-resolve' import * as commonjs from '@rollup/plugin-commonjs' import * as replace from '@rollup/plugin-replace' import * as json from '@rollup/plugin-json' import { minify } from 'rollup-plugin-esbuild-minify' import { babel } from '@rollup/plugin-babel' import presetEnv from '@babel/preset-env' import presetReact from '@babel/preset-react' import presetTs from '@babel/preset-typescript' import { AssetBundler } from './utils/asset-bundler.js' import { COMPONENTS_ENTRY_PATH, COMPONENTS_OUTPUT_PATH, NODE_ENV } from './utils/constants.js' const input: InputOptions = { input: COMPONENTS_ENTRY_PATH, external: AssetBundler.DEFAULT_EXTERNALS, plugins: [ resolve({ extensions: AssetBundler.DEFAULT_EXTENSIONS, mainFields: ['browser', 'main', 'module', 'jsnext:main'], preferBuiltins: false, }), (json as any).default(), (replace as any).default({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 'process.env.IS_BROWSER': 'true', 'process.env.': 'AdminJS.env.', preventAssignment: true, 'process.browser': true, }), (commonjs as any).default(), babel({ extensions: AssetBundler.DEFAULT_EXTENSIONS, babelrc: false, babelHelpers: 'bundled', exclude: 'node_modules/**/*.js', presets: [ [presetEnv, { targets: { node: '18', }, loose: true, modules: false, }], presetReact, presetTs, ], plugins: ['@babel/plugin-syntax-import-assertions'], }), ...(NODE_ENV === 'production' ? [minify()] : []), ], } const output: OutputOptions = { name: 'AdminJSCustom', file: COMPONENTS_OUTPUT_PATH, inlineDynamicImports: true, globals: AssetBundler.DEFAULT_GLOBALS, } const bundler = new AssetBundler(input, output) export default bundler ================================================ FILE: src/backend/bundler/generate-user-component-entry.spec.js ================================================ import path from 'path' import * as url from 'url' import AdminJS from '../../adminjs.js' import { ComponentLoader } from '../utils/index.js' import generateUserComponentEntry from './generate-user-component-entry.js' const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) const exampleComponent = '../../../spec/fixtures/example-component.js' const entryPath = './' describe('generateUserComponentEntry', function () { it('defines AdminJS.UserComponents', function () { const adminJs = new AdminJS() const entryFile = generateUserComponentEntry(adminJs, entryPath) expect(entryFile).to.have.string('AdminJS.UserComponents = {}\n') }) it('adds env variables to the entry file', function () { const adminJs = new AdminJS({ env: { ENV_NAME: 'value' }, }) const entryFile = generateUserComponentEntry(adminJs, entryPath) expect(entryFile).to.have.string('AdminJS.env.ENV_NAME = "value"\n') }) it('adds components to the entry file', function () { const loader = new ComponentLoader() const componentId = loader.add('ExampleComponent', exampleComponent) const adminJs = new AdminJS({ componentLoader: loader }) const rootEntryPath = path.resolve(entryPath) const filePath = path.relative( rootEntryPath, path.normalize(path.join(__dirname, exampleComponent)), ) const entryFile = generateUserComponentEntry(adminJs, entryPath) expect(entryFile).to.have.string([ `import ${componentId} from '${filePath.replace('.js', '')}'`, `AdminJS.UserComponents.${componentId} = ${componentId}`, ].join('\n')) AdminJS.UserComponents = {} }) }) ================================================ FILE: src/backend/bundler/generate-user-component-entry.ts ================================================ import * as path from 'path' import slash from 'slash' import AdminJS from '../../adminjs.js' /** * Generates entry file for all UsersComponents. * Entry consists of 3 parts: * 1. Setup AdminJS.UserComponents map. * 2. List of all environmental variables passed to AdminJS in configuration option. * 3. Imports of user components defined by ComponentLoader. * * @param {AdminJS} admin * @param {String} entryPath path to folder where entry file is located * @return {String} content of an entry file * * @private */ const generateUserComponentEntry = (admin: AdminJS, entryPath: string): string => { const { env = {} } = admin.options admin.componentLoader.__unsafe_merge(AdminJS.__unsafe_staticComponentLoader) const components = admin.componentLoader.getComponents() const absoluteEntryPath = path.resolve(entryPath) const setupPart = 'AdminJS.UserComponents = {}\n' const envPart = Object.keys(env).map((envKey) => ( `AdminJS.env.${envKey} = ${JSON.stringify(env[envKey])}\n` )).join('') const componentsPart = Object.keys(components || {}).map((componentId) => { const componentUrl = path.relative( absoluteEntryPath, components[componentId], ) return [ `import ${componentId} from '${slash(componentUrl)}'`, `AdminJS.UserComponents.${componentId} = ${componentId}`, ].join('\n') }).join('\n') return setupPart + envPart + componentsPart } export default generateUserComponentEntry ================================================ FILE: src/backend/bundler/globals.bundler.ts ================================================ import path from 'path' import * as url from 'url' import { InputOptions, OutputOptions } from 'rollup' import { nodeResolve as resolve } from '@rollup/plugin-node-resolve' import * as commonjs from '@rollup/plugin-commonjs' import * as replace from '@rollup/plugin-replace' import * as json from '@rollup/plugin-json' import * as polyfills from 'rollup-plugin-polyfill-node' import { minify } from 'rollup-plugin-esbuild-minify' import { AssetBundler } from './utils/asset-bundler.js' import { NODE_ENV } from './utils/constants.js' const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) const input: InputOptions = { input: path.join(__dirname, '../../frontend/global-entry.js'), plugins: [ resolve({ extensions: AssetBundler.DEFAULT_EXTENSIONS, mainFields: ['browser'], preferBuiltins: false, browser: true, }), (replace as any).default({ 'process.env.NODE_ENV': JSON.stringify(NODE_ENV), 'process.env.IS_BROWSER': 'true', 'process.stderr.fd': 'false', preventAssignment: true, 'process.browser': true, }), (json as any).default(), (commonjs as any).default(), (polyfills as any).default(), ...(NODE_ENV === 'production' ? [minify()] : []), ], } const output: OutputOptions = { name: 'globals', inlineDynamicImports: true, file: path.join(__dirname, `../../frontend/assets/scripts/global-bundle.${NODE_ENV}.js`), globals: { react: 'React', redux: 'Redux', axios: 'axios', punycode: 'punycode', uuid: 'uuid', '@adminjs/design-system/styled-components': 'styled', 'react-dom': 'ReactDOM', 'prop-types': 'PropTypes', 'react-redux': 'ReactRedux', 'react-router': 'ReactRouter', 'react-router-dom': 'ReactRouterDOM', }, } const bundler = new AssetBundler(input, output) export default bundler ================================================ FILE: src/backend/bundler/index.ts ================================================ export { default as appBundler } from './app.bundler.js' export { default as globalsBundler } from './globals.bundler.js' export { default as componentsBundler } from './components.bundler.js' export { default as generateUserComponentEntry } from './generate-user-component-entry.js' export * from './utils/constants.js' export { AssetBundler } from './utils/asset-bundler.js' ================================================ FILE: src/backend/bundler/utils/asset-bundler.ts ================================================ /* eslint-disable import/no-extraneous-dependencies */ import { readFile, mkdir, writeFile } from 'fs/promises' import { rollup, watch, InputOptions, OutputOptions } from 'rollup' import isUndefined from 'lodash/isUndefined.js' import ora from 'ora' import { ADMIN_JS_TMP_DIR, NODE_ENV } from './constants.js' export class AssetBundler { static DEFAULT_EXTENSIONS = ['.mjs', '.cjs', '.js', '.jsx', '.json', '.ts', '.tsx', '.scss'] static DEFAULT_GLOBALS = { react: 'React', redux: 'Redux', 'react-feather': 'FeatherIcons', '@adminjs/design-system/styled-components': 'styled', 'prop-types': 'PropTypes', 'react-dom': 'ReactDOM', 'react-redux': 'ReactRedux', 'react-router': 'ReactRouter', 'react-router-dom': 'ReactRouterDOM', adminjs: 'AdminJS', '@adminjs/design-system': 'AdminJSDesignSystem', } static DEFAULT_EXTERNALS = [ 'prop-types', 'react', 'react-dom', 'redux', 'react-redux', 'react-router', 'react-router-dom', '@adminjs/design-system/styled-components', 'adminjs', '@adminjs/design-system', 'react-feather', ] protected inputOptions: InputOptions = {} protected outputOptions: OutputOptions = {} constructor(input: InputOptions, output: OutputOptions) { this.createConfiguration(input, output) } public async build() { const bundle = await rollup(this.inputOptions) await bundle.write(this.outputOptions) await bundle.generate(this.outputOptions) } public async watch() { const bundle = await rollup(this.inputOptions) const spinner = ora(`Bundling files in watchmode: ${JSON.stringify(this.inputOptions)}`) const watcher = watch({ ...this.inputOptions, output: this.outputOptions, }) watcher.on('event', (event) => { if (event.code === 'START') { spinner.start('Bundling files...') } if (event.code === 'ERROR') { spinner.fail('Bundle fail:') // eslint-disable-next-line no-console console.log(event) } if (event.code === 'END') { spinner.succeed(`Finish bundling: ${bundle.watchFiles.length} files`) } }) } public async createEntry({ dir = ADMIN_JS_TMP_DIR, content, }: { write?: boolean dir?: string content: string }) { try { await mkdir(dir, { recursive: true }) } catch (error) { if (error.code !== 'EEXIST') { throw error } } await writeFile(this.inputOptions.input as string, content) } public async getOutput(): Promise { try { return await readFile(this.outputOptions.file as string, 'utf-8') } catch (error) { if (error.code !== 'ENOENT') { throw error } } return null } public createConfiguration(input: InputOptions, output: OutputOptions): void { this.inputOptions = input this.outputOptions = output if (isUndefined(this.inputOptions.input)) { throw new Error('InputOptions#input must be defined') } if (typeof this.inputOptions.input !== 'string') { throw new Error('InputOptions#input must be a "string"') } if (isUndefined(this.outputOptions.file)) { throw new Error('OutputOptions#file must be defined') } if (isUndefined(this.outputOptions.name)) { throw new Error('OutputOptions#name must be defined') } if (isUndefined(this.outputOptions.format)) { this.outputOptions.format = 'iife' } if (isUndefined(this.outputOptions.interop)) { this.outputOptions.interop = 'auto' } if (isUndefined(this.outputOptions.sourcemap)) { this.outputOptions.sourcemap = NODE_ENV === 'production' ? false : 'inline' } } public setInputOption(key: string, value: T) { this.inputOptions[key] = value return this } public setOutputOption(key: string, value: T) { this.outputOptions[key] = value return this } } ================================================ FILE: src/backend/bundler/utils/constants.ts ================================================ import path from 'path' export const NODE_ENV = process.env.NODE_ENV === 'production' ? 'production' : 'development' const DEFAULT_TMP_DIR = '.adminjs' export const ADMIN_JS_TMP_DIR = typeof process === 'object' ? process.env.ADMIN_JS_TMP_DIR || DEFAULT_TMP_DIR : DEFAULT_TMP_DIR export const COMPONENTS_ENTRY_PATH = path.join(ADMIN_JS_TMP_DIR, 'entry.js') export const COMPONENTS_OUTPUT_PATH = path.join(ADMIN_JS_TMP_DIR, 'bundle.js') ================================================ FILE: src/backend/controllers/api-controller.spec.js ================================================ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect } from 'chai' import ApiController from './api-controller.js' import { Filter } from '../utils/filter/index.js' describe('ApiController', function () { beforeEach(function () { this.total = 0 this.fieldName = 'title' this.recordJSON = { title: 'recordTitle' } this.recordStub = { toJSON: () => this.recordJSON, params: {}, recordActions: [], } this.resourceName = 'Users' this.action = { name: 'actionName', handler: this.sinon.stub().returns({ record: this.recordStub }), isAccessible: this.sinon.stub().returns(true), } const property = { name: () => this.fieldName, reference: () => false, isId: () => true, type: () => 'string' } this.resourceStub = { id: this.sinon.stub().returns('someId'), decorate: this.sinon.stub().returns({ actions: { list: this.action, edit: this.action, show: this.action, delete: this.action, new: this.action, [this.action.name]: this.action, }, getListProperties: this.sinon.stub().returns([property]), titleProperty: () => ({ name: () => this.fieldName }), properties: { [property.name()]: property }, resourceActions: () => [this.action], recordActions: () => [this.action], recordsDecorator: (records) => records, getFlattenProperties: this.sinon.stub().returns([property]), id: this.resourceName, }), find: this.sinon.stub().returns([]), count: this.sinon.stub().returns(this.total), findOne: this.sinon.stub().returns(this.recordStub), } this.recordStub.resource = this.resourceStub this.adminStub = { findResource: this.sinon.stub().returns(this.resourceStub), options: { rootPath: '/admin' }, translateMessage: () => 'message', } this.currentAdmin = { email: 'john@doe.com', name: 'John' } this.apiController = new ApiController({ admin: this.adminStub }, this.currentAdmin) this.sinon.stub(Filter.prototype, 'populate').returns([this.recordStub]) }) describe('#resourceAction', function () { it('calls the handler of correct action', async function () { await this.apiController.resourceAction({ params: { action: this.action.name, }, }, {}) expect( this.action.handler, ).to.have.been.calledWith( this.sinon.match.any, this.sinon.match.any, this.sinon.match.has('action', this.action), ) }) }) describe('#recordAction', function () { it('calls the handler of correct action', async function () { await this.apiController.recordAction({ params: { action: this.action.name, recordId: 'id', }, }, {}) expect( this.action.handler, ).to.have.been.calledWith( this.sinon.match.any, this.sinon.match.any, this.sinon.match.has('action', this.action).and( this.sinon.match.has('record', this.recordStub), ), ) }) it('throws an error when action do not return record', function (done) { this.action.handler = async () => ({ someData: 'without an record', }) this.apiController.recordAction({ params: { action: this.action.name, recordId: 'id', }, }, {}).catch((error) => { expect(error).property('name', 'ConfigurationError') done() }) }) }) }) ================================================ FILE: src/backend/controllers/api-controller.ts ================================================ /* eslint-disable max-len */ /* eslint no-unused-vars: 0 */ import populator from '../utils/populator/populator.js' import ViewHelpers from '../utils/view-helpers/view-helpers.js' import { CurrentAdmin } from '../../current-admin.interface.js' import AdminJS from '../../adminjs.js' import { ActionContext, ActionRequest, RecordActionResponse, ActionResponse, BulkActionResponse } from '../actions/action.interface.js' import ConfigurationError from '../utils/errors/configuration-error.js' import NotFoundError from '../utils/errors/not-found-error.js' import ForbiddenError from '../utils/errors/forbidden-error.js' import { requestParser } from '../utils/request-parser/index.js' import { SearchActionResponse } from '../actions/search/search-action.js' import actionErrorHandler from '../services/action-error-handler/action-error-handler.js' import { validateParam } from '../../utils/param-converter/validate-param.js' import { DecoratedProperties } from '../decorators/resource/utils/decorate-properties.js' /** * Controller responsible for the auto-generated API: `/admin_root/api/...`, where * _admin_root_ is the `rootPath` given in {@link AdminJSOptions}. * * The best way to utilise it is to use {@link ApiClient} on the frontend. * * ### Available API endpoints * *
* * | Endpoint | Method | Description | * |--------------------------|-----------------------|-------------| * | .../api/resources/{resourceId}/actions/{action} | {@link ApiController#resourceAction} | Perform customized resource action | * | .../api/resources/{resourceId}/records/{recordId}/{action} | {@link ApiController#recordAction} | Perform customized record action | * | .../api/resources/{resourceId}/bulk/{action}?recordIds={recordIds} | {@link ApiController#bulkAction} | Perform customized bulk action | * | .../api/pages/{pageName}_ | {@link ApiController#page} | Perform customized page action | * | .../api/dashboard_ | {@link ApiController#dashboard} | Perform customized dashboard action | * *
* * ### Responsibility * * In general this controllers takes handler functions you define in {@link AdminJSOptions} and: * - find all the [context information]{@link ActionContext} which is needed by the action * and is passed to the {@link Action#handler}, {@link Action#before} and {@link Action#after} * - checks if action can be invoked by particular user {@link Action#isAccessible} * - invokes {@link Action#before} and {@link Action#after} hooks * * You probably don't want to modify it, but you can call its methods by using {@link ApiClient} * * @hideconstructor */ class ApiController { private _admin: AdminJS private currentAdmin: CurrentAdmin /** * @param {Object} options * @param {AdminJSOptions} options.admin * @param {CurrentAdmin} [currentAdmin] */ constructor({ admin }, currentAdmin) { this._admin = admin this.currentAdmin = currentAdmin } /** * Returns context for given action * @private * * @param {ActionRequest} request request object * @return {Promise} action context */ async getActionContext(request: ActionRequest): Promise { const { resourceId, action: actionName } = request.params const h = new ViewHelpers(this._admin) const resource = this._admin.findResource(resourceId) const action = resource.decorate().actions[actionName] return { resource, action, h, currentAdmin: this.currentAdmin, _admin: this._admin, } } /** * Search records by query string. * * Handler function responsible for a _.../api/resources/{resourceId}/search/{query}_ route * * @param {ActionRequest} request with __params.query__ set * @param {any} response * * @return {Promise} found records */ async search(request: ActionRequest, response): Promise { request.params.action = 'search' // eslint-disable-next-line no-console console.log([ 'Using ApiController#search is deprecated in favour of resourceAction', 'It will be removed in the next version', ].join('\n')) return this.resourceAction(request, response) as Promise } /** * Performs a customized {@link Action resource action}. * To call it use {@link ApiClient#resourceAction} method. * * Handler function responsible for a _.../api/resources/{resourceId}/actions/{action}_ * * @param {ActionRequest} originalRequest * @param {any} response object from the plugin (i.e. adminjs-expressjs) * * @return {Promise} action response */ async resourceAction(originalRequest: ActionRequest, response: any): Promise { const actionContext = await this.getActionContext(originalRequest) const request = requestParser(originalRequest, actionContext.resource) return actionContext.action.handler(request, response, actionContext) } /** * Performs a customized {@link Action record action}. * To call it use {@link ApiClient#recordAction} method. * * Handler function responsible for a _.../api/resources/{resourceId}/records/{recordId}/{action}_ * * @param {ActionRequest} originalRequest * @param {any} response * * @return {Promise} action response * @throws ConfigurationError When given record action doesn't return {@link RecordJSON} * @throws ConfigurationError when action handler doesn't return Promise<{@link RecordActionResponse}> */ async recordAction(originalRequest: ActionRequest, response: any): Promise { const { recordId, resourceId } = originalRequest.params const actionContext = await this.getActionContext(originalRequest) const request = requestParser(originalRequest, actionContext.resource) if (!recordId) { throw new NotFoundError([ 'You have to pass recordId to the recordAction', ].join('\n'), 'Action#handler') } const idProperty = Object.values(actionContext.resource.decorate()?.properties as DecoratedProperties) .find((p) => p.isId()) if (!idProperty || !validateParam(recordId, idProperty)) { const invalidRecordError = actionErrorHandler( new ForbiddenError([ 'You have to pass a valid recordId to the recordAction', ].join('\n')), actionContext, ) return invalidRecordError as RecordActionResponse } let record = await actionContext.resource.findOne(recordId, actionContext) if (!record) { const missingRecordError = actionErrorHandler( new NotFoundError([ `Record with given id: "${recordId}" cannot be found in resource "${resourceId}"`, ].join('\n'), 'Action#handler'), actionContext, ) return missingRecordError as RecordActionResponse } [record] = await populator([record], actionContext) actionContext.record = record const jsonWithRecord = await actionContext.action.handler(request, response, actionContext) const isValidRecord = !!(jsonWithRecord && jsonWithRecord.record && jsonWithRecord.record.recordActions) const anErrorWasHandled = jsonWithRecord && jsonWithRecord.notice && jsonWithRecord.notice.type === 'error' if (isValidRecord || anErrorWasHandled) { return jsonWithRecord } throw new ConfigurationError( 'handler of a recordAction should return a RecordJSON object', 'Action#handler', ) } /** * Performs a customized {@link Action bulk action}. * To call it use {@link ApiClient#bulkAction} method. * * Handler function responsible for a _.../api/resources/{resourceId}/bulk/{action}?recordIds={recordIds}_ * * @param {ActionRequest} request * @param {any} response * * @return {Promise} action response * @throws NotFoundError when recordIds are missing in query or they don't exists in * the database * @throws ConfigurationError when action handler doesn't return Promise<{@link BulkActionResponse}> */ async bulkAction(originalRequest: ActionRequest, response: any): Promise { const { resourceId } = originalRequest.params const { recordIds } = originalRequest.query || {} const actionContext = await this.getActionContext(originalRequest) const request = requestParser(originalRequest, actionContext.resource) if (!recordIds) { throw new NotFoundError([ 'You have to pass "recordIds" to the bulkAction via search params: ?recordIds=...', ].join('\n'), 'Action#handler') } let records = await actionContext.resource.findMany(recordIds.split(','), actionContext) if (!records || !records.length) { throw new NotFoundError([ `record with given id: "${recordIds}" cannot be found in resource "${resourceId}"`, ].join('\n'), 'Action#handler') } records = await populator(records, actionContext) const jsonWithRecord = await actionContext.action.handler(request, response, { ...actionContext, records }) if (jsonWithRecord && jsonWithRecord.records) { return jsonWithRecord } throw new ConfigurationError( 'handler of a bulkAction should return an Array of RecordJSON object', 'Action#handler', ) } /** * Gets optional data needed by the dashboard. * To call it use {@link ApiClient#getDashboard} method. * * Handler function responsible for a _.../api/dashboard_ * * @param {ActionRequest} request * @param {any} response * * @return {Promise} action response */ async dashboard(request: any, response: any): Promise { const h = new ViewHelpers(this._admin) const handler = this._admin.options.dashboard && this._admin.options.dashboard.handler if (handler) { return handler(request, response, { h, currentAdmin: this.currentAdmin, _admin: this._admin, }) } return { message: [ 'You can override this method by setting up dashboard.handler', 'function in AdminJS options', ].join('\n'), } } /** * Gets optional data needed by the page. * To call it use {@link ApiClient#getPage} method. * * Handler function responsible for a _.../api/pages/{pageName}_ * * @param {ActionRequest} request * @param {any} response * * @return {Promise} action response */ async page(request: any, response: any): Promise { const h = new ViewHelpers(this._admin) const { pages = {} } = this._admin.options const { pageName } = request.params const { handler } = (pages[pageName] || {}) if (handler) { return handler(request, response, { h, currentAdmin: this.currentAdmin, _admin: this._admin, }) } return { message: [ 'You can override this method by setting up pages[pageName].handler', 'function in AdminJS options', ].join('\n'), } } } export default ApiController ================================================ FILE: src/backend/controllers/app-controller.ts ================================================ /* eslint-disable no-unused-vars */ import ViewHelpers from '../utils/view-helpers/view-helpers.js' import componentsBundler from '../bundler/components.bundler.js' import layoutTemplate from '../../frontend/layout-template.js' import { ActionRequest } from '../actions/action.interface.js' import AdminJS from '../../adminjs.js' import { CurrentAdmin } from '../../current-admin.interface.js' import generateUserComponentEntry from '../bundler/generate-user-component-entry.js' import { ADMIN_JS_TMP_DIR } from '../bundler/utils/constants.js' export default class AppController { private _admin: AdminJS private h: ViewHelpers private currentAdmin: CurrentAdmin constructor({ admin }, currentAdmin) { this._admin = admin this.h = new ViewHelpers(admin) this.currentAdmin = currentAdmin } async index(): Promise { return layoutTemplate(this._admin, this.currentAdmin, '') } async resourceAction({ params }: ActionRequest): Promise { const { resourceId, actionName } = params const href = this.h.resourceActionUrl({ resourceId, actionName }) return layoutTemplate(this._admin, this.currentAdmin, href) } async bulkAction({ params, query }: ActionRequest): Promise { const { resourceId, actionName } = params const recordIds = params.recordIds ?? query?.recordIds if (!recordIds) { throw new Error('you have to give "recordIds" in the request parameters') } const arrayOfIds = recordIds?.split?.(',') const href = this.h.bulkActionUrl({ resourceId, actionName, recordIds: arrayOfIds }) return layoutTemplate(this._admin, this.currentAdmin, href) } async resource({ params }: ActionRequest): Promise { const { resourceId } = params const href = this.h.resourceUrl({ resourceId }) return layoutTemplate(this._admin, this.currentAdmin, href) } async recordAction({ params }: ActionRequest): Promise { const { resourceId, actionName, recordId } = params if (!recordId) { throw new Error('you have to give "recordId" in the request parameters') } const href = this.h.recordActionUrl({ resourceId, actionName, recordId }) return layoutTemplate(this._admin, this.currentAdmin, href) } async page({ params }: ActionRequest): Promise { const { pageName } = params if (!pageName) { throw new Error('you have to give "pageName" in the request parameters') } const href = this.h.pageUrl(pageName) return layoutTemplate(this._admin, this.currentAdmin, href) } async bundleComponents(): Promise { const output = await componentsBundler.getOutput() if (output) return output await componentsBundler.createEntry({ content: generateUserComponentEntry(this._admin, ADMIN_JS_TMP_DIR), }) await componentsBundler.build() return componentsBundler.getOutput() } } ================================================ FILE: src/backend/controllers/index.ts ================================================ export { default as AppController } from './app-controller.js' export { default as ApiController } from './api-controller.js' ================================================ FILE: src/backend/decorators/action/action-decorator.spec.ts ================================================ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect } from 'chai' import sinon from 'sinon' import ActionDecorator from './action-decorator.js' import AdminJS from '../../../adminjs.js' import BaseResource from '../../adapters/resource/base-resource.js' import { ActionRequest, ActionContext, ActionResponse, Before, After } from '../../actions/action.interface.js' import ForbiddenError from '../../utils/errors/forbidden-error.js' import ValidationError from '../../utils/errors/validation-error.js' describe('ActionDecorator', function () { const request = { response: true } as unknown as ActionRequest let admin: AdminJS let resource: BaseResource let context: ActionContext let action: ActionDecorator let handler: sinon.SinonStub> beforeEach(function () { admin = sinon.createStubInstance(AdminJS) resource = sinon.createStubInstance(BaseResource) action = { name: 'myAction' } as ActionDecorator context = { resource, _admin: admin, action } as ActionContext handler = sinon.stub() }) afterEach(function () { sinon.restore() }) describe('#before', function () { it('calls all functions if they were given as an array', async function () { // 3 hooks one adding response1 key and the other adding response2 key // and finally one async adding response3 const before = [ () => ({ response1: true }), (response) => ({ ...response, response2: true, }), async (response) => ({ ...response, response3: true }), ] as unknown as Array const decorator = new ActionDecorator({ action: { before, handler, name: 'myAction', actionType: 'resource' }, admin, resource, }) const ret = await decorator.invokeBeforeHook({} as ActionRequest, {} as ActionContext) expect(ret).to.deep.eq({ response1: true, response2: true, response3: true, }) }) }) describe('#after', function () { it('calls all functions if they were given as an array', async function () { // 2 hooks one adding response1 key and the other adding response2 key const after = [ () => ({ response1: true }), (response) => ({ ...response, response2: true, }), async (response) => ({ ...response, response3: true }), ] as unknown as Array> const decorator = new ActionDecorator({ action: { after, handler, name: 'myAction', actionType: 'resource' }, admin, resource, }) const ret = await decorator.invokeAfterHook( {} as ActionResponse, {} as ActionRequest, {} as ActionContext, ) expect(ret).to.deep.eq({ response1: true, response2: true, response3: true, }) }) }) describe('#handler', function () { it('calls the before action when it is given', async function () { const mockedRequest = { response: true } const before = sinon.stub().returns(mockedRequest) const decorator = new ActionDecorator({ action: { before, handler, name: 'myAction', actionType: 'resource' }, admin, resource, }) await decorator.handler(request, 'res', context) expect(before).to.have.been.calledWith(request) expect(handler).to.have.been.calledWith( sinon.match(mockedRequest), ) }) it('calls the after action when it is given', async function () { const modifiedData = { records: false } const data = {} const after = sinon.stub().returns(modifiedData) handler = handler.resolves(data) const decorator = new ActionDecorator({ action: { name: 'myAction', handler, after, actionType: 'resource' }, admin, resource, }) const ret = await decorator.handler(request, 'res', context) expect(ret).to.equal(modifiedData) expect(handler).to.have.been.called expect(after).to.have.been.calledWith(data) }) it('returns forbidden error when its thrown', async function () { const errorMessage = 'you cannot edit this resource' const before = sinon.stub().throws(new ForbiddenError(errorMessage)) const decorator = new ActionDecorator({ action: { before, handler, name: 'myAction', actionType: 'record' }, admin, resource, }) const ret = await decorator.handler(request, 'res', context) expect(before).to.have.been.calledWith(request) expect(ret).to.have.property('notice') expect(ret.notice).to.deep.equal({ message: errorMessage, type: 'error', }) expect(handler).not.to.have.been.called }) it('returns record with validation errors when they are thrown', async function () { const errors = { email: { message: 'Wrong email', type: 'notGood', }, } const notice = { message: 'There are validation errors', type: 'validationError' } const before = sinon.stub().throws(new ValidationError(errors, notice)) const decorator = new ActionDecorator({ action: { before, handler, name: 'myAction', actionType: 'record' }, admin, resource, }) const ret = await decorator.handler(request, 'res', context) expect(before).to.have.been.calledWith(request) expect(ret).to.have.property('notice') expect(ret.notice).to.deep.equal({ message: notice.message, type: 'error', }) expect(ret).to.have.property('record') expect(ret.record).to.have.property('errors') expect(ret.record.errors).to.deep.equal(errors) expect(handler).not.to.have.been.called }) }) }) ================================================ FILE: src/backend/decorators/action/action-decorator.ts ================================================ import { DEFAULT_DRAWER_WIDTH, VariantType } from '@adminjs/design-system' import ConfigurationError from '../../utils/errors/configuration-error.js' import ViewHelpers from '../../utils/view-helpers/view-helpers.js' import AdminJS from '../../../adminjs.js' import BaseResource from '../../adapters/resource/base-resource.js' import { Action, IsFunction, ActionContext, ActionRequest, ActionResponse, After, Before, ActionHandler, } from '../../actions/action.interface.js' import { CurrentAdmin } from '../../../current-admin.interface.js' import { ActionJSON } from '../../../frontend/interfaces/action/action-json.interface.js' import BaseRecord from '../../adapters/record/base-record.js' import actionErrorHandler from '../../services/action-error-handler/action-error-handler.js' import ForbiddenError from '../../utils/errors/forbidden-error.js' import { ParsedLayoutElement, LayoutElement, layoutElementParser, } from '../../utils/layout-element-parser/index.js' const DEFAULT_VARIANT: VariantType = 'default' /** * Decorates an action * * @category Decorators */ class ActionDecorator { public name: string private _admin: AdminJS private _resource: BaseResource private h: ViewHelpers private action: Action /** * @param {Object} params * @param {Action} params.action * @param {BaseResource} params.resource * @param {AdminJS} params.admin current instance of AdminJS */ constructor({ action, admin, resource }: { action: Action; admin: AdminJS; resource: BaseResource; }) { if (!action.actionType) { throw new ConfigurationError( `action: "${action.name}" does not have an "actionType" property`, 'Action', ) } this.name = action.name this._admin = admin this._resource = resource this.h = new ViewHelpers({ options: admin.options }) /** * Original action object * @type {Action} */ this.action = action } /** * Original handler wrapped with the hook `before` and `after` methods. * * @param {ActionRequest} request * @param {any} response * @param {ActionContext} context * * @return {Promise} */ async handler( request: ActionRequest, response: any, context: ActionContext, ): Promise { try { const modifiedRequest = await this.invokeBeforeHook(request, context) this.canInvokeAction(context) const res = await this.invokeHandler(modifiedRequest, response, context) return this.invokeAfterHook(res, modifiedRequest, context) } catch (error) { return actionErrorHandler(error, context) } } /** * Invokes before action hooks if there are any * * @param {ActionRequest} request * @param {ActionContext} context * * @return {Promise} */ async invokeBeforeHook(request: ActionRequest, context: ActionContext): Promise { if (!this.action.before) { return request } if (typeof this.action.before === 'function') { return this.action.before(request, context) } if (Array.isArray(this.action.before)) { return (this.action.before as Array).reduce((prevPromise, hook) => ( prevPromise.then((modifiedRequest) => ( hook(modifiedRequest, context) )) ), Promise.resolve(request)) } throw new ConfigurationError( 'Before action hook has to be either function or Array', 'Action#Before', ) } /** * Invokes action handler if there is any * * @param {ActionRequest} request * @param {any} response * @param {ActionContext} context * * @return {Promise} */ async invokeHandler( request: ActionRequest, response: any, context: ActionContext, ): Promise { if (typeof this.action.handler === 'function') { return this.action.handler(request, response, context) } if (Array.isArray(this.action.handler)) { return (this.action.handler as Array>) .reduce((prevPromise, handler) => ( prevPromise.then(() => ( handler(request, response, context) )) ), Promise.resolve({})) } throw new ConfigurationError( 'Action handler has to be either function or Array', 'Action#Before', ) } /** * Invokes after action hooks if there are any * * @param {ActionResponse} response * @param {ActionRequest} request * @param {ActionContext} context * * @return {Promise} */ async invokeAfterHook( response: ActionResponse, request: ActionRequest, context: ActionContext, ): Promise { if (!this.action.after) { return response } if (typeof this.action.after === 'function') { return this.action.after(response, request, context) } if (Array.isArray(this.action.after)) { return (this.action.after as Array>).reduce((prevPromise, hook) => ( prevPromise.then((modifiedResponse) => ( hook(modifiedResponse, request, context) )) ), Promise.resolve(response)) } throw new ConfigurationError( 'After action hook has to be either function or Array', 'Action#After', ) } /** * Returns true when action can be performed on a record * * @return {Boolean} */ isRecordType(): boolean { return this.action.actionType.includes('record') } /** * Returns true when action can be performed on an entire resource * * @return {Boolean} */ isResourceType(): boolean { return this.action.actionType.includes('resource') } /** * Returns true when action can be performed on selected records * * @return {Boolean} */ isBulkType(): boolean { return this.action.actionType.includes('bulk') } is(what: 'isAccessible' | 'isVisible', currentAdmin?: CurrentAdmin, record?: BaseRecord): boolean { if (!['isAccessible', 'isVisible'].includes(what)) { throw new Error(`'what' has to be either "isAccessible" or "isVisible". You gave ${what}`) } let isAction if (typeof this.action[what] === 'function') { isAction = (this.action[what] as IsFunction)({ resource: this._resource, record, action: this, h: this.h, currentAdmin, _admin: this._admin, } as unknown as ActionContext) } else if (typeof this.action[what] === 'undefined') { isAction = true } else { isAction = this.action[what] } return isAction } /** * Is action visible in the UI * @param {CurrentAdmin} [currentAdmin] currently logged in admin user * @param {BaseRecord} [record] * * @return {Boolean} */ isVisible(currentAdmin?: CurrentAdmin, record?: BaseRecord): boolean { return this.is('isVisible', currentAdmin, record) } /** * Is action accessible * * @param {CurrentAdmin} [currentAdmin] currently logged in admin user * @param {BaseRecord} [record] * @return {Boolean} */ isAccessible(currentAdmin?: CurrentAdmin, record?: BaseRecord): boolean { return this.is('isAccessible', currentAdmin, record) } /** * Indicates if user can invoke given action * * @param {ActionContext} context passed action context * * @return {boolean} true given user has rights to the action * @throws {ForbiddenError} when user cannot perform given action */ canInvokeAction(context: ActionContext): boolean { const { record, records, currentAdmin } = context if (record && this.isAccessible(currentAdmin, record)) { return true } if (records && !records.find((bulkRecord) => !this.isAccessible(currentAdmin, bulkRecord))) { return true } if (!record && !records && this.isAccessible(currentAdmin)) { return true } throw new ForbiddenError('forbiddenError') } containerWidth(): ActionJSON['containerWidth'] { if (typeof this.action.containerWidth === 'undefined') { return this.action.showInDrawer ? DEFAULT_DRAWER_WIDTH : 1 // 100% for a regular action } return this.action.containerWidth } layout(currentAdmin?: CurrentAdmin): Array | null { if (this.action.layout) { let layoutConfig: Array if (typeof this.action.layout === 'function') { layoutConfig = this.action.layout(currentAdmin) as Array } else { layoutConfig = this.action.layout } return layoutConfig.map((element) => layoutElementParser(element)) } return null } variant(): VariantType { return this.action.variant || DEFAULT_VARIANT } parent(): string | null { return this.action.parent || null } custom(): Record { return this.action.custom || {} } hasHandler(): boolean { return !!this.action.handler } showResourceActions(): boolean { if (this.action.showResourceActions === undefined) return true return !!this.action.showResourceActions } /** * Serializes action to JSON format * * @param {CurrentAdmin} [currentAdmin] * * @return {ActionJSON} serialized action */ toJSON(currentAdmin?: CurrentAdmin): ActionJSON { const resourceId = this._resource._decorated?.id() || this._resource.id() return { name: this.action.name, actionType: this.action.actionType, icon: this.action.icon, label: this.action.name, resourceId, guard: this.action.guard ? this.action.guard : '', showFilter: !!this.action.showFilter, showResourceActions: this.showResourceActions(), component: this.action.component, showInDrawer: !!this.action.showInDrawer, hideActionHeader: !!this.action.hideActionHeader, containerWidth: this.containerWidth(), layout: this.layout(currentAdmin), variant: this.variant(), parent: this.parent(), hasHandler: this.hasHandler(), custom: this.custom(), } } } export default ActionDecorator ================================================ FILE: src/backend/decorators/action/index.ts ================================================ export { default as ActionDecorator } from './action-decorator.js' ================================================ FILE: src/backend/decorators/index.ts ================================================ export * from './action/index.js' export * from './property/index.js' export * from './resource/index.js' ================================================ FILE: src/backend/decorators/property/index.ts ================================================ export { PropertyDecorator } from './property-decorator.js' export type { default as PropertyOptions } from './property-options.interface.js' export * from './property-options.interface.js' ================================================ FILE: src/backend/decorators/property/property-decorator.spec.ts ================================================ import { expect } from 'chai' import sinon, { SinonStubbedInstance } from 'sinon' import PropertyDecorator from './property-decorator.js' import BaseProperty from '../../adapters/property/base-property.js' import AdminJS from '../../../adminjs.js' import ResourceDecorator from '../resource/resource-decorator.js' import { BaseResource } from '../../adapters/resource/index.js' describe('PropertyDecorator', () => { const normalName = 'normalName' let stubbedAdmin: SinonStubbedInstance & AdminJS let property: BaseProperty let args: { property: BaseProperty; admin: typeof stubbedAdmin; resource: ResourceDecorator; } beforeEach(() => { property = new BaseProperty({ path: 'name', type: 'string' }) stubbedAdmin = sinon.createStubInstance(AdminJS) // stubbedAdmin.translateProperty = sinon.stub().returns(normalName) as any args = { property, admin: stubbedAdmin, resource: { id: () => 'someId' } as ResourceDecorator } }) describe('#isSortable', () => { it('passes the execution to the base property', () => { sinon.stub(BaseProperty.prototype, 'isSortable').returns(false) expect(new PropertyDecorator(args).isSortable()).to.equal(false) }) }) describe('#isVisible', () => { it('passes execution to BaseProperty.isVisible for list when no options are specified', () => { expect(new PropertyDecorator(args).isVisible('list')).to.equal(property.isVisible()) }) it('passes execution to BaseProperty.isEditable for edit when no options are specified', () => { sinon.stub(BaseProperty.prototype, 'isVisible').returns(false) expect(new PropertyDecorator(args).isVisible('edit')).to.equal(property.isEditable()) }) it('sets new value when it is changed for all views by isVisible option', () => { const decorator = new PropertyDecorator({ ...args, options: { isVisible: false } }) expect(decorator.isVisible('list')).to.equal(false) expect(decorator.isVisible('edit')).to.equal(false) expect(decorator.isVisible('show')).to.equal(false) }) }) describe('#label', () => { it('returns translated label', () => { sinon.stub(BaseProperty.prototype, 'name').returns('normalName') expect(new PropertyDecorator(args).label()).to.equal(normalName) }) }) describe('#reference', () => { const rawReferenceValue = 'Article' const optionsReferenceValue = 'BlogPost' const ReferenceResource = 'OtherResource' as unknown as BaseResource beforeEach(() => { property = new BaseProperty({ path: 'externalId', type: 'reference' }) sinon.stub(property, 'reference').returns(rawReferenceValue) args.admin.findResource.returns(ReferenceResource) }) it('returns model from AdminJS for reference name in properties', () => { new PropertyDecorator({ ...args, property }).reference() expect(args.admin.findResource).to.have.been.calledWith(rawReferenceValue) }) it('returns model from options when they are given', () => { new PropertyDecorator({ ...args, property, options: { reference: optionsReferenceValue, }, }).reference() expect(args.admin.findResource).to.have.been.calledWith(optionsReferenceValue) }) }) describe('#type', () => { const propertyType = 'boolean' beforeEach(() => { property = new BaseProperty({ path: 'externalId', type: propertyType }) }) it('returns `reference` type if reference is set in options', () => { const decorator = new PropertyDecorator({ ...args, property, options: { reference: 'SomeReference', }, }) expect(decorator.type()).to.equal('reference') }) it('returns property reference when no options are given', () => { const decorator = new PropertyDecorator({ ...args, property }) expect(decorator.type()).to.equal(propertyType) }) }) describe('#availableValues', () => { it('map default value to { value, label } object and uses translations', () => { sinon.stub(BaseProperty.prototype, 'availableValues').returns(['val']) expect(new PropertyDecorator(args).availableValues()).to.deep.equal([{ value: 'val', label: 'val', }]) }) }) describe('#position', () => { it('returns -1 for title field', () => { sinon.stub(BaseProperty.prototype, 'isTitle').returns(true) expect(new PropertyDecorator(args).position()).to.equal(-1) }) it('returns 101 for second field', () => { sinon.stub(BaseProperty.prototype, 'isTitle').returns(false) expect(new PropertyDecorator(args).position()).to.equal(101) }) it('returns 0 for an id field', () => { sinon.stub(BaseProperty.prototype, 'isTitle').returns(false) sinon.stub(BaseProperty.prototype, 'isId').returns(true) expect(new PropertyDecorator(args).position()).to.equal(0) }) }) describe('#subProperties', () => { let propertyDecorator: PropertyDecorator const propertyName = 'super' const subPropertyName = 'nested' const subPropertyLabel = 'nestedLabel' beforeEach(() => { property = new BaseProperty({ path: propertyName, type: 'string' }) sinon.stub(property, 'subProperties').returns([ new BaseProperty({ path: subPropertyName, type: 'string' }), ]) propertyDecorator = new PropertyDecorator({ ...args, property, resource: { id: () => 'resourceId', options: { properties: { [`${propertyName}.${subPropertyName}`]: { label: subPropertyLabel }, }, }, } as unknown as ResourceDecorator, }) }) it('returns the array of decorated properties', () => { expect(propertyDecorator.subProperties()).to.have.lengthOf(1) expect(propertyDecorator.subProperties()[0]).to.be.instanceOf(PropertyDecorator) }) it('changes label of the nested property to what was given in PropertyOptions', () => { const subProperty = propertyDecorator.subProperties()[0] expect(subProperty.label()).to.eq(`${propertyName}.${subPropertyName}`) }) }) describe('#toJSON', () => { it('returns JSON representation of a property', () => { expect(new PropertyDecorator(args).toJSON()).to.have.keys( 'isTitle', 'isId', 'position', 'isSortable', 'availableValues', 'name', 'label', 'type', 'reference', 'components', 'isDisabled', 'subProperties', 'isArray', 'isDraggable', 'custom', 'resourceId', 'propertyPath', 'isRequired', 'isVirtual', 'props', 'hideLabel', 'description', ) }) }) afterEach(() => { sinon.restore() }) }) ================================================ FILE: src/backend/decorators/property/property-decorator.ts ================================================ import AdminJS from '../../../adminjs.js' import { BasePropertyJSON, PropertyPlace } from '../../../frontend/interfaces/index.js' import BaseProperty, { PropertyType } from '../../adapters/property/base-property.js' import BaseResource from '../../adapters/resource/base-resource.js' import ResourceDecorator from '../resource/resource-decorator.js' import PropertyOptions from './property-options.interface.js' import { overrideFromOptions } from './utils/override-from-options.js' /** * Decorates property * * @category Decorators */ export class PropertyDecorator { public property: BaseProperty /** * Property path including all parents. * For root property (this without a parent) it will be its name. * But when property has children their paths will include parent path: * `parentName.subPropertyName`. * * This path serves as a key in {@link PropertyOptions} to identify which * property has to be updated */ public propertyPath: string /** * Indicates if given property has been created in AdminJS and hasn't been returned by the * database adapter */ public isVirtual: boolean private _admin: AdminJS private _resource: ResourceDecorator public options: PropertyOptions /** * Array of all subProperties which were added in {@link ResourceOption} interface rather than * in the database * * @private */ private virtualSubProperties: Array /** * @param {Object} opts * @param {BaseProperty} opts.property * @param {AdminJS} opts.admin current instance of AdminJS * @param {PropertyOptions} opts.options * @param {ResourceDecorator} opts.resource */ constructor({ property, admin, options = {}, resource, path, isVirtual }: { property: BaseProperty; admin: AdminJS; options?: PropertyOptions; resource: ResourceDecorator; path?: string; isVirtual?: boolean; }) { this.property = property this._admin = admin this._resource = resource this.propertyPath = path || property.name() this.isVirtual = !!isVirtual this.virtualSubProperties = [] /** * Options passed along with a given resource * @type {PropertyOptions} */ this.options = options } /** * True if given property can be sortable * * @returns {boolean} */ isSortable(): boolean { return !!overrideFromOptions('isSortable', this.property, this.options) } /** * When given property is a reference to another Resource - it returns this Resource * * @return {BaseResource} reference resource */ reference(): BaseResource | null { const referenceResourceId = this.referenceName() if (referenceResourceId) { const resource = this._admin.findResource(referenceResourceId) return resource } return null } referenceName(): string | null { return this.options.reference || this.property.reference() } /** * Name of the property * * @returns {string} */ name(): string { return this.property.name() } /** * Resource decorator of given property */ resource(): ResourceDecorator { return this._resource } /** * Label of a property * * @return {string} */ label(): string { return this.propertyPath } /** * Property type * * @returns {PropertyType} */ type(): PropertyType { if (typeof this.options.reference === 'string') { return 'reference' } return overrideFromOptions('type', this.property, this.options) as PropertyType } /** * If given property has limited number of available values * it returns them. * * @returns {Array<{value: string, label: string}>} */ availableValues(): null | Array<{ value: string | number; label?: string }> { if (this.options.availableValues) { return this.options.availableValues } const values = this.property.availableValues() if (values) { return values.map((value) => ({ value, label: value })) } return null } isArray(): boolean { if (typeof this.options.isArray !== 'undefined') { return !!this.options.isArray } return this.property.isArray() } isDraggable(): boolean { if (typeof this.options.isDraggable !== 'undefined') { return this.isArray() && !!this.options.isDraggable } return this.property.isDraggable() } /** * Indicates if given property should be visible * * @param {'list' | 'edit' | 'show' | 'filter'} where */ isVisible(where: PropertyPlace): boolean { if (typeof this.options.isVisible === 'object' && this.options.isVisible !== null) { return !!this.options.isVisible[where] } if (typeof this.options.isVisible === 'boolean') { return this.options.isVisible } if (where === 'edit') { return this.property.isEditable() } return this.property.isVisible() } /** * Position of the field * * @return {number} */ position(): number { if (this.options.position) { return this.options.position } if (this.isTitle()) { return -1 } if (this.isId()) { return 0 } return 100 + this.property.position() } /** * If property should be treated as an ID field * * @return {boolean} */ isId(): boolean { return !!overrideFromOptions('isId', this.property, this.options) } /** * If property should be marked as a required with a star (*) * * @return {boolean} */ isRequired(): boolean { return !!overrideFromOptions('isRequired', this.property, this.options) } /** * If property should be treated as an title field * Title field is used as a link to the resource page * in the list view and in the breadcrumbs * * @return {boolean} */ isTitle(): boolean { return !!overrideFromOptions('isTitle', this.property, this.options) } /** * If property should be disabled in the UI * * @return {boolean} */ isDisabled(): boolean { return !!this.options.isDisabled } /** * Returns JSON representation of a property * * @param {PropertyPlace} [where] * * @return {PropertyJSON} */ toJSON(where?: PropertyPlace): BasePropertyJSON { return { isTitle: this.isTitle(), isId: this.isId(), position: this.position(), custom: typeof this.options.custom === 'undefined' ? {} : this.options.custom, isSortable: this.isSortable(), isRequired: this.isRequired(), availableValues: this.availableValues(), name: this.name(), propertyPath: this.propertyPath, isDisabled: this.isDisabled(), label: this.label(), type: this.type(), hideLabel: !!this.options.hideLabel, reference: this.referenceName(), components: this.options.components, subProperties: this.subProperties() .filter((subProperty) => !where || subProperty.isVisible(where)) .map((subProperty) => subProperty.toJSON(where)), isArray: this.isArray(), isDraggable: this.isDraggable(), resourceId: this._resource.id(), isVirtual: this.isVirtual, props: this.options.props || {}, description: this.options.description ? this.options.description : undefined, } } /** * Decorates subProperties * * @return {Array} decorated subProperties */ subProperties(): Array { const dbSubProperties = this.property.subProperties().map((subProperty) => { const path = `${this.propertyPath}.${subProperty.name()}` const decorated = new PropertyDecorator({ property: subProperty, admin: this._admin, options: this.getOptionsForSubProperty(path), resource: this._resource, path, }) return decorated }) return [...dbSubProperties, ...this.virtualSubProperties] } addSubProperty(subProperty: PropertyDecorator): void { this.virtualSubProperties.push(subProperty) } /** * Returns PropertyOptions passed by the user for a subProperty. Furthermore * it changes property name to the nested property key. * * @param {String} propertyPath * @return {PropertyOptions} * @private */ private getOptionsForSubProperty(propertyPath: string): PropertyOptions { const propertyOptions = (this._resource.options || {}).properties || {} return { ...propertyOptions[propertyPath], } } } export default PropertyDecorator ================================================ FILE: src/backend/decorators/property/property-options.interface.ts ================================================ import { PropertyType } from '../../adapters/property/base-property.js' /** * Options passed to a given property */ export default interface PropertyOptions { /** * if given property should be visible. It can be either boolean for all possible views, or * you can verify which view in particular should be hidden/shown. */ isVisible?: boolean | { show?: boolean; list?: boolean; edit?: boolean; filter?: boolean; }; /** * List of possible overridden components for given property. */ components?: { show?: string; list?: string; edit?: string; filter?: string; }; /** * Property type */ type?: PropertyType; /** * Indicates if property should be treated as an ID */ isId?: boolean; /** * One of given property should be treated as an "title property". Title property is "clickable" * when user sees the record in a list or show views. * * @deprecated Use ResourceOptions#titleProperty */ isTitle?: boolean; /** * Indicates if given property should be treated as array of elements. * @new In version 3.3 */ isArray?: boolean; /** * Indicates if array elements should be draggable when editing. * It is only usable if the property is an array. * @new In version 3.5 */ isDraggable?: boolean; /** * position of the field in a list, * title field (isTitle) gets position -1 by default other * fields gets position = 100. */ position?: number; /** * If options should be limited to finite set. After setting this * in the UI you will see select box instead of the input */ availableValues?: Array<{ value: string | number; label?: string; }>; /** * Custom properties passed to the frontend in {@link PropertyJSON} */ custom?: { [key: string]: any; }; /** * Additional props passed to the actual React component rendering given property in Edit * component. * * @new in version 3.3 */ props?: { [key: string]: any; }; /** * Whether given property should be editable or not. */ isDisabled?: boolean; /** * Whether given property should be sortable on list or not. */ isSortable?: boolean; /** * Whether given property should be marked as required. */ isRequired?: boolean; /** * Whether label should be hidden - false by default */ hideLabel?: boolean; /** * Name of the resource to which this property should be a reference. * If set - {@link PropertyOptions.type} always returns `reference` * @new In version 3.3 */ reference?: string; /** * Description of field. Shown as hoverable hint after label. * * To use translations provide it in locale with specified options key from resource * @example * ```js * new AdminJS({ * resources: [ * { * resource: myResource, * options: { * properties: { * myAwesomeProperty: { * description: "Plane description" || "awesomeHint", // <- message key in locale * }, * }, * }, * }, * ], * locale: { * translations: { * resources: { * myResource: { * messages: { * awesomeHint: "Locale description", * }, * }, * }, * }, * }, * }); * ``` * @new In version 5.6 */ description?: string; } ================================================ FILE: src/backend/decorators/property/utils/index.ts ================================================ export * from './override-from-options.js' ================================================ FILE: src/backend/decorators/property/utils/override-from-options.spec.ts ================================================ import { expect } from 'chai' import sinon from 'sinon' import { PropertyType, BaseProperty } from '../../../adapters/property/index.js' import { overrideFromOptions } from './override-from-options.js' describe('overrideFromOptions', () => { const propertyName = 'type' const rawValue = 'boolean' const optionsValue = 'string' let property: BaseProperty beforeEach(() => { property = sinon.createStubInstance(BaseProperty, { [propertyName]: sinon.stub<[], PropertyType>().returns(rawValue), }) as unknown as BaseProperty }) it('returns value from BaseProperty function when options are not given', () => { expect(overrideFromOptions(propertyName, property, {})).to.eq(rawValue) }) it('returns value from options it is given', () => { expect(overrideFromOptions(propertyName, property, { [propertyName]: optionsValue, })).to.eq(optionsValue) }) }) ================================================ FILE: src/backend/decorators/property/utils/override-from-options.ts ================================================ import { BaseProperty } from '../../../adapters/property/index.js' import PropertyOptions from '../property-options.interface.js' export type OverridableFromOptionsType = keyof Pick export function overrideFromOptions( optionName: T, property: BaseProperty, options: PropertyOptions, ): ReturnType | null | undefined { if (typeof options[optionName] === 'undefined') { return property[optionName]() } return options[optionName] } ================================================ FILE: src/backend/decorators/resource/index.ts ================================================ export { default as ResourceDecorator } from './resource-decorator.js' export * from './resource-options.interface.js' ================================================ FILE: src/backend/decorators/resource/resource-decorator.spec.ts ================================================ import sinon from 'sinon' import { expect } from 'chai' import ResourceDecorator from './resource-decorator.js' import PropertyDecorator from '../property/property-decorator.js' import AdminJS, { defaultOptions } from '../../../adminjs.js' import resourceStub, { expectedResult } from '../../../../spec/backend/helpers/resource-stub.js' import BaseResource from '../../adapters/resource/base-resource.js' import BaseRecord from '../../adapters/record/base-record.js' import BaseProperty from '../../adapters/property/base-property.js' const someID = 'someID' const currentAdmin = { email: 'some@email.com', name: 'someName', otherValue: 'someOther-value', } const stubAdminJS = (): AdminJS => { const stubbedAdmin = sinon.createStubInstance(AdminJS) return Object.assign(stubbedAdmin, { translateProperty: sinon.stub().returns('translated property'), translateAction: sinon.stub().returns('translated action'), translateMessage: sinon.stub().returns('translate message'), options: { ...defaultOptions, rootPath: '/admin' }, }) } describe('ResourceDecorator', function () { let stubbedAdmin: AdminJS let stubbedRecord: any let stubbedResource: BaseResource let args beforeEach(function () { stubbedRecord = sinon.stub() stubbedResource = resourceStub() stubbedResource._decorated = { id: () => 'resourceId', } as ResourceDecorator stubbedAdmin = stubAdminJS() args = { resource: stubbedResource, admin: stubbedAdmin, } }) afterEach(function () { sinon.restore() }) describe('#getResourceName', function () { it('returns resource when name is not specified in options', function () { expect( new ResourceDecorator({ ...args, options: {} }).getResourceName(), ).to.equal(someID) }) }) describe('#getNavigation', function () { it('returns custom name with icon when options were specified', function () { const options = { navigation: { name: 'someName', icon: 'someIcon', show: true }, } expect( new ResourceDecorator({ ...args, options }).getNavigation(), ).to.deep.equal(options.navigation) }) }) describe('#getProperties', function () { context('all properties are visible', function () { beforeEach(function () { sinon.stub(PropertyDecorator.prototype, 'isVisible').returns(true) }) it('returns first n items when limit is given', function () { const max = 3 const decorator = new ResourceDecorator(args) expect( decorator.getProperties({ where: 'list', max }), ).to.have.lengthOf(max) }) it('returns all properties when limit is not given', function () { const decorator = new ResourceDecorator(args) expect( decorator.getProperties({ where: 'list' }), ).to.have.lengthOf(expectedResult.properties.length) }) it('returns only showProperties from options if they were given', function () { const path = expectedResult.properties[0].path() const decorator = new ResourceDecorator({ ...args, options: { showProperties: [path], }, }) expect( decorator.getProperties({ where: 'show' }), ).to.have.lengthOf(1) }) }) }) describe('#resourceActions', function () { context('no action were specified in custom settings', function () { let decorator: ResourceDecorator beforeEach(function () { const options = {} decorator = new ResourceDecorator({ ...args, options }) }) it('returns 2 default resource actions', function () { const actions = decorator.resourceActions(currentAdmin) const [action] = actions expect(actions).to.have.lengthOf(2) expect(action).to.have.property('name', 'new') }) }) }) describe('#getPropertyByKey', function () { let decorator: ResourceDecorator beforeEach(function () { decorator = new ResourceDecorator(args) }) it('returns property by giving its key', function () { const propertyPath = expectedResult.properties[0].path() expect( decorator.getPropertyByKey(propertyPath), ).to.be.an.instanceof(PropertyDecorator) }) it('returns null when there is no property by given key', function () { expect(decorator.getPropertyByKey('some-unknown-name')).to.eq(null) }) it('returns mixed property', function () { const propertyPath = expectedResult.properties.find((p) => p.type() === 'mixed')?.path() expect( decorator.getPropertyByKey(propertyPath as string), ).to.be.an.instanceof(PropertyDecorator) }) it('returns nested property under mixed', function () { const property = expectedResult.properties.find((p) => p.type() === 'mixed') as BaseProperty const nested1Property = property?.subProperties().find((p) => p.type() !== 'mixed') as BaseProperty const path = [property.path(), nested1Property.path()].join('.') const decoratedProperty = decorator.getPropertyByKey(path) as PropertyDecorator expect(decoratedProperty).to.be.an.instanceof(PropertyDecorator) expect(decoratedProperty.propertyPath).to.eq(path) }) it('returns nested property under 2 level nested mixed', function () { const property = expectedResult.properties.find((p) => p.type() === 'mixed') as BaseProperty const nested1Property = property?.subProperties().find((p) => p.type() === 'mixed') as BaseProperty const nested2Property = nested1Property?.subProperties()[0] as BaseProperty const path = [property.path(), nested1Property.path(), nested2Property.path()].join('.') const decoratedProperty = decorator.getPropertyByKey(path) as PropertyDecorator expect(decoratedProperty).to.be.an.instanceof(PropertyDecorator) expect(decoratedProperty.propertyPath).to.eq(path) }) it('returns property when it is an array', function () { const arrayProperty = expectedResult.properties.find((p) => p.isArray()) as BaseProperty // checking of a property of first item in an array const path = [arrayProperty.path(), '0'].join('.') const decoratedProperty = decorator.getPropertyByKey(path) as PropertyDecorator expect(decoratedProperty).to.be.an.instanceof(PropertyDecorator) expect(decoratedProperty.propertyPath).to.eq(arrayProperty.path()) }) it('returns property when it is an nested array', function () { const arrayProperty = expectedResult.properties .find((p) => p.isArray() && p.type() === 'mixed') as BaseProperty const nested1Property = arrayProperty?.subProperties()[0] as BaseProperty // checking of a property of first item in an array const path = [arrayProperty.path(), '0', nested1Property.path()].join('.') const decoratedProperty = decorator.getPropertyByKey(path) as PropertyDecorator expect(decoratedProperty).to.be.an.instanceof(PropertyDecorator) expect(decoratedProperty.propertyPath).to.eq([arrayProperty.path(), nested1Property.path()].join('.')) }) }) describe('#recordAction', function () { it('returns default actions', function () { const actions = new ResourceDecorator({ ...args, options: {}, }).recordActions(stubbedRecord, currentAdmin) expect(actions).to.have.lengthOf(3) }) it('shows custom actions specified by the user', function () { const options = { actions: { customAction: { actionType: 'record' } } } const actions = new ResourceDecorator({ ...args, options, }).recordActions(stubbedRecord, currentAdmin) expect(actions).to.have.lengthOf(4) }) it('hides the given action if user set isVisible to false', function () { const options = { actions: { show: { isVisible: false } } } const actions = new ResourceDecorator({ ...args, options, }).recordActions(stubbedRecord, currentAdmin) expect(actions).to.have.lengthOf(2) }) it('passes properties to isVisible when it is a function', function () { const someRecord = { params: { param: 'someRecord' } } as unknown as BaseRecord const options = { actions: { show: { isVisible: (data) => { // it passes current admin to the isVisible function expect(data.currentAdmin).to.deep.equal(currentAdmin) expect(data.resource.id).to.equal(stubbedResource.id) expect(data.action.name).to.equal('show') expect(data.record).to.equal(someRecord) return false }, }, }, } const actions = new ResourceDecorator({ ...args, options, }).recordActions(someRecord, currentAdmin) expect(actions).to.have.lengthOf(2) }) }) describe('#toJSON', function () { it('returns JSON representation of a resource', function () { const json = new ResourceDecorator(args).toJSON(currentAdmin) expect(json).to.have.keys( 'id', 'name', 'navigation', 'href', 'actions', 'titleProperty', 'resourceActions', 'listProperties', 'editProperties', 'showProperties', 'filterProperties', 'properties', ) }) it('passes current admin to the resourceActions', function () { const resourceActionsSpy = sinon.spy(ResourceDecorator.prototype, 'resourceActions') new ResourceDecorator(args).toJSON(currentAdmin) expect(resourceActionsSpy).to.have.been.calledWith(currentAdmin) }) }) }) ================================================ FILE: src/backend/decorators/resource/resource-decorator.ts ================================================ import { DecoratedActions } from './utils/decorate-actions.js' import { BaseResource, BaseRecord } from '../../adapters/index.js' import { PropertyDecorator, ActionDecorator } from '../index.js' import ViewHelpers from '../../utils/view-helpers/view-helpers.js' import AdminJS from '../../../adminjs.js' import { ResourceOptions } from './resource-options.interface.js' import { CurrentAdmin } from '../../../current-admin.interface.js' import { ResourceJSON, PropertyPlace } from '../../../frontend/interfaces/index.js' import { decorateActions, decorateProperties, getNavigation, flatSubProperties, DecoratedProperties, getPropertyByKey, } from './utils/index.js' /** * Default maximum number of items which should be present in a list. * * @type {Number} * @private */ export const DEFAULT_MAX_COLUMNS_IN_LIST = 8 /** * Base decorator class which decorates the Resource. * * @category Decorators */ class ResourceDecorator { /** * Map of all root level properties. By root properties we mean property which is not nested * under other mixed property. * * Examples from PropertyOptions: * { * rootProperty: { type: mixed }, // root property * * // nested property - this should go be the subProperty of rootProperty * 'rootProperty.nested': { type: 'string' } * * // also root property because there is no another property of type mixed * 'another.property': { type: 'string' }, * } * * for a the reference {@see decorateProperties} */ public properties: DecoratedProperties public options: ResourceOptions public actions: DecoratedActions private _resource: BaseResource private _admin: AdminJS private h: ViewHelpers /** * @param {object} options * @param {BaseResource} options.resource resource which is decorated * @param {AdminJS} options.admin current instance of AdminJS * @param {ResourceOptions} [options.options] */ constructor({ resource, admin, options = {} }: { resource: BaseResource; admin: AdminJS; options: ResourceOptions; }) { this.getPropertyByKey = this.getPropertyByKey.bind(this) this._resource = resource this._admin = admin this.h = new ViewHelpers({ options: admin.options }) /** * Options passed along with a given resource * @type {ResourceOptions} */ this.options = options this.options.properties = this.options.properties || {} /** * List of all decorated root properties * @type {Array} */ this.properties = decorateProperties(resource, admin, this) /** * Actions for a resource * @type {Object} */ this.actions = decorateActions(resource, admin, this) } /** * Returns the name for the resource. * @return {string} resource name */ getResourceName(): string { return this.id() } /** * Returns the id for the resource. * @return {string} resource id */ id(): string { return this.options.id || this._resource.id() } /** * Returns resource parent along with the icon. By default it is a * database type with its icon * @return {Parent} ResourceJSON['parent']} */ getNavigation(): ResourceJSON['navigation'] { return getNavigation(this.options, this._resource) } /** * Returns propertyDecorator by giving property path * * @param {String} propertyPath property path * * @return {PropertyDecorator} */ getPropertyByKey(propertyPath: string): PropertyDecorator | null { return getPropertyByKey(propertyPath, this.properties) } /** * Returns list of all properties which will be visible in given place (where) * * @param {Object} options * @param {String} options.where one of: 'list', 'show', 'edit', 'filter' * @param {String} [options.max] maximum number of properties returned where there are * no overrides in the options * * @return {Array} */ getProperties({ where, max = 0 }: { where?: PropertyPlace; max?: number; }): Array { const whereProperties = `${where}Properties` // like listProperties, viewProperties etc if (where && this.options[whereProperties] && this.options[whereProperties].length) { return this.options[whereProperties] .map((propertyName) => { const property = this.getPropertyByKey(propertyName) if (!property) { // eslint-disable-next-line no-console console.error([ `[AdminJS]: There is no property of the name: "${propertyName}".`, `Check out the "${where}Properties" in the`, `resource: "${this._resource.id()}"`].join(' ')) } return property }).filter((property) => property) } const properties = Object.keys(this.properties) .filter((key) => !where || this.properties[key].isVisible(where)) .sort((key1, key2) => ( this.properties[key1].position() > this.properties[key2].position() ? 1 : -1 )) .map((key) => this.properties[key]) if (max) { return properties.slice(0, max) } return properties } /** * Returns all the properties with corresponding subProperties in one object. */ getFlattenProperties(): Record { return Object.keys(this.properties).reduce((memo, propertyName) => { const property = this.properties[propertyName] const subProperties = flatSubProperties(property) return Object.assign(memo, { [propertyName]: property }, subProperties) }, {}) } getListProperties(): Array { return this.getProperties({ where: 'list', max: DEFAULT_MAX_COLUMNS_IN_LIST }) } /** * List of all actions which should be invoked for entire resource and not * for a particular record * * @param {CurrentAdmin} currentAdmin currently logged in admin user * @return {Array} Actions assigned to resources */ resourceActions(currentAdmin?: CurrentAdmin): Array { return Object.values(this.actions) .filter((action) => ( action.isResourceType() && action.isVisible(currentAdmin) && action.isAccessible(currentAdmin) )) } /** * List of all actions which should be invoked for entire resource and not * for a particular record * * @param {CurrentAdmin} currentAdmin currently logged in admin user * @return {Array} Actions assigned to resources */ bulkActions(record: BaseRecord, currentAdmin?: CurrentAdmin): Array { return Object.values(this.actions) .filter((action) => ( action.isBulkType() && action.isVisible(currentAdmin, record) && action.isAccessible(currentAdmin, record) )) } /** * List of all actions which should be invoked for given record and not * for an entire resource * * @param {CurrentAdmin} [currentAdmin] currently logged in admin user * @return {Array} Actions assigned to each record */ recordActions(record: BaseRecord, currentAdmin?: CurrentAdmin): Array { return Object.values(this.actions) .filter((action) => ( action.isRecordType() && action.isVisible(currentAdmin, record) && action.isAccessible(currentAdmin, record) )) } /** * Returns PropertyDecorator of a property which should be treated as a title property. * * @return {PropertyDecorator} PropertyDecorator of title property */ titleProperty(): PropertyDecorator { let titleProperty const properties = Object.values(this.properties) if (this.options.titleProperty) { titleProperty = this.getPropertyByKey(this.options.titleProperty) } else { titleProperty = properties.find((p) => p.isTitle()) } return titleProperty || properties[0] } /** * Returns title for given record. * * For example: If given record has `name` property and this property has `isTitle` flag set in * options or by the Adapter - value for this property will be shown * * @param {BaseRecord} record * * @return {String} title of given record */ titleOf(record: BaseRecord): string { return record.get(this.titleProperty().name()) as string } getHref(currentAdmin?: CurrentAdmin): string | null { const { href } = this.options if (href) { if (typeof href === 'function') { return href({ resource: this._resource, currentAdmin, h: this.h, }) } return href } if (this.resourceActions(currentAdmin).find((action) => action.name === 'list')) { return this.h.resourceUrl({ resourceId: this.id() }) } return null } /** * Returns JSON representation of a resource * * @param {CurrentAdmin} currentAdmin * @return {ResourceJSON} */ toJSON(currentAdmin?: CurrentAdmin): ResourceJSON { const flattenProperties = this.getFlattenProperties() const flattenPropertiesJSON = Object.keys(flattenProperties).reduce((memo, key) => ({ ...memo, [key]: flattenProperties[key].toJSON(), }), {}) return { id: this.id(), name: this.getResourceName(), navigation: this.getNavigation(), href: this.getHref(currentAdmin), titleProperty: this.titleProperty().toJSON(), resourceActions: this.resourceActions(currentAdmin).map((ra) => ra.toJSON(currentAdmin)), actions: Object.values(this.actions).map((action) => action.toJSON(currentAdmin)), properties: flattenPropertiesJSON, listProperties: this.getProperties({ where: 'list', max: DEFAULT_MAX_COLUMNS_IN_LIST, }).map((property) => property.toJSON('list')), editProperties: this.getProperties({ where: 'edit', }).map((property) => property.toJSON('edit')), showProperties: this.getProperties({ where: 'show', }).map((property) => property.toJSON('show')), filterProperties: this.getProperties({ where: 'filter', }).map((property) => property.toJSON('filter')), } } } export default ResourceDecorator ================================================ FILE: src/backend/decorators/resource/resource-options.interface.ts ================================================ import type { IconProps } from '@adminjs/design-system' import { Action, ActionResponse, RecordActionResponse, BulkActionResponse } from '../../actions/action.interface.js' import PropertyOptions from '../property/property-options.interface.js' import { ListActionResponse } from '../../actions/list/list-action.js' import { CurrentAdmin } from '../../../current-admin.interface.js' import BaseResource from '../../adapters/resource/base-resource.js' import ViewHelpers from '../../utils/view-helpers/view-helpers.js' import { SearchActionResponse } from '../../actions/search/search-action.js' import { LocaleTranslationsBlock } from '../../../index.js' /** * @alias HrefContext * @memberof ResourceOptions */ export type HrefContext = { /** * view helpers */ h: ViewHelpers; /** * Resource on which href has been invoked. */ resource: BaseResource; /** * Currently logged in admin */ currentAdmin?: CurrentAdmin; } /** * Function returning string or string * * @alias HrefFunction * @memberof ResourceOptions */ export type HrefFunction = (context: HrefContext) => string /** * Options for given resource * * ### Usage with TypeScript * * ```typescript * import { ResourceOptions } from 'adminjs' * ``` */ export interface ResourceOptions { /** * Unique id of a resource. * * So let's suppose that you connected 2 databases to AdminJS. Both of them have * the same collection: 'users'. In this case AdminJS wont be able to distinguish them. * In this case changing Id of one of the resources helps to solve this issue. */ id?: string; /** * List of properties which should be visible on a list */ listProperties?: Array; /** * List of properties which should be visible on show view */ showProperties?: Array; /** * List of properties which should be visible on edit view */ editProperties?: Array; /** * List of properties which should be visible on the filter */ filterProperties?: Array; /** * Name of title property */ titleProperty?: string; /** * Where resource link in sidebar should redirect. Default to the list action. */ href?: HrefFunction | string; /** * Navigation option saying under which menu this resource should be nested in sidebar. * Default to the database name. * * You have couple of options: * - when you set both navigation.name and navigation.icon this resource will be nested under * this menu. * - when you set navigation.name or navigation to a string this resource will be nested under * this menu and the icon will come from the database type * - when you set navigation.icon but leave navigation.name as `null` this resource will be top * level and it will have an icon. * - when you set navigation to null this resource will be top level, but without the icon * - when you set navigation to false this resource will be hidden in the navigation * @new In version 3.3 */ navigation?: { name?: string | null; icon?: IconProps['icon']; } | string | boolean | null; /** * @deprecated in favour of {@link ResourceOptions.navigation} */ parent?: { name?: string | null; icon?: IconProps['icon']; } | string | null; /** * Default sort property and direction. */ sort?: { direction: 'asc' | 'desc'; sortBy: string; }; /** * List of properties along with their options */ properties?: Record; /** * List of all actions along with their options */ actions?: { show?: Partial>; edit?: Partial>; delete?: Partial>; bulkDelete?: Partial>; new?: Partial>; list?: Partial>; search?: Partial>; } | { [key: string]: Partial>; }; /** * Resource-specific translations */ translations?: { [language: string]: LocaleTranslationsBlock; } } ================================================ FILE: src/backend/decorators/resource/utils/decorate-actions.ts ================================================ import mergeWith from 'lodash/mergeWith.js' import ResourceDecorator from '../resource-decorator.js' import AdminJS from '../../../../adminjs.js' import { Action, ActionResponse, ACTIONS } from '../../../actions/index.js' import { BaseResource } from '../../../adapters/index.js' import { ActionDecorator } from '../../action/index.js' export type DecoratedActions = {[key: string]: ActionDecorator} function mergeCustomizer(destValue: T | Array, sourceValue: T | Array): void { if (Array.isArray(destValue)) { destValue.concat(sourceValue) } } /** * Used to create an {@link ActionDecorator} based on both * {@link AdminJS.ACTIONS default actions} and actions specified by the user * via {@link AdminJSOptions} * * @returns {Record} * @private */ export function decorateActions( resource: BaseResource, admin: AdminJS, decorator: ResourceDecorator, ): DecoratedActions { const { options } = decorator // in the end we merge actions defined by the user with the default actions. // since _.merge is a deep merge it also overrides defaults with the parameters // specified by the user. const actions = mergeWith({}, ACTIONS, options.actions || {}, mergeCustomizer) const returnActions = {} // setting default values for actions Object.keys(actions).forEach((key: string) => { const action: Action = { name: actions[key].name || key, label: actions[key].label || key, actionType: actions[key].actionType || ['resource'], ...actions[key], } returnActions[key] = new ActionDecorator({ action, admin, resource, }) }) return returnActions } ================================================ FILE: src/backend/decorators/resource/utils/decorate-properties.spec.ts ================================================ import { expect } from 'chai' import sinon, { SinonStubbedInstance } from 'sinon' import ResourceDecorator from '../resource-decorator.js' import AdminJS from '../../../../adminjs.js' import { BaseProperty, BaseResource } from '../../../adapters/index.js' import { PropertyOptions } from '../../property/index.js' import { DecoratedProperties, decorateProperties } from './decorate-properties.js' describe('decorateProperties', () => { const path = 'propertyPath' let admin: AdminJS let resource: SinonStubbedInstance & BaseResource let decorator: SinonStubbedInstance & ResourceDecorator let property: BaseProperty let decoratedProperties: DecoratedProperties beforeEach(() => { admin = sinon.createStubInstance(AdminJS) resource = sinon.createStubInstance(BaseResource) decorator = sinon.createStubInstance(ResourceDecorator) as any }) afterEach(() => { sinon.restore() }) context('One property with options', () => { const isSortable = true const newIsSortable = false const type = 'boolean' beforeEach(() => { property = new BaseProperty({ path, type, isSortable }) resource.properties.returns([property]) decorator.options = { properties: { [path]: { isSortable: newIsSortable } } } decoratedProperties = decorateProperties(resource, admin, decorator) }) it('returns just this one property', () => { expect(Object.keys(decoratedProperties)).to.have.lengthOf(1) expect(decoratedProperties[path]).not.to.be.undefined }) it('decorates it that the isSortable is updated', () => { const decorated = decoratedProperties[path] expect(decorated.isSortable()).to.eq(newIsSortable) }) it('leaves all other fields like type unchanged', () => { const decorated = decoratedProperties[path] expect(decorated.type()).to.eq(type) }) it('does not set `isVirtual` property', () => { const decorated = decoratedProperties[path] expect(decorated.isVirtual).to.eq(false) }) }) context('just options without any properties', () => { const newType = 'string' const availableValues: PropertyOptions['availableValues'] = [ { value: 'male', label: 'male' }, { value: 'female', label: 'female' }, ] beforeEach(() => { resource.properties.returns([]) decorator.options = { properties: { [path]: { type: newType, availableValues, } } } decoratedProperties = decorateProperties(resource, admin, decorator) }) it('returns just this one property', () => { expect(Object.keys(decoratedProperties)).to.have.lengthOf(1) expect(decoratedProperties[path]).not.to.be.undefined }) it('decorates it that it has type and availableValues', () => { const decorated = decoratedProperties[path] expect(decorated.type()).to.eq(newType) expect(decorated.availableValues()).to.deep.eq(availableValues) }) it('sets `isVirtual` property to true', () => { const decorated = decoratedProperties[path] expect(decorated.isVirtual).to.eq(true) }) }) context('nested properties in the database', () => { let subPropertyLevel1: BaseProperty let subPropertyLevel2: BaseProperty const newIsVisible = false const nestedPath = 'root.level1.level2' beforeEach(() => { property = new BaseProperty({ path: nestedPath.split('.')[0], type: 'mixed' }) subPropertyLevel1 = new BaseProperty({ path: nestedPath.split('.')[1], type: 'mixed' }) subPropertyLevel2 = new BaseProperty({ path: nestedPath.split('.')[2], type: 'mixed' }) sinon.stub(property, 'subProperties').returns([subPropertyLevel1]) sinon.stub(subPropertyLevel1, 'subProperties').returns([subPropertyLevel2]) resource.properties.returns([property]) }) context('options were not set', () => { beforeEach(() => { decorator.options = { properties: { } } decoratedProperties = decorateProperties(resource, admin, decorator) }) it('returns one property', () => { expect(Object.keys(decoratedProperties)).to.have.lengthOf(1) }) it('returns only root property which is not virtual', () => { expect(decoratedProperties[nestedPath.split('.')[0]]).to.have.property('isVirtual', false) }) }) context('options were set for root property', () => { beforeEach(() => { decorator.options = { properties: { [nestedPath.split('.')[0]]: { isVisible: newIsVisible, } } } decoratedProperties = decorateProperties(resource, admin, decorator) }) it('returns one property', () => { expect(Object.keys(decoratedProperties)).to.have.lengthOf(1) }) it('changes its param', () => { expect( decoratedProperties[nestedPath.split('.')[0]].isVisible('show'), ).to.eq(newIsVisible) }) }) context('options were set for nested property', () => { beforeEach(() => { decorator.options = { properties: { [nestedPath]: { isVisible: newIsVisible, } } } decoratedProperties = decorateProperties(resource, admin, decorator) }) it('returns one property', () => { expect(Object.keys(decoratedProperties)).to.have.lengthOf(1) }) it('does not change the root property', () => { expect( decoratedProperties[nestedPath.split('.')[0]].isVisible('show'), ).not.to.eq(newIsVisible) }) }) }) context('virtual nested properties and one db property', () => { beforeEach(() => { property = new BaseProperty({ path: 'otherProperty', type: 'mixed' }) decorator.options = { properties: { root: { type: 'mixed', }, 'root.nested1': { type: 'string' }, 'root.nested2': { type: 'string' }, 'root.nested3': { type: 'string' }, 'root.nestedArray': { type: 'mixed', isArray: true, }, 'root.nestedArray.name': { type: 'string' }, 'root.nestedArray.surName': { type: 'string' }, 'otherProperty.name': { type: 'string' }, }, } resource.properties.returns([property]) decoratedProperties = decorateProperties(resource, admin, decorator) }) it('returns root properties: one db property and 1 virtual', () => { expect(Object.keys(decoratedProperties)).to.have.lengthOf(2) }) it('nests 3 nested properties under the root mixed type', () => { const subProperties = decoratedProperties.root.subProperties() expect(subProperties).to.have.lengthOf(4) }) it('nests 2 properties under the root.nestedArray mixed type', () => { const subProperties = decoratedProperties.root.subProperties()[3].subProperties() expect(subProperties).to.have.lengthOf(2) }) it('nests 1 property under the `otherProperty` mixed dbProperty', () => { const subProperties = decoratedProperties.otherProperty.subProperties() expect(subProperties).to.have.lengthOf(1) }) }) }) ================================================ FILE: src/backend/decorators/resource/utils/decorate-properties.ts ================================================ import ResourceDecorator from '../resource-decorator.js' import AdminJS from '../../../../adminjs.js' import { BaseProperty, BaseResource } from '../../../adapters/index.js' import { PropertyDecorator } from '../../property/index.js' import { getPropertyByKey } from './get-property-by-key.js' import { pathToParts } from '../../../../utils/flat/path-to-parts.js' export type DecoratedProperties = {[key: string]: PropertyDecorator} const decorateDatabaseProperties = ( resource: BaseResource, admin: AdminJS, decorator: ResourceDecorator, ): DecoratedProperties => { const { options } = decorator return resource.properties().reduce((memo, property) => { const decoratedProperty = new PropertyDecorator({ property, admin, options: options.properties && options.properties[property.name()], resource: decorator, }) memo[property.name()] = decoratedProperty return memo }, {} as DecoratedProperties) } const decorateVirtualProperties = ( dbProperties: DecoratedProperties, admin: AdminJS, decorator: ResourceDecorator, ): DecoratedProperties => { const { options } = decorator if (options.properties) { return Object.keys(options.properties).reduce((memo, key) => { const existingProperty = getPropertyByKey(key, dbProperties) if (!existingProperty) { const property = new BaseProperty({ path: key, isSortable: false }) memo[key] = new PropertyDecorator({ property, admin, options: options.properties && options.properties[key], resource: decorator, isVirtual: true, }) return memo } return memo }, {} as DecoratedProperties) } return {} } /** * This function moves nested properties to existing mixed properties if there are any. * So that they could be printed as Section in the UI, and handled together as an Array if there * is a need for that. * * @param {DecoratedProperties} dbProperties * @param {DecoratedProperties} virtualProperties * @private * @hide */ const organizeNestedProperties = ( dbProperties: DecoratedProperties, virtualProperties: DecoratedProperties, ): DecoratedProperties => { const properties = { ...dbProperties, ...virtualProperties } const rootPropertyKeys = Object.keys(properties).filter((key) => { const property = properties[key] // reverse because we start by by finding from the longest path // and removes itself. (skips arrays) // changes 'root.nested.0.nested1' to [root.nested', 'root'] const parts = pathToParts(property.propertyPath, { skipArrayIndexes: true }).reverse().splice(1) if (parts.length) { const mixedPropertyPath = parts.find((part) => ( properties[part] && properties[part].type() === 'mixed' )) if (mixedPropertyPath) { const mixedProperty = properties[mixedPropertyPath] mixedProperty.addSubProperty(property) // remove from the root properties return false } } return true }) return rootPropertyKeys.reduce((memo, key) => { memo[key] = properties[key] return memo }, {} as DecoratedProperties) } /** * Initializes PropertyDecorator for all properties within a resource. When * user passes new property in the options - it will be created as well. * * @returns {Object} * @private */ export function decorateProperties( resource: BaseResource, admin: AdminJS, decorator: ResourceDecorator, ): DecoratedProperties { const dbProperties = decorateDatabaseProperties(resource, admin, decorator) const virtualProperties = decorateVirtualProperties(dbProperties, admin, decorator) return organizeNestedProperties(dbProperties, virtualProperties) } ================================================ FILE: src/backend/decorators/resource/utils/find-sub-property.ts ================================================ import PropertyDecorator from '../../property/property-decorator.js' import { PathParts } from '../../../../utils/flat/path-parts.type.js' /** * @private * * @param {PathParts} pathParts parts returned by `pathToParts` method * @param {PropertyDecorator} rootProperty where function should recursively search for * a subProperty matching one of the pathParts * * @return {PropertyDecorator | null} found subProperty */ export const findSubProperty = ( pathParts: PathParts, rootProperty: PropertyDecorator, ): PropertyDecorator | null => { const subProperties = rootProperty.subProperties() const foundPath = pathParts.find((path) => ( subProperties.find((supProperty) => supProperty.propertyPath === path))) if (foundPath) { const subProperty = subProperties.find((supProperty) => supProperty.propertyPath === foundPath) if (subProperty && foundPath !== pathParts[pathParts.length - 1]) { // if foundPath is not the last (full) path - checkout recursively all subProperties return findSubProperty(pathParts, subProperty) } return subProperty || null } return null } ================================================ FILE: src/backend/decorators/resource/utils/flat-sub-properties.ts ================================================ import PropertyDecorator from '../../property/property-decorator.js' /** * Bu default all subProperties are nested as an array in root Property. This is easy for * adapter to maintain. But in AdminJS core we need a fast way to access them by path. * * This function changes an array to object recursively (for nested subProperties) so they * could be accessed via properties['path.to.sub.property'] * * @param {PropertyDecorator} rootProperty * * @return {Record} * @private */ export const flatSubProperties = ( rootProperty: PropertyDecorator, ): Record => ( rootProperty.subProperties().reduce((subMemo, subProperty) => Object.assign( subMemo, { [subProperty.propertyPath]: subProperty }, flatSubProperties(subProperty), ), {}) ) ================================================ FILE: src/backend/decorators/resource/utils/get-navigation.spec.ts ================================================ import { expect } from 'chai' import { ResourceOptions } from '../resource-options.interface.js' import { getNavigation, DatabaseData, getIcon } from './get-navigation.js' const databaseName = 'mysql-database' const databaseType = 'MySQL' const defaultDatabase: DatabaseData = { databaseName: () => databaseName, databaseType: () => databaseType, } const mappedIcon = getIcon(databaseType) describe('.getNavigation', () => { let resourceOptions: ResourceOptions beforeEach(() => { resourceOptions = {} }) it('returns parent with icon when no options are given', () => { resourceOptions.navigation = undefined expect(getNavigation(resourceOptions, defaultDatabase)).to.deep.eq({ icon: mappedIcon, name: databaseName, show: true, }) }) it('returns null when options are set to null', () => { resourceOptions.navigation = null expect(getNavigation(resourceOptions, defaultDatabase)).to.be.null }) it('returns show false when options are set to false', () => { resourceOptions.navigation = false expect(getNavigation(resourceOptions, defaultDatabase)).to.deep.eq({ name: null, icon: '', show: false, }) }) it('returns parent with a default icon when options was set as a string', () => { const parentName = 'my navigation name' resourceOptions.navigation = parentName expect(getNavigation(resourceOptions, defaultDatabase)).to.deep.eq({ icon: mappedIcon, name: parentName, show: true, }) }) it('returns empty parent with an icon when this was set in options', () => { const icon = 'Car' resourceOptions.navigation = { icon, name: null } expect(getNavigation(resourceOptions, defaultDatabase)).to.deep.eq({ icon, name: null, show: true, }) }) it('works the same with old parent option', () => { const icon = 'Car' resourceOptions.parent = { icon, name: null } expect(getNavigation(resourceOptions, defaultDatabase)).to.deep.eq({ icon, name: null, show: true, }) }) }) ================================================ FILE: src/backend/decorators/resource/utils/get-navigation.ts ================================================ import { ResourceJSON } from '../../../../frontend/interfaces/index.js' import { ResourceOptions } from '../resource-options.interface.js' import { BaseResource, SupportedDatabasesType } from '../../../adapters/index.js' export type DatabaseData = { databaseName: BaseResource['databaseName']; databaseType: BaseResource['databaseType']; } export const DEFAULT_ICON = 'Archive' type IconMapType = {[key in SupportedDatabasesType]: string} export const getIcon = (icon?: SupportedDatabasesType | string): string => { const IconMap: IconMapType = { MariaDB: 'Sql', MySQL: 'Sql', Postgres: 'Sql', CockroachDB: 'Sql', SQLite: 'Sql', MicrosoftSQLServer: 'Sql', Oracle: 'Sql', SAPHana: 'CloudApp', MongoDB: 'Archive', other: 'Archive', } return (icon && IconMap[icon]) ? IconMap[icon] : DEFAULT_ICON } export const getNavigation = ( options: ResourceOptions, database: DatabaseData, ): ResourceJSON['navigation'] => { const navigationOption = typeof options.navigation !== 'undefined' ? options.navigation : options.parent if (navigationOption === null || navigationOption === true) { return null } if (navigationOption === false) { return { name: null, icon: '', show: false, } } if (navigationOption === undefined || typeof navigationOption === 'string') { return { name: navigationOption || database.databaseName(), icon: getIcon(database.databaseType()), show: true, } } const { name, icon } = navigationOption return { name: name || null, icon: icon || getIcon(database.databaseType()), show: true, } } ================================================ FILE: src/backend/decorators/resource/utils/get-property-by-key.ts ================================================ import { PropertyDecorator } from '../../property/index.js' import { DecoratedProperties } from './decorate-properties.js' import { findSubProperty } from './find-sub-property.js' import { pathToParts } from '../../../../utils/flat/path-to-parts.js' export const getPropertyByKey = ( propertyPath: string, properties: DecoratedProperties, ): PropertyDecorator | null => { const parts = pathToParts(propertyPath, { skipArrayIndexes: true }) const fullPath = parts[parts.length - 1] const property = properties[fullPath] if (!property) { // User asks for nested property (embed inside the mixed property) if (parts.length > 1) { const mixedPropertyPath = parts.find((part) => ( properties[part] && properties[part].type() === 'mixed' )) if (mixedPropertyPath) { const mixedProperty = properties[mixedPropertyPath] const subProperty = findSubProperty(parts, mixedProperty) if (subProperty) { return subProperty } } } } return property || null } ================================================ FILE: src/backend/decorators/resource/utils/index.ts ================================================ export * from './find-sub-property.js' export * from './flat-sub-properties.js' export * from './get-navigation.js' export * from './decorate-properties.js' export * from './decorate-actions.js' export * from './get-property-by-key.js' ================================================ FILE: src/backend/index.ts ================================================ export * from './actions/index.js' export * from './adapters/index.js' export * from './controllers/index.js' export * from './decorators/index.js' export * from './services/index.js' export * from './utils/index.js' ================================================ FILE: src/backend/services/action-error-handler/action-error-handler.spec.ts ================================================ import sinon from 'sinon' import { expect } from 'chai' import { ActionContext } from '../../actions/action.interface.js' import BaseResource from '../../adapters/resource/base-resource.js' import { CurrentAdmin } from '../../../current-admin.interface.js' import BaseRecord from '../../adapters/record/base-record.js' import ValidationError from '../../utils/errors/validation-error.js' import ActionErrorHandler from './action-error-handler.js' import ForbiddenError from '../../utils/errors/forbidden-error.js' import { ActionDecorator } from '../../decorators/index.js' describe('ActionErrorHandler', function () { let resource: BaseResource let record: BaseRecord let context: ActionContext let action: ActionDecorator const notice = { message: 'thereWereValidationErrors', type: 'error', } const currentAdmin = {} as CurrentAdmin beforeEach(function () { resource = sinon.createStubInstance(BaseResource) record = sinon.createStubInstance(BaseRecord) as unknown as BaseRecord // translateMessage = sinon.stub().returns(notice.message) action = { name: 'myAction' } as ActionDecorator context = { resource, record, currentAdmin, action } as ActionContext }) afterEach(function () { sinon.restore() }) it('returns record with validation error when ValidationError is thrown', function () { const errors = { fieldWithError: { type: 'required', message: 'Field is required', }, } const error = new ValidationError(errors) expect(ActionErrorHandler(error, context)).to.deep.equal({ record: { baseError: null, errors, params: {}, populated: {}, }, notice, records: [], meta: undefined, }) }) it('returns meta when ValidationError is thrown for the list action', function () { const errors = { fieldWithError: { type: 'required', message: 'Field is required', }, } const error = new ValidationError(errors) action.name = 'list' expect(ActionErrorHandler(error, context)).to.deep.equal({ record: { baseError: null, errors, params: {}, populated: {}, }, notice, records: [], meta: { total: 0, perPage: 0, page: 0, direction: null, sortBy: null, }, }) }) it('throws any undefined error back to the app', function () { const unknownError = new Error() expect(() => { ActionErrorHandler(unknownError, context) }).to.throw(unknownError) }) it('returns record with forbidden error when ForbiddenError is thrown', function () { const errorMessage = 'You cannot perform this action' const error = new ForbiddenError(errorMessage) expect(ActionErrorHandler(error, context)).to.deep.equal({ record: { baseError: { message: errorMessage, type: 'ForbiddenError', }, errors: {}, params: {}, populated: {}, }, records: [], notice: { message: errorMessage, type: 'error', }, meta: undefined, }) }) it('returns meta when ForbiddenError is thrown for the list action', function () { const errorMessage = 'You cannot perform this action' const error = new ForbiddenError(errorMessage) action.name = 'list' expect(ActionErrorHandler(error, context)).to.deep.equal({ record: { baseError: { message: errorMessage, type: 'ForbiddenError', }, errors: {}, params: {}, populated: {}, }, notice: { message: errorMessage, type: 'error', }, records: [], meta: { total: 0, perPage: 0, page: 0, direction: null, sortBy: null, }, }) }) }) ================================================ FILE: src/backend/services/action-error-handler/action-error-handler.ts ================================================ import { NoticeMessage } from '../../../index.js' import { ActionContext, BulkActionResponse, RecordActionResponse } from '../../actions/action.interface.js' import AppError from '../../utils/errors/app-error.js' import ForbiddenError from '../../utils/errors/forbidden-error.js' import NotFoundError from '../../utils/errors/not-found-error.js' import RecordError from '../../utils/errors/record-error.js' import ValidationError, { PropertyErrors } from '../../utils/errors/validation-error.js' /** * @private * @classdesc * Function which catches all the errors thrown by the action hooks or handler */ const actionErrorHandler = ( error: Error, context: ActionContext, ): RecordActionResponse | BulkActionResponse => { if ( error instanceof ValidationError || error instanceof ForbiddenError || error instanceof NotFoundError || error instanceof AppError ) { const { record, currentAdmin, action } = context const baseError: RecordError | null = error.baseError ?? null let baseMessage = '' let errors: PropertyErrors = {} let meta: any let notice: NoticeMessage if (error instanceof ValidationError) { baseMessage = error.baseError?.message || 'thereWereValidationErrors' errors = error.propertyErrors } else { // ForbiddenError, NotFoundError, AppError baseMessage = error.baseMessage || 'anyForbiddenError' } // Add required meta data for the list action if (action.name === 'list') { meta = { total: 0, perPage: 0, page: 0, direction: null, sortBy: null, } } const recordJson = record?.toJSON?.(currentAdmin) if (error instanceof ForbiddenError && recordJson) { recordJson.params = {} recordJson.title = '' recordJson.populated = {} } notice = { message: baseMessage, type: 'error', } if (error instanceof AppError && error.notice) { notice = { ...notice, ...error.notice, } } return { record: { ...recordJson, params: recordJson?.params ?? {}, populated: recordJson?.populated ?? {}, baseError, errors, }, records: [], notice, meta, } } throw error } export default actionErrorHandler ================================================ FILE: src/backend/services/action-error-handler/index.ts ================================================ export { default as actionErrorHandler } from './action-error-handler.js' ================================================ FILE: src/backend/services/index.ts ================================================ export * from './action-error-handler/index.js' export * from './sort-setter/index.js' ================================================ FILE: src/backend/services/sort-setter/index.ts ================================================ export { default as SortSetter } from './sort-setter.js' ================================================ FILE: src/backend/services/sort-setter/sort-setter.spec.js ================================================ import sortSetter from './sort-setter.js' describe('sortSetter', function () { const defaultFieldName = 'someFieldName' const overriddenFieldName = 'otherField' const overriddenDirection = 'desc' const resourceOptions = { sort: { sortBy: overriddenFieldName, direction: overriddenDirection, }, } it('returns query when it is passed', function () { const direction = 'asc' const sortBy = 'name' expect(sortSetter({ direction, sortBy }), defaultFieldName, {}).to.deep.equal({ direction, sortBy, }) }) it('returns defaults when no query is given', function () { expect(sortSetter({}, defaultFieldName, {})).to.deep.equal({ direction: sortSetter.DEFAULT_DIRECTION, sortBy: defaultFieldName, }) }) it('returns overridden sort settings when no defaults are given', function () { expect(sortSetter({}, defaultFieldName, resourceOptions)).to.deep.equal( resourceOptions.sort, ) }) it('throws an error when direction is not correct', function () { expect(() => { sortSetter({}, defaultFieldName, { sort: { direction: 'other' } }) }).to.throw().property('name', 'ConfigurationError') }) }) ================================================ FILE: src/backend/services/sort-setter/sort-setter.ts ================================================ import ConfigurationError from '../../utils/errors/configuration-error.js' import { ResourceOptions } from '../../decorators/resource/resource-options.interface.js' const DEFAULT_DIRECTION = 'asc' type Sort = { direction: 'asc' | 'desc'; sortBy: string; } /** * Sets sort parameters for a list. * * @private * * @param {object} query * @param {string} [query.direction] either `asc` or `desc` * @param {string} [query.sortBy] sort by field passed in query * @param {string} firstPropertyName property name which will be taken as a default * @param {ResourceOptions} resourceOptions={} options passed along with given resource * @return {Sort} */ const sortSetter = ( { direction, sortBy }: {direction?: 'asc' | 'desc'; sortBy?: string} = {}, firstPropertyName: string, resourceOptions: ResourceOptions = {}, ): Sort => { const options = resourceOptions.sort || {} as Sort if (resourceOptions && resourceOptions.sort && resourceOptions.sort.direction && !['asc', 'desc'].includes(resourceOptions.sort.direction)) { throw new ConfigurationError(` Sort direction should be either "asc" or "desc", "${resourceOptions.sort.direction} was given"`, 'global.html#ResourceOptions') } const computedDirection = direction || options.direction || DEFAULT_DIRECTION const params = { direction: computedDirection === 'asc' ? 'asc' : 'desc' as 'asc' | 'desc', sortBy: sortBy || options.sortBy || firstPropertyName, } return params } sortSetter.DEFAULT_DIRECTION = DEFAULT_DIRECTION export { DEFAULT_DIRECTION } export default sortSetter ================================================ FILE: src/backend/utils/auth/base-auth-provider.ts ================================================ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable max-len */ /* eslint-disable class-methods-use-this */ import { CurrentAdmin } from '../../../current-admin.interface.js' import { ComponentLoader } from '../component-loader.js' import { NotImplementedError } from '../errors/index.js' export interface AuthenticatePayload { [key: string]: any; } export interface AuthProviderConfig { componentLoader: ComponentLoader; authenticate: (payload: T, context?: any) => Promise; } export interface LoginHandlerOptions { data: Record; query?: Record; params?: Record; headers: Record; } // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface RefreshTokenHandlerOptions extends LoginHandlerOptions {} /** * Extendable class which includes methods allowing you to build custom auth providers or modify existing ones. * * Documentation: https://docs.adminjs.co/basics/authentication */ export class BaseAuthProvider { /** * "getUiProps" method should be used to decide which configuration variables are needed * in the frontend. By default it returns an empty object. * * @returns an object sent to the frontend app, available in `window.__APP_STATE__` */ public getUiProps(): Record { return {} } /** * Handle login action of user. The method should return a user object or null. * * @param opts Basic REST request data: data (body), query, params, headers * @param context Full request context specific to your framework, i. e. "request" and "response" in Express */ public async handleLogin(opts: LoginHandlerOptions, context?: TContext): Promise { throw new NotImplementedError('BaseAuthProvider#handleLogin') } /** * "handleLogout" allows you to perform extra actions to log out the user, you have access to request's context. * For example, you could want to log out the user from external services besides destroying AdminJS session. * By default, this method is always called by your framework plugin but does nothing. * * @param context Full request context specific to your framework, i. e. "request" and "response" in Express * @returns Returns anything, but the default plugin implementations don't do anything with the result. */ public async handleLogout(context?: TContext): Promise { return Promise.resolve() } /** * This method is assigned to an endpoint at your server's AdminJS "refreshTokenPath". It is not used by default. * In order to use this API Endpoint, override "AuthenticationBackgroundComponent" by using your ComponentLoader instance. * You can use that component to call API to refresh your user's session when specific conditions are met. The default * email/password authentication doesn't require you to refresh your session, but you may want to use "handleRefreshToken" * in case your authentication is integrated with an external IdP which issues short-lived access tokens. * * Any authentication metadata should ideally be stored under "_auth" property of CurrentAdmin. * * See more in the documentation: https://docs.adminjs.co/basics/authentication * * @param opts Basic REST request data: data (body), query, params, headers * @param context Full request context specific to your framework, i. e. "request" and "response" in Express * @returns Updated session object to be merged with existing one. */ public async handleRefreshToken(opts: RefreshTokenHandlerOptions, context?: TContext): Promise { return Promise.resolve({}) } } ================================================ FILE: src/backend/utils/auth/default-auth-provider.ts ================================================ import { AuthProviderConfig, AuthenticatePayload, BaseAuthProvider, LoginHandlerOptions } from './base-auth-provider.js' export interface DefaultAuthenticatePayload extends AuthenticatePayload { email: string; password: string; } // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface DefaultAuthProviderConfig extends AuthProviderConfig {} export class DefaultAuthProvider extends BaseAuthProvider { protected readonly authenticate constructor({ authenticate }: DefaultAuthProviderConfig) { super() this.authenticate = authenticate } override async handleLogin(opts: LoginHandlerOptions, context) { const { data = {} } = opts const { email, password } = data return this.authenticate({ email, password }, context) } } ================================================ FILE: src/backend/utils/auth/index.ts ================================================ export * from './base-auth-provider.js' export * from './default-auth-provider.js' ================================================ FILE: src/backend/utils/build-feature/build-feature.spec.ts ================================================ /* eslint-disable @typescript-eslint/no-empty-function */ import { expect } from 'chai' import { mergeResourceOptions } from './build-feature.js' import { Before, After, ActionResponse, ActionHandler } from '../../actions/action.interface.js' describe('mergeResourceOptions', function () { it('chaines before hooks', function () { const existingOptions = { actions: { new: { before: function firstBeforeHook() {} as unknown as Before, handler: null, }, edit: { after: [function firstAfterHook() {} as unknown as After], }, }, } const newOptions = { actions: { new: { before: function lastBeforeHook() {} as unknown as Before, handler: function lastHandler() {} as unknown as ActionHandler, }, edit: { after: function lastAfterHook() {} as unknown as After, }, newAction: { handler: function newHandler() {} as unknown as ActionHandler, }, }, } expect(mergeResourceOptions(existingOptions, newOptions)).to.deep.eq({ actions: { new: { before: [ existingOptions.actions.new.before, newOptions.actions.new.before, ], handler: [ newOptions.actions.new.handler, ], }, edit: { after: [ existingOptions.actions.edit.after[0], newOptions.actions.edit.after, ], }, newAction: { handler: [ newOptions.actions.newAction.handler, ], }, }, }) }) it('chaines properties', function () { const existingOptions = { properties: { password: { isVisible: true, component: 'ala', }, }, } const newOptions = { properties: { password2: { isVisible: false, component: 'ela', }, }, } expect(mergeResourceOptions(existingOptions, newOptions)).to.deep.eq({ properties: { ...existingOptions.properties, ...newOptions.properties, }, }) }) it('merges falsey options', function () { const existingOptions = { navigation: { name: 'db', }, } const newOptions = { navigation: false, } expect(mergeResourceOptions(existingOptions, newOptions)).to.deep.eq({ navigation: false, }) }) }) ================================================ FILE: src/backend/utils/build-feature/build-feature.ts ================================================ /* eslint-disable no-nested-ternary */ import uniq from 'lodash/uniq.js' import merge from 'lodash/merge.js' import AdminJS from '../../../adminjs.js' import { FeatureType } from '../../../adminjs-options.interface.js' import { ResourceOptions } from '../../decorators/resource/resource-options.interface.js' import { Action, ActionResponse } from '../../actions/action.interface.js' function mergeActionHooks( key: string, oldHook?: T | Array | null, newHook?: T | Array | null, ): Record> | Record { let hooks: Array = [] if (oldHook) { if (Array.isArray(oldHook)) { hooks = [...hooks, ...oldHook] } else if (oldHook) { hooks = [...hooks, oldHook] } } if (newHook) { if (Array.isArray(newHook)) { hooks = [...hooks, ...newHook] } else if (newHook) { hooks = [...hooks, newHook] } } return hooks.length ? { [key]: hooks } : {} } const basicOptions = ['id', 'href', 'parent', 'sort', 'navigation', 'titleProperty', 'translations'] as const const listOptions = [ 'listProperties', 'showProperties', 'editProperties', 'filterProperties', ] as const type BasicOption = typeof basicOptions[number] type ListOption = typeof listOptions[number] type MissingKeys = Required> // The following check is done in typescript to ensure that the `basicOptions` and `listOptions` // contains all the keys from ResourceOptions (+ actions and properties) which are copied // separately. If type MissingKeys has any key following condition is not meet and typescript // throws an error. // eslint-disable-next-line @typescript-eslint/no-unused-vars const hasMissingKeys: MissingKeys = {} as const /** * @name mergeResourceOptions * @function * @description * Merges 2 ResourceOptions together. Used by features * * - 'id', 'href', 'parent', 'sort' from `newOptions` override `oldOptions` * - 'listProperties', 'showProperties', 'editProperties', 'filterProperties' * are joined and made unique * - all 'properties' from `newOptions` override properties from `oldOptions` * - all 'actions' with their parameters from `newOptions` override `oldOptions` * except hooks and handler - which are chained. * * @param {ResourceOptions} oldOptions * @param {ResourceOptions} newOptions * * @return {ResourceOptions} */ const mergeResourceOptions = ( oldOptions: ResourceOptions = {}, newOptions: ResourceOptions = {}, ): ResourceOptions => { const options = { ...oldOptions } basicOptions.forEach((propName: string) => { if (propName in newOptions) { options[propName] = newOptions[propName] } }) listOptions.forEach((propName: string) => { if (propName in newOptions) { const mergedOptions = [ ...(oldOptions && (propName in oldOptions) ? oldOptions[propName] : []), ...(newOptions && (propName in newOptions) ? newOptions[propName] : []), ] options[propName] = uniq(mergedOptions) } }) if (oldOptions.properties || newOptions.properties) { options.properties = merge({}, oldOptions.properties, newOptions.properties) } if (oldOptions.actions || newOptions.actions) { options.actions = Object.keys(newOptions.actions || {}).reduce((memo, actionName) => { const action = (newOptions.actions || {})[actionName] as Action const oldAction = memo[actionName] as Action return { ...memo, [actionName]: { ...memo[actionName], ...action, ...mergeActionHooks('before', oldAction?.before, action?.before), ...mergeActionHooks('after', oldAction?.after, action?.after), ...mergeActionHooks('handler', oldAction?.handler, action?.handler), }, } }, oldOptions.actions || {}) } return options } /** * @name buildFeature * @function * @description * Higher Order Function which creates a feature * * @param {ResourceOptions} options * * @return {FeatureType} * @example * const { buildFeature } = require('adminjs') * * const feature = buildFeature({ * // resource options goes here. * }) */ const buildFeature = ( options: ResourceOptions | ((admin: AdminJS) => ResourceOptions) = {}, ): FeatureType => ( (admin, prevOptions: ResourceOptions = {}): ResourceOptions => mergeResourceOptions( prevOptions, typeof options === 'function' ? options(admin) : options, ) ) export { mergeResourceOptions, buildFeature } ================================================ FILE: src/backend/utils/build-feature/index.ts ================================================ export * from './build-feature.js' ================================================ FILE: src/backend/utils/component-loader.ts ================================================ import * as path from 'path' import * as fs from 'fs' import { ConfigurationError } from './errors/index.js' import { relativeFilePathResolver } from '../../utils/file-resolver.js' export interface ComponentDetails { overrides: boolean filePath: string } export class ComponentLoader { protected components: Record = {} public add(name: string, filePath: string, caller = 'add') { const resolvedFilePath = ComponentLoader.resolveFilePath(filePath, caller) if ((this.components[name] && this.components[name].filePath !== resolvedFilePath) || ComponentLoader.defaultComponents.includes(name) ) { throw new Error(`Component '${name}' is already defined, use .override() instead`) } this.components[name] = { overrides: false, filePath: resolvedFilePath, } return name } public override(name: string, filePath: string, caller = 'override') { const resolvedFilePath = ComponentLoader.resolveFilePath(filePath, caller) if (!this.components[name] && !ComponentLoader.defaultComponents.includes(name) ) { throw new Error(`Component '${name}' is not defined, use .add() instead`) } this.components[name] = { overrides: true, filePath: resolvedFilePath, } return name } public __unsafe_addWithoutChecks(name: string, filePath: string, caller = '__unsafe_addWithoutChecks') { const resolvedFilePath = ComponentLoader.resolveFilePath(filePath, caller) this.components[name] = { overrides: false, filePath: resolvedFilePath, } return name } public clear() { this.components = {} } public getComponents() { return Object .entries(this.components) .reduce( (result, [key, component]) => { result[key] = component.filePath return result }, {} as Record, ) } public __unsafe_merge(componentLoader: ComponentLoader): void { this.components = { ...componentLoader.components, ...this.components, } } public static resolveFilePath(filePath: string, caller: string): string { const extensions = ['.jsx', '.js', '.ts', '.tsx', '.mts', '.mjs'] const src = path.isAbsolute(filePath) ? filePath : relativeFilePathResolver(filePath, new RegExp(`.*.{1}${caller}`)) const { ext: originalFileExtension } = path.parse(src) for (const extension of extensions) { const forcedExt = extensions.includes(originalFileExtension) ? '' : extension const { root, dir, name, ext } = path.parse(src + forcedExt) const fileName = path.format({ root, dir, name, ext }) if (fs.existsSync(fileName)) { return path.format({ root, dir, name }) } } throw new ConfigurationError(`Trying to bundle file '${src}' but it doesn't exist`, 'AdminJS.html') } protected static defaultComponents = [ 'LoggedIn', 'NoRecords', 'SidebarResourceSection', 'SidebarFooter', 'SidebarBranding', 'Sidebar', 'TopBar', 'Breadcrumbs', 'FilterDrawer', 'NoticeBox', 'Version', 'SidebarPages', 'PropertyHeader', 'RecordInList', 'RecordsTableHeader', 'RecordsTable', 'SelectedRecords', 'StyledBackButton', 'ActionHeader', 'ActionButton', 'BulkActionRoute', 'DashboardRoute', 'RecordActionRoute', 'ResourceActionRoute', 'ResourceRoute', 'PageRoute', 'RouteWrapper', 'Application', 'DefaultEditAction', 'DefaultBulkDeleteAction', 'DefaultListAction', 'DefaultNewAction', 'DefaultShowAction', 'DefaultArrayShowProperty', 'DefaultArrayListProperty', 'DefaultArrayEditProperty', 'DefaultBooleanEditProperty', 'DefaultBooleanFilterProperty', 'DefaultBooleanListProperty', 'DefaultBooleanShowProperty', 'BooleanPropertyValue', 'DefaultCurrencyEditProperty', 'DefaultCurrencyShowProperty', 'DefaultCurrencyListProperty', 'DefaultCurrencyFilterProperty', 'CurrencyPropertyInputWrapper', 'DefaultDatetimeEditProperty', 'DefaultDatetimeShowProperty', 'DefaultDatetimeListProperty', 'DefaultDatetimeFilterProperty', 'DefaultPropertyValue', 'DefaultShowProperty', 'DefaultListProperty', 'DefaultEditProperty', 'DefaultFilterProperty', 'DefaultMixedShowProperty', 'DefaultMixedListProperty', 'DefaultMixedEditProperty', 'DefaultPasswordEditProperty', 'DefaultPhoneEditProperty', 'DefaultPhoneFilterProperty', 'DefaultPhoneListProperty', 'DefaultPhoneShowProperty', 'DefaultReferenceEditProperty', 'DefaultReferenceShowProperty', 'DefaultReferenceListProperty', 'DefaultReferenceFilterProperty', 'DefaultReferenceValue', 'DefaultRichtextEditProperty', 'DefaultRichtextListProperty', 'DefaultRichtextShowProperty', 'DefaultTextareaEditProperty', 'DefaultTextareaShowProperty', 'PropertyDescription', 'PropertyLabel', 'Login', 'AuthenticationBackgroundComponent', 'Footer', ] } ================================================ FILE: src/backend/utils/errors/app-error.ts ================================================ import { NoticeMessage } from '../../../index.js' import { ErrorTypeEnum } from '../../../utils/error-type.enum.js' import RecordError from './record-error.js' /** * Error which can be thrown by developer in custom actions/hooks/components * * @category Errors */ export class AppError extends Error { /** * HTTP Status code, defaults to 400 */ public statusCode: number /** * Base error message and type which is stored in the record */ public baseError: RecordError /** * Any custom message which should be seen in the UI */ public baseMessage?: string /** * Any additional error information */ public data?: Record /** * Any additional notice configuration to show in UI */ public notice?: Partial /** * @param {string} message a message to be shared with the client * @param {string} data additional data to be shared with the client */ constructor(message: string, data?: Record, notice?: Partial) { super(message) this.statusCode = 400 this.baseMessage = message this.baseError = { message, type: ErrorTypeEnum.App, } this.data = data this.notice = notice this.name = ErrorTypeEnum.App } } export default AppError ================================================ FILE: src/backend/utils/errors/configuration-error.ts ================================================ import { ErrorTypeEnum } from '../../../utils/error-type.enum.js' import * as CONSTANTS from '../../../constants.js' const buildUrl = (page: string): string => ( `${CONSTANTS.DOCS}/${page}` ) /** * Error which is thrown when user messed up something in the configuration * * @category Errors */ export class ConfigurationError extends Error { /** * @param {string} fnName name of the function, base on which error will * print on the output link to the method documentation. * @param {string} message */ constructor(message, fnName) { const msg = ` ${message} More information can be found at: ${buildUrl(fnName)} ` super(msg) this.message = msg this.name = ErrorTypeEnum.Configuration } } export default ConfigurationError ================================================ FILE: src/backend/utils/errors/forbidden-error.ts ================================================ import { ErrorTypeEnum } from '../../../utils/error-type.enum.js' import RecordError from './record-error.js' /** * Error which is thrown when user * doesn't have an access to a given resource/action. * * @category Errors */ export class ForbiddenError extends Error { /** * HTTP Status code: 403 */ public statusCode: number /** * Base error message and type which is stored in the record */ public baseError: RecordError /** * Any custom message which should be seen in the UI */ public baseMessage?: string /** * @param {string} [message] */ constructor(message?: string) { const defaultMessage = 'You cannot perform this action' super(defaultMessage) this.statusCode = 403 this.baseMessage = message this.baseError = { message: message ?? defaultMessage, type: ErrorTypeEnum.Forbidden, } this.name = ErrorTypeEnum.Forbidden } } export default ForbiddenError ================================================ FILE: src/backend/utils/errors/index.ts ================================================ export * from './app-error.js' export * from './configuration-error.js' export * from './forbidden-error.js' export * from './not-found-error.js' export * from './not-implemented-error.js' export * from './record-error.js' export * from './validation-error.js' ================================================ FILE: src/backend/utils/errors/not-found-error.ts ================================================ import { ErrorTypeEnum } from '../../../utils/error-type.enum.js' import * as CONSTANTS from '../../../constants.js' import RecordError from './record-error.js' const buildUrl = (page: string): string => ( `${CONSTANTS.DOCS}/${page}` ) /** * Error which is thrown when given record/resource/action hasn't been found. * * @category Errors */ export class NotFoundError extends Error { /** * HTTP Status code: 404 */ public statusCode: number /** * Base error message and type which is stored in the record */ public baseError: RecordError /** * Any custom message which should be seen in the UI */ public baseMessage?: string /** * @param {string} fnName name of the function, base on which error will * print on the output link to the method documentation. * @param {string} message */ constructor(message, fnName) { const msg = ` ${message} More information can be found at: ${buildUrl(fnName)} ` super(msg) this.statusCode = 404 this.baseMessage = message this.baseError = { message, type: ErrorTypeEnum.NotFound, } this.message = msg this.name = ErrorTypeEnum.NotFound } } export default NotFoundError ================================================ FILE: src/backend/utils/errors/not-implemented-error.ts ================================================ import { DOCS } from '../../../constants.js' const buildUrl = (fnName: string): string => { if (fnName) { let obj let fn if (fnName.indexOf('.') > 0) { [obj, fn] = fnName.split('.') fn = `.${fn}` } else { [obj, fn] = fnName.split('#') } return `${DOCS}/${obj}.html#${fn}` } return DOCS } /** * Error which is thrown when an abstract method is not implemented * * @category Errors */ export class NotImplementedError extends Error { /** * @param {string} fnName name of the function, base on which error will * print on the output link to the method documentation. */ constructor(fnName: string) { const message = ` You have to implement the method: ${fnName} Check out the documentation at: ${buildUrl(fnName)} ` super(message) this.message = message } } export default NotImplementedError ================================================ FILE: src/backend/utils/errors/record-error.ts ================================================ import { ErrorTypeEnum } from '../../../utils/error-type.enum.js' /** * Record Error * @alias RecordError * @memberof ValidationError */ export type RecordError = { /** * error type (i.e. required) */ type?: ErrorTypeEnum | string /** * Code of message */ message: string } export default RecordError ================================================ FILE: src/backend/utils/errors/validation-error.ts ================================================ import { ErrorTypeEnum } from '../../../utils/error-type.enum.js' import RecordError from './record-error.js' /** * Property Errors * @alias PropertyErrors * @memberof ValidationError */ export type PropertyErrors = { [key: string]: RecordError; } /** * Error which is thrown when there are validation errors with records * @category Errors */ export class ValidationError extends Error { /** * Validation errors for all properties */ public propertyErrors: PropertyErrors /** * One root validation error i.e. thrown when user wants to perform * an action which violates foreign key constraint */ public baseError: RecordError | null /** * HTTP Status code: 400 */ public statusCode: number /** * @param {PropertyErrors} propertyErrors error messages * @param {RecordError} [baseError] base error message */ constructor(propertyErrors: PropertyErrors, baseError?: RecordError) { super('Resource cannot be stored because of validation errors') this.statusCode = 400 this.propertyErrors = propertyErrors this.baseError = baseError || null this.name = ErrorTypeEnum.Validation } } export default ValidationError ================================================ FILE: src/backend/utils/filter/filter.ts ================================================ import { flat } from '../../../utils/flat/index.js' import BaseProperty from '../../adapters/property/base-property.js' import BaseResource from '../../adapters/resource/base-resource.js' import BaseRecord from '../../adapters/record/base-record.js' import { ActionContext } from '../../actions/index.js' export const PARAM_SEPARATOR = '~~' export type FilterElement = { path: string; property: BaseProperty; value: string | { from: string; to: string; }; populated?: BaseRecord | null; } interface ReduceCallback { (memo: T, element: FilterElement): T; } /** * Filter object wrapping up selected filters. * @private */ export class Filter { public filters: {[key: string]: FilterElement} private resource: BaseResource /** * Changes raw nested filters to form Object. * * @example * const filters = { * nested: {field: 'ala'}, * 'dataField~~from': '2019-08-14' * } * * const normalized = Filter.normalizeFilters(filters) * // { * // 'nested.filter': 'ala', * // 'dataField': {from: '2019-08-14'} * // } * * * @param {Object} filters * * @return {Object} */ static normalizeKeys(filters): Map { return flat.unflatten(flat.flatten(filters), { delimiter: PARAM_SEPARATOR }) } /** * @param {Object} filters selected filters * @param {BaseResource} resource resource which is filtered */ constructor(filters = {}, resource) { this.resource = resource const normalized = Filter.normalizeKeys(filters) this.filters = Object.keys(normalized).reduce((memo, path) => { memo[path] = { path, property: this.resource.property(path), value: normalized[path], } return memo }, {}) } /** * Returns filter for a given property key * * @param {String} key property key * @returns {Filter.Property | undefined} */ get(key: string): FilterElement | null { return this.filters[key] } /** * Populates all filtered properties which refers to other resources */ async populate(context: ActionContext): Promise { const keys = Object.keys(this.filters) for (let index = 0; index < keys.length; index += 1) { const key = keys[index] const referenceResource = this.resource.decorate().getPropertyByKey(key)?.reference() if (referenceResource) { const value = this.filters[key].value as string this.filters[key].populated = await referenceResource.findOne(value, context) } } return this } reduce(callback: ReduceCallback, initial: T): T { return Object.values(this.filters).reduce(callback, initial || {} as T) } isVisible(): boolean { return !!Object.keys(this.filters).length } } export default Filter ================================================ FILE: src/backend/utils/filter/index.ts ================================================ export * from './filter.js' ================================================ FILE: src/backend/utils/index.ts ================================================ export * from './auth/index.js' export * from './build-feature/index.js' export * from './errors/index.js' export * from './filter/index.js' export * from './layout-element-parser/index.js' export * from './options-parser/index.js' export * from './populator/index.js' export * from './request-parser/index.js' export * from './resources-factory/index.js' export * from './view-helpers/index.js' export * from './router/index.js' export * from './uploaded-file.type.js' export * from './component-loader.js' ================================================ FILE: src/backend/utils/layout-element-parser/index.ts ================================================ export * from './layout-element-parser.js' ================================================ FILE: src/backend/utils/layout-element-parser/layout-element-parser.spec.ts ================================================ import { expect } from 'chai' import layoutElementParser, { LayoutElement } from './layout-element-parser.js' describe('layoutElementParser', function () { const propertyName = 'name' const property2 = 'surname' const props = { mt: 'default', ml: 'xxxl' } it('parses regular string', function () { expect(layoutElementParser(propertyName)).to.deep.eq({ properties: [propertyName], props: {}, layoutElements: [], component: 'Box', }) }) it('parses list of strings', function () { expect(layoutElementParser([propertyName, property2])).to.deep.eq({ properties: [propertyName, property2], props: { }, layoutElements: [], component: 'Box', }) }) it('parses property and props', function () { expect(layoutElementParser([propertyName, props])).to.deep.eq({ properties: [propertyName], props, layoutElements: [], component: 'Box', }) }) it('recursively parses and inner element as string', function () { const innerElement: LayoutElement = ['string2', { width: 1 / 2 }] expect(layoutElementParser([props, [innerElement]])).to.deep.eq({ properties: [], props, layoutElements: [layoutElementParser(innerElement)], component: 'Box', }) }) it('recursively parses nested objects', function () { const nested: Array = [ ['companyName', { ml: 'xxl' }], 'email', ['address', 'profilePhotoLocation'], ] const complicatedElement: LayoutElement = [props, nested] expect(layoutElementParser(complicatedElement)).to.deep.eq({ properties: [], props, layoutElements: nested.map((el) => layoutElementParser(el)), component: 'Box', }) }) it('returns layoutElements when array is passed', function () { const arrayElements: LayoutElement = [ ['string1', { width: 1 / 2 }], ['string2', { width: 1 / 2 }], ] expect(layoutElementParser(arrayElements)).to.deep.eq({ properties: [], props: {}, component: 'Box', layoutElements: arrayElements.map((innerElement) => layoutElementParser(innerElement)), }) }) it('changes the component when @ is appended', function () { const headerProps = { children: 'Welcome my boy' } const componentElements: LayoutElement = ['@Header', headerProps] expect(layoutElementParser(componentElements)).to.deep.eq({ properties: [], props: headerProps, component: 'Header', layoutElements: [], }) }) }) ================================================ FILE: src/backend/utils/layout-element-parser/layout-element-parser.ts ================================================ /* eslint-disable max-len */ import { BoxProps, HeaderProps, TextProps, BadgeProps, ButtonProps, LinkProps, LabelProps, IconProps, } from '@adminjs/design-system' import { PropsWithChildren } from 'react' import { CurrentAdmin } from '../../../current-admin.interface.js' export type LayoutElement = string | Array | Array | [string, PropsWithChildren] | [PropsWithChildren, Array] | ['@Header', PropsWithChildren] | ['@H1', PropsWithChildren] | ['@H2', PropsWithChildren] | ['@H3', PropsWithChildren] | ['@H4', PropsWithChildren] | ['@H5', PropsWithChildren] | ['@Text', PropsWithChildren] | ['@Badge', PropsWithChildren] | ['@Button', PropsWithChildren] | ['@Link', PropsWithChildren] | ['@Label', PropsWithChildren] | ['@Icon', PropsWithChildren] | [string, PropsWithChildren] /** * Function returning Array used by {@link Action#layout} * * @return {Array} * @memberof Action * @alias LayoutElementFunction */ export type LayoutElementFunction = (currentAdmin?: CurrentAdmin) => Array /** * It is generated from {@link Array} passed in {@link Action#layout} * * @alias ParsedLayoutElement * @memberof ActionJSON */ export type ParsedLayoutElement = { /** List of paths to properties which should be rendered by given element */ properties: Array; /** props passed to React component which wraps elements */ props: PropsWithChildren; /** Nested layout elements */ layoutElements: Array; /** Component which should be used as a wrapper */ component: string; } const isProp = (element): boolean => !!element && typeof element === 'object' && !Array.isArray(element) const isComponentTag = (layoutElement: LayoutElement): boolean => ( Array.isArray(layoutElement) && typeof layoutElement[0] === 'string' && layoutElement[0].startsWith('@') && isProp(layoutElement[1]) ) const hasOnlyStringsProperties = function ( layoutElement: LayoutElement, ): layoutElement is [string] { return Array.isArray(layoutElement) && layoutElement.length > 0 && !isComponentTag(layoutElement) && !(layoutElement as Array).find((el) => (typeof el !== 'string')) } const hasArrayOfLayoutElements = function ( layoutElement: LayoutElement, ): layoutElement is [LayoutElement] { return Array.isArray(layoutElement) && layoutElement.length > 0 && !(layoutElement as Array).find((element) => !Array.isArray(element)) } const hasFirstStringProperty = function (layoutElement: LayoutElement): boolean { return Array.isArray(layoutElement) && typeof layoutElement[0] === 'string' && !isComponentTag(layoutElement) } const getPropertyNames = (layoutElement: LayoutElement): Array => { if (typeof layoutElement === 'string') { return [layoutElement] } if (hasOnlyStringsProperties(layoutElement)) { return layoutElement } if (hasFirstStringProperty(layoutElement)) { return [layoutElement[0]] as Array } return [] } const getInnerLayoutElements = (layoutElement: LayoutElement): Array => { // only cases like [{}, layoutElement] (whatever follows props) if (Array.isArray(layoutElement) && isProp(layoutElement[0])) { return layoutElement[1] as Array } if (hasArrayOfLayoutElements(layoutElement)) { return layoutElement } return [] } const getComponent = (layoutElement: LayoutElement): string => { if (isComponentTag(layoutElement)) { return (layoutElement[0] as string).slice(1) } return 'Box' } const getProps = (layoutElement: LayoutElement): BoxProps => { if (Array.isArray(layoutElement) && layoutElement.length) { const boxProps = (layoutElement as Array).find(isProp) return boxProps as unknown as BoxProps || {} } return {} } export const layoutElementParser = (layoutElement: LayoutElement): ParsedLayoutElement => { const props = getProps(layoutElement) const innerLayoutElements = getInnerLayoutElements(layoutElement) const properties = getPropertyNames(layoutElement) const component = getComponent(layoutElement) return { props, layoutElements: innerLayoutElements.map((el) => layoutElementParser(el)), properties, component, } } export default layoutElementParser /** * @load layout-element.doc.md * @name LayoutElement * @typedef {String | Array} LayoutElement * @memberof Action * @alias LayoutElement */ ================================================ FILE: src/backend/utils/layout-element-parser/layout-element.doc.md ================================================ {@link LayoutElement} is used to change the default layout of `edit`, `show` and `new` {@link Action actions}. You define the layout as an {@link Array} and AdminJS renders it with React components. You don't have to know React, to create a usable Layout for your actions but be sure to take a look at the possible **Props** which can be used to style the components. The most often used props are {@link BoxProps}, because {@link Box} is the default wrapper. ### Available values for a {@link LayoutElement} type To {@link Action#layout } you have to pass an {@link Array}. Where each {@link LayoutElement} could have a different type defining its position and purpose. ### Type definition Those are available types for {@link LayoutElement} | Type | Purpose | Example | |---------------|--------------------------------------------------------------|------------------| | string | It will be changed to the property in vertical layout | `layout: ['name']` | | {@link Array} | It will be changed to the properties in vertical layout | `layout: [['name', 'surname']]` | | [string, {@link BoxProps}] | property wrapped by {@link Box} component with {@link BoxProps} | `layout: [['name', {width: 1/2}]]` | | [{@link BoxProps}, {@link Array}] | Creates a Box and nest all the child LayoutElements inside. | `layout: [[{width: 1/2}, ['name', 'surname']]]` | | {@link Array} | For grouping LayoutElements inside a wrapper | `layout: [['name', {mt: 'xl'}], ['surname', , {ml: 'xl'}]]` | | [@ComponentName, PropsWithChildren] | if you precede first item with "@" it will create component of this name | `layout: [['@Header', {children: 'User Data'}]]` | ### Examples Let say you have following properties in your database: `companyName`, `email`, `address` and `companySize` #### 1. The simplest horizontal layout: ``` const layout = [ 'companyName', 'email', 'address', 'companySize', ] ``` generates: #### 2. Now Wrap everything with a {@link Box} of `2/3` max-width and horizontal margin (mx) set to auto. This will center all inputs ``` const layout = [ [{ width: 2 / 3, mx: 'auto' }, [ 'companyName', 'email', 'address', 'companySize', ]], ] ``` generates: > Hint: you can also pass an array to `width` to define how it will behave in a different responsive breakpoints. #### 3. Add headers between sections ``` const layout = [ [{ width: 2 / 3, mx: 'auto' }, [ ['@H3', { children: 'Company data' }], 'companyName', 'companySize', ['@H3', { children: 'Contact Info' }], 'email', 'address', ]], ] ``` generates: > To inject content inside the given Component pass children props to it. #### 4. Make email and address 50% width We will wrap them with a {@link Box} (default component) which is a flex. Then we will have to wrap also each of them with extra box to define paddings. I will also align to left top section that by removing `{ mx: auto }` and changing width to `1 / 2`. ``` 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 }], ]], ], ] ``` generates: #### 5. Lastly, take a look at the example with a function instead of {@link LayoutElement}. ``` const layout = currentAdmin => ([ ['@MessageBox', { message: `Welcome ${currentAdmin && currentAdmin.email}`, children: 'On this page yo can do whatever you like', variant: 'info', mb: 'xxl', }], [ 'companyName', 'companySize', 'email', 'address', ], ]) ``` Generates following **Show** page: ================================================ FILE: src/backend/utils/options-parser/index.ts ================================================ export * from './options-parser.js' ================================================ FILE: src/backend/utils/options-parser/options-parser.ts ================================================ import merge from 'lodash/merge.js' import { AdminJSOptions, Assets, BrandingOptions } from '../../../adminjs-options.interface.js' import AdminJS from '../../../adminjs.js' import { CurrentAdmin } from '../../../current-admin.interface.js' import { ThemeInState } from '../../../frontend/store/index.js' import { Locale, defaultLocale } from '../../../locale/index.js' import { flat } from '../../../utils/flat/index.js' import ViewHelpers from '../view-helpers/view-helpers.js' const defaultBranding: AdminJSOptions['branding'] = { companyName: 'Company', withMadeWithLove: true, } const defaultAssets: Assets = { styles: [], scripts: [], } export const getAssets = async (admin: AdminJS, currentAdmin?: CurrentAdmin): Promise => { const { assets } = admin.options || {} const computed = typeof assets === 'function' ? await assets(currentAdmin) : assets return merge({}, defaultAssets, computed) } export const getBranding = async ( admin: AdminJS, currentAdmin?: CurrentAdmin, ): Promise => { const { branding } = admin.options const h = new ViewHelpers(admin) const defaultLogo = h.assetPath('logo.svg') const computed = typeof branding === 'function' ? await branding(currentAdmin) : branding const merged = merge({}, defaultBranding, computed) // checking for undefined because logo can also be `false` or `null` merged.logo = merged.logo !== undefined ? merged.logo : defaultLogo return merged } export const getLocales = async (admin: AdminJS, currentAdmin?: CurrentAdmin): Promise => { const { locale = {} } = admin.options || {} const computed = typeof locale === 'function' ? await locale(currentAdmin) : locale let baseLocale: Locale = merge( {} as Partial, flat.flatten(defaultLocale) as Locale, flat.flatten(computed) as Locale, ) if (!baseLocale.translations) { baseLocale.translations = {} } // Merging translations defined in resource options admin.resources.forEach((baseResource) => { const decorated = baseResource._decorated ?? baseResource.decorate() const { translations: resourceTranslations } = decorated.options if (resourceTranslations) { // Assure that translations object structure is consistent so we can use lodash#merge const resourceLocale: Omit = { translations: {}, } Object.keys(resourceTranslations).forEach((language) => { resourceLocale.translations![language] = { resources: { [decorated.id()]: resourceTranslations[language], }, } }) baseLocale = merge(baseLocale, resourceLocale) } }) return flat.unflatten(baseLocale) } export const getTheme = async ( admin: AdminJS, currentAdmin?: CurrentAdmin, ): Promise => { const { availableThemes, defaultTheme } = admin.options let themeId = defaultTheme ?? availableThemes?.[0].id if (currentAdmin?.theme?.length) { themeId = currentAdmin?.theme } const theme = availableThemes?.find(({ id }) => id === themeId) return theme ? { ...theme, availableThemes } : null } export const getFaviconFromBranding = (branding: BrandingOptions): string => { if (branding.favicon) { const { favicon } = branding const type = favicon.match(/.*\.png$/) ? 'image/png' : 'image/x-icon' return `` } return '' } ================================================ FILE: src/backend/utils/populator/index.ts ================================================ export * from './populator.js' export * from './populate-property.js' ================================================ FILE: src/backend/utils/populator/populate-property.spec.ts ================================================ import { expect } from 'chai' import sinon, { SinonStubbedInstance } from 'sinon' import { BaseProperty, BaseRecord, BaseResource } from '../../adapters/index.js' import { PropertyDecorator, ResourceDecorator } from '../../decorators/index.js' import { populateProperty } from './populate-property.js' describe('populateProperty', () => { const userId = '1234' const path = 'userId' let resourceDecorator: SinonStubbedInstance let referenceResource: SinonStubbedInstance let record: SinonStubbedInstance let userRecord: SinonStubbedInstance let property: SinonStubbedInstance & PropertyDecorator let populatedResponse: Array | null beforeEach(() => { resourceDecorator = sinon.createStubInstance(ResourceDecorator) referenceResource = sinon.createStubInstance(BaseResource) record = sinon.createStubInstance(BaseRecord) userRecord = sinon.createStubInstance(BaseRecord) property = sinon.createStubInstance(PropertyDecorator) as typeof property property.resource.returns(resourceDecorator as unknown as ResourceDecorator) property.reference.returns(referenceResource as unknown as BaseResource) property.property = { reference: 'someRawReference' } as unknown as BaseProperty property.propertyPath = path }) afterEach(() => { sinon.restore() }) it('returns empty array when no records are given', async () => { expect(await populateProperty([], property)).to.deep.eq([]) }) context('2 same records with reference key', () => { beforeEach(async () => { record.get.returns(userId) record.selectParams.returns({ [path]: userId }) userRecord.id.returns(userId) referenceResource.findMany.resolves([userRecord]) populatedResponse = await populateProperty([record, record], property) }) it('returns 2 records', async () => { expect(populatedResponse?.length).to.eq(2) }) it('calls findMany in with the list of userIds just once', () => { expect(referenceResource.findMany).to.have.been.calledOnceWith([userId]) }) it('adds reference resource to record.populated', () => { const populatedRecord = populatedResponse && populatedResponse[0] expect(populatedRecord?.populate).to.have.been.calledWith(path, userRecord) }) }) context('record with array property being also a reference', () => { const [userId1, userId2] = ['user1', 'user2'] beforeEach(async () => { record.get.returns([userId1, userId2]) // resourceDecorator userRecord.id.returns(userId) property.isArray.returns(true) referenceResource.findMany.resolves([userRecord]) }) context('filled array ', () => { beforeEach(async () => { record.get.returns([userId1, userId2]) populatedResponse = await populateProperty([record, record], property) }) it('properly finds references in arrays', async () => { expect(referenceResource.findMany).to.have.been.calledOnceWith([userId1, userId2]) }) }) context('array value set to null', () => { beforeEach(async () => { record.get.returns(undefined) populatedResponse = await populateProperty([record, record], property) }) it('dees not look for any record', () => { expect(referenceResource.findMany).not.to.have.been.called }) }) }) context('empty references', () => { it('does not findMany for null values', async () => { record.get.returns(null) populatedResponse = await populateProperty([record], property) expect(referenceResource.findMany).not.to.have.been.called }) it('does not findMany for undefined values', async () => { record.get.returns(undefined) populatedResponse = await populateProperty([record], property) expect(referenceResource.findMany).not.to.have.been.called }) it('findMany for 0 values', async () => { record.get.returns(0) populatedResponse = await populateProperty([record], property) expect(referenceResource.findMany).to.have.been.called }) it('does not findMany for "" empty strings', async () => { record.get.returns('') populatedResponse = await populateProperty([record], property) expect(referenceResource.findMany).not.to.have.been.called }) it('does not findMany for "" empty strings in array', async () => { record.get.returns(['']) property.isArray.returns(true) populatedResponse = await populateProperty([record], property) expect(referenceResource.findMany).not.to.have.been.called }) }) }) ================================================ FILE: src/backend/utils/populator/populate-property.ts ================================================ import { BaseRecord } from '../../adapters/index.js' import PropertyDecorator from '../../decorators/property/property-decorator.js' import { ActionContext } from '../../actions/index.js' const isValueSearchable = (value: any): value is string | number => ( ['string', 'bigint', 'number'].includes(typeof value) && value !== null && value !== '' ) /** * It populates one property in given records * * @param {Array} records array of records to populate * @param {PropertyDecorator} property Decorator for the reference property to populate * @param context * @private * @hide */ export async function populateProperty( records: Array | null, property: PropertyDecorator, context?: ActionContext, ): Promise | null> { const decoratedResource = property.resource() if (!records || !records.length) { return records } const referencedResource = property.reference() if (!referencedResource) { throw new Error([ `There is no reference resource named: "${property.property.reference}"`, `for property: "${decoratedResource.id()}.properties.${property.propertyPath}"`, ].join('\n')) } // I will describe the process for following data: // - decoratedResource = 'Comment' // - referenceResource = 'User' // property.path = 'userId' // first, we create externalIdsMap[1] = null where 1 is userId. This make keys unique and assign // nulls to each of them const externalIdsMap = records.reduce((memo, baseRecord) => { const foreignKeyValue = baseRecord.get(property.propertyPath, { includeAllSiblings: true }) // 2 kind of properties returning arrays // - the one with the array type // - the one which are nested within an arrays (fetched by the help of // the options { includeAllSiblings: true } in baseRecord.get(). // so we have to take it all into consideration if (Array.isArray(foreignKeyValue)) { return foreignKeyValue.reduce((arrayMemo, valueInArray) => ({ ...arrayMemo, ...(isValueSearchable(valueInArray) ? { [valueInArray]: valueInArray } : {}), }), memo) } if (!isValueSearchable(foreignKeyValue)) { return memo } memo[foreignKeyValue] = foreignKeyValue return memo }, {}) const uniqueExternalIds = Object.values(externalIdsMap) // when no record has reference filled (ie `userId`) = return input `records` if (!uniqueExternalIds.length) { return records } // now find all referenced records: all users const referenceRecords = await referencedResource.findMany(uniqueExternalIds, context) // even if record has value for this reference - it might not have the referenced record itself // this happens quite often in mongodb where there are no constrains on the database if (!referenceRecords || !referenceRecords.length) { return records } // now assign these users to `externalIdsMap` instead of the empty object we had. To speed up // assigning them to record#populated we will do in the next step by calling: // `externalIdsMap[id]` to get populated record instead of finding them in an array referenceRecords.forEach((referenceRecord) => { // example: externalIds[1] = { ...userRecord } | null (if not found) const foreignKeyValue = referenceRecord.id() externalIdsMap[foreignKeyValue] = referenceRecord }) return records.map((record) => { // first lets extract all the existing params from the given record which belongs to given // property. Usually it will be just one element, but for arrays and items nested inside arrays // there will be more like this for array: // { // 'professions.0': '5f7462621eb3495ea0f0edd7', // 'professions.1': '5f7462621eb3495ea0f0edd6', // } const referenceParams = record.selectParams(property.propertyPath, { includeAllSiblings: true, }) || {} // next we copy the exact params structure to record.populated changing the value with found // record Object.entries(referenceParams).forEach(([path, foreignKeyValueItem]) => { record.populate(path, externalIdsMap[foreignKeyValueItem]) }) return record }) } ================================================ FILE: src/backend/utils/populator/populator.doc.md ================================================ Populates all references in records. ### Usage Take a look at an example action handler getting the Product record and populates it. We assume that Product has categoryId and it is marked as a 'reference' ({@link PropertyOptions#reference}) to a Category. ```javascript const { populator } = require('adminjs') // action handler for showing product with categories const showProductsHandler = async (request, response, context) => { const { payload } = request const { _admin, currentAdmin } = context const ProductResource = _admin.findResource('Product') const product = await ProductResource.findOne() // product.populated is empty const [populatedProduct] = await populator([product]) // populatedProduct.populated - has a categoryId filled with Category params return { record: record.toJSON(currentAdmin) // returns RecordJSON with populated field as well } } ``` ### Where you might want to use it? Populator is used in all built-in actions so you don't need to take care of populating fields on your own. Situation changes when you want to create a custom action and use data not from the context but right from the database query. ================================================ FILE: src/backend/utils/populator/populator.spec.ts ================================================ import { expect } from 'chai' import populator from './populator.js' describe('populator', () => { context('empty array given as params', () => { it('returns empty array when no records are given', async () => { const records = await populator([]) expect(records).to.have.lengthOf(0) }) }) }) ================================================ FILE: src/backend/utils/populator/populator.ts ================================================ import BaseRecord from '../../adapters/record/base-record.js' import { populateProperty } from './populate-property.js' import { ActionContext } from '../../actions/index.js' /** * @load ./populator.doc.md * @param {Array} records * @param context * @new In version 3.3 */ export async function populator( records: Array, context?:ActionContext, ): Promise> { if (!records || !records.length) { return records } const resourceDecorator = records[0].resource.decorate() const allProperties = Object.values(resourceDecorator.getFlattenProperties()) const references = allProperties.filter((p) => !!p.reference()) await Promise.all(references.map(async (propertyDecorator) => { await populateProperty(records, propertyDecorator, context) })) return records } export default populator ================================================ FILE: src/backend/utils/request-parser/index.ts ================================================ export * from './request-parser.js' ================================================ FILE: src/backend/utils/request-parser/request-parser.spec.ts ================================================ import { expect } from 'chai' import requestParser from './request-parser.js' import { ActionRequest } from '../../actions/action.interface.js' import BaseResource from '../../adapters/resource/base-resource.js' const buildResourceWithProperty = (key, property) => { const resource = { _decorated: { getPropertyByKey: (path) => (key === path ? property : null) }, } as unknown as BaseResource return resource } let resource describe('RequestParser', function () { const baseRequest: ActionRequest = { params: { resourceId: 'resourceId', action: 'edit' }, method: 'post', payload: {}, } describe('boolean values', function () { beforeEach(function () { resource = buildResourceWithProperty('isHired', { type: () => 'boolean', }) }) it('sets value to `false` when empty string is given', function () { const request = { ...baseRequest, payload: { isHired: '' } } expect(requestParser(request, resource).payload?.isHired).to.be.false }) it('changes "true" string to true', function () { const request = { ...baseRequest, payload: { isHired: 'true' } } expect(requestParser(request, resource).payload?.isHired).to.be.true }) it('changes "false" string to true', function () { const request = { ...baseRequest, payload: { isHired: 'false' } } expect(requestParser(request, resource).payload?.isHired).to.be.false }) }) }) ================================================ FILE: src/backend/utils/request-parser/request-parser.ts ================================================ import { ActionRequest } from '../../actions/index.js' import { BaseResource } from '../../adapters/index.js' import { FORM_VALUE_NULL, FORM_VALUE_EMPTY_OBJECT, FORM_VALUE_EMPTY_ARRAY, } from '../../../frontend/hooks/use-record/params-to-form-data.js' /** * Takes the original ActionRequest and convert string values to a corresponding * types. It * * @param {ActionRequest} originalRequest * @param {BaseResource} resource * @returns {ActionRequest} * * @private */ export const requestParser = ( originalRequest: ActionRequest, resource: BaseResource, ): ActionRequest => { const { payload: originalPayload } = originalRequest const payload = Object.entries(originalPayload || {}).reduce((memo, [path, formValue]) => { const property = resource._decorated?.getPropertyByKey(path) let value = formValue if (formValue === FORM_VALUE_NULL) { value = null } if (formValue === FORM_VALUE_EMPTY_OBJECT) { value = {} } if (formValue === FORM_VALUE_EMPTY_ARRAY) { value = [] } if (property) { if (property.type() === 'boolean') { if (value === 'true') { memo[path] = true return memo } if (value === 'false') { memo[path] = false return memo } if (value === '') { memo[path] = false return memo } } if (['date', 'datetime'].includes(property.type())) { if (value === '' || value === null) { memo[path] = null return memo } } if (property.type() === 'string') { const availableValues = property.availableValues() if (availableValues && !availableValues.includes(value) && value === '') { memo[path] = null return memo } } } memo[path] = value return memo }, {}) return { ...originalRequest, payload, } } export default requestParser ================================================ FILE: src/backend/utils/resources-factory/index.ts ================================================ export { default as ResourcesFactory } from './resources-factory.js' ================================================ FILE: src/backend/utils/resources-factory/resources-factory.spec.js ================================================ import { expect } from 'chai' import { ResourcesFactory } from './resources-factory.js' import { BaseDatabase, BaseResource } from '../../adapters/index.js' describe('ResourcesFactory', function () { describe('._convertDatabases', function () { context('no adapter defined', function () { it('throws an error when there are no adapters and database is given', function () { expect(() => { new ResourcesFactory()._convertDatabases(['one']) }).to.throw().property('name', 'NoDatabaseAdapterError') }) it('returns empty array when none databases were given', function () { expect(new ResourcesFactory()._convertDatabases([])).to.have.lengthOf(0) }) }) context('one adapter defined', function () { beforeEach(function () { this.resourcesInDatabase = 5 class Database extends BaseDatabase { static isAdapterFor(database) { return database === 'supported' } resources() { return new Array(5) } // eslint-disable-line class-methods-use-this } class Resource extends BaseResource {} this.resourcesFactory = new ResourcesFactory({}, [{ Database, Resource }]) }) it('takes resources from databases', function () { expect( this.resourcesFactory._convertDatabases(['supported']), ).to.have.lengthOf(this.resourcesInDatabase) }) it('throws an error when there are no adapters supporting given database', function () { expect(() => { this.resourcesFactory._convertDatabases(['not supported']) }).to.throw().property('name', 'NoDatabaseAdapterError') }) }) }) describe('._convertResources', function () { context('there are no adapters', function () { it('throws an error when resource is not subclass from BaseResource', function () { expect(() => { new ResourcesFactory({})._convertResources(['one']) }).to.throw().property('name', 'NoResourceAdapterError') }) it('returns given resource when it is subclass from BaseResource', function () { class MyResource extends BaseResource {} expect(new ResourcesFactory({})._convertResources([new MyResource()])).to.have.lengthOf(1) }) }) context('there is one adapter', function () { beforeEach(function () { class Database extends BaseDatabase {} class Resource extends BaseResource { static isAdapterFor(resource) { return resource === 'supported' } } this.resourcesFactory = new ResourcesFactory({}, [{ Database, Resource }]) this.Resource = Resource }) it('throws an error when resource is not handled by the adapter', function () { expect(() => { this.resourcesFactory._convertResources(['not supported']) }).to.throw().property('name', 'NoResourceAdapterError') }) it('throws an error when resource is not handled by the adapter and its provided with a decorator', function () { expect(() => { this.resourcesFactory._convertResources([{ resource: 'not supported', decorator: 'sth' }]) }).to.throw().property('name', 'NoResourceAdapterError') }) it('converts given resource to Resource class provided in the adapter', function () { const resources = this.resourcesFactory._convertResources(['supported']) expect(resources).to.have.lengthOf(1) expect(resources[0].resource).to.be.an.instanceOf(this.Resource) }) it('converts to Resource class when resource is provided with options', function () { const options = {} const resources = this.resourcesFactory._convertResources([{ resource: 'supported', options }]) expect(resources).to.have.lengthOf(1) expect(resources[0].resource).to.be.an.instanceOf(this.Resource) expect(resources[0].options).to.deep.equal(options) }) }) }) describe('_decorateResources', function () { beforeEach(function () { this.resourcesFactory = new ResourcesFactory({ options: {} }, []) this.assignDecoratorStub = this.sinon.stub(BaseResource.prototype, 'assignDecorator') }) it('assigns ResourceDecorator when no options were given', function () { this.resourcesFactory._decorateResources([{ resource: new BaseResource() }]) expect(this.assignDecoratorStub).to.have.been.calledWith( this.sinon.match.any, this.sinon.match({}), ) }) it('assigns ResourceDecorator with options when there were given', function () { const options = { id: 'someId' } const resource = new BaseResource() this.resourcesFactory._decorateResources([{ resource, options }]) expect(this.assignDecoratorStub).to.have.been.calledWith( this.sinon.match.any, this.sinon.match(options), ) }) }) }) ================================================ FILE: src/backend/utils/resources-factory/resources-factory.ts ================================================ import BaseResource from '../../adapters/resource/base-resource.js' import AdminJS, { Adapter } from '../../../adminjs.js' import { ResourceWithOptions } from '../../../adminjs-options.interface.js' import { mergeResourceOptions } from '../build-feature/index.js' export class NoDatabaseAdapterError extends Error { private database: string constructor(database: string) { const message = 'There are no adapters supporting one of the database you provided' super(message) this.database = database this.name = 'NoDatabaseAdapterError' } } export class NoResourceAdapterError extends Error { private resource: BaseResource constructor(resource: BaseResource) { const message = 'There are no adapters supporting one of the resource you provided' super(message) this.resource = resource this.name = 'NoResourceAdapterError' } } export class ResourcesFactory { private adapters: Array private admin: AdminJS constructor(admin, adapters: Array = []) { this.adapters = adapters this.admin = admin } buildResources({ databases, resources }): Array { const optionsResources = this._convertResources(resources) // fetch only those resources from database which weren't previously given as a resource const databaseResources = this._convertDatabases(databases).filter((dr) => ( !optionsResources.find((optionResource) => optionResource.resource.id() === dr.id()) )) return this._decorateResources([...databaseResources, ...optionsResources]) } /** * Changes database give by the user in configuration to list of supported resources * @param {Array} databases list of all databases given by the user in * {@link AdminJSOptions} * @return {Array} list of all resources from given databases */ _convertDatabases(databases: Array): Array { return databases.reduce((memoArray, db) => { const databaseAdapter = this.adapters.find((adapter) => ( adapter.Database.isAdapterFor(db) )) if (!databaseAdapter) { throw new NoDatabaseAdapterError(db) } return memoArray.concat(new databaseAdapter.Database(db).resources()) }, []) } /** * Maps resources given by user to resources supported by AdminJS. * * @param {any[]} resources array of all resources given by the user * in {@link AdminJSOptions} * @param {any} resources[].resource optionally user can give resource along * with options * @param {Object} resources[].options options given along with the resource * @return {Object[]} list of Objects with resource and options * keys * * @example * AdminJS._convertResources([rawAdminModel, {resource: rawUserMode, options: {}}]) * // => returns: [AdminModel, {resource: UserModel, options: {}}] * // where AdminModel and UserModel were converted by appropriate database adapters. */ _convertResources(resources: Array): Array { return resources.map((rawResource) => { // resource can be given either by a value or within an object within resource key const resourceObject = rawResource.resource || rawResource const resourceAdapter = this.adapters.find((adapter) => ( adapter.Resource.isAdapterFor(resourceObject) )) if (!resourceAdapter && !(resourceObject instanceof BaseResource)) { throw new NoResourceAdapterError(resourceObject) } return { resource: resourceAdapter ? new resourceAdapter.Resource(resourceObject) : resourceObject, options: rawResource.options, features: rawResource.features, } }) } /** * Assigns decorator to each resource and initializes it with `options` and current `admin` * instance * @param {Array} resources array of all mapped resources given by the * user in {@link AdminJSOptions} along with * options * @param {BaseResource} resources[].resource optionally user can give resource along * with options * @param {Object} [resources[].options] options for given resource * @return {BaseResource[]} list of resources with decorator assigned */ _decorateResources(resources: Array): Array { return resources.map((resourceObject) => { const resource = resourceObject.resource || resourceObject const { features = [], options = {} } = resourceObject const optionsFromFeatures = features.reduce((opts, feature) => ( feature(this.admin, opts) ), {}) resource.assignDecorator( this.admin, mergeResourceOptions(optionsFromFeatures, options), ) return resource }) } } export default ResourcesFactory ================================================ FILE: src/backend/utils/router/index.ts ================================================ export * from './router.js' ================================================ FILE: src/backend/utils/router/router.doc.md ================================================ Contains a list of all the routes used in AdminJS. They are grouped within 2 arrays: - `assets` - `routes` It is used by supported HTTP frameworks to render AdminJS pages. You can also use it to write your own rendering logic. ### How it looks This is the structure of the Router - both `assets` and `routes`. ```javascript { assets: [{ path: '/frontend/assets/app.min.js', src: path.join(ASSETS_ROOT, 'scripts/app.min.js'), }, ...], routes: [{ method: 'GET', path: '/resources/{resourceId}', Controller: ResourcesController, action: 'index', }, ...] } ``` ### Create router with authentication logic To create your router with authentication logic you have to: * write routes responsible for user authentication * iterate all `assets` and `routes` and handle them. The following code is almost an identical copy from @adminjs/express plugin.js file. It shows you how you can assign all the routes to express framework. ```javascript const { Router } = require('adminjs') const { routes, assets } = Router const router = new express.Router() // here you can write your authentication logic routes.forEach((route) => { // we have to change routes defined in AdminJS from {recordId} to :recordId const expressPath = route.path.replace(/{/g, ':').replace(/}/g, '') const handler = async (req, res, next) => { try { const currentAdmin = null // you can fetch admin from session, const controller = new route.Controller({ admin }, currentAdmin) const { params, query } = req const method = req.method.toLowerCase() const payload = { ...(req.fields || {}), ...(req.files || {}), } const html = await controller[route.action]({ ...req, params, query, payload, method, }, res) if (route.contentType) { res.set({ 'Content-Type': route.contentType }) } if (html) { res.send(html) } } catch (e) { next(e) } } if (route.method === 'GET') { router.get(expressPath, handler) } if (route.method === 'POST') { router.post(expressPath, handler) } }) assets.forEach((asset) => { router.get(asset.path, async (req, res) => { res.sendFile(path.resolve(asset.src)) }) }) ``` ================================================ FILE: src/backend/utils/router/router.spec.ts ================================================ import { expect } from 'chai' import Router from './router.js' describe('Router', function () { it('has both assets and routes', function () { expect(Router.assets).not.to.be.undefined expect(Router.routes).not.to.be.undefined }) it('returns development bundle by default', function () { const asset = Router.assets.find((a) => a.path === '/frontend/assets/app.bundle.js') expect(asset && asset.src).to.contain('scripts/app-bundle.development.js') }) }) ================================================ FILE: src/backend/utils/router/router.ts ================================================ import path from 'path' import * as url from 'url' import { createRequire } from 'node:module' import AppController from '../../controllers/app-controller.js' import ApiController from '../../controllers/api-controller.js' import { COMPONENTS_OUTPUT_PATH, NODE_ENV } from '../../bundler/utils/constants.js' const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) const ASSETS_ROOT = `${__dirname}/../lib/../../../frontend/assets/` /** * A function which resolves the path to AdminJS design system bundle. * * @returns {string} resolved path to AdminJS design system bundle */ const resolveDesignSystemBundle = (): string => { const require = createRequire(import.meta.url) return path.join( path.parse(require.resolve('@adminjs/design-system')).dir, `../bundle.${NODE_ENV}.js`, ) } /** * Type representing the AdminJS.Router * @memberof Router * @alias RouterType */ export type RouterType = { assets: Array<{ path: string; src: string; }>; routes: Array<{ method: string; path: string; Controller: any; action: string; contentType?: string; }>; } /** * @load ./router.doc.md * @namespace */ export const Router: RouterType = { assets: [{ path: '/frontend/assets/icomoon.css', src: path.join(ASSETS_ROOT, 'styles/icomoon.css'), }, { path: '/frontend/assets/icomoon.eot', src: path.join(ASSETS_ROOT, 'fonts/icomoon.eot'), }, { path: '/frontend/assets/icomoon.svg', src: path.join(ASSETS_ROOT, 'fonts/icomoon.svg'), }, { path: '/frontend/assets/icomoon.ttf', src: path.join(ASSETS_ROOT, 'fonts/icomoon.ttf'), }, { path: '/frontend/assets/icomoon.woff', src: path.join(ASSETS_ROOT, 'fonts/icomoon.woff'), }, { path: '/frontend/assets/app.bundle.js', src: path.join(ASSETS_ROOT, `scripts/app-bundle.${NODE_ENV}.js`), }, { path: '/frontend/assets/global.bundle.js', src: path.join(ASSETS_ROOT, `scripts/global-bundle.${NODE_ENV}.js`), }, { path: '/frontend/assets/design-system.bundle.js', src: resolveDesignSystemBundle(), }, { path: '/frontend/assets/logo.svg', src: path.join(ASSETS_ROOT, 'images/logo.svg'), }, { path: '/frontend/assets/logo-mini.svg', src: path.join(ASSETS_ROOT, 'images/logo-mini.svg'), }], routes: [{ method: 'GET', path: '', Controller: AppController, action: 'index', }, { method: 'GET', path: '/resources/{resourceId}', Controller: AppController, action: 'resource', }, { method: 'GET', path: '/api/resources/{resourceId}/search/{query}', Controller: ApiController, action: 'search', }, { method: 'GET', path: '/resources/{resourceId}/actions/{action}', Controller: AppController, action: 'resourceAction', }, { method: 'GET', path: '/api/resources/{resourceId}/actions/{action}', Controller: ApiController, action: 'resourceAction', }, { method: 'GET', path: '/api/resources/{resourceId}/actions/{action}/{query}', Controller: ApiController, action: 'resourceAction', }, { method: 'POST', path: '/api/resources/{resourceId}/actions/{action}', Controller: ApiController, action: 'resourceAction', }, { method: 'GET', path: '/resources/{resourceId}/records/{recordId}/{action}', Controller: AppController, action: 'recordAction', }, { method: 'GET', path: '/api/resources/{resourceId}/records/{recordId}/{action}', Controller: ApiController, action: 'recordAction', }, { method: 'POST', path: '/api/resources/{resourceId}/records/{recordId}/{action}', Controller: ApiController, action: 'recordAction', }, { method: 'GET', path: '/resources/{resourceId}/bulk/{action}', Controller: AppController, action: 'bulkAction', }, { method: 'GET', path: '/api/resources/{resourceId}/bulk/{action}', Controller: ApiController, action: 'bulkAction', }, { method: 'POST', path: '/api/resources/{resourceId}/bulk/{action}', Controller: ApiController, action: 'bulkAction', }, { method: 'GET', path: '/api/resources/{resourceId}/search', Controller: ApiController, action: 'search', }, { method: 'GET', path: '/api/dashboard', Controller: ApiController, action: 'dashboard', }, // Pages { method: 'GET', path: '/pages/{pageName}', Controller: AppController, action: 'page', }, { method: 'GET', path: '/api/pages/{pageName}', Controller: ApiController, action: 'page', }, { method: 'POST', path: '/api/pages/{pageName}', Controller: ApiController, action: 'page', }], } if (process.env.NODE_ENV === 'production') { Router.assets.push({ path: '/frontend/assets/components.bundle.js', src: COMPONENTS_OUTPUT_PATH, }) } else { Router.routes.push({ method: 'GET', path: '/frontend/assets/components.bundle.js', Controller: AppController, action: 'bundleComponents', contentType: 'text/javascript;charset=utf-8', }) } export default Router ================================================ FILE: src/backend/utils/uploaded-file.type.ts ================================================ /** * File uploaded via FormData to the backend. * * @memberof AdminJS * @alias UploadedFile */ export type UploadedFile = { /** * The size of the uploaded file in bytes. * this property says how many bytes of the file have been written to disk yet. */ size: number; /** * The path this file is being written to. */ path: string; /** * The mime type of this file, according to the uploading client. */ type: string; /** * The name this file had according to the uploading client. */ name: string | null; } ================================================ FILE: src/backend/utils/view-helpers/index.ts ================================================ export * from './view-helpers.js' ================================================ FILE: src/backend/utils/view-helpers/view-helpers.spec.ts ================================================ import { expect } from 'chai' import ViewHelpers from './view-helpers.js' describe('ViewHelpers', function () { describe('#urlBuilder', function () { it('returns joined path for default rootUrl', function () { const h = new ViewHelpers({}) expect(h.urlBuilder(['my', 'path'])).to.equal('/admin/my/path') }) it('returns correct url when user gives admin root path not starting with /', function () { const h = new ViewHelpers({ options: { rootPath: 'admin' } }) expect(h.urlBuilder(['my', 'path'])).to.equal('/admin/my/path') }) it('returns correct url for rootPath set to /', function () { const h = new ViewHelpers({ options: { rootPath: '/' } }) expect(h.urlBuilder(['my', 'path'])).to.equal('/my/path') }) }) }) ================================================ FILE: src/backend/utils/view-helpers/view-helpers.ts ================================================ import { AdminJSOptions, Assets } from '../../../adminjs-options.interface.js' import type { PathsInState } from '../../../index.js' type Paths = PathsInState let globalAny: any = {} try { globalAny = window } catch (error) { if (!(error instanceof ReferenceError)) { throw error } } finally { if (!globalAny) { globalAny = {} } } /** * Base Params for a any function * @alias ActionParams * @memberof ViewHelpers */ export type ActionParams = { /** * Unique Resource ID */ resourceId: string /** * Action name */ actionName: string /** * Optional query string: ?.... */ search?: string } /** * Params for a record action * @alias RecordActionParams * @extends ActionParams * @memberof ViewHelpers */ export type RecordActionParams = ActionParams & { /** * Record ID */ recordId: string } /** * Params for a bulk action * @alias BulkActionParams * @extends ActionParams * @memberof ViewHelpers */ export type BulkActionParams = ActionParams & { /** * Array of Records ID */ recordIds?: Array } /** * Params for a resource action * @alias ResourceActionParams * @extends ActionParams * @memberof ViewHelpers */ export type ResourceActionParams = ActionParams const runDate = new Date() /** * Collection of helper methods available in the views */ export class ViewHelpers { public options: Paths constructor({ options }: { options?: AdminJSOptions } = {}) { let opts: Paths = ViewHelpers.getPaths(options) opts = opts || { rootPath: '/admin', } // when ViewHelpers are used on the frontend, paths are taken from global Redux State this.options = opts } static getPaths(options?: AdminJSOptions): Paths { return options || globalAny.REDUX_STATE?.paths } /** * To each related path adds rootPath passed by the user, as well as a query string * @private * @param {Array} [paths] list of parts of the url * @return {string} path * @return {query} [search=''] query string which can be fetch * from `location.search` */ urlBuilder(paths: Array = [], search = ''): string { const separator = '/' const replace = new RegExp(`${separator}{1,}`, 'g') let { rootPath } = this.options if (!rootPath.startsWith(separator)) { rootPath = `${separator}${rootPath}` } const parts = [rootPath, ...paths] return `${parts.join(separator).replace(replace, separator)}${search}` } /** * Returns login URL * @return {string} */ loginUrl(): string { return this.options.loginPath } /** * Returns logout URL * @return {string} */ logoutUrl(): string { return this.options.logoutPath } /** * Returns URL for the dashboard * @return {string} */ dashboardUrl(): string { return this.options.rootPath } /** * Returns URL for given page name * @param {string} pageName page name which is a unique key specified in * {@link AdminJSOptions} * @return {string} */ pageUrl(pageName: string): string { return this.urlBuilder(['pages', pageName]) } /** * Returns url for a `edit` action in given Resource. Uses {@link recordActionUrl} * * @param {string} resourceId id to the resource * @param {string} recordId id to the record * @param {string} [search] optional query string */ editUrl(resourceId: string, recordId: string, search?: string): string { return this.recordActionUrl({ resourceId, recordId, actionName: 'edit', search }) } /** * Returns url for a `show` action in given Resource. Uses {@link recordActionUrl} * * @param {string} resourceId id to the resource * @param {string} recordId id to the record * @param {string} [search] optional query string */ showUrl(resourceId: string, recordId: string, search?: string): string { return this.recordActionUrl({ resourceId, recordId, actionName: 'show', search }) } /** * Returns url for a `delete` action in given Resource. Uses {@link recordActionUrl} * * @param {string} resourceId id to the resource * @param {string} recordId id to the record * @param {string} [search] optional query string */ deleteUrl(resourceId: string, recordId: string, search?: string): string { return this.recordActionUrl({ resourceId, recordId, actionName: 'delete', search }) } /** * Returns url for a `new` action in given Resource. Uses {@link resourceActionUrl} * * @param {string} resourceId id to the resource * @param {string} [search] optional query string */ newUrl(resourceId: string, search?: string): string { return this.resourceActionUrl({ resourceId, actionName: 'new', search }) } /** * Returns url for a `list` action in given Resource. Uses {@link resourceActionUrl} * * @param {string} resourceId id to the resource * @param {string} [search] optional query string */ listUrl(resourceId: string, search?: string): string { return this.resourceActionUrl({ resourceId, actionName: 'list', search }) } /** * Returns url for a `bulkDelete` action in given Resource. Uses {@link bulkActionUrl} * * @param {string} resourceId id to the resource * @param {Array} recordIds separated by comma records * @param {string} [search] optional query string */ bulkDeleteUrl(resourceId: string, recordIds: Array, search?: string): string { return this.bulkActionUrl({ resourceId, recordIds, actionName: 'bulkDelete', search }) } /** * Returns resourceAction url * * @param {ResourceActionParams} options * @param {string} options.resourceId * @param {string} options.actionName * @param {string} [options.search] optional query string * * @return {string} */ resourceActionUrl({ resourceId, actionName, search }: ResourceActionParams): string { return this.urlBuilder(['resources', resourceId, 'actions', actionName], search) } resourceUrl({ resourceId, search }: Omit): string { return this.urlBuilder(['resources', resourceId], search) } /** * Returns recordAction url * * @param {RecordActionParams} options * @param {string} options.resourceId * @param {string} options.recordId * @param {string} options.actionName * * @return {string} */ recordActionUrl({ resourceId, recordId, actionName, search }: RecordActionParams): string { return this.urlBuilder(['resources', resourceId, 'records', recordId, actionName], search) } /** * Returns bulkAction url * * @param {BulkActionParams} options * @param {string} options.resourceId * @param {Array} [options.recordIds] * @param {string} options.actionName * * @return {string} */ bulkActionUrl({ resourceId, recordIds, actionName, search }: BulkActionParams): string { const url = this.urlBuilder(['resources', resourceId, 'bulk', actionName]) if (recordIds && recordIds.length) { const query = new URLSearchParams(search) query.set('recordIds', recordIds.join(',')) return `${url}?${query.toString()}` } return `${url}${search || ''}` } /** * Returns absolute path to a given asset. * @private * * @param {string} asset * @param {Assets | undefined} assetsConfig * @return {string} */ assetPath(asset: string, assetsConfig?: Assets): string { if (this.options.assetsCDN) { const pathname = assetsConfig?.coreScripts?.[asset] ?? asset const url = new URL(pathname, this.options.assetsCDN).href // adding timestamp to the href invalidates the CDN cache return `${url}?date=${runDate.getTime()}` } return this.urlBuilder(['frontend', 'assets', asset]) } } export default ViewHelpers ================================================ FILE: src/constants.ts ================================================ /* cspell: disable */ export const DOCS = 'https://docs.adminjs.co' export const DEFAULT_PATHS = { rootPath: '/admin', logoutPath: '/admin/logout', loginPath: '/admin/login', refreshTokenPath: '/admin/refresh-token', } ================================================ FILE: src/core-scripts.interface.ts ================================================ /** * @memberof Assets * @alias CoreScripts * * Optional mapping of core AdminJS browser scripts: * - app.bundle.js * - components.bundle.js * - design-system.bundle.js * - global.bundle.js * * You may want to use it if you'd like to version assets for caching. This * will only work if you have also configured `assetsCDN` in AdminJS options. * * Example: * ``` * { * 'app.bundle.js': 'app.bundle.123456.js', * 'components.bundle.js': 'components.bundle.123456.js', * 'design-system.bundle.js': 'design-system.bundle.123456.js', * 'global.bundle.js': 'global.bundle.123456.js', * } * ``` */ export interface CoreScripts { /** * App Bundle */ 'app.bundle.js': string; /** * Custom Components */ 'components.bundle.js': string; /** * Design System Bundle */ 'design-system.bundle.js': string; /** * Global bundle */ 'global.bundle.js': string; } ================================================ FILE: src/current-admin.interface.ts ================================================ /** * Currently logged in admin. * * ### Usage with TypeScript * * ```typescript * import { CurrentAdmin } from 'adminjs' * ``` * * @alias CurrentAdmin * @memberof AdminJS */ export interface CurrentAdmin { /** * Admin has one required field which is an email */ email: string; /** * Optional title/role of an admin - this will be presented below the email */ title?: string; /** * Optional url for an avatar photo */ avatarUrl?: string; /** * Id of your admin user */ id?: string; /** * Optional ID of theme to use */ theme?: string; /** * Extra metadata specific to given Auth Provider */ _auth?: Record; /** * Also you can put as many other fields to it as you like. */ [key: string]: any; } ================================================ FILE: src/frontend/assets/styles/icomoon.css ================================================ @font-face { font-family: 'icomoon'; src: url("icomoon.eot"); src: url("icomoon.eot#iefix") format("embedded-opentype"), url("icomoon.ttf") format("truetype"), url("icomoon.woff") format("woff"), url("icomoon.svg#icomoon") format("svg"); font-weight: normal; font-style: normal; } [class^="icomoon-"], [class*=" icomoon-"] { font-family: 'icomoon' !important; speak: none; font-style: normal; font-weight: normal; font-variant: normal; text-transform: none; line-height: 1; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .icomoon-save:before { content: "\e914"; } .icomoon-calendar:before { content: "\e900"; } .icomoon-close:before { content: "\e901"; } .icomoon-collections:before { content: "\e902"; } .icomoon-dropdown-open:before { content: "\e903"; } .icomoon-dropdown-close:before { content: "\e904"; } .icomoon-edit:before { content: "\e905"; } .icomoon-eye:before { content: "\e906"; } .icomoon-export:before { content: "\e907"; } .icomoon-filter-down:before { content: "\e908"; } .icomoon-general:before { content: "\e909"; } .icomoon-info:before { content: "\e90a"; } .icomoon-moon:before { content: "\e90b"; } .icomoon-notifications:before { content: "\e90c"; } .icomoon-options:before { content: "\e90d"; } .icomoon-pagination-left:before { content: "\e90e"; } .icomoon-pagination-right:before { content: "\e90f"; } .icomoon-add:before { content: "\e910"; } .icomoon-profile:before { content: "\e911"; } .icomoon-remove-2:before { content: "\e912"; } .icomoon-remove:before { content: "\e913"; } .icomoon-search:before { content: "\e915"; } .icomoon-settings:before { content: "\e916"; } .icomoon-sun:before { content: "\e917"; } ================================================ FILE: src/frontend/bundle-entry.jsx ================================================ import { ThemeProvider } from '@adminjs/design-system/styled-components' import React, { Suspense } from 'react' import { I18nextProvider } from 'react-i18next' import { Provider } from 'react-redux' import { BrowserRouter } from 'react-router-dom' import ViewHelpers from '../backend/utils/view-helpers/view-helpers.js' import { flat } from '../utils/flat/index.js' import * as AppComponents from './components/app/index.js' import * as ActionComponents from './components/actions/index.js' import App, { OriginalApp } from './components/application.js' import { AppLoader } from './components/index.js' import Login from './components/login/index.js' import BasePropertyComponent, { CleanPropertyComponent } from './components/property-type/index.js' import * as PropertyComponentUtils from './components/property-type/utils/index.js' import * as ActionUtils from './interfaces/action/index.js' import withNotice from './hoc/with-notice.js' import * as Hooks from './hooks/index.js' import createStore from './store/store.js' import initTranslations from './utils/adminjs.i18n.js' import ApiClient from './utils/api-client.js' const env = { NODE_ENV: process.env.NODE_ENV || 'development', } const store = createStore(window.REDUX_STATE) const theme = window.THEME const { locale } = store.getState() const { i18n } = initTranslations(locale) const Application = ( }> ) const loginAppProps = window.__APP_STATE__ ?? {} const LoginApplication = ( }> ) // eslint-disable-next-line no-undef window.regeneratorRuntime = regeneratorRuntime export default { withNotice, Application, OriginalApplication: OriginalApp, LoginApplication, ViewHelpers, UserComponents: {}, ApiClient, BasePropertyComponent, CleanPropertyComponent, env, ...PropertyComponentUtils, ...AppComponents, ...ActionComponents, ...Hooks, ...ActionUtils, flat, } ================================================ FILE: src/frontend/components/actions/action.props.ts ================================================ import { Dispatch, SetStateAction } from 'react' import { ActionJSON, RecordJSON, ResourceJSON } from '../../interfaces/index.js' /** * Props which are passed to all action components * @alias ActionProps * @memberof BaseActionComponent */ export type ActionProps = { /** * Action object describing the action */ action: ActionJSON; /** * Object of type: {@link ResourceJSON} */ resource: ResourceJSON; /** * Selected record. Passed for actions with "record" actionType */ record?: RecordJSON; /** * Selected records. Passed for actions with "bulk" actionType */ records?: Array; /** * Sets tag in a header of an action. It is a function taking tag as an argument */ setTag?: Dispatch>; } ================================================ FILE: src/frontend/components/actions/bulk-delete.tsx ================================================ import { Button, DrawerContent, DrawerFooter, Icon, MessageBox, Table, TableBody, TableCell, TableRow, Text } from '@adminjs/design-system' import React, { useState } from 'react' import { useNavigate } from 'react-router' import allowOverride from '../../hoc/allow-override.js' import withNotice, { AddNoticeProps } from '../../hoc/with-notice.js' import { useTranslation } from '../../hooks/index.js' import { getActionElementCss } from '../../utils/index.js' import ApiClient from '../../utils/api-client.js' import ActionHeader from '../app/action-header/action-header.js' import BasePropertyComponent from '../property-type/index.js' import { ActionProps } from './action.props.js' import { appendForceRefresh } from './utils/append-force-refresh.js' /** * @name BulkDeleteAction * @category Actions * @description Deletes selected records. * @component * @private */ const BulkDelete: React.FC = (props) => { const { resource, records, action, addNotice } = props const navigate = useNavigate() const [loading, setLoading] = useState(false) const { translateMessage, translateButton } = useTranslation() if (!records) { return ( {translateMessage('pickSomeFirstToRemove', resource.id)} ) } const handleClick = (): void => { const api = new ApiClient() setLoading(true) const recordIds = records.map((r) => r.id) api.bulkAction({ resourceId: resource.id, actionName: action.name, recordIds, method: 'post', }).then(((response) => { setLoading(false) if (response.data.notice) { addNotice(response.data.notice) } if (response.data.redirectUrl) { const search = new URLSearchParams(window.location.search) // bulk function have recordIds in the URL so it has to be stripped before redirect search.delete('recordIds') navigate(appendForceRefresh(response.data.redirectUrl, search.toString())) } })).catch((error) => { setLoading(false) addNotice({ message: translateMessage('bulkDeleteError', resource.id), type: 'error', }) throw error }) } const contentTag = getActionElementCss(resource.id, action.name, 'drawer-content') const tableTag = getActionElementCss(resource.id, action.name, 'table') const footerTag = getActionElementCss(resource.id, action.name, 'drawer-footer') return ( <> {action?.showInDrawer ? : null} 1 ? 'theseRecordsWillBeRemoved_plural' : 'theseRecordsWillBeRemoved', resource.id, { count: records.length })} /> {records.map((record) => ( ))}
) } const FormattedBulkDelete = withNotice(BulkDelete) const OverridableFormattedBulkDelete = allowOverride(FormattedBulkDelete, 'DefaultBulkDeleteAction') export { OverridableFormattedBulkDelete as default, OverridableFormattedBulkDelete as BulkDelete, FormattedBulkDelete as OriginalBulkDelete, } ================================================ FILE: src/frontend/components/actions/edit.tsx ================================================ import { Box, Button, DrawerContent, DrawerFooter, Icon } from '@adminjs/design-system' import React, { FC, useEffect } from 'react' import { useNavigate } from 'react-router' import allowOverride from '../../hoc/allow-override.js' import useRecord from '../../hooks/use-record/use-record.js' import { useTranslation } from '../../hooks/use-translation.js' import { RecordJSON } from '../../interfaces/index.js' import { getActionElementCss } from '../../utils/index.js' import ActionHeader from '../app/action-header/action-header.js' import BasePropertyComponent from '../property-type/index.js' import { ActionProps } from './action.props.js' import { appendForceRefresh } from './utils/append-force-refresh.js' import LayoutElementRenderer from './utils/layout-element-renderer.js' const Edit: FC = (props) => { const { record: initialRecord, resource, action } = props const { record, handleChange, submit: handleSubmit, loading, setRecord, } = useRecord(initialRecord, resource.id) const { translateButton } = useTranslation() const navigate = useNavigate() useEffect(() => { if (initialRecord) { setRecord(initialRecord) } }, [initialRecord]) const submit = (event: React.FormEvent): boolean => { event.preventDefault() handleSubmit().then((response) => { if (response.data.redirectUrl) { navigate(appendForceRefresh(response.data.redirectUrl)) } }) return false } const contentTag = getActionElementCss(resource.id, action.name, 'drawer-content') const formTag = getActionElementCss(resource.id, action.name, 'form') const footerTag = getActionElementCss(resource.id, action.name, 'drawer-footer') const buttonTag = getActionElementCss(resource.id, action.name, 'drawer-submit') return ( {action?.showInDrawer ? : null} {action.layout ? action.layout.map((layoutElement, i) => ( )) : resource.editProperties.map((property) => ( ))} ) } const OverridableEdit = allowOverride(Edit, 'DefaultEditAction') export { OverridableEdit as default, OverridableEdit as Edit, Edit as OriginalEdit, } ================================================ FILE: src/frontend/components/actions/index.ts ================================================ import { New } from './new.js' import { Edit } from './edit.js' import { Show } from './show.js' import { List } from './list.js' import { BulkDelete } from './bulk-delete.js' export * from './new.js' export * from './action.props.js' export * from './edit.js' export * from './show.js' export * from './list.js' export * from './bulk-delete.js' export * from './utils/index.js' export const actions = { new: New, edit: Edit, show: Show, list: List, bulkDelete: BulkDelete, } ================================================ FILE: src/frontend/components/actions/list.tsx ================================================ import { Box, Pagination, Text } from '@adminjs/design-system' import React, { useEffect } from 'react' import { useLocation } from 'react-router' import allowOverride from '../../hoc/allow-override.js' import { useQueryParams } from '../../hooks/use-query-params.js' import useRecords from '../../hooks/use-records/use-records.js' import useSelectedRecords from '../../hooks/use-selected-records/use-selected-records.js' import { getActionElementCss } from '../../utils/index.js' import RecordsTable from '../app/records-table/records-table.js' import { ActionProps } from './action.props.js' import { REFRESH_KEY } from './utils/append-force-refresh.js' const List: React.FC = ({ resource, setTag }) => { const { records, loading, direction, sortBy, page, total, fetchData, perPage, } = useRecords(resource.id) const { selectedRecords, handleSelect, handleSelectAll, setSelectedRecords, } = useSelectedRecords(records) const location = useLocation() const { storeParams } = useQueryParams() useEffect(() => { if (setTag) { setTag(total.toString()) } }, [total]) useEffect(() => { setSelectedRecords([]) }, [resource.id]) useEffect(() => { const search = new URLSearchParams(location.search) if (search.get(REFRESH_KEY)) { setSelectedRecords([]) } else { const recordIds = search.get('recordIds')?.split?.(',') ?? [] setSelectedRecords( records.filter((r) => recordIds.includes(r.id.toString())), ) } }, [location.search, records]) const handleActionPerformed = (): any => fetchData() const handlePaginationChange = (pageNumber: number): void => { storeParams({ page: pageNumber.toString() }) } const contentTag = getActionElementCss(resource.id, 'list', 'table-wrapper') return ( ) } const OverridableList = allowOverride(List, 'DefaultListAction') export { OverridableList as default, OverridableList as List, List as OriginalList, } ================================================ FILE: src/frontend/components/actions/new.tsx ================================================ import { Box, Button, DrawerContent, DrawerFooter, Icon } from '@adminjs/design-system' import pick from 'lodash/pick.js' import React, { FC, useEffect } from 'react' import { useNavigate } from 'react-router' import allowOverride from '../../hoc/allow-override.js' import { useQueryParams } from '../../hooks/use-query-params.js' import useRecord from '../../hooks/use-record/use-record.js' import { useTranslation } from '../../hooks/use-translation.js' import { RecordJSON } from '../../interfaces/index.js' import { getActionElementCss } from '../../utils/index.js' import ActionHeader from '../app/action-header/action-header.js' import BasePropertyComponent from '../property-type/index.js' import { ActionProps } from './action.props.js' import { appendForceRefresh } from './utils/append-force-refresh.js' import LayoutElementRenderer from './utils/layout-element-renderer.js' const New: FC = (props) => { const { record: initialRecord, resource, action } = props const { record, handleChange, submit, loading, setRecord } = useRecord(initialRecord, resource.id) const { translateButton } = useTranslation() const navigate = useNavigate() const { parsedQuery, redirectUrl } = useQueryParams() useEffect(() => { if (initialRecord) { setRecord(initialRecord) } }, [initialRecord, parsedQuery]) useEffect(() => { if (parsedQuery) { const resourceProperties = pick(parsedQuery, Object.keys(resource.properties)) if (Object.keys(resourceProperties).length) { setRecord({ ...record, params: { ...record.params, ...resourceProperties } }) } } }, [parsedQuery]) const handleSubmit = (event): boolean => { event.preventDefault() if (!event.currentTarget) return false submit().then((response) => { if (response.data.redirectUrl) { navigate(appendForceRefresh(response.data.redirectUrl)) } // if record has id === has been created if (response.data.record.id && !Object.keys(response.data.record.errors).length) { handleChange({ params: {}, populated: {}, errors: {} } as RecordJSON) } }) return false } const handleCancel = () => { if (redirectUrl) { window.location.href = redirectUrl } } const contentTag = getActionElementCss(resource.id, action.name, 'drawer-content') const formTag = getActionElementCss(resource.id, action.name, 'form') const footerTag = getActionElementCss(resource.id, action.name, 'drawer-footer') const buttonTag = getActionElementCss(resource.id, action.name, 'drawer-submit') const cancelButtonTag = getActionElementCss(resource.id, action.name, 'drawer-cancel') return ( {action?.showInDrawer ? : null} {action.layout ? action.layout.map((layoutElement, i) => ( )) : resource.editProperties.map((property) => ( ))} {redirectUrl && ( )} ) } const OverridableNew = allowOverride(New, 'DefaultNewAction') export { OverridableNew as default, OverridableNew as New, New as OriginalNew, } ================================================ FILE: src/frontend/components/actions/show.tsx ================================================ import { DrawerContent } from '@adminjs/design-system' import React from 'react' import allowOverride from '../../hoc/allow-override.js' import { getActionElementCss } from '../../utils/index.js' import ActionHeader from '../app/action-header/action-header.js' import BasePropertyComponent from '../property-type/index.js' import { ActionProps } from './action.props.js' import LayoutElementRenderer from './utils/layout-element-renderer.js' /** * @name ShowAction * @category Actions * @description Shows a given record. * @component * @private */ const Show: React.FC = (props) => { const { resource, record, action } = props const properties = resource.showProperties const contentTag = getActionElementCss(resource.id, action.name, 'drawer-content') return ( {action?.showInDrawer ? : null} {action.layout ? action.layout.map((layoutElement, i) => ( )) : properties.map((property) => ( ))} ) } const OverridableShow = allowOverride(Show, 'DefaultShowAction') export { OverridableShow as default, OverridableShow as Show, Show as OriginalShow, } ================================================ FILE: src/frontend/components/actions/utils/append-force-refresh.spec.ts ================================================ import { expect } from 'chai' import { appendForceRefresh } from './append-force-refresh.js' describe('appendForceRefresh', () => { it('should add ?refresh=true to url if url has no search params', () => { const oldUrl = '/resources/Test' const newUrl = appendForceRefresh(oldUrl) expect(newUrl).to.equal('/resources/Test?refresh=true') }) it('should add &refresh=true to url if url already has search params', () => { const oldUrl = '/resources/Test?param=test' const newUrl = appendForceRefresh(oldUrl) expect(newUrl).to.equal('/resources/Test?param=test&refresh=true') }) it('should add &refresh=true to url if url already has search params but custom search is passed', () => { const oldUrl = '/resources/Test?param=test' const newUrl = appendForceRefresh(oldUrl, 'other_param=test2') expect(newUrl).to.equal('/resources/Test?other_param=test2&refresh=true') }) it('should add ?refresh=true to url if url is a full url with no search params', () => { const oldUrl = 'http://example.com/resources/Test' const newUrl = appendForceRefresh(oldUrl) expect(newUrl).to.equal('http://example.com/resources/Test?refresh=true') }) it('should add &refresh=true to url if url is a full url with search params', () => { const oldUrl = 'http://example.com/resources/Test?param=test' const newUrl = appendForceRefresh(oldUrl) expect(newUrl).to.equal('http://example.com/resources/Test?param=test&refresh=true') }) it('should add &refresh=true to url if url is a full url with search params but custom search is passed', () => { const oldUrl = 'http://example.com/resources/Test?param=test' const newUrl = appendForceRefresh(oldUrl, 'other_param=test2') expect(newUrl).to.equal('http://example.com/resources/Test?other_param=test2&refresh=true') }) it('should ignore old search params if `ignore_params=true` is contained in the new url', () => { const oldUrl = 'http://example.com/resources/Test?ignore_params=true' const newUrl = appendForceRefresh(oldUrl, 'old_param=test2') expect(newUrl).to.equal('http://example.com/resources/Test?refresh=true') }) }) ================================================ FILE: src/frontend/components/actions/utils/append-force-refresh.ts ================================================ export const REFRESH_KEY = 'refresh' export const IGNORE_PARAMS_KEY = 'ignore_params' /** * Adds refresh=true to the url, which in turn should cause list to reload. * * @param {string} url url to which function should add `refresh` * @param {string} [search] optional search query which should be updated, * if not given function will use window.location.search * @private */ export const appendForceRefresh = (url: string, search?: string): string => { const searchParamsIdx = url.lastIndexOf('?') const urlSearchParams = searchParamsIdx !== -1 ? url.substring(searchParamsIdx + 1) : null const oldParams = new URLSearchParams(search ?? urlSearchParams ?? window.location.search ?? '') const shouldIgnoreOldParams = new URLSearchParams(urlSearchParams || '').get(IGNORE_PARAMS_KEY) === 'true' const newParams = shouldIgnoreOldParams ? new URLSearchParams('') : new URLSearchParams(oldParams.toString()) newParams.set(REFRESH_KEY, 'true') const newUrl = searchParamsIdx !== -1 ? url.substring(0, searchParamsIdx) : url return `${newUrl}?${newParams.toString()}` } export const hasForceRefresh = (search: string): boolean => { const params = new URLSearchParams(search) return !!params.get(REFRESH_KEY) } export const removeForceRefresh = (search: string): string => { const params = new URLSearchParams(search) if (params.get(REFRESH_KEY)) { params.delete(REFRESH_KEY) } return params.toString() } ================================================ FILE: src/frontend/components/actions/utils/index.ts ================================================ export * from './layout-element-renderer.js' ================================================ FILE: src/frontend/components/actions/utils/layout-element-renderer.tsx ================================================ import React from 'react' import * as DesignSystem from '@adminjs/design-system' import { ActionProps } from '../action.props.js' import BasePropertyComponent from '../../property-type/index.js' import { PropertyPlace } from '../../../interfaces/property-json/property-json.interface.js' import { ParsedLayoutElement } from '../../../../backend/utils/layout-element-parser/index.js' import { BasePropertyProps } from '../../property-type/base-property-props.js' type Props = ActionProps & { layoutElement: ParsedLayoutElement; where: PropertyPlace; onChange?: BasePropertyProps['onChange']; } export const LayoutElementRenderer: React.FC = (props) => { const { layoutElement, resource, where, record, onChange } = props const { props: layoutProps, properties: propertyNames, layoutElements: innerLayoutElements, component, } = layoutElement const { children, ...other } = layoutProps const properties = propertyNames.map((name) => resource.properties[name]) const Component = DesignSystem[component] if (!Component) { return ( There is no component by the name of {component} in @adminjs/design-system. Change {`@${component}`} to available component like @Header ) } return ( {properties.map((property) => ( ))} {innerLayoutElements.map((innerLayoutElement, i) => ( ))} {children} ) } export default LayoutElementRenderer ================================================ FILE: src/frontend/components/app/action-button/action-button.tsx ================================================ import React, { ReactElement } from 'react' import { stringify } from 'qs' import { ActionResponse } from '../../../../backend/actions/action.interface.js' import allowOverride from '../../../hoc/allow-override.js' import { useAction } from '../../../hooks/index.js' import { ActionJSON, buildActionTestId } from '../../../interfaces/index.js' import { getActionElementCss } from '../../../utils/index.js' /** * @alias ActionButtonProps * @memberof ActionButton */ export type ActionButtonProps = { /** Action to which button should redirect */ action: ActionJSON /** Id of a resource of an action */ resourceId: string /** Optional recordId for _record_ action */ recordId?: string /** Optional recordIds for _bulk_ action */ recordIds?: Array /** optional callback function which will be triggered when action is performed */ actionPerformed?: (action: ActionResponse) => any children?: React.ReactNode search?: string queryParams?: Record } /** * Renders Button which redirects to given action * * ### Usage * * ``` * import { ActionButton } from 'adminjs' * ``` * * @component * @subcategory Application */ const ActionButton: React.FC = (props) => { const { children, action, actionPerformed, resourceId, recordId, recordIds, search, queryParams, } = props const { href, handleClick } = useAction( action, { resourceId, recordId, recordIds, search: stringify(queryParams, { addQueryPrefix: true }) || search, }, actionPerformed, ) if (!action) { return null } const firstChild = React.Children.toArray(children)[0] if ( !firstChild || typeof firstChild === 'string' || typeof firstChild === 'number' || typeof firstChild === 'boolean' ) { throw new Error('ActionButton has to have one child') } const contentTag = getActionElementCss(resourceId, action.name, 'button') const WrappedElement = React.cloneElement(firstChild as ReactElement, { onClick: handleClick, 'data-testid': buildActionTestId(action), 'data-css': contentTag, href, }) return WrappedElement } const OverridableActionButton = allowOverride(ActionButton, 'ActionButton') export { OverridableActionButton as default, OverridableActionButton as ActionButton, ActionButton as OriginalActionButton, } ================================================ FILE: src/frontend/components/app/action-button/index.ts ================================================ export * from './action-button.js' ================================================ FILE: src/frontend/components/app/action-header/action-header-props.tsx ================================================ import { ActionJSON, RecordJSON, ResourceJSON } from '../../../interfaces/index.js' import { ActionResponse } from '../../../../backend/actions/action.interface.js' /** * @memberof ActionHeader * @alias ActionHeaderProps */ export type ActionHeaderProps = { /** Resource for the action */ resource: ResourceJSON; /** Optional record - for _record_ actions */ record?: RecordJSON; /** If given, action header will render Filter button */ toggleFilter?: (() => any) | boolean; /** * It indicates if action without a component was performed. */ actionPerformed?: (action: ActionResponse) => any; /** An action objet */ action: ActionJSON; /** Optional tag which will be rendered as a {@link Badge} */ tag?: string; /** If set, component wont render actions */ omitActions?: boolean; }; ================================================ FILE: src/frontend/components/app/action-header/action-header.tsx ================================================ /* eslint-disable jsx-a11y/anchor-is-valid */ import { Badge, Box, ButtonGroup, cssClass, H2, H3 } from '@adminjs/design-system' import React from 'react' import { useNavigate, useLocation } from 'react-router' import allowOverride from '../../../hoc/allow-override.js' import { useActionResponseHandler, useTranslation, useModal } from '../../../hooks/index.js' import { ActionJSON, buildActionClickHandler } from '../../../interfaces/action/index.js' import { getActionElementCss, getResourceElementCss } from '../../../utils/index.js' import Breadcrumbs from '../breadcrumbs.js' import { ActionHeaderProps } from './action-header-props.js' import { actionsToButtonGroup } from './actions-to-button-group.js' import { StyledBackButton } from './styled-back-button.js' import { useFilterDrawer } from '../../../hooks/use-filter-drawer.js' /** * Header of an action. It renders Action name with buttons for all the actions. * * ### Usage * * ``` * import { ActionHeader } from 'adminjs' * ``` * * @component * @subcategory Application */ const ActionHeader: React.FC = (props) => { const { resource, actionPerformed, record, action, tag, omitActions, toggleFilter: isFilterButtonVisible, } = props const translateFunctions = useTranslation() const { translateButton, translateAction } = translateFunctions const navigate = useNavigate() const location = useLocation() const actionResponseHandler = useActionResponseHandler(actionPerformed) const modalFunctions = useModal() const { toggleFilter, filtersCount } = useFilterDrawer() if (action.hideActionHeader) { return null } const resourceId = resource.id const params = { resourceId, recordId: record?.id } // eslint-disable-next-line max-len const handleActionClick = (event, sourceAction: ActionJSON): any | Promise => buildActionClickHandler({ action: sourceAction, params, actionResponseHandler, navigate, location, translateFunctions, modalFunctions, })(event) const actionButtons = actionsToButtonGroup({ actions: record ? record.recordActions.filter((ra) => !action || action.name !== ra.name) // only new action should be seen in regular "Big" actions place : resource.resourceActions.filter( (ra) => ra.name === 'new' && (!action || action.name !== ra.name), ), params, handleClick: handleActionClick, translateFunctions, modalFunctions, }) if (typeof isFilterButtonVisible === 'function' || isFilterButtonVisible) { const filterTranslationKey = filtersCount > 0 ? 'filterActive' : 'filter' actionButtons.push({ label: translateButton(filterTranslationKey, resource.id, { count: filtersCount }), onClick: toggleFilter, icon: 'Filter', 'data-css': getResourceElementCss(resource.id, 'filter-button'), }) } // list and new actions are special and are are always const customResourceButtons = actionsToButtonGroup({ actions: action.showResourceActions ? resource.resourceActions.filter((ra) => !['list', 'new'].includes(ra.name)) : [], params: { resourceId }, handleClick: handleActionClick, translateFunctions, modalFunctions, }) const title = action ? translateAction(action.label, resourceId) : resource.name // styled which differs if action header is in the drawer or not const cssIsRootFlex = !action.showInDrawer const cssHeaderMT = action.showInDrawer ? '' : 'lg' const cssActionsMB = action.showInDrawer ? 'xl' : 'default' const CssHComponent = action.showInDrawer ? H3 : H2 const contentTag = getActionElementCss(resourceId, action.name, 'action-header') return ( {!action.showInDrawer && ( )} {action.showInDrawer && } {title} {tag ? ( {tag} ) : null} {!omitActions && ( )} ) } const OverridableActionHeader = allowOverride(ActionHeader, 'ActionHeader') export { OverridableActionHeader as default, OverridableActionHeader as ActionHeader, ActionHeader as OriginalActionHeader, } ================================================ FILE: src/frontend/components/app/action-header/actions-to-button-group.spec.ts ================================================ import { ButtonGroupProps } from '@adminjs/design-system' import { expect } from 'chai' import i18n from 'i18next' import { factory } from 'factory-girl' import { ActionJSON, ModalFunctions } from '../../../interfaces/index.js' import { actionsToButtonGroup } from './actions-to-button-group.js' import { createFunctions } from '../../../../utils/translate-functions.factory.js' import '../../spec/action-json.factory.js' const translateFunctions = createFunctions(i18n as any) const modalFunctions: ModalFunctions = { closeModal: () => { /* noop */ }, openModal: () => { /* noop */ }, } describe('actionsToButtonGroup', () => { let actions: Array const actionsCount = 5 const params = { recordId: 'recordId', resourceId: 'resourceId', recordsId: ['recordId'], } let buttonGroupProps: ButtonGroupProps['buttons'] const handleClick = () => true context('flat actions (no nesting)', () => { beforeEach(async () => { actions = await factory.buildMany('ActionJSON', actionsCount, { actionType: 'record', }) buttonGroupProps = actionsToButtonGroup({ actions, params, handleClick, translateFunctions, modalFunctions, }) }) it('returns all buttons', () => { expect(buttonGroupProps.length).to.eq(actionsCount) }) }) context('nested actions', () => { let rootActions: { normal: ActionJSON; publish: ActionJSON; export: ActionJSON; } let actionsPublish: Array let actionsExport: Array beforeEach(async () => { rootActions = { normal: await factory.build('ActionJSON', { actionType: 'record' }), publish: await factory.build('ActionJSON', { actionType: 'record', name: 'publish' }), export: await factory.build('ActionJSON', { actionType: 'record', name: 'publish' }), } actionsPublish = await factory.buildMany('ActionJSON', actionsCount, { actionType: 'record', parent: 'publish', }) actionsExport = await factory.buildMany('ActionJSON', actionsCount, { actionType: 'record', parent: 'export', }) buttonGroupProps = actionsToButtonGroup({ actions: [ ...Object.values(rootActions), ...actionsPublish, ...actionsExport, ], params, handleClick, translateFunctions, modalFunctions, }) }) it('returns 3 root buttons', () => { expect(buttonGroupProps.length).to.eq(3) }) it('returns 5 buttons for each nested action', () => { const publishButton = buttonGroupProps[1] const exportButton = buttonGroupProps[2] expect(publishButton.buttons).to.have.lengthOf(actionsCount) expect(exportButton.buttons).to.have.lengthOf(actionsCount) }) }) context('action with not existing parent', () => { const parent = 'newParent' beforeEach(async () => { actions = [ await factory.build('ActionJSON', { actionType: 'record', parent, }), ] buttonGroupProps = actionsToButtonGroup({ actions, params, handleClick, translateFunctions, modalFunctions, }) }) it('returns just one root action', () => { expect(buttonGroupProps).to.have.lengthOf(1) }) it('creates button for not existing parent', async () => { const parentButton = buttonGroupProps[0] expect(parentButton.label).to.equal(parent) }) it('nests remaining action under parent', () => { const parentButton = buttonGroupProps[0] expect(parentButton.buttons).to.have.lengthOf(1) }) }) }) ================================================ FILE: src/frontend/components/app/action-header/actions-to-button-group.ts ================================================ import { ButtonGroupProps, ButtonInGroupProps } from '@adminjs/design-system' import { actionHref, ActionJSON, buildActionTestId, ModalFunctions } from '../../../interfaces/index.js' import { DifferentActionParams } from '../../../hooks/index.js' import { TranslateFunctions } from '../../../../utils/index.js' export type actionsToButtonGroupOptions = { actions: Array; params: DifferentActionParams; handleClick: ButtonInGroupProps['onClick']; translateFunctions: TranslateFunctions; modalFunctions: ModalFunctions, } export const actionsToButtonGroup = ( options: actionsToButtonGroupOptions, ): ButtonGroupProps['buttons'] => { const { actions, params, handleClick, translateFunctions } = options const { translateAction } = translateFunctions const { resourceId } = params const buttons = actions.map((action) => { const href = actionHref(action, params) return { icon: action.icon, label: translateAction(action.label, resourceId), variant: action.variant, source: action, href: href || undefined, // when href is not defined - handle click should also be not defined // This prevents from "cursor: pointer;" onClick: href ? handleClick : undefined, 'data-testid': buildActionTestId(action), buttons: [], 'data-css': `${action.resourceId}-${action.name}-button`, } }) // nesting buttons const buttonsMap = buttons.reduce((memo, button) => { const action = button.source if (action.parent) { const parent: ButtonInGroupProps = memo[action.parent] || buttons.find((btn) => btn.source.name === action.parent) || { label: action.parent } parent.buttons = parent.buttons || [] parent.buttons.push(button) return { ...memo, [action.parent]: parent, } } return { ...memo, [button.source.name]: button, } }, {} as Record) return Object.values(buttonsMap) } ================================================ FILE: src/frontend/components/app/action-header/index.ts ================================================ export * from './action-header.js' export * from './action-header-props.js' ================================================ FILE: src/frontend/components/app/action-header/styled-back-button.tsx ================================================ import React from 'react' import { Link as RouterLink } from 'react-router-dom' import { useLocation } from 'react-router' import { ButtonCSS, ButtonProps, Icon, } from '@adminjs/design-system' import { styled } from '@adminjs/design-system/styled-components' import allowOverride from '../../../hoc/allow-override.js' // eslint-disable-next-line @typescript-eslint/no-unused-vars const StyledLink = styled(({ rounded, to, ...rest }) => )`${ButtonCSS}` export type StyledBackButtonProps = { showInDrawer: boolean; } const StyledBackButton: React.FC = (props) => { const location = useLocation() const { showInDrawer } = props const cssCloseIcon = showInDrawer ? 'ChevronRight' : 'ChevronLeft' return ( ) } const OverridableStyledBackButton = allowOverride(StyledBackButton, 'StyledBackButton') export { OverridableStyledBackButton as default, OverridableStyledBackButton as StyledBackButton, StyledBackButton as OriginalStyledBackButton, } ================================================ FILE: src/frontend/components/app/admin-modal.tsx ================================================ import { Modal } from '@adminjs/design-system' import React, { FC } from 'react' import { useSelector } from 'react-redux' import { ReduxState } from '../../store/index.js' export const AdminModal: FC = () => { const modalState = useSelector((state: ReduxState) => state.modal) return modalState.show ? : null } export default AdminModal ================================================ FILE: src/frontend/components/app/app-loader.tsx ================================================ import { Box, Loader } from '@adminjs/design-system' import React, { FC } from 'react' export const AppLoader: FC = () => ( ) ================================================ FILE: src/frontend/components/app/auth-background-component.tsx ================================================ import React from 'react' import allowOverride from '../../hoc/allow-override.js' const AuthenticationBackgroundComponent: React.FC = () => null const OverridableAuthenticationBackgroundComponent = allowOverride(AuthenticationBackgroundComponent, 'AuthenticationBackgroundComponent') export { OverridableAuthenticationBackgroundComponent as default, OverridableAuthenticationBackgroundComponent as AuthenticationBackgroundComponent, AuthenticationBackgroundComponent as OriginalAuthenticationBackgroundComponent, } ================================================ FILE: src/frontend/components/app/base-action-component.tsx ================================================ import React from 'react' import { Trans } from 'react-i18next' import { MessageBox, Link } from '@adminjs/design-system' import ErrorBoundary from './error-boundary.js' import { actions } from '../actions/index.js' import { DOCS } from '../../../constants.js' import { ActionProps } from '../actions/action.props.js' import { useTranslation } from '../../hooks/index.js' declare const AdminJS: { UserComponents: Array; } /** * Component which renders all the default and custom actions for both the Resource and the Record. * * It passes all props down to the actual Action component. * * Example of creating your own actions: * ``` * // AdminJS options * const AdminJSOptions = { * resources: [ * resource, * options: { * actions: { * myNewAction: { * label: 'amazing action', * icon: 'Add', * inVisible: (resource, record) => record.param('email') !== '', * actionType: 'record', * component: 'MyNewAction', * handler: (request, response, data) => { * return { * ... * } * } * } * } * } * ] * } * ``` * * ``` * // ./my-new-action.js * import { Box } from 'adminjs' * * const MyNewAction = (props) => { * const { resource, action, record } = props * // do something with the props and render action * return ( * Some Action Content * ) * } * ``` * * @component * @name BaseActionComponent * @subcategory Application */ export const BaseActionComponent: React.FC = (props) => { const { resource, action, record, records, setTag } = props const documentationLink = [DOCS, 'BaseAction.html'].join('/') const { translateMessage } = useTranslation() let Action = actions[action.name] if (action.component) { Action = AdminJS.UserComponents[action.component] } if (Action) { return ( ) } return Action || ( {translateMessage('noActionComponent')} See: the documentation ) } export default BaseActionComponent ================================================ FILE: src/frontend/components/app/breadcrumbs.tsx ================================================ import { Box, cssClass, Text } from '@adminjs/design-system' import { styled } from '@adminjs/design-system/styled-components' import React from 'react' import { Link } from 'react-router-dom' import ViewHelpers from '../../../backend/utils/view-helpers/view-helpers.js' import allowOverride from '../../hoc/allow-override.js' import { useTranslation } from '../../hooks/use-translation.js' import { RecordJSON, ResourceJSON } from '../../interfaces/index.js' import { getActionElementCss } from '../../utils/index.js' export const BreadcrumbLink: any = styled(Link)` color: ${({ theme }): string => theme.colors.grey60}; font-family: ${({ theme }): string => theme.font}; line-height: ${({ theme }): string => theme.lineHeights.default}; font-size: ${({ theme }): string => theme.fontSizes.default}; text-decoration: none; &:hover { color: ${({ theme }): string => theme.colors.primary100}; &:after { color: ${({ theme }): string => theme.colors.grey60}; } } &:after { content: '/'; padding: 0 ${({ theme }): string => theme.space.default}; } &:last-child { color: ${({ theme }): string => theme.colors.text}; &:after { content: ''; } } ` export const BreadcrumbText: any = styled(Text)` color: ${({ theme }): string => theme.colors.grey100}; font-family: ${({ theme }): string => theme.font}; font-weight: ${({ theme }): string => theme.fontWeights.normal.toString()}; line-height: ${({ theme }): string => theme.lineHeights.default}; font-size: ${({ theme }): string => theme.fontSizes.default}; cursor: pointer; display: inline; &:after { content: '/'; padding: 0 ${({ theme }): string => theme.space.default}; } &:last-child { &:after { content: ''; } } ` /** * @memberof Breadcrumbs */ export type BreadcrumbProps = { /** * Resource */ resource: ResourceJSON /** * record */ record?: RecordJSON | null /** * Name of an action */ actionName: string } /** * @component * @private */ const Breadcrumbs: React.FC = (props) => { const { resource, record, actionName } = props const listAction = resource.resourceActions.find(({ name }) => name === 'list') const action = resource.actions.find((a) => a.name === actionName) const h = new ViewHelpers() const { tl, ta } = useTranslation() const contentTag = getActionElementCss(resource.id, actionName, 'breadcrumbs') return ( {tl('dashboard')} {listAction ? ( {tl(resource.name, resource.id)} ) : ( {tl(resource.name, resource.id)} )} {action && action.name !== 'list' && ( {ta(action.label)} )} ) } const OverridableBreadcrumbs = allowOverride(Breadcrumbs, 'Breadcrumbs') export { OverridableBreadcrumbs as default, OverridableBreadcrumbs as Breadcrumbs, Breadcrumbs as OriginalBreadcrumbs, } ================================================ FILE: src/frontend/components/app/default-dashboard.tsx ================================================ import React from 'react' import { Box, Button, H2, H5, Illustration, IllustrationProps, Text } from '@adminjs/design-system' import { styled } from '@adminjs/design-system/styled-components' import { useTranslation } from '../../hooks/index.js' import RocketSVG from './utils/rocket-svg.js' import DiscordLogo from './utils/discord-logo-svg.js' const pageHeaderHeight = 300 const pageHeaderPaddingY = 74 const pageHeaderPaddingX = 250 export const DashboardHeader: React.FC = () => { const { translateMessage } = useTranslation() return (

{translateMessage('welcomeOnBoard_title')}

{translateMessage('welcomeOnBoard_subtitle')}
) } type BoxType = { variant: string title: string subtitle: string href: string } const boxes = ({ translateMessage }): Array => [ { variant: 'Details', title: translateMessage('addingResources_title'), subtitle: translateMessage('addingResources_subtitle'), href: 'https://docs.adminjs.co/basics/resource#providing-resources-explicitly', }, { variant: 'Docs', title: translateMessage('customizeResources_title'), subtitle: translateMessage('customizeResources_subtitle'), href: 'https://docs.adminjs.co/basics/resource#customizing-resources', }, { variant: 'Plug', title: translateMessage('customizeActions_title'), subtitle: translateMessage('customizeActions_subtitle'), href: 'https://docs.adminjs.co/basics/action', }, { variant: 'Cup', title: translateMessage('writeOwnComponents_title'), subtitle: translateMessage('writeOwnComponents_subtitle'), href: 'https://docs.adminjs.co/ui-customization/writing-your-own-components', }, { variant: 'Photos', title: translateMessage('customDashboard_title'), subtitle: translateMessage('customDashboard_subtitle'), href: 'https://docs.adminjs.co/ui-customization/dashboard-customization', }, { variant: 'IdentityCard', title: translateMessage('roleBasedAccess_title'), subtitle: translateMessage('roleBasedAccess_subtitle'), href: 'https://docs.adminjs.co/tutorials/adding-role-based-access-control', }, ] const Card = styled(Box)` display: ${({ flex }): string => (flex ? 'flex' : 'block')}; color: ${({ theme }) => theme.colors.grey100}; height: 100%; text-decoration: none; border: 1px solid transparent; border-radius: ${({ theme }) => theme.space.md}; transition: all 0.1s ease-in; &:hover { border: 1px solid ${({ theme }) => theme.colors.primary60}; box-shadow: ${({ theme }) => theme.shadows.cardHover}; } & .dsc-icon svg, .gh-icon svg { width: 64px; height: 64px; } ` Card.defaultProps = { variant: 'container', boxShadow: 'card', } export const Dashboard: React.FC = () => { const { translateMessage, translateButton } = useTranslation() return ( {boxes({ translateMessage }).map((box, index) => ( // eslint-disable-next-line react/no-array-index-key
{box.title}
{box.subtitle}
))}
{translateMessage('needMoreSolutions_title')}
{translateMessage('needMoreSolutions_subtitle')}
{translateMessage('community_title')}
{translateMessage('community_subtitle')}
{translateMessage('foundBug_title')}
{translateMessage('foundBug_subtitle')}
) } export default Dashboard ================================================ FILE: src/frontend/components/app/drawer-portal.tsx ================================================ import React, { useEffect, ReactNode, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { createPortal } from 'react-dom' import { createRoot } from 'react-dom/client' import { Drawer, DEFAULT_DRAWER_WIDTH } from '@adminjs/design-system' // @ts-ignore Note: Ignore while @adminjs/design-system/styled-components doesn't export types import { ThemeProvider } from '@adminjs/design-system/styled-components' import { ReduxState, RouterInState } from '../../store/index.js' import { setDrawerPreRoute } from '../../store/actions/set-drawer-preroute.js' /** * @alias DrawerPortalProps * @memberof DrawerPortal */ export type DrawerPortalProps = { /** * The drawer content */ children: ReactNode; /** * Optional drawer width */ width?: number | string | Array; } export type DrawerWrapperProps = { onMount: () => void; onUnmount: () => void; } const DRAWER_PORTAL_ID = 'drawerPortal' const DRAWER_PORTAL_WRAPPER_ID = 'drawerPortalWrapper' const DrawerWrapper: React.FC = ({ onMount, onUnmount }) => { useEffect(() => { onMount() return onUnmount }, []) return ( ) } const getOrCreatePortalContainer = (id: string) => { let container = document.getElementById(id) if (!container) { container = window.document.createElement('div') container.id = id window.document.body.appendChild(container) } return container } /** * Shows all of its children in a Drawer on the right. * Instead of rendering it's own {@link Drawer} component it reuses * the global Drawer via React Portal. * * ### Usage * * ``` * import { DrawerPortal } from 'adminjs' * ``` * * @component * @subcategory Application */ export const DrawerPortal: React.FC = ({ children, width }) => { const [drawerElement, setDrawerElement] = useState(document.getElementById(DRAWER_PORTAL_ID)) const { from = null } = useSelector((state) => state.router) const dispatch = useDispatch() const handleDrawerMount = () => { dispatch(setDrawerPreRoute({ previousRoute: from })) setDrawerElement(document.getElementById(DRAWER_PORTAL_ID)) } const handleDrawerUnmount = () => { dispatch(setDrawerPreRoute({ previousRoute: null })) } useEffect(() => { const innerWrapperElement = getOrCreatePortalContainer(DRAWER_PORTAL_WRAPPER_ID) if (!drawerElement && window) { const drawerRoot = createRoot(innerWrapperElement) drawerRoot.render() } return () => { const innerWrapper = document.getElementById(DRAWER_PORTAL_WRAPPER_ID) if (innerWrapper) document.body.removeChild(innerWrapper) } }, []) useEffect(() => { if (drawerElement) { drawerElement.classList.remove('hidden') if (width) { drawerElement.style.width = Array.isArray(width) ? width[0].toString() : width.toString() } return (): void => { drawerElement.style.width = DEFAULT_DRAWER_WIDTH drawerElement.classList.add('hidden') drawerElement.setAttribute('data-css', 'drawer-element') } } return () => undefined }, [drawerElement]) if (!drawerElement) { return null } return createPortal( children, drawerElement, ) } export default DrawerPortal ================================================ FILE: src/frontend/components/app/error-boundary.tsx ================================================ import React, { ReactNode } from 'react' import { Text, MessageBox } from '@adminjs/design-system' import { useTranslation } from '../../hooks/index.js' type State = { error: any; } const ErrorMessage: React.FC = ({ error }) => { const { translateMessage } = useTranslation() return ( {error.toString()} {translateMessage('seeConsoleForMore')} ) } export class ErrorBoundary extends React.Component { constructor(props) { super(props) this.state = { error: null, } } componentDidCatch(error): void { this.setState({ error }) } render(): ReactNode { const { children } = this.props const { error } = this.state if (error !== null) { return () } return children || null } } export default ErrorBoundary ================================================ FILE: src/frontend/components/app/error-message.tsx ================================================ import React, { ReactNode } from 'react' import { InfoBox, MessageBox, Text } from '@adminjs/design-system' import { useTranslation } from '../../hooks/index.js' /** * @memberof ErrorMessageBox * @alias ErrorMessageBoxProps */ export type ErrorMessageBoxProps = { title: string; children: ReactNode; testId?: string; } /** * @class * Prints error message * * @component * @private * @example * return ( * *

Text below the title

*
* ) */ const ErrorMessageBox: React.FC = (props) => { const { children, title, testId } = props return ( {children} ) } const NoResourceError: React.FC<{ resourceId: string }> = (props) => { const { resourceId } = props const { translateMessage } = useTranslation() return ( {translateMessage('error404Resource', resourceId, { resourceId })} ) } const NoActionError: React.FC<{ resourceId: string; actionName: string }> = (props) => { const { resourceId, actionName } = props const { translateMessage } = useTranslation() return ( {translateMessage('error404Action', resourceId, { resourceId, actionName })} ) } const NoRecordError: React.FC<{ resourceId: string; recordId: string; }> = (props) => { const { resourceId, recordId } = props const { translateMessage } = useTranslation() return ( {translateMessage('error404Record', resourceId, { resourceId, recordId })} ) } export { NoResourceError, NoActionError, NoRecordError, ErrorMessageBox, ErrorMessageBox as default, } ================================================ FILE: src/frontend/components/app/filter-drawer.tsx ================================================ import { Box, Button, Drawer, DrawerContent, DrawerFooter, H3, Icon } from '@adminjs/design-system' import isNil from 'lodash/isNil.js' import pickBy from 'lodash/pickBy.js' import React, { FormEventHandler, useEffect, useRef, useState } from 'react' import { useParams } from 'react-router-dom' import allowOverride from '../../hoc/allow-override.js' import { useTranslation } from '../../hooks/index.js' import { useFilterDrawer } from '../../hooks/use-filter-drawer.js' import { useQueryParams } from '../../hooks/use-query-params.js' import { RecordJSON, ResourceJSON } from '../../interfaces/index.js' import { getResourceElementCss } from '../../utils/index.js' import BasePropertyComponent from '../property-type/index.js' export type FilterProps = { resource: ResourceJSON } type MatchProps = { resourceId: string } const FilterDrawer: React.FC = (props) => { const { resource } = props const properties = resource.filterProperties const [filter, setFilter] = useState>({}) const params = useParams() const { translateButton, translateLabel } = useTranslation() const initialLoad = useRef(true) const { isVisible, toggleFilter } = useFilterDrawer() const { storeParams, clearParams, filters } = useQueryParams() useEffect(() => { if (initialLoad.current) { initialLoad.current = false } else { setFilter({}) } }, [params.resourceId]) const handleSubmit: FormEventHandler = (event) => { event.preventDefault() storeParams({ filters: pickBy(filter, (v) => !isNil(v)), page: '1' }) } const handleReset: FormEventHandler = (event) => { event.preventDefault() clearParams('filters') setFilter({}) } useEffect(() => { if (filters) { setFilter(filters) } }, [filters]) const handleChange = (propertyName: string | RecordJSON, value: any): void => { if ((propertyName as RecordJSON).params) { throw new Error('you can not pass RecordJSON to filters') } setFilter({ ...filter, [propertyName as string]: typeof value === 'string' && !value.length ? undefined : value, }) } const contentTag = getResourceElementCss(resource.id, 'filter-drawer') const cssContent = getResourceElementCss(resource.id, 'filter-drawer-content') const cssFooter = getResourceElementCss(resource.id, 'filter-drawer-footer') const cssButtonApply = getResourceElementCss(resource.id, 'filter-drawer-button-apply') const cssButtonReset = getResourceElementCss(resource.id, 'filter-drawer-button-reset') return (

{translateLabel('filters', resource.id)}

{properties.map((property) => ( ))}
) } const OverridableFilterDrawer = allowOverride(FilterDrawer, 'FilterDrawer') export { OverridableFilterDrawer as default, OverridableFilterDrawer as FilterDrawer, FilterDrawer as OriginalFilterDrawer, } ================================================ FILE: src/frontend/components/app/footer.tsx ================================================ import React from 'react' import allowOverride from '../../hoc/allow-override.js' const Footer: React.FC = () => null const OverridableFooter = allowOverride(Footer, 'Footer') export { OverridableFooter as default, OverridableFooter as Footer, Footer as OriginalFooter, } ================================================ FILE: src/frontend/components/app/index.ts ================================================ export * from './action-button/index.js' export * from './action-header/index.js' export * from './admin-modal.js' export * from './app-loader.js' export * from './auth-background-component.js' export * from './base-action-component.js' export * from './breadcrumbs.js' export * from './default-dashboard.js' export * from './drawer-portal.js' export * from './error-boundary.js' export * from './error-message.js' export * from './filter-drawer.js' export * from './logged-in.js' export * from './notice.js' export * from './records-table/index.js' export * from './sidebar/index.js' export * from './sort-link.js' export { default as SortLink } from './sort-link.js' export * from './top-bar.js' export * from './version.js' export * from './footer.js' ================================================ FILE: src/frontend/components/app/language-select/index.ts ================================================ export * from './language-select.js' ================================================ FILE: src/frontend/components/app/language-select/language-select.tsx ================================================ import { Box, Button, DropDown, DropDownItem, DropDownMenu, DropDownTrigger, Icon, } from '@adminjs/design-system' import React, { FC, useMemo } from 'react' import { useTranslation } from '../../../hooks/index.js' const LanguageSelect: FC = () => { const { i18n: { language, options: { supportedLngs }, changeLanguage, }, translateComponent, } = useTranslation() const availableLanguages: readonly string[] = useMemo( () => (supportedLngs ? supportedLngs.filter((lang) => lang !== 'cimode') : []), [supportedLngs], ) if (availableLanguages.length <= 1) { return null } return ( {availableLanguages.map((lang) => ( { changeLanguage(lang) }} > {translateComponent(`LanguageSelector.availableLanguages.${lang}`, { defaultValue: lang })} ))} ) } export default LanguageSelect ================================================ FILE: src/frontend/components/app/logged-in.tsx ================================================ import React from 'react' import { CurrentUserNav, Box, CurrentUserNavProps } from '@adminjs/design-system' import { CurrentAdmin } from '../../../current-admin.interface.js' import { useTranslation } from '../../hooks/index.js' import allowOverride from '../../hoc/allow-override.js' export type LoggedInProps = { session: CurrentAdmin; paths: { logoutPath: string; }; } const LoggedIn: React.FC = (props) => { const { session, paths } = props const { translateButton } = useTranslation() const dropActions: CurrentUserNavProps['dropActions'] = [{ label: translateButton('logout'), onClick: (event: Event): void => { event.preventDefault() window.location.href = paths.logoutPath }, icon: 'LogOut', }] return ( ) } const OverridableLoggedIn = allowOverride(LoggedIn, 'LoggedIn') export { OverridableLoggedIn as default, OverridableLoggedIn as LoggedIn, LoggedIn as OriginalLoggedIn, } ================================================ FILE: src/frontend/components/app/notice.tsx ================================================ import { Box, MessageBox, MessageBoxProps } from '@adminjs/design-system' import React, { useEffect, useMemo, useRef, useState } from 'react' import { connect } from 'react-redux' import allowOverride from '../../hoc/allow-override.js' import { useTranslation } from '../../hooks/index.js' import { dropNotice } from '../../store/actions/drop-notice.js' import { SetNoticeProgress, setNoticeProgress } from '../../store/actions/set-notice-progress.js' import { ReduxState, type NoticeMessageInState } from '../../store/index.js' const TIME_TO_DISAPPEAR = 3 export type NotifyProgress = (options: SetNoticeProgress) => void export type NoticeElementProps = { notice: NoticeMessageInState drop: () => void notifyProgress: NotifyProgress } export type NoticeElementState = { progress: number } const NoticeElement: React.FC = (props) => { const { drop, notice, notifyProgress } = props const [progress, setProgress] = useState(0) const intervalRef = useRef() const { tm, i18n: { language }, } = useTranslation() const message = useMemo( () => tm(notice.message, notice.resourceId, notice.options), [notice.id, language], ) const variant: MessageBoxProps['variant'] = notice.type === 'error' ? 'danger' : notice.type ?? 'info' useEffect(() => { intervalRef.current = window.setInterval(() => { const _progress = progress + 100 / TIME_TO_DISAPPEAR notifyProgress({ noticeId: notice.id, progress }) setProgress(_progress) return _progress }, 1000) return () => { clearInterval(intervalRef.current) } }, [notice]) useEffect(() => { if (progress >= 100) { drop() } }, [drop, progress]) return ( {notice.body} ) } type NoticeBoxPropsFromState = { notices: Array } type NoticeBoxDispatchFromState = { drop: (noticeId: string) => void notifyProgress: NotifyProgress } const NoticeBox: React.FC = (props) => { const { drop, notices, notifyProgress } = props if (!notices.length) return null return ( {notices.map((notice) => ( drop(notice.id)} notifyProgress={notifyProgress} /> ))} ) } const mapStateToProps = (state: ReduxState): NoticeBoxPropsFromState => ({ notices: state.notices, }) const mapDispatchToProps = (dispatch): NoticeBoxDispatchFromState => ({ drop: (noticeId: string): void => dispatch(dropNotice(noticeId)), notifyProgress: ({ noticeId, progress }) => dispatch(setNoticeProgress({ noticeId, progress })), }) const ConnectedNoticeBox = connect(mapStateToProps, mapDispatchToProps)(NoticeBox) const OverridableConnectedNoticeBox = allowOverride(ConnectedNoticeBox, 'NoticeBox') export { OverridableConnectedNoticeBox as NoticeBox, OverridableConnectedNoticeBox as default, ConnectedNoticeBox as OriginalNoticeBox, } ================================================ FILE: src/frontend/components/app/records-table/index.ts ================================================ export * from './no-records.js' export * from './property-header.js' export * from './record-in-list.js' export * from './records-table-header.js' export * from './records-table.js' export * from './selected-records.js' ================================================ FILE: src/frontend/components/app/records-table/no-records.tsx ================================================ import React from 'react' import { Text, Button, Icon, InfoBox } from '@adminjs/design-system' import { ResourceJSON } from '../../../interfaces/index.js' import { useTranslation } from '../../../hooks/index.js' import allowOverride from '../../../hoc/allow-override.js' import ActionButton from '../action-button/action-button.js' export type NoRecordsProps = { resource: ResourceJSON; } const NoRecordsOriginal: React.FC = (props) => { const { resource } = props const { translateButton, translateMessage } = useTranslation() const canCreate = resource.resourceActions.find((a) => a.name === 'new') return ( {translateMessage('noRecordsInResource', resource.id)} {canCreate && ( )} ) } // This hack prevents rollup from throwing an error const NoRecords = allowOverride(NoRecordsOriginal, 'NoRecords') export { NoRecords, NoRecordsOriginal as OriginalNoRecords } export default NoRecords ================================================ FILE: src/frontend/components/app/records-table/property-header.spec.tsx ================================================ import { render, RenderResult } from '@testing-library/react' import { expect } from 'chai' import { factory } from 'factory-girl' import React from 'react' import TestContextProvider from '../../spec/test-context-provider.js' import PropertyHeader from './property-header.js' import { PropertyJSON } from '../../../interfaces/index.js' import '../../spec/initialize-translations.js' import '../../spec/property-json.factory.js' const renderSubject = ( property: PropertyJSON, sortBy: string, sortDirection: 'desc' | 'asc', ): RenderResult => render(
, ) describe('', function () { const direction = 'desc' const sortBy = 'otherProperty' let property: PropertyJSON beforeEach(async function () { property = await factory.build('PropertyJSON', { isSortable: true }) factory.resetSequence('property.label') }) afterEach(function () { factory.resetSequence('property.label') }) context('render not selected but searchable field', function () { it('renders a client side translated label', async function () { const { findByText } = renderSubject(property, sortBy, direction) const translatedLabel = 'Some Property 2' const label = await findByText(translatedLabel) expect(label).to.exist }) it('wraps it within a link with an opposite direction', function () { const { container } = renderSubject(property, sortBy, direction) const a = container.querySelector('a') const href = (a && a.getAttribute('href')) || '' const query = new URLSearchParams(href.replace('/?', '')) expect(query.get('direction')).to.equal('asc') expect(query.get('sortBy')).to.equal(property.path) }) it('doesn\'t render a sort indicator', function () { const { container } = renderSubject(property, sortBy, direction) expect(container.querySelector('svg')).to.be.null }) }) }) ================================================ FILE: src/frontend/components/app/records-table/property-header.tsx ================================================ import React from 'react' import { TableCell } from '@adminjs/design-system' import { BasePropertyJSON } from '../../../interfaces/index.js' import SortLink from '../sort-link.js' import allowOverride from '../../../hoc/allow-override.js' import { useTranslation } from '../../../hooks/index.js' export type PropertyHeaderProps = { property: BasePropertyJSON; /** * Property which should be treated as main property. */ titleProperty: BasePropertyJSON; /** * currently selected direction. Either 'asc' or 'desc'. */ direction?: 'asc' | 'desc'; /** * currently selected field by which list is sorted. */ sortBy?: string; display?: string | Array; } const PropertyHeader: React.FC = (props) => { const { property, titleProperty, display } = props const { translateProperty } = useTranslation() const isMain = property.propertyPath === titleProperty.propertyPath return ( {property.isSortable ? : translateProperty(property.label, property.resourceId)} ) } const OverridablePropertyHeader = allowOverride(PropertyHeader, 'PropertyHeader') export { OverridablePropertyHeader as default, OverridablePropertyHeader as PropertyHeader, PropertyHeader as OriginalPropertyHeader, } ================================================ FILE: src/frontend/components/app/records-table/record-in-list.tsx ================================================ import React, { useState, useEffect, useCallback } from 'react' import { useNavigate, useLocation } from 'react-router' import { Placeholder, TableRow, TableCell, CheckBox, ButtonGroup, } from '@adminjs/design-system' import BasePropertyComponent from '../../property-type/index.js' import { ActionJSON, buildActionClickHandler, RecordJSON, ResourceJSON } from '../../../interfaces/index.js' import { display } from './utils/display.js' import { ActionResponse, RecordActionResponse } from '../../../../backend/actions/action.interface.js' import mergeRecordResponse from '../../../hooks/use-record/merge-record-response.js' import { useActionResponseHandler, useTranslation, useModal } from '../../../hooks/index.js' import { actionsToButtonGroup } from '../action-header/actions-to-button-group.js' import allowOverride from '../../../hoc/allow-override.js' import { getResourceElementCss } from '../../../utils/index.js' export type RecordInListProps = { resource: ResourceJSON; record: RecordJSON; actionPerformed?: (action: ActionResponse) => any; isLoading?: boolean; onSelect?: (record: RecordJSON) => void; isSelected?: boolean; } const RecordInList: React.FC = (props) => { const { resource, record: recordFromProps, actionPerformed, isLoading, onSelect, isSelected, } = props const [record, setRecord] = useState(recordFromProps) const navigate = useNavigate() const location = useLocation() const translateFunctions = useTranslation() const modalFunctions = useModal() const handleActionCallback = useCallback((actionResponse: ActionResponse) => { if (actionResponse.record && !actionResponse.redirectUrl) { setRecord(mergeRecordResponse(record, actionResponse as RecordActionResponse)) } else if (actionPerformed) { actionPerformed(actionResponse) } }, [actionPerformed, record]) const actionResponseHandler = useActionResponseHandler(handleActionCallback) useEffect(() => { setRecord(recordFromProps) }, [recordFromProps]) const { recordActions } = record const show = record.recordActions.find(({ name }) => name === 'show') const edit = record.recordActions.find(({ name }) => name === 'edit') const action = show || edit const handleClick = (event): void => { const targetTagName = (event.target as HTMLElement).tagName.toLowerCase() if (action && targetTagName !== 'a' && targetTagName !== 'button' && targetTagName !== 'svg' ) { buildActionClickHandler({ action, params: { resourceId: resource.id, recordId: record.id }, actionResponseHandler, navigate, location, translateFunctions, modalFunctions, })(event) } } const actionParams = { resourceId: resource.id, recordId: record.id } const handleActionClick = (event, sourceAction: ActionJSON): void | Promise => ( buildActionClickHandler({ action: sourceAction, params: actionParams, actionResponseHandler, navigate, location, translateFunctions, modalFunctions, })(event) ) const buttons = [{ icon: 'MoreHorizontal', variant: 'light' as const, label: undefined, 'data-testid': 'actions-dropdown', buttons: actionsToButtonGroup({ actions: recordActions, params: actionParams, handleClick: handleActionClick, translateFunctions, modalFunctions, }), }] const contentTag = getResourceElementCss(resource.id, 'table-row') return ( {onSelect && record.bulkActions.length ? ( onSelect(record)} checked={isSelected} /> ) : null} {resource.listProperties.map((property) => { const cellTag = `${resource.id}-${property.name}-table-cell` return ( {isLoading ? ( ) : ( )} ) })} {recordActions.length ? ( ) : null} ) } const OverridableRecordInList = allowOverride(RecordInList, 'RecordInList') export { OverridableRecordInList as default, OverridableRecordInList as RecordInList, RecordInList as OriginalRecordInList, } ================================================ FILE: src/frontend/components/app/records-table/records-table-header.spec.tsx ================================================ import { render } from '@testing-library/react' import { expect } from 'chai' import { factory } from 'factory-girl' import React from 'react' import TestContextProvider from '../../spec/test-context-provider.js' import RecordsTableHeader from './records-table-header.js' import { PropertyJSON } from '../../../interfaces/index.js' import '../../spec/initialize-translations.js' import '../../spec/property-json.factory.js' describe('', function () { it('renders columns for selected properties and actions', async function () { const property = await factory.build('PropertyJSON', { isSortable: true }) const { container } = render(
, ) expect(container.getElementsByTagName('td')).to.have.lengthOf(3) }) }) ================================================ FILE: src/frontend/components/app/records-table/records-table-header.tsx ================================================ import { CheckBox, TableCell, TableHead, TableRow } from '@adminjs/design-system' import React from 'react' import allowOverride from '../../../hoc/allow-override.js' import { BasePropertyJSON } from '../../../interfaces/index.js' import { getResourceElementCss } from '../../../utils/index.js' import PropertyHeader from './property-header.js' import { display } from './utils/display.js' /** * @memberof RecordsTableHeader * @alias RecordsTableHeaderProps */ export type RecordsTableHeaderProps = { /** * Property which should be treated as a Title Property */ titleProperty: BasePropertyJSON; /** * All properties which should be presented */ properties: Array; /** * Name of the property which should be marked as currently sorted by */ sortBy?: string; /** * Sort direction */ direction?: 'asc' | 'desc'; /** * Handler function invoked when checkbox is clicked. If given extra column * with checkbox will be rendered */ onSelectAll?: () => any; /** * Indicates if "bulk" checkbox should be checked */ selectedAll?: boolean; } /** * Prints `thead` section for table with records. * * ``` * import { RecordsTableHeader } from 'adminjs' * ``` * * @component * @subcategory Application * @example List with 2 properties * const properties = [{ * label: 'First Name', * name: 'firstName', * isSortable: true, * }, { * label: 'Last Name', * name: 'lastName', * }] * return ( * * * * * * John * Doe * * * * Max * Kodaly * * * *
*
* ) */ const RecordsTableHeader: React.FC = (props) => { const { titleProperty, properties, sortBy, direction, onSelectAll, selectedAll } = props const contentTag = getResourceElementCss(titleProperty.resourceId, 'table-head') const rowTag = `${titleProperty.resourceId}-table-head-row` const checkboxCss = `${titleProperty.resourceId}-checkbox-table-cell` return ( {onSelectAll ? ( onSelectAll()} checked={selectedAll} /> ) : null} {properties.map((property) => ( ))} ) } const OverridableRecordsTableHeader = allowOverride(RecordsTableHeader, 'RecordsTableHeader') export { OverridableRecordsTableHeader as default, OverridableRecordsTableHeader as RecordsTableHeader, RecordsTableHeader as OriginalRecordsTableHeader, } ================================================ FILE: src/frontend/components/app/records-table/records-table.spec.tsx ================================================ import React from 'react' import { render, RenderResult } from '@testing-library/react' import sinon from 'sinon' import { expect } from 'chai' import { factory } from 'factory-girl' import { Provider } from 'react-redux' import { RecordsTable, RecordsTableProps } from './records-table.js' import TestContextProvider from '../../spec/test-context-provider.js' import { ActionJSON, ResourceJSON, RecordJSON, PropertyJSON } from '../../../interfaces/index.js' import createStore from '../../../store/store.js' import '../../spec/resource-json.factory.js' import '../../spec/record-json.factory.js' import '../../spec/property-json.factory.js' type StubsType = { onSelect: sinon.SinonStub; onSelectAll: sinon.SinonStub; } const renderSubject = (props: Omit): RenderResult & StubsType => { const onSelect = sinon.stub() const onSelectAll = sinon.stub() const renderResult = render( , ) return { ...renderResult, onSelect, onSelectAll } } describe('', function () { let properties: Array let resource: ResourceJSON let records: Array let container: RenderResult['container'] beforeEach(async function () { const name = await factory.build('PropertyJSON', { path: 'path', isTitle: true }) properties = [ await factory.build('PropertyJSON', { path: 'id', isId: true }), name, await factory.build('PropertyJSON', { path: 'surname' }), ] resource = await factory.build('ResourceJSON', { listProperties: properties, titleProperty: name, }) }) afterEach(function () { sinon.restore() }) context('10 records are given without bulk and list actions', function () { beforeEach(async function () { records = await factory.buildMany('RecordJSON', 10, { params: { id: factory.sequence('record.id'), name: factory.sequence('record.name', (n) => `name ${n}`), surname: factory.sequence('record.surname', (n) => `surname ${n}`), }, }); ({ container } = renderSubject({ resource, records, selectedRecords: [] })) }) it('renders each record as a separate tag', function () { expect(container.querySelectorAll('tbody > tr')).to.have.lengthOf(10) }) it('does not render any link in the record rows', function () { expect(container.querySelectorAll('tbody > tr a')).to.have.lengthOf(0) }) it('does not render checkbox for selecting particular record', function () { expect(container.querySelectorAll('tbody > tr input')).to.have.lengthOf(0) }) }) context('10 records are given with bulk delete and show actions', function () { beforeEach(async function () { records = await factory.buildMany('RecordJSON', 10, { params: { id: factory.sequence('record.id'), name: factory.sequence('record.name', (n) => `name ${n}`), surname: factory.sequence('record.surname', (n) => `surname ${n}`), }, recordActions: [await factory.build('ActionJSON', { name: 'show', actionType: 'record', })], bulkActions: [await factory.build('ActionJSON', { name: 'bulkDelete', actionType: 'bulk', })], }); ({ container } = renderSubject({ resource, records, selectedRecords: [] })) }) it('renders input checkbox for selecting many records', function () { expect(container.querySelectorAll('tbody td:first-child input')).to.have.lengthOf(10) }) }) }) ================================================ FILE: src/frontend/components/app/records-table/records-table.tsx ================================================ import { Loader, Table, TableBody } from '@adminjs/design-system' import React from 'react' import { ActionResponse } from '../../../../backend/actions/action.interface.js' import allowOverride from '../../../hoc/allow-override.js' import { RecordJSON, ResourceJSON } from '../../../interfaces/index.js' import { getResourceElementCss } from '../../../utils/index.js' import NoRecords from './no-records.js' import RecordInList from './record-in-list.js' import RecordsTableHeader from './records-table-header.js' import SelectedRecords from './selected-records.js' /** * @alias RecordsTableProps * @memberof RecordsTable */ export type RecordsTableProps = { /** * Resource which type records are rendered. Base on that we define which columns should be seen. */ resource: ResourceJSON; /** * Array of records seen in the table */ records: Array; /** * Handler function invoked when someone performs action without component on a given record. * Action without component is a `delete` action - you might want to refresh the list after that */ actionPerformed?: (response: ActionResponse) => any; /** default sort by column */ sortBy?: string; /** sort direction */ direction?: 'asc' | 'desc'; /** indicates if the table should be in loading state */ isLoading?: boolean; /** list of selected records */ selectedRecords?: Array; /** handler function triggered when record is selected */ onSelect?: (record: RecordJSON) => any; /** handler function triggered when all items are selected */ onSelectAll?: () => any; } /** * @classdesc * Renders an entire records table. To fill the data you might need: * * - {@link useRecords} and * - {@link useSelectedRecords} hooks * * so make sure to see at the documentation pages for both of them * * @component * @class * @hideconstructor * @subcategory Application */ const RecordsTable: React.FC = (props) => { const { resource, records, actionPerformed, sortBy, direction, isLoading, onSelect, selectedRecords, onSelectAll, } = props if (!records.length) { if (isLoading) { return () } return () } const selectedAll = selectedRecords && !!records.find((record) => ( selectedRecords.find((selected) => selected.id === record.id) )) const recordsHaveBulkAction = !!records.find((record) => record.bulkActions.length) const contentTag = getResourceElementCss(resource.id, 'table') const selectedTag = getResourceElementCss(resource.id, 'table-selected-records') const bodyTag = getResourceElementCss(resource.id, 'table-body') return ( {records.map((record) => ( selected.id === record.id) } /> ))}
) } const OverridableRecordsTable = allowOverride(RecordsTable, 'RecordsTable') export { OverridableRecordsTable as default, OverridableRecordsTable as RecordsTable, RecordsTable as OriginalRecordsTable, } ================================================ FILE: src/frontend/components/app/records-table/selected-records.tsx ================================================ import React from 'react' import { TableCaption, Title, ButtonGroup, Box } from '@adminjs/design-system' import { useNavigate, useLocation } from 'react-router' import { ActionJSON, buildActionClickHandler, RecordJSON, ResourceJSON } from '../../../interfaces/index.js' import getBulkActionsFromRecords from './utils/get-bulk-actions-from-records.js' import { useActionResponseHandler, useTranslation, useModal } from '../../../hooks/index.js' import { actionsToButtonGroup } from '../action-header/actions-to-button-group.js' import allowOverride from '../../../hoc/allow-override.js' import { getResourceElementCss } from '../../../utils/index.js' type SelectedRecordsProps = { resource: ResourceJSON; selectedRecords?: Array; } const SelectedRecords: React.FC = (props) => { const { resource, selectedRecords } = props const translateFunctions = useTranslation() const { translateLabel } = translateFunctions const navigate = useNavigate() const location = useLocation() const actionResponseHandler = useActionResponseHandler() const modalFunctions = useModal() if (!selectedRecords || !selectedRecords.length) { return null } const params = { resourceId: resource.id, recordIds: selectedRecords.map((records) => records.id), } const handleActionClick = (event, sourceAction: ActionJSON): void => ( buildActionClickHandler({ action: sourceAction, params, actionResponseHandler, navigate, location, translateFunctions, modalFunctions, })(event) ) const bulkButtons = actionsToButtonGroup({ actions: getBulkActionsFromRecords(selectedRecords), params, handleClick: handleActionClick, translateFunctions, modalFunctions, }) const contentTag = getResourceElementCss(resource.id, 'table-caption') return ( {translateLabel('selectedRecords', resource.id, { selected: selectedRecords.length })} ) } const OverridableSelectedRecords = allowOverride(SelectedRecords, 'SelectedRecords') export { OverridableSelectedRecords as default, OverridableSelectedRecords as SelectedRecords, SelectedRecords as OriginalSelectedRecords, } ================================================ FILE: src/frontend/components/app/records-table/utils/display.tsx ================================================ export const display = (isTitle: boolean): Array => [ isTitle ? 'table-cell' : 'none', isTitle ? 'table-cell' : 'none', 'table-cell', 'table-cell', ] ================================================ FILE: src/frontend/components/app/records-table/utils/get-bulk-actions-from-records.spec.ts ================================================ import { expect } from 'chai' import { factory } from 'factory-girl' import '../../../spec/record-json.factory.js' import '../../../spec/action-json.factory.js' import { RecordJSON, ActionJSON } from '../../../../interfaces/index.js' import getBulkActionsFromRecords from './get-bulk-actions-from-records.js' describe('getBulkActionsFromRecords', function () { context('records with 2 bulk actions', function () { let actions: Array = [] let records: Array it('returns array of uniq bulk actions', async function () { actions = [ await factory.build('ActionJSON', { name: 'bulkAction1', actionType: 'bulk', }), await factory.build('ActionJSON', { name: 'bulkAction2', actionType: 'bulk', }), ] records = await factory.buildMany('RecordJSON', 5, { bulkActions: actions, }) expect(getBulkActionsFromRecords(records)).to.deep.equal(actions) }) }) }) ================================================ FILE: src/frontend/components/app/records-table/utils/get-bulk-actions-from-records.ts ================================================ import { ActionJSON, RecordJSON } from '../../../../interfaces/index.js' const getBulkActionsFromRecords = (records: Array): Array => { const actions = Object.values(records.reduce((memo, record) => ({ ...memo, ...record.bulkActions.reduce((actionsMemo, action) => ({ ...actionsMemo, [action.name]: action, }), {} as Record), }), {} as Record)) return actions } export default getBulkActionsFromRecords ================================================ FILE: src/frontend/components/app/sidebar/index.ts ================================================ import Sidebar from './sidebar.js' export * from './sidebar-resource-section.js' export { Sidebar } ================================================ FILE: src/frontend/components/app/sidebar/sidebar-branding.tsx ================================================ import React from 'react' import { Link } from 'react-router-dom' import { cssClass, themeGet } from '@adminjs/design-system' import { styled } from '@adminjs/design-system/styled-components' import ViewHelpers from '../../../../backend/utils/view-helpers/view-helpers.js' import { BrandingOptions } from '../../../../adminjs-options.interface.js' import allowOverride from '../../../hoc/allow-override.js' type Props = { branding: BrandingOptions; } export const StyledLogo: any = styled(Link)` text-align: center; display: flex; align-content: center; justify-content: center; flex-shrink: 0; padding: ${themeGet('space', 'lg')} ${themeGet('space', 'xxl')} ${themeGet('space', 'xxl')}; text-decoration: none; & > h1 { text-decoration: none; font-weight: ${themeGet('fontWeights', 'bolder')}; font-size: ${themeGet('fontWeights', 'bolder')}; color: ${themeGet('colors', 'grey80')}; font-size: ${themeGet('fontSizes', 'xl')}; line-height: ${themeGet('lineHeights', 'xl')}; } & > img { max-width: 170px; } &:hover h1 { color: ${themeGet('colors', 'primary100')}; } ` const h = new ViewHelpers() const SidebarBranding: React.FC = (props) => { const { branding } = props const { logo, companyName } = branding return ( {logo ? ( {companyName} ) :

{companyName}

}
) } export default allowOverride(SidebarBranding, 'SidebarBranding') export { SidebarBranding as OriginalSidebarBranding, SidebarBranding } ================================================ FILE: src/frontend/components/app/sidebar/sidebar-footer.tsx ================================================ import React from 'react' import { Box, MadeWithLove } from '@adminjs/design-system' import { useSelector } from 'react-redux' import { BrandingOptions } from '../../../../adminjs-options.interface.js' import allowOverride from '../../../hoc/allow-override.js' import { ReduxState } from '../../../store/index.js' const SidebarFooter: React.FC = () => { const branding = useSelector((state) => state.branding) return ( {branding.withMadeWithLove && } ) } export default allowOverride(SidebarFooter, 'SidebarFooter') export { SidebarFooter as OriginalSidebarFooter, SidebarFooter } ================================================ FILE: src/frontend/components/app/sidebar/sidebar-pages.tsx ================================================ import React from 'react' import { Navigation, NavigationElementProps } from '@adminjs/design-system' import { useNavigate, useLocation } from 'react-router' import ViewHelpers from '../../../../backend/utils/view-helpers/view-helpers.js' import { useTranslation } from '../../../hooks/use-translation.js' import { ReduxState } from '../../../store/store.js' import allowOverride from '../../../hoc/allow-override.js' type Props = { pages?: ReduxState['pages']; } const h = new ViewHelpers() const SidebarPages: React.FC = (props) => { const { pages } = props const { translateLabel, translatePage } = useTranslation() const location = useLocation() const navigate = useNavigate() if (!pages || !pages.length) { return null } const isActive = (page): boolean => ( !!location.pathname.match(`/pages/${page.name}`) ) const elements: Array = pages.map((page) => ({ id: page.name, label: translatePage(page.name), isSelected: isActive(page), icon: page.icon, href: h.pageUrl(page.name), onClick: (event, element): void => { event.preventDefault() if (element.href) { navigate(element.href) } }, })) return ( ) } export default allowOverride(SidebarPages, 'SidebarPages') export { SidebarPages as OriginalSidebarPages, SidebarPages } ================================================ FILE: src/frontend/components/app/sidebar/sidebar-resource-section.tsx ================================================ import React, { FC } from 'react' import { Navigation } from '@adminjs/design-system' import { useTranslation } from '../../../hooks/use-translation.js' import { ResourceJSON } from '../../../interfaces/index.js' import allowOverride from '../../../hoc/allow-override.js' import { useNavigationResources } from '../../../hooks/index.js' /** * @alias SidebarResourceSectionProps * @memberof SidebarResourceSection */ export type SidebarResourceSectionProps = { /** List of the resources which should be rendered */ resources: Array; } /** * Groups resources by sections and renders the list in {@link Sidebar} * * ### Usage * * ``` * import { SidebarResourceSection } from 'adminjs` * ``` * * @component * @subcategory Application * @name SidebarResourceSection */ const SidebarResourceSectionOriginal: FC = ({ resources }) => { const elements = useNavigationResources(resources) const { translateLabel } = useTranslation() return ( ) } // Rollup cannot handle type exports well - that is why we need to do this hack with // exporting default and named SidebarResourceSection const SidebarResourceSection = allowOverride(SidebarResourceSectionOriginal, 'SidebarResourceSection') export { SidebarResourceSection, SidebarResourceSectionOriginal as OriginalSidebarResourceSection } export default SidebarResourceSection ================================================ FILE: src/frontend/components/app/sidebar/sidebar.tsx ================================================ import { Box, BoxProps, cssClass } from '@adminjs/design-system' import { styled } from '@adminjs/design-system/styled-components' import React from 'react' import { useSelector } from 'react-redux' import allowOverride from '../../../hoc/allow-override.js' import { ReduxState } from '../../../store/store.js' import SidebarBranding from './sidebar-branding.js' import SidebarFooter from './sidebar-footer.js' import SidebarPages from './sidebar-pages.js' import SidebarResourceSection from './sidebar-resource-section.js' export const SIDEBAR_Z_INDEX = 50 type Props = { isVisible: boolean } const StyledSidebar = styled(Box)` top: 0; bottom: 0; overflow-y: auto; width: ${({ theme }) => theme.sizes.sidebarWidth}; border-right: ${({ theme }) => theme.borders.default}; display: flex; flex-direction: column; flex-shrink: 0; z-index: ${SIDEBAR_Z_INDEX}; background: ${({ theme }) => theme.colors.sidebar}; transition: left 0.25s ease-in-out; &.hidden { left: -${({ theme }) => theme.sizes.sidebarWidth}; } &.visible { left: 0; } ` StyledSidebar.defaultProps = { position: ['absolute', 'absolute', 'absolute', 'absolute', 'initial'], } const SidebarOriginal: React.FC = (props) => { const { isVisible } = props const branding = useSelector((state: ReduxState) => state.branding) const resources = useSelector((state: ReduxState) => state.resources) const pages = useSelector((state: ReduxState) => state.pages) return ( ) } const Sidebar = allowOverride(SidebarOriginal, 'Sidebar') export { Sidebar, SidebarOriginal as OriginalSidebar } export default Sidebar ================================================ FILE: src/frontend/components/app/sort-link.tsx ================================================ import React, { memo, useMemo } from 'react' import { useLocation, NavLink } from 'react-router-dom' import { Icon, cssClass } from '@adminjs/design-system' import { BasePropertyJSON } from '../../interfaces/index.js' import { useTranslation } from '../../hooks/index.js' export type SortLinkProps = { property: BasePropertyJSON; direction?: 'asc' | 'desc'; sortBy?: string; } const SortLink: React.FC = (props) => { const { sortBy, property, direction } = props const location = useLocation() const { translateProperty } = useTranslation() const isActive = useMemo(() => sortBy === property.propertyPath, [sortBy, property]) const query = new URLSearchParams(location.search) const oppositeDirection = (isActive && direction === 'asc') ? 'desc' : 'asc' const sortedByIcon = direction === 'asc' ? 'ChevronUp' : 'ChevronDown' query.set('direction', oppositeDirection) query.set('sortBy', property.propertyPath) return ( {translateProperty(property.label, property.resourceId)} {isActive && ()} ) } const checkSortProps = ( prevProps: Readonly, nextProps: Readonly, ) => (prevProps.direction === nextProps.direction && prevProps.property.propertyPath === nextProps.property.propertyPath && prevProps.sortBy === nextProps.sortBy && prevProps.property.resourceId === nextProps.property.resourceId) export default memo(SortLink, checkSortProps) ================================================ FILE: src/frontend/components/app/top-bar.tsx ================================================ import { Box, BoxProps, Icon, cssClass } from '@adminjs/design-system' import { styled } from '@adminjs/design-system/styled-components' import React from 'react' import { useSelector } from 'react-redux' import allowOverride from '../../hoc/allow-override.js' import { ReduxState } from '../../store/store.js' import LanguageSelect from './language-select/language-select.js' import LoggedIn from './logged-in.js' import Version from './version.js' const NavBar = styled(Box)` height: ${({ theme }) => theme.sizes.navbarHeight}; border-bottom: ${({ theme }) => theme.borders.default}; background: ${({ theme }) => theme.colors.container}; display: flex; flex-direction: row; flex-shrink: 0; align-items: center; ` NavBar.defaultProps = { className: cssClass('NavBar'), } type Props = { toggleSidebar: () => void } const TopBar: React.FC = (props) => { const { toggleSidebar } = props const session = useSelector((state: ReduxState) => state.session) const paths = useSelector((state: ReduxState) => state.paths) const versions = useSelector((state: ReduxState) => state.versions) return ( {session && session.email ? : ''} ) } const OverridableTopbar = allowOverride(TopBar, 'TopBar') export { OverridableTopbar as TopBar, OverridableTopbar as default, TopBar as OriginalTopBar } ================================================ FILE: src/frontend/components/app/utils/discord-logo-svg.tsx ================================================ import React from 'react' const DiscordLogo: React.FC = () => ( ) export { DiscordLogo } export default DiscordLogo ================================================ FILE: src/frontend/components/app/utils/rocket-svg.tsx ================================================ import React from 'react' const RocketSVG: React.FC = () => ( ) export { RocketSVG } export default RocketSVG ================================================ FILE: src/frontend/components/app/version.tsx ================================================ import React from 'react' import { cssClass, Text, Box } from '@adminjs/design-system' import { styled } from '@adminjs/design-system/styled-components' import { VersionProps } from '../../../adminjs-options.interface.js' import { useTranslation } from '../../hooks/index.js' import allowOverride from '../../hoc/allow-override.js' export type Props = { versions: VersionProps; } const VersionItem = styled(Text)` padding: 12px 24px 12px 0; ` VersionItem.defaultProps = { display: ['none', 'block'], color: 'grey100', } const Version: React.FC = (props) => { const { versions } = props const { admin, app } = versions const { translateLabel } = useTranslation() return ( {admin && ( {translateLabel('adminVersion', { version: admin })} )} {app && ( {translateLabel('appVersion', { version: app })} )} ) } const OverridableVersion = allowOverride(Version, 'Version') export { OverridableVersion as default, OverridableVersion as Version, Version as OriginalVersion, } ================================================ FILE: src/frontend/components/application.tsx ================================================ /* eslint-disable react/no-children-prop */ import React, { useEffect, useState } from 'react' import { Routes, Route } from 'react-router-dom' import { Box, Overlay } from '@adminjs/design-system' import { useLocation } from 'react-router' import ViewHelpers from '../../backend/utils/view-helpers/view-helpers.js' import Sidebar, { SIDEBAR_Z_INDEX } from './app/sidebar/sidebar.js' import TopBar from './app/top-bar.js' import Notice from './app/notice.js' import allowOverride from '../hoc/allow-override.js' import { AdminModal as Modal } from './app/admin-modal.js' import { DashboardRoute, ResourceActionRoute, RecordActionRoute, PageRoute, BulkActionRoute, ResourceRoute, } from './routes/index.js' import useHistoryListen from '../hooks/use-history-listen.js' import { AuthenticationBackgroundComponent } from './app/auth-background-component.js' import { Footer } from './app/footer.js' const h = new ViewHelpers() const App: React.FC = () => { const [sidebarVisible, toggleSidebar] = useState(false) const location = useLocation() useHistoryListen() useEffect(() => { if (sidebarVisible) { toggleSidebar(false) } }, [location]) const resourceId = ':resourceId' const actionName = ':actionName' const recordId = ':recordId' const pageName = ':pageName' // Note: replaces are required so that record/resource/bulk actions urls // are relative to their parent route const dashboardUrl = h.dashboardUrl() const resourceUrl = h.resourceUrl({ resourceId }) const recordActionUrl = h .recordActionUrl({ resourceId, recordId, actionName }) .replace(resourceUrl, '').substring(1) const resourceActionUrl = h.resourceActionUrl({ resourceId, actionName }) .replace(resourceUrl, '').substring(1) const bulkActionUrl = h.bulkActionUrl({ resourceId, actionName }) .replace(resourceUrl, '').substring(1) const pageUrl = h.pageUrl(pageName) return ( {sidebarVisible ? ( toggleSidebar(!sidebarVisible)} zIndex={SIDEBAR_Z_INDEX - 1} /> ) : null} toggleSidebar(!sidebarVisible)} /> } /> } /> } /> } /> } /> } /> } />