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
+
+
+
+
+ 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: `
-
0; else emptyList">
-
- {{ 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() }}
+
+
+
+
+
+

+
+
+
+
+
+ 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: `
-
-
+
+
You have unsaved information!
-
Do you want to continue and lose them?
+
+ Do you want to continue and lose them?
+
@@ -27,4 +37,6 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class AlertDialogComponent {}
+export class AlertDialogComponent {
+ dialogRef = inject(DialogRef);
+}
diff --git a/apps/forms/48-avoid-losing-form-data/src/app/ui/form.component.ts b/apps/forms/48-avoid-losing-form-data/src/app/ui/form.component.ts
index f3190d517..a257b6f9a 100644
--- a/apps/forms/48-avoid-losing-form-data/src/app/ui/form.component.ts
+++ b/apps/forms/48-avoid-losing-form-data/src/app/ui/form.component.ts
@@ -1,4 +1,9 @@
-import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ HostListener,
+ inject,
+} from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
@@ -71,6 +76,14 @@ export class FormComponent {
message: '',
});
+ @HostListener('window:beforeunload', ['$event'])
+ handleBeforeUnload(event: BeforeUnloadEvent) {
+ if (this.form.dirty) {
+ event.preventDefault();
+ event.returnValue = '';
+ }
+ }
+
onSubmit() {
if (this.form.valid) this.form.reset();
}
diff --git a/apps/forms/48-avoid-losing-form-data/src/styles.scss b/apps/forms/48-avoid-losing-form-data/src/styles.scss
index 77e408aa8..9d2533e3a 100644
--- a/apps/forms/48-avoid-losing-form-data/src/styles.scss
+++ b/apps/forms/48-avoid-losing-form-data/src/styles.scss
@@ -2,4 +2,6 @@
@tailwind components;
@tailwind utilities;
+@import '@angular/cdk/overlay-prebuilt.css';
+
/* You can add global styles to this file, and also import other style files */
diff --git a/apps/rxjs/11-high-order-operator-bug/src/app/app.component.ts b/apps/rxjs/11-high-order-operator-bug/src/app/app.component.ts
index fb80fb2b6..b07c26824 100644
--- a/apps/rxjs/11-high-order-operator-bug/src/app/app.component.ts
+++ b/apps/rxjs/11-high-order-operator-bug/src/app/app.component.ts
@@ -1,52 +1,221 @@
/* eslint-disable @angular-eslint/component-selector */
+import { CommonModule } from '@angular/common';
import { Component, inject, input, signal } from '@angular/core';
import { take } from 'rxjs';
import { AppService } from './app.service';
import { TopicType } from './localDB.service';
-
@Component({
selector: 'button-delete-topic',
+ standalone: true,
+ imports: [CommonModule],
template: `
-
-
{{ message() }}
+
+
+
+ {{ message() }}
+
+
`,
+ styles: [
+ `
+ .delete-container {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin: 10px 0;
+ }
+
+ .delete-button {
+ padding: 8px 16px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-weight: 500;
+ transition: all 0.2s ease;
+ }
+
+ .delete-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ .delete-button.food {
+ background-color: #ff4d4d;
+ color: white;
+ }
+
+ .delete-button.sport {
+ background-color: #4caf50;
+ color: white;
+ }
+
+ .delete-button.book {
+ background-color: #2196f3;
+ color: white;
+ }
+
+ .message {
+ padding: 8px;
+ border-radius: 4px;
+ font-size: 14px;
+ }
+
+ .message.error {
+ background-color: #ffebee;
+ color: #c62828;
+ }
+
+ .message.success {
+ background-color: #e8f5e9;
+ color: #2e7d32;
+ }
+ `,
+ ],
})
export class ButtonDeleteComponent {
readonly topic = input.required
();
message = signal('');
+ isDeleting = signal(false);
+ isError = signal(false);
private service = inject(AppService);
deleteTopic() {
+ this.isDeleting.set(true);
+ this.message.set('');
+
this.service
.deleteOldTopics(this.topic())
.pipe(take(1))
- .subscribe((result) =>
- this.message.set(
- result
- ? `All ${this.topic()} have been deleted`
- : `Error: deletion of some ${this.topic()} failed`,
- ),
- );
+ .subscribe({
+ next: (result) => {
+ this.isError.set(!result);
+ this.message.set(
+ result
+ ? `All ${this.topic()} have been deleted`
+ : `Error: deletion of some ${this.topic()} failed`,
+ );
+ },
+ complete: () => {
+ this.isDeleting.set(false);
+ },
+ });
}
}
@Component({
- imports: [ButtonDeleteComponent],
+ standalone: true,
+ imports: [ButtonDeleteComponent, CommonModule],
selector: 'app-root',
template: `
- @for (info of allInfo(); track info.id) {
- {{ info.id }} - {{ info.topic }}
- }
+
+
Topic Management
+
+
+
Current Topics
+ @for (info of allInfo(); track info.id) {
+
+ #{{ info.id }}
+ {{ info.topic }}
+
+ }
+
-
Delete Food
-
Delete Sport
-
Delete Book
+
+
Delete Topics
+
+ Delete Food Topics
+
+
+ Delete Sport Topics
+
+
+ Delete Book Topics
+
+
+
`,
+ styles: [
+ `
+ .container {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px;
+ font-family: Arial, sans-serif;
+ }
+
+ h1 {
+ color: #333;
+ text-align: center;
+ margin-bottom: 30px;
+ }
+
+ h2 {
+ color: #666;
+ margin-bottom: 20px;
+ }
+
+ .info-list {
+ background: white;
+ padding: 20px;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ margin-bottom: 30px;
+ }
+
+ .info-item {
+ display: flex;
+ align-items: center;
+ padding: 8px;
+ border-bottom: 1px solid #eee;
+ }
+
+ .info-id {
+ font-weight: bold;
+ margin-right: 10px;
+ color: #666;
+ }
+
+ .topic-badge {
+ padding: 4px 8px;
+ border-radius: 12px;
+ font-size: 12px;
+ text-transform: uppercase;
+ }
+
+ .topic-badge.food {
+ background-color: #ffebee;
+ color: #c62828;
+ }
+
+ .topic-badge.sport {
+ background-color: #e8f5e9;
+ color: #2e7d32;
+ }
+
+ .topic-badge.book {
+ background-color: #e3f2fd;
+ color: #1565c0;
+ }
+
+ .actions {
+ background: white;
+ padding: 20px;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ }
+ `,
+ ],
})
export class AppComponent {
private service = inject(AppService);
-
allInfo = this.service.getAllInfo;
}
diff --git a/apps/rxjs/11-high-order-operator-bug/src/app/app.service.ts b/apps/rxjs/11-high-order-operator-bug/src/app/app.service.ts
index df2269a89..093a21e19 100644
--- a/apps/rxjs/11-high-order-operator-bug/src/app/app.service.ts
+++ b/apps/rxjs/11-high-order-operator-bug/src/app/app.service.ts
@@ -1,5 +1,5 @@
import { inject, Injectable } from '@angular/core';
-import { merge, Observable, of } from 'rxjs';
+import { combineLatest, map, Observable, of } from 'rxjs';
import { LocalDBService, TopicType } from './localDB.service';
@Injectable({ providedIn: 'root' })
@@ -10,10 +10,14 @@ export class AppService {
deleteOldTopics(type: TopicType): Observable {
const infoByType = this.dbService.searchByType(type);
- return infoByType.length > 0
- ? infoByType
- .map((t) => this.dbService.deleteOneTopic(t.id))
- .reduce((acc, curr) => merge(acc, curr), of(true))
- : of(true);
+
+ if (infoByType.length === 0) {
+ return of(true);
+ }
+
+ // Use combineLatest to wait for all deletion operations and check if they all succeeded
+ return combineLatest(
+ infoByType.map((info) => this.dbService.deleteOneTopic(info.id)),
+ ).pipe(map((results) => results.every((result) => result === true)));
}
}
diff --git a/apps/rxjs/11-high-order-operator-bug/src/styles.scss b/apps/rxjs/11-high-order-operator-bug/src/styles.scss
index 90d4ee007..0da5d120c 100644
--- a/apps/rxjs/11-high-order-operator-bug/src/styles.scss
+++ b/apps/rxjs/11-high-order-operator-bug/src/styles.scss
@@ -1 +1,42 @@
/* You can add global styles to this file, and also import other style files */
+
+body {
+ margin: 0;
+ padding: 0;
+ background-color: #f5f5f5;
+ min-height: 100vh;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
+ Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+ line-height: 1.6;
+ color: #333;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+button {
+ font-family: inherit;
+}
+
+/* Smooth transitions */
+* {
+ transition: all 0.2s ease-in-out;
+}
+
+/* Better focus states for accessibility */
+:focus {
+ outline: 2px solid #2196f3;
+ outline-offset: 2px;
+}
+
+/* Responsive font sizing */
+html {
+ font-size: 16px;
+}
+
+@media (max-width: 768px) {
+ html {
+ font-size: 14px;
+ }
+}
diff --git a/apps/rxjs/14-race-condition/src/app/app.component.ts b/apps/rxjs/14-race-condition/src/app/app.component.ts
index a7eb77710..2b115cb40 100644
--- a/apps/rxjs/14-race-condition/src/app/app.component.ts
+++ b/apps/rxjs/14-race-condition/src/app/app.component.ts
@@ -1,40 +1,53 @@
-import {
- ChangeDetectionStrategy,
- Component,
- inject,
- OnInit,
-} from '@angular/core';
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
-import { take } from 'rxjs';
+import { switchMap, take } from 'rxjs';
import { TopicModalComponent } from './topic-dialog.component';
-import { TopicService, TopicType } from './topic.service';
+import { TopicService } from './topic.service';
@Component({
standalone: true,
+ imports: [MatButtonModule],
selector: 'app-root',
template: `
-
+
+
+
`,
+ styles: [
+ `
+ .container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 100vh;
+ padding: 20px;
+ }
+ `,
+ ],
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class AppComponent implements OnInit {
- title = 'rxjs-race-condition';
- dialog = inject(MatDialog);
- topicService = inject(TopicService);
- topics: TopicType[] = [];
+export class AppComponent {
+ private dialog = inject(MatDialog);
+ private topicService = inject(TopicService);
- ngOnInit(): void {
+ openTopicModal() {
+ // Get fresh topics data and then open modal
this.topicService
.fakeGetHttpTopic()
- .pipe(take(1))
- .subscribe((topics) => (this.topics = topics));
- }
-
- openTopicModal() {
- this.dialog.open(TopicModalComponent, {
- data: {
- topics: this.topics,
- },
- });
+ .pipe(
+ take(1),
+ switchMap((topics) =>
+ this.dialog
+ .open(TopicModalComponent, {
+ data: { topics },
+ width: '400px',
+ })
+ .afterClosed(),
+ ),
+ )
+ .subscribe();
}
}
diff --git a/apps/rxjs/14-race-condition/src/app/topic-dialog.component.ts b/apps/rxjs/14-race-condition/src/app/topic-dialog.component.ts
index e01a69a01..319b7487b 100644
--- a/apps/rxjs/14-race-condition/src/app/topic-dialog.component.ts
+++ b/apps/rxjs/14-race-condition/src/app/topic-dialog.component.ts
@@ -1,23 +1,67 @@
-import { NgFor } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
@Component({
+ standalone: true,
template: `
- Show all Topics
-
-
-
+
+
Available Topics
+
+
+
+ @for (topic of data.topics; track topic) {
+ -
+ 📌
+ {{ topic }}
+
+ }
+
+
+
+
+
+
`,
- imports: [MatDialogModule, MatButtonModule, NgFor],
+ styles: [
+ `
+ .dialog-container {
+ padding: 20px;
+ }
+
+ .topic-list {
+ list-style: none;
+ padding: 0;
+ margin: 20px 0;
+ }
+
+ .topic-item {
+ display: flex;
+ align-items: center;
+ padding: 12px;
+ margin: 8px 0;
+ background-color: #f5f5f5;
+ border-radius: 8px;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background-color: #eeeeee;
+ transform: translateX(5px);
+ }
+ }
+
+ .topic-icon {
+ margin-right: 12px;
+ font-size: 1.2em;
+ }
+
+ mat-dialog-actions {
+ margin-top: 20px;
+ }
+ `,
+ ],
+ imports: [MatDialogModule, MatButtonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TopicModalComponent {
diff --git a/apps/rxjs/14-race-condition/tsconfig.json b/apps/rxjs/14-race-condition/tsconfig.json
index 8a0cb05fc..3879b94cd 100644
--- a/apps/rxjs/14-race-condition/tsconfig.json
+++ b/apps/rxjs/14-race-condition/tsconfig.json
@@ -22,7 +22,7 @@
"path": "./tsconfig.editor.json"
},
{
- "path": "./cypress/tsconfig.base.json"
+ "path": "./cypress/tsconfig.json"
}
],
"extends": "../../../tsconfig.base.json",
diff --git a/apps/rxjs/38-catch-error/src/app/app.component.css b/apps/rxjs/38-catch-error/src/app/app.component.css
index e85a8aed5..dfd76b25d 100644
--- a/apps/rxjs/38-catch-error/src/app/app.component.css
+++ b/apps/rxjs/38-catch-error/src/app/app.component.css
@@ -30,3 +30,134 @@ button {
text-align: center;
border: 1px solid #ccc;
}
+
+.container {
+ min-height: 100vh;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: #f5f5f5;
+ padding: 20px;
+}
+
+.content {
+ background: white;
+ padding: 2rem;
+ border-radius: 12px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ width: 100%;
+ max-width: 600px;
+}
+
+h1 {
+ text-align: center;
+ color: #333;
+ margin-bottom: 2rem;
+ font-size: 1.8rem;
+}
+
+.info-box {
+ background-color: #f8f9fa;
+ padding: 1rem;
+ border-radius: 8px;
+ margin-bottom: 2rem;
+}
+
+.info-box h3 {
+ margin: 0 0 0.5rem 0;
+ color: #666;
+ font-size: 1rem;
+}
+
+.endpoints {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.endpoint {
+ background-color: #e9ecef;
+ padding: 0.25rem 0.75rem;
+ border-radius: 16px;
+ font-size: 0.9rem;
+ color: #495057;
+}
+
+.form {
+ display: flex;
+ gap: 1rem;
+ margin-bottom: 2rem;
+}
+
+.input {
+ flex: 1;
+ padding: 0.75rem 1rem;
+ border: 2px solid #dee2e6;
+ border-radius: 8px;
+ font-size: 1rem;
+ transition: all 0.2s ease;
+}
+
+.input:focus {
+ outline: none;
+ border-color: #4dabf7;
+ box-shadow: 0 0 0 3px rgba(77, 171, 247, 0.2);
+}
+
+.input.error {
+ border-color: #ff6b6b;
+}
+
+.input.success {
+ border-color: #51cf66;
+}
+
+.button {
+ padding: 0.75rem 1.5rem;
+ background-color: #4dabf7;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.button:hover:not(:disabled) {
+ background-color: #339af0;
+}
+
+.button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.response {
+ background-color: #f8f9fa;
+ padding: 1rem;
+ border-radius: 8px;
+ border-left: 4px solid #4dabf7;
+}
+
+.response.error {
+ border-left-color: #ff6b6b;
+ background-color: #fff5f5;
+}
+
+.response.success {
+ border-left-color: #51cf66;
+ background-color: #f4fce3;
+}
+
+.response h3 {
+ margin: 0 0 0.5rem 0;
+ color: #495057;
+}
+
+pre {
+ margin: 0;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ color: #495057;
+ font-size: 0.9rem;
+}
diff --git a/apps/rxjs/38-catch-error/src/app/app.component.ts b/apps/rxjs/38-catch-error/src/app/app.component.ts
index 65e177567..fc722835d 100644
--- a/apps/rxjs/38-catch-error/src/app/app.component.ts
+++ b/apps/rxjs/38-catch-error/src/app/app.component.ts
@@ -1,60 +1,94 @@
import { CommonModule } from '@angular/common';
-import { HttpClient } from '@angular/common/http';
-import { Component, DestroyRef, OnInit, inject } from '@angular/core';
+import { HttpClient, HttpErrorResponse } from '@angular/common/http';
+import { Component, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
-import { Subject, concatMap, map } from 'rxjs';
+import { Subject, catchError, concatMap, map, of } from 'rxjs';
+
+interface ApiResponse {
+ data?: unknown;
+ error?: string;
+ status: 'success' | 'error';
+}
@Component({
+ standalone: true,
imports: [CommonModule, FormsModule],
selector: 'app-root',
template: `
-
-
- possible values: posts, comments, albums, photos, todos, users
-
-
-
-
- {{ response | json }}
+
+
+
API Data Fetcher
+
+
+
Available Endpoints:
+
+
+ {{ endpoint }}
+
+
+
+
+
+
+
+
{{ response.status === 'success' ? 'Response' : 'Error' }}
+
{{ response.data || response.error | json }}
+
+
`,
styleUrls: ['./app.component.css'],
})
-export class AppComponent implements OnInit {
+export class AppComponent {
submit$ = new Subject
();
input = '';
- response: unknown;
+ response?: ApiResponse;
+
+ validEndpoints = ['posts', 'comments', 'albums', 'photos', 'todos', 'users'];
private destroyRef = inject(DestroyRef);
private http = inject(HttpClient);
- ngOnInit() {
+ constructor() {
this.submit$
.pipe(
- map(() => this.input),
+ map(() => this.input.trim()),
concatMap((value) =>
- this.http.get(`https://jsonplaceholder.typicode.com/${value}/1`),
+ this.http.get(`https://jsonplaceholder.typicode.com/${value}/1`).pipe(
+ map(
+ (data): ApiResponse => ({
+ data,
+ status: 'success',
+ }),
+ ),
+ catchError((error: HttpErrorResponse) =>
+ of({
+ error: `Failed to fetch data: ${error.message}`,
+ status: 'error' as const,
+ }),
+ ),
+ ),
),
takeUntilDestroyed(this.destroyRef),
)
- .subscribe({
- next: (value) => {
- console.log(value);
- this.response = value;
- },
- error: (error) => {
- console.log(error);
- this.response = error;
- },
- complete: () => console.log('done'),
+ .subscribe((response) => {
+ console.log(response);
+ this.response = response;
});
}
}
diff --git a/apps/rxjs/49-hold-to-save-button/src/app/app.component.ts b/apps/rxjs/49-hold-to-save-button/src/app/app.component.ts
index 8f0dbbc70..4af1110fc 100644
--- a/apps/rxjs/49-hold-to-save-button/src/app/app.component.ts
+++ b/apps/rxjs/49-hold-to-save-button/src/app/app.component.ts
@@ -1,25 +1,72 @@
-import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
+import { HoldableDirective } from './holdable.directive';
@Component({
- imports: [],
+ standalone: true,
+ imports: [HoldableDirective, CommonModule],
selector: 'app-root',
template: `
-
+
+
+
Hold to Save
+
Hold the button for 5 seconds to save
+
+
-
+
+
+
+ {{ progress().toFixed(0) }}% Complete
+
+
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
- onSend() {
- console.log('Save it!');
+ progress = signal(0);
+ saving = signal(false);
+
+ buttonClasses = () => {
+ const base =
+ 'rounded px-6 py-3 font-semibold text-white transition-all duration-200 transform';
+ const state = this.saving()
+ ? 'bg-green-500 cursor-not-allowed opacity-75'
+ : this.progress() > 0
+ ? 'bg-indigo-700 scale-95'
+ : 'bg-indigo-600 hover:bg-indigo-700 active:scale-95';
+
+ return `${base} ${state}`;
+ };
+
+ onSave() {
+ this.saving.set(true);
+ // Simulate API call
+ setTimeout(() => {
+ console.log('Saved successfully!');
+ this.saving.set(false);
+ this.progress.set(0);
+ }, 1000);
}
}
diff --git a/apps/rxjs/49-hold-to-save-button/src/app/holdable.directive.ts b/apps/rxjs/49-hold-to-save-button/src/app/holdable.directive.ts
new file mode 100644
index 000000000..999c69a7b
--- /dev/null
+++ b/apps/rxjs/49-hold-to-save-button/src/app/holdable.directive.ts
@@ -0,0 +1,62 @@
+import {
+ Directive,
+ EventEmitter,
+ HostListener,
+ Input,
+ Output,
+} from '@angular/core';
+import { Subject, interval, map, takeUntil, tap } from 'rxjs';
+
+@Directive({
+ selector: '[holdable]',
+ standalone: true,
+})
+export class HoldableDirective {
+ @Input() holdDuration = 1000; // Default 1 second
+ @Output() holdProgress = new EventEmitter();
+ @Output() holdComplete = new EventEmitter();
+
+ private holding = false;
+ private cancel$ = new Subject();
+
+ @HostListener('mousedown')
+ @HostListener('touchstart')
+ onHoldStart() {
+ this.holding = true;
+ this.startHoldTimer();
+ }
+
+ @HostListener('mouseup')
+ @HostListener('mouseleave')
+ @HostListener('touchend')
+ @HostListener('touchcancel')
+ onHoldEnd() {
+ this.holding = false;
+ this.cancel$.next();
+ this.holdProgress.emit(0);
+ }
+
+ private startHoldTimer() {
+ const startTime = Date.now();
+
+ interval(10)
+ .pipe(
+ takeUntil(this.cancel$),
+ map(() => {
+ const elapsedTime = Date.now() - startTime;
+ return (elapsedTime / this.holdDuration) * 100;
+ }),
+ tap((progress) => {
+ if (progress >= 100 && this.holding) {
+ this.holdComplete.emit();
+ this.cancel$.next();
+ }
+ }),
+ )
+ .subscribe((progress) => {
+ if (this.holding) {
+ this.holdProgress.emit(Math.min(progress, 100));
+ }
+ });
+ }
+}
diff --git a/apps/rxjs/49-hold-to-save-button/src/styles.scss b/apps/rxjs/49-hold-to-save-button/src/styles.scss
index c98dac907..b9456c94b 100644
--- a/apps/rxjs/49-hold-to-save-button/src/styles.scss
+++ b/apps/rxjs/49-hold-to-save-button/src/styles.scss
@@ -3,12 +3,41 @@
@tailwind utilities;
progress {
- @apply h-4 w-full rounded-full bg-gray-200;
+ @apply h-2 w-full rounded-full bg-gray-200;
+ transition: all 0.1s ease-out;
}
+
progress::-webkit-progress-bar {
@apply rounded-full bg-gray-200;
}
progress::-webkit-progress-value {
@apply rounded-full bg-indigo-600;
+ transition: all 0.1s ease-out;
+}
+
+progress::-moz-progress-bar {
+ @apply rounded-full bg-indigo-600;
+ transition: all 0.1s ease-out;
+}
+
+/* Add smooth transitions */
+.transition-transform {
+ transition-property: transform;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 150ms;
+}
+
+/* Add pulsing animation for saving state */
+@keyframes pulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.7;
+ }
+}
+
+.animate-pulse {
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
diff --git a/apps/signal/30-interop-rxjs-signal/src/app/list/photos.component.ts b/apps/signal/30-interop-rxjs-signal/src/app/list/photos.component.ts
index f81361957..7ffd519a5 100644
--- a/apps/signal/30-interop-rxjs-signal/src/app/list/photos.component.ts
+++ b/apps/signal/30-interop-rxjs-signal/src/app/list/photos.component.ts
@@ -1,18 +1,18 @@
import { NgFor, NgIf } from '@angular/common';
-import { Component, OnInit, inject } from '@angular/core';
+import { Component, inject } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { RouterLinkWithHref } from '@angular/router';
-import { LetDirective } from '@ngrx/component';
-import { provideComponentStore } from '@ngrx/component-store';
-import { debounceTime, distinctUntilChanged, skipWhile, tap } from 'rxjs';
+import { debounceTime, distinctUntilChanged } from 'rxjs';
import { Photo } from '../photo.model';
import { PhotoStore } from './photos.store';
@Component({
selector: 'app-photos',
+ standalone: true,
imports: [
ReactiveFormsModule,
MatFormFieldModule,
@@ -20,9 +20,9 @@ import { PhotoStore } from './photos.store';
NgIf,
NgFor,
MatInputModule,
- LetDirective,
RouterLinkWithHref,
],
+ providers: [PhotoStore],
template: `
Photos
@@ -35,77 +35,66 @@ import { PhotoStore } from './photos.store';
placeholder="find a photo" />
-
-
-
-
-
- Page :{{ vm.page }} / {{ vm.pages }}
-
-
- 0; else noPhoto">
- -
-
-
-
-
-
-
- No Photos found. Type a search word.
-
-
+
+
+
+
+ Page: {{ store.page() }} / {{ store.pages() }}
-
+
+
+
+
+
+
+ No Photos found. Type a search word.
+
+
+
+
`,
- providers: [provideComponentStore(PhotoStore)],
host: {
class: 'p-5 block',
},
})
-export default class PhotosComponent implements OnInit {
+export default class PhotosComponent {
store = inject(PhotoStore);
- readonly vm$ = this.store.vm$.pipe(
- tap(({ search }) => {
- if (!this.formInit) {
- this.search.setValue(search);
- this.formInit = true;
- }
- }),
- );
-
- private formInit = false;
- search = new FormControl();
+ search = new FormControl(this.store.search());
- ngOnInit(): void {
- this.store.search(
- this.search.valueChanges.pipe(
- skipWhile(() => !this.formInit),
- debounceTime(300),
- distinctUntilChanged(),
- ),
- );
+ constructor() {
+ this.search.valueChanges
+ .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed())
+ .subscribe((value) => {
+ if (value) this.store.updateSearch(value);
+ });
}
trackById(index: number, photo: Photo) {
diff --git a/apps/signal/30-interop-rxjs-signal/src/app/list/photos.store.ts b/apps/signal/30-interop-rxjs-signal/src/app/list/photos.store.ts
index f1315e87e..20a3439a6 100644
--- a/apps/signal/30-interop-rxjs-signal/src/app/list/photos.store.ts
+++ b/apps/signal/30-interop-rxjs-signal/src/app/list/photos.store.ts
@@ -1,18 +1,12 @@
-import { inject, Injectable } from '@angular/core';
-import {
- ComponentStore,
- OnStateInit,
- OnStoreInit,
-} from '@ngrx/component-store';
+import { computed, inject, Injectable, signal } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { tapResponse } from '@ngrx/operators';
-import { pipe } from 'rxjs';
-import { filter, mergeMap, tap } from 'rxjs/operators';
import { Photo } from '../photo.model';
import { PhotoService } from '../photos.service';
const PHOTO_STATE_KEY = 'photo_search';
-export interface PhotoState {
+interface PhotoState {
photos: Photo[];
search: string;
page: number;
@@ -31,105 +25,97 @@ const initialState: PhotoState = {
};
@Injectable()
-export class PhotoStore
- extends ComponentStore
- implements OnStoreInit, OnStateInit
-{
+export class PhotoStore {
private photoService = inject(PhotoService);
- private readonly photos$ = this.select((s) => s.photos);
- private readonly search$ = this.select((s) => s.search);
- private readonly page$ = this.select((s) => s.page);
- private readonly pages$ = this.select((s) => s.pages);
- private readonly error$ = this.select((s) => s.error);
- private readonly loading$ = this.select((s) => s.loading);
+ // Convert state to signals
+ private state = signal(this.loadInitialState());
- private readonly endOfPage$ = this.select(
- this.page$,
- this.pages$,
- (page, pages) => page === pages,
- );
+ // Computed values
+ readonly photos = computed(() => this.state().photos);
+ readonly search = computed(() => this.state().search);
+ readonly page = computed(() => this.state().page);
+ readonly pages = computed(() => this.state().pages);
+ readonly loading = computed(() => this.state().loading);
+ readonly error = computed(() => this.state().error);
+ readonly endOfPage = computed(() => this.page() === this.pages());
- readonly vm$ = this.select(
- {
- photos: this.photos$,
- search: this.search$,
- page: this.page$,
- pages: this.pages$,
- endOfPage: this.endOfPage$,
- loading: this.loading$,
- error: this.error$,
- },
- { debounce: true },
- );
+ constructor() {
+ // Initialize search on startup
+ this.searchPhotos();
+ }
- ngrxOnStoreInit() {
+ private loadInitialState(): PhotoState {
const savedJSONState = localStorage.getItem(PHOTO_STATE_KEY);
- if (savedJSONState === null) {
- this.setState(initialState);
- } else {
- const savedState = JSON.parse(savedJSONState);
- this.setState({
- ...initialState,
- search: savedState.search,
- page: savedState.page,
- });
- }
- }
+ if (!savedJSONState) return initialState;
- ngrxOnStateInit() {
- this.searchPhotos(
- this.select({
- search: this.search$,
- page: this.page$,
- }),
- );
+ const savedState = JSON.parse(savedJSONState);
+ return {
+ ...initialState,
+ search: savedState.search,
+ page: savedState.page,
+ };
}
- readonly search = this.updater(
- (state, search: string): PhotoState => ({
+ updateSearch(search: string) {
+ this.state.update((state) => ({
...state,
search,
page: 1,
- }),
- );
+ }));
+ this.searchPhotos();
+ }
- readonly nextPage = this.updater(
- (state): PhotoState => ({
+ nextPage() {
+ if (this.endOfPage()) return;
+ this.state.update((state) => ({
...state,
page: state.page + 1,
- }),
- );
+ }));
+ this.searchPhotos();
+ }
- readonly previousPage = this.updater(
- (state): PhotoState => ({
+ previousPage() {
+ if (this.page() === 1) return;
+ this.state.update((state) => ({
...state,
page: state.page - 1,
- }),
- );
+ }));
+ this.searchPhotos();
+ }
+
+ private searchPhotos() {
+ const { search, page } = this.state();
+ if (search.length < 3) return;
- readonly searchPhotos = this.effect<{ search: string; page: number }>(
- pipe(
- filter(({ search }) => search.length >= 3),
- tap(() => this.patchState({ loading: true, error: '' })),
- mergeMap(({ search, page }) =>
- this.photoService.searchPublicPhotos(search, page).pipe(
- tapResponse(
- ({ photos: { photo, pages } }) => {
- this.patchState({
- loading: false,
- photos: photo,
- pages,
- });
- localStorage.setItem(
- PHOTO_STATE_KEY,
- JSON.stringify({ search, page }),
- );
- },
- (error: unknown) => this.patchState({ error, loading: false }),
- ),
+ this.state.update((state) => ({ ...state, loading: true, error: '' }));
+
+ this.photoService
+ .searchPublicPhotos(search, page)
+ .pipe(
+ takeUntilDestroyed(),
+ tapResponse(
+ ({ photos: { photo, pages } }) => {
+ this.state.update((state) => ({
+ ...state,
+ loading: false,
+ photos: photo,
+ pages,
+ }));
+ localStorage.setItem(
+ PHOTO_STATE_KEY,
+ JSON.stringify({ search, page }),
+ );
+ },
+ (error: unknown) => {
+ this.state.update((state) => ({
+ ...state,
+ error,
+ loading: false,
+ }));
+ },
),
- ),
- ),
- );
+ )
+ .subscribe();
+ }
}
diff --git a/apps/signal/43-signal-input/src/app/app.component.ts b/apps/signal/43-signal-input/src/app/app.component.ts
index 5c2deb684..3709abe87 100644
--- a/apps/signal/43-signal-input/src/app/app.component.ts
+++ b/apps/signal/43-signal-input/src/app/app.component.ts
@@ -2,40 +2,55 @@ import { Component } from '@angular/core';
import { UserComponent } from './user.component';
@Component({
+ standalone: true,
imports: [UserComponent],
selector: 'app-root',
template: `
-
-
- Name:
-
- @if (showUser && !name.value) {
-
name required
- }
-
-
- LastName:
-
-
-
- Age:
-
-
-
+
+
+ Tennis Player Registration
+
+
+
+
+ @if (showUser && !!name.value) {
+
+ }
- @if (showUser && !!name.value) {
-
- }
`,
host: {
- class: 'p-10 block flex flex-col gap-10',
+ class: 'block p-6 min-h-screen bg-gray-50',
},
})
export class AppComponent {
diff --git a/apps/signal/43-signal-input/src/app/user.component.ts b/apps/signal/43-signal-input/src/app/user.component.ts
index 908f952c3..b5b9eb6bb 100644
--- a/apps/signal/43-signal-input/src/app/user.component.ts
+++ b/apps/signal/43-signal-input/src/app/user.component.ts
@@ -2,8 +2,8 @@ import { TitleCasePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
- Input,
- OnChanges,
+ computed,
+ input,
} from '@angular/core';
type Category = 'Youth' | 'Junior' | 'Open' | 'Senior';
@@ -16,25 +16,21 @@ const ageToCategory = (age: number): Category => {
@Component({
selector: 'app-user',
+ standalone: true,
imports: [TitleCasePipe],
template: `
- {{ fullName | titlecase }} plays tennis in the {{ category }} category!!
+ {{ fullName() | titlecase }} plays tennis in the {{ category() }} category!!
`,
host: {
class: 'text-xl text-green-800',
},
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class UserComponent implements OnChanges {
- @Input({ required: true }) name!: string;
- @Input() lastName?: string;
- @Input() age?: string;
+export class UserComponent {
+ name = input.required
();
+ lastName = input('');
+ age = input('0');
- fullName = '';
- category: Category = 'Junior';
-
- ngOnChanges(): void {
- this.fullName = `${this.name} ${this.lastName ?? ''}`;
- this.category = ageToCategory(Number(this.age) ?? 0);
- }
+ category = computed(() => ageToCategory(Number(this.age())));
+ fullName = computed(() => `${this.name()} ${this.lastName()}`);
}
diff --git a/apps/signal/43-signal-input/src/styles.scss b/apps/signal/43-signal-input/src/styles.scss
index 77e408aa8..4fc952a59 100644
--- a/apps/signal/43-signal-input/src/styles.scss
+++ b/apps/signal/43-signal-input/src/styles.scss
@@ -3,3 +3,31 @@
@tailwind utilities;
/* You can add global styles to this file, and also import other style files */
+
+@layer components {
+ .form-input {
+ @apply mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500;
+ }
+
+ .form-label {
+ @apply block text-sm font-medium text-gray-700;
+ }
+
+ .form-group {
+ @apply space-y-1;
+ }
+
+ .btn-primary {
+ @apply w-full rounded-md bg-blue-600 px-4 py-2 text-white font-medium
+ hover:bg-blue-700 focus:outline-none focus:ring-2
+ focus:ring-blue-500 focus:ring-offset-2 transition-colors;
+ }
+
+ .card {
+ @apply max-w-md mx-auto bg-white p-8 rounded-lg shadow-md;
+ }
+
+ .success-box {
+ @apply mt-8 p-4 bg-green-50 rounded-md border border-green-200;
+ }
+}
diff --git a/apps/signal/50-bug-in-effect/src/app/app.component.ts b/apps/signal/50-bug-in-effect/src/app/app.component.ts
index ec6ba09b0..688dd4adc 100644
--- a/apps/signal/50-bug-in-effect/src/app/app.component.ts
+++ b/apps/signal/50-bug-in-effect/src/app/app.component.ts
@@ -1,37 +1,62 @@
import {
ChangeDetectionStrategy,
Component,
+ computed,
effect,
model,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
+ standalone: true,
imports: [FormsModule],
selector: 'app-root',
template: `
-
+
`,
+ host: {
+ class: 'block p-6 min-h-screen bg-gray-50',
+ },
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
@@ -39,14 +64,31 @@ export class AppComponent {
ram = model(false);
gpu = model(false);
+ totalExtras = computed(() => {
+ return Number(this.drive()) + Number(this.ram()) + Number(this.gpu());
+ });
+
constructor() {
- /*
- Explain for your junior team mate why this bug occurs ...
- */
+ // Fix: Track each signal independently
effect(() => {
- if (this.drive() || this.ram() || this.gpu()) {
+ // Force effect to track each signal individually
+ const hasExtras = [this.drive(), this.ram(), this.gpu()].some(Boolean);
+ if (hasExtras) {
alert('Price increased!');
}
});
+
+ /* Bug explanation for junior dev:
+ The original implementation had a logical OR operation (||) which short-circuits.
+ This means if this.drive() is true, the other signals (ram, gpu) aren't tracked
+ by the effect because JavaScript stops evaluating the OR expression after finding
+ the first true value.
+
+ To fix this, we need to ensure all signals are tracked by the effect system.
+ We can do this by either:
+ 1. Evaluating each signal separately before the logical operation
+ 2. Using an array and some() method (current solution)
+ 3. Using a computed signal (bonus solution)
+ */
}
}
diff --git a/apps/signal/50-bug-in-effect/src/styles.scss b/apps/signal/50-bug-in-effect/src/styles.scss
index 77e408aa8..f4462a483 100644
--- a/apps/signal/50-bug-in-effect/src/styles.scss
+++ b/apps/signal/50-bug-in-effect/src/styles.scss
@@ -3,3 +3,46 @@
@tailwind utilities;
/* You can add global styles to this file, and also import other style files */
+
+@layer components {
+ .card {
+ @apply max-w-md mx-auto bg-white p-8 rounded-lg shadow-md;
+ }
+
+ .product-header {
+ @apply flex justify-between items-center mb-6 pb-4 border-b border-gray-200;
+ }
+
+ .product-name {
+ @apply text-2xl font-bold text-gray-800;
+ }
+
+ .product-price {
+ @apply text-xl font-semibold text-blue-600;
+ }
+
+ .extras-section {
+ @apply space-y-4;
+ }
+
+ .extras-title {
+ @apply text-lg font-medium text-gray-700 mb-3;
+ }
+
+ .checkbox-group {
+ @apply flex items-center space-x-3 py-2 px-4 rounded-md hover:bg-gray-50 transition-colors;
+ }
+
+ .checkbox-input {
+ @apply w-4 h-4 text-blue-600 rounded border-gray-300
+ focus:ring-blue-500 focus:ring-2;
+ }
+
+ .checkbox-label {
+ @apply text-gray-700;
+ }
+
+ .total-extras {
+ @apply mt-6 pt-4 border-t border-gray-200 text-right text-sm text-gray-600;
+ }
+}
diff --git a/apps/typescript/15-function-overload/README.md b/apps/typescript/15-function-overload/README.md
index 96ce65e9d..06429e116 100644
--- a/apps/typescript/15-function-overload/README.md
+++ b/apps/typescript/15-function-overload/README.md
@@ -5,7 +5,7 @@
### Run Application
```bash
-npx nx serve typescript-function-overload`
+npx nx serve typescript-function-overload
```
### Documentation and Instruction
diff --git a/apps/typescript/15-function-overload/src/app/app.component.ts b/apps/typescript/15-function-overload/src/app/app.component.ts
index 3ea2a7131..2432e35e4 100644
--- a/apps/typescript/15-function-overload/src/app/app.component.ts
+++ b/apps/typescript/15-function-overload/src/app/app.component.ts
@@ -4,12 +4,73 @@ import { createVehicle } from './vehicle.utils';
@Component({
standalone: true,
selector: 'app-root',
- template: ``,
+ template: `
+
+
Vehicle Types
+
+
+
+
Car
+
Type: {{ car.type }}
+
Fuel: {{ car.fuel }}
+
+
+
+
Motorcycle
+
Type: {{ moto.type }}
+
Fuel: {{ moto.fuel }}
+
+
+
+
Bus
+
Type: {{ bus.type }}
+
Capacity: {{ bus.capacity }}
+
Public Transport: {{ bus.isPublicTransport ? 'Yes' : 'No' }}
+
+
+
+
Boat
+
Type: {{ boat.type }}
+
Capacity: {{ boat.capacity }}
+
+
+
+
Bicycle
+
Type: {{ bicycle.type }}
+
+
+
+ `,
+ styles: `
+ .container {
+ @apply mx-auto max-w-4xl p-8;
+ }
+
+ h1 {
+ @apply mb-8 text-center text-3xl font-bold text-gray-800;
+ }
+
+ .vehicle-grid {
+ @apply grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3;
+ }
+
+ .vehicle-card {
+ @apply rounded-lg border border-gray-200 bg-white p-6 shadow-md;
+
+ h2 {
+ @apply mb-4 text-xl font-semibold text-indigo-600;
+ }
+
+ p {
+ @apply mb-2 text-gray-600;
+ }
+ }
+ `,
})
export class AppComponent {
car = createVehicle('car', 'diesel');
moto = createVehicle('moto', 'diesel');
- bus = createVehicle('bus', undefined, 20);
- boat = createVehicle('boat', undefined, 300, true);
+ bus = createVehicle('bus', undefined, 20, false);
+ boat = createVehicle('boat', undefined, 300);
bicycle = createVehicle('bicycle');
}
diff --git a/apps/typescript/15-function-overload/src/app/vehicle.utils.ts b/apps/typescript/15-function-overload/src/app/vehicle.utils.ts
index bec95c08d..a33b0967f 100644
--- a/apps/typescript/15-function-overload/src/app/vehicle.utils.ts
+++ b/apps/typescript/15-function-overload/src/app/vehicle.utils.ts
@@ -28,6 +28,21 @@ interface Boat {
type Vehicle = Bicycle | Car | Moto | Bus | Boat;
+export function createVehicle(type: 'bicycle'): Bicycle;
+export function createVehicle(type: 'car', fuel: Fuel): Car;
+export function createVehicle(type: 'moto', fuel: Fuel): Moto;
+export function createVehicle(
+ type: 'bus',
+ fuel: undefined,
+ capacity: number,
+ isPublicTransport: boolean,
+): Bus;
+export function createVehicle(
+ type: 'boat',
+ fuel: undefined,
+ capacity: number,
+): Boat;
+
export function createVehicle(
type: VehicleType,
fuel?: Fuel,
@@ -48,7 +63,7 @@ export function createVehicle(
case 'bus':
if (!capacity)
throw new Error(`capacity property is missing for type bus`);
- if (!isPublicTransport)
+ if (isPublicTransport === undefined)
throw new Error(`isPublicTransport property is missing for type bus`);
return { capacity, isPublicTransport, type };
}
diff --git a/apps/typescript/15-function-overload/src/styles.scss b/apps/typescript/15-function-overload/src/styles.scss
index 90d4ee007..d8a5173ec 100644
--- a/apps/typescript/15-function-overload/src/styles.scss
+++ b/apps/typescript/15-function-overload/src/styles.scss
@@ -1 +1,44 @@
/* You can add global styles to this file, and also import other style files */
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+body {
+ @apply min-h-screen bg-gradient-to-br from-blue-50 to-indigo-50 p-4;
+ font-family: system-ui, -apple-system, sans-serif;
+}
+
+.container {
+ @apply relative;
+
+ &::before {
+ content: '';
+ @apply absolute inset-0 -z-10 rounded-3xl bg-white/40 backdrop-blur-xl;
+ }
+}
+
+.vehicle-card {
+ @apply transition-all duration-300;
+
+ &:hover {
+ @apply -translate-y-1 shadow-lg;
+
+ h2 {
+ @apply text-indigo-700;
+ }
+ }
+
+ p {
+ @apply flex items-center gap-2;
+
+ &::before {
+ content: '•';
+ @apply text-indigo-400;
+ }
+ }
+}
+
+h1 {
+ @apply bg-gradient-to-r from-indigo-600 to-blue-500 bg-clip-text text-transparent;
+}
diff --git a/apps/typescript/47-enums-vs-union-types/src/app/app.component.ts b/apps/typescript/47-enums-vs-union-types/src/app/app.component.ts
index 05886724f..f44127b58 100644
--- a/apps/typescript/47-enums-vs-union-types/src/app/app.component.ts
+++ b/apps/typescript/47-enums-vs-union-types/src/app/app.component.ts
@@ -1,82 +1,65 @@
import { Component, computed, signal } from '@angular/core';
-enum Difficulty {
- EASY = 'easy',
- NORMAL = 'normal',
-}
+// Union type for Difficulty
+type Difficulty = 'easy' | 'normal';
-enum Direction {
- LEFT = 'left',
- RIGHT = 'right',
-}
+// Mapped type for Direction
+type Direction = { [K in 'left' | 'right']: K };
+const DIRECTION: Direction = {
+ left: 'left',
+ right: 'right',
+} as const;
@Component({
- imports: [],
+ standalone: true,
selector: 'app-root',
template: `
-
-
-
-
-
- Selected Difficulty: {{ difficultyLabel() }}
-
+
+
+
+
+
+
+ Selected Difficulty: {{ difficultyLabel() }}
+
-
-
-
-
-
- {{ directionLabel() }}
-
- `,
- styles: `
- section {
- @apply mx-auto my-5 flex w-fit flex-col items-center gap-2;
-
- > div {
- @apply flex w-fit gap-5;
- }
- }
-
- button {
- @apply rounded-md border px-4 py-2;
- }
+
+
+
+
+
+ {{ directionLabel() }}
+
+
`,
})
export class AppComponent {
- readonly Difficulty = Difficulty;
- readonly difficulty = signal(Difficulty.EASY);
-
- readonly Direction = Direction;
- readonly direction = signal(undefined);
+ readonly DIRECTION = DIRECTION;
+ readonly difficulty = signal('easy');
+ readonly direction = signal(
+ undefined,
+ );
readonly difficultyLabel = computed(() => {
- switch (this.difficulty()) {
- case Difficulty.EASY:
- return Difficulty.EASY;
- case Difficulty.NORMAL:
- return Difficulty.NORMAL;
- }
+ return this.difficulty();
});
readonly directionLabel = computed(() => {
const prefix = 'You chose to go';
- switch (this.direction()) {
- case Direction.LEFT:
- return `${prefix} ${Direction.LEFT}`;
- case Direction.RIGHT:
- return `${prefix} ${Direction.RIGHT}`;
- default:
- return 'Choose a direction!';
+ const currentDirection = this.direction();
+
+ if (!currentDirection) {
+ return 'Choose a direction!';
}
+
+ return `${prefix} ${currentDirection}`;
});
}
diff --git a/apps/typescript/47-enums-vs-union-types/src/styles.scss b/apps/typescript/47-enums-vs-union-types/src/styles.scss
index 77e408aa8..c110e9da0 100644
--- a/apps/typescript/47-enums-vs-union-types/src/styles.scss
+++ b/apps/typescript/47-enums-vs-union-types/src/styles.scss
@@ -2,4 +2,51 @@
@tailwind components;
@tailwind utilities;
+body {
+ @apply min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 p-8;
+ font-family:
+ system-ui,
+ -apple-system,
+ sans-serif;
+}
+
+button {
+ @apply transform transition-all duration-200 hover:scale-105 active:scale-95;
+
+ &[mat-stroked-button] {
+ @apply border-2 border-indigo-500 bg-white px-6 py-2 font-medium text-indigo-600 shadow-sm
+ hover:bg-indigo-50 hover:shadow-md
+ active:bg-indigo-100;
+ }
+}
+
+section {
+ @apply rounded-xl bg-white p-8 shadow-lg;
+
+ > div {
+ @apply mb-4;
+ }
+
+ p {
+ @apply text-center font-medium text-gray-700;
+ }
+}
+
+.container {
+ @apply mx-auto max-w-2xl space-y-6 py-8;
+}
+
+/* Wrap the template content */
+app-root {
+ @apply block;
+
+ > section {
+ @apply mb-8 last:mb-0;
+
+ &:hover {
+ @apply shadow-xl;
+ }
+ }
+}
+
/* You can add global styles to this file, and also import other style files */
diff --git a/libs/decoupling/brain/src/index.ts b/libs/decoupling/brain/src/index.ts
index 2bf3f9a23..8eb5b50e1 100644
--- a/libs/decoupling/brain/src/index.ts
+++ b/libs/decoupling/brain/src/index.ts
@@ -1,4 +1 @@
-export {
- BtnDisabledDirective,
- ButtonState,
-} from './lib/button-disabled.directive';
+export { BtnDisabledDirective } from './lib/button-disabled.directive';
diff --git a/libs/decoupling/brain/src/lib/button-disabled.directive.ts b/libs/decoupling/brain/src/lib/button-disabled.directive.ts
index e7a7f4525..33ba6cbb8 100644
--- a/libs/decoupling/brain/src/lib/button-disabled.directive.ts
+++ b/libs/decoupling/brain/src/lib/button-disabled.directive.ts
@@ -1,18 +1,27 @@
/* eslint-disable @angular-eslint/directive-selector */
/* eslint-disable @angular-eslint/no-host-metadata-property */
+import {
+ BUTTON_STATE,
+ ButtonState,
+ ButtonStateControl,
+} from '@angular-challenges/decoupling/core';
import { Directive, WritableSignal, signal } from '@angular/core';
-export type ButtonState = 'enabled' | 'disabled';
-
@Directive({
selector: 'button[btnDisabled]',
standalone: true,
host: {
'(click)': 'toggleState()',
},
+ providers: [
+ {
+ provide: BUTTON_STATE,
+ useExisting: BtnDisabledDirective,
+ },
+ ],
})
-export class BtnDisabledDirective {
- state: WritableSignal = signal('enabled');
+export class BtnDisabledDirective implements ButtonStateControl {
+ readonly state: WritableSignal = signal('enabled');
toggleState() {
this.state.set(this.state() === 'enabled' ? 'disabled' : 'enabled');
diff --git a/libs/decoupling/core/src/index.ts b/libs/decoupling/core/src/index.ts
index e69de29bb..473203f05 100644
--- a/libs/decoupling/core/src/index.ts
+++ b/libs/decoupling/core/src/index.ts
@@ -0,0 +1,2 @@
+export * from './lib/button.interface';
+export * from './lib/button.token';
diff --git a/libs/decoupling/core/src/lib/button.interface.ts b/libs/decoupling/core/src/lib/button.interface.ts
new file mode 100644
index 000000000..b977bdd96
--- /dev/null
+++ b/libs/decoupling/core/src/lib/button.interface.ts
@@ -0,0 +1,5 @@
+export type ButtonState = 'enabled' | 'disabled';
+
+export interface ButtonStateControl {
+ state: import('@angular/core').Signal;
+}
diff --git a/libs/decoupling/core/src/lib/button.token.ts b/libs/decoupling/core/src/lib/button.token.ts
new file mode 100644
index 000000000..7321c795b
--- /dev/null
+++ b/libs/decoupling/core/src/lib/button.token.ts
@@ -0,0 +1,6 @@
+import { InjectionToken } from '@angular/core';
+import { ButtonStateControl } from './button.interface';
+
+export const BUTTON_STATE = new InjectionToken(
+ 'BUTTON_STATE',
+);
diff --git a/libs/decoupling/helmet/src/lib/btn-style.directive.ts b/libs/decoupling/helmet/src/lib/btn-style.directive.ts
index 50a65b107..0c408e832 100644
--- a/libs/decoupling/helmet/src/lib/btn-style.directive.ts
+++ b/libs/decoupling/helmet/src/lib/btn-style.directive.ts
@@ -1,12 +1,14 @@
/* eslint-disable @angular-eslint/directive-selector */
-import { BtnDisabledDirective } from '@angular-challenges/decoupling/brain';
+import {
+ BUTTON_STATE,
+ ButtonStateControl,
+} from '@angular-challenges/decoupling/core';
import {
Directive,
ElementRef,
Renderer2,
effect,
inject,
- signal,
} from '@angular/core';
@Directive({
@@ -18,8 +20,7 @@ import {
},
})
export class BtnHelmetDirective {
- btnState = inject(BtnDisabledDirective, { self: true });
- public state = this.btnState?.state ?? signal('disabled').asReadonly();
+ private btnState = inject(BUTTON_STATE);
private renderer = inject(Renderer2);
private element = inject(ElementRef);
@@ -27,7 +28,7 @@ export class BtnHelmetDirective {
this.renderer.setAttribute(
this.element.nativeElement,
'data-state',
- this.state(),
+ this.btnState.state(),
);
});
}
diff --git a/package-lock.json b/package-lock.json
index 3b3a587ce..7434940f5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -73,12 +73,14 @@
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.13",
"@types/node": "18.16.9",
+ "@types/react": "^19.0.8",
+ "@types/react-dom": "^19.0.3",
"@typescript-eslint/eslint-plugin": "7.16.1",
"@typescript-eslint/parser": "7.16.1",
"@typescript-eslint/utils": "^7.16.0",
"all-contributors-cli": "^6.26.1",
"autoprefixer": "^10.4.0",
- "cypress": "^14.0.0",
+ "cypress": "^14.0.2",
"eslint": "8.57.0",
"eslint-config-prettier": "9.0.0",
"eslint-plugin-cypress": "2.15.1",
@@ -9859,6 +9861,24 @@
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"license": "MIT"
},
+ "node_modules/@types/react": {
+ "version": "19.0.8",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz",
+ "integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==",
+ "dev": true,
+ "dependencies": {
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.0.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.3.tgz",
+ "integrity": "sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==",
+ "dev": true,
+ "peerDependencies": {
+ "@types/react": "^19.0.0"
+ }
+ },
"node_modules/@types/retry": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
@@ -13813,6 +13833,12 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "dev": true
+ },
"node_modules/cuint": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz",
@@ -13821,12 +13847,11 @@
"license": "MIT"
},
"node_modules/cypress": {
- "version": "14.0.0",
- "resolved": "https://registry.npmjs.org/cypress/-/cypress-14.0.0.tgz",
- "integrity": "sha512-kEGqQr23so5IpKeg/dp6GVi7RlHx1NmW66o2a2Q4wk9gRaAblLZQSiZJuDI8UMC4LlG5OJ7Q6joAiqTrfRNbTw==",
+ "version": "14.0.2",
+ "resolved": "https://registry.npmjs.org/cypress/-/cypress-14.0.2.tgz",
+ "integrity": "sha512-3qqTU2JoVY262qkYg9I2nohwxcfsJk0dSVp/LXAjD94Jz2y6411Mf/l5uHEHiaANrOmMcHbzYgOd/ueDsZlS7A==",
"dev": true,
"hasInstallScript": true,
- "license": "MIT",
"dependencies": {
"@cypress/request": "^3.0.6",
"@cypress/xvfb": "^1.2.4",
diff --git a/package.json b/package.json
index 63dbb87e5..104f06bb0 100644
--- a/package.json
+++ b/package.json
@@ -76,12 +76,14 @@
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.13",
"@types/node": "18.16.9",
+ "@types/react": "^19.0.8",
+ "@types/react-dom": "^19.0.3",
"@typescript-eslint/eslint-plugin": "7.16.1",
"@typescript-eslint/parser": "7.16.1",
"@typescript-eslint/utils": "^7.16.0",
"all-contributors-cli": "^6.26.1",
"autoprefixer": "^10.4.0",
- "cypress": "14.0.0",
+ "cypress": "^14.0.2",
"eslint": "8.57.0",
"eslint-config-prettier": "9.0.0",
"eslint-plugin-cypress": "2.15.1",