From c3260a2c5add86ada641db91e834d9f9de62ed14 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Tue, 16 Apr 2024 15:14:58 +0200 Subject: [PATCH] feat(ui, dashboard): Toast rework (#7076) **What** - Re-works how toasts work in Medusa UI. API is now built on top of `sonner` instead of `@radix-ui/react-toast`. This is a breaking change, and we will need to update the documentation once this has been merged and released (cc: @shahednasser). - Adds an example of usage in the products list table in the new admin dashboard. As part of the coming weeks cleanup we will add toasts everywhere that they are currently missing. CLOSES CORE-1977 --- .changeset/little-buttons-sort.md | 13 + .../public/locales/en-US/translation.json | 12 + .../product-list-table/product-list-table.tsx | 30 +- packages/design-system/ui/package.json | 8 +- .../ui/src/components/toast/toast.stories.tsx | 17 +- .../ui/src/components/toast/toast.tsx | 263 ++++++------------ .../components/toaster/toaster.stories.tsx | 186 +++++++++++++ .../ui/src/components/toaster/toaster.tsx | 41 ++- .../ui/src/hooks/use-toast/index.ts | 1 - .../src/hooks/use-toast/use-toast.stories.tsx | 85 ------ .../ui/src/hooks/use-toast/use-toast.tsx | 188 ------------- packages/design-system/ui/src/index.ts | 2 +- packages/design-system/ui/src/types.ts | 31 +++ packages/design-system/ui/src/utils/toast.tsx | 214 ++++++++++++++ yarn.lock | 155 ++++++++--- 15 files changed, 730 insertions(+), 516 deletions(-) create mode 100644 .changeset/little-buttons-sort.md create mode 100644 packages/design-system/ui/src/components/toaster/toaster.stories.tsx delete mode 100644 packages/design-system/ui/src/hooks/use-toast/index.ts delete mode 100644 packages/design-system/ui/src/hooks/use-toast/use-toast.stories.tsx delete mode 100644 packages/design-system/ui/src/hooks/use-toast/use-toast.tsx create mode 100644 packages/design-system/ui/src/utils/toast.tsx diff --git a/.changeset/little-buttons-sort.md b/.changeset/little-buttons-sort.md new file mode 100644 index 0000000000..b4a1ab5ae8 --- /dev/null +++ b/.changeset/little-buttons-sort.md @@ -0,0 +1,13 @@ +--- +"@medusajs/ui": major +--- + +feat(ui): Re-work `` and `` based on `sonner`. + +This update contains breaking changes to how toasts work in `@medusajs/ui`. This update has been made to provide a better user experience and to make it easier to use toasts in your Medusa application. + +### BREAKING CHANGES + +The `useToast` hook has been removed. Users should instead use the `toast` function that is exported from the `@medusajs/ui` package. This function can be used to show toasts in your application. For more information on how to use the `toast` function, please refer to the documentation. + +The `Toaster` component is still available but the options for the component have changed. The default position has been changed to `bottom-right`. For more information on the `Toaster` component, please refer to the documentation. diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json index 9f7cf35383..3cf6cd8e36 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -66,6 +66,7 @@ "cancel": "Cancel", "complete": "Complete", "back": "Back", + "close": "Close", "continue": "Continue", "confirm": "Confirm", "edit": "Edit", @@ -283,6 +284,17 @@ "create": { "header": "Create Option" } + }, + "toasts": { + "delete": { + "success": { + "header": "Product deleted", + "description": "{{title}} was successfully deleted." + }, + "error": { + "header": "Failed to delete product" + } + } } }, "collections": { diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-list/components/product-list-table/product-list-table.tsx b/packages/admin-next/dashboard/src/v2-routes/products/product-list/components/product-list-table/product-list-table.tsx index 1f80bba000..961ca67d65 100644 --- a/packages/admin-next/dashboard/src/v2-routes/products/product-list/components/product-list-table/product-list-table.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-list/components/product-list-table/product-list-table.tsx @@ -1,6 +1,7 @@ import { PencilSquare, Trash } from "@medusajs/icons" import type { Product } from "@medusajs/medusa" -import { Button, Container, Heading, usePrompt } from "@medusajs/ui" +import { Button, Container, Heading, toast, usePrompt } from "@medusajs/ui" +import { keepPreviousData } from "@tanstack/react-query" import { createColumnHelper } from "@tanstack/react-table" import { useMemo } from "react" import { useTranslation } from "react-i18next" @@ -8,15 +9,15 @@ import { Link, Outlet, useLoaderData } from "react-router-dom" import { ActionMenu } from "../../../../../components/common/action-menu" import { DataTable } from "../../../../../components/table/data-table" +import { + useDeleteProduct, + useProducts, +} from "../../../../../hooks/api/products" import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns" import { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters" import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query" import { useDataTable } from "../../../../../hooks/use-data-table" import { productsLoader } from "../../loader" -import { - useDeleteProduct, - useProducts, -} from "../../../../../hooks/api/products" const PAGE_SIZE = 20 @@ -34,7 +35,7 @@ export const ProductListTable = () => { }, { initialData, - keepPreviousData: true, + placeholderData: keepPreviousData, } ) @@ -99,7 +100,22 @@ const ProductActions = ({ product }: { product: Product }) => { return } - await mutateAsync() + await mutateAsync(undefined, { + onSuccess: () => { + toast.success(t("products.toasts.delete.success.header"), { + description: t("products.toasts.delete.success.description", { + title: product.title, + }), + dismissLabel: t("actions.close"), + }) + }, + onError: (e) => { + toast.error(t("products.toasts.delete.error.header"), { + description: e.message, + dismissLabel: t("actions.close"), + }) + }, + }) } return ( diff --git a/packages/design-system/ui/package.json b/packages/design-system/ui/package.json index a6d161151d..73d0bf335a 100644 --- a/packages/design-system/ui/package.json +++ b/packages/design-system/ui/package.json @@ -60,19 +60,19 @@ "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.0.1", "@vitest/coverage-v8": "^0.32.2", - "autoprefixer": "^10.4.17", + "autoprefixer": "^10.4.19", "chromatic": "^6.20.0", "eslint": "^7.32.0", "eslint-plugin-storybook": "^0.6.12", "jsdom": "^22.1.0", - "postcss": "^8.4.33", + "postcss": "^8.4.38", "prop-types": "^15.8.1", "react": "^18.2.0", "react-dom": "^18.2.0", "resize-observer-polyfill": "^1.5.1", "rimraf": "^5.0.1", "storybook": "^7.0.23", - "tailwindcss": "^3.4.1", + "tailwindcss": "^3.4.3", "tsc-alias": "^1.8.7", "typescript": "^5.1.6", "vite": "^4.3.9", @@ -96,7 +96,6 @@ "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", - "@radix-ui/react-toast": "^1.1.4", "@radix-ui/react-tooltip": "^1.0.6", "@react-aria/datepicker": "^3.5.0", "@react-stately/datepicker": "^3.5.0", @@ -108,6 +107,7 @@ "prismjs": "^1.29.0", "react-currency-input-field": "^3.6.11", "react-day-picker": "^8.8.0", + "sonner": "^1.4.41", "tailwind-merge": "^2.2.1" }, "peerDependencies": { diff --git a/packages/design-system/ui/src/components/toast/toast.stories.tsx b/packages/design-system/ui/src/components/toast/toast.stories.tsx index 563390c884..1980b1fe51 100644 --- a/packages/design-system/ui/src/components/toast/toast.stories.tsx +++ b/packages/design-system/ui/src/components/toast/toast.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react" import * as React from "react" -import { Toast, ToastProvider, ToastViewport } from "./toast" +import { Toast } from "./toast" const meta: Meta = { title: "Components/Toast", @@ -10,13 +10,7 @@ const meta: Meta = { layout: "centered", }, render: (args) => { - return ( - - - - - - ) + return }, } @@ -28,7 +22,7 @@ export const Information: Story = { args: { title: "Label", description: "The quick brown fox jumps over a lazy dog.", - open: true, + variant: "info", }, } @@ -37,7 +31,6 @@ export const Warning: Story = { title: "Label", description: "The quick brown fox jumps over a lazy dog.", variant: "warning", - open: true, }, } @@ -46,7 +39,6 @@ export const Error: Story = { title: "Label", description: "The quick brown fox jumps over a lazy dog.", variant: "error", - open: true, }, } @@ -55,7 +47,6 @@ export const Success: Story = { title: "Label", description: "The quick brown fox jumps over a lazy dog.", variant: "success", - open: true, }, } @@ -64,7 +55,6 @@ export const Loading: Story = { title: "Label", description: "The quick brown fox jumps over a lazy dog.", variant: "loading", - open: true, }, } @@ -73,7 +63,6 @@ export const WithAction: Story = { title: "Scheduled meeting", description: "The meeting has been added to your calendar.", variant: "success", - open: true, action: { altText: "Undo adding meeting to calendar", onClick: () => {}, diff --git a/packages/design-system/ui/src/components/toast/toast.tsx b/packages/design-system/ui/src/components/toast/toast.tsx index b4dbd21da4..dbf332e65a 100644 --- a/packages/design-system/ui/src/components/toast/toast.tsx +++ b/packages/design-system/ui/src/components/toast/toast.tsx @@ -1,189 +1,110 @@ -"use client" - -import { - CheckCircleSolid, - ExclamationCircleSolid, - InformationCircleSolid, - Spinner, - XCircleSolid, -} from "@medusajs/icons" -import * as Primitives from "@radix-ui/react-toast" +import { CheckCircleSolid, ExclamationCircleSolid, InformationCircleSolid, Spinner, XCircleSolid } from "@medusajs/icons" import * as React from "react" +import type { ToastAction, ToastVariant } from "@/types" import { clx } from "@/utils/clx" -const ToastProvider = Primitives.Provider -ToastProvider.displayName = "ToastProvider" - -const ToastViewport = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastViewport.displayName = "Toast.Viewport" - -interface ActionProps { - label: string - altText: string - onClick: () => void | Promise -} - -interface ToastProps - extends React.ComponentPropsWithoutRef { - variant?: "info" | "success" | "warning" | "error" | "loading" - title?: string +interface ToastComponentProps { + id: string | number + variant?: ToastVariant + title: string description?: string - action?: ActionProps - disableDismiss?: boolean + action?: ToastAction + onDismiss?: (id?: string | number) => void + dismissLabel?: string } -/** - * This component is based on the [Radix UI Toast](https://www.radix-ui.com/primitives/docs/components/toast) primitives. - * - * @excludeExternal - */ -const Toast = React.forwardRef< - React.ElementRef, - ToastProps ->( - ( - { - className, - /** - * The toast's style. - */ - variant, - /** - * The toast's title. - */ - title, - /** - * The toast's content. - */ - description, - /** - * The actions to show in the toast as buttons. - */ - action, - /** - * Whether to hide the Close button. - */ - disableDismiss = false, - ...props - }: ToastProps, - ref - ) => { - let Icon = undefined +export const Toast = ({ + id, + variant = "info", + title, + description, + action, + onDismiss, + dismissLabel = "Close", +}: ToastComponentProps) => { + const hasActionables = !!action || onDismiss + let icon = undefined - switch (variant) { - case "success": - Icon = - break - case "warning": - Icon = - break - case "error": - Icon = - break - case "loading": - Icon = - break - default: - Icon = - break - } + switch (variant) { + case "success": + icon = + break + case "warning": + icon = + break + case "error": + icon = + break + case "loading": + icon = + break + case "info": + icon = + break + default: + break + } - if (action && !action.altText) { - // eslint-disable-next-line turbo/no-undeclared-env-vars - if (process.env.NODE_ENV === "development") { - console.warn( - "Omitting `altText` from the action is not recommended. Please provide a description for screen readers." - ) - } - } - - return ( - +
-
- {Icon} -
- {title && ( - - {title} - - )} - {description && ( - - {description} - - )} -
-
+ {!!icon && ( + + {icon} + + )}
- {action && ( - <> - { - e.preventDefault() - action.onClick() - }} - type="button" - > - {action.label} - -
- - )} - {!disableDismiss && ( - + {title} + + + {description} + +
+
+ {hasActionables && ( +
+ {!!action && ( + + )} + {onDismiss && ( + )}
- - ) - } -) -Toast.displayName = "Toast" - -type ToastActionElement = ActionProps - -export { - Toast, - ToastProvider, - ToastViewport, - type ToastActionElement, - type ToastProps, -} + )} +
+ ) +} \ No newline at end of file diff --git a/packages/design-system/ui/src/components/toaster/toaster.stories.tsx b/packages/design-system/ui/src/components/toaster/toaster.stories.tsx new file mode 100644 index 0000000000..95eb4bf834 --- /dev/null +++ b/packages/design-system/ui/src/components/toaster/toaster.stories.tsx @@ -0,0 +1,186 @@ +import type { Meta, StoryObj } from "@storybook/react" +import * as React from "react" + +import { Button } from "@/components/button" +import { toast } from "@/utils/toast" +import { Toaster } from "./toaster" + +const meta: Meta = { + title: "Components/Toaster", + component: Toaster, + parameters: { + layout: "centered", + }, + render: (args) => { + const makeMessageToast = () => { + toast("This is a message toast.") + } + + const makeInfoToast = () => { + toast.info("This is an info toast.") + } + + const makeSuccessToast = () => { + toast.success("This is a success toast.") + } + + const makeWarningToast = () => { + toast.warning("This is a warning toast.") + } + + const makeErrorToast = () => { + toast.error("This is an error toast.") + } + + const makeActionToast = () => { + toast.error("This is an error toast with an action.", { + action: { + label: "Retry", + altText: "Retry the request", + onClick: () => { + console.log("Retrying the request...") + }, + }, + }) + } + + const doAsyncStuff = async () => { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (Math.random() > 0.2) { + resolve("Success!") + } else { + reject("Failed!") + } + }, 3000) + }) + } + + const makeAsyncToast = async () => { + toast.promise(doAsyncStuff, { + loading: "Loading...", + success: "Success!", + error: "Failed!", + }) + } + + const makeUpdatingToast = () => { + let id: number | string = Math.random() + + const retry = async () => { + const coinFlip = Math.random() > 0.5 + + console.log("retrying", id) + + toast.loading("Request in progress", { + id: id, + description: "The request is in progress.", + }) + + // wait 3 seconds + await new Promise((resolve) => setTimeout(resolve, 3000)) + + if (coinFlip) { + toast.success("Request succeeded", { + id: id, + description: "The request succeeded.", + }) + } else { + toast.error("Request failed", { + id: id, + description: + "Insert the description here. Can be semi-long and still work.", + action: { + label: "Retry", + altText: "Retry the request", + onClick: retry, + }, + }) + } + } + + toast.error("Request failed", { + id: id, + description: + "Insert the description here. Can be semi-long and still work.", + action: { + label: "Retry", + altText: "Retry the request", + onClick: retry, + }, + }) + } + + return ( +
+
+ + + + + + + + +
+ +
+ ) + }, +} + +export default meta + +type Story = StoryObj + +export const TopRight: Story = { + args: { + position: "top-right", + }, +} + +export const TopCenter: Story = { + args: { + position: "top-center", + }, +} + +export const TopLeft: Story = { + args: { + position: "top-left", + }, +} + +export const BottomRight: Story = { + args: { + position: "bottom-right", + }, +} + +export const BottomCenter: Story = { + args: { + position: "bottom-center", + }, +} + +export const BottomLeft: Story = { + args: { + position: "bottom-left", + }, +} diff --git a/packages/design-system/ui/src/components/toaster/toaster.tsx b/packages/design-system/ui/src/components/toaster/toaster.tsx index d5947b3b49..1c6b51122d 100644 --- a/packages/design-system/ui/src/components/toaster/toaster.tsx +++ b/packages/design-system/ui/src/components/toaster/toaster.tsx @@ -1,20 +1,41 @@ "use client" import * as React from "react" +import { Toaster as Primitive } from "sonner" -import { Toast, ToastProvider, ToastViewport } from "@/components/toast" -import { useToast } from "@/hooks/use-toast" +import { clx } from "@/utils/clx" -const Toaster = () => { - const { toasts } = useToast() +interface ToasterProps + extends Omit< + React.ComponentPropsWithoutRef, + | "richColors" + | "closeButton" + | "icons" + | "theme" + | "invert" + | "loadingIcon" + | "cn" + | "toastOptions" + > {} +const Toaster = ({ + position = "bottom-right", + gap = 12, + offset = 24, + duration, + ...props +}: ToasterProps) => { return ( - - {toasts.map(({ id, ...props }) => { - return - })} - - + ) } diff --git a/packages/design-system/ui/src/hooks/use-toast/index.ts b/packages/design-system/ui/src/hooks/use-toast/index.ts deleted file mode 100644 index 3fe207215e..0000000000 --- a/packages/design-system/ui/src/hooks/use-toast/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./use-toast" diff --git a/packages/design-system/ui/src/hooks/use-toast/use-toast.stories.tsx b/packages/design-system/ui/src/hooks/use-toast/use-toast.stories.tsx deleted file mode 100644 index 3d61c36f4f..0000000000 --- a/packages/design-system/ui/src/hooks/use-toast/use-toast.stories.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react" -import * as React from "react" - -import { Button } from "@/components/button" -import { Toaster } from "@/components/toaster" -import { useToast } from "./use-toast" - -const Demo = () => { - const { toast } = useToast() - - const handleUndo = async () => { - // Wait 3 seconds then resolve with true - const confirmed = await new Promise((resolve) => { - setTimeout(() => { - resolve(true) - }, 3000) - }) - - return confirmed - } - - const handleSchedule = () => { - const onAction = async (fn: (update: any) => void) => { - fn({ - title: "Processing", - description: "Removing post from queue.", - variant: "loading", - disableDismiss: true, - action: undefined, - }) - - const confirmed = await handleUndo().then((confirmed) => { - if (confirmed) { - fn({ - title: "Success", - description: "Your post was successfully removed from the queue.", - variant: "success", - disableDismiss: false, - }) - } else { - fn({ - title: "Error", - description: - "Something went wrong, and your post was not removed from the queue. Try again.", - variant: "error", - disableDismiss: false, - }) - } - }) - } - - const t = toast({ - title: "Scheduled", - description: "Your post has been scheduled for 12:00 PM", - variant: "success", - action: { - label: "Undo", - onClick: () => onAction(t.update), - altText: - "Alternatively, you can undo this action by navigating to the scheduled posts page, and clicking the unschedule button.", - }, - }) - } - - return ( -
- - -
- ) -} - -const meta: Meta = { - title: "Hooks/useToast", - component: Demo, - parameters: { - layout: "centered", - }, -} - -export default meta - -type Story = StoryObj - -export const Default: Story = {} diff --git a/packages/design-system/ui/src/hooks/use-toast/use-toast.tsx b/packages/design-system/ui/src/hooks/use-toast/use-toast.tsx deleted file mode 100644 index e60cd6c667..0000000000 --- a/packages/design-system/ui/src/hooks/use-toast/use-toast.tsx +++ /dev/null @@ -1,188 +0,0 @@ -"use client" - -import * as React from "react" - -import type { ToastActionElement, ToastProps } from "@/components/toast" - -const TOAST_LIMIT = 1 -const TOAST_REMOVE_DELAY = 1000000 - -type ToasterToast = ToastProps & { - id: string - title?: React.ReactNode - description?: React.ReactNode - action?: ToastActionElement -} - -const actionTypes = { - ADD_TOAST: "ADD_TOAST", - UPDATE_TOAST: "UPDATE_TOAST", - DISMISS_TOAST: "DISMISS_TOAST", - REMOVE_TOAST: "REMOVE_TOAST", -} as const - -let count = 0 - -function genId() { - count = (count + 1) % Number.MAX_VALUE - return count.toString() -} - -type ActionType = typeof actionTypes - -type Action = - | { - type: ActionType["ADD_TOAST"] - toast: ToasterToast - } - | { - type: ActionType["UPDATE_TOAST"] - toast: Partial - } - | { - type: ActionType["DISMISS_TOAST"] - toastId?: ToasterToast["id"] - } - | { - type: ActionType["REMOVE_TOAST"] - toastId?: ToasterToast["id"] - } - -interface State { - toasts: ToasterToast[] -} - -const toastTimeouts = new Map>() - -const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return - } - - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId) - dispatch({ - type: "REMOVE_TOAST", - toastId: toastId, - }) - }, TOAST_REMOVE_DELAY) - - toastTimeouts.set(toastId, timeout) -} - -export const reducer = (state: State, action: Action): State => { - switch (action.type) { - case "ADD_TOAST": - return { - ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - } - - case "UPDATE_TOAST": - return { - ...state, - toasts: state.toasts.map((t) => - t.id === action.toast.id ? { ...t, ...action.toast } : t - ), - } - - case "DISMISS_TOAST": { - const { toastId } = action - - if (toastId) { - addToRemoveQueue(toastId) - } else { - state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id) - }) - } - - return { - ...state, - toasts: state.toasts.map((t) => - t.id === toastId || toastId === undefined - ? { - ...t, - open: false, - } - : t - ), - } - } - case "REMOVE_TOAST": - if (action.toastId === undefined) { - return { - ...state, - toasts: [], - } - } - return { - ...state, - toasts: state.toasts.filter((t) => t.id !== action.toastId), - } - } -} - -const listeners: Array<(state: State) => void> = [] - -let memoryState: State = { toasts: [] } - -function dispatch(action: Action) { - memoryState = reducer(memoryState, action) - listeners.forEach((listener) => { - listener(memoryState) - }) -} - -type Toast = Omit - -function toast({ ...props }: Toast) { - const id = genId() - - const update = (props: ToasterToast) => - dispatch({ - type: "UPDATE_TOAST", - toast: { ...props, id }, - }) - const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) - - dispatch({ - type: "ADD_TOAST", - toast: { - ...props, - id, - open: true, - onOpenChange: (open) => { - if (!open) dismiss() - }, - }, - }) - - return { - id: id, - dismiss, - update, - } -} - -function useToast() { - const [state, setState] = React.useState(memoryState) - - React.useEffect(() => { - listeners.push(setState) - return () => { - const index = listeners.indexOf(setState) - if (index > -1) { - listeners.splice(index, 1) - } - } - }, [state]) - - return { - ...state, - toast, - dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), - } -} - -export { toast, useToast } diff --git a/packages/design-system/ui/src/index.ts b/packages/design-system/ui/src/index.ts index d77d2a5b90..a013cab65b 100644 --- a/packages/design-system/ui/src/index.ts +++ b/packages/design-system/ui/src/index.ts @@ -40,11 +40,11 @@ export { Tooltip } from "./components/tooltip" // Hooks export { usePrompt } from "./hooks/use-prompt" -export { useToast } from "./hooks/use-toast" export { useToggleState } from "./hooks/use-toggle-state" // Utils export { clx } from "./utils/clx" +export { toast } from "./utils/toast" // Types export * from "./types" diff --git a/packages/design-system/ui/src/types.ts b/packages/design-system/ui/src/types.ts index 74ce771d92..0785720a66 100644 --- a/packages/design-system/ui/src/types.ts +++ b/packages/design-system/ui/src/types.ts @@ -1,5 +1,9 @@ +// Progress types + export type ProgressStatus = "not-started" | "in-progress" | "completed" +// Calendar types + export type DateRange = { /** * The range's start date. @@ -10,3 +14,30 @@ export type DateRange = { */ to?: Date | undefined } + +// Toast types + +export type ToasterPosition = + | "top-left" + | "top-center" + | "top-right" + | "bottom-left" + | "bottom-center" + | "bottom-right" + +export type ToastVariant = + | "info" + | "success" + | "warning" + | "error" + | "loading" + | "message" + +export type ToastActionVariant = "default" | "destructive" + +export type ToastAction = { + label: string + altText: string + onClick: () => void | Promise + variant?: ToastActionVariant +} diff --git a/packages/design-system/ui/src/utils/toast.tsx b/packages/design-system/ui/src/utils/toast.tsx new file mode 100644 index 0000000000..8b4b3b4fb3 --- /dev/null +++ b/packages/design-system/ui/src/utils/toast.tsx @@ -0,0 +1,214 @@ +import { Toast } from "@/components/toast" +import { ToastAction, ToastVariant, ToasterPosition } from "@/types" +import * as React from "react" +import { toast as toastFn } from "sonner" + +interface BaseToastProps { + id?: string | number + position?: ToasterPosition + dismissable?: boolean + dismissLabel?: string + duration?: number +} + +interface ToastProps extends BaseToastProps { + description?: string + action?: ToastAction +} + +function create(variant: ToastVariant, title: string, props: ToastProps = {}) { + return toastFn.custom( + (t) => { + return ( + toastFn.dismiss(props.id || t) : undefined + } + dismissLabel={props.dismissLabel} + variant={variant} + action={props.action} + /> + ) + }, + { + id: props.id, + position: props.position, + dismissible: props.dismissable, + } + ) +} + +function message( + /** + * The title of the toast. + */ + title: string, + /** + * The props of the toast. + */ + props: ToastProps = {} +) { + return create("message", title, props) +} + +function info( + /** + * The title of the toast. + */ title: string, + /** + * The props of the toast. + */ + props: ToastProps = {} +) { + return create("info", title, props) +} + +function error( + /** + * The title of the toast. + */ title: string, + /** + * The props of the toast. + */ + props: ToastProps = {} +) { + return create("error", title, props) +} + +function success( + /** + * The title of the toast. + */ title: string, + /** + * The props of the toast. + */ + props: ToastProps = {} +) { + return create("success", title, props) +} + +function warning( + /** + * The title of the toast. + */ title: string, + /** + * The props of the toast. + */ + props: ToastProps = {} +) { + return create("warning", title, props) +} + +type LoadingToastProps = Omit + +function loading +/** + * The title of the toast. + */( + title: string, + /** + * The props of the toast. + */ + props: ToastProps = {} +) { + return create("loading", title, { ...props, dismissable: false }) +} + +type PromiseStateProps = + | string + | { + title: string + description?: string + } + +interface PromiseToastProps extends BaseToastProps { + loading: PromiseStateProps + success: PromiseStateProps + error: PromiseStateProps +} + +function createUniqueID() { + return Math.random().toString(36).slice(2, 8) +} + +async function promise( + /** + * The promise to be resolved. + */ + promise: Promise | (() => Promise), + /** + * The props of the toast. + */ + props: PromiseToastProps +) { + let id: string | number | undefined = props.id || createUniqueID() + let shouldDismiss = id !== undefined + + id = create( + "loading", + typeof props.loading === "string" ? props.loading : props.loading.title, + { + id: id, + position: props.position, + description: + typeof props.loading === "string" + ? undefined + : props.loading.description, + duration: Infinity, + } + ) + + const p = promise instanceof Promise ? promise : promise() + + p.then(() => { + shouldDismiss = false + create( + "success", + typeof props.success === "string" ? props.success : props.success.title, + { + id: id, + position: props.position, + description: + typeof props.success === "string" + ? undefined + : props.success.description, + } + ) + }) + .catch(() => { + shouldDismiss = false + create( + "error", + typeof props.error === "string" ? props.error : props.error.title, + { + id: id, + position: props.position, + description: + typeof props.error === "string" + ? undefined + : props.error.description, + } + ) + }) + .finally(() => { + if (shouldDismiss) { + toastFn.dismiss(id) + id = undefined + } + }) + + return id +} + +export const toast = Object.assign(message, { + info, + error, + warning, + success, + promise, + loading, + dismiss: toastFn.dismiss, +}) diff --git a/yarn.lock b/yarn.lock index 6fcb2ba11a..cbdc7bdacd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9057,7 +9057,6 @@ __metadata: "@radix-ui/react-slot": ^1.0.2 "@radix-ui/react-switch": ^1.0.3 "@radix-ui/react-tabs": ^1.0.4 - "@radix-ui/react-toast": ^1.1.4 "@radix-ui/react-tooltip": ^1.0.6 "@react-aria/datepicker": ^3.5.0 "@react-stately/datepicker": ^3.5.0 @@ -9078,7 +9077,7 @@ __metadata: "@types/react-dom": ^18.2.0 "@vitejs/plugin-react": ^4.0.1 "@vitest/coverage-v8": ^0.32.2 - autoprefixer: ^10.4.17 + autoprefixer: ^10.4.19 chromatic: ^6.20.0 clsx: ^1.2.1 copy-to-clipboard: ^3.3.3 @@ -9087,7 +9086,7 @@ __metadata: eslint: ^7.32.0 eslint-plugin-storybook: ^0.6.12 jsdom: ^22.1.0 - postcss: ^8.4.33 + postcss: ^8.4.38 prism-react-renderer: ^2.0.6 prismjs: ^1.29.0 prop-types: ^15.8.1 @@ -9097,9 +9096,10 @@ __metadata: react-dom: ^18.2.0 resize-observer-polyfill: ^1.5.1 rimraf: ^5.0.1 + sonner: ^1.4.41 storybook: ^7.0.23 tailwind-merge: ^2.2.1 - tailwindcss: ^3.4.1 + tailwindcss: ^3.4.3 tsc-alias: ^1.8.7 typescript: ^5.1.6 vite: ^4.3.9 @@ -11418,37 +11418,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-toast@npm:^1.1.4": - version: 1.1.4 - resolution: "@radix-ui/react-toast@npm:1.1.4" - dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/primitive": 1.0.1 - "@radix-ui/react-collection": 1.0.3 - "@radix-ui/react-compose-refs": 1.0.1 - "@radix-ui/react-context": 1.0.1 - "@radix-ui/react-dismissable-layer": 1.0.4 - "@radix-ui/react-portal": 1.0.3 - "@radix-ui/react-presence": 1.0.1 - "@radix-ui/react-primitive": 1.0.3 - "@radix-ui/react-use-callback-ref": 1.0.1 - "@radix-ui/react-use-controllable-state": 1.0.1 - "@radix-ui/react-use-layout-effect": 1.0.1 - "@radix-ui/react-visually-hidden": 1.0.3 - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: bb8282ca87820763d3912e11c1693aa414907d08dabd10a07e77ec0ac06a27e9eed76a591fb90dec147454e674c4c57a961b541e2de76c8125ab1aefaab62c2e - languageName: node - linkType: hard - "@radix-ui/react-toggle-group@npm:1.0.4": version: 1.0.4 resolution: "@radix-ui/react-toggle-group@npm:1.0.4" @@ -20976,6 +20945,24 @@ __metadata: languageName: node linkType: hard +"autoprefixer@npm:^10.4.19": + version: 10.4.19 + resolution: "autoprefixer@npm:10.4.19" + dependencies: + browserslist: ^4.23.0 + caniuse-lite: ^1.0.30001599 + fraction.js: ^4.3.7 + normalize-range: ^0.1.2 + picocolors: ^1.0.0 + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.1.0 + bin: + autoprefixer: bin/autoprefixer + checksum: fe0178eb8b1da4f15c6535cd329926609b22d1811e047371dccce50563623f8075dd06fb167daff059e4228da651b0bdff6d9b44281541eaf0ce0b79125bfd19 + languageName: node + linkType: hard + "autoprefixer@npm:^9.8.6": version: 9.8.8 resolution: "autoprefixer@npm:9.8.8" @@ -22388,6 +22375,20 @@ __metadata: languageName: node linkType: hard +"browserslist@npm:^4.23.0": + version: 4.23.0 + resolution: "browserslist@npm:4.23.0" + dependencies: + caniuse-lite: ^1.0.30001587 + electron-to-chromium: ^1.4.668 + node-releases: ^2.0.14 + update-browserslist-db: ^1.0.13 + bin: + browserslist: cli.js + checksum: 8e9cc154529062128d02a7af4d8adeead83ca1df8cd9ee65a88e2161039f3d68a4d40fea7353cab6bae4c16182dec2fdd9a1cf7dc2a2935498cee1af0e998943 + languageName: node + linkType: hard + "bs-logger@npm:0.x": version: 0.2.6 resolution: "bs-logger@npm:0.2.6" @@ -22915,6 +22916,13 @@ __metadata: languageName: node linkType: hard +"caniuse-lite@npm:^1.0.30001587, caniuse-lite@npm:^1.0.30001599": + version: 1.0.30001610 + resolution: "caniuse-lite@npm:1.0.30001610" + checksum: 015956a0bf2e3e233da3dc00c5632bbb4d416bcd6ced2f839e33e45b197a856234f97cb046e7427b83d7e3a3d6df314dfab1c86eb9d970970e00ad85a50b4933 + languageName: node + linkType: hard + "capital-case@npm:^1.0.4": version: 1.0.4 resolution: "capital-case@npm:1.0.4" @@ -26256,6 +26264,13 @@ __metadata: languageName: node linkType: hard +"electron-to-chromium@npm:^1.4.668": + version: 1.4.736 + resolution: "electron-to-chromium@npm:1.4.736" + checksum: f3acb515dcb9333b318f9a8ee80a0c3975162da6cc45d9c00152e0de7004b2bfe1246fe6cdf0c41a1b7016780bd4324aff848830aba4227cb55480c1aa32d248 + languageName: node + linkType: hard + "elliptic@npm:^6.5.3": version: 6.5.4 resolution: "elliptic@npm:6.5.4" @@ -35839,6 +35854,15 @@ __metadata: languageName: node linkType: hard +"jiti@npm:^1.21.0": + version: 1.21.0 + resolution: "jiti@npm:1.21.0" + bin: + jiti: bin/jiti.js + checksum: 7f361219fe6c7a5e440d5f1dba4ab763a5538d2df8708cdc22561cf25ea3e44b837687931fca7cdd8cdd9f567300e90be989dd1321650045012d8f9ed6aab07f + languageName: node + linkType: hard + "jmespath@npm:0.16.0": version: 0.16.0 resolution: "jmespath@npm:0.16.0" @@ -42956,6 +42980,17 @@ __metadata: languageName: node linkType: hard +"postcss@npm:^8.4.38": + version: 8.4.38 + resolution: "postcss@npm:8.4.38" + dependencies: + nanoid: ^3.3.7 + picocolors: ^1.0.0 + source-map-js: ^1.2.0 + checksum: 955407b8f70cf0c14acf35dab3615899a2a60a26718a63c848cf3c29f2467b0533991b985a2b994430d890bd7ec2b1963e36352b0774a19143b5f591540f7c06 + languageName: node + linkType: hard + "postgres-array@npm:~2.0.0": version: 2.0.0 resolution: "postgres-array@npm:2.0.0" @@ -47357,6 +47392,16 @@ __metadata: languageName: node linkType: hard +"sonner@npm:^1.4.41": + version: 1.4.41 + resolution: "sonner@npm:1.4.41" + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + checksum: 31467ecab0fcbd5161fb76d158c698841b524686f7f598eecbc2a58d76bc496b5d5242df4350e274575aa3df59428d3aacd534415c968b19fc309c192c443330 + languageName: node + linkType: hard + "sort-keys@npm:^5.0.0": version: 5.0.0 resolution: "sort-keys@npm:5.0.0" @@ -47387,6 +47432,13 @@ __metadata: languageName: node linkType: hard +"source-map-js@npm:^1.2.0": + version: 1.2.0 + resolution: "source-map-js@npm:1.2.0" + checksum: 7e5f896ac10a3a50fe2898e5009c58ff0dc102dcb056ed27a354623a0ece8954d4b2649e1a1b2b52ef2e161d26f8859c7710350930751640e71e374fe2d321a4 + languageName: node + linkType: hard + "source-map-resolve@npm:^0.5.0": version: 0.5.3 resolution: "source-map-resolve@npm:0.5.3" @@ -48872,6 +48924,39 @@ __metadata: languageName: node linkType: hard +"tailwindcss@npm:^3.4.3": + version: 3.4.3 + resolution: "tailwindcss@npm:3.4.3" + dependencies: + "@alloc/quick-lru": ^5.2.0 + arg: ^5.0.2 + chokidar: ^3.5.3 + didyoumean: ^1.2.2 + dlv: ^1.1.3 + fast-glob: ^3.3.0 + glob-parent: ^6.0.2 + is-glob: ^4.0.3 + jiti: ^1.21.0 + lilconfig: ^2.1.0 + micromatch: ^4.0.5 + normalize-path: ^3.0.0 + object-hash: ^3.0.0 + picocolors: ^1.0.0 + postcss: ^8.4.23 + postcss-import: ^15.1.0 + postcss-js: ^4.0.1 + postcss-load-config: ^4.0.1 + postcss-nested: ^6.0.1 + postcss-selector-parser: ^6.0.11 + resolve: ^1.22.2 + sucrase: ^3.32.0 + bin: + tailwind: lib/cli.js + tailwindcss: lib/cli.js + checksum: 11e5546494f2888f693ebaa271b218b3a8e52fe59d7b629e54f2dffd6eaafd5ded2e9f0c37ad04e6a866dffb2b116d91becebad77e1441beee8bf016bb2392f9 + languageName: node + linkType: hard + "tapable@npm:^1.0.0, tapable@npm:^1.1.3": version: 1.1.3 resolution: "tapable@npm:1.1.3"