Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/api/Api.Test/Api.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,8 @@
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Api\Api.csproj" />
</ItemGroup>

</Project>
90 changes: 90 additions & 0 deletions apps/api/Api.Test/UnitTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,94 @@ public void Test()
Assert.True(true);
}
}

public class TodoRepositoryTests
{
private static TodoRepository CreateRepo() => new();

[Fact]
public void GetAll_ReturnsEmpty_WhenNoTodosAdded()
{
var repo = CreateRepo();
Assert.Empty(repo.GetAll());
}

[Fact]
public void Add_ReturnsTodoWithGeneratedId()
{
var repo = CreateRepo();
var result = repo.Add(new CreateTodoRequest("Buy milk", "From the store", false));

Assert.NotNull(result.Id);
Assert.NotEmpty(result.Id);
Assert.Equal("Buy milk", result.Title);
Assert.Equal("From the store", result.Description);
Assert.False(result.Completed);
}

[Fact]
public void Add_MultipleTodos_AreAllReturned()
{
var repo = CreateRepo();
repo.Add(new CreateTodoRequest("First", "", false));
repo.Add(new CreateTodoRequest("Second", "", false));

Assert.Equal(2, repo.GetAll().Count());
}

[Fact]
public void Update_ExistingTodo_ReturnsUpdated()
{
var repo = CreateRepo();
var todo = repo.Add(new CreateTodoRequest("Original", "", false));

var updated = repo.Update(todo.Id, new UpdateTodoRequest("Updated", null, true));

Assert.NotNull(updated);
Assert.Equal("Updated", updated!.Title);
Assert.True(updated.Completed);
Assert.Equal("", updated.Description); // unchanged
}

[Fact]
public void Update_NonExistentId_ReturnsNull()
{
var repo = CreateRepo();
var result = repo.Update("missing-id", new UpdateTodoRequest("x", null, null));
Assert.Null(result);
}

[Fact]
public void Update_OnlyPatchesProvidedFields()
{
var repo = CreateRepo();
var todo = repo.Add(new CreateTodoRequest("Title", "Desc", false));

var updated = repo.Update(todo.Id, new UpdateTodoRequest(null, null, true));

Assert.Equal("Title", updated!.Title);
Assert.Equal("Desc", updated.Description);
Assert.True(updated.Completed);
}

[Fact]
public void Remove_ExistingTodo_ReturnsTrueAndRemoves()
{
var repo = CreateRepo();
var todo = repo.Add(new CreateTodoRequest("Delete me", "", false));

var removed = repo.Remove(todo.Id);

Assert.True(removed);
Assert.Empty(repo.GetAll());
}

[Fact]
public void Remove_NonExistentId_ReturnsFalse()
{
var repo = CreateRepo();
Assert.False(repo.Remove("missing-id"));
}
}
}

1 change: 1 addition & 0 deletions apps/api/Api/Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
Expand Down
33 changes: 33 additions & 0 deletions apps/api/Api/Api.http
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,36 @@ Accept: application/json
Authorization: Bearer {{access_token}}

###

### List all todos
GET {{Api_HostAddress}}/todos
Accept: application/json

###

### Create a todo
POST {{Api_HostAddress}}/todos
Content-Type: application/json

{
"title": "Buy groceries",
"description": "Milk, eggs, bread",
"completed": false
}

###

### Update a todo (replace <id> with a real ID)
PATCH {{Api_HostAddress}}/todos/<id>
Content-Type: application/json

{
"completed": true
}

###

### Delete a todo (replace <id> with a real ID)
DELETE {{Api_HostAddress}}/todos/<id>

###
4 changes: 2 additions & 2 deletions apps/api/Api/Controllers/WeatherForecastController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ public string Id
{
get { return Guid.NewGuid().ToString(); }
}
public string DateFormatted { get; set; }
public string DateFormatted { get; set; } = string.Empty;
public int TemperatureC { get; set; }
public string Summary { get; set; }
public string Summary { get; set; } = string.Empty;

