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 #") +}