Skip to content

Commit 4d02607

Browse files
feat: build out todo feature with routing and backend API
Frontend: - TodoList, TodoForm, TodoPage components (.ts-only, Tailwind, OnPush) - lib.routes.ts with TodoStore scoped to route - /todos route added to app.routes.ts - Todos nav link added to shared nav-links - 16 passing tests across store and components Backend: - GET/POST /api/todos, PATCH/DELETE /api/todos/{id} - In-memory TodoRepository (singleton, ConcurrentDictionary) - TodoRepository registered in Program.cs - 8 new unit tests for repository (9 total, all passing) - Api.http updated with todo request examples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d25b18b commit 4d02607

17 files changed

Lines changed: 523 additions & 54 deletions

File tree

apps/api/Api.Test/Api.Test.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,8 @@
2121
</PackageReference>
2222
</ItemGroup>
2323

24+
<ItemGroup>
25+
<ProjectReference Include="..\Api\Api.csproj" />
26+
</ItemGroup>
27+
2428
</Project>

apps/api/Api.Test/UnitTest.cs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,94 @@ public void Test()
88
Assert.True(true);
99
}
1010
}
11+
12+
public class TodoRepositoryTests
13+
{
14+
private static TodoRepository CreateRepo() => new();
15+
16+
[Fact]
17+
public void GetAll_ReturnsEmpty_WhenNoTodosAdded()
18+
{
19+
var repo = CreateRepo();
20+
Assert.Empty(repo.GetAll());
21+
}
22+
23+
[Fact]
24+
public void Add_ReturnsTodoWithGeneratedId()
25+
{
26+
var repo = CreateRepo();
27+
var result = repo.Add(new CreateTodoRequest("Buy milk", "From the store", false));
28+
29+
Assert.NotNull(result.Id);
30+
Assert.NotEmpty(result.Id);
31+
Assert.Equal("Buy milk", result.Title);
32+
Assert.Equal("From the store", result.Description);
33+
Assert.False(result.Completed);
34+
}
35+
36+
[Fact]
37+
public void Add_MultipleTodos_AreAllReturned()
38+
{
39+
var repo = CreateRepo();
40+
repo.Add(new CreateTodoRequest("First", "", false));
41+
repo.Add(new CreateTodoRequest("Second", "", false));
42+
43+
Assert.Equal(2, repo.GetAll().Count());
44+
}
45+
46+
[Fact]
47+
public void Update_ExistingTodo_ReturnsUpdated()
48+
{
49+
var repo = CreateRepo();
50+
var todo = repo.Add(new CreateTodoRequest("Original", "", false));
51+
52+
var updated = repo.Update(todo.Id, new UpdateTodoRequest("Updated", null, true));
53+
54+
Assert.NotNull(updated);
55+
Assert.Equal("Updated", updated!.Title);
56+
Assert.True(updated.Completed);
57+
Assert.Equal("", updated.Description); // unchanged
58+
}
59+
60+
[Fact]
61+
public void Update_NonExistentId_ReturnsNull()
62+
{
63+
var repo = CreateRepo();
64+
var result = repo.Update("missing-id", new UpdateTodoRequest("x", null, null));
65+
Assert.Null(result);
66+
}
67+
68+
[Fact]
69+
public void Update_OnlyPatchesProvidedFields()
70+
{
71+
var repo = CreateRepo();
72+
var todo = repo.Add(new CreateTodoRequest("Title", "Desc", false));
73+
74+
var updated = repo.Update(todo.Id, new UpdateTodoRequest(null, null, true));
75+
76+
Assert.Equal("Title", updated!.Title);
77+
Assert.Equal("Desc", updated.Description);
78+
Assert.True(updated.Completed);
79+
}
80+
81+
[Fact]
82+
public void Remove_ExistingTodo_ReturnsTrueAndRemoves()
83+
{
84+
var repo = CreateRepo();
85+
var todo = repo.Add(new CreateTodoRequest("Delete me", "", false));
86+
87+
var removed = repo.Remove(todo.Id);
88+
89+
Assert.True(removed);
90+
Assert.Empty(repo.GetAll());
91+
}
92+
93+
[Fact]
94+
public void Remove_NonExistentId_ReturnsFalse()
95+
{
96+
var repo = CreateRepo();
97+
Assert.False(repo.Remove("missing-id"));
98+
}
99+
}
11100
}
101+

apps/api/Api/Api.http

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,36 @@ Accept: application/json
3030
Authorization: Bearer {{access_token}}
3131