public int TemperatureF
{
Expand Down
85 changes: 85 additions & 0 deletions apps/api/Api/Endpoints/TodoEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

public record TodoItem(
string Id,
string Title,
string Description,
bool Completed,
string CreatedAt
);

public record CreateTodoRequest(string Title, string Description, bool Completed = false);

public record UpdateTodoRequest(string? Title, string? Description, bool? Completed);

public class TodoRepository
{
private readonly ConcurrentDictionary<string, TodoItem> _todos = new();

public IEnumerable<TodoItem> GetAll() =>
_todos.Values.OrderBy(t => t.CreatedAt);

public TodoItem Add(CreateTodoRequest req)
{
var todo = new TodoItem(
Id: Guid.NewGuid().ToString(),
Title: req.Title,
Description: req.Description,
Completed: req.Completed,
CreatedAt: DateTime.UtcNow.ToString("O")
);
_todos[todo.Id] = todo;
return todo;
}

public TodoItem? Update(string id, UpdateTodoRequest req)
{
if (!_todos.TryGetValue(id, out var existing))
return null;

var updated = existing with
{
Title = req.Title ?? existing.Title,
Description = req.Description ?? existing.Description,
Completed = req.Completed ?? existing.Completed,
};
_todos[id] = updated;
return updated;
}

public bool Remove(string id) => _todos.TryRemove(id, out _);
}

public static class TodoEndpoints
{
public static IEndpointRouteBuilder MapTodoEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/todos").WithTags("Todos");

group.MapGet("", (TodoRepository repo) =>
Results.Ok(repo.GetAll()));

group.MapPost("", (CreateTodoRequest req, TodoRepository repo) =>
{
var todo = repo.Add(req);
return Results.Created($"/api/todos/{todo.Id}", todo);
});

group.MapPatch("{id}", (string id, UpdateTodoRequest req, TodoRepository repo) =>
{
var todo = repo.Update(id, req);
return todo is null ? Results.NotFound() : Results.Ok(todo);
});

group.MapDelete("{id}", (string id, TodoRepository repo) =>
repo.Remove(id) ? Results.NoContent() : Results.NotFound());

return app;
}
}
4 changes: 4 additions & 0 deletions apps/api/Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
.AddApiEndpoints();

builder.Services.AddScoped<TokenService>();
builder.Services.AddSingleton<TodoRepository>();

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

// Todo CRUD endpoints (in-memory store)
app.MapTodoEndpoints();

// Keep Identity account-management endpoints (password reset, email confirmation, 2FA setup, etc.)
// Login and refresh from this group are superseded by /api/auth/* above.
app.MapGroup("/api/account")
Expand Down
4 changes: 4 additions & 0 deletions apps/web-app/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export const routes: Route[] = [
loadChildren: () =>
import('@myorg/weather-forecast').then((m) => m.weatherForecastRoutes),
},
{
path: 'todos',
loadChildren: () => import('@myorg/todo').then((m) => m.todoRoutes),
},
{
path: 'login',
loadChildren: () => import('@myorg/login').then((m) => m.loginRoutes),
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ module.exports = [
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
// Override or add rules here
rules: {

'@/semi': ['error', 'always'],
'@/no-extra-semi': 'error',
'@/quotes': ['error', 'single', { allowTemplateLiterals: true }],
curly: ['error', 'all'],
'@angular-eslint/component-class-suffix': 'off',
},
},
Expand Down
16 changes: 12 additions & 4 deletions libs/shared/src/lib/components/main-toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,15 +164,23 @@ export class MainToolbar {

readonly themeIcon = computed(() => {
const t = this.themeService.theme();
if (t === 'light') return 'light_mode';
if (t === 'dark') return 'dark_mode';
if (t === 'light') {
return 'light_mode';
}
if (t === 'dark') {
return 'dark_mode';
}
return 'brightness_auto';
});

readonly themeTooltip = computed(() => {
const t = this.themeService.theme();
if (t === 'light') return 'Theme: Light (click for Dark)';
if (t === 'dark') return 'Theme: Dark (click for System)';
if (t === 'light') {
return 'Theme: Light (click for Dark)';
}
if (t === 'dark') {
return 'Theme: Dark (click for System)';
}
return 'Theme: System (click for Light)';
});
}
6 changes: 6 additions & 0 deletions libs/shared/src/lib/components/nav-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ export interface NavLink {
}

export const NAV_LINKS: NavLink[] = [
{
routerLink: '/todos',
icon: 'check_circle',
hint: 'Todos',
label: 'Todos',
},
{
routerLink: '/weather-forecast',
icon: 'get_app',
Expand Down
4 changes: 3 additions & 1 deletion libs/shared/src/lib/testing/wait-for-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ export async function waitForElement(
while (Date.now() - start < timeout) {
applicationRef.tick();
const el = getElement();
if (el) return el;
if (el) {
return el;
}
await new Promise((resolve) => setTimeout(resolve, interval));
}
throw new Error('Element not found in time');
Expand Down
4 changes: 3 additions & 1 deletion libs/shared/testing/wait-for-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ export async function waitForElement(
while (Date.now() - start < timeout) {
applicationRef.tick();
const el = getElement();
if (el) return el;
if (el) {
return el;
}
await new Promise((resolve) => setTimeout(resolve, interval));
}
throw new Error('Element not found in time');
Expand Down
7 changes: 7 additions & 0 deletions libs/todo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# todo

This library was generated with [Nx](https://nx.dev).

## Running unit tests

Run `nx test todo` to execute the unit tests.
34 changes: 34 additions & 0 deletions libs/todo/eslint.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const nx = require('@nx/eslint-plugin');
const baseConfig = require('../../eslint.config.cjs');

module.exports = [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
Loading
Loading