├── src ├── assets │ └── .gitkeep ├── styles.css ├── app │ ├── shared │ │ ├── interfaces │ │ │ ├── checklist.ts │ │ │ └── checklist-item.ts │ │ ├── ui │ │ │ ├── modal.component.ts │ │ │ └── form-modal.component.ts │ │ └── data-access │ │ │ ├── storage.service.ts │ │ │ └── checklist.service.ts │ ├── app.component.ts │ ├── app.routes.ts │ ├── checklist │ │ ├── ui │ │ │ ├── checklist-item-header.component.ts │ │ │ └── checklist-item-list.component.ts │ │ ├── checklist.component.ts │ │ └── data-access │ │ │ └── checklist-item.service.ts │ └── home │ │ ├── ui │ │ └── checklist-list.component.ts │ │ └── home.component.ts ├── index.html ├── main.ts └── favicon.ico ├── tsconfig.app.json ├── .gitignore ├── tsconfig.json ├── package.json ├── README.md └── angular.json /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | -------------------------------------------------------------------------------- /src/app/shared/interfaces/checklist.ts: -------------------------------------------------------------------------------- 1 | export interface Checklist { 2 | id: string; 3 | title: string; 4 | } 5 | 6 | export type AddChecklist = Pick; 7 | export type EditChecklist = { id: Checklist["id"]; data: AddChecklist }; 8 | export type RemoveChecklist = Checklist["id"]; 9 | 10 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from "@angular/core"; 2 | import { RouterModule } from "@angular/router"; 3 | 4 | @Component({ 5 | standalone: true, 6 | imports: [RouterModule], 7 | selector: "app-root", 8 | template: ` `, 9 | }) 10 | export class AppComponent { 11 | } 12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | QuicklistSignals 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Route } from "@angular/router"; 2 | 3 | export const routes: Route[] = [ 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/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 = { item: Pick; checklistId: RemoveChecklist }; 11 | export type EditChecklistItem = { id: ChecklistItem["id"]; data: AddChecklistItem["item"] }; 12 | export type RemoveChecklistItem = ChecklistItem["id"]; 13 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { DialogModule } from "@angular/cdk/dialog"; 2 | import { importProvidersFrom } from "@angular/core"; 3 | import { bootstrapApplication } from "@angular/platform-browser"; 4 | import { provideRouter } from "@angular/router"; 5 | import { defaultStoreProvider} from "@state-adapt/angular"; 6 | import { AppComponent } from "./app/app.component"; 7 | import { routes } from "./app/app.routes"; 8 | 9 | bootstrapApplication(AppComponent, { 10 | providers: [provideRouter(routes), importProvidersFrom(DialogModule), defaultStoreProvider], 11 | }).catch((err) => console.log(err)); 12 | -------------------------------------------------------------------------------- /src/app/shared/ui/modal.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ContentChild, Input, TemplateRef } from "@angular/core"; 2 | import { Dialog } from "@angular/cdk/dialog"; 3 | import { CommonModule } from "@angular/common"; 4 | 5 | @Component({ 6 | standalone: true, 7 | imports: [CommonModule], 8 | selector: "app-modal", 9 | template: `
`, 10 | }) 11 | export class ModalComponent { 12 | @Input() set isOpen(value: boolean) { 13 | if (value) { 14 | this.dialog.open(this.template); 15 | } else { 16 | this.dialog.closeAll(); 17 | } 18 | } 19 | 20 | @ContentChild(TemplateRef, { static: false }) template!: TemplateRef; 21 | 22 | constructor(public dialog: Dialog) {} 23 | } 24 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/app/checklist/ui/checklist-item-header.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from "@angular/core"; 2 | import { Checklist } from "../../shared/interfaces/checklist"; 3 | 4 | @Component({ 5 | standalone: true, 6 | selector: "app-checklist-item-header", 7 | template: ` 8 |
9 |

10 | {{ checklist.title }} 11 |

12 | 13 | 14 | 15 |
16 | `, 17 | }) 18 | export class ChecklistItemHeaderComponent { 19 | @Input() checklist!: Checklist; 20 | @Output() resetChecklist = new EventEmitter(); 21 | @Output() addItem = new EventEmitter(); 22 | } 23 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- 1 | �PNG 2 |  3 | IHDR?�~� pHYs  ��~�fIDATH��WKLQ���̔�uG�� e�n�. 6qcb�l?���D`�F#� Ku�F 1Qc� 4 | ��!���� ��C�P�|B?$���ܱ3����I&}��}�̽s�[*�ɀU�A��K��yx�gY�Ajq��3L Š���˫�OD�4��3Ϗ:X�3��o�P J�ğo#IH�a����,{>1/�2$�R AR]�)w��?�sZw^��q�Y�m_��e���r[8�^� 5 | �&p��-���A}c��- ������!����2_) E�)㊪j���v�m��ZOi� g�nW�{�n.|�e2�a&�0aŸ����be�̀��C�fˤE%-{�ֺ��׮C��N��jXi�~c�C,t��T�����r�{� �L)s��V��6%�(�#ᤙ!�]��H�ҐH$R���^R�A�61(?Y舚�>���(Z����Qm�L2�K�ZIc�� 6 | ���̧�C��2!⅄�(����"�Go��>�q��=��$%�z`ѯ��T�&����PHh�Z!=���z��O��������,*VVV�1�f*CJ�]EE��K�k��d�#5���`2yT!�}7���߈~�,���zs�����y�T��V������D��C2�G��@%̑72Y�޾{oJ�"@��^h�~ ��fĬ�!a�D��6���Ha|�3��� [>�����]7U2п���]�ė 7 | ��PU� �.Wejq�in�g��+p<ߺQH����總j[������.��� Q���p _�K�� 1(��+��bB8�\ra 8 | �́�v.l���(���ǽ�w���L��w�8�C��IEND�B`� -------------------------------------------------------------------------------- /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 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "sourceMap": true, 14 | "declaration": false, 15 | "downlevelIteration": true, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "node", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "ES2022", 21 | "useDefineForClassFields": false, 22 | "lib": [ 23 | "ES2022", 24 | "dom" 25 | ] 26 | }, 27 | "angularCompilerOptions": { 28 | "enableI18nLegacyMessageIdFormat": false, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quicklist-signals", 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 | }, 10 | "private": true, 11 | "dependencies": { 12 | "@angular/animations": "^16.0.3", 13 | "@angular/cdk": "^15.2.1", 14 | "@angular/common": "^16.0.3", 15 | "@angular/compiler": "^16.0.3", 16 | "@angular/core": "^16.0.3", 17 | "@angular/forms": "^16.0.3", 18 | "@angular/platform-browser": "^16.0.3", 19 | "@angular/platform-browser-dynamic": "^16.0.3", 20 | "@angular/router": "^16.0.3", 21 | "@state-adapt/angular": "^1.1.0", 22 | "@state-adapt/core": "^1.1.0", 23 | "@state-adapt/rxjs": "^1.1.0", 24 | "rxjs": "~7.8.0", 25 | "tslib": "^2.3.0", 26 | "zone.js": "~0.13.0" 27 | }, 28 | "devDependencies": { 29 | "@angular-devkit/build-angular": "^16.0.3", 30 | "@angular/cli": "~16.0.3", 31 | "@angular/compiler-cli": "^16.0.3", 32 | "typescript": "~4.9.4" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QuicklistSignals 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 16.0.0-next.2. 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 | -------------------------------------------------------------------------------- /src/app/home/ui/checklist-list.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from "@angular/common"; 2 | import { 3 | Component, 4 | EventEmitter, 5 | Input, 6 | Output, 7 | } from "@angular/core"; 8 | import { FormsModule } from "@angular/forms"; 9 | import { RouterModule } from "@angular/router"; 10 | import { Checklist } from "src/app/shared/interfaces/checklist"; 11 | 12 | @Component({ 13 | standalone: true, 14 | imports: [FormsModule, RouterModule, CommonModule], 15 | selector: "app-checklist-list", 16 | template: ` 17 | 24 | 25 |
26 |