3232
###
33+
34+
### List all todos
35+
GET {{Api_HostAddress}}/todos
36+
Accept: application/json
37+
38+
###
39+
40+
### Create a todo
41+
POST {{Api_HostAddress}}/todos
42+
Content-Type: application/json
43+
44+
{
45+
"title": "Buy groceries",
46+
"description": "Milk, eggs, bread",
47+
"completed": false
48+
}
49+
50+
###
51+
52+
### Update a todo (replace <id> with a real ID)
53+
PATCH {{Api_HostAddress}}/todos/<id>
54+
Content-Type: application/json
55+
56+
{
57+
"completed": true
58+
}
59+
60+
###
61+
62+
### Delete a todo (replace <id> with a real ID)
63+
DELETE {{Api_HostAddress}}/todos/<id>
64+
65+
###
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
using System;
2+
using System.Collections.Concurrent;
3+
using System.Collections.Generic;
4+
using System.Linq;
5+
using Microsoft.AspNetCore.Builder;
6+
using Microsoft.AspNetCore.Http;
7+
using Microsoft.AspNetCore.Routing;
8+
9+
public record TodoItem(
10+
string Id,
11+
string Title,
12+
string Description,
13+
bool Completed,
14+
string CreatedAt
15+
);
16+
17+
public record CreateTodoRequest(string Title, string Description, bool Completed = false);
18+
19+
public record UpdateTodoRequest(string? Title, string? Description, bool? Completed);
20+
21+
public class TodoRepository
22+
{
23+
private readonly ConcurrentDictionary<string, TodoItem> _todos = new();
24+
25+
public IEnumerable<TodoItem> GetAll() =>
26+
_todos.Values.OrderBy(t => t.CreatedAt);
27+
28+
public TodoItem Add(CreateTodoRequest req)
29+
{
30+
var todo = new TodoItem(
31+
Id: Guid.NewGuid().ToString(),
32+
Title: req.Title,
33+
Description: req.Description,
34+
Completed: req.Completed,
35+
CreatedAt: DateTime.UtcNow.ToString("O")
36+
);
37+
_todos[todo.Id] = todo;
38+
return todo;
39+
}
40+
41+
public TodoItem? Update(string id, UpdateTodoRequest req)
42+
{
43+
if (!_todos.TryGetValue(id, out var existing))
44+
return null;
45+
46+
var updated = existing with
47+
{
48+
Title = req.Title ?? existing.Title,
49+
Description = req.Description ?? existing.Description,
50+
Completed = req.Completed ?? existing.Completed,
51+
};
52+
_todos[id] = updated;
53+
return updated;
54+
}
55+
56+
public bool Remove(string id) => _todos.TryRemove(id, out _);
57+
}
58+
59+
public static class TodoEndpoints
60+
{
61+
public static IEndpointRouteBuilder MapTodoEndpoints(this IEndpointRouteBuilder app)
62+
{
63+
var group = app.MapGroup("/api/todos").WithTags("Todos");
64+
65+
group.MapGet("", (TodoRepository repo) =>
66+
Results.Ok(repo.GetAll()));
67+
68+
group.MapPost("", (CreateTodoRequest req, TodoRepository repo) =>
69+
{
70+
var todo = repo.Add(req);
71+
return Results.Created($"/api/todos/{todo.Id}", todo);
72+
});
73+
74+
group.MapPatch("{id}", (string id, UpdateTodoRequest req, TodoRepository repo) =>
75+
{
76+
var todo = repo.Update(id, req);
77+
return todo is null ? Results.NotFound() : Results.Ok(todo);
78+
});
79+
80+
group.MapDelete("{id}", (string id, TodoRepository repo) =>
81+
repo.Remove(id) ? Results.NoContent() : Results.NotFound());
82+
83+
return app;
84+
}
85+
}

apps/api/Api/Program.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
.AddApiEndpoints();
8484

8585
builder.Services.AddScoped<TokenService>();
86+
builder.Services.AddSingleton<TodoRepository>();
8687

8788
var connectionString = builder.Environment.IsDevelopment()
8889
? builder.Configuration.GetConnectionString("AZURE_SQL_CONNECTIONSTRING")
@@ -197,6 +198,9 @@
197198
// Custom JWT auth endpoints: login / refresh / logout
198199
app.MapAuthEndpoints(app.Environment.IsDevelopment());
199200

201+
// Todo CRUD endpoints (in-memory store)
202+
app.MapTodoEndpoints();
203+
200204
// Keep Identity account-management endpoints (password reset, email confirmation, 2FA setup, etc.)
201205
// Login and refresh from this group are superseded by /api/auth/* above.
202206
app.MapGroup("/api/account")

