Tutorial

DashWindで管理画面アプリを作る

Marionette の frontend/dashwind パッケージを使い、サイドバー、KPI、一覧テーブル、行アクション、設定フォームを備えた管理画面を作ります。

Step 1

プロジェクトを準備する

空の Go module を作成し、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

DashWindの役割を決める

DashWind は管理画面向けの高レベル部品です。dw.Use で DaisyUI/Tailwind と既定CSSを登録し、 dw.Shell で共通レイアウト、dw.MetricGrid でKPI、dw.ResourcePage で一覧画面を組み立てます。

Step 3

main.go を作成する

次のコードを main.go に貼り付けます。1ファイルで状態、ルーティング、DashWindシェル、Action更新まで確認できます。

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

起動して画面を確認する

ローカルサーバーを起動し、ブラウザでダッシュボードを開きます。

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

Step 5

実装を読む順番

  1. dw.Use で DashWind 用のスタイルをアプリに登録する
  2. navigationshell で全ページ共通のサイドバーとトップバーを定義する
  3. dashboard でKPIカードと補助カードを配置する
  4. customersPageResourcePage、列定義、行アクションをまとめる
  5. app.Action で状態を更新し、dw.ShellContent だけを outerHTML で差し替える

Step 6

拡張ポイント

  • 外部DBを使う場合は SetGlobal の seed データをリポジトリ層に置き換える
  • 認証を追加する場合は app.Page の先頭でセッションを確認し、未ログイン時にログインページを返す
  • 一覧が大きくなる場合は ResourcePageFiltersSearch にフォームを渡し、Actionで条件を再計算する
  • 自社ブランドに合わせる場合は dw.Options{CustomCSS: "..."} や DaisyUI テーマを変更する

次は デスクトップアプリとして実行する(任意) に進み、同じ管理画面をデスクトップWebViewで起動できます。