From 5b5150e7da92eeb709034c7d4970387a8cef6c27 Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Sat, 27 Jun 2026 18:28:49 +0330 Subject: [PATCH] Fix notification user filtering --- .../notifications/notification-history.cy.ts | 133 ++++++++++++++++-- src/components/FormElements/select.tsx | 84 ++++++----- .../Layouts/header/notification/index.tsx | 11 +- .../NotificationHistoryListFilters.tsx | 64 ++++++++- .../useNotificationHistoryTablePresenter.ts | 2 +- 5 files changed, 242 insertions(+), 52 deletions(-) diff --git a/cypress/e2e/notifications/notification-history.cy.ts b/cypress/e2e/notifications/notification-history.cy.ts index c54f681..feb7aae 100644 --- a/cypress/e2e/notifications/notification-history.cy.ts +++ b/cypress/e2e/notifications/notification-history.cy.ts @@ -13,6 +13,8 @@ type NotificationRecord = { recipient?: { full_name: string }; }; +const notificationHistoryPattern = /\/api\/proxy\/notifications(?:\?|$)/; + const makeNotification = ( overrides: Partial = {}, ): NotificationRecord => ({ @@ -46,11 +48,18 @@ const historyResponse = ( }, }); -const stubHeaderNotifications = () => { - cy.intercept("GET", "**/api/proxy/notifications/my**", { - statusCode: 200, - body: historyResponse([]), - }).as("myNotifications"); +const stubHeaderNotifications = (times = 1) => { + cy.intercept( + { + method: "GET", + url: "**/api/proxy/notifications/my**", + times, + }, + { + statusCode: 200, + body: historyResponse([]), + }, + ).as("myNotifications"); }; describe("notification history", () => { @@ -62,7 +71,7 @@ describe("notification history", () => { it("renders breadcrumbs, actions, columns, API rows, and details navigation", () => { const notification = makeNotification(); - cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, (req) => { + cy.intercept("GET", notificationHistoryPattern, (req) => { expect(req.query).to.include({ sort_by: "created_at", sort_order: "desc", @@ -100,7 +109,7 @@ describe("notification history", () => { "status", "actions", ].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) @@ -130,7 +139,7 @@ describe("notification history", () => { seen: true, }); - cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, (req) => { + cy.intercept("GET", notificationHistoryPattern, (req) => { const isFiltered = req.query.seen === "true" && req.query.search === "urgent"; req.alias = isFiltered ? "filteredNotifications" : "defaultNotifications"; @@ -165,8 +174,114 @@ describe("notification history", () => { 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", () => { - cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, (req) => { + cy.intercept("GET", notificationHistoryPattern, (req) => { req.reply({ delay: 900, statusCode: 200, diff --git a/src/components/FormElements/select.tsx b/src/components/FormElements/select.tsx index 31e53fb..1ea6ce2 100644 --- a/src/components/FormElements/select.tsx +++ b/src/components/FormElements/select.tsx @@ -153,38 +153,43 @@ type PropsType = { ) & Partial; -export function Select({ - items, - label, - defaultValue, - placeholder, - prefixIcon, - className, - name, - errors, - required, - onChange, - onBlur, - ref, - clearable = false, - onClear, - value: controlledValue, - dataCy, - disabled = false, - searchable = false, - searchPlaceholder = "Search by label or value…", - searchMode = "client", - searchDebounceMs = 300, - searchQueryKey, - searchFn, - onSearch, - onLoadMore, - hasMore = false, - isLoading = false, - isLoadingMore = false, - register: _register, - ...props -}: PropsType) { +export function Select(selectProps: PropsType) { + const hasControlledValue = Object.prototype.hasOwnProperty.call( + selectProps, + "value", + ); + const { + items, + label, + defaultValue, + placeholder, + prefixIcon, + className, + name, + errors, + required, + onChange, + onBlur, + ref, + clearable = false, + onClear, + value: controlledValue, + dataCy, + disabled = false, + searchable = false, + searchPlaceholder = "Search by label or value…", + searchMode = "client", + searchDebounceMs = 300, + searchQueryKey, + searchFn, + onSearch, + onLoadMore, + hasMore = false, + isLoading = false, + isLoadingMore = false, + register: _register, + ...props + } = selectProps; void _register; /** Parent-controlled list: search + pagination handled outside, `items` consumed as-is. */ @@ -223,11 +228,12 @@ export function Select({ const error = errors && errors[name]; useEffect(() => { - if (controlledValue !== undefined) { - setValue(controlledValue); - setIsOptionSelected(!!controlledValue); + if (hasControlledValue) { + const nextValue = controlledValue ?? ""; + setValue(nextValue); + setIsOptionSelected(!!nextValue); } - }, [controlledValue]); + }, [controlledValue, hasControlledValue]); const { isServerMode, @@ -333,7 +339,7 @@ export function Select({ }, [open, showSearch]); const emitChange = (next: string) => { - if (controlledValue === undefined) { + if (!hasControlledValue) { setValue(next); } setIsOptionSelected(!!next); @@ -345,7 +351,7 @@ export function Select({ }; const handleClear = () => { - if (controlledValue === undefined) { + if (!hasControlledValue) { setValue(""); } setIsOptionSelected(false); @@ -498,7 +504,7 @@ export function Select({ data-cy={dataCy} className="sr-only" onChange={(e) => { - if (controlledValue === undefined) { + if (!hasControlledValue) { setValue(e.target.value); } setIsOptionSelected(!!e.target.value); diff --git a/src/components/Layouts/header/notification/index.tsx b/src/components/Layouts/header/notification/index.tsx index 68977b5..eea73d7 100644 --- a/src/components/Layouts/header/notification/index.tsx +++ b/src/components/Layouts/header/notification/index.tsx @@ -5,6 +5,7 @@ import { DropdownContent, DropdownTrigger, } from "@/components/ui/dropdown"; +import { useUser } from "@/contexts"; import Modal from "@/components/ui/modal"; import Status from "@/components/ui/Status"; import { useMarkNotificationAsSeen } from "@/hooks/queries/useNotificationQueries"; @@ -13,7 +14,7 @@ import { TNotificationDetailsResponse } from "@/lib/api/types/NotificationHistor import { cn, sanitizeHtmlWithStyles } from "@/lib/utils"; import { capitalize, unixToDate } from "@/utils/shared"; import Link from "next/link"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { BellIcon } from "./icons"; import NotificationList from "./notification-list"; @@ -28,6 +29,12 @@ export function Notification() { const { mutate } = useMarkNotificationAsSeen(); 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 = ( notification: TNotificationDetailsResponse, @@ -86,7 +93,7 @@ export function Notification() { /> 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" > diff --git a/src/components/Tables/notification-history/NotificationHistoryListFilters.tsx b/src/components/Tables/notification-history/NotificationHistoryListFilters.tsx index 813b4c2..4f27f4a 100644 --- a/src/components/Tables/notification-history/NotificationHistoryListFilters.tsx +++ b/src/components/Tables/notification-history/NotificationHistoryListFilters.tsx @@ -4,6 +4,10 @@ import InputGroup from "@/components/FormElements/InputGroup"; import { Select } from "@/components/FormElements/select"; import { Button } from "@/components/ui-elements/button"; 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 { FieldValues, UseFormHandleSubmit, @@ -34,11 +38,38 @@ function countActive(values: Record): number { ) { n++; } + const userId = values.user_id; + if ( + userId !== undefined && + userId !== null && + userId !== "" && + String(userId).trim() + ) { + n++; + } const searchVal = values.search; if (typeof searchVal === "string" && searchVal.trim()) n++; return n; } +const searchUsers = async (query: string): Promise => { + 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 = ({ filterRegister, isMutating, @@ -48,7 +79,22 @@ const NotificationHistoryListFilters = ({ setValue, onClearAll, }: NotificationHistoryListFiltersProps) => { + const { user } = useUser(); const watched = watch(); + const currentUserOption = useMemo(() => { + 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( () => countActive(watched as Record), [watched], @@ -68,7 +114,7 @@ const NotificationHistoryListFilters = ({ onSubmit={handleFilterSubmit(search)} data-cy="notification-history-filter-form" > -
+
setValue("user_id", undefined)} + dataCy="notification-history-filter-user-select" + /> { }; const clearAllFilters = useCallback(() => { - filterFormReset({ seen: undefined, search: "" }); + filterFormReset({ seen: undefined, search: "", user_id: undefined }); router.push(pathName); }, [pathName, router, filterFormReset]);