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) => { /> + + + + ) +} + +export default CheckoutFromHoldModal diff --git a/src/components/Manage/HoldRequests.tsx b/src/components/Manage/HoldRequests.tsx index b24748b..efc7daf 100644 --- a/src/components/Manage/HoldRequests.tsx +++ b/src/components/Manage/HoldRequests.tsx @@ -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 | 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 (
  • @@ -37,10 +41,10 @@ const HoldRequestNotifications = (props: Props) => { {authors.map((a) => a.lf).join(' | ')}

    {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 - 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