Click the add button to create your first quicklist!

27 |
28 | `, 29 | }) 30 | export class ChecklistListComponent { 31 | @Input() checklists!: Checklist[]; 32 | @Output() delete = new EventEmitter(); 33 | @Output() edit = new EventEmitter(); 34 | 35 | constructor() {} 36 | 37 | trackByFn(index: number, checklist: Checklist) { 38 | return checklist.id; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/shared/data-access/storage.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable, InjectionToken, PLATFORM_ID } from "@angular/core"; 2 | import { of } from "rxjs"; 3 | import { Checklist } from "../interfaces/checklist"; 4 | import { ChecklistItem } from "../interfaces/checklist-item"; 5 | 6 | export const LOCAL_STORAGE = new InjectionToken( 7 | "window local storage object", 8 | { 9 | providedIn: "root", 10 | factory: () => { 11 | return inject(PLATFORM_ID) === "browser" 12 | ? window.localStorage 13 | : ({} as Storage); 14 | }, 15 | } 16 | ); 17 | 18 | @Injectable({ 19 | providedIn: "root", 20 | }) 21 | export class StorageService { 22 | 23 | storage = inject(LOCAL_STORAGE); 24 | 25 | loadChecklists() { 26 | const checklists = this.storage.getItem("checklists"); 27 | return of(checklists ? JSON.parse(checklists) as Checklist[] : []); 28 | } 29 | 30 | loadChecklistItems() { 31 | const checklists = this.storage.getItem("checklistItems"); 32 | return of(checklists ? JSON.parse(checklists) : []); 33 | } 34 | 35 | saveChecklists(checklists: Checklist[]) { 36 | this.storage.setItem("checklists", JSON.stringify(checklists)); 37 | } 38 | 39 | saveChecklistItems(checklistItems: ChecklistItem[]) { 40 | this.storage.setItem("checklistItems", JSON.stringify(checklistItems)); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/checklist/ui/checklist-item-list.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from "@angular/common"; 2 | import { 3 | Component, 4 | EventEmitter, 5 | Input, 6 | Output, 7 | } from "@angular/core"; 8 | import { ChecklistItem } from "../../shared/interfaces/checklist-item"; 9 | 10 | @Component({ 11 | standalone: true, 12 | imports: [CommonModule], 13 | selector: "app-checklist-item-list", 14 | template: ` 15 |
    16 |
  • 17 | [DONE] 18 | {{ item.title }} 19 | 20 | 21 | 22 |
  • 23 |
