feat: Implement notifications feed (#8224)

Designs: https://www.figma.com/design/z3aUuOVWUKmdHH0ofmMpEV/Web-app-3.0?node-id=10-50&t=9k6K9k7oJh5tIi09-0

![Screenshot 2024-07-22 at 17 02 10](https://github.com/user-attachments/assets/bc7da39f-8ddb-4f93-bf4e-884f063bc1c6)


CLOSES CC-219
This commit is contained in:
Stevche Radevski
2024-07-25 09:37:01 +02:00
committed by GitHub
parent 0bd46c97b7
commit a26b7cf253
10 changed files with 343 additions and 24 deletions

View File

@@ -0,0 +1 @@
export * from "./infinite-list"

View File

@@ -0,0 +1,133 @@
import { QueryKey, useInfiniteQuery } from "@tanstack/react-query"
import { ReactNode, useEffect, useMemo, useRef } from "react"
import { Skeleton } from "../skeleton"
import { toast } from "@medusajs/ui"
import { Spinner } from "@medusajs/icons"
import { useTranslation } from "react-i18next"
type InfiniteListProps<TResponse, TEntity, TParams> = {
queryKey: QueryKey
queryFn: (params: TParams) => Promise<TResponse>
queryOptions?: { enabled?: boolean }
renderItem: (item: TEntity) => ReactNode
responseKey: keyof TResponse
pageSize?: number
}
export const InfiniteList = <
TResponse extends { count: number; offset: number; limit: number },
TEntity extends { id: string },
TParams extends { offset?: number; limit?: number },
>({
queryKey,
queryFn,
queryOptions,
renderItem,
responseKey,
pageSize = 20,
}: InfiniteListProps<TResponse, TEntity, TParams>) => {
const { t } = useTranslation()
const {
data,
error,
fetchNextPage,
fetchPreviousPage,
hasPreviousPage,
hasNextPage,
isFetching,
isPending,
} = useInfiniteQuery({
queryKey: queryKey,
queryFn: async ({ pageParam = 0 }) => {
return await queryFn({
limit: pageSize,
offset: pageParam,
} as TParams)
},
initialPageParam: 0,
maxPages: 5,
getNextPageParam: (lastPage) => {
const moreItemsExist = lastPage.count > lastPage.offset + lastPage.limit
return moreItemsExist ? lastPage.offset + lastPage.limit : undefined
},
getPreviousPageParam: (firstPage) => {
const moreItemsExist = firstPage.offset !== 0
return moreItemsExist
? Math.max(firstPage.offset - firstPage.limit, 0)
: undefined
},
...queryOptions,
})
const items = useMemo(() => {
return data?.pages.flatMap((p) => p[responseKey] as TEntity[]) ?? []
}, [data, responseKey])
const parentRef = useRef<HTMLDivElement>(null)
const startObserver = useRef<IntersectionObserver>()
const endObserver = useRef<IntersectionObserver>()
useEffect(() => {
if (isPending) {
return
}
// Define the new observers after we stop fetching
if (!isFetching) {
// Define the new observers after paginating
startObserver.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasPreviousPage) {
fetchPreviousPage()
}
})
endObserver.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage()
}
})
// Register the new observers to observe the new first and last children
startObserver.current?.observe(parentRef.current!.firstChild as Element)
endObserver.current?.observe(parentRef.current!.lastChild as Element)
}
// Clear the old observers
return () => {
startObserver.current?.disconnect()
endObserver.current?.disconnect()
}
}, [
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetching,
isPending,
])
useEffect(() => {
if (error) {
toast.error(error.message)
}
}, [error])
if (isPending) {
return <Skeleton className="h-[148px] w-full rounded-lg" />
}
return (
<div ref={parentRef}>
{items &&
items.map((item) => <div key={item.id}>{renderItem(item)}</div>)}
{(isFetching || !hasNextPage) && (
<div className="my-4 flex flex-col items-center justify-center">
{isFetching && <Spinner className="animate-spin" />}
{!hasNextPage && <p className="m-2">{t("general.noMoreData")}</p>}
</div>
)}
</div>
)
}

