diff --git a/CHANGELOG.md b/CHANGELOG.md index d6df9180c..43aed9142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ### Changed +- File pages now include a "Cite Data File" dropdown for downloading file citations in EndNote XML, RIS, and BibTeX formats. - Hide "Export Metadata" on dataset and file pages that are not for the latest published dataset version. - Show "Export Metadata" on dataset and file pages for draft version. - Avoided prop-drilling for file, guestbook, user and external tool repository, so used context to share repository instances. diff --git a/public/locales/en/shared.json b/public/locales/en/shared.json index 8e95eec04..0ee482058 100644 --- a/public/locales/en/shared.json +++ b/public/locales/en/shared.json @@ -295,6 +295,8 @@ }, "downloadCitation": { "title": "Download Citation", + "citeDataset": "Cite Dataset", + "citeDataFile": "Cite Data File", "viewStyledCitation": "View Styled Citation", "downloadEndNoteXML": "Download EndNote XML", "downloadRIS": "Download RIS", @@ -302,6 +304,7 @@ "selectCSLStyle": "Select a CSL Style", "citationInStyle": "Citation in {{styleName}} style", "downloadError": "An error occurred while downloading the citation", + "downloadSuccess": "Citation downloaded successfully", "styledCitation": "Styled Citation" }, "expandableContent": { diff --git a/public/locales/es/shared.json b/public/locales/es/shared.json index 4dd420367..0a31b30b8 100644 --- a/public/locales/es/shared.json +++ b/public/locales/es/shared.json @@ -295,6 +295,8 @@ }, "downloadCitation": { "title": "Descargar cita", + "citeDataset": "Citar dataset", + "citeDataFile": "Citar archivo de datos", "viewStyledCitation": "Ver cita con estilo", "downloadEndNoteXML": "Descargar EndNote XML", "downloadRIS": "Descargar RIS", @@ -302,6 +304,7 @@ "selectCSLStyle": "Seleccionar un estilo CSL", "citationInStyle": "Cita en estilo {{styleName}}", "downloadError": "Ocurrió un error al descargar la cita", + "downloadSuccess": "Cita descargada correctamente", "styledCitation": "Cita con estilo" }, "expandableContent": { diff --git a/src/files/domain/models/FileCitation.ts b/src/files/domain/models/FileCitation.ts new file mode 100644 index 000000000..daa8a713f --- /dev/null +++ b/src/files/domain/models/FileCitation.ts @@ -0,0 +1,12 @@ +export enum FileCitationFormat { + Internal = 'Internal', + EndNote = 'EndNote', + RIS = 'RIS', + BibTeX = 'BibTeX', + CSLJson = 'CSL' +} + +export interface FormattedFileCitation { + content: string + contentType: string +} diff --git a/src/files/domain/repositories/FileRepository.ts b/src/files/domain/repositories/FileRepository.ts index e5761c21e..51a027b62 100644 --- a/src/files/domain/repositories/FileRepository.ts +++ b/src/files/domain/repositories/FileRepository.ts @@ -13,6 +13,7 @@ import { FileMetadataDTO } from '@/files/domain/useCases/DTOs/FileMetadataDTO' import { RestrictFileDTO } from '../useCases/restrictFileDTO' import { FileVersionSummarySubset } from '../models/FileVersionSummaryInfo' import { FileVersionPaginationInfo } from '../models/FileVersionPaginationInfo' +import { FileCitationFormat, FormattedFileCitation } from '../models/FileCitation' export interface FileRepository { getAllByDatasetPersistentId: ( @@ -37,6 +38,10 @@ export interface FileRepository { fileId: number | string, paginationInfo?: FileVersionPaginationInfo ) => Promise + getFileCitationByFormat: ( + fileId: number | string, + format: FileCitationFormat + ) => Promise getById: (id: number, datasetVersionNumber?: string) => Promise getMultipleFileDownloadUrl: (ids: number[], downloadMode: FileDownloadMode) => string getFileDownloadUrl: (id: number, downloadMode: FileDownloadMode) => string diff --git a/src/files/domain/useCases/getFileCitationByFormat.ts b/src/files/domain/useCases/getFileCitationByFormat.ts new file mode 100644 index 000000000..8182c27cc --- /dev/null +++ b/src/files/domain/useCases/getFileCitationByFormat.ts @@ -0,0 +1,10 @@ +import { FormattedFileCitation, FileCitationFormat } from '../models/FileCitation' +import { FileRepository } from '../repositories/FileRepository' + +export function getFileCitationByFormat( + fileRepository: FileRepository, + fileId: string | number, + format: FileCitationFormat +): Promise { + return fileRepository.getFileCitationByFormat(fileId, format) +} diff --git a/src/files/infrastructure/FileJSDataverseRepository.ts b/src/files/infrastructure/FileJSDataverseRepository.ts index d37d012db..86ed8d066 100644 --- a/src/files/infrastructure/FileJSDataverseRepository.ts +++ b/src/files/infrastructure/FileJSDataverseRepository.ts @@ -12,6 +12,8 @@ import { getDatasetFilesTotalDownloadSize, getFileAndDataset, getFileCitation, + getFileCitationByFormat as jsGetFileCitationByFormat, + FileCitationFormat as JSFileCitationFormat, getFileDataTables, getFileDownloadCount, getFileUserPermissions, @@ -47,6 +49,7 @@ import { FileMetadataDTO } from '@/files/domain/useCases/DTOs/FileMetadataDTO' import { JSDataverseReadErrorHandler } from '@/shared/helpers/JSDataverseReadErrorHandler' import { FileVersionSummarySubset } from '../domain/models/FileVersionSummaryInfo' import { FileVersionPaginationInfo } from '../domain/models/FileVersionPaginationInfo' +import { FileCitationFormat, FormattedFileCitation } from '../domain/models/FileCitation' const includeDeaccessioned = true @@ -269,6 +272,21 @@ export class FileJSDataverseRepository implements FileRepository { return getFileVersionSummaries.execute(fileId, paginationInfo?.pageSize, paginationInfo?.offset) } + getFileCitationByFormat( + fileId: number | string, + format: FileCitationFormat + ): Promise { + return jsGetFileCitationByFormat + .execute(fileId, FileJSDataverseRepository.toJSFileCitationFormat(format)) + .then((content) => ({ + content, + contentType: FileJSDataverseRepository.getCitationContentType(format) + })) + .catch((error: ReadError) => { + throw new Error(error.message) + }) + } + getById(id: number, datasetVersionNumber?: string): Promise { return FileJSDataverseRepository.getPermissionsByIdOrGuest(id) .then(({ permissions, isGuestFallback }) => { @@ -371,6 +389,38 @@ export class FileJSDataverseRepository implements FileRepository { }) } + private static getCitationContentType(format: FileCitationFormat): string { + switch (format) { + case FileCitationFormat.EndNote: + return 'application/xml' + case FileCitationFormat.RIS: + return 'application/x-research-info-systems' + case FileCitationFormat.BibTeX: + return 'application/x-bibtex' + case FileCitationFormat.CSLJson: + return 'application/vnd.citationstyles.csl+json' + case FileCitationFormat.Internal: + default: + return 'text/html' + } + } + + private static toJSFileCitationFormat(format: FileCitationFormat): JSFileCitationFormat { + switch (format) { + case FileCitationFormat.EndNote: + return JSFileCitationFormat.ENDNOTE + case FileCitationFormat.RIS: + return JSFileCitationFormat.RIS + case FileCitationFormat.BibTeX: + return JSFileCitationFormat.BIBTEX + case FileCitationFormat.CSLJson: + return JSFileCitationFormat.CSL + case FileCitationFormat.Internal: + default: + return JSFileCitationFormat.INTERNAL + } + } + getMultipleFileDownloadUrl(ids: number[], downloadMode: FileDownloadMode): string { return `/api/access/datafiles/${ids.join(',')}?format=${downloadMode}` } diff --git a/src/sections/file/File.tsx b/src/sections/file/File.tsx index 74d8b954b..8bb68f095 100644 --- a/src/sections/file/File.tsx +++ b/src/sections/file/File.tsx @@ -132,7 +132,12 @@ export function File({ {t('fileCitationTitle')} - + {t('datasetCitationTitle')} } /> - + + + + ) diff --git a/src/sections/shared/citation/citation-download/CitationDownloadButton.tsx b/src/sections/shared/citation/citation-download/CitationDownloadButton.tsx index 7cc5e902d..08e073eee 100644 --- a/src/sections/shared/citation/citation-download/CitationDownloadButton.tsx +++ b/src/sections/shared/citation/citation-download/CitationDownloadButton.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next' import { DropdownButton, DropdownButtonItem } from '@iqss/dataverse-design-system' import { ViewStyledCitationModal } from './ViewStyledCitationModal' -import { useState } from 'react' +import { startTransition, useEffect, useState } from 'react' import { CitationFormat } from '@/dataset/domain/models/DatasetCitation' import { useDownloadCitation } from './useDownloadCitation' import { FormattedCitation } from '@iqss/dataverse-client-javascript/dist/datasets/domain/models/FormattedCitation' @@ -26,32 +26,43 @@ export function CitationDownloadButton({ datasetId, version }: CitationDownloadP version }) - if (error) { - toast.error(t('downloadError')) - } + useEffect(() => { + if (error) { + toast.error(t('downloadError')) + } + }, [error, t]) const handleCloseModal = () => setIsModalOpen(false) const handleOpenModal = async () => { - setIsModalOpen(true) + startTransition(() => setIsModalOpen(true)) const result = await handleGetCitation(CitationFormat.Internal) setStyledCitation(result) } + + const handleDownload = (format: CitationFormat, filename: string) => { + void handleDownloadCitation(format, filename).then((downloaded) => { + if (downloaded) { + toast.success(t('downloadSuccess')) + } + }) + } + return ( <> - + handleDownloadCitation(CitationFormat.EndNote, `${datasetId}.xml`)}> + onClick={() => handleDownload(CitationFormat.EndNote, `${datasetId}.xml`)}> {t('downloadEndNoteXML')} handleDownloadCitation(CitationFormat.RIS, `${datasetId}.ris`)}> + onClick={() => handleDownload(CitationFormat.RIS, `${datasetId}.ris`)}> {t('downloadRIS')} handleDownloadCitation(CitationFormat.BibTeX, `${datasetId}.bib`)}> + onClick={() => handleDownload(CitationFormat.BibTeX, `${datasetId}.bib`)}> {t('downloadBibTeX')} diff --git a/src/sections/shared/citation/citation-download/FileCitationDownloadButton.tsx b/src/sections/shared/citation/citation-download/FileCitationDownloadButton.tsx new file mode 100644 index 000000000..b36193215 --- /dev/null +++ b/src/sections/shared/citation/citation-download/FileCitationDownloadButton.tsx @@ -0,0 +1,58 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'react-toastify' +import { DropdownButton, DropdownButtonItem } from '@iqss/dataverse-design-system' +import { FileRepository } from '@/files/domain/repositories/FileRepository' +import { FileCitationFormat } from '@/files/domain/models/FileCitation' +import { useDownloadFileCitation } from './useDownloadFileCitation' + +interface FileCitationDownloadButtonProps { + fileRepository: FileRepository + fileId: string | number +} + +export function FileCitationDownloadButton({ + fileRepository, + fileId +}: FileCitationDownloadButtonProps) { + const { t } = useTranslation('shared', { keyPrefix: 'downloadCitation' }) + + const { error, handleDownloadCitation } = useDownloadFileCitation({ + fileRepository, + fileId + }) + + useEffect(() => { + if (error) { + toast.error(t('downloadError')) + } + }, [error, t]) + + const handleDownload = (format: FileCitationFormat, filename: string) => { + void handleDownloadCitation(format, filename).then((downloaded) => { + if (downloaded) { + toast.success(t('downloadSuccess')) + } + }) + } + + return ( + + handleDownload(FileCitationFormat.EndNote, `${fileId}.xml`)}> + {t('downloadEndNoteXML')} + + handleDownload(FileCitationFormat.RIS, `${fileId}.ris`)}> + {t('downloadRIS')} + + handleDownload(FileCitationFormat.BibTeX, `${fileId}.bib`)}> + {t('downloadBibTeX')} + + + ) +} diff --git a/src/sections/shared/citation/citation-download/useDownloadCitation.tsx b/src/sections/shared/citation/citation-download/useDownloadCitation.tsx index 66f31b6f8..fed234d77 100644 --- a/src/sections/shared/citation/citation-download/useDownloadCitation.tsx +++ b/src/sections/shared/citation/citation-download/useDownloadCitation.tsx @@ -37,11 +37,16 @@ export function useDownloadCitation({ /** * Downloads a citation file after fetching it. */ - const handleDownloadCitation = async (format: CitationFormat, filename: string) => { + const handleDownloadCitation = async ( + format: CitationFormat, + filename: string + ): Promise => { const citation = await handleGetCitation(format) if (citation) { downloadFile(citation.content, filename, citation.contentType) + return true } + return false } return { isLoading, error, handleGetCitation, handleDownloadCitation } diff --git a/src/sections/shared/citation/citation-download/useDownloadFileCitation.tsx b/src/sections/shared/citation/citation-download/useDownloadFileCitation.tsx new file mode 100644 index 000000000..67b56f199 --- /dev/null +++ b/src/sections/shared/citation/citation-download/useDownloadFileCitation.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react' +import { FileRepository } from '@/files/domain/repositories/FileRepository' +import { getFileCitationByFormat } from '@/files/domain/useCases/getFileCitationByFormat' +import { FileCitationFormat, FormattedFileCitation } from '@/files/domain/models/FileCitation' +import { downloadFile } from './useDownloadCitation' + +export function useDownloadFileCitation({ + fileRepository, + fileId +}: { + fileRepository: FileRepository + fileId: string | number +}) { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const handleGetCitation = async ( + format: FileCitationFormat + ): Promise => { + setIsLoading(true) + setError(null) + try { + return await getFileCitationByFormat(fileRepository, fileId, format) + } catch (err) { + setError('Failed to fetch citation.') + return null + } finally { + setIsLoading(false) + } + } + + const handleDownloadCitation = async ( + format: FileCitationFormat, + filename: string + ): Promise => { + const citation = await handleGetCitation(format) + if (citation) { + downloadFile(citation.content, filename, citation.contentType) + return true + } + return false + } + + return { isLoading, error, handleGetCitation, handleDownloadCitation } +} diff --git a/src/stories/file/FileMockRepository.ts b/src/stories/file/FileMockRepository.ts index e4987b47a..4a4186590 100644 --- a/src/stories/file/FileMockRepository.ts +++ b/src/stories/file/FileMockRepository.ts @@ -19,6 +19,7 @@ import { FileMetadataDTO } from '@/files/domain/useCases/DTOs/FileMetadataDTO' import { RestrictFileDTO } from '@/files/domain/useCases/restrictFileDTO' import { FileVersionSummarySubset } from '@/files/domain/models/FileVersionSummaryInfo' import { FileVersionPaginationInfo } from '@/files/domain/models/FileVersionPaginationInfo' +import { FileCitationFormat, FormattedFileCitation } from '@/files/domain/models/FileCitation' export class FileMockRepository implements FileRepository { constructor(public readonly fileMock?: File) {} @@ -171,6 +172,20 @@ export class FileMockRepository implements FileRepository { }) } + getFileCitationByFormat( + _fileId: number | string, + _format: FileCitationFormat + ): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + content: 'Formatted file citation content', + contentType: 'text/plain' + }) + }, FakerHelper.loadingTimout()) + }) + } + updateCategories(_fileId: number | string, _categories: string[]): Promise { return new Promise((resolve) => { setTimeout(() => { diff --git a/src/stories/file/file-citation/FileCitation.stories.tsx b/src/stories/file/file-citation/FileCitation.stories.tsx index 3a7a84dd2..3a8d24666 100644 --- a/src/stories/file/file-citation/FileCitation.stories.tsx +++ b/src/stories/file/file-citation/FileCitation.stories.tsx @@ -3,6 +3,9 @@ import { WithI18next } from '../../WithI18next' import { FileCitation } from '../../../sections/file/file-citation/FileCitation' import { FileCitationMother } from '../../../../tests/component/files/domain/models/FileMother' import { DatasetVersionMother } from '../../../../tests/component/dataset/domain/models/DatasetMother' +import { FileMockRepository } from '../FileMockRepository' + +const fileRepository = new FileMockRepository() const meta: Meta = { title: 'Sections/File Page/FileCitation', @@ -21,6 +24,8 @@ export const Default: Story = { ) @@ -34,6 +39,8 @@ export const Draft: Story = { ) @@ -49,6 +56,8 @@ export const Deaccessioned: Story = { ) diff --git a/tests/component/sections/file/File.spec.tsx b/tests/component/sections/file/File.spec.tsx index c9d6a18ae..34b7cf25f 100644 --- a/tests/component/sections/file/File.spec.tsx +++ b/tests/component/sections/file/File.spec.tsx @@ -37,6 +37,7 @@ describe('File', () => { cy.findByText(`This file is part of "${testFile.datasetVersion.title}".`).should('exist') cy.findByText('File Citation').should('exist') cy.findByText(/fileName/).should('exist') + cy.findByText('Cite Data File').should('exist') cy.findByText('Dataset Citation').should('exist') cy.findByText('Cite Dataset').should('exist') cy.findAllByText(/Bennet, Elizabeth; Darcy, Fitzwilliam, 2023, "Dataset Title",/).should( diff --git a/tests/component/sections/file/file-citation/FileCitation.spec.tsx b/tests/component/sections/file/file-citation/FileCitation.spec.tsx index c9d0fb7fc..b7a23f835 100644 --- a/tests/component/sections/file/file-citation/FileCitation.spec.tsx +++ b/tests/component/sections/file/file-citation/FileCitation.spec.tsx @@ -1,24 +1,42 @@ import { FileCitation } from '../../../../../src/sections/file/file-citation/FileCitation' import { FileCitationMother } from '../../../files/domain/models/FileMother' import { DatasetVersionMother } from '../../../dataset/domain/models/DatasetMother' +import { FileRepository } from '@/files/domain/repositories/FileRepository' + +const fileRepository: FileRepository = {} as FileRepository describe('FileCitation', () => { it('renders the FileCitation', () => { const citation = FileCitationMother.create('File Title') const datasetVersion = DatasetVersionMother.createReleased() - cy.customMount() + cy.customMount( + + ) cy.findByText(/File Title/).should('exist') cy.findByText(/Bennet, Elizabeth; Darcy, Fitzwilliam, 2023, "Dataset Title",/).should('exist') cy.findByText(/RELEASED/).should('not.exist') cy.findByText(/V1/).should('exist') + cy.findByRole('button', { name: 'Cite Data File' }).should('exist') }) it('renders the FileCitation when the dataset is deaccessioned', () => { const citation = FileCitationMother.create('File Title') const datasetVersion = DatasetVersionMother.createDeaccessioned() - cy.customMount() + cy.customMount( + + ) cy.findByText(/File Title/).should('exist') cy.findByText(/Bennet, Elizabeth; Darcy, Fitzwilliam, 2023, "Dataset Title",/).should('exist') cy.findByRole('img', { name: 'tooltip icon' }).should('exist').trigger('mouseover') diff --git a/tests/component/sections/shared/citation/CitationDownloadButton.spec.tsx b/tests/component/sections/shared/citation/CitationDownloadButton.spec.tsx index 48a3520fd..08b09e331 100644 --- a/tests/component/sections/shared/citation/CitationDownloadButton.spec.tsx +++ b/tests/component/sections/shared/citation/CitationDownloadButton.spec.tsx @@ -34,7 +34,7 @@ describe('CitationDownloadButton', () => { cy.findByRole('button', { name: 'Cite Dataset' }).should('exist') }) - it('downloads EndNote XML citation', () => { + it('downloads EndNote XML citation and shows success toast', () => { datasetRepository.getDatasetCitationInOtherFormats = cy.stub().resolves(mockCitation) cy.customMount( @@ -55,6 +55,7 @@ describe('CitationDownloadButton', () => { }) cy.get('@createObjectURL').should('have.been.called') cy.get('@revokeObjectURL').should('have.been.called') + cy.findByText('Citation downloaded successfully').should('exist') }) it('downloads RIS citation and triggers file download', () => { diff --git a/tests/component/sections/shared/citation/FileCitationDownloadButton.spec.tsx b/tests/component/sections/shared/citation/FileCitationDownloadButton.spec.tsx new file mode 100644 index 000000000..7f2441609 --- /dev/null +++ b/tests/component/sections/shared/citation/FileCitationDownloadButton.spec.tsx @@ -0,0 +1,63 @@ +import { FileRepository } from '@/files/domain/repositories/FileRepository' +import { FormattedFileCitation } from '@/files/domain/models/FileCitation' +import { FileCitationDownloadButton } from '@/sections/shared/citation/citation-download/FileCitationDownloadButton' + +const fileRepository: FileRepository = {} as FileRepository +const mockCitation: FormattedFileCitation = { + content: 'Mock File Citation', + contentType: 'text/plain' +} + +describe('FileCitationDownloadButton', () => { + beforeEach(() => { + cy.window().then((win) => { + cy.stub(win.URL, 'createObjectURL').returns('mock-url') + cy.stub(win.URL, 'revokeObjectURL') + }) + }) + + it('renders the Cite Data File button', () => { + cy.customMount() + cy.findByRole('button', { name: 'Cite Data File' }).should('exist') + }) + + it('opens the dropdown list and displays citation download options', () => { + fileRepository.getFileCitationByFormat = cy.stub().resolves(mockCitation) + + cy.customMount() + + cy.findByRole('button', { name: 'Cite Data File' }).click() + cy.findByText('Download EndNote XML').should('exist') + cy.findByText('Download RIS').should('exist') + cy.findByText('Download BibTeX').should('exist') + cy.findByText('View Styled Citation').should('not.exist') + }) + + it('downloads BibTeX file citation and shows success toast', () => { + fileRepository.getFileCitationByFormat = cy.stub().resolves(mockCitation) + + cy.customMount() + + cy.findByRole('button', { name: 'Cite Data File' }).click() + cy.findByText('Download BibTeX').click() + + cy.then(() => { + expect(fileRepository.getFileCitationByFormat).to.have.been.calledWith(3, 'BibTeX') + }) + cy.window().then((win) => { + expect(win.URL['createObjectURL']).to.have.been.called + expect(win.URL['revokeObjectURL']).to.have.been.called + }) + cy.findByText('Citation downloaded successfully').should('exist') + }) + + it('handles errors when downloading file citation', () => { + fileRepository.getFileCitationByFormat = cy.stub().rejects(new Error('Download error')) + + cy.customMount() + + cy.findByRole('button', { name: 'Cite Data File' }).click() + cy.findByText('Download EndNote XML').click() + cy.findByText('An error occurred while downloading the citation').should('exist') + }) +})