mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Add inline reference creation to staging
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user