Skip to content
Draft
45 changes: 20 additions & 25 deletions src/components/FeedbackForms/MissingRecord/AuthorsField.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { FormControl, FormLabel, Checkbox, FormErrorMessage } from '@chakra-ui/react';
import { useFormContext, useFieldArray, useWatch, Controller } from 'react-hook-form';
import { AuthorsTable } from './AuthorsTable';
import { forwardRef } from 'react';
import { AuthorsTable, AuthorsTableHandle } from './AuthorsTable';
import { FormValues } from './types';

export const AuthorsField = () => {
export const AuthorsField = forwardRef<AuthorsTableHandle>(function AuthorsField(_, ref) {
const {
control,
formState: { errors },
Expand All @@ -17,31 +18,25 @@ export const AuthorsField = () => {

return (
<>
<FormControl isInvalid={!!errors.authors}>
<FormControl isRequired isInvalid={!!errors.authors}>
<FormLabel>Authors</FormLabel>
{!noAuthors && (
<>
<AuthorsTable editable={true} />
</>
)}
{!noAuthors && <AuthorsTable editable={true} ref={ref} />}
</FormControl>

<>
{authors.length === 0 && (
<FormControl isInvalid={!!errors.noAuthors}>
<Controller
name="noAuthors"
control={control}
render={({ field: { onChange, value, ref } }) => (
<Checkbox onChange={onChange} ref={ref} isChecked={value}>
Abstract has no author(s)
</Checkbox>
)}
/>
<FormErrorMessage>{errors.noAuthors && errors.noAuthors.message}</FormErrorMessage>
</FormControl>
)}
</>
{authors.length === 0 && (
<FormControl isInvalid={!!errors.noAuthors}>
<Controller
name="noAuthors"
control={control}
render={({ field: { onChange, value, ref: inputRef } }) => (
<Checkbox onChange={onChange} ref={inputRef} isChecked={value}>
Abstract has no author(s)
</Checkbox>
)}
/>
<FormErrorMessage>{errors.noAuthors && errors.noAuthors.message}</FormErrorMessage>
</FormControl>
)}
</>
);
};
});
52 changes: 41 additions & 11 deletions src/components/FeedbackForms/MissingRecord/AuthorsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,30 @@ import {
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useState, ChangeEvent, MouseEvent, useRef, useMemo, KeyboardEvent } from 'react';
import {
useState,
ChangeEvent,
MouseEvent,
useRef,
useMemo,
KeyboardEvent,
forwardRef,
useImperativeHandle,
} from 'react';
import { useFieldArray } from 'react-hook-form';
import { FormValues, IAuthor } from './types';
import { PaginationControls } from '@/components/Pagination';

export const AuthorsTable = ({ editable }: { editable: boolean }) => {
const columnHelper = createColumnHelper<IAuthor>();

export interface AuthorsTableHandle {
flush: () => void;
}

export const AuthorsTable = forwardRef<AuthorsTableHandle, { editable: boolean }>(function AuthorsTable(
{ editable },
ref,
) {
const {
fields: authors,
append,
Expand Down Expand Up @@ -42,15 +60,11 @@ export const AuthorsTable = ({ editable }: { editable: boolean }) => {

const newAuthorNameRef = useRef<HTMLInputElement>();

const isValidAuthor = (author: IAuthor) => {
return author && typeof author.name === 'string' && author.name.length > 1;
};
const isValidAuthor = (author: IAuthor) => author && typeof author.name === 'string' && author.name.length > 1;

const newAuthorIsValid = isValidAuthor(newAuthor);

const editAuthorIsValid = isValidAuthor(editAuthor.author);

const columnHelper = createColumnHelper<IAuthor>();
const columns = useMemo(() => {
return [
columnHelper.display({
Expand All @@ -63,14 +77,14 @@ export const AuthorsTable = ({ editable }: { editable: boolean }) => {
}),
columnHelper.accessor('aff', {
cell: (info) => info.getValue(),
header: 'Affilication',
header: 'Affiliation',
}),
columnHelper.accessor('orcid', {
cell: (info) => info.getValue(),
header: 'ORCiD',
}),
];
}, [columnHelper]);
}, []);

const table = useReactTable({
columns,
Expand All @@ -97,9 +111,25 @@ export const AuthorsTable = ({ editable }: { editable: boolean }) => {
append(newAuthor);
// clear input fields
setNewAuthor(null);
newAuthorNameRef.current.focus();
newAuthorNameRef.current?.focus();
};

// Flush any in-progress row (new or being edited) when navigating away
useImperativeHandle(
ref,
() => ({
flush: () => {
if (editAuthorIsValid && editAuthor.index !== -1) {
handleApplyEditAuthor();
}
if (isValidAuthor(newAuthor)) {
handleAddAuthor();
}
},
}),
[newAuthor, editAuthor, editAuthorIsValid],
);

// Changes to fields for existing authors

const handleEditAuthor = (e: MouseEvent<HTMLButtonElement>) => {
Expand Down Expand Up @@ -332,4 +362,4 @@ export const AuthorsTable = ({ editable }: { editable: boolean }) => {
<PaginationControls table={table} entries={authors} my={5} />
</>
);
};
});
30 changes: 30 additions & 0 deletions src/components/FeedbackForms/MissingRecord/DraftBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, test, expect, vi } from 'vitest';
import { render, screen } from '@/test-utils';
import { DraftBanner } from './DraftBanner';

describe('DraftBanner', () => {
test('renders nothing when show is false', () => {
render(<DraftBanner show={false} onRestore={vi.fn()} onDismiss={vi.fn()} />);
expect(screen.queryByRole('alert')).toBeNull();
});

test('renders banner with message when show is true', () => {
render(<DraftBanner show={true} onRestore={vi.fn()} onDismiss={vi.fn()} />);
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText(/unsaved draft/i)).toBeInTheDocument();
});

test('calls onRestore when Restore button is clicked', async () => {
const onRestore = vi.fn();
const { user } = render(<DraftBanner show={true} onRestore={onRestore} onDismiss={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /restore/i }));
expect(onRestore).toHaveBeenCalledOnce();
});

test('calls onDismiss when Dismiss button is clicked', async () => {
const onDismiss = vi.fn();
const { user } = render(<DraftBanner show={true} onRestore={vi.fn()} onDismiss={onDismiss} />);
await user.click(screen.getByRole('button', { name: /dismiss/i }));
expect(onDismiss).toHaveBeenCalledOnce();
});
});
28 changes: 28 additions & 0 deletions src/components/FeedbackForms/MissingRecord/DraftBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Alert, AlertDescription, AlertIcon, Button, HStack } from '@chakra-ui/react';

interface DraftBannerProps {
show: boolean;
onRestore: () => void;
onDismiss: () => void;
}

export function DraftBanner({ show, onRestore, onDismiss }: DraftBannerProps) {
if (!show) {
return null;
}

return (
<Alert status="info" borderRadius="md" mb={4}>
<AlertIcon />
<AlertDescription flex={1}>You have an unsaved draft for this form.</AlertDescription>
<HStack spacing={2}>
<Button size="sm" colorScheme="blue" onClick={onRestore}>
Restore
</Button>
<Button size="sm" variant="ghost" onClick={onDismiss}>
Dismiss
</Button>
</HStack>
</Alert>
);
}
98 changes: 98 additions & 0 deletions src/components/FeedbackForms/MissingRecord/FormChecklist.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, test, expect } from 'vitest';
import { render, screen } from '@/test-utils';
import { FormProvider, useForm } from 'react-hook-form';
import { FormValues } from './types';
import { FormChecklist } from './FormChecklist';

function Wrapper({ values }: { values: Partial<FormValues> }) {
const methods = useForm<FormValues>({
defaultValues: {
name: '',
email: '',
isNew: true,
bibcode: '',
collection: [],
title: '',
noAuthors: false,
authors: [],
publication: '',
pubDate: '',
urls: [],
abstract: '',
keywords: [],
references: [],
comments: '',
...values,
},
});
return (
<FormProvider {...methods}>
<FormChecklist />
</FormProvider>
);
}

describe('FormChecklist', () => {
test('renders 6 checklist items', () => {
render(<Wrapper values={{}} />);
expect(screen.getAllByRole('listitem')).toHaveLength(6);
});

test('all items incomplete when form is empty', () => {
render(<Wrapper values={{}} />);
screen.getAllByRole('listitem').forEach((item) => {
expect(item).toHaveAttribute('data-complete', 'false');
});
});

test('marks Name complete when name has a value', () => {
render(<Wrapper values={{ name: 'Alice' }} />);
expect(screen.getByTestId('checklist-name')).toHaveAttribute('data-complete', 'true');
});

test('marks Email complete when email is present', () => {
render(<Wrapper values={{ email: 'alice@example.com' }} />);
expect(screen.getByTestId('checklist-email')).toHaveAttribute('data-complete', 'true');
});

test('marks Title complete when title is present', () => {
render(<Wrapper values={{ title: 'My Paper' }} />);
expect(screen.getByTestId('checklist-title')).toHaveAttribute('data-complete', 'true');
});

test('marks Authors complete when authors list is non-empty', () => {
render(<Wrapper values={{ authors: [{ name: 'Alice', aff: '', orcid: '' }] }} />);
expect(screen.getByTestId('checklist-authors')).toHaveAttribute('data-complete', 'true');
});

test('marks Authors complete when noAuthors is true', () => {
render(<Wrapper values={{ noAuthors: true }} />);
expect(screen.getByTestId('checklist-authors')).toHaveAttribute('data-complete', 'true');
});

test('shows progress count', () => {
render(<Wrapper values={{ name: 'Alice', email: 'a@b.com' }} />);
expect(screen.getByText(/2 of 6/i)).toBeInTheDocument();
});

test('shows 0 of 6 when all fields empty', () => {
render(<Wrapper values={{}} />);
expect(screen.getByText(/0 of 6/i)).toBeInTheDocument();
});

test('shows 6 of 6 when all required fields complete', () => {
render(
<Wrapper
values={{
name: 'Alice',
email: 'a@b.com',
title: 'My Paper',
publication: 'Nature',
pubDate: '2024-01',
noAuthors: true,
}}
/>,
);
expect(screen.getByText(/6 of 6/i)).toBeInTheDocument();
});
});
70 changes: 70 additions & 0 deletions src/components/FeedbackForms/MissingRecord/FormChecklist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Box, HStack, List, ListItem, Text } from '@chakra-ui/react';
import { CheckCircleIcon } from '@chakra-ui/icons';
import { useFormContext, useWatch } from 'react-hook-form';
import { isNonEmptyString } from 'ramda-adjunct';
import { isValidEmail } from '@/utils/common/isValidEmail';
import { FormValues } from './types';

