feat: add CSV/JSON export, advanced bulk filters & selection to Catalog Manager
- Admin Audit Log: CSV/JSON export via ?format=query param - Catalog Manager: club_only / have_offer filters (multi-filter support) - Offer discovery: refactored discoverOffers() to accept filters object - Translations added for new filter labels - Test updates for all modified paths
This commit is contained in:
1 parent
eeca532057
commit
388d8992f8
8 files changed
+279
-62
No files matched your search
@@ -11,6 +11,8 @@ const offers = [
|
||||
costCredits: 0,
|
||||
costPoints: 0,
|
||||
limitedStack: 0,
|
||||
clubOnly: "0",
|
||||
haveOffer: "0",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
@@ -21,6 +23,8 @@ const offers = [
|
||||
costCredits: 0,
|
||||
costPoints: 5,
|
||||
limitedStack: 0,
|
||||
clubOnly: "0",
|
||||
haveOffer: "1",
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
@@ -31,6 +35,8 @@ const offers = [
|
||||
costCredits: 15,
|
||||
costPoints: 0,
|
||||
limitedStack: 100,
|
||||
clubOnly: "1",
|
||||
haveOffer: "0",
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
@@ -41,6 +47,8 @@ const offers = [
|
||||
costCredits: 0,
|
||||
costPoints: 0,
|
||||
limitedStack: 0,
|
||||
clubOnly: "0",
|
||||
haveOffer: "0",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -48,32 +56,44 @@ describe("offer discovery", () => {
|
||||
it("matches #offer IDs exactly without matching longer IDs or furniture references", () => {
|
||||
const rows = [offers[0], { ...offers[1], id: 17, itemIds: "7" }];
|
||||
expect(
|
||||
discoverOffers(rows, " #7 ", "all").items.map((item) => item.id),
|
||||
discoverOffers(rows, " #7 ", { offerFilter: "all" }).items.map(
|
||||
(item) => item.id,
|
||||
),
|
||||
).toEqual([7]);
|
||||
});
|
||||
it("requires both prices to be zero for free offers and includes points-only paid offers", () => {
|
||||
expect(
|
||||
discoverOffers(offers, "", "free").items.map((item) => item.id),
|
||||
discoverOffers(offers, "", { offerFilter: "free" }).items.map(
|
||||
(item) => item.id,
|
||||
),
|
||||
).toEqual([7, 10]);
|
||||
expect(
|
||||
discoverOffers(offers, "", "paid").items.map((item) => item.id),
|
||||
discoverOffers(offers, "", { offerFilter: "paid" }).items.map(
|
||||
(item) => item.id,
|
||||
),
|
||||
).toEqual([8, 9]);
|
||||
});
|
||||
it("recognizes semicolon bundles without requiring a single base item", () => {
|
||||
expect(
|
||||
discoverOffers(offers, "", "bundles").items.map((item) => item.id),
|
||||
discoverOffers(offers, "", { offerFilter: "bundles" }).items.map(
|
||||
(item) => item.id,
|
||||
),
|
||||
).toEqual([8, 10]);
|
||||
expect(
|
||||
discoverOffers([{ ...offers[0], itemIds: "10; ;" }], "", "bundles").items,
|
||||
discoverOffers([{ ...offers[0], itemIds: "10; ;" }], "", {
|
||||
offerFilter: "bundles",
|
||||
}).items,
|
||||
).toEqual([]);
|
||||
});
|
||||
it("identifies limited editions by stock even without sold quantities", () => {
|
||||
expect(
|
||||
discoverOffers(offers, "", "limited").items.map((item) => item.id),
|
||||
discoverOffers(offers, "", { offerFilter: "limited" }).items.map(
|
||||
(item) => item.id,
|
||||
),
|
||||
).toEqual([9]);
|
||||
});
|
||||
it("combines trimmed case-insensitive text with a facet while counting other available facets", () => {
|
||||
const result = discoverOffers(offers, " CHAIR ", "paid");
|
||||
const result = discoverOffers(offers, " CHAIR ", { offerFilter: "paid" });
|
||||
expect(result.items.map((item) => item.id)).toEqual([8]);
|
||||
expect(result.counts).toEqual({
|
||||
all: 2,
|
||||
@@ -81,28 +101,52 @@ describe("offer discovery", () => {
|
||||
paid: 1,
|
||||
bundles: 1,
|
||||
limited: 0,
|
||||
club_only_yes: 0,
|
||||
club_only_no: 2,
|
||||
have_offer_yes: 1,
|
||||
have_offer_no: 3,
|
||||
});
|
||||
});
|
||||
it("searches public names, classnames, offer IDs and furniture references", () => {
|
||||
expect(
|
||||
discoverOffers(offers, "Wooden", "all").items.map((item) => item.id),
|
||||
discoverOffers(offers, "Wooden", { offerFilter: "all" }).items.map(
|
||||
(item) => item.id,
|
||||
),
|
||||
).toEqual([7, 9]);
|
||||
expect(
|
||||
discoverOffers(offers, "table_oak", "all").items.map((item) => item.id),
|
||||
discoverOffers(offers, "table_oak", { offerFilter: "all" }).items.map(
|
||||
(item) => item.id,
|
||||
),
|
||||
).toEqual([9]);
|
||||
expect(
|
||||
discoverOffers(offers, "7", "all").items.map((item) => item.id),
|
||||
discoverOffers(offers, "7", { offerFilter: "all" }).items.map(
|
||||
(item) => item.id,
|
||||
),
|
||||
).toEqual([7]);
|
||||
expect(
|
||||
discoverOffers(offers, "11", "all").items.map((item) => item.id),
|
||||
discoverOffers(offers, "11", { offerFilter: "all" }).items.map(
|
||||
(item) => item.id,
|
||||
),
|
||||
).toEqual([8]);
|
||||
});
|
||||
it("keeps source ordering and returns empty counts when the query has no matches", () => {
|
||||
const copy = structuredClone(offers);
|
||||
expect(discoverOffers(offers, " ", "all").items).toEqual(offers);
|
||||
expect(discoverOffers(offers, "absent", "all")).toEqual({
|
||||
expect(discoverOffers(offers, " ", { offerFilter: "all" }).items).toEqual(
|
||||
offers,
|
||||
);
|
||||
expect(discoverOffers(offers, "absent", { offerFilter: "all" })).toEqual({
|
||||
items: [],
|
||||
counts: { all: 0, free: 0, paid: 0, bundles: 0, limited: 0 },
|
||||
counts: {
|
||||
all: 0,
|
||||
free: 0,
|
||||
paid: 0,
|
||||
bundles: 0,
|
||||
limited: 0,
|
||||
club_only_yes: 0,
|
||||
club_only_no: 0,
|
||||
have_offer_yes: 0,
|
||||
have_offer_no: 0,
|
||||
},
|
||||
});
|
||||
expect(offers).toEqual(copy);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,10 @@ export const OFFER_FILTERS = [
|
||||
"paid",
|
||||
"bundles",
|
||||
"limited",
|
||||
"club_only_yes",
|
||||
"club_only_no",
|
||||
"have_offer_yes",
|
||||
"have_offer_no",
|
||||
] as const;
|
||||
export type OfferFilter = (typeof OFFER_FILTERS)[number];
|
||||
type DiscoverableOffer = Pick<
|
||||
@@ -18,7 +22,10 @@ type DiscoverableOffer = Pick<
|
||||
| "costCredits"
|
||||
| "costPoints"
|
||||
| "limitedStack"
|
||||
>;
|
||||
> & {
|
||||
clubOnly?: string;
|
||||
haveOffer?: string;
|
||||
};
|
||||
|
||||
function matchesFilter(item: DiscoverableOffer, filter: OfferFilter) {
|
||||
switch (filter) {
|
||||
@@ -35,6 +42,14 @@ function matchesFilter(item: DiscoverableOffer, filter: OfferFilter) {
|
||||
);
|
||||
case "limited":
|
||||
return item.limitedStack > 0;
|
||||
case "club_only_yes":
|
||||
return item.clubOnly === "1";
|
||||
case "club_only_no":
|
||||
return item.clubOnly === "0";
|
||||
case "have_offer_yes":
|
||||
return item.haveOffer === "1";
|
||||
case "have_offer_no":
|
||||
return item.haveOffer === "0";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,37 +57,73 @@ function matchesFilter(item: DiscoverableOffer, filter: OfferFilter) {
|
||||
export function discoverOffers<T extends DiscoverableOffer>(
|
||||
items: readonly T[],
|
||||
query: string,
|
||||
filter: OfferFilter,
|
||||
filters: Record<string, string>,
|
||||
) {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
const exactId = /^#\d+$/.test(normalized) ? normalized.slice(1) : null;
|
||||
const matches =
|
||||
exactId !== null
|
||||
? items.filter((item) => String(item.id) === exactId)
|
||||
: normalized
|
||||
? items.filter((item) =>
|
||||
[
|
||||
item.catalogName,
|
||||
item.baseItemName,
|
||||
item.baseName,
|
||||
item.itemIds,
|
||||
String(item.id),
|
||||
].some((value) => value.toLowerCase().includes(normalized)),
|
||||
)
|
||||
: [...items];
|
||||
|
||||
let matches = items;
|
||||
|
||||
// Apply text search
|
||||
if (exactId !== null) {
|
||||
matches = matches.filter((item) => String(item.id) === exactId);
|
||||
} else if (normalized) {
|
||||
matches = matches.filter((item) =>
|
||||
[
|
||||
item.catalogName,
|
||||
item.baseItemName,
|
||||
item.baseName,
|
||||
item.itemIds,
|
||||
String(item.id),
|
||||
].some((value) => value.toLowerCase().includes(normalized)),
|
||||
);
|
||||
}
|
||||
|
||||
// Apply multiple filters
|
||||
for (const filterKey in filters) {
|
||||
const filterValue = filters[filterKey];
|
||||
if (filterValue && filterValue !== "all") {
|
||||
matches = matches.filter((item) =>
|
||||
matchesFilter(item, filterValue as OfferFilter),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const counts = {
|
||||
all: matches.length,
|
||||
free: 0,
|
||||
paid: 0,
|
||||
bundles: 0,
|
||||
limited: 0,
|
||||
club_only_yes: 0,
|
||||
club_only_no: 0,
|
||||
have_offer_yes: 0,
|
||||
have_offer_no: 0,
|
||||
};
|
||||
for (const item of matches)
|
||||
|
||||
// Calculate facet counts based on *all* items matching the text query, before specific filters are applied
|
||||
const initialMatchesForFacets = items.filter((item) => {
|
||||
if (exactId !== null) return String(item.id) === exactId;
|
||||
if (normalized)
|
||||
return [
|
||||
item.catalogName,
|
||||
item.baseItemName,
|
||||
item.baseName,
|
||||
item.itemIds,
|
||||
String(item.id),
|
||||
].some((value) => value.toLowerCase().includes(normalized));
|
||||
return true; // If no text query, all items are initial matches
|
||||
});
|
||||
|
||||
for (const item of initialMatchesForFacets) {
|
||||
for (const facet of OFFER_FILTERS) {
|
||||
if (facet !== "all" && matchesFilter(item, facet)) counts[facet] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// The `items` property in the return object should now be the fully filtered `matches`.
|
||||
return {
|
||||
items: matches.filter((item) => matchesFilter(item, filter)),
|
||||
items: matches,
|
||||
counts,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user