Skip to content
Draft
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
82 changes: 82 additions & 0 deletions Chores.Tests/ChoreMoveServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using Chores.Data;
using Chores.Models;
using Chores.Services;
using Microsoft.EntityFrameworkCore;

namespace Chores.Tests;

public class ChoreMoveServiceTests
{
[Fact]
public async Task TryMoveAsync_ReplacesMissingCompletionUsersAndClearsLabels()
{
await using var db = CreateDbContext();
var sourceHousehold = new Household { Name = "Home" };
var destinationHousehold = new Household { Name = "Cabin" };
var retainedUser = new AppUser { LoginName = "alice" };
var missingUser = new AppUser { LoginName = "bob" };
var sourceLabel = new Label { Name = "Kitchen", Color = "#123456", Household = sourceHousehold };
var chore = new Chore { Name = "Dishes", Schedule = Schedule.Daily, Household = sourceHousehold };

chore.Labels.Add(sourceLabel);
db.Users.AddRange(retainedUser, missingUser);
db.HouseholdMemberships.AddRange(
new HouseholdMembership { User = retainedUser, Household = sourceHousehold, IsOwner = true, JoinedAtUtc = DateTime.UtcNow },
new HouseholdMembership { User = retainedUser, Household = destinationHousehold, IsOwner = true, JoinedAtUtc = DateTime.UtcNow },
new HouseholdMembership { User = missingUser, Household = sourceHousehold, IsOwner = false, JoinedAtUtc = DateTime.UtcNow });
db.Chores.Add(chore);
db.CompletionRecords.AddRange(
new CompletionRecord { Chore = chore, CompletedByUser = retainedUser, CompletedAtUtc = DateTime.UtcNow.AddDays(-2) },
new CompletionRecord { Chore = chore, CompletedByUser = missingUser, CompletedAtUtc = DateTime.UtcNow.AddDays(-1) });
await db.SaveChangesAsync();

var service = new ChoreMoveService(db);

var moved = await service.TryMoveAsync(chore.Id, destinationHousehold.Id);

Assert.True(moved);

var updatedChore = await db.Chores
.Include(updated => updated.Labels)
.SingleAsync(updated => updated.Id == chore.Id);
Assert.Equal(destinationHousehold.Id, updatedChore.HouseholdId);
Assert.Empty(updatedChore.Labels);

var records = await db.CompletionRecords
.Include(record => record.CompletedByUser)
.Where(record => record.ChoreId == chore.Id)
.OrderBy(record => record.CompletedAtUtc)
.ToListAsync();
Assert.Equal("alice", records[0].CompletedByUser.LoginName);
Assert.Equal(LoginNameValidator.LostPlaceholderLoginName, records[1].CompletedByUser.LoginName);

var lostUser = await db.Users.SingleAsync(user => user.LoginName == LoginNameValidator.LostPlaceholderLoginName);
Assert.False(await db.HouseholdMemberships.AnyAsync(membership => membership.UserId == lostUser.Id));
}

[Fact]
public async Task TryMoveAsync_ReturnsFalseWhenDestinationMatchesCurrentHousehold()
{
await using var db = CreateDbContext();
var household = new Household { Name = "Home" };
var chore = new Chore { Name = "Dishes", Schedule = Schedule.Daily, Household = household };

db.Chores.Add(chore);
await db.SaveChangesAsync();

var service = new ChoreMoveService(db);

var moved = await service.TryMoveAsync(chore.Id, household.Id);

Assert.False(moved);
}

private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;

return new AppDbContext(options);
}
}
2 changes: 2 additions & 0 deletions Chores.Tests/InputValidatorsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public void LoginNameValidator_TryNormalize_AcceptsExpectedValues(string input,
[InlineData("ab")]
[InlineData("bad name")]
[InlineData("bad/name")]
[InlineData("lost-during-move")]
[InlineData(" Lost-During-Move ")]
[InlineData("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")]
public void LoginNameValidator_TryNormalize_RejectsInvalidValues(string input)
{
Expand Down
13 changes: 13 additions & 0 deletions Chores/Localization/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,19 @@
"chores.manageSpaces": "Bereiche verwalten",
"chores.addTitle": "Aufgabe hinzufügen",
"chores.editTitle": "Aufgabe bearbeiten",
"chores.move": "Verschieben",
"chores.moveTitle": "Aufgabe verschieben",
"chores.moveDestination": "Zielbereich",
"chores.moveSelectPrompt": "Bereich auswählen",
"chores.moveReview": "Verschiebung prüfen",
"chores.moveStart": "Aufgabe verschieben",
"chores.moveConfirmMessage": "{0} nach {1} verschieben?",
"chores.moveHistoryNote": "Der Erledigungsverlauf wird ebenfalls verschoben. Einträge von Mitgliedern ohne Zugriff auf den Zielbereich werden lost-during-move zugewiesen.",
"chores.moveNoOtherSpaces": "Sie benötigen Zugriff auf einen weiteren Bereich, bevor diese Aufgabe verschoben werden kann.",
"chores.moveLabelsTitle": "Etiketten für die verschobene Aufgabe auswählen",
"chores.moveNoLabelsInDestination": "In diesem Bereich gibt es noch keine Etiketten. Sie können jetzt speichern und später Etiketten hinzufügen.",
"chores.moveSelectDifferentSpaceError": "Wählen Sie einen anderen Bereich aus, auf den Sie zugreifen können.",
"chores.moveUnableError": "Diese Aufgabe konnte nicht verschoben werden.",
"chores.spaceColon": "Bereich:",

"complete.title": "Aufgabe als erledigt markieren",
Expand Down
13 changes: 13 additions & 0 deletions Chores/Localization/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,19 @@
"chores.manageSpaces": "Manage spaces",
"chores.addTitle": "Add Chore",
"chores.editTitle": "Edit Chore",
"chores.move": "Move",
"chores.moveTitle": "Move Chore",
"chores.moveDestination": "Destination space",
"chores.moveSelectPrompt": "Choose a space",
"chores.moveReview": "Review move",
"chores.moveStart": "Move chore",
"chores.moveConfirmMessage": "Move {0} to {1}?",
"chores.moveHistoryNote": "Completion history moves too. Records by members who can't access the destination space will be reassigned to lost-during-move.",
"chores.moveNoOtherSpaces": "You need access to another space before this chore can be moved.",
"chores.moveLabelsTitle": "Choose labels for the moved chore",
"chores.moveNoLabelsInDestination": "No labels exist in this space yet. You can save now and add labels later.",
"chores.moveSelectDifferentSpaceError": "Select a different space you can access.",
"chores.moveUnableError": "Unable to move this chore.",
"chores.spaceColon": "Space:",

"complete.title": "Mark Chore Done",
Expand Down
13 changes: 13 additions & 0 deletions Chores/Localization/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,19 @@
"chores.manageSpaces": "Gestionar espacios",
"chores.addTitle": "Agregar tarea",
"chores.editTitle": "Editar tarea",
"chores.move": "Mover",
"chores.moveTitle": "Mover tarea",
"chores.moveDestination": "Espacio de destino",
"chores.moveSelectPrompt": "Elige un espacio",
"chores.moveReview": "Revisar movimiento",
"chores.moveStart": "Mover tarea",
"chores.moveConfirmMessage": "¿Mover {0} a {1}?",
"chores.moveHistoryNote": "El historial de realizaciones también se mueve. Los registros de miembros sin acceso al espacio de destino se reasignarán a lost-during-move.",
"chores.moveNoOtherSpaces": "Necesita acceso a otro espacio antes de poder mover esta tarea.",
"chores.moveLabelsTitle": "Elegir etiquetas para la tarea movida",
"chores.moveNoLabelsInDestination": "Todavía no hay etiquetas en este espacio. Puede guardar ahora y agregar etiquetas más tarde.",
"chores.moveSelectDifferentSpaceError": "Seleccione un espacio diferente al que pueda acceder.",
"chores.moveUnableError": "No se pudo mover esta tarea.",
"chores.spaceColon": "Espacio:",

"complete.title": "Marcar tarea como realizada",
Expand Down
13 changes: 13 additions & 0 deletions Chores/Localization/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,19 @@
"chores.manageSpaces": "Terek kezelése",
"chores.addTitle": "Feladat hozzáadása",
"chores.editTitle": "Feladat szerkesztése",
"chores.move": "Áthelyezés",
"chores.moveTitle": "Feladat áthelyezése",
"chores.moveDestination": "Cél tér",
"chores.moveSelectPrompt": "Válassz egy teret",
"chores.moveReview": "Áthelyezés ellenőrzése",
"chores.moveStart": "Feladat áthelyezése",
"chores.moveConfirmMessage": "Áthelyezed a(z) {0} feladatot ide: {1}?",
"chores.moveHistoryNote": "Az elvégzési előzmények is átkerülnek. Azok a bejegyzések, amelyek készítői nem férnek hozzá a cél térhez, lost-during-move névre lesznek átírva.",
"chores.moveNoOtherSpaces": "A feladat áthelyezéséhez hozzá kell férnie egy másik térhez is.",
"chores.moveLabelsTitle": "Válassz címkéket az áthelyezett feladathoz",
"chores.moveNoLabelsInDestination": "Ebben a térben még nincsenek címkék. Most menthetsz, és később is adhatsz hozzá címkéket.",
"chores.moveSelectDifferentSpaceError": "Válassz egy másik teret, amelyhez hozzáférsz.",
"chores.moveUnableError": "A feladatot nem sikerült áthelyezni.",
"chores.spaceColon": "Tér:",

"complete.title": "Feladat elvégzettnek jelölése",
Expand Down
1 change: 1 addition & 0 deletions Chores/Pages/Chores/Edit.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
}
</div>
<button type="submit" class="btn btn-primary me-2">@T["common.save"]</button>
<a href="@Model.BuildMovePath()" class="btn btn-outline-warning me-2">@T["chores.move"]</a>
<a href="@Model.BuildManageChoresPath()" class="btn btn-outline-secondary">@T["common.cancel"]</a>
</form>
</div>
15 changes: 15 additions & 0 deletions Chores/Pages/Chores/Edit.cshtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,21 @@ public string BuildManageChoresPath()
return string.IsNullOrEmpty(queryString) ? pagePath : $"{pagePath}{queryString}";
}

