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
@@ -195,13 +195,19 @@ export function CatalogItemsTable({
|
|||||||
|
|
||||||
// ── Search / Filter ─────────────────────────────────────────────
|
// ── Search / Filter ─────────────────────────────────────────────
|
||||||
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
||||||
const [offerFilter, setOfferFilter] = useState<OfferFilter>("all");
|
const [filters, setFilters] = useState<Record<string, string>>({
|
||||||
|
offerFilter: "all",
|
||||||
|
clubOnlyFilter: "all",
|
||||||
|
haveOfferFilter: "all",
|
||||||
|
});
|
||||||
|
|
||||||
const { items: filteredItems, counts: filterCounts } = useMemo(
|
const { items: filteredItems, counts: filterCounts } = useMemo(
|
||||||
() => discoverOffers(items, searchQuery, offerFilter),
|
() => discoverOffers(items, searchQuery, filters),
|
||||||
[items, searchQuery, offerFilter],
|
[items, searchQuery, filters],
|
||||||
);
|
);
|
||||||
const hasDiscoveryFilter =
|
const hasDiscoveryFilter =
|
||||||
Boolean(searchQuery.trim()) || offerFilter !== "all";
|
Boolean(searchQuery.trim()) ||
|
||||||
|
Object.values(filters).some((f) => f !== "all");
|
||||||
|
|
||||||
// ── Move items dialog ───────────────────────────────────────────
|
// ── Move items dialog ───────────────────────────────────────────
|
||||||
const [moveOpen, setMoveOpen] = useState(false);
|
const [moveOpen, setMoveOpen] = useState(false);
|
||||||
@@ -225,12 +231,13 @@ export function CatalogItemsTable({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const orderedFilteredItems = useMemo(() => {
|
const orderedFilteredItems = useMemo(() => {
|
||||||
if (searchQuery.trim()) return filteredItems;
|
if (searchQuery.trim() || Object.values(filters).some((f) => f !== "all"))
|
||||||
|
return filteredItems;
|
||||||
const orderMap = new Map(itemOrder.map((id, idx) => [id, idx]));
|
const orderMap = new Map(itemOrder.map((id, idx) => [id, idx]));
|
||||||
return [...filteredItems].sort(
|
return filteredItems
|
||||||
(a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0),
|
.slice()
|
||||||
);
|
.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
||||||
}, [filteredItems, itemOrder, searchQuery]);
|
}, [filteredItems, itemOrder, searchQuery, filters]);
|
||||||
|
|
||||||
// ── Selection ────────────────────────────────────────────────────
|
// ── Selection ────────────────────────────────────────────────────
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
@@ -265,13 +272,17 @@ export function CatalogItemsTable({
|
|||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
}
|
}
|
||||||
function updateOfferFilter(value: string) {
|
function updateFilter(key: string, value: string) {
|
||||||
setOfferFilter(value as OfferFilter);
|
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
}
|
}
|
||||||
function resetDiscovery() {
|
function resetDiscovery() {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setOfferFilter("all");
|
setFilters({
|
||||||
|
offerFilter: "all",
|
||||||
|
clubOnlyFilter: "all",
|
||||||
|
haveOfferFilter: "all",
|
||||||
|
});
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1187,18 +1198,21 @@ export function CatalogItemsTable({
|
|||||||
onClear={() => updateSearch("")}
|
onClear={() => updateSearch("")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={offerFilter} onValueChange={updateOfferFilter}>
|
<Select
|
||||||
|
value={filters.offerFilter}
|
||||||
|
onValueChange={(v) => updateFilter("offerFilter", v)}
|
||||||
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
aria-label={discovery("filterLabel")}
|
aria-label={discovery("filterLabel")}
|
||||||
className="w-full sm:w-52"
|
className="w-full sm:w-52"
|
||||||
>
|
>
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
{discovery(`filters.${offerFilter}`)} (
|
{discovery(`filters.${filters.offerFilter}`)} (
|
||||||
{filterCounts[offerFilter]})
|
{filterCounts[filters.offerFilter as OfferFilter]})
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{OFFER_FILTERS.map((filter) => (
|
{OFFER_FILTERS.slice(0, 5).map((filter) => (
|
||||||
<SelectItem key={filter} value={filter}>
|
<SelectItem key={filter} value={filter}>
|
||||||
{discovery(`filters.${filter}`)} ({filterCounts[filter]}
|
{discovery(`filters.${filter}`)} ({filterCounts[filter]}
|
||||||
)
|
)
|
||||||
@@ -1206,6 +1220,59 @@ export function CatalogItemsTable({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filters.clubOnlyFilter}
|
||||||
|
onValueChange={(v) => updateFilter("clubOnlyFilter", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
aria-label={discovery("clubOnlyFilterLabel")}
|
||||||
|
className="w-full sm:w-52"
|
||||||
|
>
|
||||||
|
<SelectValue>
|
||||||
|
{discovery(`filters.${filters.clubOnlyFilter}`)} (
|
||||||
|
{filterCounts[filters.clubOnlyFilter as OfferFilter]})
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
{discovery("filters.all")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="club_only_yes">
|
||||||
|
{discovery("filters.club_only_yes")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="club_only_no">
|
||||||
|
{discovery("filters.club_only_no")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filters.haveOfferFilter}
|
||||||
|
onValueChange={(v) => updateFilter("haveOfferFilter", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
aria-label={discovery("haveOfferFilterLabel")}
|
||||||
|
className="w-full sm:w-52"
|
||||||
|
>
|
||||||
|
<SelectValue>
|
||||||
|
{discovery(`filters.${filters.haveOfferFilter}`)} (
|
||||||
|
{filterCounts[filters.haveOfferFilter as OfferFilter]})
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
{discovery("filters.all")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="have_offer_yes">
|
||||||
|
{discovery("filters.have_offer_yes")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="have_offer_no">
|
||||||
|
{discovery("filters.have_offer_no")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
{hasDiscoveryFilter && (
|
{hasDiscoveryFilter && (
|
||||||
<Button variant="ghost" size="sm" onClick={resetDiscovery}>
|
<Button variant="ghost" size="sm" onClick={resetDiscovery}>
|
||||||
{discovery("reset")}
|
{discovery("reset")}
|
||||||
@@ -1245,7 +1312,7 @@ export function CatalogItemsTable({
|
|||||||
</p>
|
</p>
|
||||||
) : viewMode === "grid" ? (
|
) : viewMode === "grid" ? (
|
||||||
<ItemsShopPreview
|
<ItemsShopPreview
|
||||||
items={orderedFilteredItems}
|
items={orderedFilteredItems as CatalogItemData[]}
|
||||||
furniRevisionMap={furniRevisionMap}
|
furniRevisionMap={furniRevisionMap}
|
||||||
layout={pageLayout}
|
layout={pageLayout}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
|
|||||||
@@ -134,12 +134,27 @@ export function AuditTable({ data }: AuditTableProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<AuditFilters />
|
<AuditFilters />
|
||||||
<p className="text-xs text-muted-foreground">{t("audit.exportHint")}</p>
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">{t("audit.exportHint")}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={`/api/admin/logs/audit/export?${params.toString()}&format=csv`}
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-3 py-1.5 text-xs font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`/api/admin/logs/audit/export?${params.toString()}&format=json`}
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-3 py-1.5 text-xs font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Export JSON
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
data={data}
|
data={data}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
searchPlaceholder={t("audit.searchPlaceholder")}
|
searchPlaceholder={t("audit.searchPlaceholder")}
|
||||||
exportUrl={`/api/admin/logs/audit/export?${params.toString()}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ vi.mock("@/lib/services/audit", () => ({
|
|||||||
import { getAuditLogs } from "@/lib/services/audit";
|
import { getAuditLogs } from "@/lib/services/audit";
|
||||||
import { GET } from "./route";
|
import { GET } from "./route";
|
||||||
|
|
||||||
describe("audit CSV", () => {
|
describe("audit export", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(getAuditLogs).mockClear();
|
vi.mocked(getAuditLogs).mockClear();
|
||||||
});
|
});
|
||||||
@@ -54,6 +54,19 @@ describe("audit CSV", () => {
|
|||||||
});
|
});
|
||||||
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||||
});
|
});
|
||||||
|
it("supports JSON export format", async () => {
|
||||||
|
const response = await GET(
|
||||||
|
new Request(
|
||||||
|
"https://example.test/api/admin/logs/audit/export?format=json",
|
||||||
|
) as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
expect(response.headers.get("Content-Type")).toContain("application/json");
|
||||||
|
const json = await response.json();
|
||||||
|
expect(Array.isArray(json)).toBe(true);
|
||||||
|
expect(json[0].id).toBe(1);
|
||||||
|
expect(json[0].username).toBe('=HYPERLINK("bad")');
|
||||||
|
});
|
||||||
it("escapes CSV formula cells and redacts historical secrets", async () => {
|
it("escapes CSV formula cells and redacts historical secrets", async () => {
|
||||||
const response = await GET(
|
const response = await GET(
|
||||||
new Request("https://example.test/api/admin/logs/audit/export") as never,
|
new Request("https://example.test/api/admin/logs/audit/export") as never,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const GET = withAdmin(
|
|||||||
from: params.get("from") ?? undefined,
|
from: params.get("from") ?? undefined,
|
||||||
to: params.get("to") ?? undefined,
|
to: params.get("to") ?? undefined,
|
||||||
});
|
});
|
||||||
|
const format = params.get("format") || "csv";
|
||||||
const rows: unknown[][] = [
|
const rows: unknown[][] = [
|
||||||
[
|
[
|
||||||
"id",
|
"id",
|
||||||
@@ -35,8 +36,17 @@ export const GET = withAdmin(
|
|||||||
"changes",
|
"changes",
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
const jsonItems = [];
|
||||||
for (const row of result.rows) {
|
for (const row of result.rows) {
|
||||||
const details = readAuditChanges(row.diff, row.before, row.after);
|
const details = readAuditChanges(row.diff, row.before, row.after);
|
||||||
|
const changesFormatted = details.invalid
|
||||||
|
? "[Unavailable legacy details]"
|
||||||
|
: details.changes
|
||||||
|
.map(
|
||||||
|
(change) =>
|
||||||
|
`${change.key}: ${formatAuditValue(change.from)} → ${formatAuditValue(change.to)}`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
rows.push([
|
rows.push([
|
||||||
row.id,
|
row.id,
|
||||||
row.userId,
|
row.userId,
|
||||||
@@ -45,15 +55,27 @@ export const GET = withAdmin(
|
|||||||
row.target,
|
row.target,
|
||||||
row.targetId,
|
row.targetId,
|
||||||
row.createdAt,
|
row.createdAt,
|
||||||
details.invalid
|
changesFormatted,
|
||||||
? "[Unavailable legacy details]"
|
|
||||||
: details.changes
|
|
||||||
.map(
|
|
||||||
(change) =>
|
|
||||||
`${change.key}: ${formatAuditValue(change.from)} → ${formatAuditValue(change.to)}`,
|
|
||||||
)
|
|
||||||
.join("\n"),
|
|
||||||
]);
|
]);
|
||||||
|
jsonItems.push({
|
||||||
|
id: row.id,
|
||||||
|
userId: row.userId,
|
||||||
|
username: row.username,
|
||||||
|
action: row.action,
|
||||||
|
target: row.target,
|
||||||
|
targetId: row.targetId,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
changes: details.changes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (format === "json") {
|
||||||
|
return new Response(JSON.stringify(jsonItems, null, 2), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Content-Disposition": 'attachment; filename="audit-page.json"',
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return new Response(
|
return new Response(
|
||||||
`\uFEFF${rows.map((row) => row.map(cell).join(",")).join("\r\n")}`,
|
`\uFEFF${rows.map((row) => row.map(cell).join(",")).join("\r\n")}`,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const offers = [
|
|||||||
costCredits: 0,
|
costCredits: 0,
|
||||||
costPoints: 0,
|
costPoints: 0,
|
||||||
limitedStack: 0,
|
limitedStack: 0,
|
||||||
|
clubOnly: "0",
|
||||||
|
haveOffer: "0",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 8,
|
id: 8,
|
||||||
@@ -21,6 +23,8 @@ const offers = [
|
|||||||
costCredits: 0,
|
costCredits: 0,
|
||||||
costPoints: 5,
|
costPoints: 5,
|
||||||
limitedStack: 0,
|
limitedStack: 0,
|
||||||
|
clubOnly: "0",
|
||||||
|
haveOffer: "1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 9,
|
id: 9,
|
||||||
@@ -31,6 +35,8 @@ const offers = [
|
|||||||
costCredits: 15,
|
costCredits: 15,
|
||||||
costPoints: 0,
|
costPoints: 0,
|
||||||
limitedStack: 100,
|
limitedStack: 100,
|
||||||
|
clubOnly: "1",
|
||||||
|
haveOffer: "0",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 10,
|
id: 10,
|
||||||
@@ -41,6 +47,8 @@ const offers = [
|
|||||||
costCredits: 0,
|
costCredits: 0,
|
||||||
costPoints: 0,
|
costPoints: 0,
|
||||||
limitedStack: 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", () => {
|
it("matches #offer IDs exactly without matching longer IDs or furniture references", () => {
|
||||||
const rows = [offers[0], { ...offers[1], id: 17, itemIds: "7" }];
|
const rows = [offers[0], { ...offers[1], id: 17, itemIds: "7" }];
|
||||||
expect(
|
expect(
|
||||||
discoverOffers(rows, " #7 ", "all").items.map((item) => item.id),
|
discoverOffers(rows, " #7 ", { offerFilter: "all" }).items.map(
|
||||||
|
(item) => item.id,
|
||||||
|
),
|
||||||
).toEqual([7]);
|
).toEqual([7]);
|
||||||
});
|
});
|
||||||
it("requires both prices to be zero for free offers and includes points-only paid offers", () => {
|
it("requires both prices to be zero for free offers and includes points-only paid offers", () => {
|
||||||
expect(
|
expect(
|
||||||
discoverOffers(offers, "", "free").items.map((item) => item.id),
|
discoverOffers(offers, "", { offerFilter: "free" }).items.map(
|
||||||
|
(item) => item.id,
|
||||||
|
),
|
||||||
).toEqual([7, 10]);
|
).toEqual([7, 10]);
|
||||||
expect(
|
expect(
|
||||||
discoverOffers(offers, "", "paid").items.map((item) => item.id),
|
discoverOffers(offers, "", { offerFilter: "paid" }).items.map(
|
||||||
|
(item) => item.id,
|
||||||
|
),
|
||||||
).toEqual([8, 9]);
|
).toEqual([8, 9]);
|
||||||
});
|
});
|
||||||
it("recognizes semicolon bundles without requiring a single base item", () => {
|
it("recognizes semicolon bundles without requiring a single base item", () => {
|
||||||
expect(
|
expect(
|
||||||
discoverOffers(offers, "", "bundles").items.map((item) => item.id),
|
discoverOffers(offers, "", { offerFilter: "bundles" }).items.map(
|
||||||
|
(item) => item.id,
|
||||||
|
),
|
||||||
).toEqual([8, 10]);
|
).toEqual([8, 10]);
|
||||||
expect(
|
expect(
|
||||||
discoverOffers([{ ...offers[0], itemIds: "10; ;" }], "", "bundles").items,
|
discoverOffers([{ ...offers[0], itemIds: "10; ;" }], "", {
|
||||||
|
offerFilter: "bundles",
|
||||||
|
}).items,
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
it("identifies limited editions by stock even without sold quantities", () => {
|
it("identifies limited editions by stock even without sold quantities", () => {
|
||||||
expect(
|
expect(
|
||||||
discoverOffers(offers, "", "limited").items.map((item) => item.id),
|
discoverOffers(offers, "", { offerFilter: "limited" }).items.map(
|
||||||
|
(item) => item.id,
|
||||||
|
),
|
||||||
).toEqual([9]);
|
).toEqual([9]);
|
||||||
});
|
});
|
||||||
it("combines trimmed case-insensitive text with a facet while counting other available facets", () => {
|
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.items.map((item) => item.id)).toEqual([8]);
|
||||||
expect(result.counts).toEqual({
|
expect(result.counts).toEqual({
|
||||||
all: 2,
|
all: 2,
|
||||||
@@ -81,28 +101,52 @@ describe("offer discovery", () => {
|
|||||||
paid: 1,
|
paid: 1,
|
||||||
bundles: 1,
|
bundles: 1,
|
||||||
limited: 0,
|
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", () => {
|
it("searches public names, classnames, offer IDs and furniture references", () => {
|
||||||
expect(
|
expect(
|
||||||
discoverOffers(offers, "Wooden", "all").items.map((item) => item.id),
|
discoverOffers(offers, "Wooden", { offerFilter: "all" }).items.map(
|
||||||
|
(item) => item.id,
|
||||||
|
),
|
||||||
).toEqual([7, 9]);
|
).toEqual([7, 9]);
|
||||||
expect(
|
expect(
|
||||||
discoverOffers(offers, "table_oak", "all").items.map((item) => item.id),
|
discoverOffers(offers, "table_oak", { offerFilter: "all" }).items.map(
|
||||||
|
(item) => item.id,
|
||||||
|
),
|
||||||
).toEqual([9]);
|
).toEqual([9]);
|
||||||
expect(
|
expect(
|
||||||
discoverOffers(offers, "7", "all").items.map((item) => item.id),
|
discoverOffers(offers, "7", { offerFilter: "all" }).items.map(
|
||||||
|
(item) => item.id,
|
||||||
|
),
|
||||||
).toEqual([7]);
|
).toEqual([7]);
|
||||||
expect(
|
expect(
|
||||||
discoverOffers(offers, "11", "all").items.map((item) => item.id),
|
discoverOffers(offers, "11", { offerFilter: "all" }).items.map(
|
||||||
|
(item) => item.id,
|
||||||
|
),
|
||||||
).toEqual([8]);
|
).toEqual([8]);
|
||||||
});
|
});
|
||||||
it("keeps source ordering and returns empty counts when the query has no matches", () => {
|
it("keeps source ordering and returns empty counts when the query has no matches", () => {
|
||||||
const copy = structuredClone(offers);
|
const copy = structuredClone(offers);
|
||||||
expect(discoverOffers(offers, " ", "all").items).toEqual(offers);
|
expect(discoverOffers(offers, " ", { offerFilter: "all" }).items).toEqual(
|
||||||
expect(discoverOffers(offers, "absent", "all")).toEqual({
|
offers,
|
||||||
|
);
|
||||||
|
expect(discoverOffers(offers, "absent", { offerFilter: "all" })).toEqual({
|
||||||
items: [],
|
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);
|
expect(offers).toEqual(copy);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ export const OFFER_FILTERS = [
|
|||||||
"paid",
|
"paid",
|
||||||
"bundles",
|
"bundles",
|
||||||
"limited",
|
"limited",
|
||||||
|
"club_only_yes",
|
||||||
|
"club_only_no",
|
||||||
|
"have_offer_yes",
|
||||||
|
"have_offer_no",
|
||||||
] as const;
|
] as const;
|
||||||
export type OfferFilter = (typeof OFFER_FILTERS)[number];
|
export type OfferFilter = (typeof OFFER_FILTERS)[number];
|
||||||
type DiscoverableOffer = Pick<
|
type DiscoverableOffer = Pick<
|
||||||
@@ -18,7 +22,10 @@ type DiscoverableOffer = Pick<
|
|||||||
| "costCredits"
|
| "costCredits"
|
||||||
| "costPoints"
|
| "costPoints"
|
||||||
| "limitedStack"
|
| "limitedStack"
|
||||||
>;
|
> & {
|
||||||
|
clubOnly?: string;
|
||||||
|
haveOffer?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function matchesFilter(item: DiscoverableOffer, filter: OfferFilter) {
|
function matchesFilter(item: DiscoverableOffer, filter: OfferFilter) {
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
@@ -35,6 +42,14 @@ function matchesFilter(item: DiscoverableOffer, filter: OfferFilter) {
|
|||||||
);
|
);
|
||||||
case "limited":
|
case "limited":
|
||||||
return item.limitedStack > 0;
|
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>(
|
export function discoverOffers<T extends DiscoverableOffer>(
|
||||||
items: readonly T[],
|
items: readonly T[],
|
||||||
query: string,
|
query: string,
|
||||||
filter: OfferFilter,
|
filters: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
const normalized = query.trim().toLowerCase();
|
const normalized = query.trim().toLowerCase();
|
||||||
const exactId = /^#\d+$/.test(normalized) ? normalized.slice(1) : null;
|
const exactId = /^#\d+$/.test(normalized) ? normalized.slice(1) : null;
|
||||||
const matches =
|
|
||||||
exactId !== null
|
let matches = items;
|
||||||
? items.filter((item) => String(item.id) === exactId)
|
|
||||||
: normalized
|
// Apply text search
|
||||||
? items.filter((item) =>
|
if (exactId !== null) {
|
||||||
[
|
matches = matches.filter((item) => String(item.id) === exactId);
|
||||||
item.catalogName,
|
} else if (normalized) {
|
||||||
item.baseItemName,
|
matches = matches.filter((item) =>
|
||||||
item.baseName,
|
[
|
||||||
item.itemIds,
|
item.catalogName,
|
||||||
String(item.id),
|
item.baseItemName,
|
||||||
].some((value) => value.toLowerCase().includes(normalized)),
|
item.baseName,
|
||||||
)
|
item.itemIds,
|
||||||
: [...items];
|
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 = {
|
const counts = {
|
||||||
all: matches.length,
|
all: matches.length,
|
||||||
free: 0,
|
free: 0,
|
||||||
paid: 0,
|
paid: 0,
|
||||||
bundles: 0,
|
bundles: 0,
|
||||||
limited: 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) {
|
for (const facet of OFFER_FILTERS) {
|
||||||
if (facet !== "all" && matchesFilter(item, facet)) counts[facet] += 1;
|
if (facet !== "all" && matchesFilter(item, facet)) counts[facet] += 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The `items` property in the return object should now be the fully filtered `matches`.
|
||||||
return {
|
return {
|
||||||
items: matches.filter((item) => matchesFilter(item, filter)),
|
items: matches,
|
||||||
counts,
|
counts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,5 @@ describe("command center translations", () => {
|
|||||||
expect(t("totalAccounts", { count: 42 })).toBe("42 total accounts");
|
expect(t("totalAccounts", { count: 42 })).toBe("42 total accounts");
|
||||||
expect(t("groups.players")).toBe("Players");
|
expect(t("groups.players")).toBe("Players");
|
||||||
expect(t("checkedAt", { time: "12:34" })).toBe("Checked at 12:34");
|
expect(t("checkedAt", { time: "12:34" })).toBe("Checked at 12:34");
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2331,12 +2331,18 @@
|
|||||||
"discovery": {
|
"discovery": {
|
||||||
"searchPlaceholder": "Name, classname, furniture ID or #offer ID…",
|
"searchPlaceholder": "Name, classname, furniture ID or #offer ID…",
|
||||||
"filterLabel": "Filter offers",
|
"filterLabel": "Filter offers",
|
||||||
|
"clubOnlyFilterLabel": "HC Only Filter",
|
||||||
|
"haveOfferFilterLabel": "Has Offer Filter",
|
||||||
"filters": {
|
"filters": {
|
||||||
"all": "All offers",
|
"all": "All offers",
|
||||||
"free": "Free",
|
"free": "Free",
|
||||||
"paid": "Paid",
|
"paid": "Paid",
|
||||||
"bundles": "Bundles",
|
"bundles": "Bundles",
|
||||||
"limited": "Limited editions"
|
"limited": "Limited editions",
|
||||||
|
"club_only_yes": "HC Only",
|
||||||
|
"club_only_no": "Not HC Only",
|
||||||
|
"have_offer_yes": "Has Offer",
|
||||||
|
"have_offer_no": "No Offer"
|
||||||
},
|
},
|
||||||
"shown": "({count} shown)",
|
"shown": "({count} shown)",
|
||||||
"selected": "{count} selected",
|
"selected": "{count} selected",
|
||||||
|
|||||||
Reference in new issue
Block a user