Repository navigation
test: cover modules with an admin and a blog module #176
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package facades | ||
|
|
||
| import ( | ||
| "github.com/goravel/framework/contracts/modular" | ||
| ) | ||
|
|
||
| func Modular() modular.Registry { | ||
| return App().MakeModular() | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package bootstrap | ||
|
|
||
| import ( | ||
| "github.com/goravel/framework/contracts/modular" | ||
|
|
||
| "goravel/modules/admin" | ||
| "goravel/modules/blog" | ||
| ) | ||
|
|
||
| func Modules() []modular.Module { | ||
| return []modular.Module{ | ||
| &admin.Module{}, | ||
| &blog.Module{}, | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package config | ||
|
|
||
| import ( | ||
| "github.com/goravel/framework/contracts/http" | ||
|
|
||
| "goravel/app/facades" | ||
| ) | ||
|
|
||
| func init() { | ||
| config := facades.Config() | ||
| config.Add("modular", map[string]any{ | ||
| // Directory of the application's own modules, relative to the project root. | ||
| "path": "modules", | ||
|
|
||
| // Route groups wrapping what modules register with Routes() and ApiRoutes(). | ||
| "routes": map[string]any{ | ||
| "web": map[string]any{"prefix": "", "middleware": []http.Middleware{}}, | ||
| "api": map[string]any{"prefix": "api", "middleware": []http.Middleware{}}, | ||
| }, | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,8 @@ import ( | |
| "goravel/bootstrap" | ||
| ) | ||
|
|
||
| //go:generate go run . artisan modular:publish | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why need this line?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This feature exists for third party modules. If the module source does not live in the project dir then vite cannot work properly (building, figuring out the path). Similar logic applies for language files, views etc. We need an way to bring them into project dir.
We are using it to make the integration easier. |
||
|
|
||
| func main() { | ||
| app := bootstrap.Boot() | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| // Package contracts holds the capabilities other modules implement to take part | ||
| // in the admin. Go interfaces are structural, so implementing one needs no import. | ||
| package contracts | ||
|
|
||
| // ProvidesDashboardScripts lists the Vite entries the admin loads before it | ||
| // mounts, relative to the module directory, e.g. "resources/js/dashboard.ts". | ||
| // Each one must also be in the module's ViteEntries(), which is what builds it. | ||
| type ProvidesDashboardScripts interface { | ||
| DashboardScripts() []string | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| // Package dashboard decides which Vite entries make up the admin page. | ||
| package dashboard | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "path" | ||
| "path/filepath" | ||
| "slices" | ||
|
|
||
| "github.com/goravel/framework/contracts/modular" | ||
|
|
||
| "goravel/modules/admin/contracts" | ||
| ) | ||
|
|
||
| // Name is the admin module's name, Main its own entry, which mounts the admin. | ||
| const ( | ||
| Name = "admin" | ||
| Main = "resources/js/main.ts" | ||
| ) | ||
|
|
||
| // Entries returns the project-relative Vite entries of the admin page: every | ||
| // module's dashboard scripts in module order, then the admin's Main, so all | ||
| // modules have registered by the time the admin mounts. | ||
| func Entries(registry modular.Registry) ([]string, error) { | ||
| var entries []string | ||
| for _, module := range registry.All() { | ||
| provider, ok := module.(contracts.ProvidesDashboardScripts) | ||
| if !ok { | ||
| continue | ||
| } | ||
|
|
||
| var declared []string | ||
| if vite, ok := module.(modular.ProvidesViteEntries); ok { | ||
| declared = vite.ViteEntries() | ||
| } | ||
|
|
||
| for _, script := range provider.DashboardScripts() { | ||
| if !slices.Contains(declared, script) { | ||
| return nil, fmt.Errorf("dashboard script [%s] of module [%s] is not in its ViteEntries()", script, module.Name()) | ||
| } | ||
|
|
||
| entries = append(entries, entry(registry, module.Name(), script)) | ||
| } | ||
| } | ||
|
|
||
| if !registry.Has(Name) { | ||
| return nil, fmt.Errorf("module [%s] is not registered", Name) | ||
| } | ||
|
|
||
| return append(entries, entry(registry, Name, Main)), nil | ||
| } | ||
|
|
||
| // entry joins a module-relative file onto the module directory, in the slash | ||
| // form Vite's manifest uses. | ||
| func entry(registry modular.Registry, name, file string) string { | ||
| dir, _ := registry.Path(name) | ||
|
|
||
| return path.Join(filepath.ToSlash(dir), file) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| package controllers | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "strings" | ||
|
|
||
| "github.com/goravel/framework/contracts/http" | ||
|
|
||
| "goravel/app/facades" | ||
| "goravel/modules/admin/dashboard" | ||
| ) | ||
|
|
||
| type DashboardController struct{} | ||
|
|
||
| func NewDashboardController() *DashboardController { | ||
| return &DashboardController{} | ||
| } | ||
|
|
||
| // Show renders the page the admin mounts in, with every dashboard script in one | ||
| // Vite call so shared chunks are preloaded once. | ||
| func (r *DashboardController) Show(ctx http.Context) http.Response { | ||
| entries, err := dashboard.Entries(facades.Modular()) | ||
| if err != nil { | ||
| return r.fail(ctx, err) | ||
| } | ||
|
|
||
| tags, err := facades.Vite().Tags(entries...) | ||
| if err != nil { | ||
| return r.fail(ctx, err) | ||
| } | ||
|
|
||
| boot, err := json.Marshal(map[string]any{ | ||
| "appName": facades.Config().GetString("app.name"), | ||
| "basePath": "/" + strings.Trim(facades.Config().GetString("modules.admin.path", "admin"), "/"), | ||
| }) | ||
| if err != nil { | ||
| return r.fail(ctx, err) | ||
| } | ||
|
|
||
| return ctx.Response().View().Make("admin/dashboard.tmpl", map[string]any{ | ||
| "title": facades.Lang(ctx).Get("admin/messages.title"), | ||
| "tags": tags, | ||
| "boot": string(boot), | ||
| }) | ||
| } | ||
|
|
||
| func (r *DashboardController) fail(ctx http.Context, err error) http.Response { | ||
| facades.Log().Error(err) | ||
|
|
||
| return ctx.Response().String(http.StatusInternalServerError, err.Error()) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| { | ||
| "title": "Admin" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| package admin | ||
|
|
||
| import ( | ||
| "embed" | ||
| "io/fs" | ||
|
|
||
| "github.com/goravel/framework/contracts/route" | ||
|
|
||
| "goravel/modules/admin/dashboard" | ||
| "goravel/modules/admin/routes" | ||
| ) | ||
|
|
||
| //go:embed resources/views | ||
| var views embed.FS | ||
|
|
||
| //go:embed lang | ||
| var lang embed.FS | ||
|
|
||
| // Module is a Vue admin that other modules fill: they register pages and | ||
| // dashboard widgets from a dashboard script (contracts.ProvidesDashboardScripts). | ||
| type Module struct{} | ||
|
|
||
| func (m *Module) Name() string { | ||
| return dashboard.Name | ||
| } | ||
|
|
||
| // Order -1 makes the admin the last module, so its routes come after the others'. | ||
| func (m *Module) Order() int { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not respect the module slice order in the bootstrap/modules.go file? Then this function can be removed entirely.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. make:module inserts in the end, and some app may require this ordering i.e. put themselves in the end. users don't need to be aware of that order unless necessary. this is also optional, so no need to implement if not necessary |
||
| return -1 | ||
| } | ||
|
|
||
| // Config is available as "modules.admin": path is where the admin answers. | ||
| func (m *Module) Config() map[string]any { | ||
| return map[string]any{ | ||
| "path": "admin", | ||
| } | ||
| } | ||
|
|
||
| func (m *Module) Routes(router route.Router) { | ||
| routes.Web(router) | ||
| } | ||
|
|
||
| // Views are named "admin/<file>". The application already has views under | ||
| // resources/views/admin, so this module's page is "admin/dashboard.tmpl". | ||
| func (m *Module) Views() fs.FS { | ||
| sub, _ := fs.Sub(views, "resources/views") | ||
|
|
||
| return sub | ||
| } | ||
|
|
||
| func (m *Module) Translations() fs.FS { | ||
| sub, _ := fs.Sub(lang, "lang") | ||
|
|
||
| return sub | ||
| } | ||
|
|
||
| func (m *Module) ViteEntries() []string { | ||
| return []string{dashboard.Main} | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| :root { | ||
| --admin-bg: #f6f7f9; | ||
| --admin-surface: #fff; | ||
| --admin-border: #e3e6ea; | ||
| --admin-text: #1d2433; | ||
| --admin-muted: #687284; | ||
| --admin-accent: #3b5bdb; | ||
| font-family: system-ui, sans-serif; | ||
| color: var(--admin-text); | ||
| background: var(--admin-bg); | ||
| } | ||
|
|
||
| body { margin: 0; } | ||
| .admin { display: flex; min-height: 100vh; } | ||
| .admin-sidebar { width: 220px; padding: 20px 12px; background: var(--admin-surface); border-right: 1px solid var(--admin-border); } | ||
| .admin-brand { display: block; padding: 0 8px 16px; } | ||
| .admin-sidebar h2 { margin: 16px 8px 4px; font-size: 12px; text-transform: uppercase; color: var(--admin-muted); } | ||
| .admin-sidebar a { display: block; padding: 6px 8px; border-radius: 6px; color: inherit; text-decoration: none; } | ||
| .admin-sidebar a.active { background: var(--admin-bg); color: var(--admin-accent); } | ||
| .admin-main { flex: 1; padding: 20px 32px; } | ||
| .admin-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 24px; } | ||
| .admin-header h1 { margin: 0; font-size: 22px; } | ||
| .admin-header-actions { display: flex; gap: 8px; } | ||
| .admin-widgets { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; } | ||
| .admin-card { padding: 16px; background: var(--admin-surface); border: 1px solid var(--admin-border); border-radius: 8px; } | ||
| .admin-card h3 { margin: 0 0 4px; font-size: 13px; font-weight: 500; color: var(--admin-muted); } | ||
| .admin-stat { font-size: 30px; font-weight: 600; } | ||
| .admin-button { display: inline-block; padding: 7px 14px; border: 0; border-radius: 6px; background: var(--admin-accent); color: #fff; font: inherit; text-decoration: none; cursor: pointer; } | ||
| .admin-table { width: 100%; border-collapse: collapse; background: var(--admin-surface); } | ||
| .admin-table th, .admin-table td { padding: 8px 12px; border-bottom: 1px solid var(--admin-border); text-align: left; } | ||
| .admin-form { display: grid; gap: 12px; max-width: 520px; } | ||
| .admin-form input, .admin-form textarea { padding: 8px; border: 1px solid var(--admin-border); border-radius: 6px; font: inherit; } | ||
| .admin-error { color: #c92a2a; font-size: 13px; } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| // What a dashboard script imports as '@admin/index'. Its first import is the | ||
| // admin's own registration, so the dashboard comes before every module. | ||
| import DashboardPage from './layout/DashboardPage.vue' | ||
| import { registerModule } from './lib/registry' | ||
|
|
||
| registerModule({ | ||
| id: 'dashboard', | ||
| menu: (menu) => [{ label: 'General', items: [{ label: 'Dashboard', path: '/' }] }, ...menu], | ||
| routes: [{ path: '/', component: DashboardPage }], | ||
| }) | ||
|
|
||
| export { default as AdminLink } from './layout/AdminLink.vue' | ||
| export { DashboardWidgets, HeaderActions } from './layout/slots' | ||
| export { basePath } from './lib/boot' | ||
| export { registerModule, type AdminRoute, type MenuFilter, type MenuGroup, type MenuItem, type ModuleRegistration } from './lib/registry' | ||
| export { navigate } from './lib/router' | ||
| export { createSlot, type Slot } from './lib/slot' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| <script setup lang="ts"> | ||
| import { computed } from 'vue' | ||
|
|
||
| import { appName } from '../lib/boot' | ||
| import { menu, modules, routes } from '../lib/registry' | ||
| import { activePath, currentPath, match } from '../lib/router' | ||
| import AdminLink from './AdminLink.vue' | ||
| import { DashboardWidgets, HeaderActions } from './slots' | ||
|
|
||
| // The registry is sealed before mount, so these are read once. | ||
| const groups = menu() | ||
| const fills = modules().flatMap((module) => (module.fills ? [module.fills] : [])) | ||
| const allRoutes = routes() | ||
|
|
||
| const matched = computed(() => match(allRoutes, currentPath.value)) | ||
| const active = computed(() => activePath(currentPath.value, groups)) | ||
| const title = computed(() => groups.flatMap((group) => group.items).find((item) => item.path === active.value)?.label ?? appName) | ||
| </script> | ||
|
|
||
| <template> | ||
| <DashboardWidgets.Provider> | ||
| <HeaderActions.Provider> | ||
| <div class="admin"> | ||
| <aside class="admin-sidebar"> | ||
| <strong class="admin-brand">{{ appName }}</strong> | ||
| <nav v-for="group in groups" :key="group.label"> | ||
| <h2>{{ group.label }}</h2> | ||
| <AdminLink v-for="item in group.items" :key="item.path" :to="item.path" :class="{ active: item.path === active }"> | ||
| {{ item.label }} | ||
| </AdminLink> | ||
| </nav> | ||
| </aside> | ||
|
|
||
| <div class="admin-main"> | ||
| <header class="admin-header"> | ||
| <h1>{{ title }}</h1> | ||
| <HeaderActions.Outlet class="admin-header-actions" /> | ||
| </header> | ||
|
|
||
| <main> | ||
| <component :is="matched.route.component" v-if="matched" :key="currentPath" v-bind="matched.params" /> | ||
| <p v-else>Page not found.</p> | ||
| </main> | ||
| </div> | ||
| </div> | ||
|
|
||
| <component :is="fill" v-for="(fill, i) in fills" :key="i" /> | ||
| </HeaderActions.Provider> | ||
| </DashboardWidgets.Provider> | ||
| </template> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| <script setup lang="ts"> | ||
| import { href, navigate } from '../lib/router' | ||
|
|
||
| const props = defineProps<{ to: string }>() | ||
|
|
||
| // Client-side navigation for a plain click; a new tab or window still gets the real URL. | ||
| function onClick(event: MouseEvent) { | ||
| if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return | ||
|
|
||
| event.preventDefault() | ||
| navigate(props.to) | ||
| } | ||
| </script> | ||
|
|
||
| <template> | ||
| <a :href="href(to)" @click="onClick"><slot /></a> | ||
| </template> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| <script setup lang="ts"> | ||
| import { DashboardWidgets } from './slots' | ||
| </script> | ||
|
|
||
| <template> | ||
| <DashboardWidgets.Outlet class="admin-widgets" /> | ||
| </template> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { createSlot } from '../lib/slot' | ||
|
|
||
| /** The dashboard's widget grid. Modules fill it from the `fills` of their registration, in module order. */ | ||
| export const DashboardWidgets = createSlot('dashboard-widgets') | ||
|
|
||
| /** The top right of the header. A page renders <HeaderActions.Fill> for as long as it is shown. */ | ||
| export const HeaderActions = createSlot('header-actions') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about 'Module'?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mean Module.Registry() ?