From b3aed8c6e7e2f5bf1e42817ae9c1f279bf1efdbf Mon Sep 17 00:00:00 2001 From: lukasss88 Date: Tue, 21 Oct 2025 16:44:07 +0200 Subject: [PATCH 1/2] feat: solution for content projection --- .../city-card/city-card.component.ts | 50 +++++++++++-- .../student-card/student-card.component.ts | 36 ++++++++-- .../teacher-card/teacher-card.component.ts | 42 +++++++++-- .../src/app/data-access/city.store.ts | 2 +- .../1-projection/src/app/data-access/store.ts | 9 +++ .../src/app/ui/card/card.component.ts | 70 +++++++------------ .../app/ui/list-item/list-item.component.ts | 16 ++--- 7 files changed, 151 insertions(+), 74 deletions(-) create mode 100644 apps/angular/1-projection/src/app/data-access/store.ts 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..8c0c90031 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,51 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { NgOptimizedImage } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject } 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: [], + template: ` + + + + + + + + `, + imports: [CardComponent, NgOptimizedImage, ListItemComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CityCardComponent {} +export class CityCardComponent { + 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)); + } + + addNewCity() { + 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..c72dbe903 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,49 @@ +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 { Store } from '../../data-access/store'; 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: ` + class="bg-light-green" + [itemTemplate]="itemTemplate" + (onAddNewItem)="addNewItem()"> + + + + + + `, styles: [ ` - ::ng-deep .bg-light-green { + .bg-light-green { background-color: rgba(0, 250, 0, 0.1); } `, ], - imports: [CardComponent], + imports: [CardComponent, NgOptimizedImage, ListItemComponent], changeDetection: ChangeDetectionStrategy.OnPush, + providers: [{ provide: Store, useExisting: StudentStore }], }) export class StudentCardComponent implements OnInit { private http = inject(FakeHttpService); @@ -37,4 +55,12 @@ export class StudentCardComponent implements OnInit { ngOnInit(): void { this.http.fetchStudents$.subscribe((s) => this.store.addAll(s)); } + + addNewItem() { + 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..ed2c23478 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,47 @@ -import { Component, inject, OnInit } from '@angular/core'; -import { FakeHttpService } from '../../data-access/fake-http.service'; +import { NgOptimizedImage } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + inject, + OnInit, +} from '@angular/core'; +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: ` + [itemTemplate]="itemTemplate" + class="bg-light-red" + (onAddNewItem)="addNewTeacher()"> + + + + + + `, styles: [ ` - ::ng-deep .bg-light-red { + .bg-light-red { background-color: rgba(250, 0, 0, 0.1); } `, ], - imports: [CardComponent], + imports: [CardComponent, NgOptimizedImage, ListItemComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class TeacherCardComponent implements OnInit { private http = inject(FakeHttpService); @@ -31,4 +53,12 @@ export class TeacherCardComponent implements OnInit { ngOnInit(): void { this.http.fetchTeachers$.subscribe((t) => this.store.addAll(t)); } + + addNewTeacher() { + 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 a8b523569..9fbcb346b 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/data-access/store.ts b/apps/angular/1-projection/src/app/data-access/store.ts new file mode 100644 index 000000000..f6be7b3fe --- /dev/null +++ b/apps/angular/1-projection/src/app/data-access/store.ts @@ -0,0 +1,9 @@ +import { signal } from '@angular/core'; + +export abstract class Store { + protected readonly items = signal([]); + + abstract addAll(items: T[]): void; + abstract addOne(item: T): void; + abstract deleteOne(id: number): void; +} 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..b7bc062b1 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,58 +1,36 @@ -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, input, output, TemplateRef } from '@angular/core'; @Component({ selector: 'app-card', template: ` -
- @if (type() === CardType.TEACHER) { - - } - @if (type() === CardType.STUDENT) { - - } + -
- @for (item of list(); track item) { - - } -
+
+ @for (item of list(); track item) { + + } +
- -
+ `, - imports: [ListItemComponent, NgOptimizedImage], + host: { + class: 'border-2 border-black rounded-md p-4 w-fit flex flex-col gap-3', + }, + imports: [NgTemplateOutlet], }) -export class CardComponent { - private teacherStore = inject(TeacherStore); - private studentStore = inject(StudentStore); - - readonly list = input(null); - readonly type = input.required(); - readonly customClass = input(''); - - CardType = CardType; +export class CardComponent { + readonly list = input(null); + readonly itemTemplate = input | null>(null); + onAddNewItem = output(); addNewItem() { - const type = this.type(); - if (type === CardType.TEACHER) { - this.teacherStore.addOne(randTeacher()); - } else if (type === CardType.STUDENT) { - this.studentStore.addOne(randStudent()); - } + this.onAddNewItem.emit(); } } 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..42a156374 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,11 +1,9 @@ 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({ @@ -21,19 +19,13 @@ 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(); + readonly onDeleteItem = output(); + delete(id: number) { - const type = this.type(); - if (type === CardType.TEACHER) { - this.teacherStore.deleteOne(id); - } else if (type === CardType.STUDENT) { - this.studentStore.deleteOne(id); - } + this.onDeleteItem.emit(id); } } From 32d1af7846911cf77983759ce59e2c9b10754827 Mon Sep 17 00:00:00 2001 From: lukasss88 Date: Sat, 25 Oct 2025 12:07:58 +0200 Subject: [PATCH 2/2] feat(card,teacher): typed template context via ngTemplateContextGuard; use rxResource - add CardItemDirective with ngTemplateContextGuard for type-safe `let-item` - render projected templates via contentChild(CardItemDirective) + *ngTemplateOutlet - replace manual subscription in TeacherCard ngOnInit with rxResource --- .../city-card/city-card.component.ts | 41 +++++++-------- .../student-card/student-card.component.ts | 50 ++++++++----------- .../teacher-card/teacher-card.component.ts | 48 ++++++++---------- .../src/app/data-access/city.store.ts | 15 ++++-- .../1-projection/src/app/data-access/store.ts | 9 ---- .../src/app/data-access/student.store.ts | 15 ++++-- .../src/app/data-access/teacher.store.ts | 15 ++++-- .../ui/card/card-item-context.directive.ts | 23 +++++++++ .../src/app/ui/card/card.component.ts | 11 ++-- .../app/ui/list-item/list-item.component.ts | 8 +-- 10 files changed, 128 insertions(+), 107 deletions(-) delete mode 100644 apps/angular/1-projection/src/app/data-access/store.ts create mode 100644 apps/angular/1-projection/src/app/ui/card/card-item-context.directive.ts 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 8c0c90031..fb0380d65 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,46 +1,43 @@ import { NgOptimizedImage } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { CityStore } from '../../data-access/city.store'; -import { - FakeHttpService, - randomCity, -} from '../../data-access/fake-http.service'; +import { randomCity } from '../../data-access/fake-http.service'; import { CardType } from '../../model/card.model'; +import { City } from '../../model/city.model'; +import { CardItemDirective } from '../../ui/card/card-item-context.directive'; import { CardComponent } from '../../ui/card/card.component'; import { ListItemComponent } from '../../ui/list-item/list-item.component'; @Component({ selector: 'app-city-card', template: ` - + - - - - + + + + `, - imports: [CardComponent, NgOptimizedImage, ListItemComponent], + imports: [ + CardComponent, + NgOptimizedImage, + ListItemComponent, + CardItemDirective, + ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class CityCardComponent { - private http = inject(FakeHttpService); private store = inject(CityStore); + readonly _cityType!: City; cities = this.store.cities; cardType = CardType.CITY; - ngOnInit(): void { - this.http.fetchCities$.subscribe((c) => this.store.addAll(c)); - } - addNewCity() { this.store.addOne(randomCity()); } 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 c72dbe903..09bd6592f 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,17 +1,10 @@ import { NgOptimizedImage } from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - inject, - OnInit, -} from '@angular/core'; -import { - FakeHttpService, - randStudent, -} from '../../data-access/fake-http.service'; -import { Store } from '../../data-access/store'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { randStudent } from '../../data-access/fake-http.service'; import { StudentStore } from '../../data-access/student.store'; import { CardType } from '../../model/card.model'; +import { Student } from '../../model/student.model'; +import { CardItemDirective } from '../../ui/card/card-item-context.directive'; import { CardComponent } from '../../ui/card/card.component'; import { ListItemComponent } from '../../ui/list-item/list-item.component'; @@ -19,20 +12,19 @@ import { ListItemComponent } from '../../ui/list-item/list-item.component'; selector: 'app-student-card', template: ` - - - - + + + + `, styles: [ ` @@ -41,21 +33,21 @@ import { ListItemComponent } from '../../ui/list-item/list-item.component'; } `, ], - imports: [CardComponent, NgOptimizedImage, ListItemComponent], + imports: [ + CardComponent, + NgOptimizedImage, + ListItemComponent, + CardItemDirective, + ], changeDetection: ChangeDetectionStrategy.OnPush, - providers: [{ provide: Store, useExisting: StudentStore }], }) -export class StudentCardComponent implements OnInit { - private http = inject(FakeHttpService); +export class StudentCardComponent { private store = inject(StudentStore); + readonly _studentType!: Student; students = this.store.students; cardType = CardType.STUDENT; - ngOnInit(): void { - this.http.fetchStudents$.subscribe((s) => this.store.addAll(s)); - } - addNewItem() { this.store.addOne(randStudent()); } 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 ed2c23478..e47544399 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,16 +1,10 @@ import { NgOptimizedImage } from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - inject, - OnInit, -} from '@angular/core'; -import { - FakeHttpService, - randTeacher, -} from '../../data-access/fake-http.service'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { randTeacher } from '../../data-access/fake-http.service'; import { TeacherStore } from '../../data-access/teacher.store'; import { CardType } from '../../model/card.model'; +import { Teacher } from '../../model/teacher.model'; +import { CardItemDirective } from '../../ui/card/card-item-context.directive'; import { CardComponent } from '../../ui/card/card.component'; import { ListItemComponent } from '../../ui/list-item/list-item.component'; @@ -18,20 +12,19 @@ import { ListItemComponent } from '../../ui/list-item/list-item.component'; selector: 'app-teacher-card', template: ` - - - - + + + + `, styles: [ ` @@ -40,20 +33,21 @@ import { ListItemComponent } from '../../ui/list-item/list-item.component'; } `, ], - imports: [CardComponent, NgOptimizedImage, ListItemComponent], + imports: [ + CardComponent, + NgOptimizedImage, + ListItemComponent, + CardItemDirective, + ], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class TeacherCardComponent implements OnInit { - private http = inject(FakeHttpService); +export class TeacherCardComponent { private store = inject(TeacherStore); + readonly _teacherType!: Teacher; teachers = this.store.teachers; cardType = CardType.TEACHER; - ngOnInit(): void { - this.http.fetchTeachers$.subscribe((t) => this.store.addAll(t)); - } - addNewTeacher() { this.store.addOne(randTeacher()); } 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 9fbcb346b..88acf2341 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 @@ -1,21 +1,28 @@ -import { Injectable, signal } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; +import { rxResource } from '@angular/core/rxjs-interop'; import { City } from '../model/city.model'; +import { FakeHttpService } from './fake-http.service'; @Injectable({ providedIn: 'root', }) export class CityStore { - public cities = signal([]); + http = inject(FakeHttpService); + + cities = rxResource({ + stream: () => this.http.fetchCities$, + defaultValue: [], + }); addAll(cities: City[]) { this.cities.set(cities); } addOne(city: City) { - this.cities.set([...this.cities(), city]); + this.cities.set([...this.cities.value(), city]); } deleteOne(id: number) { - this.cities.set(this.cities().filter((s) => s.id !== id)); + this.cities.set(this.cities.value().filter((s) => s.id !== id)); } } diff --git a/apps/angular/1-projection/src/app/data-access/store.ts b/apps/angular/1-projection/src/app/data-access/store.ts deleted file mode 100644 index f6be7b3fe..000000000 --- a/apps/angular/1-projection/src/app/data-access/store.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { signal } from '@angular/core'; - -export abstract class Store { - protected readonly items = signal([]); - - abstract addAll(items: T[]): void; - abstract addOne(item: T): void; - abstract deleteOne(id: number): void; -} diff --git a/apps/angular/1-projection/src/app/data-access/student.store.ts b/apps/angular/1-projection/src/app/data-access/student.store.ts index 6e7f57022..257b8c9d9 100644 --- a/apps/angular/1-projection/src/app/data-access/student.store.ts +++ b/apps/angular/1-projection/src/app/data-access/student.store.ts @@ -1,21 +1,28 @@ -import { Injectable, signal } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; +import { rxResource } from '@angular/core/rxjs-interop'; import { Student } from '../model/student.model'; +import { FakeHttpService } from './fake-http.service'; @Injectable({ providedIn: 'root', }) export class StudentStore { - public students = signal([]); + http = inject(FakeHttpService); + + students = rxResource({ + stream: () => this.http.fetchStudents$, + defaultValue: [], + }); addAll(students: Student[]) { this.students.set(students); } addOne(student: Student) { - this.students.set([...this.students(), student]); + this.students.set([...this.students.value(), student]); } deleteOne(id: number) { - this.students.set(this.students().filter((s) => s.id !== id)); + this.students.set(this.students.value().filter((s) => s.id !== id)); } } diff --git a/apps/angular/1-projection/src/app/data-access/teacher.store.ts b/apps/angular/1-projection/src/app/data-access/teacher.store.ts index 5f6dae989..3dddaebad 100644 --- a/apps/angular/1-projection/src/app/data-access/teacher.store.ts +++ b/apps/angular/1-projection/src/app/data-access/teacher.store.ts @@ -1,21 +1,28 @@ -import { Injectable, signal } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; +import { rxResource } from '@angular/core/rxjs-interop'; import { Teacher } from '../model/teacher.model'; +import { FakeHttpService } from './fake-http.service'; @Injectable({ providedIn: 'root', }) export class TeacherStore { - public teachers = signal([]); + http = inject(FakeHttpService); + + teachers = rxResource({ + stream: () => this.http.fetchTeachers$, + defaultValue: [], + }); addAll(teachers: Teacher[]) { this.teachers.set(teachers); } addOne(teacher: Teacher) { - this.teachers.set([...this.teachers(), teacher]); + this.teachers.set([...this.teachers.value(), teacher]); } deleteOne(id: number) { - this.teachers.set(this.teachers().filter((t) => t.id !== id)); + this.teachers.set(this.teachers.value().filter((t) => t.id !== id)); } } diff --git a/apps/angular/1-projection/src/app/ui/card/card-item-context.directive.ts b/apps/angular/1-projection/src/app/ui/card/card-item-context.directive.ts new file mode 100644 index 000000000..bbc9c8f53 --- /dev/null +++ b/apps/angular/1-projection/src/app/ui/card/card-item-context.directive.ts @@ -0,0 +1,23 @@ +import { Directive, inject, input, TemplateRef } from '@angular/core'; + +export interface CardItemContext { + $implicit: T; +} + +@Directive({ + selector: 'ng-template[appCardItem]', + exportAs: 'appCardItem', + standalone: true, +}) +export class CardItemDirective { + readonly appCardItem = input(); + + tpl = inject(TemplateRef>); + + static ngTemplateContextGuard( + _dir: CardItemDirective, + _ctx: unknown, + ): _ctx is CardItemContext { + return true; + } +} 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 b7bc062b1..6b0d66c22 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,5 +1,6 @@ import { NgTemplateOutlet } from '@angular/common'; -import { Component, input, output, TemplateRef } from '@angular/core'; +import { Component, contentChild, input, output } from '@angular/core'; +import { CardItemDirective } from './card-item-context.directive'; @Component({ selector: 'app-card', @@ -9,8 +10,10 @@ import { Component, input, output, TemplateRef } from '@angular/core';
@for (item of list(); track item) { + *ngTemplateOutlet=" + itemTemplate().tpl; + context: { $implicit: item } + "> }
@@ -27,7 +30,7 @@ import { Component, input, output, TemplateRef } from '@angular/core'; }) export class CardComponent { readonly list = input(null); - readonly itemTemplate = input | null>(null); + readonly itemTemplate = contentChild.required(CardItemDirective); onAddNewItem = output(); addNewItem() { 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 42a156374..743e62e95 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 @@ -11,7 +11,7 @@ import { CardType } from '../../model/card.model'; template: `
{{ name() }} -
@@ -23,9 +23,9 @@ export class ListItemComponent { readonly name = input.required(); readonly type = input.required(); - readonly onDeleteItem = output(); + readonly onDeleteItem = output(); - delete(id: number) { - this.onDeleteItem.emit(id); + delete() { + this.onDeleteItem.emit(); } }