diff --git a/src/components/FeedbackForms/AssociatedArticles/AssociatedArticlesForm.test.tsx b/src/components/FeedbackForms/AssociatedArticles/AssociatedArticlesForm.test.tsx new file mode 100644 index 000000000..ef4c2911c --- /dev/null +++ b/src/components/FeedbackForms/AssociatedArticles/AssociatedArticlesForm.test.tsx @@ -0,0 +1,31 @@ +import { render } from '@/test-utils'; +import { screen, fireEvent } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; +import { AssociatedArticlesForm } from './AssociatedArticlesForm'; + +const onOpenAlert = vi.fn(); + +describe('AssociatedArticlesForm — new bibcode auto-commit on blur', () => { + test('commits a typed bibcode to the list when focus leaves the add-bibcode group', () => { + render(); + + const stagingInput = screen.getByRole('textbox', { name: 'add new bibcode' }); + fireEvent.change(stagingInput, { target: { value: '2021arXiv210100001A' } }); + fireEvent.blur(stagingInput); + + // Committed state: value appears as a registered input row + expect(screen.getByRole('textbox', { name: 'bibcode' })).toBeInTheDocument(); + // Staging input is cleared + expect(stagingInput).toHaveValue(''); + }); + + test('does not commit when staging input is empty on blur', () => { + render(); + + const stagingInput = screen.getByRole('textbox', { name: 'add new bibcode' }); + fireEvent.blur(stagingInput); + + // No committed row + expect(screen.queryByRole('textbox', { name: 'bibcode' })).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/FeedbackForms/AssociatedArticles/AssociatedArticlesForm.tsx b/src/components/FeedbackForms/AssociatedArticles/AssociatedArticlesForm.tsx index d3ee54e74..e4335e132 100644 --- a/src/components/FeedbackForms/AssociatedArticles/AssociatedArticlesForm.tsx +++ b/src/components/FeedbackForms/AssociatedArticles/AssociatedArticlesForm.tsx @@ -16,7 +16,7 @@ import { import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ChangeEvent, KeyboardEvent, MouseEventHandler, useEffect, useRef, useState } from 'react'; +import { ChangeEvent, FocusEvent, KeyboardEvent, MouseEventHandler, useEffect, useRef, useState } from 'react'; import { FormProvider, useFieldArray, useForm, useFormContext, useWatch } from 'react-hook-form'; import { omit } from 'ramda'; @@ -332,6 +332,7 @@ export const AssociatedTable = () => { const handleAddAssociatedBibcode = () => { append({ value: newAssociatedBibcode }); setNewAssociatedBibcode(''); + newAssociatedBibcodeRef.current.focus(); }; const handleKeydownNewBibcode = (e: KeyboardEvent) => { @@ -340,11 +341,15 @@ export const AssociatedTable = () => { } }; - useEffect(() => { - if (newAssociatedBibcode === '') { - newAssociatedBibcodeRef.current.focus(); + const handleBlurNewBibcodeGroup = (e: FocusEvent) => { + if (e.currentTarget.contains(e.relatedTarget as Node)) { + return; + } + if (newAssociatedBibcode.length > 0) { + append({ value: newAssociatedBibcode }); + setNewAssociatedBibcode(''); } - }, [newAssociatedBibcode]); + }; return ( <> @@ -423,7 +428,7 @@ export const AssociatedTable = () => { - + { + const methods = useForm({ defaultValues: { references: [] } }); + return {children}; +}; + +describe('ReferencesField — new reference auto-commit on blur', () => { + test('commits a typed reference to the table when focus leaves the new-reference row', async () => { + render( + + + , + ); + + const refInput = await screen.findByRole('textbox'); + fireEvent.change(refInput, { target: { value: '2021arXiv210100001A' } }); + fireEvent.blur(refInput); + + await waitFor(() => { + expect(screen.getByText('2021arXiv210100001A')).toBeInTheDocument(); + }); + expect(refInput).toHaveValue(''); + }); + + test('does not commit when the staged reference is empty on blur', async () => { + render( + + + , + ); + + const refInput = await screen.findByRole('textbox'); + fireEvent.blur(refInput); + + // No committed rows — header row + new-entry row = 2 rows + expect(screen.getAllByRole('row')).toHaveLength(2); + }); + + test('does not auto-commit when focus moves between siblings within the new-reference row', async () => { + render( + + + , + ); + + const refInput = await screen.findByRole('textbox'); + fireEvent.change(refInput, { target: { value: '2021arXiv210100001A' } }); + + const addButton = screen.getByRole('button', { name: 'add Reference' }); + + fireEvent.blur(refInput, { relatedTarget: addButton }); + + expect(screen.getAllByRole('row')).toHaveLength(2); + }); +}); diff --git a/src/components/FeedbackForms/MissingRecord/ReferencesField.tsx b/src/components/FeedbackForms/MissingRecord/ReferencesField.tsx index 248127802..477381124 100644 --- a/src/components/FeedbackForms/MissingRecord/ReferencesField.tsx +++ b/src/components/FeedbackForms/MissingRecord/ReferencesField.tsx @@ -1,7 +1,7 @@ import { CheckIcon, CloseIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'; import { FormControl, FormLabel, HStack, IconButton, Input, Table, Tbody, Td, Th, Thead, Tr } from '@chakra-ui/react'; import { Select, SelectOption } from '@/components/Select'; -import { ChangeEvent, KeyboardEvent, MouseEvent, useRef, useState } from 'react'; +import { ChangeEvent, FocusEvent, KeyboardEvent, MouseEvent, useRef, useState } from 'react'; import { FormValues, IReference, ReferenceType, referenceTypes } from './types'; import { SelectInstance } from 'react-select'; import { useFieldArray } from 'react-hook-form'; @@ -33,6 +33,7 @@ export const ReferencesTable = ({ editable }: { editable: boolean }) => { // New row being added const [newReference, setNewReference] = useState({ type: 'Bibcode', reference: '' }); + const [newTypeMenuIsOpen, setNewTypeMenuIsOpen] = useState(false); // Existing row being edited const [editReference, setEditReference] = useState<{ index: number; reference: IReference }>({ @@ -107,9 +108,20 @@ export const ReferencesTable = ({ editable }: { editable: boolean }) => { handleAddReference(); } }; + + const handleBlurNewRefGroup = (e: FocusEvent) => { + if (e.currentTarget.contains(e.relatedTarget as Node) || newTypeMenuIsOpen) { + return; + } + if (newReferenceIsValid) { + append(newReference); + setNewReference({ type: 'Bibcode', reference: '' }); + } + }; + // Row for adding new Reference const newReferenceTableRow = ( - + {references.length + 1} {isClient && ( @@ -122,6 +134,8 @@ export const ReferencesTable = ({ editable }: { editable: boolean }) => { stylesTheme="default.sm" onChange={handleNewTypeChange} menuPortalTarget={document.body} + onMenuOpen={() => setNewTypeMenuIsOpen(true)} + onMenuClose={() => setNewTypeMenuIsOpen(false)} ref={newReferenceInputRef} /> )} diff --git a/src/components/FeedbackForms/MissingRecord/UrlsField.test.tsx b/src/components/FeedbackForms/MissingRecord/UrlsField.test.tsx new file mode 100644 index 000000000..9e3e97d55 --- /dev/null +++ b/src/components/FeedbackForms/MissingRecord/UrlsField.test.tsx @@ -0,0 +1,70 @@ +import { render } from '@/test-utils'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { ReactNode } from 'react'; +import { describe, expect, test } from 'vitest'; +import { UrlsField } from './UrlsField'; +import { FormValues } from './types'; + +const Wrapper = ({ children }: { children: ReactNode }) => { + const methods = useForm({ defaultValues: { urls: [] } }); + return {children}; +}; + +describe('UrlsField — new URL auto-commit on blur', () => { + test('commits a typed URL to the table when focus leaves the new-URL row', async () => { + render( + + + , + ); + + // useIsClient delays rendering the Select until after mount + const urlInput = await screen.findByRole('textbox'); + fireEvent.change(urlInput, { target: { value: 'https://arxiv.org/abs/2101.00001' } }); + fireEvent.blur(urlInput); + + // Committed state: URL appears as text in the table (not in an input) + await waitFor(() => { + expect(screen.getByText('https://arxiv.org/abs/2101.00001')).toBeInTheDocument(); + }); + // Staging input is reset to the arXiv default prefix + expect(urlInput).not.toHaveValue('https://arxiv.org/abs/2101.00001'); + }); + + test('does not commit when the staged URL is invalid on blur', async () => { + render( + + + , + ); + + const urlInput = await screen.findByRole('textbox'); + // Default value "https://arxiv.org/" is invalid (pathname length === 1) + fireEvent.blur(urlInput); + + // No committed rows (header row + 1 new-entry row = row count stays at 2) + const rows = screen.getAllByRole('row'); + expect(rows).toHaveLength(2); + }); + + test('does not auto-commit when focus moves between siblings within the new-URL row', async () => { + render( + + + , + ); + + const urlInput = await screen.findByRole('textbox'); + fireEvent.change(urlInput, { target: { value: 'https://arxiv.org/abs/2101.00001' } }); + + const addButton = screen.getByRole('button', { name: 'add url' }); + + // Blur with relatedTarget set to the Add button (sibling within the same ) + // The contains() guard should detect this and skip auto-commit + fireEvent.blur(urlInput, { relatedTarget: addButton }); + + // URL should NOT be committed — header row + new-entry row = 2 rows + expect(screen.getAllByRole('row')).toHaveLength(2); + }); +}); diff --git a/src/components/FeedbackForms/MissingRecord/UrlsField.tsx b/src/components/FeedbackForms/MissingRecord/UrlsField.tsx index cf654d9b0..31afd030a 100644 --- a/src/components/FeedbackForms/MissingRecord/UrlsField.tsx +++ b/src/components/FeedbackForms/MissingRecord/UrlsField.tsx @@ -2,7 +2,7 @@ import { CheckIcon, CloseIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'; import { FormControl, FormLabel, HStack, IconButton, Input, Table, Tbody, Td, Th, Thead, Tr } from '@chakra-ui/react'; import { Select, SelectOption } from '@/components/Select'; -import { ChangeEvent, KeyboardEvent, MouseEvent, useRef, useState } from 'react'; +import { ChangeEvent, FocusEvent, KeyboardEvent, MouseEvent, useRef, useState } from 'react'; import { useFieldArray } from 'react-hook-form'; import { SelectInstance } from 'react-select'; import { FormValues, IResourceUrl, ResourceUrlType, resourceUrlTypes } from './types'; @@ -40,6 +40,7 @@ export const UrlsTable = ({ editable }: { editable: boolean }) => { // New row being added const [newUrl, setNewUrl] = useState({ type: 'arXiv', url: `${ARXIV_ORIGIN}/` }); + const [newTypeMenuIsOpen, setNewTypeMenuIsOpen] = useState(false); // Existing row being edited const [editUrl, setEditUrl] = useState<{ index: number; url: IResourceUrl }>({ @@ -140,9 +141,19 @@ export const UrlsTable = ({ editable }: { editable: boolean }) => { } }; + const handleBlurNewUrlGroup = (e: FocusEvent) => { + if (e.currentTarget.contains(e.relatedTarget as Node) || newTypeMenuIsOpen) { + return; + } + if (newUrlIsValid) { + append(newUrl); + setNewUrl({ type: 'arXiv', url: `${ARXIV_ORIGIN}/` }); + } + }; + // Row for adding new url const newUrlTableRow = ( - + {urls.length + 1} {isClient && ( @@ -155,6 +166,8 @@ export const UrlsTable = ({ editable }: { editable: boolean }) => { stylesTheme="default.sm" onChange={handleNewTypeChange} menuPortalTarget={document.body} + onMenuOpen={() => setNewTypeMenuIsOpen(true)} + onMenuClose={() => setNewTypeMenuIsOpen(false)} ref={newURLTypeInputRef} /> )} diff --git a/src/components/FeedbackForms/MissingReferences/MissingReferenceTable.test.tsx b/src/components/FeedbackForms/MissingReferences/MissingReferenceTable.test.tsx new file mode 100644 index 000000000..da648ba48 --- /dev/null +++ b/src/components/FeedbackForms/MissingReferences/MissingReferenceTable.test.tsx @@ -0,0 +1,50 @@ +import { render } from '@/test-utils'; +import { screen, fireEvent } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; +import { MissingReferenceForm } from './MissingReferenceForm'; + +const onOpenAlert = vi.fn(); + +describe('MissingReferenceTable — new reference auto-commit on blur', () => { + test('commits a typed reference pair to the table when focus leaves the new-row group', () => { + render(); + + const [citingInput, citedInput] = screen.getAllByPlaceholderText('1998ApJ...501L..41Y'); + fireEvent.change(citingInput, { target: { value: '2021arXiv210100001A' } }); + fireEvent.change(citedInput, { target: { value: '2020ApJ...900..100B' } }); + fireEvent.blur(citedInput); + + // Committed state: both values appear as text in the table + expect(screen.getByText('2021arXiv210100001A')).toBeInTheDocument(); + expect(screen.getByText('2020ApJ...900..100B')).toBeInTheDocument(); + // citing persists for next entry; cited is cleared + expect(citingInput).toHaveValue('2021arXiv210100001A'); + expect(citedInput).toHaveValue(''); + }); + + test('does not commit when only one field is filled on blur', () => { + render(); + + const [citingInput] = screen.getAllByPlaceholderText('1998ApJ...501L..41Y'); + fireEvent.change(citingInput, { target: { value: '2021arXiv210100001A' } }); + fireEvent.blur(citingInput); + + // No committed row — text would appear as non-placeholder cell text + expect(screen.queryByText('2021arXiv210100001A')).not.toBeInTheDocument(); + }); + + test('does not auto-commit when focus moves between citing and cited inputs', () => { + render(); + + const [citingInput, citedInput] = screen.getAllByPlaceholderText('1998ApJ...501L..41Y'); + fireEvent.change(citingInput, { target: { value: '2021arXiv210100001A' } }); + fireEvent.change(citedInput, { target: { value: '2020ApJ...900..100B' } }); + + // Blur from citing to cited — both are inside the same new-row + fireEvent.blur(citingInput, { relatedTarget: citedInput }); + + // Guard should prevent premature commit + expect(screen.queryByText('2021arXiv210100001A')).not.toBeInTheDocument(); + expect(screen.queryByText('2020ApJ...900..100B')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/FeedbackForms/MissingReferences/MissingReferenceTable.tsx b/src/components/FeedbackForms/MissingReferences/MissingReferenceTable.tsx index af4b6f2e6..a81045b7f 100644 --- a/src/components/FeedbackForms/MissingReferences/MissingReferenceTable.tsx +++ b/src/components/FeedbackForms/MissingReferences/MissingReferenceTable.tsx @@ -14,7 +14,7 @@ import { IconButton, FormErrorMessage, } from '@chakra-ui/react'; -import { useState, ChangeEvent, MouseEvent, useRef, KeyboardEvent } from 'react'; +import { useState, ChangeEvent, FocusEvent, MouseEvent, useRef, KeyboardEvent } from 'react'; import { useFieldArray, useFormContext } from 'react-hook-form'; import { FormValues, Reference } from './types'; @@ -104,6 +104,16 @@ export const MissingReferenceTable = () => { } }; + const handleBlurNewRefGroup = (e: FocusEvent) => { + if (e.currentTarget.contains(e.relatedTarget as Node)) { + return; + } + if (newReference.citing.length > 0 && newReference.cited.length > 0) { + append(newReference); + setNewReference({ citing: newReference.citing, cited: '' }); + } + }; + return ( <> Missing References @@ -206,7 +216,7 @@ export const MissingReferenceTable = () => { ), )} - + {references.length + 1}