Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}