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
new file mode 100644@@ -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;+}@@ -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