Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4da78b4
feat(menu-plugin): data-driven admin sidebar navigation
lane711 Jun 22, 2026
8d7de3b
refactor(menu-plugin): make opt-in, remove from core plugin list
lane711 Jun 22, 2026
3e32b52
chore(plugins): regenerate manifest registry to include menu-plugin
lane711 Jun 22, 2026
6404d90
fix(menu-plugin): rename id to 'menu', restore to core, redirect plug…
lane711 Jun 22, 2026
51be3c9
fix(menu-plugin): remove duplicate Menu sidebar entry
lane711 Jun 22, 2026
d12193a
fix(menu-plugin): add Menu to hardcoded sidebar fallback, hide orphan…
lane711 Jun 22, 2026
9451ef8
feat(menu-plugin): revert to hardcoded sidebar when plugin is disabled
lane711 Jun 22, 2026
35f9460
fix(menu-plugin): query documents table for plugin status, not legacy…
lane711 Jun 22, 2026
9ab831d
fix(menu-plugin): remove Menu from hardcoded sidebar when plugin inac…
lane711 Jun 22, 2026
4df9915
Merge remote-tracking branch 'origin/main' into lane711/menu-plugin-plan
lane711 Jun 23, 2026
b3b14c3
feat(plugins): add settingsTabContent extension point to plugin SDK
lane711 Jun 23, 2026
b8bf856
fix(menu-plugin): hide plugin menu items when plugin is deactivated
lane711 Jun 23, 2026
cd87dfc
fix(menu-plugin): fix CSRF and visible value on visibility toggle forms
lane711 Jun 23, 2026
acf3a60
feat(menu-plugin): show plugin enabled/disabled status in menu manage…
lane711 Jun 23, 2026
ab51544
fix(menu-plugin): fix plugin status lookup in menu list
lane711 Jun 23, 2026
3b148a8
fix(menu-plugin): use same plugin status query as plugins list page
lane711 Jun 23, 2026
fbee953
fix(menu-plugin): fix plugin status badges, edit 404, and sidebar cov…
lane711 Jun 25, 2026
7712eef
chore: merge origin/main into lane711/menu-plugin-plan
lane711 Jun 25, 2026
be79ef8
feat(menu-plugin): add Plugins as default system menu item
lane711 Jun 25, 2026
f94a5fb
fix(menu-plugin): add POST /:id/delete route for delete form
lane711 Jun 25, 2026
7149776
fix(menu-plugin): use collection icon for Plugins menu item
lane711 Jun 25, 2026
dd5e8cc
feat(menu-plugin): clicking menu row navigates to edit page
lane711 Jun 25, 2026
f8b114b
fix(menu-plugin): default to off when not yet activated
lane711 Jun 25, 2026
3e03179
fix(menu-plugin): hide plugin menu items when plugin is deactivated
lane711 Jun 26, 2026
54da931
fix(menu-plugin): seed plugin items visible=false when plugin inactive
lane711 Jun 26, 2026
a8b9aa7
fix(menu-plugin): alphabetize parent dropdown in menu form
lane711 Jun 26, 2026
dfe47de
fix(menu-plugin): fix Save Changes on menu edit form
lane711 Jun 26, 2026
0a16ae3
fix(menu-plugin): allow reparenting system/plugin menu items
lane711 Jun 26, 2026
b3eb300
fix(menu-plugin): skip locked fields in update handler
lane711 Jun 26, 2026
4c1d440
chore: merge origin/main into lane711/menu-plugin-plan
lane711 Jun 30, 2026
ea1f0ee
chore: sync migrations-bundle timestamp after merge
lane711 Jul 1, 2026
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
1 change: 1 addition & 0 deletions my-sonicjs-app/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,4 @@ export default {
boot: app.boot,
}),
};

4 changes: 2 additions & 2 deletions my-sonicjs-app/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ workers_dev = true
# Note: database_name and database_id are automatically updated by GitHub Actions
[[d1_databases]]
binding = "DB"
database_name = "sonicjs-worktree-lane711-hello-cruel-world-plugin"
database_id = "1aa3d74a-be00-4e7f-b6dd-6a3d73701fed"
database_name = "sonicjs-worktree-lane711-menu-plugin-plan"
database_id = "2bffa108-2c31-4710-9a04-8b4f97b16b72"
migrations_dir = "./migrations"

