vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Edit issue and change details in place
802e0e52533746522b3e65b953e5cc0141aa70b9 · Keenan Nemetz · 4d ago · parent 90f1ab59
Edit issue and change details in place Put the title pencil beside the heading and the description menu on its card, matching the comment editor. Each control saves only its own field: the title response is the heading and the description response is the card, so an open draft in the other field is not replaced. The standalone /edit page stays as the fallback. Prefilled edit fields size themselves to their content and remain vertically resizable. Prose copied from a menu is query-escaped in the attribute, so untrusted markdown in an issue or comment cannot appear verbatim in the page.
server/issues_changes_test.go +94−7
| @@ -66,9 +66,10 @@ func TestIssueCanBeClosedAndReopenedInTheBrowser(t *testing.T) { | |||
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | 68 | // The browser could file, triage, discuss and close an issue or a change, but it | |
| 69 | // could not correct what one said. The two edit pages are the missing half: the | ||
| 70 | // author and a maintainer may rewrite the title and description, and nobody else | ||
| 71 | // may, on either surface. | ||
| 69 | // could not correct what one said. The edit controls are the missing half: the | ||
| 70 | // author and a maintainer may rewrite the title and description in place, and | ||
| 71 | // nobody else may, on either surface. The standalone edit page stays as the | ||
| 72 | // no-JavaScript fallback. | ||
| 72 | 73 | func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) { | |
| 73 | 74 | t.Parallel() | |
| 74 | 75 | f := newFixture(t) | |
| @@ -98,8 +99,11 @@ func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) { | |||
| 98 | 99 | t.Fatalf("no issue id in %q", resp.Header.Get("Location")) | |
| 99 | 100 | } | |
| 100 | 101 | _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID, cookie) | |
| 101 | if !strings.Contains(page, "/issues/"+issueID+"/edit") || !strings.Contains(page, "Edit</a>") { | ||
| 102 | t.Fatalf("the issue page offers no edit:\n%s", page) | ||
| 102 | if !strings.Contains(page, "data-title-edit") || !strings.Contains(page, "data-title-edit-form") || | ||
| 103 | !strings.Contains(page, `data-comment-menu`) || !strings.Contains(page, "Copy text") || | ||
| 104 | !strings.Contains(page, "data-comment-edit") || !strings.Contains(page, `name="field" value="title"`) || | ||
| 105 | !strings.Contains(page, `name="field" value="body"`) || !strings.Contains(page, "data-autosize") { | ||
| 106 | t.Fatalf("the issue page offers no in-place title and description controls:\n%s", page) | ||
| 103 | 107 | } | |
| 104 | 108 | resp, form := f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID+"/edit", cookie) | |
| 105 | 109 | if resp.StatusCode != http.StatusOK || !strings.Contains(form, `action="/r/`+f.repo+`/issues/`+issueID+`/edit"`) || | |
| @@ -124,6 +128,47 @@ func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) { | |||
| 124 | 128 | t.Fatalf("the issue page does not show the edit:\n%s", page) | |
| 125 | 129 | } | |
| 126 | 130 | ||
| 131 | // The title pencil posts only the title. Its response is the heading, not the | ||
| 132 | // description, so a description draft is left alone. | ||
| 133 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{ | ||
| 134 | "csrf": {sess.CSRF}, "field": {"title"}, "title": {"In-place issue"}, | ||
| 135 | }) | ||
| 136 | if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") || | ||
| 137 | !strings.Contains(body, `id="issue-head"`) || !strings.Contains(body, "In-place issue") || | ||
| 138 | strings.Contains(body, `id="issue-description"`) { | ||
| 139 | t.Fatalf("issue inline title edit = %d:\n%s", resp.StatusCode, body) | ||
| 140 | } | ||
| 141 | if iss, err = index.LoadIssue(mustRepo(t, f), issueID); err != nil || | ||
| 142 | iss.Title != "In-place issue" || strings.TrimSpace(iss.Description) != "corrected issue body" { | ||
| 143 | t.Fatalf("inline issue title edit = %+v, %v", iss, err) | ||
| 144 | } | ||
| 145 | |||
| 146 | // The description menu posts only the description, and comes back as the | ||
| 147 | // description card with the title untouched. | ||
| 148 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{ | ||
| 149 | "csrf": {sess.CSRF}, "field": {"body"}, "body": {"in-place issue body\n"}, | ||
| 150 | }) | ||
| 151 | if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") || | ||
| 152 | !strings.Contains(body, `id="issue-description"`) || !strings.Contains(body, "in-place issue body") || | ||
| 153 | strings.Contains(body, `id="issue-head"`) { | ||
| 154 | t.Fatalf("issue inline description edit = %d:\n%s", resp.StatusCode, body) | ||
| 155 | } | ||
| 156 | if iss, err = index.LoadIssue(mustRepo(t, f), issueID); err != nil || | ||
| 157 | iss.Title != "In-place issue" || strings.TrimSpace(iss.Description) != "in-place issue body" { | ||
| 158 | t.Fatalf("inline issue description edit = %+v, %v", iss, err) | ||
| 159 | } | ||
| 160 | |||
| 161 | // A refused title keeps the title form open with what was typed; the | ||
| 162 | // description card is not part of the response. | ||
| 163 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{ | ||
| 164 | "csrf": {sess.CSRF}, "field": {"title"}, "title": {""}, | ||
| 165 | }) | ||
| 166 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "an issue needs a title") || | ||
| 167 | !strings.Contains(body, `data-title-edit-form`) || !strings.Contains(body, `autofocus`) || | ||
| 168 | strings.Contains(body, `id="issue-description"`) { | ||
| 169 | t.Fatalf("refused inline issue title edit = %d:\n%s", resp.StatusCode, body) | ||
| 170 | } | ||
| 171 | |||
| 127 | 172 | // A change: same controls, and the same writer. | |
| 128 | 173 | resp, body = f.browserPost(t, "/r/"+f.repo+"/requests", cookie, url.Values{ | |
| 129 | 174 | "csrf": {sess.CSRF}, "title": {"First change draft"}, "body": {"original change body\n"}, | |
| @@ -137,8 +182,17 @@ func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) { | |||
| 137 | 182 | t.Fatalf("no change id in %q", resp.Header.Get("Location")) | |
| 138 | 183 | } | |
| 139 | 184 | _, page = f.browserGet(t, "/r/"+f.repo+"/requests/"+changeID, cookie) | |
| 140 | if !strings.Contains(page, "/requests/"+changeID+"/edit") || !strings.Contains(page, "Edit</a>") { | ||
| 141 | t.Fatalf("the change page offers no edit:\n%s", page) | ||
| 185 | if !strings.Contains(page, "data-title-edit") || !strings.Contains(page, "data-title-edit-form") || | ||
| 186 | !strings.Contains(page, `data-comment-menu`) || !strings.Contains(page, "Copy text") || | ||
| 187 | !strings.Contains(page, "data-comment-edit") || !strings.Contains(page, `name="field" value="title"`) || | ||
| 188 | !strings.Contains(page, `name="field" value="body"`) || !strings.Contains(page, "data-autosize") { | ||
| 189 | t.Fatalf("the change page offers no in-place title and description controls:\n%s", page) | ||
| 190 | } | ||
| 191 | // The Files tab reuses the change head but has no description form there, so | ||
| 192 | // its title pencil must keep the ordinary link to the standalone page. | ||
| 193 | if _, files := f.browserGet(t, "/r/"+f.repo+"/requests/"+changeID+"/files", cookie); !strings.Contains(files, "/requests/"+changeID+"/edit") || | ||
| 194 | strings.Contains(files, "data-title-edit") { | ||
| 195 | t.Fatalf("the Files tab does not link to the edit page:\n%s", files) | ||
| 142 | 196 | } | |
| 143 | 197 | resp, form = f.browserGet(t, "/r/"+f.repo+"/requests/"+changeID+"/edit", cookie) | |
| 144 | 198 | if resp.StatusCode != http.StatusOK || !strings.Contains(form, `action="/r/`+f.repo+`/requests/`+changeID+`/edit"`) || | |
| @@ -163,6 +217,39 @@ func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) { | |||
| 163 | 217 | t.Fatalf("the change page does not show the edit:\n%s", page) | |
| 164 | 218 | } | |
| 165 | 219 | ||
| 220 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+changeID+"/edit", cookie, url.Values{ | ||
| 221 | "csrf": {sess.CSRF}, "field": {"title"}, "title": {"In-place change"}, | ||
| 222 | }) | ||
| 223 | if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") || | ||
| 224 | !strings.Contains(body, `id="request-head"`) || !strings.Contains(body, "In-place change") || | ||
| 225 | strings.Contains(body, `id="request-description"`) || strings.Contains(body, `id="request-alert"`) { | ||
| 226 | t.Fatalf("change inline title edit = %d:\n%s", resp.StatusCode, body) | ||
| 227 | } | ||
| 228 | if req, err = metadata.GetRequest(mustRepo(t, f).Git(), changeID); err != nil || | ||
| 229 | req.Title != "In-place change" || strings.TrimSpace(req.Description) != "corrected change body" { | ||
| 230 | t.Fatalf("inline change title edit = %+v, %v", req, err) | ||
| 231 | } | ||
| 232 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+changeID+"/edit", cookie, url.Values{ | ||
| 233 | "csrf": {sess.CSRF}, "field": {"body"}, "body": {"in-place change body\n"}, | ||
| 234 | }) | ||
| 235 | if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") || | ||
| 236 | !strings.Contains(body, `id="request-description"`) || !strings.Contains(body, "in-place change body") || | ||
| 237 | strings.Contains(body, `id="request-head"`) { | ||
| 238 | t.Fatalf("change inline description edit = %d:\n%s", resp.StatusCode, body) | ||
| 239 | } | ||
| 240 | if req, err = metadata.GetRequest(mustRepo(t, f).Git(), changeID); err != nil || | ||
| 241 | req.Title != "In-place change" || strings.TrimSpace(req.Description) != "in-place change body" { | ||
| 242 | t.Fatalf("inline change description edit = %+v, %v", req, err) | ||
| 243 | } | ||
| 244 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+changeID+"/edit", cookie, url.Values{ | ||
| 245 | "csrf": {sess.CSRF}, "field": {"title"}, "title": {""}, | ||
| 246 | }) | ||
| 247 | if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") || | ||
| 248 | !strings.Contains(body, "a change needs a title") || !strings.Contains(body, `data-title-edit-form`) || | ||
| 249 | !strings.Contains(body, `autofocus`) || strings.Contains(body, `id="request-description"`) { | ||
| 250 | t.Fatalf("refused inline change title edit = %d:\n%s", resp.StatusCode, body) | ||
| 251 | } | ||
| 252 | |||
| 166 | 253 | // Somebody who is neither the author nor a maintainer may open neither form | |
| 167 | 254 | // and may not post either edit. | |
| 168 | 255 | pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_stranger")) | |