Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}