From cd2bf1f0d177c9d56c05e87377382b19c7595896 Mon Sep 17 00:00:00 2001 From: Waog Date: Thu, 6 Aug 2026 13:52:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E2=9C=A8=20add=20customizabl?= =?UTF-8?q?e=20project=20item=20wrapper=20with=20individual=20display=20op?= =?UTF-8?q?tions=20per=20item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../customizable-project-item.component.html | 46 ++++++ .../customizable-project-item.component.scss | 37 +++++ ...ustomizable-project-item.component.spec.ts | 142 ++++++++++++++++++ ...omizable-project-item.component.stories.ts | 120 +++++++++++++++ .../customizable-project-item.component.ts | 42 ++++++ .../src/lib/project-list.component.html | 4 +- .../src/lib/project-list.component.scss | 5 +- .../src/lib/project-list.component.ts | 4 +- 8 files changed, 393 insertions(+), 7 deletions(-) create mode 100644 libs/feature/project-list/src/lib/customizable-project-item.component.html create mode 100644 libs/feature/project-list/src/lib/customizable-project-item.component.scss create mode 100644 libs/feature/project-list/src/lib/customizable-project-item.component.spec.ts create mode 100644 libs/feature/project-list/src/lib/customizable-project-item.component.stories.ts create mode 100644 libs/feature/project-list/src/lib/customizable-project-item.component.ts diff --git a/libs/feature/project-list/src/lib/customizable-project-item.component.html b/libs/feature/project-list/src/lib/customizable-project-item.component.html new file mode 100644 index 00000000..0ce20b1c --- /dev/null +++ b/libs/feature/project-list/src/lib/customizable-project-item.component.html @@ -0,0 +1,46 @@ +
+ @if (customizationStateService.isPanelShown()) { + + } + + +
diff --git a/libs/feature/project-list/src/lib/customizable-project-item.component.scss b/libs/feature/project-list/src/lib/customizable-project-item.component.scss new file mode 100644 index 00000000..41132250 --- /dev/null +++ b/libs/feature/project-list/src/lib/customizable-project-item.component.scss @@ -0,0 +1,37 @@ +:host { + display: block; +} + +.customizable-project-item-container { + position: relative; +} + +.customization-panel { + position: absolute; + top: 0; + right: 0; + z-index: 10; + display: inline-flex; + align-items: center; + flex-wrap: nowrap; + gap: 0.5rem; + padding: 0 0.5rem; + border-radius: 100vh; // pill shape + background-color: white; + box-shadow: 0 2px 8px rgb(0 0 0 / 0.2); + white-space: nowrap; + opacity: 0; + pointer-events: none; +} + +:host(:hover) .customization-panel { + opacity: 1; + pointer-events: auto; +} + +.top-project-group, +.compact-group { + display: inline-flex; + align-items: center; + flex-wrap: nowrap; +} 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 new file mode 100644 index 00000000..3217a161 --- /dev/null +++ b/libs/feature/project-list/src/lib/customizable-project-item.component.spec.ts @@ -0,0 +1,142 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideRouter } from '@angular/router'; +import { CustomizationStateService } from '@portfolio/customization-state'; +import { Project } from '@portfolio/search-engine-domain'; + +import { CustomizableProjectItemComponent } from './customizable-project-item.component'; +import { ProjectItemComponent } from './project-item.component'; + +describe('CustomizableProjectItemComponent', () => { + let component: CustomizableProjectItemComponent; + let fixture: ComponentFixture; + + const mockProject: Project = { + id: 'test-project', + title: 'Test Project', + projectType: 'Web Application', + compactDescription: 'A test project for unit testing', + keyAchievements: 'Successfully implemented testing', + fullDescription: 'This is a full description of the test project', + features: ['Feature 1', 'Feature 2'], + highlights: ['Highlight 1', 'Highlight 2'], + technologies: { + fullMatches: ['Angular', 'TypeScript'], + partialMatches: [], + nonMatches: [], + }, + role: 'Developer', + team: 'Development Team', + from: new Date(Date.UTC(2024, 0)), + fromText: '01/2024', + to: new Date(Date.UTC(2024, 11)), + toText: '12/2024', + duration: { years: 1 }, + durationText: '1 year', + location: 'Remote', + workMode: 'Remote', + company: 'Test Company', + industry: 'Technology', + teamSize: 3, + engagementType: 'Client', + commercialContext: 'Paid', + usageScope: 'Public', + maturity: 'Production', + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CustomizableProjectItemComponent], + providers: [ + provideRouter([]), + { + provide: CustomizationStateService, + useValue: { + isPanelShown: signal(true), + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CustomizableProjectItemComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('project', mockProject); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should pass default values 1:1 to wrapped project item', () => { + const wrapped = fixture.debugElement.query( + By.directive(ProjectItemComponent) + ).componentInstance as ProjectItemComponent; + + expect(wrapped.project()).toEqual(mockProject); + expect(wrapped.isTopProject()).toBe(false); + expect(wrapped.compact()).toBe(false); + expect(wrapped.printMode()).toBe(false); + }); + + it('should pass all custom values 1:1 to wrapped project item', () => { + fixture.componentRef.setInput('isTopProject', true); + fixture.componentRef.setInput('compact', true); + fixture.componentRef.setInput('printMode', true); + fixture.detectChanges(); + + const wrapped = fixture.debugElement.query( + By.directive(ProjectItemComponent) + ).componentInstance as ProjectItemComponent; + + expect(wrapped.isTopProject()).toBe(true); + expect(wrapped.compact()).toBe(true); + expect(wrapped.printMode()).toBe(true); + }); + + it('should override initial isTopProject and compact values through panel actions', () => { + fixture.componentRef.setInput('isTopProject', false); + fixture.componentRef.setInput('compact', false); + fixture.detectChanges(); + + clickTopProjectButton(true); + fixture.detectChanges(); + + clickCompactButton(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}"]`) + ); + } + + function clickTopProjectButton(value: boolean) { + const toggleButtonElement = getTopProjectButton( + value + ).nativeElement.querySelector('button') as HTMLButtonElement; + toggleButtonElement.click(); + } + + function getCompactButton(value: boolean) { + return fixture.debugElement.query( + By.css(`mat-button-toggle[data-compact="${value}"]`) + ); + } + + function clickCompactButton(value: boolean) { + const toggleButtonElement = getCompactButton( + value + ).nativeElement.querySelector('button') as HTMLButtonElement; + toggleButtonElement.click(); + } +}); diff --git a/libs/feature/project-list/src/lib/customizable-project-item.component.stories.ts b/libs/feature/project-list/src/lib/customizable-project-item.component.stories.ts new file mode 100644 index 00000000..36a06391 --- /dev/null +++ b/libs/feature/project-list/src/lib/customizable-project-item.component.stories.ts @@ -0,0 +1,120 @@ +import { signal } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { CustomizationStateService } from '@portfolio/customization-state'; +import { Project } from '@portfolio/search-engine-domain'; +import type { Meta, StoryObj } from '@storybook/angular'; +import { + applicationConfig, + componentWrapperDecorator, + moduleMetadata, +} from '@storybook/angular'; + +import { CustomizableProjectItemComponent } from './customizable-project-item.component'; + +const meta: Meta = { + title: 'Feature/Customizable Project Item', + component: CustomizableProjectItemComponent, + tags: ['autodocs'], + decorators: [ + applicationConfig({ + providers: [provideRouter([])], + }), + moduleMetadata({ + providers: [ + { + provide: CustomizationStateService, + useValue: { + isPanelShown: signal(true), + }, + }, + ], + }), + componentWrapperDecorator( + story => + `
+
+ ${story} +
+
` + ), + ], + argTypes: { + project: { + control: 'object', + table: { + type: { summary: 'Project' }, + }, + }, + isTopProject: { control: 'boolean' }, + compact: { control: 'boolean' }, + printMode: { control: 'boolean' }, + }, +}; + +export default meta; +type Story = StoryObj; + +const mockProject: Project = { + title: 'Portfolio Website Development', + projectType: 'Web Application', + compactDescription: + 'Modern Angular portfolio showcasing professional experience and skills.', + keyAchievements: + 'Built responsive design with TypeScript and implemented advanced filtering.', + fullDescription: + 'Developed a comprehensive portfolio website using Angular 18 with a focus on modern web standards, accessibility, and performance. The project features a component-based architecture with reusable UI elements and implements advanced filtering capabilities for project browsing.', + features: [ + 'Responsive design with Angular Material', + 'TypeScript implementation', + 'Component-based architecture', + 'Advanced project filtering', + 'SEO optimization', + 'Performance monitoring', + ], + highlights: [ + 'Achieved 95+ Lighthouse performance score', + 'Implemented accessibility standards (WCAG 2.1)', + 'Built reusable component library', + 'Integrated automated testing suite', + ], + technologies: { + fullMatches: ['Angular', 'TypeScript'], + partialMatches: ['Jest', 'SCSS', 'RxJS'], + nonMatches: ['Nx', 'Mono Repo', 'Angular Material'], + }, + role: 'Full-Stack Developer', + team: 'Solo Project', + fromText: '01/2024', + toText: 'Present', + durationText: '2+ years', + location: 'Berlin, Germany', + workMode: 'Remote', + company: 'Personal Project', + industry: 'Technology', +} as Partial as Project; + +export const Default: Story = { + args: { + project: mockProject, + isTopProject: false, + compact: false, + printMode: false, + }, +}; + +export const AsTopProject: Story = { + args: { + project: mockProject, + isTopProject: true, + printMode: false, + }, +}; + +export const Compact: Story = { + args: { + project: mockProject, + isTopProject: false, + compact: true, + printMode: false, + }, +}; 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 new file mode 100644 index 00000000..db97f197 --- /dev/null +++ b/libs/feature/project-list/src/lib/customizable-project-item.component.ts @@ -0,0 +1,42 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, input, signal } from '@angular/core'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { CustomizationStateService } from '@portfolio/customization-state'; +import { Project } from '@portfolio/search-engine-domain'; + +import { ProjectItemComponent } from './project-item.component'; + +@Component({ + selector: 'lib-customizable-project-item', + imports: [CommonModule, MatButtonToggleModule, ProjectItemComponent], + templateUrl: './customizable-project-item.component.html', + styleUrl: './customizable-project-item.component.scss', +}) +export class CustomizableProjectItemComponent { + protected readonly customizationStateService = inject( + CustomizationStateService + ); + + project = input.required(); + isTopProject = input(false); + compact = input(false); + printMode = input(false); + + private readonly customIsTopProject = signal(null); + private readonly customCompact = signal(null); + + readonly effectiveIsTopProject = computed( + () => this.customIsTopProject() ?? this.isTopProject() + ); + readonly effectiveCompact = computed( + () => this.customCompact() ?? this.compact() + ); + + protected setIsTopProject(isTopProject: boolean): void { + this.customIsTopProject.set(isTopProject); + } + + protected setCompact(compact: boolean): void { + this.customCompact.set(compact); + } +} diff --git a/libs/feature/project-list/src/lib/project-list.component.html b/libs/feature/project-list/src/lib/project-list.component.html index e6342c91..3eb9adfe 100644 --- a/libs/feature/project-list/src/lib/project-list.component.html +++ b/libs/feature/project-list/src/lib/project-list.component.html @@ -15,7 +15,7 @@ /> } } @else { @for (project of filteredTopProjects(projectsOrder$ | async); track project.id) { - } } @else { @for (project of filteredOtherProjects(projectsOrder$ | async); track project.id) { -