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

forms.gno

8.09 Kb · 331 lines
  1package test4
  2
  3import (
  4	"chain"
  5	"chain/runtime"
  6	"regexp"
  7	"strconv"
  8	"strings"
  9
 10	"gno.land/p/nt/avl/v0"
 11	"gno.land/p/nt/ownable/v0"
 12	"gno.land/p/nt/seqid/v0"
 13	"gno.land/p/nt/ufmt/v0"
 14)
 15
 16var (
 17	forms      = avl.NewTree() // slug -> *Form
 18	nextRespID seqid.ID
 19
 20	slugRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
 21)
 22
 23// Create publishes a new form under slug, which becomes its ID and its URL
 24// segment (/r/<ns>/forms:<slug>). Slugs are unique per realm.
 25//
 26// Fields are described by four parallel, pipe-separated strings so the call
 27// stays usable from gnokey and gnoweb without structured arguments:
 28//
 29//	labels:   "Name|Why gno.land?|Server type"
 30//	kinds:    "text|textarea|select"
 31//	required: "1|1|0"
 32//	options:  "||cloud,on-prem,data-center"   (comma-separated; only select uses it)
 33//
 34// deadline is a chain height after which the form stops accepting responses,
 35// or 0 for none. onePerAddr limits each address to a single response.
 36func Create(cur realm, slug, title, description, labels, kinds, required, options string, onePerAddr bool, deadline int64) string {
 37	return create(cur, slug, title, description, labels, kinds, required, options, onePerAddr, deadline)
 38}
 39
 40// CreateForm is Create for the browser: the index page renders it as a gnoweb
 41// form, one row per field, so nothing has to be typed into the wallet.
 42//
 43// Every parameter is a string because gnoweb submits "" for an empty or
 44// unchecked input, and "" is not a bool or an int64 as far as the VM is
 45// concerned. Checkboxes send "1" when ticked. Blank rows are skipped; a row
 46// with an empty kind is text. deadline "" means none.
 47func CreateForm(cur realm,
 48	slug, title, description string,
 49	l1, k1, r1, o1 string,
 50	l2, k2, r2, o2 string,
 51	l3, k3, r3, o3 string,
 52	l4, k4, r4, o4 string,
 53	l5, k5, r5, o5 string,
 54	l6, k6, r6, o6 string,
 55	l7, k7, r7, o7 string,
 56	l8, k8, r8, o8 string,
 57	onePerAddr, deadline string,
 58) string {
 59	rows := [][4]string{
 60		{l1, k1, r1, o1}, {l2, k2, r2, o2}, {l3, k3, r3, o3}, {l4, k4, r4, o4},
 61		{l5, k5, r5, o5}, {l6, k6, r6, o6}, {l7, k7, r7, o7}, {l8, k8, r8, o8},
 62	}
 63
 64	var labels, kinds, required, options []string
 65	for _, row := range rows {
 66		label := strings.TrimSpace(row[0])
 67		if label == "" {
 68			continue
 69		}
 70		if strings.Contains(label, "|") || strings.Contains(row[3], "|") {
 71			panic(ErrPipeInField)
 72		}
 73
 74		kind := strings.TrimSpace(row[1])
 75		if kind == "" {
 76			kind = string(KindText)
 77		}
 78
 79		labels = append(labels, label)
 80		kinds = append(kinds, kind)
 81		required = append(required, boolFlag(row[2]))
 82		options = append(options, strings.TrimSpace(row[3]))
 83	}
 84
 85	var dl int64
 86	if d := strings.TrimSpace(deadline); d != "" {
 87		n, err := strconv.Atoi(d)
 88		if err != nil {
 89			panic(ErrBadDeadline)
 90		}
 91		dl = int64(n)
 92	}
 93
 94	return create(cur, slug, title, description,
 95		strings.Join(labels, "|"), strings.Join(kinds, "|"),
 96		strings.Join(required, "|"), strings.Join(options, "|"),
 97		boolFlag(onePerAddr) == "1", dl)
 98}
 99
