diff --git a/apps/testing-todos-list/project.json b/apps/testing-todos-list/project.json index a443848..4ebaa72 100644 --- a/apps/testing-todos-list/project.json +++ b/apps/testing-todos-list/project.json @@ -19,7 +19,10 @@ "apps/testing-todos-list/src/favicon.ico", "apps/testing-todos-list/src/assets" ], - "styles": ["apps/testing-todos-list/src/styles.scss"], + "styles": [ + "apps/testing-todos-list/src/styles.scss", + "./node_modules/@angular/material/prebuilt-themes/indigo-pink.css" + ], "scripts": [] }, "configurations": { diff --git a/apps/testing-todos-list/src/app/app.component.spec.ts b/apps/testing-todos-list/src/app/app.component.spec.ts deleted file mode 100644 index ceb7fb3..0000000 --- a/apps/testing-todos-list/src/app/app.component.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { AppComponent } from './app.component'; -import { NxWelcomeComponent } from './nx-welcome.component'; - -describe('AppComponent', () => { - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [AppComponent, NxWelcomeComponent], - }).compileComponents(); - }); - - it('should create the app', () => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.componentInstance; - expect(app).toBeTruthy(); - }); - - it(`should have as title 'testing-todos-list'`, () => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.componentInstance; - expect(app.title).toEqual('testing-todos-list'); - }); - - it('should render title', () => { - const fixture = TestBed.createComponent(AppComponent); - fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain( - 'Welcome testing-todos-list' - ); - }); -}); diff --git a/apps/testing-todos-list/src/app/app.component.ts b/apps/testing-todos-list/src/app/app.component.ts index 3ea12b3..a3de393 100644 --- a/apps/testing-todos-list/src/app/app.component.ts +++ b/apps/testing-todos-list/src/app/app.component.ts @@ -1,13 +1,10 @@ -import { NxWelcomeComponent } from './nx-welcome.component'; import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; @Component({ - standalone: true, - imports: [NxWelcomeComponent], selector: 'app-root', - template: ` `, - styles: [], + standalone: true, + imports: [RouterOutlet], + template: ``, }) -export class AppComponent { - title = 'testing-todos-list'; -} +export class AppComponent {} diff --git a/apps/testing-todos-list/src/app/app.route.ts b/apps/testing-todos-list/src/app/app.route.ts new file mode 100644 index 0000000..f9104ef --- /dev/null +++ b/apps/testing-todos-list/src/app/app.route.ts @@ -0,0 +1,25 @@ +import { Routes } from '@angular/router'; + +export const PARAM_TICKET_ID = 'ticketId'; + +export const APP_ROUTES: Routes = [ + { + path: '', + pathMatch: 'full', + redirectTo: 'list', + }, + { + path: 'list', + loadComponent: () => + import('./list/list.component').then((m) => m.ListComponent), + }, + { + path: `detail/:${PARAM_TICKET_ID}`, + loadComponent: () => + import('./detail/detail.component').then((m) => m.DetailComponent), + }, + { + path: '**', + redirectTo: 'list', + }, +]; diff --git a/apps/testing-todos-list/src/app/backend.service.ts b/apps/testing-todos-list/src/app/backend.service.ts new file mode 100644 index 0000000..550cb79 --- /dev/null +++ b/apps/testing-todos-list/src/app/backend.service.ts @@ -0,0 +1,109 @@ +import { Injectable } from '@angular/core'; +import { Observable, delay, of, throwError } from 'rxjs'; + +export type User = { + id: number; + name: string; +}; + +export type Ticket = { + id: number; + description: string; + assigneeId: number | null; + completed: boolean; +}; + +export type TicketUser = { + id: number; + description: string; + assignee: string; + completed: boolean; +}; + +function randomDelay() { + return Math.random() * 1000; +} + +@Injectable({ providedIn: 'root' }) +export class BackendService { + storedTickets: Ticket[] = [ + { + id: 0, + description: 'Install a monitor arm', + assigneeId: 111, + completed: false, + }, + { + id: 1, + description: 'Move the desk to the new location', + assigneeId: 111, + completed: false, + }, + ]; + + storedUsers: User[] = [ + { id: 111, name: 'Thomas' }, + { id: 222, name: 'Jack' }, + ]; + + lastId = 1; + + private findTicketById = (id: number) => + this.storedTickets.find((ticket) => ticket.id === +id); + + private findUserById = (id: number) => + this.storedUsers.find((user) => user.id === +id); + + tickets() { + return of(this.storedTickets).pipe(delay(randomDelay())); + } + + ticket(id: number): Observable { + return of(this.findTicketById(id)).pipe(delay(randomDelay())); + } + + users() { + return of(this.storedUsers).pipe(delay(randomDelay())); + } + + user(id: number) { + return of(this.findUserById(id)).pipe(delay(randomDelay())); + } + + newTicket(payload: { description: string }) { + const newTicket: Ticket = { + id: ++this.lastId, + description: payload.description, + assigneeId: null, + completed: false, + }; + + this.storedTickets = this.storedTickets.concat(newTicket); + + return of(newTicket).pipe(delay(randomDelay())); + } + + assign(ticketId: number, userId: number) { + return this.update(ticketId, { assigneeId: userId }); + } + + complete(ticketId: number, completed: boolean) { + return this.update(ticketId, { completed }); + } + + update(ticketId: number, updates: Partial>) { + const foundTicket = this.findTicketById(ticketId); + + if (!foundTicket) { + return throwError(() => new Error('ticket not found')); + } + + const updatedTicket = { ...foundTicket, ...updates }; + + this.storedTickets = this.storedTickets.map((t) => + t.id === ticketId ? updatedTicket : t + ); + + return of(updatedTicket).pipe(delay(randomDelay())); + } +} diff --git a/apps/testing-todos-list/src/app/detail/detail.component.ts b/apps/testing-todos-list/src/app/detail/detail.component.ts new file mode 100644 index 0000000..938dcc2 --- /dev/null +++ b/apps/testing-todos-list/src/app/detail/detail.component.ts @@ -0,0 +1,58 @@ +import { AsyncPipe, NgIf } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { RouterLink } from '@angular/router'; +import { LetModule } from '@ngrx/component'; +import { provideComponentStore } from '@ngrx/component-store'; +import { DetailStore } from './detail.store'; + +@Component({ + selector: 'app-detail', + standalone: true, + imports: [ + MatButtonModule, + RouterLink, + NgIf, + AsyncPipe, + MatProgressBarModule, + LetModule, + ], + template: ` +

Ticket Detail:

+ + +
+
Ticket: {{ ticket.id }}
+
+ Description: {{ ticket.description }} +
+
+ AssigneeId: {{ ticket.assigneeId }} +
+
+ Is done: {{ ticket.completed }} +
+
+
+ + + `, + providers: [provideComponentStore(DetailStore)], + host: { + class: 'p-5 block', + }, +}) +export class DetailComponent { + vm$ = inject(DetailStore).vm$; +} diff --git a/apps/testing-todos-list/src/app/detail/detail.store.ts b/apps/testing-todos-list/src/app/detail/detail.store.ts new file mode 100644 index 0000000..9d0f421 --- /dev/null +++ b/apps/testing-todos-list/src/app/detail/detail.store.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { + ComponentStore, + OnStateInit, + tapResponse, +} from '@ngrx/component-store'; +import { concatLatestFrom } from '@ngrx/effects'; +import { pipe } from 'rxjs'; +import { map, mergeMap, tap } from 'rxjs/operators'; +import { PARAM_TICKET_ID } from '../app.route'; +import { BackendService, Ticket } from '../backend.service'; + +export interface TicketState { + ticket?: Ticket; + loading: boolean; + error: unknown; +} + +const initialState: TicketState = { + loading: false, + error: '', +}; + +@Injectable() +export class DetailStore + extends ComponentStore + implements OnStateInit +{ + readonly ticket$ = this.select((state) => state.ticket); + readonly loading$ = this.select((state) => state.loading); + readonly error$ = this.select((state) => state.error); + + readonly vm$ = this.select({ + ticket: this.ticket$, + loading: this.loading$, + }); + + constructor(private backend: BackendService, private route: ActivatedRoute) { + super(initialState); + } + + readonly loadTicket = this.effect( + pipe( + concatLatestFrom(() => + this.route.params.pipe(map((p) => p[PARAM_TICKET_ID])) + ), + tap(() => this.patchState({ loading: true, error: '' })), + mergeMap(([, id]) => + this.backend.ticket(id).pipe( + tapResponse( + (ticket) => + this.patchState({ + loading: false, + ticket, + }), + (error: unknown) => this.patchState({ error }) + ) + ) + ) + ) + ); + + ngrxOnStateInit() { + this.loadTicket(); + } +} diff --git a/apps/testing-todos-list/src/app/list/list.component.spec.ts b/apps/testing-todos-list/src/app/list/list.component.spec.ts new file mode 100644 index 0000000..89a583e --- /dev/null +++ b/apps/testing-todos-list/src/app/list/list.component.spec.ts @@ -0,0 +1,42 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { BackendService } from '../backend.service'; +import { ListComponent } from './list.component'; +import { AddComponent } from './ui/add.component'; +import { RowComponent } from './ui/row.component'; + +describe('ListComponent', () => { + let component: ListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ListComponent, AddComponent, RowComponent], + imports: [ReactiveFormsModule, RouterTestingModule], + providers: [BackendService], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should search on table', async () => { + await fixture.whenStable(); + fixture.detectChanges(); + + let rows = fixture.debugElement.queryAll(By.css('.rows')); + expect(rows.length).toEqual(2); + + component.search.setValue('Install'); + fixture.detectChanges(); + await fixture.whenStable(); + + rows = fixture.debugElement.queryAll(By.css('.rows')); + expect(rows.length).toEqual(1); + }); +}); diff --git a/apps/testing-todos-list/src/app/list/list.component.ts b/apps/testing-todos-list/src/app/list/list.component.ts new file mode 100644 index 0000000..1b2fe9c --- /dev/null +++ b/apps/testing-todos-list/src/app/list/list.component.ts @@ -0,0 +1,73 @@ +import { NgFor, NgIf } from '@angular/common'; +import { Component, OnInit, inject } from '@angular/core'; +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 { LetModule } from '@ngrx/component'; +import { provideComponentStore } from '@ngrx/component-store'; +import { TicketStore } from './ticket.store'; +import { AddComponent } from './ui/add.component'; +import { RowComponent } from './ui/row.component'; + +@Component({ + selector: 'app-list', + standalone: true, + imports: [ + ReactiveFormsModule, + AddComponent, + RowComponent, + MatFormFieldModule, + MatProgressBarModule, + NgIf, + NgFor, + MatInputModule, + LetModule, + ], + template: ` +

Tickets

+ + + Search + + + + + + + +
    + +
+
+ {{ vm.error }} +
+
+ `, + providers: [provideComponentStore(TicketStore)], + host: { + class: 'p-5 block', + }, +}) +export class ListComponent implements OnInit { + private ticketStore = inject(TicketStore); + + vm$ = this.ticketStore.select({ + tickets: this.ticketStore.tickets$, + loading: this.ticketStore.loading$, + error: this.ticketStore.error$, + }); + + search = new FormControl(); + + ngOnInit(): void { + this.ticketStore.search(this.search.valueChanges); + } +} diff --git a/apps/testing-todos-list/src/app/list/ticket.store.spec.ts b/apps/testing-todos-list/src/app/list/ticket.store.spec.ts new file mode 100644 index 0000000..298b813 --- /dev/null +++ b/apps/testing-todos-list/src/app/list/ticket.store.spec.ts @@ -0,0 +1,60 @@ +import { subscribeSpyTo } from '@hirez_io/observer-spy'; +import { of, throwError } from 'rxjs'; +import { BackendService } from '../backend.service'; +import { TicketStore } from './ticket.store'; + +describe('ticketStore', () => { + let fixture: TicketStore; + let service: BackendService; + + beforeEach(() => { + service = new BackendService(); + fixture = new TicketStore(service); + }); + + describe('addTicket$', () => { + const NEW_TICKET = { + id: 1, + description: 'test 2', + assigneeId: 888, + completed: false, + }; + const tickets = [ + { + id: 0, + description: 'test', + assigneeId: 888, + completed: false, + }, + ]; + it('should add ticket with SUCCESS', () => { + spyOn(service, 'newTicket').and.returnValue(of(NEW_TICKET)); + fixture.patchState({ tickets }); + + const expectedTicket = [...tickets, NEW_TICKET]; + + fixture.addTicket('test'); + + const state = subscribeSpyTo(fixture.state$).getFirstValue(); + expect(service.newTicket).toHaveBeenCalled(); + expect(state.loading).toEqual(false); + expect(state.tickets).toEqual(expectedTicket); + }); + + it('should NOT add ticket because of service FAILURE', () => { + const ERROR = 'error'; + spyOn(service, 'newTicket').and.returnValue(throwError(ERROR)); + fixture.patchState({ tickets }); + + const expectedTicket = [...tickets]; + + fixture.addTicket('test'); + + const state = subscribeSpyTo(fixture.state$).getFirstValue(); + expect(service.newTicket).toHaveBeenCalled(); + expect(state.loading).toEqual(false); + expect(state.tickets).toEqual(expectedTicket); + expect(state.error).toEqual(ERROR); + }); + }); +}); diff --git a/apps/testing-todos-list/src/app/list/ticket.store.ts b/apps/testing-todos-list/src/app/list/ticket.store.ts new file mode 100644 index 0000000..59bf902 --- /dev/null +++ b/apps/testing-todos-list/src/app/list/ticket.store.ts @@ -0,0 +1,167 @@ +import { Injectable } from '@angular/core'; +import { + ComponentStore, + OnStateInit, + tapResponse, +} from '@ngrx/component-store'; +import { pipe } from 'rxjs'; +import { mergeMap, tap } from 'rxjs/operators'; +import { BackendService, Ticket, User } from '../backend.service'; + +export interface TicketState { + tickets: Ticket[]; + search: string; + users: User[]; + loading: boolean; + error: unknown; +} + +const initialState: TicketState = { + tickets: [], + search: '', + users: [], + loading: false, + error: '', +}; + +@Injectable() +export class TicketStore + extends ComponentStore + implements OnStateInit +{ + readonly users$ = this.select((state) => state.users); + readonly error$ = this.select((state) => state.error); + readonly loading$ = this.select((state) => state.loading); + private readonly ticketsInput$ = this.select((state) => state.tickets); + private readonly search$ = this.select((state) => state.search); + + constructor(private backend: BackendService) { + super(initialState); + } + + private readonly ticketsUsers$ = this.select( + this.users$, + this.ticketsInput$, + (users, tickets) => + users + ? tickets.map((ticket) => ({ + ...ticket, + assignee: + users.find((user) => user.id === ticket.assigneeId)?.name ?? + 'unassigned', + })) + : tickets + ); + + readonly tickets$ = this.select( + this.ticketsUsers$, + this.search$, + (tickets, search) => + tickets.filter((t) => + t.description.toLowerCase().includes(search.toLowerCase()) + ) + ); + + readonly updateAssignee = this.updater((state, ticket: Ticket) => { + const newTickets = [...state.tickets]; + const index = newTickets.findIndex((t) => t.id === ticket.id); + newTickets[index] = ticket; + return { + ...state, + loading: false, + tickets: newTickets, + }; + }); + + readonly search = this.updater((state, search: string) => ({ + ...state, + search, + })); + + readonly loadTickets = this.effect( + pipe( + tap(() => this.patchState({ loading: true, error: '' })), + mergeMap(() => + this.backend.tickets().pipe( + tapResponse( + (tickets) => + this.patchState({ + loading: false, + tickets, + }), + (error: unknown) => this.patchState({ error, loading: false }) + ) + ) + ) + ) + ); + + readonly loadUsers = this.effect( + pipe( + tap(() => this.patchState({ loading: true, error: '' })), + mergeMap(() => + this.backend.users().pipe( + tapResponse( + (users) => + this.patchState({ + loading: false, + users, + }), + (error: unknown) => this.patchState({ error, loading: false }) + ) + ) + ) + ) + ); + + readonly addTicket = this.effect( + pipe( + tap(() => this.patchState({ loading: true, error: '' })), + mergeMap((description) => + this.backend.newTicket({ description }).pipe( + tapResponse( + (newTicket) => + this.patchState((state: TicketState) => ({ + loading: false, + tickets: [...state.tickets, newTicket], + })), + (error: unknown) => this.patchState({ error, loading: false }) + ) + ) + ) + ) + ); + + readonly assignTicket = this.effect<{ userId: number; ticketId: number }>( + pipe( + tap(() => this.patchState({ loading: true, error: '' })), + mergeMap((info) => + this.backend.assign(info.ticketId, Number(info.userId)).pipe( + tapResponse( + (newTicket) => this.updateAssignee(newTicket), + (error: unknown) => this.patchState({ error, loading: false }) + ) + ) + ) + ) + ); + + readonly done = this.effect( + pipe( + tap(() => this.patchState({ loading: true, error: '' })), + mergeMap((ticketId) => + this.backend.complete(ticketId, true).pipe( + tapResponse( + (newTicket) => this.updateAssignee(newTicket), + (error: unknown) => this.patchState({ error, loading: false }) + ) + ) + ) + ) + ); + + ngrxOnStateInit() { + this.loadTickets(); + this.loadUsers(); + } +} diff --git a/apps/testing-todos-list/src/app/list/ui/add.component.ts b/apps/testing-todos-list/src/app/list/ui/add.component.ts new file mode 100644 index 0000000..8350c5b --- /dev/null +++ b/apps/testing-todos-list/src/app/list/ui/add.component.ts @@ -0,0 +1,61 @@ +import { AsyncPipe, NgIf } from '@angular/common'; +import { Component } from '@angular/core'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { TicketStore } from '../ticket.store'; + +@Component({ + selector: 'app-add', + standalone: true, + imports: [ + ReactiveFormsModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + AsyncPipe, + NgIf, + ], + template: `
+ + Description + + + Description is required + + + +
`, +}) +export class AddComponent { + form = new FormGroup({ + description: new FormControl(null, Validators.required), + }); + + loading$ = this.ticketStore.loading$; + + constructor(private ticketStore: TicketStore) {} + + submit() { + if (this.form.valid) { + this.ticketStore.addTicket(this.form.value.description ?? ''); + } + } +} diff --git a/apps/testing-todos-list/src/app/list/ui/row.component.ts b/apps/testing-todos-list/src/app/list/ui/row.component.ts new file mode 100644 index 0000000..7306ce5 --- /dev/null +++ b/apps/testing-todos-list/src/app/list/ui/row.component.ts @@ -0,0 +1,92 @@ +import { AsyncPipe, NgFor } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { RouterLink } from '@angular/router'; +import { Ticket, TicketUser } from '../../backend.service'; +import { TicketStore } from '../ticket.store'; + +@Component({ + selector: 'app-row', + standalone: true, + imports: [ + RouterLink, + ReactiveFormsModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatSelectModule, + AsyncPipe, + NgFor, + ], + template: ` +
  • + +
    +
    + + Assign to + + {{ user.name }} + + + +
    + +
    +
  • + `, + host: { + class: 'p-4 border border-blue-500 rounded flex', + }, +}) +export class RowComponent { + @Input() ticket!: TicketUser | Ticket; + + users$ = this.ticketStore.users$; + + form = new FormGroup({ + assignee: new FormControl(0, { nonNullable: true }), + }); + + constructor(private ticketStore: TicketStore) {} + + submit() { + this.ticketStore.assignTicket({ + ticketId: this.ticket.id, + userId: this.form.getRawValue().assignee, + }); + } + + done(ticketId: number) { + this.ticketStore.done(ticketId); + } +} diff --git a/apps/testing-todos-list/src/app/nx-welcome.component.ts b/apps/testing-todos-list/src/app/nx-welcome.component.ts deleted file mode 100644 index 7e34caa..0000000 --- a/apps/testing-todos-list/src/app/nx-welcome.component.ts +++ /dev/null @@ -1,801 +0,0 @@ -import { Component, ViewEncapsulation } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -/* eslint-disable */ - -@Component({ - selector: 'app-nx-welcome', - standalone: true, - imports: [CommonModule], - template: ` - - -
    -
    - -
    -

    - Hello there, - Welcome testing-todos-list 👋 -

    -
    - - -
    -
    -

    - - - - You're up and running -

    - What's next? -
    -
    - - - -
    -
    - - - - - -
    -

    Next steps

    -

    Here are some things you can do with Nx:

    -
    - - - - - Add UI library - -
    # Generate UI lib
    -nx g @nrwl/angular:lib ui
    -
    -# Add a component
    -nx g @nrwl/angular:component button --project ui
    -
    -
    - - - - - View interactive project graph - -
    nx graph
    -
    -
    - - - - - Run affected commands - -
    # see what's been affected by changes
    -nx affected:graph
    -
    -# run tests for current changes
    -nx affected:test
    -
    -# run e2e tests for current changes
    -nx affected:e2e
    -
    -
    - -

    - Carefully crafted with - - - -

    -
    -
    - `, - styles: [], - encapsulation: ViewEncapsulation.None, -}) -export class NxWelcomeComponent {} diff --git a/apps/testing-todos-list/src/main.ts b/apps/testing-todos-list/src/main.ts index 31c5da4..7fc37c6 100644 --- a/apps/testing-todos-list/src/main.ts +++ b/apps/testing-todos-list/src/main.ts @@ -1,4 +1,9 @@ import { bootstrapApplication } from '@angular/platform-browser'; +import { provideAnimations } from '@angular/platform-browser/animations'; +import { provideRouter } from '@angular/router'; import { AppComponent } from './app/app.component'; +import { APP_ROUTES } from './app/app.route'; -bootstrapApplication(AppComponent).catch((err) => console.error(err)); +bootstrapApplication(AppComponent, { + providers: [provideAnimations(), provideRouter(APP_ROUTES)], +}).catch((err) => console.error(err)); diff --git a/apps/testing-todos-list/src/styles.scss b/apps/testing-todos-list/src/styles.scss index 77e408a..6be9603 100644 --- a/apps/testing-todos-list/src/styles.scss +++ b/apps/testing-todos-list/src/styles.scss @@ -3,3 +3,11 @@ @tailwind utilities; /* You can add global styles to this file, and also import other style files */ +html, +body { + height: 100%; +} +body { + margin: 0; + font-family: Roboto, 'Helvetica Neue', sans-serif; +}