feat(admin,admin-ui,medusa): Add Medusa Admin plugin (#3334)
This commit is contained in:
committed by
GitHub
parent
d6b1ad1ccd
commit
40de54b010
592
packages/admin-ui/ui/src/hooks/use-build-timeline.tsx
Normal file
592
packages/admin-ui/ui/src/hooks/use-build-timeline.tsx
Normal file
@@ -0,0 +1,592 @@
|
||||
import {
|
||||
ClaimOrder,
|
||||
Order,
|
||||
OrderEdit,
|
||||
Refund,
|
||||
Return,
|
||||
Swap,
|
||||
} from "@medusajs/medusa"
|
||||
import {
|
||||
useAdminNotes,
|
||||
useAdminNotifications,
|
||||
useAdminOrder,
|
||||
useAdminOrderEdits,
|
||||
} from "medusa-react"
|
||||
import { useMemo } from "react"
|
||||
import { orderReturnableFields } from "../domain/orders/details/utils/order-returnable-fields"
|
||||
import { useFeatureFlag } from "../providers/feature-flag-provider"
|
||||
|
||||
export interface TimelineEvent {
|
||||
id: string
|
||||
time: Date
|
||||
first?: boolean
|
||||
orderId: string
|
||||
noNotification?: boolean
|
||||
type:
|
||||
| "payment"
|
||||
| "note"
|
||||
| "notification"
|
||||
| "placed"
|
||||
| "shipped"
|
||||
| "delivered"
|
||||
| "fulfilled"
|
||||
| "canceled"
|
||||
| "return"
|
||||
| "refund"
|
||||
| "exchange"
|
||||
| "exchange_fulfilled"
|
||||
| "claim"
|
||||
| "edit-created"
|
||||
| "edit-requested"
|
||||
| "edit-declined"
|
||||
| "edit-canceled"
|
||||
| "edit-confirmed"
|
||||
| "payment-required"
|
||||
| "refund-required"
|
||||
}
|
||||
|
||||
export interface RefundRequiredEvent extends TimelineEvent {
|
||||
currency_code: string
|
||||
}
|
||||
|
||||
export interface PaymentRequiredEvent extends TimelineEvent {
|
||||
currency_code: string
|
||||
}
|
||||
|
||||
export interface OrderEditEvent extends TimelineEvent {
|
||||
edit: OrderEdit
|
||||
}
|
||||
|
||||
export interface OrderEditRequestedEvent extends OrderEditEvent {
|
||||
email: string
|
||||
}
|
||||
|
||||
interface CancelableEvent {
|
||||
canceledAt?: Date
|
||||
isCanceled?: boolean
|
||||
}
|
||||
|
||||
export interface OrderPlacedEvent extends TimelineEvent {
|
||||
amount: number
|
||||
currency_code: string
|
||||
tax?: number
|
||||
}
|
||||
|
||||
interface OrderItem {
|
||||
title: string
|
||||
quantity: number
|
||||
thumbnail?: string
|
||||
variant: {
|
||||
title: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ReturnItem extends OrderItem {
|
||||
requestedQuantity: number
|
||||
receivedQuantity: number
|
||||
}
|
||||
|
||||
interface FulfillmentEvent extends TimelineEvent {
|
||||
sourceType: "claim" | "exchange" | undefined
|
||||
}
|
||||
|
||||
export interface ItemsFulfilledEvent extends FulfillmentEvent {
|
||||
items: OrderItem[]
|
||||
}
|
||||
|
||||
export interface ItemsShippedEvent extends FulfillmentEvent {
|
||||
items: OrderItem[]
|
||||
}
|
||||
|
||||
export interface RefundEvent extends TimelineEvent {
|
||||
amount: number
|
||||
reason: string
|
||||
currencyCode: string
|
||||
note?: string
|
||||
refund: Refund
|
||||
}
|
||||
|
||||
enum ReturnStatus {
|
||||
REQUESTED = "requested",
|
||||
RECEIVED = "received",
|
||||
REQUIRES_ACTION = "requires_action",
|
||||
CANCELED = "canceled",
|
||||
}
|
||||
|
||||
export interface ReturnEvent extends TimelineEvent {
|
||||
items: ReturnItem[]
|
||||
status: ReturnStatus
|
||||
currentStatus?: ReturnStatus
|
||||
raw: Return
|
||||
order: Order
|
||||
refunded?: boolean
|
||||
}
|
||||
|
||||
export interface NoteEvent extends TimelineEvent {
|
||||
value: string
|
||||
authorId: string
|
||||
}
|
||||
|
||||
export interface ExchangeEvent extends TimelineEvent, CancelableEvent {
|
||||
paymentStatus: string
|
||||
fulfillmentStatus: string
|
||||
returnStatus: string
|
||||
returnId: string
|
||||
returnItems: (ReturnItem | undefined)[]
|
||||
newItems: OrderItem[]
|
||||
exchangeCartId?: string
|
||||
raw: Swap
|
||||
}
|
||||
|
||||
export interface ClaimEvent extends TimelineEvent, CancelableEvent {
|
||||
returnStatus: ReturnStatus
|
||||
fulfillmentStatus?: string
|
||||
refundStatus: string
|
||||
refundAmount: number
|
||||
currencyCode: string
|
||||
claimItems: OrderItem[]
|
||||
newItems: OrderItem[]
|
||||
claimType: string
|
||||
claim: ClaimOrder
|
||||
order: Order
|
||||
}
|
||||
|
||||
export interface NotificationEvent extends TimelineEvent {
|
||||
to: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export const useBuildTimeline = (orderId: string) => {
|
||||
const { order, refetch } = useAdminOrder(orderId, {
|
||||
fields: orderReturnableFields,
|
||||
})
|
||||
|
||||
const { order_edits: edits } = useAdminOrderEdits({ order_id: orderId })
|
||||
|
||||
const { isFeatureEnabled } = useFeatureFlag()
|
||||
|
||||
const { notes } = useAdminNotes({
|
||||
resource_id: orderId,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
const { notifications } = useAdminNotifications({ resource_id: orderId })
|
||||
|
||||
const events: TimelineEvent[] | undefined = useMemo(() => {
|
||||
if (!order) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let allItems = [...order.items]
|
||||
|
||||
if (order.swaps && order.swaps.length) {
|
||||
for (const swap of order.swaps) {
|
||||
allItems = [...allItems, ...swap.additional_items]
|
||||
}
|
||||
}
|
||||
|
||||
if (order.claims && order.claims.length) {
|
||||
for (const claim of order.claims) {
|
||||
allItems = [...allItems, ...claim.additional_items]
|
||||
}
|
||||
}
|
||||
|
||||
const events: TimelineEvent[] = []
|
||||
|
||||
events.push({
|
||||
id: "refund-event",
|
||||
time: new Date(),
|
||||
orderId: order.id,
|
||||
type: "refund-required",
|
||||
currency_code: order.currency_code,
|
||||
} as RefundRequiredEvent)
|
||||
|
||||
events.push({
|
||||
id: "payment-required",
|
||||
time: new Date(),
|
||||
orderId: order.id,
|
||||
type: "payment-required",
|
||||
currency_code: order.currency_code,
|
||||
} as PaymentRequiredEvent)
|
||||
|
||||
if (isFeatureEnabled("order_editing")) {
|
||||
for (const edit of edits || []) {
|
||||
events.push({
|
||||
id: edit.id,
|
||||
time: edit.created_at,
|
||||
orderId: order.id,
|
||||
type: "edit-created",
|
||||
edit: edit,
|
||||
} as OrderEditEvent)
|
||||
|
||||
if (edit.requested_at) {
|
||||
events.push({
|
||||
id: edit.id,
|
||||
time: edit.requested_at,
|
||||
orderId: order.id,
|
||||
type: "edit-requested",
|
||||
email: order.email,
|
||||
edit: edit,
|
||||
} as OrderEditRequestedEvent)
|
||||
}
|
||||
|
||||
// // declined
|
||||
if (edit.declined_at) {
|
||||
events.push({
|
||||
id: edit.id,
|
||||
time: edit.declined_at,
|
||||
orderId: order.id,
|
||||
type: "edit-declined",
|
||||
edit: edit,
|
||||
} as OrderEditEvent)
|
||||
}
|
||||
|
||||
// // canceled
|
||||
if (edit.canceled_at) {
|
||||
events.push({
|
||||
id: edit.id,
|
||||
time: edit.canceled_at,
|
||||
orderId: order.id,
|
||||
type: "edit-canceled",
|
||||
edit: edit,
|
||||
} as OrderEditEvent)
|
||||
}
|
||||
|
||||
// confirmed
|
||||
if (edit.confirmed_at) {
|
||||
events.push({
|
||||
id: edit.id,
|
||||
time: edit.confirmed_at,
|
||||
orderId: order.id,
|
||||
type: "edit-confirmed",
|
||||
edit: edit,
|
||||
} as OrderEditEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
events.push({
|
||||
id: `${order.id}-placed`,
|
||||
time: order.created_at,
|
||||
amount: order.total,
|
||||
currency_code: order.currency_code,
|
||||
tax: order.tax_rate,
|
||||
type: "placed",
|
||||
orderId: order.id,
|
||||
} as OrderPlacedEvent)
|
||||
|
||||
if (order.status === "canceled") {
|
||||
events.push({
|
||||
id: `${order.id}-canceled`,
|
||||
time: order.updated_at,
|
||||
type: "canceled",
|
||||
orderId: order.id,
|
||||
} as TimelineEvent)
|
||||
}
|
||||
|
||||
if (notes) {
|
||||
for (const note of notes) {
|
||||
events.push({
|
||||
id: note.id,
|
||||
time: note.created_at,
|
||||
type: "note",
|
||||
authorId: note.author_id,
|
||||
value: note.value,
|
||||
orderId: order.id,
|
||||
} as NoteEvent)
|
||||
}
|
||||
}
|
||||
|
||||
for (const event of order.refunds) {
|
||||
events.push({
|
||||
amount: event.amount,
|
||||
currencyCode: order.currency_code,
|
||||
id: event.id,
|
||||
note: event.note,
|
||||
reason: event.reason,
|
||||
time: event.created_at,
|
||||
type: "refund",
|
||||
refund: event,
|
||||
} as RefundEvent)
|
||||
}
|
||||
|
||||
for (const event of order.fulfillments) {
|
||||
events.push({
|
||||
id: event.id,
|
||||
time: event.created_at,
|
||||
type: "fulfilled",
|
||||
items: event.items.map((item) => getLineItem(allItems, item.item_id)),
|
||||
noNotification: event.no_notification,
|
||||
orderId: order.id,
|
||||
} as ItemsFulfilledEvent)
|
||||
|
||||
if (event.shipped_at) {
|
||||
events.push({
|
||||
id: event.id,
|
||||
time: event.shipped_at,
|
||||
type: "shipped",
|
||||
items: event.items.map((item) => getLineItem(allItems, item.item_id)),
|
||||
noNotification: event.no_notification,
|
||||
orderId: order.id,
|
||||
} as ItemsShippedEvent)
|
||||
}
|
||||
}
|
||||
|
||||
for (const event of order.returns) {
|
||||
events.push({
|
||||
id: event.id,
|
||||
items: event.items.map((i) => getReturnItems(allItems, i)),
|
||||
status: event.status,
|
||||
currentStatus: event.status,
|
||||
time: event.updated_at,
|
||||
type: "return",
|
||||
noNotification: event.no_notification,
|
||||
orderId: order.id,
|
||||
order: order,
|
||||
raw: event as unknown as Return,
|
||||
refunded: getWasRefundClaim(event.claim_order_id, order),
|
||||
} as ReturnEvent)
|
||||
|
||||
if (event.status !== "requested") {
|
||||
events.push({
|
||||
id: event.id,
|
||||
items: event.items.map((i) => getReturnItems(allItems, i)),
|
||||
status: "requested",
|
||||
time: event.created_at,
|
||||
type: "return",
|
||||
raw: event as unknown as Return,
|
||||
currentStatus: event.status,
|
||||
noNotification: event.no_notification,
|
||||
order: order,
|
||||
orderId: order.id,
|
||||
} as ReturnEvent)
|
||||
}
|
||||
}
|
||||
|
||||
for (const event of order.swaps) {
|
||||
events.push({
|
||||
id: event.id,
|
||||
time: event.canceled_at ? event.canceled_at : event.created_at,
|
||||
noNotification: event.no_notification === true,
|
||||
fulfillmentStatus: event.fulfillment_status,
|
||||
returnId: event.return_order.id,
|
||||
paymentStatus: event.payment_status,
|
||||
returnStatus: event.return_order.status,
|
||||
type: "exchange",
|
||||
newItems: event.additional_items.map((i) => getSwapItem(i)),
|
||||
returnItems: event.return_order.items.map((i) =>
|
||||
getReturnItems(allItems, i)
|
||||
),
|
||||
exchangeCartId:
|
||||
event.payment_status !== "captured" ? event.cart_id : undefined,
|
||||
canceledAt: event.canceled_at,
|
||||
orderId: event.order_id,
|
||||
raw: event as unknown as Swap,
|
||||
} as ExchangeEvent)
|
||||
|
||||
if (
|
||||
event.fulfillment_status === "fulfilled" ||
|
||||
event.fulfillment_status === "shipped"
|
||||
) {
|
||||
events.push({
|
||||
id: event.id,
|
||||
time: event.fulfillments[0].created_at,
|
||||
type: "fulfilled",
|
||||
items: event.additional_items.map((i) => getSwapItem(i)),
|
||||
noNotification: event.no_notification,
|
||||
orderId: order.id,
|
||||
sourceType: "exchange",
|
||||
} as ItemsFulfilledEvent)
|
||||
|
||||
if (event.fulfillments[0].shipped_at) {
|
||||
events.push({
|
||||
id: event.id,
|
||||
time: event.fulfillments[0].shipped_at,
|
||||
type: "shipped",
|
||||
items: event.additional_items.map((i) => getSwapItem(i)),
|
||||
noNotification: event.no_notification,
|
||||
orderId: order.id,
|
||||
sourceType: "exchange",
|
||||
} as ItemsShippedEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (order.claims) {
|
||||
for (const claim of order.claims) {
|
||||
events.push({
|
||||
id: claim.id,
|
||||
type: "claim",
|
||||
newItems: claim.additional_items.map((i) => ({
|
||||
quantity: i.quantity,
|
||||
title: i.title,
|
||||
thumbnail: i.thumbnail,
|
||||
variant: {
|
||||
title: i.variant?.title,
|
||||
},
|
||||
})),
|
||||
fulfillmentStatus: claim.fulfillment_status,
|
||||
returnStatus: claim.return_order?.status,
|
||||
refundStatus: claim.payment_status,
|
||||
refundAmount: claim.refund_amount,
|
||||
currencyCode: order.currency_code,
|
||||
claimItems: claim.claim_items.map((i) => getClaimItem(i)),
|
||||
time: claim.canceled_at ? claim.canceled_at : claim.created_at,
|
||||
noNotification: claim.no_notification,
|
||||
claimType: claim.type,
|
||||
canceledAt: claim.canceled_at,
|
||||
orderId: order.id,
|
||||
claim,
|
||||
order,
|
||||
} as ClaimEvent)
|
||||
|
||||
if (
|
||||
claim.fulfillment_status === "fulfilled" ||
|
||||
claim.fulfillment_status === "shipped"
|
||||
) {
|
||||
events.push({
|
||||
id: claim.id,
|
||||
time: claim.fulfillments[0].created_at,
|
||||
type: "fulfilled",
|
||||
items: claim.additional_items.map((i) => getSwapItem(i)),
|
||||
noNotification: claim.no_notification,
|
||||
orderId: order.id,
|
||||
sourceType: "claim",
|
||||
} as ItemsFulfilledEvent)
|
||||
|
||||
if (claim.fulfillments[0].shipped_at) {
|
||||
events.push({
|
||||
id: claim.id,
|
||||
time: claim.fulfillments[0].shipped_at,
|
||||
type: "shipped",
|
||||
items: claim.additional_items.map((i) => getSwapItem(i)),
|
||||
noNotification: claim.no_notification,
|
||||
orderId: order.id,
|
||||
sourceType: "claim",
|
||||
} as ItemsShippedEvent)
|
||||
}
|
||||
}
|
||||
if (claim.canceled_at) {
|
||||
events.push({
|
||||
id: `${claim.id}-created`,
|
||||
type: "claim",
|
||||
newItems: claim.additional_items.map((i) => ({
|
||||
quantity: i.quantity,
|
||||
title: i.title,
|
||||
thumbnail: i.thumbnail,
|
||||
variant: {
|
||||
title: i.variant?.title,
|
||||
},
|
||||
})),
|
||||
fulfillmentStatus: claim.fulfillment_status,
|
||||
refundStatus: claim.payment_status,
|
||||
refundAmount: claim.refund_amount,
|
||||
currencyCode: order.currency_code,
|
||||
claimItems: claim.claim_items.map((i) => getClaimItem(i)),
|
||||
time: claim.created_at,
|
||||
noNotification: claim.no_notification,
|
||||
claimType: claim.type,
|
||||
isCanceled: true,
|
||||
orderId: order.id,
|
||||
} as ClaimEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (notifications) {
|
||||
for (const notification of notifications) {
|
||||
events.push({
|
||||
id: notification.id,
|
||||
time: notification.created_at,
|
||||
to: notification.to,
|
||||
type: "notification",
|
||||
title: notification.event_name,
|
||||
orderId: order.id,
|
||||
} as NotificationEvent)
|
||||
}
|
||||
}
|
||||
|
||||
events.sort((a, b) => {
|
||||
if (a.time > b.time) {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (a.time < b.time) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
|
||||
events[events.length - 1].first = true
|
||||
|
||||
return events
|
||||
}, [order, edits, notes, notifications, isFeatureEnabled])
|
||||
|
||||
return { events, refetch }
|
||||
}
|
||||
|
||||
function getLineItem(allItems, itemId) {
|
||||
const line = allItems.find((line) => line.id === itemId)
|
||||
|
||||
if (!line) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
title: line.title,
|
||||
quantity: line.quantity,
|
||||
thumbnail: line.thumbnail,
|
||||
variant: { title: line?.variant?.title || "-" },
|
||||
}
|
||||
}
|
||||
|
||||
function getReturnItems(allItems, item) {
|
||||
const line = allItems.find((li) => li.id === item.item_id)
|
||||
|
||||
if (!line) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
title: line.title,
|
||||
quantity: item.quantity,
|
||||
requestedQuantity: item.requested_quantity,
|
||||
receivedQuantity: item.received_quantity,
|
||||
variant: {
|
||||
title: line?.variant?.title || "-",
|
||||
},
|
||||
thumbnail: line.thumbnail,
|
||||
}
|
||||
}
|
||||
|
||||
function getClaimItem(claimItem) {
|
||||
return {
|
||||
title: claimItem.item.title,
|
||||
quantity: claimItem.quantity,
|
||||
thumbnail: claimItem.item.thumbnail,
|
||||
variant: {
|
||||
title: claimItem.item.variant?.title,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getSwapItem(item) {
|
||||
return {
|
||||
title: item.title,
|
||||
quantity: item.quantity,
|
||||
thumbnail: item.thumbnail,
|
||||
variant: { title: item.variant?.title },
|
||||
}
|
||||
}
|
||||
|
||||
function getWasRefundClaim(claimId, order) {
|
||||
const claim = order.claims.find((c) => c.id === claimId)
|
||||
|
||||
if (!claim) {
|
||||
return false
|
||||
}
|
||||
|
||||
return claim.type === "refund"
|
||||
}
|
||||
38
packages/admin-ui/ui/src/hooks/use-clipboard.js
Normal file
38
packages/admin-ui/ui/src/hooks/use-clipboard.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// hugely inspired from @danoc https://github.com/danoc/react-use-clipboard/blob/master/src/index.tsx
|
||||
import copy from "copy-to-clipboard"
|
||||
import React from "react"
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {Object} options
|
||||
* @param {number} options.successDuration - Duration of the success state in milliseconds
|
||||
* @param {function} options.onCopied - Callback function to call after copying
|
||||
* @returns {Array} returns tuple containing isCopied state and handleCopy function
|
||||
*/
|
||||
const useClipboard = (text, options = {}) => {
|
||||
const [isCopied, setIsCopied] = React.useState(false)
|
||||
const successDuration = options?.successDuration
|
||||
const onCopied = options?.onCopied || function () {}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isCopied && successDuration) {
|
||||
const timeout = setTimeout(() => {
|
||||
setIsCopied(false)
|
||||
}, successDuration)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [isCopied, successDuration])
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
copy(text)
|
||||
setIsCopied(true)
|
||||
onCopied()
|
||||
}, [text, onCopied, setIsCopied])
|
||||
|
||||
return [isCopied, handleCopy]
|
||||
}
|
||||
|
||||
export default useClipboard
|
||||
19
packages/admin-ui/ui/src/hooks/use-computed-height.ts
Normal file
19
packages/admin-ui/ui/src/hooks/use-computed-height.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useLayoutEffect, useRef } from "react"
|
||||
import { useWindowDimensions } from "./use-window-dimensions"
|
||||
|
||||
export const useComputedHeight = (bottomPad: number) => {
|
||||
const ref = useRef(null)
|
||||
const heightRef = useRef(0)
|
||||
|
||||
const { height } = useWindowDimensions()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (ref.current) {
|
||||
let { top } = ref.current.getBoundingClientRect()
|
||||
// take the inner height of the window, subtract 32 from it (for the bottom padding), then subtract that from the top position of our grid row (wherever that is)
|
||||
heightRef.current = height - bottomPad - top
|
||||
}
|
||||
}, [bottomPad, height])
|
||||
|
||||
return { ref, height: heightRef.current }
|
||||
}
|
||||
22
packages/admin-ui/ui/src/hooks/use-debounce.ts
Normal file
22
packages/admin-ui/ui/src/hooks/use-debounce.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export const useDebounce = <T>(value: T, delay: number): T => {
|
||||
// State and setters for debounced value
|
||||
const [debouncedValue, setDebouncedValue] = useState(value)
|
||||
useEffect(
|
||||
() => {
|
||||
// Update debounced value after delay
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
// Cancel the timeout if value changes (also on delay change or unmount)
|
||||
// This is how we prevent debounced value from updating if value is changed ...
|
||||
// .. within the delay period. Timeout gets cleared and restarted.
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
},
|
||||
[value, delay] // Only re-call effect if value or delay changes
|
||||
)
|
||||
return debouncedValue
|
||||
}
|
||||
50
packages/admin-ui/ui/src/hooks/use-detect-change.tsx
Normal file
50
packages/admin-ui/ui/src/hooks/use-detect-change.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ReactNode, useEffect } from "react"
|
||||
import { toast } from "react-hot-toast"
|
||||
import SaveNotification from "../components/atoms/save-notification"
|
||||
|
||||
type Options = {
|
||||
fn: () => Promise<void>
|
||||
title: string
|
||||
message: string
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
type UseDetectChangeProps = {
|
||||
isDirty: boolean
|
||||
reset: () => void
|
||||
options: Options
|
||||
}
|
||||
|
||||
const useDetectChange = ({ isDirty, reset, options }: UseDetectChangeProps) => {
|
||||
useEffect(() => {
|
||||
const { fn, title, message, icon } = options
|
||||
|
||||
const showToaster = () => {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<SaveNotification
|
||||
toast={t}
|
||||
icon={icon}
|
||||
title={title}
|
||||
message={message}
|
||||
onSave={fn}
|
||||
reset={reset}
|
||||
/>
|
||||
),
|
||||
{
|
||||
position: "bottom-right",
|
||||
duration: Infinity,
|
||||
id: "form-change",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isDirty) {
|
||||
showToaster()
|
||||
} else {
|
||||
toast.dismiss("form-change")
|
||||
}
|
||||
}, [isDirty, options])
|
||||
}
|
||||
|
||||
export default useDetectChange
|
||||
33
packages/admin-ui/ui/src/hooks/use-highlight-search.tsx
Normal file
33
packages/admin-ui/ui/src/hooks/use-highlight-search.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from "react"
|
||||
|
||||
export const useHighlightSearch = (name: string, query: string) => {
|
||||
function getHighlightedSearch(text: string) {
|
||||
const parts = text.split(new RegExp(`(${query})`, "gi"))
|
||||
|
||||
const str: string[] = []
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.toLowerCase() === query.toLowerCase()) {
|
||||
str.push(`<mark class="bg-orange-10">${part}</mark>`)
|
||||
} else {
|
||||
str.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
return str.join("")
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const children = document.getElementsByName(name)
|
||||
|
||||
if (children) {
|
||||
const childArray = Array.from(children)
|
||||
for (const child of childArray) {
|
||||
child.innerHTML = child.innerHTML.replace(
|
||||
child.innerHTML,
|
||||
getHighlightedSearch(child.innerText)
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [query, name])
|
||||
}
|
||||
139
packages/admin-ui/ui/src/hooks/use-imperative-dialog.tsx
Normal file
139
packages/admin-ui/ui/src/hooks/use-imperative-dialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useState } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import Button from "../components/fundamentals/button"
|
||||
import InputField from "../components/molecules/input"
|
||||
import Modal from "../components/molecules/modal"
|
||||
|
||||
const DeleteDialog = ({
|
||||
open,
|
||||
heading,
|
||||
text,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmText = "Yes, confirm",
|
||||
cancelText = "Cancel",
|
||||
extraConfirmation = false,
|
||||
entityName,
|
||||
}) => {
|
||||
const [confirmationString, setConfirmationString] = useState<string>()
|
||||
|
||||
return (
|
||||
<Modal open={open} handleClose={onCancel} isLargeModal={false}>
|
||||
<Modal.Body>
|
||||
<Modal.Content className="!py-large">
|
||||
<div className="flex flex-col">
|
||||
<span className="inter-large-semibold">{heading}</span>
|
||||
<span className="mt-1 inter-base-regular text-grey-50">{text}</span>
|
||||
</div>
|
||||
{extraConfirmation && (
|
||||
<div className="flex flex-col my-base">
|
||||
<span className="mt-1 inter-base-regular text-grey-50">
|
||||
Type the name{" "}
|
||||
<span className="font-semibold">"{entityName}"</span> to
|
||||
confirm.
|
||||
</span>
|
||||
<InputField
|
||||
autoFocus={true}
|
||||
placeholder={entityName}
|
||||
className={"mt-base"}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setConfirmationString(event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal.Content>
|
||||
<Modal.Footer className="border-none !pt-0">
|
||||
<div className="flex justify-end w-full">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="justify-center mr-2 text-small"
|
||||
size="small"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
className="justify-center text-small"
|
||||
variant="nuclear"
|
||||
onClick={onConfirm}
|
||||
disabled={extraConfirmation && entityName !== confirmationString}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
type ImperativeDialogProps =
|
||||
| {
|
||||
heading: string
|
||||
text: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
} & (
|
||||
| {
|
||||
extraConfirmation: true
|
||||
entityName: string
|
||||
}
|
||||
| {
|
||||
extraConfirmation?: false
|
||||
entityName?: never
|
||||
}
|
||||
)
|
||||
|
||||
const useImperativeDialog = () => {
|
||||
return ({
|
||||
heading,
|
||||
text,
|
||||
confirmText,
|
||||
cancelText,
|
||||
extraConfirmation,
|
||||
entityName,
|
||||
}: ImperativeDialogProps): Promise<boolean> => {
|
||||
// We want a promise here so we can "await" the user's action (either confirm or cancel)
|
||||
return new Promise((resolve) => {
|
||||
const mountRoot = createRoot(document.createElement("div"))
|
||||
let open = true
|
||||
|
||||
const onConfirm = () => {
|
||||
open = false
|
||||
resolve(true)
|
||||
// trigger a rerender to close the dialog
|
||||
render()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
open = false
|
||||
resolve(false)
|
||||
// trigger a rerender to close the dialog
|
||||
render()
|
||||
}
|
||||
|
||||
// attach the dialog in the mount node
|
||||
const render = () => {
|
||||
mountRoot.render(
|
||||
<DeleteDialog
|
||||
heading={heading}
|
||||
text={text}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
confirmText={confirmText}
|
||||
cancelText={cancelText}
|
||||
extraConfirmation={extraConfirmation}
|
||||
entityName={entityName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default useImperativeDialog
|
||||
12
packages/admin-ui/ui/src/hooks/use-is-me.tsx
Normal file
12
packages/admin-ui/ui/src/hooks/use-is-me.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useAdminGetSession } from "medusa-react"
|
||||
import { useMemo } from "react"
|
||||
|
||||
export const useIsMe = (userId: string | undefined) => {
|
||||
const { user } = useAdminGetSession()
|
||||
|
||||
const isMe = useMemo(() => {
|
||||
return user?.id === userId
|
||||
}, [user, userId])
|
||||
|
||||
return isMe
|
||||
}
|
||||
21
packages/admin-ui/ui/src/hooks/use-notification.tsx
Normal file
21
packages/admin-ui/ui/src/hooks/use-notification.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react"
|
||||
import { toast } from "react-hot-toast"
|
||||
import Notification, {
|
||||
NotificationTypes,
|
||||
} from "../components/atoms/notification"
|
||||
|
||||
const useNotification = () => {
|
||||
return (title: string, message: string, type: NotificationTypes) => {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<Notification toast={t} type={type} title={title} message={message} />
|
||||
),
|
||||
{
|
||||
position: "top-right",
|
||||
duration: 3000,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default useNotification
|
||||
30
packages/admin-ui/ui/src/hooks/use-observe-width.ts
Normal file
30
packages/admin-ui/ui/src/hooks/use-observe-width.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { MutableRefObject, useEffect, useRef, useState } from "react"
|
||||
|
||||
export const useObserveWidth = (ref: MutableRefObject<any>): number => {
|
||||
const [currentWidth, setCurrentWidth] = useState(0)
|
||||
|
||||
const observer = useRef(
|
||||
new ResizeObserver((entries) => {
|
||||
const { width } = entries[0].contentRect
|
||||
|
||||
setCurrentWidth(width)
|
||||
})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const currentRef = ref.current
|
||||
const currentObserver = observer.current
|
||||
|
||||
if (currentRef && currentObserver) {
|
||||
currentObserver.observe(currentRef)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentObserver && currentRef) {
|
||||
currentObserver.unobserve(currentRef)
|
||||
}
|
||||
}
|
||||
}, [ref, observer])
|
||||
|
||||
return currentWidth
|
||||
}
|
||||
28
packages/admin-ui/ui/src/hooks/use-on-click-outside.tsx
Normal file
28
packages/admin-ui/ui/src/hooks/use-on-click-outside.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { RefObject, useEffect } from "react"
|
||||
|
||||
type Handler = (event: MouseEvent) => void
|
||||
|
||||
const useOnClickOutside = <T extends HTMLElement = HTMLElement>(
|
||||
ref: RefObject<T>,
|
||||
handler: Handler
|
||||
): void => {
|
||||
useEffect(() => {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const el = ref?.current
|
||||
|
||||
if (!el || el.contains(event.target as Node)) {
|
||||
return
|
||||
}
|
||||
|
||||
handler(event)
|
||||
}
|
||||
|
||||
document.addEventListener("click", handleClick)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClick)
|
||||
}
|
||||
}, [ref])
|
||||
}
|
||||
|
||||
export default useOnClickOutside
|
||||
19
packages/admin-ui/ui/src/hooks/use-outside-click.ts
Normal file
19
packages/admin-ui/ui/src/hooks/use-outside-click.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from "react"
|
||||
|
||||
const useOutsideClick = (callback: () => void, ref: any) => {
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (!ref.current.contains(e.target)) {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", handleClickOutside)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickOutside)
|
||||
}
|
||||
}, [ref])
|
||||
}
|
||||
|
||||
export default useOutsideClick
|
||||
228
packages/admin-ui/ui/src/hooks/use-query-filters.ts
Normal file
228
packages/admin-ui/ui/src/hooks/use-query-filters.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import qs from "qs"
|
||||
import { useMemo, useReducer } from "react"
|
||||
import set from "lodash/set"
|
||||
|
||||
/* ********************************************* */
|
||||
/* ******************* TYPES ******************* */
|
||||
/* ********************************************* */
|
||||
|
||||
interface AdditionalFilters {
|
||||
expand?: string
|
||||
fields?: string
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
q?: string
|
||||
limit: number
|
||||
offset: number
|
||||
additionalFilters: AdditionalFilters | null
|
||||
}
|
||||
|
||||
enum Direction {
|
||||
Up = 1,
|
||||
Down = -1,
|
||||
}
|
||||
|
||||
enum FilterActionType {
|
||||
SET_QUERY = "setQuery",
|
||||
SET_FILTERS = "setFilters",
|
||||
SET_OFFSET = "setOffset",
|
||||
SET_DEFAULTS = "setDefaults",
|
||||
}
|
||||
|
||||
type FilterAction =
|
||||
| { type: FilterActionType.SET_QUERY; payload: string | undefined }
|
||||
| { type: FilterActionType.SET_FILTERS; payload: any; path: string }
|
||||
| { type: FilterActionType.SET_OFFSET; payload: number }
|
||||
| {
|
||||
type: FilterActionType.SET_DEFAULTS
|
||||
payload: AdditionalFilters | null
|
||||
}
|
||||
|
||||
const DEFAULT_ALLOWED_PARAMS = ["q", "offset", "limit"]
|
||||
const ADMIN_DEFAULT_PARAMS: Partial<FilterState> = {
|
||||
limit: 15,
|
||||
offset: 0,
|
||||
}
|
||||
|
||||
type QueryObject = Partial<Pick<FilterState, "q">> &
|
||||
Pick<FilterState, "limit" | "offset"> &
|
||||
Record<string, string>
|
||||
|
||||
/* *********************************************** */
|
||||
/* ******************* HELPERS ******************* */
|
||||
/* *********************************************** */
|
||||
|
||||
/*
|
||||
* Transform and merge state values with provided `toQuery` object and
|
||||
* return an object containing params.
|
||||
*/
|
||||
function buildQueryObject(state: FilterState, toQuery: QueryObject) {
|
||||
toQuery = toQuery || {}
|
||||
for (const [key, value] of Object.entries(state)) {
|
||||
if (key === "q") {
|
||||
if (typeof value === "string") {
|
||||
if (value) {
|
||||
toQuery["q"] = value
|
||||
} else {
|
||||
delete toQuery["q"]
|
||||
}
|
||||
}
|
||||
} else if (key === "offset" || key === "limit") {
|
||||
toQuery[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return toQuery
|
||||
}
|
||||
|
||||
/*
|
||||
* Get params from state (transformed) without additional params included.
|
||||
*/
|
||||
function getRepresentationObject(state: FilterState) {
|
||||
return buildQueryObject(state)
|
||||
}
|
||||
|
||||
/*
|
||||
* Get transformed params from state along with additional params.
|
||||
*/
|
||||
function getQueryObject(state: FilterState) {
|
||||
return buildQueryObject(state, { ...state.additionalFilters })
|
||||
}
|
||||
|
||||
/*
|
||||
* Transform query string into object representation.
|
||||
*/
|
||||
function parseQueryString<T>(
|
||||
queryString: string,
|
||||
defaults: Partial<FilterState>
|
||||
): FilterState {
|
||||
const representation = {
|
||||
...ADMIN_DEFAULT_PARAMS,
|
||||
...defaults,
|
||||
} as FilterState
|
||||
|
||||
if (!queryString) {
|
||||
return representation
|
||||
}
|
||||
|
||||
const filters = qs.parse(queryString)
|
||||
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (typeof value !== "string") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (DEFAULT_ALLOWED_PARAMS.includes(key)) {
|
||||
switch (key) {
|
||||
case "offset":
|
||||
case "limit":
|
||||
representation[key] = parseInt(value)
|
||||
break
|
||||
case "q":
|
||||
representation.q = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return representation
|
||||
}
|
||||
|
||||
/** ********************************************************/
|
||||
/** ****************** USE FILTERS HOOK ********************/
|
||||
/** ********************************************************/
|
||||
|
||||
/**
|
||||
* State reducer for the filters hook.
|
||||
*/
|
||||
function reducer(state: FilterState, action: FilterAction): FilterState {
|
||||
if (action.type === FilterActionType.SET_FILTERS) {
|
||||
const nextState = { ...state }
|
||||
// TODO: merge and change refs along the `action.path`
|
||||
set(nextState, action.path, action.payload)
|
||||
|
||||
return nextState
|
||||
}
|
||||
|
||||
if (action.type === FilterActionType.SET_QUERY) {
|
||||
// if the query term has changed reset offset to 0 also
|
||||
return { ...state, q: action.payload, offset: 0 }
|
||||
}
|
||||
|
||||
if (action.type === FilterActionType.SET_OFFSET) {
|
||||
return { ...state, offset: action.payload }
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
/*
|
||||
* Hook returns parsed search params.
|
||||
*/
|
||||
const useQueryFilters = (defaultFilters: Partial<FilterState>) => {
|
||||
const searchString = location.search.substring(1)
|
||||
|
||||
const [state, dispatch] = useReducer(
|
||||
reducer,
|
||||
parseQueryString(searchString, defaultFilters)
|
||||
)
|
||||
|
||||
/* ********* API METHODS ********* */
|
||||
|
||||
const setDefaultFilters = (filters: AdditionalFilters | null) => {
|
||||
dispatch({ type: FilterActionType.SET_DEFAULTS, payload: filters })
|
||||
}
|
||||
|
||||
const paginate = (direction: Direction) => {
|
||||
if (direction === Direction.Up) {
|
||||
const nextOffset = state.offset + state.limit
|
||||
|
||||
dispatch({ type: FilterActionType.SET_OFFSET, payload: nextOffset })
|
||||
} else {
|
||||
const nextOffset = Math.max(state.offset - state.limit, 0)
|
||||
dispatch({ type: FilterActionType.SET_OFFSET, payload: nextOffset })
|
||||
}
|
||||
}
|
||||
|
||||
const setFilters = (path: string, value: any) =>
|
||||
dispatch({ type: FilterActionType.SET_FILTERS, path, payload: value })
|
||||
|
||||
const setQuery = (queryString: string | undefined) =>
|
||||
dispatch({ type: FilterActionType.SET_QUERY, payload: queryString })
|
||||
|
||||
const getQueryString = () =>
|
||||
qs.stringify(getQueryObject(state), { skipNulls: true })
|
||||
|
||||
const getRepresentationString = () => {
|
||||
const obj = getRepresentationObject(state)
|
||||
return qs.stringify(obj, { skipNulls: true })
|
||||
}
|
||||
|
||||
/* ********* VALUES ********* */
|
||||
|
||||
const queryObject = useMemo(() => getQueryObject(state), [state])
|
||||
const representationObject = useMemo(() => getRepresentationObject(state), [
|
||||
state,
|
||||
])
|
||||
const representationString = useMemo(() => getRepresentationString(), [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
filters: {
|
||||
...state,
|
||||
},
|
||||
representationObject,
|
||||
representationString,
|
||||
queryObject,
|
||||
// API
|
||||
paginate,
|
||||
getQueryObject,
|
||||
getQueryString,
|
||||
setQuery,
|
||||
setFilters,
|
||||
setDefaultFilters,
|
||||
} as const
|
||||
}
|
||||
|
||||
export default useQueryFilters
|
||||
20
packages/admin-ui/ui/src/hooks/use-scroll.ts
Normal file
20
packages/admin-ui/ui/src/hooks/use-scroll.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useState } from "react"
|
||||
|
||||
type useScrollProps = {
|
||||
threshold?: number
|
||||
}
|
||||
|
||||
export const useScroll = ({ threshold = 0 }: useScrollProps) => {
|
||||
const [isScrolled, setIsScrolled] = useState(false)
|
||||
|
||||
const scrollListener = e => {
|
||||
const currentScrollY = e.target.scrollTop
|
||||
if (currentScrollY > threshold) {
|
||||
setIsScrolled(true)
|
||||
} else {
|
||||
setIsScrolled(false)
|
||||
}
|
||||
}
|
||||
|
||||
return { isScrolled, scrollListener }
|
||||
}
|
||||
39
packages/admin-ui/ui/src/hooks/use-selection-column.tsx
Normal file
39
packages/admin-ui/ui/src/hooks/use-selection-column.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from "react"
|
||||
import Checkbox from "../components/atoms/checkbox"
|
||||
import Table from "../components/molecules/table"
|
||||
|
||||
const IndeterminateCheckbox = React.forwardRef(
|
||||
({ indeterminate, ...rest }, ref) => {
|
||||
const defaultRef = React.useRef()
|
||||
const resolvedRef = ref || defaultRef
|
||||
|
||||
React.useEffect(() => {
|
||||
resolvedRef.current.indeterminate = indeterminate
|
||||
}, [resolvedRef, indeterminate])
|
||||
|
||||
return (
|
||||
<div onClickCapture={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
className="justify-center"
|
||||
label=""
|
||||
ref={resolvedRef}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export const useSelectionColumn = () => {
|
||||
return {
|
||||
id: "selection",
|
||||
Header: ({ getToggleAllRowsSelectedProps }) => (
|
||||
<IndeterminateCheckbox {...getToggleAllRowsSelectedProps()} />
|
||||
),
|
||||
Cell: ({ row }) => (
|
||||
<Table.Cell className="text-center">
|
||||
<IndeterminateCheckbox {...row.getToggleRowSelectedProps()} />
|
||||
</Table.Cell>
|
||||
),
|
||||
}
|
||||
}
|
||||
24
packages/admin-ui/ui/src/hooks/use-set-search-params.tsx
Normal file
24
packages/admin-ui/ui/src/hooks/use-set-search-params.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from "react"
|
||||
|
||||
/*
|
||||
* Effect hook which reflects `queryObject` k/v in the url.
|
||||
*/
|
||||
function useSetSearchParams(queryObject: Record<string, string | number>) {
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
|
||||
for (const k of url.searchParams.keys()) {
|
||||
if (!(k in queryObject)) {
|
||||
url.searchParams.delete(k)
|
||||
}
|
||||
}
|
||||
|
||||
for (const k in queryObject) {
|
||||
url.searchParams.set(k, queryObject[k].toString())
|
||||
}
|
||||
|
||||
window.history.replaceState(null, "", url.toString())
|
||||
}, [queryObject])
|
||||
}
|
||||
|
||||
export default useSetSearchParams
|
||||
46
packages/admin-ui/ui/src/hooks/use-toggle-state.ts
Normal file
46
packages/admin-ui/ui/src/hooks/use-toggle-state.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
|
||||
type StateType = [boolean, () => void, () => void, () => void] & {
|
||||
state: boolean
|
||||
open: () => void
|
||||
close: () => void
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param initialState - boolean
|
||||
* @returns An array like object with `state`, `open`, `close`, and `toggle` properties
|
||||
* to allow both object and array destructuring
|
||||
*
|
||||
* ```
|
||||
* const [showModal, openModal, closeModal, toggleModal] = useToggleState()
|
||||
* // or
|
||||
* const { state, open, close, toggle } = useToggleState()
|
||||
* ```
|
||||
*/
|
||||
|
||||
const useToggleState = (initialState = false) => {
|
||||
const [state, setState] = React.useState<boolean>(initialState)
|
||||
|
||||
const close = () => {
|
||||
setState(false)
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
setState(true)
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
setState((state) => !state)
|
||||
}
|
||||
|
||||
const hookData = [state, open, close, toggle] as StateType
|
||||
hookData.state = state
|
||||
hookData.open = open
|
||||
hookData.close = close
|
||||
hookData.toggle = toggle
|
||||
return hookData
|
||||
}
|
||||
|
||||
export default useToggleState
|
||||
25
packages/admin-ui/ui/src/hooks/use-window-dimensions.ts
Normal file
25
packages/admin-ui/ui/src/hooks/use-window-dimensions.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export const useWindowDimensions = () => {
|
||||
const [dimensions, setDimensions] = useState({
|
||||
height: window.innerHeight,
|
||||
width: window.innerWidth,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setDimensions({
|
||||
height: window.innerHeight,
|
||||
width: window.innerWidth,
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener("resize", handleResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return dimensions
|
||||
}
|
||||
Reference in New Issue
Block a user