apps/web-app/src/app/app.routes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ export const routes: Route[] = [
1414
loadChildren: () =>
1515
import('@myorg/weather-forecast').then((m) => m.weatherForecastRoutes),
1616
},
17+
{
18+
path: 'todos',
19+
loadChildren: () => import('@myorg/todo').then((m) => m.todoRoutes),
20+
},
1721
{
1822
path: 'login',
1923
loadChildren: () => import('@myorg/login').then((m) => m.loginRoutes),

libs/shared/src/lib/components/nav-links.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ export interface NavLink {
66
}
77

88
export const NAV_LINKS: NavLink[] = [
9+
{
10+
routerLink: '/todos',
11+
icon: 'check_circle',
12+
hint: 'Todos',
13+
label: 'Todos',
14+
},
915
{
1016
routerLink: '/weather-forecast',
1117
icon: 'get_app',

libs/todo/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
export * from './lib/todo/todo';
1+
export * from './lib/lib.routes';
22
export * from './lib/models/todo';
33
export * from './lib/services/todo.service';
44
export * from './lib/state/todo.store';
5+
export * from './lib/components/todo-page/todo-page';
6+
export * from './lib/components/todo-list/todo-list';
7+
export * from './lib/components/todo-form/todo-form';
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { render, screen } from '@testing-library/angular';
2+
3+
import { TodoForm } from './todo-form';
4+
5+
describe('TodoForm', () => {
6+
it('should create', async () => {
7+
await render(TodoForm);
8+
expect(screen.getByTestId('lib-todo-form')).toBeTruthy();
9+
});
10+
11+
it('should render title and description inputs', async () => {
12+
await render(TodoForm);
13+
expect(screen.getByPlaceholderText('What needs to be done?')).toBeTruthy();
14+
expect(screen.getByPlaceholderText('Optional details…')).toBeTruthy();
15+
});
16+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { ChangeDetectionStrategy, Component, output } from '@angular/core';
2+
import {
3+
ReactiveFormsModule,
4+
FormControl,
5+
FormGroup,
6+
Validators,
7+
} from '@angular/forms';
8+
import { MatFormField, MatLabel, MatError } from '@angular/material/form-field';
9+
import { MatInput } from '@angular/material/input';
10+
import { MatButton } from '@angular/material/button';
11+
import { MatIcon } from '@angular/material/icon';
12+
13+
import { CreateTodoRequest } from '../../models/todo';
14+
15+
@Component({
16+
selector: 'lib-todo-form',
17+
imports: [
18+
ReactiveFormsModule,
19+
MatFormField,
20+
MatLabel,
21+
MatError,
22+
MatInput,
23+
MatButton,
24+
MatIcon,
25+
],
26+
template: `
27+
<form class="flex flex-col gap-3" [formGroup]="form" (ngSubmit)="submit()">
28+
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
29+
<mat-form-field appearance="outline" class="flex-1">
30+
<mat-label>Title</mat-label>
31+
<input
32+
matInput
33+
formControlName="title"
34+
placeholder="What needs to be done?"
35+
autocomplete="off"
36+
/>
37+
@if (
38+
form.controls.title.hasError('required') &&
39+
form.controls.title.touched
40+
) {
41+
<mat-error>Title is required</mat-error>
42+
}
43+
</mat-form-field>
44+
<mat-form-field appearance="outline" class="flex-1">
45+
<mat-label>Description</mat-label>
46+
<input
47+
matInput
48+
formControlName="description"
49+
placeholder="Optional details…"
50+
autocomplete="off"
51+
/>
52+
</mat-form-field>
53+
<button
54+
mat-flat-button
55+
type="submit"
56+
class="h-14 shrink-0"
57+
[disabled]="form.invalid"
58+
>
59+
<mat-icon>add</mat-icon>
60+
Add
61+
</button>
62+
</div>
63+
</form>
64+
`,
65+
host: {
66+
class: 'block',
67+
'data-testid': 'lib-todo-form',
68+
},
69+
changeDetection: ChangeDetectionStrategy.OnPush,
70+
})
71+
export class TodoForm {
72+
create = output<CreateTodoRequest>();
73+
74+
readonly form = new FormGroup({
75+
title: new FormControl('', {
76+
nonNullable: true,
77+
validators: [Validators.required],
78+
}),
79+
description: new FormControl('', { nonNullable: true }),
80+
});
81+
82+
submit() {
83+
if (this.form.invalid) return;
84+
this.create.emit({
85+
title: this.form.controls.title.value.trim(),
86+
description: this.form.controls.description.value.trim(),
87+
completed: false,
88+
});
89+
this.form.reset();
90+
}
91+
}

0 commit comments

Comments
 (0)