This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user