From c5e07e7c82b2fb5f9e74ae1b65ac09ba10c2e896 Mon Sep 17 00:00:00 2001
From: ysandler
Date: Tue, 29 Apr 2025 19:16:44 -0500
Subject: [PATCH] feat: approve hold and check out from hold
---
eslint.config.mjs | 4 +-
src/collections/Checkouts/HoldRequests.ts | 7 +-
.../Manage/ApproveHoldRequestModal.tsx | 19 +-
.../Manage/CheckoutFromHoldModal.tsx | 89 +++++++
src/components/Manage/HoldRequests.tsx | 36 ++-
src/hooks/usePreventScroll.tsx | 224 ++++++++----------
src/serverActions/ApproveHoldRequests.ts | 34 +++
src/serverActions/CheckoutFromHoldRequests.ts | 30 +++
8 files changed, 296 insertions(+), 147 deletions(-)
create mode 100644 src/components/Manage/CheckoutFromHoldModal.tsx
create mode 100644 src/serverActions/ApproveHoldRequests.ts
create mode 100644 src/serverActions/CheckoutFromHoldRequests.ts
diff --git a/eslint.config.mjs b/eslint.config.mjs
index e2e6877..d768189 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -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',
{
diff --git a/src/collections/Checkouts/HoldRequests.ts b/src/collections/Checkouts/HoldRequests.ts
index 7e204c4..df553dd 100644
--- a/src/collections/Checkouts/HoldRequests.ts
+++ b/src/collections/Checkouts/HoldRequests.ts
@@ -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
}
}]
}
diff --git a/src/components/Manage/ApproveHoldRequestModal.tsx b/src/components/Manage/ApproveHoldRequestModal.tsx
index 7b3ad7e..56b5725 100644
--- a/src/components/Manage/ApproveHoldRequestModal.tsx
+++ b/src/components/Manage/ApproveHoldRequestModal.tsx
@@ -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) => {
- const acceptHoldPayload = {
+ const onSubmit = async (values: z.infer) => {
+ 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) => {
/>
{hold.isHolding ? (
-
- Hold Until
+
+ {userName} Until
) : (
@@ -48,9 +52,7 @@ const HoldRequestNotifications = (props: Props) => {
-
- {`${userRequested.firstName} ${userRequested.lastName}`}
-
+ {userName}
)}
@@ -75,14 +77,22 @@ const HoldRequestNotifications = (props: Props) => {
onClick={() => setOpenedModalId(hold.id)}
>
- Approve
+ {hold.isHolding ? 'Checkout' : 'Approve'}
-
+ {hold.isHolding ? (
+ setOpenedModalId(null)}
+ holdRequest={hold}
+ />
+ ) : (
+ setOpenedModalId(null)}
+ holdRequest={hold}
+ />
+ )}
diff --git a/src/hooks/usePreventScroll.tsx b/src/hooks/usePreventScroll.tsx
index 744eb46..986af5e 100644
--- a/src/hooks/usePreventScroll.tsx
+++ b/src/hooks/usePreventScroll.tsx
@@ -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(
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)
- );
+ )
}
diff --git a/src/serverActions/ApproveHoldRequests.ts b/src/serverActions/ApproveHoldRequests.ts
new file mode 100644
index 0000000..93be614
--- /dev/null
+++ b/src/serverActions/ApproveHoldRequests.ts
@@ -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 => {
+ 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
diff --git a/src/serverActions/CheckoutFromHoldRequests.ts b/src/serverActions/CheckoutFromHoldRequests.ts
new file mode 100644
index 0000000..d50fec6
--- /dev/null
+++ b/src/serverActions/CheckoutFromHoldRequests.ts
@@ -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 => {
+ 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