diff --git a/.changeset/hidden-collections-sidebar.md b/.changeset/hidden-collections-sidebar.md new file mode 100644 index 0000000000..ca1cff7d1e --- /dev/null +++ b/.changeset/hidden-collections-sidebar.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/core": minor +"@emdash-cms/admin": minor +--- + +Adds a `hidden` flag to collections that omits their auto-generated entry from the admin sidebar. Hidden collections keep working everywhere else — REST API, MCP, plugin hooks, and their editor at `/_emdash/admin/content/` — so a plugin that owns a collection end to end can point editors at its own admin UI instead of a raw CRUD list. Set it in a seed file (`"hidden": true`) or via the schema API. diff --git a/docs/src/content/docs/themes/seed-files.mdx b/docs/src/content/docs/themes/seed-files.mdx index 9ce91bf6b5..07d7b27ae6 100644 --- a/docs/src/content/docs/themes/seed-files.mdx +++ b/docs/src/content/docs/themes/seed-files.mdx @@ -121,8 +121,16 @@ Each collection definition creates a content type in the database: | `description` | `string` | No | Admin UI description | | `icon` | `string` | No | Lucide icon name | | `supports` | `array` | No | Features: `"drafts"`, `"revisions"` | +| `hidden` | `boolean`| No | Omit the collection's admin sidebar entry | | `fields` | `array` | Yes | Field definitions | + + ### Field Properties | Property | Type | Required | Description | diff --git a/packages/admin/src/components/Sidebar.tsx b/packages/admin/src/components/Sidebar.tsx index d69ae3607f..52915399bf 100644 --- a/packages/admin/src/components/Sidebar.tsx +++ b/packages/admin/src/components/Sidebar.tsx @@ -57,9 +57,24 @@ export function filterNavItemsByRole( return items.filter((item) => !item.minRole || userRole >= item.minRole); } +/** + * Manifest collections that get an auto-generated sidebar entry, in manifest + * order. Pure function — exported so tests can pin the `hidden` contract + * without rendering the sidebar. + * + * A hidden collection is still shipped in the manifest and stays fully + * routable at `/content/:collection`; it only loses its nav link, so a plugin + * that owns the collection end to end can steer editors to its own admin UI. + */ +export function visibleCollectionEntries( + collections: Record, +): Array<[string, T]> { + return Object.entries(collections).filter(([, config]) => !config.hidden); +} + export interface SidebarNavProps { manifest: { - collections: Record; + collections: Record; plugins: Record< string, { @@ -205,7 +220,7 @@ export function SidebarNav({ manifest }: SidebarNavProps) { const contentItems: NavItem[] = [ { to: "/", label: t`Dashboard`, icon: ADMIN_NAV_ICONS.dashboard }, ]; - for (const [name, config] of Object.entries(manifest.collections)) { + for (const [name, config] of visibleCollectionEntries(manifest.collections)) { contentItems.push({ to: "/content/$collection", label: config.label, diff --git a/packages/admin/src/lib/api/client.ts b/packages/admin/src/lib/api/client.ts index 9b0ff18ef2..2d49de3f19 100644 --- a/packages/admin/src/lib/api/client.ts +++ b/packages/admin/src/lib/api/client.ts @@ -93,6 +93,7 @@ export interface AdminManifest { supports: string[]; hasSeo: boolean; urlPattern?: string; + hidden?: boolean; fields: Record< string, { diff --git a/packages/admin/src/lib/api/schema.ts b/packages/admin/src/lib/api/schema.ts index 1b991befa4..edf8492512 100644 --- a/packages/admin/src/lib/api/schema.ts +++ b/packages/admin/src/lib/api/schema.ts @@ -36,6 +36,8 @@ export interface SchemaCollection { source?: string; urlPattern?: string; hasSeo: boolean; + /** Sidebar entry omitted in the admin; the collection stays reachable by URL */ + hidden: boolean; commentsEnabled: boolean; commentsModeration: "all" | "first_time" | "none"; commentsClosedAfterDays: number; @@ -83,6 +85,7 @@ export interface CreateCollectionInput { supports?: string[]; urlPattern?: string; hasSeo?: boolean; + hidden?: boolean; } export interface UpdateCollectionInput { @@ -93,6 +96,7 @@ export interface UpdateCollectionInput { supports?: string[]; urlPattern?: string; hasSeo?: boolean; + hidden?: boolean; commentsEnabled?: boolean; commentsModeration?: "all" | "first_time" | "none"; commentsClosedAfterDays?: number; diff --git a/packages/admin/tests/components/Sidebar.test.tsx b/packages/admin/tests/components/Sidebar.test.tsx index 6371ce0f00..12f11449de 100644 --- a/packages/admin/tests/components/Sidebar.test.tsx +++ b/packages/admin/tests/components/Sidebar.test.tsx @@ -39,6 +39,7 @@ import { resolveNavIcon, resolvePluginPageLabel, toPhosphorIconName, + visibleCollectionEntries, } from "../../src/components/Sidebar"; import { render } from "../utils/render.tsx"; @@ -105,6 +106,30 @@ describe("filterNavItemsByRole", () => { }); }); +describe("visibleCollectionEntries", () => { + const collections = { + posts: { label: "Posts" }, + contact_submissions: { label: "Contact Submissions", hidden: true }, + lead_notes: { label: "Lead Notes", hidden: true }, + pages: { label: "Pages", hidden: false }, + }; + + it("drops collections flagged hidden", () => { + expect(visibleCollectionEntries(collections).map(([slug]) => slug)).toEqual(["posts", "pages"]); + }); + + it("keeps manifest order for visible collections", () => { + expect(visibleCollectionEntries({ b: { label: "B" }, a: { label: "A" } })).toEqual([ + ["b", { label: "B" }], + ["a", { label: "A" }], + ]); + }); + + it("treats a missing hidden flag as visible", () => { + expect(visibleCollectionEntries({ posts: { label: "Posts" } })).toHaveLength(1); + }); +}); + describe("resolvePluginPageLabel", () => { // Simulates a plugin that loaded its Lingui catalog into the shared i18n // instance: known msgids translate, unknown ones return the msgid itself diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts index f2922289e8..c093f982b7 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -86,6 +86,7 @@ export const createCollectionBody = z source: z.string().regex(collectionSourcePattern).optional(), urlPattern: z.string().optional(), hasSeo: z.boolean().optional(), + hidden: z.boolean().optional(), }) .meta({ id: "CreateCollectionBody" }); @@ -98,6 +99,7 @@ export const updateCollectionBody = z supports: z.array(collectionSupportValues).optional(), urlPattern: z.string().nullish(), hasSeo: z.boolean().optional(), + hidden: z.boolean().optional(), commentsEnabled: z.boolean().optional(), commentsModeration: z.enum(["all", "first_time", "none"]).optional(), commentsClosedAfterDays: z.number().int().min(0).optional(), @@ -179,6 +181,7 @@ export const collectionSchema = z source: z.string().nullable(), urlPattern: z.string().nullable(), hasSeo: z.boolean(), + hidden: z.boolean(), createdAt: z.string(), updatedAt: z.string(), }) diff --git a/packages/core/src/astro/routes/api/schema/index.ts b/packages/core/src/astro/routes/api/schema/index.ts index ddadd34378..3ab947cad3 100644 --- a/packages/core/src/astro/routes/api/schema/index.ts +++ b/packages/core/src/astro/routes/api/schema/index.ts @@ -79,6 +79,7 @@ import type { PortableTextBlock } from "emdash"; description: c.description, icon: c.icon, supports: c.supports, + hidden: c.hidden, fields: c.fields.map((f) => ({ slug: f.slug, label: f.label, diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 042313ece0..bd674df10e 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -31,6 +31,12 @@ export interface ManifestCollection { supports: string[]; hasSeo: boolean; urlPattern?: string; + /** + * Omit the auto-generated sidebar entry in the admin. The collection is + * still listed in the manifest so its routes, editor, and API keep working + * — only the navigation link is dropped. + */ + hidden?: boolean; fields: Record< string, { diff --git a/packages/core/src/cli/commands/export-seed.ts b/packages/core/src/cli/commands/export-seed.ts index 707a8a122c..4bd69bd44c 100644 --- a/packages/core/src/cli/commands/export-seed.ts +++ b/packages/core/src/cli/commands/export-seed.ts @@ -316,6 +316,7 @@ async function exportCollections(db: Kysely): Promise 0 ? collection.supports : undefined, urlPattern: collection.urlPattern || undefined, + hidden: collection.hidden || undefined, fields: fields.map( (field): SeedField => ({ slug: field.slug, diff --git a/packages/core/src/database/migrations/054_collection_hidden.ts b/packages/core/src/database/migrations/054_collection_hidden.ts new file mode 100644 index 0000000000..dfeade4978 --- /dev/null +++ b/packages/core/src/database/migrations/054_collection_hidden.ts @@ -0,0 +1,24 @@ +import type { Kysely } from "kysely"; + +import { columnExists } from "../dialect-helpers.js"; + +/** + * Migration: hide a collection from the admin sidebar. + * + * Adds `hidden` to `_emdash_collections`. A hidden collection stays fully + * functional (REST API, MCP, plugin hooks, direct `/content/:collection` + * URLs) — only its auto-generated sidebar entry is omitted, so plugins that + * own a collection end to end can steer editors to their own admin UI. + */ +export async function up(db: Kysely): Promise { + if (!(await columnExists(db, "_emdash_collections", "hidden"))) { + await db.schema + .alterTable("_emdash_collections") + .addColumn("hidden", "integer", (col) => col.notNull().defaultTo(0)) + .execute(); + } +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("_emdash_collections").dropColumn("hidden").execute(); +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index a3ce955d45..bbc7215672 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -56,6 +56,7 @@ import * as m050 from "./050_media_usage_index_status.js"; import * as m051 from "./051_content_taxonomies_denorm.js"; import * as m052 from "./052_media_usage_read_index.js"; import * as m053 from "./053_plugin_mcp_tools.js"; +import * as m054 from "./054_collection_hidden.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -110,6 +111,7 @@ const MIGRATIONS: Readonly> = Object.freeze({ "051_content_taxonomies_denorm": m051, "052_media_usage_read_index": m052, "053_plugin_mcp_tools": m053, + "054_collection_hidden": m054, }); /** Total number of registered migrations. Exported for use in tests. */ diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index 0f4ba13ed7..05c6dd8543 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -296,6 +296,7 @@ export interface CollectionTable { search_config: string | null; // JSON: { enabled: boolean, weights: Record } has_seo: number; // 0 or 1 — opt-in SEO fields for this collection url_pattern: string | null; // URL pattern with {slug} placeholder (e.g. "/blog/{slug}") + hidden: Generated; // 0 or 1 — omit the auto-generated admin sidebar entry comments_enabled: Generated; // 0 or 1 comments_moderation: Generated; // 'all' | 'first_time' | 'none' comments_closed_after_days: Generated; // 0 = never close diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 5e4a247255..ba94114073 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -2362,6 +2362,7 @@ export class EmDashRuntime { supports: collection.supports || [], hasSeo: collection.hasSeo, urlPattern: collection.urlPattern, + ...(collection.hidden ? { hidden: true } : {}), fields, }; } diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index a2608f45a2..9cd58d2e4c 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -256,6 +256,7 @@ export class SchemaRegistry { supports: JSON.stringify(supports), source: input.source ?? "manual", has_seo: hasSeo ? 1 : 0, + hidden: input.hidden ? 1 : 0, comments_enabled: input.commentsEnabled ? 1 : 0, url_pattern: input.urlPattern ?? null, }) @@ -354,6 +355,7 @@ export class SchemaRegistry { supports: JSON.stringify(supports), source: "seed", has_seo: hasSeo ? 1 : 0, + hidden: input.hidden ? 1 : 0, comments_enabled: input.commentsEnabled ? 1 : 0, url_pattern: input.urlPattern ?? null, }) @@ -416,6 +418,7 @@ export class SchemaRegistry { ? (input.urlPattern ?? null) : (existing.urlPattern ?? null), has_seo: hasSeo ? 1 : 0, + hidden: input.hidden !== undefined ? (input.hidden ? 1 : 0) : existing.hidden ? 1 : 0, comments_enabled: input.commentsEnabled !== undefined ? input.commentsEnabled @@ -1228,6 +1231,7 @@ export class SchemaRegistry { source: row.source && isCollectionSource(row.source) ? row.source : undefined, hasSeo: row.has_seo === 1, urlPattern: row.url_pattern ?? undefined, + hidden: row.hidden === 1, commentsEnabled: row.comments_enabled === 1, commentsModeration: moderation === "all" || moderation === "first_time" || moderation === "none" diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 6d5055e981..ddff953c27 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -171,6 +171,13 @@ export interface Collection { hasSeo: boolean; /** URL pattern with {slug} placeholder (e.g. "/{slug}", "/blog/{slug}") */ urlPattern?: string; + /** + * Omit this collection's auto-generated entry from the admin sidebar. + * The collection stays fully functional everywhere else (API, MCP, hooks, + * direct `/content/:collection` URLs) — this only hides the nav link, so a + * plugin that owns the collection can point editors at its own admin UI. + */ + hidden: boolean; /** Whether comments are enabled for this collection */ commentsEnabled: boolean; /** Moderation strategy: "all" | "first_time" | "none" */ @@ -219,6 +226,8 @@ export interface CreateCollectionInput { source?: CollectionSource; urlPattern?: string; hasSeo?: boolean; + /** Omit the auto-generated admin sidebar entry (defaults to false) */ + hidden?: boolean; commentsEnabled?: boolean; } @@ -233,6 +242,8 @@ export interface UpdateCollectionInput { supports?: CollectionSupport[]; urlPattern?: string; hasSeo?: boolean; + /** Omit the auto-generated admin sidebar entry */ + hidden?: boolean; commentsEnabled?: boolean; commentsModeration?: "all" | "first_time" | "none"; commentsClosedAfterDays?: number; diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index 6c1da34284..1c9c1766b7 100644 --- a/packages/core/src/seed/apply.ts +++ b/packages/core/src/seed/apply.ts @@ -179,6 +179,7 @@ export async function applySeed( icon: collection.icon, supports: collection.supports || [], urlPattern: collection.urlPattern, + hidden: collection.hidden, commentsEnabled: collection.commentsEnabled, }); result.collections.updated++; @@ -247,6 +248,7 @@ export async function applySeed( icon: collection.icon, supports: collection.supports || [], urlPattern: collection.urlPattern, + hidden: collection.hidden, commentsEnabled: collection.commentsEnabled, }, fields, diff --git a/packages/core/src/seed/types.ts b/packages/core/src/seed/types.ts index a106bfcd51..1bcac6324e 100644 --- a/packages/core/src/seed/types.ts +++ b/packages/core/src/seed/types.ts @@ -74,6 +74,11 @@ export interface SeedCollection { icon?: string; supports?: ("drafts" | "revisions" | "preview" | "scheduling" | "search" | "seo")[]; urlPattern?: string; + /** + * Omit this collection from the admin sidebar. It stays reachable through + * the API, MCP, plugin hooks, and direct `/content/:collection` URLs. + */ + hidden?: boolean; /** Enable comments on this collection */ commentsEnabled?: boolean; fields: SeedField[]; diff --git a/packages/core/tests/unit/schema/registry.test.ts b/packages/core/tests/unit/schema/registry.test.ts index 0991d0d9ce..d04f198973 100644 --- a/packages/core/tests/unit/schema/registry.test.ts +++ b/packages/core/tests/unit/schema/registry.test.ts @@ -128,6 +128,44 @@ describe("SchemaRegistry", () => { expect(updated.supports).toEqual(["drafts"]); }); + it("collections are visible in the sidebar by default", async () => { + const collection = await registry.createCollection({ slug: "posts", label: "Posts" }); + + expect(collection.hidden).toBe(false); + }); + + it("creates a collection hidden from the sidebar", async () => { + const collection = await registry.createCollection({ + slug: "contact_submissions", + label: "Contact Submissions", + hidden: true, + }); + + expect(collection.hidden).toBe(true); + // A hidden collection is only hidden from the sidebar — it must still + // be listed by the registry so its routes, editor, API, and MCP tools + // keep resolving. + const listed = await registry.listCollections(); + expect(listed.map((c) => c.slug)).toContain("contact_submissions"); + expect(await registry.getCollection("contact_submissions")).not.toBeNull(); + }); + + it("toggles hidden on an existing collection", async () => { + await registry.createCollection({ slug: "posts", label: "Posts" }); + + expect((await registry.updateCollection("posts", { hidden: true })).hidden).toBe(true); + expect((await registry.updateCollection("posts", { hidden: false })).hidden).toBe(false); + }); + + it("preserves hidden when an update omits it", async () => { + await registry.createCollection({ slug: "posts", label: "Posts", hidden: true }); + + const updated = await registry.updateCollection("posts", { label: "Blog Posts" }); + + expect(updated.label).toBe("Blog Posts"); + expect(updated.hidden).toBe(true); + }); + it("should throw when updating non-existent collection", async () => { await expect(registry.updateCollection("nonexistent", { label: "Test" })).rejects.toThrow( SchemaError, diff --git a/packages/core/tests/unit/seed/apply.test.ts b/packages/core/tests/unit/seed/apply.test.ts index 6b82987143..b783d1fcb5 100644 --- a/packages/core/tests/unit/seed/apply.test.ts +++ b/packages/core/tests/unit/seed/apply.test.ts @@ -163,6 +163,51 @@ describe("applySeed", () => { expect(row.rows[0]?.title).toBe("Untitled"); }); + it("applies the hidden flag from the seed", async () => { + const seed: SeedFile = { + version: "1", + collections: [ + { + slug: "contact_submissions", + label: "Contact Submissions", + hidden: true, + fields: [{ slug: "title", label: "Title", type: "string" }], + }, + { + slug: "posts", + label: "Posts", + fields: [{ slug: "title", label: "Title", type: "string" }], + }, + ], + }; + + await applySeed(db, seed); + + const registry = new SchemaRegistry(db); + expect((await registry.getCollection("contact_submissions"))?.hidden).toBe(true); + expect((await registry.getCollection("posts"))?.hidden).toBe(false); + }); + + it("updates the hidden flag when re-applying with onConflict update", async () => { + const collection = { + slug: "contact_submissions", + label: "Contact Submissions", + fields: [{ slug: "title", label: "Title", type: "string" as const }], + }; + await applySeed(db, { version: "1", collections: [collection] }); + + await applySeed( + db, + { version: "1", collections: [{ ...collection, hidden: true }] }, + { + onConflict: "update", + }, + ); + + const registry = new SchemaRegistry(db); + expect((await registry.getCollection("contact_submissions"))?.hidden).toBe(true); + }); + it("should skip existing collections", async () => { // Create collection first const registry = new SchemaRegistry(db);