Skip to content

Commit dc3e99b

Browse files
feat: add @myorg/todo library with TodoStore (#110)
* 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> * 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> * fix: update TodoForm styling to match app form conventions - External labels (text-xs font-semibold text-on-surface-variant) - subscriptSizing=dynamic to remove dead space below inputs - Aligned button to bottom of fields on sm+ screens - Remove MatLabel import (no longer needed) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use mat-label and bg-surface-container in TodoForm - Restore mat-label inside mat-form-field for proper validation display - Wrap form in rounded-2xl bg-surface-container card to match other pages - Keep subscriptSizing=dynamic and sm:flex-row layout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: style empty state as alert in TodoList Replaces plain text with a Shadcn-style alert: - border border-outline-variant bg-surface-container-low rounded-xl p-4 - inbox icon + title + description Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: constrain empty state alert to max-w-md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: center empty state alert horizontally Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: use signal forms in TodoForm + add curly ESLint rule - Rewrite TodoForm with @angular/forms/signals (experimental) - form() + [formField] directive replaces ReactiveFormsModule - required() validator with custom message - submit() handles validation + reset - computed formValid() drives button disabled state - Tailwind-styled native inputs (bg-surface, border-outline, focus:ring-primary) - Error shown inline below title input when touched + invalid - Add curly: ['error', 'all'] to eslint.config.cjs - Auto-fix existing curly violations in main-toolbar, wait-for-element, todo-form Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: build error in signal forms + API error handling Signal forms fix: - submit() action receives FieldTree not plain model; read values via field.title().value() and return Promise.resolve() Error handling: - Add mutationError: string | null to TodoState - Mutation tapResponse.error sets a user-friendly message in state - Mutation tapResponse.next clears mutationError - Add clearMutationError() store method - TodoPage shows dismissible error banner for mutation errors - TodoPage shows fetch error alert (cloud_off + retry button) when store.todos.error() is set, replacing the list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: restore mat-label using SignalFormControl from signals/compat Use SignalFormControl from @angular/forms/signals/compat so the todo form keeps signal-based validation rules while integrating with mat-form-field / mat-label / mat-error via standard ReactiveFormsModule formControlName binding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: add curly braces to if statement in update-packages main.ts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: enable nullable reference types in Api project Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6ce888e commit dc3e99b

37 files changed

Lines changed: 1798 additions & 394 deletions

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.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
<PropertyGroup>
44
<TargetFramework>net10.0</TargetFramework>
5+
<Nullable>enable</Nullable>
56
</PropertyGroup>
67

78
<ItemGroup>

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+
###

apps/api/Api/Controllers/WeatherForecastController.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ public string Id
6363
{
6464
get { return Guid.NewGuid().ToString(); }
6565
}
66-
public string DateFormatted { get; set; }
66+
public string DateFormatted { get; set; } = string.Empty;
6767
public int TemperatureC { get; set; }
68-
public string Summary { get; set; }
68+
public string Summary { get; set; } = string.Empty;
6969

7070
public int TemperatureF
7171
{
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),

eslint.config.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ 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 }],
39+
curly: ['error', 'all'],
4040
'@angular-eslint/component-class-suffix': 'off',
4141
},
4242
},

libs/shared/src/lib/components/main-toolbar.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,15 +164,23 @@ export class MainToolbar {
164164

165165
readonly themeIcon = computed(() => {
166166
const t = this.themeService.theme();
167-
if (t === 'light') return 'light_mode';
168-
if (t === 'dark') return 'dark_mode';
167+
if (t === 'light') {
168+
return 'light_mode';
169+
}
170+
if (t === 'dark') {
171+
return 'dark_mode';
172+
}
169173
return 'brightness_auto';
170174
});
171175

172176
readonly themeTooltip = computed(() => {
173177
const t = this.themeService.theme();
174-
if (t === 'light') return 'Theme: Light (click for Dark)';
175-
if (t === 'dark') return 'Theme: Dark (click for System)';
178+
if (t === 'light') {
179+
return 'Theme: Light (click for Dark)';
180+
}
181+
if (t === 'dark') {
182+
return 'Theme: Dark (click for System)';
183+
}
176184
return 'Theme: System (click for Light)';
177185
});
178186
}

0 commit comments

Comments
 (0)