diff --git a/e2e/ui/fixture-server.mjs b/e2e/ui/fixture-server.mjs index 714f61a8..52f44062 100644 --- a/e2e/ui/fixture-server.mjs +++ b/e2e/ui/fixture-server.mjs @@ -15,6 +15,7 @@ const port = Number(process.env.UI_TEST_PORT || 3107); if (!Number.isInteger(port) || port < 1024 || port > 65535) throw Error("Invalid UI_TEST_PORT"); const actions = new Set([ + "@/actions/import-furni", "@/actions/events", "@/actions/admin-settings", "@/actions/admin-table-views", @@ -197,6 +198,7 @@ const server = createServer(async (req, res) => { if ( [ "/admin/attachment-harness", + "/admin/studio-harness", "/admin/jobs-harness", "/admin/settings", "/admin/articles/new", diff --git a/e2e/ui/fixtures/entry.tsx b/e2e/ui/fixtures/entry.tsx index d4ea3c6f..1dcac4a1 100644 --- a/e2e/ui/fixtures/entry.tsx +++ b/e2e/ui/fixtures/entry.tsx @@ -7,7 +7,9 @@ import { CmsSettingsForm } from "@/app/admin/settings/cms-settings-form"; import { TicketsTable } from "@/app/admin/tickets/tickets-table"; import { ArticleForm } from "@/components/admin/article-form"; import { FurnitureSyncQueue } from "@/components/admin/studio/furniture-sync-queue"; +import { StudioClient } from "@/components/admin/studio/studio-client"; import type { ArticleSaveResult } from "@/lib/article-input"; +import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel"; import { themePaletteCss } from "@/lib/theme-css"; import { PRESETS } from "@/lib/theme-presets"; import messages from "@/messages/en.json"; @@ -103,7 +105,13 @@ createRoot(root).render( ? "News editor" : "Furniture synchronization"} - {route.includes("attachment-harness") ? ( + {route.includes("studio-harness") ? ( + + ) : route.includes("attachment-harness") ? ( ) : route.includes("events/recovery") ? ( fixtureAction("create-event", input); export const updateEvent = (input: unknown) => fixtureAction("update-event", input); + +export const deleteImportedFurni = (input: unknown) => + fixtureAction("delete-imported-furni", input); +export const setFurnidataTranslateEnabled = (input: unknown) => + fixtureAction("furnidata-translation", input); diff --git a/e2e/ui/studio.spec.ts b/e2e/ui/studio.spec.ts new file mode 100644 index 00000000..dbf939af --- /dev/null +++ b/e2e/ui/studio.spec.ts @@ -0,0 +1,164 @@ +import { expect, test } from "@playwright/test"; + +const items = [ + { + id: 1, + classname: "fixture_chair", + name: "Fixture chair", + description: "Synthetic chair", + type: "flooritem", + revision: 1, + category: "other", + alreadyImported: true, + nitroExists: true, + iconUrl: "/fixture/cover.svg", + }, + { + id: 2, + classname: "fixture_table", + name: "Fixture table", + description: "Synthetic table", + type: "flooritem", + revision: 1, + category: "other", + alreadyImported: false, + nitroExists: false, + iconUrl: "/fixture/cover.svg", + }, +]; + +test("Studio opens and dismisses detail, Nitro editor and import review", async ({ + page, +}) => { + const errors: string[] = []; + const mutations: string[] = []; + let metadataRequests = 0; + page.on("pageerror", (error) => errors.push(error.message)); + await page.route("**/api/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.pathname === "/api/admin/studio/inspect") { + const { classnames } = request.postDataJSON() as { classnames: string[] }; + return route.fulfill({ + json: { + items: classnames.map((classname) => ({ + classname, + sql: [], + catalog: [], + furnidata: [], + furnidataReadable: true, + nitro: { exists: false, bytes: 0 }, + icon: { exists: false, bytes: 0 }, + })), + }, + }); + } + if (url.pathname === "/api/admin/studio/source-assets") { + const body = request.postDataJSON() as { + items: Array<{ classname: string }>; + }; + return route.fulfill({ + json: { + items: body.items.map(({ classname }) => ({ + classname, + state: "available", + revision: 1, + alternatives: [], + attempts: [], + })), + }, + }); + } + if (request.method() !== "GET") { + mutations.push(`${request.method()} ${url.pathname}`); + return route.fulfill({ + status: 405, + json: { error: "Fixture forbids mutations" }, + }); + } + if (url.pathname.endsWith("/nitro-editor")) { + metadataRequests++; + return route.fulfill({ + json: { + metadata: { + name: "fixture_chair", + logicType: "furniture_basic", + visualizationType: "furniture_static", + }, + flags: { canstandon: false, cansiton: false, canlayon: false }, + }, + }); + } + if (url.pathname === "/api/admin/import/furni") { + return route.fulfill({ + json: + url.searchParams.get("action") === "stats" + ? { totalInDb: 1, inCatalog: 1, notInCatalog: 0, missingNitro: 0 } + : { + items, + meta: { currentPage: 1, lastPage: 1, total: 2, perPage: 20 }, + }, + }); + } + if (url.pathname === "/api/admin/import/clone") + return route.fulfill({ json: { sources: [] } }); + if (url.pathname === "/api/admin/studio/import-jobs") + return route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } }); + return route.fulfill({ + status: 404, + json: { error: "Unknown fixture endpoint" }, + }); + }); + await page.goto("/admin/studio-harness"); + await expect( + page.getByRole("button", { name: "View Fixture chair", exact: true }), + ).toBeVisible(); + expect(metadataRequests).toBe(0); + await page + .getByRole("button", { name: "View Fixture chair", exact: true }) + .click(); + await expect( + page.getByRole("button", { name: "Close furniture details", exact: true }), + ).toBeVisible(); + await expect( + page.getByText("Synthetic chair", { exact: true }).first(), + ).toBeVisible(); + await page + .getByRole("button", { name: "Close furniture details", exact: true }) + .click(); + await expect( + page.getByRole("button", { name: "Close furniture details", exact: true }), + ).toHaveCount(0); + await page + .getByRole("button", { name: "Edit nitro fixture_chair", exact: true }) + .click(); + const editor = page.getByRole("dialog", { name: "Edit Nitro", exact: true }); + await expect(editor).toBeVisible(); + await expect( + editor.getByRole("switch", { name: "Can Sit On", exact: true }), + ).toBeVisible(); + expect(metadataRequests).toBe(1); + await editor.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(editor).toHaveCount(0); + await page + .getByRole("button", { name: "Import fixture_table", exact: true }) + .click(); + const review = page.getByRole("dialog", { + name: "Review furniture import", + exact: true, + }); + await expect(review).toBeVisible(); + await expect( + review.getByText("Fixture table", { exact: true }), + ).toBeVisible(); + await expect( + review.getByRole("button", { + name: "Continue to confirmation", + exact: true, + }), + ).toBeEnabled(); + await review.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(review).toHaveCount(0); + expect(mutations).toEqual([]); + expect(errors).toEqual([]); +}); diff --git a/src/components/admin/studio/furniture-jobs-query.ts b/src/components/admin/studio/furniture-jobs-query.ts index bb80b144..be23276b 100644 --- a/src/components/admin/studio/furniture-jobs-query.ts +++ b/src/components/admin/studio/furniture-jobs-query.ts @@ -1,34 +1,53 @@ import { QueryClient, queryOptions } from "@tanstack/react-query"; -import { z } from "zod/mini"; +import { + array as zArray, + boolean as zBoolean, + enum as zEnum, + int as zInt, + iso as zIso, + literal as zLiteral, + looseObject as zLooseObject, + maxLength as zMaxLength, + nonnegative as zNonnegative, + nullable as zNullable, + number as zNumber, + object as zObject, + optional as zOptional, + positive as zPositive, + refine as zRefine, + regex as zRegex, + string as zString, + uuid as zUuid, +} from "zod/mini"; import { adminFetch } from "@/lib/admin-fetch"; import type { ImportJob } from "@/lib/furni/import-job"; const endpoint = "/api/admin/studio/import-jobs"; const historyTimeoutMs = 15_000; -const jobSchema = z.looseObject({ - id: z.uuid(), - userId: z.int().check(z.positive()), - createdAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), - state: z.enum(["queued", "running", "completed", "interrupted", "cancelled"]), - translate: z.boolean(), - mode: z.optional(z.literal("repair")), - syncKind: z.optional(z.enum(["official", "clone"])), - operationId: z.optional(z.string()), - retryOf: z.optional(z.string()), - sourceId: z.optional(z.string()), - cancelRequested: z.optional(z.boolean()), - langs: z.optional(z.array(z.string())), - items: z.array( - z.looseObject({ - id: z.int().check(z.nonnegative()), - classname: z.string(), - name: z.string(), - description: z.string(), - type: z.string(), - revision: z.number(), - category: z.string(), - state: z.enum([ +const jobSchema = zLooseObject({ + id: zUuid(), + userId: zInt().check(zPositive()), + createdAt: zIso.datetime({ offset: true }), + updatedAt: zIso.datetime({ offset: true }), + state: zEnum(["queued", "running", "completed", "interrupted", "cancelled"]), + translate: zBoolean(), + mode: zOptional(zLiteral("repair")), + syncKind: zOptional(zEnum(["official", "clone"])), + operationId: zOptional(zString()), + retryOf: zOptional(zString()), + sourceId: zOptional(zString()), + cancelRequested: zOptional(zBoolean()), + langs: zOptional(zArray(zString())), + items: zArray( + zLooseObject({ + id: zInt().check(zNonnegative()), + classname: zString(), + name: zString(), + description: zString(), + type: zString(), + revision: zNumber(), + category: zString(), + state: zEnum([ "pending", "running", "done", @@ -36,26 +55,24 @@ const jobSchema = z.looseObject({ "cancelled", "interrupted", ]), - error: z.optional(z.string()), - warnings: z.optional(z.array(z.string())), - itemId: z.optional(z.number()), + error: zOptional(zString()), + warnings: zOptional(zArray(zString())), + itemId: zOptional(zNumber()), }), ), }); -const historySchema = z.object({ - ok: z.literal(true), - jobs: z.array(jobSchema).check(z.maxLength(30)), - nextCursor: z.optional( - z.nullable( - z.string().check( - z.regex( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f-]{36}$/i, - ), - z.refine((value) => { +const historySchema = zObject({ + ok: zLiteral(true), + jobs: zArray(jobSchema).check(zMaxLength(30)), + nextCursor: zOptional( + zNullable( + zString().check( + zRegex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f-]{36}$/i), + zRefine((value) => { const [timestamp, id] = value.split("|"); return ( - z.iso.datetime({ precision: 3 }).safeParse(timestamp).success && - z.uuid().safeParse(id).success + zIso.datetime({ precision: 3 }).safeParse(timestamp).success && + zUuid().safeParse(id).success ); }), ), @@ -96,9 +113,9 @@ export async function readFurnitureJobResponse( fallback: string, ): Promise { const data = await responseData(response, fallback); - const parsed = z - .object({ ok: z.literal(true), job: jobSchema }) - .safeParse(data); + const parsed = zObject({ ok: zLiteral(true), job: jobSchema }).safeParse( + data, + ); if (!parsed.success) throw Error("Invalid import job response"); return parsed.data.job as ImportJob; } diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index 558aa11f..5626f8c5 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -22,10 +22,17 @@ import { Trash2, X, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { toast } from "sonner"; import { deleteImportedFurni } from "@/actions/import-furni"; -import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog"; import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -59,15 +66,14 @@ import type { TreeNode } from "@/types/catalog"; import { BatchProgress } from "./batch-progress"; import { CatalogRail } from "./catalog-rail"; import { CheckboxDot } from "./checkbox-dot"; -import { FurnitureDetailDrawer } from "./furniture-detail-drawer"; import { FurnitureJobHistory } from "./furniture-jobs"; -import { ImportReview } from "./import-review"; import { filterFurniture } from "./studio-filters"; import { getFurniImageUrl, handleImgError, TYPE_LABELS, } from "./studio-furni-icons"; +import { StudioPanelLoading } from "./studio-panel-loading"; import type { BatchItemStatus, FurniItem, @@ -78,6 +84,22 @@ import type { import { useFurnitureJobs } from "./use-furniture-jobs"; import { useStudioTranslations } from "./use-studio-translations"; +const NitroEditorDialog = lazy(() => + import("@/app/admin/import/furni/nitro-editor-dialog").then((module) => ({ + default: module.NitroEditorDialog, + })), +); +const FurnitureDetailDrawer = lazy(() => + import("./furniture-detail-drawer").then((module) => ({ + default: module.FurnitureDetailDrawer, + })), +); +const ImportReview = lazy(() => + import("./import-review").then((module) => ({ + default: module.ImportReview, + })), +); + export function StudioClient({ source, initialTree, @@ -1917,83 +1939,101 @@ export function StudioClient({ {/* ── Detail drawer ─────────────────────────────── */} {detail ? ( - - j.state === "completed" || - j.state === "cancelled" || - j.state === "interrupted", - ) - .map((j) => j.id) - .join(",")}`} - busy={ - jobs.busy || - jobs.jobs.some( - (j) => - (j.state === "queued" || j.state === "running") && - j.items.some((i) => i.classname === detail.classname), - ) + setDetail(null)} /> } - onClose={() => setDetail(null)} - onImport={() => setReview({ items: [detail], single: true })} - onEdit={() => setEditingClassname(detail.classname)} - onRegenerate={() => void regenNitro(detail)} - onDelete={() => { - setDeleting(detail.classname); - setConfirmDelete(true); - }} - onRepair={() => - jobs.submit([detail], { - sourceId: activeSource || undefined, - translate: false, - mode: "repair", - }) - } - /> + > + + j.state === "completed" || + j.state === "cancelled" || + j.state === "interrupted", + ) + .map((j) => j.id) + .join(",")}`} + busy={ + jobs.busy || + jobs.jobs.some( + (j) => + (j.state === "queued" || j.state === "running") && + j.items.some((i) => i.classname === detail.classname), + ) + } + onClose={() => setDetail(null)} + onImport={() => setReview({ items: [detail], single: true })} + onEdit={() => setEditingClassname(detail.classname)} + onRegenerate={() => void regenNitro(detail)} + onDelete={() => { + setDeleting(detail.classname); + setConfirmDelete(true); + }} + onRepair={() => + jobs.submit([detail], { + sourceId: activeSource || undefined, + translate: false, + mode: "repair", + }) + } + /> + ) : null} {review && ( - entry.id === activeSource)?.name ?? - source.label - } - translation={ - translate - ? translateLangs.length - ? translateLangs.join(", ") - : "Default languages" - : "Off" - } - onCancel={() => setReview(null)} - busy={jobs.busy} - onConfirm={async (accepted) => { - if ( - await jobs.submit(accepted, { - sourceId: activeSource || undefined, - translate, - langs: translateLangs.length ? translateLangs : undefined, - }) - ) { - setReview(null); - setSelected(new Set()); + setReview(null)} />} + > + entry.id === activeSource)?.name ?? + source.label } - }} - /> + translation={ + translate + ? translateLangs.length + ? translateLangs.join(", ") + : "Default languages" + : "Off" + } + onCancel={() => setReview(null)} + busy={jobs.busy} + onConfirm={async (accepted) => { + if ( + await jobs.submit(accepted, { + sourceId: activeSource || undefined, + translate, + langs: translateLangs.length ? translateLangs : undefined, + }) + ) { + setReview(null); + setSelected(new Set()); + } + }} + /> + + )} + {editingClassname !== null && ( + setEditingClassname(null)} /> + } + > + !open && setEditingClassname(null)} + /> + )} - !open && setEditingClassname(null)} - /> {confirmDialog} diff --git a/src/components/admin/studio/studio-panel-loading.tsx b/src/components/admin/studio/studio-panel-loading.tsx new file mode 100644 index 00000000..06c662a8 --- /dev/null +++ b/src/components/admin/studio/studio-panel-loading.tsx @@ -0,0 +1,47 @@ +"use client"; +import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +export function StudioPanelLoading({ + onClose, + drawer = false, +}: { + onClose: () => void; + drawer?: boolean; +}) { + const t = useTranslations("pages.admin.housekeeping"); + const status = ( +

+

+ ); + if (drawer) + return ( + + ); + return ( + !open && onClose()}> + + + {t("loading")} + + {status} + + + + ); +}