# R2 Bucket for media storage (using CI bucket)
Expand Down
199 changes: 199 additions & 0 deletions packages/core/src/__tests__/services/menu-repository.sqlite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// @ts-nocheck
// Real-SQLite coverage for menu-plugin data layer.
// Tests: idempotent seeding, plugin reconcile, soft-delete guards, reorder batch.
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { createTestD1 } from '../utils/d1-sqlite'
import {
listMenuItems,
buildSidebarTree,
upsertSystemItem,
upsertPluginItem,
reorderItems,
toggleVisibility,
updateItem,
deleteItem,
} from '../../plugins/core-plugins/menu-plugin/services/menu-repository'

const MENU_TYPE_SQL = `
INSERT INTO document_types (id, name, display_name, schema, queryable_fields, settings, source, schema_version, is_system, is_active, created_at, updated_at)
VALUES ('menu_item','menu_item','Menu Item','{}','[]','{}','plugin',1,0,1,1,1)
`

const MENU_FIELDS = [
{ name: 'parent', path: '$.parent', kind: 'scalar', type: 'text', column: 'q_menu_parent' },
{ name: 'visible', path: '$.visible', kind: 'scalar', type: 'boolean', column: 'q_menu_visible' },
{ name: 'source', path: '$.source', kind: 'scalar', type: 'text', column: 'q_menu_source' },
{ name: 'pluginId', path: '$.pluginId', kind: 'scalar', type: 'text', column: 'q_menu_plugin_id' },
]

function systemItemData(overrides = {}) {
return {
label: 'Content',
url: '/admin/content',
icon: 'document',
target: '_self',
isExternal: false,
visible: true,
parent: null,
source: 'system',
pluginId: null,
permissions: [],
lockedFields: ['url', 'parent'],
...overrides,
}
}

