feat(agent): add structured outputs and media archive support
This commit is contained in:
175
components/media/__tests__/media-library-dialog.test.tsx
Normal file
175
components/media/__tests__/media-library-dialog.test.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useAuthQuery: vi.fn(),
|
||||
resolveUrls: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useMutation: () => mocks.resolveUrls,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth-query", () => ({
|
||||
useAuthQuery: (...args: unknown[]) => mocks.useAuthQuery(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div>{children}</div> : null,
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>,
|
||||
}));
|
||||
|
||||
import { MediaLibraryDialog } from "@/components/media/media-library-dialog";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function makeItems(count: number, page = 1) {
|
||||
return Array.from({ length: count }).map((_, index) => ({
|
||||
kind: "image" as const,
|
||||
source: "upload" as const,
|
||||
filename: `Item ${page}-${index + 1}`,
|
||||
previewUrl: `https://cdn.example.com/${page}-${index + 1}.jpg`,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
createdAt: page * 1000 + index,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("MediaLibraryDialog", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.useAuthQuery.mockReset();
|
||||
mocks.resolveUrls.mockReset();
|
||||
mocks.resolveUrls.mockImplementation(async () => ({}));
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
container = null;
|
||||
root = null;
|
||||
});
|
||||
|
||||
it("calls media library query with page and default pageSize 8", async () => {
|
||||
mocks.useAuthQuery.mockReturnValue(undefined);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<MediaLibraryDialog open onOpenChange={() => undefined} kindFilter="image" />,
|
||||
);
|
||||
});
|
||||
|
||||
const firstCallArgs = mocks.useAuthQuery.mock.calls[0]?.[1];
|
||||
expect(firstCallArgs).toEqual(
|
||||
expect.objectContaining({
|
||||
page: 1,
|
||||
pageSize: 8,
|
||||
kindFilter: "image",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders at most 8 cards and shows Freepik-style pagination footer", async () => {
|
||||
mocks.useAuthQuery.mockReturnValue({
|
||||
items: makeItems(10),
|
||||
page: 1,
|
||||
pageSize: 8,
|
||||
totalPages: 3,
|
||||
totalCount: 24,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<MediaLibraryDialog open onOpenChange={() => undefined} />);
|
||||
});
|
||||
|
||||
const cards = document.querySelectorAll("img[alt^='Item 1-']");
|
||||
expect(cards).toHaveLength(8);
|
||||
|
||||
expect(document.body.textContent).toContain("Previous");
|
||||
expect(document.body.textContent).toContain("Page 1 of 3");
|
||||
expect(document.body.textContent).toContain("Next");
|
||||
});
|
||||
|
||||
it("updates query args when clicking next and previous", async () => {
|
||||
const responseByPage = new Map<number, {
|
||||
items: ReturnType<typeof makeItems>;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
totalCount: number;
|
||||
}>();
|
||||
|
||||
mocks.useAuthQuery.mockImplementation((_, args: { page: number; pageSize: number }) => {
|
||||
if (!responseByPage.has(args.page)) {
|
||||
responseByPage.set(args.page, {
|
||||
items: makeItems(8, args.page),
|
||||
page: args.page,
|
||||
pageSize: args.pageSize,
|
||||
totalPages: 3,
|
||||
totalCount: 24,
|
||||
});
|
||||
}
|
||||
|
||||
return responseByPage.get(args.page);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<MediaLibraryDialog open onOpenChange={() => undefined} />);
|
||||
});
|
||||
|
||||
const nextButton = Array.from(document.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Next",
|
||||
);
|
||||
if (!(nextButton instanceof HTMLButtonElement)) {
|
||||
throw new Error("Next button not found");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
nextButton.click();
|
||||
});
|
||||
|
||||
const nextCallArgs = mocks.useAuthQuery.mock.calls.at(-1)?.[1];
|
||||
expect(nextCallArgs).toEqual(expect.objectContaining({ page: 2, pageSize: 8 }));
|
||||
|
||||
const previousButton = Array.from(document.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Previous",
|
||||
);
|
||||
if (!(previousButton instanceof HTMLButtonElement)) {
|
||||
throw new Error("Previous button not found");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
previousButton.click();
|
||||
});
|
||||
|
||||
const previousCallArgs = mocks.useAuthQuery.mock.calls.at(-1)?.[1];
|
||||
expect(previousCallArgs).toEqual(expect.objectContaining({ page: 1, pageSize: 8 }));
|
||||
});
|
||||
|
||||
it("renders 8 loading skeleton cards", async () => {
|
||||
mocks.useAuthQuery.mockReturnValue(undefined);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<MediaLibraryDialog open onOpenChange={() => undefined} />);
|
||||
});
|
||||
|
||||
expect(document.querySelectorAll(".aspect-square.animate-pulse.bg-muted")).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,7 @@ import {
|
||||
resolveMediaPreviewUrl,
|
||||
} from "@/components/media/media-preview-utils";
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MIN_LIMIT = 1;
|
||||
const MAX_LIMIT = 500;
|
||||
const DEFAULT_PAGE_SIZE = 8;
|
||||
|
||||
export type MediaLibraryMetadataItem = {
|
||||
kind: "image" | "video" | "asset";
|
||||
@@ -54,18 +52,18 @@ export type MediaLibraryDialogProps = {
|
||||
onPick?: (item: MediaLibraryItem) => void | Promise<void>;
|
||||
title?: string;
|
||||
description?: string;
|
||||
limit?: number;
|
||||
pageSize?: number;
|
||||
kindFilter?: "image" | "video" | "asset";
|
||||
pickCtaLabel?: string;
|
||||
};
|
||||
|
||||
function normalizeLimit(limit: number | undefined): number {
|
||||
if (typeof limit !== "number" || !Number.isFinite(limit)) {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
|
||||
return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, Math.floor(limit)));
|
||||
}
|
||||
type MediaLibraryResponse = {
|
||||
items: MediaLibraryMetadataItem[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
function formatDimensions(width: number | undefined, height: number | undefined): string | null {
|
||||
if (typeof width !== "number" || typeof height !== "number") {
|
||||
@@ -128,20 +126,39 @@ export function MediaLibraryDialog({
|
||||
onPick,
|
||||
title = "Mediathek",
|
||||
description,
|
||||
limit,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
kindFilter,
|
||||
pickCtaLabel = "Auswaehlen",
|
||||
}: MediaLibraryDialogProps) {
|
||||
const normalizedLimit = useMemo(() => normalizeLimit(limit), [limit]);
|
||||
const [page, setPage] = useState(1);
|
||||
const normalizedPageSize = useMemo(() => {
|
||||
if (typeof pageSize !== "number" || !Number.isFinite(pageSize)) {
|
||||
return DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.floor(pageSize));
|
||||
}, [pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPage(1);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [kindFilter]);
|
||||
|
||||
const metadata = useAuthQuery(
|
||||
api.dashboard.listMediaLibrary,
|
||||
open
|
||||
? {
|
||||
limit: normalizedLimit,
|
||||
page,
|
||||
pageSize: normalizedPageSize,
|
||||
...(kindFilter ? { kindFilter } : {}),
|
||||
}
|
||||
: "skip",
|
||||
);
|
||||
) as MediaLibraryResponse | undefined;
|
||||
const resolveUrls = useMutation(api.storage.batchGetUrlsForUserMedia);
|
||||
|
||||
const [urlMap, setUrlMap] = useState<Record<string, string | undefined>>({});
|
||||
@@ -164,7 +181,7 @@ export function MediaLibraryDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
const storageIds = collectMediaStorageIdsForResolution(metadata);
|
||||
const storageIds = collectMediaStorageIdsForResolution(metadata.items);
|
||||
if (storageIds.length === 0) {
|
||||
setUrlMap({});
|
||||
setUrlError(null);
|
||||
@@ -206,12 +223,14 @@ export function MediaLibraryDialog({
|
||||
return [];
|
||||
}
|
||||
|
||||
return metadata.map((item) => ({
|
||||
return metadata.items.map((item) => ({
|
||||
...item,
|
||||
url: resolveMediaPreviewUrl(item, urlMap),
|
||||
}));
|
||||
}, [metadata, urlMap]);
|
||||
|
||||
const visibleItems = useMemo(() => items.slice(0, DEFAULT_PAGE_SIZE), [items]);
|
||||
|
||||
const isMetadataLoading = open && metadata === undefined;
|
||||
const isInitialLoading = isMetadataLoading || (metadata !== undefined && isResolvingUrls);
|
||||
const isPreviewMode = typeof onPick !== "function";
|
||||
@@ -244,9 +263,9 @@ export function MediaLibraryDialog({
|
||||
|
||||
<div className="min-h-[320px] overflow-y-auto pr-1">
|
||||
{isInitialLoading ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 12 }).map((_, index) => (
|
||||
<div key={index} className="overflow-hidden rounded-lg border">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
{Array.from({ length: DEFAULT_PAGE_SIZE }).map((_, index) => (
|
||||
<div key={index} className="overflow-hidden rounded-lg border">
|
||||
<div className="aspect-square animate-pulse bg-muted" />
|
||||
<div className="space-y-1 p-2">
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-muted" />
|
||||
@@ -270,8 +289,8 @@ export function MediaLibraryDialog({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{items.map((item) => {
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
{visibleItems.map((item) => {
|
||||
const itemKey = getItemKey(item);
|
||||
const isPickingThis = pendingPickItemKey === itemKey;
|
||||
const itemLabel = getItemLabel(item);
|
||||
@@ -347,6 +366,30 @@ export function MediaLibraryDialog({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{metadata && !isInitialLoading && !urlError && items.length > 0 ? (
|
||||
<div className="flex shrink-0 items-center justify-center gap-2 border-t px-5 py-3" aria-live="polite">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Page {metadata.page} of {metadata.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((current) => Math.min(metadata.totalPages, current + 1))}
|
||||
disabled={page >= metadata.totalPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user