render.gno
9.28 Kb · 356 lines
1package test2
2
3import (
4 "chain/runtime"
5 "chain/runtime/unsafe"
6 "strconv"
7 "strings"
8
9 "gno.land/p/jeronimoalbi/mdform"
10 "gno.land/p/moul/md"
11 "gno.land/p/moul/mdtable"
12 "gno.land/p/moul/txlink"
13 "gno.land/p/nt/avl/v0/pager"
14 "gno.land/p/nt/mux/v0"
15 "gno.land/p/nt/ufmt/v0"
16)
17
18const (
19 formsPerPage = 20
20 responsesPerPage = 25
21)
22
23var router = mux.NewRouter()
24
25func init() {
26 router.HandleFunc("", renderIndex)
27 router.HandleFunc("{id}", renderForm)
28 router.HandleFunc("{id}/responses", renderResponses)
29 router.HandleFunc("{id}/responses.csv", renderCSV)
30}
31
32// Render routes:
33//
34// /r/<ns>/forms index of forms
35// /r/<ns>/forms:<id> a form, fillable in gnoweb
36// /r/<ns>/forms:<id>/responses its responses, paginated
37// /r/<ns>/forms:<id>/responses.csv its responses as CSV
38func Render(path string) string {
39 return router.Render(path)
40}
41
42func renderIndex(res *mux.ResponseWriter, req *mux.Request) {
43 var b strings.Builder
44
45 b.WriteString(md.H1("Forms"))
46 b.WriteString("Publish a form, collect responses on chain, read them back here. ")
47 b.WriteString("Respondents fill it in on this page and pay their own storage deposit; ")
48 b.WriteString("withdrawing a response refunds it.\n\n")
49
50 if forms.Size() == 0 {
51 b.WriteString("_No forms yet._\n\n")
52 b.WriteString(renderCreateHelp())
53 res.Write(b.String())
54 return
55 }
56
57 page := pager.NewPager(forms, formsPerPage, true).MustGetPageByPath(req.RawPath)
58 height := runtime.ChainHeight()
59
60 table := &mdtable.Table{Headers: []string{"Form", "Status", "Responses", "Fields", "Owner"}}
61 for _, item := range page.Items {
62 f := item.Value.(*Form)
63 table.Append([]string{
64 md.Link(f.Title, formURL(f.ID)),
65 statusLabel(f, height),
66 strconv.Itoa(f.ResponseCount()),
67 strconv.Itoa(len(f.Fields)),
68 shortAddr(f.Owner()),
69 })
70 }
71
72 b.WriteString(table.String())
73 b.WriteString("\n")
74 b.WriteString(page.Picker(req.RawPath))
75 b.WriteString("\n\n")
76 b.WriteString(renderCreateHelp())
77
78 res.Write(b.String())
79}
80
81func renderForm(res *mux.ResponseWriter, req *mux.Request) {
82 f, ok := GetForm(req.GetVar("id"))
83 if !ok {
84 res.Write("Form not found.")
85 return
86 }
87
88 height := runtime.ChainHeight()
89 id := f.ID
90
91 var b strings.Builder
92
93 b.WriteString(md.H1(f.Title))
94 if f.Description != "" {
95 b.WriteString(f.Description + "\n\n")
96 }
97
98 b.WriteString(ufmt.Sprintf("**Status:** %s · **Responses:** %d · **Owner:** `%s`",
99 statusLabel(f, height), f.ResponseCount(), f.Owner()))
100 if f.Deadline > 0 {
101 b.WriteString(ufmt.Sprintf(" · **Closes at height:** %d", f.Deadline))
102 }
103 if f.OnePerAddr {
104 b.WriteString(" · one response per address")
105 }
106 b.WriteString("\n\n")
107
108 b.WriteString(md.Link("View responses", responsesURL(id)) + " · " +
109 md.Link("CSV", csvURL(id)) + "\n\n")
110
111 if f.IsOpen(height) {
112 b.WriteString(md.H2("Respond"))
113 b.WriteString(renderMDForm(f))
114 } else {
115 b.WriteString("_This form is not accepting responses._\n\n")
116 }
117
118 b.WriteString(md.H3("Owner actions"))
119 if f.Closed {
120 b.WriteString(md.Link("Reopen", txlink.Call("Reopen", "id", id)))
121 } else {
122 b.WriteString(md.Link("Close", txlink.Call("Close", "id", id)))
123 }
124 b.WriteString(" · " + md.Link("Withdraw my response", txlink.Call("Withdraw", "id", id)) + "\n")
125
126 res.Write(b.String())
127}
128
129// renderMDForm draws the fillable form. gnoweb turns it into an HTML form
130// that calls Submit; each input is named after the Submit parameter it fills.
131func renderMDForm(f *Form) string {
132 form := mdform.New("exec", "Submit")
133
134 form.Input("id",
135 "value", f.ID,
136 "readonly", "true",
137 "description", "Form ID",
138 )
139
140 for i, field := range f.Fields {
141 name := "a" + strconv.Itoa(i+1)
142 label := field.Label
143 if field.Required {
144 label += " *"
145 }
146
147 switch field.Kind {
148 case KindTextarea:
149 attrs := []string{"placeholder", label, "rows", "4"}
150 if field.Required {
151 attrs = append(attrs, "required", "true")
152 }
153 form.Textarea(name, attrs...)
154
155 case KindSelect:
156 for j, opt := range field.Options {
157 attrs := []string{"description", label}
158 if j == 0 && field.Required {
159 attrs = append(attrs, "required", "true")
160 }
161 form.Select(name, opt, attrs...)
162 }
163
164 case KindNumber:
165 attrs := []string{"type", "number", "placeholder", label, "description", label}
166 if field.Required {
167 attrs = append(attrs, "required", "true")
168 }
169 form.Input(name, attrs...)
170
171 default:
172 attrs := []string{"placeholder", label, "description", label}
173 if field.Required {
174 attrs = append(attrs, "required", "true")
175 }
176 form.Input(name, attrs...)
177 }
178 }
179
180 return form.String() + "\n"
181}
182
183func renderResponses(res *mux.ResponseWriter, req *mux.Request) {
184 f, ok := GetForm(req.GetVar("id"))
185 if !ok {
186 res.Write("Form not found.")
187 return
188 }
189
190 var b strings.Builder
191
192 b.WriteString(md.H1(f.Title + " — responses"))
193 b.WriteString(ufmt.Sprintf("%d response(s). ", f.ResponseCount()))
194 b.WriteString(md.Link("Back to form", formURL(f.ID)) + " · " +
195 md.Link("CSV", csvURL(f.ID)) + "\n\n")
196
197 if f.ResponseCount() == 0 {
198 b.WriteString("_No responses yet._\n")
199 res.Write(b.String())
200 return
201 }
202
203 headers := []string{"#", "From", "Height"}
204 for _, field := range f.Fields {
205 headers = append(headers, field.Label)
206 }
207 table := &mdtable.Table{Headers: headers}
208
209 page := pager.NewPager(f.responses, responsesPerPage, false).MustGetPageByPath(req.RawPath)
210 for _, item := range page.Items {
211 r := item.Value.(*Response)
212 row := []string{r.ID.String(), shortAddr(r.Author), strconv.Itoa(int(r.Height))}
213 for _, a := range r.Answers {
214 row = append(row, cell(a))
215 }
216 table.Append(row)
217 }
218
219 b.WriteString(table.String())
220 b.WriteString("\n")
221 b.WriteString(page.Picker(req.RawPath))
222 b.WriteString("\n")
223
224 res.Write(b.String())
225}
226
227// renderCSV emits every response as CSV inside a code block, so a reviewer
228// can copy it straight into a spreadsheet.
229func renderCSV(res *mux.ResponseWriter, req *mux.Request) {
230 f, ok := GetForm(req.GetVar("id"))
231 if !ok {
232 res.Write("Form not found.")
233 return
234 }
235
236 var b strings.Builder
237
238 header := []string{"response_id", "author", "height"}
239 for _, field := range f.Fields {
240 header = append(header, field.Label)
241 }
242 b.WriteString(csvRow(header))
243
244 f.responses.Iterate("", "", func(_ string, value any) bool {
245 r := value.(*Response)
246 row := []string{r.ID.String(), r.Author.String(), strconv.Itoa(int(r.Height))}
247 row = append(row, r.Answers...)
248 b.WriteString(csvRow(row))
249 return false
250 })
251
252 res.Write(md.LanguageCodeBlock("csv", b.String()))
253}
254
255// renderCreateHelp draws the form that creates forms. gnoweb submits it as a
256// CreateForm call with every input mapped to the parameter of the same name.
257func renderCreateHelp() string {
258 form := mdform.New("exec", "CreateForm")
259
260 form.Input("slug",
261 "placeholder", "valoper-questionnaire",
262 "description", "URL name — lowercase letters, digits, hyphens",
263 "required", "true",
264 )
265 form.Input("title",
266 "placeholder", "Valoper questionnaire",
267 "description", "Title",
268 "required", "true",
269 )
270 form.Textarea("description",
271 "placeholder", "What this form is for (optional)",
272 "rows", "3",
273 )
274
275 for i := 1; i <= MaxFields; i++ {
276 n := strconv.Itoa(i)
277 form.Input("l"+n,
278 "placeholder", "Field "+n+" label (leave blank to skip)",
279 "description", "Field "+n,
280 )
281 form.Select("k"+n, string(KindText), "description", "Field "+n+" type", "selected", "true")
282 form.Select("k"+n, string(KindTextarea))
283 form.Select("k"+n, string(KindNumber))
284 form.Select("k"+n, string(KindSelect))
285 form.Checkbox("r"+n, "1", "description", "Field "+n+" required")
286 form.Input("o"+n,
287 "placeholder", "Options, comma-separated (select fields only)",
288 "description", "Field "+n+" options",
289 )
290 }
291
292 form.Checkbox("onePerAddr", "1", "description", "One response per address")
293 form.Input("deadline",
294 "type", "number",
295 "placeholder", "0",
296 "description", "Close at chain height (0 = never)",
297 )
298
299 return md.H2("Create a form") +
300 "Up to " + strconv.Itoa(MaxFields) + " fields. Blank rows are skipped. " +
301 "Submitting opens your wallet with the call already filled in.\n\n" +
302 form.String() + "\n" +
303 "From a terminal, " + md.InlineCode("Create") + " takes the same thing as pipe-separated field specs — see the realm source.\n"
304}
305
306// --- helpers ---
307
308func statusLabel(f *Form, height int64) string {
309 if f.Closed {
310 return "closed"
311 }
312 if f.Deadline > 0 && height >= f.Deadline {
313 return "expired"
314 }
315
316 return "open"
317}
318
319func formURL(id string) string { return realmURL() + ":" + id }
320func responsesURL(id string) string { return realmURL() + ":" + id + "/responses" }
321func csvURL(id string) string { return realmURL() + ":" + id + "/responses.csv" }
322
323var realmPath = unsafe.CurrentRealm().PkgPath()
324
325// realmURL is this realm's path as gnoweb addresses it, derived from where
326// it is deployed rather than hardcoded.
327func realmURL() string {
328 return strings.TrimPrefix(realmPath, "gno.land")
329}
330
331func shortAddr(a address) string {
332 s := a.String()
333 if len(s) <= 14 {
334 return s
335 }
336
337 return s[:8] + "…" + s[len(s)-4:]
338}
339
340// cell makes an answer safe inside a markdown table cell.
341func cell(s string) string {
342 s = strings.ReplaceAll(s, "|", "\\|")
343 s = strings.ReplaceAll(s, "\n", " ")
344
345 return s
346}
347
348// csvRow quotes every field, doubling embedded quotes, per RFC 4180.
349func csvRow(fields []string) string {
350 out := make([]string, len(fields))
351 for i, f := range fields {
352 out[i] = `"` + strings.ReplaceAll(f, `"`, `""`) + `"`
353 }
354
355 return strings.Join(out, ",") + "\n"
356}