24 | 25 |
26 |

Add an item

27 |

Click the add button to add your first item to this quicklist

28 |
29 | `, 30 | }) 31 | export class ChecklistItemListComponent { 32 | @Input() checklistItems!: ChecklistItem[]; 33 | @Output() toggle = new EventEmitter(); 34 | @Output() delete = new EventEmitter(); 35 | @Output() edit = new EventEmitter(); 36 | 37 | toggleItem(itemId: string) { 38 | this.toggle.emit(itemId); 39 | } 40 | 41 | trackByFn(index: number, item: ChecklistItem) { 42 | return item.id; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/shared/ui/form-modal.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from "@angular/common"; 2 | import { Component, EventEmitter, Input, Output } from "@angular/core"; 3 | import { FormGroup, ReactiveFormsModule } from "@angular/forms"; 4 | 5 | @Component({ 6 | standalone: true, 7 | imports: [ReactiveFormsModule, CommonModule], 8 | selector: "app-form-modal", 9 | template: ` 10 |

{{ title }}

11 | 12 |
13 |
14 | 15 | 16 |
17 | 20 |
21 | `, 22 | styles: [ 23 | ` 24 | :host { 25 | height: 100%; 26 | } 27 | 28 | form { 29 | padding: 1rem; 30 | } 31 | `, 32 | ], 33 | }) 34 | export class FormModalComponent { 35 | @Input() title!: string; 36 | @Input() formGroup!: FormGroup; 37 | 38 | @Output() save = new EventEmitter(); 39 | @Output() close = new EventEmitter(); 40 | 41 | constructor() {} 42 | 43 | handleSave() { 44 | this.save.emit(true); 45 | this.dismiss(); 46 | } 47 | 48 | dismiss() { 49 | this.formGroup.reset(); 50 | this.close.emit(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/app/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from "@angular/common"; 2 | import { Component, effect, inject, signal } from "@angular/core"; 3 | import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; 4 | import { ChecklistService } from "../shared/data-access/checklist.service"; 5 | import { Checklist } from "../shared/interfaces/checklist"; 6 | import { FormModalComponent } from "../shared/ui/form-modal.component"; 7 | import { ModalComponent } from "../shared/ui/modal.component"; 8 | import { ChecklistListComponent } from "./ui/checklist-list.component"; 9 | 10 | @Component({ 11 | standalone: true, 12 | selector: "app-home", 13 | template: ` 14 |

Quicklists

15 | 16 | 17 |

Your checklists

18 | 19 |

{{ error() }}

