Tutorial

Build an Admin App with DashWind

Use Marionette's frontend/dashwind package to build an admin screen with a sidebar, KPIs, a resource table, row actions, and a settings form.

Step 1

Prepare the project

Create an empty Go module and add Marionette.

mkdir marionette-dashwind-admin
cd marionette-dashwind-admin
go mod init example.com/marionette-dashwind-admin
go get github.com/YoshihideShirai/marionette@latest

Step 2

Decide what DashWind owns

DashWind provides higher-level admin building blocks. Register DaisyUI/Tailwind and default shell CSS with dw.Use, create the shared layout with dw.Shell, render KPIs with dw.MetricGrid, and build list pages with dw.ResourcePage.

Step 3

Create main.go

Paste this code into main.go. The single file demonstrates state, routes, the DashWind shell, and Action-based updates.

package main

import (
    "fmt"

    mb "github.com/YoshihideShirai/marionette/backend"
    mf "github.com/YoshihideShirai/marionette/frontend"
    dw "github.com/YoshihideShirai/marionette/frontend/dashwind"
)

type customer struct {
    ID, Name, Plan, Status string
    MRR                    int
}

var seedCustomers = []customer{
    {"cus_1001", "Acme Robotics", "Enterprise", "Active", 12800},
    {"cus_1002", "Northwind Health", "Growth", "Review", 6400},
    {"cus_1003", "Sora Logistics", "Starter", "Active", 2200},
}

var navigation = dw.Navigation{{Label: "Workspace", Items: []dw.NavItem{
    {Path: "/", Label: "Dashboard", Icon: "▦"},
    {Path: "/customers", Label: "Customers", Icon: "☷", Badge: "3"},
    {Path: "/settings", Label: "Settings", Icon: "⚙"},
}}}

func main() {
    app := mb.New()
    app.SetGlobal("customers", append([]customer(nil), seedCustomers...))

    // DashWind registers the DaisyUI/Tailwind template and shell CSS.
    dw.Use(app, dw.Options{Theme: "corporate"})

    app.Page("/", func(ctx *mb.Context) mf.Node {
        return shell("/", dashboard(ctx))
    }, mb.WithTitle("Admin Dashboard"))

    app.Page("/customers", func(ctx *mb.Context) mf.Node {
        return shell("/customers", customersPage(ctx))
    }, mb.WithTitle("Customers - Admin Dashboard"))

    app.Page("/settings", func(ctx *mb.Context) mf.Node {
        return shell("/settings", settingsPage())
    }, mb.WithTitle("Settings - Admin Dashboard"))

    app.Action("customers/toggle", func(ctx *mb.Context) mf.Node {
        id := ctx.FormValue("id")
        ctx.UpdateGlobal("customers", func(old any) any {
            customers := append([]customer(nil), old.([]customer)...)
            for i := range customers {
                if customers[i].ID == id {
                    if customers[i].Status == "Active" {
                        customers[i].Status = "Review"
                    } else {
                        customers[i].Status = "Active"
                    }
                }
            }
            return customers
        })
        return dw.ShellContent(dw.DefaultMainTargetID, customersPage(ctx))
    })

    if err := app.Run("127.0.0.1:8080"); err != nil {
        panic(err)
    }
}

func shell(currentPath string, body mf.Node) mf.Node {
    return dw.Shell(dw.ShellProps{
        CurrentPath:       currentPath,
        Brand:             dw.Brand{Title: "Revenue Ops", Subtitle: "DashWind admin", Mark: "R", Href: "/"},
        Navigation:        navigation,
        SearchPlaceholder: "Search customers",
        User:              dw.UserMenu{Name: "Admin User", Email: "admin@example.com", Initials: "AU"},
    }, body)
}

