diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae27205d..c04fb83c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: - name: Test run: | if [ "${{ github.event_name }}" = "pull_request" ]; then - pnpm nx affected --target=test --base=$NX_BASE --configuration ci + pnpm nx affected --target=test --base=$NX_BASE else pnpm test:all fi @@ -80,3 +80,34 @@ jobs: name: test-results path: junit/ if-no-files-found: ignore + + - name: Coverage summary + if: always() + uses: irongut/CodeCoverageSummary@v1.3.0 + with: + filename: coverage/**/cobertura-coverage.xml + badge: true + fail_below_min: false + format: markdown + output: both + thresholds: '60 80' + + - name: Annotate coverage report + if: always() && github.event_name == 'pull_request' + run: | + echo "> [!NOTE]" > coverage-note.md + echo "> Coverage shown for **affected projects only**. Per-file thresholds (80%) are enforced by vitest." >> coverage-note.md + echo "" >> coverage-note.md + cat code-coverage-results.md >> coverage-note.md + mv coverage-note.md code-coverage-results.md + + - name: Write coverage to job summary + if: always() + run: cat code-coverage-results.md >> $GITHUB_STEP_SUMMARY + + - name: Add coverage PR comment + if: always() && github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@v2 + with: + recreate: true + path: code-coverage-results.md diff --git a/.lintstagedrc.cjs b/.lintstagedrc.cjs new file mode 100644 index 00000000..b12084b8 --- /dev/null +++ b/.lintstagedrc.cjs @@ -0,0 +1,8 @@ +module.exports = { + '*.{ts,js}': 'eslint --cache --cache-location=.husky/_ --fix', + '*.{ts,js,css,scss,md,mdx}': 'prettier --write', + 'README.md': () => [ + 'pnpm nx run web-app:update-readme', + 'git add apps/web-app/src/assets/home.md', + ], +}; diff --git a/README.md b/README.md index b2275957..c7380b52 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,98 @@ -[![Build status](https://freshpondmedia.visualstudio.com/FreshPondMediaGit/_apis/build/status/chrisjwalk.angular-cli-netcore-ngrx-starter)](https://freshpondmedia.visualstudio.com/FreshPondMediaGit/_build/latest?definitionId=43) +[![CI](https://github.com/chrisjwalk/angular-cli-netcore-ngrx-starter/actions/workflows/ci.yml/badge.svg)](https://github.com/chrisjwalk/angular-cli-netcore-ngrx-starter/actions/workflows/ci.yml) # Nx + Angular + .NET 10.0 -This is basic demo of how to use a full stack [Nx monorepo](https://nx.dev/getting-started/tutorials/angular-monorepo-tutorial) with [Angular](https://angular.dev) and .NET 10.0 with [Microsoft.AspNetCore.SpaServices.Extensions](https://docs.microsoft.com/en-us/aspnet/core/client-side/spa/angular) and a demo Azure pipeline for Azure DevOps. +A full-stack demo using an [Nx monorepo](https://nx.dev) with [Angular](https://angular.dev) (zoneless, signals) and a .NET 10.0 Web API backend. Deployed to Azure App Service with automated PR preview deployments via Azure Static Web Apps. -## Demo +## Features -See a live demo here: [https://angularclinetcorengrxstarter.azurewebsites.net/](https://angularclinetcorengrxstarter.azurewebsites.net/) +- **Authentication** — register, login, and logout with JWT bearer tokens backed by ASP.NET Core Identity +- **Notification centre** — persistent notification panel with unread count, mark-as-read, dismiss, and action support (e.g. one-click reload on SW update) +- **PWA / service worker** — offline support; notifies users when a new app version is available with an in-app prompt to reload +- **Debug page** (`/debug`) — trigger test notifications and inspect service worker update state during development +- **PR preview deployments** — every pull request gets a live preview URL via Azure Static Web Apps -## Getting Started? +## Tech stack -- **Make sure you have at least Node 24.x or higher (w/ pnpm 10+) installed!** -- **This repository uses ASP.NET 10.0, which has a hard requirement on .NET SDK 10.0.x. Please install these items from [https://dotnet.microsoft.com/download](https://dotnet.microsoft.com/download)** +**Frontend** -## Visual Studio 2022 +- [Angular 21](https://angular.dev) — zoneless change detection, standalone components, signals +- [NgRx Signal Store](https://ngrx.io/guide/signals) — reactive state management +- [Angular Material](https://material.angular.io) — UI component library +- [Tailwind CSS v4](https://tailwindcss.com) — utility-first styling +- [Angular PWA](https://angular.dev/ecosystem/service-workers) — service worker & offline support -Make sure you have .NET 10.0 installed and/or the latest VS2026. +**Backend** -## Visual Studio Code +- [.NET 10.0](https://dotnet.microsoft.com) Web API +- [ASP.NET Core Identity](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/identity) — bearer token authentication +- [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/) with Azure SQL -> Note: Make sure you have the C# extension & .NET Debugger installed. +**Tooling** - pnpm install +- [Nx](https://nx.dev) — monorepo build system with affected commands +- [Vitest](https://vitest.dev) — unit tests with ~93% line coverage +- [Playwright](https://playwright.dev) — end-to-end tests +- [Husky](https://typicode.github.io/husky/) + [lint-staged](https://github.com/lint-staged/lint-staged) — pre-commit hooks for linting, formatting, and keeping `home.md` in sync +- [pnpm](https://pnpm.io) — package manager -## Serve Development App +## Demo -``` -pnpm start -``` +Live demo: [https://angularclinetcorengrxstarter.azurewebsites.net/](https://angularclinetcorengrxstarter.azurewebsites.net/) + +## Getting started -Both the api (dotnet) and web app (Angular) will build and run in dev mode. Open your browser on http://localhost:4200/ to see the Angular app, or https://localhost:60254/swagger to see the api documentation generated by Swagger. +**Prerequisites** -## Serve Production App (PWA enabled) +- Node 24.x+ with pnpm 10+ +- .NET SDK 10.0.x — [download](https://dotnet.microsoft.com/download) +**Install dependencies** + +```bash +pnpm install ``` -pnpm serve:prod + +## Serve development app + +```bash +pnpm start ``` +Starts both the .NET API and Angular app in dev mode. Open [http://localhost:4200](http://localhost:4200) for the app, or [https://localhost:60254/swagger](https://localhost:60254/swagger) for the API docs. + ## Lint -``` +```bash pnpm lint ``` -## Unit Tests - -Run unit tests by executing: +## Unit tests -``` +```bash pnpm test ``` -Please note that for test coverage you need dotnet-coverage to be installed: +Coverage requires `dotnet-coverage`: -``` +```bash dotnet tool install --global dotnet-coverage ``` -More information is available on https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-test - -## End-to-end Tests - -Run e2e tests by executing: +## End-to-end tests -``` +```bash pnpm e2e ``` -## Build Production App - -Build the production Angular app and Publish the release .NET app, run: +## Build for production -``` +```bash pnpm build:prod ``` -The contents of the. `/dist` folder should now contain something that can be deployed to and Azure web service or IIS instance. +Builds the Angular app and publishes the .NET project to `/dist`, ready to deploy to Azure App Service. + +## Contributing + +`apps/web-app/src/assets/home.md` is auto-generated from this file — **edit `README.md` only**. The lint-staged hook regenerates `home.md` automatically whenever `README.md` is committed. diff --git a/apps/web-app/project.json b/apps/web-app/project.json index b12854e1..8c5934d1 100644 --- a/apps/web-app/project.json +++ b/apps/web-app/project.json @@ -116,7 +116,7 @@ "executor": "nx:run-commands", "outputs": [], "options": { - "command": "cat docs/github-readme.md > README.md && cat apps/web-app/src/assets/home.md >> README.md" + "command": "sed -n '/^# /,$p' README.md > apps/web-app/src/assets/home.md" } } } diff --git a/apps/web-app/src/app/debug/debug.spec.ts b/apps/web-app/src/app/debug/debug.spec.ts new file mode 100644 index 00000000..6681045f --- /dev/null +++ b/apps/web-app/src/app/debug/debug.spec.ts @@ -0,0 +1,77 @@ +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { NotificationStore } from '@myorg/shared'; +import { fireEvent, render, screen } from '@testing-library/angular'; + +import { Debug } from './debug'; + +async function setup() { + const { fixture } = await render(Debug, { + providers: [provideNoopAnimations(), NotificationStore], + }); + const store = fixture.componentInstance.store; + return { store }; +} + +describe('Debug', () => { + it('should render debug tools heading', async () => { + await setup(); + expect(screen.getByText(/debug tools/i)).toBeTruthy(); + }); + + it('addInfo should add an info notification', async () => { + const { store } = await setup(); + fireEvent.click(screen.getByText('Add info')); + expect(store.notifications().length).toBe(1); + expect(store.notifications()[0].kind).toBe('info'); + }); + + it('addError should add an error notification', async () => { + const { store } = await setup(); + fireEvent.click(screen.getByText('Add error')); + expect(store.notifications()[0].kind).toBe('error'); + }); + + it('addAuth should add an auth notification', async () => { + const { store } = await setup(); + fireEvent.click(screen.getByText('Add auth')); + expect(store.notifications()[0].kind).toBe('auth'); + }); + + it('addSwUpdate should add a sw-update notification with reload action', async () => { + const { store } = await setup(); + fireEvent.click(screen.getByText('Add sw-update')); + const notification = store.notifications()[0]; + expect(notification.kind).toBe('sw-update'); + expect(notification.action?.label).toBe('Reload'); + }); + + it('addWithAction should add a notification with an action', async () => { + const { store } = await setup(); + fireEvent.click(screen.getByText('Add with action')); + expect(store.notifications()[0].action?.label).toBe('Do it'); + }); + + it('addAutoDismiss should add a notification with autoDismissMs', async () => { + const { store } = await setup(); + fireEvent.click(screen.getByText('Add auto-dismiss (3s)')); + expect(store.notifications()[0].autoDismissMs).toBe(3000); + }); + + it('clearAll should dismiss all notifications', async () => { + const { store } = await setup(); + fireEvent.click(screen.getByText('Add info')); + fireEvent.click(screen.getByText('Add error')); + expect(store.notifications().length).toBe(2); + fireEvent.click(screen.getByText('Clear all')); + expect(store.notifications().length).toBe(0); + }); + + it('mark all read should mark all notifications as read', async () => { + const { store } = await setup(); + fireEvent.click(screen.getByText('Add info')); + fireEvent.click(screen.getByText('Add error')); + expect(store.unreadCount()).toBe(2); + fireEvent.click(screen.getByText('Mark all read')); + expect(store.unreadCount()).toBe(0); + }); +}); diff --git a/apps/web-app/src/assets/home.md b/apps/web-app/src/assets/home.md index 652c2058..1c64ec9b 100644 --- a/apps/web-app/src/assets/home.md +++ b/apps/web-app/src/assets/home.md @@ -1,76 +1,96 @@ # Nx + Angular + .NET 10.0 -This is basic demo of how to use a full stack [Nx monorepo](https://nx.dev/getting-started/tutorials/angular-monorepo-tutorial) with [Angular](https://angular.dev) and .NET 10.0 with [Microsoft.AspNetCore.SpaServices.Extensions](https://docs.microsoft.com/en-us/aspnet/core/client-side/spa/angular) and a demo Azure pipeline for Azure DevOps. +A full-stack demo using an [Nx monorepo](https://nx.dev) with [Angular](https://angular.dev) (zoneless, signals) and a .NET 10.0 Web API backend. Deployed to Azure App Service with automated PR preview deployments via Azure Static Web Apps. -## Demo +## Features -See a live demo here: [https://angularclinetcorengrxstarter.azurewebsites.net/](https://angularclinetcorengrxstarter.azurewebsites.net/) +- **Authentication** — register, login, and logout with JWT bearer tokens backed by ASP.NET Core Identity +- **Notification centre** — persistent notification panel with unread count, mark-as-read, dismiss, and action support (e.g. one-click reload on SW update) +- **PWA / service worker** — offline support; notifies users when a new app version is available with an in-app prompt to reload +- **Debug page** (`/debug`) — trigger test notifications and inspect service worker update state during development +- **PR preview deployments** — every pull request gets a live preview URL via Azure Static Web Apps -## Getting Started? +## Tech stack -- **Make sure you have at least Node 24.x or higher (w/ pnpm 10+) installed!** -- **This repository uses ASP.NET 10.0, which has a hard requirement on .NET SDK 10.0.x. Please install these items from [https://dotnet.microsoft.com/download](https://dotnet.microsoft.com/download)** +**Frontend** -## Visual Studio 2022 +- [Angular 21](https://angular.dev) — zoneless change detection, standalone components, signals +- [NgRx Signal Store](https://ngrx.io/guide/signals) — reactive state management +- [Angular Material](https://material.angular.io) — UI component library +- [Tailwind CSS v4](https://tailwindcss.com) — utility-first styling +- [Angular PWA](https://angular.dev/ecosystem/service-workers) — service worker & offline support -Make sure you have .NET 10.0 installed and/or the latest VS2026. +**Backend** -## Visual Studio Code +- [.NET 10.0](https://dotnet.microsoft.com) Web API +- [ASP.NET Core Identity](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/identity) — bearer token authentication +- [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/) with Azure SQL -> Note: Make sure you have the C# extension & .NET Debugger installed. +**Tooling** - pnpm install +- [Nx](https://nx.dev) — monorepo build system with affected commands +- [Vitest](https://vitest.dev) — unit tests with ~93% line coverage +- [Playwright](https://playwright.dev) — end-to-end tests +- [Husky](https://typicode.github.io/husky/) + [lint-staged](https://github.com/lint-staged/lint-staged) — pre-commit hooks for linting, formatting, and keeping `home.md` in sync +- [pnpm](https://pnpm.io) — package manager -## Serve Development App +## Demo -``` -pnpm start -``` +Live demo: [https://angularclinetcorengrxstarter.azurewebsites.net/](https://angularclinetcorengrxstarter.azurewebsites.net/) + +## Getting started -Both the api (dotnet) and web app (Angular) will build and run in dev mode. Open your browser on http://localhost:4200/ to see the Angular app, or https://localhost:60254/swagger to see the api documentation generated by Swagger. +**Prerequisites** -## Serve Production App (PWA enabled) +- Node 24.x+ with pnpm 10+ +- .NET SDK 10.0.x — [download](https://dotnet.microsoft.com/download) +**Install dependencies** + +```bash +pnpm install ``` -pnpm serve:prod + +## Serve development app + +```bash +pnpm start ``` +Starts both the .NET API and Angular app in dev mode. Open [http://localhost:4200](http://localhost:4200) for the app, or [https://localhost:60254/swagger](https://localhost:60254/swagger) for the API docs. + ## Lint -``` +```bash pnpm lint ``` -## Unit Tests - -Run unit tests by executing: +## Unit tests -``` +```bash pnpm test ``` -Please note that for test coverage you need dotnet-coverage to be installed: +Coverage requires `dotnet-coverage`: -``` +```bash dotnet tool install --global dotnet-coverage ``` -More information is available on https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-test - -## End-to-end Tests - -Run e2e tests by executing: +## End-to-end tests -``` +```bash pnpm e2e ``` -## Build Production App - -Build the production Angular app and Publish the release .NET app, run: +## Build for production -``` +```bash pnpm build:prod ``` -The contents of the. `/dist` folder should now contain something that can be deployed to and Azure web service or IIS instance. +Builds the Angular app and publishes the .NET project to `/dist`, ready to deploy to Azure App Service. + +## Contributing + +`apps/web-app/src/assets/home.md` is auto-generated from this file — **edit `README.md` only**. The lint-staged hook regenerates `home.md` automatically whenever `README.md` is committed. diff --git a/docs/github-readme.md b/docs/github-readme.md deleted file mode 100644 index e646d5af..00000000 --- a/docs/github-readme.md +++ /dev/null @@ -1 +0,0 @@ -[![Build status](https://freshpondmedia.visualstudio.com/FreshPondMediaGit/_apis/build/status/chrisjwalk.angular-cli-netcore-ngrx-starter)](https://freshpondmedia.visualstudio.com/FreshPondMediaGit/_build/latest?definitionId=43) diff --git a/libs/counter/src/lib/state/counter.store.spec.ts b/libs/counter/src/lib/state/counter.store.spec.ts index 21ea5385..1ca71bb7 100644 --- a/libs/counter/src/lib/state/counter.store.spec.ts +++ b/libs/counter/src/lib/state/counter.store.spec.ts @@ -1,9 +1,11 @@ import { TestBed } from '@angular/core/testing'; import { patchState } from '@ngrx/signals'; import { unprotected } from '@ngrx/signals/testing'; +import { injectDispatch } from '@ngrx/signals/events'; import { counterInitialState, CounterStore, + counterEvents, decrementCount, incrementCount, setCount, @@ -37,4 +39,42 @@ describe('CounterStore', () => { count--; expect(store.count()).toBe(count); }); + + it('inputCount should set count from a valid numeric string', () => { + store.inputCount('42'); + expect(store.count()).toBe(42); + }); + + it('inputCount should ignore non-numeric input', () => { + store.inputCount('abc'); + expect(store.count()).toBe(0); + }); + + it('should handle setCount event via reducer', () => { + const dispatcher = TestBed.runInInjectionContext(() => + injectDispatch(counterEvents), + ); + dispatcher.setCount(7); + TestBed.flushEffects(); + expect(store.count()).toBe(7); + }); + + it('should handle incrementCount event via reducer', () => { + const dispatcher = TestBed.runInInjectionContext(() => + injectDispatch(counterEvents), + ); + dispatcher.incrementCount(); + TestBed.flushEffects(); + expect(store.count()).toBe(1); + }); + + it('should handle decrementCount event via reducer', () => { + const dispatcher = TestBed.runInInjectionContext(() => + injectDispatch(counterEvents), + ); + patchState(unprotected(store), setCount(5)); + dispatcher.decrementCount(); + TestBed.flushEffects(); + expect(store.count()).toBe(4); + }); }); diff --git a/libs/shared/src/lib/components/notification-bell.spec.ts b/libs/shared/src/lib/components/notification-bell.spec.ts index 671de2ea..b231fe97 100644 --- a/libs/shared/src/lib/components/notification-bell.spec.ts +++ b/libs/shared/src/lib/components/notification-bell.spec.ts @@ -48,4 +48,42 @@ describe('NotificationBell', () => { const button = screen.getByRole('button'); expect(button.getAttribute('aria-label')).toContain('2 unread'); }); + + it('should use singular form in aria-label when exactly 1 notification is unread', async () => { + const { fixture } = await setup(); + const store = fixture.debugElement.injector.get(NotificationStore); + store.add({ kind: 'info', title: 'One' }); + fixture.detectChanges(); + expect(screen.getByRole('button').getAttribute('aria-label')).toBe( + '1 unread notification', + ); + }); + + it('should open bottom sheet on handset devices', async () => { + const { fixture } = await setup(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const component = fixture.debugElement.componentInstance as any; + const breakpointObserver = fixture.debugElement.injector.get( + component['breakpointObserver'].constructor, + ); + vi.spyOn(breakpointObserver, 'isMatched').mockReturnValue(true); + const bottomSheet = fixture.debugElement.injector.get( + component['bottomSheet'].constructor, + ); + vi.spyOn(bottomSheet, 'open').mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + () => null as any, + ); + fireEvent.click(screen.getByRole('button')); + expect(bottomSheet.open).toHaveBeenCalled(); + }); + + it('should close the overlay when bell is clicked while panel is open', async () => { + const { component } = await setup(); + fireEvent.click(screen.getByRole('button')); // open + const markAllReadSpy = vi.spyOn(component.store, 'markAllRead'); + fireEvent.click(screen.getByRole('button')); // close + expect(markAllReadSpy).toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button')); // re-open — reuses existing overlayRef + }); }); diff --git a/libs/shared/src/lib/components/notification-list.spec.ts b/libs/shared/src/lib/components/notification-list.spec.ts index c112d267..a0094249 100644 --- a/libs/shared/src/lib/components/notification-list.spec.ts +++ b/libs/shared/src/lib/components/notification-list.spec.ts @@ -97,4 +97,21 @@ describe('NotificationList', () => { screen.queryByRole('button', { name: /mark all read/i }), ).toBeFalsy(); }); + + it('should not show unread indicator for already-read notifications', async () => { + const { store, fixture } = await setup(); + store.add({ kind: 'info', title: 'Read item' }); + store.markRead(store.notifications()[0].id); + fixture.detectChanges(); + await screen.findByText('Read item'); + expect(document.querySelectorAll('[aria-label="Unread"]')).toHaveLength(0); + }); + + it('iconFor should return notifications icon for unknown kind', async () => { + const { fixture } = await setup(); + const component = fixture.debugElement + .componentInstance as NotificationList; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(component.iconFor('unknown' as any)).toBe('notifications'); + }); }); diff --git a/libs/shared/src/lib/state/notification.store.spec.ts b/libs/shared/src/lib/state/notification.store.spec.ts index 917df5e4..a205d6bf 100644 --- a/libs/shared/src/lib/state/notification.store.spec.ts +++ b/libs/shared/src/lib/state/notification.store.spec.ts @@ -49,6 +49,16 @@ describe('NotificationStore', () => { expect(store.unreadCount()).toBe(0); }); + it('should only mark the targeted notification as read (not others)', () => { + store.add({ kind: 'info', title: 'A' }); + store.add({ kind: 'info', title: 'B' }); + const idA = store.notifications()[0].id; + store.markRead(idA); + expect(store.notifications()[0].read).toBe(true); + expect(store.notifications()[1].read).toBe(false); + expect(store.unreadCount()).toBe(1); + }); + it('should mark all notifications as read', () => { store.add({ kind: 'info', title: 'A' }); store.add({ kind: 'error', title: 'B' }); diff --git a/package.json b/package.json index afef7675..3fec749a 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "dotnet:build": "dotnet build apps/api", "dotnet:update": "dotnet outdated -u apps/api", "preinstall": "pnpm dotnet:restore", - "postinstall": "pnpm build:dotnet-builder && pnpm update-readme", + "postinstall": "pnpm build:dotnet-builder", "prepare": "husky", "update-packages": "ts-node ./tools/update-packages/src/main.ts" }, @@ -138,9 +138,5 @@ "typescript-eslint": "8.57.0", "vite": "8.0.0", "vitest": "4.1.0" - }, - "lint-staged": { - "*.{ts,js}": "eslint --cache --cache-location=.husky/_ --fix", - "*.{ts,js,css,scss,md,mdx}": "prettier --write" } } diff --git a/vite.config.mts b/vite.config.mts index d28acf26..d88a3e24 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -19,6 +19,19 @@ export const baseConfig = { provider: 'v8', reporter: ['text', 'cobertura'], include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/**/*.routes.ts', + 'src/**/*.config.ts', + 'src/main.ts', + 'src/environments/**', + ], + thresholds: { + perFile: true, + statements: 80, + branches: 80, + functions: 80, + lines: 80, + }, }, server: { deps: {