Skip to content

Commit 03b8449

Browse files
Merge pull request #28 from QuentinHourdeaux/feat/draft-detail-edit-and-filter-ui
feat(frontend): draft detail edit and state filter UI
2 parents e05a915 + 692ad2b commit 03b8449

14 files changed

Lines changed: 2292 additions & 214 deletions

‎docs/planned-prs/17-draft-detail-edit-and-filter-ui.md‎

Lines changed: 0 additions & 66 deletions
This file was deleted.

‎frontend/src/api/drafts.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ export interface CreateDraftInput {
1515
readonly stackId?: string;
1616
}
1717

18+
export interface UpdateDraftInput {
19+
readonly title?: string;
20+
readonly description?: string;
21+
readonly stateId?: string;
22+
readonly stackId?: string | null;
23+
}
24+
1825
export interface ListDraftsFilter {
1926
readonly stateId?: string;
2027
readonly stackId?: string;
@@ -67,3 +74,21 @@ export const createDraft = async (
6774

6875
return await readJson<Draft>(response);
6976
};
77+
78+
/** PATCH /api/drafts/:draftId — update a Draft's fields and/or Stack assignment. */
79+
export const updateDraft = async (
80+
draftId: string,
81+
input: UpdateDraftInput,
82+
signal?: AbortSignal,
83+
): Promise<Draft> => {
84+
const response = await fetch(`/api/drafts/${draftId}`, {
85+
method: "PATCH",
86+
headers: {
87+
"Content-Type": "application/json",
88+
},
89+
body: JSON.stringify(input),
90+
signal,
91+
});
92+
93+
return await readJson<Draft>(response);
94+
};

‎frontend/src/features/draft/draft-detail-screen.tsx‎

Lines changed: 134 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { useCallback, useEffect, useRef, useState } from "react";
22
import { Link, useParams } from "react-router";
33
import { type Draft, getDraft } from "../../api/drafts.ts";
4-
import { getStack, type Stack } from "../../api/stacks.ts";
4+
import { getStack, listStacks, type Stack } from "../../api/stacks.ts";
55
import { listStates, type State } from "../../api/states.ts";
66
import { isApiError } from "../../lib/api/api-error.ts";
77
import { isAbortError } from "../../lib/async/abort-error.ts";
88
import { readErrorMessage } from "../../lib/async/loadable.ts";
9-
import { StateBadge } from "../stack/state-badge.tsx";
9+
import { DraftEditForm } from "./draft-edit-form.tsx";
1010
import { sortStatesByPosition } from "./sort-states-by-position.ts";
1111

1212
type DraftLoadable =
@@ -25,6 +25,16 @@ type StatesEnrichmentLoadable =
2525
readonly states?: State[];
2626
};
2727

28+
type StacksEnrichmentLoadable =
29+
| { readonly kind: "none" }
30+
| { readonly kind: "loading" }
31+
| { readonly kind: "ready"; readonly stacks: Stack[] }
32+
| {
33+
readonly kind: "error";
34+
readonly message: string;
35+
readonly stacks?: Stack[];
36+
};
37+
2838
type StackEnrichmentLoadable =
2939
| { readonly kind: "none" }
3040
| { readonly kind: "loading" }
@@ -39,11 +49,15 @@ export function DraftDetailScreen() {
3949
const [statesState, setStatesState] = useState<StatesEnrichmentLoadable>({
4050
kind: "none",
4151
});
52+
const [stacksState, setStacksState] = useState<StacksEnrichmentLoadable>({
53+
kind: "none",
54+
});
4255
const [stackState, setStackState] = useState<StackEnrichmentLoadable>({
4356
kind: "none",
4457
});
4558
const [reloadToken, setReloadToken] = useState(0);
4659
const [statesReloadToken, setStatesReloadToken] = useState(0);
60+
const [stacksReloadToken, setStacksReloadToken] = useState(0);
4761
const [stackReloadToken, setStackReloadToken] = useState(0);
4862
const headingRef = useRef<HTMLHeadingElement>(null);
4963
const focusHeadingOnReadyRef = useRef(false);
@@ -56,16 +70,35 @@ export function DraftDetailScreen() {
5670
setStatesReloadToken((current) => current + 1);
5771
}, []);
5872

73+
const reloadStacks = useCallback(() => {
74+
setStacksReloadToken((current) => current + 1);
75+
}, []);
76+
5977
const reloadStack = useCallback(() => {
6078
setStackReloadToken((current) => current + 1);
6179
}, []);
6280

