This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web/components: add new issue form

author
oppiliappan
committer
dawn
date (Jul 30, 2026, 7:45 PM +0300) commit 5100b98f parent f834786e change-id yzmtnrsu
+441
+60
web/src/lib/components/repo/issues/IssueForm.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import { expect, userEvent, waitFor, within } from "storybook/test"; 4 + import IssueForm from "./IssueForm.svelte"; 5 + import MockAuthProvider from "$lib/components/testing/MockAuthProvider.svelte"; 6 + 7 + // no-ops; submitting is inert here (needs an authed agent context) 8 + const noop = () => undefined; 9 + 10 + const { Story } = defineMeta({ 11 + title: "Repo/Issues/IssueForm", 12 + component: IssueForm, 13 + tags: ["autodocs"], 14 + argTypes: { 15 + mode: { 16 + control: { type: "inline-radio" }, 17 + options: ["create", "edit"] 18 + } 19 + }, 20 + args: { 21 + repoDid: "did:plc:wshs7t2adsemcrrd4snkeqli", 22 + markup: { repo: "tangled.org/core", ref: "main", host: "tangled.org" }, 23 + mode: "create", 24 + onsaved: noop, 25 + oncancel: noop 26 + } 27 + }); 28 + </script> 29 + 30 + <Story name="Create" /> 31 + <Story 32 + name="Edit" 33 + args={{ 34 + mode: "edit", 35 + title: "Add a full trending repositories view", 36 + body: "I'd like a `/trending` view that shows more than the five repositories in the sidebar." 37 + }} 38 + /> 39 + 40 + <!-- fills the form and submits with ctrl+enter against a fake agent that rejects, showing the error alert --> 41 + <Story 42 + name="Failed submission" 43 + play={async ({ canvasElement }) => { 44 + const canvas = within(canvasElement); 45 + await userEvent.type(canvas.getByLabelText("Title"), "Something broke"); 46 + await userEvent.type( 47 + canvas.getByPlaceholderText(/describe your issue/i), 48 + "details{Control>}{Enter}{/Control}" 49 + ); 50 + await waitFor(() => 51 + expect(canvas.getByRole("alert")).toHaveTextContent(/network request failed/i) 52 + ); 53 + }} 54 + > 55 + {#snippet template(args)} 56 + <MockAuthProvider> 57 + <IssueForm {...args} /> 58 + </MockAuthProvider> 59 + {/snippet} 60 + </Story>
+125
web/src/lib/components/repo/issues/IssueForm.svelte
··· 1 + <script lang="ts"> 2 + import { untrack } from "svelte"; 3 + import { now as tidNow } from "@atcute/tid"; 4 + import CirclePlus from "$icon/circle-plus"; 5 + import Pencil from "$icon/pencil"; 6 + import X from "$icon/x"; 7 + import { putIssue } from "$lib/api/issue"; 8 + import { getAuth } from "$lib/auth.svelte"; 9 + import Button from "$lib/components/ui/Button.svelte"; 10 + import ErrorAlert from "$lib/components/ui/Error.svelte"; 11 + import Input from "$lib/components/ui/Input.svelte"; 12 + import MarkdownEditor from "$lib/components/ui/MarkdownEditor.svelte"; 13 + import Spinner from "$lib/components/ui/Spinner.svelte"; 14 + import { type MarkupContext } from "$lib/markup"; 15 + import type { IssueRecord, RecordView } from "$lib/api/records"; 16 + 17 + interface Props { 18 + repoDid: string; 19 + markup: MarkupContext; 20 + mode?: "create" | "edit"; 21 + rkey?: string; 22 + createdAt?: string; 23 + title?: string; 24 + body?: string; 25 + submitLabel?: string; 26 + bodyPlaceholder?: string; 27 + onsaved: (saved: RecordView<IssueRecord>) => void; 28 + oncancel: () => void; 29 + } 30 + 31 + let { 32 + repoDid, 33 + markup, 34 + mode = "create", 35 + rkey, 36 + createdAt, 37 + title: initialTitle = "", 38 + body: initialBody = "", 39 + submitLabel, 40 + bodyPlaceholder = "Describe your issue. Markdown is supported.", 41 + onsaved, 42 + oncancel 43 + }: Props = $props(); 44 + 45 + const auth = getAuth(); 46 + 47 + let title = $state(untrack(() => initialTitle)); 48 + let body = $state(untrack(() => initialBody)); 49 + let isPublishing = $state(false); 50 + let error = $state<string | null>(null); 51 + 52 + const submitText = $derived(submitLabel ?? (mode === "edit" ? "Save" : "Create issue")); 53 + const submitIcon = $derived(mode === "edit" ? Pencil : CirclePlus); 54 + const canSubmit = $derived(title.trim() !== "" && !isPublishing); 55 + 56 + const handleSubmit = async (e: SubmitEvent) => { 57 + e.preventDefault(); 58 + const agent = auth.agent; 59 + if (!agent || !canSubmit) return; 60 + isPublishing = true; 61 + error = null; 62 + try { 63 + const targetRkey = rkey ?? tidNow(); 64 + const record: IssueRecord = { 65 + $type: "sh.tangled.repo.issue", 66 + repo: repoDid as IssueRecord["repo"], 67 + title, 68 + body, 69 + createdAt: createdAt ?? new Date().toISOString() 70 + }; 71 + const saved = await putIssue(agent, targetRkey, record); 72 + onsaved(saved); 73 + } catch (err) { 74 + error = err instanceof Error ? err.message : "Failed to save issue"; 75 + } finally { 76 + isPublishing = false; 77 + } 78 + }; 79 + </script> 80 + 81 + <form onsubmit={handleSubmit} class="flex flex-col gap-4"> 82 + <div class="flex flex-col gap-1.5"> 83 + <label for="issue-title" class="text-sm text-foreground-default">Title</label> 84 + <Input id="issue-title" name="title" required bind:value={title} disabled={isPublishing} /> 85 + </div> 86 + 87 + <div class="flex flex-col gap-1.5"> 88 + <span class="text-sm text-foreground-default">Body</span> 89 + 90 + <MarkdownEditor 91 + id="issue-body" 92 + name="body" 93 + rows={12} 94 + placeholder={bodyPlaceholder} 95 + {markup} 96 + bind:value={body} 97 + disabled={isPublishing} 98 + /> 99 + </div> 100 + 101 + {#if error} 102 + <ErrorAlert label={error} /> 103 + {/if} 104 + 105 + <div class="flex items-center justify-end gap-2"> 106 + <Button 107 + type="button" 108 + variant="ghost" 109 + icon={X} 110 + disabled={isPublishing} 111 + onclick={oncancel} 112 + class="text-foreground-danger hover:text-foreground-danger" 113 + > 114 + Cancel 115 + </Button> 116 + <Button 117 + type="submit" 118 + variant="primary" 119 + icon={isPublishing ? Spinner : submitIcon} 120 + disabled={!canSubmit} 121 + > 122 + {submitText} 123 + </Button> 124 + </div> 125 + </form>
+11
web/src/lib/components/repo/types.ts
··· 38 38 percentage: number; 39 39 share: number; 40 40 } 41 + 42 + export interface IssueSummary { 43 + uri: string; 44 + rkey: string; 45 + title: string; 46 + state: "open" | "closed"; 47 + authorHandle: string; 48 + authorDid: string; 49 + createdAt: string; 50 + commentCount: number; 51 + }
+30
web/src/lib/components/testing/MockAuthProvider.svelte
··· 1 + <script lang="ts"> 2 + // story-only helper: injects a fake auth context so form stories can exercise 3 + // a real (failing) submission without a live session. the fake agent's fetch 4 + // handler always rejects, so putIssue/putComment land in the form's catch block. 5 + import { setContext, type Snippet } from "svelte"; 6 + import { AUTH_KEY, type Auth } from "$lib/auth.svelte"; 7 + 8 + interface Props { 9 + // message the fake agent rejects submissions with 10 + failWith?: string; 11 + children: Snippet; 12 + } 13 + 14 + let { failWith = "MockAuthProvider: network request failed, this error is intentional.", children }: Props = $props(); 15 + 16 + const agent = { 17 + sub: "did:plc:alice", 18 + handle: () => Promise.reject(new Error(failWith)) 19 + }; 20 + 21 + const auth = { 22 + agent, 23 + currentDid: "did:plc:alice", 24 + currentUser: { did: "did:plc:alice", handle: "alice.pds.tngl.boltless.dev" } 25 + } as unknown as Auth; 26 + 27 + setContext(AUTH_KEY, auth); 28 + </script> 29 + 30 + {@render children()}
+23
web/src/lib/components/ui/MarkdownEditor.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import MarkdownEditor from "./MarkdownEditor.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "UI/MarkdownEditor", 7 + component: MarkdownEditor, 8 + tags: ["autodocs"], 9 + args: { 10 + markup: { repo: "tangled.org/core", ref: "main", host: "tangled.org" }, 11 + placeholder: "Write some **markdown**. Switch to Preview to render it.", 12 + rows: 8 13 + } 14 + }); 15 + </script> 16 + 17 + <Story name="Empty" /> 18 + <Story 19 + name="With content" 20 + args={{ 21 + value: "## Hello\n\nThis is a `MarkdownEditor` with some **content**.\n\n- one\n- two" 22 + }} 23 + />
+132
web/src/lib/components/ui/MarkdownEditor.svelte
··· 1 + <script lang="ts"> 2 + import type { KeyboardEventHandler } from "svelte/elements"; 3 + import Eye from "$icon/eye"; 4 + import Pencil from "$icon/pencil"; 5 + import Button from "./Button.svelte"; 6 + import ButtonGroup from "./ButtonGroup.svelte"; 7 + import Textarea from "./Textarea.svelte"; 8 + import { renderMarkup, type MarkupContext } from "$lib/markup"; 9 + 10 + interface Props { 11 + value?: string; 12 + markup: MarkupContext; 13 + tab?: "write" | "preview"; 14 + id?: string; 15 + name?: string; 16 + rows?: number; 17 + placeholder?: string; 18 + disabled?: boolean; 19 + previewClass?: string; 20 + autofocus?: boolean; 21 + // drop the textarea/preview background so the editor blends into its surroundings 22 + transparent?: boolean; 23 + } 24 + 25 + let { 26 + value = $bindable(""), 27 + markup, 28 + tab = $bindable("write"), 29 + id, 30 + name, 31 + rows = 12, 32 + placeholder, 33 + disabled = false, 34 + previewClass = "min-h-40", 35 + autofocus = false, 36 + transparent = false 37 + }: Props = $props(); 38 + 39 + const surface = $derived(transparent ? "bg-transparent" : "bg-background-default"); 40 + 41 + let previewHtml = $state<string | null>(null); 42 + let previewing = $state(false); 43 + let textareaEl = $state<HTMLTextAreaElement>(); 44 + 45 + // focus on mount (and when returning to the write tab) if requested 46 + $effect(() => { 47 + if (autofocus) textareaEl?.focus(); 48 + }); 49 + 50 + $effect(() => { 51 + if (tab !== "preview") return; 52 + const source = value; 53 + if (!source.trim()) { 54 + previewHtml = null; 55 + previewing = false; 56 + return; 57 + } 58 + let cancelled = false; 59 + previewing = true; 60 + renderMarkup(source, markup) 61 + .then((html) => { 62 + if (!cancelled) previewHtml = html; 63 + }) 64 + .catch(() => { 65 + if (!cancelled) previewHtml = null; 66 + }) 67 + .finally(() => { 68 + if (!cancelled) previewing = false; 69 + }); 70 + return () => { 71 + cancelled = true; 72 + }; 73 + }); 74 + // ctrl/cmd+enter submits the enclosing form, mirroring the old htmx editor 75 + const handleKeydown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => { 76 + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { 77 + e.preventDefault(); 78 + e.currentTarget.form?.requestSubmit(); 79 + } 80 + }; 81 + </script> 82 + 83 + <div class="flex flex-col gap-1.5"> 84 + <ButtonGroup class="self-start"> 85 + <Button 86 + type="button" 87 + size="sm" 88 + icon={Pencil} 89 + variant={tab === "write" ? "default" : "ghost"} 90 + onclick={() => (tab = "write")} 91 + > 92 + Write 93 + </Button> 94 + <Button 95 + type="button" 96 + size="sm" 97 + icon={Eye} 98 + variant={tab === "preview" ? "default" : "ghost"} 99 + onclick={() => (tab = "preview")} 100 + > 101 + Preview 102 + </Button> 103 + </ButtonGroup> 104 + 105 + {#if tab === "write"} 106 + <Textarea 107 + {id} 108 + {name} 109 + {rows} 110 + resizeable 111 + {placeholder} 112 + bind:value 113 + bind:element={textareaEl} 114 + {disabled} 115 + onkeydown={handleKeydown} 116 + class={transparent ? "max-h-none bg-transparent" : "max-h-none"} 117 + /> 118 + {:else if previewHtml} 119 + <div 120 + class={`markup rounded border border-border-default ${surface} px-2.5 py-2 ${previewClass}`} 121 + > 122 + <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in $lib/markup --> 123 + {@html previewHtml} 124 + </div> 125 + {:else} 126 + <div 127 + class={`rounded border border-border-default ${surface} px-2.5 py-2 text-sm text-foreground-subtle italic ${previewClass}`} 128 + > 129 + {previewing ? "Rendering…" : "Nothing to preview."} 130 + </div> 131 + {/if} 132 + </div>
+3
web/src/lib/components/ui/Textarea.svelte
··· 59 59 iconLeft?: Component<SvelteHTMLElements["svg"]>; 60 60 iconRight?: Component<SvelteHTMLElements["svg"]>; 61 61 class?: string; 62 + element?: HTMLTextAreaElement; 62 63 } 63 64 64 65 let { ··· 71 72 iconLeft, 72 73 iconRight, 73 74 class: className, 75 + element = $bindable(), 74 76 ...rest 75 77 }: Props = $props(); 76 78 ··· 83 85 <IconLeft class="mt-0.5 mr-1 size-4 shrink-0 text-foreground-subtle" aria-hidden="true" /> 84 86 {/if} 85 87 <textarea 88 + bind:this={element} 86 89 bind:value 87 90 {disabled} 88 91 {readonly}
+11
web/src/lib/markup/render.ts
··· 11 11 if (contents.length > SOURCE_LIMIT) return null; 12 12 return renderMarkdown(contents, ctx); 13 13 }; 14 + 15 + // like renderDocument, but for content that is always markdown (issue and 16 + // comment bodies) rather than a repo file with an extension to sniff 17 + export const renderMarkup = async ( 18 + contents: string, 19 + ctx: MarkupContext 20 + ): Promise<string | null> => { 21 + const { SOURCE_LIMIT, renderMarkdown } = await import("./markdown"); 22 + if (contents.length > SOURCE_LIMIT) return null; 23 + return renderMarkdown(contents, ctx); 24 + };
+6
web/src/routes/[handle]/[repo]/issues/new/+page.server.ts
··· 1 + import { requireAuth } from "$lib/auth/guards"; 2 + import type { PageServerLoad } from "./$types"; 3 + 4 + export const load: PageServerLoad = (event) => { 5 + requireAuth(event); 6 + };
+40
web/src/routes/[handle]/[repo]/issues/new/+page.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from "$app/navigation"; 3 + import { page } from "$app/state"; 4 + import { resolve } from "$app/paths"; 5 + import IssueForm from "$lib/components/repo/issues/IssueForm.svelte"; 6 + 7 + let { data } = $props(); 8 + 9 + const base = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/issues`); 10 + 11 + const markup = $derived({ 12 + repo: `${data.repo.ownerHandle}/${data.repo.name}`, 13 + ref: data.repo.defaultBranch, 14 + host: page.url.host, 15 + camo: data.publicConfig?.camoEnabled 16 + }); 17 + 18 + // no issue detail route yet, so land back on the list after creating 19 + const handleSaved = async () => { 20 + await goto(resolve(base as "/")); 21 + }; 22 + 23 + const handleCancel = () => { 24 + void goto(resolve(base as "/")); 25 + }; 26 + </script> 27 + 28 + <svelte:head> 29 + <title>New issue · {data.repo.ownerHandle}/{data.repo.name} · Tangled</title> 30 + </svelte:head> 31 + 32 + <section class="mt-2 rounded bg-background-default px-6 py-6 text-foreground-default shadow-sm"> 33 + <h1 class="mb-4 text-xl font-bold text-foreground-default">Create a new issue</h1> 34 + <IssueForm 35 + {markup} 36 + repoDid={data.repo.repoDid ?? ""} 37 + onsaved={handleSaved} 38 + oncancel={handleCancel} 39 + /> 40 + </section>