feat: Implement notifications feed (#8224)
Designs: https://www.figma.com/design/z3aUuOVWUKmdHH0ofmMpEV/Web-app-3.0?node-id=10-50&t=9k6K9k7oJh5tIi09-0  CLOSES CC-219
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
export * from "./infinite-list"
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+123
-4
@@ -1,9 +1,31 @@
|
|||||||
import { BellAlert } from "@medusajs/icons"
|
import {
|
||||||
import { Drawer, Heading, IconButton } from "@medusajs/ui"
|
ArrowDownTray,
|
||||||
|
BellAlert,
|
||||||
|
InformationCircleSolid,
|
||||||
|
} from "@medusajs/icons"
|
||||||
|
import { Drawer, Heading, IconButton, Label, Text } from "@medusajs/ui"
|
||||||
import { useEffect, useState } from "react"
|
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 = () => {
|
export const Notifications = () => {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -31,10 +53,107 @@ export const Notifications = () => {
|
|||||||
</Drawer.Trigger>
|
</Drawer.Trigger>
|
||||||
<Drawer.Content>
|
<Drawer.Content>
|
||||||
<Drawer.Header>
|
<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.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.Content>
|
||||||
</Drawer>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import * as Dialog from "@radix-ui/react-dialog"
|
import * as Dialog from "@radix-ui/react-dialog"
|
||||||
|
|
||||||
import {
|
import { SidebarLeft, TriangleRightMini, XMark } from "@medusajs/icons"
|
||||||
BellAlert,
|
|
||||||
SidebarLeft,
|
|
||||||
TriangleRightMini,
|
|
||||||
XMark,
|
|
||||||
} from "@medusajs/icons"
|
|
||||||
import { IconButton, clx } from "@medusajs/ui"
|
import { IconButton, clx } from "@medusajs/ui"
|
||||||
import { PropsWithChildren } from "react"
|
import { PropsWithChildren } from "react"
|
||||||
import { Link, Outlet, UIMatch, useMatches } from "react-router-dom"
|
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 { KeybindProvider } from "../../../providers/keybind-provider"
|
||||||
import { useGlobalShortcuts } from "../../../providers/keybind-provider/hooks"
|
import { useGlobalShortcuts } from "../../../providers/keybind-provider/hooks"
|
||||||
import { useSidebar } from "../../../providers/sidebar-provider"
|
import { useSidebar } from "../../../providers/sidebar-provider"
|
||||||
|
import { Notifications } from "../notifications"
|
||||||
|
|
||||||
export const Shell = ({ children }: PropsWithChildren) => {
|
export const Shell = ({ children }: PropsWithChildren) => {
|
||||||
const globalShortcuts = useGlobalShortcuts()
|
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 ToggleSidebar = () => {
|
||||||
const { toggle } = useSidebar()
|
const { toggle } = useSidebar()
|
||||||
|
|
||||||
@@ -152,7 +136,7 @@ const Topbar = () => {
|
|||||||
<Breadcrumbs />
|
<Breadcrumbs />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-x-3">
|
<div className="flex items-center justify-end gap-x-3">
|
||||||
<ToggleNotifications />
|
<Notifications />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,3 +29,4 @@ export * from "./tax-rates"
|
|||||||
export * from "./tax-regions"
|
export * from "./tax-regions"
|
||||||
export * from "./users"
|
export * from "./users"
|
||||||
export * from "./workflow-executions"
|
export * from "./workflow-executions"
|
||||||
|
export * from "./notification"
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -50,7 +50,8 @@
|
|||||||
"unsavedChangesTitle": "Are you sure you want to leave this form?",
|
"unsavedChangesTitle": "Are you sure you want to leave this form?",
|
||||||
"unsavedChangesDescription": "You have unsaved changes that will be lost if you exit this form.",
|
"unsavedChangesDescription": "You have unsaved changes that will be lost if you exit this form.",
|
||||||
"includesTaxTooltip": "Prices in this column are tax inclusive.",
|
"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": {
|
"json": {
|
||||||
"header": "JSON",
|
"header": "JSON",
|
||||||
@@ -2109,6 +2110,12 @@
|
|||||||
"value": "Value"
|
"value": "Value"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"domain": "Notifications",
|
||||||
|
"accessibility": {
|
||||||
|
"description": "notifications about Medusa activities will be listed here."
|
||||||
|
}
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"serverError": "Server error - Try again later.",
|
"serverError": "Server error - Try again later.",
|
||||||
"invalidCredentials": "Wrong email or password"
|
"invalidCredentials": "Wrong email or password"
|
||||||
|
|||||||
@@ -53,3 +53,8 @@ export const sdk = new Medusa({
|
|||||||
type: "session",
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,22 @@ export class Product {
|
|||||||
this.client = client
|
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(
|
async batch(
|
||||||
body: HttpTypes.AdminBatchProductRequest,
|
body: HttpTypes.AdminBatchProductRequest,
|
||||||
query?: SelectParams,
|
query?: SelectParams,
|
||||||
@@ -28,6 +44,7 @@ export class Product {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
body: HttpTypes.AdminCreateProduct,
|
body: HttpTypes.AdminCreateProduct,
|
||||||
query?: SelectParams,
|
query?: SelectParams,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type AdminGetNotificationsParamsType = z.infer<
|
|||||||
export const AdminGetNotificationsParams = createFindParams({
|
export const AdminGetNotificationsParams = createFindParams({
|
||||||
limit: 50,
|
limit: 50,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
|
order: "-created_at",
|
||||||
}).merge(
|
}).merge(
|
||||||
z.object({
|
z.object({
|
||||||
q: z.string().optional(),
|
q: z.string().optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user