feat(admin,admin-ui,medusa): Add Medusa Admin plugin (#3334)

This commit is contained in:
Kasper Fabricius Kristensen
2023-03-03 10:09:16 +01:00
committed by GitHub
parent d6b1ad1ccd
commit 40de54b010
928 changed files with 85441 additions and 384 deletions

View 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"
}

View 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

View 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 }
}

View 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
}

View 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

View 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])
}

View 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

View 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
}

View 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

View 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
}

View 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

View 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

View 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

View 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 }
}

View 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>
),
}
}

View 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

View 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

View 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
}