interface ChecklistItem {
id: string;
label: string;
isComplete: boolean;
}

export function FormChecklist() {
const { control } = useFormContext<FormValues>();

const [name, email, title, publication, pubDate, authors, noAuthors] = useWatch<
FormValues,
['name', 'email', 'title', 'publication', 'pubDate', 'authors', 'noAuthors']
>({
control,
name: ['name', 'email', 'title', 'publication', 'pubDate', 'authors', 'noAuthors'],
});

const items: ChecklistItem[] = [
{ id: 'name', label: 'Name', isComplete: isNonEmptyString(name) },
{ id: 'email', label: 'Email', isComplete: !!email && isValidEmail(email) },
{ id: 'title', label: 'Title', isComplete: isNonEmptyString(title) },
{
id: 'authors',
label: 'Author(s)',
isComplete: (authors?.length ?? 0) > 0 || noAuthors === true,
},
{ id: 'publication', label: 'Publication', isComplete: isNonEmptyString(publication) },
{ id: 'pubDate', label: 'Publication Date', isComplete: isNonEmptyString(pubDate) },
];

const completedCount = items.filter((i) => i.isComplete).length;

return (
<Box borderWidth="1px" borderRadius="md" p={4} minW="200px">
<Text fontWeight="semibold" fontSize="sm" mb={2}>
Required Fields
</Text>
<Text fontSize="xs" color="gray.500" mb={3}>
{completedCount} of {items.length}
</Text>
<List spacing={2} role="list">
{items.map((item) => (
<ListItem
key={item.id}
data-testid={`checklist-${item.id}`}
data-complete={String(item.isComplete)}
role="listitem"
>
<HStack spacing={2}>
<CheckCircleIcon
color={item.isComplete ? 'green.500' : 'gray.300'}
aria-label={item.isComplete ? 'complete' : 'incomplete'}
/>
<Text fontSize="sm" color={item.isComplete ? 'inherit' : 'gray.500'}>
{item.label}
</Text>
</HStack>
</ListItem>
))}
</List>
</Box>
);
}
Loading
Loading