From baa28f5d9e55ffa7f3ca880f869ed4e17bd7f559 Mon Sep 17 00:00:00 2001 From: wojtek-krysiak Date: Sat, 8 Aug 2020 13:33:24 +0200 Subject: [PATCH] feat: allow to setup theme base on the user fixes #511 --- .eslintrc.js | 1 + example-app/src/admin.options.js | 3 ++ src/admin-bro-options.interface.ts | 75 +++++++++++++++++++++------- src/admin-bro.ts | 15 ------ src/backend/utils/options-parser.ts | 59 ++++++++++++++++++++++ src/frontend/layout-template.spec.ts | 67 ++++++++++++++----------- src/frontend/layout-template.tsx | 28 ++++++----- src/frontend/login-template.spec.ts | 9 ++-- src/frontend/login-template.tsx | 18 +++++-- src/frontend/store/index.ts | 15 +++++- src/frontend/store/store.ts | 23 ++++++++- 11 files changed, 229 insertions(+), 84 deletions(-) create mode 100644 src/backend/utils/options-parser.ts diff --git a/.eslintrc.js b/.eslintrc.js index b0d080a0e..366dfe97d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -50,6 +50,7 @@ module.exports = { 'func-names': 'off', 'prefer-arrow-callback': 'off', 'import/no-extraneous-dependencies': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', }, }, { diff --git a/example-app/src/admin.options.js b/example-app/src/admin.options.js index d591e315a..5cee6b960 100644 --- a/example-app/src/admin.options.js +++ b/example-app/src/admin.options.js @@ -32,6 +32,9 @@ const options = { admin: true, app: process.env.npm_package_version, }, + branding: currentUser => ({ + companyName: currentUser ? currentUser.email : 'something', + }), locale: { language: 'en', translations: { diff --git a/src/admin-bro-options.interface.ts b/src/admin-bro-options.interface.ts index 0c05c07b0..3b18e08cd 100644 --- a/src/admin-bro-options.interface.ts +++ b/src/admin-bro-options.interface.ts @@ -5,6 +5,7 @@ import BaseDatabase from './backend/adapters/base-database' import { PageContext } from './backend/actions/action.interface' import { ResourceOptions } from './backend/decorators/resource-options.interface' import { Locale } from './locale/config' +import { CurrentAdmin } from './current-admin.interface' /** * AdminBroOptions @@ -116,25 +117,16 @@ export default interface AdminBroOptions { /** * Options which are related to the ht. */ - branding?: BrandingOptions; + branding?: BrandingOptions | BrandingOptionsFunction; /** * Custom assets you want to pass to AdminBro */ - 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; - }; + assets?: Assets | AssetsFunction; /** * Indicates is bundled by AdminBro files like: * - components.bundle.js * - global.bundle.js + * - design-system.bundle.js * - app.bundle.js * should be taken from the same server as other AdminBro routes (default) * or should be taken from an external CDN. @@ -158,6 +150,9 @@ export default interface AdminBroOptions { * - copy * './node_modules/admin-bro/lib/frontend/assets/scripts/global-bundle.production.js' to * './public/global.bundle.js' + * * - copy + * './node_modules/admin-bro/node_modules/@admin-bro/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 AdminBro.assetsCDN} to this domain @@ -244,6 +239,37 @@ export default interface AdminBroOptions { /* cspell: enable */ +/** + * @memberof AdminBroOptions + * @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; +} + +/** + * @alias AssetsFunction + * @name AssetsFunction + * @memberof AdminBroOptions + * @returns {Assets | Promise} + * @description + * Function returning {@link Assets} + */ +export type AssetsFunction = (admin?: CurrentAdmin) => Assets | Promise + /** * Version Props * @alias VersionProps @@ -273,8 +299,6 @@ export type VersionProps = { * colors (dark theme) run: * * ```javascript - * const theme = require('admin-bro-theme-dark') - * * new AdminBro({ * branding: { * companyName: 'John Doe Family Business', @@ -310,6 +334,19 @@ export type BrandingOptions = { favicon?: string; } +/** + * Branding Options Function + * + * function returning BrandingOptions. + * + * @alias BrandingOptionsFunction + * @memberof AdminBroOptions + * @returns {BrandingOptions | Promise} + */ +export type BrandingOptionsFunction = ( + admin?: CurrentAdmin +) => BrandingOptions | Promise + /** * Object describing regular page in AdminBro * @@ -342,9 +379,14 @@ export type ResourceWithOptions = { * Function taking {@link ResourceOptions} and merging it with all other options * * @alias FeatureType + * @type function + * @returns {ResourceOptions} * @memberof AdminBroOptions */ export type FeatureType = ( + /** + * Options returned by the feature added before + */ options: ResourceOptions ) => ResourceOptions @@ -373,10 +415,5 @@ export interface AdminBroOptionsWithDefault extends AdminBroOptions { handler?: PageHandler; component?: string; }; - branding: BrandingOptions & Required>; - assets: { - styles: Array; - scripts: Array; - }; pages: Record; } diff --git a/src/admin-bro.ts b/src/admin-bro.ts index 45ed37ec7..edd409caf 100644 --- a/src/admin-bro.ts +++ b/src/admin-bro.ts @@ -3,7 +3,6 @@ import * as path from 'path' import * as fs from 'fs' import i18n, { i18n as I18n } from 'i18next' -import slash from 'slash' import AdminBroOptions, { AdminBroOptionsWithDefault } from './admin-bro-options.interface' import BaseResource from './backend/adapters/base-resource' import BaseDatabase from './backend/adapters/base-database' @@ -34,15 +33,7 @@ const defaults: AdminBroOptionsWithDefault = { loginPath: DEFAULT_PATHS.loginPath, databases: [], resources: [], - branding: { - companyName: 'Company', - softwareBrothers: true, - }, dashboard: {}, - assets: { - styles: [], - scripts: [], - }, pages: {}, } @@ -171,12 +162,6 @@ class AdminBro { */ this.options = _.merge({}, defaults, options) - const defaultLogo = slash(path.join(this.options.rootPath, '/frontend/assets/logo-mini.svg')) - this.options.branding = this.options.branding || {} - this.options.branding.logo = this.options.branding.logo !== undefined - ? this.options.branding.logo - : defaultLogo - this.initI18n() const { databases, resources } = this.options diff --git a/src/backend/utils/options-parser.ts b/src/backend/utils/options-parser.ts new file mode 100644 index 000000000..75eb49895 --- /dev/null +++ b/src/backend/utils/options-parser.ts @@ -0,0 +1,59 @@ +import merge from 'lodash/merge' +import slash from 'slash' +import path from 'path' +import AdminBro from '../../admin-bro' +import { CurrentAdmin } from '../../current-admin.interface' +import { BrandingOptions, Assets } from '../../admin-bro-options.interface' + + +const defaultBranding = { + companyName: 'Company', + softwareBrothers: true, +} +const defaultAssets = { + styles: [], + scripts: [], +} + +export const getAssets = async ( + admin: AdminBro, + 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: AdminBro, + currentAdmin?: CurrentAdmin, +): Promise => { + const { branding } = admin.options + const defaultLogo = slash(path.join( + admin.options.rootPath, + '/frontend/assets/logo-mini.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 getFaviconFromBranding = (branding: BrandingOptions): string => { + if (branding.favicon) { + const { favicon } = branding + const type = favicon.match(/.*\.png$/) ? 'image/png' : 'image/x-icon' + return `` + } + + return '' +} diff --git a/src/frontend/layout-template.spec.ts b/src/frontend/layout-template.spec.ts index a99efc05c..8ca066775 100644 --- a/src/frontend/layout-template.spec.ts +++ b/src/frontend/layout-template.spec.ts @@ -1,66 +1,77 @@ import { expect } from 'chai' import layoutTemplate from './layout-template' import AdminBro from '../admin-bro' -import AdminBroOptions from '../admin-bro-options.interface' +import { BrandingOptions } from '../admin-bro-options.interface' describe('layoutTemplate', function () { - context('AdminBro with default options and not logged in user', function () { - beforeEach(function () { - this.adminBro = new AdminBro({}) + context('AdminBro with branding options set as a function', function () { + const companyName = 'Dynamic Company' + let html: string + + beforeEach(async function () { + const adminBro = new AdminBro({ + branding: async () => ({ companyName }), + }) + + html = await layoutTemplate(adminBro, undefined, '/') }) it('renders default company name', function () { - expect( - layoutTemplate(this.adminBro, undefined, '/'), - ).to.contain(this.adminBro.options.branding.companyName) + expect(html).to.contain(companyName) }) - it('links to global bundle', function () { - expect(layoutTemplate(this.adminBro, undefined, '/')).to.contain('global.bundle.js') + it('links to global bundle', async function () { + expect(html).to.contain('global.bundle.js') }) }) describe('AdminBro with branding options given', function () { - beforeEach(function () { - this.branding = { - softwareBrothers: false, - companyName: 'Other name', - favicon: '/someImage.png', - } as AdminBroOptions['branding'] + const branding = { + softwareBrothers: false, + companyName: 'Other name', + favicon: '/someImage.png', + } as BrandingOptions + let html: string - this.adminBro = new AdminBro({ branding: this.branding }) - this.renderedContent = layoutTemplate(this.adminBro, undefined, '/') + beforeEach(async function () { + const adminBro = new AdminBro({ branding }) + + html = await layoutTemplate(adminBro, undefined, '/') }) it('renders company name', function () { - expect(this.renderedContent).to.contain(this.branding.companyName) + expect(html).to.contain(branding.companyName) }) it('renders favicon', function () { - expect(this.renderedContent).to.contain( - ``, + expect(html).to.contain( + ``, ) }) }) context('custom styles and scripts were defined in AdminBro options', function () { - beforeEach(function () { - this.scriptUrl = 'http://somescript.com' - this.styleUrl = 'http://somestyle.com' - this.adminBro = new AdminBro({ + let html: string + const scriptUrl = 'http://somescript.com' + const styleUrl = 'http://somestyle.com' + + beforeEach(async function () { + const adminBro = new AdminBro({ assets: { - styles: [this.styleUrl], - scripts: [this.scriptUrl], + styles: [styleUrl], + scripts: [scriptUrl], }, }) + + html = await layoutTemplate(adminBro, undefined, '/') }) it('adds styles to the head section', function () { - expect(layoutTemplate(this.adminBro, undefined, '/')).to.contain(this.styleUrl) + expect(html).to.contain(styleUrl) }) it('adds scripts to the body', function () { - expect(layoutTemplate(this.adminBro, undefined, '/')).to.contain(this.scriptUrl) + expect(html).to.contain(scriptUrl) }) }) }) diff --git a/src/frontend/layout-template.tsx b/src/frontend/layout-template.tsx index 559069e33..acfd30ba2 100644 --- a/src/frontend/layout-template.tsx +++ b/src/frontend/layout-template.tsx @@ -11,6 +11,7 @@ import ViewHelpers from '../backend/utils/view-helpers' import initializeStore from './store' import AdminBro from '../admin-bro' import { CurrentAdmin } from '../current-admin.interface' +import { getFaviconFromBranding } from '../backend/utils/options-parser' /** * Renders (SSR) html for given location @@ -22,16 +23,24 @@ import { CurrentAdmin } from '../current-admin.interface' * * @private */ -const html = (admin: AdminBro, currentAdmin?: CurrentAdmin, location = '/'): string => { +const html = async ( + admin: AdminBro, + currentAdmin?: CurrentAdmin, + location = '/', +): Promise => { const context = {} const h = new ViewHelpers({ options: admin.options }) - const store = initializeStore(admin, currentAdmin) + + const store = await initializeStore(admin, currentAdmin) const reduxState = store.getState() - const scripts = ((admin.options.assets && admin.options.assets.scripts) || []) + + const { branding, assets } = reduxState + + const scripts = ((assets && assets.scripts) || []) .map(s => ``) - const styles = ((admin.options.assets && admin.options.assets.styles) || []) + const styles = ((assets && assets.styles) || []) .map(l => ``) - const theme = combineStyles((admin.options.branding && admin.options.branding.theme) || {}) + const theme = combineStyles((branding.theme) || {}) const jsx = ( // eslint-disable-next-line react/jsx-filename-extension @@ -46,12 +55,7 @@ const html = (admin: AdminBro, currentAdmin?: CurrentAdmin, location = '/'): str // const appComponent = renderToString(jsx) - let faviconTag = '' - if (admin.options.branding.favicon) { - const { favicon } = admin.options.branding - const type = favicon.match(/.*\.png$/) ? 'image/png' : 'image/x-icon' - faviconTag = `` - } + const faviconTag = getFaviconFromBranding(branding) return ` @@ -64,7 +68,7 @@ const html = (admin: AdminBro, currentAdmin?: CurrentAdmin, location = '/'): str - ${admin.options.branding.companyName} + ${branding.companyName} ${faviconTag} diff --git a/src/frontend/login-template.spec.ts b/src/frontend/login-template.spec.ts index b6c77f811..7d948abc7 100644 --- a/src/frontend/login-template.spec.ts +++ b/src/frontend/login-template.spec.ts @@ -6,11 +6,12 @@ import AdminBro from '../admin-bro' describe('login-template', function () { const action = '/login' - it('renders error message', function () { + it('renders error message', async function () { const adminBro = new AdminBro({}) const errorMessage = 'Something went wrong' - expect( - loginTemplate(adminBro, { action, errorMessage }), - ).to.contain(errorMessage) + + const html = await loginTemplate(adminBro, { action, errorMessage }) + + expect(html).to.contain(errorMessage) }) }) diff --git a/src/frontend/login-template.tsx b/src/frontend/login-template.tsx index 45315c37c..e4e83ff41 100644 --- a/src/frontend/login-template.tsx +++ b/src/frontend/login-template.tsx @@ -15,8 +15,10 @@ import createStore, { initializeBranding, initializeLocale, ReduxState, + initializeAssets, } from './store/store' import ViewHelpers from '../backend/utils/view-helpers' +import { getBranding, getAssets, getFaviconFromBranding } from '../backend/utils/options-parser' type LoginTemplateAttributes = { /** @@ -29,14 +31,23 @@ type LoginTemplateAttributes = { errorMessage?: string; } -const html = (admin: AdminBro, { action, errorMessage }: LoginTemplateAttributes): string => { +const html = async ( + admin: AdminBro, + { action, errorMessage }: LoginTemplateAttributes, +): Promise => { const h = new ViewHelpers({ options: admin.options }) const store: Store = createStore() + + const branding = await getBranding(admin) + const assets = await getAssets(admin) + const faviconTag = getFaviconFromBranding(branding) + + store.dispatch(initializeBranding(branding)) + store.dispatch(initializeAssets(assets)) store.dispatch(initializeLocale(admin.locale)) - store.dispatch(initializeBranding(admin.options.branding)) - const theme = combineStyles((admin.options.branding && admin.options.branding.theme) || {}) + const theme = combineStyles((branding && branding.theme) || {}) const { locale } = store.getState() i18n .init({ @@ -75,6 +86,7 @@ const html = (admin: AdminBro, { action, errorMessage }: LoginTemplateAttributes AdminPanel ${style} + ${faviconTag} diff --git a/src/frontend/store/index.ts b/src/frontend/store/index.ts index 40d1364b4..3991c9c0e 100644 --- a/src/frontend/store/index.ts +++ b/src/frontend/store/index.ts @@ -3,6 +3,7 @@ import createStore, { initializeResources, initializeBranding, initializeDashboard, + initializeAssets, initializePaths, initializePages, setCurrentAdmin, @@ -13,8 +14,12 @@ import createStore, { import AdminBro from '../../admin-bro' import { CurrentAdmin } from '../../current-admin.interface' import pagesToStore from './pages-to-store' +import { getBranding, getAssets } from '../../backend/utils/options-parser' -const initializeStore = (admin: AdminBro, currentAdmin?: CurrentAdmin): Store => { +const initializeStore = async ( + admin: AdminBro, + currentAdmin?: CurrentAdmin, +): Promise> => { const store: Store = createStore() const AdminClass: typeof AdminBro = admin.constructor as typeof AdminBro const adminVersion = AdminClass.VERSION @@ -32,7 +37,13 @@ const initializeStore = (admin: AdminBro, currentAdmin?: CurrentAdmin): Store ({ + type: 'ASSETS_INITIALIZE', + data, +}) + export const initializePaths = (data: Paths): { type: string; data: Paths; @@ -163,6 +171,17 @@ const brandingReducer = (state = {}, action: { } } +const assetsReducer = (state = {}, action: { + type: string; + data: Assets; +}) => { + switch (action.type) { + case 'ASSETS_INITIALIZE': + return action.data + default: return state + } +} + const pathsReducer = ( state: Paths = DEFAULT_PATHS, action: {type: string; data: Paths}, @@ -242,6 +261,7 @@ const noticesReducer = (state: Array = [], action: { export type ReduxState = { resources: Array; branding: BrandingOptions; + assets: Assets; paths: Paths; session: CurrentAdmin | null; dashboard: DashboardInState; @@ -254,6 +274,7 @@ export type ReduxState = { const reducer = combineReducers({ resources: resourcesReducer, branding: brandingReducer, + assets: assetsReducer, paths: pathsReducer, session: sessionReducer, dashboard: dashboardReducer,