diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..350da47 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,33 @@ +# SC NG Commons + +Shared Library concepts for reusable frontend capabilities used by Consumer applications. + +## Language + +**Testing FAB**: +A floating action button for non-production environments that reveals configurable test and development controls. +_Avoid_: debug menu, dev button, toolbox + +**Action Widget**: +A configurable control rendered inside the expanded Testing FAB panel to alter app behavior for testing and development. +_Avoid_: option, toggle, tool + +**Select Query Param Widget**: +An Action Widget that presents selectable values and persists the selected value into a specific URL query parameter. +_Avoid_: query toggle, URL switch + +**Custom Component Widget**: +An Action Widget that renders a Consumer-provided Angular component inside the Testing FAB panel. +_Avoid_: arbitrary template, inline HTML widget + +**URL-Authoritative Selection**: +For Select Query Param Widgets, the URL query parameter value is the source of truth for current selection and restoration across reloads. +_Avoid_: local-only state, hidden persistence + +**Opt-in Provisioning**: +The Testing FAB is activated only when a Consumer application explicitly registers it through `provideTestingFab`; there is no automatic environment-based enablement. +_Avoid_: auto-enable, implicit activation + +**Consumer-Owned Activation Safety**: +The Consumer application is solely responsible for deciding where Testing FAB is enabled, including preventing production activation. +_Avoid_: library-enforced safety, implicit prod blocking diff --git a/apps/styleguide/package.json b/apps/styleguide/package.json index d908644..a5898d6 100644 --- a/apps/styleguide/package.json +++ b/apps/styleguide/package.json @@ -1,6 +1,6 @@ { "name": "@shiftcode/styleguide", - "version": "15.2.0", + "version": "15.2.1-pr87.0", "private": true, "type": "module", "scripts": { diff --git a/lerna.json b/lerna.json index b15d307..39fd91d 100644 --- a/lerna.json +++ b/lerna.json @@ -2,7 +2,7 @@ "$schema": "node_modules/lerna/schemas/lerna-schema.json", "useNx": false, "packages": ["libs/*", "apps/*"], - "version": "15.2.0", + "version": "15.2.1-pr87.0", "command": { "version": { "allowBranch": "*", diff --git a/libs/components/README.md b/libs/components/README.md index d21366d..1684714 100644 --- a/libs/components/README.md +++ b/libs/components/README.md @@ -74,3 +74,30 @@ Can be customized with css custom properties: --sc-tooltip-padding: 4px 8px; } ``` + +## [testing-fab](./src/lib/testing-fab/testing-fab.component.ts) + +Floating action button for non-production testing controls. + +```ts +import { provideTestingFab, TestingFabComponent } from '@shiftcode/ngx-components' + +bootstrapApplication(AppComponent, { + providers: [ + provideTestingFab([ + { + id: 'backend-env', + label: 'Backend Environment', + type: 'select-query-param', + queryParam: 'env', + options: [ + { label: 'QA', value: 'qa' }, + { label: 'Preview', value: 'preview' }, + ], + }, + ]), + ], +}) +``` + +Add `` once in a root-level template where it should be available. diff --git a/libs/components/package.json b/libs/components/package.json index 8ca23c2..1fba528 100644 --- a/libs/components/package.json +++ b/libs/components/package.json @@ -1,6 +1,6 @@ { "name": "@shiftcode/ngx-components", - "version": "15.2.0", + "version": "15.2.1-pr87.0", "repository": "https://github.com/shiftcode/sc-ng-commons-public", "license": "MIT", "author": "shiftcode GmbH ", diff --git a/libs/components/src/lib/testing-fab/initialize-testing-fab.ts b/libs/components/src/lib/testing-fab/initialize-testing-fab.ts new file mode 100644 index 0000000..6ffff1f --- /dev/null +++ b/libs/components/src/lib/testing-fab/initialize-testing-fab.ts @@ -0,0 +1,23 @@ +import { DOCUMENT } from '@angular/common' +import { ApplicationRef, createComponent, EnvironmentInjector, inject } from '@angular/core' + +import { TestingFabComponent } from './testing-fab.component' + +export function initializeTestingFab() { + const doc = inject(DOCUMENT) + + if (!doc.body || doc.body.querySelector('sc-testing-fab')) { + return + } + + const appRef = inject(ApplicationRef) + const environmentInjector = inject(EnvironmentInjector) + const host = doc.createElement('sc-testing-fab') + const cmpRef = createComponent(TestingFabComponent, { + hostElement: host, + environmentInjector, + }) + + appRef.attachView(cmpRef.hostView) + doc.body.appendChild(host) +} diff --git a/libs/components/src/lib/testing-fab/provide-testing-fab.spec.ts b/libs/components/src/lib/testing-fab/provide-testing-fab.spec.ts new file mode 100644 index 0000000..e5c764b --- /dev/null +++ b/libs/components/src/lib/testing-fab/provide-testing-fab.spec.ts @@ -0,0 +1,47 @@ +import { TestBed } from '@angular/core/testing' +import { DefaultUrlSerializer, Router } from '@angular/router' +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { provideTestingFab } from './provide-testing-fab' +import { TESTING_FAB_WIDGETS } from './testing-fab-config.token' +import { TestingFabWidget } from './testing-fab-widget.type' + +const serializer = new DefaultUrlSerializer() + +function createRouterMock(): Router { + return { + url: '/', + parseUrl: vi.fn((value) => serializer.parse(value)), + navigate: vi.fn().mockResolvedValue(true), + } as unknown as Router +} + +describe('provideTestingFab', () => { + afterEach(() => { + TestBed.resetTestingModule() + document.body.querySelectorAll('sc-testing-fab').forEach((el) => el.remove()) + }) + + test('supports static widgets config and renders the testing fab', () => { + const widgets: readonly TestingFabWidget[] = [{ id: 'a1', label: 'Action', type: 'action', action: vi.fn() }] + + TestBed.configureTestingModule({ + providers: [provideTestingFab(widgets), { provide: Router, useValue: createRouterMock() }], + }) + + expect(TestBed.inject(TESTING_FAB_WIDGETS)).toEqual(widgets) + expect(document.body.querySelectorAll('sc-testing-fab')).toHaveLength(1) + }) + + test('supports factory widgets config', () => { + const widgets: readonly TestingFabWidget[] = [{ id: 'a1', label: 'Action', type: 'action', action: vi.fn() }] + const factory = vi.fn(() => widgets) + + TestBed.configureTestingModule({ + providers: [provideTestingFab(factory), { provide: Router, useValue: createRouterMock() }], + }) + + expect(TestBed.inject(TESTING_FAB_WIDGETS)).toEqual(widgets) + expect(factory).toHaveBeenCalled() + }) +}) diff --git a/libs/components/src/lib/testing-fab/provide-testing-fab.ts b/libs/components/src/lib/testing-fab/provide-testing-fab.ts new file mode 100644 index 0000000..d589eff --- /dev/null +++ b/libs/components/src/lib/testing-fab/provide-testing-fab.ts @@ -0,0 +1,17 @@ +import { EnvironmentProviders, makeEnvironmentProviders, provideEnvironmentInitializer } from '@angular/core' + +import { initializeTestingFab } from './initialize-testing-fab' +import { TESTING_FAB_WIDGETS } from './testing-fab-config.token' +import { TestingFabWidget } from './testing-fab-widget.type' + +type ValueOrFactory = T | (() => T) + +export function provideTestingFab(widgets: ValueOrFactory): EnvironmentProviders { + return makeEnvironmentProviders([ + { + provide: TESTING_FAB_WIDGETS, + ...(typeof widgets === 'function' ? { useFactory: widgets } : { useValue: widgets }), + }, + provideEnvironmentInitializer(() => initializeTestingFab()), + ]) +} diff --git a/libs/components/src/lib/testing-fab/testing-fab-config.token.ts b/libs/components/src/lib/testing-fab/testing-fab-config.token.ts new file mode 100644 index 0000000..2467fd8 --- /dev/null +++ b/libs/components/src/lib/testing-fab/testing-fab-config.token.ts @@ -0,0 +1,7 @@ +import { InjectionToken } from '@angular/core' + +import { TestingFabWidget } from './testing-fab-widget.type' + +export const TESTING_FAB_WIDGETS = new InjectionToken('sc.ng.common.testing-fab.widgets', { + factory: () => [], +}) diff --git a/libs/components/src/lib/testing-fab/testing-fab-widget.type.ts b/libs/components/src/lib/testing-fab/testing-fab-widget.type.ts new file mode 100644 index 0000000..3fb430c --- /dev/null +++ b/libs/components/src/lib/testing-fab/testing-fab-widget.type.ts @@ -0,0 +1,34 @@ +import { Type } from '@angular/core' + +export type TestingFabWidget = + | TestingFabActionWidget + | TestingFabSelectQueryParamWidget + | TestingFabCustomComponentWidget + +interface TestingFabWidgetBase { + id: string + label: string +} + +export interface TestingFabActionWidget extends TestingFabWidgetBase { + type: 'action' + buttonLabel?: string + action: () => void +} + +export interface TestingFabSelectQueryParamWidget extends TestingFabWidgetBase { + type: 'select-query-param' + queryParam: string + options: readonly TestingFabSelectOption[] + hardReload?: boolean +} + +export interface TestingFabSelectOption { + value: string + label: string +} + +export interface TestingFabCustomComponentWidget extends TestingFabWidgetBase { + type: 'custom-component' + component: Type +} diff --git a/libs/components/src/lib/testing-fab/testing-fab.component.html b/libs/components/src/lib/testing-fab/testing-fab.component.html new file mode 100644 index 0000000..86bfe5a --- /dev/null +++ b/libs/components/src/lib/testing-fab/testing-fab.component.html @@ -0,0 +1,41 @@ +@let panelId = 'sc-testing-fab-panel'; + + +
+ @for (widget of widgets; track widget.id) { + @let widgetId = 'sc-testing-fab-' + widget.id; +
+ + + @switch (widget.type) { + @case ('action') { + + } + @case ('select-query-param') { + + } + @case ('custom-component') { + + } + } +
+ } +
diff --git a/libs/components/src/lib/testing-fab/testing-fab.component.scss b/libs/components/src/lib/testing-fab/testing-fab.component.scss new file mode 100644 index 0000000..a4b4d12 --- /dev/null +++ b/libs/components/src/lib/testing-fab/testing-fab.component.scss @@ -0,0 +1,101 @@ +:host { + position: fixed; + right: var(--sc-testing-fab-inset, 16px); + bottom: var(--sc-testing-fab-inset, 16px); + margin: var(--sc-testing-fab-margin, 0); + z-index: 2147483647; + --sc-testing-fab-size: 48px; + --sc-testing-fab-border-radius: 50%; + --sc-testing-fab-background-color: #1d4ed8; + --sc-testing-fab-foreground-color: #fff; + --sc-testing-fab-icon: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PCFET0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj48c3ZnIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIHZpZXdCb3g9IjAgMCA0NDIgNTc5IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHhtbDpzcGFjZT0icHJlc2VydmUiIHhtbG5zOnNlcmlmPSJodHRwOi8vd3d3LnNlcmlmLmNvbS8iIHN0eWxlPSJmaWxsLXJ1bGU6ZXZlbm9kZDtjbGlwLXJ1bGU6ZXZlbm9kZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW1pdGVybGltaXQ6MjsiPjxwYXRoIGQ9Ik0wLDE5Mi45MTdsMTkyLjkxNywtMTkyLjkxN2w5Ni4yNSwwbC0xOTIuOTE3LDE5Mi45MTdsMTkyLjkxNywxOTIuNWwtMTkyLjkxNywxOTIuOTE2bC05Ni4yNSwwbDE5Mi45MTcsLTE5Mi45MTZsLTE5Mi45MTcsLTE5Mi41WiIvPjxwYXRoIGQ9Ik0xNTIuNSwxOTIuOTE3bDE5Mi41LC0xOTIuOTE3bDk2LjY2NywwbC0xOTIuOTE3LDE5Mi45MTdsMTkyLjkxNywxOTIuNWwtOTYuNjY3LDBsLTE5Mi41LC0xOTIuNVoiLz48L3N2Zz4='); +} + +.sc-testing-fab__trigger { + width: var(--sc-testing-fab-size); + height: var(--sc-testing-fab-size); + border-radius: var(--sc-testing-fab-border-radius); + border: 0; + background: var(--sc-testing-fab-background-color); + color: var(--sc-testing-fab-foreground-color); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgb(0 0 0 / 40%); +} + +.sc-testing-fab__panel { + position: fixed; + right: var(--sc-testing-fab-inset, 16px); + bottom: calc(var(--sc-testing-fab-inset, 16px) + var(--sc-testing-fab-size) + 8px); + margin: var(--sc-testing-fab-margin, 0); + inset: auto; + min-width: 240px; + max-width: min(320px, calc(100vw - 32px)); + max-height: min(60vh, 560px); + overflow: auto; + border: 1px solid #d1d5db; + border-radius: 8px; + background: #fff; + color: #111827; + padding: 12px; + box-shadow: 0 8px 24px rgb(0 0 0 / 25%); + opacity: 0; + transform: translateY(8px) scale(0.98); + transition: + opacity 140ms ease, + transform 140ms ease; +} + +.sc-testing-fab__panel:popover-open { + opacity: 1; + transform: translateY(0) scale(1); +} + +@starting-style { + .sc-testing-fab__panel:popover-open { + opacity: 0; + transform: translateY(8px) scale(0.98); + } +} + +.sc-testing-fab__widget { + display: grid; + gap: 8px; +} + +.sc-testing-fab__widget + .sc-testing-fab__widget { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #e5e7eb; +} + +.sc-testing-fab__widget-label { + font-weight: 600; + font-size: 13px; +} + +.sc-testing-fab__select, +.sc-testing-fab__button { + width: 100%; + border: 1px solid #9ca3af; + border-radius: 6px; + padding: 6px 8px; + background: #fff; + color: inherit; +} + +.sc-testing-fab__button { + cursor: pointer; +} + +.sc-testing-fab__trigger::before { + content: ''; + width: 62%; + height: 62%; + background-image: var(--sc-testing-fab-icon); + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} diff --git a/libs/components/src/lib/testing-fab/testing-fab.component.spec.ts b/libs/components/src/lib/testing-fab/testing-fab.component.spec.ts new file mode 100644 index 0000000..7228a89 --- /dev/null +++ b/libs/components/src/lib/testing-fab/testing-fab.component.spec.ts @@ -0,0 +1,167 @@ +import { Component } from '@angular/core' +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { DefaultUrlSerializer, Router } from '@angular/router' +import { describe, expect, test, vi } from 'vitest' + +import { TestingFabComponent } from './testing-fab.component' +import { TESTING_FAB_WIDGETS } from './testing-fab-config.token' +import type { TestingFabWidget } from './testing-fab-widget.type' + +@Component({ + selector: 'sc-test-custom-widget', + template: `
custom
`, + standalone: true, +}) +class TestCustomWidgetComponent {} + +const serializer = new DefaultUrlSerializer() +function queryRequired( + fixture: ComponentFixture, + selector: string, +): TElement { + const element = (fixture.nativeElement as HTMLElement).querySelector(selector) + if (!element) { + throw new Error(`Expected element for selector "${selector}"`) + } + + return element +} + +function setup( + widgets: readonly TestingFabWidget[], + url = '/', +): { fixture: ComponentFixture; routerMock: Router } { + const routerMock = { + url, + parseUrl: vi.fn((value) => serializer.parse(value)), + navigate: vi.fn().mockResolvedValue(true), + } as unknown as Router + + TestBed.configureTestingModule({ + imports: [TestingFabComponent], + providers: [ + { provide: TESTING_FAB_WIDGETS, useValue: widgets }, + { provide: Router, useValue: routerMock }, + ], + }) + + const fixture = TestBed.createComponent(TestingFabComponent) + fixture.detectChanges() + + return { fixture, routerMock } +} + +describe('TestingFabComponent', () => { + test('renders select widget and uses URL query parameter as selected value', () => { + const widgets: readonly TestingFabWidget[] = [ + { + id: 'env', + label: 'Backend Environment', + type: 'select-query-param', + queryParam: 'env', + options: [ + { label: 'QA', value: 'qa' }, + { label: 'Prod', value: 'prod' }, + ], + }, + ] + + const { fixture } = setup(widgets, '/?env=qa') + const select = queryRequired(fixture, 'select') + expect(select.value).toBe('qa') + }) + + test('updates query parameters when select widget value changes', () => { + const widgets: readonly TestingFabWidget[] = [ + { + id: 'env', + label: 'Backend Environment', + type: 'select-query-param', + queryParam: 'env', + options: [ + { label: 'QA', value: 'qa' }, + { label: 'Prod', value: 'prod' }, + ], + }, + ] + + const { fixture, routerMock } = setup(widgets, '/?env=qa') + const select = queryRequired(fixture, 'select') + select.value = 'prod' + select.dispatchEvent(new Event('change')) + + expect(routerMock.navigate).toHaveBeenCalledWith([], { + queryParams: { env: 'prod' }, + queryParamsHandling: 'merge', + }) + + select.value = '' + select.dispatchEvent(new Event('change')) + + expect(routerMock.navigate).toHaveBeenNthCalledWith(2, [], { + queryParams: { env: null }, + queryParamsHandling: 'merge', + }) + }) + + test('forces hard reload when configured on select widget', () => { + const widgets: readonly TestingFabWidget[] = [ + { + id: 'env', + label: 'Backend Environment', + type: 'select-query-param', + queryParam: 'env', + hardReload: true, + options: [ + { label: 'QA', value: 'qa' }, + { label: 'Prod', value: 'prod' }, + ], + }, + ] + + const locationAssignSpy = vi.spyOn(window.location, 'assign').mockImplementation(() => undefined) + const { fixture, routerMock } = setup(widgets, '/?env=qa') + const select = queryRequired(fixture, 'select') + select.value = 'prod' + select.dispatchEvent(new Event('change')) + + expect(locationAssignSpy).toHaveBeenCalledTimes(1) + expect(locationAssignSpy).toHaveBeenCalledWith(expect.stringContaining('env=prod')) + expect(routerMock.navigate).not.toHaveBeenCalled() + + locationAssignSpy.mockRestore() + }) + + test('renders a custom component widget', () => { + const widgets: readonly TestingFabWidget[] = [ + { + id: 'custom', + label: 'Custom Widget', + type: 'custom-component', + component: TestCustomWidgetComponent, + }, + ] + + const { fixture } = setup(widgets) + const customWidget = queryRequired(fixture, '[data-test-id="custom-widget"]') + expect(customWidget).not.toBeNull() + }) + + test('runs action widgets when clicked', () => { + const action = vi.fn() + const widgets: readonly TestingFabWidget[] = [ + { + id: 'toggle-i18n', + label: 'Show i18n Keys', + type: 'action', + action, + }, + ] + + const { fixture } = setup(widgets) + const button = queryRequired(fixture, '.sc-testing-fab__button') + button.click() + + expect(action).toHaveBeenCalledTimes(1) + }) +}) diff --git a/libs/components/src/lib/testing-fab/testing-fab.component.ts b/libs/components/src/lib/testing-fab/testing-fab.component.ts new file mode 100644 index 0000000..cd4a9c2 --- /dev/null +++ b/libs/components/src/lib/testing-fab/testing-fab.component.ts @@ -0,0 +1,48 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { Router } from '@angular/router' +import { NgComponentOutlet } from '@angular/common' + +import { TESTING_FAB_WIDGETS } from './testing-fab-config.token' +import { TestingFabSelectQueryParamWidget } from './testing-fab-widget.type' + +@Component({ + selector: 'sc-testing-fab', + standalone: true, + imports: [NgComponentOutlet], + styleUrls: ['./testing-fab.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './testing-fab.component.html', +}) +export class TestingFabComponent { + protected readonly widgets = inject(TESTING_FAB_WIDGETS) + private readonly router = inject(Router) + + protected getSelectedValue(widget: TestingFabSelectQueryParamWidget): string { + const queryParams = this.router.parseUrl(this.router.url).queryParams + const value = queryParams[widget.queryParam] + return typeof value === 'string' ? value : '' + } + + protected setQueryParam(widget: TestingFabSelectQueryParamWidget, event: Event) { + const nextValue = (event.target as HTMLSelectElement).value + + if (widget.hardReload) { + const url = new URL(window.location.href) + if (nextValue) { + url.searchParams.set(widget.queryParam, nextValue) + } else { + url.searchParams.delete(widget.queryParam) + } + + window.location.assign(url.toString()) + return + } + + void this.router.navigate([], { + queryParams: { + [widget.queryParam]: nextValue || null, + }, + queryParamsHandling: 'merge', + }) + } +} diff --git a/libs/components/src/public-api.ts b/libs/components/src/public-api.ts index 27d7064..337f50f 100644 --- a/libs/components/src/public-api.ts +++ b/libs/components/src/public-api.ts @@ -40,6 +40,12 @@ export * from './lib/insert-view-ref/insert-view-ref.directive' // navigation-class-handler export * from './lib/navigation-class-handler/navigation-class-handler' +// testing fab +export * from './lib/testing-fab/provide-testing-fab' +export * from './lib/testing-fab/testing-fab-config.token' +export * from './lib/testing-fab/testing-fab.component' +export * from './lib/testing-fab/testing-fab-widget.type' + // rx export * from './lib/rx/provide-defaults' export * from './lib/rx/rx-if.directive' diff --git a/libs/core/package.json b/libs/core/package.json index 27305d4..fd895a3 100644 --- a/libs/core/package.json +++ b/libs/core/package.json @@ -1,6 +1,6 @@ { "name": "@shiftcode/ngx-core", - "version": "15.2.0", + "version": "15.2.1-pr87.0", "repository": "https://github.com/shiftcode/sc-ng-commons-public", "license": "MIT", "author": "shiftcode GmbH ",