mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 03:16:43 +00:00
feat(web): archived-item recovery on the item detail page (TASK-1829) (#735)
With GET now returning soft-deleted items read-only (deleted_at populated, shipped in #733), the detail route can show an archived item instead of a hard 404. Adds the recovery UI: - isArchived derived from item.deleted_at; folded into the existing canEdit derived so every edit affordance disables while archived. - A read-only "Archived" banner at the top of the item view (date via the file's relativeTime helper) with a Restore button -> api.items.restore, then re-fetches the item so the banner clears and editing re-enables. Success/error via the existing toastStore; a 409 reclaimed-slug conflict surfaces verbatim. - restoring in-flight flag; handleRestore is a standalone async function (not an effect) per CONVE-1688 / CONVE-606. No API/client/server change — GET is already ungated (#733) and api.items.restore already existed. Child of BUG-1791 (TASK-1827 in #733, TASK-1828 in #734).
This commit is contained in:
@@ -124,6 +124,7 @@
|
||||
let saveStatusTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let confirmDelete = $state(false);
|
||||
let deleting = $state(false);
|
||||
let restoring = $state(false);
|
||||
let rawMode = $state(false);
|
||||
let showMoveMenu = $state(false);
|
||||
let moving = $state(false);
|
||||
@@ -253,11 +254,20 @@
|
||||
// by workspaceStore.setCurrent via the /me endpoint. workspaceMembers is
|
||||
// still loaded for the assignee dropdown (line ~947).
|
||||
let isOwner = $derived(workspaceStore.isOwner);
|
||||
// Archived ("soft-deleted") items are returned read-only by the API
|
||||
// (HTTP 200 with deleted_at populated) so they can be reviewed and
|
||||
// restored. TASK-1829.
|
||||
let isArchived = $derived(!!item?.deleted_at);
|
||||
// Per-item edit predicate (PLAN-1100 / TASK-1105). Mirrors the server's
|
||||
// ResolveUserPermission cascade: owner → item grant → collection grant
|
||||
// → role + visibility. Drives title / content / FieldEditor / delete /
|
||||
// status affordance gating below.
|
||||
let canEdit = $derived(item ? workspaceStore.canEditItem(item) : false);
|
||||
// status affordance gating below. Folds in the archived gate (TASK-1829)
|
||||
// so every edit affordance disables while the item is archived.
|
||||
let canEdit = $derived(item && !item.deleted_at ? workspaceStore.canEditItem(item) : false);
|
||||
// Restore requires edit permission server-side, independent of the
|
||||
// archived gate that forces canEdit false — otherwise the Restore CTA
|
||||
// would render for read-only viewers and just 403 on click (Codex).
|
||||
let canRestore = $derived(item ? workspaceStore.canEditItem(item) : false);
|
||||
$effect(() => {
|
||||
if (wsSlug && collSlug && itemSlug) {
|
||||
loadData();
|
||||
@@ -2066,6 +2076,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Restore a soft-deleted ("archived") item. Standalone async handler
|
||||
// (not folded into a route-change $effect — CONVE-606/CONVE-1688) that
|
||||
// mutates `item` directly: on success it re-fetches via api.items.get so
|
||||
// the archived banner disappears and `canEdit` re-enables. The restore
|
||||
// endpoint can 409 if the slug/invocation_slug was reclaimed while
|
||||
// archived — surface that message the same way other handlers do. TASK-1829.
|
||||
async function handleRestore() {
|
||||
if (!item || restoring) return;
|
||||
restoring = true;
|
||||
try {
|
||||
await api.items.restore(wsSlug, itemSlug);
|
||||
const refreshed = await api.items.get(wsSlug, itemSlug);
|
||||
item = withInflightTags(refreshed);
|
||||
toastStore.show('Item restored', 'success');
|
||||
} catch (e: any) {
|
||||
toastStore.show(e.message ?? 'Failed to restore item', 'error');
|
||||
} finally {
|
||||
restoring = false;
|
||||
}
|
||||
}
|
||||
|
||||
let allCollections = $derived(collectionStore.collections ?? []);
|
||||
let moveTargets = $derived(allCollections.filter(c => c.slug !== collSlug));
|
||||
|
||||
@@ -2274,6 +2305,27 @@
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Archived banner (TASK-1829) — read-only notice shown above the
|
||||
title/content when the item is soft-deleted. Restore re-enables
|
||||
editing by re-fetching the (now active) item. -->
|
||||
{#if isArchived}
|
||||
<div class="archived-banner" role="status">
|
||||
<svg class="archived-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="21 8 21 21 3 21 3 8"></polyline><rect x="1" y="3" width="22" height="5"></rect><line x1="10" y1="12" x2="14" y2="12"></line></svg>
|
||||
<div class="archived-text">
|
||||
<strong>This item is archived</strong>
|
||||
{#if item.deleted_at}
|
||||
<span class="archived-date" title={new Date(item.deleted_at).toLocaleString()}>Archived {relativeTime(item.deleted_at)}</span>
|
||||
{/if}
|
||||
<span class="archived-hint">It's read-only until restored.</span>
|
||||
</div>
|
||||
{#if canRestore}
|
||||
<button class="archived-restore-btn" onclick={handleRestore} disabled={restoring}>
|
||||
{restoring ? 'Restoring…' : 'Restore'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Title -->
|
||||
<div class="title-row">
|
||||
{#if formatItemRef(item)}
|
||||
@@ -3629,6 +3681,58 @@
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* Archived banner (TASK-1829) — accent-bordered callout matching the
|
||||
.link-row.tone-* idiom; Restore mirrors .delete-confirm-btn.yes. */
|
||||
.archived-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent-orange);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.archived-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
.archived-text {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1) var(--space-2);
|
||||
font-size: 0.9em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.archived-text strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.archived-date,
|
||||
.archived-hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.archived-restore-btn {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--accent-orange);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
.archived-restore-btn:hover:not(:disabled) {
|
||||
background: var(--accent-orange);
|
||||
color: #fff;
|
||||
}
|
||||
.archived-restore-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user