-
Notifications
You must be signed in to change notification settings - Fork 29
feat: implement submission draft system #119
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| /** | ||
| * Submission Draft System - Quick Start Example | ||
| * | ||
| * This example shows how to integrate the submission draft system | ||
| * into any form component. | ||
| */ | ||
|
|
||
| import { useState, useEffect } from "react"; | ||
| import { useSubmissionDraft } from "@/hooks/use-submission-draft"; | ||
|
|
||
| export function SubmissionFormExample({ bountyId }: { bountyId: string }) { | ||
| const { draft, clearDraft, autoSave } = useSubmissionDraft(bountyId); | ||
|
|
||
| const [prUrl, setPrUrl] = useState(draft?.formData.githubPullRequestUrl || ""); | ||
| const [comments, setComments] = useState(draft?.formData.comments || ""); | ||
|
|
||
| // 2. Auto-save when form changes | ||
| useEffect(() => { | ||
| const cleanup = autoSave({ | ||
| githubPullRequestUrl: prUrl, | ||
| comments | ||
| }); | ||
| return cleanup; | ||
| }, [prUrl, comments, autoSave]); | ||
|
|
||
| // 3. Clear draft on successful submit | ||
| const handleSubmit = async () => { | ||
| // Your submit logic here | ||
| await submitToAPI({ prUrl, comments }); | ||
|
|
||
| // Clear draft after success | ||
| clearDraft(); | ||
|
|
||
| // Reset form | ||
| setPrUrl(""); | ||
| setComments(""); | ||
| }; | ||
|
|
||
| return ( | ||
| <form onSubmit={handleSubmit}> | ||
| {/* Show draft indicator */} | ||
|
Comment on lines
+27
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: fd -t f "SUBMISSION_DRAFTS_EXAMPLE.tsx"Repository: boundlessfi/bounties Length of output: 98 🏁 Script executed: cat -n docs/SUBMISSION_DRAFTS_EXAMPLE.tsxRepository: boundlessfi/bounties Length of output: 2523 Add The form handler is missing Required changes-import { useState, useEffect } from "react";
+import { useState, useEffect } from "react";
+import type { FormEvent } from "react";
- const handleSubmit = async () => {
+ const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
+ event.preventDefault();
// Your submit logic here
await submitToAPI({ prUrl, comments });🤖 Prompt for AI Agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @devJaja you skipped this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| {draft && ( | ||
| <div className="text-xs text-blue-400"> | ||
| Draft saved at {new Date(draft.updatedAt).toLocaleString()} | ||
| </div> | ||
| )} | ||
|
|
||
| <input | ||
| value={prUrl} | ||
| onChange={(e) => setPrUrl(e.target.value)} | ||
| placeholder="Pull Request URL" | ||
| /> | ||
|
|
||
| <textarea | ||
| value={comments} | ||
| onChange={(e) => setComments(e.target.value)} | ||
| placeholder="Comments" | ||
| /> | ||
|
|
||
| <button type="submit">Submit</button> | ||
| </form> | ||
| ); | ||
| } | ||
|
|
||
| // Mock submit function | ||
| async function submitToAPI(data: { prUrl: string; comments: string }) { | ||
| console.log("Submitting:", data); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| import { renderHook, act, waitFor } from "@testing-library/react"; | ||
| import { useSubmissionDraft } from "../use-submission-draft"; | ||
|
|
||
| describe("useSubmissionDraft", () => { | ||
| const bountyId = "test-bounty-123"; | ||
|
|
||
| beforeEach(() => { | ||
| localStorage.clear(); | ||
| }); | ||
|
|
||
| it("should initialize with no draft", () => { | ||
| const { result } = renderHook(() => useSubmissionDraft(bountyId)); | ||
| expect(result.current.draft).toBeNull(); | ||
| }); | ||
|
|
||
| it("should save draft", () => { | ||
| const { result } = renderHook(() => useSubmissionDraft(bountyId)); | ||
| const formData = { | ||
| githubPullRequestUrl: "https://github.com/test/pr/1", | ||
| comments: "Test comment", | ||
| }; | ||
|
|
||
| act(() => { | ||
| result.current.saveDraft(formData); | ||
| }); | ||
|
|
||
| expect(result.current.draft).not.toBeNull(); | ||
| expect(result.current.draft?.formData).toEqual(formData); | ||
| expect(result.current.draft?.bountyId).toBe(bountyId); | ||
| }); | ||
|
|
||
| it("should clear draft", () => { | ||
| const { result } = renderHook(() => useSubmissionDraft(bountyId)); | ||
| const formData = { | ||
| githubPullRequestUrl: "https://github.com/test/pr/1", | ||
| comments: "Test comment", | ||
| }; | ||
|
|
||
| act(() => { | ||
| result.current.saveDraft(formData); | ||
| }); | ||
|
|
||
| expect(result.current.draft).not.toBeNull(); | ||
|
|
||
| act(() => { | ||
| result.current.clearDraft(); | ||
| }); | ||
|
|
||
| expect(result.current.draft).toBeNull(); | ||
| }); | ||
|
|
||
| it("should auto-save after delay", async () => { | ||
| const { result } = renderHook(() => useSubmissionDraft(bountyId)); | ||
| const formData = { | ||
| githubPullRequestUrl: "https://github.com/test/pr/1", | ||
| comments: "Auto-saved comment", | ||
| }; | ||
|
|
||
| act(() => { | ||
| result.current.autoSave(formData); | ||
| }); | ||
|
|
||
| await waitFor( | ||
| () => { | ||
| expect(result.current.draft?.formData).toEqual(formData); | ||
| }, | ||
| { timeout: 1500 } | ||
| ); | ||
| }); | ||
|
|
||
| it("should persist draft across hook instances", () => { | ||
| const { result: result1 } = renderHook(() => useSubmissionDraft(bountyId)); | ||
| const formData = { | ||
| githubPullRequestUrl: "https://github.com/test/pr/1", | ||
| comments: "Persisted comment", | ||
| }; | ||
|
|
||
| act(() => { | ||
| result1.current.saveDraft(formData); | ||
| }); | ||
|
|
||
| const { result: result2 } = renderHook(() => useSubmissionDraft(bountyId)); | ||
| expect(result2.current.draft?.formData).toEqual(formData); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { useCallback } from "react"; | ||
| import { useLocalStorage } from "./use-local-storage"; | ||
| import type { SubmissionDraft, SubmissionForm } from "@/types/submission-draft"; | ||
|
|
||
| const DRAFT_KEY_PREFIX = "submission_draft_"; | ||
| const AUTO_SAVE_DELAY = 1000; | ||
|
|
||
| export function useSubmissionDraft(bountyId: string) { | ||
| const draftKey = `${DRAFT_KEY_PREFIX}${bountyId}`; | ||
| const [draft, setDraft] = useLocalStorage<SubmissionDraft | null>(draftKey, null); | ||
|
|
||
| const saveDraft = useCallback( | ||
| (formData: SubmissionForm) => { | ||
| const newDraft: SubmissionDraft = { | ||
| id: `draft_${bountyId}_${Date.now()}`, | ||
| bountyId, | ||
| formData, | ||
| updatedAt: new Date().toISOString(), | ||
| }; | ||
| setDraft(newDraft); | ||
| }, | ||
| [bountyId, setDraft] | ||
| ); | ||
|
|
||
| const clearDraft = useCallback(() => { | ||
| setDraft(null); | ||
| }, [setDraft]); | ||
|
|
||
| const autoSave = useCallback( | ||
| (formData: SubmissionForm) => { | ||
| const timer = setTimeout(() => { | ||
| if (formData.githubPullRequestUrl || formData.comments) { | ||
| saveDraft(formData); | ||
| } | ||
| }, AUTO_SAVE_DELAY); | ||
| return () => clearTimeout(timer); | ||
| }, | ||
| [saveDraft] | ||
|
Comment on lines
+29
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clear persisted draft when form content becomes empty. At Line 32, empty form state is ignored rather than persisted/cleared, so stale draft data can be restored later. 💡 Proposed fix const autoSave = useCallback(
(formData: SubmissionForm) => {
const timer = setTimeout(() => {
- if (formData.githubPullRequestUrl || formData.comments) {
+ const hasContent =
+ formData.githubPullRequestUrl.trim().length > 0 ||
+ formData.comments.trim().length > 0;
+
+ if (hasContent) {
saveDraft(formData);
+ } else {
+ clearDraft();
}
}, AUTO_SAVE_DELAY);
return () => clearTimeout(timer);
},
- [saveDraft]
+ [saveDraft, clearDraft]
);🤖 Prompt for AI Agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @devJaja you skipped this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ); | ||
|
|
||
| return { | ||
| draft, | ||
| saveDraft, | ||
| clearDraft, | ||
| autoSave, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| export interface SubmissionForm { | ||
| githubPullRequestUrl: string; | ||
| comments: string; | ||
| } | ||
|
|
||
| export interface SubmissionDraft { | ||
| id: string; | ||
| bountyId: string; | ||
| formData: SubmissionForm; | ||
| updatedAt: string; | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Scope draft storage by user identity to prevent cross-account draft exposure.
useSubmissionDraft(bounty.id)scopes persisted data only by bounty. On shared browsers, a different signed-in user visiting the same bounty can restore someone else’s draft content. Please key drafts by both user id and bounty id (likely by extending the hook API sobountyIdin the stored payload remains canonical).🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
skip it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@devJaja, understood – I'll skip this suggestion. Feel free to revisit it later if cross-user draft isolation becomes a concern.