diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 02d63388..d58ce0dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -143,7 +143,7 @@ With developer mode on, the top-bar **bug menu** shows sectioned actions (Record ## View modifier expressions (SWC) -Dynamic `invisible` / `readonly` / `required` expressions in form and list arch are evaluated client-side with a **frozen allowlist** of identifiers: record field names, `user_id`, `company_id`, and `context` (object). Expressions must be boolean JavaScript fragments (for example `state == 'done'`), not statements. Tokens such as `function`, `=>`, `[`, `` ` ``, or `;` are rejected. Static arch flags still apply when an expression is missing or invalid. +Dynamic `invisible` / `readonly` / `required` expressions in form and list arch are evaluated client-side with a **frozen allowlist** of identifiers: record field names, `user_id`, `company_id`, and `context` (object). Expressions must be boolean JavaScript fragments (for example `state == 'done'`), not statements. Tokens such as `function`, `=>`, `[`, `` ` ``, or `;` are rejected. Static arch flags still apply when an expression is missing or invalid. List column expressions that reference record fields are evaluated without a row context (static arch flags apply). Action `context` on the workspace payload is not wired yet — `context` is an empty object until then. ## Pull requests diff --git a/README.md b/README.md index e866708f..4292b5a4 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,10 @@ [![Pre-Alpha](https://img.shields.io/badge/Status-Pre--Alpha-critical)](https://github.com/ProjectMeru/sumeru) [![Docs](https://img.shields.io/badge/Docs-projectmeru.github.io-informational)](https://projectmeru.github.io/sumeru/docs/) -![Sumeru settings and apps hub](core/engine/assets/img/hero_img.png) +![Sumeru settings](core/engine/assets/img/screenshots/hero_img.png) +![Sumeru apps hub](core/engine/assets/img/screenshots/apps_imgs.png) -*Settings hub — configure companies, users, localization, and open installed apps.* +_Settings hub — configure companies, users, localization, and open installed apps._ > [!CAUTION] > @@ -89,25 +90,25 @@ Most development happens in **`sumeru_custom_addons`**. The core repo uses the s ### Custom workspace (`sumeru_custom_addons`) -| Step | Command | What it does | -| ---- | ------- | ------------ | -| First time | `make setup` | Config, go.mod replaces, import generation, SWC + login JS | -| Daily dev | `make run` or `make dev` | Regenerate imports, rebuild assets if stale, start HTTP server | -| After SWC/CSS edits | `make swc` | Force rebuild workspace bundle + login JS | -| Pull upstream | `git pull` in `sumeru` / `sumeru_addons`, then `make run` | Rebuilds assets when sources changed | -| Tests | `make check` | SWC typecheck + Go tests | -| Binary | `make build` | Imports + assets + `bin/sumeru-erp` | +| Step | Command | What it does | +| ------------------- | --------------------------------------------------------- | -------------------------------------------------------------- | +| First time | `make setup` | Config, go.mod replaces, import generation, SWC + login JS | +| Daily dev | `make run` or `make dev` | Regenerate imports, rebuild assets if stale, start HTTP server | +| After SWC/CSS edits | `make swc` | Force rebuild workspace bundle + login JS | +| Pull upstream | `git pull` in `sumeru` / `sumeru_addons`, then `make run` | Rebuilds assets when sources changed | +| Tests | `make check` | SWC typecheck + Go tests | +| Binary | `make build` | Imports + assets + `bin/sumeru-erp` | ### Core repo (`sumeru`) -| Step | Command | What it does | -| ---- | ------- | ------------ | -| First time | `make setup` | `sumeru.conf`, SWC assets, `cmd/sumeru/zimports.go` | -| Daily dev | `make run` | `generate` + `assets` + `go run` | -| Force client rebuild | `make swc` | Always rebuild SWC + login JS | -| Incremental assets | `make assets` | Build only when bundles missing or sources changed | -| Tests | `make check` | `swc-check` + `go test ./...` | -| Binary | `make build` | `generate` + `assets` + `./sumeru` binary | +| Step | Command | What it does | +| -------------------- | ------------- | --------------------------------------------------- | +| First time | `make setup` | `sumeru.conf`, SWC assets, `cmd/sumeru/zimports.go` | +| Daily dev | `make run` | `generate` + `assets` + `go run` | +| Force client rebuild | `make swc` | Always rebuild SWC + login JS | +| Incremental assets | `make assets` | Build only when bundles missing or sources changed | +| Tests | `make check` | `swc-check` + `go test ./...` | +| Binary | `make build` | `generate` + `assets` + `./sumeru` binary | ```text make run → generate (Go imports) → assets (SWC + login JS) → go run server @@ -117,11 +118,11 @@ make run → generate (Go imports) → assets (SWC + login JS) → go run The browser UI is **not stored in git**. Sources live under **`core/swc/src/`** (TypeScript); build outputs are gitignored (see `.gitignore`). -| Output | Purpose | -| ------ | ------- | -| `core/engine/assets/swc/swc.js` | Workspace UI (`/static/swc/swc.js`) | -| `core/engine/assets/js/sumeru-password-toggle.js` | Login / setup password visibility | -| `core/engine/assets/js/sumeru-password-match.js` | Setup password confirmation | +| Output | Purpose | +| ------------------------------------------------- | ----------------------------------- | +| `core/engine/assets/swc/swc.js` | Workspace UI (`/static/swc/swc.js`) | +| `core/engine/assets/js/sumeru-password-toggle.js` | Login / setup password visibility | +| `core/engine/assets/js/sumeru-password-match.js` | Setup password confirmation | Build with **`make swc`** (always rebuild) or let **`make assets`** / **`make run`** build when bundles are missing or SWC sources changed. A fresh clone needs **Node.js** — run **`make setup`** or **`make run`** once before opening the app. @@ -140,57 +141,57 @@ sumeru_custom_addons loads sumeru (core) + sumeru_addons via addons_path in sumeru.conf ``` -| Repository | Role | -| ---------- | ---- | -| **[sumeru](https://github.com/ProjectMeru/sumeru)** | Core engine + kernel apps (`base`, `mail`, …) | -| **[sumeru_addons](https://github.com/ProjectMeru/sumeru_addons)** | Standard business apps (CRM, Sales, Inventory, …) | +| Repository | Role | +| ------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| **[sumeru](https://github.com/ProjectMeru/sumeru)** | Core engine + kernel apps (`base`, `mail`, …) | +| **[sumeru_addons](https://github.com/ProjectMeru/sumeru_addons)** | Standard business apps (CRM, Sales, Inventory, …) | | **[sumeru_custom_addons](https://github.com/ProjectMeru/sumeru_custom_addons)** | Your workspace: custom addons, INI, generated imports, `make run` | **Entry binary:** `cmd/sumeru/main.go` → `sumeru/core/server`. Library code under `core/` has no `main`. ## Tech stack -| Layer | Technology | -| ----- | ---------- | -| Server | Go 1.26.6+, structured logging (`log/slog`) | -| Database | PostgreSQL | -| Modules | Go addons + XML views/menus + manifest sync | -| Workspace UI | SWC (TypeScript) — sources in `core/swc/` | -| Client build | Node.js + npm (esbuild); `make assets` / `make swc` | -| Styling | Plain CSS (`core/engine/assets/css/`) | -| API | JSON-RPC at `POST /api/rpc`, health at `GET /api/health` | +| Layer | Technology | +| ------------ | -------------------------------------------------------- | +| Server | Go 1.26.6+, structured logging (`log/slog`) | +| Database | PostgreSQL | +| Modules | Go addons + XML views/menus + manifest sync | +| Workspace UI | SWC (TypeScript) — sources in `core/swc/` | +| Client build | Node.js + npm (esbuild); `make assets` / `make swc` | +| Styling | Plain CSS (`core/engine/assets/css/`) | +| API | JSON-RPC at `POST /api/rpc`, health at `GET /api/health` | CLI flags: `-c` config, `-d` database, `-p` port, `-i` install modules, `-u` update modules. See [Tooling docs](https://projectmeru.github.io/sumeru/docs/guides/build/tooling.html). ## Documentation -| Resource | Contents | -| -------- | -------- | -| [Documentation home](https://projectmeru.github.io/sumeru/docs/) | Guides, reference, and tutorials | -| [Installation](https://projectmeru.github.io/sumeru/docs/guides/start/installation.html) | First-time setup | -| [Configuration](https://projectmeru.github.io/sumeru/docs/guides/start/configuration.html) | `sumeru.conf` keys and paths | -| [JSON-RPC API](https://projectmeru.github.io/sumeru/docs/reference/json-rpc.html) | RPC methods, auth, errors | -| [SWC architecture](https://projectmeru.github.io/sumeru/docs/guides/concepts/swc-architecture.html) | Workspace client | -| [Creating an addon](https://projectmeru.github.io/sumeru/docs/guides/build/creating-an-addon.html) | Module authoring | -| [Tooling](https://projectmeru.github.io/sumeru/docs/guides/build/tooling.html) | Makefile, import-gen, CLI | -| [sumeru_addons README](https://github.com/ProjectMeru/sumeru_addons/blob/main/README.md) | Standard business apps | -| [sumeru_custom_addons README](https://github.com/ProjectMeru/sumeru_custom_addons/blob/main/README.md) | Workspace runner | +| Resource | Contents | +| ------------------------------------------------------------------------------------------------------ | -------------------------------- | +| [Documentation home](https://projectmeru.github.io/sumeru/docs/) | Guides, reference, and tutorials | +| [Installation](https://projectmeru.github.io/sumeru/docs/guides/start/installation.html) | First-time setup | +| [Configuration](https://projectmeru.github.io/sumeru/docs/guides/start/configuration.html) | `sumeru.conf` keys and paths | +| [JSON-RPC API](https://projectmeru.github.io/sumeru/docs/reference/json-rpc.html) | RPC methods, auth, errors | +| [SWC architecture](https://projectmeru.github.io/sumeru/docs/guides/concepts/swc-architecture.html) | Workspace client | +| [Creating an addon](https://projectmeru.github.io/sumeru/docs/guides/build/creating-an-addon.html) | Module authoring | +| [Tooling](https://projectmeru.github.io/sumeru/docs/guides/build/tooling.html) | Makefile, import-gen, CLI | +| [sumeru_addons README](https://github.com/ProjectMeru/sumeru_addons/blob/main/README.md) | Standard business apps | +| [sumeru_custom_addons README](https://github.com/ProjectMeru/sumeru_custom_addons/blob/main/README.md) | Workspace runner | Configuration template: **`sumeru.conf.example`** in this repo. ## Project layout -| Path | Purpose | -| ---- | ------- | -| `core/orm/` | PostgreSQL models, CRUD, registry | +| Path | Purpose | +| -------------- | ---------------------------------------- | +| `core/orm/` | PostgreSQL models, CRUD, registry | | `core/engine/` | View XML, HTML render, templates, assets | -| `core/server/` | INI config, HTTP handlers | -| `core/module/` | Addon discovery, install/update | -| `core/sdk/` | Stable Go API for addons | -| `core/swc/` | Workspace UI source (TypeScript) | -| `cmd/sumeru/` | Server binary + generated imports | -| `addons/` | Kernel apps shipped with core | -| `test/` | Unit and integration tests | +| `core/server/` | INI config, HTTP handlers | +| `core/module/` | Addon discovery, install/update | +| `core/sdk/` | Stable Go API for addons | +| `core/swc/` | Workspace UI source (TypeScript) | +| `cmd/sumeru/` | Server binary + generated imports | +| `addons/` | Kernel apps shipped with core | +| `test/` | Unit and integration tests | ## Contributing diff --git a/addons/base/manifest.json b/addons/base/manifest.json index 998880ec..70e95540 100644 --- a/addons/base/manifest.json +++ b/addons/base/manifest.json @@ -12,7 +12,6 @@ "views/core_company_actions.xml", "views/core_users_actions.xml", "views/platform_actions.xml", - "views/res_config_settings_actions.xml", "views/res_config_settings_form_views.xml", "views/geo_actions.xml", "views/i18n_actions.xml", diff --git a/addons/base/models/res_config_settings.go b/addons/base/models/res_config_settings.go index 0d2ed938..68c14a37 100644 --- a/addons/base/models/res_config_settings.go +++ b/addons/base/models/res_config_settings.go @@ -1,10 +1,8 @@ package models -import ( - "sumeru/core/sdk" -) +import "sumeru/core/sdk" -// ResConfigSettings is the platform settings shell extended by application modules. +// ResConfigSettings is a transient settings shell model extended by addons (not linked from base menus). type ResConfigSettings struct { sdk.Model `sumeru:"model=res.config.settings"` } diff --git a/addons/base/views/i18n_actions.xml b/addons/base/views/i18n_actions.xml index c01d1bfb..aa7a77f4 100644 --- a/addons/base/views/i18n_actions.xml +++ b/addons/base/views/i18n_actions.xml @@ -3,6 +3,5 @@ - diff --git a/addons/base/views/menus.xml b/addons/base/views/menus.xml index 4f3be594..9349c2b9 100644 --- a/addons/base/views/menus.xml +++ b/addons/base/views/menus.xml @@ -5,30 +5,37 @@ - + - + - - + + + + + + + + + + + + + + + - - - - - - @@ -40,16 +47,12 @@ - + - - - - - + diff --git a/addons/base/views/platform_actions.xml b/addons/base/views/platform_actions.xml index 5c80a781..9be4584b 100644 --- a/addons/base/views/platform_actions.xml +++ b/addons/base/views/platform_actions.xml @@ -5,5 +5,8 @@ + + + diff --git a/addons/base/views/res_config_settings_actions.xml b/addons/base/views/res_config_settings_actions.xml deleted file mode 100644 index bb885d6c..00000000 --- a/addons/base/views/res_config_settings_actions.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/addons/base/views/res_config_settings_form_views.xml b/addons/base/views/res_config_settings_form_views.xml index b822d028..b4eafcf7 100644 --- a/addons/base/views/res_config_settings_form_views.xml +++ b/addons/base/views/res_config_settings_form_views.xml @@ -2,7 +2,9 @@ - +
+ +
diff --git a/addons/base/views/security_actions.xml b/addons/base/views/security_actions.xml index 2c78eb36..16ccc907 100644 --- a/addons/base/views/security_actions.xml +++ b/addons/base/views/security_actions.xml @@ -5,5 +5,8 @@ + + + diff --git a/addons/base/views/sys_access_form_views.xml b/addons/base/views/sys_access_form_views.xml index c86a5471..18d7b5b1 100644 --- a/addons/base/views/sys_access_form_views.xml +++ b/addons/base/views/sys_access_form_views.xml @@ -20,6 +20,7 @@ +

Bulk edit model permissions at Settings → Security → Model access matrix (/web/settings/model-acl).

diff --git a/addons/base/views/sys_field_access_form_views.xml b/addons/base/views/sys_field_access_form_views.xml index c98cd0bd..3054a930 100644 --- a/addons/base/views/sys_field_access_form_views.xml +++ b/addons/base/views/sys_field_access_form_views.xml @@ -11,6 +11,7 @@ +

Bulk edit at Settings → Security → Field access matrix (/web/settings/field-acl).

diff --git a/core/engine/assets/css/sumeru-settings-hub.css b/core/engine/assets/css/sumeru-settings-hub.css index 32353eba..b25fd59a 100644 --- a/core/engine/assets/css/sumeru-settings-hub.css +++ b/core/engine/assets/css/sumeru-settings-hub.css @@ -9,6 +9,16 @@ padding: 0 0.25rem 2.5rem; } +.sum-settings-account-lead { + margin: 0 0 1rem; + font-size: var(--sum-font-size-sm); + color: var(--sum-muted); +} + +.sum-settings-account-block { + margin-bottom: 1.25rem; +} + .sum-settings-hub-head { display: flex; flex-wrap: wrap; @@ -135,19 +145,72 @@ gap: 0.35rem; } -.sum-settings-hub-links a { - display: inline-block; - padding: 0.25rem 0; +.sum-settings-hub-group-title { + margin: 0.75rem 0 0.35rem; + font-size: var(--sum-font-size-sm); + font-weight: var(--sum-font-weight-semibold); + color: var(--sum-ink); +} + +.sum-settings-hub-group-title:first-of-type { + margin-top: 0; +} + +.sum-settings-hub-link { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.35rem 0; font-size: var(--sum-font-size-md); font-weight: var(--sum-font-weight-medium); color: var(--sum-header); text-decoration: none !important; } -.sum-settings-hub-links a:hover { +.sum-settings-hub-link:hover { + text-decoration: underline !important; +} + +.sum-settings-hub-link-icon { + width: 1.1rem; + flex-shrink: 0; +} + +.sum-settings-hub-links li.is-filtered-out { + display: none; +} + +.sum-settings-hub-single-link { + display: inline-flex; + align-items: center; + margin-top: 0.15rem; + padding: 0.45rem 0; + font-size: var(--sum-font-size-md); + font-weight: var(--sum-font-weight-semibold); + color: var(--sum-header); + text-decoration: none !important; +} + +.sum-settings-hub-single-link:hover { text-decoration: underline !important; } +.sum-settings-hub-footer { + margin: 2rem 0 0; + padding-top: 1rem; + border-top: 1px solid var(--sum-line); + font-size: var(--sum-font-size-sm); +} + +.sum-settings-hub-footer-link { + color: var(--sum-header); + font-weight: var(--sum-font-weight-medium); +} + +.sum-body--settings-hub .sum-shell--no-sidebar .sum-content { + max-width: 960px; +} + .sum-settings-hub-empty { grid-column: 1 / -1; margin: 0; @@ -252,3 +315,62 @@ text-overflow: ellipsis; white-space: nowrap; } + +.sum-settings-field-acl-picker { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.sum-settings-field-acl-scroll { + overflow-x: auto; + margin: 1rem 0; +} + +.sum-settings-field-acl-table { + border-collapse: collapse; + min-width: 100%; + font-size: 0.875rem; +} + +.sum-settings-field-acl-table th, +.sum-settings-field-acl-table td { + border: 1px solid var(--sum-border, #ddd); + padding: 0.35rem 0.5rem; + text-align: center; +} + +.sum-settings-field-acl-table th[scope="row"] { + text-align: left; + white-space: nowrap; +} + +.sum-settings-field-acl-table thead th { + position: sticky; + top: 0; + background: var(--sum-surface, #fff); + z-index: 1; +} + +.sum-settings-acl-badge { + display: inline-block; + margin-left: 0.35rem; + padding: 0.1rem 0.35rem; + font-size: 0.65rem; + font-weight: 600; + border-radius: 3px; + background: var(--sum-border, #e5e5e5); + color: var(--sum-text, #333); + vertical-align: middle; +} + +.sum-settings-acl-badge--kernel { + background: #fef3c7; + color: #92400e; +} + +.sum-settings-field-acl-actions { + margin: 0 0 1rem; +} diff --git a/core/engine/assets/img/hero_img.png b/core/engine/assets/img/hero_img.png deleted file mode 100644 index eed8b68a..00000000 Binary files a/core/engine/assets/img/hero_img.png and /dev/null differ diff --git a/core/engine/assets/img/screenshots/apps_imgs.png b/core/engine/assets/img/screenshots/apps_imgs.png new file mode 100644 index 00000000..922a1974 Binary files /dev/null and b/core/engine/assets/img/screenshots/apps_imgs.png differ diff --git a/core/engine/assets/img/screenshots/hero_img.png b/core/engine/assets/img/screenshots/hero_img.png new file mode 100644 index 00000000..02e4ae32 Binary files /dev/null and b/core/engine/assets/img/screenshots/hero_img.png differ diff --git a/core/engine/render/menus.go b/core/engine/render/menus.go index 0c5cdbfe..0a5c56b1 100644 --- a/core/engine/render/menus.go +++ b/core/engine/render/menus.go @@ -84,6 +84,8 @@ func fetchShellMenus(ctx context.Context) []parser.MenuItem { } } + urlActionHrefs := loadURLActionHrefs(ctx) + var allMenus []parser.MenuItem for rows.Next() { var id int @@ -107,7 +109,11 @@ func fetchShellMenus(ctx context.Context) []parser.MenuItem { m.ParentID = fmt.Sprintf("%d", parentID.Int64) } if actionID.Valid && actionID.Int64 != 0 { - m.Action = fmt.Sprintf("/web?action=%d&menu_id=%d", actionID.Int64, id) + if href := urlActionHrefs[int(actionID.Int64)]; href != "" { + m.Action = href + } else { + m.Action = fmt.Sprintf("/web?action=%d&menu_id=%d", actionID.Int64, id) + } } else if !parentID.Valid && strings.EqualFold(strings.TrimSpace(name), "Home") { m.Action = "/web/home" } @@ -116,9 +122,31 @@ func fetchShellMenus(ctx context.Context) []parser.MenuItem { if err := rows.Err(); err != nil { applog.WarnMsg(ctx, "render", "menus", "Menu rows error", err, nil) } + applySettingsNavHrefOverrides(ctx, allMenus) return allMenus } +func loadURLActionHrefs(ctx context.Context) map[int]string { + out := make(map[int]string) + rows, err := orm.Search(ctx, "sys.action.url", nil) + if err != nil { + applog.WarnMsg(ctx, "render", "menus", "Error loading URL actions", err, nil) + return out + } + for _, row := range rows { + id, ok := orm.CoerceInt64(row["id"]) + if !ok || id <= 0 { + continue + } + url := strings.TrimSpace(orm.AsString(row["url"])) + if url == "" || !strings.HasPrefix(url, "/") { + continue + } + out[int(id)] = url + } + return out +} + func buildTopBarMenus(allMenus []parser.MenuItem, appMods map[string]struct{}, menuAllowed func(parser.MenuItem) bool) []parser.MenuItem { var topMenus []parser.MenuItem for _, m := range allMenus { @@ -188,6 +216,80 @@ func shellTitleForModule(allMenus []parser.MenuItem, activeModuleID, fallback st return fallback } +func menuItemHasWindowOrURLAction(mi parser.MenuItem) bool { + action := strings.TrimSpace(mi.Action) + if strings.Contains(action, "action=") { + return true + } + return strings.HasPrefix(action, "/web/") && !strings.HasPrefix(action, "/web?") +} + +func filterSidebarLinks(links []parser.MenuItem) []parser.MenuItem { + if len(links) == 0 { + return links + } + out := links[:0] + for _, mi := range links { + if settingsNavExcludedSection(mi.Name) || settingsNavExcludedLink(mi.Name, mi.Action) { + continue + } + out = append(out, mi) + } + return out +} + +func menuItemHasAllowedChild(parentID string, allMenus []parser.MenuItem, menuAllowed func(parser.MenuItem) bool) bool { + for _, sub := range allMenus { + if sub.ParentID == parentID && menuAllowed(sub) { + return true + } + } + return false +} + +// collectSidebarLinks returns depth-first navigable descendants of parentID (not parent itself). +func collectSidebarLinks(parentID string, allMenus []parser.MenuItem, menuAllowed func(parser.MenuItem) bool) []parser.MenuItem { + var children []parser.MenuItem + for _, sub := range allMenus { + if sub.ParentID == parentID && menuAllowed(sub) { + children = append(children, sub) + } + } + sort.Slice(children, func(i, j int) bool { + if children[i].Sequence != children[j].Sequence { + return children[i].Sequence < children[j].Sequence + } + return children[i].Name < children[j].Name + }) + + var out []parser.MenuItem + for _, child := range children { + if settingsNavExcludedSection(child.Name) || settingsNavExcludedLink(child.Name, child.Action) { + continue + } + hasChildren := menuItemHasAllowedChild(child.ID, allMenus, menuAllowed) + switch { + case menuItemHasWindowOrURLAction(child): + out = append(out, child) + case !hasChildren: + out = append(out, child) + } + if hasChildren { + out = append(out, collectSidebarLinks(child.ID, allMenus, menuAllowed)...) + } + } + return out +} + +func sortMenuItemsBySequenceName(items []parser.MenuItem) { + sort.Slice(items, func(i, j int) bool { + if items[i].Sequence != items[j].Sequence { + return items[i].Sequence < items[j].Sequence + } + return items[i].Name < items[j].Name + }) +} + func buildSidebarMenus(allMenus []parser.MenuItem, activeModuleID string, menuAllowed func(parser.MenuItem) bool) []SidebarMenu { if activeModuleID == "" { return nil @@ -195,22 +297,16 @@ func buildSidebarMenus(allMenus []parser.MenuItem, activeModuleID string, menuAl var sidebarMenus []SidebarMenu var sections []SidebarMenu for _, m := range allMenus { - if m.ParentID != activeModuleID || !menuAllowed(m) { + if m.ParentID != activeModuleID || !menuAllowed(m) || settingsNavExcludedSection(m.Name) { continue } section := SidebarMenu{ID: m.ID, Name: m.Name, Sequence: m.Sequence} - for _, sub := range allMenus { - if sub.ParentID != m.ID || !menuAllowed(sub) { - continue - } - section.SubMenus = append(section.SubMenus, sub) + links := collectSidebarLinks(m.ID, allMenus, menuAllowed) + if menuItemHasWindowOrURLAction(m) && !settingsNavExcludedLink(m.Name, m.Action) { + links = append([]parser.MenuItem{m}, links...) } - sort.Slice(section.SubMenus, func(i, j int) bool { - if section.SubMenus[i].Sequence != section.SubMenus[j].Sequence { - return section.SubMenus[i].Sequence < section.SubMenus[j].Sequence - } - return section.SubMenus[i].Name < section.SubMenus[j].Name - }) + sortMenuItemsBySequenceName(links) + section.SubMenus = filterSidebarLinks(links) if len(section.SubMenus) == 0 { continue } diff --git a/core/engine/render/settings_hub_menus.go b/core/engine/render/settings_hub_menus.go new file mode 100644 index 00000000..80546d97 --- /dev/null +++ b/core/engine/render/settings_hub_menus.go @@ -0,0 +1,216 @@ +package render + +import ( + "context" + "strings" + + "sumeru/core/engine/parser" + "sumeru/core/orm" +) + +// SettingsHubLink is one navigable entry on the settings hub. +type SettingsHubLink struct { + Name string + Href string + MenuID string + WebIcon string + ActionKey string +} + +// SettingsHubGroup is an optional subgroup inside a category card. +type SettingsHubGroup struct { + Title string + Links []SettingsHubLink +} + +// SettingsHubCategory is a top-level settings hub section (settings root child). +type SettingsHubCategory struct { + Title string + Sequence int + FilterText string + SingleLink bool + Groups []SettingsHubGroup +} + +func hubLinkActionKey(mi parser.MenuItem) string { + action := strings.TrimSpace(mi.Action) + if idx := strings.Index(action, "action="); idx >= 0 { + rest := action[idx+len("action="):] + if amp := strings.Index(rest, "&"); amp >= 0 { + rest = rest[:amp] + } + rest = strings.TrimSpace(rest) + if rest != "" { + return "action:" + rest + } + } + return "menu:" + strings.TrimSpace(mi.ID) +} + +func hubLinkFromMenu(mi parser.MenuItem, seen map[string]struct{}) (SettingsHubLink, bool) { + name := strings.TrimSpace(mi.Name) + href := strings.TrimSpace(mi.Action) + if name == "" || href == "" || settingsNavExcludedLink(name, href) { + return SettingsHubLink{}, false + } + key := hubLinkActionKey(mi) + if _, dup := seen[key]; dup { + return SettingsHubLink{}, false + } + seen[key] = struct{}{} + return SettingsHubLink{ + Name: name, + Href: href, + MenuID: mi.ID, + WebIcon: mi.WebIcon, + ActionKey: key, + }, true +} + +func settingsHubFilterText(categoryTitle string, groups []SettingsHubGroup) string { + terms := []string{strings.ToLower(strings.TrimSpace(categoryTitle))} + for _, g := range groups { + if t := strings.TrimSpace(g.Title); t != "" { + terms = append(terms, strings.ToLower(t)) + } + for _, l := range g.Links { + if n := strings.TrimSpace(l.Name); n != "" { + terms = append(terms, strings.ToLower(n)) + } + } + } + return strings.Join(terms, " ") +} + +func buildSettingsHubCategory(catNode parser.MenuItem, allMenus []parser.MenuItem, menuAllowed func(parser.MenuItem) bool, seen map[string]struct{}) SettingsHubCategory { + var groups []SettingsHubGroup + var defaultLinks []SettingsHubLink + + if menuItemHasWindowOrURLAction(catNode) { + if link, ok := hubLinkFromMenu(catNode, seen); ok { + defaultLinks = append(defaultLinks, link) + } + } + + var children []parser.MenuItem + for _, sub := range allMenus { + if sub.ParentID == catNode.ID && menuAllowed(sub) { + children = append(children, sub) + } + } + sortMenuItemsBySequenceName(children) + + for _, child := range children { + if settingsNavExcludedSection(child.Name) { + continue + } + hasChildren := menuItemHasAllowedChild(child.ID, allMenus, menuAllowed) + isContainer := hasChildren && !menuItemHasWindowOrURLAction(child) + if isContainer { + subMenus := collectSidebarLinks(child.ID, allMenus, menuAllowed) + var links []SettingsHubLink + for _, mi := range subMenus { + if link, ok := hubLinkFromMenu(mi, seen); ok { + links = append(links, link) + } + } + if len(links) > 0 { + groups = append(groups, SettingsHubGroup{Title: child.Name, Links: links}) + } + continue + } + if menuItemHasWindowOrURLAction(child) || !hasChildren { + if link, ok := hubLinkFromMenu(child, seen); ok { + defaultLinks = append(defaultLinks, link) + } + } + } + + if len(defaultLinks) > 0 { + groups = append([]SettingsHubGroup{{Links: defaultLinks}}, groups...) + } + + singleLink := len(groups) == 1 && len(groups[0].Links) == 1 && groups[0].Title == "" && + strings.EqualFold(strings.TrimSpace(groups[0].Links[0].Name), strings.TrimSpace(catNode.Name)) + + return SettingsHubCategory{ + Title: catNode.Name, + Sequence: catNode.Sequence, + Groups: groups, + SingleLink: singleLink, + FilterText: settingsHubFilterText(catNode.Name, groups), + } +} + +// BuildSettingsHubCategories lists settings hub cards from the menu tree under settingsRootMenuID. +func BuildSettingsHubCategories(ctx context.Context, settingsRootMenuID string) []SettingsHubCategory { + settingsRootMenuID = strings.TrimSpace(settingsRootMenuID) + if settingsRootMenuID == "" { + return nil + } + allMenus := fetchShellMenus(ctx) + if len(allMenus) == 0 { + return nil + } + uid := orm.UIDFromContext(ctx) + menuAllowed := func(mi parser.MenuItem) bool { return shellMenuAllowed(ctx, uid, mi) } + + var rootChildren []parser.MenuItem + for _, m := range allMenus { + if m.ParentID == settingsRootMenuID && menuAllowed(m) { + rootChildren = append(rootChildren, m) + } + } + sortMenuItemsBySequenceName(rootChildren) + + seen := make(map[string]struct{}) + var categories []SettingsHubCategory + for _, catNode := range rootChildren { + if isSettingsHubNavMenu(ctx, catNode.ID) || settingsNavExcludedSection(catNode.Name) { + continue + } + cat := buildSettingsHubCategory(catNode, allMenus, menuAllowed, seen) + if len(cat.Groups) == 0 { + continue + } + categories = append(categories, cat) + } + return categories +} + +// BuildSettingsHubCategoriesForTest builds hub categories from an in-memory menu tree (tests). +func BuildSettingsHubCategoriesForTest(allMenus []parser.MenuItem, settingsRootMenuID string, menuAllowed func(parser.MenuItem) bool, skipMenuIDs ...string) []SettingsHubCategory { + settingsRootMenuID = strings.TrimSpace(settingsRootMenuID) + if settingsRootMenuID == "" || menuAllowed == nil { + return nil + } + skip := map[string]struct{}{} + for _, id := range skipMenuIDs { + if id = strings.TrimSpace(id); id != "" { + skip[id] = struct{}{} + } + } + var rootChildren []parser.MenuItem + for _, m := range allMenus { + if m.ParentID == settingsRootMenuID && menuAllowed(m) { + rootChildren = append(rootChildren, m) + } + } + sortMenuItemsBySequenceName(rootChildren) + seen := make(map[string]struct{}) + var categories []SettingsHubCategory + for _, catNode := range rootChildren { + if _, omit := skip[catNode.ID]; omit { + continue + } + if settingsNavExcludedSection(catNode.Name) { + continue + } + cat := buildSettingsHubCategory(catNode, allMenus, menuAllowed, seen) + if len(cat.Groups) == 0 { + continue + } + categories = append(categories, cat) + } + return categories +} diff --git a/core/engine/render/settings_nav.go b/core/engine/render/settings_nav.go new file mode 100644 index 00000000..a07bc457 --- /dev/null +++ b/core/engine/render/settings_nav.go @@ -0,0 +1,104 @@ +package render + +import ( + "context" + "strconv" + "strings" + + "sumeru/core/engine/parser" + "sumeru/core/orm" +) + +// MenuGeneralSettingsXMLID is the settings hub sidebar entry (URL action → SettingsHubPath). +const MenuGeneralSettingsXMLID = "base.menu_general_settings" + +const ( + SettingsHubPath = "/web/settings" + SettingsAccountPath = "/web/settings/account" + SettingsFieldACLPath = "/web/settings/field-acl" + SettingsModelACLPath = "/web/settings/model-acl" +) + +// Menu XML ids for settings security matrix pages (active sidebar highlight). +const ( + MenuFieldAccessMatrixXMLID = "base.menu_sys_field_access_matrix" + MenuModelAccessMatrixXMLID = "base.menu_sys_access_matrix" +) + +func settingsNavExcludedSection(title string) bool { + return strings.EqualFold(strings.TrimSpace(title), "Personal") +} + +func settingsNavExcludedLink(name, href string) bool { + if strings.EqualFold(strings.TrimSpace(name), "Account security") { + return true + } + return strings.Contains(strings.TrimSpace(href), SettingsAccountPath) +} + +func menuIDForXMLID(ctx context.Context, xmlID string) string { + xmlID = strings.TrimSpace(xmlID) + if xmlID == "" { + return "" + } + id, _, err := orm.ResolveXmlId(ctx, xmlID) + if err != nil || id <= 0 { + return "" + } + return strconv.Itoa(id) +} + +func isSettingsHubNavMenu(ctx context.Context, menuID string) bool { + menuID = strings.TrimSpace(menuID) + if menuID == "" { + return false + } + return menuID == menuIDForXMLID(ctx, MenuGeneralSettingsXMLID) +} + +// MenuIDIsGeneralSettings reports whether menuID is base.menu_general_settings. +func MenuIDIsGeneralSettings(ctx context.Context, menuID string) bool { + return isSettingsHubNavMenu(ctx, menuID) +} + +// MenuIDForXMLID resolves a menu xml id to the numeric menu id string used in the shell. +func MenuIDForXMLID(ctx context.Context, xmlID string) string { + return menuIDForXMLID(ctx, xmlID) +} + +func applySettingsNavHrefOverrides(ctx context.Context, menus []parser.MenuItem) { + type override struct { + xmlID string + href string + } + for _, o := range []override{ + {MenuGeneralSettingsXMLID, SettingsHubPath}, + {MenuFieldAccessMatrixXMLID, SettingsFieldACLPath}, + {MenuModelAccessMatrixXMLID, SettingsModelACLPath}, + } { + menuID := menuIDForXMLID(ctx, o.xmlID) + if menuID == "" { + continue + } + for i := range menus { + if menus[i].ID == menuID { + menus[i].Action = o.href + break + } + } + } +} + +// ApplySettingsNavHrefOverridesForTest applies in-memory href overrides (tests without DB xml ids). +func ApplySettingsNavHrefOverridesForTest(menus []parser.MenuItem, generalSettingsMenuID string) { + generalSettingsMenuID = strings.TrimSpace(generalSettingsMenuID) + if generalSettingsMenuID == "" { + return + } + for i := range menus { + if menus[i].ID == generalSettingsMenuID { + menus[i].Action = SettingsHubPath + return + } + } +} diff --git a/core/engine/templates/settings_account_inner.html b/core/engine/templates/settings_account_inner.html index 130494a6..c4ef597d 100644 --- a/core/engine/templates/settings_account_inner.html +++ b/core/engine/templates/settings_account_inner.html @@ -10,7 +10,6 @@

Account security