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..d76a8f4ce 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,73 @@ -import { NgOptimizedImage } from '@angular/common'; -import { Component, inject, input } from '@angular/core'; +import { 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'; @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: [NgTemplateOutlet], + 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; + @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/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/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 {} 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, 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; +} 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']; + }); + } } 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(); + } + } +} 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), +); 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' }, + ]; } 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: 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; + } } 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..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 @@ -1,33 +1,43 @@ 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'; import { fakeTextChapters, posts } from '../data'; import { PostHeaderComponent } from './post-header.component'; @Component({ selector: 'post', - imports: [ - ThumbnailHeaderComponent, - NgOptimizedImage, - PostHeaderComponent, - RouterLink, - ], + imports: [NgOptimizedImage, PostHeaderComponent, RouterLink], template: ` -
+
- -

{{ post().title }}

- + +

+ {{ post().title }} +

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

{{ chapter }}

} @@ -38,9 +48,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; +} diff --git a/apps/angular/45-react-in-angular/src/app/app.component.ts b/apps/angular/45-react-in-angular/src/app/app.component.ts index 87b9675cc..bc0c45366 100644 --- a/apps/angular/45-react-in-angular/src/app/app.component.ts +++ b/apps/angular/45-react-in-angular/src/app/app.component.ts @@ -1,61 +1,58 @@ -import { Component, signal } from '@angular/core'; -import { PostComponent } from './react/post.component'; +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { ReactWrapperDirective } from './react-wrapper.directive'; -type Post = { title: string; description: string }; +interface Post { + id: number; + title: string; + content: string; + pictureLink: string; +} @Component({ - imports: [PostComponent], selector: 'app-root', + standalone: true, + imports: [ReactWrapperDirective, CommonModule], template: ` -
-
- @for (post of posts; track post.title) { -
- -
- } -
-
- Selected Post: - - {{ selectedPost()?.title ?? '-' }} - +
+
+
`, - styles: [''], }) export class AppComponent { - readonly posts = [ + selectedId?: number; + + posts: Post[] = [ { - title: 'A Deep Dive into Angular', - description: - "Explore Angular's core features, its evolution, and best practices in development for creating dynamic, efficient web applications in our comprehensive guide.", - pictureLink: - 'https://images.unsplash.com/photo-1471958680802-1345a694ba6d', + id: 1, + title: 'First Post', + content: 'This is the first post content', + pictureLink: '../assets/bird.jpg', }, { - title: 'The Perfect Combination', - description: - 'Unveil the power of combining Angular & React in web development, maximizing efficiency and flexibility for building scalable, sophisticated applications.', - pictureLink: - 'https://images.unsplash.com/photo-1518717202715-9fa9d099f58a', + id: 2, + title: 'Second Post', + content: 'This is the second post content', + pictureLink: '../assets/bird.jpg', }, { - title: 'Taking Angular to the Next Level', - description: - "Discover how integrating React with Angular elevates web development, blending Angular's structure with React's UI prowess for advanced applications.", - pictureLink: - 'https://images.unsplash.com/photo-1532103050105-860af53bc6aa', + id: 3, + title: 'Third Post', + content: 'This is the third post content', + pictureLink: '../assets/bird.jpg', }, ]; - readonly selectedPost = signal(null); - - selectPost(post: Post) { - this.selectedPost.set(post); + selectPost(id: number) { + this.selectedId = id; } } diff --git a/apps/angular/45-react-in-angular/src/app/react-wrapper.directive.ts b/apps/angular/45-react-in-angular/src/app/react-wrapper.directive.ts new file mode 100644 index 000000000..a428aeaa4 --- /dev/null +++ b/apps/angular/45-react-in-angular/src/app/react-wrapper.directive.ts @@ -0,0 +1,42 @@ +import { + Directive, + ElementRef, + Input, + OnChanges, + OnDestroy, +} from '@angular/core'; +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; +import ReactPost from './react/ReactPost'; + +@Directive({ + selector: '[reactPost]', + standalone: true, +}) +export class ReactWrapperDirective implements OnChanges, OnDestroy { + @Input() title = ''; + @Input() content = ''; + @Input() selected = false; + @Input() pictureLink = ''; + private root = createRoot(this.el.nativeElement); + + constructor(private el: ElementRef) {} + + ngOnChanges() { + this.root.render( + React.createElement(ReactPost, { + title: this.title, + description: this.content, + selected: this.selected, + pictureLink: this.pictureLink, + handleClick: () => { + console.log('clicked'); + }, + }), + ); + } + + ngOnDestroy() { + this.root.unmount(); + } +} diff --git a/apps/angular/45-react-in-angular/src/app/react/ReactPost.tsx b/apps/angular/45-react-in-angular/src/app/react/ReactPost.tsx index 3f6b9e4cd..c5ab83beb 100644 --- a/apps/angular/45-react-in-angular/src/app/react/ReactPost.tsx +++ b/apps/angular/45-react-in-angular/src/app/react/ReactPost.tsx @@ -1,4 +1,4 @@ -// import React from 'react'; +import * as React from 'react'; export default function ReactPost(props: { title?: string; @@ -6,6 +6,7 @@ export default function ReactPost(props: { pictureLink?: string; selected?: boolean; handleClick: () => void; + }) { return (
diff --git a/apps/angular/45-react-in-angular/src/assets/bird.jpg b/apps/angular/45-react-in-angular/src/assets/bird.jpg new file mode 100644 index 000000000..392ea5eb6 Binary files /dev/null and b/apps/angular/45-react-in-angular/src/assets/bird.jpg differ diff --git a/apps/angular/45-react-in-angular/tailwind.config.js b/apps/angular/45-react-in-angular/tailwind.config.js index 38183db2c..d896cb505 100644 --- a/apps/angular/45-react-in-angular/tailwind.config.js +++ b/apps/angular/45-react-in-angular/tailwind.config.js @@ -1,11 +1,11 @@ -const { createGlobPatternsForDependencies } = require('@nx/angular/tailwind'); -const { join } = require('path'); +const { createGlobPatternsForDependencies } = require( '@nx/angular/tailwind' ); +const { join } = require( 'path' ); /** @type {import('tailwindcss').Config} */ module.exports = { content: [ - join(__dirname, 'src/**/!(*.stories|*.spec).{ts,html}'), - ...createGlobPatternsForDependencies(__dirname), + join( __dirname, 'src/**/!(*.stories|*.spec).{ts,tsx,html}' ), + ...createGlobPatternsForDependencies( __dirname ), ], theme: { extend: {}, diff --git a/apps/angular/45-react-in-angular/tsconfig.json b/apps/angular/45-react-in-angular/tsconfig.json index 25ca437b4..993512aac 100644 --- a/apps/angular/45-react-in-angular/tsconfig.json +++ b/apps/angular/45-react-in-angular/tsconfig.json @@ -1,8 +1,9 @@ { + "extends": "../../../tsconfig.base.json", "compilerOptions": { + "jsx": "react", "target": "es2022", "useDefineForClassFields": false, - "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "noImplicitOverride": true, @@ -23,7 +24,6 @@ "path": "./tsconfig.editor.json" } ], - "extends": "../../../tsconfig.base.json", "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, "strictInjectionParameters": true, diff --git a/apps/angular/46-simple-animations/src/app/app.component.ts b/apps/angular/46-simple-animations/src/app/app.component.ts index ae63db419..37211450d 100644 --- a/apps/angular/46-simple-animations/src/app/app.component.ts +++ b/apps/angular/46-simple-animations/src/app/app.component.ts @@ -1,86 +1,113 @@ +import { + animate, + query, + stagger, + style, + transition, + trigger, +} from '@angular/animations'; +import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; @Component({ - imports: [], + standalone: true, + imports: [CommonModule], selector: 'app-root', - styles: ` - section { - @apply flex flex-1 flex-col gap-5; - } - - .list-item { - @apply flex flex-row border-b px-5 pb-2; - - span { - @apply flex-1; - } - } - `, + animations: [ + trigger('fadeInParagraphs', [ + transition(':enter', [ + query('.timeline-item', [ + style({ opacity: 0, transform: 'translateX(-100px)' }), + stagger(200, [ + animate( + '600ms ease', + style({ opacity: 1, transform: 'translateX(0)' }), + ), + ]), + ]), + ]), + ]), + trigger('listAnimation', [ + transition(':enter', [ + query('.list-item', [ + style({ opacity: 0, transform: 'translateX(-20px)' }), + stagger(100, [ + animate( + '300ms ease', + style({ opacity: 1, transform: 'translateX(0)' }), + ), + ]), + ]), + ]), + ]), + ], template: ` -
-
-
-

2008

-

- Lorem ipsum dolor sit amet consectetur adipisicing elit. Vitae - mollitia sequi accusantium, distinctio similique laudantium eveniet - quidem sit placeat possimus tempore dolorum inventore corporis atque - quae ad, nobis explicabo delectus. -

-
+
+
+
+
+

2008

+

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Vitae + mollitia sequi accusantium, distinctio similique laudantium + eveniet quidem sit placeat possimus tempore dolorum inventore + corporis atque quae ad, nobis explicabo delectus. +

+
-
-

2010

-

- Lorem ipsum dolor sit amet consectetur adipisicing elit. Vitae - mollitia sequi accusantium, distinctio similique laudantium eveniet - quidem sit placeat possimus tempore dolorum inventore corporis atque - quae ad, nobis explicabo delectus. -

-
+
+

2010

+

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Vitae + mollitia sequi accusantium, distinctio similique laudantium + eveniet quidem sit placeat possimus tempore dolorum inventore + corporis atque quae ad, nobis explicabo delectus. +

+
-
-

2012

-

- Lorem ipsum dolor sit amet consectetur adipisicing elit. Vitae - mollitia sequi accusantium, distinctio similique laudantium eveniet - quidem sit placeat possimus tempore dolorum inventore corporis atque - quae ad, nobis explicabo delectus. -

-
-
+
+

2012

+

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Vitae + mollitia sequi accusantium, distinctio similique laudantium + eveniet quidem sit placeat possimus tempore dolorum inventore + corporis atque quae ad, nobis explicabo delectus. +

+
+
-
-
- Name: - Samuel -
+
+
+ Name: + Samuel +
-
- Age: - 28 -
+
+ Age: + 28 +
-
- Birthdate: - 02.11.1995 -
+
+ Birthdate: + 02.11.1995 +
-
- City: - Berlin -
+
+ City: + Berlin +
-
- Language: - English -
+
+ Language: + English +
-
- Like Pizza: - Hell yeah -
-
+
+ Like Pizza: + Hell yeah +
+
+
`, }) diff --git a/apps/angular/46-simple-animations/src/app/app.config.ts b/apps/angular/46-simple-animations/src/app/app.config.ts index 81a6edde4..59198e627 100644 --- a/apps/angular/46-simple-animations/src/app/app.config.ts +++ b/apps/angular/46-simple-animations/src/app/app.config.ts @@ -1,5 +1,6 @@ import { ApplicationConfig } from '@angular/core'; +import { provideAnimations } from '@angular/platform-browser/animations'; export const appConfig: ApplicationConfig = { - providers: [], + providers: [provideAnimations()], }; diff --git a/apps/angular/46-simple-animations/src/styles.scss b/apps/angular/46-simple-animations/src/styles.scss index 77e408aa8..86e55892e 100644 --- a/apps/angular/46-simple-animations/src/styles.scss +++ b/apps/angular/46-simple-animations/src/styles.scss @@ -3,3 +3,44 @@ @tailwind utilities; /* You can add global styles to this file, and also import other style files */ + +section { + @apply flex flex-1 flex-col gap-5; +} + +.list-item { + @apply flex flex-row border-b px-5 pb-2; + + span { + @apply flex-1; + } +} + +.timeline-item { + @apply mb-8; + + h3, + h4 { + @apply mb-2 text-xl font-bold; + } + + p { + @apply leading-relaxed text-gray-600; + } +} + +.container-wrapper { + @apply p-4 md:mx-20 md:my-40; +} + +.content-container { + @apply flex flex-col gap-8 md:flex-row md:gap-12; +} + +.timeline-section { + @apply w-full md:w-2/3; +} + +.info-section { + @apply w-full md:w-1/3; +} 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); + }), + ); + } +} diff --git a/apps/angular/52-lazy-load-component/src/app/app.component.ts b/apps/angular/52-lazy-load-component/src/app/app.component.ts index 6d8c03d29..5ffffd1a0 100644 --- a/apps/angular/52-lazy-load-component/src/app/app.component.ts +++ b/apps/angular/52-lazy-load-component/src/app/app.component.ts @@ -1,23 +1,23 @@ -import { Component, signal } from '@angular/core'; - +import { Component } from '@angular/core'; +import { PlaceholderComponent } from './placeholder.component'; +import { TopComponent } from './top.component'; @Component({ selector: 'app-root', template: `
- @if (topLoaded()) { + @defer (on interaction(loadBtn)) { - } @else { + } @placeholder { }
`, - 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)); 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); 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'])], }, ]; 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}`; + } +} 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); + } +} 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 */ 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); } 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, }) 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/apps/typescript/15-function-overload/README.md b/apps/typescript/15-function-overload/README.md index 96ce65e9d..06429e116 100644 --- a/apps/typescript/15-function-overload/README.md +++ b/apps/typescript/15-function-overload/README.md @@ -5,7 +5,7 @@ ### Run Application ```bash -npx nx serve typescript-function-overload` +npx nx serve typescript-function-overload ``` ### Documentation and Instruction diff --git a/apps/typescript/15-function-overload/src/app/app.component.ts b/apps/typescript/15-function-overload/src/app/app.component.ts index 3ea2a7131..2432e35e4 100644 --- a/apps/typescript/15-function-overload/src/app/app.component.ts +++ b/apps/typescript/15-function-overload/src/app/app.component.ts @@ -4,12 +4,73 @@ import { createVehicle } from './vehicle.utils'; @Component({ standalone: true, selector: 'app-root', - template: ``, + template: ` +
+

