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}