Skip to content

Commit 8566982

Browse files
Merge pull request #140 from CodeForPhilly/feat/project-detail-banner-more
feat(web): ProjectDetail soft-delete banner for staff (#113)
2 parents 2b9a64f + 344c30e commit 8566982

5 files changed

Lines changed: 48 additions & 1 deletion

File tree

apps/api/src/services/serializers/project.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export interface ProjectDetail {
7676
readonly featured: boolean;
7777
readonly createdAt: string;
7878
readonly updatedAt: string;
79+
readonly deletedAt: string | null;
7980
}
8081

8182
export interface HelpWantedRoleSummary {
@@ -243,5 +244,6 @@ export function serializeProjectDetail(
243244
featured: project.featured,
244245
createdAt: project.createdAt,
245246
updatedAt: project.updatedAt,
247+
deletedAt: project.deletedAt ?? null,
246248
};
247249
}

apps/web/src/lib/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export interface ProjectDetail {
175175
readonly featured: boolean;
176176
readonly createdAt: string;
177177
readonly updatedAt: string;
178+
readonly deletedAt: string | null;
178179
}
179180

180181
export interface PersonListItem {

apps/web/src/screens/ProjectDetail.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) {
136136
const isSoleMaintainer = (myMembership?.isMaintainer ?? false) && maintainerCount === 1;
137137
const canJoin = isSignedIn && !isMember;
138138
const canLeave = isMember && !isSoleMaintainer;
139+
// Only staff can see a soft-deleted project at all (non-staff get 404), so a
140+
// non-null deletedAt here means the viewer is staff; gate anyway for clarity.
141+
const isStaff = person?.accountLevel === 'staff' || person?.accountLevel === 'administrator';
142+
const showDeletedBanner = project.deletedAt !== null && isStaff;
139143

140144
const runMembership = async (fn: () => Promise<void>): Promise<void> => {
141145
setMemberBusy(true);
@@ -154,6 +158,24 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) {
154158

155159
return (
156160
<div className="container mx-auto px-4 py-8">
161+
{/* Soft-delete banner — staff only (project-detail.md) */}
162+
{showDeletedBanner && (
163+
<div
164+
role="status"
165+
className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-md border border-yellow-400 bg-yellow-50 px-4 py-3 text-sm text-yellow-900"
166+
>
167+
<span>This project is deleted — only staff can see it.</span>
168+
<Button
169+
size="sm"
170+
variant="outline"
171+
disabled={memberBusy}
172+
onClick={() => void runMembership(() => api.projects.restore(slug).then(() => undefined))}
173+
>
174+
{memberBusy ? 'Restoring…' : 'Restore'}
175+
</Button>
176+
</div>
177+
)}
178+
157179
{/* Header */}
158180
<div className="mb-6">
159181
<div className="flex items-start justify-between gap-4 mb-3">

apps/web/tests/ProjectDetail.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,4 +257,23 @@ describe('ProjectDetail', () => {
257257
});
258258
expect(screen.queryByRole('button', { name: /join project/i })).not.toBeInTheDocument();
259259
});
260+
261+
it('shows the soft-delete banner + Restore for staff viewing a deleted project', async () => {
262+
const deleted = { ...PROJECT, deletedAt: '2026-06-01T00:00:00Z' } as unknown as typeof PROJECT;
263+
mockSignedIn(deleted, 'staff');
264+
renderDetail();
265+
await waitFor(() => {
266+
expect(screen.getByText(/this project is deleted/i)).toBeInTheDocument();
267+
});
268+
expect(screen.getByRole('button', { name: /restore/i })).toBeInTheDocument();
269+
});
270+
271+
it('does not show the soft-delete banner for an active project', async () => {
272+
mockSignedIn({ ...PROJECT, deletedAt: null } as unknown as typeof PROJECT, 'staff');
273+
renderDetail();
274+
await waitFor(() => {
275+
expect(screen.getByRole('heading', { name: 'Sample Project', level: 1 })).toBeInTheDocument();
276+
});
277+
expect(screen.queryByText(/this project is deleted/i)).not.toBeInTheDocument();
278+
});
260279
});

specs/api/projects.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,15 @@ Fetches a single project by slug.
130130
"canDelete": false
131131
},
132132
"createdAt": "...",
133-
"updatedAt": "..."
133+
"updatedAt": "...",
134+
"deletedAt": "..." | null
134135
}
135136
```
136137

137138
`permissions` is the *current caller's* permissions on this project — the frontend uses it to decide which actions to render. The server still enforces the same rules on each mutation endpoint.
138139

140+
`deletedAt` is `null` for active projects. It is non-null only when a soft-deleted project is fetched by staff (non-staff get `404`), so the SPA can render the soft-delete banner with a Restore action.
141+
139142
### Errors
140143

141144
- `404 not_found` — slug doesn't match (or is soft-deleted and caller can't see deleted).

0 commit comments

Comments
 (0)