Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,39 @@ type Post struct {
- `uischema:` — RJSF widget selection, e.g. `uischema:"widget=ForeignKey;ui:options:resource=authors"`.
- `validate:` — server-side validation (`go-playground/validator`); failures return 422 with per-field messages.

Custom pages are registered alongside CRUD resources when a resource is not a
table or form:

```go
reg.Register(admin.NewCustomResourcePage(admin.CustomResourceConfig{
ID: "dashboard",
Name: "Dashboard",
Icon: "layout-dashboard",
Page: admin.CustomResourcePage{
ActionButtons: []admin.ActionButton{{
Type: admin.ButtonSecondary, Label: "Open Posts", Icon: "file-text",
Behavior: admin.BehaviorNavigate, ActionType: admin.ActionView,
OnClick: "/admin/posts",
}},
Sections: []admin.CustomPageSection{
{
Type: admin.CustomPageSectionStatistics,
Statistics: []admin.Statistic{{Label: "Published posts", Value: 17}},
},
{
Type: admin.CustomPageSectionCharts,
Children: []admin.Chart{{
Type: admin.ChartTypeBar,
Data: []map[string]any{{"day": "Mon", "views": 320}},
XKey: "day", YKey: "views",
}},
},
{Type: admin.CustomPageSectionText, Body: "Operational notes"},
},
},
}))
```

## Next.js: mount the UI

Three small files in your app (see `examples/web`):
Expand Down
126 changes: 126 additions & 0 deletions admin/custom_resource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package admin

import (
"context"
"fmt"
"net/url"
"strings"
)

// CustomActionHandler executes a server-owned action from a custom page button.
type CustomActionHandler func(ctx context.Context, req Request, data map[string]any) (*ActionResponse, error)

// CustomResourceConfig configures a custom admin page made of sections such as
// charts, statistics, and text.
type CustomResourceConfig struct {
ID string
Name string
Description string
Icon string
Page CustomResourcePage
Authorize func(ctx context.Context, identity Identity, action ActionType) error
Actions map[ActionType]CustomActionHandler
}

type customResource struct {
cfg CustomResourceConfig
}

// NewCustomResourcePage builds a Resource backed by a static custom page
// schema. Action buttons may either navigate with their OnClick URL or post to
// handlers configured in Actions.
func NewCustomResourcePage(cfg CustomResourceConfig) Resource {
if cfg.ID == "" {
panic("admin: CustomResourceConfig.ID is required")
}
if cfg.Name == "" {
cfg.Name = humanizeFieldName(strings.ToUpper(cfg.ID[:1]) + cfg.ID[1:])
}
return &customResource{cfg: cfg}
}

func (r *customResource) ID() string { return r.cfg.ID }

func (r *customResource) actionURL(req Request, action ActionType) string {
return req.BasePath + "/resources/" + r.cfg.ID + "/action?action=" + url.QueryEscape(string(action))
}

func (r *customResource) Info(_ context.Context, req Request) ResourceInfo {
return ResourceInfo{
ID: r.cfg.ID,
Name: r.cfg.Name,
Description: r.cfg.Description,
Icon: r.cfg.Icon,
Type: ResourceCustom,
DataURL: r.actionURL(req, ActionView),
DefaultAction: ActionView,
SupportedActions: r.page(req).ActionButtons,
}
}

func (r *customResource) authorize(ctx context.Context, req Request, action ActionType) error {
if r.cfg.Authorize != nil {
return r.cfg.Authorize(ctx, req.Identity, action)
}
return nil
}

func (r *customResource) Schema(ctx context.Context, req Request, action ActionType) (any, error) {
if action == "" {
action = ActionView
}
if action != ActionView {
return nil, fmt.Errorf("%w: no schema for action %q", ErrBadInput, action)
}
if err := r.authorize(ctx, req, action); err != nil {
return nil, err
}
page := r.page(req)
return &page, nil
}

func (r *customResource) Fetch(ctx context.Context, req Request, action ActionType, _ map[string]any) (*ActionResponse, error) {
if action == "" {
action = ActionView
}
if action != ActionView {
return nil, fmt.Errorf("%w: cannot fetch action %q", ErrBadInput, action)
}
if err := r.authorize(ctx, req, action); err != nil {
return nil, err
}
return Detail(map[string]any{}), nil
}

func (r *customResource) Act(ctx context.Context, req Request, action ActionType, data map[string]any) (*ActionResponse, error) {
if err := r.authorize(ctx, req, action); err != nil {
return nil, err
}
handler, ok := r.cfg.Actions[action]
if !ok {
return nil, fmt.Errorf("%w: action %q not supported by resource %q", ErrBadInput, action, r.cfg.ID)
}
return handler(ctx, req, data)
}

func (r *customResource) page(req Request) CustomResourcePage {
page := r.cfg.Page
page.UIType = "custom"
page.Type = ActionView
if page.ActionButtons == nil {
page.ActionButtons = []ActionButton{}
} else {
page.ActionButtons = append([]ActionButton(nil), page.ActionButtons...)
}
if page.Sections == nil {
page.Sections = []CustomPageSection{}
} else {
page.Sections = append([]CustomPageSection(nil), page.Sections...)
}
for i := range page.ActionButtons {
if page.ActionButtons[i].OnClick == "" && page.ActionButtons[i].ActionType != "" {
page.ActionButtons[i].OnClick = r.actionURL(req, page.ActionButtons[i].ActionType)
}
}
return page
}
89 changes: 89 additions & 0 deletions admin/custom_resource_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package admin_test

import (
"encoding/json"
"testing"

"github.com/rxtech-lab/admin-generator/admin"
)

func TestCustomResourcePageSchema(t *testing.T) {
res := admin.NewCustomResourcePage(admin.CustomResourceConfig{
ID: "dashboard",
Name: "Dashboard",
Icon: "layout-dashboard",
Page: admin.CustomResourcePage{
ActionButtons: []admin.ActionButton{{
Type: admin.ButtonPrimary,
Label: "Export",
Icon: "download",
Behavior: admin.BehaviorSubmit,
ActionType: admin.ActionExport,
}},
Sections: []admin.CustomPageSection{
{
Type: admin.CustomPageSectionStatistics,
Title: "Overview",
Statistics: []admin.Statistic{{
Label: "Posts",
Value: 42,
}},
},
{
Type: admin.CustomPageSectionCharts,
Title: "Traffic",
Children: []admin.Chart{{
Type: admin.ChartTypeBar,
Data: []map[string]any{{"day": "Mon", "views": 10}},
XKey: "day",
YKey: "views",
}},
},
{
Type: admin.CustomPageSectionText,
Body: "Operational notes",
},
},
},
})

info := res.Info(t.Context(), admin.Request{BasePath: "/admin"})
if info.Type != admin.ResourceCustom {
t.Fatalf("want custom resource type, got %q", info.Type)
}
if len(info.SupportedActions) != 1 {
t.Fatalf("want 1 supported action, got %d", len(info.SupportedActions))
}
if info.SupportedActions[0].OnClick != "/admin/resources/dashboard/action?action=export" {
t.Fatalf("unexpected action URL: %q", info.SupportedActions[0].OnClick)
}

raw, err := res.Schema(t.Context(), admin.Request{BasePath: "/admin"}, admin.ActionView)
if err != nil {
t.Fatalf("schema: %v", err)
}
page := raw.(*admin.CustomResourcePage)
if page.UIType != "custom" {
t.Fatalf("want custom uiType, got %q", page.UIType)
}
if len(page.Sections) != 3 {
t.Fatalf("want 3 sections, got %d", len(page.Sections))
}

encoded, err := json.Marshal(page)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded struct {
UIType string `json:"uiType"`
Sections []struct {
Type string `json:"type"`
} `json:"sections"`
}
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if decoded.UIType != "custom" || decoded.Sections[0].Type != "statistics" {
t.Fatalf("unexpected json: %s", encoded)
}
}
65 changes: 65 additions & 0 deletions admin/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const (
ResourceTable ResourceType = "table"
ResourceDetail ResourceType = "detail"
ResourceForm ResourceType = "form"
ResourceCustom ResourceType = "custom"
)

// ButtonType is the visual style of an action button.
Expand Down Expand Up @@ -112,6 +113,70 @@ type FormSchema struct {
SupportedActions []ActionButton `json:"supportedActions"`
}

// CustomPageSectionType identifies a section rendered on a custom resource page.
type CustomPageSectionType string

const (
CustomPageSectionCharts CustomPageSectionType = "charts"
CustomPageSectionStatistics CustomPageSectionType = "statistics"
CustomPageSectionText CustomPageSectionType = "text"
)

// ChartType identifies the supported chart renderers for a charts section.
type ChartType string

const (
ChartTypeBar ChartType = "bar"
ChartTypeLine ChartType = "line"
)

// ChartSeries describes one numeric series in a chart.
type ChartSeries struct {
Key string `json:"key"`
Label string `json:"label,omitempty"`
Color string `json:"color,omitempty"`
}

// Chart describes a simple bar or line chart. Data items are arbitrary maps;
// XKey selects the category label and either YKey or Series selects numeric
// values.
type Chart struct {
Type ChartType `json:"type"`
Title string `json:"title,omitempty"`
Data []map[string]any `json:"data"`
XKey string `json:"xKey"`
YKey string `json:"yKey,omitempty"`
Series []ChartSeries `json:"series,omitempty"`
}

// Statistic describes one metric in a statistics section.
type Statistic struct {
Label string `json:"label"`
Value any `json:"value"`
Description string `json:"description,omitempty"`
Trend string `json:"trend,omitempty"`
Tone string `json:"tone,omitempty"`
}

// CustomPageSection is one content block on a custom resource page.
type CustomPageSection struct {
Type CustomPageSectionType `json:"type"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Children []Chart `json:"children,omitempty"`
Statistics []Statistic `json:"statistics,omitempty"`
Body string `json:"body,omitempty"`
}

// CustomResourcePage is the schema for a custom page resource. UIType is always
// "custom".
type CustomResourcePage struct {
UIType string `json:"uiType"`
Type ActionType `json:"type"`
ActionButtons []ActionButton `json:"actionButtons"`
Sections []CustomPageSection `json:"sections"`
}

// SearchItem is one result of a search action (ForeignKey / ObjectSearch widgets).
type SearchItem struct {
Title string `json:"title"`
Expand Down
Loading