81+
const handleUpdated = useCallback((draft: Draft) => {
82+
setDraftState({
83+
kind: "ready",
84+
draft,
85+
});
86+
}, []);
87+
88+
const loadedDraftId = draftState.kind === "ready"
89+
? draftState.draft.id
90+
: null;
91+
const assignedStackId = draftState.kind === "ready"
92+
? draftState.draft.stackId
93+
: null;
94+
6395
useEffect(() => {
6496
const abortController = new AbortController();
6597
const { signal } = abortController;
6698

6799
setDraftState({ kind: "loading" });
68100
setStatesState({ kind: "none" });
101+
setStacksState({ kind: "none" });
69102
setStackState({ kind: "none" });
70103

71104
void (async () => {
@@ -102,7 +135,7 @@ export function DraftDetailScreen() {
102135
}, [draftId, reloadToken]);
103136

104137
useEffect(() => {
105-
if (draftState.kind !== "ready") {
138+
if (loadedDraftId === null) {
106139
setStatesState({ kind: "none" });
107140
return;
108141
}
@@ -148,17 +181,66 @@ export function DraftDetailScreen() {
148181
})();
149182

150183
return () => abortController.abort();
151-
}, [draftState, statesReloadToken]);
184+
}, [loadedDraftId, statesReloadToken]);
152185

153186
useEffect(() => {
154-
if (draftState.kind !== "ready" || draftState.draft.stackId === null) {
187+
if (loadedDraftId === null) {
188+
setStacksState({ kind: "none" });
189+
return;
190+
}
191+
192+
const abortController = new AbortController();
193+
const { signal } = abortController;
194+
195+
setStacksState((current) => {
196+
if (current.kind === "error") {
197+
return current;
198+
}
199+
200+
return { kind: "loading" };
201+
});
202+
203+
void (async () => {
204+
try {
205+
const stacks = await listStacks(undefined, signal);
206+
207+
if (signal.aborted) {
208+
return;
209+
}
210+
211+
setStacksState({
212+
kind: "ready",
213+
stacks,
214+
});
215+
} catch (error: unknown) {
216+
if (signal.aborted || isAbortError(error)) {
217+
return;
218+
}
219+
220+
setStacksState((current) => ({
221+
kind: "error",
222+
message: readErrorMessage(error, "Could not load Stacks."),
223+
...(current.kind === "ready"
224+
? { stacks: current.stacks }
225+
: current.kind === "error" && current.stacks !== undefined
226+
? { stacks: current.stacks }
227+
: {}),
228+
}));
229+
}
230+
})();
231+
232+
return () => abortController.abort();
233+
}, [loadedDraftId, stacksReloadToken]);
234+
235+
useEffect(() => {
236+
if (assignedStackId === null) {
155237
setStackState({ kind: "none" });
156238
return;
157239
}
158240

159241
const abortController = new AbortController();
160242
const { signal } = abortController;
161-
const { stackId } = draftState.draft;
243+
const stackId = assignedStackId;
162244

163245
setStackState((current) => {
164246
if (current.kind === "ready" && current.stack.id === stackId) {
@@ -197,7 +279,7 @@ export function DraftDetailScreen() {
197279
})();
198280

199281
return () => abortController.abort();
200-
}, [draftState, stackReloadToken]);
282+
}, [assignedStackId, stackReloadToken]);
201283

202284
useEffect(() => {
203285
if (draftState.kind !== "ready" || !focusHeadingOnReadyRef.current) {
@@ -276,13 +358,40 @@ export function DraftDetailScreen() {
276358
: statesState.kind === "error" && statesState.states !== undefined
277359
? statesState.states
278360
: [];
279-
const state = states.find((entry) => entry.id === draft.stateId);
361+
const stacks = stacksState.kind === "ready"
362+
? stacksState.stacks
363+
: stacksState.kind === "error" && stacksState.stacks !== undefined
364+
? stacksState.stacks
365+
: [];
280366

281367
return (
282368
<section
283369
className="page draft-detail"
284370
aria-labelledby="draft-detail-heading"
285371
>
372+
<nav className="breadcrumb" aria-label="Breadcrumb">
373+
<ol className="breadcrumb__list">
374+
<li className="breadcrumb__item">
375+
<Link className="breadcrumb__link" to="/">
376+
Drafts
377+
</Link>
378+
</li>
379+
{stackState.kind === "ready" && (
380+
<li className="breadcrumb__item">
381+
<Link
382+
className="breadcrumb__link"
383+
to={`/stacks/${stackState.stack.id}`}
384+
>
385+
{stackState.stack.title}
386+
</Link>
387+
</li>
388+
)}
389+
<li className="breadcrumb__item breadcrumb__item--current">
390+
<span aria-current="page">{draft.title}</span>
391+
</li>
392+
</ol>
393+
</nav>
394+
286395
<p className="page__eyebrow">Draft</p>
287396
<h1
288397
className="page__title"
@@ -306,10 +415,17 @@ export function DraftDetailScreen() {
306415
</div>
307416
)}
308417

309-
{state && (
310-
<p className="draft-detail__state">
311-
<StateBadge state={state} />
312-
</p>
418+
{stacksState.kind === "error" && (
419+
<div className="draft-detail__enrichment-error" role="alert">
420+
<p className="draft-detail__status">{stacksState.message}</p>
421+
<button
422+
className="draft-detail__retry"
423+
type="button"
424+
onClick={reloadStacks}
425+
>
426+
Retry loading Stacks
427+
</button>
428+
</div>
313429
)}
314430

315431
{stackState.kind === "error" && (
@@ -325,31 +441,12 @@ export function DraftDetailScreen() {
325441
</div>
326442
)}
327443

328-
{stackState.kind === "ready" && (
329-
<p className="draft-detail__stack">
330-
<span className="draft-detail__stack-label">Stack</span>
331-
<Link
332-
className="draft-detail__stack-link"
333-
to={`/stacks/${stackState.stack.id}`}
334-
>
335-
{stackState.stack.title}
336-
</Link>
337-
</p>
338-
)}
339-
340-
{draft.description.trim().length > 0
341-
? <p className="draft-detail__description">{draft.description}</p>
342-
: (
343-
<p className="draft-detail__description draft-detail__description--empty">
344-
No description yet.
345-
</p>
346-
)}
347-
348-
<p>
349-
<Link className="page__action-link" to="/">
350-
Back to Drafts
351-
</Link>
352-
</p>
444+
<DraftEditForm
445+
draft={draft}
446+
states={states}
447+
stacks={stacks}
448+
onUpdated={handleUpdated}
449+
/>
353450
</section>
354451
);
355452
}

0 commit comments

Comments
 (0)