Repository navigation
Answer:1 - Refactor CardComponent with content projection and directive #1557
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,60 @@ | ||
| import { ChangeDetectionStrategy, Component } from '@angular/core'; | ||
| // city-card.component.ts | ||
| 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 { CardRowDirective } from '../../ui/card/card-row.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: [], | ||
| standalone: true, | ||
| imports: [CardComponent, ListItemComponent, CardRowDirective], | ||
| template: ` | ||
| <app-card [list]="cities()" customClass="bg-yellow-500/10"> | ||
| <!-- 1. Projected City Image --> | ||
| <img src="assets/img/city.png" width="200" height="200" alt="city" /> | ||
|
|
||
| <!-- 2. Projected Row Template for City Name --> | ||
| <ng-template cardRow let-city> | ||
| <app-list-item (deleted)="deleteCity(city.id)"> | ||
| {{ city.name }} | ||
| </app-list-item> | ||
| </ng-template> | ||
|
|
||
| <!-- 3. Projected Add Button --> | ||
| <button | ||
| class="rounded-sm border border-blue-500 bg-blue-300 p-2" | ||
| (click)="addCity()"> | ||
| Add | ||
| </button> | ||
| </app-card> | ||
| `, | ||
| changeDetection: ChangeDetectionStrategy.OnPush, | ||
| }) | ||
| export class CityCardComponent {} | ||
| export class CityCardComponent implements OnInit { | ||
| private http = inject(FakeHttpService); | ||
| private store = inject(CityStore); | ||
|
|
||
| readonly cities = this.store.cities; | ||
|
|
||
| ngOnInit(): void { | ||
| this.http.fetchCities$.subscribe((cities) => this.store.addAll(cities)); | ||
| } | ||
|
|
||
| addCity(): void { | ||
| this.store.addOne(randomCity()); | ||
| } | ||
|
|
||
| deleteCity(id: number): void { | ||
| this.store.deleteOne(id); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,40 +1,66 @@ | ||
| // teacher-card.component.ts | ||
| import { | ||
| ChangeDetectionStrategy, | ||
| 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 { CardRowDirective } from '../../ui/card/card-row.directive'; | ||
| import { CardComponent } from '../../ui/card/card.component'; | ||
|
|
||
| import { ListItemComponent } from '../../ui/list-item/list-item.component'; | ||
| @Component({ | ||
| selector: 'app-teacher-card', | ||
| standalone: true, | ||
| imports: [CardComponent, ListItemComponent, CardRowDirective], | ||
| template: ` | ||
| <app-card | ||
| [list]="teachers()" | ||
| [type]="cardType" | ||
| customClass="bg-light-red"></app-card> | ||
| <app-card [list]="teachers()" customClass="bg-red-500/10"> | ||
| <!-- 1. Projected Image --> | ||
| <img | ||
| src="assets/img/teacher.png" | ||
| width="200" | ||
| height="200" | ||
| alt="teacher" /> | ||
|
|
||
| <!-- 2. Row Template passed via CardRowDirective --> | ||
| <ng-template cardRow let-teacher> | ||
| <app-list-item (deleted)="deleteTeacher(teacher.id)"> | ||
| {{ teacher.firstName }} | ||
| </app-list-item> | ||
| </ng-template> | ||
|
|
||
| <!-- 3. Projected Add Button --> | ||
| <button | ||
| class="rounded-sm border border-blue-500 bg-blue-300 p-2" | ||
| (click)="addTeacher()"> | ||
| Add | ||
| </button> | ||
| </app-card> | ||
| `, | ||
| styles: [ | ||
| ` | ||
| ::ng-deep .bg-light-red { | ||
| background-color: rgba(250, 0, 0, 0.1); | ||
| } | ||
| `, | ||
| ], | ||
| changeDetection: ChangeDetectionStrategy.Eager, | ||
| imports: [CardComponent], | ||
| }) | ||
| export class TeacherCardComponent implements OnInit { | ||
| private http = inject(FakeHttpService); | ||
| private store = inject(TeacherStore); | ||
|
|
||
| teachers = this.store.teachers; | ||
| cardType = CardType.TEACHER; | ||
| // teachers = this.store.teachers; | ||
| // cardType = CardType.TEACHER; | ||
|
|
||
| readonly teachers = this.store.teachers; | ||
|
|
||
| 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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,21 +1,23 @@ | ||
| // city.store.ts | ||
| import { Injectable, signal } from '@angular/core'; | ||
| import { City } from '../model/city.model'; | ||
|
|
||
| @Injectable({ | ||
| providedIn: 'root', | ||
| }) | ||
| export class CityStore { | ||
| private cities = signal<City[]>([]); | ||
| readonly #cities = signal<City[]>([]); | ||
| 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)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| // fake-http.service.ts | ||
| import { Injectable } from '@angular/core'; | ||
| import { | ||
| incrementalNumber, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { Directive, inject, TemplateRef } from '@angular/core'; | ||
|
|
||
| export interface CardRowContext<T> { | ||
| $implicit: T; | ||
| } | ||
|
|
||
| @Directive({ | ||
| selector: 'ng-template[cardRow]', | ||
| standalone: true, | ||
| }) | ||
| export class CardRowDirective<T> { | ||
| readonly templateRef = inject(TemplateRef<CardRowContext<T>>); | ||
|
Comment on lines
+11
to
+12
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '1,100p' apps/angular/1-projection/src/app/ui/card/card-row.directive.ts
sed -n '1,105p' apps/angular/1-projection/src/app/ui/card/card.component.ts
rg -n 'strictTemplates|angularCompilerOptions|cardRow|let-(student|teacher|city)' apps/angular/1-projection/tsconfig* apps/angular/1-projection/src/appRepository: tomalaforge/angular-challenges Length of output: 2886 🏁 Script executed: set -eu
printf '%s\n' '--- consumer templates and components ---'
for f in \
apps/angular/1-projection/src/app/component/student-card/student-card.component.ts \
apps/angular/1-projection/src/app/component/teacher-card/teacher-card.component.ts \
apps/angular/1-projection/src/app/component/city-card/city-card.component.ts \
apps/angular/1-projection/src/app/ui/card/city-card.component.ts; do
if [ -f "$f" ]; then
echo "--- $f"
cat -n "$f"
fi
done
printf '%s\n' '--- compiler configuration ---'
cat -n apps/angular/1-projection/tsconfig.json
cat -n apps/angular/1-projection/tsconfig.app.json
printf '%s\n' '--- Angular/package versions ---'
for f in package.json apps/angular/1-projection/package.json; do
if [ -f "$f" ]; then
echo "--- $f"
rg -n '"(`@angular/`|typescript|angular)"|strictTemplates|fullTemplateTypeCheck' "$f" || true
fi
done
printf '%s\n' '--- directive usages and card input bindings ---'
rg -n -C 4 'app-card|\\[list\\]|cardRow|let-(student|teacher|city)' apps/angular/1-projection/src/appRepository: tomalaforge/angular-challenges Length of output: 23273 Make projected row variables type-checked.
Add a typed 🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,64 +1,49 @@ | ||
| import { NgOptimizedImage } from '@angular/common'; | ||
| // card.component.ts | ||
|
|
||
| import { NgTemplateOutlet } from '@angular/common'; | ||
| import { | ||
| ChangeDetectionStrategy, | ||
| Component, | ||
| inject, | ||
| contentChild, | ||
| 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 { 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 { CardRowDirective } from './card-row.directive'; | ||
|
|
||
| @Component({ | ||
| selector: 'app-card', | ||
| standalone: true, | ||
| imports: [NgTemplateOutlet], | ||
| template: ` | ||
| <div | ||
| class="flex w-fit flex-col gap-3 rounded-md border-2 border-black p-4" | ||
| [class]="customClass()"> | ||
| @if (type() === CardType.TEACHER) { | ||
| <img ngSrc="assets/img/teacher.png" width="200" height="200" alt="" /> | ||
| } | ||
| @if (type() === CardType.STUDENT) { | ||
| <img ngSrc="assets/img/student.webp" width="200" height="200" alt="" /> | ||
| } | ||
| <!-- 1. Multi-slot projection for image --> | ||
| <ng-content select="img" /> | ||
|
|
||
| <!-- 2. @for loop remains here, rendering projected row template --> | ||
| <section> | ||
| @for (item of list(); track item) { | ||
| <app-list-item | ||
| [name]="item.firstName" | ||
| [id]="item.id" | ||
| [type]="type()"></app-list-item> | ||
| @for (item of list(); track item.id) { | ||
| <ng-container | ||
| [ngTemplateOutlet]="rowTemplate()?.templateRef ?? null" | ||
| [ngTemplateOutletContext]="{ $implicit: item }" /> | ||
| } | ||
| </section> | ||
|
|
||
| <button | ||
| class="rounded-sm border border-blue-500 bg-blue-300 p-2" | ||
| (click)="addNewItem()"> | ||
| Add | ||
| </button> | ||
| <!-- 3. Multi-slot projection for add button --> | ||
| <ng-content select="button" /> | ||
| </div> | ||
| `, | ||
| changeDetection: ChangeDetectionStrategy.Eager, | ||
| imports: [ListItemComponent, NgOptimizedImage], | ||
| }) | ||
| export class CardComponent { | ||
| private teacherStore = inject(TeacherStore); | ||
| private studentStore = inject(StudentStore); | ||
|
|
||
| readonly list = input<any[] | null>(null); | ||
| readonly type = input.required<CardType>(); | ||
| export class CardComponent<T extends { id: number }> { | ||
| readonly list = input<T[] | null>(null); | ||
| readonly customClass = input(''); | ||
|
|
||
| CardType = CardType; | ||
|
|
||
| addNewItem() { | ||
| const type = this.type(); | ||
| if (type === CardType.TEACHER) { | ||
| this.teacherStore.addOne(randTeacher()); | ||
| } else if (type === CardType.STUDENT) { | ||
| this.studentStore.addOne(randStudent()); | ||
| } | ||
| } | ||
| // Bonus: Query row template via Directive instead of magic string | ||
| readonly rowTemplate = contentChild(CardRowDirective<T>); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: tomalaforge/angular-challenges
Length of output: 2737
🏁 Script executed:
Repository: tomalaforge/angular-challenges
Length of output: 7935
🏁 Script executed:
Repository: tomalaforge/angular-challenges
Length of output: 7353
🏁 Script executed:
Repository: tomalaforge/angular-challenges
Length of output: 2191
Preserve records added during the initial load.
Each fetch emits after 500 ms. The Add buttons are available before that emission. Because the initial arrays are created before later
rand*()calls, an added record has a newid. If Add runs first,addOne()appends the record, butaddAll()replaces the store contents and removes it.Update all three
addAll()methods to merge records byid.Suggested fix
📍 Affects 3 files
apps/angular/1-projection/src/app/component/city-card/city-card.component.ts#L50-L50(this comment)apps/angular/1-projection/src/app/component/student-card/student-card.component.ts#L66-L66apps/angular/1-projection/src/app/component/teacher-card/teacher-card.component.ts#L60-L60🤖 Prompt for AI Agents