Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 93 additions & 14 deletions src/components/search-filters/SearchFilterCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { useTranslation } from "react-i18next";
import { Bell, BellRing, Copy, ScanSearch, Search, Settings2, Trash2 } from "lucide-react";
import {
Bell,
BellRing,
Copy,
Pause,
Play,
ScanSearch,
Search,
Settings2,
Trash2,
} from "lucide-react";
import { Badge } from "@/components/ui/badge.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Card } from "@/components/ui/card.tsx";
Expand Down Expand Up @@ -61,20 +71,31 @@
? t("searchFilters.notificationsOn")
: t("searchFilters.notificationsOff");

const isActive = filter.state === "ACTIVE";
const isRestrictedByPlan = filter.state === "INACTIVE_BY_RESTRICTED_PLAN";
const stateToggleLabel = isActive ? t("searchFilters.deactivate") : t("searchFilters.activate");

return (
<Card className="flex flex-col p-6 gap-5 shadow-md min-w-0 h-full transition-colors hover:bg-accent">
<div className="flex justify-between">
<div className="flex flex-col gap-2 min-w-0 overflow-hidden">
<div className="flex items-center gap-3 min-w-0">
<H2 className="text-ellipsis line-clamp-1">{filter.name}</H2>
<span
className="text-sm text-muted-foreground shrink-0"
suppressHydrationWarning
>
<H2 className="text-ellipsis line-clamp-1">{filter.name}</H2>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground" suppressHydrationWarning>
{intlFormatDistance(filter.created, new Date(), {
locale: i18n.language,
})}
</span>
{filter.state === "INACTIVE_BY_USER" && (
<Badge variant="secondary" className="text-xs">
{t("searchFilters.stateInactiveByUser")}
</Badge>
)}
{isRestrictedByPlan && (
<Badge variant="destructive" className="text-xs">
{t("searchFilters.stateInactiveByPlan")}
</Badge>
)}
</div>
{search.q && (
<H3 variant="muted" className="line-clamp-1 text-ellipsis">
Expand All @@ -88,6 +109,38 @@
)}
</div>
<div className="flex gap-1 shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-10 text-muted-foreground"
aria-label={stateToggleLabel}
disabled={updateFilter.isPending || isRestrictedByPlan}
onClick={() =>
updateFilter.mutate({
id: filter.id,
patch: { state: isActive ? "INACTIVE_BY_USER" : "ACTIVE" },
})
}
>
<div className="relative size-5">
<Pause
className={`absolute inset-0 size-5 transition-all duration-300 ease-in-out ${isActive ? "opacity-100 scale-100" : "opacity-0 scale-75"}`}
/>
<Play
className={`absolute inset-0 size-5 transition-all duration-300 ease-in-out ${isActive ? "opacity-0 scale-75" : "opacity-100 scale-100"}`}
/>
</div>
</Button>
</TooltipTrigger>
<TooltipContent>
{isRestrictedByPlan
? t("searchFilters.stateInactiveByPlanTooltip")
: stateToggleLabel}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
Expand All @@ -96,7 +149,7 @@
size="icon"
className="size-10 text-muted-foreground"
aria-label={notificationsLabel}
disabled={updateFilter.isPending}
disabled={updateFilter.isPending || !isActive}
onClick={() =>
updateFilter.mutate({
id: filter.id,
Expand Down Expand Up @@ -281,12 +334,38 @@
{t("searchFilters.showResults")}
</Link>
</Button>
<Button size="sm" className="gap-2 flex-1" asChild>
<Link to="/me/search-filter/$filterId" params={{ filterId: filter.id }}>
<ScanSearch className="size-4" />
{t("searchFilters.matchingProducts")}
</Link>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-1">
<Button
size="sm"
className="gap-2 w-full"
disabled={isRestrictedByPlan}
asChild={!isRestrictedByPlan}
>
{!isRestrictedByPlan ? (

Check warning on line 346 in src/components/search-filters/SearchFilterCard.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=aura-historia_webapp&issues=AZ59dSpADJcDccZnBRAy&open=AZ59dSpADJcDccZnBRAy&pullRequest=737
<Link
to="/me/search-filter/$filterId"
params={{ filterId: filter.id }}
>
<ScanSearch className="size-4" />
{t("searchFilters.matchingProducts")}
</Link>
) : (
<>
<ScanSearch className="size-4" />
{t("searchFilters.matchingProducts")}
</>
)}
</Button>
</span>
</TooltipTrigger>
{isRestrictedByPlan && (
<TooltipContent>
{t("searchFilters.matchingProductsInactive")}
</TooltipContent>
)}
</Tooltip>
</div>
</Card>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const mockFilter: UserSearchFilter = {
id: "filter-1",
name: "Barock Möbel",
notifications: false,
state: "ACTIVE",
search: { q: "Tisch" },
created: new Date("2024-01-01"),
updated: new Date("2024-03-01"),
Expand Down
122 changes: 122 additions & 0 deletions src/components/search-filters/__tests__/SearchFilterCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const mockFilter: UserSearchFilter = {
id: "filter-1",
name: "Barock Möbel",
notifications: false,
state: "ACTIVE",
search: { q: "Tisch" },
created: new Date("2024-01-01T00:00:00Z"),
updated: new Date("2024-03-01T00:00:00Z"),
Expand Down Expand Up @@ -157,4 +158,125 @@ describe("SearchFilterCard", () => {
}),
);
});

describe("resource state", () => {
it("renders pause button when filter is ACTIVE", async () => {
await act(() => {
renderWithRouter(<SearchFilterCard {...defaultProps} />);
});
expect(screen.getByRole("button", { name: /Pausieren/i })).toBeInTheDocument();
});

it("renders activate button when filter is INACTIVE_BY_USER", async () => {
const filter = { ...mockFilter, state: "INACTIVE_BY_USER" as const };
await act(() => {
renderWithRouter(<SearchFilterCard {...defaultProps} filter={filter} />);
});
expect(screen.getByRole("button", { name: /Aktivieren/i })).toBeInTheDocument();
});

it("shows Pausiert badge when filter is INACTIVE_BY_USER", async () => {
const filter = { ...mockFilter, state: "INACTIVE_BY_USER" as const };
await act(() => {
renderWithRouter(<SearchFilterCard {...defaultProps} filter={filter} />);
});
expect(screen.getByText("Pausiert")).toBeInTheDocument();
});

it("shows Gesperrt badge when filter is INACTIVE_BY_RESTRICTED_PLAN", async () => {
const filter = { ...mockFilter, state: "INACTIVE_BY_RESTRICTED_PLAN" as const };
await act(() => {
renderWithRouter(<SearchFilterCard {...defaultProps} filter={filter} />);
});
expect(screen.getByText("Gesperrt")).toBeInTheDocument();
});

it("disables state toggle when INACTIVE_BY_RESTRICTED_PLAN", async () => {
const filter = { ...mockFilter, state: "INACTIVE_BY_RESTRICTED_PLAN" as const };
await act(() => {
renderWithRouter(<SearchFilterCard {...defaultProps} filter={filter} />);
});
expect(screen.getByRole("button", { name: /Aktivieren/i })).toBeDisabled();
});

it("calls mutate with INACTIVE_BY_USER when pausing an ACTIVE filter", async () => {
await act(() => {
renderWithRouter(<SearchFilterCard {...defaultProps} />);
});
const pauseBtn = screen.getByRole("button", { name: /Pausieren/i });
await act(() => {
fireEvent.click(pauseBtn);
});
expect(mockUpdateMutate).toHaveBeenCalledWith(
expect.objectContaining({
id: "filter-1",
patch: { state: "INACTIVE_BY_USER" },
}),
);
});

it("calls mutate with ACTIVE when activating an INACTIVE_BY_USER filter", async () => {
const filter = { ...mockFilter, state: "INACTIVE_BY_USER" as const };
await act(() => {
renderWithRouter(<SearchFilterCard {...defaultProps} filter={filter} />);
});
const activateBtn = screen.getByRole("button", { name: /Aktivieren/i });
await act(() => {
fireEvent.click(activateBtn);
});
expect(mockUpdateMutate).toHaveBeenCalledWith(
expect.objectContaining({
id: "filter-1",
patch: { state: "ACTIVE" },
}),
);
});

it("shows no state badge when filter is ACTIVE", async () => {
await act(() => {
renderWithRouter(<SearchFilterCard {...defaultProps} />);
});
expect(screen.queryByText("Pausiert")).not.toBeInTheDocument();
expect(screen.queryByText("Gesperrt")).not.toBeInTheDocument();
});

it("enables matching products button when filter is INACTIVE_BY_USER", async () => {
const filter = { ...mockFilter, state: "INACTIVE_BY_USER" as const };
await act(() => {
renderWithRouter(<SearchFilterCard {...defaultProps} filter={filter} />);
});
expect(
screen.getByRole("link", { name: /Alle Suchtreffer anzeigen/i }),
).toBeInTheDocument();
});

it("disables matching products button when filter is INACTIVE_BY_RESTRICTED_PLAN", async () => {
const filter = { ...mockFilter, state: "INACTIVE_BY_RESTRICTED_PLAN" as const };
await act(() => {
renderWithRouter(<SearchFilterCard {...defaultProps} filter={filter} />);
});
expect(
screen.getByRole("button", { name: /Alle Suchtreffer anzeigen/i }),
).toBeDisabled();
});

it("disables bell button when filter is INACTIVE_BY_USER", async () => {
const filter = { ...mockFilter, state: "INACTIVE_BY_USER" as const };
await act(() => {
renderWithRouter(<SearchFilterCard {...defaultProps} filter={filter} />);
});
expect(
screen.getByRole("button", { name: /Keine Benachrichtigungen/i }),
).toBeDisabled();
});

it("enables matching products button when filter is ACTIVE", async () => {
await act(() => {
renderWithRouter(<SearchFilterCard {...defaultProps} />);
});
expect(
screen.getByRole("link", { name: /Alle Suchtreffer anzeigen/i }),
).toBeInTheDocument();
});
});
});
14 changes: 14 additions & 0 deletions src/data/internal/common/ResourceState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const RESOURCE_STATES = ["ACTIVE", "INACTIVE_BY_USER", "INACTIVE_BY_RESTRICTED_PLAN"] as const;
export type ResourceState = (typeof RESOURCE_STATES)[number];
export type PatchResourceState = "ACTIVE" | "INACTIVE_BY_USER";

export function parseResourceState(state?: string | null): ResourceState {
switch (state) {
case "ACTIVE":
case "INACTIVE_BY_USER":
case "INACTIVE_BY_RESTRICTED_PLAN":
return state;
default:
return "ACTIVE";
}
}
30 changes: 30 additions & 0 deletions src/data/internal/common/__tests__/ResourceState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { parseResourceState } from "../ResourceState.ts";

describe("parseResourceState", () => {
it("returns ACTIVE for 'ACTIVE'", () => {
expect(parseResourceState("ACTIVE")).toBe("ACTIVE");
});

it("returns INACTIVE_BY_USER for 'INACTIVE_BY_USER'", () => {
expect(parseResourceState("INACTIVE_BY_USER")).toBe("INACTIVE_BY_USER");
});

it("returns INACTIVE_BY_RESTRICTED_PLAN for 'INACTIVE_BY_RESTRICTED_PLAN'", () => {
expect(parseResourceState("INACTIVE_BY_RESTRICTED_PLAN")).toBe(
"INACTIVE_BY_RESTRICTED_PLAN",
);
});

it("returns ACTIVE for undefined", () => {
expect(parseResourceState(undefined)).toBe("ACTIVE");

Check warning on line 20 in src/data/internal/common/__tests__/ResourceState.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this redundant "undefined".

See more on https://sonarcloud.io/project/issues?id=aura-historia_webapp&issues=AZ59dSq9DJcDccZnBRAz&open=AZ59dSq9DJcDccZnBRAz&pullRequest=737
});

it("returns ACTIVE for null", () => {
expect(parseResourceState(null)).toBe("ACTIVE");
});

it("returns ACTIVE for unknown string", () => {
expect(parseResourceState("UNKNOWN")).toBe("ACTIVE");
});
});
9 changes: 9 additions & 0 deletions src/data/internal/search-filter/UserSearchFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@ import type { SearchFilterArguments } from "@/data/internal/search/SearchFilterA
import { parseProductState, mapToBackendState } from "@/data/internal/product/ProductState.ts";
import { parseShopType, mapToBackendShopType } from "@/data/internal/shop/ShopType.ts";
import { FILTER_DEFAULTS } from "@/lib/filterDefaults.ts";
import {
parseResourceState,
type ResourceState,
type PatchResourceState,
} from "@/data/internal/common/ResourceState.ts";

export type UserSearchFilter = {
readonly userId: string;
readonly id: string;
readonly name: string;
readonly enhancedSearchDescription?: string;
readonly notifications: boolean;
readonly state: ResourceState;
readonly search: SearchFilterArguments;
readonly created: Date;
readonly updated: Date;
Expand All @@ -30,6 +36,7 @@ export type UserSearchFilterPatchData = {
readonly name?: string;
readonly enhancedSearchDescription?: string | null;
readonly notifications?: boolean;
readonly state?: PatchResourceState;
readonly search?: SearchFilterArguments;
};

Expand Down Expand Up @@ -106,6 +113,7 @@ export function mapToInternalUserSearchFilter(data: UserSearchFilterData): UserS
name: data.name,
enhancedSearchDescription: data.enhancedSearchDescription,
notifications: data.notifications,
state: parseResourceState(data.state),
search: mapProductSearchDataToSearchFilterArguments(data.search),
created: new Date(data.created),
updated: new Date(data.updated),
Expand Down Expand Up @@ -148,6 +156,7 @@ export function mapToBackendPatchUserSearchFilter(
name: data.name,
enhancedSearchDescription: data.enhancedSearchDescription,
notifications: data.notifications,
state: data.state,
search: data.search ? mapSearchFilterArgumentsToProductSearchData(data.search) : undefined,
};
}
Loading
Loading