describe('menu-repository — real SQLite', () => {
let db

beforeEach(async () => {
db = createTestD1()
db.raw.prepare(MENU_TYPE_SQL).run()
await db.applyScalarSchema('menu_item', MENU_FIELDS)
})

afterEach(() => db.close())

it('upsertSystemItem is idempotent across two boots — no duplicates', async () => {
const slug = 'menu:system:content'
const data = systemItemData()

await upsertSystemItem(db, slug, data, 10)
await upsertSystemItem(db, slug, data, 10)

const items = await listMenuItems(db)
const matching = items.filter((i) => i.slug === slug)
expect(matching).toHaveLength(1)
})

it('upsertPluginItem upserts on first call then only updates url+pluginId', async () => {
const slug = 'menu:plugin:email'
const data = {
label: 'Email',
url: '/admin/email',
icon: 'envelope',
target: '_self',
isExternal: false,
visible: true,
parent: null,
source: 'plugin',
pluginId: 'email',
permissions: [],
lockedFields: ['url', 'parent'],
}

await upsertPluginItem(db, slug, data, 100)

// Simulate admin renaming label — change label in DB directly
const items1 = await listMenuItems(db)
const item = items1.find((i) => i.slug === slug)
expect(item).toBeDefined()
db.raw.prepare(`UPDATE documents SET data = json_set(data, '$.label', ?) WHERE id = ?`)
.run('Email (Custom)', item.id)

// Re-upsert with original data — should NOT overwrite admin's custom label
await upsertPluginItem(db, slug, data, 100)

const items2 = await listMenuItems(db)
const updated = items2.find((i) => i.slug === slug)
expect(updated.label).toBe('Email (Custom)')
expect(updated.url).toBe('/admin/email')
})

it('reorderItems batch-updates sort_order (R1)', async () => {
await upsertSystemItem(db, 'menu:system:content', systemItemData({ label: 'Content' }), 10)
await upsertSystemItem(db, 'menu:system:users', systemItemData({ label: 'Users', url: '/admin/users' }), 20)

const before = await listMenuItems(db)
const [a, b] = before.map((i) => ({ id: i.id, sortOrder: i.sortOrder }))

// Swap sort orders
await reorderItems(db, [
{ id: a.id, sortOrder: b.sortOrder, parent: null },
{ id: b.id, sortOrder: a.sortOrder, parent: null },
])

const after = await listMenuItems(db)
const aAfter = after.find((i) => i.id === a.id)
const bAfter = after.find((i) => i.id === b.id)
expect(aAfter.sortOrder).toBe(b.sortOrder)
expect(bAfter.sortOrder).toBe(a.sortOrder)
})

it('deleteItem blocks system source, allows user source', async () => {
await upsertSystemItem(db, 'menu:system:content', systemItemData({ source: 'system' }), 10)
const items = await listMenuItems(db)
const systemItem = items[0]

const sysResult = await deleteItem(db, systemItem.id)
expect(sysResult.ok).toBe(false)
expect(sysResult.reason).toBeDefined()

// Create a user item
const userSlug = 'menu:user:test'
await upsertSystemItem(db, userSlug, systemItemData({ source: 'user', lockedFields: [] }), 50)
const items2 = await listMenuItems(db)
const userItem = items2.find((i) => i.slug === userSlug)
expect(userItem).toBeDefined()

const userResult = await deleteItem(db, userItem.id)
expect(userResult.ok).toBe(true)

const items3 = await listMenuItems(db)
expect(items3.find((i) => i.id === userItem.id)).toBeUndefined()
})

it('toggleVisibility updates both column and data JSON', async () => {
await upsertSystemItem(db, 'menu:system:settings', systemItemData({ label: 'Settings', url: '/admin/settings' }), 40)
const items = await listMenuItems(db)
const item = items[0]

await toggleVisibility(db, item.id, false)

const row = db.raw.prepare('SELECT visible, data FROM documents WHERE id = ?').get(item.id)
expect(row.visible).toBeFalsy()
const data = JSON.parse(row.data)
expect(data.visible).toBeFalsy()
})

it('updateItem rejects locked fields', async () => {
await upsertSystemItem(db, 'menu:system:content', systemItemData(), 10)
const items = await listMenuItems(db)
const item = items[0]

// Should be rejected — url is locked
const rejected = await updateItem(db, item.id, { url: '/admin/other' }, ['url', 'parent'])
expect(rejected).toBe(false)

// Label change should succeed
const ok = await updateItem(db, item.id, { label: 'My Content' }, ['url', 'parent'])
expect(ok).toBe(true)

const updated = (await listMenuItems(db)).find((i) => i.id === item.id)
expect(updated.label).toBe('My Content')
// URL unchanged
expect(updated.url).toBe('/admin/content')
})

it('buildSidebarTree nests children under parents and sorts by sortOrder', async () => {
await upsertSystemItem(db, 'menu:system:parent', systemItemData({ label: 'Parent', url: '/admin/parent' }), 10)
const all = await listMenuItems(db)
const parentId = all[0].id

// Add child
await upsertSystemItem(db, 'menu:system:child', systemItemData({
label: 'Child',
url: '/admin/parent/child',
parent: parentId,
source: 'user',
lockedFields: [],
}), 5)

const items = await listMenuItems(db)
const tree = buildSidebarTree(items)

expect(tree).toHaveLength(1)
expect(tree[0].children).toHaveLength(1)
expect(tree[0].children[0].label).toBe('Child')
})
})
5 changes: 5 additions & 0 deletions packages/core/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import { requireAuth, requireRole, requireRbac } from './middleware/auth'
import { createAuth } from './auth/config'
import { adminRbacRoutes } from './routes/admin-rbac'
import { pluginMenuMiddleware } from './middleware/plugin-menu'
import { menuMiddleware } from './middleware/menu'
import { menuPlugin } from './plugins/core-plugins/menu-plugin'
import { analyticsPlugin } from './plugins/core-plugins/analytics'
import { eventsApiRoutes } from './plugins/core-plugins/analytics/routes/api'
import { globalVariablesPlugin } from './plugins/core-plugins/global-variables-plugin'
Expand Down Expand Up @@ -296,6 +298,7 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp {
multiTenantPlugin,
lexicalEditorPlugin,
versioningPlugin,
menuPlugin,
]
const corePluginsAfterCatchAll = [emailPlugin, magicLinkPlugin, emailReconciliationPlugin]

Expand Down Expand Up @@ -505,6 +508,8 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp {

// Plugin dynamic menu items for admin sidebar
app.use('/admin/*', pluginMenuMiddleware())
// Menu plugin sidebar replacement (DB-driven nav items)
app.use('/admin/*', menuMiddleware())

// RBAC-aware admin shell. Computes the signed-in user's effective permission
// set once, then (1) redirects the dashboard landing to the first section the
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/db/migrations-bundle.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* AUTO-GENERATED FILE - DO NOT EDIT
* Generated by: scripts/generate-migrations.ts
* Generated at: 2026-06-30T02:34:57.710Z
* Generated at: 2026-06-30T22:41:40.974Z
*
* This file contains all migration SQL bundled for use in Cloudflare Workers
* where filesystem access is not available at runtime.
Expand Down
Loading
Loading