From 7421256cb6fa2d9f34778d13680b978fe16c668e Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Mon, 8 Apr 2024 21:51:18 -0500 Subject: [PATCH] Warn before throwing out uncommitted form changes --- src/app/admin/uploads/[uploadPath]/page.tsx | 2 +- src/components/FieldSetWithStatus.tsx | 8 +++ src/photo/form/PhotoForm.tsx | 54 +++++++++++---------- src/photo/form/index.ts | 22 +++++++++ src/photo/server.ts | 14 ++++-- src/site/globals.css | 4 +- src/utility/usePreventNavigation.ts | 30 ++++++++++++ 7 files changed, 102 insertions(+), 32 deletions(-) create mode 100644 src/utility/usePreventNavigation.ts diff --git a/src/app/admin/uploads/[uploadPath]/page.tsx b/src/app/admin/uploads/[uploadPath]/page.tsx index a2faee67..1092ed7d 100644 --- a/src/app/admin/uploads/[uploadPath]/page.tsx +++ b/src/app/admin/uploads/[uploadPath]/page.tsx @@ -16,7 +16,7 @@ export default async function UploadPage({ params: { uploadPath } }: Params) { const { blobId, photoFormExif, - } = await extractExifDataFromBlobPath(uploadPath); + } = await extractExifDataFromBlobPath(uploadPath, true); if (!photoFormExif) { redirect(PATH_ADMIN); } diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx index 3c7bdd7f..6e123e97 100644 --- a/src/components/FieldSetWithStatus.tsx +++ b/src/components/FieldSetWithStatus.tsx @@ -13,6 +13,7 @@ export default function FieldSetWithStatus({ note, error, value, + isModified, onChange, selectOptions, selectOptionsDefaultLabel, @@ -31,6 +32,7 @@ export default function FieldSetWithStatus({ note?: string error?: string value: string + isModified?: boolean onChange?: (value: string) => void selectOptions?: { value: string, label: string }[] selectOptionsDefaultLabel?: string @@ -57,6 +59,12 @@ export default function FieldSetWithStatus({ ({note}) } + {isModified && + + * + } {error && {error} diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index 54b72d49..b0af5f7b 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { FORM_METADATA_ENTRIES, PhotoFormData, convertFormKeysToLabels, formHasTextContent, + getChangedFormFields, getFormErrors, isFormValid, } from '.'; @@ -16,10 +17,6 @@ import Link from 'next/link'; import { clsx } from 'clsx/lite'; import CanvasBlurCapture from '@/components/CanvasBlurCapture'; import { PATH_ADMIN_PHOTOS, PATH_ADMIN_UPLOADS } from '@/site/paths'; -import { - generateLocalNaivePostgresString, - generateLocalPostgresString, -} from '@/utility/date'; import { toastSuccess, toastWarning } from '@/toast'; import { getDimensionsFromSize } from '@/utility/size'; import ImageBlurFallback from '@/components/ImageBlurFallback'; @@ -31,6 +28,7 @@ import AiButton from '../ai/AiButton'; import Spinner from '@/components/Spinner'; import { getNextImageUrlForRequest } from '@/services/next-image'; import useDelay from '@/utility/useDelay'; +import usePreventNavigation from '@/utility/usePreventNavigation'; const THUMBNAIL_SIZE = 300; @@ -63,6 +61,21 @@ export default function PhotoForm({ const [blurError, setBlurError] = useState(); const [hasBlurData, setHasBlurData] = useState(false); + + const changedFormKeys = useMemo(() => + getChangedFormFields(initialPhotoForm, formData), + [initialPhotoForm, formData]); + const formHasChanged = changedFormKeys.length > 0; + const onlyChangedFieldIsBlurData = + changedFormKeys.length === 1 && + changedFormKeys[0] === 'blurData'; + + usePreventNavigation(formHasChanged && !onlyChangedFieldIsBlurData); + + const canFormBeSubmitted = + (type === 'create' || formHasChanged) && + isFormValid(formData) && + !aiContent?.isLoading; const didLoad1000msAgo = useDelay(1000); @@ -112,22 +125,6 @@ export default function PhotoForm({ height, } = getDimensionsFromSize(THUMBNAIL_SIZE, formData.aspectRatio); - // Generate local date strings when - // none can be extracted from EXIF - useEffect(() => { - if (!formData.takenAt || !formData.takenAtNaive) { - setFormData(data => ({ - ...data, - ...!formData.takenAt && { - takenAt: generateLocalPostgresString(), - }, - ...!formData.takenAtNaive && { - takenAtNaive: generateLocalNaivePostgresString(), - }, - })); - } - }, [formData.takenAt, formData.takenAtNaive]); - const url = formData.url ?? ''; const updateBlurData = useCallback((blurData: string) => { @@ -321,6 +318,7 @@ export default function PhotoForm({ note={note} error={formErrors[key]} value={formData[key] ?? ''} + isModified={changedFormKeys.includes(key)} onChange={value => { const formUpdated = { ...formData, [key]: value }; setFormData(formUpdated); @@ -357,10 +355,7 @@ export default function PhotoForm({ {/* Actions */}
{type === 'create' ? 'Create' : 'Update'} +
diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts index 63ab8698..5c772333 100644 --- a/src/photo/form/index.ts +++ b/src/photo/form/index.ts @@ -3,6 +3,8 @@ import { Photo, PhotoDbInsert, PhotoExif } from '..'; import { convertTimestampToNaivePostgresString, convertTimestampWithOffsetToPostgresString, + generateLocalNaivePostgresString, + generateLocalPostgresString, } from '@/utility/date'; import { getAspectRatioFromExif, getOffsetFromExif } from '@/utility/exif'; import { toFixedNumber } from '@/utility/number'; @@ -284,5 +286,25 @@ export const convertFormDataToPhotoDbInsert = ( ? parseFloat(photoForm.priorityOrder) : undefined, hidden: photoForm.hidden === 'true', + ...generateTakenAtFields(photoForm), }; }; + +export const getChangedFormFields = ( + original: Partial, + current: Partial, +) => { + return Object + .keys(current) + .filter(key => + (original[key as keyof PhotoFormData] ?? '') !== + (current[key as keyof PhotoFormData] ?? '') + ) as (keyof PhotoFormData)[]; +}; + +export const generateTakenAtFields = ( + form?: Partial +): { takenAt: string, takenAtNaive: string } => ({ + takenAt: form?.takenAt || generateLocalPostgresString(), + takenAtNaive: form?.takenAtNaive || generateLocalNaivePostgresString(), +}); diff --git a/src/photo/server.ts b/src/photo/server.ts index ce40fe79..3a606a06 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -2,7 +2,7 @@ import { getExtensionFromStorageUrl, getIdFromStorageUrl, } from '@/services/storage'; -import { convertExifToFormData } from '@/photo/form'; +import { convertExifToFormData, generateTakenAtFields } from '@/photo/form'; import { getFujifilmSimulationFromMakerNote, isExifForFujifilm, @@ -12,7 +12,8 @@ import { PhotoFormData } from './form'; import { FilmSimulation } from '@/simulation'; export const extractExifDataFromBlobPath = async ( - blobPath: string + blobPath: string, + includeInitialPhotoFields?: boolean, ): Promise<{ blobId?: string photoFormExif?: Partial @@ -55,9 +56,14 @@ export const extractExifDataFromBlobPath = async ( blobId, ...exifData && { photoFormExif: { + ...includeInitialPhotoFields && { + ...generateTakenAtFields(), + hidden: 'false', + favorite: 'false', + extension, + url, + }, ...convertExifToFormData(exifData, filmSimulation), - extension, - url, }, }, }; diff --git a/src/site/globals.css b/src/site/globals.css index 0c7a0614..f5cb2d5c 100644 --- a/src/site/globals.css +++ b/src/site/globals.css @@ -96,7 +96,9 @@ @apply text-invert bg-gray-900 dark:bg-gray-100 - disabled:bg-gray-900 disabled:dark:bg-gray-100 + disabled:text-gray-300 disabled:dark:text-gray-700 + disabled:bg-white disabled:dark:bg-black + disabled:border-gray-200 disabled:dark:border-gray-700 border-gray-900 dark:border-gray-100 active:bg-gray-700 active:border-gray-700 active:dark:bg-gray-300 active:dark:border-gray-300 diff --git a/src/utility/usePreventNavigation.ts b/src/utility/usePreventNavigation.ts new file mode 100644 index 00000000..bbf44e97 --- /dev/null +++ b/src/utility/usePreventNavigation.ts @@ -0,0 +1,30 @@ +import { useEffect } from 'react'; + +export default function usePreventNavigation( + enabled?: boolean, + // eslint-disable-next-line max-len + confirmation = 'Are you sure you want to leave this page? Any unsaved changes will be lost.', + includeButtons?: boolean, +) { + useEffect(() => { + const callback = (e: MouseEvent) => { + const target = e.target as HTMLElement | undefined; + const parent = target?.parentElement as HTMLElement | undefined; + const grandParent = parent?.parentElement as HTMLElement | undefined; + const targets = [target, parent, grandParent]; + if ( + targets.some(target => target?.tagName === 'A') && ( + !includeButtons || + targets.some(target => target?.tagName === 'BUTTON') + ) + ) { + if (enabled && !confirm(confirmation)) { + e.stopPropagation(); + e.preventDefault(); + } + } + }; + document.addEventListener('click', callback, true); + return () => document.removeEventListener('click', callback, true); + }, [enabled, confirmation, includeButtons]); +}