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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

render.gno

9.59 Kb · 361 lines
  1package forms
  2
  3import (
  4	"chain/runtime"
  5	"chain/runtime/unsafe"
  6	"strconv"
  7	"strings"
  8
  9	"gno.land/p/jeronimoalbi/mdform/v0"
 10	"gno.land/p/moul/md/v0"
 11	"gno.land/p/moul/mdtable/v0"
 12	"gno.land/p/moul/txlink/v0"
 13	"gno.land/p/nt/avl/pager/v0"
 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		// A two-option select, not a checkbox: gnoweb sends an untouched
286		// checkbox's value attribute as if it were ticked, so every field came
287		// out required. Untouched selects correctly send their default.
288		form.Select("r"+n, "no", "description", "Field "+n+" required?", "selected", "true")
289		form.Select("r"+n, "yes")
290		form.Input("o"+n,
291			"placeholder", "Options, comma-separated (select fields only)",
292			"description", "Field "+n+" options",
293		)
294	}
295
296	form.Select("onePerAddr", "no", "description", "One response per address?", "selected", "true")
297	form.Select("onePerAddr", "yes")
298	form.Input("deadline",
299		"type", "number",
300		"placeholder", "0",
301		"description", "Close at chain height (0 = never)",
302	)
303
304	return md.H2("Create a form") +
305		"Up to " + strconv.Itoa(MaxFields) + " fields. Blank rows are skipped. " +
306		"Submitting opens your wallet with the call already filled in.\n\n" +
307		form.String() + "\n" +
308		"From a terminal, " + md.InlineCode("Create") + " takes the same thing as pipe-separated field specs — see the realm source.\n"
309}
310
311// --- helpers ---
312
313func statusLabel(f *Form, height int64) string {
314	if f.Closed {
315		return "closed"
316	}
317	if f.Deadline > 0 && height >= f.Deadline {
318		return "expired"
319	}
320
321	return "open"
322}
323
324func formURL(id string) string      { return realmURL() + ":" + id }
325func responsesURL(id string) string { return realmURL() + ":" + id + "/responses" }
326func csvURL(id string) string       { return realmURL() + ":" + id + "/responses.csv" }
327
328var realmPath = unsafe.CurrentRealm().PkgPath()
329
330// realmURL is this realm's path as gnoweb addresses it, derived from where
331// it is deployed rather than hardcoded.
332func realmURL() string {
333	return strings.TrimPrefix(realmPath, "gno.land")
334}
335
336func shortAddr(a address) string {
337	s := a.String()
338	if len(s) <= 14 {
339		return s
340	}
341
342	return s[:8] + "…" + s[len(s)-4:]
343}
344
345// cell makes an answer safe inside a markdown table cell.
346func cell(s string) string {
347	s = strings.ReplaceAll(s, "|", "\\|")
348	s = strings.ReplaceAll(s, "\n", " ")
349
350	return s
351}
352
353// csvRow quotes every field, doubling embedded quotes, per RFC 4180.
354func csvRow(fields []string) string {
355	out := make([]string, len(fields))
356	for i, f := range fields {
357		out[i] = `"` + strings.ReplaceAll(f, `"`, `""`) + `"`
358	}
359
360	return strings.Join(out, ",") + "\n"
361}