diff --git a/libs/feature/customizable-color-chip-list/src/index.ts b/libs/feature/customizable-color-chip-list/src/index.ts index a95c6a1..919f8a0 100644 --- a/libs/feature/customizable-color-chip-list/src/index.ts +++ b/libs/feature/customizable-color-chip-list/src/index.ts @@ -1 +1,2 @@ export * from './lib/customizable-color-chip-list.component'; +export * from './lib/customizable-color-chip-list-url.service'; diff --git a/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list-url.service.spec.ts b/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list-url.service.spec.ts new file mode 100644 index 0000000..2770251 --- /dev/null +++ b/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list-url.service.spec.ts @@ -0,0 +1,194 @@ +import { TestBed } from '@angular/core/testing'; +import { + DefaultUrlSerializer, + NavigationEnd, + Router, + UrlTree, +} from '@angular/router'; +import { UrlStateService } from '@portfolio/url-state'; +import { Subject } from 'rxjs'; + +import { CustomizableColorChipListUrlService } from './customizable-color-chip-list-url.service'; + +describe('CustomizableColorChipListUrlService', () => { + let service: CustomizableColorChipListUrlService; + let routerMock: { + url: string; + events: Subject; + parseUrl: (url: string) => UrlTree; + }; + let urlStateServiceMock: { updateValue: jest.Mock }; + + beforeEach(() => { + const urlSerializer = new DefaultUrlSerializer(); + + routerMock = { + url: '/', + events: new Subject(), + parseUrl: (url: string) => urlSerializer.parse(url), + }; + + urlStateServiceMock = { + updateValue: jest.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + CustomizableColorChipListUrlService, + { provide: Router, useValue: routerMock }, + { provide: UrlStateService, useValue: urlStateServiceMock }, + ], + }); + }); + + function createService(): CustomizableColorChipListUrlService { + return TestBed.inject(CustomizableColorChipListUrlService); + } + + it('can create', () => { + service = createService(); + expect(service).toBeInstanceOf(CustomizableColorChipListUrlService); + }); + + it('returns null for spacing and rows by default', () => { + service = createService(); + + expect(service.getSpacing('project-id-1')).toBeNull(); + expect(service.getRows('project-id-1')).toBeNull(); + }); + + it('parses a single component with a single property from the URL', () => { + routerMock.url = '/?customColorChipLists=project-id-2:(spacing:small)'; + service = createService(); + + expect(service.getSpacing('project-id-2')).toBe('small'); + expect(service.getRows('project-id-2')).toBeNull(); + }); + + it('parses multiple components with multiple properties from the URL', () => { + routerMock.url = + '/?customColorChipLists=project-id-2:(spacing:small);project-id-5:(rows:3);project-id-7:(spacing:medium;rows:4)'; + service = createService(); + + expect(service.getSpacing('project-id-2')).toBe('small'); + expect(service.getRows('project-id-2')).toBeNull(); + + expect(service.getSpacing('project-id-5')).toBeNull(); + expect(service.getRows('project-id-5')).toBe(3); + + expect(service.getSpacing('project-id-7')).toBe('medium'); + expect(service.getRows('project-id-7')).toBe(4); + }); + + it('ignores entries without a recognized customization', () => { + routerMock.url = '/?customColorChipLists=project-id-2:(unknown:value)'; + service = createService(); + + expect(service.getSpacing('project-id-2')).toBeNull(); + expect(service.getRows('project-id-2')).toBeNull(); + }); + + it('can set spacing explicitly and pushes the serialized param to the URL', () => { + service = createService(); + service.setSpacing('project-id-2', 'small'); + + expect(service.getSpacing('project-id-2')).toBe('small'); + expect(urlStateServiceMock.updateValue).toHaveBeenCalledWith({ + customColorChipLists: 'project-id-2:(spacing:small)', + }); + }); + + it('can set rows explicitly and pushes the serialized param to the URL', () => { + service = createService(); + service.setRows('project-id-5', 3); + + expect(service.getRows('project-id-5')).toBe(3); + expect(urlStateServiceMock.updateValue).toHaveBeenCalledWith({ + customColorChipLists: 'project-id-5:(rows:3)', + }); + }); + + it('merges multiple set properties for the same component', () => { + service = createService(); + service.setSpacing('project-id-7', 'large'); + service.setRows('project-id-7', 4); + + expect(service.getSpacing('project-id-7')).toBe('large'); + expect(service.getRows('project-id-7')).toBe(4); + expect(urlStateServiceMock.updateValue).toHaveBeenLastCalledWith({ + customColorChipLists: 'project-id-7:(spacing:large;rows:4)', + }); + }); + + it('preserves customizations of other components when setting a new one', () => { + routerMock.url = '/?customColorChipLists=project-id-2:(spacing:small)'; + service = createService(); + service.setRows('project-id-5', 3); + + expect(urlStateServiceMock.updateValue).toHaveBeenCalledWith({ + customColorChipLists: + 'project-id-2:(spacing:small);project-id-5:(rows:3)', + }); + }); + + it('serializes components in stable key order independent of set order', () => { + service = createService(); + service.setRows('project-id-5', 3); + service.setSpacing('project-id-2', 'small'); + + expect(urlStateServiceMock.updateValue).toHaveBeenLastCalledWith({ + customColorChipLists: + 'project-id-2:(spacing:small);project-id-5:(rows:3)', + }); + }); + + it('does not update the URL when setting the same value again', () => { + service = createService(); + service.setSpacing('project-id-2', 'small'); + urlStateServiceMock.updateValue.mockClear(); + + service.setSpacing('project-id-2', 'small'); + + expect(urlStateServiceMock.updateValue).not.toHaveBeenCalled(); + }); + + it('removes spacing from URL when set to null', () => { + service = createService(); + service.setSpacing('project-id-2', 'small'); + urlStateServiceMock.updateValue.mockClear(); + + service.setSpacing('project-id-2', null); + + expect(service.getSpacing('project-id-2')).toBeNull(); + expect(urlStateServiceMock.updateValue).toHaveBeenCalledWith({ + customColorChipLists: null, + }); + }); + + it('removes rows from URL when set to null and keeps other component values', () => { + service = createService(); + service.setRows('project-id-2', 3); + service.setSpacing('project-id-5', 'medium'); + urlStateServiceMock.updateValue.mockClear(); + + service.setRows('project-id-2', null); + + expect(service.getRows('project-id-2')).toBeNull(); + expect(service.getSpacing('project-id-5')).toBe('medium'); + expect(urlStateServiceMock.updateValue).toHaveBeenCalledWith({ + customColorChipLists: 'project-id-5:(spacing:medium)', + }); + }); + + it('syncs customizations when the URL changes through navigation', () => { + service = createService(); + routerMock.url = + '/?customColorChipLists=project-id-2:(spacing:small;rows:3)'; + routerMock.events.next( + new NavigationEnd(1, routerMock.url, routerMock.url) + ); + + expect(service.getSpacing('project-id-2')).toBe('small'); + expect(service.getRows('project-id-2')).toBe(3); + }); +}); diff --git a/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list-url.service.ts b/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list-url.service.ts new file mode 100644 index 0000000..5cd32bb --- /dev/null +++ b/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list-url.service.ts @@ -0,0 +1,146 @@ +import { DestroyRef, inject, Injectable, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NavigationEnd, Router } from '@angular/router'; +import { ChipSpacing } from '@portfolio/color-chip'; +import { UrlStateService } from '@portfolio/url-state'; +import isEqual from 'lodash/isEqual'; +import { distinctUntilChanged, filter, map } from 'rxjs'; + +interface ColorChipListCustomization { + spacing?: ChipSpacing | null; + rows?: number | null; +} + +type CustomizationMap = Record; + +const QUERY_PARAM = 'customColorChipLists'; +const ENTRY_PATTERN = /([^;()]+):\(([^)]*)\)/g; + +/** + * Aggregates the customizations of all rendered + * `CustomizableColorChipListComponent`s into a single URL query param and + * keeps them in sync with URL changes. + */ +@Injectable({ + providedIn: 'root', +}) +export class CustomizableColorChipListUrlService { + private readonly router = inject(Router); + private readonly urlStateService = inject(UrlStateService); + private readonly destroyRef = inject(DestroyRef); + + private readonly customizations = signal( + this.getCustomizationsFromCurrentUrl() + ); + + constructor() { + this.router.events + .pipe( + filter(event => event instanceof NavigationEnd), + map(() => this.getCustomizationsFromCurrentUrl()), + distinctUntilChanged(isEqual), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(customizations => this.customizations.set(customizations)); + } + + getSpacing(urlPersistenceKey: string): ChipSpacing | null { + return this.customizations()[urlPersistenceKey]?.spacing ?? null; + } + + getRows(urlPersistenceKey: string): number | null { + return this.customizations()[urlPersistenceKey]?.rows ?? null; + } + + setSpacing(urlPersistenceKey: string, spacing: ChipSpacing | null): void { + this.updateCustomization(urlPersistenceKey, { spacing }); + } + + setRows(urlPersistenceKey: string, rows: number | null): void { + this.updateCustomization(urlPersistenceKey, { rows }); + } + + private updateCustomization( + urlPersistenceKey: string, + partial: ColorChipListCustomization + ): void { + const current = this.customizations(); + const next: CustomizationMap = { + ...current, + [urlPersistenceKey]: { ...current[urlPersistenceKey], ...partial }, + }; + + if (isEqual(current, next)) { + return; + } + + this.customizations.set(next); + this.urlStateService.updateValue({ + [QUERY_PARAM]: this.toUrlParam(next), + }); + } + + private getCustomizationsFromCurrentUrl(): CustomizationMap { + const urlTree = this.router.parseUrl(this.router.url); + return this.parseUrlParam(urlTree.queryParams[QUERY_PARAM] ?? null); + } + + private parseUrlParam(param: string | null): CustomizationMap { + const result: CustomizationMap = {}; + if (!param) { + return result; + } + + for (const match of param.matchAll(ENTRY_PATTERN)) { + const [, urlPersistenceKey, propsPart] = match; + const customization: ColorChipListCustomization = {}; + + for (const prop of propsPart.split(';')) { + const [key, value] = prop.split(':'); + if (key === 'spacing' && this.isChipSpacing(value)) { + customization.spacing = value; + } else if (key === 'rows') { + const rows = Number(value); + if (Number.isInteger(rows) && rows > 0) { + customization.rows = rows; + } + } + } + + if (Object.keys(customization).length > 0) { + result[urlPersistenceKey] = customization; + } + } + + return result; + } + + private toUrlParam(customizations: CustomizationMap): string | null { + const entries = Object.entries(customizations).filter( + ([, customization]) => + customization.spacing != null || customization.rows != null + ); + + if (entries.length === 0) { + return null; + } + + return [...entries] + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + .map(([urlPersistenceKey, customization]) => { + const props: string[] = []; + if (customization.spacing != null) { + props.push(`spacing:${customization.spacing}`); + } + if (customization.rows != null) { + props.push(`rows:${customization.rows}`); + } + return `${urlPersistenceKey}:(${props.join(';')})`; + }) + .join(';'); + } + + private isChipSpacing(value: string): value is ChipSpacing { + return value === 'small' || value === 'medium' || value === 'large'; + } +} diff --git a/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list.component.spec.ts b/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list.component.spec.ts index 221bada..ec41035 100644 --- a/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list.component.spec.ts +++ b/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list.component.spec.ts @@ -1,30 +1,75 @@ -import { signal } from '@angular/core'; +import { signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { provideRouter } from '@angular/router'; +import { ChipSpacing } from '@portfolio/color-chip'; import { ColorChipListComponent } from '@portfolio/color-chip-list'; import { CustomizationStateService } from '@portfolio/customization-state'; +import { UrlStateService } from '@portfolio/url-state'; import { CustomizableColorChipListComponent } from './customizable-color-chip-list.component'; +import { CustomizableColorChipListUrlService } from './customizable-color-chip-list-url.service'; describe('CustomizableColorChipListComponent', () => { let component: CustomizableColorChipListComponent; let fixture: ComponentFixture; + let customSpacingByKey: WritableSignal>; + let customRowsByKey: WritableSignal>; + let mockCustomizableColorChipListUrlService: { + getSpacing: jest.Mock; + getRows: jest.Mock; + setSpacing: jest.Mock; + setRows: jest.Mock; + }; + + const urlPersistenceKey = 'test-chip-list'; beforeEach(async () => { + customSpacingByKey = signal>({}); + customRowsByKey = signal>({}); + mockCustomizableColorChipListUrlService = { + getSpacing: jest.fn((key: string) => customSpacingByKey()[key] ?? null), + getRows: jest.fn((key: string) => customRowsByKey()[key] ?? null), + setSpacing: jest.fn((key: string, value: ChipSpacing | null) => { + customSpacingByKey.update(state => ({ + ...state, + [key]: value, + })); + }), + setRows: jest.fn((key: string, value: number | null) => { + customRowsByKey.update(state => ({ + ...state, + [key]: value, + })); + }), + }; + await TestBed.configureTestingModule({ imports: [CustomizableColorChipListComponent], providers: [ + provideRouter([]), { provide: CustomizationStateService, useValue: { isPanelShown: signal(true), }, }, + { + provide: UrlStateService, + useValue: { + updateValue: jest.fn(), + }, + }, + { + provide: CustomizableColorChipListUrlService, + useValue: mockCustomizableColorChipListUrlService, + }, ], }).compileComponents(); fixture = TestBed.createComponent(CustomizableColorChipListComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('urlPersistenceKey', urlPersistenceKey); fixture.detectChanges(); }); @@ -77,6 +122,13 @@ describe('CustomizableColorChipListComponent', () => { getRowsActionButton('increase').nativeElement.click(); fixture.detectChanges(); + expect( + mockCustomizableColorChipListUrlService.setSpacing + ).toHaveBeenCalledWith(urlPersistenceKey, 'small'); + expect( + mockCustomizableColorChipListUrlService.setRows + ).toHaveBeenCalledWith(urlPersistenceKey, 3); + const wrapped = fixture.debugElement.query( By.directive(ColorChipListComponent) ).componentInstance as ColorChipListComponent; @@ -99,6 +151,45 @@ describe('CustomizableColorChipListComponent', () => { expect(wrapped.rows()).toBe(1); }); + it('should remove custom rows and spacing when matching the input values again', () => { + fixture.componentRef.setInput('spacing', 'small'); + fixture.componentRef.setInput('rows', 2); + fixture.detectChanges(); + + clickSpacingButton('medium'); + fixture.detectChanges(); + getRowsActionButton('increase').nativeElement.click(); + fixture.detectChanges(); + + mockCustomizableColorChipListUrlService.setSpacing.mockClear(); + mockCustomizableColorChipListUrlService.setRows.mockClear(); + + clickSpacingButton('small'); + fixture.detectChanges(); + getRowsActionButton('decrease').nativeElement.click(); + fixture.detectChanges(); + + expect( + mockCustomizableColorChipListUrlService.setSpacing + ).toHaveBeenCalledWith(urlPersistenceKey, null); + expect( + mockCustomizableColorChipListUrlService.setRows + ).toHaveBeenCalledWith(urlPersistenceKey, null); + }); + + it('should reflect persisted spacing and rows from the URL service on load', () => { + customSpacingByKey.set({ [urlPersistenceKey]: 'medium' }); + customRowsByKey.set({ [urlPersistenceKey]: 4 }); + fixture.detectChanges(); + + const wrapped = fixture.debugElement.query( + By.directive(ColorChipListComponent) + ).componentInstance as ColorChipListComponent; + + expect(wrapped.spacing()).toBe('medium'); + expect(wrapped.rows()).toBe(4); + }); + it('should display the effective rows count between the rows buttons', () => { fixture.componentRef.setInput('rows', 4); fixture.detectChanges(); diff --git a/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list.component.stories.ts b/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list.component.stories.ts index 37bf2e8..8b6723d 100644 --- a/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list.component.stories.ts +++ b/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list.component.stories.ts @@ -1,15 +1,50 @@ -import { signal } from '@angular/core'; +import { signal, type WritableSignal } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import type { ChipSpacing } from '@portfolio/color-chip'; import { CustomizationStateService } from '@portfolio/customization-state'; import type { Meta, StoryObj } from '@storybook/angular'; -import { moduleMetadata } from '@storybook/angular'; +import { + applicationConfig, + componentWrapperDecorator, + moduleMetadata, +} from '@storybook/angular'; import { CustomizableColorChipListComponent } from './customizable-color-chip-list.component'; +import { CustomizableColorChipListUrlService } from './customizable-color-chip-list-url.service'; + +const customSpacingByKey: WritableSignal> = + signal({}); +const customRowsByKey: WritableSignal> = signal( + {} +); + +const mockCustomizableColorChipListUrlService = { + getSpacing: (urlPersistenceKey: string) => + customSpacingByKey()[urlPersistenceKey] ?? null, + getRows: (urlPersistenceKey: string) => + customRowsByKey()[urlPersistenceKey] ?? null, + setSpacing: (urlPersistenceKey: string, spacing: ChipSpacing | null) => { + customSpacingByKey.update(state => ({ + ...state, + [urlPersistenceKey]: spacing, + })); + }, + setRows: (urlPersistenceKey: string, rows: number | null) => { + customRowsByKey.update(state => ({ + ...state, + [urlPersistenceKey]: rows, + })); + }, +}; const meta: Meta = { title: 'Feature/Customizable Color Chip List', component: CustomizableColorChipListComponent, tags: ['autodocs'], decorators: [ + applicationConfig({ + providers: [provideRouter([])], + }), moduleMetadata({ providers: [ { @@ -18,15 +53,28 @@ const meta: Meta = { isPanelShown: signal(true), }, }, + { + provide: CustomizableColorChipListUrlService, + useValue: mockCustomizableColorChipListUrlService, + }, ], }), + componentWrapperDecorator( + story => + `
+
+ ${story} +
+
` + ), ], args: { printMode: false, rows: 1, - greenItems: ['Angular', 'Nx', 'SSR'], - yellowItems: ['TypeScript', 'Storybook'], - grayItems: ['Legacy API', 'Monolith'], + urlPersistenceKey: 'storybook-chip-list-default', + greenItems: ['Angular', 'Nx', 'SSR', 'RxJS', 'Signals'], + yellowItems: ['TypeScript', 'Storybook', 'Playwright'], + grayItems: ['Legacy API', 'Monolith', 'SOAP'], }, argTypes: { greenItems: { control: 'object' }, @@ -56,12 +104,14 @@ export const Default: Story = {}; export const TwoRows: Story = { args: { + urlPersistenceKey: 'storybook-chip-list-two-rows', rows: 2, }, }; export const PrintMode: Story = { args: { + urlPersistenceKey: 'storybook-chip-list-print-mode', printMode: true, rows: 2, greenItems: ['Angular', 'Nx', 'SSR', 'RxJS', 'Signals'], diff --git a/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list.component.ts b/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list.component.ts index 4b0e959..19c35bb 100644 --- a/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list.component.ts +++ b/libs/feature/customizable-color-chip-list/src/lib/customizable-color-chip-list.component.ts @@ -5,7 +5,6 @@ import { computed, inject, input, - signal, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; @@ -14,6 +13,8 @@ import type { ChipSpacing } from '@portfolio/color-chip'; import { ColorChipListComponent } from '@portfolio/color-chip-list'; import { CustomizationStateService } from '@portfolio/customization-state'; +import { CustomizableColorChipListUrlService } from './customizable-color-chip-list-url.service'; + @Component({ selector: 'lib-customizable-color-chip-list', imports: [ @@ -30,6 +31,9 @@ export class CustomizableColorChipListComponent { protected readonly customizationStateService = inject( CustomizationStateService ); + private readonly customizableColorChipListUrlService = inject( + CustomizableColorChipListUrlService + ); greenItems = input([]); yellowItems = input([]); @@ -37,9 +41,16 @@ export class CustomizableColorChipListComponent { spacing = input('large'); printMode = input(false, { transform: booleanAttribute }); rows = input(1); + urlPersistenceKey = input.required(); - private readonly customSpacing = signal(null); - private readonly customRows = signal(null); + private readonly customSpacing = computed(() => + this.customizableColorChipListUrlService.getSpacing( + this.urlPersistenceKey() + ) + ); + private readonly customRows = computed(() => + this.customizableColorChipListUrlService.getRows(this.urlPersistenceKey()) + ); readonly effectiveSpacing = computed( () => this.customSpacing() ?? this.spacing() @@ -47,14 +58,25 @@ export class CustomizableColorChipListComponent { readonly effectiveRows = computed(() => this.customRows() ?? this.rows()); protected setSpacing(spacing: ChipSpacing): void { - this.customSpacing.set(spacing); + this.customizableColorChipListUrlService.setSpacing( + this.urlPersistenceKey(), + this.spacing() === spacing ? null : spacing + ); } protected decreaseRows(): void { - this.customRows.set(Math.max(1, this.effectiveRows() - 1)); + const rows = Math.max(1, this.effectiveRows() - 1); + this.customizableColorChipListUrlService.setRows( + this.urlPersistenceKey(), + this.rows() === rows ? null : rows + ); } protected increaseRows(): void { - this.customRows.set(this.effectiveRows() + 1); + const rows = this.effectiveRows() + 1; + this.customizableColorChipListUrlService.setRows( + this.urlPersistenceKey(), + this.rows() === rows ? null : rows + ); } } diff --git a/libs/feature/project-list/src/lib/customizable-project-item.component.spec.ts b/libs/feature/project-list/src/lib/customizable-project-item.component.spec.ts index 6ab6195..74976b6 100644 --- a/libs/feature/project-list/src/lib/customizable-project-item.component.spec.ts +++ b/libs/feature/project-list/src/lib/customizable-project-item.component.spec.ts @@ -2,8 +2,10 @@ import { signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { provideRouter } from '@angular/router'; +import { CustomizableColorChipListUrlService } from '@portfolio/customizable-color-chip-list'; import { CustomizationStateService } from '@portfolio/customization-state'; import { Project } from '@portfolio/search-engine-domain'; +import { UrlStateService } from '@portfolio/url-state'; import { CustomizableProjectItemComponent } from './customizable-project-item.component'; import { CustomizableProjectItemUrlService } from './customizable-project-item-url.service'; @@ -85,12 +87,27 @@ describe('CustomizableProjectItemComponent', () => { imports: [CustomizableProjectItemComponent], providers: [ provideRouter([]), + { + provide: UrlStateService, + useValue: { + updateValue: () => undefined, + }, + }, { provide: CustomizationStateService, useValue: { isPanelShown: signal(true), }, }, + { + provide: CustomizableColorChipListUrlService, + useValue: { + getSpacing: () => null, + getRows: () => null, + setSpacing: () => undefined, + setRows: () => undefined, + }, + }, { provide: CustomizableProjectItemUrlService, useValue: mockCustomizableProjectItemUrlService, diff --git a/libs/feature/project-list/src/lib/project-item.component.html b/libs/feature/project-list/src/lib/project-item.component.html index 3b851e0..fb8bd7f 100644 --- a/libs/feature/project-list/src/lib/project-item.component.html +++ b/libs/feature/project-list/src/lib/project-item.component.html @@ -80,6 +80,7 @@

Highlights

[grayItems]="project().technologies.nonMatches" [rows]="isTopProject() ? 2 : 1" [printMode]="printMode()" + [urlPersistenceKey]="project().id" /> diff --git a/libs/feature/project-list/src/lib/project-item.component.spec.ts b/libs/feature/project-list/src/lib/project-item.component.spec.ts index bb26915..9278069 100644 --- a/libs/feature/project-list/src/lib/project-item.component.spec.ts +++ b/libs/feature/project-list/src/lib/project-item.component.spec.ts @@ -1,6 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; +import { CustomizableColorChipListUrlService } from '@portfolio/customizable-color-chip-list'; import { Project } from '@portfolio/search-engine-domain'; +import { UrlStateService } from '@portfolio/url-state'; import { ProjectItemComponent } from './project-item.component'; @@ -44,7 +46,24 @@ describe('ProjectItemComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ProjectItemComponent], - providers: [provideRouter([])], + providers: [ + provideRouter([]), + { + provide: UrlStateService, + useValue: { + updateValue: () => undefined, + }, + }, + { + provide: CustomizableColorChipListUrlService, + useValue: { + getSpacing: () => null, + getRows: () => null, + setSpacing: () => undefined, + setRows: () => undefined, + }, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(ProjectItemComponent); diff --git a/libs/feature/project-list/src/lib/project-item.component.stories.ts b/libs/feature/project-list/src/lib/project-item.component.stories.ts index 27a552a..66a8ce8 100644 --- a/libs/feature/project-list/src/lib/project-item.component.stories.ts +++ b/libs/feature/project-list/src/lib/project-item.component.stories.ts @@ -1,5 +1,7 @@ import { provideRouter } from '@angular/router'; +import { CustomizableColorChipListUrlService } from '@portfolio/customizable-color-chip-list'; import { Project } from '@portfolio/search-engine-domain'; +import { UrlStateService } from '@portfolio/url-state'; import type { Meta, StoryObj } from '@storybook/angular'; import { applicationConfig, @@ -14,7 +16,24 @@ const meta: Meta = { tags: ['autodocs'], decorators: [ applicationConfig({ - providers: [provideRouter([])], + providers: [ + provideRouter([]), + { + provide: UrlStateService, + useValue: { + updateValue: () => undefined, + }, + }, + { + provide: CustomizableColorChipListUrlService, + useValue: { + getSpacing: () => null, + getRows: () => null, + setSpacing: () => undefined, + setRows: () => undefined, + }, + }, + ], }), componentWrapperDecorator( story => diff --git a/libs/feature/skill-section/src/lib/skill-section.component.html b/libs/feature/skill-section/src/lib/skill-section.component.html index 2183f8a..667d9c5 100644 --- a/libs/feature/skill-section/src/lib/skill-section.component.html +++ b/libs/feature/skill-section/src/lib/skill-section.component.html @@ -26,6 +26,7 @@ [greenItems]="categoryRow.tagLists.fullMatches" [yellowItems]="categoryRow.tagLists.partialMatches" [grayItems]="categoryRow.tagLists.nonMatches" + [urlPersistenceKey]="sanitizeUrlPersistenceKey(categoryRow.category)" [printMode]="isPrintMode()" /> @@ -38,6 +39,7 @@ #andMoreSpacerRef class="skill-category-content and-more spacer" [grayItems]="['spacer']" + [urlPersistenceKey]="'skills-and-more-spacer'" [printMode]="isPrintMode()" />} } diff --git a/libs/feature/skill-section/src/lib/skill-section.component.spec.ts b/libs/feature/skill-section/src/lib/skill-section.component.spec.ts index 00185c6..4e845c4 100644 --- a/libs/feature/skill-section/src/lib/skill-section.component.spec.ts +++ b/libs/feature/skill-section/src/lib/skill-section.component.spec.ts @@ -3,11 +3,13 @@ import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; import { By } from '@angular/platform-browser'; import { provideRouter } from '@angular/router'; +import { CustomizableColorChipListUrlService } from '@portfolio/customizable-color-chip-list'; import { SearchEngineService, type SearchResult, } from '@portfolio/search-engine-angular'; import type { Project as SearchEngineProject } from '@portfolio/search-engine-domain'; +import { UrlStateService } from '@portfolio/url-state'; import { BehaviorSubject } from 'rxjs'; import { SkillSectionComponent } from './skill-section.component'; @@ -90,6 +92,21 @@ describe('SkillSectionComponent', () => { provide: SearchEngineService, useValue: { searchResult$: searchResultSubject.asObservable() }, }, + { + provide: UrlStateService, + useValue: { + updateValue: () => undefined, + }, + }, + { + provide: CustomizableColorChipListUrlService, + useValue: { + getSpacing: () => null, + getRows: () => null, + setSpacing: () => undefined, + setRows: () => undefined, + }, + }, provideRouter([]), ], }).compileComponents(); diff --git a/libs/feature/skill-section/src/lib/skill-section.component.ts b/libs/feature/skill-section/src/lib/skill-section.component.ts index e9395b2..5bbab4a 100644 --- a/libs/feature/skill-section/src/lib/skill-section.component.ts +++ b/libs/feature/skill-section/src/lib/skill-section.component.ts @@ -70,6 +70,10 @@ export class SkillSectionComponent implements AfterViewInit, OnDestroy { constructor(@Inject(PLATFORM_ID) private platformId: object) {} + protected sanitizeUrlPersistenceKey(category: string): string { + return encodeURIComponent(category); + } + ngAfterViewInit(): void { this.showSkeletons$.pipe(takeUntil(this.destroy$)).subscribe(show => { if (!show) {