Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow customize Admin UI branding options #610

Merged
5 changes: 5 additions & 0 deletions docs/content/docs/plugins/extending-the-admin-ui/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,8 @@ plugins: [
}),
],
```

## Avaliable custom settings

* [Adding UI Translations]({{< relref "adding-ui-translations" >}})
michaelbromley marked this conversation as resolved.
Show resolved Hide resolved
* [Customize Admin UI]({{< relref "customize-admin-ui" >}})
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
title: 'Customize Admin UI'
---

# Customize Admin UI

The Vendure Admin UI is customizable, allowing you to:

* set your brand name
* replace default favicon
* change default logos
* add you own stylesheet
* hide vendure branding
* hide admin ui version

## Example: Config code to customize admin ui

```TypeScript
// vendure-config.ts
import path from 'path';
import { VendureConfig } from '@vendure/core';
import { AdminUiPlugin } from '@vendure/admin-ui-plugin';

export const config: VendureConfig = {
// ...
plugins: [
AdminUiPlugin.init({
adminUiConfig:{
brand: 'My Store',
faviconPath: path.join(__dirname, 'assets/favicon.ico'),
smallLogoUrl: 'https://cdn.mystore.com/logo-small.png',
bigLogoUrl: 'https://cdn.mystore.com/logo-big.png',
styleUrl: 'https://cdn.mystore.com/my-store-style.css',
hideVendureBranding: false,
hideVersion: false,
}
}),
],
};
```
75 changes: 58 additions & 17 deletions packages/admin-ui-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import fs from 'fs-extra';
import { Server } from 'http';
import path from 'path';

import { DEFAULT_APP_PATH, defaultAvailableLanguages, defaultLanguage, loggerCtx } from './constants';
import { defaultAvailableLanguages, defaultLanguage, DEFAULT_APP_PATH, loggerCtx } from './constants';

/**
* @description
Expand Down Expand Up @@ -180,11 +180,10 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
? path.join(app.sourcePath, 'src')
: (app && app.path) || DEFAULT_APP_PATH;
const adminUiConfigPath = path.join(adminUiAppPath, 'vendure-ui-config.json');
const uiConfig = this.getAdminUiConfig(adminUiConfig);

const overwriteConfig = () => {
const uiConfig = this.getAdminUiConfig(adminUiConfig);
return this.overwriteAdminUiConfig(adminUiConfigPath, uiConfig);
};
const overwriteConfig = () => this.overwriteAdminUiConfig(adminUiConfigPath, uiConfig);
const overwriteFavicon = () => this.overwriteAdminUiFavicon(adminUiAppPath, uiConfig.faviconPath);

if (!AdminUiPlugin.isDevModeApp(app)) {
// If not in dev mode, start a static server for the compiled app
Expand All @@ -197,7 +196,10 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
if (app && typeof app.compile === 'function') {
Logger.info(`Compiling Admin UI app in production mode...`, loggerCtx);
app.compile()
.then(overwriteConfig)
.then(async () => {
await overwriteConfig();
await overwriteFavicon();
})
.then(
() => {
Logger.info(`Admin UI successfully compiled`, loggerCtx);
Expand All @@ -208,6 +210,7 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
);
} else {
await overwriteConfig();
await overwriteFavicon();
}
} else {
Logger.info(`Compiling Admin UI app in development mode`, loggerCtx);
Expand All @@ -220,6 +223,7 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
},
);
await overwriteConfig();
await overwriteFavicon();
}
}

Expand Down Expand Up @@ -255,6 +259,19 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
defaultLanguage: propOrDefault('defaultLanguage', defaultLanguage),
availableLanguages: propOrDefault('availableLanguages', defaultAvailableLanguages),
loginUrl: AdminUiPlugin.options.adminUiConfig?.loginUrl,
brand: AdminUiPlugin.options.adminUiConfig?.brand,
faviconPath: AdminUiPlugin.options.adminUiConfig?.faviconPath,
smallLogoUrl: AdminUiPlugin.options.adminUiConfig?.smallLogoUrl,
bigLogoUrl: AdminUiPlugin.options.adminUiConfig?.bigLogoUrl,
styleUrl: AdminUiPlugin.options.adminUiConfig?.styleUrl,
hideVendureBranding: propOrDefault(
'hideVendureBranding',
AdminUiPlugin.options.adminUiConfig?.hideVendureBranding || false,
),
hideVersion: propOrDefault(
'hideVersion',
AdminUiPlugin.options.adminUiConfig?.hideVersion || false,
),
};
}

Expand All @@ -264,7 +281,7 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
*/
private async overwriteAdminUiConfig(adminUiConfigPath: string, config: AdminUiConfig) {
try {
const content = await this.pollForConfigFile(adminUiConfigPath);
await this.pollForFile(adminUiConfigPath, 'config');
} catch (e) {
Logger.error(e.message, loggerCtx);
throw e;
Expand All @@ -278,32 +295,56 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
}

/**
* It might be that the ui-devkit compiler has not yet copied the config
* file to the expected location (particularly when running in watch mode),
* Overwrites admin-ui favicon.
*/
private async overwriteAdminUiFavicon(adminUiAppPath: string, newFaviconPath?: string) {
if (!newFaviconPath) {
Logger.verbose(`Favicon not informed, keeping original.`, loggerCtx);
return;
}

const adminUiFaviconPath = path.join(adminUiAppPath, 'favicon.ico');
try {
await this.pollForFile(adminUiFaviconPath, 'favicon');
} catch (e) {
Logger.error(e.message, loggerCtx);
throw e;
}
try {
await fs.copyFileSync(newFaviconPath, adminUiFaviconPath);
} catch (e) {
throw new Error('[AdminUiPlugin] Could not write favicon.ico file:\n' + e.message);
}
Logger.verbose(`Favicon file was replaced`, loggerCtx);
}

/**
* It might be that the ui-devkit compiler has not yet copied the file
* to the expected location (particularly when running in watch mode),
* so polling is used to check multiple times with a delay.
*/
private async pollForConfigFile(adminUiConfigPath: string) {
private async pollForFile(adminUiConfigPath: string, loggerFileName: string) {
const maxRetries = 10;
const retryDelay = 200;
let attempts = 0;

const pause = () => new Promise(resolve => setTimeout(resolve, retryDelay));

while (attempts < maxRetries) {
try {
Logger.verbose(`Checking for config file: ${adminUiConfigPath}`, loggerCtx);
const configFileContent = await fs.readFile(adminUiConfigPath, 'utf-8');
michaelbromley marked this conversation as resolved.
Show resolved Hide resolved
return configFileContent;
} catch (e) {
Logger.verbose(`Checking for ${loggerFileName} file: ${adminUiConfigPath}`, loggerCtx);

if (fs.existsSync(adminUiConfigPath)) {
return true;
} else {
attempts++;
Logger.verbose(
`Unable to locate config file: ${adminUiConfigPath} (attempt ${attempts})`,
`Unable to locate ${loggerFileName} file: ${adminUiConfigPath} (attempt ${attempts})`,
loggerCtx,
);
}
await pause();
}
throw new Error(`Unable to locate config file: ${adminUiConfigPath}`);
throw new Error(`Unable to locate ${loggerFileName} file: ${adminUiConfigPath}`);
}

private static isDevModeApp(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<clr-main-container>
<clr-header>
<div class="branding">
<a [routerLink]="['/']"><img src="assets/cube-logo-75px.png" class="logo" /></a>
<a [routerLink]="['/']"><img src="{{ smallLogoUrl || 'assets/cube-logo-75px.png' }}" class="logo" /></a>
</div>
<div class="header-nav"></div>
<div class="header-actions">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export class AppShellComponent implements OnInit {
userName$: Observable<string>;
uiLanguage$: Observable<LanguageCode>;
availableLanguages: LanguageCode[] = [];
smallLogoUrl = getAppConfig().smallLogoUrl;

constructor(
private authService: AuthService,
Expand Down
31 changes: 28 additions & 3 deletions packages/admin-ui/src/lib/core/src/core.module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { PlatformLocation } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserModule, Title } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateCompiler, TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { MessageFormatConfig, MESSAGE_FORMAT_CONFIG } from 'ngx-translate-messageformat-compiler';
Expand All @@ -20,6 +20,7 @@ import { DataModule } from './data/data.module';
import { CustomHttpTranslationLoader } from './providers/i18n/custom-http-loader';
import { InjectableTranslateMessageFormatCompiler } from './providers/i18n/custom-message-format-compiler';
import { I18nService } from './providers/i18n/i18n.service';
import { LazyLoadStylesheetService } from './providers/lazy-load-stylesheet/lazy-load-stylesheet-service';
import { LocalStorageService } from './providers/local-storage/local-storage.service';
import { registerDefaultFormInputs } from './shared/dynamic-form-inputs/register-dynamic-input-components';
import { SharedModule } from './shared/shared.module';
Expand All @@ -39,7 +40,12 @@ import { SharedModule } from './shared/shared.module';
compiler: { provide: TranslateCompiler, useClass: InjectableTranslateMessageFormatCompiler },
}),
],
providers: [{ provide: MESSAGE_FORMAT_CONFIG, useFactory: getLocales }, registerDefaultFormInputs()],
providers: [
{ provide: MESSAGE_FORMAT_CONFIG, useFactory: getLocales },
registerDefaultFormInputs(),
Title,
LazyLoadStylesheetService,
],
exports: [SharedModule, OverlayHostComponent],
declarations: [
AppShellComponent,
Expand All @@ -53,8 +59,15 @@ import { SharedModule } from './shared/shared.module';
],
})
export class CoreModule {
constructor(private i18nService: I18nService, private localStorageService: LocalStorageService) {
constructor(
private i18nService: I18nService,
private localStorageService: LocalStorageService,
private titleService: Title,
private lazyLoadStylesheetService: LazyLoadStylesheetService,
) {
this.initUiLanguages();
this.initUiTitle();
this.initUiCustomStylesheet();
}

private initUiLanguages() {
Expand All @@ -76,6 +89,18 @@ export class CoreModule {
this.i18nService.setDefaultLanguage(defaultLanguage);
this.i18nService.setAvailableLanguages(availableLanguages || [defaultLanguage]);
}

private initUiTitle() {
const title = getAppConfig().brand || 'VendureAdmin';

this.titleService.setTitle(title);
}

private initUiCustomStylesheet() {
const styleUrl = getAppConfig().styleUrl;

if (styleUrl) this.lazyLoadStylesheetService.loadStylesheet(styleUrl);
}
}

export function HttpLoaderFactory(http: HttpClient, location: PlatformLocation) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { TestBed } from '@angular/core/testing';

import { LazyLoadStylesheetService } from './lazy-load-stylesheet-service';

describe('LazyLoadStylesheetService', () => {
describe('lazyLoadStylesheet()', () => {
let service: LazyLoadStylesheetService;

beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(LazyLoadStylesheetService);
});

it('appends on document header the lazy loaded stylesheet', () => {
expect(service['document'].querySelector('head').innerHTML).not.toContain(
`<link rel="stylesheet" href="lazy-style.css">`,
);

service.loadStylesheet('lazy-style.css');

expect(service['document'].querySelector('head').innerHTML).toContain(
`<link rel="stylesheet" href="lazy-style.css">`,
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable } from '@angular/core';

@Injectable({
providedIn: 'root',
})
export class LazyLoadStylesheetService {
constructor(@Inject(DOCUMENT) private document: any) {}

/**
* Lazy load a stylesheet on document header.
*/
loadStylesheet(stylesheetPath) {
return new Promise(async resolve => {
const styleElement = document.createElement('link');

styleElement.rel = 'stylesheet';
styleElement.href = stylesheetPath;
styleElement.onload = resolve;

document.head.appendChild(styleElement);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ <h4 class="h4">
<small class="p5">Last login: {{ administrator.user.lastLogin | timeAgo }}</small>
</h4>

<p class="p5">Vendure Admin UI v{{ version }}</p>
<p class="p5" *ngIf="!hideVendureBranding || !hideVersion">
{{ hideVendureBranding ? '' : 'Vendure' }} {{ hideVersion ? '' : ('Admin UI v' + version) }}
</p>
</div>
<div class="placeholder">
<clr-icon shape="line-chart" size="128"></clr-icon>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
CoreModule,
DataService,
GetActiveAdministrator,
getAppConfig,
} from '@vendure/admin-ui/core';
import { Observable } from 'rxjs';

Expand All @@ -17,6 +18,10 @@ import { Observable } from 'rxjs';
export class WelcomeWidgetComponent implements OnInit {
version = ADMIN_UI_VERSION;
administrator$: Observable<GetActiveAdministrator.ActiveAdministrator | null>;
bigLogoUrl = getAppConfig().bigLogoUrl;
brand = getAppConfig().brand;
hideVendureBranding = getAppConfig().hideVendureBranding;
hideVersion = getAppConfig().hideVersion;

constructor(private dataService: DataService) {}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<div class="login-wrapper">
<form class="login">
<label class="title"><img src="assets/cube-logo-300px.png" /></label>
<label class="title"><img src="{{ bigLogoUrl || 'assets/cube-logo-300px.png' }}"/></label>
<div class="login-group">
<input
class="username"
Expand All @@ -18,7 +18,8 @@
[(ngModel)]="password"
[placeholder]="'common.password' | translate"
/>
<clr-alert [clrAlertType]="'danger'" [clrAlertClosable]="false" [class.visible]="errorMessage" class="login-error">
<clr-alert [clrAlertType]="'danger'" [clrAlertClosable]="false" [class.visible]="errorMessage"
class="login-error">
<clr-alert-item>
<span class="alert-text">
{{ errorMessage }}
Expand All @@ -44,6 +45,11 @@
{{ 'common.login' | translate }}
</button>
</div>
<div class="version">vendure {{ version }}</div>
<div class="version">
<span *ngIf="brand">{{ brand }}</span>
<span *ngIf="!hideVendureBranding || !hideVersion">-</span>
<span *ngIf="!hideVendureBranding">vendure</span>
<span *ngIf="!hideVersion">v{{ version }}</span>
</div>
</form>
</div>
Loading