func dashboard(ctx *mb.Context) mf.Node {
    customers := ctx.GetGlobal("customers").([]customer)
    return mf.DivProps(mf.ElementProps{Class: "space-y-6"},
        dw.PageHeader(dw.PageHeaderProps{Title: "Dashboard", Description: "Monitor subscription health and revenue."}),
        dw.MetricGrid(dw.MetricGridProps{Items: []dw.Metric{
            {Title: "Customers", Value: fmt.Sprint(len(customers)), Description: "Total accounts", Trend: "+2 this week", TrendTone: dw.ToneSuccess},
            {Title: "MRR", Value: fmt.Sprintf("$%d", totalMRR(customers)), Description: "Monthly recurring revenue", Trend: "12% growth", TrendTone: dw.ToneSuccess},
            {Title: "Needs review", Value: fmt.Sprint(countByStatus(customers, "Review")), Description: "Accounts to follow up", Trend: "Prioritize today", TrendTone: dw.ToneWarning},
        }}),
        dw.CardPanel(dw.CardPanelProps{Title: "Next actions", Description: "Use Actions to update only the main region."}, mf.Text("Open Customers and toggle a status.")),
    )
}

func customersPage(ctx *mb.Context) mf.Node {
    customers := ctx.GetGlobal("customers").([]customer)
    return dw.ResourcePage(dw.ResourcePageProps[customer]{
        Title:       "Customers",
        Description: "List, inspect, and update customer status.",
        Rows:        customers,
        Columns: []dw.Column[customer]{
            {Header: "Name", Sortable: true, Cell: func(c customer) mf.Node { return mf.Text(c.Name) }},
            {Header: "Plan", Cell: func(c customer) mf.Node { return mf.Text(c.Plan) }},
            {Header: "MRR", Class: "text-right", Cell: func(c customer) mf.Node { return mf.Text(fmt.Sprintf("$%d", c.MRR)) }},
            {Header: "Status", Cell: func(c customer) mf.Node { return mf.Badge(mf.BadgeProps{Label: c.Status, Props: mf.ComponentProps{Variant: "primary"}}) }},
        },
        RowActions: func(c customer) []dw.Action {
            return []dw.Action{{
                Label:  "Toggle",
                Action: "/customers/toggle",
                Target: "#" + dw.DefaultMainTargetID,
                Swap:   "outerHTML",
                Class:  "btn-sm btn-outline",
                Fields: map[string]string{"id": c.ID},
            }}
        },
    })
}

func settingsPage() mf.Node {
    return dw.SettingsSection(dw.SettingsSectionProps{
        Title:       "Workspace settings",
        Description: "Start with preset fields, then replace them with your own handlers.",
        Fields: []dw.Field{
            {Name: "workspace", Label: "Workspace name", Value: "Revenue Ops", Required: true},
            {Name: "timezone", Label: "Timezone", Value: "UTC"},
        },
        SubmitLabel: "Save settings",
    })
}

func totalMRR(customers []customer) int {
    total := 0
    for _, c := range customers {
        total += c.MRR
    }
    return total
}

func countByStatus(customers []customer, status string) int {
    count := 0
    for _, c := range customers {
        if c.Status == status {
            count++
        }
    }
    return count
}

Step 4

Run and inspect the UI

Start the local server and open the dashboard in your browser.

go run .
# open http://127.0.0.1:8080

Step 5

Read the implementation in order

  1. Use dw.Use to register DashWind styles on the app
  2. Define a shared sidebar and topbar with navigation and shell
  3. Place KPI cards and supporting cards in dashboard
  4. Assemble ResourcePage, column definitions, and row actions in customersPage
  5. Update state in app.Action and replace only dw.ShellContent with an outerHTML swap

Step 6

Where to extend it

  • Replace the SetGlobal seed data with a repository layer when you introduce a database
  • Add authentication by checking the session at the top of each app.Page handler and returning a login page when needed
  • For larger lists, pass filter and search forms into ResourcePage, then recalculate query state in Actions
  • Customize branding with dw.Options{CustomCSS: "..."} or by switching DaisyUI themes

Next, continue with Run as Desktop App (Optional) to launch the same admin UI inside a desktop WebView.