render.gno

16.75 Kb · 630 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"
 13	"gno.land/p/nt/mux"
 14	"gno.land/p/nt/ufmt"
 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/"),
 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(), 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) + " " + 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		passes, _ := def.Tally(record.Readonly(), commondao.NewMemberSet(dao.Members()))
416		if passes {
417			res.Write(md.BulletItem("Expected Outcome: **pass** ☑"))
418		} else {
419			res.Write(md.BulletItem("Expected Outcome: **fail** ☒"))
420		}
421	}
422
423	statusItem := "Status: " + md.Bold(string(p.Status()))
424	if reason := p.StatusReason(); reason != "" {
425		statusItem += " • " + md.Italic(reason)
426	}
427	res.Write(md.BulletItem(statusItem))
428
429	// Render proposal body
430	if body := def.Body(); body != "" {
431		res.Write(md.H2("Description"))
432		res.Write(md.Paragraph(body))
433	}
434
435	// Render voting stats and votes
436	if record.Size() > 0 {
437		renderProposalStats(res, record)
438		renderProposalVotes(res, req.RawPath, dao, p)
439	}
440}
441
442func renderProposalStats(res *mux.ResponseWriter, record *commondao.VotingRecord) {
443	totalCount := float64(record.Size())
444	table := mdtable.Table{Headers: []string{"Vote Choices", "Percentage of Votes"}}
445
446	record.IterateVotesCount(func(c commondao.VoteChoice, voteCount int) bool {
447		percentage := float64(voteCount*100) / totalCount
448
449		table.Append([]string{string(c), strconv.FormatFloat(percentage, 'f', 2, 64) + "%"})
450		return false
451	})
452
453	res.Write(md.H2("Stats"))
454	res.Write(md.Paragraph(table.String()))
455}
456
457func renderProposalVotes(res *mux.ResponseWriter, path string, dao *commondao.CommonDAO, p *commondao.Proposal) {
458	res.Write(md.H2("Votes")) // Render title here so it appears before any pager errors
459
460	record := p.VotingRecord()
461	pages, err := pager.New(path, record.Size(), pager.WithPageQueryParam("votes"), pager.WithPageSize(5))
462	if err != nil {
463		res.Write(err.Error())
464		return
465	}
466
467	table := mdtable.Table{Headers: []string{"Users", "Votes"}}
468	record.Iterate(pages.Offset(), pages.PageSize(), false, func(v commondao.Vote) bool {
469		voteDetails := md.Link(string(v.Choice), voteURL(dao.ID(), p.ID(), v.Address))
470		if v.Reason != "" {
471			voteDetails += " with a reason"
472		}
473
474		table.Append([]string{userLink(v.Address), voteDetails})
475		return false
476	})
477
478	res.Write(ufmt.Sprintf("Total number of votes: **%d**\n", record.Size()))
479	res.Write(md.Paragraph(table.String()))
480
481	if pages.HasPages() {
482		res.Write(md.Paragraph(pager.Picker(pages)))
483	}
484}
485
486func renderProposalVote(res *mux.ResponseWriter, req *mux.Request) {
487	member := address(req.GetVar("address"))
488	if !member.IsValid() {
489		res.Write("Invalid address")
490		return
491	}
492
493	dao := mustGetDAOFromRequest(req)
494	p := mustGetProposalFromRequest(req, dao)
495	v, found := p.VotingRecord().GetVote(member)
496	if !found {
497		res.Write("Vote not found")
498		return
499	}
500
501	links := []string{
502		goToDAOLink(dao.ID()),
503		goToProposalLink(dao.ID(), p.ID()),
504	}
505
506	res.Write(ufmt.Sprintf("# Vote: Proposal #%d\n", p.ID()))
507	res.Write(md.Paragraph(strings.Join(links, " • ")))
508	res.Write(md.HorizontalRule())
509
510	res.Write(md.H2("Details"))
511	res.Write(md.BulletItem("User: " + userLink(v.Address)))
512	res.Write(md.BulletItem("Vote: " + string(v.Choice)))
513
514	if v.Reason != "" {
515		res.Write(md.H2("Reason"))
516		res.Write(v.Reason)
517	}
518}
519
520func mustGetDAOFromRequest(req *mux.Request) *commondao.CommonDAO {
521	rawID := req.GetVar("daoID")
522	daoID, err := strconv.ParseUint(rawID, 10, 64)
523	if err != nil {
524		panic("Invalid DAO ID")
525	}
526
527	o := getOptions(daoID)
528	if o == nil || !o.AllowRender {
529		panic("Forbidden")
530	}
531
532	return mustGetDAO(daoID)
533}
534
535func mustGetProposalFromRequest(req *mux.Request, dao *commondao.CommonDAO) *commondao.Proposal {
536	rawID := req.GetVar("proposalID")
537	proposalID, err := strconv.ParseUint(rawID, 10, 64)
538	if err != nil {
539		panic("Invalid proposal ID")
540	}
541
542	p := dao.GetProposal(proposalID)
543	if p == nil {
544		panic("Proposal not found")
545	}
546	return p
547}
548
549func parseRealmPath(path string) *realmpath.Request {
550	r := realmpath.Parse(path)
551	r.Realm = string(realmLink)
552	return r
553}
554
555func voteLink(daoID, proposalID uint64) string {
556	return md.Link("Vote", realmLink.Call(
557		"Vote",
558		"daoID", strconv.FormatUint(daoID, 10),
559		"proposalID", strconv.FormatUint(proposalID, 10),
560		"vote", "",
561		"reason", "",
562	))
563}
564
565func executeLink(daoID, proposalID uint64) string {
566	return md.Link("Execute", realmLink.Call(
567		"Execute",
568		"daoID", strconv.FormatUint(daoID, 10),
569		"proposalID", strconv.FormatUint(proposalID, 10),
570	))
571}
572
573func textProposalLink(daoID uint64) string {
574	return ufmt.Sprintf("[General Proposal](%s)", realmLink.Call(
575		"CreateTextProposal",
576		"daoID", strconv.FormatUint(daoID, 10),
577		"title", "",
578		"body", "",
579		"votingDays", "7",
580	))
581}
582
583func updateMembersLink(daoID uint64) string {
584	return md.Link("Update Members", realmLink.Call(
585		"CreateMembersUpdateProposal",
586		"daoID", strconv.FormatUint(daoID, 10),
587		"newMembers", "",
588		"removeMembers", "",
589	))
590}
591
592func newSubDAOLink(daoID uint64) string {
593	return md.Link("New SubDAO", realmLink.Call(
594		"CreateSubDAOProposal",
595		"daoID", strconv.FormatUint(daoID, 10),
596		"name", "",
597		"members", "",
598	))
599}
600
601func dissolveSubDAOLink(daoID uint64) string {
602	return md.Link("Dissolve DAO", realmLink.Call(
603		"CreateDissolutionProposal",
604		"daoID", strconv.FormatUint(daoID, 10),
605	))
606}
607
608func goToDAOLink(daoID uint64) string {
609	return md.Link("Go to DAO", daoURL(daoID))
610}
611
612func goToProposalLink(daoID, proposalID uint64) string {
613	return md.Link("Go to Proposal", proposalURL(daoID, proposalID))
614}
615
616func userLink(addr address) string {
617	user := users.ResolveAddress(addr)
618	if user != nil {
619		return user.RenderLink("")
620	}
621	return addr.String()
622}
623
624func isVotingPeriodActive(p *commondao.Proposal) bool {
625	return p.Status() == commondao.StatusActive && time.Now().Before(p.VotingDeadline())
626}
627
628func isExecutionAllowed(p *commondao.Proposal) bool {
629	return p.Status() == commondao.StatusActive && !time.Now().Before(p.VotingDeadline())
630}