Adds a Studio 'Organize imports' dialog that groups recently imported furniture and furniture already sitting in the auto-created import pages into suggested catalog categories. Each group is presented with its suggested name, icon, and layout which can be overridden before approval; approved groups are turned into real catalog pages in a single atomic export run. Offers already inside the import subtree are moved to the new pages; brand-new furniture gets a fresh offer. - groupSuggestedCategories: generic pure helper reusing the same per-item label heuristic that drives suggestCategoryName; items with no label land in a 'Other Furni' remainder bucket - GET /api/admin/import/organize: returns items from the imported furniture tree (catalog_items JOIN items_base via page id set from the imported-furniture root) union audit-logged but not-yet-placed recent imports; marks alreadyPlaced vs new - organizeImportFurni server action: validates import-page membership before moving any offer, creates pages + inserts/moves items in one withCatalogExport snapshot, logs activity - OrganizeImportsDialog: full-featured Studio dialog with price panel (applies to new offers only), parent select, per-group approval, editable name/icon/layout with suggestion reset chips, source tags - import-pages.ts server helper: locates the imported-furniture tree - Studio nav: 'Organize imports' button with FolderTree icon, gated on CATALOG_EDIT, wired next to the catalog manager - en + nl translations for organizeImports.* keys with ICU plurals - groupSuggestedCategories unit tests (deterministic grouping, remainder handling, size+alpha ordering, label consistency)
293 lines
8.4 KiB
TypeScript
293 lines
8.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
|
import {
|
|
buildSortOrders,
|
|
groupSuggestedCategories,
|
|
type SortStrategy,
|
|
type SugFurni,
|
|
type SugFurniEntry,
|
|
sortCatalogItems,
|
|
suggestCatalogIcon,
|
|
suggestCatalogLayout,
|
|
suggestCatalogLayoutFromBaseItems,
|
|
suggestCategoryName,
|
|
} from "./organize";
|
|
|
|
function offer(overrides: Partial<CatalogItemData> = {}): CatalogItemData {
|
|
return {
|
|
id: 1,
|
|
catalogName: "",
|
|
itemIds: "1",
|
|
costCredits: 0,
|
|
costPoints: 0,
|
|
pointsType: 0,
|
|
amount: 1,
|
|
limitedSells: 0,
|
|
limitedStack: 0,
|
|
orderNumber: 0,
|
|
offerId: -1,
|
|
songId: 0,
|
|
haveOffer: "1",
|
|
clubOnly: "0",
|
|
extradata: "",
|
|
baseName: "",
|
|
baseItemName: "",
|
|
spriteId: 0,
|
|
baseItem: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const sample = [
|
|
offer({ id: 3, catalogName: "Zebra", costCredits: 10, limitedStack: 0 }),
|
|
offer({ id: 1, catalogName: "Apple", costCredits: 5, limitedStack: 0 }),
|
|
offer({ id: 2, catalogName: "Mango", costCredits: 5, limitedStack: 50 }),
|
|
];
|
|
|
|
describe("sortCatalogItems", () => {
|
|
it("sorts by name ascending", () => {
|
|
const sorted = sortCatalogItems(sample, "name-asc");
|
|
expect(sorted.map((i) => i.id)).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it("sorts by name descending", () => {
|
|
const sorted = sortCatalogItems(sample, "name-desc");
|
|
expect(sorted.map((i) => i.id)).toEqual([3, 2, 1]);
|
|
});
|
|
|
|
it("sorts by price then id", () => {
|
|
const sorted = sortCatalogItems(sample, "price-asc");
|
|
expect(sorted.map((i) => i.id)).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it("sorts expensive first", () => {
|
|
const sorted = sortCatalogItems(sample, "price-desc");
|
|
expect(sorted.map((i) => i.id)).toEqual([3, 1, 2]);
|
|
});
|
|
|
|
it("keeps limited editions first", () => {
|
|
const sorted = sortCatalogItems(sample, "limited-first");
|
|
expect(sorted.map((i) => i.id)).toEqual([2, 1, 3]);
|
|
});
|
|
|
|
it("keeps offers first", () => {
|
|
const input = [
|
|
offer({ id: 1, catalogName: "a", haveOffer: "0" }),
|
|
offer({ id: 2, catalogName: "b", haveOffer: "1" }),
|
|
];
|
|
const sorted = sortCatalogItems(input, "offer-first");
|
|
expect(sorted.map((i) => i.id)).toEqual([2, 1]);
|
|
});
|
|
|
|
it("sorts amount descending", () => {
|
|
const input = [
|
|
offer({ id: 1, catalogName: "a", amount: 1 }),
|
|
offer({ id: 2, catalogName: "b", amount: 10 }),
|
|
];
|
|
const sorted = sortCatalogItems(input, "amount-desc");
|
|
expect(sorted.map((i) => i.id)).toEqual([2, 1]);
|
|
});
|
|
|
|
it("is stable for unknown strategies", () => {
|
|
const sorted = sortCatalogItems(sample, "name-asc" as SortStrategy);
|
|
const re = sortCatalogItems([...sorted].reverse(), "name-asc");
|
|
expect(re.map((i) => i.id)).toEqual([1, 2, 3]);
|
|
});
|
|
});
|
|
|
|
describe("buildSortOrders", () => {
|
|
it("assigns sequential order numbers", () => {
|
|
const orders = buildSortOrders([sample[2], sample[0]]);
|
|
expect(orders).toEqual([
|
|
{ id: 2, orderNumber: 0 },
|
|
{ id: 3, orderNumber: 1 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("suggestCatalogLayout", () => {
|
|
it("returns null for empty lists", () => {
|
|
expect(suggestCatalogLayout([])).toBeNull();
|
|
});
|
|
|
|
it("suggests soundmachine when songs present", () => {
|
|
const input = [
|
|
offer({ songId: 55, baseItem: { interactionType: "default" } as never }),
|
|
];
|
|
expect(suggestCatalogLayout(input)?.layout).toBe("soundmachine");
|
|
});
|
|
|
|
it("suggests pets when base item type is 'p'", () => {
|
|
const input = [offer({ baseItem: { type: "p" } as never })];
|
|
expect(suggestCatalogLayout(input)?.layout).toBe("pets");
|
|
});
|
|
|
|
it("suggests trophies for trophy interactions", () => {
|
|
const input = [offer({ baseItem: { interactionType: "trophy" } as never })];
|
|
expect(suggestCatalogLayout(input)?.layout).toBe("trophies");
|
|
});
|
|
|
|
it("suggests guilds for guild interactions", () => {
|
|
const input = [offer({ baseItem: { interactionType: "guild" } as never })];
|
|
expect(suggestCatalogLayout(input)?.layout).toBe("guilds");
|
|
});
|
|
|
|
it("returns null when no strong signal", () => {
|
|
const input = [
|
|
offer({ baseItem: { interactionType: "default" } as never }),
|
|
];
|
|
expect(suggestCatalogLayout(input)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("suggestCatalogLayoutFromBaseItems", () => {
|
|
it("returns null for empty lists", () => {
|
|
expect(suggestCatalogLayoutFromBaseItems([])).toBeNull();
|
|
});
|
|
|
|
it("suggests soundmachine from interaction type", () => {
|
|
expect(
|
|
suggestCatalogLayoutFromBaseItems([{ interactionType: "soundmachine" }])
|
|
?.layout,
|
|
).toBe("soundmachine");
|
|
});
|
|
|
|
it("suggests pets from base type", () => {
|
|
expect(
|
|
suggestCatalogLayoutFromBaseItems([
|
|
{ type: "p", interactionType: "default" },
|
|
])?.layout,
|
|
).toBe("pets");
|
|
});
|
|
|
|
it("suggests trophies for trophy interactions", () => {
|
|
expect(
|
|
suggestCatalogLayoutFromBaseItems([{ interactionType: "trophy" }])
|
|
?.layout,
|
|
).toBe("trophies");
|
|
});
|
|
|
|
it("returns null when no strong signal", () => {
|
|
expect(
|
|
suggestCatalogLayoutFromBaseItems([{ interactionType: "default" }]),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
function fum(itemName?: string, interactionType?: string): SugFurni {
|
|
return { itemName, interactionType };
|
|
}
|
|
|
|
describe("suggestCategoryName", () => {
|
|
it("returns null for empty lists", () => {
|
|
expect(suggestCategoryName([])).toBeNull();
|
|
});
|
|
|
|
it("derives a pluralized label from the dominant classname token", () => {
|
|
const input = [fum("chair_wooden"), fum("chair_oak"), fum("chair_polyfon")];
|
|
expect(suggestCategoryName(input)).toBe("Chairs");
|
|
});
|
|
|
|
it("keeps singular-only tokens singular", () => {
|
|
expect(suggestCategoryName([fum("wallpaper_leaf")])).toBe("Wallpaper");
|
|
expect(suggestCategoryName([fum("floor_plain")])).toBe("Floor");
|
|
});
|
|
|
|
it("prefers interaction labels over classname tokens", () => {
|
|
const input = [
|
|
fum("trophy_cup", "trophy"),
|
|
fum("trophy_crown", "trophy"),
|
|
fum("chair_oak", "default"),
|
|
];
|
|
expect(suggestCategoryName(input)).toBe("Trophies");
|
|
});
|
|
|
|
it("uses the last-resort mapping for known interactions", () => {
|
|
expect(
|
|
suggestCategoryName([fum("soundmachine_flat", "soundmachine")]),
|
|
).toBe("Sounds");
|
|
});
|
|
|
|
it("returns null when nothing can be derived", () => {
|
|
expect(suggestCategoryName([fum("")])).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("suggestCatalogIcon", () => {
|
|
it("returns 0 for unknown categories", () => {
|
|
expect(suggestCatalogIcon([fum("")])).toBe(0);
|
|
});
|
|
|
|
it("maps detected categories to a catalog icon id", () => {
|
|
expect(suggestCatalogIcon([fum("chair_oak")])).toBeGreaterThan(0);
|
|
expect(suggestCatalogIcon([fum("trophy_cup", "trophy")])).toBe(7);
|
|
});
|
|
|
|
it("is deterministic", () => {
|
|
const a = suggestCatalogIcon([fum("chair_oak"), fum("chair_oak")]);
|
|
const b = suggestCatalogIcon([fum("chair_oak"), fum("chair_oak")]);
|
|
expect(a).toBe(b);
|
|
});
|
|
});
|
|
|
|
describe("groupSuggestedCategories", () => {
|
|
function entry(
|
|
id: number,
|
|
itemName?: string,
|
|
interactionType?: string,
|
|
): SugFurniEntry {
|
|
return { id, itemName, interactionType };
|
|
}
|
|
|
|
it("returns an empty list for no input", () => {
|
|
expect(groupSuggestedCategories([])).toEqual([]);
|
|
});
|
|
|
|
it("groups furniture by shared label, largest group first", () => {
|
|
const groups = groupSuggestedCategories([
|
|
entry(1, "chair_wooden"),
|
|
entry(2, "chair_oak"),
|
|
entry(3, "sofa_polyfon"),
|
|
entry(4, "chair_polyfon"),
|
|
]);
|
|
expect(groups.map((g) => g.name)).toEqual(["Chairs", "Sofas"]);
|
|
expect(groups[0]?.items.map((i) => i.id)).toEqual([1, 2, 4]);
|
|
expect(groups[1]?.items.map((i) => i.id)).toEqual([3]);
|
|
});
|
|
|
|
it("sorts by size then alphabetically on ties", () => {
|
|
const groups = groupSuggestedCategories([
|
|
entry(1, "sofa_polyfon"),
|
|
entry(2, "chair_oak"),
|
|
]);
|
|
expect(groups.map((g) => g.name)).toEqual(["Chairs", "Sofas"]);
|
|
});
|
|
|
|
it("falls back deterministically and drops nothing", () => {
|
|
const groups = groupSuggestedCategories([
|
|
entry(1, "sofa_polyfon"),
|
|
entry(2, ""),
|
|
entry(3, ""),
|
|
]);
|
|
expect(groups.map((g) => g.name)).toEqual(["Other Furni", "Sofas"]);
|
|
const leftover = groups.find((g) => g.name === "Other Furni");
|
|
expect(leftover?.items.map((i) => i.id)).toEqual([2, 3]);
|
|
expect(leftover).not.toBeNull();
|
|
});
|
|
|
|
it("suggests a plausible icon and layout per group", () => {
|
|
const groups = groupSuggestedCategories([entry(1, "chair_oak")]);
|
|
expect(groups[0]).toBeDefined();
|
|
expect(groups[0]?.icon).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("uses the same label as the batch name suggestion", () => {
|
|
const group = groupSuggestedCategories([
|
|
entry(1, "trophy_cup", "trophy"),
|
|
entry(2, "trophy_crown", "trophy"),
|
|
entry(3, "chair_oak"),
|
|
])[0];
|
|
expect(group?.name).toBe("Trophies");
|
|
});
|
|
});
|