Skip to content

Commit 477681e

Browse files
authored
Merge pull request #634 from Chris0Jeky/feature/611-review-hide-applied
UX-17: Hide applied proposals by default, add clear/dismiss action
2 parents 34d632a + 7215b02 commit 477681e

File tree

15 files changed

+230
-35
lines changed

15 files changed

+230
-35
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace Taskdeck.Api.Contracts;
2+
3+
/// <summary>
4+
/// Request body for the batch dismiss proposals endpoint.
5+
/// </summary>
6+
public sealed class DismissProposalsRequest
7+
{
8+
public IReadOnlyList<Guid>? Ids { get; set; }
9+
}

backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,51 @@ public async Task<IActionResult> ExecuteProposal(Guid id, CancellationToken canc
215215
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
216216
}
217217

218+
/// <summary>
219+
/// Dismisses completed proposals so they no longer appear in the default review list.
220+
/// Accepts an array of proposal IDs; only proposals in terminal states (Applied, Rejected, Failed, Expired) will be dismissed.
221+
/// </summary>
222+
[HttpPost("dismiss")]
223+
public async Task<IActionResult> DismissProposals(
224+
[FromBody] DismissProposalsRequest request,
225+
CancellationToken cancellationToken = default)
226+
{
227+
if (!TryGetCurrentUserId(out var callerUserId, out var errorResult))
228+
return errorResult!;
229+
230+
if (request.Ids is null || request.Ids.Count == 0)
231+
{
232+
return BadRequest(new ApiErrorResponse(
233+
ErrorCodes.ValidationError,
234+
"At least one proposal ID is required"));
235+
}
236+
237+
if (request.Ids.Count > MaxProposalListLimit)
238+
{
239+
return BadRequest(new ApiErrorResponse(
240+
ErrorCodes.ValidationError,
241+
$"Cannot dismiss more than {MaxProposalListLimit} proposals at once"));
242+
}
243+
244+
// Verify the caller owns each proposal being dismissed
245+
foreach (var proposalId in request.Ids.Distinct())
246+
{
247+
var proposalResult = await _proposalService.GetProposalByIdAsync(proposalId, cancellationToken);
248+
if (!proposalResult.IsSuccess)
249+
return proposalResult.ToErrorActionResult();
250+
251+
if (proposalResult.Value.RequestedByUserId != callerUserId)
252+
{
253+
return Result.Failure(ErrorCodes.Forbidden, "You can only dismiss your own proposals.").ToErrorActionResult();
254+
}
255+
}
256+
257+
var result = await _proposalService.DismissProposalsAsync(request.Ids, cancellationToken);
258+
return result.IsSuccess
259+
? Ok(new { dismissed = result.Value })
260+
: result.ToErrorActionResult();
261+
}
262+
218263
/// <summary>
219264
/// Gets a diff preview for a proposal showing what changes will be made.
220265
/// </summary>

backend/src/Taskdeck.Application/Services/AutomationProposalService.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,38 @@ public async Task<Result<string>> GetProposalDiffAsync(Guid id, CancellationToke
289289
return Result.Success(generatedDiff);
290290
}
291291

292+
public async Task<Result<int>> DismissProposalsAsync(IReadOnlyList<Guid> ids, CancellationToken cancellationToken = default)
293+
{
294+
if (ids.Count == 0)
295+
return Result.Success(0);
296+
297+
try
298+
{
299+
var proposals = await _unitOfWork.AutomationProposals.GetByIdsAsync(ids, cancellationToken);
300+
int dismissed = 0;
301+
302+
foreach (var proposal in proposals)
303+
{
304+
if (proposal.Status is ProposalStatus.Applied or ProposalStatus.Rejected
305+
or ProposalStatus.Failed or ProposalStatus.Expired)
306+
{
307+
proposal.Dismiss();
308+
dismissed++;
309+
}
310+
// Skip proposals not in a dismissible terminal state
311+
}
312+
313+
if (dismissed > 0)
314+
await _unitOfWork.SaveChangesAsync(cancellationToken);
315+
316+
return Result.Success(dismissed);
317+
}
318+
catch (DomainException ex)
319+
{
320+
return Result.Failure<int>(ex.ErrorCode, ex.Message);
321+
}
322+
}
323+
292324
private static ProposalDto MapToDto(AutomationProposal proposal)
293325
{
294326
return new ProposalDto(

backend/src/Taskdeck.Application/Services/IAutomationProposalService.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,9 @@ public interface IAutomationProposalService
5050
/// Gets the diff preview for a proposal.
5151
/// </summary>
5252
Task<Result<string>> GetProposalDiffAsync(Guid id, CancellationToken cancellationToken = default);
53+
54+
/// <summary>
55+
/// Dismisses completed proposals (Applied, Rejected, Failed, Expired) so they no longer appear in the default review list.
56+
/// </summary>
57+
Task<Result<int>> DismissProposalsAsync(IReadOnlyList<Guid> ids, CancellationToken cancellationToken = default);
5358
}

backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,16 @@ public void Expire()
131131
Touch();
132132
}
133133

