Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
571
packages/core/tests/unit/widgets/widgets.test.ts
Normal file
571
packages/core/tests/unit/widgets/widgets.test.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { ulid } from "ulidx";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { createDatabase } from "../../../src/database/connection.js";
|
||||
import { runMigrations } from "../../../src/database/migrations/runner.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { getWidgetComponents } from "../../../src/widgets/components.js";
|
||||
import type { WidgetType } from "../../../src/widgets/types.js";
|
||||
|
||||
// Regex patterns for widget validation
|
||||
const WIDGET_ID_FORMAT_REGEX = /^[a-z]+:[a-z-]+$/;
|
||||
|
||||
describe("Widget System", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = createDatabase({ url: ":memory:" });
|
||||
await runMigrations(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
describe("migration", () => {
|
||||
it("should create _emdash_widget_areas table", async () => {
|
||||
const tables = await db.introspection.getTables();
|
||||
const areasTable = tables.find((t) => t.name === "_emdash_widget_areas");
|
||||
expect(areasTable).toBeDefined();
|
||||
|
||||
const columns = areasTable!.columns.map((c) => c.name);
|
||||
expect(columns).toContain("id");
|
||||
expect(columns).toContain("name");
|
||||
expect(columns).toContain("label");
|
||||
expect(columns).toContain("description");
|
||||
});
|
||||
|
||||
it("should create _emdash_widgets table", async () => {
|
||||
const tables = await db.introspection.getTables();
|
||||
const widgetsTable = tables.find((t) => t.name === "_emdash_widgets");
|
||||
expect(widgetsTable).toBeDefined();
|
||||
|
||||
const columns = widgetsTable!.columns.map((c) => c.name);
|
||||
expect(columns).toContain("id");
|
||||
expect(columns).toContain("area_id");
|
||||
expect(columns).toContain("sort_order");
|
||||
expect(columns).toContain("type");
|
||||
expect(columns).toContain("title");
|
||||
expect(columns).toContain("content");
|
||||
expect(columns).toContain("menu_name");
|
||||
expect(columns).toContain("component_id");
|
||||
expect(columns).toContain("component_props");
|
||||
});
|
||||
|
||||
it("should enforce unique constraint on widget area name", async () => {
|
||||
const id1 = ulid();
|
||||
const id2 = ulid();
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_widget_areas")
|
||||
.values({
|
||||
id: id1,
|
||||
name: "sidebar",
|
||||
label: "Sidebar",
|
||||
description: null,
|
||||
})
|
||||
.execute();
|
||||
|
||||
await expect(
|
||||
db
|
||||
.insertInto("_emdash_widget_areas")
|
||||
.values({
|
||||
id: id2,
|
||||
name: "sidebar",
|
||||
label: "Sidebar Again",
|
||||
description: null,
|
||||
})
|
||||
.execute(),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should cascade delete widgets when area is deleted", async () => {
|
||||
const areaId = ulid();
|
||||
const widgetId = ulid();
|
||||
|
||||
// Create area
|
||||
await db
|
||||
.insertInto("_emdash_widget_areas")
|
||||
.values({
|
||||
id: areaId,
|
||||
name: "sidebar",
|
||||
label: "Sidebar",
|
||||
description: null,
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Create widget
|
||||
await db
|
||||
.insertInto("_emdash_widgets")
|
||||
.values({
|
||||
id: widgetId,
|
||||
area_id: areaId,
|
||||
sort_order: 0,
|
||||
type: "content" as WidgetType,
|
||||
title: "Test Widget",
|
||||
content: null,
|
||||
menu_name: null,
|
||||
component_id: null,
|
||||
component_props: null,
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Delete area
|
||||
await db.deleteFrom("_emdash_widget_areas").where("id", "=", areaId).execute();
|
||||
|
||||
// Verify widget was deleted
|
||||
const widgets = await db
|
||||
.selectFrom("_emdash_widgets")
|
||||
.where("area_id", "=", areaId)
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
expect(widgets).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("widget areas", () => {
|
||||
it("should create a widget area", async () => {
|
||||
const id = ulid();
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_widget_areas")
|
||||
.values({
|
||||
id,
|
||||
name: "sidebar",
|
||||
label: "Sidebar",
|
||||
description: "The main sidebar",
|
||||
})
|
||||
.execute();
|
||||
|
||||
const area = await db
|
||||
.selectFrom("_emdash_widget_areas")
|
||||
.selectAll()
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(area).not.toBeNull();
|
||||
expect(area?.name).toBe("sidebar");
|
||||
expect(area?.label).toBe("Sidebar");
|
||||
expect(area?.description).toBe("The main sidebar");
|
||||
});
|
||||
|
||||
it("should query all widget areas", async () => {
|
||||
await db
|
||||
.insertInto("_emdash_widget_areas")
|
||||
.values([
|
||||
{ id: ulid(), name: "sidebar", label: "Sidebar", description: null },
|
||||
{ id: ulid(), name: "footer", label: "Footer", description: null },
|
||||
{
|
||||
id: ulid(),
|
||||
name: "header",
|
||||
label: "Header Widgets",
|
||||
description: null,
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
|
||||
const areas = await db.selectFrom("_emdash_widget_areas").selectAll().execute();
|
||||
|
||||
expect(areas).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should query widget area by name", async () => {
|
||||
await db
|
||||
.insertInto("_emdash_widget_areas")
|
||||
.values({
|
||||
id: ulid(),
|
||||
name: "sidebar",
|
||||
label: "Sidebar",
|
||||
description: "Primary sidebar",
|
||||
})
|
||||
.execute();
|
||||
|
||||
const area = await db
|
||||
.selectFrom("_emdash_widget_areas")
|
||||
.selectAll()
|
||||
.where("name", "=", "sidebar")
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(area).not.toBeNull();
|
||||
expect(area?.label).toBe("Sidebar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("widgets", () => {
|
||||
let areaId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
areaId = ulid();
|
||||
await db
|
||||
.insertInto("_emdash_widget_areas")
|
||||
.values({
|
||||
id: areaId,
|
||||
name: "sidebar",
|
||||
label: "Sidebar",
|
||||
description: null,
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
|
||||
describe("content widgets", () => {
|
||||
it("should create a content widget", async () => {
|
||||
const id = ulid();
|
||||
const content = [{ _type: "block", children: [{ _type: "span", text: "Hello" }] }];
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_widgets")
|
||||
.values({
|
||||
id,
|
||||
area_id: areaId,
|
||||
sort_order: 0,
|
||||
type: "content" as WidgetType,
|
||||
title: "Welcome",
|
||||
content: JSON.stringify(content),
|
||||
menu_name: null,
|
||||
component_id: null,
|
||||
component_props: null,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const widget = await db
|
||||
.selectFrom("_emdash_widgets")
|
||||
.selectAll()
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(widget).not.toBeNull();
|
||||
expect(widget?.type).toBe("content");
|
||||
expect(widget?.title).toBe("Welcome");
|
||||
expect(JSON.parse(widget!.content!)).toEqual(content);
|
||||
});
|
||||
});
|
||||
|
||||
describe("menu widgets", () => {
|
||||
it("should create a menu widget", async () => {
|
||||
const id = ulid();
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_widgets")
|
||||
.values({
|
||||
id,
|
||||
area_id: areaId,
|
||||
sort_order: 0,
|
||||
type: "menu" as WidgetType,
|
||||
title: "Navigation",
|
||||
content: null,
|
||||
menu_name: "sidebar-nav",
|
||||
component_id: null,
|
||||
component_props: null,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const widget = await db
|
||||
.selectFrom("_emdash_widgets")
|
||||
.selectAll()
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(widget).not.toBeNull();
|
||||
expect(widget?.type).toBe("menu");
|
||||
expect(widget?.menu_name).toBe("sidebar-nav");
|
||||
});
|
||||
});
|
||||
|
||||
describe("component widgets", () => {
|
||||
it("should create a component widget", async () => {
|
||||
const id = ulid();
|
||||
const props = { count: 5, showDate: true };
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_widgets")
|
||||
.values({
|
||||
id,
|
||||
area_id: areaId,
|
||||
sort_order: 0,
|
||||
type: "component" as WidgetType,
|
||||
title: "Recent Posts",
|
||||
content: null,
|
||||
menu_name: null,
|
||||
component_id: "core:recent-posts",
|
||||
component_props: JSON.stringify(props),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const widget = await db
|
||||
.selectFrom("_emdash_widgets")
|
||||
.selectAll()
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(widget).not.toBeNull();
|
||||
expect(widget?.type).toBe("component");
|
||||
expect(widget?.component_id).toBe("core:recent-posts");
|
||||
expect(JSON.parse(widget!.component_props!)).toEqual(props);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ordering", () => {
|
||||
it("should order widgets by sort_order", async () => {
|
||||
await db
|
||||
.insertInto("_emdash_widgets")
|
||||
.values([
|
||||
{
|
||||
id: ulid(),
|
||||
area_id: areaId,
|
||||
sort_order: 2,
|
||||
type: "content" as WidgetType,
|
||||
title: "Third",
|
||||
content: null,
|
||||
menu_name: null,
|
||||
component_id: null,
|
||||
component_props: null,
|
||||
},
|
||||
{
|
||||
id: ulid(),
|
||||
area_id: areaId,
|
||||
sort_order: 0,
|
||||
type: "content" as WidgetType,
|
||||
title: "First",
|
||||
content: null,
|
||||
menu_name: null,
|
||||
component_id: null,
|
||||
component_props: null,
|
||||
},
|
||||
{
|
||||
id: ulid(),
|
||||
area_id: areaId,
|
||||
sort_order: 1,
|
||||
type: "content" as WidgetType,
|
||||
title: "Second",
|
||||
content: null,
|
||||
menu_name: null,
|
||||
component_id: null,
|
||||
component_props: null,
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
|
||||
const widgets = await db
|
||||
.selectFrom("_emdash_widgets")
|
||||
.selectAll()
|
||||
.where("area_id", "=", areaId)
|
||||
.orderBy("sort_order", "asc")
|
||||
.execute();
|
||||
|
||||
expect(widgets).toHaveLength(3);
|
||||
expect(widgets[0].title).toBe("First");
|
||||
expect(widgets[1].title).toBe("Second");
|
||||
expect(widgets[2].title).toBe("Third");
|
||||
});
|
||||
|
||||
it("should update sort_order for reordering", async () => {
|
||||
const ids = [ulid(), ulid(), ulid()];
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_widgets")
|
||||
.values([
|
||||
{
|
||||
id: ids[0],
|
||||
area_id: areaId,
|
||||
sort_order: 0,
|
||||
type: "content" as WidgetType,
|
||||
title: "A",
|
||||
content: null,
|
||||
menu_name: null,
|
||||
component_id: null,
|
||||
component_props: null,
|
||||
},
|
||||
{
|
||||
id: ids[1],
|
||||
area_id: areaId,
|
||||
sort_order: 1,
|
||||
type: "content" as WidgetType,
|
||||
title: "B",
|
||||
content: null,
|
||||
menu_name: null,
|
||||
component_id: null,
|
||||
component_props: null,
|
||||
},
|
||||
{
|
||||
id: ids[2],
|
||||
area_id: areaId,
|
||||
sort_order: 2,
|
||||
type: "content" as WidgetType,
|
||||
title: "C",
|
||||
content: null,
|
||||
menu_name: null,
|
||||
component_id: null,
|
||||
component_props: null,
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
|
||||
// Reorder: C (was 2) -> 0, A (was 0) -> 1, B (was 1) -> 2
|
||||
const newOrder = [ids[2], ids[0], ids[1]];
|
||||
for (let i = 0; i < newOrder.length; i++) {
|
||||
await db
|
||||
.updateTable("_emdash_widgets")
|
||||
.set({ sort_order: i })
|
||||
.where("id", "=", newOrder[i])
|
||||
.execute();
|
||||
}
|
||||
|
||||
const widgets = await db
|
||||
.selectFrom("_emdash_widgets")
|
||||
.selectAll()
|
||||
.where("area_id", "=", areaId)
|
||||
.orderBy("sort_order", "asc")
|
||||
.execute();
|
||||
|
||||
expect(widgets[0].title).toBe("C");
|
||||
expect(widgets[1].title).toBe("A");
|
||||
expect(widgets[2].title).toBe("B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("update and delete", () => {
|
||||
it("should update widget properties", async () => {
|
||||
const id = ulid();
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_widgets")
|
||||
.values({
|
||||
id,
|
||||
area_id: areaId,
|
||||
sort_order: 0,
|
||||
type: "content" as WidgetType,
|
||||
title: "Original",
|
||||
content: JSON.stringify([{ _type: "block", children: [] }]),
|
||||
menu_name: null,
|
||||
component_id: null,
|
||||
component_props: null,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const newContent = [{ _type: "block", children: [{ _type: "span", text: "Updated" }] }];
|
||||
|
||||
await db
|
||||
.updateTable("_emdash_widgets")
|
||||
.set({
|
||||
title: "Updated Title",
|
||||
content: JSON.stringify(newContent),
|
||||
})
|
||||
.where("id", "=", id)
|
||||
.execute();
|
||||
|
||||
const widget = await db
|
||||
.selectFrom("_emdash_widgets")
|
||||
.selectAll()
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(widget?.title).toBe("Updated Title");
|
||||
expect(JSON.parse(widget!.content!)).toEqual(newContent);
|
||||
});
|
||||
|
||||
it("should delete a widget", async () => {
|
||||
const id = ulid();
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_widgets")
|
||||
.values({
|
||||
id,
|
||||
area_id: areaId,
|
||||
sort_order: 0,
|
||||
type: "content" as WidgetType,
|
||||
title: "To Delete",
|
||||
content: null,
|
||||
menu_name: null,
|
||||
component_id: null,
|
||||
component_props: null,
|
||||
})
|
||||
.execute();
|
||||
|
||||
await db.deleteFrom("_emdash_widgets").where("id", "=", id).execute();
|
||||
|
||||
const widget = await db
|
||||
.selectFrom("_emdash_widgets")
|
||||
.selectAll()
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(widget).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("widget components registry", () => {
|
||||
it("should return core widget components", () => {
|
||||
const components = getWidgetComponents();
|
||||
|
||||
expect(components.length).toBeGreaterThan(0);
|
||||
|
||||
const recentPosts = components.find((c) => c.id === "core:recent-posts");
|
||||
expect(recentPosts).toBeDefined();
|
||||
expect(recentPosts?.label).toBe("Recent Posts");
|
||||
expect(recentPosts?.props).toHaveProperty("count");
|
||||
expect(recentPosts?.props).toHaveProperty("showThumbnails");
|
||||
expect(recentPosts?.props).toHaveProperty("showDate");
|
||||
});
|
||||
|
||||
it("should include categories component", () => {
|
||||
const components = getWidgetComponents();
|
||||
const categories = components.find((c) => c.id === "core:categories");
|
||||
|
||||
expect(categories).toBeDefined();
|
||||
expect(categories?.props).toHaveProperty("showCount");
|
||||
expect(categories?.props).toHaveProperty("hierarchical");
|
||||
});
|
||||
|
||||
it("should include tags component", () => {
|
||||
const components = getWidgetComponents();
|
||||
const tags = components.find((c) => c.id === "core:tags");
|
||||
|
||||
expect(tags).toBeDefined();
|
||||
expect(tags?.props).toHaveProperty("showCount");
|
||||
expect(tags?.props).toHaveProperty("limit");
|
||||
});
|
||||
|
||||
it("should include search component", () => {
|
||||
const components = getWidgetComponents();
|
||||
const search = components.find((c) => c.id === "core:search");
|
||||
|
||||
expect(search).toBeDefined();
|
||||
expect(search?.props).toHaveProperty("placeholder");
|
||||
});
|
||||
|
||||
it("should include archives component", () => {
|
||||
const components = getWidgetComponents();
|
||||
const archives = components.find((c) => c.id === "core:archives");
|
||||
|
||||
expect(archives).toBeDefined();
|
||||
expect(archives?.props).toHaveProperty("type");
|
||||
expect(archives?.props).toHaveProperty("limit");
|
||||
expect(archives?.props.type.options).toEqual([
|
||||
{ value: "monthly", label: "Monthly" },
|
||||
{ value: "yearly", label: "Yearly" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should have valid prop definitions", () => {
|
||||
const components = getWidgetComponents();
|
||||
|
||||
for (const component of components) {
|
||||
expect(component.id).toMatch(WIDGET_ID_FORMAT_REGEX);
|
||||
expect(component.label).toBeTruthy();
|
||||
|
||||
for (const [_key, prop] of Object.entries(component.props)) {
|
||||
expect(["string", "number", "boolean", "select"]).toContain(prop.type);
|
||||
expect(prop.label).toBeTruthy();
|
||||
|
||||
if (prop.type === "select") {
|
||||
expect(prop.options).toBeDefined();
|
||||
expect(Array.isArray(prop.options)).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user