From f660be5f4939af1d50c21a5040e046d1fd26be1a Mon Sep 17 00:00:00 2001
From: Leon Hudak <33522493+leohhhn@users.noreply.github.com>
Date: Fri, 26 Apr 2024 21:26:37 +0200
Subject: [PATCH] feat(examples): add simple userbook realm (#1949)
## Description
This PR arose out of a necessity to have a simple, permissionless realm
for demonstration purposes. The idea is that people can sign up once
with their address, and the Render function will paginate 50 addresses
per page, as well as include a UI to go to the next/previous page. This
realm will serve 3 purposes:
- Show how to do pagination
- Show how to use Render in harmony with `gnoweb`
- Exist as a simple entry point for new developers testing out the
chain. This realm will be used as an example in the Getting started
section in the docs.
Contributors' checklist...
- [ ] Added new tests, or not needed, or not feasible
- [ ] Provided an example (e.g. screenshot) to aid review or the PR is
self-explanatory
- [ ] Updated the official documentation or not needed
- [ ] No breaking changes were made, or a `BREAKING CHANGE: xxx` message
was included in the description
- [ ] Added references to related issues and PRs
- [ ] Provided any useful hints for running manual tests
- [ ] Added new benchmarks to [generated
graphs](https://gnoland.github.io/benchmarks), if any. More info
[here](https://github.com/gnolang/gno/blob/master/.benchmarks/README.md).
---------
Co-authored-by: deelawn
---
examples/gno.land/r/demo/userbook/gno.mod | 8 +
.../gno.land/r/demo/userbook/userbook.gno | 153 ++++++++++++++++++
.../r/demo/userbook/userbook_test.gno | 79 +++++++++
3 files changed, 240 insertions(+)
create mode 100644 examples/gno.land/r/demo/userbook/gno.mod
create mode 100644 examples/gno.land/r/demo/userbook/userbook.gno
create mode 100644 examples/gno.land/r/demo/userbook/userbook_test.gno
diff --git a/examples/gno.land/r/demo/userbook/gno.mod b/examples/gno.land/r/demo/userbook/gno.mod
new file mode 100644
index 00000000000..213586d12ee
--- /dev/null
+++ b/examples/gno.land/r/demo/userbook/gno.mod
@@ -0,0 +1,8 @@
+module gno.land/r/demo/userbook
+
+require (
+ gno.land/p/demo/avl v0.0.0-latest
+ gno.land/p/demo/mux v0.0.0-latest
+ gno.land/p/demo/testutils v0.0.0-latest
+ gno.land/p/demo/ufmt v0.0.0-latest
+)
diff --git a/examples/gno.land/r/demo/userbook/userbook.gno b/examples/gno.land/r/demo/userbook/userbook.gno
new file mode 100644
index 00000000000..85b76cbf28f
--- /dev/null
+++ b/examples/gno.land/r/demo/userbook/userbook.gno
@@ -0,0 +1,153 @@
+// This realm demonstrates a small userbook system working with gnoweb
+package userbook
+
+import (
+ "std"
+ "strconv"
+
+ "gno.land/p/demo/avl"
+ "gno.land/p/demo/mux"
+ "gno.land/p/demo/ufmt"
+)
+
+type Signup struct {
+ account string
+ height int64
+}
+
+// signups - keep a slice of signed up addresses efficient pagination
+var signups []Signup
+
+// tracker - keep track of who signed up
+var (
+ tracker *avl.Tree
+ router *mux.Router
+)
+
+const (
+ defaultPageSize = 20
+ pathArgument = "number"
+ subPath = "page/{" + pathArgument + "}"
+)
+
+func init() {
+ // Set up tracker tree
+ tracker = avl.NewTree()
+
+ // Set up route handling
+ router = mux.NewRouter()
+ router.HandleFunc("", renderHelper)
+ router.HandleFunc(subPath, renderHelper)
+
+ // Sign up the deployer
+ SignUp()
+}
+
+func SignUp() string {
+ // Get transaction caller
+ caller := std.PrevRealm().Addr().String()
+ height := std.GetHeight()
+
+ if _, exists := tracker.Get(caller); exists {
+ panic(caller + " is already signed up!")
+ }
+
+ tracker.Set(caller, struct{}{})
+ signup := Signup{
+ caller,
+ height,
+ }
+
+ signups = append(signups, signup)
+ return ufmt.Sprintf("%s added to userbook up at block #%d!", signup.account, signup.height)
+}
+
+func GetSignupsInRange(page, pageSize int) ([]Signup, int) {
+ if page < 1 {
+ panic("page number cannot be less than 1")
+ }
+
+ if pageSize < 1 || pageSize > 50 {
+ panic("page size must be from 1 to 50")
+ }
+
+ // Pagination
+ // Calculate indexes
+ startIndex := (page - 1) * pageSize
+ endIndex := startIndex + pageSize
+
+ // If page does not contain any users
+ if startIndex >= len(signups) {
+ return nil, -1
+ }
+
+ // If page contains fewer users than the page size
+ if endIndex > len(signups) {
+ endIndex = len(signups)
+ }
+
+ return signups[startIndex:endIndex], endIndex
+}
+
+func renderHelper(res *mux.ResponseWriter, req *mux.Request) {
+ totalSignups := len(signups)
+ res.Write("# Welcome to UserBook!\n\n")
+
+ // Get URL parameter
+ page, err := strconv.Atoi(req.GetVar("number"))
+ if err != nil {
+ page = 1 // render first page on bad input
+ }
+
+ // Fetch paginated signups
+ fetchedSignups, endIndex := GetSignupsInRange(page, defaultPageSize)
+ // Handle empty page case
+ if len(fetchedSignups) == 0 {
+ res.Write("No users on this page!\n\n")
+ res.Write("---\n\n")
+ res.Write("[Back to Page #1](/r/demo/userbook:page/1)\n\n")
+ return
+ }
+
+ // Write page title
+ res.Write(ufmt.Sprintf("## UserBook - Page #%d:\n\n", page))
+
+ // Write signups
+ pageStartIndex := defaultPageSize * (page - 1)
+ for i, signup := range fetchedSignups {
+ out := ufmt.Sprintf("#### User #%d - %s - signed up at Block #%d\n", pageStartIndex+i, signup.account, signup.height)
+ res.Write(out)
+ }
+
+ res.Write("---\n\n")
+
+ // Write UserBook info
+ latestSignupIndex := totalSignups - 1
+ res.Write(ufmt.Sprintf("#### Total users: %d\n", totalSignups))
+ res.Write(ufmt.Sprintf("#### Latest signup: User #%d at Block #%d\n", latestSignupIndex, signups[latestSignupIndex].height))
+
+ res.Write("---\n\n")
+
+ // Write page number
+ res.Write(ufmt.Sprintf("You're viewing page #%d", page))
+
+ // Write navigation buttons
+ var prevPage string
+ var nextPage string
+ // If we are on any page that is not the first page
+ if page > 1 {
+ prevPage = ufmt.Sprintf(" - [Previous page](/r/demo/userbook:page/%d)", page-1)
+ }
+
+ // If there are more pages after the current one
+ if endIndex < totalSignups {
+ nextPage = ufmt.Sprintf(" - [Next page](/r/demo/userbook:page/%d)\n\n", page+1)
+ }
+
+ res.Write(prevPage)
+ res.Write(nextPage)
+}
+
+func Render(path string) string {
+ return router.Render(path)
+}
diff --git a/examples/gno.land/r/demo/userbook/userbook_test.gno b/examples/gno.land/r/demo/userbook/userbook_test.gno
new file mode 100644
index 00000000000..8d10d381e08
--- /dev/null
+++ b/examples/gno.land/r/demo/userbook/userbook_test.gno
@@ -0,0 +1,79 @@
+package userbook
+
+import (
+ "std"
+ "strings"
+ "testing"
+
+ "gno.land/p/demo/testutils"
+ "gno.land/p/demo/ufmt"
+)
+
+func TestRender(t *testing.T) {
+ // Sign up 20 users + deployer
+ for i := 0; i < 20; i++ {
+ addrName := ufmt.Sprintf("test%d", i)
+ caller := testutils.TestAddress(addrName)
+ std.TestSetOrigCaller(caller)
+ SignUp()
+ }
+
+ testCases := []struct {
+ name string
+ nextPage bool
+ prevPage bool
+ path string
+ expectedNumberOfUsers int
+ }{
+ {
+ name: "1st page render",
+ nextPage: true,
+ prevPage: false,
+ path: "page/1",
+ expectedNumberOfUsers: 20,
+ },
+ {
+ name: "2nd page render",
+ nextPage: false,
+ prevPage: true,
+ path: "page/2",
+ expectedNumberOfUsers: 1,
+ },
+ {
+ name: "Invalid path render",
+ nextPage: true,
+ prevPage: false,
+ path: "page/invalidtext",
+ expectedNumberOfUsers: 20,
+ },
+ {
+ name: "Empty Page",
+ nextPage: false,
+ prevPage: false,
+ path: "page/1000",
+ expectedNumberOfUsers: 0,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := Render(tc.path)
+ numUsers := countUsers(got)
+
+ if tc.prevPage && !strings.Contains(got, "Previous page") {
+ t.Fatalf("expected to find Previous page, didn't find it")
+ }
+ if tc.nextPage && !strings.Contains(got, "Next page") {
+ t.Fatalf("expected to find Next page, didn't find it")
+ }
+
+ if tc.expectedNumberOfUsers != numUsers {
+ t.Fatalf("expected %d, got %d users", tc.expectedNumberOfUsers, numUsers)
+ }
+ })
+ }
+}
+
+func countUsers(input string) int {
+ return strings.Count(input, "#### User #")
+}