Skip to content

Commit d25b18b

Browse files
feat: add @myorg/todo library with TodoStore (closes #109)
- Todo model: id, title, description, completed, createdAt - TodoService: getAll, create, update, remove - TodoStore (scoped): rxResource for fetching, rxMethod for mutations - enableSync/disableSync toggles API fetching (returns [] when off) - Todo component: .ts-only, inline template, Tailwind, OnPush Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a679563 commit d25b18b

19 files changed

Lines changed: 793 additions & 261 deletions

eslint.config.cjs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ module.exports = [
3333
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
3434
// Override or add rules here
3535
rules: {
36-
3736
'@/semi': ['error', 'always'],
3837
'@/no-extra-semi': 'error',
3938
'@/quotes': ['error', 'single', { allowTemplateLiterals: true }],

libs/todo/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# todo
2+
3+
This library was generated with [Nx](https://nx.dev).
4+
5+
## Running unit tests
6+
7+
Run `nx test todo` to execute the unit tests.

libs/todo/eslint.config.cjs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
const nx = require('@nx/eslint-plugin');
2+
const baseConfig = require('../../eslint.config.cjs');
3+
4+
module.exports = [
5+
...nx.configs['flat/angular'],
6+
...nx.configs['flat/angular-template'],
7+
...baseConfig,
8+
{
9+
files: ['**/*.ts'],
10+
rules: {
11+
'@angular-eslint/directive-selector': [
12+
'error',
13+
{
14+
type: 'attribute',
15+
prefix: 'lib',
16+
style: 'camelCase',
17+
},
18+
],
19+
'@angular-eslint/component-selector': [
20+
'error',
21+
{
22+
type: 'element',
23+
prefix: 'lib',
24+
style: 'kebab-case',
25+
},
26+
],
27+
},
28+
},
29+
{
30+
files: ['**/*.html'],
31+
// Override or add rules here
32+
rules: {},
33+
},
34+
];

libs/todo/project.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "todo",
3+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
4+
"sourceRoot": "libs/todo/src",
5+
"prefix": "lib",
6+
"projectType": "library",
7+
"tags": [],
8+
"targets": {
9+
"test": {
10+
"executor": "@nx/vitest:test",
11+
"outputs": ["{options.reportsDirectory}"],
12+
"options": {
13+
"reportsDirectory": "coverage/libs/todo"
14+
}
15+
},
16+
"lint": {
17+
"executor": "@nx/eslint:lint"
18+
}
19+
}
20+
}

libs/todo/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './lib/todo/todo';
2+
export * from './lib/models/todo';
3+
export * from './lib/services/todo.service';
4+
export * from './lib/state/todo.store';

libs/todo/src/lib/models/todo.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export type Todo = {
2+
id: string;
3+
title: string;
4+
description: string;
5+
completed: boolean;
6+
createdAt: string;
7+
};
8+
9+
export type CreateTodoRequest = Omit<Todo, 'id' | 'createdAt'>;
10+
export type UpdateTodoRequest = Partial<Omit<Todo, 'id' | 'createdAt'>>;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { HttpClient } from '@angular/common/http';
2+
import { Injectable, inject } from '@angular/core';
3+
4+
import { CreateTodoRequest, Todo, UpdateTodoRequest } from '../models/todo';
5+
6+
@Injectable({ providedIn: 'root' })
7+
export class TodoService {
8+
private readonly http = inject(HttpClient);
9+
10+
getAll() {
11+
return this.http.get<Todo[]>('/api/todos');
12+
}
13+
14+
create(todo: CreateTodoRequest) {
15+
return this.http.post<Todo>('/api/todos', todo);
16+
}
17+
18+
update(id: string, changes: UpdateTodoRequest) {
19+
return this.http.patch<Todo>(`/api/todos/${id}`, changes);
20+
}
21+
22+
remove(id: string) {
23+
return this.http.delete<void>(`/api/todos/${id}`);
24+
}
25+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { provideHttpClient } from '@angular/common/http';
2+
import { provideHttpClientTesting } from '@angular/common/http/testing';
3+
import { ApplicationRef } from '@angular/core';
4+
import { TestBed } from '@angular/core/testing';
5+
import { of, throwError } from 'rxjs';
6+
7+
import { Todo } from '../models/todo';
8+
import { TodoService } from '../services/todo.service';
9+
import { TodoStore } from './todo.store';
10+
11+
const mockTodos: Todo[] = [
12+
{
13+
id: '1',
14+
title: 'Buy groceries',
15+
description: 'Milk, eggs, bread',
16+
completed: false,
17+
createdAt: '2024-01-01T00:00:00Z',
18+
},
19+
{
20+
id: '2',
21+
title: 'Write tests',
22+
description: 'Cover the todo store',
23+
completed: true,
24+
createdAt: '2024-01-02T00:00:00Z',
25+
},
26+
];
27+
28+
describe('TodoStore', () => {
29+
let store: TodoStore;
30+
let service: TodoService;
31+
let appRef: ApplicationRef;
32+
33+
beforeEach(() => {
34+
TestBed.configureTestingModule({
35+
providers: [TodoStore, provideHttpClient(), provideHttpClientTesting()],
36+
});
37+
38+
store = TestBed.inject(TodoStore);
39+
service = TestBed.inject(TodoService);
40+
appRef = TestBed.inject(ApplicationRef);
41+
});
42+
43+
it('should create', () => {
44+
expect(store).toBeTruthy();
45+
});
46+
47+
it('should have syncEnabled true after onInit hook', () => {
48+
expect(store.syncEnabled()).toBe(true);
49+
});
50+
51+
describe('loading todos', () => {
52+
it('should load todos when sync is enabled', async () => {
53+
vi.spyOn(service, 'getAll').mockReturnValue(of(mockTodos));
54+
store.enableSync();
55+
await appRef.whenStable();
56+
57+
expect(store.todos.value()).toEqual(mockTodos);
58+
expect(store.todos.isLoading()).toBe(false);
59+
expect(store.todos.error()).toBeFalsy();
60+
});
61+
62+
it('should capture loading error', async () => {
63+
const error = new Error('network error');
64+
vi.spyOn(service, 'getAll').mockReturnValue(throwError(() => error));
65+
store.enableSync();
66+
await appRef.whenStable();
67+
68+
expect(store.todos.error()).toBeTruthy();
69+
expect(store.todos.isLoading()).toBe(false);
70+
});
71+
72+
it('should return empty list when sync is disabled', async () => {
73+
vi.spyOn(service, 'getAll').mockReturnValue(of(mockTodos));
74+
store.disableSync();
75+
await appRef.whenStable();
76+
77+
expect(store.todos.value()).toEqual([]);
78+
});
79+
});
80+
81+
describe('create', () => {
82+
it('should call service.create and reload', async () => {
83+
vi.spyOn(service, 'getAll').mockReturnValue(of(mockTodos));
84+
const newTodo: Todo = {
85+
id: '3',
86+
title: 'New task',
87+
description: '',
88+
completed: false,
89+
createdAt: '2024-01-03T00:00:00Z',
90+
};
91+
const createSpy = vi
92+
.spyOn(service, 'create')
93+
.mockReturnValue(of(newTodo));
94+
95+
store.create({ title: 'New task', description: '', completed: false });
96+
await appRef.whenStable();
97+
98+
expect(createSpy).toHaveBeenCalledWith({
99+
title: 'New task',
100+
description: '',
101+
completed: false,
102+
});
103+
});
104+
});
105+
106+
describe('update', () => {
107+
it('should call service.update and reload', async () => {
108+
vi.spyOn(service, 'getAll').mockReturnValue(of(mockTodos));
109+
const updateSpy = vi
110+
.spyOn(service, 'update')
111+
.mockReturnValue(of({ ...mockTodos[0], title: 'Updated' }));
112+
113+
store.update({ id: '1', changes: { title: 'Updated' } });
114+
await appRef.whenStable();
115+
116+
expect(updateSpy).toHaveBeenCalledWith('1', { title: 'Updated' });
117+
});
118+
});
119+
120+
describe('remove', () => {
121+
it('should call service.remove and reload', async () => {
122+
vi.spyOn(service, 'getAll').mockReturnValue(of(mockTodos));
123+
const removeSpy = vi
124+
.spyOn(service, 'remove')
125+
.mockReturnValue(of(undefined));
126+
127+
store.remove('1');
128+
await appRef.whenStable();
129+
130+
expect(removeSpy).toHaveBeenCalledWith('1');
131+
});
132+
});
133+
134+
describe('toggle', () => {
135+
it('should call service.update with flipped completed and reload', async () => {
136+
vi.spyOn(service, 'getAll').mockReturnValue(of(mockTodos));
137+
const updateSpy = vi
138+
.spyOn(service, 'update')
139+
.mockReturnValue(of({ ...mockTodos[0], completed: true }));
140+
141+
store.toggle(mockTodos[0]);
142+
await appRef.whenStable();
143+
144+
expect(updateSpy).toHaveBeenCalledWith('1', { completed: true });
145+
});
146+
});
147+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { computed, inject } from '@angular/core';
2+
import { rxResource } from '@angular/core/rxjs-interop';
3+
import { tapResponse } from '@ngrx/operators';
4+
import {
5+
patchState,
6+
signalStore,
7+
signalStoreFeature,
8+
withComputed,
9+
withHooks,
10+
withMethods,
11+
withProps,
12+
withState,
13+
} from '@ngrx/signals';
14+
import { rxMethod } from '@ngrx/signals/rxjs-interop';
15+
import { of, pipe, switchMap } from 'rxjs';
16+
17+
import { CreateTodoRequest, Todo, UpdateTodoRequest } from '../models/todo';
18+
import { TodoService } from '../services/todo.service';
19+
20+
export type TodoState = {
21+
syncEnabled: boolean;
22+
};
23+
24+
export const todoInitialState: TodoState = {
25+
syncEnabled: false,
26+
};
27+
28+
export function withTodoFeature() {
29+
return signalStoreFeature(
30+
withState(todoInitialState),
31+
withProps(() => ({
32+
todoService: inject(TodoService),
33+
})),
34+
withComputed(({ syncEnabled }) => ({
35+
params: computed(() => ({ syncEnabled: syncEnabled() })),
36+
})),
37+
withProps(({ todoService, params }) => ({
38+
todos: rxResource({
39+
params,
40+
stream: ({ params: { syncEnabled } }) =>
41+
syncEnabled ? todoService.getAll() : of([] as Todo[]),
42+
}),
43+
})),
44+
withMethods(({ todoService, todos, ...store }) => ({
45+
enableSync() {
46+
patchState(store, { syncEnabled: true });
47+
},
48+
49+
disableSync() {
50+
patchState(store, { syncEnabled: false });
51+
},
52+
53+
reload() {
54+
todos.reload();
55+
},
56+
57+
create: rxMethod<CreateTodoRequest>(
58+
pipe(
59+
switchMap((todo) =>
60+
todoService.create(todo).pipe(
61+
tapResponse({
62+
next: () => todos.reload(),
63+
error: (error) => console.error('Failed to create todo', error),
64+
}),
65+
),
66+
),
67+
),
68+
),
69+
70+
update: rxMethod<{ id: string; changes: UpdateTodoRequest }>(
71+
pipe(
72+
switchMap(({ id, changes }) =>
73+
todoService.update(id, changes).pipe(
74+
tapResponse({
75+
next: () => todos.reload(),
76+
error: (error) => console.error('Failed to update todo', error),
77+
}),
78+
),
79+
),
80+
),
81+
),
82+
83+
remove: rxMethod<string>(
84+
pipe(
85+
switchMap((id) =>
86+
todoService.remove(id).pipe(
87+
tapResponse({
88+
next: () => todos.reload(),
89+
error: (error) => console.error('Failed to remove todo', error),
90+
}),
91+
),
92+
),
93+
),
94+
),
95+
96+
toggle: rxMethod<Todo>(
97+
pipe(
98+
switchMap((todo) =>
99+
todoService.update(todo.id, { completed: !todo.completed }).pipe(
100+
tapResponse({
101+
next: () => todos.reload(),
102+
error: (error) => console.error('Failed to toggle todo', error),
103+
}),
104+
),
105+
),
106+
),
107+
),
108+
})),
109+
);
110+
}
111+
112+
export const TodoStore = signalStore(
113+
withTodoFeature(),
114+
withHooks({
115+
onInit({ enableSync }) {
116+
enableSync();
117+
},
118+
}),
119+
);
120+
121+
export type TodoStore = InstanceType<typeof TodoStore>;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { provideHttpClient } from '@angular/common/http';
2+
import { provideHttpClientTesting } from '@angular/common/http/testing';
3+
import { render, screen } from '@testing-library/angular';
4+
5+
import { Todo } from './todo';
6+
7+
describe('Todo', () => {
8+
it('should create', async () => {
9+
await render(Todo, {
10+
providers: [provideHttpClient(), provideHttpClientTesting()],
11+
});
12+
13+
expect(screen.getByTestId('lib-todo')).toBeTruthy();
14+
});
15+
});

0 commit comments

Comments
 (0)