feat: approve hold and check out from hold

This commit is contained in:
Yehoshua Sandler 2025-04-29 19:16:44 -05:00
parent 27c29949da
commit c5e07e7c82
8 changed files with 296 additions and 147 deletions

View File

@ -14,9 +14,9 @@ const eslintConfig = [
{
rules: {
'@next/next/no-img-element': 'off',
'@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-empty-object-type': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{

View File

@ -1,4 +1,3 @@
import { HoldRequest } from "@/payload-types";
import { CollectionConfig } from "payload";
const HoldRequests: CollectionConfig = {
@ -56,8 +55,8 @@ const HoldRequests: CollectionConfig = {
hooks:
{
beforeValidate: [({ data, originalDoc }) => {
if (data?.isCheckedOut && !data.copy) return originalDoc
if (originalDoc.isCheckedOut) return originalDoc
if (data?.isCheckedOut && !originalDoc.copy) return originalDoc
if (originalDoc.isCheckedOut && !data?.isCheckedOut) return originalDoc
}],
afterChange: [({ value, data, req }) => {
@ -69,6 +68,8 @@ const HoldRequests: CollectionConfig = {
copy: data?.copy,
}
})
return data
}
}]
}

View File

@ -26,6 +26,8 @@ import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { Button } from '../ui/button'
import { Select } from '@headlessui/react'
import approveHoldRequest from '@/serverActions/ApproveHoldRequests'
import { toast } from 'sonner'
const formSchema = z
.object({
@ -90,18 +92,21 @@ const ApproveHoldRequestModal = (props: Props) => {
setAvailableCopies(availableCopies)
})
.catch((err) => {
availableCopies
console.error(err)
})
.finally(() => {})
}, [availableCopies, setAvailableCopies, isOpen])
}, [availableCopies, setAvailableCopies, isOpen, book.id, repository.id])
const onSubmit = (values: z.infer<typeof formSchema>) => {
const acceptHoldPayload = {
const onSubmit = async (values: z.infer<typeof formSchema>) => {
const updateRequest = await approveHoldRequest({
holdRequestId: hold.id,
copyId: parseInt(values.copyId, 10),
untilDate: values.untilDate,
}
console.log(acceptHoldPayload)
})
if (!updateRequest) toast('There was an issue approving that request.')
else toast('Approved')
onOpenChange()
}
@ -162,7 +167,7 @@ const ApproveHoldRequestModal = (props: Props) => {
/>
<Button
className="mt-4 inline-flex items-center justify-center self-end rounded-lg bg-black px-4 py-2 text-sm font-medium text-zinc-50 dark:bg-white dark:text-zinc-900"
className="mt-4 cursor-pointer inline-flex items-center justify-center self-end rounded-lg bg-black px-4 py-2 text-sm font-medium text-zinc-50 dark:bg-white dark:text-zinc-900"
type="submit"
>
Approve Hold

View File

@ -0,0 +1,89 @@
'use client'
import {
Dialog,
DialogDescription,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog'
import { Book, HoldRequest, User } from '@/payload-types'
import { Variants, Transition } from 'motion/react'
import { Button } from '../ui/button'
import { toast } from 'sonner'
import checkoutFromHoldRequest from '@/serverActions/CheckoutFromHoldRequests'
type Props = {
holdRequest: HoldRequest
isOpen: boolean
onOpenChange: () => void
}
const CheckoutFromHoldModal = (props: Props) => {
const { holdRequest, isOpen, onOpenChange } = props
const customVariants: Variants = {
initial: {
scale: 0.9,
filter: 'blur(10px)',
y: '100%',
},
animate: {
scale: 1,
filter: 'blur(0px)',
y: 0,
},
}
const customTransition: Transition = {
type: 'spring',
bounce: 0,
duration: 0.4,
}
const hold = holdRequest as HoldRequest
const book = hold.book as Book
const userRequested = hold.userRequested as User
const onSubmit = async () => {
const updateRequest = await checkoutFromHoldRequest({
holdRequestId: hold.id,
})
if (!updateRequest) toast('There was an issue checking out that hold request.')
else toast('Checked out')
onOpenChange()
}
return (
<Dialog
open={isOpen}
onOpenChange={onOpenChange}
variants={customVariants}
transition={customTransition}
>
<DialogContent className="w-full max-w-md bg-white p-6 dark:bg-zinc-900">
<DialogHeader>
<DialogTitle className="text-zinc-900 dark:text-white">
Checkout held for for {`${userRequested.firstName} ${userRequested.lastName}`}?
</DialogTitle>
<DialogDescription className="text-zinc-600 dark:text-zinc-400">
{book.title}
</DialogDescription>
</DialogHeader>
<Button
className="mt-4 cursor-pointer inline-flex items-center justify-center self-end rounded-lg bg-black px-4 py-2 text-sm font-medium text-zinc-50 dark:bg-white dark:text-zinc-900"
type="button"
onClick={onSubmit}
>
Checkout Held Copy
</Button>
<DialogClose />
</DialogContent>
</Dialog>
)
}
export default CheckoutFromHoldModal

View File

@ -3,8 +3,8 @@ import { Author, Book, HoldRequest, Repository, User } from '@/payload-types'
import { Button } from '../ui/button'
import Image from 'next/image'
import ApproveHoldRequestModal from './ApproveHoldRequestModal'
import { DialogTrigger } from '../ui/dialog'
import { useState } from 'react'
import CheckoutFromHoldModal from './CheckoutFromHoldModal'
type Props = {
repos: PaginatedDocs<Repository> | null
@ -24,7 +24,11 @@ const HoldRequestNotifications = (props: Props) => {
const book = hold.book as Book
const authors = book.authors as Author[]
const dateRequested = hold.dateRequested ? new Date(hold.dateRequested) : new Date()
const holdingUntilDate = hold.holdingUntilDate
? new Date(hold.holdingUntilDate)
: new Date()
const userRequested = hold.userRequested as User
const userName = `${userRequested.firstName} ${userRequested.lastName}`
return (
<li key={hold.id} className="col-span-1 rounded-lg shadow-sm border border-accent">
@ -37,10 +41,10 @@ const HoldRequestNotifications = (props: Props) => {
{authors.map((a) => a.lf).join(' | ')}
</p>
{hold.isHolding ? (
<span>
<span className="mr-0.5 text-xs">Hold Until</span>
<span className="text-wrap">
<span className="mr-0.5 text-xs">{userName} Until</span>
<time className="inline-flex shrink-0 items-center rounded-full bg-background/20 px-1.5 py-0.5 text-xs font-medium text-emerald-600 ring-1 ring-emerald-600/20 ring-inset">
{hold.holdingUntilDate}
{holdingUntilDate.toLocaleDateString()}
</time>
</span>
) : (
@ -48,9 +52,7 @@ const HoldRequestNotifications = (props: Props) => {
<time className="inline-flex shrink-0 items-center rounded-full bg-background/20 px-1.5 py-0.5 text-xs font-medium text-amber-500 ring-1 ring-amber-600/20 ring-inset">
{dateRequested.toLocaleDateString()}
</time>
<span className="text-xs">
{`${userRequested.firstName} ${userRequested.lastName}`}
</span>
<span className="text-xs">{userName}</span>
</span>
)}
</div>
@ -75,14 +77,22 @@ const HoldRequestNotifications = (props: Props) => {
onClick={() => setOpenedModalId(hold.id)}
>
<Image width={24} height={24} src="/images/approve.svg" alt="approve hold" />
<span>Approve</span>
<span>{hold.isHolding ? 'Checkout' : 'Approve'}</span>
</Button>
<ApproveHoldRequestModal
isOpen={openedModalId === hold.id}
onOpenChange={setOpenedModalId}
holdRequest={hold}
/>
{hold.isHolding ? (
<CheckoutFromHoldModal
isOpen={openedModalId === hold.id}
onOpenChange={() => setOpenedModalId(null)}
holdRequest={hold}
/>
) : (
<ApproveHoldRequestModal
isOpen={openedModalId === hold.id}
onOpenChange={() => setOpenedModalId(null)}
holdRequest={hold}
/>
)}
</div>
</div>
</li>

View File

@ -1,11 +1,11 @@
import { useEffect, useLayoutEffect } from 'react';
import { useEffect, useLayoutEffect } from 'react'
function isMac(): boolean | undefined {
return testPlatform(/^Mac/);
return testPlatform(/^Mac/)
}
function isIPhone(): boolean | undefined {
return testPlatform(/^iPhone/);
return testPlatform(/^iPhone/)
}
function isIPad(): boolean | undefined {
@ -13,60 +13,57 @@ function isIPad(): boolean | undefined {
testPlatform(/^iPad/) ||
// iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support.
(isMac() && navigator.maxTouchPoints > 1)
);
)
}
function isIOS(): boolean | undefined {
return isIPhone() || isIPad();
return isIPhone() || isIPad()
}
function testPlatform(re: RegExp): boolean | undefined {
return typeof window !== 'undefined' && window.navigator != null
? re.test(window.navigator.platform)
: undefined;
: undefined
}
const KEYBOARD_BUFFER = 24;
const KEYBOARD_BUFFER = 24
export const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
export const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
interface PreventScrollOptions {
/** Whether the scroll lock is disabled. */
isDisabled?: boolean;
focusCallback?: () => void;
isDisabled?: boolean
focusCallback?: () => void
}
function chain(...callbacks: any[]): (...args: any[]) => void {
return (...args: any[]) => {
for (let callback of callbacks) {
for (const callback of callbacks) {
if (typeof callback === 'function') {
callback(...args);
callback(...args)
}
}
};
}
}
// @ts-ignore
const visualViewport = typeof document !== 'undefined' && window.visualViewport;
const visualViewport = typeof document !== 'undefined' && window.visualViewport
export function isScrollable(node: Element): boolean {
let style = window.getComputedStyle(node);
return /(auto|scroll)/.test(
style.overflow + style.overflowX + style.overflowY
);
const style = window.getComputedStyle(node)
return /(auto|scroll)/.test(style.overflow + style.overflowX + style.overflowY)
}
export function getScrollParent(node: Element): Element {
if (isScrollable(node)) {
node = node.parentElement as HTMLElement;
node = node.parentElement as HTMLElement
}
while (node && !isScrollable(node)) {
node = node.parentElement as HTMLElement;
node = node.parentElement as HTMLElement
}
return node || document.scrollingElement || document.documentElement;
return node || document.scrollingElement || document.documentElement
}
// HTML input types that do not cause the software keyboard to appear.
@ -80,11 +77,11 @@ const nonTextInputTypes = new Set([
'button',
'submit',
'reset',
]);
])
// The number of active usePreventScroll calls. Used to determine whether to revert back to the original page style/scroll position
let preventScrollCount = 0;
let restore: () => void;
let preventScrollCount = 0
let restore: () => void
/**
* Prevents scrolling on the document body on mount, and
@ -92,27 +89,27 @@ let restore: () => void;
* shift due to the scrollbars disappearing.
*/
export function usePreventScroll(options: PreventScrollOptions = {}) {
let { isDisabled } = options;
const { isDisabled } = options
useIsomorphicLayoutEffect(() => {
if (isDisabled) {
return;
return
}
preventScrollCount++;
preventScrollCount++
if (preventScrollCount === 1) {
if (isIOS()) {
restore = preventScrollMobileSafari();
restore = preventScrollMobileSafari()
}
}
return () => {
preventScrollCount--;
preventScrollCount--
if (preventScrollCount === 0) {
restore?.();
restore?.()
}
};
}, [isDisabled]);
}
}, [isDisabled])
}
// Mobile Safari is a whole different beast. Even with overflow: hidden,
@ -142,79 +139,72 @@ export function usePreventScroll(options: PreventScrollOptions = {}) {
// 6. As a last resort, handle window scroll events, and scroll back to the top. This can happen when attempting
// to navigate to an input with the next/previous buttons that's outside a modal.
function preventScrollMobileSafari() {
let scrollable: Element;
let lastY = 0;
let onTouchStart = (e: TouchEvent) => {
let scrollable: Element
let lastY = 0
const onTouchStart = (e: TouchEvent) => {
// Store the nearest scrollable parent element from the element that the user touched.
scrollable = getScrollParent(e.target as Element);
if (
scrollable === document.documentElement &&
scrollable === document.body
) {
return;
scrollable = getScrollParent(e.target as Element)
if (scrollable === document.documentElement && scrollable === document.body) {
return
}
lastY = e.changedTouches[0].pageY;
};
lastY = e.changedTouches[0].pageY
}
let onTouchMove = (e: TouchEvent) => {
const onTouchMove = (e: TouchEvent) => {
// Prevent scrolling the window.
if (
!scrollable ||
scrollable === document.documentElement ||
scrollable === document.body
) {
e.preventDefault();
return;
if (!scrollable || scrollable === document.documentElement || scrollable === document.body) {
e.preventDefault()
return
}
// Prevent scrolling up when at the top and scrolling down when at the bottom
// of a nested scrollable area, otherwise mobile Safari will start scrolling
// the window instead. Unfortunately, this disables bounce scrolling when at
// the top but it's the best we can do.
let y = e.changedTouches[0].pageY;
let scrollTop = scrollable.scrollTop;
let bottom = scrollable.scrollHeight - scrollable.clientHeight;
const y = e.changedTouches[0].pageY
const scrollTop = scrollable.scrollTop
const bottom = scrollable.scrollHeight - scrollable.clientHeight
if (bottom === 0) {
return;
return
}
if ((scrollTop <= 0 && y > lastY) || (scrollTop >= bottom && y < lastY)) {
e.preventDefault();
e.preventDefault()
}
lastY = y;
};
lastY = y
}
let onTouchEnd = (e: TouchEvent) => {
let target = e.target as HTMLElement;
const onTouchEnd = (e: TouchEvent) => {
const target = e.target as HTMLElement
// Apply this change if we're not already focused on the target element
if (isInput(target) && target !== document.activeElement) {
e.preventDefault();
e.preventDefault()
// Apply a transform to trick Safari into thinking the input is at the top of the page
// so it doesn't try to scroll it into view. When tapping on an input, this needs to
// be done before the "focus" event, so we have to focus the element ourselves.
target.style.transform = 'translateY(-2000px)';
target.focus();
target.style.transform = 'translateY(-2000px)'
target.focus()
requestAnimationFrame(() => {
target.style.transform = '';
});
target.style.transform = ''
})
}
};
}
let onFocus = (e: FocusEvent) => {
let target = e.target as HTMLElement;
const onFocus = (e: FocusEvent) => {
const target = e.target as HTMLElement
if (isInput(target)) {
// Transform also needs to be applied in the focus event in cases where focus moves
// other than tapping on an input directly, e.g. the next/previous buttons in the
// software keyboard. In these cases, it seems applying the transform in the focus event
// is good enough, whereas when tapping an input, it must be done before the focus event. 🤷‍♂️
target.style.transform = 'translateY(-2000px)';
target.style.transform = 'translateY(-2000px)'
requestAnimationFrame(() => {
target.style.transform = '';
target.style.transform = ''
// This will have prevented the browser from scrolling the focused element into view,
// so we need to do this ourselves in a way that doesn't cause the whole page to scroll.
@ -223,48 +213,44 @@ function preventScrollMobileSafari() {
// If the keyboard is already visible, do this after one additional frame
// to wait for the transform to be removed.
requestAnimationFrame(() => {
scrollIntoView(target);
});
scrollIntoView(target)
})
} else {
// Otherwise, wait for the visual viewport to resize before scrolling so we can
// measure the correct position to scroll to.
visualViewport.addEventListener(
'resize',
() => scrollIntoView(target),
{ once: true }
);
visualViewport.addEventListener('resize', () => scrollIntoView(target), { once: true })
}
}
});
})
}
};
}
let onWindowScroll = () => {
const onWindowScroll = () => {
// Last resort. If the window scrolled, scroll it back to the top.
// It should always be at the top because the body will have a negative margin (see below).
window.scrollTo(0, 0);
};
window.scrollTo(0, 0)
}
// Record the original scroll position so we can restore it.
// Then apply a negative margin to the body to offset it by the scroll position. This will
// enable us to scroll the window to the top, which is required for the rest of this to work.
let scrollX = window.pageXOffset;
let scrollY = window.pageYOffset;
const scrollX = window.pageXOffset
const scrollY = window.pageYOffset
let restoreStyles = chain(
const restoreStyles = chain(
setStyle(
document.documentElement,
'paddingRight',
`${window.innerWidth - document.documentElement.clientWidth}px`
)
`${window.innerWidth - document.documentElement.clientWidth}px`,
),
// setStyle(document.documentElement, 'overflow', 'hidden'),
// setStyle(document.body, 'marginTop', `-${scrollY}px`),
);
)
// Scroll to the top. The negative margin on the body will make this appear the same.
window.scrollTo(0, 0);
window.scrollTo(0, 0)
let removeEvents = chain(
const removeEvents = chain(
addEvent(document, 'touchstart', onTouchStart, {
passive: false,
capture: true,
@ -278,33 +264,29 @@ function preventScrollMobileSafari() {
capture: true,
}),
addEvent(document, 'focus', onFocus, true),
addEvent(window, 'scroll', onWindowScroll)
);
addEvent(window, 'scroll', onWindowScroll),
)
return () => {
// Restore styles and scroll the page back to where it was.
restoreStyles();
removeEvents();
window.scrollTo(scrollX, scrollY);
};
restoreStyles()
removeEvents()
window.scrollTo(scrollX, scrollY)
}
}
// Sets a CSS property on an element, and returns a function to revert it to the previous value.
function setStyle(
element: HTMLElement,
style: keyof React.CSSProperties,
value: string
) {
function setStyle(element: HTMLElement, style: keyof React.CSSProperties, value: string) {
// https://github.com/microsoft/TypeScript/issues/17827#issuecomment-391663310
// @ts-ignore
let cur = element.style[style];
const cur = element.style[style]
// @ts-ignore
element.style[style] = value;
element.style[style] = value
return () => {
// @ts-ignore
element.style[style] = cur;
};
element.style[style] = cur
}
}
// Adds an event listener to an element, and returns a function to remove it.
@ -312,49 +294,47 @@ function addEvent<K extends keyof GlobalEventHandlersEventMap>(
target: EventTarget,
event: K,
handler: (this: Document, ev: GlobalEventHandlersEventMap[K]) => any,
options?: boolean | AddEventListenerOptions
options?: boolean | AddEventListenerOptions,
) {
// @ts-ignore
target.addEventListener(event, handler, options);
target.addEventListener(event, handler, options)
return () => {
// @ts-ignore
target.removeEventListener(event, handler, options);
};
target.removeEventListener(event, handler, options)
}
}
function scrollIntoView(target: Element) {
let root = document.scrollingElement || document.documentElement;
const root = document.scrollingElement || document.documentElement
while (target && target !== root) {
// Find the parent scrollable element and adjust the scroll position if the target is not already in view.
let scrollable = getScrollParent(target);
const scrollable = getScrollParent(target)
if (
scrollable !== document.documentElement &&
scrollable !== document.body &&
scrollable !== target
) {
let scrollableTop = scrollable.getBoundingClientRect().top;
let targetTop = target.getBoundingClientRect().top;
let targetBottom = target.getBoundingClientRect().bottom;
const scrollableTop = scrollable.getBoundingClientRect().top
const targetTop = target.getBoundingClientRect().top
const targetBottom = target.getBoundingClientRect().bottom
// Buffer is needed for some edge cases
const keyboardHeight =
scrollable.getBoundingClientRect().bottom + KEYBOARD_BUFFER;
const keyboardHeight = scrollable.getBoundingClientRect().bottom + KEYBOARD_BUFFER
if (targetBottom > keyboardHeight) {
scrollable.scrollTop += targetTop - scrollableTop;
scrollable.scrollTop += targetTop - scrollableTop
}
}
// @ts-ignore
target = scrollable.parentElement;
target = scrollable.parentElement
}
}
export function isInput(target: Element) {
return (
(target instanceof HTMLInputElement &&
!nonTextInputTypes.has(target.type)) ||
(target instanceof HTMLInputElement && !nonTextInputTypes.has(target.type)) ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
);
)
}

