From da75b09c90091d602813895b420f42414921763b Mon Sep 17 00:00:00 2001 From: Philip Peterson Date: Mon, 26 May 2025 16:06:25 -0700 Subject: [PATCH] POC: Use gomponents for Explore Users page --- components/components.go | 25 ++++++ components/explore_navbar.go | 66 +++++++++++++++ components/explore_search.go | 45 ++++++++++ components/explore_users_page.go | 62 ++++++++++++++ components/search_button.go | 32 +++++++ components/search_combo.go | 19 +++++ components/search_input.go | 20 +++++ components/sort_option.go | 21 +++++ components/user_list.go | 92 ++++++++++++++++++++ components/user_name.go | 26 ++++++ go.mod | 1 + go.sum | 2 + modules/templates/htmlrenderer.go | 11 +++ routers/web/explore/user.go | 117 +++++++++++++++++++++++++- services/context/context.go | 1 + services/context/context_response.go | 26 ++++++ services/contexttest/context_tests.go | 7 ++ 17 files changed, 569 insertions(+), 4 deletions(-) create mode 100644 components/components.go create mode 100644 components/explore_navbar.go create mode 100644 components/explore_search.go create mode 100644 components/explore_users_page.go create mode 100644 components/search_button.go create mode 100644 components/search_combo.go create mode 100644 components/search_input.go create mode 100644 components/sort_option.go create mode 100644 components/user_list.go create mode 100644 components/user_name.go diff --git a/components/components.go b/components/components.go new file mode 100644 index 0000000000..17ad772eee --- /dev/null +++ b/components/components.go @@ -0,0 +1,25 @@ +package components + +import ( + "code.gitea.io/gitea/modules/svg" + g "maragu.dev/gomponents" +) + +func If(condition bool, node g.Node) g.Node { + if condition { + return node + } + return nil +} + +func SVG(icon string, others ...any) g.Node { + return g.Raw(string(svg.RenderHTML(icon))) +} + +// Utility to add "active" class if condition is true +func classIf(condition bool, class string) string { + if condition { + return class + } + return "" +} diff --git a/components/explore_navbar.go b/components/explore_navbar.go new file mode 100644 index 0000000000..e060f67540 --- /dev/null +++ b/components/explore_navbar.go @@ -0,0 +1,66 @@ +package components + +import ( + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/translation" + g "maragu.dev/gomponents" + gh "maragu.dev/gomponents/html" +) + +type ExploreNavbarProps struct { + PageIsExploreRepositories bool + UsersPageIsDisabled bool + AppSubUrl string + PageIsExploreUsers bool + PageIsExploreCode bool + IsRepoIndexerEnabled bool + CodePageIsDisabled bool + PageIsExploreOrganizations bool + OrganizationsPageIsDisabled bool + Locale translation.Locale +} + +func ExploreNavbar(data ExploreNavbarProps) g.Node { + tr := func(key string) string { + return string(data.Locale.Tr(key)) + } + + isCodeGlobalDisabled := unit.TypeCode.UnitGlobalDisabled() + + return g.El("overflow-menu", + gh.Class("ui secondary pointing tabular top attached borderless menu secondary-nav"), + gh.Div( + gh.Class("overflow-menu-items tw-justify-center"), + gh.A( + gh.Class(classIf(data.PageIsExploreRepositories, "active ")+"item"), + gh.Href(data.AppSubUrl+"/explore/repos"), + SVG("octicon-repo"), + g.Text(" "+tr("explore.repos")), + ), + If(!data.UsersPageIsDisabled, + gh.A( + gh.Class(classIf(data.PageIsExploreUsers, "active ")+"item"), + gh.Href(data.AppSubUrl+"/explore/users"), + SVG("octicon-person"), + g.Text(" "+tr("explore.users")), + ), + ), + If(!data.OrganizationsPageIsDisabled, + gh.A( + gh.Class(classIf(data.PageIsExploreOrganizations, "active ")+"item"), + gh.Href(data.AppSubUrl+"/explore/organizations"), + SVG("octicon-organization"), + g.Text(" "+tr("explore.organizations")), + ), + ), + If(!isCodeGlobalDisabled && data.IsRepoIndexerEnabled && !data.CodePageIsDisabled, + gh.A( + gh.Class(classIf(data.PageIsExploreCode, "active ")+"item"), + gh.Href(data.AppSubUrl+"/explore/code"), + SVG("octicon-code"), + g.Text(" "+tr("explore.code")), + ), + ), + ), + ) +} diff --git a/components/explore_search.go b/components/explore_search.go new file mode 100644 index 0000000000..c1789939fe --- /dev/null +++ b/components/explore_search.go @@ -0,0 +1,45 @@ +package components + +import ( + g "maragu.dev/gomponents" + gh "maragu.dev/gomponents/html" +) + +func ExploreSearchMenu(data ExploreUsersPageProps, pageIsExploreUsers bool) g.Node { + // Corresponds to templates/explore/search.tmpl + + tr := func(key string) string { + return string(data.Locale.Tr(key)) + } + + return g.Group([]g.Node{ + gh.Div( + gh.Class("ui small secondary filter menu tw-items-center tw-mx-0"), + gh.Form( + gh.Class("ui form ignore-dirty tw-flex-1"), + If(pageIsExploreUsers, + SearchCombo(data.Locale, data.Keyword, tr("search.user_kind")), + ), + If(!pageIsExploreUsers, + SearchCombo(data.Locale, data.Keyword, tr("search.org_kind")), + ), + ), + gh.Div( + gh.Class("ui small dropdown type jump item tw-mr-0"), + gh.Span( + gh.Class("text"), + g.Text(tr("repo.issues.filter_sort")), + ), + SVG("octicon-triangle-down", 14, "dropdown icon"), + gh.Div( + gh.Class("menu"), + SortOption(data, "newest", tr("repo.issues.filter_sort.latest")), + SortOption(data, "oldest", tr("repo.issues.filter_sort.oldest")), + SortOption(data, "alphabetically", tr("repo.issues.label.filter_sort.alphabetically")), + SortOption(data, "reversealphabetically", tr("repo.issues.label.filter_sort.reverse_alphabetically")), + ), + ), + ), + gh.Div(gh.Class("divider")), + }) +} diff --git a/components/explore_users_page.go b/components/explore_users_page.go new file mode 100644 index 0000000000..932cfbe705 --- /dev/null +++ b/components/explore_users_page.go @@ -0,0 +1,62 @@ +package components + +import ( + "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/services/context" + g "maragu.dev/gomponents" + gh "maragu.dev/gomponents/html" +) + +type ExploreUsersPageProps struct { + Title string + Locale translation.Locale + Keyword string + SortType string + Users []*user.User + // ContextUser *user.User + Context *context.Context + IsSigned bool +} + +func ExploreUsersPage(data ExploreUsersPageProps) g.Node { + // pageIsExplore := true + pageIsExploreUsers := true + + head, err := data.Context.HTMLPartial(200, "base/head") + if err != nil { + panic("could not render head") + } + + footer, err := data.Context.HTMLPartial(200, "base/footer") + if err != nil { + panic("could not render footer") + } + + return g.Group([]g.Node{ + g.Raw(head), + gh.Div( + gh.Role("main"), + gh.Aria("label", data.Title), + gh.Class("page-content explore users"), + ExploreNavbar(ExploreNavbarProps{ + Locale: data.Locale, + PageIsExploreUsers: pageIsExploreUsers, + }), + gh.Div( + gh.Class("ui container"), + ExploreSearchMenu(data, true), + UserList(UserListProps{ + // ContextUser: data.ContextUser, + Context: data.Context, + Users: data.Users, + IsSigned: data.IsSigned, + Locale: data.Locale, + PageIsAdminUsers: false, + }), + // Pagination(data), + ), + ), + g.Raw(footer), + }) +} diff --git a/components/search_button.go b/components/search_button.go new file mode 100644 index 0000000000..fe62b7c803 --- /dev/null +++ b/components/search_button.go @@ -0,0 +1,32 @@ +package components + +import ( + g "maragu.dev/gomponents" + gh "maragu.dev/gomponents/html" +) + +func SearchButton(disabled bool, tooltip string) g.Node { + // Corresponds to templates/shared/search/button.tmpl + + class := "ui icon button" + if disabled { + class += " disabled" + } + + btn := gh.Button( + gh.Type("submit"), + gh.Class(class), + SVG("octicon-search", 16), + ) + + if tooltip != "" { + btn = gh.Button( + gh.Type("submit"), + gh.Class(class), + g.Attr("data-tooltip-content", tooltip), + SVG("octicon-search", 16), + ) + } + + return btn +} diff --git a/components/search_combo.go b/components/search_combo.go new file mode 100644 index 0000000000..67abe4788b --- /dev/null +++ b/components/search_combo.go @@ -0,0 +1,19 @@ +package components + +import ( + "code.gitea.io/gitea/modules/translation" + g "maragu.dev/gomponents" + gh "maragu.dev/gomponents/html" +) + +func SearchCombo(locale translation.Locale, value, placeholder string) g.Node { + // Corresponds to templates/shared/search/combo.tmpl + + disabled := false + return gh.Div( + gh.Class("ui small fluid action input"), + SearchInput(value, placeholder, disabled), + // TODO SearchModeDropdown + SearchButton(disabled, ""), + ) +} diff --git a/components/search_input.go b/components/search_input.go new file mode 100644 index 0000000000..ef23e53bb2 --- /dev/null +++ b/components/search_input.go @@ -0,0 +1,20 @@ +package components + +import ( + g "maragu.dev/gomponents" + gh "maragu.dev/gomponents/html" +) + +func SearchInput(value, placeholder string, disabled bool) g.Node { + // Corresponds to templates/shared/search/input.tmpl + + return gh.Input( + gh.Type("search"), + gh.Name("q"), + gh.MaxLength("255"), + g.Attr("spellcheck", "false"), + gh.Value(value), + gh.Placeholder(placeholder), + If(disabled, gh.Disabled()), + ) +} diff --git a/components/sort_option.go b/components/sort_option.go new file mode 100644 index 0000000000..e7bea224a2 --- /dev/null +++ b/components/sort_option.go @@ -0,0 +1,21 @@ +package components + +import ( + "fmt" + "net/url" + + g "maragu.dev/gomponents" + gh "maragu.dev/gomponents/html" +) + +func SortOption(data ExploreUsersPageProps, sortType, label string) g.Node { + active := "" + if data.SortType == sortType { + active = "active " + } + return gh.A( + gh.Class(active+"item"), + gh.Href(fmt.Sprintf("?sort=%s&q=%s", sortType, url.QueryEscape(data.Keyword))), + g.Text(label), + ) +} diff --git a/components/user_list.go b/components/user_list.go new file mode 100644 index 0000000000..add95bdcd4 --- /dev/null +++ b/components/user_list.go @@ -0,0 +1,92 @@ +package components + +import ( + "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + templates "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/services/context" + g "maragu.dev/gomponents" + gh "maragu.dev/gomponents/html" +) + +type UserListProps struct { + Users []*user.User + // ContextUser *user.User + IsSigned bool + PageIsAdminUsers bool + Locale translation.Locale + Context *context.Context +} + +func UserList(data UserListProps) g.Node { + tr := func(key string, args ...any) string { + return string(data.Locale.Tr(key, args...)) + } + + if len(data.Users) == 0 { + return gh.Div( + gh.Class("flex-list"), + gh.Div( + gh.Class("flex-item"), + g.Text(tr("search.no_results")), + ), + ) + } + + return gh.Div( + gh.Class("flex-list"), + g.Group(g.Map(data.Users, func(u *user.User) g.Node { + utils := templates.NewAvatarUtils(data.Context) + + return gh.Div( + gh.Class("flex-item tw-items-center"), + gh.Div( + gh.Class("flex-item-leading"), + g.Raw(string(utils.Avatar(u, 48))), + ), + gh.Div( + gh.Class("flex-item-main"), + gh.Div( + gh.Class("flex-item-title"), + UserName(UserNameProps{ + Locale: data.Locale, + User: u, + }), + If(u.Visibility.IsPrivate(), + gh.Span( + gh.Class("ui basic tiny label"), + g.Text(tr("repo.desc.private")), + ), + ), + ), + gh.Div( + gh.Class("flex-item-body"), + If(u.Location != "", + gh.Span( + gh.Class("flex-text-inline"), + SVG("octicon-location", 16), + g.Text(u.Location), + ), + ), + If(u.Email != "" && (data.PageIsAdminUsers || (setting.UI.ShowUserEmail && data.IsSigned && !u.KeepEmailPrivate)), + gh.Span( + gh.Class("flex-text-inline"), + SVG("octicon-mail", 16), + gh.A( + gh.Href("mailto:"+u.Email), + g.Text(u.Email), + ), + ), + ), + gh.Span( + gh.Class("flex-text-inline"), + SVG("octicon-calendar", 16), + g.Raw(tr("user.joined_on", templates.NewDateUtils().AbsoluteShort(u.CreatedUnix))), + ), + ), + ), + ) + })), + ) +} diff --git a/components/user_name.go b/components/user_name.go new file mode 100644 index 0000000000..6060a824d0 --- /dev/null +++ b/components/user_name.go @@ -0,0 +1,26 @@ +package components + +import ( + "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/translation" + g "maragu.dev/gomponents" + gh "maragu.dev/gomponents/html" +) + +type UserNameProps struct { + User *user.User + Locale translation.Locale +} + +func UserName(data UserNameProps) g.Node { + return gh.A( + gh.Class("text muted"), + gh.Href(data.User.HomeLink()), + g.Group([]g.Node{ + g.Text(data.User.Name), + If(data.User.FullName != "" && data.User.FullName != data.User.Name, + g.Text(" ("+data.User.FullName+")"), + ), + }), + ) +} diff --git a/go.mod b/go.mod index a99e9b8214..7712884f68 100644 --- a/go.mod +++ b/go.mod @@ -311,6 +311,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + maragu.dev/gomponents v1.1.0 // indirect ) replace github.com/hashicorp/go-version => github.com/6543/go-version v1.3.1 diff --git a/go.sum b/go.sum index 24abf49099..c780079c21 100644 --- a/go.sum +++ b/go.sum @@ -1004,6 +1004,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +maragu.dev/gomponents v1.1.0 h1:iCybZZChHr1eSlvkWp/JP3CrZGzctLudQ/JI3sBcO4U= +maragu.dev/gomponents v1.1.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM= modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= diff --git a/modules/templates/htmlrenderer.go b/modules/templates/htmlrenderer.go index 529284f7e8..2667cc9380 100644 --- a/modules/templates/htmlrenderer.go +++ b/modules/templates/htmlrenderer.go @@ -57,6 +57,17 @@ func (h *HTMLRender) HTML(w io.Writer, status int, tplName TplName, data any, ct return t.Execute(w, data) } +func (h *HTMLRender) Gomponents(w io.Writer, status int, data []byte) error { //nolint:revive + if respWriter, ok := w.(http.ResponseWriter); ok { + if respWriter.Header().Get("Content-Type") == "" { + respWriter.Header().Set("Content-Type", "text/html; charset=utf-8") + } + respWriter.WriteHeader(status) + } + _, err := w.Write(data) + return err +} + func (h *HTMLRender) TemplateLookup(name string, ctx context.Context) (TemplateExecutor, error) { //nolint:revive tmpls := h.templates.Load() if tmpls == nil { diff --git a/routers/web/explore/user.go b/routers/web/explore/user.go index af48e6fb79..ebfb39d7cf 100644 --- a/routers/web/explore/user.go +++ b/routers/web/explore/user.go @@ -7,6 +7,7 @@ import ( "bytes" "net/http" + "code.gitea.io/gitea/components" "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" @@ -126,6 +127,116 @@ func RenderUserSearch(ctx *context.Context, opts user_model.SearchUserOptions, t ctx.HTML(http.StatusOK, tplName) } +func RenderUserSearch2(ctx *context.Context, opts user_model.SearchUserOptions, title string) { + // Sitemap index for sitemap paths + isSitemap := ctx.PathParam("idx") != "" + if isSitemap { + opts.Page = int(ctx.PathParamInt64("idx")) + opts.PageSize = setting.UI.SitemapPagingNum + } else { + opts.Page = ctx.FormInt("page") + } + if opts.Page <= 1 { + opts.Page = 1 + } + + var ( + users []*user_model.User + count int64 + err error + orderBy db.SearchOrderBy + ) + + // we can not set orderBy to `models.SearchOrderByXxx`, because there may be a JOIN in the statement, different tables may have the same name columns + + sortOrder := ctx.FormString("sort") + if sortOrder == "" { + sortOrder = setting.UI.ExploreDefaultSort + } + + switch sortOrder { + case "newest": + orderBy = "`user`.id DESC" + case "oldest": + orderBy = "`user`.id ASC" + case "leastupdate": + orderBy = "`user`.updated_unix ASC" + case "reversealphabetically": + orderBy = "`user`.name DESC" + case "lastlogin": + orderBy = "`user`.last_login_unix ASC" + case "reverselastlogin": + orderBy = "`user`.last_login_unix DESC" + case "alphabetically": + orderBy = "`user`.name ASC" + case "recentupdate": + fallthrough + default: + // in case the sortType is not valid, we set it to recentupdate + sortOrder = "recentupdate" + orderBy = "`user`.updated_unix DESC" + } + + ctx.Data["SortType"] = sortOrder + + if opts.SupportedSortOrders != nil && !opts.SupportedSortOrders.Contains(sortOrder) { + ctx.NotFound(nil) + return + } + + opts.Keyword = ctx.FormTrim("q") + opts.OrderBy = orderBy + if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) { + users, count, err = user_model.SearchUsers(ctx, opts) + if err != nil { + ctx.ServerError("SearchUsers", err) + return + } + } + if isSitemap { + m := sitemap.NewSitemap() + for _, item := range users { + m.Add(sitemap.URL{URL: item.HTMLURL(), LastMod: item.UpdatedUnix.AsTimePtr()}) + } + ctx.Resp.Header().Set("Content-Type", "text/xml") + if _, err := m.WriteTo(ctx.Resp); err != nil { + log.Error("Failed writing sitemap: %v", err) + } + return + } + + ctx.Data["Keyword"] = opts.Keyword + ctx.Data["Total"] = count + ctx.Data["Users"] = users + ctx.Data["UsersTwoFaStatus"] = user_model.UserList(users).GetTwoFaStatus(ctx) + ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) + pager.AddParamFromRequest(ctx.Req) + ctx.Data["Page"] = pager + + var IsSigned bool + if val, ok := ctx.Data["IsSigned"]; ok && val != nil { + IsSigned = *val.(*bool) + } + + data := components.ExploreUsersPage(components.ExploreUsersPageProps{ + Title: title, + Locale: ctx.Base.Locale, + Keyword: opts.Keyword, + SortType: sortOrder, + Users: users, + IsSigned: IsSigned, + Context: ctx, + }) + + var bodyBuffer bytes.Buffer + data.Render(&bodyBuffer) + + ctx.Gomponents(http.StatusOK, bodyBuffer.String(), "ExploreUsersPage") +} + // Users render explore users page func Users(ctx *context.Context) { if setting.Service.Explore.DisableUsersPage { @@ -135,8 +246,6 @@ func Users(ctx *context.Context) { ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage ctx.Data["Title"] = ctx.Tr("explore") - ctx.Data["PageIsExplore"] = true - ctx.Data["PageIsExploreUsers"] = true ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled supportedSortOrders := container.SetOf( @@ -151,7 +260,7 @@ func Users(ctx *context.Context) { ctx.SetFormString("sort", sortOrder) } - RenderUserSearch(ctx, user_model.SearchUserOptions{ + RenderUserSearch2(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, Type: user_model.UserTypeIndividual, ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, @@ -159,5 +268,5 @@ func Users(ctx *context.Context) { Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate}, SupportedSortOrders: supportedSortOrders, - }, tplExploreUsers) + }, string(ctx.Tr("explore"))) } diff --git a/services/context/context.go b/services/context/context.go index 32ec260aab..305c580f06 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -33,6 +33,7 @@ import ( type Render interface { TemplateLookup(tmpl string, templateCtx context.Context) (templates.TemplateExecutor, error) HTML(w io.Writer, status int, name templates.TplName, data any, templateCtx context.Context) error + Gomponents(w io.Writer, status int, data []byte) error } // Context represents context of a web request. diff --git a/services/context/context_response.go b/services/context/context_response.go index 4e11e29b69..231ebc1a88 100644 --- a/services/context/context_response.go +++ b/services/context/context_response.go @@ -9,6 +9,7 @@ import ( "html/template" "net" "net/http" + "net/http/httptest" "net/url" "path" "strconv" @@ -91,6 +92,31 @@ func (ctx *Context) HTML(status int, name templates.TplName) { } } +func (ctx *Context) HTMLPartial(status int, name templates.TplName) (string, error) { + log.Debug("Partial Template: %s", name) + + rec := httptest.NewRecorder() + + err := ctx.Render.HTML(rec, status, name, ctx.Data, ctx.TemplateContext) + if err != nil { + return "", err + } + + return rec.Body.String(), nil +} + +func (ctx *Context) Gomponents(status int, html string, tplName string) { + log.Debug("Component: %s", tplName) + + err := ctx.Render.Gomponents(ctx.Resp, status, []byte(html)) + if err == nil || errors.Is(err, syscall.EPIPE) { + return + } + + err = fmt.Errorf("failed to render component: %s, error: %s", tplName, err) + ctx.ServerError("Render failed", err) // show the 500 error page +} + // JSONTemplate renders the template as JSON response // keep in mind that the template is processed in HTML context, so JSON-things should be handled carefully, eg: by JSEscape func (ctx *Context) JSONTemplate(tmpl templates.TplName) { diff --git a/services/contexttest/context_tests.go b/services/contexttest/context_tests.go index b54023897b..e08b07677b 100644 --- a/services/contexttest/context_tests.go +++ b/services/contexttest/context_tests.go @@ -198,3 +198,10 @@ func (tr *MockRender) HTML(w io.Writer, status int, _ templates.TplName, _ any, } return nil } + +func (tr *MockRender) Gomponents(w io.Writer, status int, _ string) error { + if resp, ok := w.(http.ResponseWriter); ok { + resp.WriteHeader(status) + } + return nil +}