Skip to content

Commit 7eea850

Browse files
committed
fix(web): restore accept stamp modal
1 parent a25f97a commit 7eea850

7 files changed

Lines changed: 483 additions & 19 deletions

File tree

internal/api/server_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,43 @@ func TestDetail(t *testing.T) {
410410
}
411411
}
412412

413+
func TestDetail_UpstreamSubmissionWithoutURLs_IsFlaggedExplicitly(t *testing.T) {
414+
db := newFakeDB()
415+
db.items["w-1"] = &fakeItem{id: "w-1", title: "Fix bug", status: "in_review", priority: 1, postedBy: "alice", effortLevel: "medium"}
416+
db.completions["w-1"] = "c-1"
417+
418+
client := sdk.New(sdk.ClientConfig{
419+
DB: db,
420+
RigHandle: "alice",
421+
Mode: "wild-west",
422+
ListPendingItems: func() (map[string][]sdk.PendingItem, error) {
423+
return map[string][]sdk.PendingItem{
424+
"w-1": {{
425+
RigHandle: "charlie",
426+
Status: "in_review",
427+
CompletedBy: "charlie",
428+
Evidence: "https://example.com/proof",
429+
}},
430+
}, nil
431+
},
432+
})
433+
434+
ts := httptest.NewServer(New(client))
435+
defer ts.Close()
436+
437+
var resp DetailResponse
438+
r := getJSON(t, ts, "/api/wanted/w-1", &resp)
439+
if r.StatusCode != http.StatusOK {
440+
t.Fatalf("expected 200, got %d", r.StatusCode)
441+
}
442+
if len(resp.UpstreamPRs) != 1 {
443+
t.Fatalf("expected 1 submission, got %+v", resp.UpstreamPRs)
444+
}
445+
if !resp.UpstreamPRs[0].IsUpstream {
446+
t.Fatalf("upstream submission should be flagged explicitly even without URLs: %+v", resp.UpstreamPRs[0])
447+
}
448+
}
449+
413450
func TestHostedPublic_ReadsPendingOnlyForkItem(t *testing.T) {
414451
mainDB := newFakeDB()
415452
forkDB := newFakeDB()

internal/api/types.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ type StampJSON struct {
8080

8181
// UpstreamPRJSON is the JSON representation of a pending upstream PR.
8282
type UpstreamPRJSON struct {
83+
IsUpstream bool `json:"is_upstream"`
8384
RigHandle string `json:"rig_handle"`
8485
Status string `json:"status"`
8586
ClaimedBy string `json:"claimed_by,omitempty"`
@@ -338,6 +339,7 @@ func toDetailResponse(d *sdk.DetailResult, mode string) *DetailResponse {
338339
// the first entry so the poster sees all submissions in one place.
339340
if d.Item != nil && d.Item.Status == "in_review" && d.Completion != nil {
340341
upstreamPRs = append(upstreamPRs, UpstreamPRJSON{
342+
IsUpstream: false,
341343
RigHandle: d.Completion.CompletedBy,
342344
Status: "in_review",
343345
CompletedBy: d.Completion.CompletedBy,
@@ -351,6 +353,7 @@ func toDetailResponse(d *sdk.DetailResult, mode string) *DetailResponse {
351353
delta = d.Item.Status + " → " + p.Status
352354
}
353355
upstreamPRs = append(upstreamPRs, UpstreamPRJSON{
356+
IsUpstream: true,
354357
RigHandle: p.RigHandle,
355358
Status: p.Status,
356359
ClaimedBy: p.ClaimedBy,

web/src/api/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface Stamp {
6363
}
6464

6565
export interface UpstreamPR {
66+
is_upstream: boolean;
6667
rig_handle: string;
6768
status: string;
6869
claimed_by?: string;
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
.overlay {
2+
position: fixed;
3+
inset: 0;
4+
display: flex;
5+
align-items: center;
6+
justify-content: center;
7+
background: rgba(62, 39, 35, 0.6);
8+
z-index: 100;
9+
}
10+
11+
.dialog {
12+
background: var(--surface);
13+
border: 2px solid var(--border);
14+
border-radius: var(--radius-md);
15+
padding: var(--space-6);
16+
max-width: 460px;
17+
width: 90%;
18+
box-shadow: var(--shadow-lg);
19+
}
20+
21+
.title {
22+
margin: 0 0 var(--space-2);
23+
color: var(--fg);
24+
font-size: var(--text-xl);
25+
}
26+
27+
.message {
28+
margin: 0 0 var(--space-4);
29+
color: var(--dim);
30+
font-size: var(--text-base);
31+
line-height: 1.5;
32+
}
33+
34+
.field {
35+
display: block;
36+
margin-bottom: var(--space-3);
37+
}
38+
39+
.label {
40+
display: block;
41+
margin-bottom: var(--space-1);
42+
color: var(--dim);
43+
font-size: var(--text-sm);
44+
font-family: var(--font-heading);
45+
letter-spacing: 0.05em;
46+
text-transform: uppercase;
47+
}
48+
49+
.select,
50+
.textarea {
51+
width: 100%;
52+
box-sizing: border-box;
53+
padding: var(--space-2) 10px;
54+
background: var(--bg);
55+
border: 1px solid var(--border);
56+
border-radius: var(--radius-sm);
57+
color: var(--fg);
58+
font-size: var(--text-base);
59+
font-family: var(--font-body);
60+
}
61+
62+
.textarea {
63+
resize: vertical;
64+
}
65+
66+
.actions {
67+
display: flex;
68+
gap: var(--space-2);
69+
justify-content: flex-end;
70+
margin-top: var(--space-4);
71+
}
72+
73+
.cancelBtn {
74+
padding: 6px 18px;
75+
border-radius: var(--radius-sm);
76+
border: 1px solid var(--border);
77+
background: transparent;
78+
color: var(--dim);
79+
cursor: pointer;
80+
font-family: var(--font-heading);
81+
font-size: var(--text-sm);
82+
font-weight: 600;
83+
letter-spacing: 0.05em;
84+
text-transform: uppercase;
85+
}
86+
87+
.confirmBtn {
88+
padding: 6px 18px;
89+
border-radius: var(--radius-sm);
90+
border: 1px solid var(--green);
91+
background: transparent;
92+
color: var(--green);
93+
cursor: pointer;
94+
font-family: var(--font-heading);
95+
font-size: var(--text-sm);
96+
font-weight: 600;
97+
letter-spacing: 0.05em;
98+
text-transform: uppercase;
99+
}
100+
101+
.cancelBtn:disabled,
102+
.confirmBtn:disabled {
103+
cursor: not-allowed;
104+
opacity: 0.6;
105+
}
106+
107+
@media (max-width: 768px) {
108+
.overlay {
109+
align-items: flex-end;
110+
}
111+
112+
.dialog {
113+
position: fixed;
114+
bottom: 0;
115+
left: 0;
116+
right: 0;
117+
max-width: 100%;
118+
width: 100%;
119+
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
120+
max-height: 90vh;
121+
overflow: auto;
122+
}
123+
124+
.actions {
125+
flex-direction: column;
126+
}
127+
128+
.cancelBtn,
129+
.confirmBtn,
130+
.select,
131+
.textarea {
132+
min-height: 44px;
133+
}
134+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { useEffect, useState } from "react";
2+
import { useFocusTrap } from "../hooks/useFocusTrap";
3+
import styles from "./AcceptDialog.module.css";
4+
5+
export interface AcceptStampInput {
6+
quality: number;
7+
reliability?: number;
8+
severity?: string;
9+
message?: string;
10+
}
11+
12+
interface AcceptDialogProps {
13+
label: string;
14+
submitting: boolean;
15+
onCancel: () => void;
16+
onSubmit: (stamp: AcceptStampInput) => Promise<void>;
17+
}
18+
19+
export function AcceptDialog({ label, submitting, onCancel, onSubmit }: AcceptDialogProps) {
20+
const trapRef = useFocusTrap(true);
21+
const titleId = "accept-submission-title";
22+
const [quality, setQuality] = useState("5");
23+
const [reliability, setReliability] = useState("");
24+
const [severity, setSeverity] = useState("leaf");
25+
const [message, setMessage] = useState("");
26+
27+
useEffect(() => {
28+
const handler = (e: KeyboardEvent) => {
29+
if (e.key === "Escape" && !submitting) onCancel();
30+
};
31+
window.addEventListener("keydown", handler);
32+
return () => window.removeEventListener("keydown", handler);
33+
}, [onCancel, submitting]);
34+
35+
const submit = async () => {
36+
await onSubmit({
37+
quality: Number(quality),
38+
reliability: reliability ? Number(reliability) : undefined,
39+
severity,
40+
message: message.trim() || undefined,
41+
});
42+
};
43+
44+
return (
45+
<div className={styles.overlay} onClick={() => !submitting && onCancel()}>
46+
<div
47+
ref={trapRef}
48+
className={styles.dialog}
49+
role="dialog"
50+
aria-modal="true"
51+
aria-labelledby={titleId}
52+
onClick={(e) => e.stopPropagation()}
53+
>
54+
<h3 id={titleId} className={styles.title}>
55+
Accept Submission
56+
</h3>
57+
<p className={styles.message}>Add a stamp for {label} before marking it complete.</p>
58+
59+
<label className={styles.field}>
60+
<span className={styles.label}>Quality</span>
61+
<select
62+
className={styles.select}
63+
value={quality}
64+
onChange={(e) => setQuality(e.target.value)}
65+
disabled={submitting}
66+
>
67+
{[1, 2, 3, 4, 5].map((value) => (
68+
<option key={value} value={value}>
69+
{value}
70+
</option>
71+
))}
72+
</select>
73+
</label>
74+
75+
<label className={styles.field}>
76+
<span className={styles.label}>Reliability</span>
77+
<select
78+
className={styles.select}
79+
value={reliability}
80+
onChange={(e) => setReliability(e.target.value)}
81+
disabled={submitting}
82+
>
83+
<option value="">Match quality</option>
84+
{[1, 2, 3, 4, 5].map((value) => (
85+
<option key={value} value={value}>
86+
{value}
87+
</option>
88+
))}
89+
</select>
90+
</label>
91+
92+
<label className={styles.field}>
93+
<span className={styles.label}>Severity</span>
94+
<select
95+
className={styles.select}
96+
value={severity}
97+
onChange={(e) => setSeverity(e.target.value)}
98+
disabled={submitting}
99+
>
100+
<option value="leaf">Leaf</option>
101+
<option value="branch">Branch</option>
102+
<option value="root">Root</option>
103+
</select>
104+
</label>
105+
106+
<label className={styles.field}>
107+
<span className={styles.label}>Message</span>
108+
<textarea
109+
className={styles.textarea}
110+
value={message}
111+
onChange={(e) => setMessage(e.target.value)}
112+
placeholder="Optional feedback for the completion"
113+
disabled={submitting}
114+
rows={3}
115+
/>
116+
</label>
117+
118+
<div className={styles.actions}>
119+
<button type="button" className={styles.cancelBtn} onClick={onCancel} disabled={submitting}>
120+
Cancel
121+
</button>
122+
<button type="button" className={styles.confirmBtn} onClick={submit} disabled={submitting}>
123+
{submitting ? "Accepting..." : "Accept"}
124+
</button>
125+
</div>
126+
</div>
127+
</div>
128+
);
129+
}

0 commit comments

Comments
 (0)