diff --git a/packages/design-system/src/lib/components/modal/Modal.tsx b/packages/design-system/src/lib/components/modal/Modal.tsx index daf21f78a..a3d324c33 100644 --- a/packages/design-system/src/lib/components/modal/Modal.tsx +++ b/packages/design-system/src/lib/components/modal/Modal.tsx @@ -8,12 +8,13 @@ import { ModalFooter } from './ModalFooter' interface ModalProps { show: boolean onHide: () => void + centered?: boolean size?: 'sm' | 'lg' | 'xl' } -function Modal({ show, onHide, size, children }: PropsWithChildren) { +function Modal({ show, onHide, centered, size, children }: PropsWithChildren) { return ( - + {children} ) diff --git a/public/locales/en/collection.json b/public/locales/en/collection.json index d45c4b482..f950adb7f 100644 --- a/public/locales/en/collection.json +++ b/public/locales/en/collection.json @@ -23,5 +23,12 @@ "datasetFilterTypeLabel": "Datasets", "fileFilterTypeLabel": "Files", "searchThisCollectionPlaceholder": "Search this collection...", - "searchSubmitButtonLabel": "Search submit" + "searchSubmitButtonLabel": "Search submit", + "publish": { + "title": "Publish Collection", + "button": "Publish", + "question": "Are you sure you want to publish your collection? Once you do so it must remain published.", + "error": "There was an error publishing your collection." + }, + "publishedAlert": "Your collection is now public." } diff --git a/public/locales/en/shared.json b/public/locales/en/shared.json index 47fc276cf..403cf5307 100644 --- a/public/locales/en/shared.json +++ b/public/locales/en/shared.json @@ -2,6 +2,8 @@ "asterisksIndicateRequiredFields": "Asterisks indicate required fields", "remove": "Remove", "add": "Add", + "cancel": "Cancel", + "continue": "Continue", "pageNumberNotFound": { "heading": "Page Number Not Found", "message": "The page number you requested does not exist. Please try a different page number." diff --git a/src/collection/domain/repositories/CollectionRepository.ts b/src/collection/domain/repositories/CollectionRepository.ts index 4d54cb2d2..0187d9a73 100644 --- a/src/collection/domain/repositories/CollectionRepository.ts +++ b/src/collection/domain/repositories/CollectionRepository.ts @@ -16,4 +16,5 @@ export interface CollectionRepository { paginationInfo: CollectionItemsPaginationInfo, searchCriteria?: CollectionSearchCriteria ): Promise + publish(collectionIdOrAlias: number | string): Promise } diff --git a/src/collection/domain/useCases/publishCollection.ts b/src/collection/domain/useCases/publishCollection.ts new file mode 100644 index 000000000..0e0805927 --- /dev/null +++ b/src/collection/domain/useCases/publishCollection.ts @@ -0,0 +1,10 @@ +import { CollectionRepository } from '../repositories/CollectionRepository' + +export function publishCollection( + collectionRepository: CollectionRepository, + id: string +): Promise { + return collectionRepository.publish(id).catch((error: Error) => { + throw new Error(error.message) + }) +} diff --git a/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts b/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts index 85f526ad2..3f745a569 100644 --- a/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts +++ b/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts @@ -5,7 +5,8 @@ import { getCollection, getCollectionFacets, getCollectionUserPermissions, - getCollectionItems + getCollectionItems, + publishCollection } from '@iqss/dataverse-client-javascript' import { JSCollectionMapper } from '../mappers/JSCollectionMapper' import { CollectionDTO } from '../../domain/useCases/DTOs/CollectionDTO' @@ -57,4 +58,7 @@ export class CollectionJSDataverseRepository implements CollectionRepository { } }) } + publish(collectionIdOrAlias: number | string): Promise { + return publishCollection.execute(collectionIdOrAlias) + } } diff --git a/src/sections/Route.enum.ts b/src/sections/Route.enum.ts index 179458fd3..e2d5a5248 100644 --- a/src/sections/Route.enum.ts +++ b/src/sections/Route.enum.ts @@ -29,5 +29,6 @@ export enum QueryParamKey { PERSISTENT_ID = 'persistentId', QUERY = 'q', COLLECTION_ITEM_TYPES = 'types', - PAGE = 'page' + PAGE = 'page', + COLLECTION_ID = 'collectionId' } diff --git a/src/sections/collection/Collection.module.scss b/src/sections/collection/Collection.module.scss index 25af1b00f..f3d91b901 100644 --- a/src/sections/collection/Collection.module.scss +++ b/src/sections/collection/Collection.module.scss @@ -11,6 +11,12 @@ gap: 10px; } +.action-buttons { + display: flex; + justify-content: flex-end; + margin-bottom: 1rem; +} + .subtext { color: $dv-subtext-color; } diff --git a/src/sections/collection/Collection.tsx b/src/sections/collection/Collection.tsx index 6edb35b25..0db7f0916 100644 --- a/src/sections/collection/Collection.tsx +++ b/src/sections/collection/Collection.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { Col, Row } from '@iqss/dataverse-design-system' +import { Alert, Col, Row } from '@iqss/dataverse-design-system' import { CollectionRepository } from '../../collection/domain/repositories/CollectionRepository' import { useCollection } from './useCollection' import { useScrollTop } from '../../shared/hooks/useScrollTop' @@ -13,11 +13,14 @@ import { CollectionInfo } from './CollectionInfo' import { CollectionSkeleton } from './CollectionSkeleton' import { PageNotFound } from '../page-not-found/PageNotFound' import { CreatedAlert } from './CreatedAlert' +import { PublishCollectionButton } from './publish-collection/PublishCollectionButton' +import styles from './Collection.module.scss' interface CollectionProps { collectionRepository: CollectionRepository collectionId: string created: boolean + published: boolean collectionQueryParams: UseCollectionQueryParamsReturnType infiniteScrollEnabled?: boolean } @@ -26,12 +29,13 @@ export function Collection({ collectionId, collectionRepository, created, + published, collectionQueryParams }: CollectionProps) { useTranslation('collection') useScrollTop() const { user } = useSession() - const { collection, isLoading } = useCollection(collectionRepository, collectionId) + const { collection, isLoading } = useCollection(collectionRepository, collectionId, published) const { collectionUserPermissions } = useGetCollectionUserPermissions({ collectionIdOrAlias: collectionId, collectionRepository @@ -39,8 +43,10 @@ export function Collection({ const canUserAddCollection = Boolean(collectionUserPermissions?.canAddCollection) const canUserAddDataset = Boolean(collectionUserPermissions?.canAddDataset) + const canUserPublishCollection = user && Boolean(collectionUserPermissions?.canPublishCollection) const showAddDataActions = Boolean(user && (canUserAddCollection || canUserAddDataset)) + const { t } = useTranslation('collection') if (!isLoading && !collection) { return @@ -56,6 +62,19 @@ export function Collection({ {created && } + {published && ( + + {t('publishedAlert')} + + )} + {!collection.isReleased && canUserPublishCollection && ( +
+ +
+ )} )} () const location = useLocation() - const state = location.state as { created: boolean } | undefined + const state = location.state as { published: boolean; created: boolean } | undefined const created = state?.created ?? false + const published = state?.published ?? false return ( ) diff --git a/src/sections/collection/publish-collection/PublishCollectionButton.tsx b/src/sections/collection/publish-collection/PublishCollectionButton.tsx new file mode 100644 index 000000000..e1d04b373 --- /dev/null +++ b/src/sections/collection/publish-collection/PublishCollectionButton.tsx @@ -0,0 +1,37 @@ +import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import { CollectionRepository } from '../../../collection/domain/repositories/CollectionRepository' +import { PublishCollectionModal } from './PublishCollectionModal' +import { Button } from '@iqss/dataverse-design-system' +import { GlobeAmericas } from 'react-bootstrap-icons' +import styles from '../../shared/add-data-actions/AddDataActionsButton.module.scss' + +interface PublishCollectionButtonProps { + repository: CollectionRepository + collectionId: string +} +export function PublishCollectionButton({ + repository, + collectionId +}: PublishCollectionButtonProps) { + const { t } = useTranslation('collection') + const [showModal, setShowModal] = useState(false) + + return ( + <> + setShowModal(false)} + /> + + + ) +} diff --git a/src/sections/collection/publish-collection/PublishCollectionModal.module.scss b/src/sections/collection/publish-collection/PublishCollectionModal.module.scss new file mode 100644 index 000000000..9e5edbad1 --- /dev/null +++ b/src/sections/collection/publish-collection/PublishCollectionModal.module.scss @@ -0,0 +1,10 @@ +@import "node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module"; + +.errorText { + color: $dv-danger-color; +} + +.warningText { + margin-bottom: 0; + color: $dv-warning-color; +} \ No newline at end of file diff --git a/src/sections/collection/publish-collection/PublishCollectionModal.tsx b/src/sections/collection/publish-collection/PublishCollectionModal.tsx new file mode 100644 index 000000000..260ea6b22 --- /dev/null +++ b/src/sections/collection/publish-collection/PublishCollectionModal.tsx @@ -0,0 +1,75 @@ +import { useTranslation } from 'react-i18next' +import { Button, Modal, Stack } from '@iqss/dataverse-design-system' +import { usePublishCollection } from './usePublishCollection' + +import styles from './PublishCollectionModal.module.scss' +import { useNavigate } from 'react-router-dom' +import { CollectionRepository } from '../../../collection/domain/repositories/CollectionRepository' +import { SubmissionStatus } from '../../shared/form/DatasetMetadataForm/useSubmitDataset' +import { RouteWithParams } from '../../Route.enum' + +interface PublishCollectionModalProps { + show: boolean + repository: CollectionRepository + collectionId: string + handleClose: () => void +} + +export function PublishCollectionModal({ + show, + repository, + collectionId, + handleClose +}: PublishCollectionModalProps) { + const { t: tShared } = useTranslation('shared') + const { t: tCollection } = useTranslation('collection') + + const navigate = useNavigate() + const { submissionStatus, submitPublish, publishError } = usePublishCollection( + repository, + collectionId, + onPublishSucceed + ) + + function onPublishSucceed() { + navigate(RouteWithParams.COLLECTIONS(collectionId), { + state: { published: true } + }) + handleClose() + } + + return ( + + + {tCollection('publish.title')} + + + +

{tCollection('publish.question')}

+ {submissionStatus === SubmissionStatus.Errored && ( +

+ `${tCollection('publish.error')} ${publishError ? publishError : ''}` +

+ )} +
+
+ + + + +
+ ) +} diff --git a/src/sections/collection/publish-collection/usePublishCollection.tsx b/src/sections/collection/publish-collection/usePublishCollection.tsx new file mode 100644 index 000000000..2a58b7a39 --- /dev/null +++ b/src/sections/collection/publish-collection/usePublishCollection.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react' +import { CollectionRepository } from '../../../collection/domain/repositories/CollectionRepository' +import { publishCollection } from '../../../collection/domain/useCases/publishCollection' + +import { SubmissionStatus } from '../../shared/form/DatasetMetadataForm/useSubmitDataset' + +type UsePublishCollectionReturnType = + | { + submissionStatus: + | SubmissionStatus.NotSubmitted + | SubmissionStatus.IsSubmitting + | SubmissionStatus.SubmitComplete + submitPublish: () => void + publishError: null + } + | { + submissionStatus: SubmissionStatus.Errored + submitPublish: () => void + publishError: string + } + +export function usePublishCollection( + repository: CollectionRepository, + collectionId: string, + onPublishSucceed: () => void +): UsePublishCollectionReturnType { + const [submissionStatus, setSubmissionStatus] = useState( + SubmissionStatus.NotSubmitted + ) + const [publishError, setPublishError] = useState(null) + + const submitPublish = (): void => { + setSubmissionStatus(SubmissionStatus.IsSubmitting) + + publishCollection(repository, collectionId) + .then(() => { + setPublishError(null) + setSubmissionStatus(SubmissionStatus.SubmitComplete) + onPublishSucceed() + return + }) + .catch((err) => { + const errorMessage = err instanceof Error && err.message ? err.message : 'Unknown Error' // TODO: i18n + + setPublishError(errorMessage) + setSubmissionStatus(SubmissionStatus.Errored) + }) + } + + return { + submissionStatus, + submitPublish, + publishError + } as UsePublishCollectionReturnType +} diff --git a/src/sections/collection/useCollection.tsx b/src/sections/collection/useCollection.tsx index d75e763a7..74c99159f 100644 --- a/src/sections/collection/useCollection.tsx +++ b/src/sections/collection/useCollection.tsx @@ -3,13 +3,17 @@ import { Collection } from '../../collection/domain/models/Collection' import { useEffect, useState } from 'react' import { getCollectionById } from '../../collection/domain/useCases/getCollectionById' -export function useCollection(collectionRepository: CollectionRepository, collectionId: string) { +export function useCollection( + collectionRepository: CollectionRepository, + collectionId: string, + published?: boolean +) { const [isLoading, setIsLoading] = useState(true) const [collection, setCollection] = useState() useEffect(() => { setIsLoading(true) - + setCollection(undefined) getCollectionById(collectionRepository, collectionId) .then((collection: Collection | undefined) => { setCollection(collection) @@ -20,7 +24,7 @@ export function useCollection(collectionRepository: CollectionRepository, collec .finally(() => { setIsLoading(false) }) - }, [collectionRepository, collectionId]) + }, [collectionRepository, collectionId, published]) return { collection, isLoading } } diff --git a/src/sections/dataset/dataset-action-buttons/publish-dataset-menu/PublishDatasetMenu.tsx b/src/sections/dataset/dataset-action-buttons/publish-dataset-menu/PublishDatasetMenu.tsx index de6b0bd87..bf6fce3ef 100644 --- a/src/sections/dataset/dataset-action-buttons/publish-dataset-menu/PublishDatasetMenu.tsx +++ b/src/sections/dataset/dataset-action-buttons/publish-dataset-menu/PublishDatasetMenu.tsx @@ -27,7 +27,6 @@ export function PublishDatasetMenu({ dataset, datasetRepository }: PublishDatase } const handleSelect = () => { - // TODO - Implement upload files setShowModal(true) } diff --git a/src/stories/collection/Collection.stories.tsx b/src/stories/collection/Collection.stories.tsx index e194d7ab5..c56e42f20 100644 --- a/src/stories/collection/Collection.stories.tsx +++ b/src/stories/collection/Collection.stories.tsx @@ -5,6 +5,7 @@ import { WithLayout } from '../WithLayout' import { WithLoggedInUser } from '../WithLoggedInUser' import { CollectionMockRepository } from './CollectionMockRepository' import { CollectionLoadingMockRepository } from './CollectionLoadingMockRepository' +import { UnpublishedCollectionMockRepository } from '@/stories/collection/UnpublishedCollectionMockRepository' const meta: Meta = { title: 'Pages/Collection', @@ -25,6 +26,7 @@ export const Default: Story = { collectionRepository={new CollectionMockRepository()} collectionId="collection" created={false} + published={false} collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, @@ -40,6 +42,7 @@ export const Loading: Story = { collectionRepository={new CollectionLoadingMockRepository()} collectionId="collection" created={false} + published={false} collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, typesQuery: undefined }} /> ) @@ -52,6 +55,19 @@ export const LoggedIn: Story = { collectionRepository={new CollectionMockRepository()} collectionId="collection" created={false} + published={false} + collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, typesQuery: undefined }} + /> + ) +} +export const Unpublished: Story = { + decorators: [WithLoggedInUser], + render: () => ( + ) @@ -64,6 +80,19 @@ export const Created: Story = { collectionRepository={new CollectionMockRepository()} collectionId="collection" created={true} + published={false} + collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, typesQuery: undefined }} + /> + ) +} +export const Published: Story = { + decorators: [WithLoggedInUser], + render: () => ( + ) diff --git a/src/stories/collection/CollectionMockRepository.ts b/src/stories/collection/CollectionMockRepository.ts index a9aea847e..9766af900 100644 --- a/src/stories/collection/CollectionMockRepository.ts +++ b/src/stories/collection/CollectionMockRepository.ts @@ -77,4 +77,11 @@ export class CollectionMockRepository implements CollectionRepository { }, FakerHelper.loadingTimout()) }) } + publish(_persistentId: string): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve() + }, FakerHelper.loadingTimout()) + }) + } } diff --git a/src/stories/collection/UnpublishedCollectionMockRepository.ts b/src/stories/collection/UnpublishedCollectionMockRepository.ts new file mode 100644 index 000000000..c9194f4f7 --- /dev/null +++ b/src/stories/collection/UnpublishedCollectionMockRepository.ts @@ -0,0 +1,14 @@ +import { CollectionMother } from '../../../tests/component/collection/domain/models/CollectionMother' +import { Collection } from '../../collection/domain/models/Collection' +import { FakerHelper } from '../../../tests/component/shared/FakerHelper' +import { CollectionMockRepository } from '@/stories/collection/CollectionMockRepository' + +export class UnpublishedCollectionMockRepository extends CollectionMockRepository { + getById(_id: string): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(CollectionMother.createUnpublished()) + }, FakerHelper.loadingTimout()) + }) + } +} diff --git a/src/stories/collection/publish-collection/PublishCollectionModal.stories.tsx b/src/stories/collection/publish-collection/PublishCollectionModal.stories.tsx new file mode 100644 index 000000000..4b415ed80 --- /dev/null +++ b/src/stories/collection/publish-collection/PublishCollectionModal.stories.tsx @@ -0,0 +1,27 @@ +import { Meta, StoryObj } from '@storybook/react' +import { PublishCollectionModal } from '../../../sections/collection/publish-collection/PublishCollectionModal' +import { CollectionMockRepository } from '../CollectionMockRepository' +import { WithI18next } from '../../WithI18next' +import { WithLoggedInUser } from '../../WithLoggedInUser' + +const meta: Meta = { + title: 'Sections/Collection Page/PublishCollectionModal', + component: PublishCollectionModal, + decorators: [WithI18next] +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + decorators: [WithLoggedInUser], + render: () => ( + {}} + /> + ) +} diff --git a/tests/component/sections/collection/collection-publish/PublishCollectionModal.spec.tsx b/tests/component/sections/collection/collection-publish/PublishCollectionModal.spec.tsx new file mode 100644 index 000000000..f6051500b --- /dev/null +++ b/tests/component/sections/collection/collection-publish/PublishCollectionModal.spec.tsx @@ -0,0 +1,45 @@ +import { CollectionRepository } from '../../../../../src/collection/domain/repositories/CollectionRepository' +import { PublishCollectionModal } from '../../../../../src/sections/collection/publish-collection/PublishCollectionModal' + +describe('PublishCollectionModal', () => { + it('displays an error message when publishCollection fails', () => { + const handleClose = cy.stub() + const repository = {} as CollectionRepository // Mock the repository as needed + const errorMessage = 'Publishing failed' + repository.publish = cy.stub().as('repositoryPublish').rejects(new Error(errorMessage)) + + cy.mountAuthenticated( + + ) + + // Trigger the Publish action + cy.findByRole('button', { name: 'Continue' }).click() + + // Check if the error message is displayed + cy.contains(errorMessage).should('exist') + }) + it('renders the PublishDatasetModal and triggers submitPublish on button click', () => { + const handleClose = cy.stub() + const repository = {} as CollectionRepository // Mock the repository as needed + repository.publish = cy.stub().as('repositoryPublish').resolves() + cy.customMount( + + ) + + // Check if the modal is rendered + cy.findByText('Publish Collection').should('exist') + cy.contains('Are you sure you want to publish your collection?').should('exist') + cy.findByRole('button', { name: 'Continue' }).click() + cy.get('@repositoryPublish').should('have.been.calledWith', 'testCollectionId') + }) +}) diff --git a/tests/e2e-integration/e2e/sections/collection/Collection.spec.ts b/tests/e2e-integration/e2e/sections/collection/Collection.spec.ts index de56e93f0..8af6e0b6f 100644 --- a/tests/e2e-integration/e2e/sections/collection/Collection.spec.ts +++ b/tests/e2e-integration/e2e/sections/collection/Collection.spec.ts @@ -32,7 +32,24 @@ describe('Collection Page', () => { cy.findAllByText(title).should('be.visible') }) }) - + it('Successfully publishes a collection', () => { + const timestamp = new Date().valueOf() + const uniqueCollectionId = `test-publish-collection-${timestamp}` + cy.wrap(CollectionHelper.create(uniqueCollectionId)) + .its('id') + .then((collectionId: string) => { + console.log('collectionId', collectionId) + cy.visit(`/spa/collections/${collectionId}`) + cy.findByText('Unpublished').should('exist') + cy.findByRole('button', { name: 'Publish' }).click() + + cy.findByText(/Publish Collection/i).should('exist') + cy.findByRole('button', { name: 'Continue' }).click() + cy.contains('Your collection is now public.').should('exist') + cy.findByText('Unpublished').should('not.exist') + cy.findByRole('button', { name: 'Publish' }).should('not.exist') + }) + }) it('Navigates to Create Dataset page when New Dataset link clicked', () => { cy.visit('/spa/collections') diff --git a/tests/e2e-integration/integration/collection/CollectionJSDataverseRepository.spec.ts b/tests/e2e-integration/integration/collection/CollectionJSDataverseRepository.spec.ts index b81b2ccb6..daed0a08b 100644 --- a/tests/e2e-integration/integration/collection/CollectionJSDataverseRepository.spec.ts +++ b/tests/e2e-integration/integration/collection/CollectionJSDataverseRepository.spec.ts @@ -49,4 +49,17 @@ describe('Collection JSDataverse Repository', () => { expect(collection).to.deep.equal(collectionExpected) }) }) + it('publishes the collection', async () => { + const timestamp = new Date().valueOf() + const uniqueCollectionId = `test-publish-collection-${timestamp}` + const collectionResponse = await CollectionHelper.create(uniqueCollectionId) + await collectionRepository.publish(collectionResponse.id) + await collectionRepository.getById(collectionResponse.id).then((collection) => { + if (!collection) { + throw new Error('Collection not found') + } + + expect(collection.isReleased).to.deep.equal(true) + }) + }) })