Skip to content

Commit 8d0b3c8

Browse files
committed
feat(testing-fab): add toggle widget type
1 parent d2963f2 commit 8d0b3c8

8 files changed

Lines changed: 181 additions & 48 deletions

File tree

apps/styleguide/src/main.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
provideLogger,
1414
withBrowserConsoleTransport,
1515
} from '@shiftcode/ngx-core'
16-
import { filter, map, startWith } from 'rxjs'
16+
import { map, startWith } from 'rxjs'
1717

1818
import { AppComponent } from './app/app.component'
1919
import { provideSgConfig } from './provide-sg-config'
@@ -33,8 +33,8 @@ bootstrapApplication(AppComponent, {
3333
provideTestingFab([
3434
{
3535
id: 'body-bg-color',
36-
label: 'Body Background',
3736
type: 'select-query-param',
37+
label: 'Body Background',
3838
queryParam: 'body-bg',
3939
options: [
4040
{ value: '#e0202b', label: 'red' },
@@ -59,6 +59,12 @@ bootstrapApplication(AppComponent, {
5959
{ value: '#35978a', label: 'turquoise' },
6060
],
6161
},
62+
{
63+
id: 'body-bg-light',
64+
type: 'toggle-query-param',
65+
label: 'Use Light background',
66+
queryParam: 'body-bg-light',
67+
},
6268
]),
6369
provideEnvironmentInitializer(() => {
6470
const origin = inject(ORIGIN)
@@ -69,11 +75,15 @@ bootstrapApplication(AppComponent, {
6975
filterIfInstanceOf(NavigationEnd),
7076
map((ev) => ev.urlAfterRedirects),
7177
startWith(inject(Router).url),
72-
map((path) => URL.parse(path, origin)?.searchParams?.get('body-bg') || null),
73-
filter((bgColor) => bgColor === null || /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(bgColor)),
78+
map((path) => {
79+
const qp = URL.parse(path, origin)?.searchParams
80+
const bgColor = qp?.get('body-bg') || '#fff'
81+
const lightFlag = qp?.get('body-bg-light') === 'true'
82+
return [/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(bgColor) ? bgColor : '#fff', lightFlag] as const
83+
}),
7484
)
75-
.subscribe((bgColor) => {
76-
doc.body.style.backgroundColor = bgColor || 'unset'
85+
.subscribe(([bgColor, useLightBg]) => {
86+
doc.body.style.backgroundColor = useLightBg ? `color-mix(in oklab, ${bgColor}, white 50%)` : bgColor
7787
})
7888
}),
7989
],

libs/components/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ bootstrapApplication(AppComponent, {
9595
{ label: 'Preview', value: 'preview' },
9696
],
9797
},
98+
{
99+
id: 'enable-feature-x',
100+
label: 'Enable Feature X',
101+
type: 'toggle-query-param',
102+
queryParam: 'feature-x',
103+
},
98104
]),
99105
],
100106
})

libs/components/src/lib/testing-fab/provide-testing-fab.spec.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ function setup(testingFabProviders: EnvironmentProviders) {
4949

5050
describe('provideTestingFab', () => {
5151
test('supports static widgets config and renders the testing fab', async () => {
52-
const widgets: readonly TestingFabWidget[] = [{ id: 'a1', label: 'Action', type: 'action', action: vi.fn() }]
52+
const widgets: readonly TestingFabWidget[] = [
53+
{ id: 'a1', label: 'Action', type: 'toggle-query-param', queryParam: 'flag' },
54+
]
5355

5456
setup(provideTestingFab(widgets))
5557

@@ -59,7 +61,9 @@ describe('provideTestingFab', () => {
5961
})
6062

6163
test('supports factory widgets config', async () => {
62-
const widgets: readonly TestingFabWidget[] = [{ id: 'a1', label: 'Action', type: 'action', action: vi.fn() }]
64+
const widgets: readonly TestingFabWidget[] = [
65+
{ id: 'a1', label: 'Action', type: 'toggle-query-param', queryParam: 'flag' },
66+
]
6367
const factory = vi.fn(() => widgets)
6468

6569
setup(provideTestingFab(factory))

libs/components/src/lib/testing-fab/testing-fab-widget.type.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,16 @@
11
import { Type } from '@angular/core'
22

33
export type TestingFabWidget =
4-
| TestingFabActionWidget
54
| TestingFabSelectQueryParamWidget
5+
| TestingFabToggleQueryParamWidget
66
| TestingFabCustomComponentWidget
77

88
interface TestingFabWidgetBase {
9+
type: 'select-query-param' | 'toggle-query-param' | 'custom-component'
910
id: string
1011
label: string
1112
}
1213

13-
export interface TestingFabActionWidget extends TestingFabWidgetBase {
14-
type: 'action'
15-
buttonLabel?: string
16-
action: () => void
17-
}
18-
1914
export interface TestingFabSelectQueryParamWidget extends TestingFabWidgetBase {
2015
type: 'select-query-param'
2116
queryParam: string
@@ -24,6 +19,12 @@ export interface TestingFabSelectQueryParamWidget extends TestingFabWidgetBase {
2419
skipEmptyOption?: boolean
2520
}
2621

22+
export interface TestingFabToggleQueryParamWidget extends TestingFabWidgetBase {
23+
type: 'toggle-query-param'
24+
queryParam: string
25+
hardReload?: boolean
26+
}
27+
2728
export interface TestingFabSelectOption {
2829
value: string
2930
label: string

libs/components/src/lib/testing-fab/testing-fab.component.html

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,10 @@
1515
@for (widget of widgets; track widget.id) {
1616
@let widgetId = 'sc-testing-fab-' + widget.id;
1717
<div class="sc-testing-fab__widget">
18-
<label [attr.for]="widgetId" class="sc-testing-fab__widget-label">{{ widget.label }}</label>
19-
18+
@let currentValue = qpWidgetValues()[widget.id];
2019
@switch (widget.type) {
21-
@case ('action') {
22-
<button type="button" [id]="widgetId" class="sc-testing-fab__action" (click)="widget.action()">
23-
{{ widget.buttonLabel ?? widget.label }}
24-
</button>
25-
}
2620
@case ('select-query-param') {
27-
@let currentValue = qpWidgetValues()[widget.id];
21+
<label [attr.for]="widgetId" class="sc-testing-fab__widget-label">{{ widget.label }}</label>
2822
<select
2923
[id]="widgetId"
3024
class="sc-testing-fab__select"
@@ -39,7 +33,21 @@
3933
}
4034
</select>
4135
}
36+
@case ('toggle-query-param') {
37+
<span class="sc-testing-fab__widget-label">{{ widget.label }}</span>
38+
39+
<label [attr.for]="widgetId" class="sc-testing-fab__checkbox">
40+
<input
41+
type="checkbox"
42+
[id]="widgetId"
43+
[checked]="currentValue"
44+
(change)="setToggleQueryParam(widget, $event)"
45+
/>
46+
<span [innerText]="currentValue ? 'Enabled' : 'Disabled'"></span>
47+
</label>
48+
}
4249
@case ('custom-component') {
50+
<span class="sc-testing-fab__widget-label">{{ widget.label }}</span>
4351
<ng-container [ngComponentOutlet]="widget.component" />
4452
}
4553
}

libs/components/src/lib/testing-fab/testing-fab.component.scss

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,19 @@ $defaultFabShadowHover: 0 0 32px rgb(0 0 0 / 40%);
120120
.sc-testing-fab__widget-label {
121121
font-weight: 600;
122122
font-size: 12px;
123+
line-height: 1.5;
124+
}
125+
.sc-testing-fab__checkbox {
126+
display: flex;
127+
align-items: center;
128+
gap: 8px;
129+
border: 1px solid #9ca3af;
130+
border-radius: 3px;
131+
padding: 6px 8px;
132+
& > span {
133+
flex: 1 1 auto;
134+
user-select: none;
135+
}
123136
}
124137

125138
.sc-testing-fab__select,

libs/components/src/lib/testing-fab/testing-fab.component.spec.ts

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,77 @@ describe('TestingFabComponent', () => {
117117
})
118118
})
119119

120+
test.each([
121+
['true', true],
122+
['1', true],
123+
['', true],
124+
['false', false],
125+
])('renders toggle widget from URL query parameter (%s)', (toggleValue, expectedChecked) => {
126+
const widgets: readonly TestingFabWidget[] = [
127+
{
128+
id: 'feature-flag',
129+
label: 'Feature Flag',
130+
type: 'toggle-query-param',
131+
queryParam: 'feature',
132+
},
133+
]
134+
135+
const encodedValue = encodeURIComponent(toggleValue)
136+
const { fixture, routerEvents } = setup(widgets, `/?feature=${encodedValue}`)
137+
routerEvents.next(new NavigationEnd(1, `/?feature=${encodedValue}`, `/?feature=${encodedValue}`))
138+
fixture.detectChanges()
139+
140+
const checkbox = queryRequired<HTMLInputElement>(fixture, 'input[type="checkbox"]')
141+
expect(checkbox.checked).toBe(expectedChecked)
142+
})
143+
144+
test('updates query parameters when toggle widget value changes', () => {
145+
const widgets: readonly TestingFabWidget[] = [
146+
{
147+
id: 'feature-flag',
148+
label: 'Feature Flag',
149+
type: 'toggle-query-param',
150+
queryParam: 'feature',
151+
},
152+
]
153+
154+
const { fixture, routerMock } = setup(widgets, '/?feature=true')
155+
const checkbox = queryRequired<HTMLInputElement>(fixture, 'input[type="checkbox"]')
156+
157+
checkbox.checked = false
158+
checkbox.dispatchEvent(new Event('change'))
159+
expect(routerMock.navigate).toHaveBeenCalledWith([], {
160+
queryParams: { feature: null },
161+
queryParamsHandling: 'merge',
162+
})
163+
164+
checkbox.checked = true
165+
checkbox.dispatchEvent(new Event('change'))
166+
expect(routerMock.navigate).toHaveBeenNthCalledWith(2, [], {
167+
queryParams: { feature: 'true' },
168+
queryParamsHandling: 'merge',
169+
})
170+
})
171+
172+
test('forces hard reload when configured on toggle widget', () => {
173+
const widgets: readonly TestingFabWidget[] = [
174+
{
175+
id: 'feature-flag',
176+
label: 'Feature Flag',
177+
type: 'toggle-query-param',
178+
queryParam: 'feature',
179+
hardReload: true,
180+
},
181+
]
182+
183+
const { fixture, routerMock } = setup(widgets, '/?feature=true')
184+
const checkbox = queryRequired<HTMLInputElement>(fixture, 'input[type="checkbox"]')
185+
checkbox.checked = false
186+
expect(() => checkbox.dispatchEvent(new Event('change'))).not.toThrow()
187+
188+
expect(routerMock.navigate).not.toHaveBeenCalled()
189+
})
190+
120191
test('forces hard reload when configured on select widget', () => {
121192
const widgets: readonly TestingFabWidget[] = [
122193
{
@@ -155,24 +226,6 @@ describe('TestingFabComponent', () => {
155226
expect(customWidget).not.toBeNull()
156227
})
157228

158-
test('runs action widgets when clicked', () => {
159-
const action = vi.fn()
160-
const widgets: readonly TestingFabWidget[] = [
161-
{
162-
id: 'toggle-i18n',
163-
label: 'Show i18n Keys',
164-
type: 'action',
165-
action,
166-
},
167-
]
168-
169-
const { fixture } = setup(widgets)
170-
const button = queryRequired<HTMLButtonElement>(fixture, '.sc-testing-fab__action')
171-
button.click()
172-
173-
expect(action).toHaveBeenCalledTimes(1)
174-
})
175-
176229
test('loads saved FAB position from local storage', () => {
177230
const { fixture } = setup([], '/', 'top-left')
178231
const host = fixture.nativeElement as HTMLElement

libs/components/src/lib/testing-fab/testing-fab.component.ts

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import { ChangeDetectionStrategy, Component, computed, effect, inject, signal }
33
import { toSignal } from '@angular/core/rxjs-interop'
44
import { NavigationEnd, Router } from '@angular/router'
55
import { filterIfInstanceOf, LocalStorage, ORIGIN } from '@shiftcode/ngx-core'
6+
import { isDefined } from '@shiftcode/utilities'
67
import { map, startWith } from 'rxjs'
78

89
import { TESTING_FAB_WIDGETS } from './testing-fab-config.token'
9-
import { TestingFabSelectQueryParamWidget } from './testing-fab-widget.type'
10+
import { TestingFabSelectQueryParamWidget, TestingFabToggleQueryParamWidget } from './testing-fab-widget.type'
1011

1112
const FAB_POSITIONS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const
1213
type TestingFabPosition = (typeof FAB_POSITIONS)[number]
@@ -23,6 +24,10 @@ function parsePosition(value: unknown): TestingFabPosition | null {
2324
return null
2425
}
2526

27+
function isToggleQueryParamValueTruthy(value: string): boolean {
28+
return value === 'true' || value === '1' || value === ''
29+
}
30+
2631
@Component({
2732
selector: 'sc-testing-fab',
2833
standalone: true,
@@ -59,13 +64,23 @@ export class TestingFabComponent {
5964
{ initialValue: new URLSearchParams() },
6065
)
6166

62-
protected readonly qpWidgetValues = computed((): Record<string, string> => {
67+
protected readonly qpWidgetValues = computed((): Record<string, string | boolean> => {
6368
const qp = this.queryParams()
64-
return Object.fromEntries(
65-
this.widgets
66-
.filter((w) => w.type === 'select-query-param')
67-
.map((w) => [w.id, qp.get(w.queryParam) ?? ''] as const),
68-
)
69+
const valueEntries = this.widgets
70+
.map((w): readonly [string, string | boolean] | null => {
71+
switch (w.type) {
72+
case 'select-query-param':
73+
return [w.id, qp.get(w.queryParam) ?? '']
74+
case 'toggle-query-param':
75+
return [w.id, qp.has(w.queryParam) ? isToggleQueryParamValueTruthy(qp.get(w.queryParam) ?? '') : false]
76+
case 'custom-component':
77+
return null
78+
default:
79+
return w
80+
}
81+
})
82+
.filter(isDefined)
83+
return Object.fromEntries(valueEntries)
6984
})
7085

7186
constructor() {
@@ -157,6 +172,29 @@ export class TestingFabComponent {
157172
})
158173
}
159174

175+
protected setToggleQueryParam(widget: TestingFabToggleQueryParamWidget, event: Event): void {
176+
const nextEnabled = (event.target as HTMLInputElement).checked
177+
const nextValue = nextEnabled ? 'true' : null
178+
179+
if (widget.hardReload) {
180+
const url = new URL(window.location.href)
181+
if (nextValue) {
182+
url.searchParams.set(widget.queryParam, nextValue)
183+
} else {
184+
url.searchParams.delete(widget.queryParam)
185+
}
186+
window.location.assign(url.toString())
187+
return
188+
}
189+
190+
void this.router.navigate([], {
191+
queryParams: {
192+
[widget.queryParam]: nextValue,
193+
},
194+
queryParamsHandling: 'merge',
195+
})
196+
}
197+
160198
private positionFromCoordinates(x: number, y: number): TestingFabPosition {
161199
const vertical = y <= window.innerHeight / 2 ? 'top' : 'bottom'
162200
const horizontal = x <= window.innerWidth / 2 ? 'left' : 'right'

0 commit comments

Comments
 (0)