webui: convert sketch-todo-panel to SketchTailwindElement with comprehensive test suite
Convert sketch-todo-panel component from LitElement with CSS-in-JS to SketchTailwindElement
inheritance using Tailwind utility classes, and add complete testing infrastructure with
TypeScript demo module and comprehensive test coverage.
Component Conversion:
- Replace LitElement with SketchTailwindElement inheritance to disable shadow DOM
- Remove 200+ lines of CSS-in-JS styles in favor of Tailwind utility classes
- Convert all styling to Tailwind class compositions while maintaining visual parity
- Add inline fadeIn animation using <style> tag following established patterns
- Preserve all existing functionality: todo rendering, comment system, loading states
CSS-to-Tailwind Mapping:
- Main container: flex flex-col h-full bg-transparent overflow-hidden
- Header section: py-2 px-3 border-b border-gray-300 bg-gray-100 font-semibold text-xs
- Content area: flex-1 overflow-y-auto p-2 pb-5 text-xs leading-relaxed min-h-0
- Todo items: flex items-start p-2 mb-1.5 rounded bg-white border border-gray-300 gap-2
- Loading state: animate-spin with proper Tailwind spinner classes
- Comment modal: fixed inset-0 bg-black bg-opacity-30 z-[10000] with centered content
- Status icons: ✅ completed, 🦉 in-progress, ⚪ queued with proper sizing
- Interactive buttons: hover states and transitions using Tailwind utility classes
Test Infrastructure:
- Create sketch-todo-panel.test.ts with 14 comprehensive test cases
- Test initialization, visibility, state management (loading/error/empty states)
- Test todo rendering: status icons, task descriptions, progress counts
- Test comment system: button visibility, modal interactions, event dispatch
- Test error handling: invalid JSON parsing, empty content scenarios
- Test Tailwind integration: proper class usage, shadow DOM disabled
- Test scrollable interface: large todo lists render and scroll correctly
- Use @sand4rt/experimental-ct-web framework following established patterns
- Include helper functions for mock TodoItem creation and test utilities
Demo Module Integration:
- Create sketch-todo-panel.demo.ts following established TypeScript demo pattern
- Add comprehensive demo scenarios: basic usage, loading/error/empty states
- Include large scrollable list demonstration with multiple todo items
- Add interactive comment functionality testing with event logging
- Add sketch-todo-panel to demo-runner.ts knownComponents registry
- Demonstrate all component states and user interactions comprehensively
TypeScript Compatibility:
- Fix property access for private @state() properties using component.evaluate()
- Use type assertions for addEventListener/removeEventListener on SketchTailwindElement
- Address interface compatibility issues with proper TypeScript patterns
- Remove unused imports and helper functions to maintain ESLint compliance
- Follow established patterns from other SketchTailwindElement components
Files Modified:
- sketch/webui/src/web-components/sketch-todo-panel.ts: TailwindElement conversion with complete CSS removal
- sketch/webui/src/web-components/demo/demo-framework/demo-runner.ts: Added component to registry
Files Added:
- sketch/webui/src/web-components/sketch-todo-panel.test.ts: Comprehensive test suite with 14 test cases
- sketch/webui/src/web-components/demo/sketch-todo-panel.demo.ts: Interactive TypeScript demo module
The conversion maintains complete functional parity while enabling consistent
Tailwind-based styling, comprehensive test coverage, and improved development
experience through integrated demo infrastructure.
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: seada6841c7c375e5k
diff --git a/webui/src/web-components/sketch-todo-panel.test.ts b/webui/src/web-components/sketch-todo-panel.test.ts
new file mode 100644
index 0000000..50131dd
--- /dev/null
+++ b/webui/src/web-components/sketch-todo-panel.test.ts
@@ -0,0 +1,345 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { test, expect } from "@sand4rt/experimental-ct-web";
+import { SketchTodoPanel } from "./sketch-todo-panel";
+import { TodoItem } from "../types";
+
+// Helper function to create mock todo items
+function createMockTodoItem(props: Partial<TodoItem> = {}): TodoItem {
+ return {
+ id: props.id || "task-1",
+ status: props.status || "queued",
+ task: props.task || "Sample task description",
+ ...props,
+ };
+}
+
+test("initializes with default properties", async ({ mount }) => {
+ const component = await mount(SketchTodoPanel, {});
+
+ // Check default properties
+ const visible = await component.evaluate((el: SketchTodoPanel) => el.visible);
+ expect(visible).toBe(false);
+
+ // When not visible, component should not render content
+ const content = await component.textContent();
+ expect(content?.trim()).toBe("");
+});
+
+test("displays empty state when visible but no data", async ({ mount }) => {
+ const component = await mount(SketchTodoPanel, {
+ props: {
+ visible: true,
+ },
+ });
+
+ // Should show empty state message
+ await expect(component).toContainText("No todos available");
+});
+
+test("displays loading state correctly", async ({ mount }) => {
+ const component = await mount(SketchTodoPanel, {
+ props: {
+ visible: true,
+ },
+ });
+
+ // Set loading state through component method
+ await component.evaluate((el: SketchTodoPanel) => {
+ (el as any).loading = true;
+ });
+
+ // Should show loading message and spinner
+ await expect(component).toContainText("Loading todos...");
+ // Check for spinner element (it has animate-spin class)
+ await expect(component.locator(".animate-spin")).toBeVisible();
+});
+
+test("displays error state correctly", async ({ mount }) => {
+ const component = await mount(SketchTodoPanel, {
+ props: {
+ visible: true,
+ },
+ });
+
+ // Set error state through component method
+ await component.evaluate((el: SketchTodoPanel) => {
+ (el as any).error = "Failed to load todo data";
+ });
+
+ // Should show error message
+ await expect(component).toContainText("Error: Failed to load todo data");
+ // Error text should have red color
+ await expect(component.locator(".text-red-600")).toBeVisible();
+});
+
+test("renders todo items correctly", async ({ mount }) => {
+ const mockTodos = [
+ createMockTodoItem({
+ id: "task-1",
+ status: "completed",
+ task: "Complete the first task",
+ }),
+ createMockTodoItem({
+ id: "task-2",
+ status: "in-progress",
+ task: "Work on the second task",
+ }),
+ createMockTodoItem({
+ id: "task-3",
+ status: "queued",
+ task: "Start the third task",
+ }),
+ ];
+
+ const component = await mount(SketchTodoPanel, {
+ props: {
+ visible: true,
+ },
+ });
+
+ // Update component with todo data
+ await component.evaluate((el: SketchTodoPanel, todos) => {
+ el.updateTodoContent(JSON.stringify({ items: todos }));
+ }, mockTodos);
+
+ // Check that all tasks are rendered
+ await expect(component).toContainText("Complete the first task");
+ await expect(component).toContainText("Work on the second task");
+ await expect(component).toContainText("Start the third task");
+
+ // Check that status icons are present (emojis)
+ await expect(component).toContainText("✅"); // completed
+ await expect(component).toContainText("🦉"); // in-progress
+ await expect(component).toContainText("⚪"); // queued
+});
+
+test("displays correct todo count in header", async ({ mount }) => {
+ const mockTodos = [
+ createMockTodoItem({ id: "task-1", status: "completed" }),
+ createMockTodoItem({ id: "task-2", status: "completed" }),
+ createMockTodoItem({ id: "task-3", status: "in-progress" }),
+ createMockTodoItem({ id: "task-4", status: "queued" }),
+ ];
+
+ const component = await mount(SketchTodoPanel, {
+ props: {
+ visible: true,
+ },
+ });
+
+ await component.evaluate((el: SketchTodoPanel, todos) => {
+ el.updateTodoContent(JSON.stringify({ items: todos }));
+ }, mockTodos);
+
+ // Should show "2/4" (2 completed out of 4 total)
+ await expect(component).toContainText("2/4");
+ await expect(component).toContainText("Sketching...");
+});
+
+test("shows comment button only for non-completed items", async ({ mount }) => {
+ const mockTodos = [
+ createMockTodoItem({ id: "task-1", status: "completed" }),
+ createMockTodoItem({ id: "task-2", status: "in-progress" }),
+ createMockTodoItem({ id: "task-3", status: "queued" }),
+ ];
+
+ const component = await mount(SketchTodoPanel, {
+ props: {
+ visible: true,
+ },
+ });
+
+ await component.evaluate((el: SketchTodoPanel, todos) => {
+ el.updateTodoContent(JSON.stringify({ items: todos }));
+ }, mockTodos);
+
+ // Comment buttons (💬) should only appear for in-progress and queued items
+ const commentButtons = component.locator('button[title*="Add comment"]');
+ await expect(commentButtons).toHaveCount(2); // Only for in-progress and queued
+});
+
+test("opens comment box when comment button is clicked", async ({ mount }) => {
+ const mockTodos = [
+ createMockTodoItem({
+ id: "task-1",
+ status: "in-progress",
+ task: "Work on important task",
+ }),
+ ];
+
+ const component = await mount(SketchTodoPanel, {
+ props: {
+ visible: true,
+ },
+ });
+
+ await component.evaluate((el: SketchTodoPanel, todos) => {
+ el.updateTodoContent(JSON.stringify({ items: todos }));
+ }, mockTodos);
+
+ // Click the comment button
+ await component.locator('button[title*="Add comment"]').click();
+
+ // Comment overlay should be visible
+ await expect(component.locator(".fixed.inset-0")).toBeVisible();
+ await expect(component).toContainText("Comment on TODO Item");
+ await expect(component).toContainText("Status: In Progress");
+ await expect(component).toContainText("Work on important task");
+});
+
+test("closes comment box when cancel is clicked", async ({ mount }) => {
+ const mockTodos = [createMockTodoItem({ id: "task-1", status: "queued" })];
+
+ const component = await mount(SketchTodoPanel, {
+ props: {
+ visible: true,
+ },
+ });
+
+ await component.evaluate((el: SketchTodoPanel, todos) => {
+ el.updateTodoContent(JSON.stringify({ items: todos }));
+ }, mockTodos);
+
+ // Open comment box
+ await component.locator('button[title*="Add comment"]').click();
+ await expect(component.locator(".fixed.inset-0")).toBeVisible();
+
+ // Click cancel
+ await component.locator('button:has-text("Cancel")').click();
+
+ // Comment overlay should be hidden
+ await expect(component.locator(".fixed.inset-0")).not.toBeVisible();
+});
+
+test("dispatches todo-comment event when comment is submitted", async ({
+ mount,
+}) => {
+ const mockTodos = [
+ createMockTodoItem({
+ id: "task-1",
+ status: "in-progress",
+ task: "Important task",
+ }),
+ ];
+
+ const component = await mount(SketchTodoPanel, {
+ props: {
+ visible: true,
+ },
+ });
+
+ await component.evaluate((el: SketchTodoPanel, todos) => {
+ el.updateTodoContent(JSON.stringify({ items: todos }));
+ }, mockTodos);
+
+ // Set up event listener
+ const eventPromise = component.evaluate((el: SketchTodoPanel) => {
+ return new Promise((resolve) => {
+ (el as any).addEventListener(
+ "todo-comment",
+ (e: any) => {
+ resolve(e.detail.comment);
+ },
+ { once: true },
+ );
+ });
+ });
+
+ // Open comment box
+ await component.locator('button[title*="Add comment"]').click();
+
+ // Fill in comment
+ await component
+ .locator('textarea[placeholder*="Type your comment"]')
+ .fill("This is a test comment");
+
+ // Submit comment
+ await component.locator('button:has-text("Add Comment")').click();
+
+ // Wait for event and check content
+ const eventDetail = await eventPromise;
+ expect(eventDetail).toContain("This is a test comment");
+ expect(eventDetail).toContain("Important task");
+ expect(eventDetail).toContain("In Progress");
+
+ // Comment box should be closed after submission
+ await expect(component.locator(".fixed.inset-0")).not.toBeVisible();
+});
+
+test("handles invalid JSON gracefully", async ({ mount }) => {
+ const component = await mount(SketchTodoPanel, {
+ props: {
+ visible: true,
+ },
+ });
+
+ // Update with invalid JSON
+ await component.evaluate((el: SketchTodoPanel) => {
+ el.updateTodoContent("{invalid json}");
+ });
+
+ // Should show error state
+ await expect(component).toContainText("Error: Failed to parse todo data");
+});
+
+test("handles empty content gracefully", async ({ mount }) => {
+ const component = await mount(SketchTodoPanel, {
+ props: {
+ visible: true,
+ },
+ });
+
+ // Update with empty content
+ await component.evaluate((el: SketchTodoPanel) => {
+ el.updateTodoContent("");
+ });
+
+ // Should show empty state
+ await expect(component).toContainText("No todos available");
+});
+
+test("renders with proper Tailwind classes", async ({ mount }) => {
+ const component = await mount(SketchTodoPanel, {
+ props: {
+ visible: true,
+ },
+ });
+
+ // Check main container has correct Tailwind classes
+ const container = component.locator(".flex.flex-col.h-full");
+ await expect(container).toBeVisible();
+
+ // Check that it's using Tailwind instead of shadow DOM styling
+ const shadowRoot = await component.evaluate((el) => el.shadowRoot);
+ expect(shadowRoot).toBeNull(); // SketchTailwindElement disables shadow DOM
+});
+
+test("displays todos in scrollable container", async ({ mount }) => {
+ const mockTodos = Array.from({ length: 10 }, (_, i) =>
+ createMockTodoItem({
+ id: `task-${i + 1}`,
+ status:
+ i % 3 === 0 ? "completed" : i % 3 === 1 ? "in-progress" : "queued",
+ task: `Task number ${i + 1} with some description text`,
+ }),
+ );
+
+ const component = await mount(SketchTodoPanel, {
+ props: {
+ visible: true,
+ },
+ });
+
+ await component.evaluate((el: SketchTodoPanel, todos) => {
+ el.updateTodoContent(JSON.stringify({ items: todos }));
+ }, mockTodos);
+
+ // Check that scrollable container exists
+ const scrollContainer = component.locator(".overflow-y-auto");
+ await expect(scrollContainer).toBeVisible();
+
+ // All tasks should be rendered
+ for (let i = 1; i <= 10; i++) {
+ await expect(component).toContainText(`Task number ${i}`);
+ }
+});