feat(catalog): add reviewed bulk edits and complete category duplication
This commit is contained in:
1 parent
0bedc04692
commit
55a47949f1
26 files changed
+2504
-85
No files matched your search
@@ -1,19 +1,27 @@
|
||||
"use client";
|
||||
import { Loader2, Search } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getBulkOfferDestinations } from "@/actions/catalog-bulk";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { requestCatalogNavigation } from "../client/use-catalog-navigation";
|
||||
import { useLatestRequest } from "../client/use-latest-request";
|
||||
import {
|
||||
type CatalogSearchResult,
|
||||
type SearchCatalogKind,
|
||||
validSearchQuery,
|
||||
} from "../domain/search";
|
||||
import { BulkOfferEditor } from "./bulk-offer-editor";
|
||||
export function CatalogSearch({
|
||||
catalogType = "normal",
|
||||
canEdit = false,
|
||||
}: {
|
||||
catalogType?: SearchCatalogKind;
|
||||
canEdit?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.catalog.workspace");
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -22,6 +30,69 @@ export function CatalogSearch({
|
||||
"idle",
|
||||
);
|
||||
const requests = useLatestRequest();
|
||||
const bulk = useTranslations("pages.admin.catalog.bulk");
|
||||
const router = useRouter();
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [pages, setPages] = useState<{ id: number; caption: string }[] | null>(
|
||||
null,
|
||||
);
|
||||
const [destinationsLoading, setDestinationsLoading] = useState(false);
|
||||
const [selectionError, setSelectionError] = useState<string | null>(null);
|
||||
const destinationRequest = useRef(0);
|
||||
const destinationBusy = useRef(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Catalog switches invalidate the selection and destination requests.
|
||||
useEffect(() => {
|
||||
destinationRequest.current += 1;
|
||||
destinationBusy.current = false;
|
||||
setSelected(new Set());
|
||||
setPages(null);
|
||||
setDestinationsLoading(false);
|
||||
setSelectionError(null);
|
||||
return () => {
|
||||
destinationRequest.current += 1;
|
||||
};
|
||||
}, [catalogType]);
|
||||
async function loadDestinations() {
|
||||
if (destinationBusy.current) return;
|
||||
destinationBusy.current = true;
|
||||
const request = ++destinationRequest.current;
|
||||
setDestinationsLoading(true);
|
||||
setSelectionError(null);
|
||||
try {
|
||||
const result = await getBulkOfferDestinations();
|
||||
if (request !== destinationRequest.current) return;
|
||||
if (!result.ok) {
|
||||
setSelectionError(result.error);
|
||||
return;
|
||||
}
|
||||
setPages(result.data.pages);
|
||||
} catch {
|
||||
if (request === destinationRequest.current)
|
||||
setSelectionError(bulk("requestFailed"));
|
||||
} finally {
|
||||
if (request === destinationRequest.current) {
|
||||
destinationBusy.current = false;
|
||||
setDestinationsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
function mayEdit() {
|
||||
if (requestCatalogNavigation()) return true;
|
||||
setSelectionError(bulk("disabledReason"));
|
||||
return false;
|
||||
}
|
||||
function toggleOffer(id: number) {
|
||||
if (!mayEdit()) return;
|
||||
setSelected((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else if (next.size < 500) next.add(id);
|
||||
return next;
|
||||
});
|
||||
if (!pages) void loadDestinations();
|
||||
}
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Successful bulk edits must refresh unchanged search queries.
|
||||
useEffect(() => {
|
||||
const request = requests.start();
|
||||
setResults([]);
|
||||
@@ -52,7 +123,7 @@ export function CatalogSearch({
|
||||
clearTimeout(timer);
|
||||
requests.cancel();
|
||||
};
|
||||
}, [query, catalogType, requests]);
|
||||
}, [query, catalogType, requests, refreshKey]);
|
||||
return (
|
||||
<section
|
||||
aria-label={t("globalSearch")}
|
||||
@@ -72,6 +143,53 @@ export function CatalogSearch({
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
{canEdit && catalogType === "normal" && selected.size > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border p-2">
|
||||
<span className="text-sm">
|
||||
{bulk("selectedCount", { count: selected.size })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelected(new Set())}
|
||||
>
|
||||
{bulk("clearSelection")}
|
||||
</Button>
|
||||
{pages ? (
|
||||
<BulkOfferEditor
|
||||
ids={[...selected]}
|
||||
allPages={pages}
|
||||
beforeEdit={mayEdit}
|
||||
onApplied={() => {
|
||||
setSelected(new Set());
|
||||
setRefreshKey((value) => value + 1);
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={destinationsLoading}
|
||||
onClick={() => void loadDestinations()}
|
||||
>
|
||||
{destinationsLoading && (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
{bulk(
|
||||
destinationsLoading
|
||||
? "loadingDestinations"
|
||||
: "retryDestinations",
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectionError && (
|
||||
<p role="alert" className="text-sm text-[var(--admin-error)]">
|
||||
{selectionError}
|
||||
</p>
|
||||
)}
|
||||
<div aria-live="polite">
|
||||
{status === "loading" && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
@@ -93,10 +211,26 @@ export function CatalogSearch({
|
||||
{results.length > 0 && (
|
||||
<ul className="max-h-80 overflow-y-auto divide-y divide-[var(--admin-border)]">
|
||||
{results.map((result) => (
|
||||
<li key={result.kind + result.id}>
|
||||
<li
|
||||
key={result.kind + result.id}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{canEdit &&
|
||||
catalogType === "normal" &&
|
||||
result.kind === "offer" && (
|
||||
<Checkbox
|
||||
aria-label={bulk("selectOffer", {
|
||||
name: result.caption,
|
||||
id: result.id,
|
||||
})}
|
||||
checked={selected.has(result.id)}
|
||||
disabled={selected.size >= 500 && !selected.has(result.id)}
|
||||
onCheckedChange={() => toggleOffer(result.id)}
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
href={result.href}
|
||||
className="block min-w-0 rounded px-2 py-2 hover:bg-[var(--admin-canvas)] focus-visible:outline-2"
|
||||
className="block min-w-0 flex-1 rounded px-2 py-2 hover:bg-[var(--admin-canvas)] focus-visible:outline-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
||||
Reference in new issue
Block a user