100// boolFlag normalises a yes/no select value (or anything a human or gnokey
101// might send) to the "1"/"0" the field-spec parser expects. Anything that is
102// not affirmative is false, so an untouched control means "no".
103func boolFlag(s string) string {
104	switch strings.ToLower(strings.TrimSpace(s)) {
105	case "1", "true", "on", "yes":
106		return "1"
107	}
108	return "0"
109}
110
111func create(cur realm, slug, title, description, labels, kinds, required, options string, onePerAddr bool, deadline int64) string {
112	caller := mustUserCaller(cur)
113
114	slug = strings.TrimSpace(slug)
115	if len(slug) < MinSlugLen || len(slug) > MaxSlugLen || !slugRe.MatchString(slug) {
116		panic(ErrBadSlug)
117	}
118	if forms.Has(slug) {
119		panic(ErrSlugTaken)
120	}
121
122	title = strings.TrimSpace(title)
123	description = strings.TrimSpace(description)
124
125	if title == "" {
126		panic(ErrEmptyTitle)
127	}
128	if len(title) > MaxTitleLen {
129		panic(ErrTitleTooLong)
130	}
131	if len(description) > MaxDescriptionLen {
132		panic(ErrDescriptionTooLong)
133	}
134
135	height := runtime.ChainHeight()
136	if deadline < 0 || (deadline > 0 && deadline <= height) {
137		panic(ErrBadDeadline)
138	}
139
140	fields := parseFields(labels, kinds, required, options)
141
142	f := &Form{
143		ID:          slug,
144		Title:       title,
145		Description: description,
146		Fields:      fields,
147		OnePerAddr:  onePerAddr,
148		Deadline:    deadline,
149		CreatedAt:   height,
150		owner:       ownable.NewWithAddress(caller),
151		responses:   avl.NewTree(),
152		byAddr:      avl.NewTree(),
153	}
154
155	forms.Set(slug, f)
156
157	chain.Emit("FormCreated", "id", slug, "owner", caller.String(), "title", title)
158
159	return slug
160}
161
162// Close stops a form from accepting responses. Owner only.
163func Close(cur realm, id string) {
164	f := mustGetForm(id)
165	f.owner.AssertOwnedBy(mustUserCaller(cur))
166
167	if f.Closed {
168		panic(ErrFormClosed)
169	}
170
171	f.Closed = true
172
173	chain.Emit("FormClosed", "id", id)
174}
175
176// Reopen lets a closed form accept responses again. Owner only. A form
177// whose deadline has passed stays closed regardless.
178func Reopen(cur realm, id string) {
179	f := mustGetForm(id)
180	f.owner.AssertOwnedBy(mustUserCaller(cur))
181
182	if !f.Closed {
183		panic(ErrFormOpen)
184	}
185	if f.Deadline > 0 && runtime.ChainHeight() >= f.Deadline {
186		panic(ErrDeadlinePassed)
187	}
188
189	f.Closed = false
190
191	chain.Emit("FormReopened", "id", id)
192}
193
194// TransferOwnership hands a form to another address. Owner only.
195func TransferOwnership(cur realm, id string, to address) {
196	f := mustGetForm(id)
197	f.owner.AssertOwnedBy(mustUserCaller(cur))
198
199	if err := f.owner.TransferOwnership(0, cur, to); err != nil {
200		panic(err)
201	}
202
203	chain.Emit("FormTransferred", "id", id, "to", to.String())
204}
205
206// GetForm returns a form by ID.
207func GetForm(id string) (*Form, bool) {
208	raw := forms.Get(id)
209	if raw == nil {
210		return nil, false
211	}
212
213	return raw.(*Form), true
214}
215
216// ResponseCount returns the number of responses a form has, or 0 if the
217// form does not exist.
218func ResponseCount(id string) int {
219	f, ok := GetForm(id)
220	if !ok {
221		return 0
222	}
223
224	return f.ResponseCount()
225}
226
227// FormCount returns how many forms exist.
228func FormCount() int {
229	return forms.Size()
230}
231
232func mustGetForm(id string) *Form {
233	f, ok := GetForm(id)
234	if !ok {
235		panic(ErrFormNotFound)
236	}
237
238	return f
239}
240
241// mustUserCaller returns the address of the user account that made the
242// call. Only user accounts may create forms or respond: a realm submitting
243// on someone's behalf would be attributed to the realm, which is never what
244// a form wants.
245func mustUserCaller(cur realm) address {
246	if !cur.IsCurrent() {
247		panic("realm value is not the caller's live cur")
248	}
249
250	prev := cur.Previous()
251	if !prev.IsUser() {
252		panic(ErrNotUserCall)
253	}
254
255	return prev.Address()
256}
257
258// parseFields decodes the four parallel field-spec strings.
259func parseFields(labels, kinds, required, options string) []Field {
260	labelList := splitFields(labels)
261	kindList := splitFields(kinds)
262	reqList := splitFields(required)
263	optList := splitFields(options)
264
265	n := len(labelList)
266	if n == 0 || (n == 1 && labelList[0] == "") {
267		panic(ErrNoFields)
268	}
269	if n > MaxFields {
270		panic(ErrTooManyFields)
271	}
272	if len(kindList) != n || len(reqList) != n {
273		panic(ErrFieldSpecMismatch)
274	}
275	// options may be omitted entirely when no field is a select.
276	if len(optList) != n && !(len(optList) == 1 && optList[0] == "") {
277		panic(ErrFieldSpecMismatch)
278	}
279
280	fields := make([]Field, 0, n)
281	for i := 0; i < n; i++ {
282		label := strings.TrimSpace(labelList[i])
283		if label == "" {
284			panic(ErrEmptyLabel)
285		}
286		if len(label) > MaxLabelLen {
287			panic(ufmt.Errorf("field %d: label is too long", i+1))
288		}
289
290		kind := FieldKind(strings.TrimSpace(kindList[i]))
291		switch kind {
292		case KindText, KindTextarea, KindNumber, KindSelect:
293		default:
294			panic(ErrBadFieldKind)
295		}
296
297		field := Field{
298			Label:    label,
299			Kind:     kind,
300			Required: strings.TrimSpace(reqList[i]) == "1",
301		}
302
303		if kind == KindSelect {
304			raw := ""
305			if len(optList) == n {
306				raw = optList[i]
307			}
308			for _, o := range strings.Split(raw, ",") {
309				o = strings.TrimSpace(o)
310				if o == "" {
311					continue
312				}
313				if len(o) > MaxOptionLen {
314					panic(ufmt.Errorf("field %d: option is too long", i+1))
315				}
316				field.Options = append(field.Options, o)
317			}
318			if len(field.Options) == 0 {
319				panic(ErrSelectNoOptions)
320			}
321		}
322
323		fields = append(fields, field)
324	}
325
326	return fields
327}
328
329func splitFields(s string) []string {
330	return strings.Split(s, "|")
331}