From ecfa2bf5d80396a3260b17280496962446eeb879 Mon Sep 17 00:00:00 2001 From: pitoi Date: Thu, 16 Jul 2026 18:56:07 +0000 Subject: [PATCH] Generated with Hive: Render icon_url thumbnails in LingoCard and NeighborView, add icon upload to CreateLingoNodeDialog and node detail panel --- .../lingo/CreateLingoNodeDialog.test.tsx | 233 +++++++++++++++++- .../unit/components/lingo/LingoCard.test.tsx | 84 +++++-- .../components/lingo/NeighborView.test.tsx | 2 + .../components/CreateLingoNodeDialog.tsx | 154 +++++++++++- .../learn/lingo/components/LingoCard.tsx | 18 +- .../learn/lingo/components/LingoExplorer.tsx | 35 +++ .../learn/lingo/components/NeighborView.tsx | 203 ++++++++++++++- .../components/CreateLingoNodeDialog.tsx | 154 +++++++++++- .../w/[slug]/lingo/components/LingoCard.tsx | 18 +- .../[slug]/lingo/components/LingoExplorer.tsx | 5 + .../[slug]/lingo/components/NeighborView.tsx | 203 ++++++++++++++- 11 files changed, 1059 insertions(+), 50 deletions(-) diff --git a/src/__tests__/unit/components/lingo/CreateLingoNodeDialog.test.tsx b/src/__tests__/unit/components/lingo/CreateLingoNodeDialog.test.tsx index 3e5441d747..63de618448 100644 --- a/src/__tests__/unit/components/lingo/CreateLingoNodeDialog.test.tsx +++ b/src/__tests__/unit/components/lingo/CreateLingoNodeDialog.test.tsx @@ -76,6 +76,7 @@ import { toast } from "sonner"; // ─── Helpers ────────────────────────────────────────────────────────────────── const SLUG = "test-ws"; +const WORKSPACE_ID = "ws-id-001"; let mockFetch: ReturnType; @@ -85,15 +86,23 @@ beforeEach(() => { vi.mocked(toast).mockClear(); vi.mocked(toast.success).mockClear(); vi.mocked(toast.error).mockClear(); + // Mock URL.createObjectURL / revokeObjectURL + vi.stubGlobal("URL", { + ...URL, + createObjectURL: vi.fn(() => "blob:mock-preview-url"), + revokeObjectURL: vi.fn(), + }); }); function renderDialog(overrides: Partial<{ isOpen: boolean; + workspaceId: string; onClose: () => void; onCreated: (node: any) => void; }> = {}) { const props = { workspaceSlug: SLUG, + workspaceId: WORKSPACE_ID, isOpen: true, onClose: vi.fn(), onCreated: vi.fn(), @@ -102,7 +111,13 @@ function renderDialog(overrides: Partial<{ return { ...render(), props }; } -// ─── Tests ──────────────────────────────────────────────────────────────────── +function makeFile(name = "icon.png", type = "image/png", size = 1024) { + const file = new File(["x".repeat(size)], name, { type }); + Object.defineProperty(file, "size", { value: size }); + return file; +} + +// ─── Core dialog tests ──────────────────────────────────────────────────────── describe("CreateLingoNodeDialog", () => { it("renders name and definition fields when open", () => { @@ -352,3 +367,219 @@ describe("CreateLingoNodeDialog", () => { }); }); }); + +// ─── Icon upload chain tests ────────────────────────────────────────────────── + +describe("CreateLingoNodeDialog — icon upload chain", () => { + it("renders an 'Add icon' button", () => { + renderDialog(); + expect(screen.getByTestId("add-icon-button")).toBeInTheDocument(); + }); + + it("does NOT call fetch when a file is selected (deferred upload)", async () => { + renderDialog(); + const fileInput = screen.getByTestId("icon-file-input"); + const file = makeFile(); + + await act(async () => { + fireEvent.change(fileInput, { target: { files: [file] } }); + }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("shows a preview after valid file select", async () => { + renderDialog(); + const fileInput = screen.getByTestId("icon-file-input"); + const file = makeFile(); + + await act(async () => { + fireEvent.change(fileInput, { target: { files: [file] } }); + }); + + expect(screen.getByTestId("icon-preview")).toBeInTheDocument(); + expect(screen.getByTestId("icon-preview")).toHaveAttribute("src", "blob:mock-preview-url"); + }); + + it("shows inline error for invalid file type", async () => { + renderDialog(); + const fileInput = screen.getByTestId("icon-file-input"); + const file = makeFile("doc.pdf", "application/pdf"); + + await act(async () => { + fireEvent.change(fileInput, { target: { files: [file] } }); + }); + + expect(screen.getByTestId("icon-error")).toBeInTheDocument(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("shows inline error for oversized file", async () => { + renderDialog(); + const fileInput = screen.getByTestId("icon-file-input"); + const file = makeFile("big.png", "image/png", 11 * 1024 * 1024); + + await act(async () => { + fireEvent.change(fileInput, { target: { files: [file] } }); + }); + + expect(screen.getByTestId("icon-error")).toBeInTheDocument(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("fires presign → PUT → POST in sequence on form submit with a valid file", async () => { + const S3_PATH = "uploads/ws-id-001/lingo-icons/123_abc_icon.png"; + const PRESIGN_URL = "https://s3.example.com/presigned"; + + // 1. Presign call + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ presignedUrl: PRESIGN_URL, s3Path: S3_PATH }), + }); + // 2. PUT to S3 + mockFetch.mockResolvedValueOnce({ ok: true }); + // 3. POST create node + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + success: true, + data: { ref_id: "new-ref", name: "Icon Node", icon_url: S3_PATH }, + }), + }); + + const { props } = renderDialog(); + + // Select file first + const fileInput = screen.getByTestId("icon-file-input"); + const file = makeFile("icon.png", "image/png", 1024); + await act(async () => { + fireEvent.change(fileInput, { target: { files: [file] } }); + }); + + // Fill in name + fireEvent.change(screen.getByTestId("lingo-name-input"), { + target: { value: "Icon Node" }, + }); + + // Submit + await act(async () => { + fireEvent.click(screen.getByTestId("lingo-create-submit")); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + // Call 1: presign + expect(mockFetch.mock.calls[0][0]).toBe("/api/upload/presigned-url"); + expect(JSON.parse(mockFetch.mock.calls[0][1].body)).toMatchObject({ + workspaceId: WORKSPACE_ID, + context: "lingo", + filename: "icon.png", + contentType: "image/png", + }); + + // Call 2: PUT to S3 + expect(mockFetch.mock.calls[1][0]).toBe(PRESIGN_URL); + expect(mockFetch.mock.calls[1][1].method).toBe("PUT"); + + // Call 3: POST create node with icon_url + expect(mockFetch.mock.calls[2][0]).toBe(`/api/workspaces/${SLUG}/lingo/nodes`); + const postBody = JSON.parse(mockFetch.mock.calls[2][1].body); + expect(postBody.icon_url).toBe(S3_PATH); + + expect(props.onCreated).toHaveBeenCalledWith( + expect.objectContaining({ name: "Icon Node", icon_url: S3_PATH }), + ); + }); + + it("aborts node creation and shows error when presign fails", async () => { + // Presign fails + mockFetch.mockResolvedValueOnce({ + ok: false, + json: () => Promise.resolve({ error: "Presign error" }), + }); + + const { props } = renderDialog(); + + const fileInput = screen.getByTestId("icon-file-input"); + const file = makeFile(); + await act(async () => { + fireEvent.change(fileInput, { target: { files: [file] } }); + }); + + fireEvent.change(screen.getByTestId("lingo-name-input"), { + target: { value: "Fail Node" }, + }); + + await act(async () => { + fireEvent.click(screen.getByTestId("lingo-create-submit")); + }); + + await waitFor(() => { + expect(screen.getByTestId("lingo-create-error")).toBeInTheDocument(); + }); + + // Only 1 fetch call (the presign); node POST never fires + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(props.onCreated).not.toHaveBeenCalled(); + }); + + it("aborts node creation and shows error when S3 PUT fails", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ presignedUrl: "https://s3.example.com/p", s3Path: "some/path" }), + }); + // PUT fails + mockFetch.mockResolvedValueOnce({ ok: false }); + + const { props } = renderDialog(); + + const fileInput = screen.getByTestId("icon-file-input"); + const file = makeFile(); + await act(async () => { + fireEvent.change(fileInput, { target: { files: [file] } }); + }); + + fireEvent.change(screen.getByTestId("lingo-name-input"), { + target: { value: "Fail Node" }, + }); + + await act(async () => { + fireEvent.click(screen.getByTestId("lingo-create-submit")); + }); + + await waitFor(() => { + expect(screen.getByTestId("lingo-create-error")).toBeInTheDocument(); + }); + + // 2 calls (presign + PUT); node POST never fires + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(props.onCreated).not.toHaveBeenCalled(); + }); + + it("submits without icon when no file is selected", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ success: true, data: { ref_id: "r1", name: "No Icon" } }), + }); + + renderDialog(); + fireEvent.change(screen.getByTestId("lingo-name-input"), { + target: { value: "No Icon" }, + }); + + await act(async () => { + fireEvent.click(screen.getByTestId("lingo-create-submit")); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + const postBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(postBody).not.toHaveProperty("icon_url"); + }); +}); diff --git a/src/__tests__/unit/components/lingo/LingoCard.test.tsx b/src/__tests__/unit/components/lingo/LingoCard.test.tsx index d5776df5e0..006b6fcef0 100644 --- a/src/__tests__/unit/components/lingo/LingoCard.test.tsx +++ b/src/__tests__/unit/components/lingo/LingoCard.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import React from "react"; import { LingoCard } from "@/app/w/[slug]/lingo/components/LingoCard"; +import { LingoCard as LearnLingoCard } from "@/app/w/[slug]/learn/lingo/components/LingoCard"; import type { LingoNode } from "@/app/api/mock/lingo/nodes"; const baseNode: LingoNode = { @@ -13,27 +14,72 @@ const baseNode: LingoNode = { date_added_to_graph: 1750000000, }; -describe("LingoCard", () => { - it("renders the node name", () => { - render( {}} />); - expect(screen.getByText("Test Term")).toBeInTheDocument(); - }); +// Run the same test suite for both component tree locations +const testSuites: Array<{ label: string; Component: typeof LingoCard }> = [ + { label: "lingo/LingoCard", Component: LingoCard }, + { label: "learn/lingo/LingoCard", Component: LearnLingoCard }, +]; - it("renders lingo-type-badge when lingo_type is present", () => { - const node: LingoNode = { ...baseNode, lingo_type: "company_jargon" }; - render( {}} />); - const badge = screen.getByTestId("lingo-type-badge"); - expect(badge).toBeInTheDocument(); - expect(badge).toHaveTextContent("company_jargon"); - }); +testSuites.forEach(({ label, Component }) => { + describe(label, () => { + it("renders the node name", () => { + render( {}} />); + expect(screen.getByText("Test Term")).toBeInTheDocument(); + }); - it("does not render lingo-type-badge when lingo_type is undefined", () => { - render( {}} />); - expect(screen.queryByTestId("lingo-type-badge")).not.toBeInTheDocument(); - }); + it("renders lingo-type-badge when lingo_type is present", () => { + const node: LingoNode = { ...baseNode, lingo_type: "company_jargon" }; + render( {}} />); + const badge = screen.getByTestId("lingo-type-badge"); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveTextContent("company_jargon"); + }); + + it("does not render lingo-type-badge when lingo_type is undefined", () => { + render( {}} />); + expect(screen.queryByTestId("lingo-type-badge")).not.toBeInTheDocument(); + }); + + it("renders definition when present", () => { + render( {}} />); + expect(screen.getByText("A test definition.")).toBeInTheDocument(); + }); + + it("renders icon thumbnail when icon_url is set", () => { + const node: LingoNode = { + ...baseNode, + icon_url: "uploads/ws-1/lingo-icons/123_abc_logo.png", + }; + render( {}} />); + const img = screen.getByTestId("lingo-icon-thumbnail"); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute( + "src", + "/api/upload/presigned-url?s3Key=uploads%2Fws-1%2Flingo-icons%2F123_abc_logo.png", + ); + expect(img).toHaveAttribute("loading", "lazy"); + }); + + it("does not render icon thumbnail when icon_url is absent", () => { + render( {}} />); + expect(screen.queryByTestId("lingo-icon-thumbnail")).not.toBeInTheDocument(); + }); + + it("does not render icon thumbnail when icon_url is null", () => { + const node: LingoNode = { ...baseNode, icon_url: null }; + render( {}} />); + expect(screen.queryByTestId("lingo-icon-thumbnail")).not.toBeInTheDocument(); + }); - it("renders definition when present", () => { - render( {}} />); - expect(screen.getByText("A test definition.")).toBeInTheDocument(); + it("renders both badge and thumbnail when both icon_url and lingo_type are set", () => { + const node: LingoNode = { + ...baseNode, + lingo_type: "company_jargon", + icon_url: "uploads/ws-1/lingo-icons/img.png", + }; + render( {}} />); + expect(screen.getByTestId("lingo-icon-thumbnail")).toBeInTheDocument(); + expect(screen.getByTestId("lingo-type-badge")).toBeInTheDocument(); + }); }); }); diff --git a/src/__tests__/unit/components/lingo/NeighborView.test.tsx b/src/__tests__/unit/components/lingo/NeighborView.test.tsx index 5d8e5f151a..aff41e0343 100644 --- a/src/__tests__/unit/components/lingo/NeighborView.test.tsx +++ b/src/__tests__/unit/components/lingo/NeighborView.test.tsx @@ -8,6 +8,8 @@ import React from "react"; vi.mock("lucide-react", () => ({ Trash2: () => , Plus: () => , + ImageIcon: () => , + Paperclip: () => , })); vi.mock("@/components/ui/button", () => ({ diff --git a/src/app/w/[slug]/learn/lingo/components/CreateLingoNodeDialog.tsx b/src/app/w/[slug]/learn/lingo/components/CreateLingoNodeDialog.tsx index 14ccbfd86e..a37bf5d7b2 100644 --- a/src/app/w/[slug]/learn/lingo/components/CreateLingoNodeDialog.tsx +++ b/src/app/w/[slug]/learn/lingo/components/CreateLingoNodeDialog.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from "react"; import { toast } from "sonner"; +import { ImageIcon, X } from "lucide-react"; import { Dialog, DialogContent, @@ -23,8 +24,12 @@ import { import type { LingoNode } from "@/app/api/mock/lingo/nodes"; import { LINGO_TYPES, type LingoType } from "@/lib/constants/lingo"; +const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"]; +const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB + interface CreateLingoNodeDialogProps { workspaceSlug: string; + workspaceId: string; isOpen: boolean; onClose: () => void; onCreated: (node: LingoNode) => void; @@ -32,6 +37,7 @@ interface CreateLingoNodeDialogProps { export function CreateLingoNodeDialog({ workspaceSlug, + workspaceId, isOpen, onClose, onCreated, @@ -41,7 +47,12 @@ export function CreateLingoNodeDialog({ const [lingoType, setLingoType] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const [iconPreviewUrl, setIconPreviewUrl] = useState(null); + const [iconError, setIconError] = useState(null); + const nameInputRef = useRef(null); + const fileInputRef = useRef(null); // Reset form when dialog opens useEffect(() => { @@ -56,6 +67,47 @@ export function CreateLingoNodeDialog({ } }, [isOpen]); + const handleClose = () => { + // Cleanup object URL to avoid memory leaks + if (iconPreviewUrl) { + URL.revokeObjectURL(iconPreviewUrl); + setIconPreviewUrl(null); + } + setSelectedFile(null); + setIconError(null); + onClose(); + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + // Reset input value so same file can be re-selected + e.target.value = ""; + if (!file) return; + + // Client-side validation + if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { + setIconError("Invalid file type. Please select a JPEG, PNG, GIF, or WebP image."); + return; + } + if (file.size > MAX_FILE_SIZE_BYTES) { + setIconError("File is too large. Maximum size is 10 MB."); + return; + } + + setIconError(null); + // Revoke previous preview URL + if (iconPreviewUrl) URL.revokeObjectURL(iconPreviewUrl); + setSelectedFile(file); + setIconPreviewUrl(URL.createObjectURL(file)); + }; + + const handleRemoveIcon = () => { + if (iconPreviewUrl) URL.revokeObjectURL(iconPreviewUrl); + setSelectedFile(null); + setIconPreviewUrl(null); + setIconError(null); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const trimmedName = name.trim(); @@ -65,6 +117,46 @@ export function CreateLingoNodeDialog({ setError(null); try { + // Upload icon if selected + let iconUrl: string | undefined; + if (selectedFile && workspaceId) { + // Step 1: Presign + const presignRes = await fetch("/api/upload/presigned-url", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workspaceId, + filename: selectedFile.name, + contentType: selectedFile.type, + size: selectedFile.size, + context: "lingo", + }), + }); + if (!presignRes.ok) { + const err = await presignRes.json().catch(() => ({})); + setError((err as { error?: string }).error ?? "Failed to get upload URL"); + return; + } + const { presignedUrl, s3Path } = (await presignRes.json()) as { + presignedUrl: string; + s3Path: string; + }; + + // Step 2: PUT to S3 + const putRes = await fetch(presignedUrl, { + method: "PUT", + headers: { "Content-Type": selectedFile.type }, + body: selectedFile, + }); + if (!putRes.ok) { + setError("Failed to upload icon. Please try again."); + return; + } + + iconUrl = s3Path; + } + + // Step 3: Create the node const res = await fetch(`/api/workspaces/${workspaceSlug}/lingo/nodes`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -72,12 +164,13 @@ export function CreateLingoNodeDialog({ name: trimmedName, definition: definition.trim() || undefined, ...(lingoType ? { lingo_type: lingoType } : {}), + ...(iconUrl ? { icon_url: iconUrl } : {}), }), }); const json = await res.json() as { success: boolean; - data?: { ref_id?: string; name: string; definition?: string; lingo_type?: LingoType }; + data?: { ref_id?: string; name: string; definition?: string; lingo_type?: LingoType; icon_url?: string | null }; alreadyExists?: boolean; error?: string; }; @@ -94,6 +187,7 @@ export function CreateLingoNodeDialog({ node_type: "Lingo", date_added_to_graph: Date.now() / 1000, lingo_type: lingoType || undefined, + icon_url: json.data?.icon_url ?? iconUrl ?? null, }; if (json.alreadyExists) { @@ -103,7 +197,7 @@ export function CreateLingoNodeDialog({ } onCreated(node); - onClose(); + handleClose(); } catch { setError("Something went wrong. Please try again."); } finally { @@ -112,7 +206,7 @@ export function CreateLingoNodeDialog({ }; return ( - { if (!open) onClose(); }}> + { if (!open) handleClose(); }}> New Lingo Node @@ -172,11 +266,63 @@ export function CreateLingoNodeDialog({ + {/* Icon picker */} +
+ + + {iconPreviewUrl ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Icon preview + +
+ ) : ( + + )} + {iconError && ( +

+ {iconError} +

+ )} +
+ + + ) : ( + + )} + {iconError && ( +

+ {iconError} +

+ )} + +