From 6c03f87b612187bd92cb4d5c6b35238fa18980a1 Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Fri, 7 Feb 2025 19:16:50 +0300 Subject: [PATCH 01/42] feat(projection): implement card directives and improve UI styling --- .../1-projection/src/app/app.component.ts | 21 ++++- .../city-card/city-card.component.ts | 81 +++++++++++++++++-- .../student-card/student-card.component.ts | 68 ++++++++++++---- .../teacher-card/teacher-card.component.ts | 62 ++++++++++++-- .../src/app/data-access/city.store.ts | 2 +- .../src/app/ui/card/card.component.ts | 76 +++++++++++------ .../src/app/ui/card/card.directives.ts | 13 +++ .../app/ui/list-item/list-item.component.ts | 43 ++++++---- package-lock.json | 11 ++- package.json | 2 +- 10 files changed, 300 insertions(+), 79 deletions(-) create mode 100644 apps/angular/1-projection/src/app/ui/card/card.directives.ts diff --git a/apps/angular/1-projection/src/app/app.component.ts b/apps/angular/1-projection/src/app/app.component.ts index df654bbc2..26c0f5699 100644 --- a/apps/angular/1-projection/src/app/app.component.ts +++ b/apps/angular/1-projection/src/app/app.component.ts @@ -6,12 +6,25 @@ import { TeacherCardComponent } from './component/teacher-card/teacher-card.comp @Component({ selector: 'app-root', template: ` -
- - - +
+
+ + + +
`, + styles: [ + ` + .app-container { + @apply min-h-screen bg-gray-50 p-8; + } + .cards-grid { + @apply grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3; + @apply mx-auto max-w-7xl; + } + `, + ], imports: [TeacherCardComponent, StudentCardComponent, CityCardComponent], }) export class AppComponent {} diff --git a/apps/angular/1-projection/src/app/component/city-card/city-card.component.ts b/apps/angular/1-projection/src/app/component/city-card/city-card.component.ts index 8895c8c84..80467cac1 100644 --- a/apps/angular/1-projection/src/app/component/city-card/city-card.component.ts +++ b/apps/angular/1-projection/src/app/component/city-card/city-card.component.ts @@ -1,9 +1,80 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { NgOptimizedImage } from '@angular/common'; +import { Component, inject, OnInit } from '@angular/core'; +import { CityStore } from '../../data-access/city.store'; +import { + FakeHttpService, + randomCity, +} from '../../data-access/fake-http.service'; +import { CardType } from '../../model/card.model'; +import { CardComponent } from '../../ui/card/card.component'; +import { ListItemComponent } from '../../ui/list-item/list-item.component'; @Component({ selector: 'app-city-card', - template: 'TODO City', - imports: [], - changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + +
+ City +
+ + + + + + + +
+ +
+
+ `, + styles: [ + ` + .city-card { + @apply bg-gradient-to-br from-blue-50 to-white; + } + .header-content { + @apply flex justify-center; + } + .header-image { + @apply rounded-lg object-cover shadow-sm; + } + .add-button { + @apply w-full rounded-md bg-blue-500 px-4 py-2 text-white; + @apply transition-colors duration-200 hover:bg-blue-600; + @apply disabled:cursor-not-allowed disabled:opacity-50; + } + `, + ], + imports: [CardComponent, ListItemComponent, NgOptimizedImage], + standalone: true, }) -export class CityCardComponent {} +export class CityCardComponent implements OnInit { + private http = inject(FakeHttpService); + private store = inject(CityStore); + + cities = this.store.cities; + cardType = CardType.CITY; + + ngOnInit(): void { + this.http.fetchCities$.subscribe((c) => this.store.addAll(c)); + } + + addCity() { + this.store.addOne(randomCity()); + } + + deleteCity(id: number) { + this.store.deleteOne(id); + } +} diff --git a/apps/angular/1-projection/src/app/component/student-card/student-card.component.ts b/apps/angular/1-projection/src/app/component/student-card/student-card.component.ts index bdfa4abd4..e78bf093c 100644 --- a/apps/angular/1-projection/src/app/component/student-card/student-card.component.ts +++ b/apps/angular/1-projection/src/app/component/student-card/student-card.component.ts @@ -1,31 +1,63 @@ +import { NgOptimizedImage } from '@angular/common'; +import { Component, inject, OnInit } from '@angular/core'; import { - ChangeDetectionStrategy, - Component, - inject, - OnInit, -} from '@angular/core'; -import { FakeHttpService } from '../../data-access/fake-http.service'; + FakeHttpService, + randStudent, +} from '../../data-access/fake-http.service'; import { StudentStore } from '../../data-access/student.store'; import { CardType } from '../../model/card.model'; import { CardComponent } from '../../ui/card/card.component'; +import { ListItemComponent } from '../../ui/list-item/list-item.component'; @Component({ selector: 'app-student-card', template: ` - + + +
+ Student +
+ + + + + + + +
+ +
+
`, styles: [ ` - ::ng-deep .bg-light-green { - background-color: rgba(0, 250, 0, 0.1); + .student-card { + @apply bg-gradient-to-br from-green-50 to-white; + } + .header-content { + @apply flex justify-center; + } + .header-image { + @apply rounded-lg object-cover shadow-sm; + } + .add-button { + @apply w-full rounded-md bg-blue-500 px-4 py-2 text-white; + @apply transition-colors duration-200 hover:bg-blue-600; + @apply disabled:cursor-not-allowed disabled:opacity-50; } `, ], - imports: [CardComponent], - changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CardComponent, ListItemComponent, NgOptimizedImage], + standalone: true, }) export class StudentCardComponent implements OnInit { private http = inject(FakeHttpService); @@ -37,4 +69,12 @@ export class StudentCardComponent implements OnInit { ngOnInit(): void { this.http.fetchStudents$.subscribe((s) => this.store.addAll(s)); } + + addStudent() { + this.store.addOne(randStudent()); + } + + deleteStudent(id: number) { + this.store.deleteOne(id); + } } diff --git a/apps/angular/1-projection/src/app/component/teacher-card/teacher-card.component.ts b/apps/angular/1-projection/src/app/component/teacher-card/teacher-card.component.ts index adf0ad3c1..12657c179 100644 --- a/apps/angular/1-projection/src/app/component/teacher-card/teacher-card.component.ts +++ b/apps/angular/1-projection/src/app/component/teacher-card/teacher-card.component.ts @@ -1,25 +1,63 @@ +import { NgOptimizedImage } from '@angular/common'; import { Component, inject, OnInit } from '@angular/core'; -import { FakeHttpService } from '../../data-access/fake-http.service'; +import { + FakeHttpService, + randTeacher, +} from '../../data-access/fake-http.service'; import { TeacherStore } from '../../data-access/teacher.store'; import { CardType } from '../../model/card.model'; import { CardComponent } from '../../ui/card/card.component'; +import { ListItemComponent } from '../../ui/list-item/list-item.component'; @Component({ selector: 'app-teacher-card', template: ` - + + +
+ Teacher +
+ + + + + + + +
+ +
+
`, styles: [ ` - ::ng-deep .bg-light-red { - background-color: rgba(250, 0, 0, 0.1); + .teacher-card { + @apply bg-gradient-to-br from-red-50 to-white; + } + .header-content { + @apply flex justify-center; + } + .header-image { + @apply rounded-lg object-cover shadow-sm; + } + .add-button { + @apply w-full rounded-md bg-blue-500 px-4 py-2 text-white; + @apply transition-colors duration-200 hover:bg-blue-600; + @apply disabled:cursor-not-allowed disabled:opacity-50; } `, ], - imports: [CardComponent], + imports: [CardComponent, ListItemComponent, NgOptimizedImage], + standalone: true, }) export class TeacherCardComponent implements OnInit { private http = inject(FakeHttpService); @@ -31,4 +69,12 @@ export class TeacherCardComponent implements OnInit { ngOnInit(): void { this.http.fetchTeachers$.subscribe((t) => this.store.addAll(t)); } + + addTeacher() { + this.store.addOne(randTeacher()); + } + + deleteTeacher(id: number) { + this.store.deleteOne(id); + } } diff --git a/apps/angular/1-projection/src/app/data-access/city.store.ts b/apps/angular/1-projection/src/app/data-access/city.store.ts index 8a08086d5..957763f71 100644 --- a/apps/angular/1-projection/src/app/data-access/city.store.ts +++ b/apps/angular/1-projection/src/app/data-access/city.store.ts @@ -5,7 +5,7 @@ import { City } from '../model/city.model'; providedIn: 'root', }) export class CityStore { - private cities = signal([]); + public cities = signal([]); addAll(cities: City[]) { this.cities.set(cities); diff --git a/apps/angular/1-projection/src/app/ui/card/card.component.ts b/apps/angular/1-projection/src/app/ui/card/card.component.ts index 1a6c3648c..cb12965b1 100644 --- a/apps/angular/1-projection/src/app/ui/card/card.component.ts +++ b/apps/angular/1-projection/src/app/ui/card/card.component.ts @@ -1,52 +1,82 @@ -import { NgOptimizedImage } from '@angular/common'; -import { Component, inject, input } from '@angular/core'; +import { NgOptimizedImage, NgTemplateOutlet } from '@angular/common'; +import { + Component, + ContentChild, + inject, + input, + TemplateRef, +} from '@angular/core'; import { randStudent, randTeacher } from '../../data-access/fake-http.service'; import { StudentStore } from '../../data-access/student.store'; import { TeacherStore } from '../../data-access/teacher.store'; import { CardType } from '../../model/card.model'; import { ListItemComponent } from '../list-item/list-item.component'; +import { CardFooterDirective, CardHeaderDirective } from './card.directives'; @Component({ selector: 'app-card', template: ` -
- @if (type() === CardType.TEACHER) { - - } - @if (type() === CardType.STUDENT) { - - } +
+ +
+ +
-
+ +
@for (item of list(); track item) { - + }
- + +
`, - imports: [ListItemComponent, NgOptimizedImage], + styles: [ + ` + .card { + @apply w-full max-w-sm overflow-hidden rounded-lg border border-gray-200 shadow-md; + @apply bg-white transition-all duration-200 hover:shadow-lg; + } + .card-header { + @apply bg-gray-50 p-4; + } + .card-content { + @apply max-h-[300px] space-y-2 overflow-y-auto p-4; + } + .card-footer { + @apply border-t border-gray-100 p-4; + } + `, + ], + imports: [ + ListItemComponent, + NgOptimizedImage, + NgTemplateOutlet, + CardHeaderDirective, + CardFooterDirective, + ], + standalone: true, }) export class CardComponent { private teacherStore = inject(TeacherStore); private studentStore = inject(StudentStore); - readonly list = input(null); + readonly list = input.required(); readonly type = input.required(); readonly customClass = input(''); CardType = CardType; + // Template reference for list items + @ContentChild('itemTemplate', { static: true }) + itemTemplate!: TemplateRef; + addNewItem() { const type = this.type(); if (type === CardType.TEACHER) { diff --git a/apps/angular/1-projection/src/app/ui/card/card.directives.ts b/apps/angular/1-projection/src/app/ui/card/card.directives.ts new file mode 100644 index 000000000..be50b0892 --- /dev/null +++ b/apps/angular/1-projection/src/app/ui/card/card.directives.ts @@ -0,0 +1,13 @@ +import { Directive } from '@angular/core'; + +@Directive({ + selector: '[cardHeader]', + standalone: true, +}) +export class CardHeaderDirective {} + +@Directive({ + selector: '[cardFooter]', + standalone: true, +}) +export class CardFooterDirective {} diff --git a/apps/angular/1-projection/src/app/ui/list-item/list-item.component.ts b/apps/angular/1-projection/src/app/ui/list-item/list-item.component.ts index cffabb451..7228b12ed 100644 --- a/apps/angular/1-projection/src/app/ui/list-item/list-item.component.ts +++ b/apps/angular/1-projection/src/app/ui/list-item/list-item.component.ts @@ -1,40 +1,49 @@ import { ChangeDetectionStrategy, Component, - inject, + EventEmitter, input, + Output, } from '@angular/core'; -import { StudentStore } from '../../data-access/student.store'; -import { TeacherStore } from '../../data-access/teacher.store'; import { CardType } from '../../model/card.model'; @Component({ selector: 'app-list-item', template: ` -
- {{ name() }} -
`, + styles: [ + ` + .list-item { + @apply flex items-center justify-between rounded-md bg-white p-3; + @apply border border-gray-100 transition-colors hover:bg-gray-50; + } + .item-name { + @apply font-medium text-gray-700; + } + .delete-btn { + @apply rounded-full p-2 transition-colors hover:bg-red-50; + } + `, + ], standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) export class ListItemComponent { - private teacherStore = inject(TeacherStore); - private studentStore = inject(StudentStore); - readonly id = input.required(); readonly name = input.required(); readonly type = input.required(); - delete(id: number) { - const type = this.type(); - if (type === CardType.TEACHER) { - this.teacherStore.deleteOne(id); - } else if (type === CardType.STUDENT) { - this.studentStore.deleteOne(id); - } + @Output() delete = new EventEmitter(); + + onDelete() { + this.delete.emit(); } } diff --git a/package-lock.json b/package-lock.json index 3b3a587ce..e527afd06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,7 +78,7 @@ "@typescript-eslint/utils": "^7.16.0", "all-contributors-cli": "^6.26.1", "autoprefixer": "^10.4.0", - "cypress": "^14.0.0", + "cypress": "^13.17.0", "eslint": "8.57.0", "eslint-config-prettier": "9.0.0", "eslint-plugin-cypress": "2.15.1", @@ -13821,12 +13821,11 @@ "license": "MIT" }, "node_modules/cypress": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-14.0.0.tgz", - "integrity": "sha512-kEGqQr23so5IpKeg/dp6GVi7RlHx1NmW66o2a2Q4wk9gRaAblLZQSiZJuDI8UMC4LlG5OJ7Q6joAiqTrfRNbTw==", + "version": "13.17.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.17.0.tgz", + "integrity": "sha512-5xWkaPurwkIljojFidhw8lFScyxhtiFHl/i/3zov+1Z5CmY4t9tjIdvSXfu82Y3w7wt0uR9KkucbhkVvJZLQSA==", "dev": true, "hasInstallScript": true, - "license": "MIT", "dependencies": { "@cypress/request": "^3.0.6", "@cypress/xvfb": "^1.2.4", @@ -13876,7 +13875,7 @@ "cypress": "bin/cypress" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^16.0.0 || ^18.0.0 || >=20.0.0" } }, "node_modules/cypress/node_modules/arch": { diff --git a/package.json b/package.json index 63dbb87e5..d816c80fe 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "@typescript-eslint/utils": "^7.16.0", "all-contributors-cli": "^6.26.1", "autoprefixer": "^10.4.0", - "cypress": "14.0.0", + "cypress": "^13.17.0", "eslint": "8.57.0", "eslint-config-prettier": "9.0.0", "eslint-plugin-cypress": "2.15.1", From a4f70195388ad56599fcda74239acce2e0ed4f40 Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Fri, 7 Feb 2025 19:47:49 +0300 Subject: [PATCH 02/42] feat(challenge-3): enhance ngFor directive with empty state handling --- .../src/app/app.component.ts | 114 ++++++++++++++++-- .../directives/enhanced-ngfor.directive.ts | 39 ++++++ 2 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 apps/angular/3-directive-enhancement/src/app/directives/enhanced-ngfor.directive.ts diff --git a/apps/angular/3-directive-enhancement/src/app/app.component.ts b/apps/angular/3-directive-enhancement/src/app/app.component.ts index 8d37369a1..9091d2992 100644 --- a/apps/angular/3-directive-enhancement/src/app/app.component.ts +++ b/apps/angular/3-directive-enhancement/src/app/app.component.ts @@ -1,24 +1,122 @@ -import { NgFor, NgIf } from '@angular/common'; import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { EnhancedNgForDirective } from './directives/enhanced-ngfor.directive'; interface Person { name: string; } @Component({ - imports: [NgFor, NgIf], selector: 'app-root', template: ` - -
- {{ person.name }} +
+

Person List

+ +
+
+ 👤 + {{ person.name }} +
+ +
+ 👥 +

The list is empty !!

+
+
+
+ +
+ +
- - The list is empty !! +
`, - styles: [], + styles: [ + ` + .container { + @apply mx-auto mt-8 max-w-2xl rounded-lg bg-white p-8 shadow-lg; + } + + h1 { + @apply mb-6 text-2xl font-bold text-gray-800; + } + + .list-container { + @apply min-h-[200px] rounded-lg bg-gray-50 p-4; + } + + .person-item { + @apply mb-2 flex items-center gap-2 rounded-md bg-white p-4 shadow-sm; + @apply border border-gray-100; + @apply transition-all duration-200; + @apply hover:border-blue-200 hover:shadow-md; + span { + @apply text-xl; + } + } + + .empty-state { + @apply flex h-[200px] flex-col items-center justify-center; + @apply text-gray-500; + span { + @apply mb-2 text-4xl; + } + p { + @apply text-lg; + } + } + + .controls { + @apply mt-6 flex justify-between gap-4; + } + + .btn { + @apply flex items-center gap-2 rounded-lg px-6 py-3 font-medium; + @apply transform transition-all duration-200; + @apply disabled:cursor-not-allowed disabled:opacity-50; + @apply hover:scale-105 active:scale-95; + @apply shadow-sm; + + .icon { + @apply text-lg; + } + } + + .add { + @apply bg-gradient-to-r from-blue-500 to-blue-600 text-white; + @apply hover:from-blue-600 hover:to-blue-700; + @apply focus:ring-2 focus:ring-blue-500 focus:ring-offset-2; + } + + .clear { + @apply bg-gradient-to-r from-red-50 to-red-100 text-red-600; + @apply hover:from-red-100 hover:to-red-200; + @apply focus:ring-2 focus:ring-red-500 focus:ring-offset-2; + } + `, + ], changeDetection: ChangeDetectionStrategy.OnPush, + imports: [EnhancedNgForDirective], + standalone: true, }) export class AppComponent { persons: Person[] = []; + + addPerson() { + this.persons = [ + ...this.persons, + { name: `Person ${this.persons.length + 1}` }, + ]; + } + + clearPersons() { + this.persons = []; + } } diff --git a/apps/angular/3-directive-enhancement/src/app/directives/enhanced-ngfor.directive.ts b/apps/angular/3-directive-enhancement/src/app/directives/enhanced-ngfor.directive.ts new file mode 100644 index 000000000..a3d39c58d --- /dev/null +++ b/apps/angular/3-directive-enhancement/src/app/directives/enhanced-ngfor.directive.ts @@ -0,0 +1,39 @@ +import { NgForOf } from '@angular/common'; +import { + Directive, + Input, + IterableDiffers, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; + +@Directive({ + selector: '[ngFor][ngForOf][ngForEmpty]', + standalone: true, +}) +export class EnhancedNgForDirective extends NgForOf { + @Input('ngForEmpty') empty!: TemplateRef; + private vcRef: ViewContainerRef; + + constructor( + viewContainer: ViewContainerRef, + template: TemplateRef, + differs: IterableDiffers, + ) { + super(viewContainer, template, differs); + this.vcRef = viewContainer; + } + + override ngDoCheck(): void { + if ( + this.ngForOf && + Array.isArray(this.ngForOf) && + this.ngForOf.length === 0 + ) { + this.vcRef.clear(); + this.vcRef.createEmbeddedView(this.empty); + } else { + super.ngDoCheck(); + } + } +} From 636cd8abbf57a754c055fb959239d23c6aa98315 Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Fri, 7 Feb 2025 20:04:12 +0300 Subject: [PATCH 03/42] feat(challenge-4): implement typed template context outlets --- .../src/app/app.component.ts | 18 ++++++++++++++---- .../src/app/list.component.ts | 17 +++++++++++++++-- .../src/app/person.component.ts | 17 +++++++++++++++-- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/angular/4-typed-context-outlet/src/app/app.component.ts b/apps/angular/4-typed-context-outlet/src/app/app.component.ts index 23be9dac6..303e9c16d 100644 --- a/apps/angular/4-typed-context-outlet/src/app/app.component.ts +++ b/apps/angular/4-typed-context-outlet/src/app/app.component.ts @@ -1,10 +1,19 @@ -import { NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ListComponent } from './list.component'; import { PersonComponent } from './person.component'; +interface Student { + name: string; + age: number; +} + +interface City { + name: string; + country: string; +} + @Component({ - imports: [NgTemplateOutlet, PersonComponent, ListComponent], + imports: [PersonComponent, ListComponent], selector: 'app-root', template: ` @@ -26,6 +35,7 @@ import { PersonComponent } from './person.component'; `, changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, }) export class AppComponent { person = { @@ -33,12 +43,12 @@ export class AppComponent { age: 3, }; - students = [ + students: Student[] = [ { name: 'toto', age: 3 }, { name: 'titi', age: 4 }, ]; - cities = [ + cities: City[] = [ { name: 'Paris', country: 'France' }, { name: 'Berlin', country: 'Germany' }, ]; diff --git a/apps/angular/4-typed-context-outlet/src/app/list.component.ts b/apps/angular/4-typed-context-outlet/src/app/list.component.ts index b9946e428..44e5d4667 100644 --- a/apps/angular/4-typed-context-outlet/src/app/list.component.ts +++ b/apps/angular/4-typed-context-outlet/src/app/list.component.ts @@ -7,6 +7,11 @@ import { TemplateRef, } from '@angular/core'; +interface ListContext { + $implicit: T; + index: number; +} + @Component({ selector: 'list', imports: [CommonModule], @@ -15,17 +20,25 @@ import {
No Template `, changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, }) export class ListComponent { @Input() list!: TItem[]; @ContentChild('listRef', { read: TemplateRef }) - listTemplateRef!: TemplateRef; + listTemplateRef!: TemplateRef>; + + static ngTemplateContextGuard( + dir: ListComponent, + ctx: unknown, + ): ctx is ListContext { + return true; + } } diff --git a/apps/angular/4-typed-context-outlet/src/app/person.component.ts b/apps/angular/4-typed-context-outlet/src/app/person.component.ts index 59eb00ab1..1d3e82800 100644 --- a/apps/angular/4-typed-context-outlet/src/app/person.component.ts +++ b/apps/angular/4-typed-context-outlet/src/app/person.component.ts @@ -6,6 +6,11 @@ interface Person { age: number; } +interface PersonContext { + $implicit: string; // for the name + age: number; +} + @Component({ imports: [NgTemplateOutlet], selector: 'person', @@ -18,10 +23,18 @@ interface Person { No Template `, + standalone: true, }) export class PersonComponent { @Input() person!: Person; - @ContentChild('#personRef', { read: TemplateRef }) - personTemplateRef!: TemplateRef; + @ContentChild('personRef', { read: TemplateRef }) + personTemplateRef!: TemplateRef; + + static ngTemplateContextGuard( + dir: PersonComponent, + ctx: unknown, + ): ctx is PersonContext { + return true; + } } From e758beba0b4e2c918be040b4399fed2f44a12ec8 Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Fri, 7 Feb 2025 20:23:17 +0300 Subject: [PATCH 04/42] test(challenge-5): implement unit tests for CRUD application --- .../src/app/app.component.ts | 78 ++++++++++--------- .../components/todo-item.component.spec.ts | 71 +++++++++++++++++ .../src/app/components/todo-item.component.ts | 78 +++++++++++++++++++ .../src/app/interfaces/todo.interface.ts | 11 +++ .../src/app/services/todo.service.spec.ts | 72 +++++++++++++++++ .../src/app/services/todo.service.ts | 66 ++++++++++++++++ 6 files changed, 340 insertions(+), 36 deletions(-) create mode 100644 apps/angular/5-crud-application/src/app/components/todo-item.component.spec.ts create mode 100644 apps/angular/5-crud-application/src/app/components/todo-item.component.ts create mode 100644 apps/angular/5-crud-application/src/app/interfaces/todo.interface.ts create mode 100644 apps/angular/5-crud-application/src/app/services/todo.service.spec.ts create mode 100644 apps/angular/5-crud-application/src/app/services/todo.service.ts diff --git a/apps/angular/5-crud-application/src/app/app.component.ts b/apps/angular/5-crud-application/src/app/app.component.ts index 9152ff5e4..8cef4deae 100644 --- a/apps/angular/5-crud-application/src/app/app.component.ts +++ b/apps/angular/5-crud-application/src/app/app.component.ts @@ -1,50 +1,56 @@ import { CommonModule } from '@angular/common'; -import { HttpClient } from '@angular/common/http'; import { Component, OnInit } from '@angular/core'; -import { randText } from '@ngneat/falso'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { TodoItemComponent } from './components/todo-item.component'; +import { TodoService } from './services/todo.service'; @Component({ - imports: [CommonModule], selector: 'app-root', + standalone: true, + imports: [CommonModule, TodoItemComponent, MatProgressSpinner], template: ` -
- {{ todo.title }} - +
+

Todo List

+ + @if (todoService.loading()) { + + } + + @if (todoService.error()) { +
+ {{ todoService.error() }} +
+ } + + @for (todo of todoService.todos(); track todo.id) { + + }
`, - styles: [], + styles: [ + ` + .container { + max-width: 800px; + margin: 2rem auto; + padding: 0 1rem; + } + .loader { + margin: 2rem auto; + } + .error { + color: #ff4444; + padding: 1rem; + border: 1px solid #ff4444; + border-radius: 4px; + margin: 1rem 0; + } + `, + ], }) export class AppComponent implements OnInit { - todos!: any[]; - - constructor(private http: HttpClient) {} + constructor(public todoService: TodoService) {} ngOnInit(): void { - this.http - .get('https://jsonplaceholder.typicode.com/todos') - .subscribe((todos) => { - this.todos = todos; - }); - } - - update(todo: any) { - this.http - .put( - `https://jsonplaceholder.typicode.com/todos/${todo.id}`, - JSON.stringify({ - todo: todo.id, - title: randText(), - body: todo.body, - userId: todo.userId, - }), - { - headers: { - 'Content-type': 'application/json; charset=UTF-8', - }, - }, - ) - .subscribe((todoUpdated: any) => { - this.todos[todoUpdated.id - 1] = todoUpdated; - }); + this.todoService.getTodos().subscribe(); } } diff --git a/apps/angular/5-crud-application/src/app/components/todo-item.component.spec.ts b/apps/angular/5-crud-application/src/app/components/todo-item.component.spec.ts new file mode 100644 index 000000000..76ce176ad --- /dev/null +++ b/apps/angular/5-crud-application/src/app/components/todo-item.component.spec.ts @@ -0,0 +1,71 @@ +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { of } from 'rxjs'; +import { TodoService } from '../services/todo.service'; +import { TodoItemComponent } from './todo-item.component'; + +describe('TodoItemComponent', () => { + let component: TodoItemComponent; + let fixture: ComponentFixture; + let todoService: jest.Mocked; + + const mockTodo = { + id: 1, + title: 'Test Todo', + completed: false, + userId: 1, + }; + + beforeEach(async () => { + const spy = { + updateTodo: jest.fn().mockReturnValue(of(mockTodo)), + deleteTodo: jest.fn().mockReturnValue(of(void 0)), + todos: jest.fn(), + loading: jest.fn(), + error: jest.fn(), + getTodos: jest.fn(), + } as unknown as jest.Mocked; + + await TestBed.configureTestingModule({ + imports: [TodoItemComponent, MatProgressSpinnerModule], + providers: [{ provide: TodoService, useValue: spy }], + }).compileComponents(); + + fixture = TestBed.createComponent(TodoItemComponent); + component = fixture.componentInstance; + component.todo = mockTodo; + todoService = TestBed.inject(TodoService) as jest.Mocked; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show processing state during update', fakeAsync(() => { + component.updateTodo(); + expect(component.processing).toBe(true); + tick(); + expect(component.processing).toBe(false); + })); + + it('should show processing state during delete', fakeAsync(() => { + component.deleteTodo(); + expect(component.processing).toBe(true); + tick(); + expect(component.processing).toBe(false); + })); + + it('should call service methods', () => { + component.updateTodo(); + expect(todoService.updateTodo).toHaveBeenCalled(); + + component.deleteTodo(); + expect(todoService.deleteTodo).toHaveBeenCalled(); + }); +}); diff --git a/apps/angular/5-crud-application/src/app/components/todo-item.component.ts b/apps/angular/5-crud-application/src/app/components/todo-item.component.ts new file mode 100644 index 000000000..b1cbaa772 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/components/todo-item.component.ts @@ -0,0 +1,78 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { randText } from '@ngneat/falso'; +import { Todo } from '../interfaces/todo.interface'; +import { TodoService } from '../services/todo.service'; + +@Component({ + selector: 'app-todo-item', + standalone: true, + imports: [CommonModule, MatProgressSpinner], + template: ` +
+ {{ todo.title }} +
+ + + +
+
+ `, + styles: [ + ` + .todo-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border-bottom: 1px solid #eee; + } + .completed { + text-decoration: line-through; + color: #888; + } + .processing { + opacity: 0.7; + } + .actions { + display: flex; + gap: 0.5rem; + align-items: center; + } + .delete { + background: #ff4444; + color: white; + } + `, + ], +}) +export class TodoItemComponent { + @Input({ required: true }) todo!: Todo; + processing = false; + + constructor(private todoService: TodoService) {} + + updateTodo() { + this.processing = true; + this.todoService + .updateTodo(this.todo.id, { + title: randText(), + completed: this.todo.completed, + }) + .subscribe({ + next: () => (this.processing = false), + error: () => (this.processing = false), + }); + } + + deleteTodo() { + this.processing = true; + this.todoService.deleteTodo(this.todo.id).subscribe({ + next: () => (this.processing = false), + error: () => (this.processing = false), + }); + } +} diff --git a/apps/angular/5-crud-application/src/app/interfaces/todo.interface.ts b/apps/angular/5-crud-application/src/app/interfaces/todo.interface.ts new file mode 100644 index 000000000..cf598b68c --- /dev/null +++ b/apps/angular/5-crud-application/src/app/interfaces/todo.interface.ts @@ -0,0 +1,11 @@ +export interface Todo { + id: number; + title: string; + completed: boolean; + userId: number; +} + +export interface TodoUpdate { + title: string; + completed: boolean; +} diff --git a/apps/angular/5-crud-application/src/app/services/todo.service.spec.ts b/apps/angular/5-crud-application/src/app/services/todo.service.spec.ts new file mode 100644 index 000000000..4ec1140c8 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/services/todo.service.spec.ts @@ -0,0 +1,72 @@ +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { Todo } from '../interfaces/todo.interface'; +import { TodoService } from './todo.service'; + +describe('TodoService', () => { + let service: TodoService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [TodoService], + }); + service = TestBed.inject(TodoService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + const mockTodo: Todo = { + id: 1, + title: 'Test Todo', + completed: false, + userId: 1, + }; + + it('should fetch todos', () => { + const mockTodos = [mockTodo]; + + service.getTodos().subscribe((todos) => { + expect(todos).toEqual(mockTodos); + expect(service.todos()).toEqual(mockTodos); + }); + + const req = httpMock.expectOne( + 'https://jsonplaceholder.typicode.com/todos', + ); + expect(req.request.method).toBe('GET'); + req.flush(mockTodos); + }); + + it('should update todo', () => { + const update = { title: 'Updated Todo', completed: true }; + const updatedTodo = { ...mockTodo, ...update }; + + service.updateTodo(1, update).subscribe((todo) => { + expect(todo).toEqual(updatedTodo); + }); + + const req = httpMock.expectOne( + 'https://jsonplaceholder.typicode.com/todos/1', + ); + expect(req.request.method).toBe('PUT'); + req.flush(updatedTodo); + }); + + it('should delete todo', () => { + service.deleteTodo(1).subscribe(); + + const req = httpMock.expectOne( + 'https://jsonplaceholder.typicode.com/todos/1', + ); + expect(req.request.method).toBe('DELETE'); + req.flush({}); + }); +}); diff --git a/apps/angular/5-crud-application/src/app/services/todo.service.ts b/apps/angular/5-crud-application/src/app/services/todo.service.ts new file mode 100644 index 000000000..4b0cf08b9 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/services/todo.service.ts @@ -0,0 +1,66 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, signal } from '@angular/core'; +import { Observable, throwError } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { Todo, TodoUpdate } from '../interfaces/todo.interface'; + +@Injectable({ + providedIn: 'root', +}) +export class TodoService { + private apiUrl = 'https://jsonplaceholder.typicode.com/todos'; + todos = signal([]); + loading = signal(false); + error = signal(null); + + constructor(private http: HttpClient) {} + + getTodos(): Observable { + this.loading.set(true); + return this.http.get(this.apiUrl).pipe( + map((todos) => { + this.todos.set(todos); + this.loading.set(false); + return todos; + }), + catchError((error) => { + this.error.set('Failed to load todos'); + this.loading.set(false); + return throwError(() => error); + }), + ); + } + + updateTodo(id: number, update: TodoUpdate): Observable { + return this.http + .put(`${this.apiUrl}/${id}`, update, { + headers: { + 'Content-type': 'application/json; charset=UTF-8', + }, + }) + .pipe( + map((updatedTodo) => { + this.todos.update((todos) => + todos.map((todo) => (todo.id === id ? updatedTodo : todo)), + ); + return updatedTodo; + }), + catchError((error) => { + this.error.set(`Failed to update todo #${id}`); + return throwError(() => error); + }), + ); + } + + deleteTodo(id: number): Observable { + return this.http.delete(`${this.apiUrl}/${id}`).pipe( + map(() => { + this.todos.update((todos) => todos.filter((todo) => todo.id !== id)); + }), + catchError((error) => { + this.error.set(`Failed to delete todo #${id}`); + return throwError(() => error); + }), + ); + } +} From c147b3cc861308285067ee319052e08457090eed Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Fri, 7 Feb 2025 20:31:36 +0300 Subject: [PATCH 05/42] feat(challenge-6): implement role-based structural directives and routing --- .../has-role-super-admin.directive.ts | 40 ++++++++++++++++ .../src/app/directives/has-role.directive.ts | 47 +++++++++++++++++++ .../src/app/guards/role.guard.ts | 19 ++++++++ .../src/app/information.component.ts | 23 ++++----- .../6-structural-directive/src/app/routes.ts | 14 +++++- 5 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 apps/angular/6-structural-directive/src/app/directives/has-role-super-admin.directive.ts create mode 100644 apps/angular/6-structural-directive/src/app/directives/has-role.directive.ts create mode 100644 apps/angular/6-structural-directive/src/app/guards/role.guard.ts diff --git a/apps/angular/6-structural-directive/src/app/directives/has-role-super-admin.directive.ts b/apps/angular/6-structural-directive/src/app/directives/has-role-super-admin.directive.ts new file mode 100644 index 000000000..a372e51fa --- /dev/null +++ b/apps/angular/6-structural-directive/src/app/directives/has-role-super-admin.directive.ts @@ -0,0 +1,40 @@ +import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core'; +import { distinctUntilChanged, map } from 'rxjs/operators'; +import { UserStore } from '../user.store'; + +@Directive({ + selector: '[hasRoleSuperAdmin]', + standalone: true, +}) +export class HasRoleSuperAdminDirective { + private hasView = false; + + constructor( + private templateRef: TemplateRef, + private vcr: ViewContainerRef, + private userStore: UserStore, + ) {} + + @Input() set hasRoleSuperAdmin(value: boolean) { + if (value) { + this.updateView(); + } + } + + private updateView() { + this.userStore.user$ + .pipe( + map((user) => user?.isAdmin ?? false), + distinctUntilChanged(), + ) + .subscribe((isAdmin) => { + if (isAdmin && !this.hasView) { + this.vcr.createEmbeddedView(this.templateRef); + this.hasView = true; + } else if (!isAdmin && this.hasView) { + this.vcr.clear(); + this.hasView = false; + } + }); + } +} diff --git a/apps/angular/6-structural-directive/src/app/directives/has-role.directive.ts b/apps/angular/6-structural-directive/src/app/directives/has-role.directive.ts new file mode 100644 index 000000000..5071945c1 --- /dev/null +++ b/apps/angular/6-structural-directive/src/app/directives/has-role.directive.ts @@ -0,0 +1,47 @@ +import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core'; +import { distinctUntilChanged, map } from 'rxjs/operators'; +import { Role, User } from '../user.model'; +import { UserStore } from '../user.store'; + +@Directive({ + selector: '[hasRole]', + standalone: true, +}) +export class HasRoleDirective { + private roles: Role[] = []; + private hasView = false; + + constructor( + private templateRef: TemplateRef, + private vcr: ViewContainerRef, + private userStore: UserStore, + ) {} + + @Input() set hasRole(roleOrRoles: Role | Role[]) { + this.roles = Array.isArray(roleOrRoles) ? roleOrRoles : [roleOrRoles]; + this.updateView(); + } + + private updateView() { + this.userStore.user$ + .pipe( + map((user) => this.matchRoles(user)), + distinctUntilChanged(), + ) + .subscribe((hasRole) => { + if (hasRole && !this.hasView) { + this.vcr.createEmbeddedView(this.templateRef); + this.hasView = true; + } else if (!hasRole && this.hasView) { + this.vcr.clear(); + this.hasView = false; + } + }); + } + + private matchRoles(user?: User): boolean { + if (!user) return false; + if (user.isAdmin) return true; + return this.roles.some((role) => user.roles.includes(role)); + } +} diff --git a/apps/angular/6-structural-directive/src/app/guards/role.guard.ts b/apps/angular/6-structural-directive/src/app/guards/role.guard.ts new file mode 100644 index 000000000..ab056e078 --- /dev/null +++ b/apps/angular/6-structural-directive/src/app/guards/role.guard.ts @@ -0,0 +1,19 @@ +import { inject } from '@angular/core'; +import { CanMatchFn } from '@angular/router'; +import { map } from 'rxjs'; +import { Role } from '../user.model'; +import { UserStore } from '../user.store'; + +export function hasRole(roles: Role[]): CanMatchFn { + return () => { + const userStore = inject(UserStore); + + return userStore.user$.pipe( + map((user) => { + if (!user) return false; + if (user.isAdmin) return true; + return roles.some((role) => user.roles.includes(role)); + }), + ); + }; +} diff --git a/apps/angular/6-structural-directive/src/app/information.component.ts b/apps/angular/6-structural-directive/src/app/information.component.ts index 81b339520..3399d1039 100644 --- a/apps/angular/6-structural-directive/src/app/information.component.ts +++ b/apps/angular/6-structural-directive/src/app/information.component.ts @@ -1,23 +1,20 @@ -import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { UserStore } from './user.store'; +import { HasRoleSuperAdminDirective } from './directives/has-role-super-admin.directive'; +import { HasRoleDirective } from './directives/has-role.directive'; @Component({ selector: 'app-information', - imports: [CommonModule], + standalone: true, + imports: [HasRoleDirective, HasRoleSuperAdminDirective], template: `

Information Panel

- -
visible only for super admin
-
visible if manager
-
visible if manager and/or reader
-
visible if manager and/or writer
-
visible if client
+
visible only for super admin
+
visible if manager
+
visible if manager and/or reader
+
visible if manager and/or writer
+
visible if client
visible for everyone
`, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class InformationComponent { - user$ = this.userStore.user$; - constructor(private userStore: UserStore) {} -} +export class InformationComponent {} diff --git a/apps/angular/6-structural-directive/src/app/routes.ts b/apps/angular/6-structural-directive/src/app/routes.ts index 4db203f3b..3a00f1416 100644 --- a/apps/angular/6-structural-directive/src/app/routes.ts +++ b/apps/angular/6-structural-directive/src/app/routes.ts @@ -1,4 +1,7 @@ -export const APP_ROUTES = [ +import { Routes } from '@angular/router'; +import { hasRole } from './guards/role.guard'; + +export const APP_ROUTES: Routes = [ { path: '', loadComponent: () => @@ -10,5 +13,14 @@ export const APP_ROUTES = [ import('./dashboard/admin.component').then( (m) => m.AdminDashboardComponent, ), + canMatch: [hasRole(['MANAGER'])], + }, + { + path: 'enter', + loadComponent: () => + import('./dashboard/manager.component').then( + (m) => m.ManagerDashboardComponent, + ), + canMatch: [hasRole(['READER', 'WRITER', 'CLIENT'])], }, ]; From f1b4ccb22fe9158ed10ea36fa837d4f62f263e51 Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Fri, 7 Feb 2025 20:36:39 +0300 Subject: [PATCH 06/42] feat(challenge-8): implement pure pipe for person display --- apps/angular/8-pure-pipe/src/app/app.component.ts | 11 ++++------- .../8-pure-pipe/src/app/pipes/person-display.pipe.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 apps/angular/8-pure-pipe/src/app/pipes/person-display.pipe.ts diff --git a/apps/angular/8-pure-pipe/src/app/app.component.ts b/apps/angular/8-pure-pipe/src/app/app.component.ts index 41dd38e25..264f8f971 100644 --- a/apps/angular/8-pure-pipe/src/app/app.component.ts +++ b/apps/angular/8-pure-pipe/src/app/app.component.ts @@ -1,20 +1,17 @@ import { NgFor } from '@angular/common'; import { Component } from '@angular/core'; +import { PersonDisplayPipe } from './pipes/person-display.pipe'; @Component({ - imports: [NgFor], selector: 'app-root', + standalone: true, + imports: [NgFor, PersonDisplayPipe], template: `
- {{ heavyComputation(person, index) }} + {{ person | personDisplay: index }}
`, }) export class AppComponent { persons = ['toto', 'jack']; - - heavyComputation(name: string, index: number) { - // very heavy computation - return `${name} - ${index}`; - } } diff --git a/apps/angular/8-pure-pipe/src/app/pipes/person-display.pipe.ts b/apps/angular/8-pure-pipe/src/app/pipes/person-display.pipe.ts new file mode 100644 index 000000000..53d9f80f8 --- /dev/null +++ b/apps/angular/8-pure-pipe/src/app/pipes/person-display.pipe.ts @@ -0,0 +1,12 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'personDisplay', + standalone: true, +}) +export class PersonDisplayPipe implements PipeTransform { + transform(name: string, index: number): string { + // very heavy computation + return `${name} - ${index}`; + } +} From 7e4655d3a2077f7185322439b0b2b5fa4283a662 Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Fri, 7 Feb 2025 20:42:22 +0300 Subject: [PATCH 07/42] feat(challenge-9): implement reusable wrap function pipe --- .../src/app/app.component.ts | 23 ++++++++++++------- .../src/app/pipes/wrap-fn.pipe.ts | 16 +++++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 apps/angular/9-wrap-function-pipe/src/app/pipes/wrap-fn.pipe.ts diff --git a/apps/angular/9-wrap-function-pipe/src/app/app.component.ts b/apps/angular/9-wrap-function-pipe/src/app/app.component.ts index dd576ae49..991cdaf3e 100644 --- a/apps/angular/9-wrap-function-pipe/src/app/app.component.ts +++ b/apps/angular/9-wrap-function-pipe/src/app/app.component.ts @@ -1,33 +1,40 @@ import { NgFor } from '@angular/common'; import { Component } from '@angular/core'; +import { WrapFnPipe } from './pipes/wrap-fn.pipe'; + +interface Person { + name: string; + age: number; +} @Component({ - imports: [NgFor], selector: 'app-root', + standalone: true, + imports: [NgFor, WrapFnPipe], template: `
- {{ showName(person.name, index) }} - {{ isAllowed(person.age, isFirst) }} + {{ showName | wrapFn: person.name : index }} + {{ isAllowed | wrapFn: person.age : isFirst }}
`, }) export class AppComponent { - persons = [ + persons: Person[] = [ { name: 'Toto', age: 10 }, { name: 'Jack', age: 15 }, { name: 'John', age: 30 }, ]; - showName(name: string, index: number) { + showName = (name: string, index: number): string => { // very heavy computation return `${name} - ${index}`; - } + }; - isAllowed(age: number, isFirst: boolean) { + isAllowed = (age: number, isFirst: boolean): string => { if (isFirst) { return 'always allowed'; } else { return age > 25 ? 'allowed' : 'declined'; } - } + }; } diff --git a/apps/angular/9-wrap-function-pipe/src/app/pipes/wrap-fn.pipe.ts b/apps/angular/9-wrap-function-pipe/src/app/pipes/wrap-fn.pipe.ts new file mode 100644 index 000000000..782530b60 --- /dev/null +++ b/apps/angular/9-wrap-function-pipe/src/app/pipes/wrap-fn.pipe.ts @@ -0,0 +1,16 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +type AnyFunction = (...args: any[]) => any; + +@Pipe({ + name: 'wrapFn', + standalone: true, +}) +export class WrapFnPipe implements PipeTransform { + transform( + fn: TFn, + ...args: Parameters + ): ReturnType { + return fn(...args); + } +} From 0f66a4ba572452fe44aecbfd5c32fe2d40a68627 Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Fri, 7 Feb 2025 21:00:01 +0300 Subject: [PATCH 08/42] feat(challenge-10): implement utility wrapper pipe --- .../src/app/app.component.ts | 27 ++++++++++++------- .../src/app/pipes/utils.pipe.ts | 13 +++++++++ package-lock.json | 10 +++---- package.json | 2 +- 4 files changed, 36 insertions(+), 16 deletions(-) create mode 100644 apps/angular/10-utility-wrapper-pipe/src/app/pipes/utils.pipe.ts diff --git a/apps/angular/10-utility-wrapper-pipe/src/app/app.component.ts b/apps/angular/10-utility-wrapper-pipe/src/app/app.component.ts index 764d4b9d0..089b7bef2 100644 --- a/apps/angular/10-utility-wrapper-pipe/src/app/app.component.ts +++ b/apps/angular/10-utility-wrapper-pipe/src/app/app.component.ts @@ -1,35 +1,42 @@ import { NgFor } from '@angular/common'; import { Component } from '@angular/core'; -import { PersonUtils } from './person.utils'; +import { UtilsPipe } from './pipes/utils.pipe'; + +interface Person { + name: string; + age: number; +} + +interface Activity { + name: string; + minimumAge: number; +} @Component({ - imports: [NgFor], selector: 'app-root', + standalone: true, + imports: [NgFor, UtilsPipe], template: `
{{ activity.name }} :
- {{ showName(person.name, index) }} - {{ isAllowed(person.age, isFirst, activity.minimumAge) }} + {{ 'showName' | utils: person.name : index }} + {{ 'isAllowed' | utils: person.age : isFirst : activity.minimumAge }}
`, }) export class AppComponent { - persons = [ + persons: Person[] = [ { name: 'Toto', age: 10 }, { name: 'Jack', age: 15 }, { name: 'John', age: 30 }, ]; - activities = [ + activities: Activity[] = [ { name: 'biking', minimumAge: 12 }, { name: 'hiking', minimumAge: 25 }, { name: 'dancing', minimumAge: 1 }, ]; - - showName = PersonUtils.showName; - - isAllowed = PersonUtils.isAllowed; } diff --git a/apps/angular/10-utility-wrapper-pipe/src/app/pipes/utils.pipe.ts b/apps/angular/10-utility-wrapper-pipe/src/app/pipes/utils.pipe.ts new file mode 100644 index 000000000..2212050a9 --- /dev/null +++ b/apps/angular/10-utility-wrapper-pipe/src/app/pipes/utils.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { PersonUtils } from '../person.utils'; + +@Pipe({ + name: 'utils', + standalone: true, +}) +export class UtilsPipe implements PipeTransform { + transform(fnName: keyof typeof PersonUtils, ...args: any[]): string { + // @ts-ignore + return PersonUtils[fnName].apply(null, args); + } +} diff --git a/package-lock.json b/package-lock.json index e527afd06..b08e66252 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,7 +78,7 @@ "@typescript-eslint/utils": "^7.16.0", "all-contributors-cli": "^6.26.1", "autoprefixer": "^10.4.0", - "cypress": "^13.17.0", + "cypress": "^14.0.2", "eslint": "8.57.0", "eslint-config-prettier": "9.0.0", "eslint-plugin-cypress": "2.15.1", @@ -13821,9 +13821,9 @@ "license": "MIT" }, "node_modules/cypress": { - "version": "13.17.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.17.0.tgz", - "integrity": "sha512-5xWkaPurwkIljojFidhw8lFScyxhtiFHl/i/3zov+1Z5CmY4t9tjIdvSXfu82Y3w7wt0uR9KkucbhkVvJZLQSA==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-14.0.2.tgz", + "integrity": "sha512-3qqTU2JoVY262qkYg9I2nohwxcfsJk0dSVp/LXAjD94Jz2y6411Mf/l5uHEHiaANrOmMcHbzYgOd/ueDsZlS7A==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -13875,7 +13875,7 @@ "cypress": "bin/cypress" }, "engines": { - "node": "^16.0.0 || ^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" } }, "node_modules/cypress/node_modules/arch": { diff --git a/package.json b/package.json index d816c80fe..38b0c286d 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "@typescript-eslint/utils": "^7.16.0", "all-contributors-cli": "^6.26.1", "autoprefixer": "^10.4.0", - "cypress": "^13.17.0", + "cypress": "^14.0.2", "eslint": "8.57.0", "eslint-config-prettier": "9.0.0", "eslint-plugin-cypress": "2.15.1", From c8e8b9a864a212499265c16cec9a2e9687d6c3bc Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Fri, 7 Feb 2025 21:07:41 +0300 Subject: [PATCH 09/42] feat(challenge-13): implement CSS variables and host-context styling --- .../src/app/page.component.ts | 27 ++++++++-- .../src/app/static-text.component.ts | 54 +++++++++++-------- .../src/app/text.component.ts | 39 +++++++++++--- 3 files changed, 87 insertions(+), 33 deletions(-) diff --git a/apps/angular/13-highly-customizable-css/src/app/page.component.ts b/apps/angular/13-highly-customizable-css/src/app/page.component.ts index 029ca52d2..2abc8c465 100644 --- a/apps/angular/13-highly-customizable-css/src/app/page.component.ts +++ b/apps/angular/13-highly-customizable-css/src/app/page.component.ts @@ -5,12 +5,31 @@ import { TextComponent } from './text.component'; @Component({ selector: 'page', + standalone: true, imports: [TextStaticComponent, TextComponent], template: ` - - - - This is a blue text +
+ + + + + This is a blue text + +
`, + styles: [ + ` + .container { + max-width: 600px; + margin: 32px auto; + padding: 0 16px; + } + `, + ], }) export class PageComponent {} diff --git a/apps/angular/13-highly-customizable-css/src/app/static-text.component.ts b/apps/angular/13-highly-customizable-css/src/app/static-text.component.ts index 70d57d9a3..484cc43a3 100644 --- a/apps/angular/13-highly-customizable-css/src/app/static-text.component.ts +++ b/apps/angular/13-highly-customizable-css/src/app/static-text.component.ts @@ -1,32 +1,44 @@ /* eslint-disable @angular-eslint/component-selector */ -import { Component, Input } from '@angular/core'; +import { Component } from '@angular/core'; import { TextComponent } from './text.component'; -export type StaticTextType = 'normal' | 'warning' | 'error'; - @Component({ selector: 'static-text', imports: [TextComponent], template: ` - This is a static text + This is a static text `, -}) -export class TextStaticComponent { - @Input() set type(type: StaticTextType) { - switch (type) { - case 'error': { - this.font = 30; - this.color = 'red'; - break; + styles: [ + ` + :host { + --text-font-size: 14px; + --text-color: #2c3e50; + --text-font-weight: 400; + display: block; } - case 'warning': { - this.font = 25; - this.color = 'orange'; - break; + + :host([type='error']) { + --text-font-size: 16px; + --text-color: #e74c3c; + --text-font-weight: 600; + } + + :host([type='error']) ::ng-deep p { + background: rgba(231, 76, 60, 0.1); + border-left: 4px solid #e74c3c; } - } - } - font = 10; - color = 'black'; -} + :host([type='warning']) { + --text-font-size: 15px; + --text-color: #f39c12; + --text-font-weight: 500; + } + + :host([type='warning']) ::ng-deep p { + background: rgba(243, 156, 18, 0.1); + border-left: 4px solid #f39c12; + } + `, + ], +}) +export class TextStaticComponent {} diff --git a/apps/angular/13-highly-customizable-css/src/app/text.component.ts b/apps/angular/13-highly-customizable-css/src/app/text.component.ts index 452e76a8e..cd6b9bcef 100644 --- a/apps/angular/13-highly-customizable-css/src/app/text.component.ts +++ b/apps/angular/13-highly-customizable-css/src/app/text.component.ts @@ -1,16 +1,39 @@ /* eslint-disable @angular-eslint/component-selector */ -import { Component, Input } from '@angular/core'; +import { Component } from '@angular/core'; @Component({ selector: 'text', standalone: true, template: ` -

- -

+

`, + styles: [ + ` + :host { + --text-font-size: 10px; + --text-color: black; + --text-line-height: 1.5; + --text-font-weight: 400; + display: block; + margin: 8px 0; + } + + p { + font-size: var(--text-font-size); + color: var(--text-color); + line-height: var(--text-line-height); + font-weight: var(--text-font-weight); + font-family: 'Segoe UI', system-ui, sans-serif; + margin: 0; + padding: 12px 16px; + border-radius: 4px; + transition: all 0.2s ease; + } + + p:hover { + transform: translateX(4px); + } + `, + ], }) -export class TextComponent { - @Input() font = 10; - @Input() color = 'black'; -} +export class TextComponent {} From 99f46393da6ed619e1ca798d4deba5f91e3b305c Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Fri, 7 Feb 2025 21:18:34 +0300 Subject: [PATCH 10/42] feat(challenge-16): implement row-level currency service injection --- .../src/app/app.component.ts | 20 ++++++++++++---- .../src/app/currency-provider.directive.ts | 17 +++++++++++++ .../src/app/currency.service.ts | 4 ++-- .../src/app/currency.token.ts | 5 ++++ .../src/app/product.model.ts | 24 ++++++++++++------- 5 files changed, 54 insertions(+), 16 deletions(-) create mode 100644 apps/angular/16-master-dependency-injection/src/app/currency-provider.directive.ts create mode 100644 apps/angular/16-master-dependency-injection/src/app/currency.token.ts diff --git a/apps/angular/16-master-dependency-injection/src/app/app.component.ts b/apps/angular/16-master-dependency-injection/src/app/app.component.ts index 5bb91c2b2..1669df6e8 100644 --- a/apps/angular/16-master-dependency-injection/src/app/app.component.ts +++ b/apps/angular/16-master-dependency-injection/src/app/app.component.ts @@ -1,8 +1,8 @@ import { TableComponent } from '@angular-challenges/shared/ui'; -import { AsyncPipe, NgFor } from '@angular/common'; +import { AsyncPipe, NgFor, NgIf } from '@angular/common'; import { Component, Directive } from '@angular/core'; +import { CurrencyProviderDirective } from './currency-provider.directive'; import { CurrencyPipe } from './currency.pipe'; -import { CurrencyService } from './currency.service'; import { Product, products } from './product.model'; interface ProductContext { @@ -23,8 +23,15 @@ export class ProductDirective { } @Component({ - imports: [TableComponent, CurrencyPipe, AsyncPipe, NgFor, ProductDirective], - providers: [CurrencyService], + imports: [ + TableComponent, + CurrencyPipe, + AsyncPipe, + NgFor, + NgIf, + ProductDirective, + CurrencyProviderDirective, + ], selector: 'app-root', template: ` @@ -36,7 +43,10 @@ export class ProductDirective { - + diff --git a/apps/angular/16-master-dependency-injection/src/app/currency-provider.directive.ts b/apps/angular/16-master-dependency-injection/src/app/currency-provider.directive.ts new file mode 100644 index 000000000..fbf89f6d1 --- /dev/null +++ b/apps/angular/16-master-dependency-injection/src/app/currency-provider.directive.ts @@ -0,0 +1,17 @@ +import { Directive, Input, OnInit } from '@angular/core'; +import { CurrencyService } from './currency.service'; + +@Directive({ + selector: '[currencyProvider]', + standalone: true, + providers: [CurrencyService], +}) +export class CurrencyProviderDirective implements OnInit { + @Input() currencyCode!: string; + + constructor(private currencyService: CurrencyService) {} + + ngOnInit() { + this.currencyService.setState({ code: this.currencyCode }); + } +} diff --git a/apps/angular/16-master-dependency-injection/src/app/currency.service.ts b/apps/angular/16-master-dependency-injection/src/app/currency.service.ts index 38b403e48..d48a5f4c0 100644 --- a/apps/angular/16-master-dependency-injection/src/app/currency.service.ts +++ b/apps/angular/16-master-dependency-injection/src/app/currency.service.ts @@ -10,8 +10,8 @@ export interface Currency { export const currency: Currency[] = [ { name: 'Euro', code: 'EUR', symbol: '€' }, - { name: 'Dollar US', code: 'USD', symbol: 'US$' }, - { name: 'Dollar Autralien', code: 'AUD', symbol: 'AU$' }, + { name: 'Dollar US', code: 'USD', symbol: '$' }, + { name: 'Dollar Autralien', code: 'AUD', symbol: '$' }, { name: 'Livre Sterling', code: 'GBP', symbol: '£' }, { name: 'Dollar Canadien', code: 'CAD', symbol: 'CAD' }, ]; diff --git a/apps/angular/16-master-dependency-injection/src/app/currency.token.ts b/apps/angular/16-master-dependency-injection/src/app/currency.token.ts new file mode 100644 index 000000000..3f8ce2562 --- /dev/null +++ b/apps/angular/16-master-dependency-injection/src/app/currency.token.ts @@ -0,0 +1,5 @@ +import { InjectionToken } from '@angular/core'; + +export const PRODUCT_CURRENCY_CODE = new InjectionToken( + 'PRODUCT_CURRENCY_CODE', +); diff --git a/apps/angular/16-master-dependency-injection/src/app/product.model.ts b/apps/angular/16-master-dependency-injection/src/app/product.model.ts index 174e7dc77..b90ee56d6 100644 --- a/apps/angular/16-master-dependency-injection/src/app/product.model.ts +++ b/apps/angular/16-master-dependency-injection/src/app/product.model.ts @@ -8,45 +8,51 @@ export interface Product { export const products: Product[] = [ { - name: 'bike', + name: 'Bike', priceA: 1000, priceB: 2000, priceC: 2200, currencyCode: 'USD', }, - { name: 'tent', priceA: 112, priceB: 120, priceC: 41, currencyCode: 'EUR' }, + { name: 'Tent', priceA: 112, priceB: 120, priceC: 41, currencyCode: 'EUR' }, + { - name: 'sofa', + name: 'Sofa', priceA: 500, priceB: 422, priceC: 5000, currencyCode: 'EUR', }, + { - name: 'watch', + name: 'Watch', priceA: 50, priceB: 130, priceC: 150, currencyCode: 'AUD', }, + { - name: 'computer', + name: 'Computer', priceA: 1000, priceB: 2200, priceC: 3500, currencyCode: 'GBP', }, - { name: 'mug', priceA: 10, priceB: 15, priceC: 20, currencyCode: 'EUR' }, + + { name: 'Mug', priceA: 10, priceB: 15, priceC: 20, currencyCode: 'EUR' }, + { - name: 'headset', + name: 'Headset', priceA: 100, priceB: 150, priceC: 220, currencyCode: 'CAD', }, - { name: 'cable', priceA: 5, priceB: 10, priceC: 15, currencyCode: 'EUR' }, + + { name: 'Cable', priceA: 5, priceB: 10, priceC: 15, currencyCode: 'EUR' }, { - name: 'table', + name: 'Table', priceA: 100, priceB: 20, priceC: 500, From 01ebdce32d37c8a311ac099861c1236753f9f451 Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Fri, 7 Feb 2025 21:25:19 +0300 Subject: [PATCH 11/42] feat(challenge-21): implement smooth anchor navigation --- .../src/app/home.component.ts | 2 +- .../src/app/nav-button.component.ts | 19 ++++++++++++++++++- .../21-anchor-navigation/src/styles.scss | 4 ++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/angular/21-anchor-navigation/src/app/home.component.ts b/apps/angular/21-anchor-navigation/src/app/home.component.ts index 6ef9bc2b6..f48480f27 100644 --- a/apps/angular/21-anchor-navigation/src/app/home.component.ts +++ b/apps/angular/21-anchor-navigation/src/app/home.component.ts @@ -5,7 +5,7 @@ import { NavButtonComponent } from './nav-button.component'; imports: [NavButtonComponent], selector: 'app-home', template: ` - Foo Page + Foo Page
Empty Scroll Bottom diff --git a/apps/angular/21-anchor-navigation/src/app/nav-button.component.ts b/apps/angular/21-anchor-navigation/src/app/nav-button.component.ts index 9e3b6d42f..01e8217ee 100644 --- a/apps/angular/21-anchor-navigation/src/app/nav-button.component.ts +++ b/apps/angular/21-anchor-navigation/src/app/nav-button.component.ts @@ -1,10 +1,13 @@ /* eslint-disable @angular-eslint/component-selector */ import { Component, Input } from '@angular/core'; +import { RouterLink } from '@angular/router'; + @Component({ selector: 'nav-button', standalone: true, + imports: [RouterLink], template: ` - + `, @@ -14,4 +17,18 @@ import { Component, Input } from '@angular/core'; }) export class NavButtonComponent { @Input() href = ''; + fragment = ''; + + ngOnInit() { + const [path, fragment] = this.href.split('#'); + this.href = path || ''; + this.fragment = fragment || ''; + } + + handleClick() { + if (this.fragment) { + const element = document.getElementById(this.fragment); + element?.scrollIntoView({ behavior: 'smooth' }); + } + } } diff --git a/apps/angular/21-anchor-navigation/src/styles.scss b/apps/angular/21-anchor-navigation/src/styles.scss index 77e408aa8..d1e86f4b1 100644 --- a/apps/angular/21-anchor-navigation/src/styles.scss +++ b/apps/angular/21-anchor-navigation/src/styles.scss @@ -3,3 +3,7 @@ @tailwind utilities; /* You can add global styles to this file, and also import other style files */ + +html { + scroll-behavior: smooth; +} From d7bf81e06210a8c43ea57c64891130543d683a6e Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Fri, 7 Feb 2025 21:50:30 +0300 Subject: [PATCH 12/42] feat(challenge-22): implement route parameter handling --- .../22-router-input/src/app/app.component.ts | 87 ++++++- .../22-router-input/src/app/app.routes.ts | 4 +- .../22-router-input/src/app/home.component.ts | 230 +++++++++++++++++- .../22-router-input/src/app/test.component.ts | 80 +++++- 4 files changed, 372 insertions(+), 29 deletions(-) diff --git a/apps/angular/22-router-input/src/app/app.component.ts b/apps/angular/22-router-input/src/app/app.component.ts index 9dfc11200..8f6d5974a 100644 --- a/apps/angular/22-router-input/src/app/app.component.ts +++ b/apps/angular/22-router-input/src/app/app.component.ts @@ -6,20 +6,81 @@ import { RouterLink, RouterModule } from '@angular/router'; imports: [RouterLink, RouterModule, ReactiveFormsModule], selector: 'app-root', template: ` - - - - - - - +
+
+ + +
+
+ + +
+
+ + +
+ +
`, + styles: [ + ` + .container { + max-width: 600px; + margin: 2rem auto; + padding: 2rem; + background: #f8f9fa; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + .form-group { + margin-bottom: 1rem; + } + label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #495057; + } + input { + width: 100%; + padding: 0.5rem; + border: 1px solid #ced4da; + border-radius: 4px; + font-size: 1rem; + } + .button-group { + margin-top: 1.5rem; + display: flex; + gap: 1rem; + } + button { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: opacity 0.2s; + } + button:hover { + opacity: 0.9; + } + .primary { + background: #0d6efd; + color: white; + } + .secondary { + background: #6c757d; + color: white; + } + `, + ], }) export class AppComponent { - userName = new FormControl(); - testId = new FormControl(); + userName = new FormControl(''); + testId = new FormControl(0); } diff --git a/apps/angular/22-router-input/src/app/app.routes.ts b/apps/angular/22-router-input/src/app/app.routes.ts index f5d3487c4..160d10e95 100644 --- a/apps/angular/22-router-input/src/app/app.routes.ts +++ b/apps/angular/22-router-input/src/app/app.routes.ts @@ -8,8 +8,8 @@ export const appRoutes: Route[] = [ { path: 'subscription/:testId', loadComponent: () => import('./test.component'), - data: { - permission: 'admin', + resolve: { + permission: () => 'admin', }, }, ]; diff --git a/apps/angular/22-router-input/src/app/home.component.ts b/apps/angular/22-router-input/src/app/home.component.ts index 0ddc1501d..293fda8ea 100644 --- a/apps/angular/22-router-input/src/app/home.component.ts +++ b/apps/angular/22-router-input/src/app/home.component.ts @@ -1,9 +1,235 @@ +import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; + @Component({ selector: 'app-home', - imports: [], + standalone: true, + imports: [RouterLink, CommonModule], template: ` -
Home
+
+
+

Welcome to Test Portal

+

Manage and track your test subscriptions

+
+ +
+

Quick Actions

+
+
+
📊
+

View Tests

+

Access and manage your existing test subscriptions

+ +
+
+
🔍
+

Track Progress

+

Monitor your test completion and results

+ +
+
+
📅
+

Schedule Tests

+

Plan and organize upcoming test sessions

+ +
+
+
+ +
+

Getting Started

+
+
+
1
+
+

Create Account

+

Set up your user profile to begin

+
+
+
+
2
+
+

Select Test

+

Choose from available test options

+
+
+
+
3
+
+

Start Testing

+

Begin your assessment journey

+
+
+
+
+
`, + styles: [ + ` + .home-container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + } + + .hero { + text-align: center; + padding: 3rem 0; + background: linear-gradient(to right, #f8f9fa, #e9ecef); + border-radius: 12px; + margin-bottom: 3rem; + } + + .hero h1 { + font-size: 2.5rem; + color: #212529; + margin-bottom: 1rem; + } + + .subtitle { + font-size: 1.25rem; + color: #6c757d; + } + + .features { + margin-bottom: 3rem; + } + + h2 { + font-size: 1.75rem; + color: #343a40; + margin-bottom: 1.5rem; + } + + .cards-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; + } + + .card { + background: white; + padding: 1.5rem; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: transform 0.2s; + display: flex; + flex-direction: column; + } + + .card:hover { + transform: translateY(-5px); + } + + .card-icon { + font-size: 2rem; + margin-bottom: 1rem; + } + + .card h3 { + font-size: 1.25rem; + color: #343a40; + margin-bottom: 0.5rem; + } + + .card p { + color: #6c757d; + line-height: 1.5; + margin-bottom: 1rem; + flex-grow: 1; + } + + .action-button { + background: #0d6efd; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: background-color 0.2s; + } + + .action-button:hover { + background: #0b5ed7; + } + + .steps { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + } + + .step { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + .step-number { + width: 40px; + height: 40px; + background: #0d6efd; + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 1.25rem; + } + + .step-content h3 { + font-size: 1.1rem; + color: #343a40; + margin-bottom: 0.25rem; + } + + .step-content p { + color: #6c757d; + font-size: 0.9rem; + } + + @media (max-width: 768px) { + .home-container { + padding: 1rem; + } + + .hero { + padding: 2rem 1rem; + } + + .hero h1 { + font-size: 2rem; + } + + .cards-grid, + .steps { + grid-template-columns: 1fr; + } + } + `, + ], }) export default class HomeComponent {} diff --git a/apps/angular/22-router-input/src/app/test.component.ts b/apps/angular/22-router-input/src/app/test.component.ts index 747ab4483..539a3c7e9 100644 --- a/apps/angular/22-router-input/src/app/test.component.ts +++ b/apps/angular/22-router-input/src/app/test.component.ts @@ -1,21 +1,77 @@ -import { AsyncPipe } from '@angular/common'; -import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { map } from 'rxjs'; @Component({ selector: 'app-subscription', - imports: [AsyncPipe], + standalone: true, + imports: [CommonModule], template: ` -
TestId: {{ testId$ | async }}
-
Permission: {{ permission$ | async }}
-
User: {{ user$ | async }}
+
+
+ TestId: + {{ testId }} +
+
+ Permission: + {{ permission }} +
+
+ User: + {{ user }} +
+
`, + styles: [ + ` + .info-container { + margin-top: 2rem; + padding: 1.5rem; + background: white; + border-radius: 6px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + } + .info-item { + display: flex; + align-items: center; + padding: 0.75rem 0; + border-bottom: 1px solid #e9ecef; + } + .info-item:last-child { + border-bottom: none; + } + .label { + font-weight: 600; + color: #495057; + width: 120px; + } + .value { + color: #212529; + } + `, + ], }) -export default class TestComponent { - private activatedRoute = inject(ActivatedRoute); +export default class TestComponent implements OnInit { + testId: string = ''; + permission: string = ''; + user: string = ''; - testId$ = this.activatedRoute.params.pipe(map((p) => p['testId'])); - permission$ = this.activatedRoute.data.pipe(map((d) => d['permission'])); - user$ = this.activatedRoute.queryParams.pipe(map((q) => q['user'])); + constructor(private route: ActivatedRoute) {} + + ngOnInit() { + // Get route parameters + this.route.params.subscribe((params) => { + this.testId = params['testId']; + }); + + // Get query parameters + this.route.queryParams.subscribe((queryParams) => { + this.user = queryParams['user']; + }); + + // Get resolved data + this.route.data.subscribe((data) => { + this.permission = data['permission']; + }); + } } From 2970e07d1dcf685af1e99bd83b60e08ae21e69ba Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Fri, 7 Feb 2025 22:07:38 +0300 Subject: [PATCH 13/42] feat(challenge-31): migrate app to standalone components --- .../src/app/app.component.ts | 4 ++- .../src/app/app.config.ts | 7 ++++ .../src/app/app.routes.ts | 35 +++++++++++++++++++ .../31-module-to-standalone/src/main.ts | 11 +++--- 4 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 apps/angular/31-module-to-standalone/src/app/app.config.ts create mode 100644 apps/angular/31-module-to-standalone/src/app/app.routes.ts diff --git a/apps/angular/31-module-to-standalone/src/app/app.component.ts b/apps/angular/31-module-to-standalone/src/app/app.component.ts index 986df84b5..c4c7ecbfe 100644 --- a/apps/angular/31-module-to-standalone/src/app/app.component.ts +++ b/apps/angular/31-module-to-standalone/src/app/app.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; +import { RouterLink, RouterOutlet } from '@angular/router'; @Component({ selector: 'app-root', @@ -25,6 +26,7 @@ import { Component } from '@angular/core'; host: { class: 'flex flex-col p-4 gap-3', }, - standalone: false, + standalone: true, + imports: [RouterLink, RouterOutlet], }) export class AppComponent {} diff --git a/apps/angular/31-module-to-standalone/src/app/app.config.ts b/apps/angular/31-module-to-standalone/src/app/app.config.ts new file mode 100644 index 000000000..0700d1c43 --- /dev/null +++ b/apps/angular/31-module-to-standalone/src/app/app.config.ts @@ -0,0 +1,7 @@ +import { ApplicationConfig } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [provideRouter(routes)], +}; diff --git a/apps/angular/31-module-to-standalone/src/app/app.routes.ts b/apps/angular/31-module-to-standalone/src/app/app.routes.ts new file mode 100644 index 000000000..0517bc539 --- /dev/null +++ b/apps/angular/31-module-to-standalone/src/app/app.routes.ts @@ -0,0 +1,35 @@ +import { IsAuthorizedGuard } from '@angular-challenges/module-to-standalone/admin/shared'; +import { Routes } from '@angular/router'; + +export const routes: Routes = [ + { path: '', redirectTo: 'home', pathMatch: 'full' }, + { + path: 'home', + loadChildren: () => + import('@angular-challenges/module-to-standalone/home').then( + (m) => m.ModuleToStandaloneHomeModule, + ), + }, + { + path: 'admin', + canActivate: [IsAuthorizedGuard], + loadChildren: () => + import('@angular-challenges/module-to-standalone/admin/feature').then( + (m) => m.AdminFeatureModule, + ), + }, + { + path: 'user', + loadChildren: () => + import('@angular-challenges/module-to-standalone/user/shell').then( + (m) => m.UserShellModule, + ), + }, + { + path: 'forbidden', + loadChildren: () => + import('@angular-challenges/module-to-standalone/forbidden').then( + (m) => m.ForbiddenModule, + ), + }, +]; diff --git a/apps/angular/31-module-to-standalone/src/main.ts b/apps/angular/31-module-to-standalone/src/main.ts index 16de2365d..f3a7223da 100644 --- a/apps/angular/31-module-to-standalone/src/main.ts +++ b/apps/angular/31-module-to-standalone/src/main.ts @@ -1,6 +1,7 @@ -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; -import { AppModule } from './app/app.module'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; +import { appConfig } from './app/app.config'; -platformBrowserDynamic() - .bootstrapModule(AppModule) - .catch((err) => console.error(err)); +bootstrapApplication(AppComponent, appConfig).catch((err) => + console.error(err), +); From 3a94606cc710cb88812608d8052c0ae04a9c0721 Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Sat, 8 Feb 2025 14:09:11 +0300 Subject: [PATCH 14/42] fix(challenge-32): resolve change detection infinite loop --- .../src/app/main-navigation.component.ts | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/apps/angular/32-change-detection-bug/src/app/main-navigation.component.ts b/apps/angular/32-change-detection-bug/src/app/main-navigation.component.ts index 3d5ce20f8..b88aa9455 100644 --- a/apps/angular/32-change-detection-bug/src/app/main-navigation.component.ts +++ b/apps/angular/32-change-detection-bug/src/app/main-navigation.component.ts @@ -10,6 +10,7 @@ interface MenuItem { @Component({ selector: 'app-nav', + standalone: true, imports: [RouterLink, RouterLinkActive, NgFor], template: ` @@ -37,29 +38,20 @@ export class NavigationComponent { } @Component({ + standalone: true, imports: [NavigationComponent, NgIf, AsyncPipe], template: ` - - - + - - - - `, - host: {}, }) export class MainNavigationComponent { private fakeBackend = inject(FakeServiceService); - readonly info$ = this.fakeBackend.getInfoFromBackend(); - getMenu(prop: string) { - return [ - { path: '/foo', name: `Foo ${prop}` }, - { path: '/bar', name: `Bar ${prop}` }, - ]; - } + readonly menus: MenuItem[] = [ + { path: '/foo', name: 'Foo' }, + { path: '/bar', name: 'Bar' }, + ]; } From 8d8b76c9afc64f29f8dce6ec6b577bb8d978bd0f Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Sat, 8 Feb 2025 14:29:31 +0300 Subject: [PATCH 15/42] feat(challenge-33): decouple button behavior from styling --- libs/decoupling/brain/src/index.ts | 5 +---- .../brain/src/lib/button-disabled.directive.ts | 17 +++++++++++++---- libs/decoupling/core/src/index.ts | 2 ++ .../decoupling/core/src/lib/button.interface.ts | 5 +++++ libs/decoupling/core/src/lib/button.token.ts | 6 ++++++ .../helmet/src/lib/btn-style.directive.ts | 11 ++++++----- 6 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 libs/decoupling/core/src/lib/button.interface.ts create mode 100644 libs/decoupling/core/src/lib/button.token.ts diff --git a/libs/decoupling/brain/src/index.ts b/libs/decoupling/brain/src/index.ts index 2bf3f9a23..8eb5b50e1 100644 --- a/libs/decoupling/brain/src/index.ts +++ b/libs/decoupling/brain/src/index.ts @@ -1,4 +1 @@ -export { - BtnDisabledDirective, - ButtonState, -} from './lib/button-disabled.directive'; +export { BtnDisabledDirective } from './lib/button-disabled.directive'; diff --git a/libs/decoupling/brain/src/lib/button-disabled.directive.ts b/libs/decoupling/brain/src/lib/button-disabled.directive.ts index e7a7f4525..33ba6cbb8 100644 --- a/libs/decoupling/brain/src/lib/button-disabled.directive.ts +++ b/libs/decoupling/brain/src/lib/button-disabled.directive.ts @@ -1,18 +1,27 @@ /* eslint-disable @angular-eslint/directive-selector */ /* eslint-disable @angular-eslint/no-host-metadata-property */ +import { + BUTTON_STATE, + ButtonState, + ButtonStateControl, +} from '@angular-challenges/decoupling/core'; import { Directive, WritableSignal, signal } from '@angular/core'; -export type ButtonState = 'enabled' | 'disabled'; - @Directive({ selector: 'button[btnDisabled]', standalone: true, host: { '(click)': 'toggleState()', }, + providers: [ + { + provide: BUTTON_STATE, + useExisting: BtnDisabledDirective, + }, + ], }) -export class BtnDisabledDirective { - state: WritableSignal = signal('enabled'); +export class BtnDisabledDirective implements ButtonStateControl { + readonly state: WritableSignal = signal('enabled'); toggleState() { this.state.set(this.state() === 'enabled' ? 'disabled' : 'enabled'); diff --git a/libs/decoupling/core/src/index.ts b/libs/decoupling/core/src/index.ts index e69de29bb..473203f05 100644 --- a/libs/decoupling/core/src/index.ts +++ b/libs/decoupling/core/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/button.interface'; +export * from './lib/button.token'; diff --git a/libs/decoupling/core/src/lib/button.interface.ts b/libs/decoupling/core/src/lib/button.interface.ts new file mode 100644 index 000000000..b977bdd96 --- /dev/null +++ b/libs/decoupling/core/src/lib/button.interface.ts @@ -0,0 +1,5 @@ +export type ButtonState = 'enabled' | 'disabled'; + +export interface ButtonStateControl { + state: import('@angular/core').Signal; +} diff --git a/libs/decoupling/core/src/lib/button.token.ts b/libs/decoupling/core/src/lib/button.token.ts new file mode 100644 index 000000000..7321c795b --- /dev/null +++ b/libs/decoupling/core/src/lib/button.token.ts @@ -0,0 +1,6 @@ +import { InjectionToken } from '@angular/core'; +import { ButtonStateControl } from './button.interface'; + +export const BUTTON_STATE = new InjectionToken( + 'BUTTON_STATE', +); diff --git a/libs/decoupling/helmet/src/lib/btn-style.directive.ts b/libs/decoupling/helmet/src/lib/btn-style.directive.ts index 50a65b107..0c408e832 100644 --- a/libs/decoupling/helmet/src/lib/btn-style.directive.ts +++ b/libs/decoupling/helmet/src/lib/btn-style.directive.ts @@ -1,12 +1,14 @@ /* eslint-disable @angular-eslint/directive-selector */ -import { BtnDisabledDirective } from '@angular-challenges/decoupling/brain'; +import { + BUTTON_STATE, + ButtonStateControl, +} from '@angular-challenges/decoupling/core'; import { Directive, ElementRef, Renderer2, effect, inject, - signal, } from '@angular/core'; @Directive({ @@ -18,8 +20,7 @@ import { }, }) export class BtnHelmetDirective { - btnState = inject(BtnDisabledDirective, { self: true }); - public state = this.btnState?.state ?? signal('disabled').asReadonly(); + private btnState = inject(BUTTON_STATE); private renderer = inject(Renderer2); private element = inject(ElementRef); @@ -27,7 +28,7 @@ export class BtnHelmetDirective { this.renderer.setAttribute( this.element.nativeElement, 'data-state', - this.state(), + this.btnState.state(), ); }); } From b8d43ae70f004024195428e6a26fccdd44fddd52 Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Sat, 8 Feb 2025 14:45:17 +0300 Subject: [PATCH 16/42] feat(challenge-39): injection-token --- .../angular/39-injection-token/src/app/phone.component.ts | 2 ++ .../src/app/timer-container.component.ts | 7 ++++--- apps/angular/39-injection-token/src/app/timer-token.ts | 8 ++++++++ .../angular/39-injection-token/src/app/timer.component.ts | 7 ++++--- .../angular/39-injection-token/src/app/video.component.ts | 2 ++ 5 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 apps/angular/39-injection-token/src/app/timer-token.ts diff --git a/apps/angular/39-injection-token/src/app/phone.component.ts b/apps/angular/39-injection-token/src/app/phone.component.ts index 41ee3cfc0..6b63fe239 100644 --- a/apps/angular/39-injection-token/src/app/phone.component.ts +++ b/apps/angular/39-injection-token/src/app/phone.component.ts @@ -1,9 +1,11 @@ import { Component } from '@angular/core'; import { TimerContainerComponent } from './timer-container.component'; +import { getTimerProvider } from './timer-token'; @Component({ selector: 'app-phone', imports: [TimerContainerComponent], + providers: [getTimerProvider(2000)], template: `
Phone Call Timer: diff --git a/apps/angular/39-injection-token/src/app/timer-container.component.ts b/apps/angular/39-injection-token/src/app/timer-container.component.ts index 67db6059a..817026377 100644 --- a/apps/angular/39-injection-token/src/app/timer-container.component.ts +++ b/apps/angular/39-injection-token/src/app/timer-container.component.ts @@ -1,6 +1,7 @@ -import { Component } from '@angular/core'; -import { DEFAULT_TIMER } from './data'; +import { Component, inject } from '@angular/core'; +import { TIMER_VALUE } from './timer-token'; import { TimerComponent } from './timer.component'; + @Component({ selector: 'timer-container', imports: [TimerComponent], @@ -16,5 +17,5 @@ import { TimerComponent } from './timer.component'; }, }) export class TimerContainerComponent { - timer = DEFAULT_TIMER; + timer = inject(TIMER_VALUE, { optional: true }) ?? 1000; } diff --git a/apps/angular/39-injection-token/src/app/timer-token.ts b/apps/angular/39-injection-token/src/app/timer-token.ts new file mode 100644 index 000000000..5cfffe6dc --- /dev/null +++ b/apps/angular/39-injection-token/src/app/timer-token.ts @@ -0,0 +1,8 @@ +import { InjectionToken } from '@angular/core'; + +export const TIMER_VALUE = new InjectionToken('TIMER_VALUE'); + +export const getTimerProvider = (value: number) => ({ + provide: TIMER_VALUE, + useValue: value, +}); diff --git a/apps/angular/39-injection-token/src/app/timer.component.ts b/apps/angular/39-injection-token/src/app/timer.component.ts index 95707ec61..b529b3790 100644 --- a/apps/angular/39-injection-token/src/app/timer.component.ts +++ b/apps/angular/39-injection-token/src/app/timer.component.ts @@ -1,7 +1,7 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { interval } from 'rxjs'; -import { DEFAULT_TIMER } from './data'; +import { TIMER_VALUE } from './timer-token'; @Component({ selector: 'timer', @@ -11,5 +11,6 @@ import { DEFAULT_TIMER } from './data'; `, }) export class TimerComponent { - timer = toSignal(interval(DEFAULT_TIMER)); + private timerValue = inject(TIMER_VALUE, { optional: true }) ?? 1000; + timer = toSignal(interval(this.timerValue)); } diff --git a/apps/angular/39-injection-token/src/app/video.component.ts b/apps/angular/39-injection-token/src/app/video.component.ts index ba0a218b4..7f44d9b36 100644 --- a/apps/angular/39-injection-token/src/app/video.component.ts +++ b/apps/angular/39-injection-token/src/app/video.component.ts @@ -1,9 +1,11 @@ import { Component } from '@angular/core'; import { TimerContainerComponent } from './timer-container.component'; +import { getTimerProvider } from './timer-token'; @Component({ selector: 'app-video', imports: [TimerContainerComponent], + providers: [getTimerProvider(1000)], template: `
Video Call Timer: From 5cb4be667bae67d8988bbb3111b95207bd78c56e Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Sat, 8 Feb 2025 15:50:14 +0300 Subject: [PATCH 17/42] feat(challenge-44): transition through states --- .../44-view-transition/src/app/app.config.ts | 7 +- .../src/app/blog/blog.component.ts | 42 ++++++++-- .../src/app/blog/thumbnail.component.ts | 13 +++- .../src/app/post/post.component.ts | 70 +++++++++++++++-- .../44-view-transition/src/styles.scss | 77 +++++++++++++++++++ 5 files changed, 194 insertions(+), 15 deletions(-) diff --git a/apps/angular/44-view-transition/src/app/app.config.ts b/apps/angular/44-view-transition/src/app/app.config.ts index 4c128f040..59e0c71b0 100644 --- a/apps/angular/44-view-transition/src/app/app.config.ts +++ b/apps/angular/44-view-transition/src/app/app.config.ts @@ -1,5 +1,9 @@ import { ApplicationConfig } from '@angular/core'; -import { provideRouter, withComponentInputBinding } from '@angular/router'; +import { + provideRouter, + withComponentInputBinding, + withViewTransitions, +} from '@angular/router'; export const appConfig: ApplicationConfig = { providers: [ @@ -12,6 +16,7 @@ export const appConfig: ApplicationConfig = { }, ], withComponentInputBinding(), + withViewTransitions(), // Enable view transitions ), ], }; diff --git a/apps/angular/44-view-transition/src/app/blog/blog.component.ts b/apps/angular/44-view-transition/src/app/blog/blog.component.ts index 29291d21e..01370b234 100644 --- a/apps/angular/44-view-transition/src/app/blog/blog.component.ts +++ b/apps/angular/44-view-transition/src/app/blog/blog.component.ts @@ -1,4 +1,11 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + QueryList, + ViewChildren, +} from '@angular/core'; import { posts } from '../data'; import { ThumbnailComponent } from './thumbnail.component'; @@ -7,17 +14,42 @@ import { ThumbnailComponent } from './thumbnail.component'; imports: [ThumbnailComponent], template: `
+ class="fixed left-0 right-0 top-0 z-50 flex h-20 items-center justify-center border-b-2 bg-white text-4xl shadow-md" + style="view-transition-name: page-header"> Blog List
-
+
@for (post of posts; track post.id) { - + }
`, changeDetection: ChangeDetectionStrategy.OnPush, }) -export default class BlogComponent { +export default class BlogComponent implements AfterViewInit { posts = posts; + @ViewChildren('thumbnails') thumbnails!: QueryList; + + ngAfterViewInit() { + // Store thumbnail positions in sessionStorage + this.thumbnails.forEach((thumbnail) => { + const element = thumbnail.nativeElement; + const postId = element.getAttribute('data-post-id'); + const rect = element.getBoundingClientRect(); + sessionStorage.setItem( + `post-position-${postId}`, + JSON.stringify({ + top: rect.top + window.scrollY, + left: rect.left, + width: rect.width, + height: rect.height, + }), + ); + }); + } } diff --git a/apps/angular/44-view-transition/src/app/blog/thumbnail.component.ts b/apps/angular/44-view-transition/src/app/blog/thumbnail.component.ts index dd2e25e26..68fef3c28 100644 --- a/apps/angular/44-view-transition/src/app/blog/thumbnail.component.ts +++ b/apps/angular/44-view-transition/src/app/blog/thumbnail.component.ts @@ -15,14 +15,21 @@ import { ThumbnailHeaderComponent } from './thumbnail-header.component'; width="960" height="540" class="rounded-t-3xl" + [style]="{ 'view-transition-name': 'post-image-' + post().id }" [priority]="post().id === '1'" /> -

{{ post().title }}

+

+ {{ post().title }} +

{{ post().description }}

- + `, host: { - class: 'w-full max-w-[600px] rounded-3xl border-none shadow-lg', + class: 'w-full max-w-[600px] rounded-3xl border-none shadow-lg', }, changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/apps/angular/44-view-transition/src/app/post/post.component.ts b/apps/angular/44-view-transition/src/app/post/post.component.ts index edb87f780..9f315a310 100644 --- a/apps/angular/44-view-transition/src/app/post/post.component.ts +++ b/apps/angular/44-view-transition/src/app/post/post.component.ts @@ -1,9 +1,12 @@ import { NgOptimizedImage } from '@angular/common'; import { + AfterViewInit, ChangeDetectionStrategy, Component, computed, + ElementRef, input, + ViewChild, } from '@angular/core'; import { RouterLink } from '@angular/router'; import { ThumbnailHeaderComponent } from '../blog/thumbnail-header.component'; @@ -19,15 +22,31 @@ import { PostHeaderComponent } from './post-header.component'; RouterLink, ], template: ` -
+
+ - -

{{ post().title }}

- + +

+ {{ post().title }} +

+ @for (chapter of fakeTextChapter; track $index) {

{{ chapter }}

} @@ -38,9 +57,48 @@ import { PostHeaderComponent } from './post-header.component'; }, changeDetection: ChangeDetectionStrategy.OnPush, }) -export default class PostComponent { +export default class PostComponent implements AfterViewInit { id = input.required(); post = computed(() => posts.filter((p) => p.id === this.id())[0]); - fakeTextChapter = fakeTextChapters; + + @ViewChild('postImage') postImage!: ElementRef; + @ViewChild('postContainer') postContainer!: ElementRef; + + private previousPosition: any = null; + + ngAfterViewInit() { + // Get the stored position + const storedPosition = sessionStorage.getItem(`post-position-${this.id()}`); + if (storedPosition) { + this.previousPosition = JSON.parse(storedPosition); + this.applyInitialPosition(); + } + + // Animate to final position + requestAnimationFrame(() => { + this.postContainer.nativeElement.style.transform = 'none'; + this.postContainer.nativeElement.style.transition = + 'transform 0.3s ease-out'; + }); + } + + getImageTransitionStyle() { + return { + 'view-transition-name': `post-image-${this.id()}`, + 'transform-origin': 'top left', + }; + } + + private applyInitialPosition() { + if (this.previousPosition) { + const currentRect = this.postImage.nativeElement.getBoundingClientRect(); + const scaleX = this.previousPosition.width / currentRect.width; + const scaleY = this.previousPosition.height / currentRect.height; + const translateX = this.previousPosition.left - currentRect.left; + const translateY = this.previousPosition.top - currentRect.top; + + this.postContainer.nativeElement.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`; + } + } } diff --git a/apps/angular/44-view-transition/src/styles.scss b/apps/angular/44-view-transition/src/styles.scss index b5c61c956..54db592ed 100644 --- a/apps/angular/44-view-transition/src/styles.scss +++ b/apps/angular/44-view-transition/src/styles.scss @@ -1,3 +1,80 @@ @tailwind base; @tailwind components; @tailwind utilities; + +/* Add this to your global styles.css */ + +::view-transition-old(*), +::view-transition-new(*) { + animation: none; + mix-blend-mode: normal; + transition: transform 0.3s ease-out; +} + +/* Page header transition */ +.page-header { + view-transition-name: page-header; +} + +::view-transition-old(page-header) { + animation: slide-out 0.3s ease-out; +} + +::view-transition-new(page-header) { + animation: slide-in 0.3s ease-in; +} + +/* Shared transitions */ +.shared-element { + transform-origin: top left; + will-change: transform; +} + +@keyframes fade-out { + from { + opacity: 1; + transform: scale(1) translateY(0); + } + to { + opacity: 0; + transform: scale(0.95) translateY(20px); + } +} + +@keyframes fade-in { + from { + opacity: 0; + transform: scale(1.05) translateY(-20px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes slide-out { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(-100%); + opacity: 0; + } +} + +@keyframes slide-in { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Handle scrolling */ +html.no-scroll { + overflow: hidden; +} From 804899739b08037d89eac61f08634f71d9589416 Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Sat, 8 Feb 2025 15:54:58 +0300 Subject: [PATCH 18/42] refactor(challenge-44): remove thumbnail header from post component template --- .../44-view-transition/src/app/post/post.component.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/angular/44-view-transition/src/app/post/post.component.ts b/apps/angular/44-view-transition/src/app/post/post.component.ts index 9f315a310..a59b08bdb 100644 --- a/apps/angular/44-view-transition/src/app/post/post.component.ts +++ b/apps/angular/44-view-transition/src/app/post/post.component.ts @@ -9,23 +9,14 @@ import { ViewChild, } from '@angular/core'; import { RouterLink } from '@angular/router'; -import { ThumbnailHeaderComponent } from '../blog/thumbnail-header.component'; import { fakeTextChapters, posts } from '../data'; import { PostHeaderComponent } from './post-header.component'; @Component({ selector: 'post', - imports: [ - ThumbnailHeaderComponent, - NgOptimizedImage, - PostHeaderComponent, - RouterLink, - ], + imports: [NgOptimizedImage, PostHeaderComponent, RouterLink], template: `
- }
`, - standalone: false, + standalone: true, + imports: [PlaceholderComponent, TopComponent], }) -export class AppComponent { - topLoaded = signal(false); -} +export class AppComponent {} diff --git a/apps/angular/52-lazy-load-component/src/app/placeholder.component.ts b/apps/angular/52-lazy-load-component/src/app/placeholder.component.ts index cbb2b5fa6..3d1e31043 100644 --- a/apps/angular/52-lazy-load-component/src/app/placeholder.component.ts +++ b/apps/angular/52-lazy-load-component/src/app/placeholder.component.ts @@ -13,6 +13,6 @@ import { Component } from '@angular/core'; height: 50%; } `, - standalone: false, + standalone: true, }) export class PlaceholderComponent {} diff --git a/apps/angular/52-lazy-load-component/src/app/top.component.ts b/apps/angular/52-lazy-load-component/src/app/top.component.ts index e1ca9012c..ff2d80d4d 100644 --- a/apps/angular/52-lazy-load-component/src/app/top.component.ts +++ b/apps/angular/52-lazy-load-component/src/app/top.component.ts @@ -13,6 +13,6 @@ import { Component } from '@angular/core'; height: 50%; } `, - standalone: false, + standalone: true, }) export class TopComponent {} diff --git a/apps/angular/52-lazy-load-component/src/main.ts b/apps/angular/52-lazy-load-component/src/main.ts index 16de2365d..31c5da482 100644 --- a/apps/angular/52-lazy-load-component/src/main.ts +++ b/apps/angular/52-lazy-load-component/src/main.ts @@ -1,6 +1,4 @@ -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; -import { AppModule } from './app/app.module'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; -platformBrowserDynamic() - .bootstrapModule(AppModule) - .catch((err) => console.error(err)); +bootstrapApplication(AppComponent).catch((err) => console.error(err)); From cdfd3edfffd77a1353e86b0d9cdd5145ba592a5d Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Sat, 8 Feb 2025 17:01:08 +0300 Subject: [PATCH 22/42] feat(challenge-55): implement back button navigation handling --- .../src/app/app.component.ts | 5 ++-- .../src/app/app.routes.ts | 3 ++ .../app/dialog/confirm-dialog.component.ts | 27 ++++++++++++++++++ .../app/dialog/dialog-strategy.interface.ts | 11 ++++++++ .../src/app/dialog/dialog.guard.ts | 22 +++++++++++++++ .../strategies/sensitive-dialog.strategy.ts | 28 +++++++++++++++++++ .../strategies/simple-dialog.strategy.ts | 12 ++++++++ .../simple-action/simple-action.component.ts | 3 ++ 8 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 apps/angular/55-back-button-navigation/src/app/dialog/confirm-dialog.component.ts create mode 100644 apps/angular/55-back-button-navigation/src/app/dialog/dialog-strategy.interface.ts create mode 100644 apps/angular/55-back-button-navigation/src/app/dialog/dialog.guard.ts create mode 100644 apps/angular/55-back-button-navigation/src/app/dialog/strategies/sensitive-dialog.strategy.ts create mode 100644 apps/angular/55-back-button-navigation/src/app/dialog/strategies/simple-dialog.strategy.ts diff --git a/apps/angular/55-back-button-navigation/src/app/app.component.ts b/apps/angular/55-back-button-navigation/src/app/app.component.ts index baffdae25..44b12a9e1 100644 --- a/apps/angular/55-back-button-navigation/src/app/app.component.ts +++ b/apps/angular/55-back-button-navigation/src/app/app.component.ts @@ -1,9 +1,10 @@ import { Component } from '@angular/core'; -import { RouterLink, RouterOutlet } from '@angular/router'; +import { RouterOutlet } from '@angular/router'; @Component({ - imports: [RouterOutlet, RouterLink], + imports: [RouterOutlet], selector: 'app-root', templateUrl: './app.component.html', + standalone: true, }) export class AppComponent {} diff --git a/apps/angular/55-back-button-navigation/src/app/app.routes.ts b/apps/angular/55-back-button-navigation/src/app/app.routes.ts index 7deecd57a..5e492e7d6 100644 --- a/apps/angular/55-back-button-navigation/src/app/app.routes.ts +++ b/apps/angular/55-back-button-navigation/src/app/app.routes.ts @@ -1,4 +1,5 @@ import { Routes } from '@angular/router'; +import { DialogGuard } from './dialog/dialog.guard'; import { HomeComponent } from './home/home.component'; import { SensitiveActionComponent } from './sensitive-action/sensitive-action.component'; import { SimpleActionComponent } from './simple-action/simple-action.component'; @@ -16,9 +17,11 @@ export const APP_ROUTES: Routes = [ { path: 'simple-action', component: SimpleActionComponent, + canDeactivate: [DialogGuard], }, { path: 'sensitive-action', component: SensitiveActionComponent, + canDeactivate: [DialogGuard], }, ]; diff --git a/apps/angular/55-back-button-navigation/src/app/dialog/confirm-dialog.component.ts b/apps/angular/55-back-button-navigation/src/app/dialog/confirm-dialog.component.ts new file mode 100644 index 000000000..247ccf0f3 --- /dev/null +++ b/apps/angular/55-back-button-navigation/src/app/dialog/confirm-dialog.component.ts @@ -0,0 +1,27 @@ +import { Component, Inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; + +@Component({ + selector: 'app-confirm-dialog', + standalone: true, + imports: [MatDialogModule, MatButtonModule], + template: ` +

Confirm Navigation

+ {{ data.message }} + + + + + `, +}) +export class ConfirmDialogComponent { + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: { message: string }, + ) {} +} diff --git a/apps/angular/55-back-button-navigation/src/app/dialog/dialog-strategy.interface.ts b/apps/angular/55-back-button-navigation/src/app/dialog/dialog-strategy.interface.ts new file mode 100644 index 000000000..b793a992b --- /dev/null +++ b/apps/angular/55-back-button-navigation/src/app/dialog/dialog-strategy.interface.ts @@ -0,0 +1,11 @@ +import { InjectionToken } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { Observable } from 'rxjs'; + +export interface DialogBackButtonStrategy { + handleBackButton(dialogRef: MatDialogRef): Observable; +} + +export const DIALOG_STRATEGY = new InjectionToken( + 'DIALOG_STRATEGY', +); diff --git a/apps/angular/55-back-button-navigation/src/app/dialog/dialog.guard.ts b/apps/angular/55-back-button-navigation/src/app/dialog/dialog.guard.ts new file mode 100644 index 000000000..f6b44b359 --- /dev/null +++ b/apps/angular/55-back-button-navigation/src/app/dialog/dialog.guard.ts @@ -0,0 +1,22 @@ +import { Injectable, inject } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { CanDeactivate } from '@angular/router'; +import { Observable, of } from 'rxjs'; +import { DIALOG_STRATEGY } from './dialog-strategy.interface'; + +@Injectable({ providedIn: 'root' }) +export class DialogGuard implements CanDeactivate { + private dialog = inject(MatDialog); + + canDeactivate(component: unknown): Observable { + const dialogRef = + this.dialog.openDialogs[this.dialog.openDialogs.length - 1]; + const strategy = inject(DIALOG_STRATEGY, { optional: true }); + + if (dialogRef && strategy) { + return strategy.handleBackButton(dialogRef); + } + + return of(true); + } +} diff --git a/apps/angular/55-back-button-navigation/src/app/dialog/strategies/sensitive-dialog.strategy.ts b/apps/angular/55-back-button-navigation/src/app/dialog/strategies/sensitive-dialog.strategy.ts new file mode 100644 index 000000000..9be48a65a --- /dev/null +++ b/apps/angular/55-back-button-navigation/src/app/dialog/strategies/sensitive-dialog.strategy.ts @@ -0,0 +1,28 @@ +import { Injectable, inject } from '@angular/core'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Observable, from } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ConfirmDialogComponent } from '../confirm-dialog.component'; +import { DialogBackButtonStrategy } from '../dialog-strategy.interface'; + +@Injectable({ providedIn: 'root' }) +export class SensitiveDialogStrategy implements DialogBackButtonStrategy { + private dialog = inject(MatDialog); + + handleBackButton(dialogRef: MatDialogRef): Observable { + const confirmRef = this.dialog.open(ConfirmDialogComponent, { + width: '300px', + data: { message: 'Are you sure you want to leave?' }, + }); + + return from(confirmRef.afterClosed()).pipe( + map((result) => { + if (result) { + dialogRef.close(); + return false; + } + return false; + }), + ); + } +} diff --git a/apps/angular/55-back-button-navigation/src/app/dialog/strategies/simple-dialog.strategy.ts b/apps/angular/55-back-button-navigation/src/app/dialog/strategies/simple-dialog.strategy.ts new file mode 100644 index 000000000..c8b53fbaa --- /dev/null +++ b/apps/angular/55-back-button-navigation/src/app/dialog/strategies/simple-dialog.strategy.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { Observable, of } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { DialogBackButtonStrategy } from '../dialog-strategy.interface'; + +@Injectable({ providedIn: 'root' }) +export class SimpleDialogStrategy implements DialogBackButtonStrategy { + handleBackButton(dialogRef: MatDialogRef): Observable { + return of(false).pipe(tap(() => dialogRef.close())); + } +} diff --git a/apps/angular/55-back-button-navigation/src/app/simple-action/simple-action.component.ts b/apps/angular/55-back-button-navigation/src/app/simple-action/simple-action.component.ts index fe97e7368..7dac7b74e 100644 --- a/apps/angular/55-back-button-navigation/src/app/simple-action/simple-action.component.ts +++ b/apps/angular/55-back-button-navigation/src/app/simple-action/simple-action.component.ts @@ -1,12 +1,15 @@ import { Component, inject } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; +import { DIALOG_STRATEGY } from '../dialog/dialog-strategy.interface'; import { DialogComponent } from '../dialog/dialog.component'; +import { SimpleDialogStrategy } from '../dialog/strategies/simple-dialog.strategy'; @Component({ imports: [MatButtonModule], selector: 'app-simple-action', templateUrl: './simple-action.component.html', + providers: [{ provide: DIALOG_STRATEGY, useClass: SimpleDialogStrategy }], }) export class SimpleActionComponent { readonly #dialog = inject(MatDialog); From 04cbdb8802294fa0d8ff8039174176016b351526 Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Sat, 8 Feb 2025 17:26:36 +0300 Subject: [PATCH 23/42] feat(challenge-41): implement ControlValueAccessor with enhanced UI --- .../feedback-form.component.html | 59 ++++++++++--------- .../feedback-form/feedback-form.component.ts | 15 +++-- .../rating-control.component.ts | 39 ++++++++++-- .../41-control-value-accessor/src/styles.scss | 59 +++++++++++++++++++ 4 files changed, 129 insertions(+), 43 deletions(-) diff --git a/apps/forms/41-control-value-accessor/src/app/feedback-form/feedback-form.component.html b/apps/forms/41-control-value-accessor/src/app/feedback-form/feedback-form.component.html index 9334b5bc9..f138e4b9c 100644 --- a/apps/forms/41-control-value-accessor/src/app/feedback-form/feedback-form.component.html +++ b/apps/forms/41-control-value-accessor/src/app/feedback-form/feedback-form.component.html @@ -1,29 +1,30 @@ - - - - - - - - +
+ + + + + + + + +
diff --git a/apps/forms/41-control-value-accessor/src/app/feedback-form/feedback-form.component.ts b/apps/forms/41-control-value-accessor/src/app/feedback-form/feedback-form.component.ts index 4110d6cf7..4ce7809a7 100644 --- a/apps/forms/41-control-value-accessor/src/app/feedback-form/feedback-form.component.ts +++ b/apps/forms/41-control-value-accessor/src/app/feedback-form/feedback-form.component.ts @@ -25,17 +25,16 @@ export class FeedbackFormComponent { email: new FormControl('', { validators: Validators.required, }), + rating: new FormControl(null, { + validators: Validators.required, + }), comment: new FormControl(), }); - rating: string | null = null; - submitForm(): void { - this.feedBackSubmit.emit({ - ...this.feedbackForm.value, - rating: this.rating, - }); - - this.feedbackForm.reset(); + if (this.feedbackForm.valid) { + this.feedBackSubmit.emit(this.feedbackForm.value); + this.feedbackForm.reset(); + } } } diff --git a/apps/forms/41-control-value-accessor/src/app/rating-control/rating-control.component.ts b/apps/forms/41-control-value-accessor/src/app/rating-control/rating-control.component.ts index d6dc31631..bced6a211 100644 --- a/apps/forms/41-control-value-accessor/src/app/rating-control/rating-control.component.ts +++ b/apps/forms/41-control-value-accessor/src/app/rating-control/rating-control.component.ts @@ -1,20 +1,47 @@ -import { Component, EventEmitter, Output } from '@angular/core'; +import { Component } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; @Component({ standalone: true, selector: 'app-rating-control', templateUrl: 'rating-control.component.html', styleUrls: ['rating-control.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: RatingControlComponent, + multi: true, + }, + ], }) -export class RatingControlComponent { - @Output() - readonly ratingUpdated: EventEmitter = new EventEmitter(); - +export class RatingControlComponent implements ControlValueAccessor { value: number | null = null; + disabled = false; + onChange: (value: string | null) => void = () => {}; + onTouched: () => void = () => {}; + + writeValue(value: number | null): void { + this.value = value; + } + + registerOnChange(fn: (value: string | null) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } setRating(index: number): void { + if (this.disabled) return; + this.value = index + 1; - this.ratingUpdated.emit(`${this.value}`); + this.onChange(this.value?.toString() ?? null); + this.onTouched(); } isStarActive(index: number, value: number | null): boolean { diff --git a/apps/forms/41-control-value-accessor/src/styles.scss b/apps/forms/41-control-value-accessor/src/styles.scss index 77e408aa8..a3b886fe0 100644 --- a/apps/forms/41-control-value-accessor/src/styles.scss +++ b/apps/forms/41-control-value-accessor/src/styles.scss @@ -2,4 +2,63 @@ @tailwind components; @tailwind utilities; +body { + @apply min-h-screen bg-gray-100 p-4; + font-family: system-ui, -apple-system, sans-serif; +} + +.container { + @apply mx-auto flex min-h-screen max-w-lg items-center justify-center; +} + +.feedback-form { + @apply w-full rounded-lg bg-white p-8 shadow-lg; + + &-title { + @apply mb-6 text-center text-2xl font-bold text-gray-800; + } + + &-control { + @apply mb-4 w-full rounded-md border border-gray-300 px-4 py-2 text-gray-700 transition-all; + + &:focus { + @apply border-blue-500 outline-none ring-2 ring-blue-200; + } + } + + textarea.feedback-form-control { + @apply min-h-[120px] resize-none; + } + + &-submit { + @apply mt-4 w-full rounded-md bg-blue-600 px-6 py-3 text-white transition-colors; + + &:hover:not(:disabled) { + @apply bg-blue-700; + } + + &:disabled { + @apply cursor-not-allowed bg-gray-400; + } + } +} + +.rating { + @apply mb-4 flex justify-center gap-2; + + .star { + @apply h-10 w-10 cursor-pointer transition-all; + fill: #d1d5db; + + &:hover { + @apply scale-110; + fill: #fbbf24; + } + + &-active { + fill: #fbbf24; + } + } +} + /* You can add global styles to this file, and also import other style files */ From f2e568719905053feaeae20398a019e3a6786644 Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Sat, 8 Feb 2025 17:41:15 +0300 Subject: [PATCH 24/42] feat(challenge-48): implement form data protection with dialog --- .../src/app/app.config.ts | 6 ++- .../src/app/app.routes.ts | 10 +++-- .../src/app/guards/form.guard.ts | 32 ++++++++++++++ .../src/app/pages/page.component.ts | 42 ++++++++++++++++++- .../src/app/ui/dialog.component.ts | 26 ++++++++---- .../src/app/ui/form.component.ts | 15 ++++++- .../48-avoid-losing-form-data/src/styles.scss | 2 + 7 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 apps/forms/48-avoid-losing-form-data/src/app/guards/form.guard.ts diff --git a/apps/forms/48-avoid-losing-form-data/src/app/app.config.ts b/apps/forms/48-avoid-losing-form-data/src/app/app.config.ts index a7c1007b9..5f951cda6 100644 --- a/apps/forms/48-avoid-losing-form-data/src/app/app.config.ts +++ b/apps/forms/48-avoid-losing-form-data/src/app/app.config.ts @@ -1,7 +1,11 @@ import { ApplicationConfig } from '@angular/core'; +import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter, withComponentInputBinding } from '@angular/router'; import { appRoutes } from './app.routes'; export const appConfig: ApplicationConfig = { - providers: [provideRouter(appRoutes, withComponentInputBinding())], + providers: [ + provideRouter(appRoutes, withComponentInputBinding()), + provideAnimations(), + ], }; diff --git a/apps/forms/48-avoid-losing-form-data/src/app/app.routes.ts b/apps/forms/48-avoid-losing-form-data/src/app/app.routes.ts index 84be34b9a..6be8a57fb 100644 --- a/apps/forms/48-avoid-losing-form-data/src/app/app.routes.ts +++ b/apps/forms/48-avoid-losing-form-data/src/app/app.routes.ts @@ -1,6 +1,7 @@ import { Route } from '@angular/router'; -import { JoinComponent } from './pages/join.component'; +import { formGuard } from './guards/form.guard'; import { PageComponent } from './pages/page.component'; +import { FormComponent } from './ui/form.component'; export const appRoutes: Route[] = [ { @@ -10,20 +11,21 @@ export const appRoutes: Route[] = [ }, { path: 'form', - loadComponent: () => JoinComponent, + component: FormComponent, + canDeactivate: [formGuard], }, { path: 'page-1', data: { title: 'Page 1', }, - loadComponent: () => PageComponent, + component: PageComponent, }, { path: 'page-2', data: { title: 'Page 2', }, - loadComponent: () => PageComponent, + component: PageComponent, }, ]; diff --git a/apps/forms/48-avoid-losing-form-data/src/app/guards/form.guard.ts b/apps/forms/48-avoid-losing-form-data/src/app/guards/form.guard.ts new file mode 100644 index 000000000..d4ebad6ff --- /dev/null +++ b/apps/forms/48-avoid-losing-form-data/src/app/guards/form.guard.ts @@ -0,0 +1,32 @@ +import { Dialog } from '@angular/cdk/dialog'; +import { inject } from '@angular/core'; +import { CanDeactivateFn } from '@angular/router'; +import { Observable, map } from 'rxjs'; +import { AlertDialogComponent } from '../ui/dialog.component'; +import { FormComponent } from '../ui/form.component'; + +export const formGuard: CanDeactivateFn = ( + component, +): Observable | boolean => { + const dialog = inject(Dialog); + + if (component.form.dirty) { + const dialogRef = dialog.open(AlertDialogComponent, { + disableClose: true, + ariaDescribedBy: 'alert-dialog-description', + ariaLabelledBy: 'alert-dialog-title', + }); + + return dialogRef.closed.pipe( + map((result) => { + if (result) { + component.form.reset(); + return true; + } + return false; + }), + ); + } + + return true; +}; diff --git a/apps/forms/48-avoid-losing-form-data/src/app/pages/page.component.ts b/apps/forms/48-avoid-losing-form-data/src/app/pages/page.component.ts index 13f4e09c2..e952c567d 100644 --- a/apps/forms/48-avoid-losing-form-data/src/app/pages/page.component.ts +++ b/apps/forms/48-avoid-losing-form-data/src/app/pages/page.component.ts @@ -3,8 +3,46 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core'; @Component({ standalone: true, template: ` -
-

{{ title() }}

+
+
+

+ {{ title() }} +

+
+ +
+
+ Sample Image +
+ +
+
+

+ Lorem ipsum dolor, sit amet consectetur adipisicing elit. Aut qui + hic atque tenetur quis eius quos ea neque sunt, accusantium soluta + minus veniam tempora deserunt? Molestiae eius quidem quam + repellat. +

+ +

+ Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum + explicabo quidem voluptatum voluptas illo accusantium ipsam quis, + vel mollitia? Vel provident culpa dignissimos possimus, + perferendis consectetur odit accusantium dolorem amet voluptates + aliquid, ducimus tempore incidunt quas. +

+
+ + + Learn More + +
+
`, changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/apps/forms/48-avoid-losing-form-data/src/app/ui/dialog.component.ts b/apps/forms/48-avoid-losing-form-data/src/app/ui/dialog.component.ts index 661da9bcf..15bfcd66c 100644 --- a/apps/forms/48-avoid-losing-form-data/src/app/ui/dialog.component.ts +++ b/apps/forms/48-avoid-losing-form-data/src/app/ui/dialog.component.ts @@ -1,24 +1,34 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; - -// NOTE : this is just the dialog content, you need to implement dialog logic +import { DialogRef } from '@angular/cdk/dialog'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; @Component({ standalone: true, template: ` -
- - - + + + `, - imports: [AsyncPipe, CurrencyPipe], + imports: [CurrencyPipe], providers: [CurrencyService], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProductRowComponent { protected productInfo!: Product; + private currencyService = inject(CurrencyService); @Input({ required: true }) set product(product: Product) { this.currencyService.updateCode(product.currencyCode); this.productInfo = product; } - - currencyService = inject(CurrencyService); } From b34c2a1fcd8d5fff3b877c92776add396a66cbd9 Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Mon, 10 Feb 2025 20:55:01 +0300 Subject: [PATCH 38/42] feat(challenge-56): enhance UI with modern design system --- .../src/app/app.component.ts | 16 +- .../src/app/checkout.component.ts | 85 +++++------ .../src/app/dashboard.component.ts | 44 ++++-- .../src/app/order-state.service.ts | 29 ++++ .../src/app/order.component.ts | 141 +++++++++++------- .../src/app/payment.component.ts | 35 ++++- 6 files changed, 228 insertions(+), 122 deletions(-) create mode 100644 apps/signal/56-forms-and-signal/src/app/order-state.service.ts diff --git a/apps/signal/56-forms-and-signal/src/app/app.component.ts b/apps/signal/56-forms-and-signal/src/app/app.component.ts index d6690ea9a..872987238 100644 --- a/apps/signal/56-forms-and-signal/src/app/app.component.ts +++ b/apps/signal/56-forms-and-signal/src/app/app.component.ts @@ -5,13 +5,17 @@ import { RouterOutlet } from '@angular/router'; imports: [RouterOutlet], selector: 'app-root', template: ` -

Shop

-
- +
+
+
+

Shop

+
+
+ +
+ +
`, - host: { - class: 'w-full flex justify-center flex-col items-center p-4 gap-10', - }, }) export class AppComponent {} diff --git a/apps/signal/56-forms-and-signal/src/app/checkout.component.ts b/apps/signal/56-forms-and-signal/src/app/checkout.component.ts index f9d831088..7b551a3ef 100644 --- a/apps/signal/56-forms-and-signal/src/app/checkout.component.ts +++ b/apps/signal/56-forms-and-signal/src/app/checkout.component.ts @@ -1,55 +1,56 @@ -import { - ChangeDetectionStrategy, - Component, - computed, - input, -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { RouterLink } from '@angular/router'; -import { products } from './products'; +import { OrderStateService } from './order-state.service'; @Component({ selector: 'app-dashboard', imports: [RouterLink], template: ` -

Checkout

- -
-
Your order:
-
- {{ quantity() }} x {{ product()?.name }}: {{ product()?.price }}€ +
+

Checkout

+ + + + +
+

Order Summary

+
+
+ {{ orderState.product()?.name }} × {{ orderState.quantity() }} +
+
+ {{ orderState.total() }}€ +
+
-
-
Billing Information
-
...
-
...
-
...
-
...
-
...
+ +
+

Billing Information

+
+ Form fields would go here... +
+
- + +
`, changeDetection: ChangeDetectionStrategy.OnPush, }) -export default class DashboardComponent { - quantity = input(1); - productId = input('1'); - - product = computed(() => - products.find((product) => product.id === this.productId()), - ); - totalWithVAT = computed( - () => this.quantity() * (this.product()?.price ?? 0) * 1.21, - ); +export default class CheckoutComponent { + protected orderState = inject(OrderStateService); } diff --git a/apps/signal/56-forms-and-signal/src/app/dashboard.component.ts b/apps/signal/56-forms-and-signal/src/app/dashboard.component.ts index d96fedfd7..dd3534484 100644 --- a/apps/signal/56-forms-and-signal/src/app/dashboard.component.ts +++ b/apps/signal/56-forms-and-signal/src/app/dashboard.component.ts @@ -6,22 +6,34 @@ import { products } from './products'; selector: 'app-dashboard', imports: [RouterLink], template: ` -

List of Products

-
    - @for (product of products; track product.id) { -
  • -
    - {{ product.name }} ({{ product.price }}€) - -
    -
  • - } -
+
+

+ Available Products +

+ +
    + @for (product of products; track product.id) { +
  • +
    +
    +

    + {{ product.name }} +

    +

    {{ product.price }}€

    +
    + +
    +
  • + } +
+
`, changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/apps/signal/56-forms-and-signal/src/app/order-state.service.ts b/apps/signal/56-forms-and-signal/src/app/order-state.service.ts new file mode 100644 index 000000000..32d55aef2 --- /dev/null +++ b/apps/signal/56-forms-and-signal/src/app/order-state.service.ts @@ -0,0 +1,29 @@ +import { computed, Injectable, signal } from '@angular/core'; +import { products } from './products'; + +@Injectable({ providedIn: 'root' }) +export class OrderStateService { + private readonly productIdSignal = signal('1'); + private readonly quantitySignal = signal(1); + + readonly product = computed(() => + products.find((p) => p.id === this.productIdSignal()), + ); + + readonly quantity = computed(() => this.quantitySignal()); + + readonly totalWithoutVat = computed( + () => (this.product()?.price ?? 0) * this.quantity(), + ); + + readonly vat = computed(() => this.totalWithoutVat() * 0.21); + readonly total = computed(() => this.totalWithoutVat() + this.vat()); + + updateProductId(id: string) { + this.productIdSignal.set(id); + } + + updateQuantity(quantity: number) { + this.quantitySignal.set(quantity); + } +} diff --git a/apps/signal/56-forms-and-signal/src/app/order.component.ts b/apps/signal/56-forms-and-signal/src/app/order.component.ts index 2b03ba814..f4c8a7c92 100644 --- a/apps/signal/56-forms-and-signal/src/app/order.component.ts +++ b/apps/signal/56-forms-and-signal/src/app/order.component.ts @@ -1,71 +1,108 @@ import { ChangeDetectionStrategy, Component, - computed, - input, + effect, + inject, + Input, } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; -import { products } from './products'; +import { OrderStateService } from './order-state.service'; @Component({ selector: 'app-order', imports: [RouterLink, ReactiveFormsModule], template: ` -

Order

-
-
- - - -
-
SubTotal
-
{{ totalWithoutVat() }} €
-
-
-
VAT (21%)
-
{{ vat() }} €
-
-
-
Total
-
{{ total() }} €
-
- -
+
+

Order Details

+ +
+ +
+

Selected Product

+
{{ orderState.product()?.name }}
+
+ {{ orderState.product()?.price }}€ +
+
+ + +
+ + + + + +
+
+
Subtotal
+
{{ orderState.totalWithoutVat() }}€
+
+
+
VAT (21%)
+
{{ orderState.vat() }}€
+
+
+
Total
+
{{ orderState.total() }}€
+
+
+ + + +
+
`, changeDetection: ChangeDetectionStrategy.OnPush, }) export default class OrderComponent { + protected orderState = inject(OrderStateService); + protected quantities = [1, 2, 3, 4, 5]; + + @Input() set productId(id: string) { + this.orderState.updateProductId(id); + } + form = new FormGroup({ - quantity: new FormControl(1, { nonNullable: true }), + quantity: new FormControl(this.orderState.quantity(), { + nonNullable: true, + }), }); - productId = input('1'); - price = computed( - () => products.find((p) => p.id === this.productId())?.price ?? 0, - ); - quantity = toSignal(this.form.controls.quantity.valueChanges, { - initialValue: this.form.getRawValue().quantity, - }); - totalWithoutVat = computed(() => Number(this.price()) * this.quantity()); - vat = computed(() => this.totalWithoutVat() * 0.21); - total = computed(() => this.totalWithoutVat() + this.vat()); + constructor() { + // Update state when form changes + effect(() => { + const quantity = Number(this.form.get('quantity')?.value); + if (quantity) { + this.orderState.updateQuantity(quantity); + } + }); + + // Update form when state changes + effect(() => { + this.form.patchValue( + { quantity: this.orderState.quantity() }, + { emitEvent: false }, + ); + }); + } } diff --git a/apps/signal/56-forms-and-signal/src/app/payment.component.ts b/apps/signal/56-forms-and-signal/src/app/payment.component.ts index 800bd6f36..1589b4bcf 100644 --- a/apps/signal/56-forms-and-signal/src/app/payment.component.ts +++ b/apps/signal/56-forms-and-signal/src/app/payment.component.ts @@ -5,13 +5,36 @@ import { RouterLink } from '@angular/router'; selector: 'app-dashboard', imports: [RouterLink], template: ` -

Payment Success

+
+
+
+ + + +
+

+ Payment Successful! +

+

Thank you for your purchase

+
- + +
`, changeDetection: ChangeDetectionStrategy.OnPush, }) From fd26cc716328760c192f5de2b352df9cd775e0ec Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Mon, 10 Feb 2025 21:15:23 +0300 Subject: [PATCH 39/42] fix(nx): resolve static/dynamic import conflict in users library --- .../src/app/detail/detail.component.ts | 3 +- .../static-dynamic-import/shared/src/index.ts | 2 + package-lock.json | 405 ++++++++++++++++++ package.json | 1 + 4 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 libs/static-dynamic-import/shared/src/index.ts diff --git a/apps/testing/29-real-life-application/src/app/detail/detail.component.ts b/apps/testing/29-real-life-application/src/app/detail/detail.component.ts index 1afdfb31f..2ecc38bea 100644 --- a/apps/testing/29-real-life-application/src/app/detail/detail.component.ts +++ b/apps/testing/29-real-life-application/src/app/detail/detail.component.ts @@ -1,4 +1,4 @@ -import { AsyncPipe, NgIf } from '@angular/common'; +import { NgIf } from '@angular/common'; import { Component, inject } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatProgressBarModule } from '@angular/material/progress-bar'; @@ -13,7 +13,6 @@ import { DetailStore } from './detail.store'; MatButtonModule, RouterLink, NgIf, - AsyncPipe, MatProgressBarModule, LetDirective, ], diff --git a/libs/static-dynamic-import/shared/src/index.ts b/libs/static-dynamic-import/shared/src/index.ts new file mode 100644 index 000000000..d18bd8123 --- /dev/null +++ b/libs/static-dynamic-import/shared/src/index.ts @@ -0,0 +1,2 @@ +export { UserComponent } from './lib/user.component'; +export type { User } from './lib/user.model'; diff --git a/package-lock.json b/package-lock.json index 7434940f5..520065b2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@nx/angular": "20.2.0", "@swc/helpers": "0.5.12", "@tanstack/angular-query-experimental": "5.62.3", + "crypto-browserify": "^3.12.1", "rxjs": "7.8.1", "tailwindcss": "3.4.3", "tslib": "^2.3.0", @@ -11567,6 +11568,21 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/asn1.js/node_modules/bn.js": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", + "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==" + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -12115,6 +12131,11 @@ "dev": true, "license": "MIT" }, + "node_modules/bn.js": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", + "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -12203,6 +12224,115 @@ "node": ">=8" } }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==" + }, + "node_modules/browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "dependencies": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", + "dependencies": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "node_modules/browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", + "dependencies": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/browserify-rsa": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz", + "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", + "dependencies": { + "bn.js": "^5.2.1", + "randombytes": "^2.1.0", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/browserify-sign": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.3.tgz", + "integrity": "sha512-JWCZW6SKhfhjJxO8Tyiiy+XYB7cqd2S5/+WeYHsKdNKFlCBhKbblba1A/HN/90YwtxKc8tCErjffZl++UNmGiw==", + "dependencies": { + "bn.js": "^5.2.1", + "browserify-rsa": "^4.1.0", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "elliptic": "^6.5.5", + "hash-base": "~3.0", + "inherits": "^2.0.4", + "parse-asn1": "^5.1.7", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/browserify-sign/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/browserify-sign/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/browserify-sign/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/browserify-sign/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/browserify-sign/node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/browserslist": { "version": "4.24.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", @@ -12310,6 +12440,11 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==" + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -12750,6 +12885,18 @@ "node": ">=8" } }, + "node_modules/cipher-base": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.6.tgz", + "integrity": "sha512-3Ek9H3X6pj5TgenXYtNWdaBon1tgYCaebd+XPg0keyjEbEfkD4KkmAxkQ/i1vYvxdcT5nscLBfq9VJRmCBcFSw==", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cjs-module-lexer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", @@ -13427,6 +13574,45 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/create-ecdh": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", + "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", + "dependencies": { + "bn.js": "^4.1.0", + "elliptic": "^6.5.3" + } + }, + "node_modules/create-ecdh/node_modules/bn.js": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", + "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==" + }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "node_modules/create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dependencies": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -13481,6 +13667,31 @@ "node": ">= 8" } }, + "node_modules/crypto-browserify": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz", + "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", + "dependencies": { + "browserify-cipher": "^1.0.1", + "browserify-sign": "^4.2.3", + "create-ecdh": "^4.0.4", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "diffie-hellman": "^5.0.3", + "hash-base": "~3.0.4", + "inherits": "^2.0.4", + "pbkdf2": "^3.1.2", + "public-encrypt": "^4.0.3", + "randombytes": "^2.1.0", + "randomfill": "^1.0.4" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/css-blank-pseudo": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz", @@ -14563,6 +14774,15 @@ "node": ">=6" } }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -14640,6 +14860,21 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "dependencies": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "node_modules/diffie-hellman/node_modules/bn.js": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", + "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -14891,6 +15126,25 @@ "integrity": "sha512-dB68l59BI75W1BUGVTAEJy45CEVuEGy9qPVVQ8pnHyHMn36PLPPoE1mjLH+lo9rKulO3HC2OhbACI/8tCqJBcA==", "license": "ISC" }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/elliptic/node_modules/bn.js": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", + "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==" + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -15652,6 +15906,15 @@ "node": ">=0.8.x" } }, + "node_modules/evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "dependencies": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -17162,6 +17425,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash-base": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz", + "integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -17183,6 +17467,16 @@ "he": "bin/he" } }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "node_modules/homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -21984,6 +22278,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -22118,6 +22422,23 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "dependencies": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + }, + "bin": { + "miller-rabin": "bin/miller-rabin" + } + }, + "node_modules/miller-rabin/node_modules/bn.js": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", + "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==" + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -22240,6 +22561,11 @@ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", "license": "ISC" }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==" + }, "node_modules/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", @@ -23866,6 +24192,22 @@ "node": ">=6" } }, + "node_modules/parse-asn1": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.7.tgz", + "integrity": "sha512-CTM5kuWR3sx9IFamcl5ErfPl6ea/N8IYwiJ+vpeB2g+1iknv7zBl5uPwbMbRVznRVbrNY6lGuDoE5b30grmbqg==", + "dependencies": { + "asn1.js": "^4.10.1", + "browserify-aes": "^1.2.0", + "evp_bytestokey": "^1.0.3", + "hash-base": "~3.0", + "pbkdf2": "^3.1.2", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -24042,6 +24384,21 @@ "node": ">=8" } }, + "node_modules/pbkdf2": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz", + "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==", + "dependencies": { + "create-hash": "^1.1.2", + "create-hmac": "^1.1.4", + "ripemd160": "^2.0.1", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/peek-readable": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.3.1.tgz", @@ -25911,6 +26268,24 @@ "url": "https://github.com/sponsors/lupomontero" } }, + "node_modules/public-encrypt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", + "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", + "dependencies": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/public-encrypt/node_modules/bn.js": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", + "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==" + }, "node_modules/pump": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", @@ -26025,6 +26400,15 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "dependencies": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -26793,6 +27177,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/ripemd160": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", + "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, "node_modules/rollup": { "version": "4.26.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.26.0.tgz", @@ -27364,6 +27757,18 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "dependencies": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + }, + "bin": { + "sha.js": "bin.js" + } + }, "node_modules/shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", diff --git a/package.json b/package.json index 104f06bb0..466afe222 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@nx/angular": "20.2.0", "@swc/helpers": "0.5.12", "@tanstack/angular-query-experimental": "5.62.3", + "crypto-browserify": "^3.12.1", "rxjs": "7.8.1", "tailwindcss": "3.4.3", "tslib": "^2.3.0", From 040e19b62145101b29935759f12ee3efbfc1ae1d Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Tue, 11 Feb 2025 15:53:48 +0300 Subject: [PATCH 40/42] perf(scroll): optimize change detection for scroll events --- .../src/app/app.component.ts | 73 +++++++++++++------ 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/apps/performance/12-optimize-change-detection/src/app/app.component.ts b/apps/performance/12-optimize-change-detection/src/app/app.component.ts index dd818cad5..a9d5c1950 100644 --- a/apps/performance/12-optimize-change-detection/src/app/app.component.ts +++ b/apps/performance/12-optimize-change-detection/src/app/app.component.ts @@ -1,45 +1,70 @@ import { AsyncPipe, NgIf } from '@angular/common'; -import { Component, HostListener } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + NgZone, + OnDestroy, + OnInit, +} from '@angular/core'; import { BehaviorSubject } from 'rxjs'; @Component({ + standalone: true, imports: [NgIf, AsyncPipe], selector: 'app-root', template: ` -
Top
-
Middle
-
Bottom
- +
Top
+
Middle
+
Bottom
+ + `, styles: [ ` :host { - height: 1500px; - display: flex; - flex-direction: column; - justify-content: space-between; - - button { - position: fixed; - bottom: 1rem; - left: 1rem; - z-index: 1; - padding: 1rem; - } + min-height: 150vh; + display: block; } `, ], }) -export class AppComponent { - title = 'scroll-cd'; - +export class AppComponent implements OnInit, OnDestroy { private displayButtonSubject = new BehaviorSubject(false); displayButton$ = this.displayButtonSubject.asObservable(); - @HostListener('window:scroll', ['$event']) - onScroll() { - const pos = window.pageYOffset; - this.displayButtonSubject.next(pos > 50); + private scrollHandler: () => void; + private lastState = false; + + constructor( + private ngZone: NgZone, + private cdr: ChangeDetectorRef, + ) { + this.scrollHandler = () => { + const shouldShow = window.pageYOffset > 50; + if (shouldShow !== this.lastState) { + this.lastState = shouldShow; + this.displayButtonSubject.next(shouldShow); + this.cdr.detectChanges(); + } + }; + } + + ngOnInit() { + this.ngZone.runOutsideAngular(() => { + window.addEventListener('scroll', this.scrollHandler, { passive: true }); + }); + } + + ngOnDestroy() { + window.removeEventListener('scroll', this.scrollHandler); } goToTop() { From 138540c4f541bfc9599995265c189114c447b946 Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Tue, 11 Feb 2025 15:58:02 +0300 Subject: [PATCH 41/42] perf(change-detection): optimize with OnPush and component splitting --- .../src/app/app.component.ts | 4 +- .../src/app/name-list.component.ts | 27 +++++++++ .../src/app/person-input.component.ts | 37 ++++++++++++ .../src/app/person-list.component.ts | 56 ++++--------------- .../src/app/random.component.ts | 6 +- 5 files changed, 83 insertions(+), 47 deletions(-) create mode 100644 apps/performance/34-default-vs-onpush/src/app/name-list.component.ts create mode 100644 apps/performance/34-default-vs-onpush/src/app/person-input.component.ts diff --git a/apps/performance/34-default-vs-onpush/src/app/app.component.ts b/apps/performance/34-default-vs-onpush/src/app/app.component.ts index 88b0a6571..7af7eaaa5 100644 --- a/apps/performance/34-default-vs-onpush/src/app/app.component.ts +++ b/apps/performance/34-default-vs-onpush/src/app/app.component.ts @@ -1,9 +1,10 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { randFirstName } from '@ngneat/falso'; import { PersonListComponent } from './person-list.component'; import { RandomComponent } from './random.component'; @Component({ + standalone: true, imports: [PersonListComponent, RandomComponent], selector: 'app-root', template: ` @@ -14,6 +15,7 @@ import { RandomComponent } from './random.component'; `, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent { girlList = randFirstName({ gender: 'female', length: 10 }); diff --git a/apps/performance/34-default-vs-onpush/src/app/name-list.component.ts b/apps/performance/34-default-vs-onpush/src/app/name-list.component.ts new file mode 100644 index 000000000..e4d49b524 --- /dev/null +++ b/apps/performance/34-default-vs-onpush/src/app/name-list.component.ts @@ -0,0 +1,27 @@ +import { CDFlashingDirective } from '@angular-challenges/shared/directives'; +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { MatListModule } from '@angular/material/list'; +@Component({ + selector: 'app-name-list', + standalone: true, + imports: [MatListModule, CDFlashingDirective, CommonModule], + template: ` + +
Empty list
+ +
+

{{ name }}

+
+
+ +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NameListComponent { + @Input() names: string[] = []; +} diff --git a/apps/performance/34-default-vs-onpush/src/app/person-input.component.ts b/apps/performance/34-default-vs-onpush/src/app/person-input.component.ts new file mode 100644 index 000000000..27f2ed0b1 --- /dev/null +++ b/apps/performance/34-default-vs-onpush/src/app/person-input.component.ts @@ -0,0 +1,37 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Output, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; + +@Component({ + selector: 'app-person-input', + standalone: true, + imports: [FormsModule, MatFormFieldModule, MatInputModule], + template: ` + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PersonInputComponent { + @Output() addPerson = new EventEmitter(); + label = ''; + + handleKey(event: KeyboardEvent) { + if (event.key === 'Enter' && this.label.trim()) { + this.addPerson.emit(this.label); + this.label = ''; + } + } +} diff --git a/apps/performance/34-default-vs-onpush/src/app/person-list.component.ts b/apps/performance/34-default-vs-onpush/src/app/person-list.component.ts index 4cd92396a..c4ec4fd7b 100644 --- a/apps/performance/34-default-vs-onpush/src/app/person-list.component.ts +++ b/apps/performance/34-default-vs-onpush/src/app/person-list.component.ts @@ -1,67 +1,35 @@ -import { Component, Input } from '@angular/core'; - import { CDFlashingDirective } from '@angular-challenges/shared/directives'; import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { MatChipsModule } from '@angular/material/chips'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatListModule } from '@angular/material/list'; - +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { NameListComponent } from './name-list.component'; +import { PersonInputComponent } from './person-input.component'; @Component({ selector: 'app-person-list', + standalone: true, imports: [ - CommonModule, - FormsModule, - MatListModule, - MatFormFieldModule, - MatInputModule, - MatChipsModule, + PersonInputComponent, + NameListComponent, CDFlashingDirective, + CommonModule, ], template: `

{{ title | titlecase }}

- - - - - -
Empty list
- -
-

- {{ name }} -

-
-
- -
+ + `, host: { class: 'w-full flex flex-col items-center', }, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class PersonListComponent { @Input() names: string[] = []; @Input() title = ''; - label = ''; - - handleKey(event: KeyboardEvent) { - if (event.key === 'Enter') { - this.names?.unshift(this.label); - this.label = ''; - } + addPerson(name: string) { + this.names = [name, ...this.names]; } } diff --git a/apps/performance/34-default-vs-onpush/src/app/random.component.ts b/apps/performance/34-default-vs-onpush/src/app/random.component.ts index 71479e28d..435045ee5 100644 --- a/apps/performance/34-default-vs-onpush/src/app/random.component.ts +++ b/apps/performance/34-default-vs-onpush/src/app/random.component.ts @@ -1,11 +1,13 @@ import { CDFlashingDirective } from '@angular-challenges/shared/directives'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; @Component({ selector: 'app-random', + standalone: true, + imports: [CDFlashingDirective], template: `
I do nothing but I'm here
`, - imports: [CDFlashingDirective], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class RandomComponent {} From c9d9509633634046e4e9db8a617325f66c9bfc50 Mon Sep 17 00:00:00 2001 From: ianbalijawa Date: Tue, 11 Feb 2025 16:05:56 +0300 Subject: [PATCH 42/42] perf(memoization): optimize fibonacci calculation with pure pipe --- .../35-memoization/src/app/app.component.ts | 40 +++++++++++++------ .../35-memoization/src/app/fibonacci.pipe.ts | 19 +++++++++ .../src/app/person-list.component.ts | 38 ++++++++---------- 3 files changed, 62 insertions(+), 35 deletions(-) create mode 100644 apps/performance/35-memoization/src/app/fibonacci.pipe.ts diff --git a/apps/performance/35-memoization/src/app/app.component.ts b/apps/performance/35-memoization/src/app/app.component.ts index 04c7758fc..2d5b2bb30 100644 --- a/apps/performance/35-memoization/src/app/app.component.ts +++ b/apps/performance/35-memoization/src/app/app.component.ts @@ -1,25 +1,39 @@ import { NgIf } from '@angular/common'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { generateList } from './generateList'; import { PersonListComponent } from './person-list.component'; @Component({ - imports: [PersonListComponent, NgIf], selector: 'app-root', + standalone: true, + imports: [PersonListComponent, NgIf], template: ` -

Performance is key!!

- +
+
+
+

+ Performance is key!! +

+ +
- + +
+
`, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent { persons = generateList(); diff --git a/apps/performance/35-memoization/src/app/fibonacci.pipe.ts b/apps/performance/35-memoization/src/app/fibonacci.pipe.ts new file mode 100644 index 000000000..e2a9f4be7 --- /dev/null +++ b/apps/performance/35-memoization/src/app/fibonacci.pipe.ts @@ -0,0 +1,19 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +const fibonacci = (num: number): number => { + if (num === 1 || num === 2) { + return 1; + } + return fibonacci(num - 1) + fibonacci(num - 2); +}; + +@Pipe({ + name: 'fibonacci', + standalone: true, + pure: true, +}) +export class FibonacciPipe implements PipeTransform { + transform(value: number): number { + return fibonacci(value); + } +} diff --git a/apps/performance/35-memoization/src/app/person-list.component.ts b/apps/performance/35-memoization/src/app/person-list.component.ts index 28cbac267..c9e1b2acb 100644 --- a/apps/performance/35-memoization/src/app/person-list.component.ts +++ b/apps/performance/35-memoization/src/app/person-list.component.ts @@ -1,22 +1,16 @@ -import { Component, Input } from '@angular/core'; - import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatChipsModule } from '@angular/material/chips'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatListModule } from '@angular/material/list'; +import { FibonacciPipe } from './fibonacci.pipe'; import { Person } from './person.model'; -const fibonacci = (num: number): number => { - if (num === 1 || num === 2) { - return 1; - } - return fibonacci(num - 1) + fibonacci(num - 2); -}; - @Component({ selector: 'app-person-list', + standalone: true, imports: [ CommonModule, FormsModule, @@ -24,40 +18,40 @@ const fibonacci = (num: number): number => { MatFormFieldModule, MatInputModule, MatChipsModule, + FibonacciPipe, ], template: ` -

+

{{ title | titlecase }}

- + - - -
-

{{ person.name }}

- {{ calculate(person.fib) }} + + +
+

{{ person.name }}

+ + Fib({{ person.fib }}) = {{ person.fib | fibonacci }} +
`, host: { - class: 'w-full flex flex-col items-center', + class: 'block w-full', }, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class PersonListComponent { @Input() persons: Person[] = []; @Input() title = ''; - label = ''; - - calculate(num: number) { - return fibonacci(num); - } }
{{ product.name }} {{ product.priceA | currency | async }} {{ product.priceB | currency | async }}{{ productInfo.name }}{{ productInfo.priceA | currency | async }}{{ productInfo.priceB | currency | async }}{{ productInfo.priceC | currency | async }}{{ productInfo.priceA | currency }}{{ productInfo.priceB | currency }}{{ productInfo.priceC | currency }}