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