Vehicle Types

+ +
+
+

Car

+

Type: {{ car.type }}

+

Fuel: {{ car.fuel }}

+
+ +
+

Motorcycle

+

Type: {{ moto.type }}

+

Fuel: {{ moto.fuel }}

+
+ +
+

Bus

+

Type: {{ bus.type }}

+

Capacity: {{ bus.capacity }}

+

Public Transport: {{ bus.isPublicTransport ? 'Yes' : 'No' }}

+
+ +
+

Boat

+

Type: {{ boat.type }}

+

Capacity: {{ boat.capacity }}

+
+ +
+

Bicycle

+

Type: {{ bicycle.type }}

+
+
+
+ `, + styles: ` + .container { + @apply mx-auto max-w-4xl p-8; + } + + h1 { + @apply mb-8 text-center text-3xl font-bold text-gray-800; + } + + .vehicle-grid { + @apply grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3; + } + + .vehicle-card { + @apply rounded-lg border border-gray-200 bg-white p-6 shadow-md; + + h2 { + @apply mb-4 text-xl font-semibold text-indigo-600; + } + + p { + @apply mb-2 text-gray-600; + } + } + `, }) export class AppComponent { car = createVehicle('car', 'diesel'); moto = createVehicle('moto', 'diesel'); - bus = createVehicle('bus', undefined, 20); - boat = createVehicle('boat', undefined, 300, true); + bus = createVehicle('bus', undefined, 20, false); + boat = createVehicle('boat', undefined, 300); bicycle = createVehicle('bicycle'); } diff --git a/apps/typescript/15-function-overload/src/app/vehicle.utils.ts b/apps/typescript/15-function-overload/src/app/vehicle.utils.ts index bec95c08d..a33b0967f 100644 --- a/apps/typescript/15-function-overload/src/app/vehicle.utils.ts +++ b/apps/typescript/15-function-overload/src/app/vehicle.utils.ts @@ -28,6 +28,21 @@ interface Boat { type Vehicle = Bicycle | Car | Moto | Bus | Boat; +export function createVehicle(type: 'bicycle'): Bicycle; +export function createVehicle(type: 'car', fuel: Fuel): Car; +export function createVehicle(type: 'moto', fuel: Fuel): Moto; +export function createVehicle( + type: 'bus', + fuel: undefined, + capacity: number, + isPublicTransport: boolean, +): Bus; +export function createVehicle( + type: 'boat', + fuel: undefined, + capacity: number, +): Boat; + export function createVehicle( type: VehicleType, fuel?: Fuel, @@ -48,7 +63,7 @@ export function createVehicle( case 'bus': if (!capacity) throw new Error(`capacity property is missing for type bus`); - if (!isPublicTransport) + if (isPublicTransport === undefined) throw new Error(`isPublicTransport property is missing for type bus`); return { capacity, isPublicTransport, type }; } diff --git a/apps/typescript/15-function-overload/src/styles.scss b/apps/typescript/15-function-overload/src/styles.scss index 90d4ee007..d8a5173ec 100644 --- a/apps/typescript/15-function-overload/src/styles.scss +++ b/apps/typescript/15-function-overload/src/styles.scss @@ -1 +1,44 @@ /* You can add global styles to this file, and also import other style files */ + +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply min-h-screen bg-gradient-to-br from-blue-50 to-indigo-50 p-4; + font-family: system-ui, -apple-system, sans-serif; +} + +.container { + @apply relative; + + &::before { + content: ''; + @apply absolute inset-0 -z-10 rounded-3xl bg-white/40 backdrop-blur-xl; + } +} + +.vehicle-card { + @apply transition-all duration-300; + + &:hover { + @apply -translate-y-1 shadow-lg; + + h2 { + @apply text-indigo-700; + } + } + + p { + @apply flex items-center gap-2; + + &::before { + content: '•'; + @apply text-indigo-400; + } + } +} + +h1 { + @apply bg-gradient-to-r from-indigo-600 to-blue-500 bg-clip-text text-transparent; +} diff --git a/apps/typescript/47-enums-vs-union-types/src/app/app.component.ts b/apps/typescript/47-enums-vs-union-types/src/app/app.component.ts index 05886724f..f44127b58 100644 --- a/apps/typescript/47-enums-vs-union-types/src/app/app.component.ts +++ b/apps/typescript/47-enums-vs-union-types/src/app/app.component.ts @@ -1,82 +1,65 @@ import { Component, computed, signal } from '@angular/core'; -enum Difficulty { - EASY = 'easy', - NORMAL = 'normal', -} +// Union type for Difficulty +type Difficulty = 'easy' | 'normal'; -enum Direction { - LEFT = 'left', - RIGHT = 'right', -} +// Mapped type for Direction +type Direction = { [K in 'left' | 'right']: K }; +const DIRECTION: Direction = { + left: 'left', + right: 'right', +} as const; @Component({ - imports: [], + standalone: true, selector: 'app-root', template: ` -
-
- - -
-

