Skip to content

feat(workspace): add recycle bin listing and restore actions#7

Merged
hammond01 merged 4 commits into
devfrom
feature/recycle-bin-restore
May 22, 2026
Merged

feat(workspace): add recycle bin listing and restore actions#7
hammond01 merged 4 commits into
devfrom
feature/recycle-bin-restore

Conversation

@hammond01
Copy link
Copy Markdown
Owner

Summary

  • add deleted filter support for captures/items listing APIs
  • add restore endpoints:
    • POST /api/v1/captures/{captureId}/restore
    • POST /api/v1/items/{itemId}/restore
  • add restore business logic for soft-deleted capture/item and linked entity sync
  • add activity actions for restore events
  • add frontend Recycle Bin panel with restore actions for deleted captures/items
  • extend API client/types and localized copy for restore/recycle bin labels

Validation

  • backend: ./gradlew test
  • frontend tests: npx vitest run
  • frontend build: npm run build

Notes

  • keeps current default list behavior (deleted omitted => active records only)
  • restore operations update audit fields and emit activity logs

Copilot AI review requested due to automatic review settings May 22, 2026 06:13
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds “Recycle Bin” support across backend and frontend by exposing soft-deleted captures/items via listing filters, providing restore endpoints/business logic, and surfacing restore actions in the workspace UI.

Changes:

  • Backend: add deleted query filter to captures/items list endpoints and add restore endpoints for both entities (with linked entity sync + activity logging).
  • Frontend: extend API client/types for deleted/restore support and add a Recycle Bin panel with restore actions.
  • Tests/i18n: add integration coverage for restore + capture deleted filter, plus localized UI copy for recycle bin/restore.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
