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: ` -
{{ product.name }} {{ product.priceA | currency | async }} {{ product.priceB | currency | async }}