View File

@@ -1,9 +1,31 @@
import { BellAlert } from "@medusajs/icons"
import { Drawer, Heading, IconButton } from "@medusajs/ui"
import {
ArrowDownTray,
BellAlert,
InformationCircleSolid,
} from "@medusajs/icons"
import { Drawer, Heading, IconButton, Label, Text } from "@medusajs/ui"
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { HttpTypes } from "@medusajs/types"
import { formatDistance } from "date-fns"
import { Divider } from "../../common/divider"
import { InfiniteList } from "../../common/infinite-list"
import { sdk } from "../../../lib/client"
import { notificationQueryKeys } from "../../../hooks/api"
interface NotificationData {
title: string
description?: string
file?: {
filename?: string
url?: string
mimeType?: string
}
}
export const Notifications = () => {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
@@ -31,10 +53,107 @@ export const Notifications = () => {
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Heading>Notifications</Heading>
<Drawer.Title asChild>
<Heading>{t("notifications.domain")}</Heading>
</Drawer.Title>
<Drawer.Description className="sr-only">
{t("notifications.accessibility.description")}
</Drawer.Description>
</Drawer.Header>
<Drawer.Body>Notifications will go here</Drawer.Body>
<Drawer.Body className="overflow-y-auto px-0">
<InfiniteList<
HttpTypes.AdminNotificationListResponse,
HttpTypes.AdminNotification,
HttpTypes.AdminNotificationListParams
>
responseKey="notifications"
queryKey={notificationQueryKeys.all}
queryFn={(params) => sdk.admin.notification.list(params)}
queryOptions={{ enabled: open }}
renderItem={(notification) => {
return (
<Notification
key={notification.id}
notification={notification}
/>
)
}}
/>
</Drawer.Body>
</Drawer.Content>
</Drawer>
)
}
const Notification = ({
notification,
}: {
notification: HttpTypes.AdminNotification
}) => {
const data = notification.data as unknown as NotificationData | undefined
// We need at least the title to render a notification in the feed
if (!data?.title) {
return null
}
return (
<>
<div className="flex items-start justify-center gap-3 border-b p-6">
<div className="text-ui-fg-muted flex size-5 items-center justify-center">
<InformationCircleSolid />
</div>
<div className="flex w-full flex-col gap-y-3">
<div>
<div className="align-center flex flex-row justify-between">
<Text size="small" leading="compact" weight="plus">
{data.title}
</Text>
<Text
as={"span"}
className="text-ui-fg-subtle"
size="small"
leading="compact"
weight="plus"
>
{formatDistance(notification.created_at, new Date(), {
addSuffix: true,
})}
</Text>
</div>
{!!data.description && (
<Text
className="text-ui-fg-subtle whitespace-pre-line"
size="small"
>
{data.description}
</Text>
)}
</div>
<NotificationFile file={data.file} />
</div>
</div>
</>
)
}
const NotificationFile = ({ file }: { file: NotificationData["file"] }) => {
if (!file?.url) {
return null
}
return (
<div className="shadow-elevation-card-rest bg-ui-bg-component transition-fg rounded-md px-3 py-2">
<div className="flex w-full flex-row items-center justify-between gap-2">
<Text size="small" leading="compact">
{file?.filename ?? file.url}
</Text>
<IconButton variant="transparent" asChild>
<a href={file.url} download={file.filename ?? `${Date.now()}`}>
<ArrowDownTray />
</a>
</IconButton>
</div>
</div>
)
}

View File

