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
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@
_writing = false;
}

// The goal the module answered with shows now, with the picker closed, while the
// goals are read again.
StateHasChanged();
await ReadAsync();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
Without the Progress ports the view says the module is not set up, under its own test
id; goals-view-empty is the board's real empty state alone.

Every write goes through the module and the view is read again once it lands:
Every write goes through the module and the view is read again once it lands. An
attach or a detach draws the goal the module answers with at once, and the reading
after it brings the counts and statuses:
- the iteration switch, through IIterationSettings.SetLength. The first time 2 weeks
is chosen — no two-week start kept yet — TwoWeekStartDialog asks for the day first,
offering the Monday of the current week; Save keeps it through SetTwoWeekStart and
Expand Down Expand Up @@ -253,21 +255,61 @@ else
StateHasChanged();
});

/// <summary>What a write came to: whether the view is read again, and the goal the
/// module answered with, drawn before that reading.</summary>
private readonly record struct Written(bool Read, GoalDto? Goal = null);

private Task WriteAsync(Func<Task<bool>> write) => WriteAsync(async () => new Written(await write()));

/// <summary>Runs a write made here and the read after it, with the module's Changed
/// for that write set aside.</summary>
private async Task WriteAsync(Func<Task<bool>> write)
private async Task WriteAsync(Func<Task<Written>> write)
{
_ownWrites++;
try
{
if (await write()) await ReadAsync();
var written = await write();
if (!written.Read) return;

// The write's own finally has run, so the board is no longer busy: the goal the
// module answered with shows now, and the reading brings the counts and statuses.
// Drawn only with a reading after it, which sets the counts straight.
if (written.Goal is { } goal)
{
Draw(goal);
StateHasChanged();
}

await ReadAsync();
}
finally
{
_ownWrites--;
}
}

/// <summary>A goal as the module answered a write: it stands in for the goal last read,
/// with that goal's counts, and what it holds leaves every other goal, since an item
/// belongs to at most one. A goal not read yet — one the picker's New goal… just
/// created — is left to the reading: the board has no row for it, and taking a moved
/// item off its old goal would show it under No goal until then.</summary>
private void Draw(GoalDto goal)
{
if (_summaries.All(summary => summary.Goal.Id != goal.Id)) return;

var held = goal.Attachments.Select(attachment => attachment.Item).ToHashSet();
_summaries =
[
.. _summaries.Select(summary => summary.Goal.Id == goal.Id
? summary with { Goal = goal }
: summary with
{
Goal = summary.Goal with { Attachments = [.. summary.Goal.Attachments.Where(attachment => !held.Contains(attachment.Item))] }
})
];
Remap();
}