20 | 21 | 26 | 27 | 28 | 29 | 42 | 43 | 44 | `, 45 | imports: [ 46 | ModalComponent, 47 | FormModalComponent, 48 | ReactiveFormsModule, 49 | ChecklistListComponent, 50 | CommonModule 51 | ], 52 | }) 53 | export default class HomeComponent { 54 | cs = inject(ChecklistService); 55 | fb = inject(FormBuilder); 56 | 57 | checklists = this.cs.checklists; 58 | error = this.cs.error; 59 | checklistBeingEdited = signal | null>(null); 60 | 61 | checklistForm = this.fb.nonNullable.group({ 62 | title: ["", Validators.required], 63 | }); 64 | 65 | constructor() { 66 | // TODO: Use [patchValue] directive to react to signal in template 67 | effect(() => { 68 | const checklist = this.checklistBeingEdited(); 69 | 70 | if (checklist) { 71 | this.checklistForm.patchValue({ 72 | title: checklist.title, 73 | }); 74 | } 75 | }); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "quicklist-signals": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "inlineTemplate": true, 11 | "inlineStyle": true, 12 | "skipTests": true 13 | }, 14 | "@schematics/angular:class": { 15 | "skipTests": true 16 | }, 17 | "@schematics/angular:directive": { 18 | "skipTests": true 19 | }, 20 | "@schematics/angular:guard": { 21 | "skipTests": true 22 | }, 23 | "@schematics/angular:interceptor": { 24 | "skipTests": true 25 | }, 26 | "@schematics/angular:pipe": { 27 | "skipTests": true 28 | }, 29 | "@schematics/angular:resolver": { 30 | "skipTests": true 31 | }, 32 | "@schematics/angular:service": { 33 | "skipTests": true 34 | } 35 | }, 36 | "root": "", 37 | "sourceRoot": "src", 38 | "prefix": "app", 39 | "architect": { 40 | "build": { 41 | "builder": "@angular-devkit/build-angular:browser", 42 | "options": { 43 | "outputPath": "dist/quicklist-signals", 44 | "index": "src/index.html", 45 | "main": "src/main.ts", 46 | "polyfills": [ 47 | "zone.js" 48 | ], 49 | "tsConfig": "tsconfig.app.json", 50 | "assets": [ 51 | "src/favicon.ico", 52 | "src/assets" 53 | ], 54 | "styles": [ 55 | "src/styles.css" 56 | ], 57 | "scripts": [] 58 | }, 59 | "configurations": { 60 | "production": { 61 | "budgets": [ 62 | { 63 | "type": "initial", 64 | "maximumWarning": "500kb", 65 | "maximumError": "1mb" 66 | }, 67 | { 68 | "type": "anyComponentStyle", 69 | "maximumWarning": "2kb", 70 | "maximumError": "4kb" 71 | } 72 | ], 73 | "outputHashing": "all" 74 | }, 75 | "development": { 76 | "buildOptimizer": false, 77 | "optimization": false, 78 | "vendorChunk": true, 79 | "extractLicenses": false, 80 | "sourceMap": true, 81 | "namedChunks": true 82 | } 83 | }, 84 | "defaultConfiguration": "production" 85 | }, 86 | "serve": { 87 | "builder": "@angular-devkit/build-angular:dev-server", 88 | "configurations": { 89 | "production": { 90 | "browserTarget": "quicklist-signals:build:production" 91 | }, 92 | "development": { 93 | "browserTarget": "quicklist-signals:build:development" 94 | } 95 | }, 96 | "defaultConfiguration": "development" 97 | }, 98 | "extract-i18n": { 99 | "builder": "@angular-devkit/build-angular:extract-i18n", 100 | "options": { 101 | "browserTarget": "quicklist-signals:build" 102 | } 103 | } 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/app/checklist/checklist.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from "@angular/common"; 2 | import { Component, computed, effect, inject, signal } from "@angular/core"; 3 | import { toSignal } from "@angular/core/rxjs-interop"; 4 | import { FormBuilder, Validators } from "@angular/forms"; 5 | import { ActivatedRoute } from "@angular/router"; 6 | import { ChecklistService } from "../shared/data-access/checklist.service"; 7 | import { ChecklistItem } from "../shared/interfaces/checklist-item"; 8 | import { FormModalComponent } from "../shared/ui/form-modal.component"; 9 | import { ModalComponent } from "../shared/ui/modal.component"; 10 | import { ChecklistItemService } from "./data-access/checklist-item.service"; 11 | import { ChecklistItemHeaderComponent } from "./ui/checklist-item-header.component"; 12 | import { ChecklistItemListComponent } from "./ui/checklist-item-list.component"; 13 | 14 | @Component({ 15 | standalone: true, 16 | imports: [ 17 | CommonModule, 18 | ChecklistItemHeaderComponent, 19 | ChecklistItemListComponent, 20 | ModalComponent, 21 | FormModalComponent, 22 | ], 23 | selector: "app-checklist", 24 | template: ` 25 | 31 | 32 | 38 | 39 | 40 | 41 | 57 | 58 | 59 | `, 60 | }) 61 | export default class ChecklistComponent { 62 | cs = inject(ChecklistService); 63 | cis = inject(ChecklistItemService); 64 | fb = inject(FormBuilder); 65 | route = inject(ActivatedRoute); 66 | 67 | checklistItemBeingEdited = signal | null>(null); 68 | 69 | params = toSignal(this.route.paramMap); 70 | 71 | items = computed(() => 72 | this.cis 73 | .checklistItems() 74 | .filter((item) => item.checklistId === this.params()?.get("id")) 75 | ); 76 | 77 | checklist = computed(() => 78 | this.cs 79 | .checklists() 80 | .find((checklist) => checklist.id === this.params()?.get("id")) 81 | ); 82 | 83 | checklistItemForm = this.fb.nonNullable.group({ 84 | title: ["", Validators.required], 85 | }); 86 | 87 | constructor() { 88 | // TODO: Use [patchValue] directive to react to signal in template 89 | effect(() => { 90 | const item = this.checklistItemBeingEdited(); 91 | if (item) { 92 | this.checklistItemForm.patchValue({ 93 | title: item.title, 94 | }); 95 | } 96 | }); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/app/shared/data-access/checklist.service.ts: -------------------------------------------------------------------------------- 1 | import { computed, effect, inject, Injectable, signal } from "@angular/core"; 2 | import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; 3 | import { Subject } from "rxjs"; 4 | import { ChecklistItemService } from "../../checklist/data-access/checklist-item.service"; 5 | import { AddChecklist } from "../interfaces/checklist"; 6 | import { StorageService } from "./storage.service"; 7 | import { 8 | Checklist, 9 | EditChecklist, 10 | } from "../interfaces/checklist"; 11 | 12 | export interface ChecklistsState { 13 | checklists: Checklist[]; 14 | loaded: boolean; 15 | error: string | null; 16 | } 17 | 18 | @Injectable({ 19 | providedIn: "root", 20 | }) 21 | export class ChecklistService { 22 | private checklistItemService = inject(ChecklistItemService); 23 | private storageService = inject(StorageService); 24 | 25 | // state 26 | private state = signal({ 27 | checklists: [], 28 | loaded: false, 29 | error: null, 30 | }); 31 | 32 | // selectors 33 | checklists = computed(() => this.state().checklists); 34 | loaded = computed(() => this.state().loaded); 35 | error = computed(() => this.state().error); 36 | 37 | // sources 38 | private checklistsLoaded$ = this.storageService.loadChecklists(); 39 | add$ = new Subject(); 40 | edit$ = new Subject(); 41 | remove$ = this.checklistItemService.checklistRemoved$; 42 | 43 | constructor() { 44 | // reducers 45 | this.checklistsLoaded$.pipe(takeUntilDestroyed()).subscribe({ 46 | next: (checklists) => 47 | this.state.update((state) => ({ 48 | ...state, 49 | checklists, 50 | loaded: true, 51 | })), 52 | error: (err) => this.state.update((state) => ({...state, error: err})) 53 | }); 54 | 55 | this.add$.pipe(takeUntilDestroyed()).subscribe((checklist) => 56 | this.state.update((state) => ({ 57 | ...state, 58 | checklists: [...state.checklists, this.addIdToChecklist(checklist)], 59 | })) 60 | ); 61 | 62 | this.edit$.pipe(takeUntilDestroyed()).subscribe((update) => 63 | this.state.update((state) => ({ 64 | ...state, 65 | checklists: state.checklists.map((checklist) => 66 | checklist.id === update.id 67 | ? { ...checklist, title: update.data.title } 68 | : checklist 69 | ), 70 | })) 71 | ); 72 | 73 | this.remove$.pipe(takeUntilDestroyed()).subscribe((id) => 74 | this.state.update((state) => ({ 75 | ...state, 76 | checklists: state.checklists.filter((checklist) => checklist.id !== id), 77 | })) 78 | ); 79 | 80 | // effects 81 | effect(() => { 82 | if (this.loaded()) { 83 | this.storageService.saveChecklists(this.checklists()); 84 | } 85 | }); 86 | } 87 | 88 | private addIdToChecklist(checklist: AddChecklist) { 89 | return { 90 | ...checklist, 91 | id: this.generateSlug(checklist.title), 92 | }; 93 | } 94 | 95 | private generateSlug(title: string) { 96 | // NOTE: This is a simplistic slug generator and will not handle things like special characters. 97 | let slug = title.toLowerCase().replace(/\s+/g, "-"); 98 | 99 | // Check if the slug already exists 100 | const matchingSlugs = this.checklists().find( 101 | (checklist) => checklist.id === slug 102 | ); 103 | 104 | // If the title is already being used, add a string to make the slug unique 105 | if (matchingSlugs) { 106 | slug = slug + Date.now().toString(); 107 | } 108 | 109 | return slug; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/app/checklist/data-access/checklist-item.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, signal, effect, computed, inject } from "@angular/core"; 2 | import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; 3 | import { Subject } from "rxjs"; 4 | import { RemoveChecklist } from "src/app/shared/interfaces/checklist"; 5 | import { StorageService } from "../../shared/data-access/storage.service"; 6 | import { 7 | AddChecklistItem, 8 | ChecklistItem, 9 | EditChecklistItem, 10 | RemoveChecklistItem, 11 | } from "../../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 | // state 25 | private state = signal({ 26 | checklistItems: [], 27 | loaded: false, 28 | }); 29 | 30 | // selectors 31 | checklistItems = computed(() => this.state().checklistItems); 32 | loaded = computed(() => this.state().loaded); 33 | 34 | // sources 35 | private checklistItemsLoaded$ = this.storageService.loadChecklistItems(); 36 | 37 | add$ = new Subject(); 38 | remove$ = new Subject(); 39 | edit$ = new Subject(); 40 | checklistRemoved$ = new Subject(); 41 | toggle$ = new Subject(); 42 | reset$ = new Subject(); 43 | 44 | constructor() { 45 | // reducers 46 | this.checklistItemsLoaded$ 47 | .pipe(takeUntilDestroyed()) 48 | .subscribe((checklistItems) => 49 | this.state.update((state) => ({ 50 | ...state, 51 | checklistItems, 52 | loaded: true, 53 | })) 54 | ); 55 | 56 | this.add$.pipe(takeUntilDestroyed()).subscribe((checklistItem) => 57 | this.state.update((state) => ({ 58 | ...state, 59 | checklistItems: [ 60 | ...state.checklistItems, 61 | { 62 | id: Date.now().toString(), 63 | checklistId: checklistItem.checklistId, 64 | checked: false, 65 | ...checklistItem.item, 66 | }, 67 | ], 68 | })) 69 | ); 70 | 71 | this.remove$.pipe(takeUntilDestroyed()).subscribe((id) => 72 | this.state.update((state) => ({ 73 | ...state, 74 | checklistItems: state.checklistItems.filter((item) => item.id !== id), 75 | })) 76 | ); 77 | 78 | this.edit$.pipe(takeUntilDestroyed()).subscribe((update) => 79 | this.state.update((state) => ({ 80 | ...state, 81 | checklistItems: state.checklistItems.map((item) => 82 | item.id === update.id ? { ...item, title: update.data.title } : item 83 | ), 84 | })) 85 | ); 86 | 87 | this.checklistRemoved$.pipe(takeUntilDestroyed()).subscribe((checklistId) => 88 | this.state.update((state) => ({ 89 | ...state, 90 | checklistItems: state.checklistItems.filter( 91 | (item) => item.checklistId !== checklistId 92 | ), 93 | })) 94 | ); 95 | 96 | this.toggle$.pipe(takeUntilDestroyed()).subscribe((checklistItemId) => 97 | this.state.update((state) => ({ 98 | ...state, 99 | checklistItems: state.checklistItems.map((item) => 100 | item.id === checklistItemId 101 | ? { ...item, checked: !item.checked } 102 | : item 103 | ), 104 | })) 105 | ); 106 | 107 | this.reset$.pipe(takeUntilDestroyed()).subscribe((checklistId) => 108 | this.state.update((state) => ({ 109 | ...state, 110 | checklistItems: state.checklistItems.map((item) => 111 | item.checklistId === checklistId ? { ...item, checked: false } : item 112 | ), 113 | })) 114 | ); 115 | 116 | // effects 117 | effect(() => { 118 | if (this.loaded()) { 119 | this.storageService.saveChecklistItems(this.checklistItems()); 120 | } 121 | }); 122 | } 123 | } 124 | --------------------------------------------------------------------------------