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}