diff --git a/packages/admin-next/dashboard/src/components/common/infinite-list/index.ts b/packages/admin-next/dashboard/src/components/common/infinite-list/index.ts new file mode 100644 index 0000000000..c60bfb6b12 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/infinite-list/index.ts @@ -0,0 +1 @@ +export * from "./infinite-list" diff --git a/packages/admin-next/dashboard/src/components/common/infinite-list/infinite-list.tsx b/packages/admin-next/dashboard/src/components/common/infinite-list/infinite-list.tsx new file mode 100644 index 0000000000..6b8b3e5240 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/infinite-list/infinite-list.tsx @@ -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 = { + queryKey: QueryKey + queryFn: (params: TParams) => Promise + 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) => { + 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(null) + const startObserver = useRef() + const endObserver = useRef() + + 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 + } + + return ( +
+ {items && + items.map((item) =>
{renderItem(item)}
)} + + {(isFetching || !hasNextPage) && ( +
+ {isFetching && } + {!hasNextPage &&

{t("general.noMoreData")}

} +
+ )} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/layout/notifications/notifications.tsx b/packages/admin-next/dashboard/src/components/layout/notifications/notifications.tsx index 921147ec64..b0bcf25c3b 100644 --- a/packages/admin-next/dashboard/src/components/layout/notifications/notifications.tsx +++ b/packages/admin-next/dashboard/src/components/layout/notifications/notifications.tsx @@ -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 = () => { - Notifications + + {t("notifications.domain")} + + + {t("notifications.accessibility.description")} + - Notifications will go here + + + responseKey="notifications" + queryKey={notificationQueryKeys.all} + queryFn={(params) => sdk.admin.notification.list(params)} + queryOptions={{ enabled: open }} + renderItem={(notification) => { + return ( + + ) + }} + /> + ) } + +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 ( + <> +
+
+ +
+
+
+
+ + {data.title} + + + {formatDistance(notification.created_at, new Date(), { + addSuffix: true, + })} + +
+ {!!data.description && ( + + {data.description} + + )} +
+ +
+
+ + ) +} + +const NotificationFile = ({ file }: { file: NotificationData["file"] }) => { + if (!file?.url) { + return null + } + + return ( +
+
+ + {file?.filename ?? file.url} + + + + + + +
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/layout/shell/shell.tsx b/packages/admin-next/dashboard/src/components/layout/shell/shell.tsx index e360f0960c..626696b3bf 100644 --- a/packages/admin-next/dashboard/src/components/layout/shell/shell.tsx +++ b/packages/admin-next/dashboard/src/components/layout/shell/shell.tsx @@ -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 ( - - - - ) -} - const ToggleSidebar = () => { const { toggle } = useSidebar() @@ -152,7 +136,7 @@ const Topbar = () => {
- +
) diff --git a/packages/admin-next/dashboard/src/hooks/api/index.ts b/packages/admin-next/dashboard/src/hooks/api/index.ts index 782d130c2c..4b15966954 100644 --- a/packages/admin-next/dashboard/src/hooks/api/index.ts +++ b/packages/admin-next/dashboard/src/hooks/api/index.ts @@ -29,3 +29,4 @@ export * from "./tax-rates" export * from "./tax-regions" export * from "./users" export * from "./workflow-executions" +export * from "./notification" diff --git a/packages/admin-next/dashboard/src/hooks/api/notification.tsx b/packages/admin-next/dashboard/src/hooks/api/notification.tsx new file mode 100644 index 0000000000..93e2f84e18 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/api/notification.tsx @@ -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, + 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 } +} diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index 455e1f30e8..929f2e331c 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -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" diff --git a/packages/admin-next/dashboard/src/lib/client/client.ts b/packages/admin-next/dashboard/src/lib/client/client.ts index 67c98dabda..a95c1619e5 100644 --- a/packages/admin-next/dashboard/src/lib/client/client.ts +++ b/packages/admin-next/dashboard/src/lib/client/client.ts @@ -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 +} diff --git a/packages/core/js-sdk/src/admin/product.ts b/packages/core/js-sdk/src/admin/product.ts index e5e7bce00d..899494a895 100644 --- a/packages/core/js-sdk/src/admin/product.ts +++ b/packages/core/js-sdk/src/admin/product.ts @@ -13,6 +13,22 @@ export class Product { this.client = client } + async export( + body: HttpTypes.AdminExportProductRequest, + query?: SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/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, diff --git a/packages/medusa/src/api/admin/notifications/validators.ts b/packages/medusa/src/api/admin/notifications/validators.ts index e360309d22..d0129e9532 100644 --- a/packages/medusa/src/api/admin/notifications/validators.ts +++ b/packages/medusa/src/api/admin/notifications/validators.ts @@ -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(),