render.gno
16.80 Kb · 631 lines
1package commondao
2
3import (
4 "strconv"
5 "strings"
6 "time"
7
8 "gno.land/p/jeronimoalbi/pager"
9 "gno.land/p/moul/md"
10 "gno.land/p/moul/mdtable"
11 "gno.land/p/moul/realmpath"
12 "gno.land/p/nt/commondao/v0"
13 "gno.land/p/nt/mux/v0"
14 "gno.land/p/nt/ufmt/v0"
15
16 "gno.land/r/sys/users"
17)
18
19const dateFormat = "Mon, 02 Jan 2006 03:04pm MST"
20
21func Render(path string) string {
22 router := mux.NewRouter()
23 router.HandleFunc("", renderHome)
24 router.HandleFunc("{daoID}", renderDAO)
25 router.HandleFunc("{daoID}/settings", renderSettings)
26 router.HandleFunc("{daoID}/proposals", renderProposalsList)
27 router.HandleFunc("{daoID}/proposals/{proposalID}", renderProposal)
28 router.HandleFunc("{daoID}/proposals/{proposalID}/vote/{address}", renderProposalVote)
29 return router.Render(path)
30}
31
32func renderHome(res *mux.ResponseWriter, req *mux.Request) {
33 res.Write(md.H1("Common DAO"))
34 res.Write(md.HorizontalRule())
35 res.Write(ufmt.Sprintf(
36 md.Paragraph("This realm can be used to create CommonDAO instances based on %s package."),
37 md.Link("commondao", "/p/nt/commondao/v0/"),
38 ))
39
40 pages, err := pager.New(req.RawPath, daos.Size(), pager.WithPageSize(10))
41 if err != nil {
42 res.Write(err.Error())
43 return
44 }
45
46 var items []string
47 daos.ReverseIterateByOffset(pages.Offset(), pages.PageSize(), func(_ string, v any) bool {
48 dao := v.(*commondao.CommonDAO)
49 o := getOptions(dao.ID())
50 if o.AllowRender && o.AllowListing {
51 items = append(items, md.Link(dao.Name(), daoURL(dao.ID())))
52 }
53 return false
54 })
55
56 if len(items) == 0 {
57 return
58 }
59
60 res.Write(md.Paragraph("Here is a list of some of the DAOs that were created:"))
61 res.Write(md.Paragraph(md.BulletList(items)))
62
63 if pages.HasPages() {
64 res.Write(md.Paragraph(pager.Picker(pages)))
65 }
66}
67
68func renderDAO(res *mux.ResponseWriter, req *mux.Request) {
69 dao := mustGetDAOFromRequest(req)
70
71 // Render header messages
72 if dao.IsDeleted() {
73 res.Write(md.Blockquote("⚠ This DAO has been dissolved"))
74 }
75
76 // Render header
77 res.Write(md.H1(dao.Name()))
78 if desc := dao.Description(); desc != "" {
79 res.Write(md.Paragraph(desc))
80 }
81
82 // Render main menu
83 menu := []string{
84 md.Link("View Proposals", daoProposalsURL(dao.ID())),
85 md.Link("View Settings", settingsURL(dao.ID())),
86 }
87
88 if parentDAO := dao.Parent(); parentDAO != nil {
89 menu = append(menu, md.Link("Go to Parent DAO", daoURL(parentDAO.ID())))
90 }
91
92 res.Write(md.Paragraph(strings.Join(menu, " • ")))
93 res.Write(md.HorizontalRule())
94
95 // Render members
96 members := dao.Members()
97 if members.Size() == 0 {
98 res.Write(md.Paragraph(md.Bold("⚠ The DAO has no members")))
99 } else {
100 renderMembers(res, req.RawPath, members)
101 }
102
103 // Render organization tree
104 if dao.Children().Len() > 0 {
105 r := parseRealmPath(req.RawPath)
106 dissolvedVisible := r.Query.Has("dissolved") || dao.IsDeleted()
107 if dissolvedVisible || !dao.IsDeleted() {
108 res.Write(md.H2("Tree"))
109
110 // Render toggle only when DAO is not dissolved,
111 // otherwise render the whole tree when DAO is dissolved
112 if !dao.IsDeleted() {
113 var toggleLink string
114 if dissolvedVisible {
115 r.Query.Del("dissolved")
116 toggleLink = md.Link("hide", r.String())
117 } else {
118 r.Query.Add("dissolved", "")
119 toggleLink = md.Link("show", r.String())
120 }
121
122 res.Write(md.Paragraph("Dissolved: " + toggleLink))
123 }
124
125 renderTree(res, dao, "", dissolvedVisible)
126 }
127 }
128
129 // Render latest proposals
130 proposals := dao.ActiveProposals()
131 if proposals.Size() > 0 {
132 res.Write(md.H2("Latest Proposals"))
133 proposals.Iterate(0, 3, true, func(p *commondao.Proposal) bool {
134 renderProposalsListItem(res, dao, p)
135 return false
136 })
137 }
138
139 // Render proposal creation links
140 if !dao.IsDeleted() {
141 renderCreateProposalSection(dao.ID(), res)
142 }
143}
144
145func renderCreateProposalSection(daoID uint64, res *mux.ResponseWriter) {
146 var (
147 cols []string
148 o = getOptions(daoID)
149 )
150
151 if o.AllowTextProposals {
152 cols = append(cols, md.Paragraph(textProposalLink(daoID))+
153 md.Paragraph(
154 "This type of proposal is also known as text proposal which can be used for example "+
155 "to get consensus on initiatives without actually making any change on-chain.",
156 ),
157 )
158 }
159
160 if o.AllowSubDAOProposals {
161 cols = append(cols, md.Paragraph(newSubDAOLink(daoID))+
162 md.Paragraph(
163 "This type of proposal is used to create SubDAOs, "+
164 "which are used to create tree based DAOs.",
165 ),
166 )
167 }
168
169 if o.AllowDissolutionProposals {
170 cols = append(cols, md.Paragraph(dissolveSubDAOLink(daoID))+
171 md.Paragraph(
172 "This type of proposal can be used to dissolve DAOs and SubDAOs.",
173 )+
174 md.Paragraph(
175 "Dissolving a DAO can't be undone, once the dissolution proposal passes "+
176 "and is executed DAO will be readonly.",
177 ),
178 )
179 }
180
181 if o.AllowMembersUpdate {
182 cols = append(cols, md.Paragraph(updateMembersLink(daoID))+
183 md.Paragraph(
184 "This type of proposal can be used to add new members to this DAO "+
185 "and also to remove existing ones.",
186 )+
187 md.Paragraph(
188 "A single proposal allows new members to be added and any number of existing ones "+
189 "removed within the same proposal.",
190 ),
191 )
192 }
193
194 if len(cols) == 0 {
195 return
196 }
197
198 res.Write(md.H2("Create Proposal"))
199 res.Write(md.Paragraph("These are the proposal supported by this DAO:"))
200 res.Write(md.Columns(cols, false))
201}
202
203func renderMembers(res *mux.ResponseWriter, path string, members commondao.MemberStorage) {
204 pages, err := pager.New(path, members.Size(), pager.WithPageQueryParam("members"), pager.WithPageSize(8))
205 if err != nil {
206 res.Write(err.Error())
207 return
208 }
209
210 table := mdtable.Table{Headers: []string{"Members"}}
211 members.IterateByOffset(pages.Offset(), pages.PageSize(), func(addr address) bool {
212 table.Append([]string{userLink(addr)})
213 return false
214 })
215
216 res.Write(md.Paragraph(table.String()))
217
218 if pages.HasPages() {
219 res.Write(md.Paragraph(pager.Picker(pages)))
220 }
221}
222
223func renderTree(res *mux.ResponseWriter, dao *commondao.CommonDAO, indent string, showDissolved bool) {
224 daoLink := md.Link(dao.Name(), daoURL(dao.ID()))
225 if dao.IsDeleted() {
226 // Strikethough dissolved DAO names
227 daoLink = md.Strikethrough(daoLink)
228 }
229
230 res.Write(indent + md.BulletItem(daoLink))
231
232 indent += " "
233 dao.Children().ForEach(func(_ int, v any) bool {
234 subDAO, ok := v.(*commondao.CommonDAO)
235 if !ok {
236 return false
237 }
238
239 if showDissolved || !subDAO.IsDeleted() {
240 renderTree(res, subDAO, indent, showDissolved)
241 }
242 return false
243 })
244}
245
246func renderSettings(res *mux.ResponseWriter, req *mux.Request) {
247 dao := mustGetDAOFromRequest(req)
248 o := getOptions(dao.ID())
249
250 // Render header
251 res.Write(md.H1(dao.Name() + ": Settings"))
252
253 // Render main menu
254 res.Write(md.Paragraph(goToDAOLink(dao.ID())))
255 res.Write(md.HorizontalRule())
256
257 // Render options
258 table := mdtable.Table{Headers: []string{"Options", "Values"}}
259 table.Append([]string{"Allow Render", strconv.FormatBool(o.AllowRender)})
260 table.Append([]string{"Allow SubDAOs", strconv.FormatBool(o.AllowChildren)})
261 table.Append([]string{"Enable Voting", strconv.FormatBool(o.AllowVoting)})
262 table.Append([]string{"Enable Proposal Execution", strconv.FormatBool(o.AllowExecution)})
263 table.Append([]string{"Text Proposals", strconv.FormatBool(o.AllowTextProposals)})
264 table.Append([]string{"Members Update proposals", strconv.FormatBool(o.AllowMembersUpdate)})
265 table.Append([]string{"SubDAO creation proposals", strconv.FormatBool(o.AllowSubDAOProposals)})
266 table.Append([]string{"DAO dissolution proposals", strconv.FormatBool(o.AllowDissolutionProposals)})
267
268 res.Write(md.H2("Options"))
269 res.Write(table.String())
270}
271
272func renderProposalsList(res *mux.ResponseWriter, req *mux.Request) {
273 dao := mustGetDAOFromRequest(req)
274
275 // Render header
276 res.Write(md.H1(dao.Name() + ": Proposals"))
277
278 // Render main menu
279 res.Write(md.Paragraph(goToDAOLink(dao.ID())))
280 res.Write(md.HorizontalRule())
281
282 // Render proposals
283 if dao.ActiveProposals().Size() == 0 && dao.FinishedProposals().Size() == 0 {
284 res.Write(md.Paragraph(md.Bold("⚠ The DAO has no proposals")))
285 return
286 }
287
288 proposals := dao.ActiveProposals()
289 renderFinished := req.Query.Has("finished")
290 if renderFinished {
291 proposals = dao.FinishedProposals()
292 }
293
294 pages, err := pager.New(req.RawPath, proposals.Size(), pager.WithPageSize(8))
295 if err != nil {
296 res.Write(err.Error())
297 return
298 }
299
300 var viewLink, sortLink string
301
302 r := parseRealmPath(req.RawPath)
303 if renderFinished {
304 r.Query.Del("finished")
305 viewLink = md.Link("active", r.String())
306 } else {
307 r.Query.Add("finished", "")
308 viewLink = md.Link("finished", r.String())
309 }
310
311 r = parseRealmPath(req.RawPath)
312 reverseSort := r.Query.Get("order") != "asc"
313 if reverseSort {
314 r.Query.Set("order", "asc")
315 sortLink = md.Link("oldest", r.String())
316 } else {
317 r.Query.Set("order", "desc")
318 sortLink = md.Link("newest", r.String())
319 }
320
321 res.Write(md.Paragraph("View: " + viewLink + " • Sort by: " + sortLink))
322
323 if proposals.Size() == 0 {
324 if renderFinished {
325 res.Write(md.Paragraph("Currently there are no finished proposals"))
326 } else {
327 res.Write(md.Paragraph("Currently there are no active proposals"))
328 }
329 } else {
330 proposals.Iterate(pages.Offset(), pages.PageSize(), reverseSort, func(p *commondao.Proposal) bool {
331 renderProposalsListItem(res, dao, p)
332 return false
333 })
334 }
335
336 // Render pager
337 if pages.HasPages() {
338 res.Write(md.HorizontalRule())
339 res.Write(pager.Picker(pages))
340 }
341}
342
343func renderProposalsListItem(res *mux.ResponseWriter, dao *commondao.CommonDAO, p *commondao.Proposal) {
344 def := p.Definition()
345 record := p.VotingRecord()
346 o := getOptions(dao.ID())
347
348 // Render title
349 res.Write(ufmt.Sprintf("**[#%d %s](%s)** \n", p.ID(), md.EscapeText(def.Title()), proposalURL(dao.ID(), p.ID())))
350
351 // Render details
352 res.Write(ufmt.Sprintf("Created by %s \n", userLink(p.Creator())))
353 res.Write(ufmt.Sprintf("Voting ends on %s \n", p.VotingDeadline().UTC().Format(dateFormat)))
354
355 // Render status
356 status := []string{
357 ufmt.Sprintf("Votes: **%d**", record.Size()),
358 ufmt.Sprintf("Status: **%s**", string(p.Status())),
359 }
360
361 // Render actions
362 if o.AllowVoting && isVotingPeriodActive(p) {
363 status = append(status, voteLink(dao.ID(), p.ID()))
364 }
365
366 if o.AllowExecution && isExecutionAllowed(p) {
367 status = append(status, executeLink(dao.ID(), p.ID()))
368 }
369
370 res.Write(md.Paragraph(strings.Join(status, " • ")))
371}
372
373func renderProposal(res *mux.ResponseWriter, req *mux.Request) {
374 dao := mustGetDAOFromRequest(req)
375 p := mustGetProposalFromRequest(req, dao)
376
377 // Check that proposal has no issues
378 if err := p.Validate(); err != nil {
379 res.Write(md.Blockquote("⚠ **ERROR**: " + err.Error()))
380 }
381
382 votingActive := isVotingPeriodActive(p)
383 if votingActive {
384 res.Write(
385 md.Blockquote("Voting ends on " + md.Bold(p.VotingDeadline().UTC().Format(dateFormat))),
386 )
387 }
388
389 def := p.Definition()
390
391 // Render header
392 res.Write(md.H1("#" + strconv.FormatUint(p.ID(), 10) + " " + md.EscapeText(def.Title())))
393
394 // Render main menu
395 items := []string{goToDAOLink(dao.ID())}
396 o := getOptions(dao.ID())
397 if o.AllowVoting && votingActive {
398 items = append(items, voteLink(dao.ID(), p.ID()))
399 }
400
401 if o.AllowExecution && isExecutionAllowed(p) {
402 items = append(items, executeLink(dao.ID(), p.ID()))
403 }
404
405 res.Write(md.Paragraph(strings.Join(items, " • ")))
406 res.Write(md.HorizontalRule())
407
408 // Render details
409 res.Write(md.H2("Details"))
410 res.Write(md.BulletItem("Proposer: " + userLink(p.Creator())))
411 res.Write(md.BulletItem("Submit Time: " + p.CreatedAt().UTC().Format(time.RFC1123)))
412
413 record := p.VotingRecord()
414 if p.Status() == commondao.StatusActive {
415 ctx := commondao.MustNewVotingContext(record, dao.Members())
416 passes, _ := def.Tally(ctx)
417 if passes {
418 res.Write(md.BulletItem("Expected Outcome: **pass** ☑"))
419 } else {
420 res.Write(md.BulletItem("Expected Outcome: **fail** ☒"))
421 }
422 }
423
424 statusItem := "Status: " + md.Bold(string(p.Status()))
425 if reason := p.StatusReason(); reason != "" {
426 statusItem += " • " + md.Italic(reason)
427 }
428 res.Write(md.BulletItem(statusItem))
429
430 // Render proposal body
431 if body := def.Body(); body != "" {
432 res.Write(md.H2("Description"))
433 res.Write(md.Paragraph(body))
434 }
435
436 // Render voting stats and votes
437 if record.Size() > 0 {
438 renderProposalStats(res, record)
439 renderProposalVotes(res, req.RawPath, dao, p)
440 }
441}
442
443func renderProposalStats(res *mux.ResponseWriter, record *commondao.VotingRecord) {
444 totalCount := float64(record.Size())
445 table := mdtable.Table{Headers: []string{"Vote Choices", "Percentage of Votes"}}
446
447 record.IterateVotesCount(func(c commondao.VoteChoice, voteCount int) bool {
448 percentage := float64(voteCount*100) / totalCount
449
450 table.Append([]string{string(c), strconv.FormatFloat(percentage, 'f', 2, 64) + "%"})
451 return false
452 })
453
454 res.Write(md.H2("Stats"))
455 res.Write(md.Paragraph(table.String()))
456}
457
458func renderProposalVotes(res *mux.ResponseWriter, path string, dao *commondao.CommonDAO, p *commondao.Proposal) {
459 res.Write(md.H2("Votes")) // Render title here so it appears before any pager errors
460
461 record := p.VotingRecord()
462 pages, err := pager.New(path, record.Size(), pager.WithPageQueryParam("votes"), pager.WithPageSize(5))
463 if err != nil {
464 res.Write(err.Error())
465 return
466 }
467
468 table := mdtable.Table{Headers: []string{"Users", "Votes"}}
469 record.Iterate(pages.Offset(), pages.PageSize(), false, func(v commondao.Vote) bool {
470 voteDetails := md.Link(string(v.Choice), voteURL(dao.ID(), p.ID(), v.Address))
471 if v.Reason != "" {
472 voteDetails += " with a reason"
473 }
474
475 table.Append([]string{userLink(v.Address), voteDetails})
476 return false
477 })
478
479 res.Write(ufmt.Sprintf("Total number of votes: **%d**\n", record.Size()))
480 res.Write(md.Paragraph(table.String()))
481
482 if pages.HasPages() {
483 res.Write(md.Paragraph(pager.Picker(pages)))
484 }
485}
486
487func renderProposalVote(res *mux.ResponseWriter, req *mux.Request) {
488 member := address(req.GetVar("address"))
489 if !member.IsValid() {
490 res.Write("Invalid address")
491 return
492 }
493
494 dao := mustGetDAOFromRequest(req)
495 p := mustGetProposalFromRequest(req, dao)
496 v, found := p.VotingRecord().GetVote(member)
497 if !found {
498 res.Write("Vote not found")
499 return
500 }
501
502 links := []string{
503 goToDAOLink(dao.ID()),
504 goToProposalLink(dao.ID(), p.ID()),
505 }
506
507 res.Write(ufmt.Sprintf("# Vote: Proposal #%d\n", p.ID()))
508 res.Write(md.Paragraph(strings.Join(links, " • ")))
509 res.Write(md.HorizontalRule())
510
511 res.Write(md.H2("Details"))
512 res.Write(md.BulletItem("User: " + userLink(v.Address)))
513 res.Write(md.BulletItem("Vote: " + string(v.Choice)))
514
515 if v.Reason != "" {
516 res.Write(md.H2("Reason"))
517 res.Write(v.Reason)
518 }
519}
520
521func mustGetDAOFromRequest(req *mux.Request) *commondao.CommonDAO {
522 rawID := req.GetVar("daoID")
523 daoID, err := strconv.ParseUint(rawID, 10, 64)
524 if err != nil {
525 panic("Invalid DAO ID")
526 }
527
528 o := getOptions(daoID)
529 if o == nil || !o.AllowRender {
530 panic("Forbidden")
531 }
532
533 return mustGetDAO(daoID)
534}
535
536func mustGetProposalFromRequest(req *mux.Request, dao *commondao.CommonDAO) *commondao.Proposal {
537 rawID := req.GetVar("proposalID")
538 proposalID, err := strconv.ParseUint(rawID, 10, 64)
539 if err != nil {
540 panic("Invalid proposal ID")
541 }
542
543 p := dao.GetProposal(proposalID)
544 if p == nil {
545 panic("Proposal not found")
546 }
547 return p
548}
549
550func parseRealmPath(path string) *realmpath.Request {
551 r := realmpath.Parse(path)
552 r.Realm = string(realmLink)
553 return r
554}
555
556func voteLink(daoID, proposalID uint64) string {
557 return md.Link("Vote", realmLink.Call(
558 "Vote",
559 "daoID", strconv.FormatUint(daoID, 10),
560 "proposalID", strconv.FormatUint(proposalID, 10),
561 "vote", "",
562 "reason", "",
563 ))
564}
565
566func executeLink(daoID, proposalID uint64) string {
567 return md.Link("Execute", realmLink.Call(
568 "Execute",
569 "daoID", strconv.FormatUint(daoID, 10),
570 "proposalID", strconv.FormatUint(proposalID, 10),
571 ))
572}
573
574func textProposalLink(daoID uint64) string {
575 return ufmt.Sprintf("[General Proposal](%s)", realmLink.Call(
576 "CreateTextProposal",
577 "daoID", strconv.FormatUint(daoID, 10),
578 "title", "",
579 "body", "",
580 "votingDays", "7",
581 ))
582}
583
584func updateMembersLink(daoID uint64) string {
585 return md.Link("Update Members", realmLink.Call(
586 "CreateMembersUpdateProposal",
587 "daoID", strconv.FormatUint(daoID, 10),
588 "newMembers", "",
589 "removeMembers", "",
590 ))
591}
592
593func newSubDAOLink(daoID uint64) string {
594 return md.Link("New SubDAO", realmLink.Call(
595 "CreateSubDAOProposal",
596 "daoID", strconv.FormatUint(daoID, 10),
597 "name", "",
598 "members", "",
599 ))
600}
601
602func dissolveSubDAOLink(daoID uint64) string {
603 return md.Link("Dissolve DAO", realmLink.Call(
604 "CreateDissolutionProposal",
605 "daoID", strconv.FormatUint(daoID, 10),
606 ))
607}
608
609func goToDAOLink(daoID uint64) string {
610 return md.Link("Go to DAO", daoURL(daoID))
611}
612
613func goToProposalLink(daoID, proposalID uint64) string {
614 return md.Link("Go to Proposal", proposalURL(daoID, proposalID))
615}
616
617func userLink(addr address) string {
618 user := users.ResolveAddress(addr)
619 if user != nil {
620 return user.RenderLink("")
621 }
622 return addr.String()
623}
624
625func isVotingPeriodActive(p *commondao.Proposal) bool {
626 return p.Status() == commondao.StatusActive && time.Now().Before(p.VotingDeadline())
627}
628
629func isExecutionAllowed(p *commondao.Proposal) bool {
630 return p.Status() == commondao.StatusActive && !time.Now().Before(p.VotingDeadline())
631}