134+
public void Dismiss()
135+
{
136+
if (Status != ProposalStatus.Applied && Status != ProposalStatus.Rejected
137+
&& Status != ProposalStatus.Failed && Status != ProposalStatus.Expired)
138+
throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot dismiss proposal in status {Status}");
139+
140+
Status = ProposalStatus.Dismissed;
141+
Touch();
142+
}
143+
134144
public void SetDiffPreview(string diffPreview)
135145
{
136146
if (Status != ProposalStatus.PendingReview)
@@ -164,7 +174,8 @@ public enum ProposalStatus
164174
Rejected,
165175
Applied,
166176
Failed,
167-
Expired
177+
Expired,
178+
Dismissed
168179
}
169180

170181
public enum RiskLevel

backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ public async Task<IReadOnlyList<AutomationProposal>> GetByIdsAsync(IEnumerable<G
6767
}
6868

6969
return await _dbSet
70-
.AsNoTracking()
7170
.Where(proposal => uniqueIds.Contains(proposal.Id))
7271
.ToListAsync(cancellationToken);
7372
}

frontend/taskdeck-web/src/api/automationApi.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,9 @@ export const automationApi = {
4040
const { data } = await http.get<{ diff: string }>(`/automation/proposals/${encodeURIComponent(id)}/diff`)
4141
return data.diff
4242
},
43+
44+
async dismissProposals(ids: string[]): Promise<{ dismissed: number }> {
45+
const { data } = await http.post<{ dismissed: number }>('/automation/proposals/dismiss', { ids })
46+
return data
47+
},
4348
}

frontend/taskdeck-web/src/tests/views/ReviewView.spec.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,12 +237,20 @@ describe('ReviewView', () => {
237237

238238
const { wrapper } = await mountAt('/workspace/review')
239239

240+
// Actionable proposals visible by default
240241
expect(wrapper.text()).toContain('Review required')
241242
expect(wrapper.text()).toContain('Approved, ready to apply')
242-
expect(wrapper.text()).toContain('Applied to board')
243243
expect(wrapper.text()).toContain('Approve for board')
244244
expect(wrapper.text()).toContain('Apply to board')
245245
expect(wrapper.text()).toContain('Changes stay in review until you approve them.')
246+
247+
// Applied proposals hidden by default (showCompleted is off)
248+
expect(wrapper.text()).not.toContain('Applied to board')
249+
250+
// Toggle showCompleted on to reveal applied proposals
251+
const toggle = wrapper.find('.td-review__toggle-input')
252+
await toggle.setValue(true)
253+
expect(wrapper.text()).toContain('Applied to board')
246254
})
247255

248256
it('renders capture provenance and canonical review links', async () => {

frontend/taskdeck-web/src/types/automation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export type ProposalSourceType = 'Queue' | 'Chat' | 'Manual'
22
export type ProposalSourceTypeValue = ProposalSourceType | number
3-
export type ProposalStatus = 'PendingReview' | 'Approved' | 'Rejected' | 'Applied' | 'Failed' | 'Expired'
3+
export type ProposalStatus = 'PendingReview' | 'Approved' | 'Rejected' | 'Applied' | 'Failed' | 'Expired' | 'Dismissed'
44
export type ProposalStatusValue = ProposalStatus | number
55
export type ProposalRiskLevel = 'Low' | 'Medium' | 'High' | 'Critical'
66
export type ProposalRiskLevelValue = ProposalRiskLevel | number

frontend/taskdeck-web/src/utils/automation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ProposalRiskLevelValue, ProposalSourceTypeValue, ProposalStatusValue } from '../types/automation'
22

3-
const proposalStatusByIndex = ['PendingReview', 'Approved', 'Rejected', 'Applied', 'Failed', 'Expired'] as const
3+
const proposalStatusByIndex = ['PendingReview', 'Approved', 'Rejected', 'Applied', 'Failed', 'Expired', 'Dismissed'] as const
44
const proposalSourceByIndex = ['Queue', 'Chat', 'Manual'] as const
55
const proposalRiskByIndex = ['Low', 'Medium', 'High', 'Critical'] as const
66

0 commit comments

Comments
 (0)