├── .editorconfig ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── README.md ├── angular.json ├── package-lock.json ├── package.json ├── src ├── app │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.config.ts │ ├── app.routes.ts │ ├── checklist │ │ ├── checklist.component.spec.ts │ │ ├── checklist.component.ts │ │ ├── data-access │ │ │ ├── checklist-item.service.spec.ts │ │ │ └── checklist-item.service.ts │ │ └── ui │ │ │ ├── checklist-header.component.spec.ts │ │ │ ├── checklist-header.component.ts │ │ │ ├── checklist-item-list.component.spec.ts │ │ │ └── checklist-item-list.component.ts │ ├── home │ │ ├── home.component.spec.ts │ │ ├── home.component.ts │ │ └── ui │ │ │ ├── checklist-list.component.spec.ts │ │ │ └── checklist-list.component.ts │ └── shared │ │ ├── data-access │ │ ├── checklist.service.spec.ts │ │ ├── checklist.service.ts │ │ ├── storage.service.spec.ts │ │ └── storage.service.ts │ │ ├── interfaces │ │ ├── checklist-item.ts │ │ └── checklist.ts │ │ └── ui │ │ ├── form-modal.component.spec.ts │ │ ├── form-modal.component.ts │ │ ├── modal.component.spec.ts │ │ └── modal.component.ts ├── assets │ └── .gitkeep ├── favicon.ico ├── index.html ├── main.ts └── styles.scss ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | /test-results/ 44 | /playwright-report/ 45 | /playwright/.cache/ 46 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AngularstartQuicklists 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 16.2.0-rc.0. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. 28 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "angularstart-quicklists": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "inlineTemplate": true, 11 | "inlineStyle": true, 12 | "style": "scss", 13 | "standalone": true 14 | }, 15 | "@schematics/angular:directive": { 16 | "standalone": true 17 | }, 18 | "@schematics/angular:pipe": { 19 | "standalone": true 20 | } 21 | }, 22 | "root": "", 23 | "sourceRoot": "src", 24 | "prefix": "app", 25 | "architect": { 26 | "build": { 27 | "builder": "@angular-devkit/build-angular:application", 28 | "options": { 29 | "outputPath": { 30 | "base": "dist/angularstart-quicklists" 31 | }, 32 | "index": "src/index.html", 33 | "polyfills": ["zone.js"], 34 | "tsConfig": "tsconfig.app.json", 35 | "inlineStyleLanguage": "scss", 36 | "assets": ["src/favicon.ico", "src/assets"], 37 | "styles": ["src/styles.scss"], 38 | "scripts": [], 39 | "browser": "src/main.ts" 40 | }, 41 | "configurations": { 42 | "production": { 43 | "budgets": [ 44 | { 45 | "type": "initial", 46 | "maximumWarning": "500kb", 47 | "maximumError": "1mb" 48 | }, 49 | { 50 | "type": "anyComponentStyle", 51 | "maximumWarning": "2kb", 52 | "maximumError": "4kb" 53 | } 54 | ], 55 | "outputHashing": "all" 56 | }, 57 | "development": { 58 | "optimization": false, 59 | "extractLicenses": false, 60 | "sourceMap": true, 61 | "namedChunks": true 62 | } 63 | }, 64 | "defaultConfiguration": "production" 65 | }, 66 | "serve": { 67 | "builder": "@angular-devkit/build-angular:dev-server", 68 | "configurations": { 69 | "production": { 70 | "buildTarget": "angularstart-quicklists:build:production" 71 | }, 72 | "development": { 73 | "buildTarget": "angularstart-quicklists:build:development" 74 | } 75 | }, 76 | "defaultConfiguration": "development" 77 | }, 78 | "extract-i18n": { 79 | "builder": "@angular-devkit/build-angular:extract-i18n", 80 | "options": { 81 | "buildTarget": "angularstart-quicklists:build" 82 | } 83 | }, 84 | "test": { 85 | "builder": "@angular-devkit/build-angular:jest", 86 | "options": { 87 | "tsConfig": "tsconfig.spec.json", 88 | "polyfills": ["zone.js", "zone.js/testing"] 89 | } 90 | } 91 | } 92 | } 93 | }, 94 | "schematics": { 95 | "@schematics/angular:component": { 96 | "type": "component" 97 | }, 98 | "@schematics/angular:directive": { 99 | "type": "directive" 100 | }, 101 | "@schematics/angular:service": { 102 | "type": "service" 103 | }, 104 | "@schematics/angular:guard": { 105 | "typeSeparator": "." 106 | }, 107 | "@schematics/angular:interceptor": { 108 | "typeSeparator": "." 109 | }, 110 | "@schematics/angular:module": { 111 | "typeSeparator": "." 112 | }, 113 | "@schematics/angular:pipe": { 114 | "typeSeparator": "." 115 | }, 116 | "@schematics/angular:resolver": { 117 | "typeSeparator": "." 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angularstart-quicklists", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test" 10 | }, 11 | "private": true, 12 | "dependencies": { 13 | "@angular/animations": "^20.0.0-rc.1", 14 | "@angular/common": "^20.0.0-rc.1", 15 | "@angular/compiler": "^20.0.0-rc.1", 16 | "@angular/core": "^20.0.0-rc.1", 17 | "@angular/forms": "^20.0.0-rc.1", 18 | "@angular/platform-browser": "^20.0.0-rc.1", 19 | "@angular/platform-browser-dynamic": "^20.0.0-rc.1", 20 | "@angular/router": "^20.0.0-rc.1", 21 | "@angular/cdk": "^17.0.0-rc.0", 22 | "rxjs": "~7.8.0", 23 | "tslib": "^2.3.0", 24 | "zone.js": "~0.15.0" 25 | }, 26 | "devDependencies": { 27 | "@angular-devkit/build-angular": "^20.0.0-rc.2", 28 | "@angular/cli": "~20.0.0-rc.2", 29 | "@angular/compiler-cli": "^20.0.0-rc.1", 30 | "@hirez_io/observer-spy": "^2.2.0", 31 | "@types/jest": "^29.5.3", 32 | "jest": "^29.6.2", 33 | "jest-environment-jsdom": "^29.6.2", 34 | "karma": "~6.4.0", 35 | "karma-chrome-launcher": "~3.2.0", 36 | "karma-coverage": "~2.2.0", 37 | "karma-jasmine": "~5.1.0", 38 | "karma-jasmine-html-reporter": "~2.1.0", 39 | "typescript": "~5.8.3" 40 | } 41 | } -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { AppComponent } from './app.component'; 3 | 4 | describe('AppComponent', () => { 5 | beforeEach(() => 6 | TestBed.configureTestingModule({ 7 | imports: [AppComponent], 8 | }) 9 | ); 10 | 11 | it('should create the app', () => { 12 | const fixture = TestBed.createComponent(AppComponent); 13 | const app = fixture.componentInstance; 14 | expect(app).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { RouterOutlet } from '@angular/router'; 4 | 5 | @Component({ 6 | selector: 'app-root', 7 | standalone: true, 8 | imports: [CommonModule, RouterOutlet], 9 | template: ` `, 10 | styles: [], 11 | }) 12 | export class AppComponent { 13 | title = 'angularstart-quicklists'; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationConfig, 3 | importProvidersFrom, 4 | provideBrowserGlobalErrorListeners, 5 | provideCheckNoChangesConfig, 6 | provideZonelessChangeDetection, 7 | } from '@angular/core'; 8 | import { provideRouter } from '@angular/router'; 9 | import { DialogModule } from '@angular/cdk/dialog'; 10 | 11 | import { routes } from './app.routes'; 12 | 13 | export const appConfig: ApplicationConfig = { 14 | providers: [ 15 | provideRouter(routes), 16 | importProvidersFrom(DialogModule), 17 | provideZonelessChangeDetection(), 18 | provideBrowserGlobalErrorListeners(), 19 | provideCheckNoChangesConfig({ 20 | exhaustive: true, 21 | interval: 500, 22 | }), 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | 3 | export const routes: Routes = [ 4 | { 5 | path: 'home', 6 | loadComponent: () => import('./home/home.component'), 7 | }, 8 | { 9 | path: 'checklist/:id', 10 | loadComponent: () => import('./checklist/checklist.component'), 11 | }, 12 | { 13 | path: '', 14 | redirectTo: 'home', 15 | pathMatch: 'full', 16 | }, 17 | ]; 18 | -------------------------------------------------------------------------------- /src/app/checklist/checklist.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import ChecklistComponent from './checklist.component'; 3 | import { ChecklistService } from '../shared/data-access/checklist.service'; 4 | import { ActivatedRoute, convertToParamMap } from '@angular/router'; 5 | import { of } from 'rxjs'; 6 | import { By } from '@angular/platform-browser'; 7 | import { DebugElement } from '@angular/core'; 8 | import { ModalComponent } from '../shared/ui/modal.component'; 9 | import { MockModalComponent } from '../shared/ui/modal.component.spec'; 10 | import { FormModalComponent } from '../shared/ui/form-modal.component'; 11 | import { MockFormModalComponent } from '../shared/ui/form-modal.component.spec'; 12 | import { FormBuilder } from '@angular/forms'; 13 | import { ChecklistItemService } from './data-access/checklist-item.service'; 14 | import { ChecklistItem } from '../shared/interfaces/checklist-item'; 15 | 16 | describe('ChecklistComponent', () => { 17 | let component: ChecklistComponent; 18 | let fixture: ComponentFixture; 19 | let formBuilder: FormBuilder; 20 | let checklistItemService: ChecklistItemService; 21 | 22 | const mockParamId = 'two'; 23 | 24 | const mockChecklists = [ 25 | { id: 'one', title: 'one' }, 26 | { id: 'two', title: 'two' }, 27 | { id: 'three', title: 'three' }, 28 | ]; 29 | 30 | const mockChecklistItems = [ 31 | { checklistId: 'one', title: 'abc' }, 32 | { checklistId: 'two', title: 'def' }, 33 | ]; 34 | 35 | beforeEach(() => { 36 | TestBed.configureTestingModule({ 37 | imports: [ChecklistComponent], 38 | providers: [ 39 | { 40 | provide: ChecklistService, 41 | useValue: { 42 | checklists: jest.fn().mockReturnValue(mockChecklists), 43 | }, 44 | }, 45 | { 46 | provide: ChecklistItemService, 47 | useValue: { 48 | checklistItems: jest.fn().mockReturnValue(mockChecklistItems), 49 | add$: { 50 | next: jest.fn(), 51 | }, 52 | remove$: { 53 | next: jest.fn(), 54 | }, 55 | edit$: { 56 | next: jest.fn(), 57 | }, 58 | toggle$: { 59 | next: jest.fn(), 60 | }, 61 | reset$: { 62 | next: jest.fn(), 63 | }, 64 | }, 65 | }, 66 | { 67 | provide: ActivatedRoute, 68 | useValue: { 69 | paramMap: of( 70 | convertToParamMap({ 71 | id: mockParamId, 72 | }) 73 | ), 74 | }, 75 | }, 76 | { 77 | provide: FormBuilder, 78 | useValue: { 79 | nonNullable: { 80 | group: jest.fn().mockReturnValue({ 81 | patchValue: jest.fn(), 82 | reset: jest.fn(), 83 | get: jest.fn(), 84 | getRawValue: jest.fn(), 85 | }), 86 | }, 87 | }, 88 | }, 89 | ], 90 | }) 91 | .overrideComponent(ChecklistComponent, { 92 | remove: { imports: [ModalComponent, FormModalComponent] }, 93 | add: { imports: [MockModalComponent, MockFormModalComponent] }, 94 | }) 95 | .compileComponents(); 96 | 97 | fixture = TestBed.createComponent(ChecklistComponent); 98 | component = fixture.componentInstance; 99 | 100 | checklistItemService = TestBed.inject(ChecklistItemService); 101 | formBuilder = TestBed.inject(FormBuilder); 102 | 103 | fixture.detectChanges(); 104 | }); 105 | 106 | it('should create', () => { 107 | expect(component).toBeTruthy(); 108 | }); 109 | 110 | describe('app-checklist-header', () => { 111 | let checklistHeader: DebugElement; 112 | 113 | beforeEach(() => { 114 | checklistHeader = fixture.debugElement.query( 115 | By.css('app-checklist-header') 116 | ); 117 | }); 118 | 119 | describe('input: checklist', () => { 120 | it('should use the checklist matching the id from the route param', () => { 121 | const matchingChecklist = mockChecklists.find( 122 | (checklist) => checklist.id === mockParamId 123 | ); 124 | 125 | expect(checklistHeader.componentInstance.checklist).toEqual( 126 | matchingChecklist 127 | ); 128 | }); 129 | }); 130 | 131 | describe('output: resetChecklist', () => { 132 | it('should next reset$ source with emitted value', () => { 133 | const testId = 5; 134 | checklistHeader.triggerEventHandler('resetChecklist', testId); 135 | 136 | expect(checklistItemService.reset$.next).toHaveBeenCalledWith(testId); 137 | }); 138 | }); 139 | }); 140 | 141 | describe('app-modal', () => { 142 | let appModal: DebugElement; 143 | 144 | beforeEach(() => { 145 | appModal = fixture.debugElement.query(By.css('app-modal')); 146 | }); 147 | 148 | describe('input: isOpen', () => { 149 | it('should be truthy when checklist header emits addItem', () => { 150 | const checklistHeader = fixture.debugElement.query( 151 | By.css('app-checklist-header') 152 | ); 153 | 154 | checklistHeader.triggerEventHandler('addItem', null); 155 | 156 | fixture.detectChanges(); 157 | 158 | const modal = fixture.debugElement.query(By.css('app-modal')); 159 | 160 | expect(modal.componentInstance.isOpen).toBeTruthy(); 161 | }); 162 | }); 163 | }); 164 | 165 | describe('app-checklist-item-list', () => { 166 | let checklistItemList: DebugElement; 167 | 168 | beforeEach(() => { 169 | checklistItemList = fixture.debugElement.query( 170 | By.css('app-checklist-item-list') 171 | ); 172 | }); 173 | 174 | describe('input: checklistItems', () => { 175 | it('should use checklist items filtered with current checklist id', () => { 176 | const input: ChecklistItem[] = 177 | checklistItemList.componentInstance.checklistItems; 178 | 179 | expect(input.length).toEqual( 180 | mockChecklistItems.filter((item) => item.checklistId === mockParamId) 181 | .length 182 | ); 183 | expect(input.every((item) => item.checklistId === mockParamId)); 184 | }); 185 | }); 186 | 187 | describe('output: delete', () => { 188 | it('should next remove$ source with emitted value', () => { 189 | const testId = 5; 190 | checklistItemList.triggerEventHandler('delete', testId); 191 | 192 | expect(checklistItemService.remove$.next).toHaveBeenCalledWith(testId); 193 | }); 194 | }); 195 | 196 | describe('output: toggle', () => { 197 | it('should next toggle$ source with emitted value', () => { 198 | const testId = 5; 199 | checklistItemList.triggerEventHandler('toggle', testId); 200 | 201 | expect(checklistItemService.toggle$.next).toHaveBeenCalledWith(testId); 202 | }); 203 | }); 204 | 205 | describe('output: edit', () => { 206 | let testChecklistItem: any; 207 | 208 | beforeEach(() => { 209 | testChecklistItem = { id: '1', title: 'test' } as any; 210 | checklistItemList.triggerEventHandler('edit', testChecklistItem); 211 | fixture.detectChanges(); 212 | }); 213 | 214 | it('should open modal', () => { 215 | const modal = fixture.debugElement.query(By.css('app-modal')); 216 | expect(modal.componentInstance.isOpen).toBeTruthy(); 217 | }); 218 | 219 | it('should patch form with checklist title', () => { 220 | expect(component.checklistItemForm.patchValue).toHaveBeenCalledWith({ 221 | title: testChecklistItem.title, 222 | }); 223 | }); 224 | }); 225 | }); 226 | 227 | describe('app-form-modal', () => { 228 | let appFormModal: DebugElement; 229 | 230 | beforeEach(() => { 231 | component.checklistItemBeingEdited.set({}); 232 | fixture.detectChanges(); 233 | 234 | appFormModal = fixture.debugElement.query(By.css('app-form-modal')); 235 | }); 236 | 237 | describe('output: save', () => { 238 | describe('checklist item not being edited', () => { 239 | it('should next add$ source with form values and current checklist id', () => { 240 | appFormModal.triggerEventHandler('save'); 241 | expect(checklistItemService.add$.next).toHaveBeenCalledWith({ 242 | item: component.checklistItemForm.getRawValue(), 243 | checklistId: component.checklist()?.id, 244 | }); 245 | }); 246 | }); 247 | 248 | describe('checklist item being edited', () => { 249 | let testChecklistItem: any; 250 | 251 | beforeEach(() => { 252 | testChecklistItem = { id: '5', title: 'hello' }; 253 | component.checklistItemBeingEdited.set(testChecklistItem); 254 | }); 255 | 256 | it('should next edit$ source with current id and form data', () => { 257 | appFormModal.triggerEventHandler('save'); 258 | expect(checklistItemService.edit$.next).toHaveBeenCalledWith({ 259 | id: testChecklistItem.id, 260 | data: component.checklistItemForm.getRawValue(), 261 | }); 262 | }); 263 | }); 264 | }); 265 | 266 | describe('output: close', () => { 267 | it('should close app-modal', () => { 268 | appFormModal.triggerEventHandler('close'); 269 | fixture.detectChanges(); 270 | 271 | const modal = fixture.debugElement.query(By.css('app-modal')); 272 | 273 | expect(modal.componentInstance.isOpen).toBeFalsy(); 274 | }); 275 | 276 | it('should reset form', () => { 277 | component.checklistItemForm.get('title')?.setValue('test'); 278 | fixture.detectChanges(); 279 | 280 | appFormModal.triggerEventHandler('close'); 281 | fixture.detectChanges(); 282 | 283 | expect(component.checklistItemForm.reset).toHaveBeenCalled(); 284 | }); 285 | }); 286 | }); 287 | }); 288 | -------------------------------------------------------------------------------- /src/app/checklist/checklist.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, computed, effect, inject, signal } from '@angular/core'; 2 | import { toSignal } from '@angular/core/rxjs-interop'; 3 | import { ChecklistHeaderComponent } from './ui/checklist-header.component'; 4 | import { ChecklistService } from '../shared/data-access/checklist.service'; 5 | import { ActivatedRoute } from '@angular/router'; 6 | import { CommonModule } from '@angular/common'; 7 | import { ChecklistItemListComponent } from './ui/checklist-item-list.component'; 8 | import { ModalComponent } from '../shared/ui/modal.component'; 9 | import { ChecklistItem } from '../shared/interfaces/checklist-item'; 10 | import { FormBuilder } from '@angular/forms'; 11 | import { FormModalComponent } from '../shared/ui/form-modal.component'; 12 | import { ChecklistItemService } from './data-access/checklist-item.service'; 13 | 14 | @Component({ 15 | standalone: true, 16 | selector: 'app-checklist', 17 | template: ` 18 | @if (checklist(); as checklist) { 19 | 24 | } 25 | 26 | 32 | 33 | 34 | 35 | 51 | 52 | 53 | `, 54 | imports: [ 55 | ChecklistHeaderComponent, 56 | ChecklistItemListComponent, 57 | ModalComponent, 58 | FormModalComponent, 59 | ], 60 | }) 61 | export default class ChecklistComponent { 62 | checklistService = inject(ChecklistService); 63 | checklistItemService = inject(ChecklistItemService); 64 | route = inject(ActivatedRoute); 65 | formBuilder = inject(FormBuilder); 66 | 67 | checklistItemBeingEdited = signal | null>(null); 68 | 69 | params = toSignal(this.route.paramMap); 70 | 71 | items = computed(() => 72 | this.checklistItemService 73 | .checklistItems() 74 | .filter((item) => item.checklistId === this.params()?.get('id')), 75 | ); 76 | 77 | checklist = computed(() => 78 | this.checklistService 79 | .checklists() 80 | .find((checklist) => checklist.id === this.params()?.get('id')), 81 | ); 82 | 83 | checklistItemForm = this.formBuilder.nonNullable.group({ 84 | title: [''], 85 | }); 86 | 87 | constructor() { 88 | effect(() => { 89 | const checklistItem = this.checklistItemBeingEdited(); 90 | 91 | if (!checklistItem) { 92 | this.checklistItemForm.reset(); 93 | } else { 94 | this.checklistItemForm.patchValue({ 95 | title: checklistItem.title, 96 | }); 97 | } 98 | }); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/app/checklist/data-access/checklist-item.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { ChecklistItemService } from './checklist-item.service'; 3 | import { Component, Injector, runInInjectionContext } from '@angular/core'; 4 | import { StorageService } from 'src/app/shared/data-access/storage.service'; 5 | import { Subject } from 'rxjs'; 6 | 7 | describe('ChecklistItemService', () => { 8 | let service: ChecklistItemService; 9 | let storageService: StorageService; 10 | let loadChecklistItemsSubject: Subject; 11 | 12 | beforeEach(() => { 13 | loadChecklistItemsSubject = new Subject(); 14 | 15 | TestBed.configureTestingModule({ 16 | providers: [ 17 | ChecklistItemService, 18 | { 19 | provide: StorageService, 20 | useValue: { 21 | loadChecklistItems: jest 22 | .fn() 23 | .mockReturnValue(loadChecklistItemsSubject), 24 | saveChecklistItems: jest.fn(), 25 | }, 26 | }, 27 | ], 28 | }); 29 | 30 | service = TestBed.inject(ChecklistItemService); 31 | storageService = TestBed.inject(StorageService); 32 | }); 33 | 34 | it('should create', () => { 35 | expect(service).toBeTruthy(); 36 | }); 37 | 38 | describe('source: add$', () => { 39 | let item = { title: 'test' }; 40 | let checklistId = 'one'; 41 | 42 | beforeEach(() => { 43 | service.add$.next({ item, checklistId }); 44 | }); 45 | 46 | it('should add the supplied data to the checklists array', () => { 47 | expect( 48 | service 49 | .checklistItems() 50 | .find((checklistItem) => checklistItem.title === item.title) 51 | ).toBeTruthy(); 52 | }); 53 | 54 | it('should not remove other data from the checklists array', () => { 55 | service.add$.next({ item, checklistId }); 56 | expect(service.checklistItems().length).toEqual(2); 57 | }); 58 | }); 59 | 60 | describe('source: edit$', () => { 61 | let preEdit = { title: 'test' }; 62 | let postEdit = { title: 'edited' }; 63 | const checklistId = '1'; 64 | 65 | beforeEach(() => { 66 | service.add$.next({ item: preEdit, checklistId }); 67 | const addedChecklistItem = service.checklistItems()[0]; 68 | service.edit$.next({ id: addedChecklistItem.id, data: { ...postEdit } }); 69 | }); 70 | 71 | it('should edit the checklist with the supplied data', () => { 72 | const checklist = service.checklistItems()[0]; 73 | expect(checklist).toEqual({ 74 | id: checklist.id, 75 | checklistId, 76 | checked: false, 77 | ...postEdit, 78 | }); 79 | }); 80 | }); 81 | 82 | describe('source: remove$', () => { 83 | beforeEach(() => { 84 | // add some test data 85 | Date.now = jest.fn(() => 1); 86 | service.add$.next({ item: { title: 'abc' }, checklistId: '1' }); 87 | Date.now = jest.fn(() => 2); 88 | service.add$.next({ item: { title: 'def' }, checklistId: '2' }); 89 | Date.now = jest.fn(() => 3); 90 | service.add$.next({ item: { title: 'ghi' }, checklistId: '3' }); 91 | }); 92 | 93 | it('should remove the checklist with the supplied id', () => { 94 | const testChecklistItem = service.checklistItems()[0]; 95 | service.remove$.next(testChecklistItem.id); 96 | expect( 97 | service 98 | .checklistItems() 99 | .find((checklistItem) => checklistItem.id === testChecklistItem.id) 100 | ).toBeFalsy(); 101 | }); 102 | 103 | it('should NOT remove checklists that do not match the id', () => { 104 | const testChecklistItem = service.checklistItems()[0]; 105 | const prevLength = service.checklistItems().length; 106 | service.remove$.next(testChecklistItem.id); 107 | expect(service.checklistItems().length).toEqual(prevLength - 1); 108 | }); 109 | }); 110 | 111 | describe('source: toggle$', () => { 112 | beforeEach(() => { 113 | // add some test data 114 | Date.now = jest.fn(() => 1); 115 | service.add$.next({ item: { title: 'abc' }, checklistId: '1' }); 116 | Date.now = jest.fn(() => 2); 117 | service.add$.next({ item: { title: 'def' }, checklistId: '2' }); 118 | Date.now = jest.fn(() => 3); 119 | service.add$.next({ item: { title: 'ghi' }, checklistId: '3' }); 120 | }); 121 | 122 | it('should toggle the checklist with the supplied id', () => { 123 | const testChecklistItem = service.checklistItems()[0]; 124 | service.toggle$.next(testChecklistItem.id); 125 | expect( 126 | service 127 | .checklistItems() 128 | .find((checklistItem) => checklistItem.id === testChecklistItem.id) 129 | ?.checked 130 | ).toEqual(true); 131 | }); 132 | 133 | it('should NOT toggle checklists that do not match the id', () => { 134 | const testChecklistItem = service.checklistItems()[0]; 135 | service.toggle$.next(testChecklistItem.id); 136 | expect( 137 | service 138 | .checklistItems() 139 | .filter((checklistItem) => checklistItem.id !== testChecklistItem.id) 140 | .every((item) => !item.checked) 141 | ).toBeTruthy(); 142 | }); 143 | }); 144 | 145 | describe('source: reset$', () => { 146 | beforeEach(() => { 147 | // add some test data 148 | Date.now = jest.fn(() => 1); 149 | service.add$.next({ item: { title: 'abc' }, checklistId: '1' }); 150 | Date.now = jest.fn(() => 2); 151 | service.add$.next({ item: { title: 'def' }, checklistId: '1' }); 152 | Date.now = jest.fn(() => 3); 153 | service.add$.next({ item: { title: 'ghi' }, checklistId: '3' }); 154 | 155 | service.toggle$.next('1'); 156 | service.toggle$.next('2'); 157 | service.toggle$.next('3'); 158 | }); 159 | 160 | it('should set checked of all items matching checklistId to false', () => { 161 | service.reset$.next('1'); 162 | 163 | expect( 164 | service 165 | .checklistItems() 166 | .filter((item) => item.checklistId === '1') 167 | .every((item) => item.checked === false) 168 | ).toBeTruthy(); 169 | }); 170 | 171 | it('should not set checked status of NON matching items to false', () => { 172 | service.reset$.next('1'); 173 | expect( 174 | service 175 | .checklistItems() 176 | .filter((item) => item.checklistId !== '1') 177 | .every((item) => item.checked === true) 178 | ).toBeTruthy(); 179 | }); 180 | }); 181 | 182 | describe('source: checklistRemoved$', () => { 183 | beforeEach(() => { 184 | // add some test data 185 | Date.now = jest.fn(() => 1); 186 | service.add$.next({ item: { title: 'abc' }, checklistId: '1' }); 187 | Date.now = jest.fn(() => 2); 188 | service.add$.next({ item: { title: 'def' }, checklistId: '1' }); 189 | Date.now = jest.fn(() => 3); 190 | service.add$.next({ item: { title: 'ghi' }, checklistId: '3' }); 191 | }); 192 | 193 | it('should remove every checklist item that matches checklistId', () => { 194 | service.checklistRemoved$.next('1'); 195 | expect( 196 | service.checklistItems().find((item) => item.checklistId === '1') 197 | ).toBeFalsy(); 198 | }); 199 | 200 | it('should NOT remove checklist items that dont match the checklistId', () => { 201 | service.checklistRemoved$.next('1'); 202 | expect( 203 | service.checklistItems().find((item) => item.checklistId === '3') 204 | ).toBeTruthy(); 205 | }); 206 | }); 207 | 208 | describe('source: checklistItemsLoaded$', () => { 209 | it('should update checklistItems state when loadChecklists() emits', () => { 210 | const testData = [{}, {}]; 211 | loadChecklistItemsSubject.next(testData); 212 | expect(service.checklistItems()).toEqual(testData); 213 | }); 214 | 215 | it('should set loaded flag to true if loaded successfully', () => { 216 | expect(service.loaded()).toEqual(false); 217 | loadChecklistItemsSubject.next([]); 218 | expect(service.loaded()).toEqual(true); 219 | }); 220 | }); 221 | 222 | describe('effect: checklistItems()', () => { 223 | it('should call saveChecklistItems method with checklistItems when checklistItems() changes', () => { 224 | const { flushEffects } = setUp(); 225 | loadChecklistItemsSubject.next([]); 226 | service.add$.next({ item: { title: 'test' }, checklistId: '1' }); 227 | flushEffects(); 228 | expect(storageService.saveChecklistItems).toHaveBeenCalledWith( 229 | service.checklistItems() 230 | ); 231 | }); 232 | 233 | it('should NOT call saveChecklistItems if the loaded flag is false', () => { 234 | const { flushEffects } = setUp(); 235 | service.add$.next({ item: { title: 'test' }, checklistId: '1' }); 236 | flushEffects(); 237 | expect(storageService.saveChecklistItems).not.toHaveBeenCalledWith(); 238 | }); 239 | }); 240 | 241 | function setUp() { 242 | /* 243 | * https://github.com/jscutlery/devkit/blob/43924070eb8433f56ff9a2e65a24bf48b4a5122e/packages/rx-computed/src/lib/rx-computed.spec.ts#L133 244 | */ 245 | const { flushEffects } = setUpSignalTesting(); 246 | 247 | return { 248 | flushEffects, 249 | }; 250 | } 251 | }); 252 | 253 | function setUpWithoutInjectionContext() { 254 | const { flushEffects } = setUpSignalTesting(); 255 | 256 | return { 257 | flushEffects, 258 | }; 259 | } 260 | 261 | function setUpSignalTesting() { 262 | const injector = TestBed.inject(Injector); 263 | const fixture = TestBed.createComponent(NoopComponent); 264 | 265 | /* Inspiration: https://github.com/angular/angular/blob/06b498f67f2ad16bb465ef378bdb16da84e41a1c/packages/core/rxjs-interop/test/to_observable_spec.ts#LL30C25-L30C25 */ 266 | return { 267 | flushEffects() { 268 | fixture.detectChanges(); 269 | }, 270 | runInTestingInjectionContext(fn: () => T): T { 271 | return runInInjectionContext(injector, fn); 272 | }, 273 | }; 274 | } 275 | 276 | @Component({ 277 | standalone: true, 278 | template: '', 279 | }) 280 | class NoopComponent {} 281 | -------------------------------------------------------------------------------- /src/app/checklist/data-access/checklist-item.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, effect, inject, linkedSignal } from '@angular/core'; 2 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 3 | import { Subject } from 'rxjs'; 4 | import { StorageService } from 'src/app/shared/data-access/storage.service'; 5 | import { RemoveChecklist } from 'src/app/shared/interfaces/checklist'; 6 | import { 7 | AddChecklistItem, 8 | ChecklistItem, 9 | EditChecklistItem, 10 | RemoveChecklistItem, 11 | } from 'src/app/shared/interfaces/checklist-item'; 12 | 13 | export interface ChecklistItemsState { 14 | checklistItems: ChecklistItem[]; 15 | loaded: boolean; 16 | } 17 | 18 | @Injectable({ 19 | providedIn: 'root', 20 | }) 21 | export class ChecklistItemService { 22 | private storageService = inject(StorageService); 23 | 24 | // sources 25 | loadedChecklistItems = this.storageService.loadChecklistItems(); 26 | add$ = new Subject(); 27 | remove$ = new Subject(); 28 | edit$ = new Subject(); 29 | toggle$ = new Subject(); 30 | reset$ = new Subject(); 31 | checklistRemoved$ = new Subject(); 32 | 33 | // state 34 | checklistItems = linkedSignal({ 35 | source: this.loadedChecklistItems.value, 36 | computation: (checklistItems) => checklistItems ?? [], 37 | }); 38 | 39 | constructor() { 40 | this.add$.pipe(takeUntilDestroyed()).subscribe((checklistItem) => 41 | this.checklistItems.update((checklistItems) => [ 42 | ...checklistItems, 43 | { 44 | ...checklistItem.item, 45 | id: Date.now().toString(), 46 | checklistId: checklistItem.checklistId, 47 | checked: false, 48 | }, 49 | ]), 50 | ); 51 | 52 | this.edit$ 53 | .pipe(takeUntilDestroyed()) 54 | .subscribe((update) => 55 | this.checklistItems.update((checklistItems) => 56 | checklistItems.map((item) => 57 | item.id === update.id 58 | ? { ...item, title: update.data.title } 59 | : item, 60 | ), 61 | ), 62 | ); 63 | 64 | this.remove$ 65 | .pipe(takeUntilDestroyed()) 66 | .subscribe((id) => 67 | this.checklistItems.update((checklistItems) => 68 | checklistItems.filter((item) => item.id !== id), 69 | ), 70 | ); 71 | 72 | this.toggle$ 73 | .pipe(takeUntilDestroyed()) 74 | .subscribe((checklistItemId) => 75 | this.checklistItems.update((checklistItems) => 76 | checklistItems.map((item) => 77 | item.id === checklistItemId 78 | ? { ...item, checked: !item.checked } 79 | : item, 80 | ), 81 | ), 82 | ); 83 | 84 | this.reset$ 85 | .pipe(takeUntilDestroyed()) 86 | .subscribe((checklistId) => 87 | this.checklistItems.update((checklistItems) => 88 | checklistItems.map((item) => 89 | item.checklistId === checklistId 90 | ? { ...item, checked: false } 91 | : item, 92 | ), 93 | ), 94 | ); 95 | 96 | this.checklistRemoved$ 97 | .pipe(takeUntilDestroyed()) 98 | .subscribe((checklistId) => 99 | this.checklistItems.update((checklistItems) => 100 | checklistItems.filter((item) => item.checklistId !== checklistId), 101 | ), 102 | ); 103 | 104 | // effects 105 | effect(() => { 106 | const checklistItems = this.checklistItems(); 107 | if (this.loadedChecklistItems.status() === 'resolved') { 108 | this.storageService.saveChecklistItems(checklistItems); 109 | } 110 | }); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/app/checklist/ui/checklist-header.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { ChecklistHeaderComponent } from './checklist-header.component'; 3 | import { By } from '@angular/platform-browser'; 4 | import { subscribeSpyTo } from '@hirez_io/observer-spy'; 5 | import { RouterTestingModule } from '@angular/router/testing'; 6 | 7 | describe('ChecklistHeaderComponent', () => { 8 | let component: ChecklistHeaderComponent; 9 | let fixture: ComponentFixture; 10 | 11 | beforeEach(() => { 12 | TestBed.configureTestingModule({ 13 | imports: [ChecklistHeaderComponent, RouterTestingModule], 14 | }) 15 | .overrideComponent(ChecklistHeaderComponent, { 16 | remove: { imports: [] }, 17 | add: { imports: [] }, 18 | }) 19 | .compileComponents(); 20 | 21 | fixture = TestBed.createComponent(ChecklistHeaderComponent); 22 | component = fixture.componentInstance; 23 | 24 | component.checklist = { 25 | id: 'one', 26 | title: 'one', 27 | }; 28 | 29 | fixture.detectChanges(); 30 | }); 31 | 32 | it('should create', () => { 33 | expect(component).toBeTruthy(); 34 | }); 35 | 36 | describe('output: addItem', () => { 37 | it('should emit when add item button is clicked', () => { 38 | const observerSpy = subscribeSpyTo(component.addItem); 39 | 40 | const addButton = fixture.debugElement.query( 41 | By.css('[data-testid="create-checklist-item-button"]') 42 | ); 43 | 44 | addButton.nativeElement.click(); 45 | 46 | expect(observerSpy.getValuesLength()).toEqual(1); 47 | }); 48 | }); 49 | 50 | describe('output: resetChecklist', () => { 51 | it('should emit with the current checklistId when reset button is clicked', () => { 52 | const observerSpy = subscribeSpyTo(component.resetChecklist); 53 | 54 | const resetButton = fixture.debugElement.query( 55 | By.css('[data-testid="reset-items-button"]') 56 | ); 57 | 58 | resetButton.nativeElement.click(); 59 | 60 | expect(observerSpy.getLastValue()).toEqual(component.checklist.id); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/app/checklist/ui/checklist-header.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, input, output } from '@angular/core'; 2 | import { RouterLink } from '@angular/router'; 3 | import { Checklist } from 'src/app/shared/interfaces/checklist'; 4 | 5 | @Component({ 6 | standalone: true, 7 | selector: 'app-checklist-header', 8 | template: ` 9 |
10 | Back 11 |

12 | {{ checklist().title }} 13 |

14 |
15 | 21 | 27 |
28 |
29 | `, 30 | styles: [ 31 | ` 32 | button { 33 | margin-left: 1rem; 34 | } 35 | `, 36 | ], 37 | imports: [RouterLink], 38 | }) 39 | export class ChecklistHeaderComponent { 40 | checklist = input.required(); 41 | addItem = output(); 42 | resetChecklist = output(); 43 | } 44 | -------------------------------------------------------------------------------- /src/app/checklist/ui/checklist-item-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { ChecklistItemListComponent } from './checklist-item-list.component'; 3 | import { By } from '@angular/platform-browser'; 4 | import { subscribeSpyTo } from '@hirez_io/observer-spy'; 5 | 6 | describe('ChecklistItemListComponent', () => { 7 | let component: ChecklistItemListComponent; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(() => { 11 | TestBed.configureTestingModule({ 12 | imports: [ChecklistItemListComponent], 13 | }) 14 | .overrideComponent(ChecklistItemListComponent, { 15 | remove: { imports: [] }, 16 | add: { imports: [] }, 17 | }) 18 | .compileComponents(); 19 | 20 | fixture = TestBed.createComponent(ChecklistItemListComponent); 21 | component = fixture.componentInstance; 22 | 23 | component.checklistItems = []; 24 | 25 | fixture.detectChanges(); 26 | }); 27 | 28 | it('should create', () => { 29 | expect(component).toBeTruthy(); 30 | }); 31 | 32 | describe('input: checklistItems', () => { 33 | it('should render a list item for each element', () => { 34 | const testData = [{}, {}, {}] as any; 35 | component.checklistItems = testData; 36 | 37 | fixture.detectChanges(); 38 | 39 | const result = fixture.debugElement.queryAll( 40 | By.css('[data-testid="checklist-item"]') 41 | ); 42 | 43 | expect(result.length).toEqual(testData.length); 44 | }); 45 | 46 | it('should render empty message when checklist items are empty', () => { 47 | const testData = [] as any; 48 | component.checklistItems = testData; 49 | 50 | fixture.detectChanges(); 51 | 52 | const emptyMessage = fixture.debugElement.query( 53 | By.css('[data-testid="no-checklist-items-message"]') 54 | ); 55 | 56 | expect(emptyMessage).toBeTruthy(); 57 | }); 58 | 59 | it('should NOT render empty if there are checklists', () => { 60 | const testData = [{}] as any; 61 | component.checklistItems = testData; 62 | 63 | fixture.detectChanges(); 64 | 65 | const emptyMessage = fixture.debugElement.query( 66 | By.css('[data-testid="no-checklist-items-message"]') 67 | ); 68 | 69 | expect(emptyMessage).toBeFalsy(); 70 | }); 71 | }); 72 | 73 | describe('output: delete', () => { 74 | it('should emit checklist item id to be deleted', () => { 75 | const testData = [{ id: '1', title: 'test' }] as any; 76 | component.checklistItems = testData; 77 | 78 | const observerSpy = subscribeSpyTo(component.delete); 79 | 80 | fixture.detectChanges(); 81 | 82 | const deleteButton = fixture.debugElement.query( 83 | By.css('[data-testid="delete-checklist-item-button"]') 84 | ); 85 | deleteButton.nativeElement.click(); 86 | 87 | expect(observerSpy.getLastValue()).toEqual(testData[0].id); 88 | }); 89 | }); 90 | 91 | describe('output: toggle', () => { 92 | it('should emit checklist item id to be toggled', () => { 93 | const testData = [{ id: '1', title: 'test' }] as any; 94 | component.checklistItems = testData; 95 | 96 | const observerSpy = subscribeSpyTo(component.toggle); 97 | 98 | fixture.detectChanges(); 99 | 100 | const toggleButton = fixture.debugElement.query( 101 | By.css('[data-testid="toggle-checklist-item-button"]') 102 | ); 103 | toggleButton.nativeElement.click(); 104 | 105 | expect(observerSpy.getLastValue()).toEqual(testData[0].id); 106 | }); 107 | }); 108 | 109 | describe('output: edit', () => { 110 | it('should emit checklist item to be edited', () => { 111 | const testData = [{ id: '1', title: 'test' }] as any; 112 | component.checklistItems = testData; 113 | 114 | const observerSpy = subscribeSpyTo(component.edit); 115 | fixture.detectChanges(); 116 | 117 | const editButton = fixture.debugElement.query( 118 | By.css('[data-testid="edit-checklist-item-button"]') 119 | ); 120 | 121 | editButton.nativeElement.click(); 122 | 123 | expect(observerSpy.getLastValue()).toEqual(testData[0]); 124 | }); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /src/app/checklist/ui/checklist-item-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, input, output } from '@angular/core'; 2 | import { ChecklistItem } from 'src/app/shared/interfaces/checklist-item'; 3 | 4 | @Component({ 5 | standalone: true, 6 | selector: 'app-checklist-item-list', 7 | template: ` 8 |
9 |
    10 | @for (item of checklistItems(); track item.id) { 11 |
  • 12 |
    13 | @if (item.checked) { 14 | 15 | } 16 | {{ item.title }} 17 |
    18 |
    19 | 25 | 31 | 37 |
    38 |
  • 39 | } @empty { 40 |
    41 |

    Add an item

    42 |

    43 | Click the add button to add your first item to this quicklist 44 |

    45 |
    46 | } 47 |
48 |
49 | `, 50 | styles: [ 51 | ` 52 | ul { 53 | padding: 0; 54 | margin: 0; 55 | } 56 | li { 57 | font-size: 1.5em; 58 | display: flex; 59 | justify-content: space-between; 60 | background: var(--color-light); 61 | list-style-type: none; 62 | margin-bottom: 1rem; 63 | padding: 1rem; 64 | 65 | button { 66 | margin-left: 1rem; 67 | } 68 | } 69 | `, 70 | ], 71 | }) 72 | export class ChecklistItemListComponent { 73 | checklistItems = input.required(); 74 | delete = output(); 75 | edit = output(); 76 | toggle = output(); 77 | } 78 | -------------------------------------------------------------------------------- /src/app/home/home.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import HomeComponent from './home.component'; 3 | import { By } from '@angular/platform-browser'; 4 | import { ModalComponent } from '../shared/ui/modal.component'; 5 | import { MockModalComponent } from '../shared/ui/modal.component.spec'; 6 | import { ChecklistService } from '../shared/data-access/checklist.service'; 7 | import { DebugElement } from '@angular/core'; 8 | import { FormModalComponent } from '../shared/ui/form-modal.component'; 9 | import { MockFormModalComponent } from '../shared/ui/form-modal.component.spec'; 10 | import { Checklist } from '../shared/interfaces/checklist'; 11 | import { FormBuilder } from '@angular/forms'; 12 | import { ChecklistListComponent } from './ui/checklist-list.component'; 13 | import { MockChecklistListComponent } from './ui/checklist-list.component.spec'; 14 | 15 | describe('HomeComponent', () => { 16 | let component: HomeComponent; 17 | let fixture: ComponentFixture; 18 | let checklistService: ChecklistService; 19 | let formBuilder: FormBuilder; 20 | 21 | const mockChecklists: Checklist[] = [{ id: '1', title: 'test' }]; 22 | 23 | beforeEach(() => { 24 | TestBed.configureTestingModule({ 25 | imports: [HomeComponent], 26 | providers: [ 27 | { 28 | provide: FormBuilder, 29 | useValue: { 30 | nonNullable: { 31 | group: jest.fn().mockReturnValue({ 32 | patchValue: jest.fn(), 33 | reset: jest.fn(), 34 | get: jest.fn(), 35 | getRawValue: jest.fn(), 36 | }), 37 | }, 38 | }, 39 | }, 40 | { 41 | provide: ChecklistService, 42 | useValue: { 43 | checklists: jest.fn().mockReturnValue(mockChecklists), 44 | add$: { 45 | next: jest.fn(), 46 | }, 47 | remove$: { 48 | next: jest.fn(), 49 | }, 50 | edit$: { 51 | next: jest.fn(), 52 | }, 53 | }, 54 | }, 55 | ], 56 | }) 57 | .overrideComponent(HomeComponent, { 58 | remove: { 59 | imports: [ModalComponent, FormModalComponent, ChecklistListComponent], 60 | }, 61 | add: { 62 | imports: [ 63 | MockModalComponent, 64 | MockFormModalComponent, 65 | MockChecklistListComponent, 66 | ], 67 | }, 68 | }) 69 | .compileComponents(); 70 | 71 | checklistService = TestBed.inject(ChecklistService); 72 | formBuilder = TestBed.inject(FormBuilder); 73 | 74 | fixture = TestBed.createComponent(HomeComponent); 75 | component = fixture.componentInstance; 76 | fixture.detectChanges(); 77 | }); 78 | 79 | it('should create', () => { 80 | expect(component).toBeTruthy(); 81 | }); 82 | 83 | describe('app-checklist-list', () => { 84 | let list: DebugElement; 85 | 86 | beforeEach(() => { 87 | list = fixture.debugElement.query(By.css('app-checklist-list')); 88 | }); 89 | 90 | describe('input: checklists', () => { 91 | it('should use checklists selector as input', () => { 92 | expect(list.componentInstance.checklists).toEqual(mockChecklists); 93 | }); 94 | }); 95 | 96 | describe('output: delete', () => { 97 | it('should next remove$ source with emitted value', () => { 98 | const testId = 5; 99 | list.triggerEventHandler('delete', testId); 100 | 101 | expect(checklistService.remove$.next).toHaveBeenCalledWith(testId); 102 | }); 103 | }); 104 | 105 | describe('output: edit', () => { 106 | let testChecklist: any; 107 | beforeEach(() => { 108 | testChecklist = { id: '1', title: 'test' } as any; 109 | list.triggerEventHandler('edit', testChecklist); 110 | fixture.detectChanges(); 111 | }); 112 | 113 | it('should open modal', () => { 114 | const modal = fixture.debugElement.query(By.css('app-modal')); 115 | expect(modal.componentInstance.isOpen).toBeTruthy(); 116 | }); 117 | 118 | it('should patch form with checklist title', () => { 119 | expect(component.checklistForm.patchValue).toHaveBeenCalledWith({ 120 | title: testChecklist.title, 121 | }); 122 | }); 123 | }); 124 | }); 125 | 126 | describe('app-modal', () => { 127 | let appModal: DebugElement; 128 | 129 | beforeEach(() => { 130 | appModal = fixture.debugElement.query(By.css('app-modal')); 131 | }); 132 | 133 | describe('input: isOpen', () => { 134 | it('should be truthy when add button clicked', () => { 135 | const addButton = fixture.debugElement.query( 136 | By.css('[data-testid="create-checklist-button"]') 137 | ); 138 | 139 | addButton.nativeElement.click(); 140 | 141 | fixture.detectChanges(); 142 | 143 | const modal = fixture.debugElement.query(By.css('app-modal')); 144 | 145 | expect(modal.componentInstance.isOpen).toBeTruthy(); 146 | }); 147 | }); 148 | }); 149 | 150 | describe('app-form-modal', () => { 151 | let appFormModal: DebugElement; 152 | 153 | beforeEach(() => { 154 | component.checklistBeingEdited.set({}); 155 | fixture.detectChanges(); 156 | 157 | appFormModal = fixture.debugElement.query(By.css('app-form-modal')); 158 | }); 159 | 160 | describe('output: save', () => { 161 | describe('checklist not being edited', () => { 162 | it('should next add$ source with form values', () => { 163 | appFormModal.triggerEventHandler('save'); 164 | expect(checklistService.add$.next).toHaveBeenCalledWith( 165 | component.checklistForm.getRawValue() 166 | ); 167 | }); 168 | }); 169 | 170 | describe('checklist being edited', () => { 171 | let testChecklist: any; 172 | 173 | beforeEach(() => { 174 | testChecklist = { id: '5', title: 'hello' }; 175 | component.checklistBeingEdited.set(testChecklist); 176 | }); 177 | 178 | it('should next edit$ source with current id and form data', () => { 179 | appFormModal.triggerEventHandler('save'); 180 | expect(checklistService.edit$.next).toHaveBeenCalledWith({ 181 | id: testChecklist.id, 182 | data: component.checklistForm.getRawValue(), 183 | }); 184 | }); 185 | }); 186 | }); 187 | 188 | describe('output: close', () => { 189 | it('should close app-modal', () => { 190 | appFormModal.triggerEventHandler('close'); 191 | fixture.detectChanges(); 192 | 193 | const modal = fixture.debugElement.query(By.css('app-modal')); 194 | 195 | expect(modal.componentInstance.isOpen).toBeFalsy(); 196 | }); 197 | 198 | it('should reset form', () => { 199 | component.checklistForm.get('title')?.setValue('test'); 200 | fixture.detectChanges(); 201 | 202 | appFormModal.triggerEventHandler('close'); 203 | fixture.detectChanges(); 204 | 205 | expect(component.checklistForm.reset).toHaveBeenCalled(); 206 | }); 207 | }); 208 | }); 209 | }); 210 | -------------------------------------------------------------------------------- /src/app/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, effect, inject, signal } from '@angular/core'; 2 | import { ChecklistListComponent } from './ui/checklist-list.component'; 3 | import { Checklist } from '../shared/interfaces/checklist'; 4 | import { ModalComponent } from '../shared/ui/modal.component'; 5 | import { FormModalComponent } from '../shared/ui/form-modal.component'; 6 | import { FormBuilder } from '@angular/forms'; 7 | import { ChecklistService } from '../shared/data-access/checklist.service'; 8 | 9 | @Component({ 10 | selector: 'app-home', 11 | standalone: true, 12 | template: ` 13 |
14 |

Quicklists

15 | 16 |
17 | 18 |
19 |

Your checklists

20 | 25 |
26 | 27 | 28 | 29 | 46 | 47 | 48 | `, 49 | imports: [ChecklistListComponent, ModalComponent, FormModalComponent], 50 | }) 51 | export default class HomeComponent { 52 | formBuilder = inject(FormBuilder); 53 | checklistService = inject(ChecklistService); 54 | 55 | checklistBeingEdited = signal | null>(null); 56 | 57 | checklistForm = this.formBuilder.nonNullable.group({ 58 | title: [''], 59 | }); 60 | 61 | constructor() { 62 | effect(() => { 63 | const checklist = this.checklistBeingEdited(); 64 | 65 | if (!checklist) { 66 | this.checklistForm.reset(); 67 | } else { 68 | this.checklistForm.patchValue({ 69 | title: checklist.title, 70 | }); 71 | } 72 | }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/app/home/ui/checklist-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | import { ChecklistListComponent } from './checklist-list.component'; 4 | import { By } from '@angular/platform-browser'; 5 | import { subscribeSpyTo } from '@hirez_io/observer-spy'; 6 | 7 | import { Component, EventEmitter, Input, Output } from '@angular/core'; 8 | import { Checklist } from 'src/app/shared/interfaces/checklist'; 9 | 10 | @Component({ 11 | standalone: true, 12 | selector: 'app-checklist-list', 13 | template: `

Hello world

`, 14 | }) 15 | export class MockChecklistListComponent { 16 | @Input({ required: true }) checklists!: Checklist[]; 17 | @Output() delete = new EventEmitter(); 18 | @Output() edit = new EventEmitter(); 19 | } 20 | 21 | describe('ChecklistListComponent', () => { 22 | let component: ChecklistListComponent; 23 | let fixture: ComponentFixture; 24 | 25 | beforeEach(() => { 26 | TestBed.configureTestingModule({ 27 | imports: [ChecklistListComponent, RouterTestingModule], 28 | }) 29 | .overrideComponent(ChecklistListComponent, { 30 | remove: { imports: [] }, 31 | add: { imports: [] }, 32 | }) 33 | .compileComponents(); 34 | 35 | fixture = TestBed.createComponent(ChecklistListComponent); 36 | component = fixture.componentInstance; 37 | 38 | component.checklists = []; 39 | 40 | fixture.detectChanges(); 41 | }); 42 | 43 | it('should create', () => { 44 | expect(component).toBeTruthy(); 45 | }); 46 | 47 | describe('input: checklists', () => { 48 | it('should render a list item for each element', () => { 49 | const testData = [{}, {}, {}] as any; 50 | component.checklists = testData; 51 | 52 | fixture.detectChanges(); 53 | 54 | const result = fixture.debugElement.queryAll( 55 | By.css('[data-testid="checklist-item"]') 56 | ); 57 | 58 | expect(result.length).toEqual(testData.length); 59 | }); 60 | 61 | it('should render empty message when checklists are empty', () => { 62 | const testData = [] as any; 63 | component.checklists = testData; 64 | 65 | fixture.detectChanges(); 66 | 67 | const emptyMessage = fixture.debugElement.query( 68 | By.css('[data-testid="no-checklists-message"]') 69 | ); 70 | 71 | expect(emptyMessage).toBeTruthy(); 72 | }); 73 | 74 | it('should NOT render empty if there are checklists', () => { 75 | const testData = [{}] as any; 76 | component.checklists = testData; 77 | 78 | fixture.detectChanges(); 79 | 80 | const emptyMessage = fixture.debugElement.query( 81 | By.css('[data-testid="no-checklists-message"]') 82 | ); 83 | 84 | expect(emptyMessage).toBeFalsy(); 85 | }); 86 | }); 87 | 88 | describe('output: delete', () => { 89 | it('should emit checklist id to be deleted', () => { 90 | const testData = [{ id: '1', title: 'test' }] as any; 91 | component.checklists = testData; 92 | 93 | const observerSpy = subscribeSpyTo(component.delete); 94 | 95 | fixture.detectChanges(); 96 | 97 | const deleteButton = fixture.debugElement.query( 98 | By.css('[data-testid="delete-checklist"]') 99 | ); 100 | deleteButton.nativeElement.click(); 101 | 102 | expect(observerSpy.getLastValue()).toEqual(testData[0].id); 103 | }); 104 | }); 105 | 106 | describe('output: edit', () => { 107 | it('should emit checklist to be edited', () => { 108 | const testData = [{ id: '1', title: 'test' }]; 109 | component.checklists = testData; 110 | 111 | const observerSpy = subscribeSpyTo(component.edit); 112 | fixture.detectChanges(); 113 | 114 | const editButton = fixture.debugElement.query( 115 | By.css('[data-testid="edit-checklist"]') 116 | ); 117 | 118 | editButton.nativeElement.click(); 119 | 120 | expect(observerSpy.getLastValue()).toEqual(testData[0]); 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /src/app/home/ui/checklist-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, input, output } from '@angular/core'; 2 | import { RouterLink } from '@angular/router'; 3 | import { Checklist } from 'src/app/shared/interfaces/checklist'; 4 | 5 | @Component({ 6 | standalone: true, 7 | selector: 'app-checklist-list', 8 | template: ` 9 |
    10 | @for (checklist of checklists(); track checklist.id) { 11 |
  • 12 | 16 | {{ checklist.title }} 17 | 18 |
    19 | 22 | 28 |
    29 |
  • 30 | } @empty { 31 |

    32 | Click the add button to create your first checklist! 33 |

    34 | } 35 |
36 | `, 37 | styles: [ 38 | ` 39 | ul { 40 | padding: 0; 41 | margin: 0; 42 | } 43 | li { 44 | font-size: 1.5em; 45 | display: flex; 46 | justify-content: space-between; 47 | background: var(--color-light); 48 | list-style-type: none; 49 | margin-bottom: 1rem; 50 | padding: 1rem; 51 | 52 | button { 53 | margin-left: 1rem; 54 | } 55 | } 56 | `, 57 | ], 58 | imports: [RouterLink], 59 | }) 60 | export class ChecklistListComponent { 61 | checklists = input.required(); 62 | delete = output(); 63 | edit = output(); 64 | } 65 | -------------------------------------------------------------------------------- /src/app/shared/data-access/checklist.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { ChecklistService } from './checklist.service'; 3 | import { Subject } from 'rxjs'; 4 | import { StorageService } from './storage.service'; 5 | import { Component, Injector, runInInjectionContext } from '@angular/core'; 6 | import { ChecklistItemService } from 'src/app/checklist/data-access/checklist-item.service'; 7 | 8 | describe('ChecklistService', () => { 9 | let service: ChecklistService; 10 | let storageService: StorageService; 11 | let loadChecklistsSubject: Subject; 12 | 13 | beforeEach(() => { 14 | loadChecklistsSubject = new Subject(); 15 | 16 | TestBed.configureTestingModule({ 17 | providers: [ 18 | ChecklistService, 19 | { 20 | provide: StorageService, 21 | useValue: { 22 | loadChecklists: jest.fn().mockReturnValue(loadChecklistsSubject), 23 | saveChecklists: jest.fn(), 24 | }, 25 | }, 26 | { 27 | provide: ChecklistItemService, 28 | useValue: { 29 | checklistRemoved$: new Subject(), 30 | }, 31 | }, 32 | ], 33 | }); 34 | 35 | service = TestBed.inject(ChecklistService); 36 | storageService = TestBed.inject(StorageService); 37 | }); 38 | 39 | it('should create', () => { 40 | expect(service).toBeTruthy(); 41 | }); 42 | 43 | describe('source: add$', () => { 44 | let testData = { title: 'test' }; 45 | 46 | beforeEach(() => { 47 | service.add$.next(testData); 48 | }); 49 | 50 | it('should add the supplied data to the checklists array', () => { 51 | expect( 52 | service 53 | .checklists() 54 | .find((checklist) => checklist.title === testData.title) 55 | ).toBeTruthy(); 56 | }); 57 | 58 | it('should not remove other data from the checklists array', () => { 59 | service.add$.next({ title: 'another' }); 60 | expect(service.checklists().length).toEqual(2); 61 | }); 62 | }); 63 | 64 | describe('source: edit$', () => { 65 | let preEdit = { title: 'test' }; 66 | let postEdit = { title: 'edited' }; 67 | 68 | beforeEach(() => { 69 | service.add$.next(preEdit); 70 | const addedChecklist = service.checklists()[0]; 71 | service.edit$.next({ id: addedChecklist.id, data: { ...postEdit } }); 72 | }); 73 | 74 | it('should edit the checklist with the supplied data', () => { 75 | const checklist = service.checklists()[0]; 76 | expect(checklist).toEqual({ id: checklist.id, ...postEdit }); 77 | }); 78 | }); 79 | 80 | describe('source: remove$', () => { 81 | beforeEach(() => { 82 | // add some test data 83 | service.add$.next({ title: 'abc' }); 84 | service.add$.next({ title: 'def' }); 85 | service.add$.next({ title: 'ghi' }); 86 | }); 87 | 88 | it('should remove the checklist with the supplied id', () => { 89 | const testChecklist = service.checklists()[0]; 90 | service.remove$.next(testChecklist.id); 91 | expect( 92 | service 93 | .checklists() 94 | .find((checklist) => checklist.id === testChecklist.id) 95 | ).toBeFalsy(); 96 | }); 97 | 98 | it('should NOT remove checklists that do not match the id', () => { 99 | const testChecklist = service.checklists()[0]; 100 | const prevLength = service.checklists().length; 101 | service.remove$.next(testChecklist.id); 102 | expect(service.checklists().length).toEqual(prevLength - 1); 103 | }); 104 | }); 105 | 106 | describe('source: checklistsLoaded$', () => { 107 | it('should update checklists state when loadChecklists() emits', () => { 108 | const testData = [{}, {}]; 109 | loadChecklistsSubject.next(testData); 110 | expect(service.checklists()).toEqual(testData); 111 | }); 112 | 113 | it('should set loaded flag to true if loaded successfully', () => { 114 | expect(service.loaded()).toEqual(false); 115 | loadChecklistsSubject.next([]); 116 | expect(service.loaded()).toEqual(true); 117 | }); 118 | 119 | it('should set the error state if load fails', () => { 120 | expect(service.error()).toEqual(null); 121 | const testError = 'err'; 122 | loadChecklistsSubject.error(testError); 123 | expect(service.error()).toEqual(testError); 124 | }); 125 | }); 126 | 127 | describe('effect: checklists()', () => { 128 | it('should call saveChecklists method with checklists when checklists() changes', () => { 129 | const { flushEffects } = setUp(); 130 | loadChecklistsSubject.next([]); 131 | service.add$.next({ title: 'test' }); 132 | flushEffects(); 133 | expect(storageService.saveChecklists).toHaveBeenCalledWith( 134 | service.checklists() 135 | ); 136 | }); 137 | 138 | it('should NOT call saveChecklists if the loaded flag is false', () => { 139 | const { flushEffects } = setUp(); 140 | service.add$.next({ title: 'test' }); 141 | flushEffects(); 142 | expect(storageService.saveChecklists).not.toHaveBeenCalledWith(); 143 | }); 144 | }); 145 | 146 | function setUp() { 147 | /* 148 | * https://github.com/jscutlery/devkit/blob/43924070eb8433f56ff9a2e65a24bf48b4a5122e/packages/rx-computed/src/lib/rx-computed.spec.ts#L133 149 | */ 150 | const { flushEffects } = setUpSignalTesting(); 151 | 152 | return { 153 | flushEffects, 154 | }; 155 | } 156 | }); 157 | 158 | function setUpWithoutInjectionContext() { 159 | const { flushEffects } = setUpSignalTesting(); 160 | 161 | return { 162 | flushEffects, 163 | }; 164 | } 165 | 166 | function setUpSignalTesting() { 167 | const injector = TestBed.inject(Injector); 168 | const fixture = TestBed.createComponent(NoopComponent); 169 | 170 | /* Inspiration: https://github.com/angular/angular/blob/06b498f67f2ad16bb465ef378bdb16da84e41a1c/packages/core/rxjs-interop/test/to_observable_spec.ts#LL30C25-L30C25 */ 171 | return { 172 | flushEffects() { 173 | fixture.detectChanges(); 174 | }, 175 | runInTestingInjectionContext(fn: () => T): T { 176 | return runInInjectionContext(injector, fn); 177 | }, 178 | }; 179 | } 180 | 181 | @Component({ 182 | standalone: true, 183 | template: '', 184 | }) 185 | class NoopComponent {} 186 | -------------------------------------------------------------------------------- /src/app/shared/data-access/checklist.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, effect, inject, linkedSignal } from '@angular/core'; 2 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 3 | import { Subject } from 'rxjs'; 4 | import { AddChecklist, EditChecklist } from '../interfaces/checklist'; 5 | import { ChecklistItemService } from 'src/app/checklist/data-access/checklist-item.service'; 6 | import { StorageService } from './storage.service'; 7 | 8 | @Injectable({ 9 | providedIn: 'root', 10 | }) 11 | export class ChecklistService { 12 | private checklistItemService = inject(ChecklistItemService); 13 | private storageService = inject(StorageService); 14 | 15 | // sources 16 | loadedChecklists = this.storageService.loadChecklists(); 17 | add$ = new Subject(); 18 | edit$ = new Subject(); 19 | remove$ = this.checklistItemService.checklistRemoved$; 20 | 21 | // state 22 | checklists = linkedSignal({ 23 | source: this.loadedChecklists.value, 24 | computation: (checklists) => checklists ?? [], 25 | }); 26 | 27 | constructor() { 28 | this.add$ 29 | .pipe(takeUntilDestroyed()) 30 | .subscribe((checklist) => 31 | this.checklists.update((checklists) => [ 32 | ...checklists, 33 | this.addIdToChecklist(checklist), 34 | ]), 35 | ); 36 | 37 | this.remove$ 38 | .pipe(takeUntilDestroyed()) 39 | .subscribe((id) => 40 | this.checklists.update((checklists) => 41 | checklists.filter((checklist) => checklist.id !== id), 42 | ), 43 | ); 44 | 45 | this.edit$ 46 | .pipe(takeUntilDestroyed()) 47 | .subscribe((update) => 48 | this.checklists.update((checklists) => 49 | checklists.map((checklist) => 50 | checklist.id === update.id 51 | ? { ...checklist, title: update.data.title } 52 | : checklist, 53 | ), 54 | ), 55 | ); 56 | 57 | // effects 58 | effect(() => { 59 | const checklists = this.checklists(); 60 | if (this.loadedChecklists.status() === 'resolved') { 61 | this.storageService.saveChecklists(checklists); 62 | } 63 | }); 64 | } 65 | 66 | private addIdToChecklist(checklist: AddChecklist) { 67 | return { 68 | ...checklist, 69 | id: this.generateSlug(checklist.title), 70 | }; 71 | } 72 | 73 | private generateSlug(title: string) { 74 | // NOTE: This is a simplistic slug generator and will not handle things like special characters. 75 | let slug = title.toLowerCase().replace(/\s+/g, '-'); 76 | 77 | // Check if the slug already exists 78 | const matchingSlugs = this.checklists().find( 79 | (checklist) => checklist.id === slug, 80 | ); 81 | 82 | // If the title is already being used, add a string to make the slug unique 83 | if (matchingSlugs) { 84 | slug = slug + Date.now().toString(); 85 | } 86 | 87 | return slug; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/app/shared/data-access/storage.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { StorageService } from './storage.service'; 3 | import { subscribeSpyTo } from '@hirez_io/observer-spy'; 4 | 5 | describe('StorageService', () => { 6 | let service: StorageService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({ 10 | providers: [StorageService], 11 | }); 12 | 13 | jest.clearAllMocks(); 14 | 15 | service = TestBed.inject(StorageService); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(service).toBeTruthy(); 20 | }); 21 | 22 | describe('loadChecklists', () => { 23 | it('should return an observable of whatever data is stored on the "checklists" key', () => { 24 | const testData = [{}, {}]; 25 | const getItem = jest 26 | .spyOn(Storage.prototype, 'getItem') 27 | .mockReturnValue(JSON.stringify(testData)); 28 | 29 | const observerSpy = subscribeSpyTo(service.loadChecklists()); 30 | 31 | expect(observerSpy.getLastValue()).toEqual(testData); 32 | expect(getItem).toHaveBeenCalledWith('checklists'); 33 | expect(getItem).toHaveBeenCalledTimes(1); 34 | }); 35 | 36 | it('should return an empty array if value is null in storage', () => { 37 | const getItem = jest 38 | .spyOn(Storage.prototype, 'getItem') 39 | .mockReturnValue(null); 40 | 41 | const observerSpy = subscribeSpyTo(service.loadChecklists()); 42 | 43 | expect(observerSpy.getLastValue()).toEqual([]); 44 | expect(getItem).toHaveBeenCalledWith('checklists'); 45 | expect(getItem).toHaveBeenCalledTimes(1); 46 | }); 47 | }); 48 | 49 | describe('saveChecklists()', () => { 50 | it('should call setItem of local storage on checklists key with supplied data', () => { 51 | const setItem = jest.spyOn(Storage.prototype, 'setItem'); 52 | 53 | const testChecklists = [{}, {}] as any; 54 | 55 | service.saveChecklists(testChecklists); 56 | 57 | expect(setItem).toHaveBeenCalledWith( 58 | 'checklists', 59 | JSON.stringify(testChecklists) 60 | ); 61 | }); 62 | }); 63 | 64 | describe('loadChecklistItems()', () => { 65 | it('should return an observable of whatever data is stored on the "checklistsItems" key', () => { 66 | const testData = [{}, {}]; 67 | const getItem = jest 68 | .spyOn(Storage.prototype, 'getItem') 69 | .mockReturnValue(JSON.stringify(testData)); 70 | 71 | const observerSpy = subscribeSpyTo(service.loadChecklistItems()); 72 | 73 | expect(observerSpy.getLastValue()).toEqual(testData); 74 | expect(getItem).toHaveBeenCalledWith('checklistItems'); 75 | expect(getItem).toHaveBeenCalledTimes(1); 76 | }); 77 | 78 | it('should return an empty array if value is null in storage', () => { 79 | const getItem = jest 80 | .spyOn(Storage.prototype, 'getItem') 81 | .mockReturnValue(null); 82 | 83 | const observerSpy = subscribeSpyTo(service.loadChecklistItems()); 84 | 85 | expect(observerSpy.getLastValue()).toEqual([]); 86 | expect(getItem).toHaveBeenCalledWith('checklistItems'); 87 | expect(getItem).toHaveBeenCalledTimes(1); 88 | }); 89 | }); 90 | 91 | describe('saveChecklistItems()', () => { 92 | it('should call setItem of local storage on checklistItems key with supplied data', () => { 93 | const setItem = jest.spyOn(Storage.prototype, 'setItem'); 94 | 95 | const testChecklistItems = [{}, {}] as any; 96 | 97 | service.saveChecklistItems(testChecklistItems); 98 | 99 | expect(setItem).toHaveBeenCalledWith( 100 | 'checklistItems', 101 | JSON.stringify(testChecklistItems) 102 | ); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/app/shared/data-access/storage.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | InjectionToken, 4 | PLATFORM_ID, 5 | inject, 6 | resource, 7 | } from '@angular/core'; 8 | import { of } from 'rxjs'; 9 | import { Checklist } from '../interfaces/checklist'; 10 | import { ChecklistItem } from '../interfaces/checklist-item'; 11 | 12 | export const LOCAL_STORAGE = new InjectionToken( 13 | 'window local storage object', 14 | { 15 | providedIn: 'root', 16 | factory: () => { 17 | return inject(PLATFORM_ID) === 'browser' 18 | ? window.localStorage 19 | : ({} as Storage); 20 | }, 21 | }, 22 | ); 23 | 24 | @Injectable({ 25 | providedIn: 'root', 26 | }) 27 | export class StorageService { 28 | storage = inject(LOCAL_STORAGE); 29 | 30 | loadChecklists() { 31 | // NOTE: simulating async api 32 | return resource({ 33 | loader: () => 34 | Promise.resolve(this.storage.getItem('checklists')).then( 35 | (checklists) => 36 | checklists ? (JSON.parse(checklists) as Checklist[]) : [], 37 | ), 38 | }); 39 | } 40 | 41 | loadChecklistItems() { 42 | return resource({ 43 | loader: () => 44 | Promise.resolve(this.storage.getItem('checklistItems')).then( 45 | (checklistItems) => 46 | checklistItems 47 | ? (JSON.parse(checklistItems) as ChecklistItem[]) 48 | : [], 49 | ), 50 | }); 51 | } 52 | 53 | saveChecklists(checklists: Checklist[]) { 54 | this.storage.setItem('checklists', JSON.stringify(checklists)); 55 | } 56 | 57 | saveChecklistItems(checklistItems: ChecklistItem[]) { 58 | this.storage.setItem('checklistItems', JSON.stringify(checklistItems)); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/app/shared/interfaces/checklist-item.ts: -------------------------------------------------------------------------------- 1 | import { RemoveChecklist } from './checklist'; 2 | 3 | export interface ChecklistItem { 4 | id: string; 5 | checklistId: string; 6 | title: string; 7 | checked: boolean; 8 | } 9 | 10 | export type AddChecklistItem = { 11 | item: Omit; 12 | checklistId: RemoveChecklist; 13 | }; 14 | export type EditChecklistItem = { 15 | id: ChecklistItem['id']; 16 | data: AddChecklistItem['item']; 17 | }; 18 | export type RemoveChecklistItem = ChecklistItem['id']; 19 | -------------------------------------------------------------------------------- /src/app/shared/interfaces/checklist.ts: -------------------------------------------------------------------------------- 1 | export interface Checklist { 2 | id: string; 3 | title: string; 4 | } 5 | 6 | export type AddChecklist = Omit; 7 | export type EditChecklist = { id: Checklist['id']; data: AddChecklist }; 8 | export type RemoveChecklist = Checklist['id']; 9 | -------------------------------------------------------------------------------- /src/app/shared/ui/form-modal.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { FormModalComponent } from './form-modal.component'; 3 | import { FormControl, FormGroup } from '@angular/forms'; 4 | import { By } from '@angular/platform-browser'; 5 | import { SubscriberSpy, subscribeSpyTo } from '@hirez_io/observer-spy'; 6 | import { Component, Output, EventEmitter, Input } from '@angular/core'; 7 | 8 | @Component({ 9 | standalone: true, 10 | selector: 'app-form-modal', 11 | template: `
`, 12 | }) 13 | export class MockFormModalComponent { 14 | @Input() formGroup!: FormGroup; 15 | @Output() save = new EventEmitter(); 16 | @Output() close = new EventEmitter(); 17 | } 18 | 19 | describe('FormModalComponent', () => { 20 | let component: FormModalComponent; 21 | let fixture: ComponentFixture; 22 | 23 | beforeEach(() => { 24 | TestBed.configureTestingModule({ 25 | imports: [FormModalComponent], 26 | }) 27 | .overrideComponent(FormModalComponent, { 28 | remove: { imports: [] }, 29 | add: { imports: [] }, 30 | }) 31 | .compileComponents(); 32 | 33 | fixture = TestBed.createComponent(FormModalComponent); 34 | component = fixture.componentInstance; 35 | 36 | component.formGroup = new FormGroup({ 37 | title: new FormControl(), 38 | }); 39 | 40 | fixture.detectChanges(); 41 | }); 42 | 43 | it('should create', () => { 44 | expect(component).toBeTruthy(); 45 | }); 46 | 47 | describe('input: formGroup', () => { 48 | it('should render a text input for each control', () => { 49 | const controls = { 50 | testOne: new FormControl(''), 51 | testTwo: new FormControl(''), 52 | testThree: new FormControl(''), 53 | }; 54 | 55 | component.formGroup = new FormGroup(controls); 56 | 57 | fixture.detectChanges(); 58 | 59 | const inputs = fixture.debugElement.queryAll( 60 | By.css('input[type="text"]') 61 | ); 62 | 63 | expect(inputs.length).toEqual(Object.entries(controls).length); 64 | }); 65 | 66 | it('should bind the inputs to the controls', () => { 67 | const testValue = 'hello'; 68 | 69 | component.formGroup = new FormGroup({ 70 | title: new FormControl(testValue), 71 | }); 72 | 73 | fixture.detectChanges(); 74 | 75 | const input = fixture.debugElement.query(By.css('input[type="text"]')); 76 | 77 | expect(input.nativeElement.value).toEqual(testValue); 78 | }); 79 | 80 | it('should use the form control name as label for input', () => { 81 | component.formGroup = new FormGroup({ 82 | title: new FormControl(), 83 | }); 84 | 85 | fixture.detectChanges(); 86 | 87 | const label = fixture.debugElement.query(By.css('label')); 88 | 89 | expect(label.nativeElement.innerHTML).toContain('title'); 90 | }); 91 | }); 92 | 93 | describe('output: save', () => { 94 | it('should emit when the save button is clicked', () => { 95 | const observerSpy = subscribeSpyTo(component.save); 96 | 97 | const saveButton = fixture.debugElement.query( 98 | By.css('form button[type="submit"]') 99 | ); 100 | 101 | saveButton.nativeElement.click(); 102 | 103 | expect(observerSpy.getValuesLength()).toEqual(1); 104 | }); 105 | }); 106 | 107 | describe('output: close', () => { 108 | let closeSpy: SubscriberSpy; 109 | 110 | beforeEach(() => { 111 | closeSpy = subscribeSpyTo(component.close); 112 | }); 113 | 114 | it('should emit when save button is clicked', () => { 115 | const saveButton = fixture.debugElement.query( 116 | By.css('form button[type="submit"]') 117 | ); 118 | 119 | saveButton.nativeElement.click(); 120 | 121 | expect(closeSpy.getValuesLength()).toEqual(1); 122 | }); 123 | 124 | it('should emit when the close button is clicked', () => { 125 | const closeButton = fixture.debugElement.query( 126 | By.css('[data-testid="close-modal-button"]') 127 | ); 128 | 129 | closeButton.nativeElement.click(); 130 | 131 | expect(closeSpy.getValuesLength()).toEqual(1); 132 | }); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /src/app/shared/ui/form-modal.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule, KeyValuePipe } from '@angular/common'; 2 | import { Component, input, output } from '@angular/core'; 3 | import { FormGroup, ReactiveFormsModule } from '@angular/forms'; 4 | 5 | @Component({ 6 | standalone: true, 7 | selector: 'app-form-modal', 8 | template: ` 9 |
10 |

{{ title() }}

11 | 12 |
13 |
14 |
15 | @for (control of formGroup().controls | keyvalue; track control.key) { 16 |
17 | 18 | 23 |
24 | } 25 | 26 |
27 |
28 | `, 29 | styles: [ 30 | ` 31 | form { 32 | padding: 1rem; 33 | } 34 | 35 | div { 36 | display: flex; 37 | flex-direction: column; 38 | 39 | label { 40 | margin-bottom: 1rem; 41 | font-weight: bold; 42 | } 43 | 44 | input { 45 | font-size: 1.5rem; 46 | padding: 10px; 47 | } 48 | } 49 | 50 | section button { 51 | margin-top: 1rem; 52 | width: 100%; 53 | } 54 | `, 55 | ], 56 | imports: [ReactiveFormsModule, KeyValuePipe], 57 | }) 58 | export class FormModalComponent { 59 | formGroup = input.required(); 60 | title = input.required(); 61 | save = output(); 62 | close = output(); 63 | } 64 | -------------------------------------------------------------------------------- /src/app/shared/ui/modal.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { ModalComponent } from './modal.component'; 3 | 4 | import { 5 | Component, 6 | ContentChild, 7 | EmbeddedViewRef, 8 | Input, 9 | OnChanges, 10 | OnDestroy, 11 | SimpleChanges, 12 | TemplateRef, 13 | ViewChild, 14 | ViewContainerRef, 15 | } from '@angular/core'; 16 | import { Dialog } from '@angular/cdk/dialog'; 17 | 18 | @Component({ 19 | standalone: true, 20 | selector: 'app-modal', 21 | template: ` `, 22 | }) 23 | export class MockModalComponent implements OnDestroy, OnChanges { 24 | @Input() isOpen!: boolean; 25 | 26 | ngOnChanges(changes: SimpleChanges) { 27 | if (changes['isOpen'] && this.templateRef) { 28 | this.viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef); 29 | } else if (this.viewRef) { 30 | this.viewRef.destroy(); 31 | this.viewRef = null; 32 | } 33 | } 34 | 35 | @ViewChild('container', { read: ViewContainerRef }) 36 | viewContainerRef!: ViewContainerRef; 37 | @ContentChild(TemplateRef) templateRef!: TemplateRef; 38 | 39 | private viewRef: EmbeddedViewRef | null = null; 40 | 41 | ngOnDestroy() { 42 | if (this.viewRef) { 43 | this.viewRef.destroy(); 44 | } 45 | } 46 | } 47 | 48 | @Component({ 49 | standalone: true, 50 | template: ` 51 | 52 | 53 |

test content

54 |
55 |
56 | `, 57 | imports: [ModalComponent], 58 | }) 59 | class TestHostComponent { 60 | isOpen = false; 61 | @ViewChild('testTemplate') templateRef!: TemplateRef; 62 | } 63 | 64 | describe('ModalComponent', () => { 65 | let component: ModalComponent; 66 | let hostComponent: TestHostComponent; 67 | let fixture: ComponentFixture; 68 | let dialog: Dialog; 69 | 70 | beforeEach(() => { 71 | TestBed.configureTestingModule({ 72 | imports: [ModalComponent, TestHostComponent], 73 | providers: [ 74 | { 75 | provide: Dialog, 76 | useValue: { 77 | open: jest.fn(), 78 | closeAll: jest.fn(), 79 | }, 80 | }, 81 | ], 82 | }) 83 | .overrideComponent(ModalComponent, { 84 | remove: { imports: [] }, 85 | add: { imports: [] }, 86 | }) 87 | .compileComponents(); 88 | 89 | dialog = TestBed.inject(Dialog); 90 | 91 | fixture = TestBed.createComponent(TestHostComponent); 92 | hostComponent = fixture.componentInstance; 93 | component = fixture.debugElement.children[0].componentInstance; 94 | fixture.detectChanges(); 95 | }); 96 | 97 | it('should create', () => { 98 | expect(component).toBeTruthy(); 99 | }); 100 | 101 | describe('input: isOpen', () => { 102 | it('should call open method of dialog with template ref when changed to true', () => { 103 | hostComponent.isOpen = true; 104 | fixture.detectChanges(); 105 | 106 | expect(dialog.open).toHaveBeenCalledWith(hostComponent.templateRef); 107 | }); 108 | 109 | it('should call closeAll method of dialog when changed to false', () => { 110 | hostComponent.isOpen = true; 111 | fixture.detectChanges(); 112 | hostComponent.isOpen = false; 113 | fixture.detectChanges(); 114 | 115 | expect(dialog.closeAll).toHaveBeenCalled(); 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /src/app/shared/ui/modal.component.ts: -------------------------------------------------------------------------------- 1 | import { Dialog } from '@angular/cdk/dialog'; 2 | import { 3 | Component, 4 | TemplateRef, 5 | contentChild, 6 | effect, 7 | inject, 8 | input, 9 | } from '@angular/core'; 10 | 11 | @Component({ 12 | standalone: true, 13 | selector: 'app-modal', 14 | template: `
`, 15 | }) 16 | export class ModalComponent { 17 | dialog = inject(Dialog); 18 | isOpen = input.required(); 19 | template = contentChild.required(TemplateRef); 20 | 21 | constructor() { 22 | effect(() => { 23 | const isOpen = this.isOpen(); 24 | 25 | if (isOpen) { 26 | this.dialog.open(this.template(), { panelClass: 'dialog-container' }); 27 | } else { 28 | this.dialog.closeAll(); 29 | } 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuamorony/angularstart-quicklists/c155e70ebf967c19c36dd92e59f9cb25e65ca2df/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuamorony/angularstart-quicklists/c155e70ebf967c19c36dd92e59f9cb25e65ca2df/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AngularstartQuicklists 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { appConfig } from './app/app.config'; 3 | import { AppComponent } from './app/app.component'; 4 | 5 | bootstrapApplication(AppComponent, appConfig).catch((err) => 6 | console.error(err) 7 | ); 8 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | :root { 3 | --color-primary: #32db64; 4 | --color-secondary: #2dd36f; 5 | --color-dark: #1c1d3b; 6 | --color-light: #ffffff; 7 | } 8 | 9 | html { 10 | height: 100%; 11 | } 12 | 13 | body { 14 | margin: 0; 15 | height: 100%; 16 | background: var(--color-dark); 17 | font-family: "Roboto", sans-serif; 18 | } 19 | 20 | header { 21 | display: flex; 22 | justify-content: space-between; 23 | align-items: center; 24 | padding: 2rem; 25 | background: var(--color-light); 26 | } 27 | 28 | section { 29 | height: 100%; 30 | padding: 2rem; 31 | background: var(--color-primary); 32 | } 33 | 34 | button { 35 | height: 30px; 36 | } 37 | 38 | .dialog-container { 39 | position: absolute !important; 40 | background: var(--color-dark); 41 | top: 0; 42 | bottom: 0; 43 | right: 0; 44 | left: 0; 45 | } 46 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "esModuleInterop": true, 9 | "strict": true, 10 | "types": [ 11 | "jest" 12 | ], 13 | "noImplicitOverride": true, 14 | "noPropertyAccessFromIndexSignature": true, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "sourceMap": true, 18 | "declaration": false, 19 | "experimentalDecorators": true, 20 | "moduleResolution": "bundler", 21 | "importHelpers": true, 22 | "target": "ES2022", 23 | "module": "ES2022", 24 | "useDefineForClassFields": false, 25 | "lib": [ 26 | "ES2022", 27 | "dom" 28 | ] 29 | }, 30 | "angularCompilerOptions": { 31 | "enableI18nLegacyMessageIdFormat": false, 32 | "strictInjectionParameters": true, 33 | "strictInputAccessModifiers": true, 34 | "strictTemplates": true, 35 | "_enabledBlockTypes": ["if", "for", "switch", "defer"] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jest" 8 | ] 9 | }, 10 | "include": [ 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | --------------------------------------------------------------------------------