From 31303460f09f52635ec34aed5e716919e6813f10 Mon Sep 17 00:00:00 2001 From: matthieu-crouzet Date: Wed, 17 Apr 2024 15:34:34 +0200 Subject: [PATCH 1/3] feat(chrome-ext): support states --- apps/chrome-devtools/README.md | 2 + apps/chrome-devtools/package.json | 3 +- .../chrome-devtools/schemas/state.schema.json | 43 +++ .../src/app-devtools/app.component.html | 111 ++++--- .../src/app-devtools/app.component.ts | 80 +++-- .../config-panel-pres.component.ts | 11 +- .../debug-panel-pres.template.html | 1 + .../debug-panel/debug-panel.service.ts | 2 + .../localization-panel-pres.component.ts | 204 ++++++++----- .../localization-panel-pres.template.html | 82 ++--- .../state-panel/state-panel.component.ts | 282 ++++++++++++++++++ .../state-panel/state-panel.template.html | 55 ++++ .../theming-panel-pres.component.ts | 104 ++++--- .../theming-panel-pres.template.html | 27 +- .../config-form/config-form.component.ts | 7 + .../src/extension/background.ts | 122 +++++++- .../src/extension/interface.ts | 35 +++ apps/chrome-devtools/src/extension/options.ts | 16 + apps/chrome-devtools/src/manifest.json | 5 + apps/chrome-devtools/src/options.html | 14 + .../src/services/connection.service.ts | 19 +- apps/chrome-devtools/src/services/index.ts | 2 + .../src/services/localization.service.ts | 85 ++++++ .../src/services/state.service.ts | 195 ++++++++++++ .../src/styles/design-factory-custom.scss | 3 + apps/showcase/src/app/app.module.ts | 2 +- docs/dev-tools/chrome-devtools.md | 2 + .../devkit/application-devkit.interface.ts | 25 ++ .../application-devtools.message.service.ts | 53 +++- .../devkit/application-devtools.service.ts | 18 +- .../devkit/localization-devtools.service.ts | 5 +- .../src/devkit/styling-devkit.interface.ts | 5 + .../styling-devtools.message.service.ts | 5 + .../src/devkit/styling-devtools.service.ts | 14 + yarn.lock | 3 +- 35 files changed, 1388 insertions(+), 254 deletions(-) create mode 100644 apps/chrome-devtools/schemas/state.schema.json create mode 100644 apps/chrome-devtools/src/app-devtools/state-panel/state-panel.component.ts create mode 100644 apps/chrome-devtools/src/app-devtools/state-panel/state-panel.template.html create mode 100644 apps/chrome-devtools/src/extension/options.ts create mode 100644 apps/chrome-devtools/src/options.html create mode 100644 apps/chrome-devtools/src/services/localization.service.ts create mode 100644 apps/chrome-devtools/src/services/state.service.ts diff --git a/apps/chrome-devtools/README.md b/apps/chrome-devtools/README.md index 6867167501..2c3296fde2 100644 --- a/apps/chrome-devtools/README.md +++ b/apps/chrome-devtools/README.md @@ -8,6 +8,8 @@ The extension comes with the following features: - **Visual Testing toggle** - **Rule Engine current state**: rule engine state, rule engine logs, etc. - **Configuration**: display and modification of the application components configuration. +- **Theming**: display and modification of the application theming variables. +- **States**: save customization to be able to apply it later or share it. ## Details diff --git a/apps/chrome-devtools/package.json b/apps/chrome-devtools/package.json index fa6214b85a..a249d77642 100644 --- a/apps/chrome-devtools/package.json +++ b/apps/chrome-devtools/package.json @@ -15,7 +15,7 @@ "build": "yarn nx build chrome-devtools", "postbuild:patch": "yarn patch:package && yarn patch:manifest && yarn patch:extension", "patch:package": "cpy 'package.json' 'dist' && patch-package-json-main", - "copy:assets": "cpy 'src/manifest.json' 'src/devtools.html' dist --flat && cpy './src/assets/**' dist", + "copy:assets": "cpy 'src/manifest.json' 'src/devtools.html' 'src/options.html' dist --flat && cpy './src/assets/**' dist", "patch:manifest": "node scripts/sanitize-manifest.cjs", "build:extension": "tsc -b tsconfig.extension.json", "patch:extension": "node scripts/sanitize-extension.cjs && node scripts/set-manifest-version.cjs", @@ -87,6 +87,7 @@ "@angular/router": "~18.0.0", "@design-factory/design-factory": "~17.1.0", "@ng-bootstrap/ng-bootstrap": "^17.0.0", + "@ng-select/ng-select": "^12.0.7", "@ngrx/entity": "~18.0.0", "@ngrx/store": "~18.0.0", "@o3r/application": "workspace:^", diff --git a/apps/chrome-devtools/schemas/state.schema.json b/apps/chrome-devtools/schemas/state.schema.json new file mode 100644 index 0000000000..5faed105fa --- /dev/null +++ b/apps/chrome-devtools/schemas/state.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "OtterDevtoolsChromeExtensionState", + "description": "Schema of Otter Devtools chrome extension state", + "type": "object", + "required": [ + "color", + "colorContrast", + "name" + ], + "properties": { + "color": { + "type": "string" + }, + "colorContrast": { + "type": "string" + }, + "name": { + "type": "string" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "localizations": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "stylingVariables": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } +} diff --git a/apps/chrome-devtools/src/app-devtools/app.component.html b/apps/chrome-devtools/src/app-devtools/app.component.html index 0d6fabcd32..461e3e9ec5 100644 --- a/apps/chrome-devtools/src/app-devtools/app.component.html +++ b/apps/chrome-devtools/src/app-devtools/app.component.html @@ -1,42 +1,75 @@ - - +
+ +
+
+ + +
+ + {{item.name}} +
+
+ +
+ + {{item.name}} +
+
+
+
+
+
diff --git a/apps/chrome-devtools/src/app-devtools/app.component.ts b/apps/chrome-devtools/src/app-devtools/app.component.ts index b89bae5306..39a1ff4db2 100644 --- a/apps/chrome-devtools/src/app-devtools/app.component.ts +++ b/apps/chrome-devtools/src/app-devtools/app.component.ts @@ -1,21 +1,33 @@ -import { AsyncPipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, OnDestroy } from '@angular/core'; +import { AsyncPipe, JsonPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, effect, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { FormBuilder, FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { DfSelectModule, DfTooltipModule } from '@design-factory/design-factory'; import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; -import { type RulesetExecutionDebug, RulesetHistoryPresModule } from '@o3r/rules-engine'; -import { Observable, Subscription } from 'rxjs'; +import { RulesetHistoryPresModule } from '@o3r/rules-engine'; import { AppConnectionComponent } from '../components/app-connection/app-connection.component'; -import { ComponentPanelPresComponent } from './component-panel/component-panel-pres.component'; +import type { State } from '../extension/interface'; +import { StateService } from '../services'; import { ChromeExtensionConnectionService, isApplicationInformationMessage } from '../services/connection.service'; import { RulesetHistoryService } from '../services/ruleset-history.service'; +import { ComponentPanelPresComponent } from './component-panel/component-panel-pres.component'; import { ConfigPanelPresComponent } from './config-panel/config-panel-pres.component'; import { DebugPanelPresComponent } from './debug-panel/debug-panel-pres.component'; import { DebugPanelService } from './debug-panel/debug-panel.service'; import { LocalizationPanelPresComponent } from './localization-panel/localization-panel-pres.component'; +import { StatePanelComponent } from './state-panel/state-panel.component'; import { ThemingPanelPresComponent } from './theming-panel/theming-panel-pres.component'; @Component({ selector: 'app-root', templateUrl: './app.component.html', + styles: ` + ::ng-deep ng-select.local-change .ng-select-container { + border-color: var(--bs-recommend-warning-color); + border-width: medium; + box-sizing: content-box; + } + `, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ @@ -27,32 +39,50 @@ import { ThemingPanelPresComponent } from './theming-panel/theming-panel-pres.co AppConnectionComponent, LocalizationPanelPresComponent, ThemingPanelPresComponent, - AsyncPipe + AsyncPipe, + StatePanelComponent, + FormsModule, + ReactiveFormsModule, + DfSelectModule, + DfTooltipModule, + JsonPipe ] }) -export class AppComponent implements OnDestroy { - private readonly subscription = new Subscription(); +export class AppComponent { + private readonly stateService = inject(StateService); + private readonly formBuilder = inject(FormBuilder); + private readonly connectionService = inject(ChromeExtensionConnectionService); + private readonly debugPanelService = inject(DebugPanelService); + private readonly rulesetHistoryService = inject(RulesetHistoryService); - public rulesetExecutions$: Observable; + public readonly activeStateName = computed(() => this.stateService.activeState()?.name); + public readonly states = computed(() => Object.values(this.stateService.states())); + public readonly hasLocalChanges = this.stateService.hasLocalChanges; + public form = this.formBuilder.group({ + activeStateName: new FormControl(this.activeStateName()) + }); - constructor( - connectionService: ChromeExtensionConnectionService, - debugPanelService: DebugPanelService, - rulesetHistoryService: RulesetHistoryService - ) { - this.rulesetExecutions$ = rulesetHistoryService.rulesetExecutions$; + public rulesetExecutions$ = this.rulesetHistoryService.rulesetExecutions$; - this.subscription.add( - connectionService.message$.subscribe((message) => { - if (isApplicationInformationMessage(message)) { - debugPanelService.update(message); - } - }) - ); + constructor() { + effect(() => { + this.form.controls.activeStateName.setValue(this.activeStateName(), { emitEvent: false }); + }); + const message = toSignal(this.connectionService.message$); + effect(() => { + const msg = message(); + if (isApplicationInformationMessage(msg)) { + this.debugPanelService.update(msg); + } + }); + const activateStateNameFormValueChanges = toSignal(this.form.controls.activeStateName.valueChanges, { initialValue: this.activeStateName() }); + effect(() => { + const stateName = activateStateNameFormValueChanges(); + void this.stateService.setActiveState(stateName); + }, { allowSignalWrites: true }); } - /** @inheritDoc */ - public ngOnDestroy() { - this.subscription.unsubscribe(); + public stateCompareWithFn(state: State, selectedStateName: string) { + return state.name === selectedStateName; } } diff --git a/apps/chrome-devtools/src/app-devtools/config-panel/config-panel-pres.component.ts b/apps/chrome-devtools/src/app-devtools/config-panel/config-panel-pres.component.ts index 301aa0a3ce..808b22d292 100644 --- a/apps/chrome-devtools/src/app-devtools/config-panel/config-panel-pres.component.ts +++ b/apps/chrome-devtools/src/app-devtools/config-panel/config-panel-pres.component.ts @@ -4,9 +4,9 @@ import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angul import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; import { ConfigurationModel } from '@o3r/configuration'; import { combineLatest, Observable } from 'rxjs'; -import { filter, map, startWith } from 'rxjs/operators'; +import { map, startWith } from 'rxjs/operators'; import { ConfigFormComponent } from '../../components/config-form/config-form.component'; -import { ChromeExtensionConnectionService, isConfigurationsMessage } from '../../services/connection.service'; +import { ChromeExtensionConnectionService, filterAndMapMessage, isConfigurationsMessage } from '../../services/connection.service'; @Component({ selector: 'o3r-config-panel-pres', @@ -37,9 +37,10 @@ export class ConfigPanelPresComponent { } ); const configs$ = connectionService.message$.pipe( - filter(isConfigurationsMessage), - map((message) => Object.values(message.configurations) - .filter((config): config is ConfigurationModel => !!config) + filterAndMapMessage( + isConfigurationsMessage, + (message) => Object.values(message.configurations) + .filter((config): config is ConfigurationModel => !!config) ) ); this.filteredConfigs$ = combineLatest([ diff --git a/apps/chrome-devtools/src/app-devtools/debug-panel/debug-panel-pres.template.html b/apps/chrome-devtools/src/app-devtools/debug-panel/debug-panel-pres.template.html index 13d47c1946..a79fab9476 100644 --- a/apps/chrome-devtools/src/app-devtools/debug-panel/debug-panel-pres.template.html +++ b/apps/chrome-devtools/src/app-devtools/debug-panel/debug-panel-pres.template.html @@ -3,6 +3,7 @@

Information

-
+
+ @if (downloadState()) { +
+ {{downloadState()?.text}} +
+ }
    @for (state of states() | keyvalue; track state.key) {
  • diff --git a/apps/chrome-devtools/src/components/app-connection/app-connection.component.ts b/apps/chrome-devtools/src/components/app-connection/app-connection.component.ts index fb09ede3f7..05f69d4802 100644 --- a/apps/chrome-devtools/src/components/app-connection/app-connection.component.ts +++ b/apps/chrome-devtools/src/components/app-connection/app-connection.component.ts @@ -1,11 +1,7 @@ import { AsyncPipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, OnDestroy } from '@angular/core'; -import { Observable, of, Subscription } from 'rxjs'; -import { catchError, map, startWith, take, timeout } from 'rxjs/operators'; -import { ChromeExtensionConnectionService, isRuleEngineEventsMessage } from '../../services/connection.service'; -import { RulesetHistoryService } from '../../services/ruleset-history.service'; - -type AppState = 'loading' | 'timeout' | 'connected'; +import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core'; +import { Observable, Subscription } from 'rxjs'; +import { AppState, ChromeExtensionConnectionService } from '../../services/connection.service'; @Component({ selector: 'app-connection', @@ -17,32 +13,14 @@ type AppState = 'loading' | 'timeout' | 'connected'; ] }) export class AppConnectionComponent implements OnDestroy { - /** Stream of application's state */ - public appState$: Observable; - private readonly subscription = new Subscription(); + private readonly connectionService = inject(ChromeExtensionConnectionService); - constructor( - connectionService: ChromeExtensionConnectionService, - rulesetHistoryService: RulesetHistoryService - ) { - this.subscription.add( - connectionService.message$.subscribe((message) => { - if (isRuleEngineEventsMessage(message)) { - rulesetHistoryService.update(message); - } - }) - ); - - this.appState$ = connectionService.message$.pipe( - map(() => 'connected' as AppState), - take(1), - startWith('loading' as AppState), - timeout(3000), - catchError(() => of('timeout' as AppState)) - ); + /** Stream of application's state */ + public appState$: Observable = this.connectionService.appState$; - connectionService.activate(); + constructor() { + this.connectionService.activate(); } /** @inheritDoc */ diff --git a/apps/chrome-devtools/src/services/connection.service.ts b/apps/chrome-devtools/src/services/connection.service.ts index c2de597a59..9f3653dbf2 100644 --- a/apps/chrome-devtools/src/services/connection.service.ts +++ b/apps/chrome-devtools/src/services/connection.service.ts @@ -2,8 +2,18 @@ import { ApplicationRef, Injectable, OnDestroy } from '@angular/core'; import type { Dictionary } from '@ngrx/entity'; import type { ConfigurationModel } from '@o3r/configuration'; import { otterMessageType } from '@o3r/core'; -import { type Observable, ReplaySubject, Subscription } from 'rxjs'; -import { debounceTime, distinctUntilChanged, filter, map, shareReplay } from 'rxjs/operators'; +import {type Observable, of, ReplaySubject, Subscription} from 'rxjs'; +import { + catchError, + debounceTime, + distinctUntilChanged, + filter, + map, + shareReplay, + startWith, + take, + timeout +} from 'rxjs/operators'; import type { AvailableMessageContents } from './message.interface'; import type { ApplicationInformationContentMessage } from '@o3r/application'; @@ -59,6 +69,8 @@ export const filterAndMapMessage = ( shareReplay({ refCount: true, bufferSize: 1 }) ); +export type AppState = 'loading' | 'timeout' | 'connected'; + /** * Service to communicate with the current tab */ @@ -71,6 +83,15 @@ export class ChromeExtensionConnectionService implements OnDestroy { /** Stream of messages received from the service worker */ public message$ = this.messageSubject.asObservable(); + /** Stream the state of the extension connection to the Otter application*/ + public appState$ = this.message$.pipe( + map(() => 'connected' as AppState), + take(1), + startWith('loading' as AppState), + timeout(3000), + catchError(() => of('timeout' as AppState)) + ); + private readonly configurations = new ReplaySubject>(1); public configurations$ = this.configurations.asObservable(); diff --git a/apps/chrome-devtools/src/services/localization.service.ts b/apps/chrome-devtools/src/services/localization.service.ts index 1d856e1572..66295b0500 100644 --- a/apps/chrome-devtools/src/services/localization.service.ts +++ b/apps/chrome-devtools/src/services/localization.service.ts @@ -13,6 +13,7 @@ import { ChromeExtensionConnectionService, filterAndMapMessage } from './connect @Injectable({ providedIn: 'root' }) export class LocalizationService { private readonly connectionService = inject(ChromeExtensionConnectionService); + private readonly lang = signal(undefined); public readonly localizationsMetadata = toSignal( this.connectionService.message$.pipe( filterAndMapMessage( @@ -40,7 +41,6 @@ export class LocalizationService { ), { initialValue: false } ); - private readonly lang = signal(undefined); public readonly currentLanguage = this.lang.asReadonly(); public readonly translationsForCurrentLanguage: Signal> = toSignal( @@ -55,9 +55,29 @@ export class LocalizationService { ); constructor() { + const activated = toSignal(this.connectionService.appState$); + effect(() => { + if (activated() === 'connected') { + this.connectionService.sendMessage( + 'requestMessages', + { + only: [ + 'localizations', + 'languages', + 'switchLanguage', + 'isTranslationDeactivationEnabled' + ] + } + ); + } + }); + effect(() => { - this.connectionService.sendMessage('switchLanguage', { language: this.currentLanguage() }); - this.connectionService.sendMessage('requestMessages', { only: ['getTranslationValuesContentMessage'] }); + const currentLanguage = this.currentLanguage(); + if (currentLanguage) { + this.connectionService.sendMessage('switchLanguage', { language: this.currentLanguage() }); + this.connectionService.sendMessage('requestMessages', { only: ['getTranslationValuesContentMessage'] }); + } }); const externalSwitchLanguage = toSignal( this.connectionService.message$.pipe( diff --git a/apps/chrome-devtools/src/services/ruleset-history.service.ts b/apps/chrome-devtools/src/services/ruleset-history.service.ts index d4a6cc596e..b523930fc9 100644 --- a/apps/chrome-devtools/src/services/ruleset-history.service.ts +++ b/apps/chrome-devtools/src/services/ruleset-history.service.ts @@ -1,14 +1,17 @@ -import { Injectable } from '@angular/core'; +import { effect, inject, Injectable } from '@angular/core'; import type { RulesEngineDebugEventsContentMessage, RulesetExecutionDebug } from '@o3r/rules-engine'; import { rulesetReportToHistory } from '@o3r/rules-engine'; -import { map, Observable, ReplaySubject } from 'rxjs'; -import {shareReplay} from 'rxjs/operators'; +import { filter, map, Observable, ReplaySubject } from 'rxjs'; +import { shareReplay } from 'rxjs/operators'; +import { ChromeExtensionConnectionService, isRuleEngineEventsMessage } from './connection.service'; +import { toSignal } from '@angular/core/rxjs-interop'; @Injectable({ providedIn: 'root' }) export class RulesetHistoryService { private readonly ruleEngineDebugEventsSubject = new ReplaySubject(1); + private readonly connectionService = inject(ChromeExtensionConnectionService); /** Ruleset history stream */ public readonly ruleEngineDebugEvents$ = this.ruleEngineDebugEventsSubject.asObservable(); @@ -21,6 +24,16 @@ export class RulesetHistoryService { shareReplay({bufferSize: 1, refCount: true}) ); + constructor() { + const extensionMessage = toSignal(this.connectionService.message$.pipe(filter(isRuleEngineEventsMessage))); + effect(() => { + const message = extensionMessage(); + if (message) { + this.update(message); + } + }); + } + /** * Update the ruleset history * @param message Message from the background service diff --git a/apps/chrome-devtools/src/services/state.service.ts b/apps/chrome-devtools/src/services/state.service.ts index 41a3678f16..dc4b9b8a22 100644 --- a/apps/chrome-devtools/src/services/state.service.ts +++ b/apps/chrome-devtools/src/services/state.service.ts @@ -1,10 +1,14 @@ -import { computed, effect, inject, Injectable, signal, type Signal, untracked } from '@angular/core'; +import { computed, effect, inject, Injectable, signal, type Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { ChromeExtensionConnectionService, filterAndMapMessage, isApplicationInformationMessage } from './connection.service'; +import { + ChromeExtensionConnectionService, + filterAndMapMessage, + isApplicationInformationMessage +} from './connection.service'; import { ACTIVE_STATE_NAME_KEY, type State, type StateOverride, STATES_KEY } from '../extension/interface'; import { LocalizationService } from './localization.service'; -@Injectable({ providedIn: 'root' }) +@Injectable({providedIn: 'root'}) export class StateService { private readonly connectionService = inject(ChromeExtensionConnectionService); private readonly localizationService = inject(LocalizationService); @@ -75,12 +79,12 @@ export class StateService { effect(() => { const state = this.activeState(); + const languages = this.languages(); this.updateLocalState(state || {}, true); // TODO reset configuration (is it possible? based on default value from metadata if present?) - // Reset all languages before applying override of the new state - untracked(this.languages).forEach((lang) => this.connectionService.sendMessage('reloadLocalizationKeys', { lang })); // Reset all styling variables before applying override of the new state this.connectionService.sendMessage('resetStylingVariables', {}); + languages.forEach((lang) => this.connectionService.sendMessage('reloadLocalizationKeys', {lang})); if (!state) { this.connectionService.sendMessage('unselectState', {}); return;