Skip to content

Commit a26b7cf

Browse files
authored
1 parent 0bd46c9 commit a26b7cf

File tree

10 files changed

+343
-24
lines changed

10 files changed

+343
-24
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./infinite-list"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { QueryKey, useInfiniteQuery } from "@tanstack/react-query"
2+
import { ReactNode, useEffect, useMemo, useRef } from "react"
3+
import { Skeleton } from "../skeleton"
4+
import { toast } from "@medusajs/ui"
5+
import { Spinner } from "@medusajs/icons"
6+
import { useTranslation } from "react-i18next"
7+
8+
type InfiniteListProps<TResponse, TEntity, TParams> = {
9+
queryKey: QueryKey
10+
queryFn: (params: TParams) => Promise<TResponse>
11+
queryOptions?: { enabled?: boolean }
12+
renderItem: (item: TEntity) => ReactNode
13+
responseKey: keyof TResponse
14+
pageSize?: number
15+
}
16+
17+
export const InfiniteList = <
18+
TResponse extends { count: number; offset: number; limit: number },
19+
TEntity extends { id: string },
20+
TParams extends { offset?: number; limit?: number },
21+
>({
22+
queryKey,
23+
queryFn,
24+
queryOptions,
25+
renderItem,
26+
responseKey,
27+
pageSize = 20,
28+
}: InfiniteListProps<TResponse, TEntity, TParams>) => {
29+
const { t } = useTranslation()
30+
31+
const {
32+
data,
33+
error,
34+
fetchNextPage,
35+
fetchPreviousPage,
36+
hasPreviousPage,
37+
hasNextPage,
38+
isFetching,
39+
isPending,
40+
} = useInfiniteQuery({
41+
queryKey: queryKey,
42+
queryFn: async ({ pageParam = 0 }) => {
43+
return await queryFn({
44+
limit: pageSize,
45+
offset: pageParam,
46+
} as TParams)
47+
},
48+
initialPageParam: 0,
49+
maxPages: 5,
50+
getNextPageParam: (lastPage) => {
51+
const moreItemsExist = lastPage.count > lastPage.offset + lastPage.limit
52+
return moreItemsExist ? lastPage.offset + lastPage.limit : undefined
53+
},
54+
getPreviousPageParam: (firstPage) => {
55+
const moreItemsExist = firstPage.offset !== 0
56+
return moreItemsExist
57+
? Math.max(firstPage.offset - firstPage.limit, 0)
58+
: undefined
59+
},
60+
...queryOptions,
61+
})
62+
63+
const items = useMemo(() => {
64+
return data?.pages.flatMap((p) => p[responseKey] as TEntity[]) ?? []
65+
}, [data, responseKey])
66+
67+
const parentRef = useRef<HTMLDivElement>(null)
68+
const startObserver = useRef<IntersectionObserver>()
69+
const endObserver = useRef<IntersectionObserver>()
70+
71+
useEffect(() => {
72+
if (isPending) {
73+
return
74+
}
75+
76+
// Define the new observers after we stop fetching
77+
if (!isFetching) {
78+
// Define the new observers after paginating
79+
startObserver.current = new IntersectionObserver((entries) => {
80+
if (entries[0].isIntersecting && hasPreviousPage) {
81+
fetchPreviousPage()
82+
}
83+
})
84+
85+
endObserver.current = new IntersectionObserver((entries) => {
86+
if (entries[0].isIntersecting && hasNextPage) {
87+
fetchNextPage()
88+
}
89+
})
90+
91+
// Register the new observers to observe the new first and last children
92+
startObserver.current?.observe(parentRef.current!.firstChild as Element)
93+
endObserver.current?.observe(parentRef.current!.lastChild as Element)
94+
}
95+
96+
// Clear the old observers
97+
return () => {
98+
startObserver.current?.disconnect()
99+
endObserver.current?.disconnect()
100+
}
101+
}, [
102+
fetchNextPage,
103+
fetchPreviousPage,
104+
hasNextPage,
105+
hasPreviousPage,
106+
isFetching,
107+
isPending,
108+
])
109+
110+
useEffect(() => {
111+
if (error) {
112+
toast.error(error.message)
113+
}
114+
}, [error])
115+
116+
if (isPending) {
117+
return <Skeleton className="h-[148px] w-full rounded-lg" />
118+
}
119+
120+
return (
121+
<div ref={parentRef}>
122+
{items &&
123+
items.map((item) => <div key={item.id}>{renderItem(item)}</div>)}
124+
125+
{(isFetching || !hasNextPage) && (
126+
<div className="my-4 flex flex-col items-center justify-center">
127+
{isFetching && <Spinner className="animate-spin" />}
128+
{!hasNextPage && <p className="m-2">{t("general.noMoreData")}</p>}
129+
</div>
130+
)}
131+
</div>
132+
)
133+
}

