diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index eb0d503da..d37e500b9 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -328,7 +328,7 @@ dataset/ │ ├── Dataset.ts │ ├── DatasetFormFields.ts │ ├── DatasetPaginationInfo.ts - │ ├── DatasetPreview.ts + │ ├── DatasetItemTypePreview.ts │ ├── DatasetValidationResponse.ts │ └── TotalDatasetsCount.ts └── repositories/ @@ -655,11 +655,11 @@ This means: import { Home } from '../../../../src/sections/home/Home' import { DatasetRepository } from '../../../../src/dataset/domain/repositories/DatasetRepository' -import { DatasetPreviewMother } from '../../dataset/domain/models/DatasetPreviewMother' +import { DatasetItemTypePreviewMother } from '../../dataset/domain/models/DatasetItemTypePreviewMother' const datasetRepository: DatasetRepository = {} as DatasetRepository const totalDatasetsCount = 10 -const datasets = DatasetPreviewMother.createMany(totalDatasetsCount) +const datasets = DatasetItemTypePreviewMother.createMany(totalDatasetsCount) describe('Home page', () => { beforeEach(() => { datasetRepository.getAll = cy.stub().resolves(datasets) diff --git a/dev-env/docker-compose-dev.yml b/dev-env/docker-compose-dev.yml index cdefa5ea0..7f6f21c26 100644 --- a/dev-env/docker-compose-dev.yml +++ b/dev-env/docker-compose-dev.yml @@ -66,8 +66,8 @@ services: -Ddataverse.files.s3.connection-pool-size=2048 -Ddataverse.files.s3.custom-endpoint-region=us-east-1 -Ddataverse.files.s3.custom-endpoint-url=https://s3.us-east-1.amazonaws.com - expose: - - '8080' + ports: + - '8080:8080' networks: - dataverse depends_on: diff --git a/dev-env/vite.config.ts b/dev-env/vite.config.ts index 972a2ce0c..1effd4727 100644 --- a/dev-env/vite.config.ts +++ b/dev-env/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import istanbul from 'vite-plugin-istanbul' +import * as path from 'path' export default defineConfig({ plugins: [ @@ -20,5 +21,11 @@ export default defineConfig({ hmr: { clientPort: 8000 // nginx reverse proxy port } + }, + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + '@tests': path.resolve(__dirname, 'tests') + } } }) diff --git a/packages/design-system/CHANGELOG.md b/packages/design-system/CHANGELOG.md index eab236892..fa66761af 100644 --- a/packages/design-system/CHANGELOG.md +++ b/packages/design-system/CHANGELOG.md @@ -32,7 +32,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - **FormSelect:** remove withinMultipleFieldsGroup prop. - **FormText:** remove withinMultipleFieldsGroup prop. - **FormTextArea:** remove withinMultipleFieldsGroup prop. -- **FormInputGroup:** remove hasVisibleLabel prop. +- **FormInputGroup:** remove hasVisibleLabel prop and accepts className prop. - **FormInputGroupText:** refactor type. - **Card:** NEW card element to show header and body. - **ProgressBar:** NEW progress bar element to show progress. @@ -42,7 +42,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - **NavbarDropdownItem:** Now accepts `as` prop and takes `as` Element props. - **FormInputGroup:** extend Props Interface to accept `hasValidation` prop to properly show rounded corners in an with validation - **Button:** extend Props Interface to accept `size` prop. -- **FormInput:** extend Props Interface to accept `autoFocus` prop. +- **FormInput:** extend Props Interface to accept `autoFocus` and `type: 'search'` prop. - **FormTextArea:** extend Props Interface to accept `autoFocus` prop. - **FormSelect:** extend Props Interface to accept `autoFocus` prop. - **Stack:** NEW Stack element to manage layouts. @@ -51,6 +51,8 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - **Spinner:** New Spinner component. - **CloseButton:** NEW close button component. - **Tab:** extend Props Interface to accept `disabled` prop to disable the tab. +- **Offcanvas:** NEW Offcanvas component. +- **FormCheckbox:** modify Props Interface to allow any react node as `label` prop. # [1.1.0](https://github.com/IQSS/dataverse-frontend/compare/@iqss/dataverse-design-system@1.0.1...@iqss/dataverse-design-system@1.1.0) (2024-03-12) diff --git a/packages/design-system/src/lib/assets/styles/bootstrap-customized.scss b/packages/design-system/src/lib/assets/styles/bootstrap-customized.scss index b1e7768bf..935ceda5f 100644 --- a/packages/design-system/src/lib/assets/styles/bootstrap-customized.scss +++ b/packages/design-system/src/lib/assets/styles/bootstrap-customized.scss @@ -95,6 +95,9 @@ $tooltip-max-width: 500px; // Spinner @import 'bootstrap/scss/spinners'; +// OffCanvas +@import 'bootstrap/scss/offcanvas'; + // Navbar $navbar-light-brand-color: $dv-brand-color; diff --git a/packages/design-system/src/lib/components/form/form-group/form-element/FormCheckbox.tsx b/packages/design-system/src/lib/components/form/form-group/form-element/FormCheckbox.tsx index 37dffe19a..d0a4c4c49 100644 --- a/packages/design-system/src/lib/components/form/form-group/form-element/FormCheckbox.tsx +++ b/packages/design-system/src/lib/components/form/form-group/form-element/FormCheckbox.tsx @@ -4,7 +4,7 @@ import * as React from 'react' export interface FormCheckboxProps extends Omit, 'type'> { id: string - label: string + label: React.ReactNode isValid?: boolean isInvalid?: boolean invalidFeedback?: string diff --git a/packages/design-system/src/lib/components/form/form-group/form-element/FormInput.tsx b/packages/design-system/src/lib/components/form/form-group/form-element/FormInput.tsx index 530b3c2b6..01c0dbf77 100644 --- a/packages/design-system/src/lib/components/form/form-group/form-element/FormInput.tsx +++ b/packages/design-system/src/lib/components/form/form-group/form-element/FormInput.tsx @@ -4,7 +4,7 @@ import * as React from 'react' export type FormInputElement = HTMLInputElement | HTMLTextAreaElement export interface FormInputProps extends React.HTMLAttributes { - type?: 'text' | 'email' | 'password' + type?: 'text' | 'email' | 'password' | 'search' readOnly?: boolean name?: string isValid?: boolean diff --git a/packages/design-system/src/lib/components/form/form-group/form-input-group/FormInputGroup.tsx b/packages/design-system/src/lib/components/form/form-group/form-input-group/FormInputGroup.tsx index 9694502f0..dceb62024 100644 --- a/packages/design-system/src/lib/components/form/form-group/form-input-group/FormInputGroup.tsx +++ b/packages/design-system/src/lib/components/form/form-group/form-input-group/FormInputGroup.tsx @@ -5,11 +5,12 @@ import { FormInputGroupText } from './FormInputGroupText' interface FormInputGroupProps { children: ReactNode hasValidation?: boolean + className?: string } -function FormInputGroup({ children, hasValidation }: FormInputGroupProps) { +function FormInputGroup({ children, hasValidation, className = '' }: FormInputGroupProps) { return ( - + {children} ) diff --git a/packages/design-system/src/lib/components/offcanvas/Offcanvas.tsx b/packages/design-system/src/lib/components/offcanvas/Offcanvas.tsx new file mode 100644 index 000000000..5b53bedea --- /dev/null +++ b/packages/design-system/src/lib/components/offcanvas/Offcanvas.tsx @@ -0,0 +1,41 @@ +import { Offcanvas as OffcanvasBS } from 'react-bootstrap' +import { OffcanvasHeader } from './OffcanvasHeader' +import { OffcanvasTitle } from './OffcanvasTitle' +import { OffcanvasBody } from './OffcanvasBody' + +// https://react-bootstrap.netlify.app/docs/components/offcanvas + +interface OffcanvasProps { + show: boolean + placement?: 'start' | 'end' | 'top' | 'bottom' + responsive?: 'sm' | 'md' | 'lg' | 'xl' | 'xxl' + onShow?: () => void + onHide?: () => void + children: React.ReactNode +} + +const Offcanvas = ({ + show, + placement = 'start', + responsive, + onHide, + onShow, + children +}: OffcanvasProps) => { + return ( + + {children} + + ) +} + +Offcanvas.Header = OffcanvasHeader +Offcanvas.Title = OffcanvasTitle +Offcanvas.Body = OffcanvasBody + +export { Offcanvas } diff --git a/packages/design-system/src/lib/components/offcanvas/OffcanvasBody.tsx b/packages/design-system/src/lib/components/offcanvas/OffcanvasBody.tsx new file mode 100644 index 000000000..8f5220b49 --- /dev/null +++ b/packages/design-system/src/lib/components/offcanvas/OffcanvasBody.tsx @@ -0,0 +1,10 @@ +import { OffcanvasBody as OffcanvasBodyBS } from 'react-bootstrap' + +export interface OffcanvasBodyProps { + children: React.ReactNode + dataTestId?: string +} + +export const OffcanvasBody = ({ children, dataTestId }: OffcanvasBodyProps) => { + return {children} +} diff --git a/packages/design-system/src/lib/components/offcanvas/OffcanvasHeader.tsx b/packages/design-system/src/lib/components/offcanvas/OffcanvasHeader.tsx new file mode 100644 index 000000000..20b753935 --- /dev/null +++ b/packages/design-system/src/lib/components/offcanvas/OffcanvasHeader.tsx @@ -0,0 +1,19 @@ +import { OffcanvasHeader as OffcanvasHeaderBS } from 'react-bootstrap' + +export interface OffcanvasHeaderProps { + closeLabel?: string + closeButton?: boolean + children: React.ReactNode +} + +export const OffcanvasHeader = ({ + closeLabel = 'Close', + closeButton = true, + children +}: OffcanvasHeaderProps) => { + return ( + + {children} + + ) +} diff --git a/packages/design-system/src/lib/components/offcanvas/OffcanvasTitle.tsx b/packages/design-system/src/lib/components/offcanvas/OffcanvasTitle.tsx new file mode 100644 index 000000000..662bc9c92 --- /dev/null +++ b/packages/design-system/src/lib/components/offcanvas/OffcanvasTitle.tsx @@ -0,0 +1,9 @@ +import { OffcanvasTitle as OffcanvasTitleBS } from 'react-bootstrap' + +export interface OffcanvasTitleProps { + children: React.ReactNode +} + +export const OffcanvasTitle = ({ children }: OffcanvasTitleProps) => { + return {children} +} diff --git a/packages/design-system/src/lib/index.ts b/packages/design-system/src/lib/index.ts index e8dfe2a15..5d6de754d 100644 --- a/packages/design-system/src/lib/index.ts +++ b/packages/design-system/src/lib/index.ts @@ -31,3 +31,4 @@ export { Stack } from './components/stack/Stack' export { Spinner } from './components/spinner/Spinner' export { TransferList, type TransferListItem } from './components/transfer-list/TransferList' export { CloseButton } from './components/close-button/CloseButton' +export { Offcanvas } from './components/offcanvas/Offcanvas' diff --git a/packages/design-system/src/lib/stories/form/Form.stories.tsx b/packages/design-system/src/lib/stories/form/Form.stories.tsx index c8ebb26f6..3a41e0dcd 100644 --- a/packages/design-system/src/lib/stories/form/Form.stories.tsx +++ b/packages/design-system/src/lib/stories/form/Form.stories.tsx @@ -78,6 +78,15 @@ export const AllInputTypes: Story = { + + + + Search something + + + + + ) } diff --git a/packages/design-system/src/lib/stories/offcanvas/Offcanvas.stories.tsx b/packages/design-system/src/lib/stories/offcanvas/Offcanvas.stories.tsx new file mode 100644 index 000000000..3ab3ec7b8 --- /dev/null +++ b/packages/design-system/src/lib/stories/offcanvas/Offcanvas.stories.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { Offcanvas } from '../../components/offcanvas/Offcanvas' +import { Button } from '../../components/button/Button' + +const meta: Meta = { + title: 'Offcanvas', + component: Offcanvas, + tags: ['autodocs'] +} + +export default meta +type Story = StoryObj + +const OffcanvasWithTrigger = ({ + placement = 'start', + responsive +}: { + placement?: 'start' | 'end' | 'top' | 'bottom' + responsive?: 'sm' | 'md' | 'lg' | 'xl' | 'xxl' +}) => { + const [show, setShow] = useState(responsive ? true : false) + + const handleClose = () => setShow(false) + const handleShow = () => setShow(true) + + return ( +
+ {!responsive && } + + + + Offcanvas Title + + + {responsive ? ( +
+

Resize your browser to show the responsive offcanvas toggle.

+

+ Responsive offcanvas classes hide content outside the viewport from a specified + breakpoint and down. Above that breakpoint, the contents within will behave as + usual. +

+
+ ) : ( +

All the content goes here

+ )} +
+
+
+ ) +} + +export const Default: Story = { + render: () => +} + +export const TopPlacement: Story = { + render: () => +} + +export const EndPlacement: Story = { + render: () => +} + +export const BottomPlacement: Story = { + render: () => +} + +export const Responsive: Story = { + render: () => +} diff --git a/packages/design-system/tests/component/off-canvas/Offcanvas.spec.tsx b/packages/design-system/tests/component/off-canvas/Offcanvas.spec.tsx new file mode 100644 index 000000000..7ae621832 --- /dev/null +++ b/packages/design-system/tests/component/off-canvas/Offcanvas.spec.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react' +import { Offcanvas } from '../../../src/lib/components/offcanvas/Offcanvas' + +const OffcanvasWithTrigger = ({ + placement, + responsive, + withCloseButton +}: { + placement?: 'start' | 'end' | 'top' | 'bottom' + responsive?: 'sm' | 'md' | 'lg' | 'xl' | 'xxl' + withCloseButton?: boolean +}) => { + const [show, setShow] = useState(false) + + const handleClose = () => setShow(false) + const handleShow = () => setShow(true) + + return ( +
+ + + + + Offcanvas Title + + + {responsive ? ( +
+

Resize your browser to show the responsive offcanvas toggle.

+

+ Responsive offcanvas classes hide content outside the viewport from a specified + breakpoint and down. Above that breakpoint, the contents within will behave as + usual. +

+
+ ) : ( +

All the content goes here

+ )} +
+
+
+ ) +} + +describe('Offcanvas', () => { + it('opens and close correctly by a button', () => { + cy.viewport(375, 700) + + cy.mount() + + cy.findByTestId('off-canvas-body').should('not.be.visible') + + cy.findByRole('button', { name: /Open Offcanvas/i }).click() + cy.findByTestId('off-canvas-body').should('be.visible') + + cy.findByLabelText(/Close/).click() + cy.findByTestId('off-canvas-body').should('not.be.visible') + }) + + it('is shown automatically after lg screens (992px)', () => { + cy.viewport(1200, 700) + + cy.mount() + + cy.findByTestId('off-canvas-body').should('be.visible') + }) + + it('is not show an placement start as default props if not passed', () => { + cy.viewport(375, 700) + + cy.mount() + + cy.findByTestId('off-canvas-body').should('not.be.visible') + + cy.findByRole('button', { name: /Open Offcanvas/i }).click() + + cy.findByRole('dialog').should('have.class', 'offcanvas-start') + }) +}) diff --git a/public/locales/en/collection.json b/public/locales/en/collection.json index 79a4473af..d45c4b482 100644 --- a/public/locales/en/collection.json +++ b/public/locales/en/collection.json @@ -3,5 +3,25 @@ "authenticated": "This collection currently has no datasets. You can add to it by using the Add Data button on this page.", "anonymous": "This collection currently has no datasets. Please <1>log in to see if you are able to add to it." }, - "createdAlert": "You have successfully created your collection! To learn more about what you can do with your collection, check out the User Guide." + "noItemsMessage": { + "authenticated": "This collection currently has no {{typeOfEmptyItems}}. You can add to it by using the Add Data button on this page.", + "anonymous": "This collection currently has no {{typeOfEmptyItems}}. Please <1>log in to see if you are able to add to it.", + "itemTypeMessage": { + "all": "collections, datasets or files", + "collection": "collections", + "dataset": "datasets", + "file": "files", + "collectionAndDataset": "collections or datasets", + "collectionAndFile": "collections or files", + "datasetAndFile": "datasets or files" + } + }, + "noSearchMatches": "There are no collections, datasets, or files that match your search. Please try a new search by using other or broader terms. You can also check out the search guide for tips.", + "createdAlert": "You have successfully created your collection! To learn more about what you can do with your collection, check out the User Guide.", + "filterResults": "Filter Results", + "collectionFilterTypeLabel": "Collections", + "datasetFilterTypeLabel": "Datasets", + "fileFilterTypeLabel": "Files", + "searchThisCollectionPlaceholder": "Search this collection...", + "searchSubmitButtonLabel": "Search submit" } diff --git a/src/collection/domain/models/CollectionItemSubset.ts b/src/collection/domain/models/CollectionItemSubset.ts new file mode 100644 index 000000000..3fbc3b0b7 --- /dev/null +++ b/src/collection/domain/models/CollectionItemSubset.ts @@ -0,0 +1,13 @@ +import { CollectionItemTypePreview } from './CollectionItemTypePreview' +import { DatasetItemTypePreview } from '../../../dataset/domain/models/DatasetItemTypePreview' +import { FileItemTypePreview } from '../../../files/domain/models/FileItemTypePreview' + +export interface CollectionItemSubset { + items: CollectionItem[] + totalItemCount: number +} + +export type CollectionItem = + | CollectionItemTypePreview + | DatasetItemTypePreview + | FileItemTypePreview diff --git a/src/collection/domain/models/CollectionItemType.ts b/src/collection/domain/models/CollectionItemType.ts new file mode 100644 index 000000000..a4343fc1f --- /dev/null +++ b/src/collection/domain/models/CollectionItemType.ts @@ -0,0 +1,5 @@ +export enum CollectionItemType { + FILE = 'file', + DATASET = 'dataset', + COLLECTION = 'collection' +} diff --git a/src/collection/domain/models/CollectionItemTypePreview.ts b/src/collection/domain/models/CollectionItemTypePreview.ts new file mode 100644 index 000000000..383224cec --- /dev/null +++ b/src/collection/domain/models/CollectionItemTypePreview.ts @@ -0,0 +1,14 @@ +import { CollectionItemType } from './CollectionItemType' + +export interface CollectionItemTypePreview { + type: CollectionItemType.COLLECTION + isReleased: boolean + name: string + alias: string + description?: string + affiliation?: string + releaseOrCreateDate: Date + thumbnail?: string + parentCollectionName: string + parentCollectionAlias: string +} diff --git a/src/collection/domain/models/CollectionItemsPaginationInfo.ts b/src/collection/domain/models/CollectionItemsPaginationInfo.ts new file mode 100644 index 000000000..084926342 --- /dev/null +++ b/src/collection/domain/models/CollectionItemsPaginationInfo.ts @@ -0,0 +1,7 @@ +import { PaginationInfo } from '../../../shared/pagination/domain/models/PaginationInfo' + +export class CollectionItemsPaginationInfo extends PaginationInfo { + constructor(page = 1, pageSize = 10, totalItems = 0, itemName = 'result') { + super(page, pageSize, totalItems, itemName) + } +} diff --git a/src/collection/domain/models/CollectionPreview.ts b/src/collection/domain/models/CollectionPreview.ts deleted file mode 100644 index c7842f611..000000000 --- a/src/collection/domain/models/CollectionPreview.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface CollectionPreview { - id: string - name: string - isReleased: boolean - releaseOrCreateDate: Date - parentCollectionId?: string - parentCollectionName?: string - description?: string - affiliation?: string - thumbnail?: string -} diff --git a/src/collection/domain/models/CollectionSearchCriteria.tsx b/src/collection/domain/models/CollectionSearchCriteria.tsx new file mode 100644 index 000000000..247439c7d --- /dev/null +++ b/src/collection/domain/models/CollectionSearchCriteria.tsx @@ -0,0 +1,20 @@ +import { type CollectionItemType } from './CollectionItemType' + +export class CollectionSearchCriteria { + constructor( + public readonly searchText?: string, + public readonly itemTypes?: CollectionItemType[] + ) {} + + withSearchText(searchText: string | undefined): CollectionSearchCriteria { + return new CollectionSearchCriteria(searchText, this.itemTypes) + } + + withItemTypes(itemTypes: CollectionItemType[] | undefined): CollectionSearchCriteria { + return new CollectionSearchCriteria(this.searchText, itemTypes) + } + + hasSearchText(): boolean { + return !!this.searchText + } +} diff --git a/src/collection/domain/repositories/CollectionRepository.ts b/src/collection/domain/repositories/CollectionRepository.ts index 3cc8c3e96..4d54cb2d2 100644 --- a/src/collection/domain/repositories/CollectionRepository.ts +++ b/src/collection/domain/repositories/CollectionRepository.ts @@ -1,5 +1,8 @@ import { Collection } from '../models/Collection' import { CollectionFacet } from '../models/CollectionFacet' +import { CollectionItemsPaginationInfo } from '../models/CollectionItemsPaginationInfo' +import { CollectionItemSubset } from '../models/CollectionItemSubset' +import { CollectionSearchCriteria } from '../models/CollectionSearchCriteria' import { CollectionUserPermissions } from '../models/CollectionUserPermissions' import { CollectionDTO } from '../useCases/DTOs/CollectionDTO' @@ -8,4 +11,9 @@ export interface CollectionRepository { create(collection: CollectionDTO, hostCollection?: string): Promise getFacets(collectionIdOrAlias: number | string): Promise getUserPermissions(collectionIdOrAlias: number | string): Promise + getItems( + collectionId: string, + paginationInfo: CollectionItemsPaginationInfo, + searchCriteria?: CollectionSearchCriteria + ): Promise } diff --git a/src/collection/domain/useCases/getCollectionItems.ts b/src/collection/domain/useCases/getCollectionItems.ts new file mode 100644 index 000000000..44b15c7eb --- /dev/null +++ b/src/collection/domain/useCases/getCollectionItems.ts @@ -0,0 +1,17 @@ +import { CollectionRepository } from '../repositories/CollectionRepository' +import { CollectionItemsPaginationInfo } from '../models/CollectionItemsPaginationInfo' +import { CollectionItemSubset } from '../models/CollectionItemSubset' +import { CollectionSearchCriteria } from '../models/CollectionSearchCriteria' + +export async function getCollectionItems( + collectionRepository: CollectionRepository, + collectionId: string, + paginationInfo: CollectionItemsPaginationInfo, + searchCriteria: CollectionSearchCriteria +): Promise { + return collectionRepository + .getItems(collectionId, paginationInfo, searchCriteria) + .catch((error: Error) => { + throw new Error(error.message) + }) +} diff --git a/src/collection/infrastructure/mappers/JSCollectionItemsMapper.ts b/src/collection/infrastructure/mappers/JSCollectionItemsMapper.ts new file mode 100644 index 000000000..10076bee8 --- /dev/null +++ b/src/collection/infrastructure/mappers/JSCollectionItemsMapper.ts @@ -0,0 +1,58 @@ +import { + CollectionPreview as JSCollectionPreview, + DatasetPreview as JSDatasetPreview, + FilePreview as JSFilePreview, + CollectionItemType as JSCollectionItemType +} from '@iqss/dataverse-client-javascript' +import { PublicationStatus } from '../../../shared/core/domain/models/PublicationStatus' +import { CollectionItem } from '../../domain/models/CollectionItemSubset' +import { CollectionItemTypePreview } from '../../domain/models/CollectionItemTypePreview' +import { JSDatasetPreviewMapper } from '../../../dataset/infrastructure/mappers/JSDatasetPreviewMapper' +import { JSFileItemTypePreviewMapper } from '../../../files/infrastructure/mappers/JSFileItemTypePreviewMapper' + +export class JSCollectionItemsMapper { + static toCollectionItemsPreviews( + jsCollectionItems: (JSCollectionPreview | JSDatasetPreview | JSFilePreview)[] + ): CollectionItem[] { + const items: CollectionItem[] = [] + + jsCollectionItems.forEach((item: JSCollectionPreview | JSDatasetPreview | JSFilePreview) => { + if (item.type === JSCollectionItemType.COLLECTION) { + items.push(this.toCollectionItemTypePreview(item)) + } + + if (item.type === JSCollectionItemType.DATASET) { + items.push(JSDatasetPreviewMapper.toDatasetItemTypePreview(item)) + } + + if (item.type === JSCollectionItemType.FILE) { + items.push(JSFileItemTypePreviewMapper.toFileItemTypePreview(item)) + } + }) + + return items + } + + static toCollectionItemTypePreview( + jsCollectionPreview: JSCollectionPreview + ): CollectionItemTypePreview { + return { + type: jsCollectionPreview.type, + isReleased: JSCollectionItemsMapper.toIsRelasedCollection( + jsCollectionPreview.publicationStatuses + ), + name: jsCollectionPreview.name, + alias: jsCollectionPreview.alias, + description: jsCollectionPreview.description, + affiliation: jsCollectionPreview.affiliation, + releaseOrCreateDate: jsCollectionPreview.releaseOrCreateDate, + thumbnail: jsCollectionPreview.imageUrl, + parentCollectionName: jsCollectionPreview.parentName, + parentCollectionAlias: jsCollectionPreview.parentAlias + } + } + + static toIsRelasedCollection(jsPublicationStatus: PublicationStatus[]): boolean { + return jsPublicationStatus.includes(PublicationStatus.Published) + } +} diff --git a/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts b/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts index 0eda9f56c..85f526ad2 100644 --- a/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts +++ b/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts @@ -4,12 +4,17 @@ import { createCollection, getCollection, getCollectionFacets, - getCollectionUserPermissions + getCollectionUserPermissions, + getCollectionItems } from '@iqss/dataverse-client-javascript' import { JSCollectionMapper } from '../mappers/JSCollectionMapper' import { CollectionDTO } from '../../domain/useCases/DTOs/CollectionDTO' import { CollectionFacet } from '../../domain/models/CollectionFacet' import { CollectionUserPermissions } from '../../domain/models/CollectionUserPermissions' +import { CollectionItemsPaginationInfo } from '../../domain/models/CollectionItemsPaginationInfo' +import { CollectionItemSubset } from '../../domain/models/CollectionItemSubset' +import { CollectionSearchCriteria } from '../../domain/models/CollectionSearchCriteria' +import { JSCollectionItemsMapper } from '../mappers/JSCollectionItemsMapper' export class CollectionJSDataverseRepository implements CollectionRepository { getById(id: string): Promise { @@ -33,4 +38,23 @@ export class CollectionJSDataverseRepository implements CollectionRepository { .execute(collectionIdOrAlias) .then((jsCollectionUserPermissions) => jsCollectionUserPermissions) } + + getItems( + collectionId: string, + paginationInfo: CollectionItemsPaginationInfo, + searchCriteria: CollectionSearchCriteria + ): Promise { + return getCollectionItems + .execute(collectionId, paginationInfo?.pageSize, paginationInfo?.offset, searchCriteria) + .then((jsCollectionItemSubset) => { + const collectionItemsPreviewsMapped = JSCollectionItemsMapper.toCollectionItemsPreviews( + jsCollectionItemSubset.items + ) + + return { + items: collectionItemsPreviewsMapped, + totalItemCount: jsCollectionItemSubset.totalItemCount + } + }) + } } diff --git a/src/dataset/domain/models/DatasetItemTypePreview.ts b/src/dataset/domain/models/DatasetItemTypePreview.ts new file mode 100644 index 000000000..dd37a1e00 --- /dev/null +++ b/src/dataset/domain/models/DatasetItemTypePreview.ts @@ -0,0 +1,15 @@ +import { CollectionItemType } from '../../../collection/domain/models/CollectionItemType' +import { PublicationStatus } from '../../../shared/core/domain/models/PublicationStatus' +import { DatasetVersion } from './Dataset' + +export interface DatasetItemTypePreview { + type: CollectionItemType.DATASET + persistentId: string + version: DatasetVersion + releaseOrCreateDate: Date + description: string + thumbnail?: string + publicationStatuses: PublicationStatus[] + parentCollectionName: string + parentCollectionAlias: string +} diff --git a/src/dataset/domain/models/DatasetPreview.ts b/src/dataset/domain/models/DatasetPreview.ts deleted file mode 100644 index e7688499d..000000000 --- a/src/dataset/domain/models/DatasetPreview.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { DatasetVersion } from './Dataset' - -export class DatasetPreview { - constructor( - public persistentId: string, - public version: DatasetVersion, - public releaseOrCreateDate: Date, - public description: string, - public thumbnail?: string - ) {} - - get abbreviatedDescription(): string { - if (this.description.length > 280) { - return `${this.description.substring(0, 280)}...` - } - return this.description - } -} diff --git a/src/dataset/domain/models/DatasetsWithCount.ts b/src/dataset/domain/models/DatasetsWithCount.ts index 9194f4b62..7b217a5b7 100644 --- a/src/dataset/domain/models/DatasetsWithCount.ts +++ b/src/dataset/domain/models/DatasetsWithCount.ts @@ -1,7 +1,7 @@ -import { DatasetPreview } from '../../domain/models/DatasetPreview' +import { DatasetItemTypePreview } from './DatasetItemTypePreview' import { TotalDatasetsCount } from './TotalDatasetsCount' export interface DatasetsWithCount { - datasetPreviews: DatasetPreview[] + datasetPreviews: DatasetItemTypePreview[] totalCount: TotalDatasetsCount } diff --git a/src/dataset/infrastructure/mappers/JSDatasetPreviewMapper.ts b/src/dataset/infrastructure/mappers/JSDatasetPreviewMapper.ts index e6e69b930..c832be823 100644 --- a/src/dataset/infrastructure/mappers/JSDatasetPreviewMapper.ts +++ b/src/dataset/infrastructure/mappers/JSDatasetPreviewMapper.ts @@ -1,22 +1,26 @@ import { DatasetPreview as JSDatasetPreview } from '@iqss/dataverse-client-javascript/dist/datasets/domain/models/DatasetPreview' -import { DatasetPreview } from '../../domain/models/DatasetPreview' +import { DatasetItemTypePreview } from '../../domain/models/DatasetItemTypePreview' import { DatasetVersionInfo as JSDatasetVersionInfo } from '@iqss/dataverse-client-javascript/dist/datasets/domain/models/Dataset' import { JSDatasetVersionMapper } from './JSDatasetVersionMapper' export class JSDatasetPreviewMapper { - static toDatasetPreview(jsDatasetPreview: JSDatasetPreview): DatasetPreview { - return new DatasetPreview( - jsDatasetPreview.persistentId, - JSDatasetVersionMapper.toVersion( + static toDatasetItemTypePreview(jsDatasetPreview: JSDatasetPreview): DatasetItemTypePreview { + return { + type: jsDatasetPreview.type, + persistentId: jsDatasetPreview.persistentId, + version: JSDatasetVersionMapper.toVersion( jsDatasetPreview.versionId, jsDatasetPreview.versionInfo, jsDatasetPreview.title, jsDatasetPreview.citation ), - JSDatasetPreviewMapper.toPreviewDate(jsDatasetPreview.versionInfo), - jsDatasetPreview.description, - undefined // TODO: get dataset thumbnail from Dataverse https://github.com/IQSS/dataverse-frontend/issues/203 - ) + releaseOrCreateDate: JSDatasetPreviewMapper.toPreviewDate(jsDatasetPreview.versionInfo), + description: jsDatasetPreview.description, + thumbnail: undefined, // TODO: get dataset thumbnail from Dataverse https://github.com/IQSS/dataverse-frontend/issues/203 + publicationStatuses: jsDatasetPreview.publicationStatuses, + parentCollectionName: jsDatasetPreview.parentCollectionName, + parentCollectionAlias: jsDatasetPreview.parentCollectionAlias + } } static toPreviewDate(jsVersionInfo: JSDatasetVersionInfo): Date { diff --git a/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts b/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts index ef68c1c5d..cb352631a 100644 --- a/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts +++ b/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts @@ -59,7 +59,7 @@ export class DatasetJSDataverseRepository implements DatasetRepository { .then((subset: DatasetPreviewSubset) => { const datasetPreviewsMapped = subset.datasetPreviews.map( (datasetPreview: JSDatasetPreview) => - JSDatasetPreviewMapper.toDatasetPreview(datasetPreview) + JSDatasetPreviewMapper.toDatasetItemTypePreview(datasetPreview) ) return { datasetPreviews: datasetPreviewsMapped, diff --git a/src/files/domain/models/FileItemTypePreview.ts b/src/files/domain/models/FileItemTypePreview.ts new file mode 100644 index 000000000..3c129bfdf --- /dev/null +++ b/src/files/domain/models/FileItemTypePreview.ts @@ -0,0 +1,29 @@ +import { CollectionItemType } from '../../../collection/domain/models/CollectionItemType' +import { PublicationStatus } from '../../../shared/core/domain/models/PublicationStatus' + +export interface FileItemTypePreview { + type: CollectionItemType.FILE + id: number + name: string + persistentId?: string + url: string + thumbnail?: string + description: string + fileType: string + fileContentType: string + sizeInBytes: number + md5?: string + checksum?: FilePreviewChecksum + unf?: string + datasetName: string + datasetId: number + datasetPersistentId: string + datasetCitation: string + publicationStatuses: PublicationStatus[] + releaseOrCreateDate: Date +} + +export interface FilePreviewChecksum { + type: string + value: string +} diff --git a/src/files/infrastructure/mappers/JSFileItemTypePreviewMapper.ts b/src/files/infrastructure/mappers/JSFileItemTypePreviewMapper.ts new file mode 100644 index 000000000..e3ae4dea3 --- /dev/null +++ b/src/files/infrastructure/mappers/JSFileItemTypePreviewMapper.ts @@ -0,0 +1,28 @@ +import { FilePreview as JSFilePreview } from '@iqss/dataverse-client-javascript' +import { FileItemTypePreview } from '../../domain/models/FileItemTypePreview' + +export class JSFileItemTypePreviewMapper { + static toFileItemTypePreview(jsFilePreview: JSFilePreview): FileItemTypePreview { + return { + type: jsFilePreview.type, + id: jsFilePreview.fileId, + name: jsFilePreview.name, + persistentId: jsFilePreview.filePersistentId, + url: jsFilePreview.url, + thumbnail: jsFilePreview.imageUrl, + description: jsFilePreview.description, + fileType: jsFilePreview.fileType, + fileContentType: jsFilePreview.fileContentType, + sizeInBytes: jsFilePreview.sizeInBytes, + md5: jsFilePreview.md5, + checksum: jsFilePreview.checksum, + unf: jsFilePreview.unf, + datasetName: jsFilePreview.datasetName, + datasetId: jsFilePreview.datasetId, + datasetPersistentId: jsFilePreview.datasetPersistentId, + datasetCitation: jsFilePreview.datasetCitation, + publicationStatuses: jsFilePreview.publicationStatuses, + releaseOrCreateDate: jsFilePreview.releaseOrCreateDate + } + } +} diff --git a/src/sections/Route.enum.ts b/src/sections/Route.enum.ts index cca272366..179458fd3 100644 --- a/src/sections/Route.enum.ts +++ b/src/sections/Route.enum.ts @@ -27,5 +27,7 @@ export const RouteWithParams = { export enum QueryParamKey { VERSION = 'version', PERSISTENT_ID = 'persistentId', - QUERY = 'q' + QUERY = 'q', + COLLECTION_ITEM_TYPES = 'types', + PAGE = 'page' } diff --git a/src/sections/collection/Collection.module.scss b/src/sections/collection/Collection.module.scss index 0b2d0fc18..25af1b00f 100644 --- a/src/sections/collection/Collection.module.scss +++ b/src/sections/collection/Collection.module.scss @@ -1,12 +1,6 @@ @import 'node_modules/bootstrap/scss/functions'; @import 'node_modules/bootstrap/scss/variables'; -@import "node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module"; - -.container { - display: flex; - justify-content: flex-end; - margin-bottom: $spacer; -} +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; .header { margin-bottom: $spacer; @@ -19,4 +13,4 @@ .subtext { color: $dv-subtext-color; -} \ No newline at end of file +} diff --git a/src/sections/collection/Collection.tsx b/src/sections/collection/Collection.tsx index 8a0e34aed..6edb35b25 100644 --- a/src/sections/collection/Collection.tsx +++ b/src/sections/collection/Collection.tsx @@ -1,52 +1,46 @@ -import { Alert, Col, Row } from '@iqss/dataverse-design-system' -import { DatasetRepository } from '../../dataset/domain/repositories/DatasetRepository' -import { DatasetsList } from './datasets-list/DatasetsList' -import { DatasetsListWithInfiniteScroll } from './datasets-list/DatasetsListWithInfiniteScroll' -import { BreadcrumbsGenerator } from '../shared/hierarchy/BreadcrumbsGenerator' - -import styles from './Collection.module.scss' -import AddDataActionsButton from '../shared/add-data-actions/AddDataActionsButton' -import { useSession } from '../session/SessionContext' -import { useCollection } from './useCollection' +import { useTranslation } from 'react-i18next' +import { Col, Row } from '@iqss/dataverse-design-system' import { CollectionRepository } from '../../collection/domain/repositories/CollectionRepository' -import { PageNotFound } from '../page-not-found/PageNotFound' -import { CollectionSkeleton } from './CollectionSkeleton' -import { CollectionInfo } from './CollectionInfo' -import { Trans, useTranslation } from 'react-i18next' +import { useCollection } from './useCollection' import { useScrollTop } from '../../shared/hooks/useScrollTop' +import { useSession } from '../session/SessionContext' import { useGetCollectionUserPermissions } from '../../shared/hooks/useGetCollectionUserPermissions' +import { type UseCollectionQueryParamsReturnType } from './useGetCollectionQueryParams' +import { BreadcrumbsGenerator } from '../shared/hierarchy/BreadcrumbsGenerator' +import AddDataActionsButton from '../shared/add-data-actions/AddDataActionsButton' +import { CollectionItemsPanel } from './collection-items-panel/CollectionItemsPanel' +import { CollectionInfo } from './CollectionInfo' +import { CollectionSkeleton } from './CollectionSkeleton' +import { PageNotFound } from '../page-not-found/PageNotFound' +import { CreatedAlert } from './CreatedAlert' interface CollectionProps { - repository: CollectionRepository - datasetRepository: DatasetRepository - id: string + collectionRepository: CollectionRepository + collectionId: string created: boolean - page?: number + collectionQueryParams: UseCollectionQueryParamsReturnType infiniteScrollEnabled?: boolean } export function Collection({ - repository, - id, - datasetRepository, + collectionId, + collectionRepository, created, - page, - infiniteScrollEnabled = false + collectionQueryParams }: CollectionProps) { + useTranslation('collection') useScrollTop() const { user } = useSession() - const { collection, isLoading } = useCollection(repository, id) + const { collection, isLoading } = useCollection(collectionRepository, collectionId) const { collectionUserPermissions } = useGetCollectionUserPermissions({ - collectionIdOrAlias: id, - collectionRepository: repository + collectionIdOrAlias: collectionId, + collectionRepository }) const canUserAddCollection = Boolean(collectionUserPermissions?.canAddCollection) const canUserAddDataset = Boolean(collectionUserPermissions?.canAddDataset) - const showAddDataActions = user && (canUserAddCollection || canUserAddDataset) - - const { t } = useTranslation('collection') + const showAddDataActions = Boolean(user && (canUserAddCollection || canUserAddDataset)) if (!isLoading && !collection) { return @@ -61,48 +55,24 @@ export function Collection({ <> - {created && ( - - - ) - }} - /> - - )} - {showAddDataActions && ( -
- -
- )} + {created && } )} - {infiniteScrollEnabled ? ( - - ) : ( - - )} + + ) : null + } + /> ) diff --git a/src/sections/collection/CollectionFactory.tsx b/src/sections/collection/CollectionFactory.tsx index 3d6af655c..e6ee41d30 100644 --- a/src/sections/collection/CollectionFactory.tsx +++ b/src/sections/collection/CollectionFactory.tsx @@ -1,12 +1,11 @@ import { ReactElement } from 'react' -import { Collection } from './Collection' -import { DatasetJSDataverseRepository } from '../../dataset/infrastructure/repositories/DatasetJSDataverseRepository' -import { useLocation, useParams, useSearchParams } from 'react-router-dom' +import { useLocation, useParams } from 'react-router-dom' import { CollectionJSDataverseRepository } from '../../collection/infrastructure/repositories/CollectionJSDataverseRepository' +import { Collection } from './Collection' import { INFINITE_SCROLL_ENABLED } from './config' +import { useGetCollectionQueryParams } from './useGetCollectionQueryParams' -const datasetRepository = new DatasetJSDataverseRepository() -const repository = new CollectionJSDataverseRepository() +const collectionRepository = new CollectionJSDataverseRepository() export class CollectionFactory { static create(): ReactElement { return @@ -14,20 +13,18 @@ export class CollectionFactory { } function CollectionWithSearchParams() { - const [searchParams] = useSearchParams() + const collectionQueryParams = useGetCollectionQueryParams() const { collectionId = 'root' } = useParams<{ collectionId: string }>() const location = useLocation() - const page = searchParams.get('page') ? parseInt(searchParams.get('page') as string) : undefined const state = location.state as { created: boolean } | undefined const created = state?.created ?? false return ( ) diff --git a/src/sections/collection/CollectionHelper.ts b/src/sections/collection/CollectionHelper.ts new file mode 100644 index 000000000..d16273f05 --- /dev/null +++ b/src/sections/collection/CollectionHelper.ts @@ -0,0 +1,25 @@ +import { CollectionItemType } from '../../collection/domain/models/CollectionItemType' +import { QueryParamKey } from '../Route.enum' + +export class CollectionHelper { + static defineCollectionQueryParams(searchParams: URLSearchParams) { + const pageQuery = searchParams.get('page') + ? parseInt(searchParams.get('page') as string, 10) + : 1 + + const searchQuery = searchParams.get(QueryParamKey.QUERY) + ? decodeURIComponent(searchParams.get(QueryParamKey.QUERY) as string) + : undefined + + const typesParam = searchParams.get(QueryParamKey.COLLECTION_ITEM_TYPES) ?? undefined + + const typesQuery = typesParam + ?.split(',') + .map((type) => decodeURIComponent(type)) + .filter((type) => + Object.values(CollectionItemType).includes(type as CollectionItemType) + ) as CollectionItemType[] + + return { pageQuery, searchQuery, typesQuery } + } +} diff --git a/src/sections/collection/CollectionInfo.tsx b/src/sections/collection/CollectionInfo.tsx index 8c2d2ee45..82c6ef0ae 100644 --- a/src/sections/collection/CollectionInfo.tsx +++ b/src/sections/collection/CollectionInfo.tsx @@ -18,7 +18,9 @@ export function CollectionInfo({ collection }: CollectionInfoProps) { ({collection.affiliation}) )} {!collection.isReleased && ( - Unpublished +
+ Unpublished +
)} diff --git a/src/sections/collection/CreatedAlert.tsx b/src/sections/collection/CreatedAlert.tsx new file mode 100644 index 000000000..6e33f6904 --- /dev/null +++ b/src/sections/collection/CreatedAlert.tsx @@ -0,0 +1,23 @@ +import { Alert } from '@iqss/dataverse-design-system' +import { Trans, useTranslation } from 'react-i18next' + +export const CreatedAlert = () => { + const { t } = useTranslation('collection') + return ( + + + ) + }} + /> + + ) +} diff --git a/src/sections/collection/collection-items-panel/CollectionItemsPanel.module.scss b/src/sections/collection/collection-items-panel/CollectionItemsPanel.module.scss new file mode 100644 index 000000000..f852e9d6c --- /dev/null +++ b/src/sections/collection/collection-items-panel/CollectionItemsPanel.module.scss @@ -0,0 +1,35 @@ +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; + +.items-panel { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.top-wrapper { + display: flex; + flex-direction: column; + gap: 0.5rem; + + @media (min-width: 768px) { + flex-direction: row; + align-items: center; + justify-content: space-between; + } + + .add-data-slot { + align-self: flex-end; + } +} + +.bottom-wrapper { + display: flex; + flex-direction: column; + gap: 0.5rem; + + @media (min-width: 992px) { + display: grid; + grid-template-columns: 1fr 3fr; + gap: 1rem; + } +} diff --git a/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx b/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx new file mode 100644 index 000000000..cb21400c3 --- /dev/null +++ b/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx @@ -0,0 +1,225 @@ +import { useEffect, useRef, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { CollectionItemsPaginationInfo } from '@/collection/domain/models/CollectionItemsPaginationInfo' +import { CollectionSearchCriteria } from '@/collection/domain/models/CollectionSearchCriteria' +import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' +import { useGetAccumulatedItems } from './useGetAccumulatedItems' +import { UseCollectionQueryParamsReturnType } from '../useGetCollectionQueryParams' +import { useLoadMoreOnPopStateEvent } from './useLoadMoreOnPopStateEvent' +import { useLoading } from '@/sections/loading/LoadingContext' +import { QueryParamKey } from '../../Route.enum' +import { CollectionHelper } from '../CollectionHelper' +import { FilterPanel } from './filter-panel/FilterPanel' +import { ItemsList } from './items-list/ItemsList' +import { SearchPanel } from './search-panel/SearchPanel' +import { ItemTypeChange } from './filter-panel/type-filters/TypeFilters' +import styles from './CollectionItemsPanel.module.scss' + +interface CollectionItemsPanelProps { + collectionId: string + collectionRepository: CollectionRepository + collectionQueryParams: UseCollectionQueryParamsReturnType + addDataSlot: JSX.Element | null +} + +/** + * HOW IT WORKS: + * This component loads items on 5 different scenarios: + * 1. When the component mounts + * 2. When the user scrolls to the bottom of the list and there are more items to load + * 3. When the user submits a search query in the search panel + * 4. When the user changes the item types in the filter panel + * 5. When the user navigates back and forward in the browser + * + * It initializes the search criteria with the query params in the URL. + * By default if no query params are present in the URL, the search query is empty and the item types are COLLECTION and DATASET. + * Every time a load of items is triggered, the pagination info is updated and the URL is updated with the new query params so it can be shared and the user can navigate back and forward in the browser. + */ + +export const CollectionItemsPanel = ({ + collectionId, + collectionRepository, + collectionQueryParams, + addDataSlot +}: CollectionItemsPanelProps) => { + const { setIsLoading } = useLoading() + const [_, setSearchParams] = useSearchParams() + + useLoadMoreOnPopStateEvent(loadItemsOnBackAndForwardNavigation) + + // This object will update every time we update a query param in the URL with the setSearchParams setter + const currentSearchCriteria = new CollectionSearchCriteria( + collectionQueryParams.searchQuery, + collectionQueryParams.typesQuery || [CollectionItemType.COLLECTION, CollectionItemType.DATASET] + ) + + const [paginationInfo, setPaginationInfo] = useState( + new CollectionItemsPaginationInfo() + ) + const itemsListContainerRef = useRef(null) + + const { + isLoadingItems, + accumulatedItems, + totalAvailable, + hasNextPage, + error, + loadMore, + isEmptyItems, + areItemsAvailable, + accumulatedCount + } = useGetAccumulatedItems({ + collectionRepository, + collectionId + }) + + async function handleLoadMoreOnBottomReach(currentPagination: CollectionItemsPaginationInfo) { + let paginationInfoToSend = currentPagination + if (totalAvailable !== undefined) { + paginationInfoToSend = currentPagination.goToNextPage() + } + + const totalItemsCount = await loadMore(paginationInfoToSend, currentSearchCriteria) + + if (totalItemsCount !== undefined) { + const paginationInfoUpdated = paginationInfoToSend.withTotal(totalItemsCount) + setPaginationInfo(paginationInfoUpdated) + } + } + + const handleSearchSubmit = async (searchValue: string) => { + itemsListContainerRef.current?.scrollTo({ top: 0 }) + + const resetPaginationInfo = new CollectionItemsPaginationInfo() + setPaginationInfo(resetPaginationInfo) + + if (searchValue === '') { + // Update the URL without the search value, keep other querys + setSearchParams((currentSearchParams) => { + currentSearchParams.delete(QueryParamKey.QUERY) + return currentSearchParams + }) + } else { + // Update the URL with the search value ,keep other querys and include all item types always + setSearchParams((currentSearchParams) => ({ + ...currentSearchParams, + [QueryParamKey.COLLECTION_ITEM_TYPES]: [ + CollectionItemType.COLLECTION, + CollectionItemType.DATASET, + CollectionItemType.FILE + ].join(','), + [QueryParamKey.QUERY]: searchValue + })) + } + + // WHEN SEARCHING, WE RESET THE PAGINATION INFO AND KEEP ALL ITEM TYPES!! + const newCollectionSearchCriteria = new CollectionSearchCriteria( + searchValue === '' ? undefined : searchValue, + [CollectionItemType.COLLECTION, CollectionItemType.DATASET, CollectionItemType.FILE] + ) + + const totalItemsCount = await loadMore(resetPaginationInfo, newCollectionSearchCriteria, true) + + if (totalItemsCount !== undefined) { + const paginationInfoUpdated = resetPaginationInfo.withTotal(totalItemsCount) + setPaginationInfo(paginationInfoUpdated) + } + } + + const handleItemsTypeChange = async (itemTypeChange: ItemTypeChange) => { + const { type, checked } = itemTypeChange + + // These istanbul comments are only because checking if itemTypes is undefined is not possible is just a good defensive code to have + const newItemsTypes = checked + ? [...new Set([...(currentSearchCriteria?.itemTypes ?? /* istanbul ignore next */ []), type])] + : (currentSearchCriteria.itemTypes ?? /* istanbul ignore next */ []).filter( + (itemType) => itemType !== type + ) + + // KEEP SEARCH VALUE IF EXISTS + itemsListContainerRef.current?.scrollTo({ top: 0 }) + + const resetPaginationInfo = new CollectionItemsPaginationInfo() + setPaginationInfo(resetPaginationInfo) + + // Update the URL with the new item types, keep other querys and include the search value if exists + setSearchParams((currentSearchParams) => ({ + ...currentSearchParams, + [QueryParamKey.COLLECTION_ITEM_TYPES]: newItemsTypes.join(','), + ...(currentSearchCriteria.searchText && { + [QueryParamKey.QUERY]: currentSearchCriteria.searchText + }) + })) + + const newCollectionSearchCriteria = new CollectionSearchCriteria( + currentSearchCriteria.searchText, + newItemsTypes + ) + + const totalItemsCount = await loadMore(resetPaginationInfo, newCollectionSearchCriteria, true) + + if (totalItemsCount !== undefined) { + const paginationInfoUpdated = resetPaginationInfo.withTotal(totalItemsCount) + setPaginationInfo(paginationInfoUpdated) + } + } + + async function loadItemsOnBackAndForwardNavigation() { + const searchParams = new URLSearchParams(window.location.search) + const collectionQueryParams = CollectionHelper.defineCollectionQueryParams(searchParams) + + const newCollectionSearchCriteria = new CollectionSearchCriteria( + collectionQueryParams.searchQuery, + collectionQueryParams.typesQuery + ) + + const newPaginationInfo = new CollectionItemsPaginationInfo() + const totalItemsCount = await loadMore(newPaginationInfo, newCollectionSearchCriteria, true) + + if (totalItemsCount !== undefined) { + const paginationInfoUpdated = newPaginationInfo.withTotal(totalItemsCount) + setPaginationInfo(paginationInfoUpdated) + } + } + + useEffect(() => { + setIsLoading(isLoadingItems) + }, [isLoadingItems, setIsLoading]) + + return ( +
+
+ +
{addDataSlot}
+
+ +
+ + + +
+
+ ) +} diff --git a/src/sections/collection/collection-items-panel/filter-panel/FilterPanel.module.scss b/src/sections/collection/collection-items-panel/filter-panel/FilterPanel.module.scss new file mode 100644 index 000000000..49655a3c7 --- /dev/null +++ b/src/sections/collection/collection-items-panel/filter-panel/FilterPanel.module.scss @@ -0,0 +1,21 @@ +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; + +.filter-panel { + @media (min-width: 992px) { + padding: 1rem; + border: 1px solid $dv-border-color; + border-radius: 4px; + } + + .toggle-canvas-btn { + display: block; + + @media (min-width: 992px) { + display: none; + } + } + + .filters-wrapper { + width: 100%; + } +} diff --git a/src/sections/collection/collection-items-panel/filter-panel/FilterPanel.tsx b/src/sections/collection/collection-items-panel/filter-panel/FilterPanel.tsx new file mode 100644 index 000000000..64e33fca5 --- /dev/null +++ b/src/sections/collection/collection-items-panel/filter-panel/FilterPanel.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button, Offcanvas } from '@iqss/dataverse-design-system' +import { FunnelFill } from 'react-bootstrap-icons' +import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' +import { ItemTypeChange, TypeFilters } from './type-filters/TypeFilters' +import styles from './FilterPanel.module.scss' + +interface FilterPanelProps { + currentItemTypes?: CollectionItemType[] + onItemTypesChange: (itemTypeChange: ItemTypeChange) => void + isLoadingCollectionItems: boolean +} + +export const FilterPanel = ({ + currentItemTypes, + onItemTypesChange, + isLoadingCollectionItems +}: FilterPanelProps) => { + const { t } = useTranslation('collection') + + const [showOffcanvas, setShowOffcanvas] = useState(false) + + const handleCloseOffcanvas = () => setShowOffcanvas(false) + const handleShowOffcanvas = () => setShowOffcanvas(true) + + return ( +
+ + + + + {t('filterResults')} + + +
+ +
+
+
+
+ ) +} diff --git a/src/sections/collection/collection-items-panel/filter-panel/type-filters/TypeFilters.module.scss b/src/sections/collection/collection-items-panel/filter-panel/type-filters/TypeFilters.module.scss new file mode 100644 index 000000000..ef546289e --- /dev/null +++ b/src/sections/collection/collection-items-panel/filter-panel/type-filters/TypeFilters.module.scss @@ -0,0 +1,24 @@ +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; + +.type-filters { + input[type='checkbox'] { + cursor: pointer; + + & + label { + color: $dv-primary-color; + cursor: pointer; + } + + &:checked + label { + font-weight: 500; + } + + &:disabled { + opacity: 0.7; + + & + label { + opacity: 1; + } + } + } +} diff --git a/src/sections/collection/collection-items-panel/filter-panel/type-filters/TypeFilters.tsx b/src/sections/collection/collection-items-panel/filter-panel/type-filters/TypeFilters.tsx new file mode 100644 index 000000000..e5d381cb4 --- /dev/null +++ b/src/sections/collection/collection-items-panel/filter-panel/type-filters/TypeFilters.tsx @@ -0,0 +1,87 @@ +import { ChangeEvent } from 'react' +import { useTranslation } from 'react-i18next' +import { Form, Icon, IconName, Stack } from '@iqss/dataverse-design-system' +import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' +import styles from './TypeFilters.module.scss' + +interface TypeFiltersProps { + currentItemTypes?: CollectionItemType[] + onItemTypesChange: (itemTypeChange: ItemTypeChange) => void + isLoadingCollectionItems: boolean +} + +export interface ItemTypeChange { + type: CollectionItemType + checked: boolean +} + +export const TypeFilters = ({ + currentItemTypes, + onItemTypesChange, + isLoadingCollectionItems +}: TypeFiltersProps) => { + const { t } = useTranslation('collection') + + const handleItemTypeChange = (type: CollectionItemType, checked: boolean) => { + onItemTypesChange({ type, checked }) + } + + const collectionCheckDisabled = + isLoadingCollectionItems || + (currentItemTypes?.length === 1 && currentItemTypes?.includes(CollectionItemType.COLLECTION)) + + const datasetCheckDisabled = + isLoadingCollectionItems || + (currentItemTypes?.length === 1 && currentItemTypes?.includes(CollectionItemType.DATASET)) + + const fileCheckDisabled = + isLoadingCollectionItems || + (currentItemTypes?.length === 1 && currentItemTypes?.includes(CollectionItemType.FILE)) + + return ( + + ) => + handleItemTypeChange(CollectionItemType.COLLECTION, e.target.checked) + } + label={ + <> + + {t('collectionFilterTypeLabel')} + + } + checked={Boolean(currentItemTypes?.includes(CollectionItemType.COLLECTION))} + disabled={collectionCheckDisabled} + /> + ) => + handleItemTypeChange(CollectionItemType.DATASET, e.target.checked) + } + label={ + <> + + {t('datasetFilterTypeLabel')} + + } + checked={Boolean(currentItemTypes?.includes(CollectionItemType.DATASET))} + disabled={datasetCheckDisabled} + /> + ) => + handleItemTypeChange(CollectionItemType.FILE, e.target.checked) + } + label={ + <> + + {t('fileFilterTypeLabel')} + + } + checked={Boolean(currentItemTypes?.includes(CollectionItemType.FILE))} + disabled={fileCheckDisabled} + /> + + ) +} diff --git a/src/sections/collection/datasets-list/ErrorDatasetsMessage.tsx b/src/sections/collection/collection-items-panel/items-list/ErrorItemsMessage.tsx similarity index 56% rename from src/sections/collection/datasets-list/ErrorDatasetsMessage.tsx rename to src/sections/collection/collection-items-panel/items-list/ErrorItemsMessage.tsx index a12bb88a5..248808710 100644 --- a/src/sections/collection/datasets-list/ErrorDatasetsMessage.tsx +++ b/src/sections/collection/collection-items-panel/items-list/ErrorItemsMessage.tsx @@ -1,10 +1,10 @@ import { Alert } from '@iqss/dataverse-design-system' -interface ErrorDatasetsMessageProps { +interface ErrorItemsMessageProps { errorMessage: string } -export const ErrorDatasetsMessage = ({ errorMessage }: ErrorDatasetsMessageProps) => ( +export const ErrorItemsMessage = ({ errorMessage }: ErrorItemsMessageProps) => ( {errorMessage} diff --git a/src/sections/collection/collection-items-panel/items-list/ItemsList.module.scss b/src/sections/collection/collection-items-panel/items-list/ItemsList.module.scss new file mode 100644 index 000000000..253026076 --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/ItemsList.module.scss @@ -0,0 +1,65 @@ +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; + +.items-list { + --inline-padding: 1rem; + + height: 650px; + max-height: 650px; + padding-inline: var(--inline-padding); + overflow-x: hidden; + overflow-y: auto; + border: 1px solid $dv-border-color; + border-radius: 4px; + + @media screen and (max-width: 768px) { + --inline-padding: 0.5rem; + } + + @media screen and (min-width: 1280px) { + height: 60vh; + max-height: 60vh; + } + + &.empty-or-error { + padding-block: var(--inline-padding); + } + + .custom-message-container { + padding: 0.5em 1em; + background: $dv-warning-box-color; + } + + > header { + position: sticky; + top: 0; + z-index: 10; + width: calc(100% + (var(--inline-padding) * 2)); + padding: 0.5rem var(--inline-padding); + background-color: var(--bs-white); + box-shadow: 0 0 10px 0 rgba(0 0 0 / 30%); + transform: translateX(calc(var(--inline-padding) * -1)); + } + + > ul { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin: 0; + padding: 1rem 0; + + li { + list-style: none; + } + } + + .pagination-results-skeleton { + position: sticky; + top: 0; + z-index: 10; + width: calc(100% + (var(--inline-padding) * 2)); + padding: 1rem var(--inline-padding) 0.5rem; + background-color: var(--bs-white); + box-shadow: 0 0 10px 0 rgba(0 0 0 / 30%); + transform: translateX(calc(var(--inline-padding) * -1)); + } +} diff --git a/src/sections/collection/collection-items-panel/items-list/ItemsList.tsx b/src/sections/collection/collection-items-panel/items-list/ItemsList.tsx new file mode 100644 index 000000000..99bed2d79 --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/ItemsList.tsx @@ -0,0 +1,155 @@ +import { ForwardedRef, forwardRef } from 'react' +import Skeleton, { SkeletonTheme } from 'react-loading-skeleton' +import useInfiniteScroll from 'react-infinite-scroll-hook' +import cn from 'classnames' +import { type CollectionItem } from '@/collection/domain/models/CollectionItemSubset' +import { CollectionItemsPaginationInfo } from '@/collection/domain/models/CollectionItemsPaginationInfo' +import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' +import { PaginationResultsInfo } from '@/sections/shared/pagination/PaginationResultsInfo' +import { NO_COLLECTION_ITEMS } from '../useGetAccumulatedItems' +import { ErrorItemsMessage } from './ErrorItemsMessage' +import { NoItemsMessage } from './NoItemsMessage' +import { NoSearchMatchesMessage } from './NoSearchMatchesMessage' +import { CollectionCard } from './collection-card/CollectionCard' +import { DatasetCard } from './dataset-card/DatasetCard' +import { FileCard } from './file-card/FileCard' +import styles from './ItemsList.module.scss' + +interface ItemsListProps { + items: CollectionItem[] + error: string | null + accumulatedCount: number + isLoadingItems: boolean + areItemsAvailable: boolean + hasNextPage: boolean + isEmptyItems: boolean + hasSearchValue: boolean + paginationInfo: CollectionItemsPaginationInfo + onBottomReach: (paginationInfo: CollectionItemsPaginationInfo) => void + itemsTypesSelected: CollectionItemType[] +} + +export const ItemsList = forwardRef( + ( + { + items, + error, + accumulatedCount, + isLoadingItems, + areItemsAvailable, + hasNextPage, + isEmptyItems, + hasSearchValue, + paginationInfo, + onBottomReach, + itemsTypesSelected + }: ItemsListProps, + ref + ) => { + const [sentryRef, { rootRef }] = useInfiniteScroll({ + loading: isLoadingItems, + hasNextPage: hasNextPage, + onLoadMore: () => void onBottomReach(paginationInfo), + disabled: !!error, + rootMargin: '0px 0px 250px 0px' + }) + + const showNoItemsMessage = !isLoadingItems && isEmptyItems && !hasSearchValue + const showNoSearchMatchesMessage = !isLoadingItems && isEmptyItems && hasSearchValue + + const showSentrySkeleton = hasNextPage && !error && !isEmptyItems + const showNotSentrySkeleton = isLoadingItems && isEmptyItems + + return ( +
+
} + data-testid="items-list-scrollable-container"> + {showNoItemsMessage && } + + {showNoSearchMatchesMessage && } + + {error && } + + {areItemsAvailable && ( + <> +
+ {isLoadingItems ? ( + + + + ) : ( + + )} +
+ +
    + {items.map((collectionItem, index) => ( +
  • + {collectionItem?.type === CollectionItemType.COLLECTION && ( + + )} + {collectionItem?.type === CollectionItemType.DATASET && ( + + )} + {collectionItem?.type === CollectionItemType.FILE && ( + + )} +
  • + ))} +
+ + )} + + {showSentrySkeleton && ( +
+ + {accumulatedCount === NO_COLLECTION_ITEMS && } + + +
+ )} + {showNotSentrySkeleton && ( + + + + )} +
+
+ ) + } +) + +ItemsList.displayName = 'ItemsList' + +export const InitialLoadingSkeleton = () => ( + <> +
+ +
+ + + + + + + + +) + +export const LoadingSkeleton = ({ numOfSkeletons }: { numOfSkeletons: number }) => ( + <> + {Array.from({ length: numOfSkeletons }).map((_, index) => ( + + ))} + +) diff --git a/src/sections/collection/collection-items-panel/items-list/NoItemsMessage.tsx b/src/sections/collection/collection-items-panel/items-list/NoItemsMessage.tsx new file mode 100644 index 000000000..6b568040f --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/NoItemsMessage.tsx @@ -0,0 +1,74 @@ +import { Trans, useTranslation } from 'react-i18next' +import { useSession } from '@/sections/session/SessionContext' +import { Route } from '@/sections/Route.enum' +import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' +import styles from './ItemsList.module.scss' + +interface NoItemsMessageProps { + itemsTypesSelected: CollectionItemType[] +} + +export function NoItemsMessage({ itemsTypesSelected }: NoItemsMessageProps) { + const { t } = useTranslation('collection') + const { user } = useSession() + + const itemTypeMessages = { + all: t('noItemsMessage.itemTypeMessage.all'), + [CollectionItemType.COLLECTION]: t('noItemsMessage.itemTypeMessage.collection'), + [CollectionItemType.DATASET]: t('noItemsMessage.itemTypeMessage.dataset'), + [CollectionItemType.FILE]: t('noItemsMessage.itemTypeMessage.file'), + collectionAndDataset: t('noItemsMessage.itemTypeMessage.collectionAndDataset'), + collectionAndFile: t('noItemsMessage.itemTypeMessage.collectionAndFile'), + datasetAndFile: t('noItemsMessage.itemTypeMessage.datasetAndFile') + } + + const getMessageKey = (itemsTypesSelected: CollectionItemType[]) => { + const itemCount = itemsTypesSelected.length + + if (itemCount === 3) return itemTypeMessages.all + if (itemCount === 1) { + const itemType = itemsTypesSelected[0] + return itemTypeMessages[itemType] + } + + if (itemCount === 2) { + if ( + itemsTypesSelected.includes(CollectionItemType.COLLECTION) && + itemsTypesSelected.includes(CollectionItemType.DATASET) + ) { + return itemTypeMessages.collectionAndDataset + } + if ( + itemsTypesSelected.includes(CollectionItemType.COLLECTION) && + itemsTypesSelected.includes(CollectionItemType.FILE) + ) { + return itemTypeMessages.collectionAndFile + } + if ( + itemsTypesSelected.includes(CollectionItemType.DATASET) && + itemsTypesSelected.includes(CollectionItemType.FILE) + ) { + return itemTypeMessages.datasetAndFile + } + } + } + + const messageKey = getMessageKey(itemsTypesSelected) + + return ( +
+ {user ? ( +

{t('noItemsMessage.authenticated', { typeOfEmptyItems: messageKey })}

+ ) : ( + log in + }} + /> + )} +
+ ) +} diff --git a/src/sections/collection/collection-items-panel/items-list/NoSearchMatchesMessage.tsx b/src/sections/collection/collection-items-panel/items-list/NoSearchMatchesMessage.tsx new file mode 100644 index 000000000..ded2e1726 --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/NoSearchMatchesMessage.tsx @@ -0,0 +1,24 @@ +import { Trans, useTranslation } from 'react-i18next' +import styles from './ItemsList.module.scss' + +export const NoSearchMatchesMessage = () => { + const { t } = useTranslation('collection') + + return ( +
+ + ) + }} + /> +
+ ) +} diff --git a/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCard.module.scss b/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCard.module.scss new file mode 100644 index 000000000..a3eabca8f --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCard.module.scss @@ -0,0 +1,92 @@ +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/typography.module'; + +.card-main-container { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 10px; + border: 1px solid $dv-collection-border-color; + border-radius: 4px; +} + +.card-header-container { + display: flex; + gap: 1rem; + justify-content: space-between; + + .left-side-content { + display: flex; + flex-wrap: wrap; + gap: 8px; + + .affiliation { + margin: 0; + color: $dv-subtext-color; + } + } + + .top-right-icon { + height: fit-content; + color: $dv-collection-border-color; + font-size: 1.3em; + line-height: 1.1; + + > span { + margin-right: 0; + } + } +} + +.thumbnail-and-info-wrapper { + display: flex; + gap: 1rem; +} + +.card-thumbnail-container { + padding-block: 0.25rem; + + img { + width: 64px; + max-width: 64px; + height: 48px; + max-height: 48px; + vertical-align: top; + } + + .icon { + height: fit-content; + color: $dv-collection-border-color; + + > span { + margin-right: 0; + font-size: 40px; + line-height: 1; + } + } +} + +.card-info-container { + align-self: flex-start; + font-size: $dv-font-size-sm; + + .date-link-wrapper { + display: flex; + flex-wrap: wrap; + column-gap: 0.5rem; + + .date { + color: $dv-subtext-color; + } + } + + .description { + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + width: 100%; + overflow: hidden; + color: black; + } +} diff --git a/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCard.tsx b/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCard.tsx new file mode 100644 index 000000000..768d77d10 --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCard.tsx @@ -0,0 +1,21 @@ +import { CollectionCardHeader } from './CollectionCardHeader' +import { CollectionCardThumbnail } from './CollectionCardThumbnail' +import { CollectionCardInfo } from './CollectionCardInfo' +import { CollectionItemTypePreview } from '@/collection/domain/models/CollectionItemTypePreview' +import styles from './CollectionCard.module.scss' + +interface CollectionCardProps { + collectionPreview: CollectionItemTypePreview +} + +export function CollectionCard({ collectionPreview }: CollectionCardProps) { + return ( +
+ +
+ + +
+
+ ) +} diff --git a/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCardHeader.tsx b/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCardHeader.tsx new file mode 100644 index 000000000..6dffc3655 --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCardHeader.tsx @@ -0,0 +1,37 @@ +import { Badge, Icon, IconName } from '@iqss/dataverse-design-system' +import { Route } from '@/sections/Route.enum' +import { CollectionItemTypePreview } from '@/collection/domain/models/CollectionItemTypePreview' +import { DvObjectType } from '@/shared/hierarchy/domain/models/UpwardHierarchyNode' +import { LinkToPage } from '@/sections/shared/link-to-page/LinkToPage' +import styles from './CollectionCard.module.scss' + +interface CollectionCardHeaderProps { + collectionPreview: CollectionItemTypePreview +} + +export function CollectionCardHeader({ collectionPreview }: CollectionCardHeaderProps) { + return ( +
+
+ + {collectionPreview.name} + + {collectionPreview.affiliation && ( +

({collectionPreview.affiliation})

+ )} + {!collectionPreview.isReleased && ( +
+ Unpublished +
+ )} +
+ +
+ +
+
+ ) +} diff --git a/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCardInfo.tsx b/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCardInfo.tsx new file mode 100644 index 000000000..5c182b285 --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCardInfo.tsx @@ -0,0 +1,44 @@ +import { useParams } from 'react-router-dom' +import { Stack } from '@iqss/dataverse-design-system' +import { ROOT_COLLECTION_ALIAS } from '@/collection/domain/models/Collection' +import { CollectionItemTypePreview } from '@/collection/domain/models/CollectionItemTypePreview' +import { DvObjectType } from '@/shared/hierarchy/domain/models/UpwardHierarchyNode' +import { DateHelper } from '@/shared/helpers/DateHelper' +import { Route } from '@/sections/Route.enum' +import { LinkToPage } from '@/sections/shared/link-to-page/LinkToPage' +import styles from './CollectionCard.module.scss' + +interface CollectionCardInfoProps { + collectionPreview: CollectionItemTypePreview +} + +export function CollectionCardInfo({ collectionPreview }: CollectionCardInfoProps) { + const { collectionId = ROOT_COLLECTION_ALIAS } = useParams<{ collectionId: string }>() + const isStandingOnParentCollectionPage = collectionPreview.parentCollectionAlias === collectionId + + return ( +
+ +
+ + {!isStandingOnParentCollectionPage && ( + + {collectionPreview.parentCollectionName} + + )} +
+ + {collectionPreview.description && ( +

{collectionPreview.description}

+ )} +
+
+ ) +} diff --git a/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCardThumbnail.tsx b/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCardThumbnail.tsx new file mode 100644 index 000000000..88c56bb6e --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCardThumbnail.tsx @@ -0,0 +1,29 @@ +import { Icon, IconName } from '@iqss/dataverse-design-system' +import { CollectionItemTypePreview } from '@/collection/domain/models/CollectionItemTypePreview' +import { DvObjectType } from '@/shared/hierarchy/domain/models/UpwardHierarchyNode' +import { Route } from '@/sections/Route.enum' +import { LinkToPage } from '@/sections/shared/link-to-page/LinkToPage' +import styles from './CollectionCard.module.scss' + +interface CollectionCardCardThumbnailProps { + collectionPreview: CollectionItemTypePreview +} + +export function CollectionCardThumbnail({ collectionPreview }: CollectionCardCardThumbnailProps) { + return ( +
+ + {collectionPreview.thumbnail ? ( + {collectionPreview.name} + ) : ( +
+ +
+ )} +
+
+ ) +} diff --git a/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCard.module.scss b/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCard.module.scss new file mode 100644 index 000000000..041920e88 --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCard.module.scss @@ -0,0 +1,102 @@ +@use 'sass:color'; +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/typography.module'; + +.card-main-container { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 10px; + border: 1px solid $dv-dataset-border-color; + border-radius: 4px; +} + +.thumbnail-and-info-wrapper { + display: flex; + gap: 1rem; +} + +.card-header-container { + display: flex; + gap: 1rem; + justify-content: space-between; + + .left-side-content { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .top-right-icon { + height: fit-content; + font-size: 1.3em; + + & span { + margin-right: 0; + } + } +} + +.card-thumbnail-container { + padding-block: 0.25rem; + + img { + width: 64px; + max-width: 64px; + height: 48px; + max-height: 48px; + vertical-align: top; + } + + :global .icon-dataset { + margin-right: 0; + font-size: 40px; + line-height: 1; + } +} + +.card-info-container { + display: flex; + flex-direction: column; + gap: 4px; + align-self: flex-start; + width: 100%; + font-size: $dv-font-size-sm; + + .date { + color: $dv-subtext-color; + } + + .citation-box { + display: -webkit-box; + padding: 4px; + overflow: hidden; + background-color: color.adjust($dv-primary-color, $lightness: 51%); + border-radius: 4px; + -webkit-line-clamp: 5; + line-clamp: 5; + -webkit-box-orient: vertical; + + a { + word-break: break-all; + } + + &.deaccesioned { + background-color: color.adjust($dv-danger-box-color, $lightness: 6%); + } + + & > div { + margin-bottom: 0; + } + } + + .description { + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + width: 100%; + overflow: hidden; + color: #000; + } +} diff --git a/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCard.tsx b/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCard.tsx new file mode 100644 index 000000000..12bf17bf6 --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCard.tsx @@ -0,0 +1,32 @@ +import { DatasetItemTypePreview } from '@/dataset/domain/models/DatasetItemTypePreview' +import { DatasetCardHeader } from './DatasetCardHeader' +import { DatasetCardThumbnail } from './DatasetCardThumbnail' +import { DatasetCardInfo } from './DatasetCardInfo' +import styles from './DatasetCard.module.scss' + +interface DatasetCardProps { + datasetPreview: DatasetItemTypePreview +} + +export function DatasetCard({ datasetPreview }: DatasetCardProps) { + return ( +
+ +
+ + +
+
+ ) +} diff --git a/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardHeader.tsx b/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardHeader.tsx new file mode 100644 index 000000000..857a0c7a3 --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardHeader.tsx @@ -0,0 +1,35 @@ +import { Route } from '@/sections/Route.enum' +import { DatasetCardHelper } from './DatasetCardHelper' +import { DatasetVersion } from '@/dataset/domain/models/Dataset' +import { DvObjectType } from '@/shared/hierarchy/domain/models/UpwardHierarchyNode' +import { DatasetIcon } from '@/sections/dataset/dataset-icon/DatasetIcon' +import { DatasetLabels } from '@/sections/dataset/dataset-labels/DatasetLabels' +import { LinkToPage } from '@/sections/shared/link-to-page/LinkToPage' +import styles from './DatasetCard.module.scss' + +interface DatasetCardHeaderProps { + persistentId: string + version: DatasetVersion +} + +export function DatasetCardHeader({ persistentId, version }: DatasetCardHeaderProps) { + return ( +
+
+ + {version.title} + + +
+
+ +
+
+ ) +} diff --git a/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardHelper.ts b/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardHelper.ts new file mode 100644 index 000000000..bfde0ba9c --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardHelper.ts @@ -0,0 +1,18 @@ +import { + DatasetNonNumericVersionSearchParam, + DatasetPublishingStatus +} from '@/dataset/domain/models/Dataset' + +export class DatasetCardHelper { + static getDatasetSearchParams( + persistentId: string, + publishingStatus: DatasetPublishingStatus + ): Record { + const params: Record = { persistentId: persistentId } + + if (publishingStatus === DatasetPublishingStatus.DRAFT) { + params.version = DatasetNonNumericVersionSearchParam.DRAFT + } + return params + } +} diff --git a/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardInfo.tsx b/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardInfo.tsx new file mode 100644 index 000000000..194ac622d --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardInfo.tsx @@ -0,0 +1,33 @@ +import cn from 'classnames' +import { DatasetPublishingStatus, DatasetVersion } from '@/dataset/domain/models/Dataset' +import { DateHelper } from '@/shared/helpers/DateHelper' +import { CitationDescription } from '@/sections/shared/citation/CitationDescription' +import styles from './DatasetCard.module.scss' + +interface DatasetCardInfoProps { + version: DatasetVersion + releaseOrCreateDate: Date + description: string +} + +export function DatasetCardInfo({ + version, + releaseOrCreateDate, + description +}: DatasetCardInfoProps) { + return ( +
+ +
+ +
+

{description}

+
+ ) +} diff --git a/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardThumbnail.tsx b/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardThumbnail.tsx new file mode 100644 index 000000000..fa5e49d82 --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardThumbnail.tsx @@ -0,0 +1,42 @@ +import { Route } from '../../../../Route.enum' +import { + DatasetPublishingStatus, + DatasetVersion +} from '../../../../../dataset/domain/models/Dataset' +import { DatasetCardHelper } from './DatasetCardHelper' +import { DvObjectType } from '../../../../../shared/hierarchy/domain/models/UpwardHierarchyNode' +import { DatasetThumbnail } from '../../../../dataset/dataset-thumbnail/DatasetThumbnail' +import { LinkToPage } from '../../../../shared/link-to-page/LinkToPage' +import styles from './DatasetCard.module.scss' + +interface DatasetCardThumbnailProps { + persistentId: string + version: DatasetVersion + thumbnail?: string +} + +export function DatasetCardThumbnail({ + persistentId, + version, + thumbnail +}: DatasetCardThumbnailProps) { + return ( +
+ + + +
+ ) +} diff --git a/src/sections/collection/collection-items-panel/items-list/file-card/FileCard.module.scss b/src/sections/collection/collection-items-panel/items-list/file-card/FileCard.module.scss new file mode 100644 index 000000000..7514bb2d1 --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/file-card/FileCard.module.scss @@ -0,0 +1,112 @@ +@use 'sass:color'; +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/typography.module'; + +.card-main-container { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 10px; + border: 1px solid $dv-file-border-color; + border-radius: 4px; +} + +.card-header-container { + display: flex; + gap: 1rem; + justify-content: space-between; + + .left-side-content { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .top-right-icon { + height: fit-content; + color: $dv-file-border-color; + font-size: 1.3em; + line-height: 1.1; + + > span { + margin-right: 0; + } + } +} + +.thumbnail-and-info-wrapper { + display: flex; + gap: 1rem; +} + +.card-thumbnail-container { + padding-block: 0.25rem; + + img { + width: 64px; + max-width: 64px; + height: 48px; + max-height: 48px; + vertical-align: top; + } + + .icon { + height: fit-content; + color: $dv-file-border-color; + + > span { + margin-right: 0; + font-size: 40px; + line-height: 1; + } + } +} + +.tooltip { + display: table-cell; + width: 430px; + height: 430px; + vertical-align: middle; + + img { + width: 100%; + max-width: 390px; + } +} + +.card-info-container { + align-self: flex-start; + font-size: $dv-font-size-sm; + + .date-link-wrapper { + display: flex; + flex-wrap: wrap; + column-gap: 0.5rem; + + .date { + color: $dv-subtext-color; + } + } + + .info { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; + color: $dv-subtext-color; + + > p { + margin: 0; + } + } + + .description { + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + width: 100%; + overflow: hidden; + color: black; + } +} diff --git a/src/sections/collection/collection-items-panel/items-list/file-card/FileCard.tsx b/src/sections/collection/collection-items-panel/items-list/file-card/FileCard.tsx new file mode 100644 index 000000000..c1b67a446 --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/file-card/FileCard.tsx @@ -0,0 +1,21 @@ +import { FileItemTypePreview } from '@/files/domain/models/FileItemTypePreview' +import { FileCardHeader } from './FileCardHeader' +import { FileCardThumbnail } from './FileCardThumbnail' +import { FileCardInfo } from './FileCardInfo' +import styles from './FileCard.module.scss' + +interface FileCardProps { + filePreview: FileItemTypePreview +} + +export function FileCard({ filePreview }: FileCardProps) { + return ( +
+ +
+ + +
+
+ ) +} diff --git a/src/sections/collection/collection-items-panel/items-list/file-card/FileCardHeader.tsx b/src/sections/collection/collection-items-panel/items-list/file-card/FileCardHeader.tsx new file mode 100644 index 000000000..43f5f0c0b --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/file-card/FileCardHeader.tsx @@ -0,0 +1,45 @@ +import { Icon, IconName } from '@iqss/dataverse-design-system' +import { PublicationStatus } from '@/shared/core/domain/models/PublicationStatus' +import { DvObjectType } from '@/shared/hierarchy/domain/models/UpwardHierarchyNode' +import { FileItemTypePreview } from '@/files/domain/models/FileItemTypePreview' +import { FileType } from '@/files/domain/models/FileMetadata' +import { FileTypeToFileIconMap } from '@/sections/file/file-preview/FileTypeToFileIconMap' +import { Route } from '@/sections/Route.enum' +import { LinkToPage } from '@/sections/shared/link-to-page/LinkToPage' +import { DatasetLabels } from '@/sections/dataset/dataset-labels/DatasetLabels' +import { FileCardHelper } from './FileCardHelper' +import styles from './FileCard.module.scss' + +interface FileCardHeaderProps { + filePreview: FileItemTypePreview +} + +export function FileCardHeader({ filePreview }: FileCardHeaderProps) { + const iconFileType = new FileType(filePreview.fileContentType) + const iconName = FileTypeToFileIconMap[iconFileType.value] || IconName.OTHER + + return ( +
+
+ + {filePreview.name} + + +
+
+ +
+
+ ) +} diff --git a/src/sections/collection/collection-items-panel/items-list/file-card/FileCardHelper.ts b/src/sections/collection/collection-items-panel/items-list/file-card/FileCardHelper.ts new file mode 100644 index 000000000..cdd60c36b --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/file-card/FileCardHelper.ts @@ -0,0 +1,51 @@ +import { + DatasetLabel, + DatasetLabelSemanticMeaning, + DatasetLabelValue, + DatasetNonNumericVersionSearchParam +} from '@/dataset/domain/models/Dataset' +import { PublicationStatus } from '@/shared/core/domain/models/PublicationStatus' + +export class FileCardHelper { + static getDatasetSearchParams(persistentId: string, isDraft: boolean): Record { + const params: Record = { persistentId: persistentId } + if (isDraft) { + params.version = DatasetNonNumericVersionSearchParam.DRAFT + } + return params + } + static getFileSearchParams(id: number, isDraft: boolean): Record { + const params: Record = { id: id.toString() } + if (isDraft) { + params.datasetVersion = DatasetNonNumericVersionSearchParam.DRAFT + } + return params + } + + static getDatasetLabels( + publicationStatuses: PublicationStatus[], + someDatasetVersionHasBeenReleased: boolean + ) { + const labels: DatasetLabel[] = [] + if (publicationStatuses.includes(PublicationStatus.Draft)) { + labels.push(new DatasetLabel(DatasetLabelSemanticMeaning.DATASET, DatasetLabelValue.DRAFT)) + } + if (!someDatasetVersionHasBeenReleased) { + labels.push( + new DatasetLabel(DatasetLabelSemanticMeaning.WARNING, DatasetLabelValue.UNPUBLISHED) + ) + } + return labels + } + + static formatBytesToCompactNumber(bytes: number): string { + const byteValueNumberFormatter = Intl.NumberFormat(undefined, { + notation: 'compact', + style: 'unit', + unit: 'byte', + unitDisplay: 'narrow' + }) + + return byteValueNumberFormatter.format(bytes) + } +} diff --git a/src/sections/collection/collection-items-panel/items-list/file-card/FileCardInfo.tsx b/src/sections/collection/collection-items-panel/items-list/file-card/FileCardInfo.tsx new file mode 100644 index 000000000..977212bb6 --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/file-card/FileCardInfo.tsx @@ -0,0 +1,53 @@ +import { Stack } from '@iqss/dataverse-design-system' +import { PublicationStatus } from '@/shared/core/domain/models/PublicationStatus' +import { FileItemTypePreview } from '@/files/domain/models/FileItemTypePreview' +import { DateHelper } from '@/shared/helpers/DateHelper' +import { FileCardHelper } from './FileCardHelper' +import { Route } from '@/sections/Route.enum' +import { DvObjectType } from '@/shared/hierarchy/domain/models/UpwardHierarchyNode' +import { LinkToPage } from '@/sections/shared/link-to-page/LinkToPage' +import { CopyToClipboardButton } from '@/sections/dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/copy-to-clipboard-button/CopyToClipboardButton' +import styles from './FileCard.module.scss' + +interface FileCardInfoProps { + filePreview: FileItemTypePreview +} + +export function FileCardInfo({ filePreview }: FileCardInfoProps) { + const bytesFormatted = FileCardHelper.formatBytesToCompactNumber(filePreview.sizeInBytes) + + return ( +
+ +
+ + + {filePreview.datasetName} + +
+ +
+ {filePreview.fileType} + {`- ${bytesFormatted}`} + {filePreview.checksum && ( + + {`- ${filePreview.checksum.type}:`} + + + )} +
+

{filePreview.description}

+
+
+ ) +} diff --git a/src/sections/collection/collection-items-panel/items-list/file-card/FileCardThumbnail.tsx b/src/sections/collection/collection-items-panel/items-list/file-card/FileCardThumbnail.tsx new file mode 100644 index 000000000..8a4a35948 --- /dev/null +++ b/src/sections/collection/collection-items-panel/items-list/file-card/FileCardThumbnail.tsx @@ -0,0 +1,48 @@ +import { Icon, IconName, Tooltip } from '@iqss/dataverse-design-system' +import { FileItemTypePreview } from '@/files/domain/models/FileItemTypePreview' +import { PublicationStatus } from '@/shared/core/domain/models/PublicationStatus' +import { DvObjectType } from '@/shared/hierarchy/domain/models/UpwardHierarchyNode' +import { FileType } from '@/files/domain/models/FileMetadata' +import { FileTypeToFileIconMap } from '@/sections/file/file-preview/FileTypeToFileIconMap' +import { FileCardHelper } from './FileCardHelper' +import { Route } from '@/sections/Route.enum' +import { LinkToPage } from '@/sections/shared/link-to-page/LinkToPage' +import styles from './FileCard.module.scss' + +interface FileCardThumbnailProps { + filePreview: FileItemTypePreview +} + +export function FileCardThumbnail({ filePreview }: FileCardThumbnailProps) { + const iconFileType = new FileType(filePreview.fileContentType) + const iconName = FileTypeToFileIconMap[iconFileType.value] || IconName.OTHER + + return ( +
+ + {filePreview.thumbnail ? ( + + {filePreview.name} +
+ } + placement="top" + maxWidth={430}> + {filePreview.name} + + ) : ( +
+ +
+ )} + + + ) +} diff --git a/src/sections/collection/collection-items-panel/search-panel/SearchPanel.module.scss b/src/sections/collection/collection-items-panel/search-panel/SearchPanel.module.scss new file mode 100644 index 000000000..c246aa601 --- /dev/null +++ b/src/sections/collection/collection-items-panel/search-panel/SearchPanel.module.scss @@ -0,0 +1,26 @@ +.search-panel { + display: flex; + flex-direction: column; + + @media (min-width: 768px) { + flex-direction: row; + gap: 0.5rem; + align-items: center; + justify-content: space-between; + } +} + +.search-form { + @media (min-width: 768px) { + min-width: 325px; + } +} + +.search-input-group { + margin-bottom: 0 !important; +} + +.advanced-search-btn { + width: fit-content; + min-width: fit-content; +} diff --git a/src/sections/collection/collection-items-panel/search-panel/SearchPanel.tsx b/src/sections/collection/collection-items-panel/search-panel/SearchPanel.tsx new file mode 100644 index 000000000..47c08db60 --- /dev/null +++ b/src/sections/collection/collection-items-panel/search-panel/SearchPanel.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button, Form } from '@iqss/dataverse-design-system' +import { Search } from 'react-bootstrap-icons' +import styles from './SearchPanel.module.scss' + +interface SearchPanelProps { + currentSearchValue?: string + isLoadingCollectionItems: boolean + onSubmitSearch: (searchValue: string) => void +} + +export const SearchPanel = ({ + currentSearchValue = '', + isLoadingCollectionItems, + onSubmitSearch +}: SearchPanelProps) => { + const { t } = useTranslation('collection') + + const [searchValue, setSearchValue] = useState(currentSearchValue) + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault() + + const trimmedSearchValue = searchValue.trim() + + const encodedSearchValue = encodeURIComponent(trimmedSearchValue) + + onSubmitSearch(encodedSearchValue) + } + + const handleSearchChange = (event: React.ChangeEvent) => { + setSearchValue(event.target.value) + } + + useEffect(() => { + setSearchValue(currentSearchValue) + }, [currentSearchValue]) + + return ( +
+
+ + + */} +
+ ) +} diff --git a/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx b/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx new file mode 100644 index 000000000..8179cc2a1 --- /dev/null +++ b/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx @@ -0,0 +1,120 @@ +import { useMemo, useState } from 'react' +import { getCollectionItems } from '@/collection/domain/useCases/getCollectionItems' +import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { + CollectionItem, + CollectionItemSubset +} from '@/collection/domain/models/CollectionItemSubset' +import { CollectionItemsPaginationInfo } from '@/collection/domain/models/CollectionItemsPaginationInfo' +import { CollectionSearchCriteria } from '@/collection/domain/models/CollectionSearchCriteria' + +export const NO_COLLECTION_ITEMS = 0 + +type UseGetAccumulatedItemsReturnType = { + isLoadingItems: boolean + accumulatedItems: CollectionItem[] + totalAvailable: number | undefined + hasNextPage: boolean + error: string | null + loadMore: ( + paginationInfo: CollectionItemsPaginationInfo, + criteria: CollectionSearchCriteria, + resetAccumulated?: boolean + ) => Promise + isEmptyItems: boolean + areItemsAvailable: boolean + accumulatedCount: number +} + +type UseGetAccumulatedItemsParams = { + collectionRepository: CollectionRepository + collectionId: string +} + +export const useGetAccumulatedItems = ({ + collectionRepository, + collectionId +}: UseGetAccumulatedItemsParams): UseGetAccumulatedItemsReturnType => { + const [isLoadingItems, setIsLoadingItems] = useState(false) + const [accumulatedItems, setAccumulatedItems] = useState([]) + const [hasNextPage, setHasNextPage] = useState(true) + const [totalAvailable, setTotalAvailable] = useState(undefined) + const [error, setError] = useState(null) + + const isEmptyItems = useMemo(() => totalAvailable === NO_COLLECTION_ITEMS, [totalAvailable]) + const areItemsAvailable = useMemo(() => { + return typeof totalAvailable === 'number' && totalAvailable > NO_COLLECTION_ITEMS && !error + }, [totalAvailable, error]) + const accumulatedCount = useMemo(() => accumulatedItems.length, [accumulatedItems]) + + const loadMore = async ( + pagination: CollectionItemsPaginationInfo, + searchCriteria: CollectionSearchCriteria, + resetAccumulated = false + ): Promise => { + setIsLoadingItems(true) + + try { + const { items, totalItemCount } = await loadNextItems( + collectionRepository, + collectionId, + pagination, + searchCriteria + ) + + const newAccumulatedItems = !resetAccumulated ? [...accumulatedItems, ...items] : items + + setAccumulatedItems(newAccumulatedItems) + + setTotalAvailable(totalItemCount) + + const isNextPage = !resetAccumulated + ? newAccumulatedItems.length < totalItemCount + : items.length < totalItemCount + + setHasNextPage(isNextPage) + + if (!isNextPage) { + setIsLoadingItems(false) + } + + return totalItemCount + } catch (err) { + const errorMessage = + err instanceof Error && err.message + ? err.message + : 'Something went wrong getting the collection items' + setError(errorMessage) + } finally { + setIsLoadingItems(false) + } + } + + return { + isLoadingItems, + accumulatedItems, + totalAvailable, + hasNextPage, + error, + loadMore, + isEmptyItems, + areItemsAvailable, + accumulatedCount + } +} + +async function loadNextItems( + collectionRepository: CollectionRepository, + collectionId: string, + paginationInfo: CollectionItemsPaginationInfo, + searchCriteria: CollectionSearchCriteria +): Promise { + return getCollectionItems( + collectionRepository, + collectionId, + paginationInfo, + searchCriteria + ).catch((err: Error) => { + throw new Error(err.message) + }) +} diff --git a/src/sections/collection/collection-items-panel/useLoadMoreOnPopStateEvent.ts b/src/sections/collection/collection-items-panel/useLoadMoreOnPopStateEvent.ts new file mode 100644 index 000000000..9de66b2c9 --- /dev/null +++ b/src/sections/collection/collection-items-panel/useLoadMoreOnPopStateEvent.ts @@ -0,0 +1,22 @@ +import { useEffect } from 'react' + +/** + * Hook that listens to the popstate event and calls the onPopStateEvent function. + * This is to load collection items when the user navigates back and forward in the browser history within the collection page. + * @param onPopStateEvent - Function to be called when the popstate event is triggered + */ + +export const useLoadMoreOnPopStateEvent = (onPopStateEvent: () => Promise) => { + useEffect(() => { + const handlePopState = (_e: PopStateEvent) => { + void onPopStateEvent() + } + + window.addEventListener('popstate', handlePopState) + + return () => { + window.removeEventListener('popstate', handlePopState) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) +} diff --git a/src/sections/collection/datasets-list/DatasetsList.module.scss b/src/sections/collection/datasets-list/DatasetsList.module.scss deleted file mode 100644 index 3bf17dfb6..000000000 --- a/src/sections/collection/datasets-list/DatasetsList.module.scss +++ /dev/null @@ -1,50 +0,0 @@ -@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; - -.container { - min-height: calc(100vh + 100px); - padding: 15px; - border: 1px solid #ddd; - border-radius: 4px; -} - -.empty-message-container { - padding: 0.5em 1em; - background: $dv-warning-box-color; -} - -// Infinite scroll enabled -.scrollable-container { - --inline-padding: 1rem; - - height: 650px; - max-height: 650px; - padding-inline: var(--inline-padding); - overflow-x: hidden; - overflow-y: auto; - border: 1px solid #ddd; - border-radius: 4px; - - @media screen and (max-width: 768px) { - --inline-padding: 8px; - } - - @media screen and (min-width: 1280px) { - height: 60vh; - max-height: 60vh; - } - - &--empty-or-error { - padding-block: 1rem; - } - - .sticky-pagination-results { - position: sticky; - top: 0; - z-index: 10; - width: calc(100% + (var(--inline-padding) * 2)); - padding: 1rem var(--inline-padding) 0.5rem; - background-color: var(--bs-white); - box-shadow: 0 0 10px 0 rgba(0 0 0 / 30%); - transform: translateX(calc(var(--inline-padding) * -1)); - } -} diff --git a/src/sections/collection/datasets-list/DatasetsList.tsx b/src/sections/collection/datasets-list/DatasetsList.tsx deleted file mode 100644 index a0240a9cd..000000000 --- a/src/sections/collection/datasets-list/DatasetsList.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useDatasets } from './useDatasets' -import styles from './DatasetsList.module.scss' -import { DatasetRepository } from '../../../dataset/domain/repositories/DatasetRepository' -import { useEffect, useState } from 'react' -import { PaginationResultsInfo } from '../../shared/pagination/PaginationResultsInfo' -import { PaginationControls } from '../../shared/pagination/PaginationControls' -import { DatasetPaginationInfo } from '../../../dataset/domain/models/DatasetPaginationInfo' -import { useLoading } from '../../loading/LoadingContext' -import { DatasetsListSkeleton } from './DatasetsListSkeleton' -import { NoDatasetsMessage } from './NoDatasetsMessage' -import { DatasetCard } from './dataset-card/DatasetCard' -import { PageNumberNotFound } from './PageNumberNotFound' - -interface DatasetsListProps { - datasetRepository: DatasetRepository - collectionId: string - page?: number -} - -const NO_DATASETS = 0 -export function DatasetsList({ datasetRepository, page, collectionId }: DatasetsListProps) { - const { setIsLoading } = useLoading() - const [paginationInfo, setPaginationInfo] = useState( - new DatasetPaginationInfo(page) - ) - const { datasets, isLoading, pageNumberNotFound } = useDatasets( - datasetRepository, - collectionId, - setPaginationInfo, - paginationInfo - ) - - useEffect(() => { - setIsLoading(isLoading) - }, [isLoading, setIsLoading]) - - if (isLoading) { - return - } - - if (pageNumberNotFound) { - return ( -
- -
- ) - } - - return ( -
- {datasets.length === NO_DATASETS ? ( - - ) : ( - <> -
- -
- {datasets.map((dataset) => ( - - ))} - - - )} -
- ) -} diff --git a/src/sections/collection/datasets-list/DatasetsListSkeleton.tsx b/src/sections/collection/datasets-list/DatasetsListSkeleton.tsx deleted file mode 100644 index b07bc39d3..000000000 --- a/src/sections/collection/datasets-list/DatasetsListSkeleton.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import styles from './DatasetsList.module.scss' -import Skeleton, { SkeletonTheme } from 'react-loading-skeleton' - -export function DatasetsListSkeleton() { - return ( - -
-
- -
-
- - - - - - - - - - -
-
-
- ) -} diff --git a/src/sections/collection/datasets-list/DatasetsListWithInfiniteScroll.tsx b/src/sections/collection/datasets-list/DatasetsListWithInfiniteScroll.tsx deleted file mode 100644 index 5372afd92..000000000 --- a/src/sections/collection/datasets-list/DatasetsListWithInfiniteScroll.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useEffect, useState } from 'react' -import useInfiniteScroll from 'react-infinite-scroll-hook' -import cn from 'classnames' -import { SkeletonTheme } from 'react-loading-skeleton' -import { DatasetRepository } from '../../../dataset/domain/repositories/DatasetRepository' -import { PaginationResultsInfo } from '../../shared/pagination/PaginationResultsInfo' -import { DatasetPaginationInfo } from '../../../dataset/domain/models/DatasetPaginationInfo' -import { useLoading } from '../../loading/LoadingContext' -import { NoDatasetsMessage } from './NoDatasetsMessage' -import { DatasetCard } from './dataset-card/DatasetCard' -import { InitialLoadingSkeleton, LoadingSkeleton } from './DatasetsListWithInfiniteScrollSkeletons' -import { ErrorDatasetsMessage } from './ErrorDatasetsMessage' -import { NO_DATASETS, useLoadDatasets } from './useLoadDatasets' -import styles from './DatasetsList.module.scss' - -interface DatasetsListWithInfiniteScrollProps { - datasetRepository: DatasetRepository - collectionId: string -} - -const PAGE_SIZE = 10 -const INITIAL_PAGE = 1 - -export function DatasetsListWithInfiniteScroll({ - datasetRepository, - collectionId -}: DatasetsListWithInfiniteScrollProps) { - const { setIsLoading } = useLoading() - const [paginationInfo, setPaginationInfo] = useState( - new DatasetPaginationInfo(INITIAL_PAGE) - ) - const { - isLoading, - accumulatedDatasets, - totalAvailable, - hasNextPage, - error, - loadMore, - isEmptyDatasets, - areDatasetsAvailable, - accumulatedCount - } = useLoadDatasets(datasetRepository, collectionId, paginationInfo) - - const [sentryRef, { rootRef }] = useInfiniteScroll({ - loading: isLoading, - hasNextPage: hasNextPage, - onLoadMore: loadMore as VoidFunction, - disabled: !!error, - rootMargin: '0px 0px 250px 0px' - }) - - useEffect(() => { - setIsLoading(isLoading) - }, [isLoading, setIsLoading]) - - useEffect(() => { - const updatePaginationTotalItems = () => { - if (totalAvailable && totalAvailable !== paginationInfo.totalItems) { - setPaginationInfo(paginationInfo.withTotal(totalAvailable)) - } - } - - updatePaginationTotalItems() - }, [totalAvailable, paginationInfo]) - - useEffect(() => { - const updatePaginationPageNumber = () => { - setPaginationInfo((currentPagination) => - currentPagination.goToPage(accumulatedCount / PAGE_SIZE + 1) - ) - } - - updatePaginationPageNumber() - }, [accumulatedCount]) - - return ( -
- {isEmptyDatasets && } - - {error && } - - {areDatasetsAvailable && ( - <> -
- -
- {accumulatedDatasets.map((dataset) => ( - - ))} - - )} - - {hasNextPage && !error && !isEmptyDatasets && ( -
- - {accumulatedCount === NO_DATASETS && } - - -
- )} -
- ) -} diff --git a/src/sections/collection/datasets-list/DatasetsListWithInfiniteScrollSkeletons.tsx b/src/sections/collection/datasets-list/DatasetsListWithInfiniteScrollSkeletons.tsx deleted file mode 100644 index 56a97bec6..000000000 --- a/src/sections/collection/datasets-list/DatasetsListWithInfiniteScrollSkeletons.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import styles from './DatasetsList.module.scss' -import Skeleton from 'react-loading-skeleton' - -export const InitialLoadingSkeleton = () => ( - <> -
- -
- - - - - - - - -) - -export const LoadingSkeleton = () => ( - <> - - - - -) diff --git a/src/sections/collection/datasets-list/NoDatasetsMessage.tsx b/src/sections/collection/datasets-list/NoDatasetsMessage.tsx deleted file mode 100644 index 345c56e14..000000000 --- a/src/sections/collection/datasets-list/NoDatasetsMessage.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import styles from './DatasetsList.module.scss' -import { Trans, useTranslation } from 'react-i18next' -import { useSession } from '../../session/SessionContext' -import { Route } from '../../Route.enum' - -export function NoDatasetsMessage() { - const { t } = useTranslation('collection') - const { user } = useSession() - - return ( -
- {user ? ( -

{t('noDatasetsMessage.authenticated')}

- ) : ( - -

- This collection currently has no datasets. Please log in to - see if you are able to add to it. -

-
- )} -
- ) -} diff --git a/src/sections/collection/datasets-list/PageNumberNotFound.tsx b/src/sections/collection/datasets-list/PageNumberNotFound.tsx deleted file mode 100644 index b3feb1cb3..000000000 --- a/src/sections/collection/datasets-list/PageNumberNotFound.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Alert } from '@iqss/dataverse-design-system' -import { useTranslation } from 'react-i18next' - -export function PageNumberNotFound() { - const { t } = useTranslation('pageNumberNotFound') - - return ( - - {t('message')} - - ) -} diff --git a/src/sections/collection/datasets-list/collection-card/CollectionCard.module.scss b/src/sections/collection/datasets-list/collection-card/CollectionCard.module.scss deleted file mode 100644 index 3bc6554fc..000000000 --- a/src/sections/collection/datasets-list/collection-card/CollectionCard.module.scss +++ /dev/null @@ -1,70 +0,0 @@ -@use 'sass:color'; -@import "node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module"; -@import "node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/typography.module"; - -.container { - margin: 6px 0; - padding: 4px 10px; - border: 1px solid $dv-collection-border-color; -} - -.header { - display: flex; - justify-content: space-between; -} - -.title { - display: flex;gap: 8px; -} - -.icon { - margin-top: 2px; - color: $dv-collection-border-color; - font-size: 1.3em; - line-height: 1.1; - - > div >span { - margin-right: 0; - } -} - -.thumbnail { - width: 48px; - margin: 8px 12px 6px 0; - font-size: 2.8em; - - img { - vertical-align: top; - } -} - -.info { - display: flex; - color: $dv-subtext-color; -} - -.card-info-container { - display: flex; - font-size: $dv-font-size-sm; -} - -.description { - display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; - flex-direction: column; - width: 100%; - overflow: hidden; - color: black; -} - -.date { - color: $dv-subtext-color; - -} - -.affiliation { - color: $dv-subtext-color; -} - -.badge { - margin-right: 0.5em; -} \ No newline at end of file diff --git a/src/sections/collection/datasets-list/collection-card/CollectionCard.tsx b/src/sections/collection/datasets-list/collection-card/CollectionCard.tsx deleted file mode 100644 index c7c15691a..000000000 --- a/src/sections/collection/datasets-list/collection-card/CollectionCard.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { CollectionCardHeader } from './CollectionCardHeader' -import { CollectionCardThumbnail } from './CollectionCardThumbnail' -import { CollectionCardInfo } from './CollectionCardInfo' -import { Stack } from '@iqss/dataverse-design-system' -import { CollectionPreview } from '../../../../collection/domain/models/CollectionPreview' -import styles from './CollectionCard.module.scss' - -interface CollectionCardProps { - collectionPreview: CollectionPreview -} - -export function CollectionCard({ collectionPreview }: CollectionCardProps) { - return ( -
- -
- - - - -
-
- ) -} diff --git a/src/sections/collection/datasets-list/collection-card/CollectionCardHeader.tsx b/src/sections/collection/datasets-list/collection-card/CollectionCardHeader.tsx deleted file mode 100644 index 14f075034..000000000 --- a/src/sections/collection/datasets-list/collection-card/CollectionCardHeader.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { LinkToPage } from '../../../shared/link-to-page/LinkToPage' -import { Route } from '../../../Route.enum' -import { CollectionPreview } from '../../../../collection/domain/models/CollectionPreview' -import styles from './CollectionCard.module.scss' -import { Badge, Icon, IconName } from '@iqss/dataverse-design-system' -import { DvObjectType } from '../../../../shared/hierarchy/domain/models/UpwardHierarchyNode' - -interface CollectionCardHeaderProps { - collectionPreview: CollectionPreview -} - -export function CollectionCardHeader({ collectionPreview }: CollectionCardHeaderProps) { - return ( - <> -
-
- - {collectionPreview.name} - - {collectionPreview.affiliation && ( - ({collectionPreview.affiliation}) - )} - {!collectionPreview.isReleased && ( -
- Unpublished -
- )} -
- -
- -
-
- - ) -} diff --git a/src/sections/collection/datasets-list/collection-card/CollectionCardHelper.ts b/src/sections/collection/datasets-list/collection-card/CollectionCardHelper.ts deleted file mode 100644 index 28d49fe70..000000000 --- a/src/sections/collection/datasets-list/collection-card/CollectionCardHelper.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { - DatasetLabel, - DatasetLabelSemanticMeaning, - DatasetLabelValue -} from '../../../../dataset/domain/models/Dataset' -export class CollectionCardHelper { - static getLabel(isReleased: boolean) { - const labels: DatasetLabel[] = [] - - if (!isReleased) { - labels.push( - new DatasetLabel(DatasetLabelSemanticMeaning.WARNING, DatasetLabelValue.UNPUBLISHED) - ) - } - return labels - } -} diff --git a/src/sections/collection/datasets-list/collection-card/CollectionCardInfo.tsx b/src/sections/collection/datasets-list/collection-card/CollectionCardInfo.tsx deleted file mode 100644 index 93c9b44e7..000000000 --- a/src/sections/collection/datasets-list/collection-card/CollectionCardInfo.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import styles from './CollectionCard.module.scss' -import { DateHelper } from '../../../../shared/helpers/DateHelper' -import { Stack } from '@iqss/dataverse-design-system' -import { LinkToPage } from '../../../shared/link-to-page/LinkToPage' -import { Route } from '../../../Route.enum' -import { CollectionPreview } from '../../../../collection/domain/models/CollectionPreview' -import { DvObjectType } from '../../../../shared/hierarchy/domain/models/UpwardHierarchyNode' - -interface CollectionCardInfoProps { - collectionPreview: CollectionPreview -} - -export function CollectionCardInfo({ collectionPreview }: CollectionCardInfoProps) { - return ( -
- - - - {DateHelper.toDisplayFormat(collectionPreview.releaseOrCreateDate)} - - {collectionPreview.parentCollectionName && collectionPreview.parentCollectionId && ( - - {collectionPreview.parentCollectionName} - - )} - - -

{collectionPreview.description}

-
-
- ) -} diff --git a/src/sections/collection/datasets-list/collection-card/CollectionCardThumbnail.tsx b/src/sections/collection/datasets-list/collection-card/CollectionCardThumbnail.tsx deleted file mode 100644 index b5eac9791..000000000 --- a/src/sections/collection/datasets-list/collection-card/CollectionCardThumbnail.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import styles from './CollectionCard.module.scss' -import { LinkToPage } from '../../../shared/link-to-page/LinkToPage' -import { Route } from '../../../Route.enum' -import { Icon, IconName } from '@iqss/dataverse-design-system' -import { CollectionPreview } from '../../../../collection/domain/models/CollectionPreview' -import { DvObjectType } from '../../../../shared/hierarchy/domain/models/UpwardHierarchyNode' - -interface CollectionCardCardThumbnailProps { - collectionPreview: CollectionPreview -} - -export function CollectionCardThumbnail({ collectionPreview }: CollectionCardCardThumbnailProps) { - return ( -
- - {collectionPreview.thumbnail ? ( - {collectionPreview.name} - ) : ( -
- -
- )} -
-
- ) -} diff --git a/src/sections/collection/datasets-list/dataset-card/DatasetCard.module.scss b/src/sections/collection/datasets-list/dataset-card/DatasetCard.module.scss deleted file mode 100644 index 92f7696b2..000000000 --- a/src/sections/collection/datasets-list/dataset-card/DatasetCard.module.scss +++ /dev/null @@ -1,71 +0,0 @@ -@use 'sass:color'; -@import "node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module"; -@import "node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/typography.module"; - -.container { - margin: 6px 0; - padding: 4px 10px; - border: 1px solid $dv-info-border-color; -} - -.header { - display: flex; - justify-content: space-between; -} - -.title { - display: flex; - - > * { - margin-right: .5em; - } -} - -.icon { - margin-top: 2px; - font-size: 1.3em; - - > div >span { - margin-right: 0; - } -} - -.thumbnail { - width: 48px; - margin: 8px 12px 6px 0; - font-size: 2.8em; - - img { - vertical-align: top; - } -} - -.info { - display: flex; -} - -.description { - display: flex; - flex-direction: column; - width: 100%; - font-size: $dv-font-size-sm; -} - -.date { - color: $dv-subtext-color; - -} - -.citation-box { - margin-top: 4px; - margin-bottom: .5em; - padding: 4px; - background-color: color.adjust($dv-primary-color, $lightness: 51%) ; -} - -.citation-box-deaccessioned { - margin-top: 4px; - margin-bottom: .5em; - padding: 4px; - background-color: color.adjust($dv-danger-box-color, $lightness: 6%); -} \ No newline at end of file diff --git a/src/sections/collection/datasets-list/dataset-card/DatasetCard.tsx b/src/sections/collection/datasets-list/dataset-card/DatasetCard.tsx deleted file mode 100644 index 5e8712da2..000000000 --- a/src/sections/collection/datasets-list/dataset-card/DatasetCard.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { DatasetPreview } from '../../../../dataset/domain/models/DatasetPreview' -import styles from './DatasetCard.module.scss' -import { DatasetCardHeader } from './DatasetCardHeader' -import { DatasetCardThumbnail } from './DatasetCardThumbnail' -import { DatasetCardInfo } from './DatasetCardInfo' - -interface DatasetCardProps { - dataset: DatasetPreview -} - -export function DatasetCard({ dataset }: DatasetCardProps) { - return ( -
- -
- - -
-
- ) -} diff --git a/src/sections/collection/datasets-list/dataset-card/DatasetCardHeader.tsx b/src/sections/collection/datasets-list/dataset-card/DatasetCardHeader.tsx deleted file mode 100644 index 942f338f9..000000000 --- a/src/sections/collection/datasets-list/dataset-card/DatasetCardHeader.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import styles from './DatasetCard.module.scss' -import { LinkToPage } from '../../../shared/link-to-page/LinkToPage' -import { Route } from '../../../Route.enum' -import { DatasetLabels } from '../../../dataset/dataset-labels/DatasetLabels' -import { DatasetIcon } from '../../../dataset/dataset-icon/DatasetIcon' -import { - DatasetPublishingStatus, - DatasetVersion, - DatasetNonNumericVersionSearchParam -} from '../../../../dataset/domain/models/Dataset' -import { DvObjectType } from '../../../../shared/hierarchy/domain/models/UpwardHierarchyNode' - -interface DatasetCardHeaderProps { - persistentId: string - version: DatasetVersion -} -function getSearchParams( - persistentId: string, - publishingStatus: DatasetPublishingStatus -): Record { - const params: Record = { persistentId: persistentId } - if (publishingStatus === DatasetPublishingStatus.DRAFT) { - params.version = DatasetNonNumericVersionSearchParam.DRAFT - } - return params -} -export function DatasetCardHeader({ persistentId, version }: DatasetCardHeaderProps) { - return ( -
-
- - {version.title} - - -
-
- -
-
- ) -} diff --git a/src/sections/collection/datasets-list/dataset-card/DatasetCardInfo.tsx b/src/sections/collection/datasets-list/dataset-card/DatasetCardInfo.tsx deleted file mode 100644 index c8f46f8f6..000000000 --- a/src/sections/collection/datasets-list/dataset-card/DatasetCardInfo.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import styles from './DatasetCard.module.scss' -import { DateHelper } from '../../../../shared/helpers/DateHelper' -import { CitationDescription } from '../../../shared/citation/CitationDescription' -import { DatasetPublishingStatus, DatasetVersion } from '../../../../dataset/domain/models/Dataset' - -interface DatasetCardInfoProps { - version: DatasetVersion - releaseOrCreateDate: Date - abbreviatedDescription: string -} - -export function DatasetCardInfo({ - version, - releaseOrCreateDate, - abbreviatedDescription -}: DatasetCardInfoProps) { - return ( -
- {DateHelper.toDisplayFormat(releaseOrCreateDate)} - - - - {abbreviatedDescription} -
- ) -} diff --git a/src/sections/collection/datasets-list/dataset-card/DatasetCardThumbnail.tsx b/src/sections/collection/datasets-list/dataset-card/DatasetCardThumbnail.tsx deleted file mode 100644 index 78af22d41..000000000 --- a/src/sections/collection/datasets-list/dataset-card/DatasetCardThumbnail.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import styles from './DatasetCard.module.scss' -import { LinkToPage } from '../../../shared/link-to-page/LinkToPage' -import { Route } from '../../../Route.enum' -import { DatasetThumbnail } from '../../../dataset/dataset-thumbnail/DatasetThumbnail' -import { DatasetPublishingStatus, DatasetVersion } from '../../../../dataset/domain/models/Dataset' -import { DvObjectType } from '../../../../shared/hierarchy/domain/models/UpwardHierarchyNode' - -interface DatasetCardThumbnailProps { - persistentId: string - version: DatasetVersion - thumbnail?: string -} - -export function DatasetCardThumbnail({ - persistentId, - version, - thumbnail -}: DatasetCardThumbnailProps) { - return ( -
- - - -
- ) -} diff --git a/src/sections/collection/datasets-list/file-card/FileCard.module.scss b/src/sections/collection/datasets-list/file-card/FileCard.module.scss deleted file mode 100644 index ff436a6f8..000000000 --- a/src/sections/collection/datasets-list/file-card/FileCard.module.scss +++ /dev/null @@ -1,75 +0,0 @@ -@use 'sass:color'; -@import "node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module"; -@import "node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/typography.module"; - -.container { - margin: 6px 0; - padding: 4px 10px; - border: 1px solid $dv-file-border-color; -} - -.header { - display: flex; - justify-content: space-between; -} - -.title { - display: flex;gap: 8px; -} - -.icon { - margin-top: 2px; - font-size: 1.3em; - line-height: 1.1; - - > div >span { - margin-right: 0; - } -} - -.thumbnail { - width: 48px; - margin: 8px 12px 6px 0; - font-size: 2.8em; - - img { - vertical-align: top; - } -} - -.info { - display: flex; - color: $dv-subtext-color; -} - -.card-info-container { - display: flex; - font-size: $dv-font-size-sm; -} - -.description { - display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; - flex-direction: column; - width: 100%; - overflow: hidden; - color: black; -} - -.date { - color: $dv-subtext-color; - -} - -.citation-box { - margin-top: 4px; - margin-bottom: .5em; - padding: 4px; - background-color: color.adjust($dv-primary-color, $lightness: 51%) ; -} - -.citation-box-deaccessioned { - margin-top: 4px; - margin-bottom: .5em; - padding: 4px; - background-color: color.adjust($dv-danger-box-color, $lightness: 6%); -} \ No newline at end of file diff --git a/src/sections/collection/datasets-list/file-card/FileCard.tsx b/src/sections/collection/datasets-list/file-card/FileCard.tsx deleted file mode 100644 index a1900e4d6..000000000 --- a/src/sections/collection/datasets-list/file-card/FileCard.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import styles from './FileCard.module.scss' -import { FileCardHeader } from './FileCardHeader' -import { FileCardThumbnail } from './FileCardThumbnail' -import { FileCardInfo } from './FileCardInfo' -import { FilePreview } from '../../../../files/domain/models/FilePreview' -import { Stack } from '@iqss/dataverse-design-system' - -interface FileCardProps { - filePreview: FilePreview - persistentId: string -} - -export function FileCard({ filePreview, persistentId }: FileCardProps) { - return ( -
- -
- - - - -
-
- ) -} diff --git a/src/sections/collection/datasets-list/file-card/FileCardHeader.tsx b/src/sections/collection/datasets-list/file-card/FileCardHeader.tsx deleted file mode 100644 index a8a887920..000000000 --- a/src/sections/collection/datasets-list/file-card/FileCardHeader.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import styles from './FileCard.module.scss' -import { LinkToPage } from '../../../shared/link-to-page/LinkToPage' -import { Route } from '../../../Route.enum' -import { FilePreview } from '../../../../files/domain/models/FilePreview' -import { DatasetLabels } from '../../../dataset/dataset-labels/DatasetLabels' -import { FileCardIcon } from './FileCardIcon' -import { FileType } from '../../../../files/domain/models/FileMetadata' -import { FileCardHelper } from './FileCardHelper' -import { DvObjectType } from '../../../../shared/hierarchy/domain/models/UpwardHierarchyNode' - -interface FileCardHeaderProps { - filePreview: FilePreview -} - -export function FileCardHeader({ filePreview }: FileCardHeaderProps) { - const iconFileType = new FileType('text/tab-separated-values', 'Comma Separated Values') - return ( -
-
- - {filePreview.name} - - -
-
- -
-
- ) -} diff --git a/src/sections/collection/datasets-list/file-card/FileCardHelper.ts b/src/sections/collection/datasets-list/file-card/FileCardHelper.ts deleted file mode 100644 index ce506c1ce..000000000 --- a/src/sections/collection/datasets-list/file-card/FileCardHelper.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - DatasetLabel, - DatasetLabelSemanticMeaning, - DatasetLabelValue, - DatasetNonNumericVersionSearchParam, - DatasetPublishingStatus -} from '../../../../dataset/domain/models/Dataset' -export class FileCardHelper { - static getDatasetSearchParams( - persistentId: string, - publishingStatus: DatasetPublishingStatus - ): Record { - const params: Record = { persistentId: persistentId } - if (publishingStatus === DatasetPublishingStatus.DRAFT) { - params.version = DatasetNonNumericVersionSearchParam.DRAFT - } - return params - } - static getFileSearchParams( - id: number, - publishingStatus: DatasetPublishingStatus - ): Record { - const params: Record = { id: id.toString() } - if (publishingStatus === DatasetPublishingStatus.DRAFT) { - params.datasetVersion = DatasetNonNumericVersionSearchParam.DRAFT - } - return params - } - - static getDatasetLabels( - datasetPublishingStatus: DatasetPublishingStatus, - someDatasetVersionHasBeenReleased: boolean | undefined - ) { - const labels: DatasetLabel[] = [] - if (datasetPublishingStatus === DatasetPublishingStatus.DRAFT) { - labels.push(new DatasetLabel(DatasetLabelSemanticMeaning.DATASET, DatasetLabelValue.DRAFT)) - } - if ( - someDatasetVersionHasBeenReleased == undefined || - someDatasetVersionHasBeenReleased == false - ) { - labels.push( - new DatasetLabel(DatasetLabelSemanticMeaning.WARNING, DatasetLabelValue.UNPUBLISHED) - ) - } - return labels - } -} diff --git a/src/sections/collection/datasets-list/file-card/FileCardIcon.tsx b/src/sections/collection/datasets-list/file-card/FileCardIcon.tsx deleted file mode 100644 index c8f695c69..000000000 --- a/src/sections/collection/datasets-list/file-card/FileCardIcon.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import styles from './FileCard.module.scss' -import { IconName } from '@iqss/dataverse-design-system' -import { FileType } from '../../../../files/domain/models/FileMetadata' -import { FileTypeToFileIconMap } from '../../../file/file-preview/FileTypeToFileIconMap' - -export function FileCardIcon({ type }: { type: FileType }) { - const icon = FileTypeToFileIconMap[type.value] || IconName.OTHER - - return ( - - {icon} - - ) -} diff --git a/src/sections/collection/datasets-list/file-card/FileCardInfo.tsx b/src/sections/collection/datasets-list/file-card/FileCardInfo.tsx deleted file mode 100644 index 2e11ea917..000000000 --- a/src/sections/collection/datasets-list/file-card/FileCardInfo.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import styles from './FileCard.module.scss' -import { DateHelper } from '../../../../shared/helpers/DateHelper' -import { FilePreview } from '../../../../files/domain/models/FilePreview' -import { Stack } from '@iqss/dataverse-design-system' -import { LinkToPage } from '../../../shared/link-to-page/LinkToPage' -import { Route } from '../../../Route.enum' -import { FileChecksum } from '../../../dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/FileChecksum' -import { FileTabularData } from '../../../dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/FileTabularData' -import { FileCardHelper } from './FileCardHelper' -import { DvObjectType } from '../../../../shared/hierarchy/domain/models/UpwardHierarchyNode' -import { FileLabels } from '../../../file/file-labels/FileLabels' - -interface FileCardInfoProps { - filePreview: FilePreview - persistentId: string -} - -export function FileCardInfo({ filePreview, persistentId }: FileCardInfoProps) { - return ( -
- - - {DateHelper.toDisplayFormat(filePreview.metadata.depositDate)} -{' '} - - {filePreview.datasetName} - - - - - {filePreview.metadata.type.toDisplayFormat()} - {filePreview.metadata.size.toString()} - - - - - -

{filePreview.metadata.description}

-
-
- ) -} diff --git a/src/sections/collection/datasets-list/file-card/FileCardThumbnail.tsx b/src/sections/collection/datasets-list/file-card/FileCardThumbnail.tsx deleted file mode 100644 index 95bbe515d..000000000 --- a/src/sections/collection/datasets-list/file-card/FileCardThumbnail.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import styles from './FileCard.module.scss' -import { LinkToPage } from '../../../shared/link-to-page/LinkToPage' -import { Route } from '../../../Route.enum' -import { FileThumbnail } from '../../../dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/file-thumbnail/FileThumbnail' -import { FilePreview } from '../../../../files/domain/models/FilePreview' -import { FileCardHelper } from './FileCardHelper' -import { DvObjectType } from '../../../../shared/hierarchy/domain/models/UpwardHierarchyNode' - -interface FileCardThumbnailProps { - filePreview: FilePreview - thumbnail?: string -} - -export function FileCardThumbnail({ filePreview }: FileCardThumbnailProps) { - return ( -
- - - -
- ) -} diff --git a/src/sections/collection/datasets-list/useDatasets.tsx b/src/sections/collection/datasets-list/useDatasets.tsx deleted file mode 100644 index 2aa14a52b..000000000 --- a/src/sections/collection/datasets-list/useDatasets.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useEffect, useState } from 'react' -import { DatasetRepository } from '../../../dataset/domain/repositories/DatasetRepository' -import { getDatasetsWithCount } from '../../../dataset/domain/useCases/getDatasetsWithCount' -import { TotalDatasetsCount } from '../../../dataset/domain/models/TotalDatasetsCount' -import { DatasetPaginationInfo } from '../../../dataset/domain/models/DatasetPaginationInfo' -import { DatasetPreview } from '../../../dataset/domain/models/DatasetPreview' -import { DatasetsWithCount } from '../../../dataset/domain/models/DatasetsWithCount' - -export function useDatasets( - datasetRepository: DatasetRepository, - collectionId: string, - onPaginationInfoChange: (paginationInfo: DatasetPaginationInfo) => void, - paginationInfo: DatasetPaginationInfo -) { - const [pageNumberNotFound, setPageNumberNotFound] = useState(false) - const [datasets, setDatasets] = useState([]) - const [isLoading, setIsLoading] = useState(true) - const [totalDatasetsCount, setTotalDatasetsCount] = useState() - - const fetchDatasetsWithCount = () => { - return getDatasetsWithCount(datasetRepository, collectionId, paginationInfo).then( - (datasetsWithCount: DatasetsWithCount) => { - setTotalDatasetsCount(totalDatasetsCount) - if (datasetsWithCount.totalCount === 0) { - setIsLoading(false) - return Promise.resolve() - } - if (totalDatasetsCount !== paginationInfo.totalItems) { - paginationInfo = paginationInfo.withTotal(datasetsWithCount.totalCount) - onPaginationInfoChange(paginationInfo) - - if (paginationInfo.page > paginationInfo.totalPages) { - setPageNumberNotFound(true) - setIsLoading(false) - return Promise.resolve() - } - setDatasets(datasetsWithCount.datasetPreviews) - setIsLoading(false) - } - } - ) - } - - useEffect(() => { - setIsLoading(true) - fetchDatasetsWithCount().catch(() => { - console.error('There was an error getting the datasets') - setIsLoading(false) - }) - }, [datasetRepository, paginationInfo.page]) - - return { - datasets, - totalDatasetsCount, - isLoading, - pageNumberNotFound - } -} diff --git a/src/sections/collection/datasets-list/useLoadDatasets.tsx b/src/sections/collection/datasets-list/useLoadDatasets.tsx deleted file mode 100644 index 314fb999f..000000000 --- a/src/sections/collection/datasets-list/useLoadDatasets.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useMemo, useState } from 'react' -import { getDatasetsWithCount } from '../../../dataset/domain/useCases/getDatasetsWithCount' -import { DatasetPreview } from '../../../dataset/domain/models/DatasetPreview' -import { DatasetRepository } from '../../../dataset/domain/repositories/DatasetRepository' -import { DatasetPaginationInfo } from '../../../dataset/domain/models/DatasetPaginationInfo' -import { DatasetsWithCount } from '../../../dataset/domain/models/DatasetsWithCount' - -export const NO_DATASETS = 0 - -async function loadNextDatasets( - datasetRepository: DatasetRepository, - collectionId: string, - paginationInfo: DatasetPaginationInfo -): Promise { - return getDatasetsWithCount(datasetRepository, collectionId, paginationInfo).catch((_err) => { - throw new Error('Something went wrong getting the datasets') - }) -} - -export function useLoadDatasets( - datasetRepository: DatasetRepository, - collectionId: string, - paginationInfo: DatasetPaginationInfo -) { - const [isLoading, setLoading] = useState(false) - const [accumulatedDatasets, setAccumulatedDatasets] = useState([]) - const [hasNextPage, setHasNextPage] = useState(true) - const [totalAvailable, setTotalAvailable] = useState(undefined) - const [error, setError] = useState(null) - - const isEmptyDatasets = useMemo(() => totalAvailable === NO_DATASETS, [totalAvailable]) - const areDatasetsAvailable = useMemo(() => { - return typeof totalAvailable === 'number' && totalAvailable > NO_DATASETS && !error - }, [totalAvailable, error]) - const accumulatedCount = useMemo(() => accumulatedDatasets.length, [accumulatedDatasets]) - - const loadMore = async () => { - setLoading(true) - try { - const { datasetPreviews, totalCount } = await loadNextDatasets( - datasetRepository, - collectionId, - paginationInfo - ) - const isNextPage = paginationInfo.page * paginationInfo.pageSize < totalCount - - setAccumulatedDatasets((current) => [...current, ...datasetPreviews]) - setTotalAvailable(totalCount) - - setHasNextPage(isNextPage) - - if (!isNextPage) { - setLoading(false) - } - } catch (err) { - const errorMessage = - err instanceof Error && err.message - ? err.message - : 'Something went wrong getting the datasets' - setError(errorMessage) - } finally { - setLoading(false) - } - } - - return { - isLoading, - accumulatedDatasets, - totalAvailable, - hasNextPage, - error, - loadMore, - isEmptyDatasets, - areDatasetsAvailable, - accumulatedCount - } -} diff --git a/src/sections/collection/useGetCollectionQueryParams.ts b/src/sections/collection/useGetCollectionQueryParams.ts new file mode 100644 index 000000000..292db143f --- /dev/null +++ b/src/sections/collection/useGetCollectionQueryParams.ts @@ -0,0 +1,17 @@ +import { useSearchParams } from 'react-router-dom' +import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' +import { CollectionHelper } from './CollectionHelper' + +export interface UseCollectionQueryParamsReturnType { + pageQuery: number + searchQuery?: string + typesQuery?: CollectionItemType[] +} + +export const useGetCollectionQueryParams = (): UseCollectionQueryParamsReturnType => { + const [searchParams] = useSearchParams() + + const collectionQueryParams = CollectionHelper.defineCollectionQueryParams(searchParams) + + return collectionQueryParams +} diff --git a/src/sections/dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/FileDate.tsx b/src/sections/dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/FileDate.tsx index 682ca39cc..b160cfe86 100644 --- a/src/sections/dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/FileDate.tsx +++ b/src/sections/dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/FileDate.tsx @@ -2,6 +2,8 @@ import { FileDate as FileDateModel } from '../../../../../../../files/domain/mod import { useTranslation } from 'react-i18next' import { DateHelper } from '../../../../../../../shared/helpers/DateHelper' +// TODO: use time tag with dateTime attr https://developer.mozilla.org/en-US/docs/Web/HTML/Element/time + export function FileDate({ date }: { date: FileDateModel }) { const { t } = useTranslation('files') return ( diff --git a/src/sections/dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/copy-to-clipboard-button/CopyToClipboard.module.scss b/src/sections/dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/copy-to-clipboard-button/CopyToClipboard.module.scss index 621a9a2aa..60570d0b8 100644 --- a/src/sections/dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/copy-to-clipboard-button/CopyToClipboard.module.scss +++ b/src/sections/dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/copy-to-clipboard-button/CopyToClipboard.module.scss @@ -1,8 +1,10 @@ -@import "node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module"; +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; .container { + display: inline-flex; + align-items: center; margin: 0 3px; - cursor: pointer + cursor: pointer; } %icon { @@ -17,4 +19,4 @@ @extend %icon; color: $dv-success-color; -} \ No newline at end of file +} diff --git a/src/sections/dataset/dataset-labels/DatasetLabels.module.scss b/src/sections/dataset/dataset-labels/DatasetLabels.module.scss index ec77535e7..1a5cf5aa0 100644 --- a/src/sections/dataset/dataset-labels/DatasetLabels.module.scss +++ b/src/sections/dataset/dataset-labels/DatasetLabels.module.scss @@ -1,3 +1,6 @@ -.container > * { - margin-right: 0.5em; -} \ No newline at end of file +.dataset-labels-container { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} diff --git a/src/sections/dataset/dataset-labels/DatasetLabels.tsx b/src/sections/dataset/dataset-labels/DatasetLabels.tsx index e60f0cff5..e6ba9d096 100644 --- a/src/sections/dataset/dataset-labels/DatasetLabels.tsx +++ b/src/sections/dataset/dataset-labels/DatasetLabels.tsx @@ -21,7 +21,7 @@ interface DatasetLabelsProps { export function DatasetLabels({ labels }: DatasetLabelsProps) { return ( -
+
{labels.map((label: DatasetLabel, index) => { return ( } + // TODO: use time tag with dateTime attr https://developer.mozilla.org/en-US/docs/Web/HTML/Element/time + return (
diff --git a/src/sections/file/file-metadata/FileMetadata.tsx b/src/sections/file/file-metadata/FileMetadata.tsx index 2655d2d12..88f079926 100644 --- a/src/sections/file/file-metadata/FileMetadata.tsx +++ b/src/sections/file/file-metadata/FileMetadata.tsx @@ -97,6 +97,7 @@ export function FileMetadata({ {t('metadata.fields.depositDate')} + {/* TODO: use time tag with dateTime attr https://developer.mozilla.org/en-US/docs/Web/HTML/Element/time */} {DateHelper.toDisplayFormatYYYYMMDD(metadata.depositDate)} {metadata.publicationDate && ( @@ -104,6 +105,7 @@ export function FileMetadata({ {t('metadata.fields.metadataReleaseDate')} + {/* TODO: use time tag with dateTime attr https://developer.mozilla.org/en-US/docs/Web/HTML/Element/time */} {DateHelper.toDisplayFormatYYYYMMDD(metadata.publicationDate)} )} @@ -112,6 +114,7 @@ export function FileMetadata({ {t('metadata.fields.publicationDate')} + {/* TODO: use time tag with dateTime attr https://developer.mozilla.org/en-US/docs/Web/HTML/Element/time */} {metadata.embargo ? ( { @@ -25,6 +26,10 @@ export const SearchInput = () => { const searchParams = new URLSearchParams() searchParams.set(QueryParamKey.QUERY, encodedSearchValue) + searchParams.set( + QueryParamKey.COLLECTION_ITEM_TYPES, + [CollectionItemType.COLLECTION, CollectionItemType.DATASET, CollectionItemType.FILE].join(',') + ) const collectionUrlWithQuery = `${Route.COLLECTIONS_BASE}?${searchParams.toString()}` diff --git a/src/sections/shared/hierarchy/BreadcrumbsSkeleton.tsx b/src/sections/shared/hierarchy/BreadcrumbsSkeleton.tsx index 42582bee5..b87775d7b 100644 --- a/src/sections/shared/hierarchy/BreadcrumbsSkeleton.tsx +++ b/src/sections/shared/hierarchy/BreadcrumbsSkeleton.tsx @@ -3,7 +3,7 @@ import Skeleton, { SkeletonTheme } from 'react-loading-skeleton' export function BreadcrumbsSkeleton() { return ( - + ) } diff --git a/src/shared/core/domain/models/PublicationStatus.ts b/src/shared/core/domain/models/PublicationStatus.ts new file mode 100644 index 000000000..a56386f35 --- /dev/null +++ b/src/shared/core/domain/models/PublicationStatus.ts @@ -0,0 +1,5 @@ +export enum PublicationStatus { + Published = 'Published', + Unpublished = 'Unpublished', + Draft = 'Draft' +} diff --git a/src/stories/collection/Collection.stories.tsx b/src/stories/collection/Collection.stories.tsx index 92d541d54..e194d7ab5 100644 --- a/src/stories/collection/Collection.stories.tsx +++ b/src/stories/collection/Collection.stories.tsx @@ -2,13 +2,9 @@ import type { Meta, StoryObj } from '@storybook/react' import { Collection } from '../../sections/collection/Collection' import { WithI18next } from '../WithI18next' import { WithLayout } from '../WithLayout' -import { DatasetMockRepository } from '../dataset/DatasetMockRepository' -import { DatasetLoadingMockRepository } from '../dataset/DatasetLoadingMockRepository' -import { NoDatasetsMockRepository } from '../dataset/NoDatasetsMockRepository' import { WithLoggedInUser } from '../WithLoggedInUser' import { CollectionMockRepository } from './CollectionMockRepository' import { CollectionLoadingMockRepository } from './CollectionLoadingMockRepository' -import { NoCollectionMockRepository } from './NoCollectionMockRepository' const meta: Meta = { title: 'Pages/Collection', @@ -26,22 +22,14 @@ type Story = StoryObj export const Default: Story = { render: () => ( - ) -} - -export const InfiniteScrollingEnabled: Story = { - render: () => ( - ) } @@ -49,21 +37,10 @@ export const InfiniteScrollingEnabled: Story = { export const Loading: Story = { render: () => ( - ) -} - -export const NoResults: Story = { - render: () => ( - ) } @@ -72,10 +49,10 @@ export const LoggedIn: Story = { decorators: [WithLoggedInUser], render: () => ( ) } @@ -84,10 +61,10 @@ export const Created: Story = { decorators: [WithLoggedInUser], render: () => ( ) } diff --git a/src/stories/collection/CollectionErrorMockRepository.ts b/src/stories/collection/CollectionErrorMockRepository.ts new file mode 100644 index 000000000..2d5e8b4e7 --- /dev/null +++ b/src/stories/collection/CollectionErrorMockRepository.ts @@ -0,0 +1,37 @@ +import { CollectionItemsPaginationInfo } from '@/collection/domain/models/CollectionItemsPaginationInfo' +import { CollectionItemSubset } from '@/collection/domain/models/CollectionItemSubset' +import { CollectionSearchCriteria } from '@/collection/domain/models/CollectionSearchCriteria' +import { FakerHelper } from '../../../tests/component/shared/FakerHelper' +import { Collection } from '../../collection/domain/models/Collection' +import { CollectionFacet } from '../../collection/domain/models/CollectionFacet' +import { CollectionMockRepository } from './CollectionMockRepository' + +export class CollectionErrorMockRepository extends CollectionMockRepository { + getById(_id: string): Promise { + return new Promise((_resolve, reject) => { + setTimeout(() => { + reject('Something went wrong') + }, FakerHelper.loadingTimout()) + }) + } + + getFacets(_collectionIdOrAlias: number | string): Promise { + return new Promise((_resolve, reject) => { + setTimeout(() => { + reject('Something went wrong') + }, FakerHelper.loadingTimout()) + }) + } + + getItems( + _collectionId: string, + _paginationInfo: CollectionItemsPaginationInfo, + _searchCriteria?: CollectionSearchCriteria + ): Promise { + return new Promise((_resolve, reject) => { + setTimeout(() => { + reject('Something went wrong') + }, FakerHelper.loadingTimout()) + }) + } +} diff --git a/src/stories/collection/CollectionLoadingMockRepository.ts b/src/stories/collection/CollectionLoadingMockRepository.ts index bb41133e8..cf48f565e 100644 --- a/src/stories/collection/CollectionLoadingMockRepository.ts +++ b/src/stories/collection/CollectionLoadingMockRepository.ts @@ -2,6 +2,9 @@ import { CollectionDTO, CollectionUserPermissions } from '@iqss/dataverse-client import { Collection } from '../../collection/domain/models/Collection' import { CollectionMockRepository } from './CollectionMockRepository' import { CollectionFacet } from '../../collection/domain/models/CollectionFacet' +import { CollectionItemsPaginationInfo } from '@/collection/domain/models/CollectionItemsPaginationInfo' +import { CollectionItemSubset } from '@/collection/domain/models/CollectionItemSubset' +import { CollectionSearchCriteria } from '@/collection/domain/models/CollectionSearchCriteria' export class CollectionLoadingMockRepository extends CollectionMockRepository { getById(_id: string): Promise { @@ -16,4 +19,11 @@ export class CollectionLoadingMockRepository extends CollectionMockRepository { getUserPermissions(_collectionIdOrAlias: number | string): Promise { return new Promise(() => {}) } + getItems( + _collectionId: string, + _paginationInfo: CollectionItemsPaginationInfo, + _searchCriteria?: CollectionSearchCriteria + ): Promise { + return new Promise(() => {}) + } } diff --git a/src/stories/collection/CollectionMockRepository.ts b/src/stories/collection/CollectionMockRepository.ts index 6d2030b30..a9aea847e 100644 --- a/src/stories/collection/CollectionMockRepository.ts +++ b/src/stories/collection/CollectionMockRepository.ts @@ -6,6 +6,11 @@ import { CollectionDTO } from '../../collection/domain/useCases/DTOs/CollectionD import { CollectionFacet } from '../../collection/domain/models/CollectionFacet' import { CollectionFacetMother } from '../../../tests/component/collection/domain/models/CollectionFacetMother' import { CollectionUserPermissions } from '../../collection/domain/models/CollectionUserPermissions' +import { CollectionItemsPaginationInfo } from '@/collection/domain/models/CollectionItemsPaginationInfo' +import { CollectionItemSubset } from '@/collection/domain/models/CollectionItemSubset' +import { CollectionSearchCriteria } from '@/collection/domain/models/CollectionSearchCriteria' +import { CollectionItemsMother } from '../../../tests/component/collection/domain/models/CollectionItemsMother' +import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' export class CollectionMockRepository implements CollectionRepository { getById(_id: string): Promise { @@ -38,4 +43,38 @@ export class CollectionMockRepository implements CollectionRepository { }, FakerHelper.loadingTimout()) }) } + + getItems( + _collectionId: string, + paginationInfo: CollectionItemsPaginationInfo, + searchCriteria?: CollectionSearchCriteria + ): Promise { + const numberOfCollections = Math.floor(paginationInfo.pageSize / 3) + const numberOfDatasets = Math.floor(paginationInfo.pageSize / 3) + const numberOfFiles = paginationInfo.pageSize - numberOfCollections - numberOfDatasets + + const items = CollectionItemsMother.createItems({ + numberOfCollections, + numberOfDatasets, + numberOfFiles + }) + + const isDefaultSelected = + searchCriteria?.itemTypes?.length === 2 && + searchCriteria?.itemTypes?.includes(CollectionItemType.COLLECTION) && + searchCriteria?.itemTypes?.includes(CollectionItemType.DATASET) + + const filteredByTypeItems = items.filter((item) => + searchCriteria?.itemTypes?.includes(item.type) + ) + + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + items: filteredByTypeItems, + totalItemCount: isDefaultSelected ? 6 : 200 // This is a fake number, its big so we can always scroll to load more items for the story + }) + }, FakerHelper.loadingTimout()) + }) + } } diff --git a/src/stories/collection/NoCollectionMockRepository.ts b/src/stories/collection/NoCollectionMockRepository.ts index c082d5c82..17cb063a0 100644 --- a/src/stories/collection/NoCollectionMockRepository.ts +++ b/src/stories/collection/NoCollectionMockRepository.ts @@ -1,3 +1,6 @@ +import { CollectionItemsPaginationInfo } from '@/collection/domain/models/CollectionItemsPaginationInfo' +import { CollectionItemSubset } from '@/collection/domain/models/CollectionItemSubset' +import { CollectionSearchCriteria } from '@/collection/domain/models/CollectionSearchCriteria' import { FakerHelper } from '../../../tests/component/shared/FakerHelper' import { Collection } from '../../collection/domain/models/Collection' import { CollectionFacet } from '../../collection/domain/models/CollectionFacet' @@ -19,4 +22,19 @@ export class NoCollectionMockRepository extends CollectionMockRepository { }, FakerHelper.loadingTimout()) }) } + + getItems( + _collectionId: string, + _paginationInfo: CollectionItemsPaginationInfo, + _searchCriteria?: CollectionSearchCriteria + ): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + items: [], + totalItemCount: 0 + }) + }, FakerHelper.loadingTimout()) + }) + } } diff --git a/src/stories/collection/collection-items-panel/CollectionCard.stories.tsx b/src/stories/collection/collection-items-panel/CollectionCard.stories.tsx new file mode 100644 index 000000000..05443406e --- /dev/null +++ b/src/stories/collection/collection-items-panel/CollectionCard.stories.tsx @@ -0,0 +1,42 @@ +import { Meta, StoryObj } from '@storybook/react' +import { WithI18next } from '../../WithI18next' +import { CollectionCard } from '@/sections/collection/collection-items-panel/items-list/collection-card/CollectionCard' +import { CollectionItemTypePreviewMother } from '../../../../tests/component/collection/domain/models/CollectionItemTypePreviewMother' +import { FakerHelper } from '../../../../tests/component/shared/FakerHelper' + +const meta: Meta = { + title: 'Sections/Collection Page/CollectionCard', + component: CollectionCard, + decorators: [WithI18next] +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + ) +} + +export const WithLongDescription: Story = { + render: () => { + const collectionPreview = CollectionItemTypePreviewMother.create({ + description: FakerHelper.paragraph(20) + }) + + return + } +} + +export const Unpublished: Story = { + render: () => ( + + ) +} + +export const WithThumbnail: Story = { + render: () => ( + + ) +} diff --git a/src/stories/collection/collection-items-panel/CollectionItemsPanel.stories.tsx b/src/stories/collection/collection-items-panel/CollectionItemsPanel.stories.tsx new file mode 100644 index 000000000..4d0e23056 --- /dev/null +++ b/src/stories/collection/collection-items-panel/CollectionItemsPanel.stories.tsx @@ -0,0 +1,184 @@ +import { Meta, StoryObj } from '@storybook/react' +import { CollectionItemsPanel } from '@/sections/collection/collection-items-panel/CollectionItemsPanel' +import AddDataActionsButton from '@/sections/shared/add-data-actions/AddDataActionsButton' +import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' +import { WithI18next } from '@/stories/WithI18next' +import { WithLoggedInUser } from '@/stories/WithLoggedInUser' +import { CollectionMockRepository } from '../CollectionMockRepository' +import { CollectionLoadingMockRepository } from '../CollectionLoadingMockRepository' +import { NoCollectionMockRepository } from '../NoCollectionMockRepository' +import { CollectionErrorMockRepository } from '../CollectionErrorMockRepository' + +const meta: Meta = { + title: 'Sections/Collection Page/CollectionItemsPanel', + component: CollectionItemsPanel, + decorators: [WithI18next], + parameters: { + // Sets the delay for all stories. + chromatic: { delay: 15000, pauseAnimationAtEnd: true } + } +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + ) +} + +export const WithAllFiltersAndSearchValue: Story = { + render: () => ( + + ) +} + +export const WithAddDataButtons: Story = { + render: () => ( + + } + /> + ) +} + +export const LoadingItems: Story = { + render: () => ( + + ) +} + +export const NoSearchMatches: Story = { + render: () => ( + + ) +} + +export const NoCollectionDatasetsOrFiles: Story = { + render: () => ( + + ) +} +export const NoCollectionDatasetsOrFilesAuthenticatedUser: Story = { + decorators: [WithLoggedInUser], + render: () => ( + + } + /> + ) +} + +export const NoCollections: Story = { + render: () => ( + + ) +} + +export const NoDatasets: Story = { + render: () => ( + + ) +} + +export const NoFiles: Story = { + render: () => ( + + ) +} + +export const WithErrorLoadingItems: Story = { + render: () => ( + + ) +} diff --git a/src/stories/collection/collection-items-panel/DatasetCard.stories.tsx b/src/stories/collection/collection-items-panel/DatasetCard.stories.tsx new file mode 100644 index 000000000..4edb1f002 --- /dev/null +++ b/src/stories/collection/collection-items-panel/DatasetCard.stories.tsx @@ -0,0 +1,25 @@ +import { Meta, StoryObj } from '@storybook/react' +import { DatasetCard } from '@/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCard' +import { WithI18next } from '../../WithI18next' +import { DatasetItemTypePreviewMother } from '../../../../tests/component/dataset/domain/models/DatasetItemTypePreviewMother' + +const meta: Meta = { + title: 'Sections/Collection Page/DatasetCard', + component: DatasetCard, + decorators: [WithI18next] +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => +} + +export const Deaccessioned: Story = { + render: () => +} + +export const WithThumbnail: Story = { + render: () => +} diff --git a/src/stories/collection/collection-items-panel/FileCard.stories.tsx b/src/stories/collection/collection-items-panel/FileCard.stories.tsx new file mode 100644 index 000000000..05a17fea4 --- /dev/null +++ b/src/stories/collection/collection-items-panel/FileCard.stories.tsx @@ -0,0 +1,36 @@ +import { Meta, StoryObj } from '@storybook/react' +import { FileCard } from '@/sections/collection/collection-items-panel/items-list/file-card/FileCard' +import { WithI18next } from '../../WithI18next' +import { FileItemTypePreviewMother } from '../../../../tests/component/files/domain/models/FileItemTypePreviewMother' +import { FakerHelper } from '../../../../tests/component/shared/FakerHelper' + +const meta: Meta = { + title: 'Sections/Collection Page/FileCard', + component: FileCard, + decorators: [WithI18next] +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => +} + +export const WithLongDescription: Story = { + render: () => { + const filePreview = FileItemTypePreviewMother.create({ + description: FakerHelper.paragraph(20) + }) + + return + } +} + +export const WithDraft: Story = { + render: () => +} + +export const UnpublishedWithDraft: Story = { + render: () => +} diff --git a/src/stories/collection/datasets-list/CollectionCard.stories.tsx b/src/stories/collection/datasets-list/CollectionCard.stories.tsx deleted file mode 100644 index ddb26a65a..000000000 --- a/src/stories/collection/datasets-list/CollectionCard.stories.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react' -import { WithI18next } from '../../WithI18next' -import { CollectionCard } from '../../../sections/collection/datasets-list/collection-card/CollectionCard' -import { CollectionPreviewMother } from '../../../../tests/component/collection/domain/models/CollectionPreviewMother' -import { FakerHelper } from '../../../../tests/component/shared/FakerHelper' - -const meta: Meta = { - title: 'Sections/Collection Page/CollectionCard', - component: CollectionCard, - decorators: [WithI18next] -} - -export default meta -type Story = StoryObj - -export const Default: Story = { - render: () => -} - -export const RequiredOnly: Story = { - render: () => ( - - ) -} -export const WithLongDescription: Story = { - render: () => { - const collectionPreview = CollectionPreviewMother.create({ - description: FakerHelper.paragraph(20) - }) - - return - } -} - -export const Unpublished: Story = { - render: () => -} diff --git a/src/stories/collection/datasets-list/DatasetCard.stories.tsx b/src/stories/collection/datasets-list/DatasetCard.stories.tsx deleted file mode 100644 index 1a83b7327..000000000 --- a/src/stories/collection/datasets-list/DatasetCard.stories.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react' -import { WithI18next } from '../../WithI18next' -import { DatasetCard } from '../../../sections/collection/datasets-list/dataset-card/DatasetCard' -import { DatasetPreviewMother } from '../../../../tests/component/dataset/domain/models/DatasetPreviewMother' - -const meta: Meta = { - title: 'Sections/Collection Page/DatasetCard', - component: DatasetCard, - decorators: [WithI18next] -} - -export default meta -type Story = StoryObj - -export const Default: Story = { - render: () => -} - -export const Deaccessioned: Story = { - render: () => -} diff --git a/src/stories/collection/datasets-list/DatasetsList.stories.tsx b/src/stories/collection/datasets-list/DatasetsList.stories.tsx deleted file mode 100644 index 108a54a90..000000000 --- a/src/stories/collection/datasets-list/DatasetsList.stories.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react' -import { WithI18next } from '../../WithI18next' -import { DatasetMockRepository } from '../../dataset/DatasetMockRepository' -import { DatasetsList } from '../../../sections/collection/datasets-list/DatasetsList' -import { DatasetLoadingMockRepository } from '../../dataset/DatasetLoadingMockRepository' -import { NoDatasetsMockRepository } from '../../dataset/NoDatasetsMockRepository' - -const meta: Meta = { - title: 'Sections/Collection Page/DatasetsList', - component: DatasetsList, - decorators: [WithI18next], - parameters: { - // Sets the delay for all stories. - chromatic: { delay: 15000, pauseAnimationAtEnd: true } - } -} - -export default meta -type Story = StoryObj - -export const Default: Story = { - render: () => -} - -export const Loading: Story = { - render: () => ( - - ) -} - -export const NoResults: Story = { - render: () => ( - - ) -} diff --git a/src/stories/collection/datasets-list/DatasetsListWithInfiniteScroll.stories.tsx b/src/stories/collection/datasets-list/DatasetsListWithInfiniteScroll.stories.tsx deleted file mode 100644 index 88980694a..000000000 --- a/src/stories/collection/datasets-list/DatasetsListWithInfiniteScroll.stories.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react' -import { WithI18next } from '../../WithI18next' -import { DatasetMockRepository } from '../../dataset/DatasetMockRepository' -import { DatasetsListWithInfiniteScroll } from '../../../sections/collection/datasets-list/DatasetsListWithInfiniteScroll' -import { DatasetLoadingMockRepository } from '../../dataset/DatasetLoadingMockRepository' -import { NoDatasetsMockRepository } from '../../dataset/NoDatasetsMockRepository' -import { DatasetErrorMockRepository } from '../../dataset/DatasetErrorMockRepository' - -const meta: Meta = { - title: 'Sections/Collection/DatasetsListWithInfiniteScroll', - component: DatasetsListWithInfiniteScroll, - decorators: [WithI18next] -} - -export default meta -type Story = StoryObj - -export const Default: Story = { - render: () => ( - - ) -} - -export const Loading: Story = { - render: () => ( - - ) -} - -export const NoResults: Story = { - render: () => ( - - ) -} - -export const Error: Story = { - render: () => ( - - ) -} diff --git a/src/stories/collection/datasets-list/FileCard.stories.tsx b/src/stories/collection/datasets-list/FileCard.stories.tsx deleted file mode 100644 index 4c832cb30..000000000 --- a/src/stories/collection/datasets-list/FileCard.stories.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react' -import { WithI18next } from '../../WithI18next' -import { FileCard } from '../../../sections/collection/datasets-list/file-card/FileCard' -import { FilePreviewMother } from '../../../../tests/component/files/domain/models/FilePreviewMother' -import { - FileLabelMother, - FileMetadataMother -} from '../../../../tests/component/files/domain/models/FileMetadataMother' -import { FakerHelper } from '../../../../tests/component/shared/FakerHelper' -import { DatasetPublishingStatus } from '../../../dataset/domain/models/Dataset' - -const meta: Meta = { - title: 'Sections/Collection Page/FileCard', - component: FileCard, - decorators: [WithI18next] -} - -export default meta -type Story = StoryObj - -export const Default: Story = { - render: () => -} -export const TabDelimited: Story = { - render: () => -} -export const WithLongDescription: Story = { - render: () => { - const filePreview = FilePreviewMother.createDefault({ - metadata: FileMetadataMother.createDefault({ - description: FakerHelper.paragraph(20) - }) - }) - return - } -} -export const WithChecksum: Story = { - render: () => ( - - ) -} -export const WithDraft: Story = { - render: () => ( - - ) -} - -export const ReleasedWithDraft: Story = { - render: () => ( - - ) -} -export const WithAllLabels: Story = { - render: () => { - const filePreview = FilePreviewMother.createDefault({ - datasetPublishingStatus: DatasetPublishingStatus.DRAFT, - someDatasetVersionHasBeenReleased: false, - metadata: FileMetadataMother.createDefault({ - description: FakerHelper.paragraph(5), - labels: FileLabelMother.createMany(4) - }) - }) - return - } -} diff --git a/src/stories/collection/datasets-list/NoDatasetsMessage.stories.tsx b/src/stories/collection/datasets-list/NoDatasetsMessage.stories.tsx deleted file mode 100644 index e4fcbd133..000000000 --- a/src/stories/collection/datasets-list/NoDatasetsMessage.stories.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react' -import { Collection } from '../../../sections/collection/Collection' -import { WithI18next } from '../../WithI18next' -import { NoDatasetsMessage } from '../../../sections/collection/datasets-list/NoDatasetsMessage' -import { WithLoggedInUser } from '../../WithLoggedInUser' - -const meta: Meta = { - title: 'Sections/Collection Page/NoDatasetsMessage', - component: Collection, - decorators: [WithI18next] -} - -export default meta -type Story = StoryObj - -export const AnonymousUser: Story = { - render: () => -} - -export const AuthenticatedUser: Story = { - decorators: [WithLoggedInUser], - render: () => -} diff --git a/src/stories/dataset/DatasetMockRepository.ts b/src/stories/dataset/DatasetMockRepository.ts index 5a68c8c13..446216e55 100644 --- a/src/stories/dataset/DatasetMockRepository.ts +++ b/src/stories/dataset/DatasetMockRepository.ts @@ -2,7 +2,7 @@ import { Dataset, DatasetLock } from '../../dataset/domain/models/Dataset' import { DatasetRepository } from '../../dataset/domain/repositories/DatasetRepository' import { DatasetMother } from '../../../tests/component/dataset/domain/models/DatasetMother' import { DatasetPaginationInfo } from '../../dataset/domain/models/DatasetPaginationInfo' -import { DatasetPreviewMother } from '../../../tests/component/dataset/domain/models/DatasetPreviewMother' +import { DatasetItemTypePreviewMother } from '../../../tests/component/dataset/domain/models/DatasetItemTypePreviewMother' import { DatasetDTO } from '../../dataset/domain/useCases/DTOs/DatasetDTO' import { DatasetsWithCount } from '../../dataset/domain/models/DatasetsWithCount' import { FakerHelper } from '../../../tests/component/shared/FakerHelper' @@ -18,7 +18,9 @@ export class DatasetMockRepository implements DatasetRepository { return new Promise((resolve) => { setTimeout(() => { resolve({ - datasetPreviews: DatasetPreviewMother.createManyRealistic(paginationInfo.pageSize), + datasetPreviews: DatasetItemTypePreviewMother.createManyRealistic( + paginationInfo.pageSize + ), totalCount: 200 }) }, FakerHelper.loadingTimout()) diff --git a/tests/component/collection/domain/models/CollectionItemTypePreviewMother.ts b/tests/component/collection/domain/models/CollectionItemTypePreviewMother.ts new file mode 100644 index 000000000..854bb6dc1 --- /dev/null +++ b/tests/component/collection/domain/models/CollectionItemTypePreviewMother.ts @@ -0,0 +1,90 @@ +import { faker } from '@faker-js/faker' +import { FakerHelper } from '../../../shared/FakerHelper' +import { CollectionItemTypePreview } from '../../../../../src/collection/domain/models/CollectionItemTypePreview' +import { CollectionItemType } from '../../../../../src/collection/domain/models/CollectionItemType' + +export class CollectionItemTypePreviewMother { + static create(props?: Partial): CollectionItemTypePreview { + return { + type: CollectionItemType.COLLECTION, + alias: faker.datatype.string(10), + name: faker.lorem.words(3), + isReleased: faker.datatype.boolean(), + releaseOrCreateDate: faker.date.recent(), + parentCollectionAlias: faker.datatype.string(10), + parentCollectionName: faker.lorem.words(3), + description: faker.datatype.boolean() + ? `${faker.lorem.paragraph()} **${faker.lorem.sentence()}** ${faker.lorem.paragraph()}` + : undefined, + affiliation: faker.datatype.boolean() ? faker.lorem.words(3) : undefined, + ...props + } + } + + static createMany( + amount: number, + props?: Partial + ): CollectionItemTypePreview[] { + return Array.from({ length: amount }).map(() => this.create(props)) + } + + static createRealistic(): CollectionItemTypePreview { + return CollectionItemTypePreviewMother.create({ + isReleased: true, + name: 'Scientific Research Collection', + alias: 'scientific-research-collection', + releaseOrCreateDate: new Date('2021-01-01'), + parentCollectionAlias: 'parent-alias', + parentCollectionName: 'University Parent Collection', + description: 'We do all the science.', + affiliation: 'Scientific Research University' + }) + } + + static createWithOnlyRequiredFields( + props?: Partial + ): CollectionItemTypePreview { + return CollectionItemTypePreviewMother.create({ + name: FakerHelper.collectionName(), + isReleased: faker.datatype.boolean(), + affiliation: undefined, + description: undefined, + ...props + }) + } + + static createComplete(): CollectionItemTypePreview { + return CollectionItemTypePreviewMother.create({ + isReleased: faker.datatype.boolean(), + name: FakerHelper.collectionName(), + parentCollectionAlias: faker.datatype.string(10), + parentCollectionName: faker.lorem.words(3), + releaseOrCreateDate: FakerHelper.pastDate(), + description: FakerHelper.paragraph(), + affiliation: FakerHelper.affiliation() + }) + } + static createUnpublished(): CollectionItemTypePreview { + return CollectionItemTypePreviewMother.createWithOnlyRequiredFields({ + isReleased: false, + affiliation: FakerHelper.affiliation() + }) + } + static createWithDescription(): CollectionItemTypePreview { + return CollectionItemTypePreviewMother.createWithOnlyRequiredFields({ + description: FakerHelper.paragraph() + }) + } + + static createWithAffiliation(): CollectionItemTypePreview { + return CollectionItemTypePreviewMother.createWithOnlyRequiredFields({ + affiliation: FakerHelper.affiliation() + }) + } + + static createWithThumbnail(): CollectionItemTypePreview { + return CollectionItemTypePreviewMother.create({ + thumbnail: FakerHelper.getImageUrl() + }) + } +} diff --git a/tests/component/collection/domain/models/CollectionItemsMother.ts b/tests/component/collection/domain/models/CollectionItemsMother.ts new file mode 100644 index 000000000..6e9dc819b --- /dev/null +++ b/tests/component/collection/domain/models/CollectionItemsMother.ts @@ -0,0 +1,24 @@ +import { CollectionItem } from '@/collection/domain/models/CollectionItemSubset' +import { FileItemTypePreviewMother } from '../../../files/domain/models/FileItemTypePreviewMother' +import { CollectionItemTypePreviewMother } from './CollectionItemTypePreviewMother' +import { DatasetItemTypePreviewMother } from '../../../dataset/domain/models/DatasetItemTypePreviewMother' + +interface CreateItemsProps { + numberOfCollections?: number + numberOfDatasets?: number + numberOfFiles?: number +} + +export class CollectionItemsMother { + static createItems({ + numberOfCollections = 1, + numberOfDatasets = 1, + numberOfFiles = 1 + }: CreateItemsProps): CollectionItem[] { + const collections = CollectionItemTypePreviewMother.createMany(numberOfCollections) + const datasets = DatasetItemTypePreviewMother.createMany(numberOfDatasets) + const files = FileItemTypePreviewMother.createMany(numberOfFiles) + + return [...collections, ...datasets, ...files] + } +} diff --git a/tests/component/collection/domain/models/CollectionPreviewMother.ts b/tests/component/collection/domain/models/CollectionPreviewMother.ts deleted file mode 100644 index 62e9509a3..000000000 --- a/tests/component/collection/domain/models/CollectionPreviewMother.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { faker } from '@faker-js/faker' -import { FakerHelper } from '../../../shared/FakerHelper' -import { CollectionPreview } from '../../../../../src/collection/domain/models/CollectionPreview' - -export class CollectionPreviewMother { - static create(props?: Partial): CollectionPreview { - return { - id: faker.datatype.uuid(), - name: faker.lorem.words(3), - isReleased: faker.datatype.boolean(), - releaseOrCreateDate: faker.date.recent(), - parentCollectionId: faker.datatype.boolean() ? faker.datatype.uuid() : undefined, - parentCollectionName: faker.datatype.boolean() ? faker.lorem.words(3) : undefined, - description: faker.datatype.boolean() - ? `${faker.lorem.paragraph()} **${faker.lorem.sentence()}** ${faker.lorem.paragraph()}` - : undefined, - affiliation: faker.datatype.boolean() ? faker.lorem.words(3) : undefined, - ...props - } - } - - static createRealistic(): CollectionPreview { - return CollectionPreviewMother.create({ - id: 'science', - isReleased: true, - name: 'Scientific Research Collection', - releaseOrCreateDate: new Date('2021-01-01'), - parentCollectionId: 'parentId', - parentCollectionName: 'University Parent Collection', - description: 'We do all the science.', - affiliation: 'Scientific Research University' - }) - } - - static createWithOnlyRequiredFields(props?: Partial): CollectionPreview { - return CollectionPreviewMother.create({ - id: faker.datatype.uuid(), - name: FakerHelper.collectionName(), - isReleased: faker.datatype.boolean(), - affiliation: undefined, - description: undefined, - ...props - }) - } - - static createComplete(): CollectionPreview { - return CollectionPreviewMother.create({ - id: faker.datatype.uuid(), - isReleased: faker.datatype.boolean(), - name: FakerHelper.collectionName(), - parentCollectionId: faker.datatype.uuid(), - parentCollectionName: faker.lorem.words(3), - releaseOrCreateDate: FakerHelper.pastDate(), - description: FakerHelper.paragraph(), - affiliation: FakerHelper.affiliation() - }) - } - static createUnpublished(): CollectionPreview { - return CollectionPreviewMother.createWithOnlyRequiredFields({ - isReleased: false, - affiliation: FakerHelper.affiliation() - }) - } - static createWithDescription(): CollectionPreview { - return CollectionPreviewMother.createWithOnlyRequiredFields({ - description: FakerHelper.paragraph() - }) - } - - static createWithAffiliation(): CollectionPreview { - return CollectionPreviewMother.createWithOnlyRequiredFields({ - affiliation: FakerHelper.affiliation() - }) - } -} diff --git a/tests/component/dataset/domain/models/DatasetItemTypePreviewMother.ts b/tests/component/dataset/domain/models/DatasetItemTypePreviewMother.ts new file mode 100644 index 000000000..a71f34fa6 --- /dev/null +++ b/tests/component/dataset/domain/models/DatasetItemTypePreviewMother.ts @@ -0,0 +1,66 @@ +import { faker } from '@faker-js/faker' +import { DatasetItemTypePreview } from '../../../../../src/dataset/domain/models/DatasetItemTypePreview' +import { DatasetVersionMother } from './DatasetMother' +import { FakerHelper } from '../../../shared/FakerHelper' +import { CollectionItemType } from '../../../../../src/collection/domain/models/CollectionItemType' +import { PublicationStatus } from '../../../../../src/shared/core/domain/models/PublicationStatus' + +export class DatasetItemTypePreviewMother { + static createMany(count: number): DatasetItemTypePreview[] { + return Array.from({ length: count }, () => this.create()) + } + + static createManyRealistic(count: number): DatasetItemTypePreview[] { + return Array.from({ length: count }, () => this.createRealistic()) + } + + static create(props?: Partial): DatasetItemTypePreview { + const datasetPreview = { + persistentId: faker.datatype.uuid(), + version: DatasetVersionMother.create(), + releaseOrCreateDate: FakerHelper.pastDate(), + description: faker.lorem.paragraph(), + thumbnail: faker.datatype.boolean() ? FakerHelper.getImageUrl() : undefined, + publicationStatuses: [PublicationStatus.Published], + parentCollectionName: faker.lorem.word(), + parentCollectionAlias: faker.lorem.slug(), + ...props + } + return { + type: CollectionItemType.DATASET, + persistentId: datasetPreview.persistentId, + version: datasetPreview.version, + releaseOrCreateDate: datasetPreview.releaseOrCreateDate, + description: datasetPreview.description, + publicationStatuses: datasetPreview.publicationStatuses, + parentCollectionName: datasetPreview.parentCollectionName, + parentCollectionAlias: datasetPreview.parentCollectionAlias, + thumbnail: datasetPreview.thumbnail + } + } + + static createRealistic(): DatasetItemTypePreview { + return faker.datatype.boolean() ? this.createDraft() : this.createDeaccessioned() + } + + static createDraft(): DatasetItemTypePreview { + return this.create({ + version: DatasetVersionMother.createDraft(), + publicationStatuses: [PublicationStatus.Draft] + }) + } + + static createWithThumbnail(): DatasetItemTypePreview { + return this.create({ thumbnail: FakerHelper.getImageUrl() }) + } + + static createWithNoThumbnail(): DatasetItemTypePreview { + return this.create({ thumbnail: undefined }) + } + + static createDeaccessioned(): DatasetItemTypePreview { + return this.create({ + version: DatasetVersionMother.createDeaccessioned() + }) + } +} diff --git a/tests/component/dataset/domain/models/DatasetPreviewMother.ts b/tests/component/dataset/domain/models/DatasetPreviewMother.ts deleted file mode 100644 index e596c26bd..000000000 --- a/tests/component/dataset/domain/models/DatasetPreviewMother.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { faker } from '@faker-js/faker' -import { DatasetPreview } from '../../../../../src/dataset/domain/models/DatasetPreview' -import { DatasetVersionMother } from './DatasetMother' -import { FakerHelper } from '../../../shared/FakerHelper' - -export class DatasetPreviewMother { - static createMany(count: number): DatasetPreview[] { - return Array.from({ length: count }, () => this.create()) - } - - static createManyRealistic(count: number): DatasetPreview[] { - return Array.from({ length: count }, () => this.createRealistic()) - } - - static create(props?: Partial): DatasetPreview { - const datasetPreview = { - persistentId: faker.datatype.uuid(), - version: DatasetVersionMother.create(), - releaseOrCreateDate: FakerHelper.pastDate(), - description: faker.lorem.paragraph(), - thumbnail: faker.datatype.boolean() ? FakerHelper.getImageUrl() : undefined, - ...props - } - - return new DatasetPreview( - datasetPreview.persistentId, - datasetPreview.version, - datasetPreview.releaseOrCreateDate, - datasetPreview.description, - datasetPreview.thumbnail - ) - } - - static createRealistic(): DatasetPreview { - return faker.datatype.boolean() ? this.createDraft() : this.createDeaccessioned() - } - - static createDraft(): DatasetPreview { - return this.create({ - version: DatasetVersionMother.createDraft() - }) - } - - static createWithThumbnail(): DatasetPreview { - return this.create({ thumbnail: FakerHelper.getImageUrl() }) - } - - static createWithNoThumbnail(): DatasetPreview { - return this.create({ thumbnail: undefined }) - } - - static createDeaccessioned(): DatasetPreview { - return this.create({ - version: DatasetVersionMother.createDeaccessioned() - }) - } -} diff --git a/tests/component/files/domain/models/FileItemTypePreviewMother.ts b/tests/component/files/domain/models/FileItemTypePreviewMother.ts new file mode 100644 index 000000000..0752acc2c --- /dev/null +++ b/tests/component/files/domain/models/FileItemTypePreviewMother.ts @@ -0,0 +1,78 @@ +import { faker } from '@faker-js/faker' +import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' +import { FileItemTypePreview } from '@/files/domain/models/FileItemTypePreview' +import { PublicationStatus } from '@/shared/core/domain/models/PublicationStatus' +import { FakerHelper } from '../../../shared/FakerHelper' + +export class FileItemTypePreviewMother { + static create(props?: Partial): FileItemTypePreview { + return { + type: CollectionItemType.FILE, + id: faker.datatype.number(), + name: faker.lorem.words(3), + persistentId: faker.datatype.uuid(), + url: faker.internet.url(), + thumbnail: FakerHelper.getImageUrl(), + description: faker.lorem.paragraph(), + fileType: faker.system.fileType(), + fileContentType: faker.system.mimeType(), + sizeInBytes: faker.datatype.number(), + md5: faker.datatype.uuid(), + checksum: { + type: faker.lorem.word(), + value: faker.datatype.uuid() + }, + unf: faker.datatype.uuid(), + datasetName: faker.lorem.words(3), + datasetId: faker.datatype.number(), + datasetPersistentId: faker.datatype.uuid(), + datasetCitation: faker.lorem.paragraph(), + publicationStatuses: [PublicationStatus.Published], + releaseOrCreateDate: faker.date.past(), + ...props + } + } + + static createMany(amount: number, props?: Partial): FileItemTypePreview[] { + return Array.from({ length: amount }).map(() => this.create(props)) + } + + static createRealistic(props?: Partial): FileItemTypePreview { + return this.create({ + id: 2, + name: 'test file', + persistentId: 'test pid2', + url: 'http://dataverse.com', + thumbnail: 'http://dataverseimage.com', + description: 'test description', + fileType: 'testtype', + fileContentType: 'test/type', + sizeInBytes: 10, + md5: 'testmd5', + checksum: { + type: 'md5', + value: 'testmd5' + }, + unf: 'testunf', + datasetName: 'test dataset', + datasetId: 1, + datasetPersistentId: 'test pid1', + datasetCitation: 'test citation', + publicationStatuses: [PublicationStatus.Published], + releaseOrCreateDate: new Date('2023-05-15T08:21:01Z'), + ...props + }) + } + + static createWithDraft(): FileItemTypePreview { + return this.create({ + publicationStatuses: [PublicationStatus.Draft] + }) + } + + static createUnpublishedWithDraft(): FileItemTypePreview { + return this.create({ + publicationStatuses: [PublicationStatus.Draft, PublicationStatus.Unpublished] + }) + } +} diff --git a/tests/component/sections/collection/Collection.spec.tsx b/tests/component/sections/collection/Collection.spec.tsx index ee08987d9..9cc22ef63 100644 --- a/tests/component/sections/collection/Collection.spec.tsx +++ b/tests/component/sections/collection/Collection.spec.tsx @@ -1,47 +1,53 @@ import { Collection } from '../../../../src/sections/collection/Collection' -import { DatasetRepository } from '../../../../src/dataset/domain/repositories/DatasetRepository' -import { DatasetPreviewMother } from '../../dataset/domain/models/DatasetPreviewMother' import { CollectionRepository } from '../../../../src/collection/domain/repositories/CollectionRepository' import { CollectionMother } from '../../collection/domain/models/CollectionMother' -const datasetRepository: DatasetRepository = {} as DatasetRepository -const totalDatasetsCount = 200 -const datasets = DatasetPreviewMother.createMany(totalDatasetsCount) const collectionRepository = {} as CollectionRepository const collection = CollectionMother.create({ name: 'Collection Name' }) const userPermissionsMock = CollectionMother.createUserPermissions() -const datasetsWithCount = { datasetPreviews: datasets, totalCount: totalDatasetsCount } - describe('Collection page', () => { beforeEach(() => { - datasetRepository.getAllWithCount = cy.stub().resolves(datasetsWithCount) collectionRepository.getById = cy.stub().resolves(collection) collectionRepository.getUserPermissions = cy.stub().resolves(userPermissionsMock) }) it('renders skeleton while loading', () => { + const DELAYED_TIME = 200 + collectionRepository.getById = cy.stub().callsFake(() => { + return Cypress.Promise.delay(DELAYED_TIME).then(() => collection) + }) + cy.customMount( ) + cy.clock() + cy.findByTestId('collection-skeleton').should('exist') cy.findByRole('heading', { name: 'Collection Name' }).should('not.exist') + + cy.tick(DELAYED_TIME) + + cy.findByTestId('collection-skeleton').should('not.exist') + cy.findByRole('heading', { name: 'Collection Name' }).should('exist') + + cy.clock().then((clock) => clock.restore()) }) it('renders page not found when collection is undefined', () => { collectionRepository.getById = cy.stub().resolves(undefined) cy.customMount( ) @@ -51,10 +57,10 @@ describe('Collection page', () => { it('renders the breadcrumbs', () => { cy.customMount( ) @@ -64,10 +70,10 @@ describe('Collection page', () => { it('renders collection title', () => { cy.customMount( ) cy.findByRole('heading', { name: 'Collection Name' }).should('exist') @@ -76,10 +82,10 @@ describe('Collection page', () => { it('does not render the Add Data dropdown button', () => { cy.customMount( ) cy.findByRole('button', { name: /Add Data/i }).should('not.exist') @@ -88,10 +94,10 @@ describe('Collection page', () => { it('does render the Add Data dropdown button when user logged in', () => { cy.mountAuthenticated( ) @@ -102,69 +108,13 @@ describe('Collection page', () => { cy.findByText('New Dataset').should('be.visible') }) - it('renders the datasets list', () => { - cy.customMount( - - ) - - cy.findByText('1 to 10 of 200 Datasets').should('exist') - - datasets.forEach((dataset) => { - cy.findByText(dataset.version.title).should('exist') - }) - }) - - it('renders the correct page when passing the page number as a query param', () => { - cy.customMount( - - ) - - cy.findByText('41 to 50 of 200 Datasets').should('exist') - }) - - it('renders the datasets list with infinite scrolling enabled', () => { - const first10Elements = datasets.slice(0, 10) - - datasetRepository.getAllWithCount = cy.stub().resolves({ - datasetPreviews: first10Elements, - totalCount: totalDatasetsCount - }) - - cy.customMount( - - ) - - cy.findByText('10 of 200 Datasets displayed').should('exist') - - first10Elements.forEach((dataset) => { - cy.findByText(dataset.version.title).should('exist') - }) - }) - it('shows the created alert when the collection was just created', () => { cy.customMount( ) @@ -181,10 +131,10 @@ describe('Collection page', () => { cy.mountAuthenticated( ) diff --git a/tests/component/sections/collection/CollectionHelper.spec.tsx b/tests/component/sections/collection/CollectionHelper.spec.tsx new file mode 100644 index 000000000..9ca99e207 --- /dev/null +++ b/tests/component/sections/collection/CollectionHelper.spec.tsx @@ -0,0 +1,65 @@ +import { CollectionHelper } from '@/sections/collection/CollectionHelper' +import { QueryParamKey } from '@/sections/Route.enum' +import { CollectionItemType } from '@iqss/dataverse-client-javascript' + +const QUERY_VALUE = 'John%20Doe' +const DECODED_QUERY_VALUE = 'John Doe' +const PAGE_NUMBER = 1 + +describe('CollectionHelper', () => { + it('define collection query params correctly when all query params are in the url', () => { + const searchParams = new URLSearchParams({}) + + searchParams.set(QueryParamKey.QUERY, QUERY_VALUE) + searchParams.set( + QueryParamKey.COLLECTION_ITEM_TYPES, + [CollectionItemType.COLLECTION, CollectionItemType.DATASET].join(',') + ) + searchParams.set(QueryParamKey.PAGE, PAGE_NUMBER.toString()) + const collectionQueryParams = CollectionHelper.defineCollectionQueryParams(searchParams) + + expect(collectionQueryParams.searchQuery).to.equal(DECODED_QUERY_VALUE) + expect(collectionQueryParams.typesQuery).to.deep.equal([ + CollectionItemType.COLLECTION, + CollectionItemType.DATASET + ]) + expect(collectionQueryParams.pageQuery).to.equal(PAGE_NUMBER) + }) + + it('define collection query params correctly when only query param is in the url', () => { + const searchParams = new URLSearchParams({}) + + searchParams.set(QueryParamKey.QUERY, QUERY_VALUE) + const collectionQueryParams = CollectionHelper.defineCollectionQueryParams(searchParams) + + expect(collectionQueryParams.searchQuery).to.equal(DECODED_QUERY_VALUE) + expect(collectionQueryParams.typesQuery).to.deep.equal(undefined) + expect(collectionQueryParams.pageQuery).to.equal(1) + }) + + it('define collection query params correctly when only types query param is in the url', () => { + const searchParams = new URLSearchParams({}) + + searchParams.set( + QueryParamKey.COLLECTION_ITEM_TYPES, + [CollectionItemType.COLLECTION, CollectionItemType.DATASET].join(',') + ) + const collectionQueryParams = CollectionHelper.defineCollectionQueryParams(searchParams) + + expect(collectionQueryParams.searchQuery).to.equal(undefined) + expect(collectionQueryParams.typesQuery).to.deep.equal([ + CollectionItemType.COLLECTION, + CollectionItemType.DATASET + ]) + expect(collectionQueryParams.pageQuery).to.equal(1) + }) + + it('define collection query params correctly when there are no query params in the url', () => { + const searchParams = new URLSearchParams({}) + const collectionQueryParams = CollectionHelper.defineCollectionQueryParams(searchParams) + + expect(collectionQueryParams.searchQuery).to.equal(undefined) + expect(collectionQueryParams.typesQuery).to.deep.equal(undefined) + expect(collectionQueryParams.pageQuery).to.equal(1) + }) +}) diff --git a/tests/component/sections/collection/collection-items-panel/CollectionItemsPanel.spec.tsx b/tests/component/sections/collection/collection-items-panel/CollectionItemsPanel.spec.tsx new file mode 100644 index 000000000..4ea332bfa --- /dev/null +++ b/tests/component/sections/collection/collection-items-panel/CollectionItemsPanel.spec.tsx @@ -0,0 +1,443 @@ +import { CollectionItemsPanel } from '@/sections/collection/collection-items-panel/CollectionItemsPanel' +import { ROOT_COLLECTION_ALIAS } from '@/collection/domain/models/Collection' +import { + CollectionItem, + CollectionItemSubset +} from '@/collection/domain/models/CollectionItemSubset' +import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { CollectionItemsMother } from '@tests/component/collection/domain/models/CollectionItemsMother' +import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' + +const collectionRepository: CollectionRepository = {} as CollectionRepository + +const totalItemCount = 200 +const items = CollectionItemsMother.createItems({ + numberOfCollections: 4, + numberOfDatasets: 3, + numberOfFiles: 3 +}) + +const itemsWithCount: CollectionItemSubset = { items, totalItemCount } + +describe('CollectionItemsPanel', () => { + beforeEach(() => { + cy.viewport(1280, 720) + + collectionRepository.getItems = cy.stub().resolves(itemsWithCount) + }) + + it('renders skeleton while loading', () => { + cy.customMount( + + ) + + cy.findByTestId('collection-items-list-infinite-scroll-skeleton').should('exist') + }) + + describe('NoItemsMessage', () => { + it('renders correct no items message when there are no collection, dataset or files', () => { + const emptyItems: CollectionItem[] = [] + const emptyItemsWithCount: CollectionItemSubset = { items: emptyItems, totalItemCount: 0 } + collectionRepository.getItems = cy.stub().resolves(emptyItemsWithCount) + + cy.customMount( + + ) + + cy.findByText(/This collection currently has no collections, datasets or files./).should( + 'exist' + ) + }) + + it('renders correct no items message when there are no collections', () => { + const emptyItems: CollectionItem[] = [] + const emptyItemsWithCount: CollectionItemSubset = { items: emptyItems, totalItemCount: 0 } + collectionRepository.getItems = cy.stub().resolves(emptyItemsWithCount) + + cy.customMount( + + ) + + cy.findByText(/This collection currently has no collections./).should('exist') + }) + + it('renders correct no items message when there are no datasets', () => { + const emptyItems: CollectionItem[] = [] + const emptyItemsWithCount: CollectionItemSubset = { items: emptyItems, totalItemCount: 0 } + collectionRepository.getItems = cy.stub().resolves(emptyItemsWithCount) + + cy.customMount( + + ) + + cy.findByText(/This collection currently has no datasets./).should('exist') + }) + + it('renders correct no items message when there are no files', () => { + const emptyItems: CollectionItem[] = [] + const emptyItemsWithCount: CollectionItemSubset = { items: emptyItems, totalItemCount: 0 } + collectionRepository.getItems = cy.stub().resolves(emptyItemsWithCount) + + cy.customMount( + + ) + + cy.findByText(/This collection currently has no files./).should('exist') + }) + + it('renders correct no items message when there are no collections and datasets', () => { + const emptyItems: CollectionItem[] = [] + const emptyItemsWithCount: CollectionItemSubset = { items: emptyItems, totalItemCount: 0 } + collectionRepository.getItems = cy.stub().resolves(emptyItemsWithCount) + + cy.customMount( + + ) + + cy.findByText(/This collection currently has no collections or datasets./).should('exist') + }) + + it('renders correct no items message when there are no collections and files', () => { + const emptyItems: CollectionItem[] = [] + const emptyItemsWithCount: CollectionItemSubset = { items: emptyItems, totalItemCount: 0 } + collectionRepository.getItems = cy.stub().resolves(emptyItemsWithCount) + + cy.customMount( + + ) + + cy.findByText(/This collection currently has no collections or files./).should('exist') + }) + + it('renders correct no items message when there are no datasets and files', () => { + const emptyItems: CollectionItem[] = [] + const emptyItemsWithCount: CollectionItemSubset = { items: emptyItems, totalItemCount: 0 } + collectionRepository.getItems = cy.stub().resolves(emptyItemsWithCount) + + cy.customMount( + + ) + + cy.findByText(/This collection currently has no datasets or files./).should('exist') + }) + }) + + it('renders the no search results message when there are no items matching the search query', () => { + const emptyItems: CollectionItem[] = [] + const emptyItemsWithCount: CollectionItemSubset = { items: emptyItems, totalItemCount: 0 } + collectionRepository.getItems = cy.stub().resolves(emptyItemsWithCount) + + cy.customMount( + + ) + + cy.findByText(/There are no collections, datasets, or files that match your search./).should( + 'exist' + ) + }) + + it('renders error message when there is an error', () => { + collectionRepository.getItems = cy.stub().rejects(new Error('some error')) + + cy.customMount( + + ) + + cy.findByText('Error').should('exist') + }) + + it('renders the 10 first items', () => { + cy.customMount( + + ) + + cy.findByText('10 of 200 results displayed').should('exist') + + cy.findByTestId('items-list').should('exist').children().should('have.length', 10) + }) + + it('renders the first 10 items with more to load, showing the bottom loading skeleton', () => { + cy.customMount( + + ) + + cy.findByTestId('items-list').should('exist').children().should('have.length', 10) + cy.findByTestId('collection-items-list-infinite-scroll-skeleton').should('exist') + }) + + it('renders 10 first items and then loads more items when scrolling to the bottom', () => { + cy.customMount( + + ) + + cy.findByTestId('items-list').should('exist').children().should('have.length', 10) + cy.findByTestId('collection-items-list-infinite-scroll-skeleton').should('exist') + + cy.findByTestId('items-list-scrollable-container').scrollTo('bottom') + + cy.findByTestId('items-list').should('exist').children().should('have.length', 20) + cy.findByTestId('collection-items-list-infinite-scroll-skeleton').should('exist') + }) + + it('renders 4 items with no more to load, correct results in header, and no bottom skeleton loader', () => { + const first4Elements = items.slice(0, 4) + const first4ElementsWithCount: CollectionItemSubset = { + items: first4Elements, + totalItemCount: 4 + } + collectionRepository.getItems = cy.stub().resolves(first4ElementsWithCount) + + cy.customMount( + + ) + + cy.findByText('4 results').should('exist') + cy.findByTestId('items-list').should('exist').children().should('have.length', 4) + cy.findByTestId('collection-items-list-infinite-scroll-skeleton').should('not.exist') + }) + + it('sets Collections and Datasets as default selected when no types query param is passed', () => { + cy.customMount( + + ) + + cy.findByRole('checkbox', { name: /Collections/ }).should('be.checked') + cy.findByRole('checkbox', { name: /Datasets/ }).should('be.checked') + cy.findByRole('checkbox', { name: /Files/ }).should('not.be.checked') + }) + + /* + The things that happened inside the handleSearchSubmit and the handleItemsTypeChange function is not currently possible to test, will be tested in e2e tests + Adding this so it goes through the test coverage + */ + describe('Functions called on search submit, filter and popstate event type changes', () => { + it('submits the search correctly with a value and without a value', () => { + cy.customMount( + + ) + + cy.findByPlaceholderText('Search this collection...').type('Some search') + cy.findByRole('button', { name: /Search submit/ }).click() + + cy.findByPlaceholderText('Search this collection...').clear() + cy.findByRole('button', { name: /Search submit/ }).click() + }) + + it('changes the types correctly without an existing search value', () => { + cy.customMount( + + ) + + cy.findByRole('checkbox', { name: /Collections/ }).uncheck() + cy.findByRole('checkbox', { name: /Datasets/ }).uncheck() + cy.findByRole('checkbox', { name: /Files/ }).check() + + cy.findByRole('checkbox', { name: /Collections/ }).check() + cy.findByRole('checkbox', { name: /Datasets/ }).check() + cy.findByRole('checkbox', { name: /Files/ }).uncheck() + }) + + it('changes the types correctly with a search value', () => { + cy.customMount( + + ) + + cy.findByRole('checkbox', { name: /Collections/ }).uncheck() + cy.findByRole('checkbox', { name: /Datasets/ }).uncheck() + cy.findByRole('checkbox', { name: /Files/ }).check() + + cy.findByRole('checkbox', { name: /Collections/ }).check() + cy.findByRole('checkbox', { name: /Datasets/ }).check() + cy.findByRole('checkbox', { name: /Files/ }).uncheck() + }) + + it('it calls the loadItemsOnBackAndForwardNavigation on pop state event when navigating back and forward', () => { + cy.customMount( + + ) + + cy.window().then((window) => { + const popStateEvent = new window.PopStateEvent('popstate', { + state: { yourData: 'example' } + }) + + window.dispatchEvent(popStateEvent) + }) + }) + }) +}) diff --git a/tests/component/sections/collection/collection-items-panel/FilterPanel.spec.tsx b/tests/component/sections/collection/collection-items-panel/FilterPanel.spec.tsx new file mode 100644 index 000000000..bf6d126da --- /dev/null +++ b/tests/component/sections/collection/collection-items-panel/FilterPanel.spec.tsx @@ -0,0 +1,28 @@ +import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' +import { FilterPanel } from '@/sections/collection/collection-items-panel/filter-panel/FilterPanel' + +describe('FilterPanel', () => { + it('should open and close correctly the off canvas in mobile view', () => { + cy.viewport(375, 700) + + const onItemTypesChange = cy.stub().as('onItemTypesChange') + + cy.customMount( + + ) + + cy.findByRole('button', { name: /Filter Results/ }).click() + + cy.findByTestId('filter-panel-off-canvas-body').should('not.be.visible') + + cy.findByTestId('filter-panel-off-canvas-body').should('be.visible') + + cy.findByLabelText(/Close/).click() + + cy.findByTestId('filter-panel-off-canvas-body').should('not.be.visible') + }) +}) diff --git a/tests/component/sections/collection/collection-items-panel/SearchPanel.spec.tsx b/tests/component/sections/collection/collection-items-panel/SearchPanel.spec.tsx new file mode 100644 index 000000000..4b220b455 --- /dev/null +++ b/tests/component/sections/collection/collection-items-panel/SearchPanel.spec.tsx @@ -0,0 +1,49 @@ +import { SearchPanel } from '@/sections/collection/collection-items-panel/search-panel/SearchPanel' + +describe('SearchPanel', () => { + it('prefills the search input with the search query param', () => { + const onSubmitSearch = cy.stub().as('onSubmitSearch') + + const SEARCH_VALUE = 'some search' + cy.customMount( + + ) + + cy.findByPlaceholderText('Search this collection...').should('have.value', SEARCH_VALUE) + }) + + it('search submit button is disabled while loading items', () => { + const onSubmitSearch = cy.stub().as('onSubmitSearch') + cy.customMount() + + cy.findByLabelText(/Search submit/) + .should('exist') + .should('be.disabled') + }) + + it('updates the search value while typing something', () => { + const onSubmitSearch = cy.stub().as('onSubmitSearch') + cy.customMount() + + cy.findByPlaceholderText('Search this collection...').type('John Doe') + + cy.findByPlaceholderText('Search this collection...').should('have.value', 'John Doe') + }) + + it('submits the search value whit the correct argument', () => { + const onSubmitSearch = cy.stub().as('onSubmitSearch') + const SEARCH_VALUE = 'John Doe' + const expectedCallWithValue = encodeURIComponent(SEARCH_VALUE) + + cy.customMount() + + cy.findByPlaceholderText('Search this collection...').type(SEARCH_VALUE) + cy.findByRole('button', { name: /Search submit/ }).click() + + cy.wrap(onSubmitSearch).should('have.been.calledWith', expectedCallWithValue) + }) +}) diff --git a/tests/component/sections/collection/collection-items-panel/TypeFilters.spec.tsx b/tests/component/sections/collection/collection-items-panel/TypeFilters.spec.tsx new file mode 100644 index 000000000..8cf194f01 --- /dev/null +++ b/tests/component/sections/collection/collection-items-panel/TypeFilters.spec.tsx @@ -0,0 +1,124 @@ +import { TypeFilters } from '@/sections/collection/collection-items-panel/filter-panel/type-filters/TypeFilters' +import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' + +describe('TypeFilters', () => { + it('sets default selected checkboxes based on current item types prop', () => { + const onItemTypesChange = cy.stub().as('onItemTypesChange') + cy.customMount( + + ) + + cy.findByRole('checkbox', { name: /Collections/ }).should('be.checked') + cy.findByRole('checkbox', { name: /Datasets/ }).should('not.be.checked') + cy.findByRole('checkbox', { name: /Files/ }).should('be.checked') + }) + + it('if there is only one selected checkbox this one is disabled so user can not deselect all of them', () => { + const onItemTypesChange = cy.stub().as('onItemTypesChange') + + cy.customMount( + + ) + + cy.findByRole('checkbox', { name: /Collections/ }) + .should('be.checked') + .should('be.disabled') + cy.findByRole('checkbox', { name: /Datasets/ }) + .should('not.be.checked') + .should('not.be.disabled') + cy.findByRole('checkbox', { name: /Files/ }).should('not.be.checked').should('not.be.disabled') + }) + + it('checkboxes should be disabled while loading items', () => { + const onItemTypesChange = cy.stub().as('onItemTypesChange') + + cy.customMount( + + ) + + cy.findByRole('checkbox', { name: /Collections/ }) + .should('exist') + .should('be.disabled') + cy.findByRole('checkbox', { name: /Datasets/ }) + .should('exist') + .should('be.disabled') + cy.findByRole('checkbox', { name: /Files/ }).should('exist').should('be.disabled') + }) + + describe('when a checkbox is clicked', () => { + it('calls onItemTypesChange with the correct arguments when the collections checkbox is clicked', () => { + const onItemTypesChange = cy.stub().as('onItemTypesChange') + + cy.customMount( + + ) + + cy.findByRole('checkbox', { name: /Collections/ }).click() + + cy.wrap(onItemTypesChange).should('have.been.calledWith', { + type: CollectionItemType.COLLECTION, + checked: true + }) + + cy.findByRole('checkbox', { name: /Collections/ }).click() + }) + + it('calls onItemTypesChange with the correct arguments when the dataset checkbox is clicked', () => { + const onItemTypesChange = cy.stub().as('onItemTypesChange') + + cy.customMount( + + ) + + cy.findByRole('checkbox', { name: /Datasets/ }).click() + + cy.wrap(onItemTypesChange).should('have.been.calledWith', { + type: CollectionItemType.DATASET, + checked: true + }) + + cy.findByRole('checkbox', { name: /Datasets/ }).click() + }) + + it('calls onItemTypesChange with the correct arguments when the files checkbox is clicked', () => { + const onItemTypesChange = cy.stub().as('onItemTypesChange') + + cy.customMount( + + ) + + cy.findByRole('checkbox', { name: /Files/ }).click() + + cy.wrap(onItemTypesChange).should('have.been.calledWith', { + type: CollectionItemType.FILE, + checked: true + }) + + cy.findByRole('checkbox', { name: /Files/ }).click() + }) + }) +}) diff --git a/tests/component/sections/collection/collection-items-panel/collection-card/CollectionCard.spec.tsx b/tests/component/sections/collection/collection-items-panel/collection-card/CollectionCard.spec.tsx new file mode 100644 index 000000000..51b466b7e --- /dev/null +++ b/tests/component/sections/collection/collection-items-panel/collection-card/CollectionCard.spec.tsx @@ -0,0 +1,26 @@ +import { CollectionCard } from '@/sections/collection/collection-items-panel/items-list/collection-card/CollectionCard' +import { DateHelper } from '@/shared/helpers/DateHelper' +import { CollectionItemTypePreviewMother } from '../../../../collection/domain/models/CollectionItemTypePreviewMother' + +describe('CollectionCard', () => { + it('should render the card', () => { + const collectionPreview = CollectionItemTypePreviewMother.createRealistic() + + cy.customMount() + + cy.contains(DateHelper.toDisplayFormat(collectionPreview.releaseOrCreateDate)).should('exist') + collectionPreview.description && cy.findByText(collectionPreview.description).should('exist') + collectionPreview.parentCollectionName && + cy.findByText(collectionPreview.parentCollectionName).should('exist') + collectionPreview.affiliation && cy.contains(collectionPreview.affiliation).should('exist') + collectionPreview.name && cy.contains(collectionPreview.name).should('exist') + }) + + it('should render the card with a thumbnail', () => { + const collectionPreview = CollectionItemTypePreviewMother.createWithThumbnail() + + cy.customMount() + + cy.findByAltText(collectionPreview.name).should('exist') + }) +}) diff --git a/tests/component/sections/collection/collection-items-panel/dataset-card/DatasetCard.spec.tsx b/tests/component/sections/collection/collection-items-panel/dataset-card/DatasetCard.spec.tsx new file mode 100644 index 000000000..61819c94f --- /dev/null +++ b/tests/component/sections/collection/collection-items-panel/dataset-card/DatasetCard.spec.tsx @@ -0,0 +1,17 @@ +import { DatasetCard } from '@/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCard' +import { DatasetItemTypePreviewMother } from '@tests/component/dataset/domain/models/DatasetItemTypePreviewMother' +import { DateHelper } from '@/shared/helpers/DateHelper' + +describe('DatasetCard', () => { + it('should render the card', () => { + const dataset = DatasetItemTypePreviewMother.createWithThumbnail() + + cy.customMount() + + cy.findByText(dataset.version.title).should('exist') + + cy.findByRole('img', { name: dataset.version.title }).should('exist') + cy.findByText(DateHelper.toDisplayFormat(dataset.releaseOrCreateDate)).should('exist') + cy.findByText(/Admin, Dataverse, 2023, "Dataset Title",/).should('exist') + }) +}) diff --git a/tests/component/sections/collection/datasets-list/dataset-card/DatasetCardHeader.spec.tsx b/tests/component/sections/collection/collection-items-panel/dataset-card/DatasetCardHeader.spec.tsx similarity index 70% rename from tests/component/sections/collection/datasets-list/dataset-card/DatasetCardHeader.spec.tsx rename to tests/component/sections/collection/collection-items-panel/dataset-card/DatasetCardHeader.spec.tsx index e621ac768..4ae8ec92d 100644 --- a/tests/component/sections/collection/datasets-list/dataset-card/DatasetCardHeader.spec.tsx +++ b/tests/component/sections/collection/collection-items-panel/dataset-card/DatasetCardHeader.spec.tsx @@ -1,9 +1,9 @@ -import { DatasetCardHeader } from '../../../../../../src/sections/collection/datasets-list/dataset-card/DatasetCardHeader' -import { DatasetPreviewMother } from '../../../../dataset/domain/models/DatasetPreviewMother' +import { DatasetCardHeader } from '@/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardHeader' +import { DatasetItemTypePreviewMother } from '@tests/component/dataset/domain/models/DatasetItemTypePreviewMother' describe('DatasetCardHeader', () => { it('should render the header', () => { - const dataset = DatasetPreviewMother.create() + const dataset = DatasetItemTypePreviewMother.create() cy.customMount( ) @@ -17,7 +17,7 @@ describe('DatasetCardHeader', () => { cy.findByLabelText('icon-dataset').should('exist') }) it('should render the correct search param for draft version', () => { - const dataset = DatasetPreviewMother.createDraft() + const dataset = DatasetItemTypePreviewMother.createDraft() cy.customMount( ) diff --git a/tests/component/sections/collection/datasets-list/dataset-card/DatasetCardInfo.spec.tsx b/tests/component/sections/collection/collection-items-panel/dataset-card/DatasetCardInfo.spec.tsx similarity index 53% rename from tests/component/sections/collection/datasets-list/dataset-card/DatasetCardInfo.spec.tsx rename to tests/component/sections/collection/collection-items-panel/dataset-card/DatasetCardInfo.spec.tsx index 0c4c3608f..9ef0f054f 100644 --- a/tests/component/sections/collection/datasets-list/dataset-card/DatasetCardInfo.spec.tsx +++ b/tests/component/sections/collection/collection-items-panel/dataset-card/DatasetCardInfo.spec.tsx @@ -1,16 +1,16 @@ -import { DatasetPreviewMother } from '../../../../dataset/domain/models/DatasetPreviewMother' -import { DateHelper } from '../../../../../../src/shared/helpers/DateHelper' -import styles from '../../../../../../src/sections/collection/datasets-list/dataset-card/DatasetCard.module.scss' -import { DatasetCardInfo } from '../../../../../../src/sections/collection/datasets-list/dataset-card/DatasetCardInfo' +import { DatasetCardInfo } from '@/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardInfo' +import { DatasetItemTypePreviewMother } from '../../../../dataset/domain/models/DatasetItemTypePreviewMother' +import { DateHelper } from '@/shared/helpers/DateHelper' +import styles from '@/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCard.module.scss' describe('DatasetCardInfo', () => { it('should render the dataset info', () => { - const dataset = DatasetPreviewMother.createDraft() + const dataset = DatasetItemTypePreviewMother.createDraft() cy.customMount( ) @@ -20,16 +20,16 @@ describe('DatasetCardInfo', () => { .parent() .parent() .should('have.class', styles['citation-box']) - cy.findByText(dataset.abbreviatedDescription).should('exist') + cy.findByText(dataset.description).should('exist') }) it('should render the citation with the deaccessioned background if the dataset is deaccessioned', () => { - const dataset = DatasetPreviewMother.createDeaccessioned() + const dataset = DatasetItemTypePreviewMother.createDeaccessioned() cy.customMount( ) @@ -37,6 +37,6 @@ describe('DatasetCardInfo', () => { .should('exist') .parent() .parent() - .should('have.class', styles['citation-box-deaccessioned']) + .should('have.class', styles['deaccesioned']) }) }) diff --git a/tests/component/sections/collection/datasets-list/dataset-card/DatasetCardThumbnail.spec.tsx b/tests/component/sections/collection/collection-items-panel/dataset-card/DatasetCardThumbnail.spec.tsx similarity index 70% rename from tests/component/sections/collection/datasets-list/dataset-card/DatasetCardThumbnail.spec.tsx rename to tests/component/sections/collection/collection-items-panel/dataset-card/DatasetCardThumbnail.spec.tsx index c703c353c..2ed3aa609 100644 --- a/tests/component/sections/collection/datasets-list/dataset-card/DatasetCardThumbnail.spec.tsx +++ b/tests/component/sections/collection/collection-items-panel/dataset-card/DatasetCardThumbnail.spec.tsx @@ -1,9 +1,9 @@ -import { DatasetPreviewMother } from '../../../../dataset/domain/models/DatasetPreviewMother' -import { DatasetCardThumbnail } from '../../../../../../src/sections/collection/datasets-list/dataset-card/DatasetCardThumbnail' +import { DatasetCardThumbnail } from '@/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardThumbnail' +import { DatasetItemTypePreviewMother } from '@tests/component/dataset/domain/models/DatasetItemTypePreviewMother' describe('DatasetCardThumbnail', () => { it('should render the thumbnail', () => { - const dataset = DatasetPreviewMother.createWithThumbnail() + const dataset = DatasetItemTypePreviewMother.createWithThumbnail() cy.customMount( { }) it('should render the placeholder if the dataset has no thumbnail', () => { - const dataset = DatasetPreviewMother.createWithNoThumbnail() + const dataset = DatasetItemTypePreviewMother.createWithNoThumbnail() cy.customMount( { + it('should render the card', () => { + const filePreview = FileItemTypePreviewMother.create() + cy.customMount() + + cy.contains(DateHelper.toDisplayFormat(filePreview.releaseOrCreateDate)).should('exist') + cy.contains(filePreview.fileType).should('exist') + filePreview.checksum?.type && cy.contains(filePreview.checksum?.type).should('exist') + cy.contains(FileCardHelper.formatBytesToCompactNumber(filePreview.sizeInBytes)).should('exist') + filePreview.description && cy.findByText(filePreview.description).should('exist') + filePreview.datasetName && cy.findByText(filePreview.datasetName).should('exist') + }) +}) diff --git a/tests/component/sections/collection/collection-items-panel/file-card/FileCardHeader.spec.tsx b/tests/component/sections/collection/collection-items-panel/file-card/FileCardHeader.spec.tsx new file mode 100644 index 000000000..58159507e --- /dev/null +++ b/tests/component/sections/collection/collection-items-panel/file-card/FileCardHeader.spec.tsx @@ -0,0 +1,62 @@ +import { FileCardHeader } from '@/sections/collection/collection-items-panel/items-list/file-card/FileCardHeader' +import { FileItemTypePreviewMother } from '@tests/component/files/domain/models/FileItemTypePreviewMother' +import { PublicationStatus } from '@/shared/core/domain/models/PublicationStatus' + +describe('FileCard', () => { + it('should render the correct link to the file preview page', () => { + const filePreview = FileItemTypePreviewMother.createRealistic() + + cy.customMount() + + cy.findByText(filePreview.name) + .should('exist') + .should('have.attr', 'href', `/files?id=${filePreview.id}`) + }) + + it('should render the correct draft link for the file preview page', () => { + const filePreview = FileItemTypePreviewMother.createRealistic({ + publicationStatuses: [PublicationStatus.Draft] + }) + + cy.customMount() + + cy.findByText(filePreview.name) + .should('exist') + .should('have.attr', 'href', `/files?id=${filePreview.id}&datasetVersion=DRAFT`) + }) + + it('should not render any label if file belongs to a published dataset', () => { + const filePreview = FileItemTypePreviewMother.create() + + cy.customMount() + + cy.findByText('Draft').should('not.exist') + + cy.findByText('Unpublished').should('not.exist') + cy.findByText('Published').should('not.exist') + }) + + it('should render the draft label if file belongs to a draft dataset', () => { + const filePreview = FileItemTypePreviewMother.create({ + publicationStatuses: [PublicationStatus.Draft] + }) + + cy.customMount() + + cy.findByText('Draft').should('exist') + cy.findByText('Unpublished').should('not.exist') + cy.findByText('Published').should('not.exist') + }) + + it('should render the draft and unpublished labels if file belongs to a draft and unpublished dataset', () => { + const filePreview = FileItemTypePreviewMother.create({ + publicationStatuses: [PublicationStatus.Draft, PublicationStatus.Unpublished] + }) + + cy.customMount() + + cy.findByText('Draft').should('exist') + cy.findByText('Unpublished').should('exist') + cy.findByText('Published').should('not.exist') + }) +}) diff --git a/tests/component/sections/collection/collection-items-panel/file-card/FileCardThumbnail.spec.tsx b/tests/component/sections/collection/collection-items-panel/file-card/FileCardThumbnail.spec.tsx new file mode 100644 index 000000000..e89126c40 --- /dev/null +++ b/tests/component/sections/collection/collection-items-panel/file-card/FileCardThumbnail.spec.tsx @@ -0,0 +1,37 @@ +import { FileCardThumbnail } from '@/sections/collection/collection-items-panel/items-list/file-card/FileCardThumbnail' +import { FileItemTypePreviewMother } from '@tests/component/files/domain/models/FileItemTypePreviewMother' +import { PublicationStatus } from '@/shared/core/domain/models/PublicationStatus' + +describe('FileCardThumbnail', () => { + it('should render the correct link to the file preview page', () => { + const filePreview = FileItemTypePreviewMother.create() + cy.customMount() + + cy.findByRole('link').should('exist').should('have.attr', 'href', `/files?id=${filePreview.id}`) + }) + + it('should render the correct draft link for the file preview page', () => { + const filePreview = FileItemTypePreviewMother.createRealistic({ + publicationStatuses: [PublicationStatus.Draft] + }) + cy.customMount() + + cy.findByRole('link') + .should('exist') + .should('have.attr', 'href', `/files?id=${filePreview.id}&datasetVersion=DRAFT`) + }) + + it('should render the thumbnail if it has one', () => { + const filePreview = FileItemTypePreviewMother.create() + cy.customMount() + + cy.findByAltText(filePreview.name).should('exist') + }) + + it('should not render the thumbnail if it does not have one', () => { + const filePreview = FileItemTypePreviewMother.create({ thumbnail: undefined }) + cy.customMount() + + cy.findByAltText(filePreview.name).should('not.exist') + }) +}) diff --git a/tests/component/sections/collection/datasets-list/DatasetsList.spec.tsx b/tests/component/sections/collection/datasets-list/DatasetsList.spec.tsx deleted file mode 100644 index 7be7dadae..000000000 --- a/tests/component/sections/collection/datasets-list/DatasetsList.spec.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { DatasetRepository } from '../../../../../src/dataset/domain/repositories/DatasetRepository' -import { DatasetsList } from '../../../../../src/sections/collection/datasets-list/DatasetsList' -import { DatasetPreviewMother } from '../../../dataset/domain/models/DatasetPreviewMother' -import { DatasetPreview } from '@iqss/dataverse-client-javascript' -import { DatasetPaginationInfo } from '../../../../../src/dataset/domain/models/DatasetPaginationInfo' - -const datasetRepository: DatasetRepository = {} as DatasetRepository -const totalDatasetsCount = 200 -const datasets = DatasetPreviewMother.createMany(totalDatasetsCount) -const datasetsWithCount = { datasetPreviews: datasets, totalCount: totalDatasetsCount } -describe('Datasets List', () => { - beforeEach(() => { - datasetRepository.getAllWithCount = cy.stub().resolves(datasetsWithCount) - }) - - it('renders skeleton while loading', () => { - cy.customMount() - - cy.findByTestId('datasets-list-skeleton').should('exist') - datasets.forEach((dataset) => { - cy.findByRole('link', { name: dataset.version.title }).should('not.exist') - }) - }) - - it('renders no datasets message when there are no datasets', () => { - const emptyDatasets: DatasetPreview[] = [] - const emptyDatasetsWithCount = { datasetPreviews: emptyDatasets, totalCount: 0 } - datasetRepository.getAllWithCount = cy.stub().resolves(emptyDatasetsWithCount) - cy.customMount() - - cy.findByText(/This collection currently has no datasets./).should('exist') - }) - - it('renders the datasets list', () => { - cy.customMount() - - cy.wrap(datasetRepository.getAllWithCount).should( - 'be.calledOnceWith', - 'root', - new DatasetPaginationInfo(1, 10, 0) - ) - cy.findByText('1 to 10 of 200 Datasets').should('exist') - datasets.forEach((dataset) => { - cy.findByText(dataset.version.title) - .should('exist') - .should('have.attr', 'href', `/datasets?persistentId=${dataset.persistentId}`) - }) - }) - - it('renders the datasets list with the correct header on a page different than the first one ', () => { - cy.customMount() - - cy.findByRole('button', { name: '6' }).click() - cy.wrap(datasetRepository.getAllWithCount).should( - 'be.calledWith', - 'root', - new DatasetPaginationInfo(1, 10, 200).goToPage(6) - ) - cy.findByText('51 to 60 of 200 Datasets').should('exist') - }) - - it('renders the datasets list correct page when passing the page number as a query param', () => { - cy.customMount( - - ) - cy.wrap(datasetRepository.getAllWithCount).should( - 'be.calledWith', - 'root', - new DatasetPaginationInfo(1, 10, 0).goToPage(5) - ) - cy.findByText('41 to 50 of 200 Datasets').should('exist') - }) - - it('renders the page not found message when the page number is not found', () => { - cy.customMount( - - ) - - cy.findByText('Page Number Not Found').should('exist') - }) -}) diff --git a/tests/component/sections/collection/datasets-list/DatasetsListWithInfiniteScroll.spec.tsx b/tests/component/sections/collection/datasets-list/DatasetsListWithInfiniteScroll.spec.tsx deleted file mode 100644 index 8f0f10498..000000000 --- a/tests/component/sections/collection/datasets-list/DatasetsListWithInfiniteScroll.spec.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { DatasetRepository } from '../../../../../src/dataset/domain/repositories/DatasetRepository' -import { DatasetsListWithInfiniteScroll } from '../../../../../src/sections/collection/datasets-list/DatasetsListWithInfiniteScroll' -import { DatasetPreviewMother } from '../../../dataset/domain/models/DatasetPreviewMother' - -const datasetRepository: DatasetRepository = {} as DatasetRepository -const totalDatasetsCount = 200 -const datasets = DatasetPreviewMother.createMany(totalDatasetsCount) -const first10Elements = datasets.slice(0, 10) -const only4DatasetsCount = 4 - -describe('Datasets List with Infinite Scroll', () => { - beforeEach(() => { - datasetRepository.getAllWithCount = cy.stub().resolves({ - datasetPreviews: first10Elements, - totalCount: totalDatasetsCount - }) - }) - it('renders skeleton while loading', () => { - cy.customMount( - - ) - - cy.findByTestId('datasets-list-infinite-scroll-skeleton').should('exist') - datasets.forEach((dataset) => { - cy.findByRole('link', { name: dataset.version.title }).should('not.exist') - }) - }) - - it('renders no datasets message when there are no datasets', () => { - datasetRepository.getAllWithCount = cy.stub().resolves({ - datasetPreviews: [], - totalCount: 0 - }) - - cy.customMount( - - ) - - cy.findByText(/This collection currently has no datasets./).should('exist') - }) - - it('renders the first 10 datasets', () => { - cy.customMount( - - ) - - cy.findByText('10 of 200 Datasets displayed').should('exist') - first10Elements.forEach((dataset) => { - cy.findByText(dataset.version.title) - .should('exist') - .should('have.attr', 'href', `/datasets?persistentId=${dataset.persistentId}`) - }) - }) - - it('renders the first 10 datasets with more to load, showing the bottom loading skeleton but not the header skeleton', () => { - cy.customMount( - - ) - - first10Elements.forEach((dataset) => { - cy.findByText(dataset.version.title) - .should('exist') - .should('have.attr', 'href', `/datasets?persistentId=${dataset.persistentId}`) - }) - cy.findByTestId('datasets-list-infinite-scroll-skeleton-header').should('not.exist') - cy.findByTestId('datasets-list-infinite-scroll-skeleton').should('exist') - }) - - it('renders 4 datasets with no more to load, correct results in header, and no bottom skeleton loader', () => { - const first4Elements = datasets.slice(0, only4DatasetsCount) - datasetRepository.getAllWithCount = cy.stub().resolves({ - datasetPreviews: first4Elements, - totalCount: only4DatasetsCount - }) - cy.customMount( - - ) - - cy.findByText(`${only4DatasetsCount} Datasets`).should('exist') - first4Elements.forEach((dataset) => { - cy.findByText(dataset.version.title) - .should('exist') - .should('have.attr', 'href', `/datasets?persistentId=${dataset.persistentId}`) - }) - cy.findByTestId('datasets-list-infinite-scroll-skeleton').should('not.exist') - }) - - it('renders error message when there is an error', () => { - datasetRepository.getAllWithCount = cy.stub().rejects(new Error('some error')) - - cy.customMount( - - ) - - cy.findByText('Error').should('exist') - }) -}) diff --git a/tests/component/sections/collection/datasets-list/NoDatasetsMessage.spec.tsx b/tests/component/sections/collection/datasets-list/NoDatasetsMessage.spec.tsx deleted file mode 100644 index e32b731d7..000000000 --- a/tests/component/sections/collection/datasets-list/NoDatasetsMessage.spec.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { NoDatasetsMessage } from '../../../../../src/sections/collection/datasets-list/NoDatasetsMessage' - -describe('No Datasets Message', () => { - it('renders the message for anonymous user', () => { - cy.customMount() - cy.findByText(/This collection currently has no datasets. Please /).should('exist') - cy.findByRole('link', { name: 'log in' }).should( - 'have.attr', - 'href', - '/loginpage.xhtml?redirectPage=%2Fdataverse.xhtml' - ) - }) - - it('renders the message for authenticated user', () => { - cy.mountAuthenticated() - cy.findByText( - 'This collection currently has no datasets. You can add to it by using the Add Data button on this page.' - ).should('exist') - }) -}) diff --git a/tests/component/sections/collection/datasets-list/collection-card/CollectionCard.spec.tsx b/tests/component/sections/collection/datasets-list/collection-card/CollectionCard.spec.tsx deleted file mode 100644 index 839866f0b..000000000 --- a/tests/component/sections/collection/datasets-list/collection-card/CollectionCard.spec.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { DateHelper } from '../../../../../../src/shared/helpers/DateHelper' -import { CollectionPreviewMother } from '../../../../collection/domain/models/CollectionPreviewMother' -import { CollectionCard } from '../../../../../../src/sections/collection/datasets-list/collection-card/CollectionCard' - -describe('CollectionCard', () => { - it('should render the card', () => { - const collectionPreview = CollectionPreviewMother.createRealistic() - - cy.customMount() - - cy.contains(DateHelper.toDisplayFormat(collectionPreview.releaseOrCreateDate)).should('exist') - collectionPreview.description && cy.findByText(collectionPreview.description).should('exist') - collectionPreview.parentCollectionName && - cy.findByText(collectionPreview.parentCollectionName).should('exist') - collectionPreview.affiliation && cy.contains(collectionPreview.affiliation).should('exist') - collectionPreview.name && cy.contains(collectionPreview.name).should('exist') - }) -}) diff --git a/tests/component/sections/collection/datasets-list/dataset-card/DatasetCard.spec.tsx b/tests/component/sections/collection/datasets-list/dataset-card/DatasetCard.spec.tsx deleted file mode 100644 index 1e6af96bc..000000000 --- a/tests/component/sections/collection/datasets-list/dataset-card/DatasetCard.spec.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { DatasetPreviewMother } from '../../../../dataset/domain/models/DatasetPreviewMother' -import { DatasetCard } from '../../../../../../src/sections/collection/datasets-list/dataset-card/DatasetCard' -import { DateHelper } from '../../../../../../src/shared/helpers/DateHelper' -import styles from '../../../../../../src/sections/collection/datasets-list/dataset-card/DatasetCard.module.scss' - -describe('DatasetCard', () => { - it('should render the card', () => { - const dataset = DatasetPreviewMother.createWithThumbnail() - cy.customMount() - - cy.findByText(dataset.version.title).should('exist') - - cy.findByRole('img', { name: dataset.version.title }).should('exist') - cy.findByText(DateHelper.toDisplayFormat(dataset.releaseOrCreateDate)).should('exist') - cy.findByText(/Admin, Dataverse, 2023, "Dataset Title",/) - .should('exist') - .parent() - .parent() - .should('have.class', styles['citation-box']) - cy.findByText(dataset.abbreviatedDescription).should('exist') - }) -}) diff --git a/tests/component/sections/collection/datasets-list/file-card/FileCard.spec.tsx b/tests/component/sections/collection/datasets-list/file-card/FileCard.spec.tsx deleted file mode 100644 index d048fc5c0..000000000 --- a/tests/component/sections/collection/datasets-list/file-card/FileCard.spec.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { FileCard } from '../../../../../../src/sections/collection/datasets-list/file-card/FileCard' -import { FilePreviewMother } from '../../../../files/domain/models/FilePreviewMother' -import { DateHelper } from '../../../../../../src/shared/helpers/DateHelper' - -describe('FileCard', () => { - it('should render the card', () => { - const filePreview = FilePreviewMother.createTabular() - const persistentId = 'test-persistent-id' - cy.customMount() - - cy.contains(DateHelper.toDisplayFormat(filePreview.metadata.depositDate)).should('exist') - cy.contains(filePreview.metadata.type.toDisplayFormat()).should('exist') - cy.contains(filePreview.metadata.size.toString()).should('exist') - filePreview.metadata.description && - cy.findByText(filePreview.metadata.description).should('exist') - filePreview.datasetName && cy.findByText(filePreview.datasetName).should('exist') - cy.contains(DateHelper.toDisplayFormat(filePreview.metadata.depositDate)).should('exist') - }) -}) diff --git a/tests/e2e-integration/e2e/sections/collection/Collection.spec.ts b/tests/e2e-integration/e2e/sections/collection/Collection.spec.ts index 097db570d..5e71f8c05 100644 --- a/tests/e2e-integration/e2e/sections/collection/Collection.spec.ts +++ b/tests/e2e-integration/e2e/sections/collection/Collection.spec.ts @@ -116,23 +116,4 @@ describe('Collection Page', () => { cy.findByText(/Dataverse Admin/i).should('exist') }) }) - - it('12 Datasets - displays first 10 datasets, scroll to the bottom and displays the remaining 2 datasets', () => { - const collectionId = 'collection-1' + Date.now().toString() - cy.wrap(CollectionHelper.create(collectionId)).then(() => { - cy.wrap(DatasetHelper.createMany(12, collectionId), { timeout: 10_000 }).then(() => { - cy.visit(`/spa/collections/${collectionId}`) - - cy.findAllByText(/Scientific Research/i).should('exist') - cy.findByText(/Dataverse Admin/i).should('exist') - - cy.findByText('10 of 12 Datasets displayed').should('exist') - - cy.get('[data-testid="scrollable-container"]').scrollTo('bottom', { - ensureScrollable: false - }) - cy.findByText('12 of 12 Datasets displayed').should('exist') - }) - }) - }) }) diff --git a/tests/e2e-integration/e2e/sections/collection/CollectionItemsPanel.spec.ts b/tests/e2e-integration/e2e/sections/collection/CollectionItemsPanel.spec.ts new file mode 100644 index 000000000..37e33b7e2 --- /dev/null +++ b/tests/e2e-integration/e2e/sections/collection/CollectionItemsPanel.spec.ts @@ -0,0 +1,267 @@ +import { CollectionItem } from '@/collection/domain/models/CollectionItemSubset' +import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' +import { QueryParamKey } from '@/sections/Route.enum' +import { DatasetHelper } from '@tests/e2e-integration/shared/datasets/DatasetHelper' +import { FileHelper } from '@tests/e2e-integration/shared/files/FileHelper' +import { TestsUtils } from '@tests/e2e-integration/shared/TestsUtils' +import { Interception } from 'cypress/types/net-stubbing' + +const numbersOfDatasetsToCreate = [1, 2, 3, 4, 5, 6, 7, 8] + +const SEARCH_ENDPOINT_REGEX = /^\/api\/v1\/search(\?.*)?$/ + +function extractInfoFromInterceptedResponse(interception: Interception) { + const totalCount = interception?.response?.body?.data.total_count as number + const totalItemsInResponse = interception?.response?.body?.data.items.length as number + const collectionsInResponse = ( + interception?.response?.body?.data.items as CollectionItem[] + ).filter((item: CollectionItem) => item.type === CollectionItemType.COLLECTION) + const datasetsInResponse = (interception?.response?.body?.data.items as CollectionItem[]).filter( + (item: CollectionItem) => item.type === CollectionItemType.DATASET + ) + const filesInResponse = (interception?.response?.body?.data.items as CollectionItem[]).filter( + (item: CollectionItem) => item.type === CollectionItemType.FILE + ) + + return { + totalCount, + totalItemsInResponse, + collectionsInResponse, + datasetsInResponse, + filesInResponse + } +} + +describe('Collection Items Panel', () => { + before(() => { + TestsUtils.setup() + TestsUtils.login() + }) + + beforeEach(async () => { + cy.intercept(SEARCH_ENDPOINT_REGEX).as('getCollectionItems') + + // Creates 8 datasets with 1 file each + for (const _number of numbersOfDatasetsToCreate) { + await DatasetHelper.createWithFile(FileHelper.create()) + } + }) + + afterEach(() => { + DatasetHelper.destroyAllDatasets().catch((error) => { + console.error('Error destroying datasets:', error) + }) + }) + + it('performs different search, filtering and respond to back and forward navigation', () => { + cy.visit(`/spa/collections`) + + cy.wait('@getCollectionItems').then((interception) => { + const { totalItemsInResponse, collectionsInResponse, datasetsInResponse, filesInResponse } = + extractInfoFromInterceptedResponse(interception) + + cy.findByTestId('items-list') + .should('exist') + .children() + .should('have.length', totalItemsInResponse) + + collectionsInResponse.length > 0 && + cy.findAllByTestId('collection-card').should('have.length', collectionsInResponse.length) + + datasetsInResponse.length > 0 && + cy.findAllByTestId('dataset-card').should('have.length', datasetsInResponse.length) + + filesInResponse.length > 0 && + cy.findAllByTestId('file-card').should('have.length', filesInResponse.length) + }) + + // 1 - Now select the Files checkbox + cy.findByRole('checkbox', { name: /Files/ }).click() + + cy.wait('@getCollectionItems').then((interception) => { + const { totalItemsInResponse, collectionsInResponse, datasetsInResponse, filesInResponse } = + extractInfoFromInterceptedResponse(interception) + + cy.findByTestId('items-list') + .should('exist') + .children() + .should('have.length', totalItemsInResponse) + + collectionsInResponse.length > 0 && + cy.findAllByTestId('collection-card').should('have.length', collectionsInResponse.length) + + datasetsInResponse.length > 0 && + cy.findAllByTestId('dataset-card').should('have.length', datasetsInResponse.length) + + filesInResponse.length > 0 && + cy.findAllByTestId('file-card').should('have.length', filesInResponse.length) + + const firstExpectedURL = new URLSearchParams({ + [QueryParamKey.COLLECTION_ITEM_TYPES]: [ + CollectionItemType.COLLECTION, + CollectionItemType.DATASET, + CollectionItemType.FILE + ].join(',') + }).toString() + + cy.url().should('include', `/collections?${firstExpectedURL}`) + }) + + // 2 - Now perform a search in the input + cy.findByPlaceholderText('Search this collection...').type('Darwin{enter}') + + cy.wait('@getCollectionItems').then((interception) => { + const { totalItemsInResponse, collectionsInResponse, datasetsInResponse, filesInResponse } = + extractInfoFromInterceptedResponse(interception) + + cy.findByTestId('items-list') + .should('exist') + .children() + .should('have.length', totalItemsInResponse) + + collectionsInResponse.length > 0 && + cy.findAllByTestId('collection-card').should('have.length', collectionsInResponse.length) + + datasetsInResponse.length > 0 && + cy.findAllByTestId('dataset-card').should('have.length', datasetsInResponse.length) + + filesInResponse.length > 0 && + cy.findAllByTestId('file-card').should('have.length', filesInResponse.length) + + const secondExpectedURL = new URLSearchParams({ + [QueryParamKey.COLLECTION_ITEM_TYPES]: [ + CollectionItemType.COLLECTION, + CollectionItemType.DATASET, + CollectionItemType.FILE + ].join(','), + [QueryParamKey.QUERY]: 'Darwin' + }).toString() + + cy.url().should('include', `/collections?${secondExpectedURL}`) + }) + + // 3 - Clear the search and assert that the search is performed correctly and the url is updated correctly + cy.findByPlaceholderText('Search this collection...').clear() + cy.findByRole('button', { name: /Search submit/ }).click() + + cy.wait('@getCollectionItems').then((interception) => { + const { totalItemsInResponse, collectionsInResponse, datasetsInResponse, filesInResponse } = + extractInfoFromInterceptedResponse(interception) + + cy.findByTestId('items-list') + .should('exist') + .children() + .should('have.length', totalItemsInResponse) + + collectionsInResponse.length > 0 && + cy.findAllByTestId('collection-card').should('have.length', collectionsInResponse.length) + + datasetsInResponse.length > 0 && + cy.findAllByTestId('dataset-card').should('have.length', datasetsInResponse.length) + + filesInResponse.length > 0 && + cy.findAllByTestId('file-card').should('have.length', filesInResponse.length) + + const thirdExpectedURL = new URLSearchParams({ + [QueryParamKey.COLLECTION_ITEM_TYPES]: [ + CollectionItemType.COLLECTION, + CollectionItemType.DATASET, + CollectionItemType.FILE + ].join(',') + }).toString() + + cy.url().should('include', `/collections?${thirdExpectedURL}`) + }) + + // 4 - Uncheck the Collections checkbox + + cy.findByRole('checkbox', { name: /Collections/ }).click() + + cy.wait('@getCollectionItems').then((interception) => { + const { totalItemsInResponse, collectionsInResponse, datasetsInResponse, filesInResponse } = + extractInfoFromInterceptedResponse(interception) + + cy.findByTestId('items-list') + .should('exist') + .children() + .should('have.length', totalItemsInResponse) + + collectionsInResponse.length > 0 && + cy.findAllByTestId('collection-card').should('have.length', collectionsInResponse.length) + + datasetsInResponse.length > 0 && + cy.findAllByTestId('dataset-card').should('have.length', datasetsInResponse.length) + + filesInResponse.length > 0 && + cy.findAllByTestId('file-card').should('have.length', filesInResponse.length) + + const fourthExpectedURL = new URLSearchParams({ + [QueryParamKey.COLLECTION_ITEM_TYPES]: [ + CollectionItemType.DATASET, + CollectionItemType.FILE + ].join(',') + }).toString() + + cy.url().should('include', `/collections?${fourthExpectedURL}`) + }) + + // 5 - Uncheck the Dataset checkbox + cy.findByRole('checkbox', { name: /Datasets/ }).click() + + cy.wait('@getCollectionItems').then((interception) => { + const { totalItemsInResponse, collectionsInResponse, datasetsInResponse, filesInResponse } = + extractInfoFromInterceptedResponse(interception) + + cy.findByTestId('items-list') + .should('exist') + .children() + .should('have.length', totalItemsInResponse) + + collectionsInResponse.length > 0 && + cy.findAllByTestId('collection-card').should('have.length', collectionsInResponse.length) + + datasetsInResponse.length > 0 && + cy.findAllByTestId('dataset-card').should('have.length', datasetsInResponse.length) + + filesInResponse.length > 0 && + cy.findAllByTestId('file-card').should('have.length', filesInResponse.length) + + const fifthExpectedURL = new URLSearchParams({ + [QueryParamKey.COLLECTION_ITEM_TYPES]: [CollectionItemType.FILE].join(',') + }).toString() + + cy.url().should('include', `/collections?${fifthExpectedURL}`) + }) + + //6 - Navigate back with the browser and assert that the url is updated correctly and the items are displayed correctly as in step 4 + cy.go('back') + + cy.wait('@getCollectionItems').then((interception) => { + const { totalItemsInResponse, collectionsInResponse, datasetsInResponse, filesInResponse } = + extractInfoFromInterceptedResponse(interception) + + cy.findByTestId('items-list') + .should('exist') + .children() + .should('have.length', totalItemsInResponse) + + collectionsInResponse.length > 0 && + cy.findAllByTestId('collection-card').should('have.length', collectionsInResponse.length) + + datasetsInResponse.length > 0 && + cy.findAllByTestId('dataset-card').should('have.length', datasetsInResponse.length) + + filesInResponse.length > 0 && + cy.findAllByTestId('file-card').should('have.length', filesInResponse.length) + + const fourthExpectedURL = new URLSearchParams({ + [QueryParamKey.COLLECTION_ITEM_TYPES]: [ + CollectionItemType.DATASET, + CollectionItemType.FILE + ].join(',') + }).toString() + + cy.url().should('include', `/collections?${fourthExpectedURL}`) + }) + }) +}) diff --git a/tests/e2e-integration/e2e/sections/homepage/Homepage.spec.tsx b/tests/e2e-integration/e2e/sections/homepage/Homepage.spec.tsx index 1e245ad3c..09c19beab 100644 --- a/tests/e2e-integration/e2e/sections/homepage/Homepage.spec.tsx +++ b/tests/e2e-integration/e2e/sections/homepage/Homepage.spec.tsx @@ -1,3 +1,4 @@ +import { CollectionItemType } from '../../../../../src/collection/domain/models/CollectionItemType' import { QueryParamKey } from '../../../../../src/sections/Route.enum' describe('Homepage', () => { @@ -11,6 +12,10 @@ describe('Homepage', () => { const searchParams = new URLSearchParams() searchParams.set(QueryParamKey.QUERY, encodedSearchValue) + searchParams.set( + QueryParamKey.COLLECTION_ITEM_TYPES, + [CollectionItemType.COLLECTION, CollectionItemType.DATASET, CollectionItemType.FILE].join(',') + ) cy.url().should('include', `/collections?${searchParams.toString()}`) }) diff --git a/tests/e2e-integration/integration/datasets/DatasetJSDataverseRepository.spec.ts b/tests/e2e-integration/integration/datasets/DatasetJSDataverseRepository.spec.ts index 5698a0ee1..247aa1fc3 100644 --- a/tests/e2e-integration/integration/datasets/DatasetJSDataverseRepository.spec.ts +++ b/tests/e2e-integration/integration/datasets/DatasetJSDataverseRepository.spec.ts @@ -302,7 +302,7 @@ describe('Dataset JSDataverse Repository', () => { }) }) - it('gets the DatasetPreview', () => { + it('gets the DatasetItemTypePreview', () => { const previewCollectionId = 'DatasetJSDataverseRepositoryPreview' + Date.now().toString() cy.wrap(CollectionHelper.createAndPublish(previewCollectionId)).then(() => { diff --git a/tsconfig.json b/tsconfig.json index 0923b81c4..7a25b67d2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,12 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + + "paths": { + "@/*": ["./src/*"], + "@tests/*": ["./tests/*"] + } }, "include": [ "src", diff --git a/vite.config.ts b/vite.config.ts index 9281bbd1b..c255fb8af 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import istanbul from 'vite-plugin-istanbul' +import * as path from 'path' export default defineConfig({ plugins: [ @@ -12,5 +13,11 @@ export default defineConfig({ ], preview: { port: 5173 + }, + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + '@tests': path.resolve(__dirname, 'tests') + } } })