frontend/src/types.ts Add optional deletedAt fields to Capture/Item types.
frontend/src/services/api.ts Add deleted list query param, getItem, and restore API calls.
frontend/src/pages/WorkspacePage.tsx Fetch deleted lists, implement restore flows, and render Recycle Bin panel.
frontend/src/pages/WorkspacePage.css Add Recycle Bin layout styles.
frontend/src/i18n/workspaceCopy.ts Add recycle bin + restore labels and error copy.
backend/src/test/java/io/opspace/backend/capture/CaptureControllerIntegrationTests.java Add tests for deleted filter (captures) and restore endpoints.
backend/src/main/java/io/opspace/backend/service/ItemService.java Add deleted filtering and item restore logic (incl. linked capture restore + activity log).
backend/src/main/java/io/opspace/backend/service/CaptureService.java Add deleted filtering and capture restore logic (incl. linked item restore + activity log).
backend/src/main/java/io/opspace/backend/domain/ActivityAction.java Add *_RESTORED activity actions.
backend/src/main/java/io/opspace/backend/controller/ItemController.java Accept deleted query param and add restore endpoint.
backend/src/main/java/io/opspace/backend/controller/CaptureController.java Accept deleted query param and add restore endpoint.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread frontend/src/i18n/workspaceCopy.ts Outdated
Comment thread frontend/src/pages/WorkspacePage.tsx Outdated
Comment on lines +370 to +380
const matchesCaptureFilter =
captureFilter === 'all' ||
(captureFilter === 'converted' ? Boolean(restoredCapture.convertedItemId) : !restoredCapture.convertedItemId)
if (matchesCaptureFilter) {
setCaptures((current) => {
const exists = current.some((capture) => capture.id === captureId)
if (exists) {
return current.map((capture) => (capture.id === captureId ? restoredCapture : capture))
}
return [restoredCapture, ...current]
})
Comment on lines +383 to +395
if (restoredCapture.convertedItemId) {
const restoredItem = await getItem(restoredCapture.convertedItemId).catch(() => null)
if (restoredItem) {
setDeletedItems((current) => current.filter((item) => item.id !== restoredItem.id))
if (matchesItemFilter(restoredItem)) {
setItems((current) => {
const exists = current.some((item) => item.id === restoredItem.id)
if (exists) {
return current.map((item) => (item.id === restoredItem.id ? restoredItem : item))
}
return [restoredItem, ...current]
})
}
Comment on lines +416 to +424
if (matchesItemFilter(restoredItem)) {
setItems((current) => {
const exists = current.some((item) => item.id === restoredItem.id)
if (exists) {
return current.map((item) => (item.id === restoredItem.id ? restoredItem : item))
}
return [restoredItem, ...current]
})
}
Comment on lines +429 to +443
const matchesCaptureFilter =
captureFilter === 'all' ||
(captureFilter === 'converted'
? Boolean(restoredCapture.convertedItemId)
: !restoredCapture.convertedItemId)
if (matchesCaptureFilter) {
setCaptures((current) => {
const exists = current.some((capture) => capture.id === sourceCaptureId)
if (exists) {
return current.map((capture) =>
capture.id === sourceCaptureId ? restoredCapture : capture,
)
}
return [restoredCapture, ...current]
})
Comment on lines +362 to +406
const performRestoreCapture = async (captureId: string) => {
setRestoringCaptureId(captureId)
setRecycleBinError(null)
try {
await restoreCapture(captureId)
const restoredCapture = await getCapture(captureId)
setDeletedCaptures((current) => current.filter((capture) => capture.id !== captureId))

const matchesCaptureFilter =
captureFilter === 'all' ||
(captureFilter === 'converted' ? Boolean(restoredCapture.convertedItemId) : !restoredCapture.convertedItemId)
if (matchesCaptureFilter) {
setCaptures((current) => {
const exists = current.some((capture) => capture.id === captureId)
if (exists) {
return current.map((capture) => (capture.id === captureId ? restoredCapture : capture))
}
return [restoredCapture, ...current]
})
}

if (restoredCapture.convertedItemId) {
const restoredItem = await getItem(restoredCapture.convertedItemId).catch(() => null)
if (restoredItem) {
setDeletedItems((current) => current.filter((item) => item.id !== restoredItem.id))
if (matchesItemFilter(restoredItem)) {
setItems((current) => {
const exists = current.some((item) => item.id === restoredItem.id)
if (exists) {
return current.map((item) => (item.id === restoredItem.id ? restoredItem : item))
}
return [restoredItem, ...current]
})
}
}
}

await refreshDeletedData()
} catch (error) {
const message = error instanceof Error ? error.message : ui.failedToRestore
setRecycleBinError(message)
} finally {
setRestoringCaptureId(null)
}
}
Comment on lines +408 to +454
const performRestoreItem = async (itemId: string, sourceCaptureId: string) => {
setRestoringItemId(itemId)
setRecycleBinError(null)
try {
await restoreItem(itemId)
const restoredItem = await getItem(itemId)
setDeletedItems((current) => current.filter((item) => item.id !== itemId))

if (matchesItemFilter(restoredItem)) {
setItems((current) => {
const exists = current.some((item) => item.id === restoredItem.id)
if (exists) {
return current.map((item) => (item.id === restoredItem.id ? restoredItem : item))
}
return [restoredItem, ...current]
})
}

const restoredCapture = await getCapture(sourceCaptureId).catch(() => null)
if (restoredCapture) {
setDeletedCaptures((current) => current.filter((capture) => capture.id !== sourceCaptureId))
const matchesCaptureFilter =
captureFilter === 'all' ||
(captureFilter === 'converted'
? Boolean(restoredCapture.convertedItemId)
: !restoredCapture.convertedItemId)
if (matchesCaptureFilter) {
setCaptures((current) => {
const exists = current.some((capture) => capture.id === sourceCaptureId)
if (exists) {
return current.map((capture) =>
capture.id === sourceCaptureId ? restoredCapture : capture,
)
}
return [restoredCapture, ...current]
})
}
}

await refreshDeletedData()
} catch (error) {
const message = error instanceof Error ? error.message : ui.failedToRestore
setRecycleBinError(message)
} finally {
setRestoringItemId(null)
}
}
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 22, 2026 07:00
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@hammond01 hammond01 marked this pull request as ready for review May 22, 2026 07:01
@hammond01 hammond01 merged commit 4dc107d into dev May 22, 2026
3 of 4 checks passed
Copilot stopped work on behalf of hammond01 due to an error May 22, 2026 07:02
@hammond01 hammond01 deleted the feature/recycle-bin-restore branch May 22, 2026 07:02
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Comment on lines 36 to 45
@GetMapping
public ResponseEntity<List<Item>> getItems(
@RequestParam(required = false) Integer limit,
@RequestParam(required = false) String cursor,
@RequestParam(required = false) ItemStatus status,
@RequestParam(required = false) ItemType type
@RequestParam(required = false) ItemType type,
@RequestParam(required = false) Boolean deleted
) {
CursorPage<Item> page = itemService.getItems(limit, cursor, status, type);
CursorPage<Item> page = itemService.getItems(limit, cursor, status, type, deleted);
ResponseEntity.BodyBuilder response = ResponseEntity.ok();
Comment on lines +151 to +154
const [deletedCapturePage, deletedItemPage] = await Promise.allSettled([
listCaptures({ limit: PAGE_SIZE, deleted: true }),
listItems({ limit: PAGE_SIZE, deleted: true }),
])
Comment on lines +207 to +216
const refreshDeletedData = async () => {
try {
const [deletedCapturePage, deletedItemPage] = await Promise.all([
listCaptures({ limit: PAGE_SIZE, deleted: true }),
listItems({ limit: PAGE_SIZE, deleted: true }),
])
setDeletedCaptures(deletedCapturePage.data)
setDeletedItems(deletedItemPage.data)
} catch {
// recycle bin refresh errors are non-blocking for primary workflow
Comment on lines 38 to +42
@RequestParam(required = false) Integer limit,
@RequestParam(required = false) String cursor,
@RequestParam(required = false) ItemStatus status,
@RequestParam(required = false) ItemType type
@RequestParam(required = false) ItemType type,
@RequestParam(required = false) Boolean deleted
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants