collective

Browse / github.com/storytold/artcraft / patch

fix(moodboard): keep grouped video references visible

fix(moodboard): keep grouped video references visible Fixes video references disappearing when grouped, including nested groups. Reuses VideoNode with independent dragging disabled and group selection preserved. Validation: 43 tests pass, including 5 new tests; 4 fail before the fix. Scoped TypeScript, ESLint and Prettier pass. Nx module-boundary checking skipped without a cached graph. Canvas drawing and media playback are stubbed in jsdom; no native playback or full Tauri run. Separate CI commit carries the conditional Netlify guard from #2477 for the retired docs deployment.

Status
open
Score
0.00
Adopters
0 other operators
Author
umairrrkhan (imported from pull request #2479, open)
Base
7a2935cf9e3922e90e4ed5f3556b1cb107013148
Size
+218 −4 in 2 files
Review
pending
Shared
2026-10-28
Id
82f0979bb83f63cf8dac5169

How it went

No other operator has reported using it yet.

Diff

raw · frontend/libs/components/moodboard/src/lib/canvas/nodes/GroupNode.spec.tsx frontend/libs/components/moodboard/src/lib/canvas/nodes/GroupNode.tsx

diff --git a/frontend/libs/components/moodboard/src/lib/canvas/nodes/GroupNode.spec.tsx b/frontend/libs/components/moodboard/src/lib/canvas/nodes/GroupNode.spec.tsxnew file mode 100644--- /dev/null+++ b/frontend/libs/components/moodboard/src/lib/canvas/nodes/GroupNode.spec.tsx@@ -0,0 +1,203 @@+import {+  act,+  cleanup,+  fireEvent,+  render,+  screen,+} from "@testing-library/react";+import type { ReactNode } from "react";+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";+import { useMoodboardStore } from "../MoodboardStore";+import { GroupNode } from "./GroupNode";+import { ImageNode } from "./ImageNode";+import { TextNode } from "./TextNode";+import { VideoNode } from "./VideoNode";++interface PrimitiveProps {+  id?: string;+  children?: ReactNode;+  draggable?: boolean;+  image?: HTMLVideoElement;+  onMouseDown?: (event: { evt: MouseEvent }) => void;+}++// Use the browser entry rather than Konva's Node entry, which requires+// the native canvas package even though drawing is stubbed below.+vi.mock("konva", async () => ({+  default: (await import("konva/lib/index")).default,+}));++// Replace only the canvas drawing boundary. Exercise the actual node+// components, media lifecycle, grouping actions, and selection callbacks.+vi.mock("react-konva", async () => {+  const { forwardRef, useImperativeHandle } = await import("react");+  const Shape = forwardRef<unknown, PrimitiveProps>(+    ({ id, children, draggable, image, onMouseDown }, ref) => {+      useImperativeHandle(ref, () => ({ getLayer: () => null }));+      return (+        <div+          data-testid={id}+          data-draggable={String(Boolean(draggable))}+          data-media-src={image?.getAttribute("src")}+          onMouseDown={(event) => onMouseDown?.({ evt: event.nativeEvent })}+        >+          {children}+        </div>+      );+    },+  );+  return { Group: Shape, Rect: Shape, Image: Shape, Text: Shape };+});++describe("grouped moodboard videos", () => {+  beforeEach(() => {+    useMoodboardStore.getState().reset();+    vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();+    vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(+      () => undefined,+    );+    vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(+      () => undefined,+    );+  });++  afterEach(() => {+    cleanup();+    vi.restoreAllMocks();+  });++  it("renders a grouped video and loads its media source", () => {+    const { videoId, groupId } = groupVideoWithImage();+    render(<BoardNodes />);++    const videoShape = screen.getByTestId(videoId);+    expect(screen.getByTestId(groupId).contains(videoShape)).toBe(true);+    expect(videoShape.getAttribute("data-draggable")).toBe("false");++    const video = vi.mocked(HTMLMediaElement.prototype.play).mock+      .contexts[0] as HTMLVideoElement;+    expect(video.getAttribute("src")).toBe("https://cdn.test/reference.mp4");+    expect(video.muted).toBe(true);+    expect(video.loop).toBe(true);+    act(() => video.dispatchEvent(new Event("loadedmetadata")));+    expect(videoShape.getAttribute("data-media-src")).toBe(+      "https://cdn.test/reference.mp4",+    );+  });++  it("keeps videos visible inside nested groups", () => {+    const { videoId, groupId } = groupVideoWithImage();+    const store = useMoodboardStore.getState();+    const textId = store.addText({ x: 700, y: 100 });+    store.setSelection([groupId, textId]);+    const outerId = groupSelection();+    render(<BoardNodes />);++    const video = screen.getByTestId(videoId);+    expect(screen.getByTestId(groupId).contains(video)).toBe(true);+    expect(screen.getByTestId(outerId).contains(video)).toBe(true);+  });++  it("selects the containing group when its video is clicked", () => {+    const { videoId, groupId } = groupVideoWithImage();+    const onSelect = vi.fn((id: string) => {+      useMoodboardStore.getState().toggleInSelection(id, false);+    });+    render(<BoardNodes onSelect={onSelect} />);++    fireEvent.mouseDown(screen.getByTestId(videoId));++    expect(onSelect).toHaveBeenCalledTimes(1);+    expect(onSelect.mock.calls[0][0]).toBe(groupId);+    expect([...useMoodboardStore.getState().selectedIds]).toEqual([groupId]);+  });++  it("preserves existing image and text group rendering", () => {+    const store = useMoodboardStore.getState();+    const imageId = store.addImage(+      "https://cdn.test/image.png",+      { x: 0, y: 0 },+      100,+      100,+    );+    const textId = store.addText({ x: 200, y: 100 });+    store.setSelection([imageId, textId]);+    const groupId = groupSelection();+    render(<BoardNodes />);++    const group = screen.getByTestId(groupId);+    expect(group.contains(screen.getByTestId(imageId))).toBe(true);+    expect(group.contains(screen.getByTestId(textId))).toBe(true);+  });++  it("keeps videos visible through ungroup, undo, and redo", () => {+    const { videoId, groupId } = groupVideoWithImage();+    render(<BoardNodes />);++    act(() => useMoodboardStore.getState().ungroup());+    expect(screen.getByTestId(videoId).getAttribute("data-draggable")).toBe(+      "true",+    );+    expect(screen.queryByTestId(groupId)).toBeNull();++    act(() => useMoodboardStore.getState().undo());+    expect(+      screen.getByTestId(groupId).contains(screen.getByTestId(videoId)),+    ).toBe(true);++    act(() => useMoodboardStore.getState().redo());+    expect(screen.getByTestId(videoId).getAttribute("data-draggable")).toBe(+      "true",+    );+    expect(screen.queryByTestId(groupId)).toBeNull();+  });+});++function groupVideoWithImage() {+  const store = useMoodboardStore.getState();+  const videoId = store.addVideo(+    "https://cdn.test/reference.mp4",+    { x: 100, y: 100 },+    320,+    180,+  );+  const imageId = store.addImage(+    "https://cdn.test/image.png",+    { x: 500, y: 100 },+    200,+    100,+  );+  store.setSelection([videoId, imageId]);+  return { videoId, groupId: groupSelection() };+}++function BoardNodes({+  onSelect = () => undefined,+}: {+  onSelect?: (id: string) => void;+}) {+  const nodes = useMoodboardStore((s) => s.nodes);+  const rootOrder = useMoodboardStore((s) => s.rootOrder);+  return rootOrder.map((id) => {+    const node = nodes[id];+    if (node.kind === "group") {+      return (+        <GroupNode key={id} node={node} selected={false} onSelect={onSelect} />+      );+    }+    const props = { selected: false, draggable: true, onSelect };+    if (node.kind === "video")+      return <VideoNode key={id} node={node} {...props} />;+    if (node.kind === "image")+      return <ImageNode key={id} node={node} {...props} />;+    if (node.kind === "text")+      return <TextNode key={id} node={node} {...props} />;+    return null;+  });+}++function groupSelection(): string {+  const id = useMoodboardStore.getState().group();+  if (!id) throw new Error("Expected the selected nodes to form a group");+  return id;+}diff --git a/frontend/libs/components/moodboard/src/lib/canvas/nodes/GroupNode.tsx b/frontend/libs/components/moodboard/src/lib/canvas/nodes/GroupNode.tsx--- a/frontend/libs/components/moodboard/src/lib/canvas/nodes/GroupNode.tsx+++ b/frontend/libs/components/moodboard/src/lib/canvas/nodes/GroupNode.tsx@@ -6,6 +6,7 @@ import { GroupNode as GroupNodeData, MoodboardNode } from "../types"; import { useMoodboardStore } from "../MoodboardStore"; import { useNodeDrag } from "../interactions/useNodeDrag"; import { ImageNode } from "./ImageNode";+import { VideoNode } from "./VideoNode"; import { TextNode } from "./TextNode";  interface Props {@@ -21,7 +22,7 @@ interface Props { // nodes never fire `onSelect`. Using a module-level constant keeps the // reference stable across renders, which lets the memoized child nodes // skip work when nothing else about them changed.-const NO_OP_SELECT = () => {};+const NO_OP_SELECT = () => undefined;  const GroupNodeInner = ({ node, selected, onSelect }: Props) => {   const drag = useNodeDrag(node.id);@@ -38,9 +39,8 @@ const GroupNodeInner = ({ node, selected, onSelect }: Props) => {     ),   ); -  const handleSelect = (-    e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,-  ) => onSelect(node.id, e);+  const handleSelect = (e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) =>+    onSelect(node.id, e);    return (     <Group@@ -81,6 +81,17 @@ const GroupNodeInner = ({ node, selected, onSelect }: Props) => {             />           );         }+        if (child.kind === "video") {+          return (+            <VideoNode+              key={child.id}+              node={child}+              draggable={false}+              selected={false}+              onSelect={NO_OP_SELECT}+            />+          );+        }         if (child.kind === "text") {           return (             <TextNode