Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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"))