private async Task ReadAsync()
{
if (_standUp is null || _goals is null) return;
Expand Down Expand Up @@ -392,24 +434,24 @@ else
try
{
var attached = await goals.AttachAsync(goalId, item);
if (attached.IsSuccess) return true;
if (attached.IsSuccess) return new Written(true, attached.Value);

if (attached.Error.Code != ItemInOtherGoal)
{
_writeError = attached.Error.Message;
return false;
return new Written(false);
}

// The picker names the goal that holds it and asks before the move.
var holder = await goals.FindGoalOfAsync(item);
if (holder.IsFailure || holder.Value is not { } held)
{
_writeError = attached.Error.Message;
return false;
return new Written(false);
}

Open(new Picking(request.Subject, Option(held), goalId));
return false;
return new Written(false);
}
finally
{
Expand Down Expand Up @@ -438,16 +480,14 @@ else
if (created.IsFailure)
{
_writeError = created.Error.Message;
return false;
return new Written(false);
}

var attached = await goals.AttachAsync(goalId, GoalItemDto.ForTask(created.Value));
if (attached.IsFailure)
{
_writeError = $"{request.Title} was added to the backlog, but not to this goal: {attached.Error.Message}";
}
if (attached.IsSuccess) return new Written(true, attached.Value);

return true;
_writeError = $"{request.Title} was added to the backlog, but not to this goal: {attached.Error.Message}";
return new Written(true);
}
finally
{
Expand Down Expand Up @@ -518,13 +558,13 @@ else
{
_pickError = written.Error.Message;
// A goal set before the refusal is on the board now.
return _pickCreated is not null;
return new Written(_pickCreated is not null);
}

_pickError = null;
_pickCreated = null;
_picking = null;
return true;
return new Written(true, written.Value);
}
finally
{
Expand Down Expand Up @@ -596,8 +636,10 @@ else
try
{
var detached = await goals.DetachAsync(goalId, item);
if (detached.IsFailure) _writeError = detached.Error.Message;
return true;
if (detached.IsSuccess) return new Written(true, detached.Value);

_writeError = detached.Error.Message;
return new Written(true);
}
finally
{
Expand Down
47 changes: 41 additions & 6 deletions tests/Backlog.Desktop.UI.UnitTests/GoalAttachTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,31 @@ public async Task Attaching_work_another_goal_holds_says_it_will_move_and_moves_
control.WaitForAssertion(() => Assert.Equal("Docs", control.Find("[data-testid=goal-control-open]").TextContent.Trim()));
}

/// <summary>The goal the module answers with is drawn at once and the picker closes,
/// while the goals are read again.</summary>
[Fact]
public async Task An_attach_shows_the_new_goal_and_closes_the_picker_before_the_goals_are_read_again()
{
var docs = Goal("Docs");
var goals = Compose(new FakeGoals(Goal("Launch"), docs));
var control = await OpenPickerAsync();
await ChooseAsync(control, docs.Goal.Id);
var listing = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
goals.AnswersLater = true;
goals.ListGate = listing.Task;

control.Find("[data-testid=goal-picker-attach]").Click();

control.WaitForAssertion(() =>
{
Assert.Empty(control.FindAll("[data-testid=goal-picker]"));
Assert.Equal("Docs", control.Find("[data-testid=goal-control-open]").TextContent.Trim());
});
Assert.Equal([$"Attach {docs.Goal.Id} Task:{TaskId} move:False"], goals.Writes);

listing.SetResult();
}

[Fact]
public async Task A_new_goal_is_set_and_the_work_attached_to_it()
{
Expand Down Expand Up @@ -335,8 +360,17 @@ public event Action? Changed { add { } remove { } }
/// <summary>Set, every attach is refused with these words.</summary>
public string? Refuse { get; set; }

public Task<Result<IReadOnlyList<GoalSummaryDto>>> ListAsync(DateOnly? windowStart = null, DateOnly? windowEnd = null, CancellationToken cancellationToken = default) =>
Task.FromResult(Result.Success<IReadOnlyList<GoalSummaryDto>>([.. Goals]));
/// <summary>A list of the goals that lands only when the test says so.</summary>
public Task? ListGate { get; set; }

/// <summary>Each attach is answered after a yield, as the module's file write is.</summary>
public bool AnswersLater { get; set; }

public async Task<Result<IReadOnlyList<GoalSummaryDto>>> ListAsync(DateOnly? windowStart = null, DateOnly? windowEnd = null, CancellationToken cancellationToken = default)
{
if (ListGate is { } gate) await gate;
return Result.Success<IReadOnlyList<GoalSummaryDto>>([.. Goals]);
}

public Task<Result<GoalDto>> GetAsync(Guid goalId, CancellationToken cancellationToken = default) =>
Task.FromResult(Result.Success(Goals.Single(goal => goal.Goal.Id == goalId).Goal));
Expand All @@ -361,21 +395,22 @@ public Task<Result> DeleteAsync(Guid goalId, CancellationToken cancellationToken
public Task<Result> MoveAsync(Guid goalId, int toIndex, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();

public Task<Result<GoalDto>> AttachAsync(Guid goalId, GoalItemDto item, bool moveFromOtherGoal = false, CancellationToken cancellationToken = default)
public async Task<Result<GoalDto>> AttachAsync(Guid goalId, GoalItemDto item, bool moveFromOtherGoal = false, CancellationToken cancellationToken = default)
{
Writes.Add($"Attach {goalId} {item.Kind}:{item.TaskId?.ToString() ?? item.PlanTag ?? $"{item.Repository}#{item.Number}"} move:{moveFromOtherGoal}");
if (Refuse is { } refusal) return Task.FromResult(Result.Failure<GoalDto>(Error.Validation("progress.task_required", refusal)));
if (AnswersLater) await Task.Yield();
if (Refuse is { } refusal) return Result.Failure<GoalDto>(Error.Validation("progress.task_required", refusal));

if (Holder(item) is { } holder && holder.Goal.Id != goalId)
{
if (!moveFromOtherGoal) return Task.FromResult(Result.Failure<GoalDto>(ProgressErrors.ItemInOtherGoal(holder.Goal.Title)));
if (!moveFromOtherGoal) return Result.Failure<GoalDto>(ProgressErrors.ItemInOtherGoal(holder.Goal.Title));
Replace(holder.Goal with { Attachments = [.. holder.Goal.Attachments.Where(attachment => attachment.Item != item)] });
}

var target = Goals.Single(goal => goal.Goal.Id == goalId).Goal;
var attached = target with { Attachments = [.. target.Attachments, new GoalAttachmentDto(item, Today)] };
Replace(attached);
return Task.FromResult(Result.Success(attached));
return Result.Success(attached);
}

public Task<Result<GoalDto>> DetachAsync(Guid goalId, GoalItemDto item, CancellationToken cancellationToken = default) =>
Expand Down
Loading
Loading