From cce4a857e404ffa3f9fc6f23ac2753eb6c2dd898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Jim=C3=A9nez?= Date: Tue, 7 Jan 2025 14:18:53 +0000 Subject: [PATCH] Add custom Id tests --- src/bibliography/domain/BibliographyEntry.ts | 66 +++++++------- .../ui/BibliographyEntryForm.test.tsx | 90 +++++++++++++++---- src/bibliography/ui/BibliographyEntryForm.tsx | 85 +++++++++--------- 3 files changed, 148 insertions(+), 93 deletions(-) diff --git a/src/bibliography/domain/BibliographyEntry.ts b/src/bibliography/domain/BibliographyEntry.ts index c7e11fd1f..bb4df293b 100644 --- a/src/bibliography/domain/BibliographyEntry.ts +++ b/src/bibliography/domain/BibliographyEntry.ts @@ -28,12 +28,15 @@ export default class BibliographyEntry { constructor(cslData?: CslData | null | undefined) { this.cslData = cslData ? produce(cslData, (draft) => { - _.keys(draft) + // Remove properties starting with '_' + Object.keys(draft) .filter((key) => key.startsWith('_')) - .forEach(_.partial(_.unset, draft)) + .forEach((key) => delete draft[key]) + + // Normalize author data if (draft.author) { - draft.author = draft.author.map( - _.partialRight(_.pick, authorProperties) + draft.author = draft.author.map((author) => + _.pick(author, authorProperties) ) } }) @@ -41,75 +44,72 @@ export default class BibliographyEntry { } get id(): string { - return _.get(this.cslData, 'id', '') + return this.cslData.id || '' } get primaryAuthor(): string { - return _.head(this.authors) || '' + return this.authors[0] || '' } get authors(): string[] { - return _.get(this.cslData, 'author', []).map(getName) + return (this.cslData.author || []).map(getName) } get year(): string { - const start = _.get(this.cslData, 'issued.date-parts.0.0', '') - const end = _.get(this.cslData, 'issued.date-parts.1.0', '') + const dates = _.get(this.cslData, 'issued.date-parts', []) + const start = dates[0]?.[0] || '' + const end = dates[1]?.[0] || '' return end ? `${start}–${end}` : String(start) } get title(): string { - return _.get(this.cslData, 'title', '') + return this.cslData.title || '' } get shortContainerTitle(): string { - return _.get(this.cslData, 'container-title-short', '') + return this.cslData['container-title-short'] || '' } get shortTitle(): string { - return _.get(this.cslData, 'title-short', '') + return this.cslData['title-short'] || '' } get collectionNumber(): string { - return _.get(this.cslData, 'collection-number', '') + return this.cslData['collection-number'] || '' } get volume(): string { - return _.get(this.cslData, 'volume', '') + return this.cslData.volume || '' } get link(): string { - const url = _.get(this.cslData, 'URL', '') - const doi = _.get(this.cslData, 'DOI', '') - return url || (doi ? `https://doi.org/${doi}` : '') + return ( + this.cslData.URL || + (this.cslData.DOI ? `https://doi.org/${this.cslData.DOI}` : '') + ) } get authorYearTitle(): string { return `${this.primaryAuthor} ${this.year} ${this.title}` } - get abberviationContainer(): string | undefined { - const containerTitleShort = this.shortContainerTitle - const collectionNumber = this.collectionNumber - ? ` ${this.collectionNumber}` - : '' - return containerTitleShort - ? `${containerTitleShort}${collectionNumber}` - : undefined + get abbreviationContainer(): string | undefined { + const container = this.shortContainerTitle + const number = this.collectionNumber ? ` ${this.collectionNumber}` : '' + return container ? `${container}${number}` : undefined } get abbreviationTitle(): string | undefined { - const { shortTitle } = this - const volume = this.volume ? ` ${this.volume}` : '' - return shortTitle ? `${shortTitle}${volume}` : undefined + const title = this.shortTitle + const vol = this.volume ? ` ${this.volume}` : '' + return title ? `${title}${vol}` : undefined } get abbreviations(): string | undefined { - const { abberviationContainer, abbreviationTitle } = this - if (abberviationContainer && abbreviationTitle) { - return `${abberviationContainer} = ${abbreviationTitle}` - } - return abberviationContainer ?? abbreviationTitle ?? undefined + const container = this.abbreviationContainer + const title = this.abbreviationTitle + if (container && title) return `${container} = ${title}` + return container || title } get label(): string { diff --git a/src/bibliography/ui/BibliographyEntryForm.test.tsx b/src/bibliography/ui/BibliographyEntryForm.test.tsx index 1dcde8a13..c42891b3a 100644 --- a/src/bibliography/ui/BibliographyEntryForm.test.tsx +++ b/src/bibliography/ui/BibliographyEntryForm.test.tsx @@ -1,34 +1,88 @@ import React from 'react' -import { render, screen } from '@testing-library/react' -import _ from 'lodash' - +import { render, screen, waitFor } from '@testing-library/react' import { changeValueByLabel, clickNth } from 'test-support/utils' import BibliographyEntryForm from './BibliographyEntryForm' import { bibliographyEntryFactory } from 'test-support/bibliography-fixtures' import BibliographyEntry from 'bibliography/domain/BibliographyEntry' -let json: string -let entry: BibliographyEntry -let onSubmit: () => void +let mockJson: string +let mockEntry: BibliographyEntry +let onSubmitMock: jest.Mock beforeEach(() => { - entry = bibliographyEntryFactory.build() - json = JSON.stringify(entry.toCslData(), null, 2) - onSubmit = jest.fn() + mockEntry = bibliographyEntryFactory.build() + mockJson = JSON.stringify(mockEntry.toCslData(), null, 2) + onSubmitMock = jest.fn() +}) + +test('Form updates and submits entry with correct data', async () => { + render() + changeValueByLabel(screen, 'Data', mockJson) + await screen.findByText(new RegExp(`\\(${mockEntry.year}\\)`)) + clickNth(screen, 'Save', 0) + + expect(onSubmitMock).toHaveBeenCalledWith(mockEntry) }) -test(`Changing document calls onChange with updated value.`, async () => { - render() - changeValueByLabel(screen, 'Data', json) - await screen.findByText(new RegExp(_.escapeRegExp(`(${entry.year})`))) +test('Displays CSL-JSON input correctly', async () => { + render() + const textarea = screen.getByLabelText('Data') as HTMLTextAreaElement + await waitFor(() => { + expect(textarea.value.replace(/\s/g, '')).toContain( + JSON.stringify(mockEntry.toCslData()).replace(/\s/g, '') + ) + }) +}) + +test('Applies custom ID when no ID exists', async () => { + const entryWithoutId = bibliographyEntryFactory.build({ + toCslData: () => ({ ...mockEntry.toCslData(), id: undefined }), + }) + const jsonWithoutId = JSON.stringify(entryWithoutId.toCslData(), null, 2) + + render() + changeValueByLabel(screen, 'Data', jsonWithoutId) + + // Wait for debounce to complete before submitting + await waitFor( + () => expect(screen.getByRole('button', { name: /Save/i })).toBeEnabled(), + { timeout: 1000 } + ) + clickNth(screen, 'Save', 0) - expect(onSubmit).toHaveBeenCalledWith(entry) + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled() + + const submittedEntry = onSubmitMock.mock.calls[0][0] + + expect(submittedEntry.id).not.toBeUndefined() + expect(submittedEntry.id).not.toMatch(/^temp_id/) + }) }) -test(`Shows value as CSL-JSON.`, async () => { - render() - await screen.findByDisplayValue( - new RegExp(_.escapeRegExp(json).replace(/\s+/g, '\\s*')) +test('Preserves existing ID', async () => { + const entryWithValidId = bibliographyEntryFactory.build({ + toCslData: () => ({ ...mockEntry.toCslData(), id: 'validId123' }), + }) + const jsonWithValidId = JSON.stringify(entryWithValidId.toCslData(), null, 2) + + render() + changeValueByLabel(screen, 'Data', jsonWithValidId) + + // Wait for debounce to complete before submitting + await waitFor( + () => expect(screen.getByRole('button', { name: /Save/i })).toBeEnabled(), + { timeout: 1000 } ) + + clickNth(screen, 'Save', 0) + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled() + + const submittedEntry = onSubmitMock.mock.calls[0][0] + + expect(submittedEntry.id).toEqual('validId123') + }) }) diff --git a/src/bibliography/ui/BibliographyEntryForm.tsx b/src/bibliography/ui/BibliographyEntryForm.tsx index d3e25270d..0de6615b7 100644 --- a/src/bibliography/ui/BibliographyEntryForm.tsx +++ b/src/bibliography/ui/BibliographyEntryForm.tsx @@ -14,21 +14,19 @@ import { generateIds } from 'bibliography/domain/GenerateIds' import './BibliographyEntryForm.css' -function BibliographyHelp() { - return ( -

- You can enter a DOI, CSL-JSON, BibTeX, or any{' '} - - supported input format - - . BibTeX can be generated with{' '} - - BibTeX Online Editor - - . -

- ) -} +const BibliographyHelp = () => ( +

+ You can enter a DOI, CSL-JSON, BibTeX, or any{' '} + + supported input format + + . BibTeX can be generated with{' '} + + BibTeX Online Editor + + . +

+) interface Props { value?: BibliographyEntry | null @@ -46,17 +44,24 @@ interface State { } export default class BibliographyEntryForm extends Component { - static defaultProps: { value: null; disabled: false } + static defaultProps = { value: null, disabled: false } + private promise: Promise private doLoad: (value: string) => Promise | undefined constructor(props: Props) { super(props) - this.state = props.value + this.state = this.getInitialState(props.value) + this.promise = Promise.resolve() + this.doLoad = _.debounce(this.load, 500, { leading: false, trailing: true }) + } + + private getInitialState(value?: BibliographyEntry | null): State { + return value ? { - citation: props.value.toHtml(), - cslData: [props.value.toCslData()], - value: JSON.stringify(props.value.toCslData(), null, 2), + citation: value.toHtml(), + cslData: [value.toCslData()], + value: JSON.stringify(value.toCslData(), null, 2), loading: false, customId: '', isInvalid: false, @@ -69,18 +74,13 @@ export default class BibliographyEntryForm extends Component { customId: '', isInvalid: false, } - this.promise = Promise.resolve() - this.doLoad = _.debounce(this.load, 500, { - leading: false, - trailing: true, - }) } - get isValid(): boolean { + private get isValid(): boolean { return _.isArray(this.state.cslData) && this.state.cslData.length === 1 } - get isInvalid(): boolean { + private get isInvalid(): boolean { return ( !this.state.loading && !_.isEmpty(this.state.value) && @@ -88,11 +88,11 @@ export default class BibliographyEntryForm extends Component { ) } - get isDisabled(): boolean { + private get isDisabled(): boolean { return !this.isValid || this.props.disabled } - handleChange = (event: React.ChangeEvent): void => { + private handleChange = (event: React.ChangeEvent): void => { this.setState({ ...this.state, value: event.target.value, @@ -102,16 +102,11 @@ export default class BibliographyEntryForm extends Component { this.promise = this.doLoad(event.target.value) || this.promise } - load = (value: string): Promise => { + private load = (value: string): Promise => { this.promise.cancel() const handleSuccess = (cite: Cite): void => { - const cslData = cite.get({ - format: 'real', - type: 'json', - style: 'csl', - }) - + const cslData = cite.get({ format: 'real', type: 'json', style: 'csl' }) const customId = generateIds(cslData[0]) this.setState({ @@ -146,7 +141,7 @@ export default class BibliographyEntryForm extends Component { }) } - formatCitation = (cite: Cite): string => { + private formatCitation = (cite: Cite): string => { return cite.format('bibliography', { format: 'html', template: 'citation-apa', @@ -154,18 +149,24 @@ export default class BibliographyEntryForm extends Component { }) } - handleSubmit = (event: React.FormEvent): void => { + private handleSubmit = (event: React.FormEvent): void => { event.preventDefault() if (this.state.cslData && this.state.cslData[0]) { - const entryData = { - ...this.state.cslData[0], - id: this.state.customId, - } as CslData & { id: string } + const entryData = this.applyCustomIdIfNeeded(this.state.cslData[0]) const entry = new BibliographyEntry(entryData) this.props.onSubmit(entry) } } + private applyCustomIdIfNeeded = ( + cslData: CslData + ): CslData & { id: string } => { + const id = cslData.id?.trim() + return !id || id.startsWith('temp_id') + ? { ...cslData, id: this.state.customId } + : { ...cslData, id } + } + render(): JSX.Element { const parsed = new Parser().parse(this.state.citation) return (