Public: anyone can read this; the crew can change it.
A form error on the issue, pull and edit pages is §5's field error, not .error-note #4807
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4708-a-form-error-on-the-issue-pull-and-edit → main 3 commits fork 2 threads · 2 unresolved
8 files
changed +149
−22
docs/design/areas/edit.md added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | # Edit | |
| 2 | ||
| 3 | The editor's errors follow §5's form field: an error about one input sits in that input's `.field` as `.error`, with | |
| 4 | `data-invalid`, and the input carries `aria-invalid` and `aria-describedby`. A new file's path input and the fork's | |
| 5 | branch input live outside `field()` (the path sits in the editor's head row, with no visible label), so their `.error` | |
| 6 | row is written by hand to the same shape. An error that belongs to no input (not allowed, branch moved, origin | |
| 7 | unreachable) is the page's one flash, `tone=danger`, passed to `page()` as `flash` because the form re-renders in place | |
| 8 | instead of redirecting. |
src/web/layout.ts +8 −2
| @@ -51,6 +51,11 @@ export interface PaletteItem { | ||
| 51 | 51 | export interface PageOptions { |
| 52 | 52 | /** The request: user, theme and flash all come from here. */ |
| 53 | 53 | ctx: Ctx; |
| 54 | /** | |
| 55 | * A flash for this response itself, for a form that re-renders instead of | |
| 56 | * redirecting. It stands in for the cookie's, so a page never shows two. | |
| 57 | */ | |
| 58 | flash?: Flash; | |
| 54 | 59 | title: string; |
| 55 | 60 | /** |
| 56 | 61 | * One sentence for the places that show a page before it is opened: a |
| @@ -511,7 +516,8 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 511 | 516 | const placeholder = secret(); |
| 512 | 517 | const { ctx } = options; |
| 513 | 518 | const theme = ctx.theme ?? options.theme; |
| 514 | const flash = readFlash(ctx.request); | |
| 519 | const cookieFlash = readFlash(ctx.request); | |
| 520 | const flash = options.flash ?? cookieFlash; | |
| 515 | 521 | const links = pageLinks(ctx); |
| 516 | 522 | |
| 517 | 523 | // The body's markup, built before the document so the icons it used can be |
| @@ -669,7 +675,7 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 669 | 675 | link: linkHeader(links), |
| 670 | 676 | }); |
| 671 | 677 | // A flash is shown exactly once: rendering it is also what clears it. |
| 672 | if (flash) headers.append("set-cookie", clearFlashCookie(ctx.url)); | |
| 678 | if (cookieFlash) headers.append("set-cookie", clearFlashCookie(ctx.url)); | |
| 673 | 679 | if (tagged) { |
| 674 | 680 | const etag = weakEtag(text); |
| 675 | 681 | headers.set("etag", etag); |
src/web/pages/edit.ts +68 −16
| @@ -64,6 +64,11 @@ export interface Draft { | ||
| 64 | 64 | */ |
| 65 | 65 | propose?: ProposeTarget; |
| 66 | 66 | error?: string; |
| 67 | /** | |
| 68 | * The field an error is about. Without it the error is the form's own (not | |
| 69 | * allowed, branch moved) and is the page's flash. | |
| 70 | */ | |
| 71 | errorOn?: "path" | "message" | "branch"; | |
| 67 | 72 | } |
| 68 | 73 | |
| 69 | 74 | /** |
| @@ -161,6 +166,10 @@ function editorForm( | ||
| 161 | 166 | const encoded = encodeURIComponent(draft.ref); |
| 162 | 167 | const suggestion = |
| 163 | 168 | draft.branch !== "" ? draft.branch : suggestBranch(username, draft.path); |
| 169 | const pathError = | |
| 170 | draft.error !== undefined && draft.errorOn === "path" && draft.creating | |
| 171 | ? draft.error | |
| 172 | : undefined; | |
| 164 | 173 | |
| 165 | 174 | return html`<form |
| 166 | 175 | method="post" |
| @@ -185,6 +194,9 @@ function editorForm( | ||
| 185 | 194 | autocomplete="off" |
| 186 | 195 | spellcheck="false" |
| 187 | 196 | required |
| 197 | ${pathError | |
| 198 | ? raw('aria-invalid="true" aria-describedby="f-path-err"') | |
| 199 | : null} | |
| 188 | 200 | />`, |
| 189 | 201 | )} |
| 190 | 202 | ${when(!draft.creating, () => |
| @@ -232,6 +244,13 @@ function editorForm( | ||
| 232 | 244 | )} |
| 233 | 245 | </div> |
| 234 | 246 | |
| 247 | ${when( | |
| 248 | pathError, | |
| 249 | (message) => | |
| 250 | html`<div class="field" data-invalid> | |
| 251 | <span class="error" id="f-path-err">${message}</span> | |
| 252 | </div>`, | |
| 253 | )} | |
| 235 | 254 | <div |
| 236 | 255 | class="editor-wrap" |
| 237 | 256 | id="editor-write" |
| @@ -277,6 +296,9 @@ ${draft.content}</textarea | ||
| 277 | 296 | ? `Create ${draft.path || "a file"}` |
| 278 | 297 | : `Update ${draft.path}`, |
| 279 | 298 | required: true, |
| 299 | ...(draft.error && draft.errorOn === "message" | |
| 300 | ? { error: draft.error } | |
| 301 | : {}), | |
| 280 | 302 | })} |
| 281 | 303 | ${field({ |
| 282 | 304 | label: "Extended description", |
| @@ -410,6 +432,10 @@ function proposeBlock( | ||
| 410 | 432 | propose: ProposeTarget, |
| 411 | 433 | suggestion: string, |
| 412 | 434 | ): Html { |
| 435 | const branchError = | |
| 436 | draft.error !== undefined && draft.errorOn === "branch" | |
| 437 | ? draft.error | |
| 438 | : undefined; | |
| 413 | 439 | return html`<fieldset class="editor-target propose"> |
| 414 | 440 | <legend>${icon("fork")}Propose a change</legend> |
| 415 | 441 | <p class="propose-line"> |
| @@ -437,7 +463,15 @@ function proposeBlock( | ||
| 437 | 463 | autocomplete="off" |
| 438 | 464 | spellcheck="false" |
| 439 | 465 | required |
| 466 | ${branchError | |
| 467 | ? raw('aria-invalid="true" aria-describedby="f-branch-err"') | |
| 468 | : null} | |
| 440 | 469 | /> |
| 470 | ${when( | |
| 471 | branchError, | |
| 472 | (message) => | |
| 473 | html`<span class="error" id="f-branch-err">${message}</span>`, | |
| 474 | )} | |
| 441 | 475 | <span class="hint" |
| 442 | 476 | >The pull's head, as |
| 443 | 477 | <span class="mono">${propose.fork.split("/")[0]}:${suggestion}</span |
| @@ -467,11 +501,22 @@ export async function editorPage( | ||
| 467 | 501 | ): Promise<Response> { |
| 468 | 502 | const username = ctx.auth.user?.username ?? "edit"; |
| 469 | 503 | const shape = mirrorFor(draft.path, draft.content); |
| 504 | // An error with no field of its own is the form's: a flash, as everywhere. | |
| 505 | const formLevel = | |
| 506 | draft.error !== undefined && | |
| 507 | (draft.errorOn === undefined || | |
| 508 | (draft.errorOn === "path" && !draft.creating) || | |
| 509 | (draft.errorOn === "branch" && draft.propose === undefined)) | |
| 510 | ? draft.error | |
| 511 | : undefined; | |
| 470 | 512 | // Only an editor that will actually use the tokenizer pays for it — and |
| 471 | 513 | // only that page relaxes the script CSP to admit a file from this origin. |
| 472 | 514 | const asset = shape.mirror ? await highlightAsset() : null; |
| 473 | 515 | return page({ |
| 474 | 516 | ctx, |
| 517 | ...(formLevel !== undefined | |
| 518 | ? { flash: { tone: "danger" as const, message: formLevel } } | |
| 519 | : {}), | |
| 475 | 520 | title: `${draft.creating ? "New file" : draft.path} · ${scope.facts.name}`, |
| 476 | 521 | crumbs: scope.crumbs, |
| 477 | 522 | palette: scope.palette, |
| @@ -491,13 +536,6 @@ export async function editorPage( | ||
| 491 | 536 | : "Editing a file"}`, |
| 492 | 537 | { sub: html`in <span class="mono">${scope.fullName}</span>` }, |
| 493 | 538 | )} |
| 494 | ${when( | |
| 495 | draft.error, | |
| 496 | (message) => | |
| 497 | html`<p class="error-note" role="alert"> | |
| 498 | ${icon("alert")}<span>${message}</span> | |
| 499 | </p>`, | |
| 500 | )} | |
| 501 | 539 | ${editorForm(scope, draft, username, { |
| 502 | 540 | lang: shape.lang, |
| 503 | 541 | mirror: shape.mirror && asset !== null, |
| @@ -715,15 +753,22 @@ export async function runContribute( | ||
| 715 | 753 | form: Form, |
| 716 | 754 | ): Promise<Response> { |
| 717 | 755 | const draft = proposeDraft(form, propose); |
| 718 | const refuse = (message: string): Promise<Response> => | |
| 719 | editorPage(ctx, scope, { ...draft, error: message }); | |
| 756 | const refuse = ( | |
| 757 | message: string, | |
| 758 | errorOn?: Draft["errorOn"], | |
| 759 | ): Promise<Response> => | |
| 760 | editorPage(ctx, scope, { | |
| 761 | ...draft, | |
| 762 | error: message, | |
| 763 | ...(errorOn ? { errorOn } : {}), | |
| 764 | }); | |
| 720 | 765 | |
| 721 | 766 | if (!isSafeRef(draft.ref)) return notFoundPage(ctx); |
| 722 | 767 | if (draft.path === "" || !isSafeTreePath(draft.path)) |
| 723 | return refuse("Give the file a path this repository can hold."); | |
| 724 | if (draft.subject === "") return refuse("A commit needs a message."); | |
| 768 | return refuse("Give the file a path this repository can hold.", "path"); | |
| 769 | if (draft.subject === "") return refuse("A commit needs a message.", "message"); | |
| 725 | 770 | if (draft.branch === "") |
| 726 | return refuse("Give the branch on your fork a name."); | |
| 771 | return refuse("Give the branch on your fork a name.", "branch"); | |
| 727 | 772 | |
| 728 | 773 | const message = |
| 729 | 774 | draft.body === "" ? draft.subject : `${draft.subject}\n\n${draft.body}`; |
| @@ -830,13 +875,20 @@ export async function editSubmit(ctx: Ctx): Promise<Response> { | ||
| 830 | 875 | creating, |
| 831 | 876 | mustBranch, |
| 832 | 877 | }; |
| 833 | const refuse = (message: string): Promise<Response> => | |
| 834 | editorPage(ctx, scope, { ...draft, error: message }); | |
| 878 | const refuse = ( | |
| 879 | message: string, | |
| 880 | errorOn?: Draft["errorOn"], | |
| 881 | ): Promise<Response> => | |
| 882 | editorPage(ctx, scope, { | |
| 883 | ...draft, | |
| 884 | error: message, | |
| 885 | ...(errorOn ? { errorOn } : {}), | |
| 886 | }); | |
| 835 | 887 | |
| 836 | 888 | if (!isSafeRef(ref)) return notFoundPage(ctx); |
| 837 | 889 | if (path === "" || !isSafeTreePath(path)) |
| 838 | return refuse("Give the file a path this repository can hold."); | |
| 839 | if (subject === "") return refuse("A commit needs a message."); | |
| 890 | return refuse("Give the file a path this repository can hold.", "path"); | |
| 891 | if (subject === "") return refuse("A commit needs a message.", "message"); | |
| 840 | 892 | |
| 841 | 893 | const message = description === "" ? subject : `${subject}\n\n${description}`; |
| 842 | 894 | const written = await editFiles(ctx.app, ctx.auth.user, repo, { |
src/web/pages/issues.ts +1 −2
| @@ -26,7 +26,7 @@ import { | ||
| 26 | 26 | signInHref, |
| 27 | 27 | } from "../chrome.ts"; |
| 28 | 28 | import { field, readForm } from "../form.ts"; |
| 29 | import { html, raw, when, type Html } from "../html.ts"; | |
| 29 | import { html, raw, type Html } from "../html.ts"; | |
| 30 | 30 | import { flashTo, page } from "../layout.ts"; |
| 31 | 31 | import { btn, empty, field as formField } from "../ui.ts"; |
| 32 | 32 | import { |
| @@ -88,7 +88,6 @@ function newIssueForm( | ||
| 88 | 88 | ): Html { |
| 89 | 89 | return html`<div class="narrow-wide mt"> |
| 90 | 90 | <form method="post" action="${repoBase(repo)}/issues/new"> |
| 91 | ${when(error, (message) => html`<p class="error-note">${message}</p>`)} | |
| 92 | 91 | ${formField({ |
| 93 | 92 | label: "Title", |
| 94 | 93 | name: "title", |
src/web/pages/pulls.ts +1 −1
| @@ -67,7 +67,6 @@ function composeForm( | ||
| 67 | 67 | const cross = parent !== undefined; |
| 68 | 68 | return html`<form method="post" action="${repoBase(repo)}/pulls/new"> |
| 69 | 69 | <input type="hidden" name="baseRepo" value="${cross ? "parent" : "self"}" /> |
| 70 | ${when(error, (message) => html`<p class="error-note">${message}</p>`)} | |
| 71 | 70 | ${card( |
| 72 | 71 | html`<div class="field-row"> |
| 73 | 72 | <div class="field"> |
| @@ -102,6 +101,7 @@ function composeForm( | ||
| 102 | 101 | value: values.title, |
| 103 | 102 | required: true, |
| 104 | 103 | autofocus: true, |
| 104 | ...(error ? { error } : {}), | |
| 105 | 105 | })} |
| 106 | 106 | ${formField({ |
| 107 | 107 | label: "Description", |
test/issues.test.ts +13
| @@ -167,6 +167,19 @@ describe("issue pages end to end", () => { | ||
| 167 | 167 | expect(detail).not.toContain('data-state="closed"'); |
| 168 | 168 | }); |
| 169 | 169 | |
| 170 | test("an empty issue title is refused in the title field", async () => { | |
| 171 | const res = await fetch(`${base}/mel/proj/issues/new`, { | |
| 172 | method: "POST", | |
| 173 | headers: { cookie }, | |
| 174 | body: form({ title: "", body: "" }), | |
| 175 | redirect: "manual", | |
| 176 | }); | |
| 177 | expect(res.status).toBe(422); | |
| 178 | const page = await res.text(); | |
| 179 | expect(page).not.toContain("error-note"); | |
| 180 | expect(page).toMatch(/<div class="field" data-invalid>[\s\S]*?id="f-title"[^>]*aria-invalid="true"[\s\S]*?class="error"/); | |
| 181 | }); | |
| 182 | ||
| 170 | 183 | test("cross-origin issue post is refused (CSRF)", async () => { |
| 171 | 184 | const res = await fetch(`${base}/mel/proj/issues/new`, { |
| 172 | 185 | method: "POST", |
test/pulls.test.ts +13
| @@ -127,6 +127,19 @@ describe("pull requests end to end", () => { | ||
| 127 | 127 | expect(pr).toContain("Ready to merge"); |
| 128 | 128 | }); |
| 129 | 129 | |
| 130 | test("an empty pull title is refused in the title field", async () => { | |
| 131 | const res = await post("/dev/app/pulls/new", { | |
| 132 | base: "main", | |
| 133 | head: "main", | |
| 134 | title: "", | |
| 135 | body: "", | |
| 136 | }); | |
| 137 | expect(res.status).toBe(422); | |
| 138 | const page = await res.text(); | |
| 139 | expect(page).not.toContain("error-note"); | |
| 140 | expect(page).toMatch(/<div class="field" data-invalid>[\s\S]*?id="f-title"[^>]*aria-invalid="true"[\s\S]*?class="error"/); | |
| 141 | }); | |
| 142 | ||
| 130 | 143 | test("merge the PR, base advances, PR shows merged", async () => { |
| 131 | 144 | const merged = await post("/dev/app/pulls/1/merge", { strategy: "merge" }); |
| 132 | 145 | expect(merged.status).toBe(303); |
test/web-workflows.test.ts +37 −1
| @@ -432,7 +432,43 @@ describe("fork at the origin, and propose a change through it", () => { | ||
| 432 | 432 | expect(html).toContain("second go"); |
| 433 | 433 | expect(html).toContain("the body I typed"); |
| 434 | 434 | expect(html).toContain('value="alice/second-go"'); |
| 435 | expect(html).toContain("error-note"); | |
| 435 | // A refusal that is the whole form's sits in a danger toast, and no | |
| 436 | // field claims to be invalid. | |
| 437 | expect(html).toMatch(/class="toast" data-tone="danger"/); | |
| 438 | expect(html.match(/class="toast"/g)).toHaveLength(1); | |
| 439 | expect(html).not.toContain("data-invalid"); | |
| 440 | expect(html).not.toContain("aria-invalid"); | |
| 441 | expect(html).not.toContain("error-note"); | |
| 442 | }); | |
| 443 | ||
| 444 | test("a missing commit message or file path is the error of that field", async () => { | |
| 445 | const form = { | |
| 446 | ref: "main", | |
| 447 | path: "a.txt", | |
| 448 | content: "x\n", | |
| 449 | message: "", | |
| 450 | branch: "alice/x", | |
| 451 | }; | |
| 452 | const noMessage = await ( | |
| 453 | await web(alice, `${browse}/contribute`, { form }) | |
| 454 | ).text(); | |
| 455 | expect(noMessage).toMatch( | |
| 456 | /<div class="field" data-invalid>[\s\S]*?id="f-message"[^>]*aria-invalid="true"[\s\S]*?class="error"[^>]*>\s*A commit needs a message/, | |
| 457 | ); | |
| 458 | expect(noMessage).not.toContain("data-tone=\"danger\""); | |
| 459 | ||
| 460 | const noPath = await ( | |
| 461 | await web(alice, `${browse}/contribute`, { | |
| 462 | form: { ...form, path: "", message: "m", creating: "1" }, | |
| 463 | }) | |
| 464 | ).text(); | |
| 465 | expect(noPath).toMatch( | |
| 466 | /name="path"[^>]*aria-invalid="true"[^>]*aria-describedby="f-path-err"/, | |
| 467 | ); | |
| 468 | expect(noPath).toMatch( | |
| 469 | /<span class="error" id="f-path-err">Give the file a path/, | |
| 470 | ); | |
| 471 | expect(noPath).not.toContain("error-note"); | |
| 436 | 472 | }); |
| 437 | 473 | |
| 438 | 474 | test("contribute refuses a cross-origin form, and anonymous is sent to sign in", async () => { |
new above
offline