public string BuildMovePath()
{
var queryBuilder = new QueryBuilder
{
{ "id", ChoreId.ToString(CultureInfo.InvariantCulture) }
};

if (LabelId.HasValue)
{
queryBuilder.Add("labelId", LabelId.Value.ToString(CultureInfo.InvariantCulture));
}

return $"{Request.PathBase}/Chores/Move{queryBuilder.ToQueryString().Value}";
}

private async Task LoadAvailableLabelsAsync(int householdId)
{
AvailableLabels = await _db.Labels
Expand Down
61 changes: 61 additions & 0 deletions Chores/Pages/Chores/Move.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
@page
@model MoveModel
@inject UiTranslationService T
@{
ViewData["Title"] = T["chores.moveTitle"];
}

<div style="max-width:480px;">
<h2 class="mb-3">@T["chores.moveTitle"]</h2>
<p class="text-muted mb-1">@T["chores.choreName"] @Model.ChoreName</p>
<p class="text-muted">@T["chores.spaceColon"] @Model.CurrentHouseholdName</p>

@if (!Model.DestinationSpaces.Any())
{
<div class="alert alert-info">@T["chores.moveNoOtherSpaces"]</div>
<a href="@Model.BuildEditPath()" class="btn btn-outline-secondary">@T["common.back"]</a>
}
else if (Model.ShowConfirmation)
{
<div class="alert alert-warning">
<p class="mb-2">@string.Format(T["chores.moveConfirmMessage"], Model.ChoreName, Model.DestinationHouseholdName)</p>
<p class="mb-0">@T["chores.moveHistoryNote"]</p>
</div>

<form method="post" asp-page-handler="Start">
<div asp-validation-summary="All" class="text-danger mb-3"></div>
@if (Model.LabelId.HasValue)
{
<input type="hidden" asp-for="LabelId" />
}
<input type="hidden" asp-for="ChoreId" />
<input type="hidden" asp-for="DestinationHouseholdId" />
<button type="submit" class="btn btn-warning me-2">@T["chores.moveStart"]</button>
<a href="@Model.BuildMovePath()" class="btn btn-outline-secondary">@T["common.back"]</a>
</form>
}
else
{
<form method="post" asp-page-handler="Confirm">
<div asp-validation-summary="All" class="text-danger mb-3"></div>
@if (Model.LabelId.HasValue)
{
<input type="hidden" asp-for="LabelId" />
}
<input type="hidden" asp-for="ChoreId" />
<div class="mb-3">
<label asp-for="DestinationHouseholdId" class="form-label">@T["chores.moveDestination"]</label>
<select asp-for="DestinationHouseholdId" class="form-select">
<option value="">@T["chores.moveSelectPrompt"]</option>
@foreach (var space in Model.DestinationSpaces)
{
<option value="@space.HouseholdId">@space.Household.Name</option>
}
</select>
<span asp-validation-for="DestinationHouseholdId" class="text-danger"></span>
</div>
<button type="submit" class="btn btn-primary me-2">@T["chores.moveReview"]</button>
<a href="@Model.BuildEditPath()" class="btn btn-outline-secondary">@T["common.cancel"]</a>
</form>
}
</div>
Loading