@@ -1,11 +1,6 @@
import * as Dialog from "@radix-ui/react-dialog"
import {
BellAlert,
SidebarLeft,
TriangleRightMini,
XMark,
} from "@medusajs/icons"
import { SidebarLeft, TriangleRightMini, XMark } from "@medusajs/icons"
import { IconButton, clx } from "@medusajs/ui"
import { PropsWithChildren } from "react"
import { Link, Outlet, UIMatch, useMatches } from "react-router-dom"
@@ -14,6 +9,7 @@ import { useTranslation } from "react-i18next"
import { KeybindProvider } from "../../../providers/keybind-provider"
import { useGlobalShortcuts } from "../../../providers/keybind-provider/hooks"
import { useSidebar } from "../../../providers/sidebar-provider"
import { Notifications } from "../notifications"
export const Shell = ({ children }: PropsWithChildren) => {
const globalShortcuts = useGlobalShortcuts()
@@ -107,18 +103,6 @@ const Breadcrumbs = () => {
)
}
const ToggleNotifications = () => {
return (
<IconButton
size="small"
variant="transparent"
className="text-ui-fg-muted transition-fg hover:text-ui-fg-subtle"
>
<BellAlert />
</IconButton>
)
}
const ToggleSidebar = () => {
const { toggle } = useSidebar()
@@ -152,7 +136,7 @@ const Topbar = () => {
<Breadcrumbs />
</div>
<div className="flex items-center justify-end gap-x-3">
<ToggleNotifications />
<Notifications />
</div>
</div>
)

View File

@@ -29,3 +29,4 @@ export * from "./tax-rates"
export * from "./tax-regions"
export * from "./users"
export * from "./workflow-executions"
export * from "./notification"

View File

@@ -0,0 +1,51 @@
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
import { HttpTypes } from "@medusajs/types"
import { sdk } from "../../lib/client"
import { queryKeysFactory } from "../../lib/query-key-factory"
const NOTIFICATION_QUERY_KEY = "notification" as const
export const notificationQueryKeys = queryKeysFactory(NOTIFICATION_QUERY_KEY)
export const useNotification = (
id: string,
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminNotificationResponse,
Error,
HttpTypes.AdminNotificationResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: notificationQueryKeys.detail(id),
queryFn: async () => sdk.admin.notification.retrieve(id, query),
...options,
})
return { ...data, ...rest }
}
export const useNotifications = (
query?: HttpTypes.AdminNotificationListParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminNotificationListResponse,
Error,
HttpTypes.AdminNotificationListResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => sdk.admin.notification.list(query),
queryKey: notificationQueryKeys.list(query),
...options,
})
return { ...data, ...rest }
}

View File

@@ -50,7 +50,8 @@
"unsavedChangesTitle": "Are you sure you want to leave this form?",
"unsavedChangesDescription": "You have unsaved changes that will be lost if you exit this form.",
"includesTaxTooltip": "Prices in this column are tax inclusive.",
"excludesTaxTooltip": "Prices in this column are tax exclusive."
"excludesTaxTooltip": "Prices in this column are tax exclusive.",
"noMoreData": "No more data"
},
"json": {
"header": "JSON",
@@ -2109,6 +2110,12 @@
"value": "Value"
}
},
"notifications": {
"domain": "Notifications",
"accessibility": {
"description": "notifications about Medusa activities will be listed here."
}
},
"errors": {
"serverError": "Server error - Try again later.",
"invalidCredentials": "Wrong email or password"

View File

@@ -53,3 +53,8 @@ export const sdk = new Medusa({
type: "session",
},
})
// useful when you want to call the BE from the console and try things out quickly
if (typeof window !== "undefined") {
;(window as any).__sdk = sdk
}

View File

@@ -13,6 +13,22 @@ export class Product {
this.client = client
}
async export(
body: HttpTypes.AdminExportProductRequest,
query?: SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExportProductResponse>(
`/admin/products/export`,
{
method: "POST",
headers,
body,
query,
}
)
}
async batch(
body: HttpTypes.AdminBatchProductRequest,
query?: SelectParams,
@@ -28,6 +44,7 @@ export class Product {
}
)
}
async create(
body: HttpTypes.AdminCreateProduct,
query?: SelectParams,

View File

@@ -12,6 +12,7 @@ export type AdminGetNotificationsParamsType = z.infer<
export const AdminGetNotificationsParams = createFindParams({
limit: 50,
offset: 0,
order: "-created_at",
}).merge(
z.object({
q: z.string().optional(),