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

7.95 Kb · 329 lines
  1package test2
  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 what a checkbox or a human might send to "1" or "0".
101func boolFlag(s string) string {
102	switch strings.ToLower(strings.TrimSpace(s)) {
103	case "1", "true", "on", "yes":
104		return "1"
105	}
106	return "0"
107}
108
109func create(cur realm, slug, title, description, labels, kinds, required, options string, onePerAddr bool, deadline int64) string {
110	caller := mustUserCaller(cur)
111
112	slug = strings.TrimSpace(slug)
113	if len(slug) < MinSlugLen || len(slug) > MaxSlugLen || !slugRe.MatchString(slug) {
114		panic(ErrBadSlug)
115	}
116	if forms.Has(slug) {
117		panic(ErrSlugTaken)
118	}
119
120	title = strings.TrimSpace(title)
121	description = strings.TrimSpace(description)
122
123	if title == "" {
124		panic(ErrEmptyTitle)
125	}
126	if len(title) > MaxTitleLen {
127		panic(ErrTitleTooLong)
128	}
129	if len(description) > MaxDescriptionLen {
130		panic(ErrDescriptionTooLong)
131	}
132
133	height := runtime.ChainHeight()
134	if deadline < 0 || (deadline > 0 && deadline <= height) {
135		panic(ErrBadDeadline)
136	}
137
138	fields := parseFields(labels, kinds, required, options)
139
140	f := &Form{
141		ID:          slug,
142		Title:       title,
143		Description: description,
144		Fields:      fields,
145		OnePerAddr:  onePerAddr,
146		Deadline:    deadline,
147		CreatedAt:   height,
148		owner:       ownable.NewWithAddress(caller),
149		responses:   avl.NewTree(),
150		byAddr:      avl.NewTree(),
151	}
152
153	forms.Set(slug, f)
154
155	chain.Emit("FormCreated", "id", slug, "owner", caller.String(), "title", title)
156
157	return slug
158}
159
160// Close stops a form from accepting responses. Owner only.
161func Close(cur realm, id string) {
162	f := mustGetForm(id)
163	f.owner.AssertOwnedBy(mustUserCaller(cur))
164
165	if f.Closed {
166		panic(ErrFormClosed)
167	}
168
169	f.Closed = true
170
171	chain.Emit("FormClosed", "id", id)
172}
173
174// Reopen lets a closed form accept responses again. Owner only. A form
175// whose deadline has passed stays closed regardless.
176func Reopen(cur realm, id string) {
177	f := mustGetForm(id)
178	f.owner.AssertOwnedBy(mustUserCaller(cur))
179
180	if !f.Closed {
181		panic(ErrFormOpen)
182	}
183	if f.Deadline > 0 && runtime.ChainHeight() >= f.Deadline {
184		panic(ErrDeadlinePassed)
185	}
186
187	f.Closed = false
188
189	chain.Emit("FormReopened", "id", id)
190}
191
192// TransferOwnership hands a form to another address. Owner only.
193func TransferOwnership(cur realm, id string, to address) {
194	f := mustGetForm(id)
195	f.owner.AssertOwnedBy(mustUserCaller(cur))
196
197	if err := f.owner.TransferOwnership(0, cur, to); err != nil {
198		panic(err)
199	}
200
201	chain.Emit("FormTransferred", "id", id, "to", to.String())
202}
203
204// GetForm returns a form by ID.
205func GetForm(id string) (*Form, bool) {
206	raw := forms.Get(id)
207	if raw == nil {
208		return nil, false
209	}
210
211	return raw.(*Form), true
212}
213
214// ResponseCount returns the number of responses a form has, or 0 if the
215// form does not exist.
216func ResponseCount(id string) int {
217	f, ok := GetForm(id)
218	if !ok {
219		return 0
220	}
221
222	return f.ResponseCount()
223}
224
225// FormCount returns how many forms exist.
226func FormCount() int {
227	return forms.Size()
228}
229
230func mustGetForm(id string) *Form {
231	f, ok := GetForm(id)
232	if !ok {
233		panic(ErrFormNotFound)
234	}
235
236	return f
237}
238
239// mustUserCaller returns the address of the user account that made the
240// call. Only user accounts may create forms or respond: a realm submitting
241// on someone's behalf would be attributed to the realm, which is never what
242// a form wants.
243func mustUserCaller(cur realm) address {
244	if !cur.IsCurrent() {
245		panic("realm value is not the caller's live cur")
246	}
247
248	prev := cur.Previous()
249	if !prev.IsUser() {
250		panic(ErrNotUserCall)
251	}
252
253	return prev.Address()
254}
255
256// parseFields decodes the four parallel field-spec strings.
257func parseFields(labels, kinds, required, options string) []Field {
258	labelList := splitFields(labels)
259	kindList := splitFields(kinds)
260	reqList := splitFields(required)
261	optList := splitFields(options)
262
263	n := len(labelList)
264	if n == 0 || (n == 1 && labelList[0] == "") {
265		panic(ErrNoFields)
266	}
267	if n > MaxFields {
268		panic(ErrTooManyFields)
269	}
270	if len(kindList) != n || len(reqList) != n {
271		panic(ErrFieldSpecMismatch)
272	}
273	// options may be omitted entirely when no field is a select.
274	if len(optList) != n && !(len(optList) == 1 && optList[0] == "") {
275		panic(ErrFieldSpecMismatch)
276	}
277
278	fields := make([]Field, 0, n)
279	for i := 0; i < n; i++ {
280		label := strings.TrimSpace(labelList[i])
281		if label == "" {
282			panic(ErrEmptyLabel)
283		}
284		if len(label) > MaxLabelLen {
285			panic(ufmt.Errorf("field %d: label is too long", i+1))
286		}
287
288		kind := FieldKind(strings.TrimSpace(kindList[i]))
289		switch kind {
290		case KindText, KindTextarea, KindNumber, KindSelect:
291		default:
292			panic(ErrBadFieldKind)
293		}
294
295		field := Field{
296			Label:    label,
297			Kind:     kind,
298			Required: strings.TrimSpace(reqList[i]) == "1",
299		}
300
301		if kind == KindSelect {
302			raw := ""
303			if len(optList) == n {
304				raw = optList[i]
305			}
306			for _, o := range strings.Split(raw, ",") {
307				o = strings.TrimSpace(o)
308				if o == "" {
309					continue
310				}
311				if len(o) > MaxOptionLen {
312					panic(ufmt.Errorf("field %d: option is too long", i+1))
313				}
314				field.Options = append(field.Options, o)
315			}
316			if len(field.Options) == 0 {
317				panic(ErrSelectNoOptions)
318			}
319		}
320
321		fields = append(fields, field)
322	}
323
324	return fields
325}
326
327func splitFields(s string) []string {
328	return strings.Split(s, "|")
329}