packages/admin-next/dashboard/src/components/layout/notifications/notifications.tsx

+123-4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,31 @@
1-
import { BellAlert } from "@medusajs/icons"
2-
import { Drawer, Heading, IconButton } from "@medusajs/ui"
1+
import {
2+
ArrowDownTray,
3+
BellAlert,
4+
InformationCircleSolid,
5+
} from "@medusajs/icons"
6+
import { Drawer, Heading, IconButton, Label, Text } from "@medusajs/ui"
37
import { useEffect, useState } from "react"
8+
import { useTranslation } from "react-i18next"
9+
import { HttpTypes } from "@medusajs/types"
10+
import { formatDistance } from "date-fns"
11+
import { Divider } from "../../common/divider"
12+
import { InfiniteList } from "../../common/infinite-list"
13+
import { sdk } from "../../../lib/client"
14+
import { notificationQueryKeys } from "../../../hooks/api"
15+
16+
interface NotificationData {
17+
title: string
18+
description?: string
19+
file?: {
20+
filename?: string
21+
url?: string
22+
mimeType?: string
23+
}
24+
}
425

526
export const Notifications = () => {
627
const [open, setOpen] = useState(false)
28+
const { t } = useTranslation()
729

830
useEffect(() => {
931
const onKeyDown = (e: KeyboardEvent) => {
@@ -31,10 +53,107 @@ export const Notifications = () => {
3153
</Drawer.Trigger>
3254
<Drawer.Content>
3355
<Drawer.Header>
34-
<Heading>Notifications</Heading>
56+
<Drawer.Title asChild>
57+
<Heading>{t("notifications.domain")}</Heading>
58+
</Drawer.Title>
59+
<Drawer.Description className="sr-only">
60+
{t("notifications.accessibility.description")}
61+
</Drawer.Description>
3562
</Drawer.Header>
36-
<Drawer.Body>Notifications will go here</Drawer.Body>
63+
<Drawer.Body className="overflow-y-auto px-0">
64+
<InfiniteList<
65+
HttpTypes.AdminNotificationListResponse,
66+
HttpTypes.AdminNotification,
67+
HttpTypes.AdminNotificationListParams
68+
>
69+
responseKey="notifications"
70+
queryKey={notificationQueryKeys.all}
71+
queryFn={(params) => sdk.admin.notification.list(params)}
72+
queryOptions={{ enabled: open }}
73+
renderItem={(notification) => {
74+
return (
75+
<Notification
76+
key={notification.id}
77+
notification={notification}
78+
/>
79+
)
80+
}}
81+
/>
82+
</Drawer.Body>
3783
</Drawer.Content>
3884
</Drawer>
3985
)
4086
}
87+
88+
const Notification = ({
89+
notification,
90+
}: {
91+
notification: HttpTypes.AdminNotification
92+
}) => {
93+
const data = notification.data as unknown as NotificationData | undefined
94+
95+
// We need at least the title to render a notification in the feed
96+
if (!data?.title) {
97+
return null
98+
}
99+
100+
return (
101+
<>
102+
<div className="flex items-start justify-center gap-3 border-b p-6">
103+
<div className="text-ui-fg-muted flex size-5 items-center justify-center">
104+
<InformationCircleSolid />
105+
</div>
106+
<div className="flex w-full flex-col gap-y-3">
107+
<div>
108+
<div className="align-center flex flex-row justify-between">
109+
<Text size="small" leading="compact" weight="plus">
110+
{data.title}
111+
</Text>
112+
<Text
113+
as={"span"}
114+
className="text-ui-fg-subtle"
115+
size="small"
116+
leading="compact"
117+
weight="plus"
118+
>
119+
{formatDistance(notification.created_at, new Date(), {
120+
addSuffix: true,
121+
})}
122+
</Text>
123+
</div>
124+
{!!data.description && (
125+
<Text
126+
className="text-ui-fg-subtle whitespace-pre-line"
127+
size="small"
128+
>
129+
{data.description}
130+
</Text>
131+
)}
132+
</div>
133+
<NotificationFile file={data.file} />
134+
</div>
135+
</div>
136+
</>
137+
)
138+
}
139+
140+
const NotificationFile = ({ file }: { file: NotificationData["file"] }) => {
141+
if (!file?.url) {
142+
return null
143+
}
144+
145+
return (
146+
<div className="shadow-elevation-card-rest bg-ui-bg-component transition-fg rounded-md px-3 py-2">
147+
<div className="flex w-full flex-row items-center justify-between gap-2">
148+
<Text size="small" leading="compact">
149+
{file?.filename ?? file.url}
150+
</Text>
151+
<IconButton variant="transparent" asChild>
152+
<a href={file.url} download={file.filename ?? `${Date.now()}`}>
153+
<ArrowDownTray />
154+
</a>
155+
</IconButton>
156+
</div>
157+
</div>
158+
)
159+
}

packages/admin-next/dashboard/src/components/layout/shell/shell.tsx

+3-19
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
import * as Dialog from "@radix-ui/react-dialog"
22

3-
import {
4-
BellAlert,
5-
SidebarLeft,
6-
TriangleRightMini,
7-
XMark,
8-
} from "@medusajs/icons"
3+
import { SidebarLeft, TriangleRightMini, XMark } from "@medusajs/icons"
94
import { IconButton, clx } from "@medusajs/ui"
105
import { PropsWithChildren } from "react"
116
import { Link, Outlet, UIMatch, useMatches } from "react-router-dom"
@@ -14,6 +9,7 @@ import { useTranslation } from "react-i18next"
149
import { KeybindProvider } from "../../../providers/keybind-provider"
1510
import { useGlobalShortcuts } from "../../../providers/keybind-provider/hooks"
1611
import { useSidebar } from "../../../providers/sidebar-provider"
12+
import { Notifications } from "../notifications"
1713

1814
export const Shell = ({ children }: PropsWithChildren) => {
1915
const globalShortcuts = useGlobalShortcuts()
@@ -107,18 +103,6 @@ const Breadcrumbs = () => {
107103
)
108104
}
109105

110-
const ToggleNotifications = () => {
111-
return (
112-
<IconButton
113-
size="small"
114-
variant="transparent"
115-
className="text-ui-fg-muted transition-fg hover:text-ui-fg-subtle"
116-
>
117-
<BellAlert />
118-
</IconButton>
119-
)
120-
}
121-
122106
const ToggleSidebar = () => {
123107
const { toggle } = useSidebar()
124108

@@ -152,7 +136,7 @@ const Topbar = () => {
152136
<Breadcrumbs />
153137
</div>
154138
<div className="flex items-center justify-end gap-x-3">
155-
<ToggleNotifications />
139+
<Notifications />
156140
</div>
157141
</div>
158142
)

packages/admin-next/dashboard/src/hooks/api/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ export * from "./tax-rates"
2929
export * from "./tax-regions"
3030
export * from "./users"
3131
export * from "./workflow-executions"
32+
export * from "./notification"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
2+
3+
import { HttpTypes } from "@medusajs/types"
4+
import { sdk } from "../../lib/client"
5+
import { queryKeysFactory } from "../../lib/query-key-factory"
6+
7+
const NOTIFICATION_QUERY_KEY = "notification" as const
8+
export const notificationQueryKeys = queryKeysFactory(NOTIFICATION_QUERY_KEY)
9+
10+
export const useNotification = (
11+
id: string,
12+
query?: Record<string, any>,
13+
options?: Omit<
14+
UseQueryOptions<
15+
HttpTypes.AdminNotificationResponse,
16+
Error,
17+
HttpTypes.AdminNotificationResponse,
18+
QueryKey
19+
>,
20+
"queryFn" | "queryKey"
21+
>
22+
) => {
23+
const { data, ...rest } = useQuery({
24+
queryKey: notificationQueryKeys.detail(id),
25+
queryFn: async () => sdk.admin.notification.retrieve(id, query),
26+
...options,
27+
})
28+
29+
return { ...data, ...rest }
30+
}
31+
32+
export const useNotifications = (
33+
query?: HttpTypes.AdminNotificationListParams,
34+
options?: Omit<
35+
UseQueryOptions<
36+
HttpTypes.AdminNotificationListResponse,
37+
Error,
38+
HttpTypes.AdminNotificationListResponse,
39+
QueryKey
40+
>,
41+
"queryFn" | "queryKey"
42+
>
43+
) => {
44+
const { data, ...rest } = useQuery({
45+
queryFn: () => sdk.admin.notification.list(query),
46+
queryKey: notificationQueryKeys.list(query),
47+
...options,
48+
})
49+
50+
return { ...data, ...rest }
51+
}

0 commit comments

Comments
 (0)