Skip to content

Commit bdd9c40

Browse files
authored
Merge pull request #1 from pertrai1/challenge-1-projection
Challenge 1: Projection
2 parents e955435 + 2c8eb30 commit bdd9c40

File tree

9 files changed

+222
-71
lines changed

9 files changed

+222
-71
lines changed

apps/angular/1-projection/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,31 @@ npx nx serve angular-projection
1111
### Documentation and Instruction
1212

1313
Challenge documentation is [here](https://angular-challenges.vercel.app/challenges/angular/1-projection/).
14+
15+
## Information
16+
17+
In Angular, content projection is a powerful technique for creating highly customizable components. Utilizing and understanding the concepts of <b>ng-content</b> and <b>ngTemplateOutlet</b> can significantly enhance your ability to create shareable components.
18+
19+
You can learn all about <b>ng-content</b> [here](https://angular.dev/guide/components/content-projection) from simple projection to more complex ones.
20+
21+
To learn about <b>ngTemplateOutlet</b>, you can find the API documentation [here](https://angular.dev/api/common/NgTemplateOutlet) along with some basic examples.
22+
23+
With these two tools in hand, you are now ready to take on the challenge.
24+
25+
## Statement
26+
27+
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.
28+
29+
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`.
30+
31+
## Constraints
32+
33+
- You <b>must</b> refactor the `CardComponent` and `ListItemComponent`.
34+
- The `@for` must be declared and remain inside the `CardComponent`. You might be tempted to move it to the `ParentCardComponent` like `TeacherCardComponent`.
35+
- `CardComponent` should not contain any conditions.
36+
- CSS: try to avoid using `::ng-deep`. Find a better way to handle CSS styling.
37+
38+
## Bonus Challenges
39+
40+
- Use the signal API to manage your components state (documentation [here](https://angular.dev/guide/signals))
41+
- To reference the template, use a directive instead of magic strings ([What is wrong with magic strings?](https://softwareengineering.stackexchange.com/a/365344))
Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,72 @@
1-
import { ChangeDetectionStrategy, Component } from '@angular/core';
1+
import { NgOptimizedImage } from '@angular/common';
2+
import {
3+
ChangeDetectionStrategy,
4+
Component,
5+
inject,
6+
OnInit,
7+
} from '@angular/core';
8+
import { CityStore } from '../../data-access/city.store';
9+
import {
10+
FakeHttpService,
11+
randomCity,
12+
} from '../../data-access/fake-http.service';
13+
import { CardImageDirective } from '../../ui/card/card-image.directive';
14+
import { CardListItemDirective } from '../../ui/card/card-list-item.directive';
15+
import { CardComponent } from '../../ui/card/card.component';
16+
import { ListItemComponent } from '../../ui/list-item/list-item.component';
217

318
@Component({
419
selector: 'app-city-card',
5-
template: 'TODO City',
6-
imports: [],
20+
template: `
21+
<app-card
22+
[list]="cities()"
23+
customClass="bg-light-blue"
24+
(addItem)="addCity()">
25+
<!-- Projected image template -->
26+
<ng-template cardImage>
27+
<img ngSrc="assets/img/city.png" width="200" height="200" />
28+
</ng-template>
29+
30+
<!-- Projected list item template -->
31+
<ng-template cardListItem let-city>
32+
<app-list-item
33+
[name]="city.name + ' (' + city.country + ')'"
34+
[id]="city.id"
35+
(deleteItem)="deleteCity($event)" />
36+
</ng-template>
37+
</app-card>
38+
`,
39+
styles: [
40+
`
41+
.bg-light-blue {
42+
background-color: rgba(0, 0, 250, 0.1);
43+
}
44+
`,
45+
],
46+
imports: [
47+
CardComponent,
48+
CardImageDirective,
49+
CardListItemDirective,
50+
ListItemComponent,
51+
NgOptimizedImage,
52+
],
753
changeDetection: ChangeDetectionStrategy.OnPush,
854
})
9-
export class CityCardComponent {}
55+
export class CityCardComponent implements OnInit {
56+
private http = inject(FakeHttpService);
57+
private store = inject(CityStore);
58+
59+
cities = this.store.cities;
60+
61+
ngOnInit(): void {
62+
this.http.fetchCities$.subscribe((c) => this.store.addAll(c));
63+
}
64+
65+
addCity(): void {
66+
this.store.addOne(randomCity());
67+
}
68+
69+
deleteCity(id: number): void {
70+
this.store.deleteOne(id);
71+
}
72+
}
Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,72 @@
1+
import { NgOptimizedImage } from '@angular/common';
12
import {
23
ChangeDetectionStrategy,
34
Component,
45
inject,
56
OnInit,
67
} from '@angular/core';
7-
import { FakeHttpService } from '../../data-access/fake-http.service';
8+
import {
9+
FakeHttpService,
10+
randStudent,
11+
} from '../../data-access/fake-http.service';
812
import { StudentStore } from '../../data-access/student.store';
9-
import { CardType } from '../../model/card.model';
13+
import { CardImageDirective } from '../../ui/card/card-image.directive';
14+
import { CardListItemDirective } from '../../ui/card/card-list-item.directive';
1015
import { CardComponent } from '../../ui/card/card.component';
16+
import { ListItemComponent } from '../../ui/list-item/list-item.component';
1117

1218
@Component({
1319
selector: 'app-student-card',
1420
template: `
1521
<app-card
1622
[list]="students()"
17-
[type]="cardType"
18-
customClass="bg-light-green" />
23+
customClass="bg-light-green"
24+
(addItem)="addStudent()">
25+
<!-- Projected image template -->
26+
<ng-template cardImage>
27+
<img ngSrc="assets/img/student.webp" width="200" height="200" />
28+
</ng-template>
29+
30+
<!-- Projected list item template -->
31+
<ng-template cardListItem let-student>
32+
<app-list-item
33+
[name]="student.firstName"
34+
[id]="student.id"
35+
(deleteItem)="deleteStudent($event)" />
36+
</ng-template>
37+
</app-card>
1938
`,
2039
styles: [
2140
`
22-
::ng-deep .bg-light-green {
41+
.bg-light-green {
2342
background-color: rgba(0, 250, 0, 0.1);
2443
}
2544
`,
2645
],
27-
imports: [CardComponent],
46+
imports: [
47+
CardComponent,
48+
CardImageDirective,
49+
CardListItemDirective,
50+
ListItemComponent,
51+
NgOptimizedImage,
52+
],
2853
changeDetection: ChangeDetectionStrategy.OnPush,
2954
})
3055
export class StudentCardComponent implements OnInit {
3156
private http = inject(FakeHttpService);
3257
private store = inject(StudentStore);
3358

3459
students = this.store.students;
35-
cardType = CardType.STUDENT;
3660

3761
ngOnInit(): void {
3862
this.http.fetchStudents$.subscribe((s) => this.store.addAll(s));
3963
}
64+
65+
addStudent(): void {
66+
this.store.addOne(randStudent());
67+
}
68+
69+
deleteStudent(id: number): void {
70+
this.store.deleteOne(id);
71+
}
4072
}
Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,66 @@
1+
import { NgOptimizedImage } from '@angular/common';
12
import { Component, inject, OnInit } from '@angular/core';
2-
import { FakeHttpService } from '../../data-access/fake-http.service';
3+
import {
4+
FakeHttpService,
5+
randTeacher,
6+
} from '../../data-access/fake-http.service';
37
import { TeacherStore } from '../../data-access/teacher.store';
4-
import { CardType } from '../../model/card.model';
8+
import { CardImageDirective } from '../../ui/card/card-image.directive';
9+
import { CardListItemDirective } from '../../ui/card/card-list-item.directive';
510
import { CardComponent } from '../../ui/card/card.component';
11+
import { ListItemComponent } from '../../ui/list-item/list-item.component';
612

713
@Component({
814
selector: 'app-teacher-card',
915
template: `
1016
<app-card
1117
[list]="teachers()"
12-
[type]="cardType"
13-
customClass="bg-light-red"></app-card>
18+
customClass="bg-light-red"
19+
(addItem)="addTeacher()">
20+
<!-- Projected image template -->
21+
<ng-template cardImage>
22+
<img ngSrc="assets/img/teacher.png" width="200" height="200" />
23+
</ng-template>
24+
25+
<!-- Projected list item template -->
26+
<ng-template cardListItem let-teacher>
27+
<app-list-item
28+
[name]="teacher.firstName"
29+
[id]="teacher.id"
30+
(deleteItem)="deleteTeacher($event)" />
31+
</ng-template>
32+
</app-card>
1433
`,
1534
styles: [
1635
`
17-
::ng-deep .bg-light-red {
36+
.bg-light-red {
1837
background-color: rgba(250, 0, 0, 0.1);
1938
}
2039
`,
2140
],
22-
imports: [CardComponent],
41+
imports: [
42+
CardComponent,
43+
CardImageDirective,
44+
CardListItemDirective,
45+
ListItemComponent,
46+
NgOptimizedImage,
47+
],
2348
})
2449
export class TeacherCardComponent implements OnInit {
2550
private http = inject(FakeHttpService);
2651
private store = inject(TeacherStore);
2752

2853
teachers = this.store.teachers;
29-
cardType = CardType.TEACHER;
3054

3155
ngOnInit(): void {
3256
this.http.fetchTeachers$.subscribe((t) => this.store.addAll(t));
3357
}
58+
59+
addTeacher(): void {
60+
this.store.addOne(randTeacher());
61+
}
62+
63+
deleteTeacher(id: number): void {
64+
this.store.deleteOne(id);
65+
}
3466
}

apps/angular/1-projection/src/app/data-access/city.store.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,20 @@ import { City } from '../model/city.model';
55
providedIn: 'root',
66
})
77
export class CityStore {
8-
private cities = signal<City[]>([]);
8+
private _cities = signal<City[]>([]);
9+
10+
// Expose read-only signal
11+
readonly cities = this._cities.asReadonly();
912

1013
addAll(cities: City[]) {
11-
this.cities.set(cities);
14+
this._cities.set(cities);
1215
}
1316

1417
addOne(city: City) {
15-
this.cities.set([...this.cities(), city]);
18+
this._cities.set([...this._cities(), city]);
1619
}
1720

1821
deleteOne(id: number) {
19-
this.cities.set(this.cities().filter((s) => s.id !== id));
22+
this._cities.set(this._cities().filter((s) => s.id !== id));
2023
}
2124
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Directive, TemplateRef, inject } from '@angular/core';
2+
3+
@Directive({
4+
selector: '[cardImage]',
5+
})
6+
export class CardImageDirective {
7+
public templateRef = inject(TemplateRef<unknown>);
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Directive, TemplateRef, inject } from '@angular/core';
2+
3+
@Directive({
4+
selector: '[cardListItem]',
5+
})
6+
export class CardListItemDirective {
7+
public templateRef = inject(TemplateRef<unknown>);
8+
}
Lines changed: 22 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,48 @@
1-
import { NgOptimizedImage } from '@angular/common';
2-
import { Component, inject, input } from '@angular/core';
3-
import { randStudent, randTeacher } from '../../data-access/fake-http.service';
4-
import { StudentStore } from '../../data-access/student.store';
5-
import { TeacherStore } from '../../data-access/teacher.store';
6-
import { CardType } from '../../model/card.model';
7-
import { ListItemComponent } from '../list-item/list-item.component';
1+
import { NgTemplateOutlet } from '@angular/common';
2+
import { Component, contentChild, input, output } from '@angular/core';
3+
import { CardImageDirective } from './card-image.directive';
4+
import { CardListItemDirective } from './card-list-item.directive';
85

96
@Component({
107
selector: 'app-card',
118
template: `
129
<div
1310
class="flex w-fit flex-col gap-3 rounded-md border-2 border-black p-4"
1411
[class]="customClass()">
15-
@if (type() === CardType.TEACHER) {
16-
<img ngSrc="assets/img/teacher.png" width="200" height="200" />
17-
}
18-
@if (type() === CardType.STUDENT) {
19-
<img ngSrc="assets/img/student.webp" width="200" height="200" />
12+
<!-- Image section using content projection -->
13+
@if (imageTemplate()) {
14+
<ng-container [ngTemplateOutlet]="imageTemplate()!.templateRef" />
2015
}
2116
17+
<!-- List section using content projection -->
2218
<section>
2319
@for (item of list(); track item) {
24-
<app-list-item
25-
[name]="item.firstName"
26-
[id]="item.id"
27-
[type]="type()"></app-list-item>
20+
@if (listItemTemplate()) {
21+
<ng-container
22+
[ngTemplateOutlet]="listItemTemplate()!.templateRef"
23+
[ngTemplateOutletContext]="{ $implicit: item }" />
24+
}
2825
}
2926
</section>
3027
28+
<!-- Button section using content projection -->
3129
<button
3230
class="rounded-sm border border-blue-500 bg-blue-300 p-2"
33-
(click)="addNewItem()">
31+
(click)="addItem.emit()">
3432
Add
3533
</button>
3634
</div>
3735
`,
38-
imports: [ListItemComponent, NgOptimizedImage],
36+
imports: [NgTemplateOutlet],
3937
})
4038
export class CardComponent {
41-
private teacherStore = inject(TeacherStore);
42-
private studentStore = inject(StudentStore);
43-
44-
readonly list = input<any[] | null>(null);
45-
readonly type = input.required<CardType>();
39+
readonly list = input<unknown[]>([]);
4640
readonly customClass = input('');
4741

48-
CardType = CardType;
42+
// Output for add action - parent components will handle their specific logic
43+
readonly addItem = output<void>();
4944

50-
addNewItem() {
51-
const type = this.type();
52-
if (type === CardType.TEACHER) {
53-
this.teacherStore.addOne(randTeacher());
54-
} else if (type === CardType.STUDENT) {
55-
this.studentStore.addOne(randStudent());
56-
}
57-
}
45+
// Content children for projected templates
46+
readonly imageTemplate = contentChild(CardImageDirective);
47+
readonly listItemTemplate = contentChild(CardListItemDirective);
5848
}

0 commit comments

Comments
 (0)