Fix notification user filtering
continuous-integration/drone/push Build is passing

This commit is contained in:
AmirReza Jamali
2026-06-27 18:28:49 +03:30
parent 561931876f
commit 5b5150e7da
5 changed files with 242 additions and 52 deletions
@@ -13,6 +13,8 @@ type NotificationRecord = {
recipient?: { full_name: string }; recipient?: { full_name: string };
}; };
const notificationHistoryPattern = /\/api\/proxy\/notifications(?:\?|$)/;
const makeNotification = ( const makeNotification = (
overrides: Partial<NotificationRecord> = {}, overrides: Partial<NotificationRecord> = {},
): NotificationRecord => ({ ): NotificationRecord => ({
@@ -46,11 +48,18 @@ const historyResponse = (
}, },
}); });
const stubHeaderNotifications = () => { const stubHeaderNotifications = (times = 1) => {
cy.intercept("GET", "**/api/proxy/notifications/my**", { cy.intercept(
statusCode: 200, {
body: historyResponse([]), method: "GET",
}).as("myNotifications"); url: "**/api/proxy/notifications/my**",
times,
},
{
statusCode: 200,
body: historyResponse([]),
},
).as("myNotifications");
}; };
describe("notification history", () => { describe("notification history", () => {
@@ -62,7 +71,7 @@ describe("notification history", () => {
it("renders breadcrumbs, actions, columns, API rows, and details navigation", () => { it("renders breadcrumbs, actions, columns, API rows, and details navigation", () => {
const notification = makeNotification(); const notification = makeNotification();
cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, (req) => { cy.intercept("GET", notificationHistoryPattern, (req) => {
expect(req.query).to.include({ expect(req.query).to.include({
sort_by: "created_at", sort_by: "created_at",
sort_order: "desc", sort_order: "desc",
@@ -100,7 +109,7 @@ describe("notification history", () => {
"status", "status",
"actions", "actions",
].forEach((column) => { ].forEach((column) => {
cy.contains("th", new RegExp(`^${column}$`, "i")).should("be.visible"); cy.contains("th", new RegExp(`^${column}$`, "i")).should("exist");
}); });
cy.contains("tr", notification.title) cy.contains("tr", notification.title)
@@ -130,7 +139,7 @@ describe("notification history", () => {
seen: true, seen: true,
}); });
cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, (req) => { cy.intercept("GET", notificationHistoryPattern, (req) => {
const isFiltered = const isFiltered =
req.query.seen === "true" && req.query.search === "urgent"; req.query.seen === "true" && req.query.search === "urgent";
req.alias = isFiltered ? "filteredNotifications" : "defaultNotifications"; req.alias = isFiltered ? "filteredNotifications" : "defaultNotifications";
@@ -165,8 +174,114 @@ describe("notification history", () => {
cy.contains(defaultNotification.title).should("be.visible"); cy.contains(defaultNotification.title).should("be.visible");
}); });
it("filters notification history by user when a user is selected", () => {
const currentUserNotification = makeNotification({
id: "notification-current-user",
title: "Current user notification",
});
const selectedUserNotification = makeNotification({
id: "notification-selected-user",
title: "Selected user notification",
recipient: { full_name: "Notification Admin" },
});
cy.intercept("GET", notificationHistoryPattern, {
statusCode: 200,
body: historyResponse([currentUserNotification]),
}).as("currentUserNotifications");
cy.intercept("GET", "**/api/proxy/users**", {
statusCode: 200,
body: {
success: true,
message: "ok",
data: {
users: [
{
id: "64f9b24d2f1b2f0012345678",
full_name: "Notification Admin",
username: "notification-admin",
email: "notification-admin@example.com",
role: "admin",
status: "active",
},
],
},
meta: {
offset: 0,
limit: 20,
total: 1,
page: 1,
pages: 1,
has_more: false,
},
},
}).as("users");
cy.intercept("GET", notificationHistoryPattern, (req) => {
expect(req.query.user_id).to.eq("64f9b24d2f1b2f0012345678");
req.reply({
statusCode: 200,
body: historyResponse([selectedUserNotification]),
});
}).as("selectedUserNotifications");
cy.visit("/notification-history");
cy.wait("@currentUserNotifications");
cy.contains(currentUserNotification.title).should("be.visible");
cy.getByCy("notification-history-filter-panel-toggle").click();
cy.getByCy("notification-history-filter-user-select")
.siblings("button")
.click();
cy.getByCy("notification-history-filter-user-select-search").type(
"Notification Admin",
);
cy.wait("@users");
cy.getByCy("notification-history-filter-user-select-option")
.filter('[data-value="64f9b24d2f1b2f0012345678"]')
.click();
cy.getByCy("notification-history-filter-submit-button").click();
cy.wait("@selectedUserNotifications");
cy.location("search").should(
"include",
"user_id=64f9b24d2f1b2f0012345678",
);
cy.contains(selectedUserNotification.title).should("be.visible");
cy.contains(currentUserNotification.title).should("not.exist");
});
it("shows the current user filter when notification history is opened for the current user", () => {
const notification = makeNotification({
id: "notification-current-user-filter",
title: "Current user filtered notification",
});
cy.intercept("GET", notificationHistoryPattern, (req) => {
expect(req.query.user_id).to.eq("507f1f77bcf86cd799439011");
req.reply({
statusCode: 200,
body: historyResponse([notification]),
});
}).as("currentUserFilteredNotifications");
cy.visit("/notification-history?user_id=507f1f77bcf86cd799439011");
cy.wait("@currentUserFilteredNotifications");
cy.getByCy("notification-history-filter-panel-toggle").should(
"contain.text",
"1 active",
);
cy.getByCy("notification-history-filter-panel-toggle").click();
cy.getByCy("notification-history-filter-user-select")
.siblings("button")
.should("contain.text", "E2E Admin One");
cy.contains(notification.title).should("be.visible");
});
it("shows loading and empty states, and opens the create page", () => { it("shows loading and empty states, and opens the create page", () => {
cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, (req) => { cy.intercept("GET", notificationHistoryPattern, (req) => {
req.reply({ req.reply({
delay: 900, delay: 900,
statusCode: 200, statusCode: 200,
+45 -39
View File
@@ -153,38 +153,43 @@ type PropsType<T extends FieldValues> = {
) & ) &
Partial<UseFormRegisterReturn>; Partial<UseFormRegisterReturn>;
export function Select<T extends FieldValues>({ export function Select<T extends FieldValues>(selectProps: PropsType<T>) {
items, const hasControlledValue = Object.prototype.hasOwnProperty.call(
label, selectProps,
defaultValue, "value",
placeholder, );
prefixIcon, const {
className, items,
name, label,
errors, defaultValue,
required, placeholder,
onChange, prefixIcon,
onBlur, className,
ref, name,
clearable = false, errors,
onClear, required,
value: controlledValue, onChange,
dataCy, onBlur,
disabled = false, ref,
searchable = false, clearable = false,
searchPlaceholder = "Search by label or value…", onClear,
searchMode = "client", value: controlledValue,
searchDebounceMs = 300, dataCy,
searchQueryKey, disabled = false,
searchFn, searchable = false,
onSearch, searchPlaceholder = "Search by label or value…",
onLoadMore, searchMode = "client",
hasMore = false, searchDebounceMs = 300,
isLoading = false, searchQueryKey,
isLoadingMore = false, searchFn,
register: _register, onSearch,
...props onLoadMore,
}: PropsType<T>) { hasMore = false,
isLoading = false,
isLoadingMore = false,
register: _register,
...props
} = selectProps;
void _register; void _register;
/** Parent-controlled list: search + pagination handled outside, `items` consumed as-is. */ /** Parent-controlled list: search + pagination handled outside, `items` consumed as-is. */
@@ -223,11 +228,12 @@ export function Select<T extends FieldValues>({
const error = errors && errors[name]; const error = errors && errors[name];
useEffect(() => { useEffect(() => {
if (controlledValue !== undefined) { if (hasControlledValue) {
setValue(controlledValue); const nextValue = controlledValue ?? "";
setIsOptionSelected(!!controlledValue); setValue(nextValue);
setIsOptionSelected(!!nextValue);
} }
}, [controlledValue]); }, [controlledValue, hasControlledValue]);
const { const {
isServerMode, isServerMode,
@@ -333,7 +339,7 @@ export function Select<T extends FieldValues>({
}, [open, showSearch]); }, [open, showSearch]);
const emitChange = (next: string) => { const emitChange = (next: string) => {
if (controlledValue === undefined) { if (!hasControlledValue) {
setValue(next); setValue(next);
} }
setIsOptionSelected(!!next); setIsOptionSelected(!!next);
@@ -345,7 +351,7 @@ export function Select<T extends FieldValues>({
}; };
const handleClear = () => { const handleClear = () => {
if (controlledValue === undefined) { if (!hasControlledValue) {
setValue(""); setValue("");
} }
setIsOptionSelected(false); setIsOptionSelected(false);
@@ -498,7 +504,7 @@ export function Select<T extends FieldValues>({
data-cy={dataCy} data-cy={dataCy}
className="sr-only" className="sr-only"
onChange={(e) => { onChange={(e) => {
if (controlledValue === undefined) { if (!hasControlledValue) {
setValue(e.target.value); setValue(e.target.value);
} }
setIsOptionSelected(!!e.target.value); setIsOptionSelected(!!e.target.value);
@@ -5,6 +5,7 @@ import {
DropdownContent, DropdownContent,
DropdownTrigger, DropdownTrigger,
} from "@/components/ui/dropdown"; } from "@/components/ui/dropdown";
import { useUser } from "@/contexts";
import Modal from "@/components/ui/modal"; import Modal from "@/components/ui/modal";
import Status from "@/components/ui/Status"; import Status from "@/components/ui/Status";
import { useMarkNotificationAsSeen } from "@/hooks/queries/useNotificationQueries"; import { useMarkNotificationAsSeen } from "@/hooks/queries/useNotificationQueries";
@@ -13,7 +14,7 @@ import { TNotificationDetailsResponse } from "@/lib/api/types/NotificationHistor
import { cn, sanitizeHtmlWithStyles } from "@/lib/utils"; import { cn, sanitizeHtmlWithStyles } from "@/lib/utils";
import { capitalize, unixToDate } from "@/utils/shared"; import { capitalize, unixToDate } from "@/utils/shared";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useMemo, useState } from "react";
import { BellIcon } from "./icons"; import { BellIcon } from "./icons";
import NotificationList from "./notification-list"; import NotificationList from "./notification-list";
@@ -28,6 +29,12 @@ export function Notification() {
const { mutate } = useMarkNotificationAsSeen(); const { mutate } = useMarkNotificationAsSeen();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { user } = useUser();
const notificationHistoryHref = useMemo(() => {
if (!user?.id) return "/notification-history";
return `/notification-history?user_id=${encodeURIComponent(user.id)}`;
}, [user?.id]);
const handleNotificationClick = ( const handleNotificationClick = (
notification: TNotificationDetailsResponse, notification: TNotificationDetailsResponse,
@@ -86,7 +93,7 @@ export function Notification() {
/> />
<Link <Link
href="/notification-history" href={notificationHistoryHref}
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="block rounded-xl border border-primary/40 bg-white/70 p-2 text-center text-sm font-semibold tracking-wide text-primary outline-none transition-colors hover:bg-primary/10 focus:bg-primary/10 focus:text-primary focus-visible:border-primary dark:border-dark-3 dark:bg-dark-2/70 dark:text-dark-6 dark:hover:border-dark-5 dark:hover:bg-dark-3 dark:hover:text-dark-7 dark:focus-visible:border-dark-5 dark:focus-visible:bg-dark-3 dark:focus-visible:text-dark-7" className="block rounded-xl border border-primary/40 bg-white/70 p-2 text-center text-sm font-semibold tracking-wide text-primary outline-none transition-colors hover:bg-primary/10 focus:bg-primary/10 focus:text-primary focus-visible:border-primary dark:border-dark-3 dark:bg-dark-2/70 dark:text-dark-6 dark:hover:border-dark-5 dark:hover:bg-dark-3 dark:hover:text-dark-7 dark:focus-visible:border-dark-5 dark:focus-visible:bg-dark-3 dark:focus-visible:text-dark-7"
> >
@@ -4,6 +4,10 @@ import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select"; import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button"; import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel"; import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { useUser } from "@/contexts";
import { apiDefaultParams } from "@/constants/Api_Params";
import { userService } from "@/lib/api/services/user-services";
import { ILabelValue } from "@/types/shared";
import { import {
FieldValues, FieldValues,
UseFormHandleSubmit, UseFormHandleSubmit,
@@ -34,11 +38,38 @@ function countActive(values: Record<string, unknown>): number {
) { ) {
n++; n++;
} }
const userId = values.user_id;
if (
userId !== undefined &&
userId !== null &&
userId !== "" &&
String(userId).trim()
) {
n++;
}
const searchVal = values.search; const searchVal = values.search;
if (typeof searchVal === "string" && searchVal.trim()) n++; if (typeof searchVal === "string" && searchVal.trim()) n++;
return n; return n;
} }
const searchUsers = async (query: string): Promise<ILabelValue[]> => {
const response = await userService.adminsList({
...apiDefaultParams,
search: query,
offset: 0,
limit: 20,
});
return (response.data?.users ?? []).map((user) => ({
value: user.id,
label:
user.full_name ||
user.username ||
user.email ||
user.id,
}));
};
const NotificationHistoryListFilters = ({ const NotificationHistoryListFilters = ({
filterRegister, filterRegister,
isMutating, isMutating,
@@ -48,7 +79,22 @@ const NotificationHistoryListFilters = ({
setValue, setValue,
onClearAll, onClearAll,
}: NotificationHistoryListFiltersProps) => { }: NotificationHistoryListFiltersProps) => {
const { user } = useUser();
const watched = watch(); const watched = watch();
const currentUserOption = useMemo<ILabelValue[]>(() => {
if (!user?.id) return [];
return [
{
value: user.id,
label:
user.full_name ||
user.username ||
user.email ||
user.id,
},
];
}, [user?.email, user?.full_name, user?.id, user?.username]);
const activeCount = useMemo( const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>), () => countActive(watched as Record<string, unknown>),
[watched], [watched],
@@ -68,7 +114,7 @@ const NotificationHistoryListFilters = ({
onSubmit={handleFilterSubmit(search)} onSubmit={handleFilterSubmit(search)}
data-cy="notification-history-filter-form" data-cy="notification-history-filter-form"
> >
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-3">
<Select <Select
{...filterRegister("seen")} {...filterRegister("seen")}
items={[ items={[
@@ -82,6 +128,22 @@ const NotificationHistoryListFilters = ({
value={watch("seen")} value={watch("seen")}
onClear={() => setValue("seen", undefined)} onClear={() => setValue("seen", undefined)}
/> />
<Select
{...filterRegister("user_id")}
items={currentUserOption}
label="User"
name="user_id"
placeholder="Current user"
clearable
searchable
searchMode="server"
searchPlaceholder="Search users"
searchQueryKey={["notification-history-user-filter"]}
searchFn={searchUsers}
value={watch("user_id")}
onClear={() => setValue("user_id", undefined)}
dataCy="notification-history-filter-user-select"
/>
<InputGroup <InputGroup
{...filterRegister("search")} {...filterRegister("search")}
name="search" name="search"
@@ -83,7 +83,7 @@ const useNotificationHistoryTablePresenter = () => {
}; };
const clearAllFilters = useCallback(() => { const clearAllFilters = useCallback(() => {
filterFormReset({ seen: undefined, search: "" }); filterFormReset({ seen: undefined, search: "", user_id: undefined });
router.push(pathName); router.push(pathName);
}, [pathName, router, filterFormReset]); }, [pathName, router, filterFormReset]);