From 723bbb22dddb7bf5b7f93a7dd97c2e92e682caa6 Mon Sep 17 00:00:00 2001 From: Waog Date: Tue, 11 Aug 2026 12:24:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E2=9C=A8=20add=20customizabl?= =?UTF-8?q?e=20project=20item=20state=20persistence=20in=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tomizable-project-item-url.service.spec.ts | 190 ++++++++++++++++++ .../customizable-project-item-url.service.ts | 139 +++++++++++++ ...ustomizable-project-item.component.spec.ts | 87 +++++++- .../customizable-project-item.component.ts | 24 ++- 4 files changed, 434 insertions(+), 6 deletions(-) create mode 100644 libs/feature/project-list/src/lib/customizable-project-item-url.service.spec.ts create mode 100644 libs/feature/project-list/src/lib/customizable-project-item-url.service.ts diff --git a/libs/feature/project-list/src/lib/customizable-project-item-url.service.spec.ts b/libs/feature/project-list/src/lib/customizable-project-item-url.service.spec.ts new file mode 100644 index 0000000..fb33274 --- /dev/null +++ b/libs/feature/project-list/src/lib/customizable-project-item-url.service.spec.ts @@ -0,0 +1,190 @@ +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 { CustomizableProjectItemUrlService } from './customizable-project-item-url.service'; + +describe('CustomizableProjectItemUrlService', () => { + let service: CustomizableProjectItemUrlService; + 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: [ + CustomizableProjectItemUrlService, + { provide: Router, useValue: routerMock }, + { provide: UrlStateService, useValue: urlStateServiceMock }, + ], + }); + }); + + function createService(): CustomizableProjectItemUrlService { + return TestBed.inject(CustomizableProjectItemUrlService); + } + + it('can create', () => { + service = createService(); + expect(service).toBeInstanceOf(CustomizableProjectItemUrlService); + }); + + it('returns null for isTopProject/compact by default', () => { + service = createService(); + + expect(service.getIsTopProject('project-id-1')).toBeNull(); + expect(service.getCompact('project-id-1')).toBeNull(); + }); + + it('parses a single project with a single property from the URL', () => { + routerMock.url = '/?customProjItems=project-id-2:(top:true)'; + service = createService(); + + expect(service.getIsTopProject('project-id-2')).toBe(true); + expect(service.getCompact('project-id-2')).toBeNull(); + }); + + it('parses multiple projects with multiple properties from the URL', () => { + routerMock.url = + '/?customProjItems=project-id-2:(top:true);project-id-5:(compact:false);project-id-7:(top:false;compact:false)'; + service = createService(); + + expect(service.getIsTopProject('project-id-2')).toBe(true); + expect(service.getCompact('project-id-2')).toBeNull(); + + expect(service.getIsTopProject('project-id-5')).toBeNull(); + expect(service.getCompact('project-id-5')).toBe(false); + + expect(service.getIsTopProject('project-id-7')).toBe(false); + expect(service.getCompact('project-id-7')).toBe(false); + }); + + it('ignores projects not present in the URL', () => { + routerMock.url = '/?customProjItems=project-id-2:(top:true)'; + service = createService(); + + expect(service.getIsTopProject('unknown-project')).toBeNull(); + expect(service.getCompact('unknown-project')).toBeNull(); + }); + + it('can set isTopProject explicitly and pushes the serialized param to the URL', () => { + service = createService(); + service.setIsTopProject('project-id-2', true); + + expect(service.getIsTopProject('project-id-2')).toBe(true); + expect(urlStateServiceMock.updateValue).toHaveBeenCalledWith({ + customProjItems: 'project-id-2:(top:true)', + }); + }); + + it('can set compact explicitly and pushes the serialized param to the URL', () => { + service = createService(); + service.setCompact('project-id-5', false); + + expect(service.getCompact('project-id-5')).toBe(false); + expect(urlStateServiceMock.updateValue).toHaveBeenCalledWith({ + customProjItems: 'project-id-5:(compact:false)', + }); + }); + + it('merges multiple set properties for the same project', () => { + service = createService(); + service.setIsTopProject('project-id-7', false); + service.setCompact('project-id-7', false); + + expect(service.getIsTopProject('project-id-7')).toBe(false); + expect(service.getCompact('project-id-7')).toBe(false); + expect(urlStateServiceMock.updateValue).toHaveBeenLastCalledWith({ + customProjItems: 'project-id-7:(top:false;compact:false)', + }); + }); + + it('preserves customizations of other projects when setting a new one', () => { + routerMock.url = '/?customProjItems=project-id-2:(top:true)'; + service = createService(); + service.setCompact('project-id-5', false); + + expect(urlStateServiceMock.updateValue).toHaveBeenCalledWith({ + customProjItems: 'project-id-2:(top:true);project-id-5:(compact:false)', + }); + }); + + it('serializes projects in stable project-id order independent of set order', () => { + service = createService(); + service.setCompact('project-id-5', false); + service.setIsTopProject('project-id-2', true); + + expect(urlStateServiceMock.updateValue).toHaveBeenLastCalledWith({ + customProjItems: 'project-id-2:(top:true);project-id-5:(compact:false)', + }); + }); + + it('does not update the URL when setting the same value again', () => { + service = createService(); + service.setIsTopProject('project-id-2', true); + urlStateServiceMock.updateValue.mockClear(); + + service.setIsTopProject('project-id-2', true); + + expect(urlStateServiceMock.updateValue).not.toHaveBeenCalled(); + }); + + it('removes isTopProject from URL when set to null', () => { + service = createService(); + service.setIsTopProject('project-id-2', true); + urlStateServiceMock.updateValue.mockClear(); + + service.setIsTopProject('project-id-2', null); + + expect(service.getIsTopProject('project-id-2')).toBeNull(); + expect(urlStateServiceMock.updateValue).toHaveBeenCalledWith({ + customProjItems: null, + }); + }); + + it('removes compact from URL when set to null and keeps other project values', () => { + service = createService(); + service.setCompact('project-id-2', false); + service.setIsTopProject('project-id-5', true); + urlStateServiceMock.updateValue.mockClear(); + + service.setCompact('project-id-2', null); + + expect(service.getCompact('project-id-2')).toBeNull(); + expect(service.getIsTopProject('project-id-5')).toBe(true); + expect(urlStateServiceMock.updateValue).toHaveBeenCalledWith({ + customProjItems: 'project-id-5:(top:true)', + }); + }); + + it('syncs customizations when the URL changes through navigation', () => { + service = createService(); + routerMock.url = '/?customProjItems=project-id-2:(top:true)'; + routerMock.events.next( + new NavigationEnd(1, routerMock.url, routerMock.url) + ); + + expect(service.getIsTopProject('project-id-2')).toBe(true); + }); +}); diff --git a/libs/feature/project-list/src/lib/customizable-project-item-url.service.ts b/libs/feature/project-list/src/lib/customizable-project-item-url.service.ts new file mode 100644 index 0000000..3e5f525 --- /dev/null +++ b/libs/feature/project-list/src/lib/customizable-project-item-url.service.ts @@ -0,0 +1,139 @@ +import { DestroyRef, inject, Injectable, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NavigationEnd, Router } from '@angular/router'; +import { UrlStateService } from '@portfolio/url-state'; +import isEqual from 'lodash/isEqual'; +import { distinctUntilChanged, filter, map } from 'rxjs'; + +interface ProjectItemCustomization { + isTopProject?: boolean | null; + compact?: boolean | null; +} + +type CustomizationMap = Record; + +const QUERY_PARAM = 'customProjItems'; +const ENTRY_PATTERN = /([^;()]+):\(([^)]*)\)/g; + +/** + * Aggregates the customizations of all rendered `CustomizableProjectItemComponent`s + * into a single URL query param and keeps them in sync with URL changes. + */ +@Injectable({ + providedIn: 'root', +}) +export class CustomizableProjectItemUrlService { + 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)); + } + + getIsTopProject(projectId: string): boolean | null { + return this.customizations()[projectId]?.isTopProject ?? null; + } + + getCompact(projectId: string): boolean | null { + return this.customizations()[projectId]?.compact ?? null; + } + + setIsTopProject(projectId: string, isTopProject: boolean | null): void { + this.updateCustomization(projectId, { isTopProject }); + } + + setCompact(projectId: string, compact: boolean | null): void { + this.updateCustomization(projectId, { compact }); + } + + private updateCustomization( + projectId: string, + partial: ProjectItemCustomization + ): void { + const current = this.customizations(); + const next: CustomizationMap = { + ...current, + [projectId]: { ...current[projectId], ...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 [, projectId, propsPart] = match; + const customization: ProjectItemCustomization = {}; + + for (const prop of propsPart.split(';')) { + const [key, value] = prop.split(':'); + if (key === 'top') { + customization.isTopProject = value === 'true'; + } else if (key === 'compact') { + customization.compact = value === 'true'; + } + } + + if (Object.keys(customization).length > 0) { + result[projectId] = customization; + } + } + + return result; + } + + private toUrlParam(customizations: CustomizationMap): string | null { + const entries = Object.entries(customizations).filter( + ([, customization]) => + customization.isTopProject != null || customization.compact != null + ); + + if (entries.length === 0) { + return null; + } + + return [...entries] + .sort(([projectIdA], [projectIdB]) => + projectIdA.localeCompare(projectIdB) + ) + .map(([projectId, customization]) => { + const props: string[] = []; + if (customization.isTopProject != null) { + props.push(`top:${customization.isTopProject}`); + } + if (customization.compact != null) { + props.push(`compact:${customization.compact}`); + } + return `${projectId}:(${props.join(';')})`; + }) + .join(';'); + } +} 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 3217a16..6ab6195 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 @@ -1,4 +1,4 @@ -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'; @@ -6,11 +6,22 @@ import { CustomizationStateService } from '@portfolio/customization-state'; import { Project } from '@portfolio/search-engine-domain'; import { CustomizableProjectItemComponent } from './customizable-project-item.component'; +import { CustomizableProjectItemUrlService } from './customizable-project-item-url.service'; import { ProjectItemComponent } from './project-item.component'; describe('CustomizableProjectItemComponent', () => { let component: CustomizableProjectItemComponent; let fixture: ComponentFixture; + let customIsTopProjectByProjectId: WritableSignal< + Record + >; + let customCompactByProjectId: WritableSignal>; + let mockCustomizableProjectItemUrlService: { + getIsTopProject: jest.Mock; + getCompact: jest.Mock; + setIsTopProject: jest.Mock; + setCompact: jest.Mock; + }; const mockProject: Project = { id: 'test-project', @@ -46,6 +57,30 @@ describe('CustomizableProjectItemComponent', () => { }; beforeEach(async () => { + customIsTopProjectByProjectId = signal>({}); + customCompactByProjectId = signal>({}); + mockCustomizableProjectItemUrlService = { + getIsTopProject: jest.fn( + (projectId: string) => + customIsTopProjectByProjectId()[projectId] ?? null + ), + getCompact: jest.fn( + (projectId: string) => customCompactByProjectId()[projectId] ?? null + ), + setIsTopProject: jest.fn((projectId: string, value: boolean | null) => { + customIsTopProjectByProjectId.update(state => ({ + ...state, + [projectId]: value, + })); + }), + setCompact: jest.fn((projectId: string, value: boolean | null) => { + customCompactByProjectId.update(state => ({ + ...state, + [projectId]: value, + })); + }), + }; + await TestBed.configureTestingModule({ imports: [CustomizableProjectItemComponent], providers: [ @@ -56,6 +91,10 @@ describe('CustomizableProjectItemComponent', () => { isPanelShown: signal(true), }, }, + { + provide: CustomizableProjectItemUrlService, + useValue: mockCustomizableProjectItemUrlService, + }, ], }).compileComponents(); @@ -114,6 +153,52 @@ describe('CustomizableProjectItemComponent', () => { expect(wrapped.compact()).toBe(true); }); + it('should forward panel actions to CustomizableProjectItemUrlService keyed by project id', () => { + clickTopProjectButton(true); + clickCompactButton(true); + + expect( + mockCustomizableProjectItemUrlService.setIsTopProject + ).toHaveBeenCalledWith(mockProject.id, true); + expect( + mockCustomizableProjectItemUrlService.setCompact + ).toHaveBeenCalledWith(mockProject.id, true); + }); + + it('should pass null to URL service when selected value equals input default', () => { + fixture.componentRef.setInput('isTopProject', false); + fixture.componentRef.setInput('compact', false); + fixture.detectChanges(); + + clickTopProjectButton(true); + clickCompactButton(true); + mockCustomizableProjectItemUrlService.setIsTopProject.mockClear(); + mockCustomizableProjectItemUrlService.setCompact.mockClear(); + + clickTopProjectButton(false); + clickCompactButton(false); + + expect( + mockCustomizableProjectItemUrlService.setIsTopProject + ).toHaveBeenCalledWith(mockProject.id, null); + expect( + mockCustomizableProjectItemUrlService.setCompact + ).toHaveBeenCalledWith(mockProject.id, null); + }); + + it('should reflect a customization already present in CustomizableProjectItemUrlService on load', () => { + customIsTopProjectByProjectId.set({ [mockProject.id]: true }); + customCompactByProjectId.set({ [mockProject.id]: true }); + fixture.detectChanges(); + + const wrapped = fixture.debugElement.query( + By.directive(ProjectItemComponent) + ).componentInstance as ProjectItemComponent; + + expect(wrapped.isTopProject()).toBe(true); + expect(wrapped.compact()).toBe(true); + }); + function getTopProjectButton(value: boolean) { return fixture.debugElement.query( By.css(`mat-button-toggle[data-top-project="${value}"]`) diff --git a/libs/feature/project-list/src/lib/customizable-project-item.component.ts b/libs/feature/project-list/src/lib/customizable-project-item.component.ts index db97f19..b6779e9 100644 --- a/libs/feature/project-list/src/lib/customizable-project-item.component.ts +++ b/libs/feature/project-list/src/lib/customizable-project-item.component.ts @@ -1,9 +1,10 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, inject, input, signal } from '@angular/core'; +import { Component, computed, inject, input } from '@angular/core'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { CustomizationStateService } from '@portfolio/customization-state'; import { Project } from '@portfolio/search-engine-domain'; +import { CustomizableProjectItemUrlService } from './customizable-project-item-url.service'; import { ProjectItemComponent } from './project-item.component'; @Component({ @@ -16,14 +17,21 @@ export class CustomizableProjectItemComponent { protected readonly customizationStateService = inject( CustomizationStateService ); + private readonly customizableProjectItemUrlService = inject( + CustomizableProjectItemUrlService + ); project = input.required(); isTopProject = input(false); compact = input(false); printMode = input(false); - private readonly customIsTopProject = signal(null); - private readonly customCompact = signal(null); + private readonly customIsTopProject = computed(() => + this.customizableProjectItemUrlService.getIsTopProject(this.project().id) + ); + private readonly customCompact = computed(() => + this.customizableProjectItemUrlService.getCompact(this.project().id) + ); readonly effectiveIsTopProject = computed( () => this.customIsTopProject() ?? this.isTopProject() @@ -33,10 +41,16 @@ export class CustomizableProjectItemComponent { ); protected setIsTopProject(isTopProject: boolean): void { - this.customIsTopProject.set(isTopProject); + this.customizableProjectItemUrlService.setIsTopProject( + this.project().id, + this.isTopProject() === isTopProject ? null : isTopProject + ); } protected setCompact(compact: boolean): void { - this.customCompact.set(compact); + this.customizableProjectItemUrlService.setCompact( + this.project().id, + this.compact() === compact ? null : compact + ); } }