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

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}