feat(catalog): add saved packages, preview and bulk price editing
This commit is contained in:
1 parent
867113d5d4
commit
047cd9f3dd
48 files changed
+7712
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
allowed: true,
|
||||
query: vi.fn(async () => [{ id: 2, classname: "sofa", name: "Sofa" }]),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({
|
||||
requirePermission: vi.fn(async () => {
|
||||
if (!state.allowed) throw new Error("forbidden");
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { CATALOG_VIEW: "catalog.view" },
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
ItemsBase: { id: "id", itemName: "itemName", publicName: "publicName" },
|
||||
db: { select: () => ({ from: () => ({ where: state.query }) }) },
|
||||
}));
|
||||
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { getCatalogPreviewAssets } from "./catalog-preview-assets";
|
||||
|
||||
describe("preview asset authorization and bounds", () => {
|
||||
beforeEach(() => {
|
||||
state.allowed = true;
|
||||
state.query.mockClear();
|
||||
});
|
||||
it("checks catalog viewing permission before querying", async () => {
|
||||
state.allowed = false;
|
||||
await expect(getCatalogPreviewAssets([2])).rejects.toThrow("forbidden");
|
||||
expect(requirePermission).toHaveBeenCalledWith("catalog.view");
|
||||
expect(state.query).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects oversized and invalid requests without querying", async () => {
|
||||
expect((await getCatalogPreviewAssets(Array(201).fill(2))).ok).toBe(false);
|
||||
expect((await getCatalogPreviewAssets([NaN, -2])).ok).toBe(false);
|
||||
expect(state.query).not.toHaveBeenCalled();
|
||||
});
|
||||
it("returns only database-backed classname and display name", async () => {
|
||||
expect(await getCatalogPreviewAssets([2, 2])).toEqual({
|
||||
ok: true,
|
||||
assets: [{ id: 2, classname: "sofa", name: "Sofa" }],
|
||||
});
|
||||
expect(state.query).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
"use server";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, ItemsBase } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
export async function getCatalogPreviewAssets(itemIds: number[]) {
|
||||
await requirePermission(PERMS.CATALOG_VIEW);
|
||||
if (
|
||||
!Array.isArray(itemIds) ||
|
||||
itemIds.length > 200 ||
|
||||
itemIds.some(
|
||||
(id) => !Number.isSafeInteger(id) || id <= 0 || id > 2147483647,
|
||||
)
|
||||
)
|
||||
return { ok: false as const, assets: [] };
|
||||
const ids = [...new Set(itemIds)];
|
||||
if (!ids.length) return { ok: true as const, assets: [] };
|
||||
try {
|
||||
const assets = await db
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
classname: ItemsBase.itemName,
|
||||
name: ItemsBase.publicName,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(inArray(ItemsBase.id, ids));
|
||||
return { ok: true as const, assets };
|
||||
} catch {
|
||||
return { ok: false as const, assets: [] };
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { CatalogTree } from "@/components/admin/catalog-tree";
|
||||
import Link from "@/components/link";
|
||||
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
|
||||
import { CatalogSyncStatus } from "@/features/catalog/components/catalog-sync-status";
|
||||
import { CatalogPackagesDialog } from "@/features/catalog/components/packages/catalog-packages-dialog";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import {
|
||||
CatalogItems,
|
||||
@@ -506,6 +507,7 @@ export default async function CatalogPagesPage({
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
||||
<CatalogPackagesDialog canEdit={canEdit} />
|
||||
{canEdit && (
|
||||
<CatalogManagerDialog
|
||||
catalogType="normal"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
int,
|
||||
longtext,
|
||||
mysqlTable,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
export const CatalogPackages = mysqlTable("website_catalog_packages", {
|
||||
id: varchar("id", { length: 36 }).primaryKey(),
|
||||
name: varchar("name", { length: 128 }).notNull(),
|
||||
version: int("version", { unsigned: true }).notNull(),
|
||||
status: varchar("status", { length: 16 }).notNull(),
|
||||
mode: varchar("mode", { length: 16 }).notNull(),
|
||||
payload: longtext("payload").notNull(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,70 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type { PackageSnapshot } from "@/features/catalog/domain/packages";
|
||||
|
||||
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
||||
|
||||
import { PackageCategoryPicker } from "./package-category-picker";
|
||||
import { PackageSnapshotEditor } from "./package-snapshot-editor";
|
||||
|
||||
it("renders a bounded category page from a large destination list", () => {
|
||||
const pages = Array.from({ length: 5000 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
caption: `Category ${index + 1}`,
|
||||
}));
|
||||
const markup = renderToStaticMarkup(
|
||||
<PackageCategoryPicker
|
||||
pages={pages}
|
||||
selected={[]}
|
||||
onChange={() => {}}
|
||||
multiple
|
||||
/>,
|
||||
);
|
||||
expect((markup.match(/type="checkbox"/g) ?? []).length).toBe(25);
|
||||
expect(markup).toContain("Category 25");
|
||||
expect(markup).not.toContain("Category 26");
|
||||
});
|
||||
it("bounds offer controls and disables editing in a published or read-only snapshot", () => {
|
||||
const snapshot: PackageSnapshot = {
|
||||
pages: [
|
||||
{
|
||||
id: 10,
|
||||
parentId: -1,
|
||||
caption: "Rooms",
|
||||
enabled: "1",
|
||||
visible: "1",
|
||||
iconImage: 1,
|
||||
minRank: 1,
|
||||
clubOnly: "0",
|
||||
vipOnly: "0",
|
||||
orderNum: 1,
|
||||
pageLayout: "default_3x3",
|
||||
},
|
||||
],
|
||||
offers: Array.from({ length: 500 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
pageId: 10,
|
||||
catalogName: `Offer ${index + 1}`,
|
||||
itemIds: "42",
|
||||
costCredits: 100,
|
||||
costPoints: 0,
|
||||
pointsType: 0,
|
||||
amount: 1,
|
||||
})),
|
||||
};
|
||||
const markup = renderToStaticMarkup(
|
||||
<PackageSnapshotEditor
|
||||
snapshot={snapshot}
|
||||
disabled
|
||||
revision={1}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(markup).toContain("Offer 25");
|
||||
expect(markup).not.toContain("Offer 26");
|
||||
expect(markup).toContain('<fieldset disabled=""');
|
||||
expect(markup).not.toContain("selectMatching");
|
||||
expect((markup.match(/aria-label="costCredits #[0-9]+"/g) ?? []).length).toBe(
|
||||
25,
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,30 @@
|
||||
import "server-only";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
"Source categories or offers changed since this package was created. Create a fresh package to include the current data.":
|
||||
"sourceChanged",
|
||||
"Package changed. Reload it before saving": "saveConflict",
|
||||
"Package changed. Refresh the publication preview": "publishConflict",
|
||||
"Catalog changed. Refresh the publication preview": "catalogChanged",
|
||||
"Catalog package not found": "missing",
|
||||
"A package supports at most 200 categories including descendants":
|
||||
"pageLimit",
|
||||
"A package supports at most 500 offers": "offerLimit",
|
||||
"Package data exceeds 8 MB safety limit": "payloadLimit",
|
||||
"Package membership cannot change": "membership",
|
||||
"Package hierarchy cannot change": "hierarchy",
|
||||
"Package furniture references cannot change": "references",
|
||||
"Package furniture reference no longer exists": "furnitureMissing",
|
||||
"Package published, but audit logging failed.": "auditWarning",
|
||||
"Package published, but the hotel update was not delivered.": "hotelWarning",
|
||||
"Package published, but export scheduling failed.": "exportWarning",
|
||||
"Package published. Refresh the page to see the latest catalog.":
|
||||
"refreshWarning",
|
||||
};
|
||||
export async function packageMessage(message: string): Promise<string> {
|
||||
const key = messages[message];
|
||||
if (!key) return message;
|
||||
const t = await getTranslations("pages.admin.catalogPackages.backend");
|
||||
return t(key);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user