fix(web): count issue form limits like the server and refresh the challenge after back navigation
- commit
- bc786300b89e85722d296fbef5ff74982fd274aa
- author
- brice · · 2026-10-19 07:55 UTC
- parent
- e687d9c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +167 -16
M internal/adapters/web/issues_form.templ +3 -3
| @@ -29,19 +29,19 @@ templ IssueForm(l Layout, p IssueFormPage) { | |||
| 29 | 29 | <input type="hidden" name="form_token" value={ p.FormToken }/> | |
| 30 | 30 | <input type="hidden" name="solution" value=""/> | |
| 31 | 31 | <label for="issue-title">title</label> | |
| 32 | - | <input id="issue-title" name="title" type="text" maxlength="120" required autocomplete="off" value={ p.Title }/> | |
| 32 | + | <input id="issue-title" name="title" type="text" required autocomplete="off" value={ p.Title }/> | |
| 33 | 33 | <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output> | |
| 34 | 34 | if p.ErrorField == "title" { | |
| 35 | 35 | <p class="f-error" role="alert">{ p.Error }</p> | |
| 36 | 36 | } | |
| 37 | 37 | <label for="issue-body">description</label> | |
| 38 | - | <textarea id="issue-body" name="body" rows="8" maxlength="10000" placeholder="Markdown, optional">{ p.Body }</textarea> | |
| 38 | + | <textarea id="issue-body" name="body" rows="8" placeholder="Markdown, optional">{ p.Body }</textarea> | |
| 39 | 39 | <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output> | |
| 40 | 40 | if p.ErrorField == "body" { | |
| 41 | 41 | <p class="f-error" role="alert">{ p.Error }</p> | |
| 42 | 42 | } | |
| 43 | 43 | <label for="issue-author">name (optional, not verified)</label> | |
| 44 | - | <input id="issue-author" name="author" type="text" maxlength="40" autocomplete="off" value={ p.Author }/> | |
| 44 | + | <input id="issue-author" name="author" type="text" autocomplete="off" value={ p.Author }/> | |
| 45 | 45 | <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output> | |
| 46 | 46 | if p.ErrorField == "author" { | |
| 47 | 47 | <p class="f-error" role="alert">{ p.Error }</p> | |
| @@ -29,19 +29,19 @@ templ IssueForm(l Layout, p IssueFormPage) { | |||||
| 29 | <input type="hidden" name="form_token" value={ p.FormToken }/> | 29 | <input type="hidden" name="form_token" value={ p.FormToken }/> | ||
| 30 | <input type="hidden" name="solution" value=""/> | 30 | <input type="hidden" name="solution" value=""/> | ||
| 31 | <label for="issue-title">title</label> | 31 | <label for="issue-title">title</label> | ||
| 32 | - | <input id="issue-title" name="title" type="text" maxlength="120" required autocomplete="off" value={ p.Title }/> | 32 | + | <input id="issue-title" name="title" type="text" required autocomplete="off" value={ p.Title }/> |
| 33 | <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output> | 33 | <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output> | ||
| 34 | if p.ErrorField == "title" { | 34 | if p.ErrorField == "title" { | ||
| 35 | <p class="f-error" role="alert">{ p.Error }</p> | 35 | <p class="f-error" role="alert">{ p.Error }</p> | ||
| 36 | } | 36 | } | ||
| 37 | <label for="issue-body">description</label> | 37 | <label for="issue-body">description</label> | ||
| 38 | - | <textarea id="issue-body" name="body" rows="8" maxlength="10000" placeholder="Markdown, optional">{ p.Body }</textarea> | 38 | + | <textarea id="issue-body" name="body" rows="8" placeholder="Markdown, optional">{ p.Body }</textarea> |
| 39 | <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output> | 39 | <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output> | ||
| 40 | if p.ErrorField == "body" { | 40 | if p.ErrorField == "body" { | ||
| 41 | <p class="f-error" role="alert">{ p.Error }</p> | 41 | <p class="f-error" role="alert">{ p.Error }</p> | ||
| 42 | } | 42 | } | ||
| 43 | <label for="issue-author">name (optional, not verified)</label> | 43 | <label for="issue-author">name (optional, not verified)</label> | ||
| 44 | - | <input id="issue-author" name="author" type="text" maxlength="40" autocomplete="off" value={ p.Author }/> | 44 | + | <input id="issue-author" name="author" type="text" autocomplete="off" value={ p.Author }/> |
| 45 | <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output> | 45 | <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output> | ||
| 46 | if p.ErrorField == "author" { | 46 | if p.ErrorField == "author" { | ||
| 47 | <p class="f-error" role="alert">{ p.Error }</p> | 47 | <p class="f-error" role="alert">{ p.Error }</p> | ||
M internal/adapters/web/issues_form_templ.go +6 -6 generated file: collapsed
M internal/adapters/web/issues_form_test.go +2 -1
| @@ -79,13 +79,14 @@ func TestIssueFormGet(t *testing.T) { | |||
| 79 | 79 | `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`, | |
| 80 | 80 | `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`, | |
| 81 | 81 | `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`, | |
| 82 | - | `name="title" type="text" maxlength="120" required`, `maxlength="10000"`, `name="author" type="text" maxlength="40"`, | |
| 82 | + | `name="title" type="text" required`, `<output data-for="issue-body" data-max="10000"`, `<output data-for="issue-author" data-max="40"`, | |
| 83 | 83 | `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`, | |
| 84 | 84 | `<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`, | |
| 85 | 85 | `/-/static/js/issue-form.`, | |
| 86 | 86 | } { | |
| 87 | 87 | assert.Contains(t, body, want) | |
| 88 | 88 | } | |
| 89 | + | assert.NotContains(t, body, `maxlength`, "the limits count code points, not UTF-16 units") | |
| 89 | 90 | assert.NotContains(t, body, `name="website" type="hidden"`) | |
| 90 | 91 | assert.NotContains(t, body, `type="hidden" name="website"`) | |
| 91 | 92 | noscript := body[strings.Index(body, "<noscript>"):strings.Index(body, "</noscript>")] | |
| @@ -79,13 +79,14 @@ func TestIssueFormGet(t *testing.T) { | |||||
| 79 | `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`, | 79 | `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`, | ||
| 80 | `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`, | 80 | `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`, | ||
| 81 | `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`, | 81 | `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`, | ||
| 82 | - | `name="title" type="text" maxlength="120" required`, `maxlength="10000"`, `name="author" type="text" maxlength="40"`, | 82 | + | `name="title" type="text" required`, `<output data-for="issue-body" data-max="10000"`, `<output data-for="issue-author" data-max="40"`, |
| 83 | `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`, | 83 | `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`, | ||
| 84 | `<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`, | 84 | `<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`, | ||
| 85 | `/-/static/js/issue-form.`, | 85 | `/-/static/js/issue-form.`, | ||
| 86 | } { | 86 | } { | ||
| 87 | assert.Contains(t, body, want) | 87 | assert.Contains(t, body, want) | ||
| 88 | } | 88 | } | ||
| 89 | + | assert.NotContains(t, body, `maxlength`, "the limits count code points, not UTF-16 units") | |||
| 89 | assert.NotContains(t, body, `name="website" type="hidden"`) | 90 | assert.NotContains(t, body, `name="website" type="hidden"`) | ||
| 90 | assert.NotContains(t, body, `type="hidden" name="website"`) | 91 | assert.NotContains(t, body, `type="hidden" name="website"`) | ||
| 91 | noscript := body[strings.Index(body, "<noscript>"):strings.Index(body, "</noscript>")] | 92 | noscript := body[strings.Index(body, "<noscript>"):strings.Index(body, "</noscript>")] | ||
M internal/adapters/web/jstest/issue-form.test.mjs +87 -0
| @@ -94,3 +94,90 @@ test('state: the button opens only when solved, waited and not over', () => { | |||
| 94 | 94 | assert.equal(s({ solved: true, now: 6000, unsupported: true }).disabled, true); | |
| 95 | 95 | assert.equal(s({ failed: true, solved: true, now: 6000 }).disabled, true); | |
| 96 | 96 | }); | |
| 97 | + | ||
| 98 | + | test('serverLength counts NFC code points like the server', () => { | |
| 99 | + | assert.equal(C.serverLength('title', 'e\u0301'.repeat(61)), 61); | |
| 100 | + | assert.equal(C.serverLength('title', '\u0344'.repeat(61)), 122); | |
| 101 | + | assert.equal(C.counterClass(122, 120), 'over'); | |
| 102 | + | assert.equal(C.serverLength('title', '😀'.repeat(120)), 120); | |
| 103 | + | assert.equal(C.counterClass(120, 120), 'warn'); | |
| 104 | + | assert.equal(C.serverLength('title', '😀'.repeat(121)), 121); | |
| 105 | + | assert.equal(C.counterClass(121, 120), 'over'); | |
| 106 | + | }); | |
| 107 | + | ||
| 108 | + | test('serverLength drops what the server strips and trims', () => { | |
| 109 | + | assert.equal(C.serverLength('title', ' ab\u202e\u200f\u061c\u2066\u0007\u0085 '), 2); | |
| 110 | + | assert.equal(C.serverLength('title', ' \u202e '), 0); | |
| 111 | + | assert.equal(C.serverLength('author', ' me '), 2); | |
| 112 | + | assert.equal(C.serverLength('body', 'a\nb\tc\u0007\u202a\r\n'), 6); | |
| 113 | + | assert.equal(C.serverLength('body', ' a '), 3); | |
| 114 | + | assert.equal(C.serverLength('title', 'a\tb'), 2); | |
| 115 | + | }); | |
| 116 | + | ||
| 117 | + | test('state keeps the button disabled when a title is empty or a field is over', () => { | |
| 118 | + | const base = { solved: true, now: 2, minAt: 1, sending: false, unsupported: false, failed: false, overs: false, empty: false }; | |
| 119 | + | assert.equal(C.state(base).disabled, false); | |
| 120 | + | assert.equal(C.state({ ...base, overs: true }).disabled, true); | |
| 121 | + | assert.equal(C.state({ ...base, empty: true }).disabled, true); | |
| 122 | + | }); | |
| 123 | + | ||
| 124 | + | function fakeStorage() { | |
| 125 | + | const m = new Map(); | |
| 126 | + | return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), m }; | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | test('drafts are saved, restored once, and scoped to the path', () => { | |
| 130 | + | const st = fakeStorage(); | |
| 131 | + | const k = C.draftKey('/r/issues/new'); | |
| 132 | + | assert.notEqual(k, C.draftKey('/other/issues/new')); | |
| 133 | + | C.saveDraft(st, k, { title: 't', body: 'b\nc', author: 'a', solution: 'x' }); | |
| 134 | + | assert.deepEqual(C.restoreDraft(st, k), { title: 't', body: 'b\nc', author: 'a' }); | |
| 135 | + | assert.equal(st.m.size, 0); | |
| 136 | + | assert.equal(C.restoreDraft(st, k), null); | |
| 137 | + | st.setItem(k, 'not json'); | |
| 138 | + | assert.equal(C.restoreDraft(st, k), null); | |
| 139 | + | }); | |
| 140 | + | ||
| 141 | + | test('onPageShow reloads only for a bfcache restore', () => { | |
| 142 | + | assert.equal(C.onPageShow(true), 'reload'); | |
| 143 | + | assert.equal(C.onPageShow(false), 'none'); | |
| 144 | + | assert.equal(C.onPageShow(undefined), 'none'); | |
| 145 | + | }); | |
| 146 | + | ||
| 147 | + | test('init restores a draft and reloads on a bfcache pageshow', () => { | |
| 148 | + | const listeners = {}; | |
| 149 | + | const mk = (o) => ({ listeners: {}, addEventListener(t, f) { this.listeners[t] = f; }, ...o }); | |
| 150 | + | const title = mk({ id: 'issue-title', name: 'title', value: '' }); | |
| 151 | + | const body = mk({ id: 'issue-body', name: 'body', value: '' }); | |
| 152 | + | const author = mk({ id: 'issue-author', name: 'author', value: '' }); | |
| 153 | + | const outs = [['issue-title', 120], ['issue-body', 10000], ['issue-author', 40]].map(([f, max]) => ({ dataset: { for: f, max: String(max) }, textContent: '', className: '' })); | |
| 154 | + | const button = { disabled: false }; | |
| 155 | + | const solution = { value: '' }; | |
| 156 | + | const sel = { 'button[type="submit"]': button, '[data-status]': { textContent: '' }, 'input[name="solution"]': solution, | |
| 157 | + | '[name="title"]': title, '[name="body"]': body, '[name="author"]': author, | |
| 158 | + | '#issue-title': title, '#issue-body': body, '#issue-author': author }; | |
| 159 | + | const form = { dataset: { minSubmitAt: '0' }, hidden: true, querySelector: (s) => sel[s] ?? null, | |
| 160 | + | querySelectorAll: () => outs, addEventListener() {}, removeAttribute() {} }; | |
| 161 | + | const st = fakeStorage(); | |
| 162 | + | let reloads = 0; | |
| 163 | + | const saved = { sessionStorage: window.sessionStorage, location: window.location, addEventListener: window.addEventListener, Worker: window.Worker }; | |
| 164 | + | Object.assign(window, { sessionStorage: st, location: { pathname: '/r/issues/new', reload() { reloads++; } }, | |
| 165 | + | addEventListener(t, f) { listeners[t] = f; }, Worker: undefined }); | |
| 166 | + | try { | |
| 167 | + | C.saveDraft(st, C.draftKey('/r/issues/new'), { title: 'Hello', body: 'B', author: 'me' }); | |
| 168 | + | window.ermiteIssueForm.init(form); | |
| 169 | + | assert.equal(title.value, 'Hello'); | |
| 170 | + | assert.equal(outs[0].textContent, '5 / 120'); | |
| 171 | + | assert.equal(st.m.size, 0); | |
| 172 | + | assert.equal(button.disabled, true); | |
| 173 | + | listeners.pageshow({ persisted: false }); | |
| 174 | + | assert.equal(reloads, 0); | |
| 175 | + | title.value = 'Edited'; | |
| 176 | + | listeners.pageshow({ persisted: true }); | |
| 177 | + | assert.equal(reloads, 1); | |
| 178 | + | assert.deepEqual(JSON.parse(st.getItem(C.draftKey('/r/issues/new'))), { title: 'Edited', body: 'B', author: 'me' }); | |
| 179 | + | assert.equal(button.disabled, true); | |
| 180 | + | } finally { | |
| 181 | + | for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete window[k]; else window[k] = v; } | |
| 182 | + | } | |
| 183 | + | }); | |
| @@ -94,3 +94,90 @@ test('state: the button opens only when solved, waited and not over', () => { | |||||
| 94 | assert.equal(s({ solved: true, now: 6000, unsupported: true }).disabled, true); | 94 | assert.equal(s({ solved: true, now: 6000, unsupported: true }).disabled, true); | ||
| 95 | assert.equal(s({ failed: true, solved: true, now: 6000 }).disabled, true); | 95 | assert.equal(s({ failed: true, solved: true, now: 6000 }).disabled, true); | ||
| 96 | }); | 96 | }); | ||
| 97 | + | ||||
| 98 | + | test('serverLength counts NFC code points like the server', () => { | |||
| 99 | + | assert.equal(C.serverLength('title', 'e\u0301'.repeat(61)), 61); | |||
| 100 | + | assert.equal(C.serverLength('title', '\u0344'.repeat(61)), 122); | |||
| 101 | + | assert.equal(C.counterClass(122, 120), 'over'); | |||
| 102 | + | assert.equal(C.serverLength('title', '😀'.repeat(120)), 120); | |||
| 103 | + | assert.equal(C.counterClass(120, 120), 'warn'); | |||
| 104 | + | assert.equal(C.serverLength('title', '😀'.repeat(121)), 121); | |||
| 105 | + | assert.equal(C.counterClass(121, 120), 'over'); | |||
| 106 | + | }); | |||
| 107 | + | ||||
| 108 | + | test('serverLength drops what the server strips and trims', () => { | |||
| 109 | + | assert.equal(C.serverLength('title', ' ab\u202e\u200f\u061c\u2066\u0007\u0085 '), 2); | |||
| 110 | + | assert.equal(C.serverLength('title', ' \u202e '), 0); | |||
| 111 | + | assert.equal(C.serverLength('author', ' me '), 2); | |||
| 112 | + | assert.equal(C.serverLength('body', 'a\nb\tc\u0007\u202a\r\n'), 6); | |||
| 113 | + | assert.equal(C.serverLength('body', ' a '), 3); | |||
| 114 | + | assert.equal(C.serverLength('title', 'a\tb'), 2); | |||
| 115 | + | }); | |||
| 116 | + | ||||
| 117 | + | test('state keeps the button disabled when a title is empty or a field is over', () => { | |||
| 118 | + | const base = { solved: true, now: 2, minAt: 1, sending: false, unsupported: false, failed: false, overs: false, empty: false }; | |||
| 119 | + | assert.equal(C.state(base).disabled, false); | |||
| 120 | + | assert.equal(C.state({ ...base, overs: true }).disabled, true); | |||
| 121 | + | assert.equal(C.state({ ...base, empty: true }).disabled, true); | |||
| 122 | + | }); | |||
| 123 | + | ||||
| 124 | + | function fakeStorage() { | |||
| 125 | + | const m = new Map(); | |||
| 126 | + | return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), m }; | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | test('drafts are saved, restored once, and scoped to the path', () => { | |||
| 130 | + | const st = fakeStorage(); | |||
| 131 | + | const k = C.draftKey('/r/issues/new'); | |||
| 132 | + | assert.notEqual(k, C.draftKey('/other/issues/new')); | |||
| 133 | + | C.saveDraft(st, k, { title: 't', body: 'b\nc', author: 'a', solution: 'x' }); | |||
| 134 | + | assert.deepEqual(C.restoreDraft(st, k), { title: 't', body: 'b\nc', author: 'a' }); | |||
| 135 | + | assert.equal(st.m.size, 0); | |||
| 136 | + | assert.equal(C.restoreDraft(st, k), null); | |||
| 137 | + | st.setItem(k, 'not json'); | |||
| 138 | + | assert.equal(C.restoreDraft(st, k), null); | |||
| 139 | + | }); | |||
| 140 | + | ||||
| 141 | + | test('onPageShow reloads only for a bfcache restore', () => { | |||
| 142 | + | assert.equal(C.onPageShow(true), 'reload'); | |||
| 143 | + | assert.equal(C.onPageShow(false), 'none'); | |||
| 144 | + | assert.equal(C.onPageShow(undefined), 'none'); | |||
| 145 | + | }); | |||
| 146 | + | ||||
| 147 | + | test('init restores a draft and reloads on a bfcache pageshow', () => { | |||
| 148 | + | const listeners = {}; | |||
| 149 | + | const mk = (o) => ({ listeners: {}, addEventListener(t, f) { this.listeners[t] = f; }, ...o }); | |||
| 150 | + | const title = mk({ id: 'issue-title', name: 'title', value: '' }); | |||
| 151 | + | const body = mk({ id: 'issue-body', name: 'body', value: '' }); | |||
| 152 | + | const author = mk({ id: 'issue-author', name: 'author', value: '' }); | |||
| 153 | + | const outs = [['issue-title', 120], ['issue-body', 10000], ['issue-author', 40]].map(([f, max]) => ({ dataset: { for: f, max: String(max) }, textContent: '', className: '' })); | |||
| 154 | + | const button = { disabled: false }; | |||
| 155 | + | const solution = { value: '' }; | |||
| 156 | + | const sel = { 'button[type="submit"]': button, '[data-status]': { textContent: '' }, 'input[name="solution"]': solution, | |||
| 157 | + | '[name="title"]': title, '[name="body"]': body, '[name="author"]': author, | |||
| 158 | + | '#issue-title': title, '#issue-body': body, '#issue-author': author }; | |||
| 159 | + | const form = { dataset: { minSubmitAt: '0' }, hidden: true, querySelector: (s) => sel[s] ?? null, | |||
| 160 | + | querySelectorAll: () => outs, addEventListener() {}, removeAttribute() {} }; | |||
| 161 | + | const st = fakeStorage(); | |||
| 162 | + | let reloads = 0; | |||
| 163 | + | const saved = { sessionStorage: window.sessionStorage, location: window.location, addEventListener: window.addEventListener, Worker: window.Worker }; | |||
| 164 | + | Object.assign(window, { sessionStorage: st, location: { pathname: '/r/issues/new', reload() { reloads++; } }, | |||
| 165 | + | addEventListener(t, f) { listeners[t] = f; }, Worker: undefined }); | |||
| 166 | + | try { | |||
| 167 | + | C.saveDraft(st, C.draftKey('/r/issues/new'), { title: 'Hello', body: 'B', author: 'me' }); | |||
| 168 | + | window.ermiteIssueForm.init(form); | |||
| 169 | + | assert.equal(title.value, 'Hello'); | |||
| 170 | + | assert.equal(outs[0].textContent, '5 / 120'); | |||
| 171 | + | assert.equal(st.m.size, 0); | |||
| 172 | + | assert.equal(button.disabled, true); | |||
| 173 | + | listeners.pageshow({ persisted: false }); | |||
| 174 | + | assert.equal(reloads, 0); | |||
| 175 | + | title.value = 'Edited'; | |||
| 176 | + | listeners.pageshow({ persisted: true }); | |||
| 177 | + | assert.equal(reloads, 1); | |||
| 178 | + | assert.deepEqual(JSON.parse(st.getItem(C.draftKey('/r/issues/new'))), { title: 'Edited', body: 'B', author: 'me' }); | |||
| 179 | + | assert.equal(button.disabled, true); | |||
| 180 | + | } finally { | |||
| 181 | + | for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete window[k]; else window[k] = v; } | |||
| 182 | + | } | |||
| 183 | + | }); | |||
M internal/adapters/web/static/js/issue-form.js +69 -6
| @@ -17,15 +17,54 @@ window.ermiteIssueForm = (() => { | |||
| 17 | 17 | }, | |
| 18 | 18 | // length counts code points, like the server. | |
| 19 | 19 | length(s) { return [...String(s)].length; }, | |
| 20 | + | // serverLength is the length the server will see for field ('title', | |
| 21 | + | // 'body' or 'author'): NFC, control and bidi characters removed (body | |
| 22 | + | // keeps \n and \t), title and author trimmed (domain.cleanText). | |
| 23 | + | serverLength(field, value) { | |
| 24 | + | const multiline = field === 'body'; | |
| 25 | + | let s = String(value).normalize('NFC'); | |
| 26 | + | if (multiline) s = s.replace(/\r\n/g, '\n'); | |
| 27 | + | let out = ''; | |
| 28 | + | for (const ch of s) { | |
| 29 | + | const c = ch.codePointAt(0); | |
| 30 | + | if (multiline && (c === 0x0a || c === 0x09)) { out += ch; continue; } | |
| 31 | + | if (c < 0x20 || (c >= 0x7f && c <= 0x9f) || c === 0x2028 || c === 0x2029) continue; | |
| 32 | + | if (c === 0x200e || c === 0x200f || c === 0x061c || | |
| 33 | + | (c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069)) continue; | |
| 34 | + | out += ch; | |
| 35 | + | } | |
| 36 | + | if (!multiline) out = out.trim(); | |
| 37 | + | return [...out].length; | |
| 38 | + | }, | |
| 39 | + | draftKey(path) { return 'ermite:issue-draft:' + path; }, | |
| 40 | + | // saveDraft keeps the text fields in storage (never the solution). | |
| 41 | + | saveDraft(storage, key, draft) { | |
| 42 | + | try { storage.setItem(key, JSON.stringify({ title: draft.title, body: draft.body, author: draft.author })); } catch (_) { /* storage unavailable */ } | |
| 43 | + | }, | |
| 44 | + | // restoreDraft returns the saved draft, or null, and clears it. | |
| 45 | + | restoreDraft(storage, key) { | |
| 46 | + | try { | |
| 47 | + | const raw = storage.getItem(key); | |
| 48 | + | if (raw === null || raw === undefined) return null; | |
| 49 | + | storage.removeItem(key); | |
| 50 | + | const d = JSON.parse(raw); | |
| 51 | + | if (!d || typeof d !== 'object') return null; | |
| 52 | + | return { title: String(d.title || ''), body: String(d.body || ''), author: String(d.author || '') }; | |
| 53 | + | } catch (_) { return null; } | |
| 54 | + | }, | |
| 55 | + | // onPageShow decides what to do on pageshow: a page restored from the | |
| 56 | + | // back/forward cache has a challenge that may be spent or expired. | |
| 57 | + | onPageShow(persisted) { return persisted === true ? 'reload' : 'none'; }, | |
| 20 | 58 | // state is what the button and the status line show. | |
| 21 | - | // {solved, now, minAt, sending, unsupported, failed, overs} | |
| 22 | - | state({ solved, now, minAt, sending, unsupported, failed, overs }) { | |
| 59 | + | // {solved, now, minAt, sending, unsupported, failed, overs, empty} | |
| 60 | + | state({ solved, now, minAt, sending, unsupported, failed, overs, empty }) { | |
| 23 | 61 | if (unsupported) return { disabled: true, status: 'Your browser cannot run the check.' }; | |
| 24 | 62 | if (failed) return { disabled: true, status: 'The check failed. Reload the page.' }; | |
| 25 | 63 | if (sending) return { disabled: true, status: 'Sending…' }; | |
| 26 | 64 | if (!solved) return { disabled: true, status: 'Checking your browser… (this takes a few seconds)' }; | |
| 27 | 65 | if (now < minAt) return { disabled: true, status: 'Almost ready…' }; | |
| 28 | 66 | if (overs) return { disabled: true, status: 'A field is too long.' }; | |
| 67 | + | if (empty) return { disabled: true, status: 'Enter a title.' }; | |
| 29 | 68 | return { disabled: false, status: 'Ready.' }; | |
| 30 | 69 | }, | |
| 31 | 70 | }; | |
| @@ -36,7 +75,7 @@ window.ermiteIssueForm = (() => { | |||
| 36 | 75 | const status = $('[data-status]'); | |
| 37 | 76 | const solution = $('input[name="solution"]'); | |
| 38 | 77 | const minAt = Number(form.dataset.minSubmitAt) || 0; | |
| 39 | - | const ctx = { solved: false, sending: false, unsupported: false, failed: false, overs: false }; | |
| 78 | + | const ctx = { solved: false, sending: false, unsupported: false, failed: false, overs: false, empty: true }; | |
| 40 | 79 | let timer = null; | |
| 41 | 80 | ||
| 42 | 81 | function render() { | |
| @@ -47,14 +86,38 @@ window.ermiteIssueForm = (() => { | |||
| 47 | 86 | if (ctx.solved && Date.now() < minAt && !ctx.sending) timer = setTimeout(render, minAt - Date.now() + 20); | |
| 48 | 87 | } | |
| 49 | 88 | ||
| 50 | - | for (const out of form.querySelectorAll('output[data-for]')) { | |
| 89 | + | const storage = (() => { try { return window.sessionStorage; } catch (_) { return null; } })(); | |
| 90 | + | const draftKey = core.draftKey(window.location ? window.location.pathname : ''); | |
| 91 | + | const fields = { title: $('[name="title"]'), body: $('[name="body"]'), author: $('[name="author"]') }; | |
| 92 | + | if (storage) { | |
| 93 | + | const d = core.restoreDraft(storage, draftKey); | |
| 94 | + | if (d) for (const k of Object.keys(fields)) if (fields[k]) fields[k].value = d[k]; | |
| 95 | + | } | |
| 96 | + | window.addEventListener('pageshow', (e) => { | |
| 97 | + | if (core.onPageShow(e.persisted) !== 'reload') return; | |
| 98 | + | // The old challenge may be spent or expired: never reuse its solution. | |
| 99 | + | if (button) button.disabled = true; | |
| 100 | + | if (storage) { | |
| 101 | + | core.saveDraft(storage, draftKey, { | |
| 102 | + | title: fields.title ? fields.title.value : '', | |
| 103 | + | body: fields.body ? fields.body.value : '', | |
| 104 | + | author: fields.author ? fields.author.value : '', | |
| 105 | + | }); | |
| 106 | + | } | |
| 107 | + | window.location.reload(); | |
| 108 | + | }); | |
| 109 | + | ||
| 110 | + | const outs = [...form.querySelectorAll('output[data-for]')]; | |
| 111 | + | for (const out of outs) { | |
| 51 | 112 | const field = form.querySelector('#' + out.dataset.for); | |
| 113 | + | const name = field.name; | |
| 52 | 114 | const max = Number(out.dataset.max); | |
| 53 | 115 | const update = () => { | |
| 54 | - | const n = core.length(field.value); | |
| 116 | + | const n = core.serverLength(name, field.value); | |
| 55 | 117 | out.textContent = n + ' / ' + max; | |
| 56 | 118 | out.className = core.counterClass(n, max); | |
| 57 | - | ctx.overs = [...form.querySelectorAll('output[data-for]')].some((o) => o.className === 'over'); | |
| 119 | + | ctx.overs = outs.some((o) => o.className === 'over'); | |
| 120 | + | if (name === 'title') ctx.empty = n === 0; | |
| 58 | 121 | render(); | |
| 59 | 122 | }; | |
| 60 | 123 | field.addEventListener('input', update); | |
| @@ -17,15 +17,54 @@ window.ermiteIssueForm = (() => { | |||||
| 17 | }, | 17 | }, | ||
| 18 | // length counts code points, like the server. | 18 | // length counts code points, like the server. | ||
| 19 | length(s) { return [...String(s)].length; }, | 19 | length(s) { return [...String(s)].length; }, | ||
| 20 | + | // serverLength is the length the server will see for field ('title', | |||
| 21 | + | // 'body' or 'author'): NFC, control and bidi characters removed (body | |||
| 22 | + | // keeps \n and \t), title and author trimmed (domain.cleanText). | |||
| 23 | + | serverLength(field, value) { | |||
| 24 | + | const multiline = field === 'body'; | |||
| 25 | + | let s = String(value).normalize('NFC'); | |||
| 26 | + | if (multiline) s = s.replace(/\r\n/g, '\n'); | |||
| 27 | + | let out = ''; | |||
| 28 | + | for (const ch of s) { | |||
| 29 | + | const c = ch.codePointAt(0); | |||
| 30 | + | if (multiline && (c === 0x0a || c === 0x09)) { out += ch; continue; } | |||
| 31 | + | if (c < 0x20 || (c >= 0x7f && c <= 0x9f) || c === 0x2028 || c === 0x2029) continue; | |||
| 32 | + | if (c === 0x200e || c === 0x200f || c === 0x061c || | |||
| 33 | + | (c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069)) continue; | |||
| 34 | + | out += ch; | |||
| 35 | + | } | |||
| 36 | + | if (!multiline) out = out.trim(); | |||
| 37 | + | return [...out].length; | |||
| 38 | + | }, | |||
| 39 | + | draftKey(path) { return 'ermite:issue-draft:' + path; }, | |||
| 40 | + | // saveDraft keeps the text fields in storage (never the solution). | |||
| 41 | + | saveDraft(storage, key, draft) { | |||
| 42 | + | try { storage.setItem(key, JSON.stringify({ title: draft.title, body: draft.body, author: draft.author })); } catch (_) { /* storage unavailable */ } | |||
| 43 | + | }, | |||
| 44 | + | // restoreDraft returns the saved draft, or null, and clears it. | |||
| 45 | + | restoreDraft(storage, key) { | |||
| 46 | + | try { | |||
| 47 | + | const raw = storage.getItem(key); | |||
| 48 | + | if (raw === null || raw === undefined) return null; | |||
| 49 | + | storage.removeItem(key); | |||
| 50 | + | const d = JSON.parse(raw); | |||
| 51 | + | if (!d || typeof d !== 'object') return null; | |||
| 52 | + | return { title: String(d.title || ''), body: String(d.body || ''), author: String(d.author || '') }; | |||
| 53 | + | } catch (_) { return null; } | |||
| 54 | + | }, | |||
| 55 | + | // onPageShow decides what to do on pageshow: a page restored from the | |||
| 56 | + | // back/forward cache has a challenge that may be spent or expired. | |||
| 57 | + | onPageShow(persisted) { return persisted === true ? 'reload' : 'none'; }, | |||
| 20 | // state is what the button and the status line show. | 58 | // state is what the button and the status line show. | ||
| 21 | - | // {solved, now, minAt, sending, unsupported, failed, overs} | 59 | + | // {solved, now, minAt, sending, unsupported, failed, overs, empty} |
| 22 | - | state({ solved, now, minAt, sending, unsupported, failed, overs }) { | 60 | + | state({ solved, now, minAt, sending, unsupported, failed, overs, empty }) { |
| 23 | if (unsupported) return { disabled: true, status: 'Your browser cannot run the check.' }; | 61 | if (unsupported) return { disabled: true, status: 'Your browser cannot run the check.' }; | ||
| 24 | if (failed) return { disabled: true, status: 'The check failed. Reload the page.' }; | 62 | if (failed) return { disabled: true, status: 'The check failed. Reload the page.' }; | ||
| 25 | if (sending) return { disabled: true, status: 'Sending…' }; | 63 | if (sending) return { disabled: true, status: 'Sending…' }; | ||
| 26 | if (!solved) return { disabled: true, status: 'Checking your browser… (this takes a few seconds)' }; | 64 | if (!solved) return { disabled: true, status: 'Checking your browser… (this takes a few seconds)' }; | ||
| 27 | if (now < minAt) return { disabled: true, status: 'Almost ready…' }; | 65 | if (now < minAt) return { disabled: true, status: 'Almost ready…' }; | ||
| 28 | if (overs) return { disabled: true, status: 'A field is too long.' }; | 66 | if (overs) return { disabled: true, status: 'A field is too long.' }; | ||
| 67 | + | if (empty) return { disabled: true, status: 'Enter a title.' }; | |||
| 29 | return { disabled: false, status: 'Ready.' }; | 68 | return { disabled: false, status: 'Ready.' }; | ||
| 30 | }, | 69 | }, | ||
| 31 | }; | 70 | }; | ||
| @@ -36,7 +75,7 @@ window.ermiteIssueForm = (() => { | |||||
| 36 | const status = $('[data-status]'); | 75 | const status = $('[data-status]'); | ||
| 37 | const solution = $('input[name="solution"]'); | 76 | const solution = $('input[name="solution"]'); | ||
| 38 | const minAt = Number(form.dataset.minSubmitAt) || 0; | 77 | const minAt = Number(form.dataset.minSubmitAt) || 0; | ||
| 39 | - | const ctx = { solved: false, sending: false, unsupported: false, failed: false, overs: false }; | 78 | + | const ctx = { solved: false, sending: false, unsupported: false, failed: false, overs: false, empty: true }; |
| 40 | let timer = null; | 79 | let timer = null; | ||
| 41 | 80 | ||||
| 42 | function render() { | 81 | function render() { | ||
| @@ -47,14 +86,38 @@ window.ermiteIssueForm = (() => { | |||||
| 47 | if (ctx.solved && Date.now() < minAt && !ctx.sending) timer = setTimeout(render, minAt - Date.now() + 20); | 86 | if (ctx.solved && Date.now() < minAt && !ctx.sending) timer = setTimeout(render, minAt - Date.now() + 20); | ||
| 48 | } | 87 | } | ||
| 49 | 88 | ||||
| 50 | - | for (const out of form.querySelectorAll('output[data-for]')) { | 89 | + | const storage = (() => { try { return window.sessionStorage; } catch (_) { return null; } })(); |
| 90 | + | const draftKey = core.draftKey(window.location ? window.location.pathname : ''); | |||
| 91 | + | const fields = { title: $('[name="title"]'), body: $('[name="body"]'), author: $('[name="author"]') }; | |||
| 92 | + | if (storage) { | |||
| 93 | + | const d = core.restoreDraft(storage, draftKey); | |||
| 94 | + | if (d) for (const k of Object.keys(fields)) if (fields[k]) fields[k].value = d[k]; | |||
| 95 | + | } | |||
| 96 | + | window.addEventListener('pageshow', (e) => { | |||
| 97 | + | if (core.onPageShow(e.persisted) !== 'reload') return; | |||
| 98 | + | // The old challenge may be spent or expired: never reuse its solution. | |||
| 99 | + | if (button) button.disabled = true; | |||
| 100 | + | if (storage) { | |||
| 101 | + | core.saveDraft(storage, draftKey, { | |||
| 102 | + | title: fields.title ? fields.title.value : '', | |||
| 103 | + | body: fields.body ? fields.body.value : '', | |||
| 104 | + | author: fields.author ? fields.author.value : '', | |||
| 105 | + | }); | |||
| 106 | + | } | |||
| 107 | + | window.location.reload(); | |||
| 108 | + | }); | |||
| 109 | + | ||||
| 110 | + | const outs = [...form.querySelectorAll('output[data-for]')]; | |||
| 111 | + | for (const out of outs) { | |||
| 51 | const field = form.querySelector('#' + out.dataset.for); | 112 | const field = form.querySelector('#' + out.dataset.for); | ||
| 113 | + | const name = field.name; | |||
| 52 | const max = Number(out.dataset.max); | 114 | const max = Number(out.dataset.max); | ||
| 53 | const update = () => { | 115 | const update = () => { | ||
| 54 | - | const n = core.length(field.value); | 116 | + | const n = core.serverLength(name, field.value); |
| 55 | out.textContent = n + ' / ' + max; | 117 | out.textContent = n + ' / ' + max; | ||
| 56 | out.className = core.counterClass(n, max); | 118 | out.className = core.counterClass(n, max); | ||
| 57 | - | ctx.overs = [...form.querySelectorAll('output[data-for]')].some((o) => o.className === 'over'); | 119 | + | ctx.overs = outs.some((o) => o.className === 'over'); |
| 120 | + | if (name === 'title') ctx.empty = n === 0; | |||
| 58 | render(); | 121 | render(); | ||
| 59 | }; | 122 | }; | ||
| 60 | field.addEventListener('input', update); | 123 | field.addEventListener('input', update); | ||