View File

@ -0,0 +1,34 @@
'use server'
import { getPayload } from 'payload'
import config from '@/payload.config'
import { HoldRequest } from '@/payload-types'
type Props = {
holdRequestId: number,
copyId: number,
untilDate: string,
}
export const approveHoldRequest = async (props: Props): Promise<HoldRequest | null> => {
const { holdRequestId, copyId, untilDate } = props
const payloadConfig = await config
const payload = await getPayload({ config: payloadConfig })
try {
const updatedHold = await payload.update({
collection: 'holdRequests',
id: holdRequestId,
data: {
copy: copyId,
holdingUntilDate: untilDate,
isHolding: true,
}
})
return updatedHold
} catch (_) {
return null
}
}
export default approveHoldRequest

View File

@ -0,0 +1,30 @@
'use server'
import { getPayload } from 'payload'
import config from '@/payload.config'
import { HoldRequest } from '@/payload-types'
type Props = {
holdRequestId: number
}
export const checkoutFromHoldRequest = async (props: Props): Promise<HoldRequest | null> => {
const { holdRequestId } = props
const payloadConfig = await config
const payload = await getPayload({ config: payloadConfig })
try {
const updatedHold = await payload.update({
collection: 'holdRequests',
id: holdRequestId,
data: {
isCheckedOut: true,
}
})
return updatedHold
} catch (_) {
return null
}
}
export default checkoutFromHoldRequest