diff --git a/.eslint/.eslintrc.module-boundaries.client.js b/.eslint/.eslintrc.module-boundaries.client.js index c5bb6f5a..dcede865 100644 --- a/.eslint/.eslintrc.module-boundaries.client.js +++ b/.eslint/.eslintrc.module-boundaries.client.js @@ -36,6 +36,10 @@ exports.constraints = [ onlyDependOnLibsWithTags: [], sourceTag: 'scope:client-material', }, + { + onlyDependOnLibsWithTags: [], + sourceTag: 'scope:client-guided-tour', + }, { onlyDependOnLibsWithTags: [], sourceTag: 'scope:client-pwa-offline', @@ -221,6 +225,7 @@ exports.constraints = [ 'scope:client-diagnostics', 'scope:client-gql', 'scope:client-grpc', + 'scope:client-guided-tour', 'scope:client-material', 'scope:client-pwa-offline', 'scope:client-service-worker', diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 0e59434a..b0502379 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -63,6 +63,28 @@ jobs: library-name: client-d3-charts npm-automation-token: ${{ secrets.NPM_AUTOMATION_TOKEN }} + publish-client-guided-tour: + needs: checks + if: ${{ needs.checks.outputs.origin == 'true' && fromJSON(needs.checks.outputs.changes).packageGuidedTour == 'true' }} + runs-on: ubuntu-latest + + steps: + - name: Checkout sources + uses: actions/checkout@v3 + + - name: Setup environment + uses: ./.github/actions/setup-environment + with: + compodoc: false + cypress: false + firebase-tools: false + + - name: NPM publish + uses: ./.github/actions/npm-publish + with: + library-name: client-guided-tour + npm-automation-token: ${{ secrets.NPM_AUTOMATION_TOKEN }} + publish-client-pwa-offline: needs: checks if: ${{ needs.checks.outputs.origin == 'true' && fromJSON(needs.checks.outputs.changes).packagePwaOffline == 'true' }} diff --git a/README.md b/README.md index fbccb2b3..d5ddff28 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ npx nx generate client-feature client- --tags=scope:client- --tags=scope:client-ui-,type:ui +npx nx generate client-ui client- --tags=scope:client-,type:ui ``` #### `data-access` library diff --git a/apps/client/src/app/client-routing.module.ts b/apps/client/src/app/client-routing.module.ts index 60f8e94f..7257c82a 100644 --- a/apps/client/src/app/client-routing.module.ts +++ b/apps/client/src/app/client-routing.module.ts @@ -24,6 +24,11 @@ export const routes: Route[] = [ data: { feature: 'chatbot', icon: 'chat' }, loadChildren: () => import('@app/client-chatbot').then(mod => mod.AppChatbotModule), }, + { + path: 'guided-tour', + data: { feature: 'guided-tour', icon: 'tour' }, + loadChildren: () => import('@app/client-guided-tour').then(mod => mod.AppGuidedTourExampleModule), + }, { path: '', outlet: 'sidebar', diff --git a/libs/client-dashboards/src/lib/dashboards.module.ts b/libs/client-dashboards/src/lib/dashboards.module.ts index b6fb39e1..5922db69 100644 --- a/libs/client-dashboards/src/lib/dashboards.module.ts +++ b/libs/client-dashboards/src/lib/dashboards.module.ts @@ -19,8 +19,8 @@ import { AppTableControlsModule } from './modules/table-controls/table-controls. MatButtonModule, MatIconModule, AppTableControlsModule, - AppDashboardsRoutingModule, AppDirectivesModule, + AppDashboardsRoutingModule, ], declarations: [AppDashboardsComponent, AppTableComponent], }) diff --git a/libs/client-guided-tour/.eslintrc.json b/libs/client-guided-tour/.eslintrc.json new file mode 100644 index 00000000..3a51d69a --- /dev/null +++ b/libs/client-guided-tour/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../.eslintrc.js", "../../.eslintrc.angular.js"], + "ignorePatterns": ["!**/*"] +} diff --git a/libs/client-guided-tour/LICENSE b/libs/client-guided-tour/LICENSE new file mode 100644 index 00000000..c71e3a1f --- /dev/null +++ b/libs/client-guided-tour/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2023 rfprod. + +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. diff --git a/libs/client-guided-tour/README.md b/libs/client-guided-tour/README.md new file mode 100644 index 00000000..edcb99c9 --- /dev/null +++ b/libs/client-guided-tour/README.md @@ -0,0 +1,117 @@ +# Guided Tour Module for Angular + +Guided tour feature for Angular clients. + +## Description + +TBD + +## Usage + +### Within the development workspace + +Import the module + +```typescript +... +import { AppGuidedTourModule } from '@app/client-guided-tour'; + +@NgModule({ + imports: [ + ... + AppGuidedTourModule, + ... + ], + ... +}) +export class AppModule {} +``` + +### As an external package + +Install the package + +```bash +yarn add @rfprodz/client-guided-tour @angular/cdk @angular/common @angular/core @angular/material +``` + +Import the module + +```typescript +... +import { AppGuidedTourModule } from '@rfprodz/client-guided-tour'; + +@NgModule({ + imports: [ + ... + AppGuidedTourModule, + ... + ], + ... +}) +export class AppModule {} +``` + +Use in components + +```html +... +

... Some content ...

+ +
first item to explain
+ +

... Some content ...

+ +
second item to explain
+... +``` + +```typescript +@Component({ + ... + providers: [AppGuidedTourService], // <-- declare a service + ... +}) +export class GuidedComponent implements AfterViewInit { + /** Locate steps. */ + @ViewChildren(AppGuidedTourDirective) public steps!: QueryList; + + /** Configure the tour. */ + public tourConig$ = signal([ + { + index: 0, + title: 'first', + subtitle: 'First step', + description: 'The first step. Highlighting disabled.', + first: true, + last: false, + }, + { + index: 1, + title: 'second', + subtitle: 'Second step', + description: 'The second step. Highlighting enabled.', + first: false, + last: true, + }, + ]); + + constructor(public readonly tour: AppGuidedTourService) {} + + public ngAfterViewInit(): void { + this.tour.configuration = this.steps; // <-- add steps to the service + } +} +``` + +## Developer commands reference + +```bash +npx nx run tools:help --search client-guided-tour: +``` + +## References + +- [Nx](https://nx.dev) +- [Angular](https://angular.io) +- [Angular Material](https://material.angular.io) diff --git a/libs/client-guided-tour/jest.config.ts b/libs/client-guided-tour/jest.config.ts new file mode 100644 index 00000000..4e4cec70 --- /dev/null +++ b/libs/client-guided-tour/jest.config.ts @@ -0,0 +1,22 @@ +import { Config } from '@jest/types'; + +const jestPresetAngularSerializers = require('jest-preset-angular/build/serializers'); + +const config: Config.InitialOptions = { + coverageDirectory: '../../coverage/libs/client-guided-tour', + coverageThreshold: { + // TODO: bump unit test coverage and remove this override + global: { + branches: 0, + functions: 27, + lines: 32, + statements: 35, + }, + }, + displayName: 'client-guided-tour', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + snapshotSerializers: jestPresetAngularSerializers, +}; + +export default config; diff --git a/libs/client-guided-tour/ng-package.json b/libs/client-guided-tour/ng-package.json new file mode 100644 index 00000000..b96ca3fb --- /dev/null +++ b/libs/client-guided-tour/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/libs/client-guided-tour", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/client-guided-tour/package.json b/libs/client-guided-tour/package.json new file mode 100644 index 00000000..b8424968 --- /dev/null +++ b/libs/client-guided-tour/package.json @@ -0,0 +1,31 @@ +{ + "name": "@rfprodz/client-guided-tour", + "version": "1.0.0", + "description": "Guided tour feature for Angular clients.", + "keywords": [ + "angular-module", + "angular-directive", + "angular-component", + "angular-service" + ], + "homepage": "https://github.com/rfprod/nx-ng-starter", + "bugs": { + "url": "https://github.com/rfprod/nx-ng-starter/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/rfprod/nx-ng-starter" + }, + "license": "MIT", + "author": "rfprod ", + "dependencies": { + "tslib": "2.6.2" + }, + "peerDependencies": { + "@angular/cdk": "17.0.3", + "@angular/common": "17.0.6", + "@angular/core": "17.0.6", + "@angular/material": "17.0.3", + "@angular/router": "17.0.6" + } +} diff --git a/libs/client-guided-tour/project.json b/libs/client-guided-tour/project.json new file mode 100644 index 00000000..9ecc8055 --- /dev/null +++ b/libs/client-guided-tour/project.json @@ -0,0 +1,62 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "client-guided-tour", + "prefix": "app", + "projectType": "library", + "sourceRoot": "libs/client-guided-tour/src", + "tags": ["scope:client-guided-tour", "type:ui"], + "targets": { + "build": { + "configurations": { + "development": { + "tsConfig": "libs/client-guided-tour/tsconfig.lib.json" + }, + "production": { + "tsConfig": "libs/client-guided-tour/tsconfig.lib.prod.json" + } + }, + "defaultConfiguration": "production", + "executor": "@angular-devkit/build-angular:ng-packagr", + "options": { + "project": "libs/client-guided-tour/ng-package.json" + }, + "outputs": ["{workspaceRoot}/dist/libs/client-guided-tour"] + }, + "lint": { + "executor": "@angular-eslint/builder:lint", + "options": { + "eslintConfig": "libs/client-guided-tour/.eslintrc.json", + "lintFilePatterns": ["libs/client-guided-tour/**/*.ts"] + }, + "outputs": ["{options.outputFile}"] + }, + "prettier-check": { + "executor": "./tools/executors/prettier:check", + "options": { + "config": "" + }, + "outputs": ["{workspaceRoot}/dist/prettier/libs/client-guided-tour"] + }, + "stylelint-check": { + "executor": "./tools/executors/stylelint:check", + "options": { + "config": "" + }, + "outputs": ["{workspaceRoot}/dist/stylelint/libs/client-guided-tour"] + }, + "test": { + "executor": "@nx/jest:jest", + "options": { + "jestConfig": "libs/client-guided-tour/jest.config.ts" + }, + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"] + }, + "tsc-check": { + "executor": "./tools/executors/tsc:check", + "options": { + "tsConfig": "libs/client-guided-tour/tsconfig.lib.json" + }, + "outputs": ["{workspaceRoot}/dist/out-tsc/libs/client-guided-tour"] + } + } +} diff --git a/libs/client-guided-tour/src/index.ts b/libs/client-guided-tour/src/index.ts new file mode 100644 index 00000000..cf807b20 --- /dev/null +++ b/libs/client-guided-tour/src/index.ts @@ -0,0 +1,4 @@ +export * from './lib/components'; +export * from './lib/guided-tour.module'; +export * from './lib/guided-tour-example.module'; +export * from './lib/services'; diff --git a/libs/client-guided-tour/src/lib/components/guided-tour-example/guided-tour-example.component.html b/libs/client-guided-tour/src/lib/components/guided-tour-example/guided-tour-example.component.html new file mode 100644 index 00000000..ee73d279 --- /dev/null +++ b/libs/client-guided-tour/src/lib/components/guided-tour-example/guided-tour-example.component.html @@ -0,0 +1,21 @@ +

... Some content ...

+ + +
second item to explain
+ +

... Some content ...

+ +
third item to explain
+ +

... Some content ...

+ +
first item to explain
+
+ +

... Some content ...

+ +@if (!tour.active) { + +} @else { + +} diff --git a/libs/client-guided-tour/src/lib/components/guided-tour-example/guided-tour-example.component.scss b/libs/client-guided-tour/src/lib/components/guided-tour-example/guided-tour-example.component.scss new file mode 100644 index 00000000..7ffd8ed7 --- /dev/null +++ b/libs/client-guided-tour/src/lib/components/guided-tour-example/guided-tour-example.component.scss @@ -0,0 +1,4 @@ +:host { + display: block; + padding: 16px; +} diff --git a/libs/client-guided-tour/src/lib/components/guided-tour-example/guided-tour-example.component.spec.ts b/libs/client-guided-tour/src/lib/components/guided-tour-example/guided-tour-example.component.spec.ts new file mode 100644 index 00000000..b9223e7b --- /dev/null +++ b/libs/client-guided-tour/src/lib/components/guided-tour-example/guided-tour-example.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AppGuidedTourExampleComponent } from './guided-tour-example.component'; + +describe('AppGuidedTourExampleComponent', () => { + let component: AppGuidedTourExampleComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [AppGuidedTourExampleComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AppGuidedTourExampleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/client-guided-tour/src/lib/components/guided-tour-example/guided-tour-example.component.ts b/libs/client-guided-tour/src/lib/components/guided-tour-example/guided-tour-example.component.ts new file mode 100644 index 00000000..26a57ad7 --- /dev/null +++ b/libs/client-guided-tour/src/lib/components/guided-tour-example/guided-tour-example.component.ts @@ -0,0 +1,55 @@ +import { AfterViewInit, ChangeDetectionStrategy, Component, QueryList, signal, ViewChildren } from '@angular/core'; + +import { AppGuidedTourService } from '../../services/guided-tour/guided-tour.service'; +import { AppGuidedTourDirective } from '../guided-tour/guided-tour.directive'; +import { IGuidedTourData } from '../guided-tour/guided-tour.interface'; + +/** An example of a guided tour. */ +@Component({ + selector: 'app-guided-tour-example', + templateUrl: './guided-tour-example.component.html', + styleUrls: ['./guided-tour-example.component.scss'], + providers: [AppGuidedTourService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AppGuidedTourExampleComponent implements AfterViewInit { + /** Tour steps. */ + @ViewChildren(AppGuidedTourDirective) public steps!: QueryList; + + /** Example tour configuration. */ + public tourConig$ = signal([ + { + index: 0, + title: 'first', + subtitle: 'First step', + description: 'The first step. Highlighting disabled.', + first: true, + last: false, + }, + { + index: 1, + title: 'second', + subtitle: 'Second step', + description: 'The second step. Highlighting enabled.', + first: false, + last: false, + }, + { + index: 2, + title: 'third', + subtitle: 'Third step', + description: 'The final step. Highlighting enabled.', + first: false, + last: true, + }, + ]); + + /** + * @param tour Guided tour service. + */ + constructor(public readonly tour: AppGuidedTourService) {} + + public ngAfterViewInit(): void { + this.tour.configuration = this.steps; + } +} diff --git a/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.component.html b/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.component.html new file mode 100644 index 00000000..0eaf0f2e --- /dev/null +++ b/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.component.html @@ -0,0 +1,27 @@ + + + {{ data.title }} + {{ data.subtitle }} + + + +

{{ data.description }}

+
+ + + + + + + + +
diff --git a/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.component.scss b/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.component.scss new file mode 100644 index 00000000..1074557e --- /dev/null +++ b/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.component.scss @@ -0,0 +1,9 @@ +:host { + display: block; +} + +.actions { + & > *:not(:first-child):not(:last-child) { + margin: 0 6px; + } +} diff --git a/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.component.spec.ts b/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.component.spec.ts new file mode 100644 index 00000000..c343a149 --- /dev/null +++ b/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.component.spec.ts @@ -0,0 +1,36 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AppGuidedTourComponent } from './guided-tour.component'; +import { GUIDED_TOUR_DATA, IGuidedTourData } from './guided-tour.interface'; + +describe('AppGuidedTourComponent', () => { + let component: AppGuidedTourComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [AppGuidedTourComponent], + providers: [ + { + provide: GUIDED_TOUR_DATA, + useValue: { + index: 0, + title: 'title', + subtitle: 'subtitle', + description: 'description', + first: true, + last: true, + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AppGuidedTourComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.component.ts b/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.component.ts new file mode 100644 index 00000000..6f49714a --- /dev/null +++ b/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.component.ts @@ -0,0 +1,22 @@ +import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; + +import { AppGuidedTourService } from '../../services/guided-tour/guided-tour.service'; +import { GUIDED_TOUR_DATA, IGuidedTourData } from './guided-tour.interface'; + +/** Guided tour component. */ +@Component({ + selector: 'app-guided-tour', + templateUrl: './guided-tour.component.html', + styleUrls: ['./guided-tour.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AppGuidedTourComponent { + /** + * @param tour Guided tour service. + * @param data Guided tour step data. + */ + constructor( + public readonly tour: AppGuidedTourService, + @Inject(GUIDED_TOUR_DATA) public readonly data: IGuidedTourData, + ) {} +} diff --git a/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.directive.ts b/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.directive.ts new file mode 100644 index 00000000..1915b08b --- /dev/null +++ b/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.directive.ts @@ -0,0 +1,183 @@ +import { ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { AfterContentInit, Directive, ElementRef, Injector, Input, OnDestroy, ViewContainerRef } from '@angular/core'; + +import { OVERLAY_REFERENCE } from '../../providers/overlay.provider'; +import { AppGuidedTourService } from '../../services/guided-tour/guided-tour.service'; +import { AppGuidedTourComponent } from './guided-tour.component'; +import { GUIDED_TOUR_DATA, IGuidedTourData } from './guided-tour.interface'; + +/** Guided tour directive. */ +@Directive({ + selector: '[appGuidedTour]', +}) +export class AppGuidedTourDirective implements AfterContentInit, OnDestroy { + /** Guided tour step configuration. */ + @Input() public appGuidedTour: IGuidedTourData | undefined = void 0; + + @Input() public highlightElement = false; + + /** Connected positions configuration. */ + @Input() public flexibleConnectedPositions: ConnectedPosition[] = [ + // below origin + { + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + }, + { + originX: 'center', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top', + }, + { + originX: 'center', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + }, + // above origin + { + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + }, + { + originX: 'center', + originY: 'top', + overlayX: 'end', + overlayY: 'bottom', + }, + { + originX: 'center', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + }, + ]; + + /** Scroll strategy. */ + @Input() public scrollStrategy: 'block' | 'close' | 'noop' | 'reposition' = 'reposition'; + + /** Native element ref. */ + private nativeElement?: HTMLElement; + + /** Overlay reference. */ + private overlayRef: OverlayRef | null = null; + + /** + * @param el A wrapper around a native element inside of a View. + * @param overlay Service to create Overlays. + * @param overlayConfig Initial configuration used when creating an overlay. + * @param viewContainerRef Represents a container where one or more views can be attached to a component. + * @param tour Guided tour service. + */ + constructor( + private readonly el: ElementRef, + private readonly overlay: Overlay, + private readonly overlayConfig: OverlayConfig, + private readonly viewContainerRef: ViewContainerRef, + private readonly tour: AppGuidedTourService, + ) {} + + /** Overlay configurator. */ + private configureOverlay(): void { + const positionHost = this.nativeElement; + if (typeof positionHost !== 'undefined') { + this.overlayConfig.hasBackdrop = false; + this.overlayConfig.panelClass = ''; + this.overlayConfig.minHeight = void 0; + this.overlayConfig.minWidth = void 0; + this.overlayConfig.maxHeight = void 0; + this.overlayConfig.maxWidth = void 0; + this.overlayConfig.height = void 0; + this.overlayConfig.width = void 0; + switch (this.scrollStrategy) { + case 'block': + this.overlayConfig.scrollStrategy = this.overlay.scrollStrategies.block(); + break; + case 'close': + this.overlayConfig.scrollStrategy = this.overlay.scrollStrategies.close(); + break; + case 'noop': + this.overlayConfig.scrollStrategy = this.overlay.scrollStrategies.noop(); + break; + case 'reposition': + default: + this.overlayConfig.scrollStrategy = this.overlay.scrollStrategies.reposition(); + break; + } + this.overlayConfig.positionStrategy = this.overlay + .position() + .flexibleConnectedTo(positionHost) + .setOrigin(positionHost) + .withPositions(this.flexibleConnectedPositions); + } + } + + /** Overlay creator. */ + private createOverlay(): OverlayRef | null { + if (this.overlayRef === null) { + this.configureOverlay(); + this.overlayRef = this.overlay.create(this.overlayConfig); + } + return this.overlayRef; + } + + /** Disposes an overlay. */ + public dispose(): void { + if (this.overlayRef !== null) { + void this.overlayRef?.detach(); + void this.overlayRef?.dispose(); + this.overlayRef = null; + this.decorateNativeEl(true); + } + } + + /** Displays an overlay. */ + public display(): void { + if (typeof this.appGuidedTour !== 'undefined' && !this.overlayRef?.hasAttached) { + const overlayRef = this.createOverlay(); + const context = Injector.create({ + providers: [ + { + provide: OVERLAY_REFERENCE, + useValue: overlayRef, + }, + { + provide: GUIDED_TOUR_DATA, + useValue: { ...this.appGuidedTour }, + }, + { + provide: AppGuidedTourService, + useValue: this.tour, + }, + ], + }); + const portal = new ComponentPortal(AppGuidedTourComponent, this.viewContainerRef, context); + void this.overlayRef?.attach(portal); + this.decorateNativeEl(); + } + } + + private decorateNativeEl(reset?: boolean) { + if (typeof this.nativeElement !== 'undefined' && this.highlightElement) { + if (reset === true) { + this.nativeElement.style.border = 'unset'; + return; + } + this.nativeElement.style.border = '1px dotted black'; + } + } + + public ngAfterContentInit(): void { + this.nativeElement = this.el.nativeElement; + } + + public ngOnDestroy(): void { + this.dispose(); + } +} diff --git a/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.interface.ts b/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.interface.ts new file mode 100644 index 00000000..4aaa02cc --- /dev/null +++ b/libs/client-guided-tour/src/lib/components/guided-tour/guided-tour.interface.ts @@ -0,0 +1,27 @@ +import { InjectionToken, Provider } from '@angular/core'; + +/** Guided tour step data. */ +export interface IGuidedTourData { + index: number; + title: string; + subtitle: string; + description: string; + first: boolean; + last: boolean; +} + +/** Guided tour step data injection token. */ +export const GUIDED_TOUR_DATA = new InjectionToken('GUIDED_TOUR_DATA'); + +/** Guided tour step data provider. */ +export const guidedTourDataProvider: Provider = { + provide: GUIDED_TOUR_DATA, + useValue: { + index: 0, + title: '', + subtitle: '', + description: '', + first: true, + last: true, + }, +}; diff --git a/libs/client-guided-tour/src/lib/components/index.ts b/libs/client-guided-tour/src/lib/components/index.ts new file mode 100644 index 00000000..0e536c68 --- /dev/null +++ b/libs/client-guided-tour/src/lib/components/index.ts @@ -0,0 +1,2 @@ +export * from './guided-tour/guided-tour.directive'; +export * from './guided-tour/guided-tour.interface'; diff --git a/libs/client-guided-tour/src/lib/guided-tour-example-routing.module.ts b/libs/client-guided-tour/src/lib/guided-tour-example-routing.module.ts new file mode 100644 index 00000000..7c4c5c03 --- /dev/null +++ b/libs/client-guided-tour/src/lib/guided-tour-example-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { Route, RouterModule } from '@angular/router'; + +import { AppGuidedTourExampleComponent } from './components/guided-tour-example/guided-tour-example.component'; + +const routes: Route[] = [ + { + path: '', + component: AppGuidedTourExampleComponent, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class AppGuidedTourExampleRoutingModule {} diff --git a/libs/client-guided-tour/src/lib/guided-tour-example.module.ts b/libs/client-guided-tour/src/lib/guided-tour-example.module.ts new file mode 100644 index 00000000..edcf71b4 --- /dev/null +++ b/libs/client-guided-tour/src/lib/guided-tour-example.module.ts @@ -0,0 +1,14 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; + +import { AppGuidedTourExampleComponent } from './components/guided-tour-example/guided-tour-example.component'; +import { AppGuidedTourModule } from './guided-tour.module'; +import { AppGuidedTourExampleRoutingModule } from './guided-tour-example-routing.module'; + +@NgModule({ + imports: [CommonModule, MatButtonModule, MatIconModule, AppGuidedTourModule, AppGuidedTourExampleRoutingModule], + declarations: [AppGuidedTourExampleComponent], +}) +export class AppGuidedTourExampleModule {} diff --git a/libs/client-guided-tour/src/lib/guided-tour.module.ts b/libs/client-guided-tour/src/lib/guided-tour.module.ts new file mode 100644 index 00000000..e14266dd --- /dev/null +++ b/libs/client-guided-tour/src/lib/guided-tour.module.ts @@ -0,0 +1,18 @@ +import { OverlayModule } from '@angular/cdk/overlay'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; + +import { AppGuidedTourComponent } from './components/guided-tour/guided-tour.component'; +import { AppGuidedTourDirective } from './components/guided-tour/guided-tour.directive'; +import { overlayProvider } from './providers/overlay.provider'; + +@NgModule({ + imports: [CommonModule, OverlayModule, MatButtonModule, MatIconModule, MatCardModule], + declarations: [AppGuidedTourComponent, AppGuidedTourDirective], + providers: [overlayProvider], + exports: [AppGuidedTourDirective], +}) +export class AppGuidedTourModule {} diff --git a/libs/client-guided-tour/src/lib/providers/overlay.provider.ts b/libs/client-guided-tour/src/lib/providers/overlay.provider.ts new file mode 100644 index 00000000..a4539239 --- /dev/null +++ b/libs/client-guided-tour/src/lib/providers/overlay.provider.ts @@ -0,0 +1,14 @@ +import { OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { InjectionToken, Provider } from '@angular/core'; + +/** The overlay reference injection token. */ +export const OVERLAY_REFERENCE = new InjectionToken('OverlayReference'); + +/** Overlay config provider. */ +export const overlayProvider: Provider = { + provide: OverlayConfig, + useFactory: () => + new OverlayConfig({ + direction: 'ltr', + }), +}; diff --git a/libs/client-guided-tour/src/lib/services/guided-tour/guided-tour.service.ts b/libs/client-guided-tour/src/lib/services/guided-tour/guided-tour.service.ts new file mode 100644 index 00000000..b2829451 --- /dev/null +++ b/libs/client-guided-tour/src/lib/services/guided-tour/guided-tour.service.ts @@ -0,0 +1,53 @@ +import { Injectable, QueryList } from '@angular/core'; + +import { AppGuidedTourDirective } from '../../components/guided-tour/guided-tour.directive'; + +/** + * @title Guided tour service. + * @description This service should be injected once for each tour. Use component `providers` array to declare. + */ +@Injectable({ + providedIn: 'any', +}) +export class AppGuidedTourService { + /** Guider tour steps. */ + private steps: AppGuidedTourDirective[] = []; + + /** Guided tour step configuration. */ + public set configuration(stepsQuery: QueryList) { + this.steps = stepsQuery.toArray().sort((x, y) => (x.appGuidedTour?.index ?? 0) - (y.appGuidedTour?.index ?? 0)); + } + + /** Active step. */ + public active?: AppGuidedTourDirective; + + /** Activate next step. */ + public next() { + this.active?.dispose(); + const stepIndex = (this.active?.appGuidedTour?.index ?? -1) + 1; + if (stepIndex <= this.steps.length - 1) { + this.active = this.steps.at(stepIndex); + if (typeof this.active !== 'undefined') { + this.active.display(); + } + } + } + + /** Activate previous step. */ + public previous() { + this.active?.dispose(); + const stepIndex = (this.active?.appGuidedTour?.index ?? -1) - 1; + if (stepIndex >= 0) { + this.active = this.steps.at(stepIndex); + if (typeof this.active !== 'undefined') { + this.active.display(); + } + } + } + + /** End tour. */ + public end() { + this.active?.dispose(); + this.active = void 0; + } +} diff --git a/libs/client-guided-tour/src/lib/services/index.ts b/libs/client-guided-tour/src/lib/services/index.ts new file mode 100644 index 00000000..02a40fce --- /dev/null +++ b/libs/client-guided-tour/src/lib/services/index.ts @@ -0,0 +1 @@ +export * from './guided-tour/guided-tour.service'; diff --git a/libs/client-guided-tour/src/test-setup.ts b/libs/client-guided-tour/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/client-guided-tour/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/client-guided-tour/tsconfig.eslint.json b/libs/client-guided-tour/tsconfig.eslint.json new file mode 100644 index 00000000..49781c7d --- /dev/null +++ b/libs/client-guided-tour/tsconfig.eslint.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "types": ["node", "jest"] + }, + "extends": "./tsconfig.json", + "include": ["src"] +} diff --git a/libs/client-guided-tour/tsconfig.json b/libs/client-guided-tour/tsconfig.json new file mode 100644 index 00000000..6fd7d12e --- /dev/null +++ b/libs/client-guided-tour/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.eslint.json" + }, + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/client-guided-tour/tsconfig.lib.json b/libs/client-guided-tour/tsconfig.lib.json new file mode 100644 index 00000000..4d80f755 --- /dev/null +++ b/libs/client-guided-tour/tsconfig.lib.json @@ -0,0 +1,19 @@ +{ + "angularCompilerOptions": { + "enableResourceInlining": true, + "skipTemplateCodegen": true, + "strictMetadataEmit": true + }, + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "lib": ["dom", "ES2022"], + "outDir": "../../dist/out-tsc", + "sourceMap": true, + "target": "ES2022" + }, + "exclude": ["src/test-setup.ts", "**/*.spec.ts", "jest.config.ts"], + "extends": "./tsconfig.json", + "include": ["**/*.ts"] +} diff --git a/libs/client-guided-tour/tsconfig.lib.prod.json b/libs/client-guided-tour/tsconfig.lib.prod.json new file mode 100644 index 00000000..0e06848c --- /dev/null +++ b/libs/client-guided-tour/tsconfig.lib.prod.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false, + "target": "ES2022", + "useDefineForClassFields": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/libs/client-guided-tour/tsconfig.spec.json b/libs/client-guided-tour/tsconfig.spec.json new file mode 100644 index 00000000..68970ff3 --- /dev/null +++ b/libs/client-guided-tour/tsconfig.spec.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "allowJs": true, + "baseUrl": "../../", + "emitDecoratorMetadata": false, + "module": "commonjs", + "outDir": "../../dist/out-tsc", + "types": ["jest", "node"] + }, + "extends": "./tsconfig.json", + "files": ["src/test-setup.ts"], + "include": ["**/*.spec.ts", "**/*.d.ts", "jest.config.ts"] +} diff --git a/libs/client-pwa-offline/README.md b/libs/client-pwa-offline/README.md index 69191536..05bc3a46 100644 --- a/libs/client-pwa-offline/README.md +++ b/libs/client-pwa-offline/README.md @@ -34,7 +34,7 @@ export class AppModule {} Install the package ```bash -yarn add @rfprodz/client-pwa-offline @angular/material +yarn add @rfprodz/client-pwa-offline @angular/common @angular/core @angular/material @angular/router ``` Import the module diff --git a/tools/ts/actions/changes/changes.config.spec.ts b/tools/ts/actions/changes/changes.config.spec.ts index efbf9cae..e8177446 100644 --- a/tools/ts/actions/changes/changes.config.spec.ts +++ b/tools/ts/actions/changes/changes.config.spec.ts @@ -8,6 +8,7 @@ describe('changesConfig', () => { mobile: ['android/**', 'capacitor.config.json'], packageCharts: ['libs/client-d3-charts/package.json'], packageEliza: ['libs/client-util-eliza/package.json'], + packageGuidedTour: ['libs/client-guided-tour/package.json'], packagePwaOffline: ['libs/client-pwa-offline/package.json'], packageBackendDiagnostics: ['libs/backend-diagnostics/package.json'], packages: ['libs/client-d3-charts/package.json', 'libs/client-util-eliza/package.json', 'libs/backend-diagnostics/package.json'], diff --git a/tools/ts/actions/changes/changes.config.ts b/tools/ts/actions/changes/changes.config.ts index c53973d4..68009cd4 100644 --- a/tools/ts/actions/changes/changes.config.ts +++ b/tools/ts/actions/changes/changes.config.ts @@ -3,6 +3,7 @@ const electron = ['.electron/**', 'index.js']; const mobile = ['android/**', 'capacitor.config.json']; const packageCharts = ['libs/client-d3-charts/package.json']; const packageEliza = ['libs/client-util-eliza/package.json']; +const packageGuidedTour = ['libs/client-guided-tour/package.json']; const packagePwaOffline = ['libs/client-pwa-offline/package.json']; const packageBackendDiagnostics = ['libs/backend-diagnostics/package.json']; const packages = [...packageCharts, ...packageEliza, ...packageBackendDiagnostics]; @@ -32,6 +33,7 @@ export const changesConfig = { mobile, packageCharts, packageEliza, + packageGuidedTour, packagePwaOffline, packageBackendDiagnostics, packages, diff --git a/tools/workspace-plugin/src/generators/client-feature/client-feature.generator.spec.ts b/tools/workspace-plugin/src/generators/client-feature/client-feature.generator.spec.ts index 4f13646c..f8656c19 100644 --- a/tools/workspace-plugin/src/generators/client-feature/client-feature.generator.spec.ts +++ b/tools/workspace-plugin/src/generators/client-feature/client-feature.generator.spec.ts @@ -28,12 +28,12 @@ describe('client-feature', () => { it('should throw error if a name is missing', async () => { const expected = 'The name must start with client- and contain only lower case letters and dashes.'; - await expect(generator.default(tree, { ...context, name: '' })).rejects.toThrowError(expected); + await expect(generator.default(tree, { ...context, name: '' })).rejects.toThrow(expected); }); it('should throw error if a name has incorrect format', async () => { const expected = 'The name must start with client- and contain only lower case letters and dashes.'; - await expect(generator.default(tree, { ...context, name: 'feature-client' })).rejects.toThrowError(expected); + await expect(generator.default(tree, { ...context, name: 'feature-client' })).rejects.toThrow(expected); }); const testTimeout = 10000; diff --git a/tools/workspace-plugin/src/generators/client-feature/files/tsconfig.lib.json__tmpl__ b/tools/workspace-plugin/src/generators/client-feature/files/tsconfig.lib.json__tmpl__ index 9d7816e6..abb71579 100644 --- a/tools/workspace-plugin/src/generators/client-feature/files/tsconfig.lib.json__tmpl__ +++ b/tools/workspace-plugin/src/generators/client-feature/files/tsconfig.lib.json__tmpl__ @@ -11,7 +11,7 @@ "inlineSources": true, "lib": ["dom", "ES2022"], "outDir": "../../dist/out-tsc", - "target": "ES2022", + "target": "ES2022" }, "exclude": ["src/test-setup.ts", "**/*.spec.ts", "jest.config.ts"], "extends": "./tsconfig.json", diff --git a/tools/workspace-plugin/src/generators/client-store/client-store.generator.spec.ts b/tools/workspace-plugin/src/generators/client-store/client-store.generator.spec.ts index 2c0bf118..d9a1e64f 100644 --- a/tools/workspace-plugin/src/generators/client-store/client-store.generator.spec.ts +++ b/tools/workspace-plugin/src/generators/client-store/client-store.generator.spec.ts @@ -28,12 +28,12 @@ describe('client-store', () => { it('should throw error if a name is missing', async () => { const expected = 'The name must start with client-store- and contain only lower case letters and dashes.'; - await expect(generator.default(tree, { ...context, name: '' })).rejects.toThrowError(expected); + await expect(generator.default(tree, { ...context, name: '' })).rejects.toThrow(expected); }); it('should throw error if a name has incorrect format', async () => { const expected = 'The name must start with client-store- and contain only lower case letters and dashes.'; - await expect(generator.default(tree, { ...context, name: 'store-client' })).rejects.toThrowError(expected); + await expect(generator.default(tree, { ...context, name: 'store-client' })).rejects.toThrow(expected); }); const testTimeout = 10000; diff --git a/tools/workspace-plugin/src/generators/client-store/files/tsconfig.lib.json__tmpl__ b/tools/workspace-plugin/src/generators/client-store/files/tsconfig.lib.json__tmpl__ index 9d7816e6..abb71579 100644 --- a/tools/workspace-plugin/src/generators/client-store/files/tsconfig.lib.json__tmpl__ +++ b/tools/workspace-plugin/src/generators/client-store/files/tsconfig.lib.json__tmpl__ @@ -11,7 +11,7 @@ "inlineSources": true, "lib": ["dom", "ES2022"], "outDir": "../../dist/out-tsc", - "target": "ES2022", + "target": "ES2022" }, "exclude": ["src/test-setup.ts", "**/*.spec.ts", "jest.config.ts"], "extends": "./tsconfig.json", diff --git a/tools/workspace-plugin/src/generators/client-ui/client-ui.generator.spec.ts b/tools/workspace-plugin/src/generators/client-ui/client-ui.generator.spec.ts index 1dfc5c02..1e1da508 100644 --- a/tools/workspace-plugin/src/generators/client-ui/client-ui.generator.spec.ts +++ b/tools/workspace-plugin/src/generators/client-ui/client-ui.generator.spec.ts @@ -15,8 +15,8 @@ describe('client-ui', () => { let tree: Tree; const context: ISchematicContext = { - name: 'client-ui-test', - tags: `scope:client-ui-test,type:ui`, + name: 'client-test', + tags: `scope:client-test,type:ui`, }; beforeEach(() => { @@ -27,13 +27,13 @@ describe('client-ui', () => { }); it('should throw error if a name is missing', async () => { - const expected = 'The name must start with client-ui- and contain only lower case letters and dashes.'; - await expect(generator.default(tree, { ...context, name: '' })).rejects.toThrowError(expected); + const expected = 'The name must start with client- and contain only lower case letters and dashes.'; + await expect(generator.default(tree, { ...context, name: '' })).rejects.toThrow(expected); }); it('should throw error if a name has incorrect format', async () => { - const expected = 'The name must start with client-ui- and contain only lower case letters and dashes.'; - await expect(generator.default(tree, { ...context, name: 'ui-client' })).rejects.toThrowError(expected); + const expected = 'The name must start with client- and contain only lower case letters and dashes.'; + await expect(generator.default(tree, { ...context, name: 'ui-client' })).rejects.toThrow(expected); }); const testTimeout = 10000; @@ -52,7 +52,7 @@ describe('client-ui', () => { expectFileToExist(`/libs/${context.name}/src/index.ts`, tree); expectFileToExist(`/libs/${context.name}/src/test-setup.ts`, tree); - const kebabCaseName = context.name.replace('client-ui-', ''); + const kebabCaseName = context.name.replace('client-', ''); const barrel = tree.read(`/libs/${context.name}/src/index.ts`, 'utf-8'); expect(barrel).toContain(`export * from './lib/${kebabCaseName}.module';`); diff --git a/tools/workspace-plugin/src/generators/client-ui/client-ui.generator.ts b/tools/workspace-plugin/src/generators/client-ui/client-ui.generator.ts index 229cd7b8..7f86d691 100644 --- a/tools/workspace-plugin/src/generators/client-ui/client-ui.generator.ts +++ b/tools/workspace-plugin/src/generators/client-ui/client-ui.generator.ts @@ -14,7 +14,7 @@ const addFiles = (schema: ISchematicContext, tree: Tree) => { const config: ProjectConfiguration = readProjectConfiguration(tree, schema.name); const root = config.root; - const generateFilesConf = generateFilesConfig(schema.name, 'client-ui-'); + const generateFilesConf = generateFilesConfig(schema.name, 'client-'); generateFiles(tree, joinPathFragments(__dirname, './files'), root, { ...generateFilesConf, @@ -25,8 +25,8 @@ export default async function (tree: Tree, schema: ISchematicContext) { const name = schema.name; const tags = schema.tags; - if (!/^client-ui-[a-z-]+$/.test(name)) { - const message = 'The name must start with client-ui- and contain only lower case letters and dashes.'; + if (!/^client-[a-z-]+$/.test(name)) { + const message = 'The name must start with client- and contain only lower case letters and dashes.'; throw new Error(message); } diff --git a/tools/workspace-plugin/src/generators/client-ui/files/tsconfig.lib.json__tmpl__ b/tools/workspace-plugin/src/generators/client-ui/files/tsconfig.lib.json__tmpl__ index 9d7816e6..abb71579 100644 --- a/tools/workspace-plugin/src/generators/client-ui/files/tsconfig.lib.json__tmpl__ +++ b/tools/workspace-plugin/src/generators/client-ui/files/tsconfig.lib.json__tmpl__ @@ -11,7 +11,7 @@ "inlineSources": true, "lib": ["dom", "ES2022"], "outDir": "../../dist/out-tsc", - "target": "ES2022", + "target": "ES2022" }, "exclude": ["src/test-setup.ts", "**/*.spec.ts", "jest.config.ts"], "extends": "./tsconfig.json", diff --git a/tools/workspace-plugin/src/generators/client-util/client-util.generator.spec.ts b/tools/workspace-plugin/src/generators/client-util/client-util.generator.spec.ts index 1b665a53..b45e727a 100644 --- a/tools/workspace-plugin/src/generators/client-util/client-util.generator.spec.ts +++ b/tools/workspace-plugin/src/generators/client-util/client-util.generator.spec.ts @@ -28,12 +28,12 @@ describe('client-util', () => { it('should throw error if a name is missing', async () => { const expected = 'The name must start with client-util- and contain only lower case letters and dashes.'; - await expect(generator.default(tree, { ...context, name: '' })).rejects.toThrowError(expected); + await expect(generator.default(tree, { ...context, name: '' })).rejects.toThrow(expected); }); it('should throw error if a name has incorrect format', async () => { const expected = 'The name must start with client-util- and contain only lower case letters and dashes.'; - await expect(generator.default(tree, { ...context, name: 'util-client' })).rejects.toThrowError(expected); + await expect(generator.default(tree, { ...context, name: 'util-client' })).rejects.toThrow(expected); }); const testTimeout = 10000; diff --git a/tools/workspace-plugin/src/generators/client-util/files/tsconfig.lib.json__tmpl__ b/tools/workspace-plugin/src/generators/client-util/files/tsconfig.lib.json__tmpl__ index 9d7816e6..abb71579 100644 --- a/tools/workspace-plugin/src/generators/client-util/files/tsconfig.lib.json__tmpl__ +++ b/tools/workspace-plugin/src/generators/client-util/files/tsconfig.lib.json__tmpl__ @@ -11,7 +11,7 @@ "inlineSources": true, "lib": ["dom", "ES2022"], "outDir": "../../dist/out-tsc", - "target": "ES2022", + "target": "ES2022" }, "exclude": ["src/test-setup.ts", "**/*.spec.ts", "jest.config.ts"], "extends": "./tsconfig.json", diff --git a/tsconfig.base.json b/tsconfig.base.json index 1e3f5410..cb3011df 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -52,6 +52,7 @@ "@app/client-elements": ["libs/client-elements/src/index.ts"], "@app/client-gql": ["libs/client-gql/src/index.ts"], "@app/client-grpc": ["libs/client-grpc/src/index.ts"], + "@app/client-guided-tour": ["libs/client-guided-tour/src/index.ts"], "@app/client-material": ["libs/client-material/src/index.ts"], "@app/client-pwa-offline": ["libs/client-pwa-offline/src/index.ts"], "@app/client-service-worker": ["libs/client-service-worker/src/index.ts"],