[NIFI-12727] Detect theme based on OS setting, allow user override (#8352)

* [NIFI-12727] Detect theme based on OS setting, allow user override

* update storage service types, introduce theming service for user selection of theme settings

* review feedback, update menu option disaplay names, update storage service types, delete expired local storage items on init

* check for existence of window.matchmedia

* rebase and address review comments

This closes #8352
This commit is contained in:
Scott Aslan 2024-02-07 08:26:07 -05:00 committed by GitHub
parent 72f6d8a680
commit f39f3ea252
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 292 additions and 93 deletions

View File

@ -15,9 +15,11 @@
* limitations under the License.
*/
import { Component } from '@angular/core';
import { Component, Inject } from '@angular/core';
import { GuardsCheckEnd, GuardsCheckStart, NavigationCancel, Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Storage } from './service/storage.service';
import { ThemingService } from './service/theming.service';
@Component({
selector: 'nifi',
@ -28,7 +30,11 @@ export class AppComponent {
title = 'nifi';
guardLoading = true;
constructor(private router: Router) {
constructor(
private router: Router,
private storage: Storage,
private themingService: ThemingService
) {
this.router.events.pipe(takeUntilDestroyed()).subscribe((event) => {
if (event instanceof GuardsCheckStart) {
this.guardLoading = true;
@ -37,5 +43,21 @@ export class AppComponent {
this.guardLoading = false;
}
});
let theme = this.storage.getItem('theme');
// Initially check if dark mode is enabled on system
const darkModeOn = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
// If dark mode is enabled then directly switch to the dark-theme
this.themingService.toggleTheme(darkModeOn, theme);
if (window.matchMedia) {
// Watch for changes of the preference
window.matchMedia('(prefers-color-scheme: dark)').addListener((e) => {
theme = this.storage.getItem('theme');
this.themingService.toggleTheme(e.matches, theme);
});
}
}
}

View File

@ -26,6 +26,12 @@ import { CanvasState } from '../index';
import { CanvasView } from '../../service/canvas-view.service';
import { BirdseyeView } from '../../service/birdseye-view.service';
interface StorageTransform {
scale: number;
translateX: number;
translateY: number;
}
@Injectable()
export class TransformEffects {
private static readonly VIEW_PREFIX: string = 'nifi-view-';
@ -48,7 +54,7 @@ export class TransformEffects {
const name: string = TransformEffects.VIEW_PREFIX + processGroupId;
// create the item to store
const item = {
const item: StorageTransform = {
scale: transform.scale,
translateX: transform.translate.x,
translateY: transform.translate.y
@ -70,7 +76,7 @@ export class TransformEffects {
try {
// see if we can restore the view position from storage
const name: string = TransformEffects.VIEW_PREFIX + processGroupId;
const item = this.storage.getItem(name);
const item: StorageTransform | null = this.storage.getItem(name);
// ensure the item is valid
if (item) {

View File

@ -47,7 +47,9 @@ export class NavigationControl {
private storage: Storage
) {
try {
const item = this.storage.getItem(NavigationControl.CONTROL_VISIBILITY_KEY);
const item: { [key: string]: boolean } | null = this.storage.getItem(
NavigationControl.CONTROL_VISIBILITY_KEY
);
if (item) {
this.navigationCollapsed = item[NavigationControl.NAVIGATION_KEY] === false;
this.store.dispatch(setNavigationCollapsed({ navigationCollapsed: this.navigationCollapsed }));
@ -62,7 +64,7 @@ export class NavigationControl {
this.store.dispatch(setNavigationCollapsed({ navigationCollapsed: this.navigationCollapsed }));
// update the current value in storage
let item = this.storage.getItem(NavigationControl.CONTROL_VISIBILITY_KEY);
let item: { [key: string]: boolean } | null = this.storage.getItem(NavigationControl.CONTROL_VISIBILITY_KEY);
if (item == null) {
item = {};
}

View File

@ -65,7 +65,9 @@ export class OperationControl {
private storage: Storage
) {
try {
const item = this.storage.getItem(OperationControl.CONTROL_VISIBILITY_KEY);
const item: { [key: string]: boolean } | null = this.storage.getItem(
OperationControl.CONTROL_VISIBILITY_KEY
);
if (item) {
this.operationCollapsed = item[OperationControl.OPERATION_KEY] === false;
this.store.dispatch(setOperationCollapsed({ operationCollapsed: this.operationCollapsed }));
@ -80,7 +82,7 @@ export class OperationControl {
this.store.dispatch(setOperationCollapsed({ operationCollapsed: this.operationCollapsed }));
// update the current value in storage
let item = this.storage.getItem(OperationControl.CONTROL_VISIBILITY_KEY);
let item: { [key: string]: boolean } | null = this.storage.getItem(OperationControl.CONTROL_VISIBILITY_KEY);
if (item == null) {
item = {};
}

View File

@ -17,6 +17,11 @@
import { Injectable } from '@angular/core';
interface StorageEntry<T> {
expires: number;
item: T;
}
@Injectable({
providedIn: 'root'
})
@ -24,13 +29,29 @@ export class Storage {
private static readonly MILLIS_PER_DAY: number = 86400000;
private static readonly TWO_DAYS: number = Storage.MILLIS_PER_DAY * 2;
constructor() {
for (let i = 0; i < localStorage.length; i++) {
try {
// get the next item
const key: string | null = localStorage.key(i);
if (key) {
// attempt to get the item which will expire if necessary
this.getItem(key);
}
} catch (e) {
//do nothing
}
}
}
/**
* Checks the expiration for the specified entry.
*
* @param {object} entry
* @returns {boolean}
*/
private checkExpiration(entry: any): boolean {
private checkExpiration<T>(entry: StorageEntry<T>): boolean {
if (entry.expires) {
// get the expiration
const expires: Date = new Date(entry.expires);
@ -48,10 +69,10 @@ export class Storage {
*
* @param {string} key
*/
private getEntry(key: string): null | any {
private getEntry<T>(key: string): null | StorageEntry<T> {
try {
// parse the entry
const item: any | null = localStorage.getItem(key);
const item = localStorage.getItem(key);
if (!item) {
return null;
}
@ -76,12 +97,12 @@ export class Storage {
* @param {object} item
* @param {number} expires
*/
public setItem(key: string, item: any, expires?: number): void {
public setItem<T>(key: string, item: T, expires?: number): void {
// calculate the expiration
expires = expires != null ? expires : new Date().valueOf() + Storage.TWO_DAYS;
// create the entry
const entry = {
const entry: StorageEntry<T> = {
expires,
item
};
@ -108,8 +129,8 @@ export class Storage {
*
* @param {type} key
*/
public getItem(key: string): null | any {
const entry = this.getEntry(key);
public getItem<T>(key: string): null | T {
const entry: StorageEntry<T> | null = this.getEntry(key);
if (entry === null) {
return null;
}

View File

@ -0,0 +1,48 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Inject, Injectable } from '@angular/core';
import { DOCUMENT } from '@angular/common';
export const DARK_THEME = 'DARK_THEME';
export const LIGHT_THEME = 'LIGHT_THEME';
export const OS_SETTING = 'OS_SETTING';
@Injectable({ providedIn: 'root' })
export class ThemingService {
constructor(@Inject(DOCUMENT) private _document: Document) {}
toggleTheme(darkModeOn: boolean, theme: any) {
if (darkModeOn) {
if (theme === DARK_THEME) {
this._document.body.classList.toggle('dark-theme', true);
} else if (theme === LIGHT_THEME) {
this._document.body.classList.toggle('dark-theme', false);
} else {
this._document.body.classList.toggle('dark-theme', true);
}
} else {
if (theme === DARK_THEME) {
this._document.body.classList.toggle('dark-theme', true);
} else if (theme === LIGHT_THEME) {
this._document.body.classList.toggle('dark-theme', false);
} else {
this._document.body.classList.toggle('dark-theme', false);
}
}
}
}

View File

@ -30,7 +30,6 @@
<ng-content></ng-content>
</div>
<div class="flex justify-between items-center gap-x-1" *ngIf="currentUser$ | async as user">
<mat-slide-toggle color="primary" (click)="toggleTheme()"></mat-slide-toggle>
<div class="flex flex-col justify-between items-end gap-y-1">
<div class="current-user">{{ user.identity }}</div>
<a href="#" *ngIf="allowLogin(user)">log in</a>
@ -140,6 +139,24 @@
<i class="fa fa-fw fa-info-circle mr-2"></i>
About
</button>
<button mat-menu-item [matMenuTriggerFor]="theming">Appearance</button>
</mat-menu>
<mat-menu #theming="matMenu" xPosition="before">
<button mat-menu-item class="global-menu-item" (click)="toggleTheme(LIGHT_THEME)">
<i class="fa fa-fw fa-check mr-2" *ngIf="theme === LIGHT_THEME"></i>
<i class="fa fa-fw mr-2" *ngIf="theme !== LIGHT_THEME"></i>
Light
</button>
<button mat-menu-item class="global-menu-item" (click)="toggleTheme(DARK_THEME)">
<i class="fa fa-fw fa-check mr-2" *ngIf="theme === DARK_THEME"></i>
<i class="fa fa-fw mr-2" *ngIf="theme !== DARK_THEME"></i>
Dark
</button>
<button mat-menu-item class="global-menu-item" (click)="toggleTheme(OS_SETTING)">
<i class="fa fa-fw fa-check mr-2" *ngIf="theme === OS_SETTING || theme === null"></i>
<i class="fa fa-fw mr-2" *ngIf="theme !== OS_SETTING && theme !== null"></i>
Device
</button>
</mat-menu>
</div>
</div>

View File

@ -15,8 +15,8 @@
* limitations under the License.
*/
import { Component, Inject } from '@angular/core';
import { AsyncPipe, DOCUMENT, NgIf, NgOptimizedImage } from '@angular/common';
import { Component } from '@angular/core';
import { AsyncPipe, NgIf, NgOptimizedImage } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDividerModule } from '@angular/material/divider';
import { MatMenuModule } from '@angular/material/menu';
@ -31,12 +31,14 @@ import { selectCurrentUser } from '../../../state/current-user/current-user.sele
import { MatButtonModule } from '@angular/material/button';
import { NiFiState } from '../../../state';
import { selectFlowConfiguration } from '../../../state/flow-configuration/flow-configuration.selectors';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { Storage } from '../../../service/storage.service';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { OS_SETTING, LIGHT_THEME, DARK_THEME, ThemingService } from '../../../service/theming.service';
@Component({
selector: 'navigation',
standalone: true,
providers: [Storage],
imports: [
NgOptimizedImage,
AsyncPipe,
@ -45,24 +47,39 @@ import { Storage } from '../../../service/storage.service';
NgIf,
RouterLink,
MatButtonModule,
MatSlideToggleModule,
FormsModule
FormsModule,
MatCheckboxModule
],
templateUrl: './navigation.component.html',
styleUrls: ['./navigation.component.scss']
})
export class Navigation {
theme: any | undefined;
darkModeOn: boolean | undefined;
LIGHT_THEME: string = LIGHT_THEME;
DARK_THEME: string = DARK_THEME;
OS_SETTING: string = OS_SETTING;
currentUser$ = this.store.select(selectCurrentUser);
flowConfiguration$ = this.store.select(selectFlowConfiguration);
isDarkMode = false;
constructor(
private store: Store<NiFiState>,
private authStorage: AuthStorage,
private authService: AuthService,
private storage: Storage,
@Inject(DOCUMENT) private _document: Document
) {}
private themingService: ThemingService
) {
this.darkModeOn = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
this.theme = this.storage.getItem('theme');
if (window.matchMedia) {
// Watch for changes of the preference
window.matchMedia('(prefers-color-scheme: dark)').addListener((e) => {
this.darkModeOn = e.matches;
this.theme = this.storage.getItem('theme');
});
}
}
allowLogin(user: CurrentUser): boolean {
return user.anonymous && location.protocol === 'https:';
@ -97,14 +114,13 @@ export class Navigation {
}
getCanvasLink(): string {
const canvasRoute = this.storage.getItem('current-canvas-route');
const canvasRoute = this.storage.getItem<string>('current-canvas-route');
return canvasRoute || '/';
}
toggleTheme(value = !this.isDarkMode) {
this.isDarkMode = value;
this._document.body.classList.toggle('dark-theme', value);
// TODO: save to local storage or read OS settings???
toggleTheme(theme: string) {
this.theme = theme;
this.storage.setItem('theme', theme);
this.themingService.toggleTheme(!!this.darkModeOn, theme);
}
}

View File

@ -17,6 +17,7 @@
// Custom Colors following Material Design
// For more information: https://m2.material.io/design/color/the-color-system.html
@use '@angular/material' as mat;
// The $material-primary-light-palette define the PRIMARY AND ACCENT palettes for all Angular Material components used throughout Apache NiFi
$material-primary-light-palette: (
@ -390,3 +391,66 @@ $warn-dark-palette: (
A700: rgba(black, 0.87),
)
);
// Define the palettes for your theme
$material-primary-light: mat.define-palette($material-primary-light-palette);
$material-accent-light: mat.define-palette($material-primary-light-palette, A400, A100, A700);
$nifi-canvas-primary-light: mat.define-palette($nifi-canvas-light-palette);
$nifi-canvas-accent-light: mat.define-palette($nifi-canvas-accent-light-palette, 400, 100, 700);
$warn-light: mat.define-palette($warn-light-palette, 400, 100, 700);
// Create the theme objects. We can extract the values we need from the theme passed into the mixins.
$material-theme-light: mat.define-light-theme(
(
color: (
primary: $material-primary-light,
accent: $material-accent-light,
warn: $warn-light
),
//typography: mat.define-typography-config(), // TODO: typography
density: -3
)
);
$nifi-canvas-theme-light: mat.define-light-theme(
(
color: (
primary: $nifi-canvas-primary-light,
accent: $nifi-canvas-accent-light,
warn: $warn-light
),
//typography: mat.define-typography-config(), // TODO: typography
density: -3
)
);
// Create the color palettes used in our dark theme.
$material-primary-dark: mat.define-palette($material-primary-dark-palette);
$material-accent-dark: mat.define-palette($material-primary-dark-palette, A400, A100, A700);
$nifi-canvas-primary-dark: mat.define-palette($nifi-canvas-dark-palette);
$nifi-canvas-accent-dark: mat.define-palette($nifi-canvas-accent-dark-palette);
$warn-dark: mat.define-palette($warn-dark-palette, 600, 200, 800);
$material-theme-dark: mat.define-dark-theme(
(
color: (
primary: $material-primary-dark,
accent: $material-accent-dark,
warn: $warn-dark
),
density: 0,
typography: mat.define-typography-config(),
)
);
$nifi-canvas-theme-dark: mat.define-dark-theme(
(
color: (
primary: $nifi-canvas-primary-dark,
accent: $nifi-canvas-accent-dark,
warn: $warn-dark,
),
//typography: mat.define-typography-config(), // TODO: typography
density: -3
)
);

View File

@ -391,3 +391,67 @@ $warn-dark-palette: (
A700: rgba(black, 0.87),
)
);
// Define the palettes for your theme
$material-primary-light: mat.define-palette($material-primary-light-palette);
$material-accent-light: mat.define-palette($material-primary-light-palette, A400, A100, A700);
$nifi-canvas-primary-light: mat.define-palette($nifi-canvas-light-palette);
$nifi-canvas-accent-light: mat.define-palette($nifi-canvas-accent-light-palette, 400, 100, 700);
$warn-light: mat.define-palette($warn-light-palette, 400, 100, 700);
// Create the theme objects. We can extract the values we need from the theme passed into the mixins.
$material-theme-light: mat.define-light-theme(
(
color: (
primary: $material-primary-light,
accent: $material-accent-light,
warn: $warn-light
),
//typography: mat.define-typography-config(), // TODO: typography
density: -3
)
);
$nifi-canvas-theme-light: mat.define-light-theme(
(
color: (
primary: $nifi-canvas-primary-light,
accent: $nifi-canvas-accent-light,
warn: $warn-light
),
//typography: mat.define-typography-config(), // TODO: typography
density: -3
)
);
// Create the color palettes used in our dark theme.
$material-primary-dark: mat.define-palette($material-primary-dark-palette);
$material-accent-dark: mat.define-palette($material-primary-dark-palette, A400, A100, A700);
$nifi-canvas-primary-dark: mat.define-palette($nifi-canvas-dark-palette);
$nifi-canvas-accent-dark: mat.define-palette($nifi-canvas-accent-dark-palette);
$warn-dark: mat.define-palette($warn-dark-palette, 600, 200, 800);
$material-theme-dark: mat.define-dark-theme(
(
color: (
primary: $material-primary-dark,
accent: $material-accent-dark,
warn: $warn-dark
),
density: 0,
typography: mat.define-typography-config(),
)
);
$nifi-canvas-theme-dark: mat.define-dark-theme(
(
color: (
primary: $nifi-canvas-primary-dark,
accent: $nifi-canvas-accent-dark,
warn: $warn-dark,
),
//typography: mat.define-typography-config(), // TODO: typography
density: -3
)
);

View File

@ -456,38 +456,6 @@ $appFontPath: '~roboto-fontface/fonts';
}
}
// Define the palettes for your theme
$material-primary-light: mat.define-palette($material-primary-light-palette);
$material-accent-light: mat.define-palette($material-primary-light-palette, A400, A100, A700);
$nifi-canvas-primary-light: mat.define-palette($nifi-canvas-light-palette);
$nifi-canvas-accent-light: mat.define-palette($nifi-canvas-accent-light-palette, 400, 100, 700);
$warn-light: mat.define-palette($warn-light-palette, 400, 100, 700);
// Create the theme objects. We can extract the values we need from the theme passed into the mixins.
$material-theme-light: mat.define-light-theme(
(
color: (
primary: $material-primary-light,
accent: $material-accent-light,
warn: $warn-light
),
//typography: mat.define-typography-config(), // TODO: typography
density: -3
)
);
$nifi-canvas-theme-light: mat.define-light-theme(
(
color: (
primary: $nifi-canvas-primary-light,
accent: $nifi-canvas-accent-light,
warn: $warn-light
),
//typography: mat.define-typography-config(), // TODO: typography
density: -3
)
);
// only include this once (not needed for dark mode)
@include nifi-styles();
@ -538,37 +506,6 @@ $nifi-canvas-theme-light: mat.define-light-theme(
@include provenance-event-dialog.nifi-theme($material-theme-light);
.dark-theme {
// Create the color palettes used in our dark theme.
$material-primary-dark: mat.define-palette($material-primary-dark-palette);
$material-accent-dark: mat.define-palette($material-primary-dark-palette, A400, A100, A700);
$nifi-canvas-primary-dark: mat.define-palette($nifi-canvas-dark-palette);
$nifi-canvas-accent-dark: mat.define-palette($nifi-canvas-accent-dark-palette);
$warn-dark: mat.define-palette($warn-dark-palette, 600, 200, 800);
$material-theme-dark: mat.define-dark-theme(
(
color: (
primary: $material-primary-dark,
accent: $material-accent-dark,
warn: $warn-dark
),
density: 0,
typography: mat.define-typography-config(),
)
);
$nifi-canvas-theme-dark: mat.define-dark-theme(
(
color: (
primary: $nifi-canvas-primary-dark,
accent: $nifi-canvas-accent-dark,
warn: $warn-dark,
),
//typography: mat.define-typography-config(), // TODO: typography
density: -3
)
);
// Include the dark theme color styles.
@include mat.all-component-colors($material-theme-dark);