Selected Difficulty: {{ difficultyLabel() }}

-
+
+
+
+ + +
+

Selected Difficulty: {{ difficultyLabel() }}

+
-
-
- - -
-

{{ directionLabel() }}

-
- `, - styles: ` - section { - @apply mx-auto my-5 flex w-fit flex-col items-center gap-2; - - > div { - @apply flex w-fit gap-5; - } - } - - button { - @apply rounded-md border px-4 py-2; - } +
+
+ + +
+

{{ directionLabel() }}

+
+
`, }) export class AppComponent { - readonly Difficulty = Difficulty; - readonly difficulty = signal(Difficulty.EASY); - - readonly Direction = Direction; - readonly direction = signal(undefined); + readonly DIRECTION = DIRECTION; + readonly difficulty = signal('easy'); + readonly direction = signal( + undefined, + ); readonly difficultyLabel = computed(() => { - switch (this.difficulty()) { - case Difficulty.EASY: - return Difficulty.EASY; - case Difficulty.NORMAL: - return Difficulty.NORMAL; - } + return this.difficulty(); }); readonly directionLabel = computed(() => { const prefix = 'You chose to go'; - switch (this.direction()) { - case Direction.LEFT: - return `${prefix} ${Direction.LEFT}`; - case Direction.RIGHT: - return `${prefix} ${Direction.RIGHT}`; - default: - return 'Choose a direction!'; + const currentDirection = this.direction(); + + if (!currentDirection) { + return 'Choose a direction!'; } + + return `${prefix} ${currentDirection}`; }); } diff --git a/apps/typescript/47-enums-vs-union-types/src/styles.scss b/apps/typescript/47-enums-vs-union-types/src/styles.scss index 77e408aa8..c110e9da0 100644 --- a/apps/typescript/47-enums-vs-union-types/src/styles.scss +++ b/apps/typescript/47-enums-vs-union-types/src/styles.scss @@ -2,4 +2,51 @@ @tailwind components; @tailwind utilities; +body { + @apply min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 p-8; + font-family: + system-ui, + -apple-system, + sans-serif; +} + +button { + @apply transform transition-all duration-200 hover:scale-105 active:scale-95; + + &[mat-stroked-button] { + @apply border-2 border-indigo-500 bg-white px-6 py-2 font-medium text-indigo-600 shadow-sm + hover:bg-indigo-50 hover:shadow-md + active:bg-indigo-100; + } +} + +section { + @apply rounded-xl bg-white p-8 shadow-lg; + + > div { + @apply mb-4; + } + + p { + @apply text-center font-medium text-gray-700; + } +} + +.container { + @apply mx-auto max-w-2xl space-y-6 py-8; +} + +/* Wrap the template content */ +app-root { + @apply block; + + > section { + @apply mb-8 last:mb-0; + + &:hover { + @apply shadow-xl; + } + } +} + /* You can add global styles to this file, and also import other style files */ 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(), ); }); } 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 3b3a587ce..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", @@ -73,12 +74,14 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.13", "@types/node": "18.16.9", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", "@typescript-eslint/eslint-plugin": "7.16.1", "@typescript-eslint/parser": "7.16.1", "@typescript-eslint/utils": "^7.16.0", "all-contributors-cli": "^6.26.1", "autoprefixer": "^10.4.0", - "cypress": "^14.0.0", + "cypress": "^14.0.2", "eslint": "8.57.0", "eslint-config-prettier": "9.0.0", "eslint-plugin-cypress": "2.15.1", @@ -9859,6 +9862,24 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "license": "MIT" }, + "node_modules/@types/react": { + "version": "19.0.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz", + "integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==", + "dev": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.3.tgz", + "integrity": "sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, "node_modules/@types/retry": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", @@ -11547,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", @@ -12095,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", @@ -12183,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", @@ -12290,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", @@ -12730,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", @@ -13407,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", @@ -13461,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", @@ -13813,6 +14044,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true + }, "node_modules/cuint": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz", @@ -13821,12 +14058,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": "14.0.2", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-14.0.2.tgz", + "integrity": "sha512-3qqTU2JoVY262qkYg9I2nohwxcfsJk0dSVp/LXAjD94Jz2y6411Mf/l5uHEHiaANrOmMcHbzYgOd/ueDsZlS7A==", "dev": true, "hasInstallScript": true, - "license": "MIT", "dependencies": { "@cypress/request": "^3.0.6", "@cypress/xvfb": "^1.2.4", @@ -14538,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", @@ -14615,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", @@ -14866,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", @@ -15627,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", @@ -17137,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", @@ -17158,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", @@ -21959,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", @@ -22093,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", @@ -22215,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", @@ -23841,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", @@ -24017,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", @@ -25886,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", @@ -26000,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", @@ -26768,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", @@ -27339,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 63dbb87e5..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", @@ -76,12 +77,14 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.13", "@types/node": "18.16.9", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", "@typescript-eslint/eslint-plugin": "7.16.1", "@typescript-eslint/parser": "7.16.1", "@typescript-eslint/utils": "^7.16.0", "all-contributors-cli": "^6.26.1", "autoprefixer": "^10.4.0", - "cypress": "14.0.0", + "cypress": "^14.0.2", "eslint": "8.57.0", "eslint-config-prettier": "9.0.0", "eslint-plugin-cypress": "2.15.1",
{{ 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 }}