From 2c8eb30b6a20bf53aa069f1de25383168a384851 Mon Sep 17 00:00:00 2001 From: Rob Simpson Date: Fri, 18 Jul 2025 09:13:09 -0400 Subject: [PATCH] feat(projection): challenge 1: projection --- apps/angular/1-projection/README.md | 28 ++++++++ .../city-card/city-card.component.ts | 71 +++++++++++++++++-- .../student-card/student-card.component.ts | 46 ++++++++++-- .../teacher-card/teacher-card.component.ts | 46 ++++++++++-- .../src/app/data-access/city.store.ts | 11 +-- .../src/app/ui/card/card-image.directive.ts | 8 +++ .../app/ui/card/card-list-item.directive.ts | 8 +++ .../src/app/ui/card/card.component.ts | 54 ++++++-------- .../app/ui/list-item/list-item.component.ts | 21 ++---- 9 files changed, 222 insertions(+), 71 deletions(-) create mode 100644 apps/angular/1-projection/src/app/ui/card/card-image.directive.ts create mode 100644 apps/angular/1-projection/src/app/ui/card/card-list-item.directive.ts diff --git a/apps/angular/1-projection/README.md b/apps/angular/1-projection/README.md index 781198ead..208a857b6 100644 --- a/apps/angular/1-projection/README.md +++ b/apps/angular/1-projection/README.md @@ -11,3 +11,31 @@ npx nx serve angular-projection ### Documentation and Instruction Challenge documentation is [here](https://angular-challenges.vercel.app/challenges/angular/1-projection/). + +## Information + +In Angular, content projection is a powerful technique for creating highly customizable components. Utilizing and understanding the concepts of ng-content and ngTemplateOutlet can significantly enhance your ability to create shareable components. + +You can learn all about ng-content [here](https://angular.dev/guide/components/content-projection) from simple projection to more complex ones. + +To learn about ngTemplateOutlet, you can find the API documentation [here](https://angular.dev/api/common/NgTemplateOutlet) along with some basic examples. + +With these two tools in hand, you are now ready to take on the challenge. + +## Statement + +You will start with a fully functional application that includes a dashboard containing a teacher card and a student card. The goal is to implement the city card. + +While the application works, the developer experience is far from being optimal. Every time you need to implement a new card, you have to modify the `card.component.ts`. In real-life projects, this component can be shared among many applications. The goal of the challenge is to create a `CardComponent` that can be customized without any modifications. Once you've created this component, you can begin implementing the `CityCardComponent` and ensure you are not touching the `CardComponent`. + +## Constraints + +- You must refactor the `CardComponent` and `ListItemComponent`. +- The `@for` must be declared and remain inside the `CardComponent`. You might be tempted to move it to the `ParentCardComponent` like `TeacherCardComponent`. +- `CardComponent` should not contain any conditions. +- CSS: try to avoid using `::ng-deep`. Find a better way to handle CSS styling. + +## Bonus Challenges + +- Use the signal API to manage your components state (documentation [here](https://angular.dev/guide/signals)) +- To reference the template, use a directive instead of magic strings ([What is wrong with magic strings?](https://softwareengineering.stackexchange.com/a/365344)) 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..bcbed2a9a 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,72 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { NgOptimizedImage } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + inject, + OnInit, +} from '@angular/core'; +import { CityStore } from '../../data-access/city.store'; +import { + FakeHttpService, + randomCity, +} from '../../data-access/fake-http.service'; +import { CardImageDirective } from '../../ui/card/card-image.directive'; +import { CardListItemDirective } from '../../ui/card/card-list-item.directive'; +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: [], + template: ` + + + + + + + + + + + + `, + styles: [ + ` + .bg-light-blue { + background-color: rgba(0, 0, 250, 0.1); + } + `, + ], + imports: [ + CardComponent, + CardImageDirective, + CardListItemDirective, + ListItemComponent, + NgOptimizedImage, + ], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CityCardComponent {} +export class CityCardComponent implements OnInit { + private http = inject(FakeHttpService); + private store = inject(CityStore); + + cities = this.store.cities; + + ngOnInit(): void { + this.http.fetchCities$.subscribe((c) => this.store.addAll(c)); + } + + addCity(): void { + this.store.addOne(randomCity()); + } + + deleteCity(id: number): void { + 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..df0cd15f9 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,30 +1,55 @@ +import { NgOptimizedImage } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, OnInit, } from '@angular/core'; -import { FakeHttpService } from '../../data-access/fake-http.service'; +import { + FakeHttpService, + randStudent, +} from '../../data-access/fake-http.service'; import { StudentStore } from '../../data-access/student.store'; -import { CardType } from '../../model/card.model'; +import { CardImageDirective } from '../../ui/card/card-image.directive'; +import { CardListItemDirective } from '../../ui/card/card-list-item.directive'; import { CardComponent } from '../../ui/card/card.component'; +import { ListItemComponent } from '../../ui/list-item/list-item.component'; @Component({ selector: 'app-student-card', template: ` + customClass="bg-light-green" + (addItem)="addStudent()"> + + + + + + + + + + `, styles: [ ` - ::ng-deep .bg-light-green { + .bg-light-green { background-color: rgba(0, 250, 0, 0.1); } `, ], - imports: [CardComponent], + imports: [ + CardComponent, + CardImageDirective, + CardListItemDirective, + ListItemComponent, + NgOptimizedImage, + ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class StudentCardComponent implements OnInit { @@ -32,9 +57,16 @@ export class StudentCardComponent implements OnInit { private store = inject(StudentStore); students = this.store.students; - cardType = CardType.STUDENT; ngOnInit(): void { this.http.fetchStudents$.subscribe((s) => this.store.addAll(s)); } + + addStudent(): void { + this.store.addOne(randStudent()); + } + + deleteStudent(id: number): void { + 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..f19e7574c 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,34 +1,66 @@ +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 { CardImageDirective } from '../../ui/card/card-image.directive'; +import { CardListItemDirective } from '../../ui/card/card-list-item.directive'; import { CardComponent } from '../../ui/card/card.component'; +import { ListItemComponent } from '../../ui/list-item/list-item.component'; @Component({ selector: 'app-teacher-card', template: ` + customClass="bg-light-red" + (addItem)="addTeacher()"> + + + + + + + + + + `, styles: [ ` - ::ng-deep .bg-light-red { + .bg-light-red { background-color: rgba(250, 0, 0, 0.1); } `, ], - imports: [CardComponent], + imports: [ + CardComponent, + CardImageDirective, + CardListItemDirective, + ListItemComponent, + NgOptimizedImage, + ], }) export class TeacherCardComponent implements OnInit { private http = inject(FakeHttpService); private store = inject(TeacherStore); teachers = this.store.teachers; - cardType = CardType.TEACHER; ngOnInit(): void { this.http.fetchTeachers$.subscribe((t) => this.store.addAll(t)); } + + addTeacher(): void { + this.store.addOne(randTeacher()); + } + + deleteTeacher(id: number): void { + 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 a8b523569..83580ae7b 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,17 +5,20 @@ import { City } from '../model/city.model'; providedIn: 'root', }) export class CityStore { - private cities = signal([]); + private _cities = signal([]); + + // Expose read-only signal + readonly cities = this._cities.asReadonly(); addAll(cities: City[]) { - this.cities.set(cities); + this._cities.set(cities); } addOne(city: City) { - this.cities.set([...this.cities(), city]); + this._cities.set([...this._cities(), city]); } deleteOne(id: number) { - this.cities.set(this.cities().filter((s) => s.id !== id)); + this._cities.set(this._cities().filter((s) => s.id !== id)); } } diff --git a/apps/angular/1-projection/src/app/ui/card/card-image.directive.ts b/apps/angular/1-projection/src/app/ui/card/card-image.directive.ts new file mode 100644 index 000000000..991641442 --- /dev/null +++ b/apps/angular/1-projection/src/app/ui/card/card-image.directive.ts @@ -0,0 +1,8 @@ +import { Directive, TemplateRef, inject } from '@angular/core'; + +@Directive({ + selector: '[cardImage]', +}) +export class CardImageDirective { + public templateRef = inject(TemplateRef); +} diff --git a/apps/angular/1-projection/src/app/ui/card/card-list-item.directive.ts b/apps/angular/1-projection/src/app/ui/card/card-list-item.directive.ts new file mode 100644 index 000000000..ef1438d1e --- /dev/null +++ b/apps/angular/1-projection/src/app/ui/card/card-list-item.directive.ts @@ -0,0 +1,8 @@ +import { Directive, TemplateRef, inject } from '@angular/core'; + +@Directive({ + selector: '[cardListItem]', +}) +export class CardListItemDirective { + public templateRef = inject(TemplateRef); +} 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..568921ba3 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,10 +1,7 @@ -import { NgOptimizedImage } from '@angular/common'; -import { Component, inject, input } from '@angular/core'; -import { randStudent, randTeacher } from '../../data-access/fake-http.service'; -import { StudentStore } from '../../data-access/student.store'; -import { TeacherStore } from '../../data-access/teacher.store'; -import { CardType } from '../../model/card.model'; -import { ListItemComponent } from '../list-item/list-item.component'; +import { NgTemplateOutlet } from '@angular/common'; +import { Component, contentChild, input, output } from '@angular/core'; +import { CardImageDirective } from './card-image.directive'; +import { CardListItemDirective } from './card-list-item.directive'; @Component({ selector: 'app-card', @@ -12,47 +9,40 @@ import { ListItemComponent } from '../list-item/list-item.component';
- @if (type() === CardType.TEACHER) { - - } - @if (type() === CardType.STUDENT) { - + + @if (imageTemplate()) { + } +
@for (item of list(); track item) { - + @if (listItemTemplate()) { + + } }
+
`, - imports: [ListItemComponent, NgOptimizedImage], + imports: [NgTemplateOutlet], }) export class CardComponent { - private teacherStore = inject(TeacherStore); - private studentStore = inject(StudentStore); - - readonly list = input(null); - readonly type = input.required(); + readonly list = input([]); readonly customClass = input(''); - CardType = CardType; + // Output for add action - parent components will handle their specific logic + readonly addItem = output(); - addNewItem() { - const type = this.type(); - if (type === CardType.TEACHER) { - this.teacherStore.addOne(randTeacher()); - } else if (type === CardType.STUDENT) { - this.studentStore.addOne(randStudent()); - } - } + // Content children for projected templates + readonly imageTemplate = contentChild(CardImageDirective); + readonly listItemTemplate = contentChild(CardListItemDirective); } 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 5d504f372..c8b2f2c41 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,19 +1,16 @@ import { ChangeDetectionStrategy, Component, - inject, 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() }} -
@@ -21,19 +18,9 @@ import { CardType } from '../../model/card.model'; 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 for delete action - parent components will handle their specific logic + readonly deleteItem = output(); }