Add inline reference creation to staging

This commit is contained in:
karilint
2026-05-25 17:53:12 +03:00
parent 8f2d14e681
commit 96f6831fc7
3 changed files with 421 additions and 15 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ export const referenceTableColumns: MRT_ColumnDef<Reference>[] = [
size: 20,
},
{
accessorFn: ({ ref_authors }) => ref_authors.find(author => author.au_num === 1)?.author_surname ?? 'Not found',
accessorFn: ({ ref_authors }) => ref_authors?.find(author => author.au_num === 1)?.author_surname ?? 'Not found',
Cell,
header: 'Author',
maxSize: 60,
@@ -1,15 +1,219 @@
import { ArrayFrame, Grouped } from './common/tabLayoutHelpers'
import { useDetailContext } from './Context/DetailContext'
import { DetailContextProvider, modeOptionToMode, useDetailContext } from './Context/DetailContext'
import { SelectingTable } from './common/SelectingTable'
import { useGetAllReferencesQuery } from '@/redux/referenceReducer'
import { useEditReferenceMutation, useGetAllReferencesQuery, useGetReferenceTypesQuery } from '@/redux/referenceReducer'
import { referenceTableColumns } from '@/common'
import { MRT_RowData } from 'material-react-table'
import { Editable, Reference } from '@/shared/types'
import { EditDataType, Editable, Reference, ReferenceDetailsType, ReferenceType } from '@/shared/types'
import { EditableTable } from './common/EditableTable'
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack } from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import SaveIcon from '@mui/icons-material/Save'
import { useMemo, useState } from 'react'
import { ReferenceTab } from '@/components/Reference/Tabs/ReferenceTab'
import { emptyReference } from './common/defaultValues'
import {
DropdownSelector,
DropdownSelectorWithSearch,
EditableTextField,
RadioSelector,
} from './common/editingComponents'
import type { DropdownOption } from './common/editingComponents'
import type { FieldsWithErrorsType, OptionalRadioSelectionProps, TextFieldOptions } from './DetailView'
import type { ValidationObject } from '@/shared/validators/validator'
import {
createReferenceValidatorWithLabels,
ReferenceDisplayLabelMap,
ReferenceFieldDisplayNames,
} from '@/shared/validators/reference'
import { useNotify } from '@/hooks/notification'
import { formatReferenceValidationErrorMessage } from '@/components/Reference/referenceValidationErrors'
const NewReferenceDialogContent = ({
onClose,
onCreated,
referenceValidator,
referenceFieldDisplayLabelMap,
referenceTypes,
}: {
onClose: () => void
onCreated: (reference: ReferenceDetailsType) => void
referenceValidator: (
editData: EditDataType<ReferenceDetailsType>,
field: keyof EditDataType<ReferenceDetailsType>
) => ValidationObject
referenceFieldDisplayLabelMap?: ReferenceDisplayLabelMap
referenceTypes?: ReferenceType[]
}) => {
const { editData, setFieldsWithErrors } = useDetailContext<ReferenceDetailsType>()
const [editReferenceRequest, { isLoading }] = useEditReferenceMutation()
const { notify } = useNotify()
const validateAllFields = () => {
const nextFieldsWithErrors: FieldsWithErrorsType = {}
for (const field in editData) {
const fieldKey = field as keyof EditDataType<ReferenceDetailsType>
const errorObject = referenceValidator(editData, fieldKey)
if (errorObject.error) {
nextFieldsWithErrors[String(fieldKey)] = errorObject
}
}
setFieldsWithErrors(() => nextFieldsWithErrors)
return Object.keys(nextFieldsWithErrors).length === 0
}
const handleSave = async () => {
if (!validateAllFields()) {
notify('Please fix reference validation errors before saving.', 'error')
return
}
try {
const savedReference = await editReferenceRequest(editData).unwrap()
const refType = referenceTypes?.find(referenceType => referenceType.ref_type_id === editData.ref_type_id)
const createdReference = {
...editData,
rid: savedReference.rid,
ref_authors: editData.ref_authors ?? [],
ref_journal: editData.ref_journal ?? null,
ref_ref_type: { ref_type: refType?.ref_type ?? '' },
} as ReferenceDetailsType
notify('Saved reference successfully.')
onCreated(createdReference)
onClose()
} catch (e) {
const message = formatReferenceValidationErrorMessage(e, editData.ref_type_id, referenceFieldDisplayLabelMap)
notify(message, 'error')
}
}
return (
<>
<DialogContent dividers>
<ReferenceTab />
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button disabled={isLoading} onClick={() => void handleSave()} startIcon={<SaveIcon />} variant="contained">
Save reference
</Button>
</DialogActions>
</>
)
}
const NewReferenceDialog = ({
open,
onClose,
onCreated,
}: {
open: boolean
onClose: () => void
onCreated: (reference: ReferenceDetailsType) => void
}) => {
const [fieldsWithErrors, setFieldsWithErrors] = useState<FieldsWithErrorsType>({})
const { data: referenceTypes } = useGetReferenceTypesQuery()
const referenceFieldDisplayLabelMap = useMemo(() => {
if (!referenceTypes) return undefined
return referenceTypes.reduce((acc, referenceType) => {
const labelsForType = referenceType.ref_field_name.reduce<ReferenceFieldDisplayNames>((typeLabels, field) => {
if (field.field_name && field.ref_field_name) {
typeLabels[field.field_name as keyof ReferenceFieldDisplayNames] = field.ref_field_name
}
return typeLabels
}, {})
return { ...acc, [referenceType.ref_type_id]: labelsForType }
}, {} as ReferenceDisplayLabelMap)
}, [referenceTypes])
const referenceValidator = useMemo(
() => createReferenceValidatorWithLabels(referenceFieldDisplayLabelMap),
[referenceFieldDisplayLabelMap]
)
const textField = (field: keyof EditDataType<ReferenceDetailsType>, options?: TextFieldOptions) => (
<EditableTextField<ReferenceDetailsType> field={field} {...options} />
)
const dropdown = (
field: keyof EditDataType<ReferenceDetailsType>,
options: Array<DropdownOption | string>,
name: string,
disabled?: boolean
) => <DropdownSelector<ReferenceDetailsType> field={field} options={options} name={name} disabled={disabled} />
const dropdownWithSearch = (
field: keyof EditDataType<ReferenceDetailsType>,
options: Array<DropdownOption | string>,
name: string,
disabled?: boolean,
label?: string
) => (
<DropdownSelectorWithSearch<ReferenceDetailsType>
field={field}
options={options}
name={name}
disabled={disabled}
label={label}
/>
)
const radioSelection = (
field: keyof EditDataType<ReferenceDetailsType>,
options: Array<DropdownOption | string>,
name: string,
optionalRadioSelectionProps?: OptionalRadioSelectionProps
) => (
<RadioSelector<ReferenceDetailsType> field={field} options={options} name={name} {...optionalRadioSelectionProps} />
)
const bigTextField = (field: keyof EditDataType<ReferenceDetailsType>) => (
<EditableTextField<ReferenceDetailsType> field={field} type="text" big />
)
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="lg">
<DialogTitle>Add new reference</DialogTitle>
{open && (
<DetailContextProvider<ReferenceDetailsType>
contextState={{
data: emptyReference,
mode: modeOptionToMode.new,
setMode: () => undefined,
editData: emptyReference as EditDataType<ReferenceDetailsType>,
textField,
dropdown,
dropdownWithSearch,
radioSelection,
bigTextField,
validator: referenceValidator,
fieldsWithErrors,
setFieldsWithErrors,
}}
>
<NewReferenceDialogContent
onClose={onClose}
onCreated={onCreated}
referenceValidator={referenceValidator}
referenceFieldDisplayLabelMap={referenceFieldDisplayLabelMap}
referenceTypes={referenceTypes}
/>
</DetailContextProvider>
)}
</Dialog>
)
}
export const StagingView = <T extends MRT_RowData>() => {
const { bigTextField } = useDetailContext<T>()
const { data, isError } = useGetAllReferencesQuery()
const { bigTextField, editData, setEditData } = useDetailContext<T>()
const [newReferenceDialogOpen, setNewReferenceDialogOpen] = useState(false)
const { data, isError } = useGetAllReferencesQuery(undefined, { refetchOnFocus: true })
const editInfoArray = [
['Date', new Date().toLocaleDateString('en-CA')],
@@ -18,21 +222,53 @@ export const StagingView = <T extends MRT_RowData>() => {
['Comment', bigTextField('comment')],
]
const handleReferenceCreated = (reference: ReferenceDetailsType) => {
const selectedReferences = (editData.references ?? []) as Editable<Reference>[]
if (selectedReferences.some(selectedReference => selectedReference.rid === reference.rid)) return
const stagedReference = {
...(reference as unknown as Reference),
ref_authors: reference.ref_authors ?? [],
ref_journal: reference.ref_journal ?? null,
rowState: 'new',
}
setEditData({
...editData,
references: [...selectedReferences, stagedReference],
})
}
return (
<>
<ArrayFrame array={editInfoArray} title="Reference for the new data" />
<Grouped title="Reference">
<SelectingTable<Reference, T>
buttonText="Add existing reference"
data={data}
title="References"
isError={isError}
columns={referenceTableColumns}
idFieldName="rid"
fieldName="references"
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} alignItems={{ xs: 'stretch', sm: 'flex-start' }}>
<SelectingTable<Reference, T>
buttonText="Add existing reference"
data={data}
title="References"
isError={isError}
columns={referenceTableColumns}
idFieldName="rid"
fieldName="references"
/>
<Button
onClick={() => setNewReferenceDialogOpen(true)}
variant="outlined"
startIcon={<AddIcon />}
sx={{ marginBottom: '1em' }}
>
Add new reference
</Button>
</Stack>
<EditableTable<Editable<Reference>, T> columns={referenceTableColumns} field="references" />
</Grouped>
<NewReferenceDialog
open={newReferenceDialogOpen}
onClose={() => setNewReferenceDialogOpen(false)}
onCreated={handleReferenceCreated}
/>
</>
)
}
@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import '@testing-library/jest-dom'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
DetailContextProvider,
modeOptionToMode,
useDetailContext,
} from '@/components/DetailView/Context/DetailContext'
import { StagingView } from '@/components/DetailView/StagingView'
import type { EditDataType, Reference, ReferenceDetailsType } from '@/shared/types'
import type { ValidationObject } from '@/shared/validators/validator'
const mockUseGetAllReferencesQuery = jest.fn()
const mockUseGetReferenceTypesQuery = jest.fn()
const mockEditReferenceRequest = jest.fn()
const mockNotify = jest.fn()
jest.mock('@/redux/referenceReducer', () => ({
useGetAllReferencesQuery: (...args: unknown[]) => mockUseGetAllReferencesQuery(...args),
useGetReferenceTypesQuery: () => mockUseGetReferenceTypesQuery(),
useEditReferenceMutation: () => [mockEditReferenceRequest, { isLoading: false }],
}))
jest.mock('@/components/DetailView/common/SelectingTable', () => ({
SelectingTable: ({ buttonText }: { buttonText: string }) => <button type="button">{buttonText}</button>,
}))
jest.mock('../../components/DetailView/common/SelectingTable', () => ({
SelectingTable: ({ buttonText }: { buttonText: string }) => <button type="button">{buttonText}</button>,
}))
jest.mock('@/components/DetailView/common/EditableTable', () => ({
EditableTable: () => <div data-testid="selected-references" />,
}))
jest.mock('../../components/DetailView/common/EditableTable', () => ({
EditableTable: () => <div data-testid="selected-references" />,
}))
jest.mock('@/components/Reference/Tabs/ReferenceTab', () => ({
ReferenceTab: () => {
const { editData, setEditData } = useDetailContext<ReferenceDetailsType>()
return (
<div>
Reference form
<button
type="button"
onClick={() =>
setEditData({
...editData,
ref_type_id: 1,
title_primary: 'Created reference',
date_primary: 2026,
ref_authors: [
{
rid: 0,
au_num: 1,
author_surname: 'Doe',
author_initials: 'J',
field_id: 1,
},
],
ref_journal: undefined,
} as unknown as EditDataType<ReferenceDetailsType>)
}
>
Fill reference
</button>
</div>
)
},
}))
jest.mock('@/shared/validators/reference', () => ({
createReferenceValidatorWithLabels: () => (_editData: unknown, field: string) => ({ name: field, error: null }),
}))
jest.mock('@/hooks/notification', () => ({
useNotify: () => ({ notify: mockNotify }),
}))
type StagingData = {
references: Reference[]
comment: string
}
const SelectedReferencesProbe = () => {
const { editData } = useDetailContext<StagingData>()
const reference = editData.references[0]
return (
<>
<div data-testid="reference-count">{editData.references.length}</div>
{reference && (
<div data-testid="created-reference">
{[
reference.ref_authors[0]?.author_surname,
reference.date_primary,
reference.title_primary,
reference.ref_ref_type?.ref_type,
].join('|')}
</div>
)}
</>
)
}
const renderStagingView = () => {
const validator = (): ValidationObject => ({ name: 'test', error: '' })
return render(
<DetailContextProvider<StagingData>
contextState={{
data: { references: [], comment: '' },
mode: modeOptionToMode['staging-new'],
setMode: jest.fn(),
editData: { references: [], comment: '' } as EditDataType<StagingData>,
textField: jest.fn(() => <input aria-label="field" />),
bigTextField: jest.fn(() => <textarea aria-label="comment" />),
dropdown: jest.fn(() => <select aria-label="dropdown" />),
dropdownWithSearch: jest.fn(() => <select aria-label="dropdown search" />),
radioSelection: jest.fn(() => <input aria-label="radio" />),
validator,
fieldsWithErrors: {},
setFieldsWithErrors: jest.fn(),
}}
>
<StagingView<StagingData> />
<SelectedReferencesProbe />
</DetailContextProvider>
)
}
describe('StagingView', () => {
beforeEach(() => {
jest.clearAllMocks()
mockUseGetAllReferencesQuery.mockReturnValue({ data: [], isError: false })
mockUseGetReferenceTypesQuery.mockReturnValue({
data: [{ ref_type_id: 1, ref_type: 'Article', ref_field_name: [] }],
})
mockEditReferenceRequest.mockReturnValue({
unwrap: () =>
Promise.resolve({
rid: 123,
}),
})
})
it('creates a new reference in a dialog and adds it to the staged references', async () => {
renderStagingView()
expect(screen.getByRole('button', { name: /add existing reference/i })).toBeTruthy()
expect(screen.getByTestId('reference-count').textContent).toBe('0')
await userEvent.click(screen.getByRole('button', { name: /add new reference/i }))
expect(screen.getByRole('dialog', { name: /add new reference/i })).toBeTruthy()
expect(screen.getByText(/reference form/i)).toBeTruthy()
await userEvent.click(screen.getByRole('button', { name: /fill reference/i }))
await userEvent.click(screen.getByRole('button', { name: /save reference/i }))
await waitFor(() => expect(screen.getByTestId('reference-count').textContent).toBe('1'))
expect(screen.getByTestId('created-reference').textContent).toBe('Doe|2026|Created reference|Article')
expect(mockUseGetAllReferencesQuery).toHaveBeenCalledWith(undefined, { refetchOnFocus: true })
expect(mockEditReferenceRequest).toHaveBeenCalledTimes(1)
})
})