render.gno
8.07 Kb · 314 lines
1package test
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.String())),
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.String()
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.String(),
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.String())) + " · " +
195 md.Link("CSV", csvURL(f.ID.String())) + "\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
255func renderCreateHelp() string {
256 return md.H3("Create a form") +
257 "Call " + md.InlineCode("Create") + " with a title, a description, and four pipe-separated " +
258 "field specs — labels, kinds (" + md.InlineCode("text|textarea|number|select") + "), " +
259 "required flags (" + md.InlineCode("1|0") + ") and select options (comma-separated). " +
260 "Up to " + strconv.Itoa(MaxFields) + " fields.\n\n" +
261 md.Link("Create a form", txlink.Call("Create")) + "\n"
262}
263
264// --- helpers ---
265
266func statusLabel(f *Form, height int64) string {
267 if f.Closed {
268 return "closed"
269 }
270 if f.Deadline > 0 && height >= f.Deadline {
271 return "expired"
272 }
273
274 return "open"
275}
276
277func formURL(id string) string { return realmURL() + ":" + id }
278func responsesURL(id string) string { return realmURL() + ":" + id + "/responses" }
279func csvURL(id string) string { return realmURL() + ":" + id + "/responses.csv" }
280
281var realmPath = unsafe.CurrentRealm().PkgPath()
282
283// realmURL is this realm's path as gnoweb addresses it, derived from where
284// it is deployed rather than hardcoded.
285func realmURL() string {
286 return strings.TrimPrefix(realmPath, "gno.land")
287}
288
289func shortAddr(a address) string {
290 s := a.String()
291 if len(s) <= 14 {
292 return s
293 }
294
295 return s[:8] + "…" + s[len(s)-4:]
296}
297
298// cell makes an answer safe inside a markdown table cell.
299func cell(s string) string {
300 s = strings.ReplaceAll(s, "|", "\\|")
301 s = strings.ReplaceAll(s, "\n", " ")
302
303 return s
304}
305
306// csvRow quotes every field, doubling embedded quotes, per RFC 4180.
307func csvRow(fields []string) string {
308 out := make([]string, len(fields))
309 for i, f := range fields {
310 out[i] = `"` + strings.ReplaceAll(f, `"`, `""`) + `"`
311 }
312
313 return strings.Join(out, ",") + "\n"
314}