diff --git a/README.md b/README.md index 6b0eb4164..d92185194 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,8 @@ The environment is accessible through the following URLs: > did not reflect the user's current location within the site, our new SPA design now includes this feature in the breadcrumbs. > Additionally, we have aligned with best practices by positioning all breadcrumbs at the top, before anything else in the UI. > +> We have also introduced action items as the last item of the breadcrumb, eg: Collection > Dataset Name > Edit Dataset Metadata +> > This update gives users a clear indication of their current position within the application's hierarchy. > > ### Changes in Functionality & Behavior @@ -200,11 +202,17 @@ The environment is accessible through the following URLs: > search, whose search facets are reduced compared to other in-application searches. Therefore, if we find evidence that > the assumption is incorrect, we will work on extending the search capabilities to support Solr. > +> We have also introduced infinite scroll pagination here. +> > #### Dataverses/Datasets list > > The original JSF Dataverses/Datasets list on the home page uses normal paging buttons at the bottom of the list. > We have implemented infinite scrolling in this list, replacing the normal paging buttons, but the goal would be to be > able to toggle between normal paging and infinite scrolling via a toggle setting or button. +> +> #### Create/Edit Collection Page Identifier Field +> +> A feature has been added to suggest an identifier to the user based on the collection name entered. diff --git a/packages/design-system/CHANGELOG.md b/packages/design-system/CHANGELOG.md index 80b0b57b0..580561b86 100644 --- a/packages/design-system/CHANGELOG.md +++ b/packages/design-system/CHANGELOG.md @@ -39,6 +39,13 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - **Select Multiple:** add is-invalid classname if isInvalid prop is true. - **Card:** NEW card element to show header and body. - **ProgressBar:** NEW progress bar element to show progress. +- **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. +- **FormTextArea:** extend Props Interface to accept `autoFocus` prop. +- **FormSelect:** extend Props Interface to accept `autoFocus` prop. +- **Stack:** NEW Stack element to manage layouts. # [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/components/button/Button.tsx b/packages/design-system/src/lib/components/button/Button.tsx index 6d4360e6b..130e80e40 100644 --- a/packages/design-system/src/lib/components/button/Button.tsx +++ b/packages/design-system/src/lib/components/button/Button.tsx @@ -4,10 +4,12 @@ import { Button as ButtonBS } from 'react-bootstrap' import { IconName } from '../icon/IconName' import { Icon } from '../icon/Icon' +type ButtonSize = 'sm' | 'lg' type ButtonVariant = 'primary' | 'secondary' | 'link' type ButtonType = 'button' | 'reset' | 'submit' interface ButtonProps extends HTMLAttributes { + size?: ButtonSize variant?: ButtonVariant disabled?: boolean onClick?: (event: MouseEvent) => void @@ -18,6 +20,7 @@ interface ButtonProps extends HTMLAttributes { } export function Button({ + size, variant = 'primary', disabled = false, onClick, @@ -29,6 +32,7 @@ export function Button({ }: ButtonProps) { return ( { disabled?: boolean value?: string | number required?: boolean + autoFocus?: boolean } export const FormInput = React.forwardRef(function FormInput( @@ -24,6 +25,7 @@ export const FormInput = React.forwardRef(function FormInput( disabled, value, required, + autoFocus, ...props }: FormInputProps, ref @@ -39,6 +41,7 @@ export const FormInput = React.forwardRef(function FormInput( disabled={disabled} value={value} required={required} + autoFocus={autoFocus} ref={ref as React.ForwardedRef} {...props} /> diff --git a/packages/design-system/src/lib/components/form/form-group/form-element/FormSelect.tsx b/packages/design-system/src/lib/components/form/form-group/form-element/FormSelect.tsx index c70a3c39d..0bb3aaf04 100644 --- a/packages/design-system/src/lib/components/form/form-group/form-element/FormSelect.tsx +++ b/packages/design-system/src/lib/components/form/form-group/form-element/FormSelect.tsx @@ -9,10 +9,19 @@ export interface FormSelectProps isInvalid?: boolean isValid?: boolean disabled?: boolean + autoFocus?: boolean } export const FormSelect = React.forwardRef(function FormSelect( - { value, isInvalid, isValid, disabled, children, ...props }: PropsWithChildren, + { + value, + isInvalid, + isValid, + disabled, + autoFocus, + children, + ...props + }: PropsWithChildren, ref ) { return ( @@ -21,6 +30,7 @@ export const FormSelect = React.forwardRef(function FormSelect( isInvalid={isInvalid} isValid={isValid} disabled={disabled} + autoFocus={autoFocus} ref={ref as React.ForwardedRef} {...props}> {children} diff --git a/packages/design-system/src/lib/components/form/form-group/form-element/FormTextArea.tsx b/packages/design-system/src/lib/components/form/form-group/form-element/FormTextArea.tsx index 0fdb994b5..b18448dc5 100644 --- a/packages/design-system/src/lib/components/form/form-group/form-element/FormTextArea.tsx +++ b/packages/design-system/src/lib/components/form/form-group/form-element/FormTextArea.tsx @@ -8,10 +8,11 @@ export interface FormTextAreaProps extends Omit} {...props} /> 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 3d6ed63dc..9694502f0 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 @@ -2,8 +2,17 @@ import { ReactNode } from 'react' import { InputGroup } from 'react-bootstrap' import { FormInputGroupText } from './FormInputGroupText' -function FormInputGroup({ children }: { children: ReactNode }) { - return {children} +interface FormInputGroupProps { + children: ReactNode + hasValidation?: boolean +} + +function FormInputGroup({ children, hasValidation }: FormInputGroupProps) { + return ( + + {children} + + ) } FormInputGroup.Text = FormInputGroupText diff --git a/packages/design-system/src/lib/components/navbar/navbar-dropdown/NavbarDropdownItem.tsx b/packages/design-system/src/lib/components/navbar/navbar-dropdown/NavbarDropdownItem.tsx index e658d74ce..39c5fe64b 100644 --- a/packages/design-system/src/lib/components/navbar/navbar-dropdown/NavbarDropdownItem.tsx +++ b/packages/design-system/src/lib/components/navbar/navbar-dropdown/NavbarDropdownItem.tsx @@ -1,20 +1,26 @@ import { NavDropdown } from 'react-bootstrap' -import { PropsWithChildren } from 'react' +import { ComponentPropsWithoutRef, ElementType, PropsWithChildren } from 'react' -interface NavbarDropdownItemProps { - href: string +type NavbarDropdownItemProps = { + href?: string onClick?: () => void disabled?: boolean -} + as?: T +} & (T extends keyof JSX.IntrinsicElements ? JSX.IntrinsicElements[T] : ComponentPropsWithoutRef) -export function NavbarDropdownItem({ +export function NavbarDropdownItem({ href, onClick, disabled, - children -}: PropsWithChildren) { + children, + as, + ...props +}: PropsWithChildren>) { + /* eslint-disable @typescript-eslint/no-explicit-any */ + const Component: ElementType | undefined = as + return ( - + {children} ) diff --git a/packages/design-system/src/lib/components/stack/Stack.tsx b/packages/design-system/src/lib/components/stack/Stack.tsx new file mode 100644 index 000000000..c7e871b70 --- /dev/null +++ b/packages/design-system/src/lib/components/stack/Stack.tsx @@ -0,0 +1,26 @@ +import { ComponentPropsWithoutRef, ElementType } from 'react' +import { Stack as StackBS } from 'react-bootstrap' + +type StackProps = { + direction?: 'horizontal' | 'vertical' + gap?: 0 | 1 | 2 | 3 | 4 | 5 + as?: T + children: React.ReactNode +} & (T extends keyof JSX.IntrinsicElements ? JSX.IntrinsicElements[T] : ComponentPropsWithoutRef) + +export function Stack({ + direction = 'vertical', + gap = 3, + as, + children, + ...rest +}: StackProps) { + /* eslint-disable @typescript-eslint/no-explicit-any */ + const Component: ElementType = as || 'div' + + return ( + + {children} + + ) +} diff --git a/packages/design-system/src/lib/index.ts b/packages/design-system/src/lib/index.ts index fbac72d65..28240a8af 100644 --- a/packages/design-system/src/lib/index.ts +++ b/packages/design-system/src/lib/index.ts @@ -27,3 +27,4 @@ export { RequiredInputSymbol } from './components/form/required-input-symbol/Req export { SelectMultiple } from './components/select-multiple/SelectMultiple' export { Card } from './components/card/Card' export { ProgressBar } from './components/progress-bar/ProgressBar' +export { Stack } from './components/stack/Stack' diff --git a/packages/design-system/src/lib/stories/button/Button.stories.tsx b/packages/design-system/src/lib/stories/button/Button.stories.tsx index 30621588f..3b4bc0aaa 100644 --- a/packages/design-system/src/lib/stories/button/Button.stories.tsx +++ b/packages/design-system/src/lib/stories/button/Button.stories.tsx @@ -94,6 +94,20 @@ export const AllVariantsAtAGlance: Story = { ) } +export const AllSizesAtAGlance: Story = { + render: () => ( + <> + + + + + ) +} + export const Disabled: Story = { render: () => ( <> diff --git a/packages/design-system/src/lib/stories/stack/Stack.stories.tsx b/packages/design-system/src/lib/stories/stack/Stack.stories.tsx new file mode 100644 index 000000000..e466129b5 --- /dev/null +++ b/packages/design-system/src/lib/stories/stack/Stack.stories.tsx @@ -0,0 +1,120 @@ +import { CSSProperties } from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { Stack } from '../../components/stack/Stack' +import { Col } from '../../components/grid/Col' + +/** + * ## Description + * Stacks are vertical by default and stacked items are full-width by default. Use the gap prop to add space between items. + * + * Use direction="horizontal" for horizontal layouts. Stacked items are vertically centered by default and only take up their necessary width. + * + * Use the gap prop to add space between items. + */ +const meta: Meta = { + tags: ['autodocs'], + title: 'Stack', + component: Stack +} + +export default meta +type Story = StoryObj + +const inlineStyles: CSSProperties = { + backgroundColor: '#337AB7', + color: 'white', + padding: '0.5rem' +} + +export const VerticalStack: Story = { + render: () => ( + +
Item 1
+
Item 2
+
Item 3
+
+ ) +} + +/** + * Use direction="horizontal" for horizontal layouts. + * Stacked items are vertically centered by default and only take up their necessary width. + */ +export const HorizontalStack: Story = { + render: () => ( + +
Item 1
+
Item 2
+
Item 3
+
+ ) +} +/** + * By using Columns as childrens of the Stack, you can create a layout with columns that are full-width by default. + */ +export const HorizontalStackWithColumns: Story = { + render: () => ( + + Item 1 + Item 2 + Item 3 + + ) +} +/** + * Gap 0 = 0 + * + * Gap 1 = 0.25rem (4px) + * + * Gap 2 = 0.5rem (8px) + * + * Gap 3 = 1rem (16px) + * + * Gap 4 = 1.5rem (24px) + * + * Gap 5 = 3rem (48px) + */ +export const AllGaps: Story = { + render: () => ( + + + Item 1 + Item 2 + + + Item 1 + Item 2 + + + Item 1 + Item 2 + + + Item 1 + Item 2 + + + Item 1 + Item 2 + + + Item 1 + Item 2 + + + ) +} + +/** + * Use `as` prop to render the Stack as a different element. + * If you inspect the rendered HTML, you will see that the Stack is rendered as a section element. + */ +export const StackAsSection: Story = { + render: () => ( + +
Item 1
+
Item 2
+
Item 3
+
+ ) +} diff --git a/packages/design-system/tests/component/stack/Stack.spec.tsx b/packages/design-system/tests/component/stack/Stack.spec.tsx new file mode 100644 index 000000000..b32e65882 --- /dev/null +++ b/packages/design-system/tests/component/stack/Stack.spec.tsx @@ -0,0 +1,38 @@ +import { Stack } from '../../../src/lib/components/stack/Stack' + +describe('Stack', () => { + it('renders vertically by default', () => { + cy.mount( + +
Item 1
+
Item 2
+
Item 3
+
+ ) + + cy.findByTestId('vertical-by-default').should('have.css', 'flex-direction', 'column') + }) + + it('renders horizontally when direction="horizontal"', () => { + cy.mount( + +
Item 1
+
Item 2
+
Item 3
+
+ ) + + cy.findByTestId('horizontal').should('have.css', 'flex-direction', 'row') + }) + + it('renders with the correct gap', () => { + cy.mount( + +
Item 1
+
Item 2
+
+ ) + + cy.findByTestId('gap').should('have.css', 'gap', '24px') + }) +}) diff --git a/public/locales/en/collection.json b/public/locales/en/collection.json index 0f9fa0011..79a4473af 100644 --- a/public/locales/en/collection.json +++ b/public/locales/en/collection.json @@ -1,6 +1,7 @@ { "noDatasetsMessage": { - "authenticated": "This dataverse currently has no datasets. You can add to it by using the Add Data button on this page.", - "anonymous": "This dataverse currently has no datasets. Please <1>log in to see if you are able to add to it." - } + "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." } diff --git a/public/locales/en/createCollection.json b/public/locales/en/createCollection.json new file mode 100644 index 000000000..e561c89c7 --- /dev/null +++ b/public/locales/en/createCollection.json @@ -0,0 +1,63 @@ +{ + "pageTitle": "Create Collection", + "fields": { + "hostCollection": { + "label": "Host Collection", + "description": "The collection which contains this data.", + "required": "Host Collection is required" + }, + "name": { + "label": "Collection Name", + "description": "The project, department, university, professor, or journal this collection will contain data for.", + "required": "Collection Name is required" + }, + "affiliation": { + "label": "Affiliation", + "description": "The organization with which this collection is affiliated." + }, + "alias": { + "label": "Identifier", + "description": "Short name used for the URL of this collection.", + "required": "Identifier is required", + "invalid": { + "format": "Identifier is not valid. Valid characters are a-Z, 0-9, '_', and '-'.", + "maxLength": "Identifier must be at most {{maxLength}} characters." + }, + "suggestion": "Psst... try this" + }, + "storage": { + "label": "Storage", + "description": "A storage service to be used for datasets in this collection." + }, + "type": { + "label": "Category", + "description": "The type that most closely reflects this collection.", + "required": "Category is required" + }, + "description": { + "label": "Description", + "description": "A summary describing the purpose, nature or scope of this collection." + }, + "contacts": { + "label": "Email", + "description": "The email address(es) of the contact(s) for the collection.", + "required": "Email is required", + "invalid": "Email is not a valid email" + }, + "metadataFields": { + "label": "Metadata Fields", + "helperText": "Choose the metadata fields to use in dataset templates and when adding a dataset to this dataverse." + }, + "browseSearchFacets": { + "label": "Browse/Search Facets", + "helperText": "Choose the metadata fields to use as facets for browsing datasets and dataverses in this dataverse." + } + }, + "submitStatus": { + "success": "Collection created successfully." + }, + "formButtons": { + "save": "Create Collection", + "cancel": "Cancel" + } +} diff --git a/public/locales/en/shared.json b/public/locales/en/shared.json new file mode 100644 index 000000000..2d2caa7dc --- /dev/null +++ b/public/locales/en/shared.json @@ -0,0 +1,3 @@ +{ + "asterisksIndicateRequiredFields": "Asterisks indicate required fields" +} diff --git a/src/Router.tsx b/src/Router.tsx index f44267638..f6a6426f3 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -9,6 +9,7 @@ import { CollectionFactory } from './sections/collection/CollectionFactory' import { UploadDatasetFilesFactory } from './sections/upload-dataset-files/UploadDatasetFilesFactory' import { EditDatasetMetadataFactory } from './sections/edit-dataset-metadata/EditDatasetMetadataFactory' import { DatasetNonNumericVersion } from './dataset/domain/models/Dataset' +import { CreateCollectionFactory } from './sections/create-collection/CreateCollectionFactory' const router = createBrowserRouter( [ @@ -25,6 +26,10 @@ const router = createBrowserRouter( path: Route.COLLECTIONS, element: CollectionFactory.create() }, + { + path: Route.CREATE_COLLECTION, + element: CreateCollectionFactory.create() + }, { path: Route.DATASETS, element: DatasetFactory.create() diff --git a/src/assets/variables.scss b/src/assets/variables.scss index 3d244d437..45b6480c6 100644 --- a/src/assets/variables.scss +++ b/src/assets/variables.scss @@ -1,2 +1,3 @@ $footer-height: 256px; -$body-available-height: calc(100vh - $footer-height); \ No newline at end of file +$body-available-height: calc(100vh - $footer-height); +$header-aproximate-height: 62px; diff --git a/src/collection/domain/repositories/CollectionRepository.ts b/src/collection/domain/repositories/CollectionRepository.ts index 3f0fc8406..0ad3641c9 100644 --- a/src/collection/domain/repositories/CollectionRepository.ts +++ b/src/collection/domain/repositories/CollectionRepository.ts @@ -1,5 +1,7 @@ import { Collection } from '../models/Collection' +import { CollectionDTO } from '../useCases/DTOs/CollectionDTO' export interface CollectionRepository { getById: (id: string) => Promise + create(collection: CollectionDTO, hostCollection?: string): Promise } diff --git a/src/collection/domain/useCases/DTOs/CollectionDTO.ts b/src/collection/domain/useCases/DTOs/CollectionDTO.ts new file mode 100644 index 000000000..1e32dc172 --- /dev/null +++ b/src/collection/domain/useCases/DTOs/CollectionDTO.ts @@ -0,0 +1,65 @@ +export interface CollectionDTO { + alias: string + name: string + contacts: string[] + type: CollectionType +} + +export enum CollectionType { + RESEARCHERS = 'RESEARCHERS', + RESEARCH_PROJECTS = 'RESEARCH_PROJECTS', + JOURNALS = 'JOURNALS', + ORGANIZATIONS_INSTITUTIONS = 'ORGANIZATIONS_INSTITUTIONS', + TEACHING_COURSES = 'TEACHING_COURSES', + UNCATEGORIZED = 'UNCATEGORIZED', + LABORATORY = 'LABORATORY', + RESEARCH_GROUP = 'RESEARCH_GROUP', + DEPARTMENT = 'DEPARTMENT' +} + +export const collectionTypeOptions = { + RESEARCHERS: { + label: 'Researchers', + value: CollectionType.RESEARCHERS + }, + RESEARCH_PROJECTS: { + label: 'Research Projects', + value: CollectionType.RESEARCH_PROJECTS + }, + JOURNALS: { + label: 'Journals', + value: CollectionType.JOURNALS + }, + ORGANIZATIONS_INSTITUTIONS: { + label: 'Organizations/Institutions', + value: CollectionType.ORGANIZATIONS_INSTITUTIONS + }, + TEACHING_COURSES: { + label: 'Teaching Courses', + value: CollectionType.TEACHING_COURSES + }, + UNCATEGORIZED: { + label: 'Uncategorized', + value: CollectionType.UNCATEGORIZED + }, + LABORATORY: { + label: 'Laboratory', + value: CollectionType.LABORATORY + }, + RESEARCH_GROUP: { + label: 'Research Group', + value: CollectionType.RESEARCH_GROUP + }, + DEPARTMENT: { + label: 'Department', + value: CollectionType.DEPARTMENT + } +} as const + +export const collectionStorageOptions = { + LOCAL_DEFAULT: 'Local (Default)', + LOCAL: 'Local' +} as const + +export type CollectionStorage = + (typeof collectionStorageOptions)[keyof typeof collectionStorageOptions] diff --git a/src/collection/domain/useCases/createCollection.ts b/src/collection/domain/useCases/createCollection.ts new file mode 100644 index 000000000..68af8ba32 --- /dev/null +++ b/src/collection/domain/useCases/createCollection.ts @@ -0,0 +1,13 @@ +import { WriteError } from '@iqss/dataverse-client-javascript' +import { CollectionRepository } from '../repositories/CollectionRepository' +import { CollectionDTO } from './DTOs/CollectionDTO' + +export function createCollection( + collectionRepository: CollectionRepository, + collection: CollectionDTO, + hostCollection?: string +): Promise { + return collectionRepository.create(collection, hostCollection).catch((error: WriteError) => { + throw error + }) +} diff --git a/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts b/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts index c8cb5ea5a..367ff7e01 100644 --- a/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts +++ b/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts @@ -1,7 +1,8 @@ import { CollectionRepository } from '../../domain/repositories/CollectionRepository' import { Collection } from '../../domain/models/Collection' -import { getCollection } from '@iqss/dataverse-client-javascript' +import { createCollection, getCollection } from '@iqss/dataverse-client-javascript' import { JSCollectionMapper } from '../mappers/JSCollectionMapper' +import { CollectionDTO } from '../../domain/useCases/DTOs/CollectionDTO' export class CollectionJSDataverseRepository implements CollectionRepository { getById(id: string): Promise { @@ -9,4 +10,10 @@ export class CollectionJSDataverseRepository implements CollectionRepository { .execute(id) .then((jsCollection) => JSCollectionMapper.toCollection(jsCollection)) } + + create(collection: CollectionDTO, hostCollection?: string): Promise { + return createCollection + .execute(collection, hostCollection) + .then((newCollectionIdentifier) => newCollectionIdentifier) + } } diff --git a/src/sections/Route.enum.ts b/src/sections/Route.enum.ts index ab6c4c0d4..56a3ad867 100644 --- a/src/sections/Route.enum.ts +++ b/src/sections/Route.enum.ts @@ -8,5 +8,11 @@ export enum Route { UPLOAD_DATASET_FILES = '/datasets/upload-files', EDIT_DATASET_METADATA = '/datasets/edit-metadata', FILES = '/files', - COLLECTIONS = '/collections' + COLLECTIONS = '/collections', + CREATE_COLLECTION = '/collections/:ownerCollectionId/create' +} + +export const RouteWithParams = { + CREATE_COLLECTION: (ownerCollectionId?: string) => + `/collections/${ownerCollectionId ?? 'root'}/create` } diff --git a/src/sections/collection/Collection.tsx b/src/sections/collection/Collection.tsx index 473c2bea4..282388dac 100644 --- a/src/sections/collection/Collection.tsx +++ b/src/sections/collection/Collection.tsx @@ -1,4 +1,4 @@ -import { Col, Row } from '@iqss/dataverse-design-system' +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' @@ -12,11 +12,14 @@ import { CollectionRepository } from '../../collection/domain/repositories/Colle import { PageNotFound } from '../page-not-found/PageNotFound' import { CollectionSkeleton } from './CollectionSkeleton' import { CollectionInfo } from './CollectionInfo' +import { Trans, useTranslation } from 'react-i18next' +import { useScrollTop } from '../../shared/hooks/useScrollTop' interface CollectionProps { repository: CollectionRepository datasetRepository: DatasetRepository id: string + created: boolean page?: number infiniteScrollEnabled?: boolean } @@ -25,11 +28,14 @@ export function Collection({ repository, id, datasetRepository, + created, page, infiniteScrollEnabled = false }: CollectionProps) { + useScrollTop() const { user } = useSession() const { collection, isLoading } = useCollection(repository, id) + const { t } = useTranslation('collection') if (!isLoading && !collection) { return @@ -44,6 +50,23 @@ export function Collection({ <> + {created && ( + + + ) + }} + /> + + )} {user && (
diff --git a/src/sections/collection/CollectionFactory.tsx b/src/sections/collection/CollectionFactory.tsx index a23875bcc..6654a6a05 100644 --- a/src/sections/collection/CollectionFactory.tsx +++ b/src/sections/collection/CollectionFactory.tsx @@ -1,7 +1,7 @@ import { ReactElement } from 'react' import { Collection } from './Collection' import { DatasetJSDataverseRepository } from '../../dataset/infrastructure/repositories/DatasetJSDataverseRepository' -import { useSearchParams } from 'react-router-dom' +import { useLocation, useSearchParams } from 'react-router-dom' import { CollectionJSDataverseRepository } from '../../collection/infrastructure/repositories/CollectionJSDataverseRepository' import { INFINITE_SCROLL_ENABLED } from './config' @@ -15,8 +15,11 @@ export class CollectionFactory { function CollectionWithSearchParams() { const [searchParams] = useSearchParams() + const location = useLocation() const page = searchParams.get('page') ? parseInt(searchParams.get('page') as string) : undefined const id = searchParams.get('id') ? (searchParams.get('id') as string) : 'root' + const state = location.state as { created: boolean } | undefined + const created = state?.created ?? false return ( ) diff --git a/src/sections/collection/datasets-list/NoDatasetsMessage.tsx b/src/sections/collection/datasets-list/NoDatasetsMessage.tsx index 60115ed19..345c56e14 100644 --- a/src/sections/collection/datasets-list/NoDatasetsMessage.tsx +++ b/src/sections/collection/datasets-list/NoDatasetsMessage.tsx @@ -14,7 +14,7 @@ export function NoDatasetsMessage() { ) : (

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

diff --git a/src/sections/create-collection/CreateCollection.tsx b/src/sections/create-collection/CreateCollection.tsx new file mode 100644 index 000000000..9db15db0c --- /dev/null +++ b/src/sections/create-collection/CreateCollection.tsx @@ -0,0 +1,78 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useCollection } from '../collection/useCollection' +import { CollectionRepository } from '../../collection/domain/repositories/CollectionRepository' +import { useLoading } from '../loading/LoadingContext' +import { useSession } from '../session/SessionContext' +import { RequiredFieldText } from '../shared/form/RequiredFieldText/RequiredFieldText' +import { BreadcrumbsGenerator } from '../shared/hierarchy/BreadcrumbsGenerator' +import { CollectionForm, CollectionFormData } from './collection-form/CollectionForm' +import { SeparationLine } from '../shared/layout/SeparationLine/SeparationLine' +import { PageNotFound } from '../page-not-found/PageNotFound' +import { CreateCollectionSkeleton } from './CreateCollectionSkeleton' + +interface CreateCollectionProps { + ownerCollectionId: string + collectionRepository: CollectionRepository +} + +export function CreateCollection({ + ownerCollectionId, + collectionRepository +}: CreateCollectionProps) { + const { t } = useTranslation('createCollection') + const { isLoading, setIsLoading } = useLoading() + const { user } = useSession() + + const { collection, isLoading: isLoadingCollection } = useCollection( + collectionRepository, + ownerCollectionId + ) + + useEffect(() => { + if (!isLoadingCollection) { + setIsLoading(false) + } + }, [isLoading, isLoadingCollection, setIsLoading]) + + if (!isLoadingCollection && !collection) { + return + } + + if (isLoadingCollection || !collection) { + return + } + + const formDefaultValues: CollectionFormData = { + hostCollection: collection.name, + name: user?.displayName ? `${user?.displayName} Collection` : '', + alias: '', + type: '', + contacts: [{ value: user?.email ?? '' }], + affiliation: user?.affiliation ?? '', + storage: 'Local (Default)', + description: '' + } + + return ( +
+ +
+

{t('pageTitle')}

+
+ + + + + +
+ ) +} diff --git a/src/sections/create-collection/CreateCollectionFactory.tsx b/src/sections/create-collection/CreateCollectionFactory.tsx new file mode 100644 index 000000000..7b14b50f6 --- /dev/null +++ b/src/sections/create-collection/CreateCollectionFactory.tsx @@ -0,0 +1,26 @@ +import { ReactElement } from 'react' +import { useParams } from 'react-router-dom' +import { CollectionJSDataverseRepository } from '../../collection/infrastructure/repositories/CollectionJSDataverseRepository' +import { CreateCollection } from './CreateCollection' + +const collectionRepository = new CollectionJSDataverseRepository() + +export class CreateCollectionFactory { + static create(): ReactElement { + return + } +} + +function CreateCollectionWithParams() { + const { ownerCollectionId = 'root' } = useParams<{ ownerCollectionId: string }>() + + // TODO:ME What roles can create a collection, what checks to do? + + return ( + + ) +} diff --git a/src/sections/create-collection/CreateCollectionSkeleton.tsx b/src/sections/create-collection/CreateCollectionSkeleton.tsx new file mode 100644 index 000000000..6dfe6a60e --- /dev/null +++ b/src/sections/create-collection/CreateCollectionSkeleton.tsx @@ -0,0 +1,81 @@ +import Skeleton, { SkeletonTheme } from 'react-loading-skeleton' +import { Col, Row, Stack } from '@iqss/dataverse-design-system' +import { BreadcrumbsSkeleton } from '../shared/hierarchy/BreadcrumbsSkeleton' +import 'react-loading-skeleton/dist/skeleton.css' +import { SeparationLine } from '../shared/layout/SeparationLine/SeparationLine' + +export const CreateCollectionSkeleton = () => ( + +
+ + + + + + + + {/* Top fields section skeleton */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+) diff --git a/src/sections/create-collection/collection-form/CollectionForm.module.scss b/src/sections/create-collection/collection-form/CollectionForm.module.scss new file mode 100644 index 000000000..b0dcb21cb --- /dev/null +++ b/src/sections/create-collection/collection-form/CollectionForm.module.scss @@ -0,0 +1,58 @@ +@import 'src/assets/variables'; + +.form-container { + scroll-margin-top: calc(#{$header-aproximate-height} + 1rem); +} + +.identifier-field-group { + :global .input-group-text { + font-size: 14px; + } + + .suggestion-container { + display: flex; + gap: 0.5rem; + align-items: center; + width: 100%; + margin-top: -0.75rem; + margin-bottom: 1rem; + padding-top: 0.25rem; + + :global .form-text { + margin-top: 0; + } + + .apply-suggestion-btn { + display: grid; + place-items: center; + padding: 3px; + border-radius: 50%; + } + } +} + +.contact-row { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } +} + +.dynamic-fields-button-container { + margin-top: calc(24px + 8px); // 24px text label height & 8px its margin bottom + + &.on-composed-multiple { + @media screen and (max-width: 575px) { + margin-top: 0; + } + } + + &.on-primitive-multiple { + margin-top: 0; + + @media screen and (max-width: 575px) { + margin-top: 1rem; + } + } +} diff --git a/src/sections/create-collection/collection-form/CollectionForm.tsx b/src/sections/create-collection/collection-form/CollectionForm.tsx new file mode 100644 index 000000000..151372002 --- /dev/null +++ b/src/sections/create-collection/collection-form/CollectionForm.tsx @@ -0,0 +1,142 @@ +import { MouseEvent, useMemo, useRef } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' +import { Alert, Button, Card, Stack } from '@iqss/dataverse-design-system' +import { CollectionRepository } from '../../../collection/domain/repositories/CollectionRepository' +import { + CollectionType, + CollectionStorage +} from '../../../collection/domain/useCases/DTOs/CollectionDTO' +import { SeparationLine } from '../../shared/layout/SeparationLine/SeparationLine' +import { SubmissionStatus, useSubmitCollection } from './useSubmitCollection' +import { TopFieldsSection } from './top-fields-section/TopFieldsSection' +import { MetadataFieldsSection } from './metadata-fields-section/MetadataFieldsSection' +import { BrowseSearchFacetsSection } from './browse-search-facets-section/BrowseSearchFacetsSection' +import styles from './CollectionForm.module.scss' + +export interface CollectionFormProps { + collectionRepository: CollectionRepository + ownerCollectionId: string + defaultValues: CollectionFormData +} + +export type CollectionFormData = { + hostCollection: string + name: string + affiliation: string + alias: string + storage: CollectionStorage + type: CollectionType | '' + description: string + contacts: { value: string }[] +} +// On the submit function callback, type is CollectionType as type field is required and wont never be "" +export type CollectionFormValuesOnSubmit = Omit & { + type: CollectionType +} + +export const CollectionForm = ({ + collectionRepository, + ownerCollectionId, + defaultValues +}: CollectionFormProps) => { + const formContainerRef = useRef(null) + const { t } = useTranslation('createCollection') + const navigate = useNavigate() + + const { submitForm, submitError, submissionStatus } = useSubmitCollection( + collectionRepository, + ownerCollectionId, + onSubmittedCollectionError + ) + + const form = useForm({ + mode: 'onChange', + defaultValues + }) + + const { formState } = form + + const preventEnterSubmit = (e: React.KeyboardEvent) => { + // When pressing Enter, only submit the form if the user is focused on the submit button itself + if (e.key !== 'Enter') return + + const isButton = e.target instanceof HTMLButtonElement + const isButtonTypeSubmit = isButton ? (e.target as HTMLButtonElement).type === 'submit' : false + + if (!isButton && !isButtonTypeSubmit) e.preventDefault() + } + + function onSubmittedCollectionError() { + if (formContainerRef.current) { + formContainerRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + } + + const handleCancel = (event: MouseEvent) => { + event.preventDefault() + navigate(-1) + } + + const disableSubmitButton = useMemo(() => { + return submissionStatus === SubmissionStatus.IsSubmitting || !formState.isDirty + }, [submissionStatus, formState.isDirty]) + + // TODO:ME Apply max width to container + return ( +
+ {submissionStatus === SubmissionStatus.Errored && ( + + {submitError} + + )} + {submissionStatus === SubmissionStatus.SubmitComplete && ( + + {t('submitStatus.success')} + + )} + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ ) +} diff --git a/src/sections/create-collection/collection-form/browse-search-facets-section/BrowseSearchFacetsSection.tsx b/src/sections/create-collection/collection-form/browse-search-facets-section/BrowseSearchFacetsSection.tsx new file mode 100644 index 000000000..9e131e899 --- /dev/null +++ b/src/sections/create-collection/collection-form/browse-search-facets-section/BrowseSearchFacetsSection.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from 'react-i18next' +import { Alert, Col, Form, Row } from '@iqss/dataverse-design-system' + +export const BrowseSearchFacetsSection = () => { + const { t } = useTranslation('createCollection') + + return ( + + + {t('fields.browseSearchFacets.label')} + + + {t('fields.browseSearchFacets.helperText')} + + + Work in progress + + + + + ) +} diff --git a/src/sections/create-collection/collection-form/metadata-fields-section/MetadataFieldsSection.tsx b/src/sections/create-collection/collection-form/metadata-fields-section/MetadataFieldsSection.tsx new file mode 100644 index 000000000..8395ea535 --- /dev/null +++ b/src/sections/create-collection/collection-form/metadata-fields-section/MetadataFieldsSection.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from 'react-i18next' +import { Alert, Col, Form, Row } from '@iqss/dataverse-design-system' + +export const MetadataFieldsSection = () => { + const { t } = useTranslation('createCollection') + + return ( + + + {t('fields.metadataFields.label')} + + + {t('fields.metadataFields.helperText')} + + + Work in progress + + + + + ) +} diff --git a/src/sections/create-collection/collection-form/top-fields-section/ContactsField.tsx b/src/sections/create-collection/collection-form/top-fields-section/ContactsField.tsx new file mode 100644 index 000000000..3c7c93744 --- /dev/null +++ b/src/sections/create-collection/collection-form/top-fields-section/ContactsField.tsx @@ -0,0 +1,142 @@ +import { Col, Form, Row } from '@iqss/dataverse-design-system' +import { useCallback, useMemo } from 'react' +import { Controller, UseControllerProps, useFieldArray, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import cn from 'classnames' +import styles from '../CollectionForm.module.scss' +// TODO:ME This imports are only used in the DynamicFieldsButtons component (temporal) +import { MouseEvent } from 'react' +import { Button, Tooltip } from '@iqss/dataverse-design-system' +import { Dash, Plus } from 'react-bootstrap-icons' + +interface ContactsFieldProps { + rules: UseControllerProps['rules'] +} + +export const ContactsField = ({ rules }: ContactsFieldProps) => { + const { t } = useTranslation('createCollection') + const { control } = useFormContext() + + const { + fields: fieldsArray, + insert, + remove + } = useFieldArray({ + name: 'contacts', + control: control + }) + + const builtFieldNameWithIndex = useCallback((fieldIndex: number) => { + return `contacts.${fieldIndex}.value` + }, []) + + // We give the label the same ID as the first field, so that clicking on the label focuses the first field only + const controlID = useMemo(() => builtFieldNameWithIndex(0), [builtFieldNameWithIndex]) + + const handleOnAddField = (index: number) => { + insert( + index + 1, + { value: '' }, + { + shouldFocus: true, + focusName: builtFieldNameWithIndex(index + 1) + } + ) + } + + const handleOnRemoveField = (index: number) => remove(index) + + return ( + + + {t('fields.contacts.label')} + + + {(fieldsArray as { id: string; value: string }[]).map((field, index) => ( + + ( + <> + + + + {error?.message} + + + handleOnAddField(index)} + onRemoveButtonClick={() => handleOnRemoveField(index)} + originalField={index === 0} + /> + + + )} + /> + + ))} + + ) +} + +// TODO:ME Create reusable DynamicFieldsButtons component inside shared Form when merged with issue 422 +// TODO:ME This component here is temporal, it will be moved to the shared form folder +interface DynamicFieldsButtonsProps { + fieldName: string + originalField?: boolean + onAddButtonClick: (event: MouseEvent) => void + onRemoveButtonClick: (event: MouseEvent) => void +} + +const DynamicFieldsButtons = ({ + fieldName, + originalField, + onAddButtonClick, + onRemoveButtonClick +}: DynamicFieldsButtonsProps) => { + return ( +
+ + + + {!originalField && ( + + + + )} +
+ ) +} diff --git a/src/sections/create-collection/collection-form/top-fields-section/IdentifierField.tsx b/src/sections/create-collection/collection-form/top-fields-section/IdentifierField.tsx new file mode 100644 index 000000000..3db7f0049 --- /dev/null +++ b/src/sections/create-collection/collection-form/top-fields-section/IdentifierField.tsx @@ -0,0 +1,84 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { Controller, UseControllerProps, useFormContext, useWatch } from 'react-hook-form' +import { Button, Col, Form } from '@iqss/dataverse-design-system' +import { CheckCircle } from 'react-bootstrap-icons' +import styles from '../CollectionForm.module.scss' + +export const collectionNameToAlias = (name: string) => { + if (!name) return '' + + return name + .toLowerCase() // Convert to lowercase + .trim() // Remove leading/trailing whitespace + .replace(/[^\w\s-]/g, '') // Remove non-alphanumeric characters except for spaces and hyphens + .replace(/[\s_-]+/g, '-') // Replace spaces and underscores with hyphens + .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens + .slice(0, 60) // Limit to 60 characters +} + +interface IdentifierFieldProps { + rules: UseControllerProps['rules'] +} + +export const IdentifierField = ({ rules }: IdentifierFieldProps) => { + const { t } = useTranslation('createCollection') + const { control, setValue } = useFormContext() + const nameFieldValue = useWatch({ name: 'name' }) as string + + const aliasSuggestion = useMemo(() => collectionNameToAlias(nameFieldValue), [nameFieldValue]) + + const applyAliasSuggestion = () => + setValue('alias', aliasSuggestion, { shouldValidate: true, shouldDirty: true }) + + return ( + + + {t('fields.alias.label')} + + + ( + + + + {window.location.origin}/spa/collections/?id= + + + {error?.message} + + + {aliasSuggestion !== '' && value !== aliasSuggestion && ( +
+ + {t('fields.alias.suggestion')} 👉 {aliasSuggestion} + + +
+ )} + + )} + /> +
+ ) +} diff --git a/src/sections/create-collection/collection-form/top-fields-section/TopFieldsSection.tsx b/src/sections/create-collection/collection-form/top-fields-section/TopFieldsSection.tsx new file mode 100644 index 000000000..2ae6ee826 --- /dev/null +++ b/src/sections/create-collection/collection-form/top-fields-section/TopFieldsSection.tsx @@ -0,0 +1,224 @@ +import { Controller, UseControllerProps, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { Col, Form, Row } from '@iqss/dataverse-design-system' +import { + collectionTypeOptions, + collectionStorageOptions +} from '../../../../collection/domain/useCases/DTOs/CollectionDTO' +import { Validator } from '../../../../shared/helpers/Validator' +import { ContactsField } from './ContactsField' +import { IdentifierField } from './IdentifierField' + +export const TopFieldsSection = () => { + const { t } = useTranslation('createCollection') + const { control } = useFormContext() + + const hostCollectionRules: UseControllerProps['rules'] = { + required: t('fields.hostCollection.required') + } + + const nameRules: UseControllerProps['rules'] = { + required: t('fields.name.required') + } + + const aliasRules: UseControllerProps['rules'] = { + required: t('fields.alias.required'), + maxLength: { + value: 60, + message: t('fields.alias.invalid.maxLength', { maxLength: 60 }) + }, + validate: (value: string) => { + if (!Validator.isValidIdentifier(value)) { + return t('fields.alias.invalid.format') + } + return true + } + } + + const typeRules: UseControllerProps['rules'] = { required: t('fields.type.required') } + + const contactsRules: UseControllerProps['rules'] = { + required: t('fields.contacts.required'), + validate: (value: string) => { + if (!Validator.isValidEmail(value)) { + return t('fields.contacts.invalid') + } + return true + } + } + + return ( +
+ {/* Host Collection */} + + + + {t('fields.hostCollection.label')} + + ( + + + {error?.message} + + )} + /> + + + + {/* Name & Affiliation */} + + + + {t('fields.name.label')} + + ( + + + {error?.message} + + )} + /> + + + + + {t('fields.affiliation.label')} + + ( + + + {error?.message} + + )} + /> + + + + {/* Identifier(alias) & Storage */} + + + + + + {t('fields.storage.label')} + + ( + + + {/* TODO:ME What are this options? do they come from a configuration? */} + + {Object.values(collectionStorageOptions).map((type) => ( + + ))} + + {error?.message} + + )} + /> + + + + {/* Category (type) & Email (contacts) & Description */} + + + + + {t('fields.type.label')} + + ( + + + + {Object.values(collectionTypeOptions).map(({ label, value }) => ( + + ))} + + {error?.message} + + )} + /> + + + + + + + + {t('fields.description.label')} + + ( + + + {error?.message} + + )} + /> + + +
+ ) +} diff --git a/src/sections/create-collection/collection-form/useSubmitCollection.ts b/src/sections/create-collection/collection-form/useSubmitCollection.ts new file mode 100644 index 000000000..41c6a183c --- /dev/null +++ b/src/sections/create-collection/collection-form/useSubmitCollection.ts @@ -0,0 +1,84 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { WriteError } from '@iqss/dataverse-client-javascript' +import { createCollection } from '../../../collection/domain/useCases/createCollection' +import { CollectionRepository } from '../../../collection/domain/repositories/CollectionRepository' +import { CollectionDTO } from '../../../collection/domain/useCases/DTOs/CollectionDTO' +import { CollectionFormData, CollectionFormValuesOnSubmit } from './CollectionForm' +import { Route } from '../../Route.enum' +import { JSDataverseWriteErrorHandler } from '../../../shared/helpers/JSDataverseWriteErrorHandler' + +export enum SubmissionStatus { + NotSubmitted = 'NotSubmitted', + IsSubmitting = 'IsSubmitting', + SubmitComplete = 'SubmitComplete', + Errored = 'Errored' +} + +type UseSubmitCollectionReturnType = + | { + submissionStatus: + | SubmissionStatus.NotSubmitted + | SubmissionStatus.IsSubmitting + | SubmissionStatus.SubmitComplete + submitForm: (formData: CollectionFormData) => void + submitError: null + } + | { + submissionStatus: SubmissionStatus.Errored + submitForm: (formData: CollectionFormData) => void + submitError: string + } + +export function useSubmitCollection( + collectionRepository: CollectionRepository, + ownerCollectionId: string, + onSubmitErrorCallback: () => void +): UseSubmitCollectionReturnType { + const navigate = useNavigate() + + const [submissionStatus, setSubmissionStatus] = useState( + SubmissionStatus.NotSubmitted + ) + const [submitError, setSubmitError] = useState(null) + + const submitForm = (formData: CollectionFormValuesOnSubmit): void => { + setSubmissionStatus(SubmissionStatus.IsSubmitting) + + const newCollection: CollectionDTO = { + name: formData.name, + alias: formData.alias, + type: formData.type, + contacts: formData.contacts.map((contact) => contact.value) + } + + // TODO: We can't send the hostCollection name, but we should send the hostCollection alias + // So in a next iteration we should get the hostCollection alias from the hostCollection name selected + + createCollection(collectionRepository, newCollection, ownerCollectionId) + .then(() => { + setSubmitError(null) + setSubmissionStatus(SubmissionStatus.SubmitComplete) + + navigate(`${Route.COLLECTIONS}?id=${newCollection.alias}`, { + state: { created: true } + }) + return + }) + .catch((err: WriteError) => { + const error = new JSDataverseWriteErrorHandler(err) + const formattedError = error.getReasonWithoutStatusCode() ?? error.getErrorMessage() + + setSubmitError(formattedError) + setSubmissionStatus(SubmissionStatus.Errored) + + onSubmitErrorCallback() + }) + } + + return { + submissionStatus, + submitForm, + submitError + } as UseSubmitCollectionReturnType +} diff --git a/src/sections/layout/header/Header.tsx b/src/sections/layout/header/Header.tsx index e65ae69d5..fcb506e3e 100644 --- a/src/sections/layout/header/Header.tsx +++ b/src/sections/layout/header/Header.tsx @@ -1,9 +1,9 @@ import dataverse_logo from '../../../assets/dataverse_brand_icon.svg' import { useTranslation } from 'react-i18next' import { Navbar } from '@iqss/dataverse-design-system' -import { Route } from '../../Route.enum' +import { Route, RouteWithParams } from '../../Route.enum' import { useSession } from '../../session/SessionContext' -import { useNavigate } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import { BASE_URL } from '../../../config' const currentPage = 0 @@ -18,6 +18,8 @@ export function Header() { }) } + const createCollectionRoute = RouteWithParams.CREATE_COLLECTION() + return ( - + {t('navigation.newCollection')} - + {t('navigation.newDataset')} @@ -52,4 +54,3 @@ export function Header() { } // TODO: AddData Dropdown item needs proper permissions checking, see Spike #318 -// TODO: Add page for "New Collection", see Issue #319 diff --git a/src/sections/shared/add-data-actions/AddDataActionsButton.tsx b/src/sections/shared/add-data-actions/AddDataActionsButton.tsx index 701651170..6c4366726 100644 --- a/src/sections/shared/add-data-actions/AddDataActionsButton.tsx +++ b/src/sections/shared/add-data-actions/AddDataActionsButton.tsx @@ -3,7 +3,7 @@ import { Dropdown } from 'react-bootstrap' import { Link } from 'react-router-dom' import { DropdownButton } from '@iqss/dataverse-design-system' import { PlusLg } from 'react-bootstrap-icons' -import { Route } from '../../Route.enum' +import { Route, RouteWithParams } from '../../Route.enum' import styles from './AddDataActionsButton.module.scss' interface AddDataActionsButtonProps { @@ -17,16 +17,18 @@ export default function AddDataActionsButton({ collectionId }: AddDataActionsBut ? `${Route.CREATE_DATASET}?collectionId=${collectionId}` : Route.CREATE_DATASET + const createCollectionRoute = RouteWithParams.CREATE_COLLECTION(collectionId) + return ( }> - + {t('navigation.newCollection')} - + {t('navigation.newDataset')} diff --git a/src/sections/shared/form/RequiredFieldText/RequiredFieldText.tsx b/src/sections/shared/form/RequiredFieldText/RequiredFieldText.tsx index f1e3376ee..9c218aca6 100644 --- a/src/sections/shared/form/RequiredFieldText/RequiredFieldText.tsx +++ b/src/sections/shared/form/RequiredFieldText/RequiredFieldText.tsx @@ -2,11 +2,12 @@ import { RequiredInputSymbol } from '@iqss/dataverse-design-system' import { useTranslation } from 'react-i18next' export function RequiredFieldText() { - const { t } = useTranslation('datasetMetadataForm') + const { t } = useTranslation('shared') + return (

- {t('requiredFields')} + {t('asterisksIndicateRequiredFields')}

) } diff --git a/src/shared/helpers/JSDataverseWriteErrorHandler.ts b/src/shared/helpers/JSDataverseWriteErrorHandler.ts new file mode 100644 index 000000000..c0e1263a9 --- /dev/null +++ b/src/shared/helpers/JSDataverseWriteErrorHandler.ts @@ -0,0 +1,36 @@ +import { WriteError } from '@iqss/dataverse-client-javascript' + +export class JSDataverseWriteErrorHandler { + private error: WriteError + + constructor(error: WriteError) { + this.error = error + } + + public getErrorMessage(): string { + return this.error.message + } + + public getReason(): string | null { + // Reason comes after "Reason was: " + const reasonMatch = this.error.message.match(/Reason was: (.*)/) + return reasonMatch ? reasonMatch[1] : null + } + + public getStatusCode(): number | null { + // Status code comes inside [] brackets + const statusCodeMatch = this.error.message.match(/\[(\d+)\]/) + return statusCodeMatch ? parseInt(statusCodeMatch[1]) : null + } + + public getReasonWithoutStatusCode(): string | null { + const reason = this.getReason() + if (!reason) return null + + const statusCode = this.getStatusCode() + if (statusCode === null) return reason + + // Remove status code from reason + return reason.replace(`[${statusCode}]`, '').trim() + } +} diff --git a/src/shared/helpers/Validator.ts b/src/shared/helpers/Validator.ts new file mode 100644 index 000000000..bdbc8e6fb --- /dev/null +++ b/src/shared/helpers/Validator.ts @@ -0,0 +1,12 @@ +export class Validator { + static isValidEmail(email: string): boolean { + const EMAIL_REGEX = + /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])/ + return EMAIL_REGEX.test(email) + } + + static isValidIdentifier(input: string): boolean { + const IDENTIFIER_REGEX = /^[a-zA-Z0-9_-]+$/ + return IDENTIFIER_REGEX.test(input) + } +} diff --git a/src/shared/hooks/useScrollTop.ts b/src/shared/hooks/useScrollTop.ts new file mode 100644 index 000000000..9c5494140 --- /dev/null +++ b/src/shared/hooks/useScrollTop.ts @@ -0,0 +1,7 @@ +import { useEffect } from 'react' + +export const useScrollTop = () => { + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }) + }, []) +} diff --git a/src/stories/collection/Collection.stories.tsx b/src/stories/collection/Collection.stories.tsx index c8685f45a..92d541d54 100644 --- a/src/stories/collection/Collection.stories.tsx +++ b/src/stories/collection/Collection.stories.tsx @@ -29,6 +29,7 @@ export const Default: Story = { repository={new CollectionMockRepository()} datasetRepository={new DatasetMockRepository()} id="collection" + created={false} /> ) } @@ -40,6 +41,7 @@ export const InfiniteScrollingEnabled: Story = { datasetRepository={new DatasetMockRepository()} id="collection" infiniteScrollEnabled={true} + created={false} /> ) } @@ -50,6 +52,7 @@ export const Loading: Story = { repository={new CollectionLoadingMockRepository()} datasetRepository={new DatasetLoadingMockRepository()} id="collection" + created={false} /> ) } @@ -60,6 +63,7 @@ export const NoResults: Story = { repository={new NoCollectionMockRepository()} datasetRepository={new NoDatasetsMockRepository()} id="collection" + created={false} /> ) } @@ -71,6 +75,19 @@ export const LoggedIn: Story = { repository={new CollectionMockRepository()} datasetRepository={new DatasetMockRepository()} id="collection" + created={false} + /> + ) +} + +export const Created: Story = { + decorators: [WithLoggedInUser], + render: () => ( + ) } diff --git a/src/stories/collection/CollectionLoadingMockRepository.ts b/src/stories/collection/CollectionLoadingMockRepository.ts index 7cfda5fe7..531f63acb 100644 --- a/src/stories/collection/CollectionLoadingMockRepository.ts +++ b/src/stories/collection/CollectionLoadingMockRepository.ts @@ -1,3 +1,4 @@ +import { CollectionDTO } from '@iqss/dataverse-client-javascript' import { Collection } from '../../collection/domain/models/Collection' import { CollectionMockRepository } from './CollectionMockRepository' @@ -5,4 +6,7 @@ export class CollectionLoadingMockRepository extends CollectionMockRepository { getById(_id: string): Promise { return new Promise(() => {}) } + create(_collection: CollectionDTO, _hostCollection?: string): Promise { + return new Promise(() => {}) + } } diff --git a/src/stories/collection/CollectionMockRepository.ts b/src/stories/collection/CollectionMockRepository.ts index f74e63f54..2e318be90 100644 --- a/src/stories/collection/CollectionMockRepository.ts +++ b/src/stories/collection/CollectionMockRepository.ts @@ -2,6 +2,7 @@ import { CollectionRepository } from '../../collection/domain/repositories/Colle import { CollectionMother } from '../../../tests/component/collection/domain/models/CollectionMother' import { Collection } from '../../collection/domain/models/Collection' import { FakerHelper } from '../../../tests/component/shared/FakerHelper' +import { CollectionDTO } from '../../collection/domain/useCases/DTOs/CollectionDTO' export class CollectionMockRepository implements CollectionRepository { getById(_id: string): Promise { @@ -11,4 +12,11 @@ export class CollectionMockRepository implements CollectionRepository { }, FakerHelper.loadingTimout()) }) } + create(_collection: CollectionDTO, _hostCollection?: string): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(1) + }, FakerHelper.loadingTimout()) + }) + } } diff --git a/src/stories/create-collection/CreateCollection.stories.tsx b/src/stories/create-collection/CreateCollection.stories.tsx new file mode 100644 index 000000000..44258c24d --- /dev/null +++ b/src/stories/create-collection/CreateCollection.stories.tsx @@ -0,0 +1,46 @@ +import { Meta, StoryObj } from '@storybook/react' +import { CreateCollection } from '../../sections/create-collection/CreateCollection' +import { WithI18next } from '../WithI18next' +import { WithLayout } from '../WithLayout' +import { WithLoggedInUser } from '../WithLoggedInUser' +import { CollectionMockRepository } from '../collection/CollectionMockRepository' +import { CollectionLoadingMockRepository } from '../collection/CollectionLoadingMockRepository' +import { NoCollectionMockRepository } from '../collection/NoCollectionMockRepository' + +const meta: Meta = { + title: 'Pages/Create Collection', + component: CreateCollection, + decorators: [WithI18next, WithLayout, WithLoggedInUser], + 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 OwnerCollectionNotFound: Story = { + render: () => ( + + ) +} diff --git a/tests/component/sections/collection/Collection.spec.tsx b/tests/component/sections/collection/Collection.spec.tsx index a4d929414..09330ff0d 100644 --- a/tests/component/sections/collection/Collection.spec.tsx +++ b/tests/component/sections/collection/Collection.spec.tsx @@ -23,6 +23,7 @@ describe('Collection page', () => { repository={collectionRepository} id="collection" datasetRepository={datasetRepository} + created={false} /> ) @@ -37,6 +38,7 @@ describe('Collection page', () => { repository={collectionRepository} id="collection" datasetRepository={datasetRepository} + created={false} /> ) @@ -49,6 +51,7 @@ describe('Collection page', () => { repository={collectionRepository} id="collection" datasetRepository={datasetRepository} + created={false} /> ) @@ -61,6 +64,7 @@ describe('Collection page', () => { repository={collectionRepository} id="collection" datasetRepository={datasetRepository} + created={false} /> ) cy.findByRole('heading', { name: 'Collection Name' }).should('exist') @@ -72,6 +76,7 @@ describe('Collection page', () => { repository={collectionRepository} datasetRepository={datasetRepository} id="collection" + created={false} /> ) cy.findByRole('button', { name: /Add Data/i }).should('not.exist') @@ -83,6 +88,7 @@ describe('Collection page', () => { repository={collectionRepository} datasetRepository={datasetRepository} id="collection" + created={false} /> ) @@ -99,6 +105,7 @@ describe('Collection page', () => { repository={collectionRepository} datasetRepository={datasetRepository} id="collection" + created={false} /> ) @@ -116,6 +123,7 @@ describe('Collection page', () => { datasetRepository={datasetRepository} page={5} id="collection" + created={false} /> ) @@ -136,6 +144,7 @@ describe('Collection page', () => { datasetRepository={datasetRepository} id="collection" infiniteScrollEnabled + created={false} /> ) @@ -145,4 +154,17 @@ describe('Collection page', () => { cy.findByText(dataset.version.title).should('exist') }) }) + + it('shows the created alert when the collection was just created', () => { + cy.customMount( + + ) + + cy.findByRole('alert').should('exist').should('include.text', 'Success!') + }) }) diff --git a/tests/component/sections/collection/datasets-list/DatasetsList.spec.tsx b/tests/component/sections/collection/datasets-list/DatasetsList.spec.tsx index c87fe16ba..7be7dadae 100644 --- a/tests/component/sections/collection/datasets-list/DatasetsList.spec.tsx +++ b/tests/component/sections/collection/datasets-list/DatasetsList.spec.tsx @@ -28,7 +28,7 @@ describe('Datasets List', () => { datasetRepository.getAllWithCount = cy.stub().resolves(emptyDatasetsWithCount) cy.customMount() - cy.findByText(/This dataverse currently has no datasets./).should('exist') + cy.findByText(/This collection currently has no datasets./).should('exist') }) it('renders the datasets list', () => { diff --git a/tests/component/sections/collection/datasets-list/DatasetsListWithInfiniteScroll.spec.tsx b/tests/component/sections/collection/datasets-list/DatasetsListWithInfiniteScroll.spec.tsx index 434acc122..8f0f10498 100644 --- a/tests/component/sections/collection/datasets-list/DatasetsListWithInfiniteScroll.spec.tsx +++ b/tests/component/sections/collection/datasets-list/DatasetsListWithInfiniteScroll.spec.tsx @@ -36,7 +36,7 @@ describe('Datasets List with Infinite Scroll', () => { ) - cy.findByText(/This dataverse currently has no datasets./).should('exist') + cy.findByText(/This collection currently has no datasets./).should('exist') }) it('renders the first 10 datasets', () => { diff --git a/tests/component/sections/collection/datasets-list/NoDatasetsMessage.spec.tsx b/tests/component/sections/collection/datasets-list/NoDatasetsMessage.spec.tsx index 27a019e8e..e32b731d7 100644 --- a/tests/component/sections/collection/datasets-list/NoDatasetsMessage.spec.tsx +++ b/tests/component/sections/collection/datasets-list/NoDatasetsMessage.spec.tsx @@ -3,7 +3,7 @@ import { NoDatasetsMessage } from '../../../../../src/sections/collection/datase describe('No Datasets Message', () => { it('renders the message for anonymous user', () => { cy.customMount() - cy.findByText(/This dataverse currently has no datasets. Please /).should('exist') + cy.findByText(/This collection currently has no datasets. Please /).should('exist') cy.findByRole('link', { name: 'log in' }).should( 'have.attr', 'href', @@ -14,7 +14,7 @@ describe('No Datasets Message', () => { it('renders the message for authenticated user', () => { cy.mountAuthenticated() cy.findByText( - 'This dataverse currently has no datasets. You can add to it by using the Add Data button on this page.' + '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/create-collection/CollectionForm.spec.tsx b/tests/component/sections/create-collection/CollectionForm.spec.tsx new file mode 100644 index 000000000..7d82e1746 --- /dev/null +++ b/tests/component/sections/create-collection/CollectionForm.spec.tsx @@ -0,0 +1,356 @@ +import { + CollectionForm, + CollectionFormData +} from '../../../../src/sections/create-collection/collection-form/CollectionForm' +import { CollectionRepository } from '../../../../src/collection/domain/repositories/CollectionRepository' +import { UserRepository } from '../../../../src/users/domain/repositories/UserRepository' +import { CollectionMother } from '../../collection/domain/models/CollectionMother' +import { UserMother } from '../../users/domain/models/UserMother' +import { collectionNameToAlias } from '../../../../src/sections/create-collection/collection-form/top-fields-section/IdentifierField' + +const collectionRepository: CollectionRepository = {} as CollectionRepository + +const OWNER_COLLECTION_ID = 'root' + +const COLLECTION_NAME = 'Collection Name' +const collection = CollectionMother.create({ name: COLLECTION_NAME }) + +const testUser = UserMother.create() +const userRepository: UserRepository = {} as UserRepository + +const defaultCollectionName = `${testUser.displayName} Collection` + +const formDefaultValues: CollectionFormData = { + hostCollection: collection.name, + name: defaultCollectionName, + alias: '', + type: '', + contacts: [{ value: testUser.email }], + affiliation: testUser.affiliation ?? '', + storage: 'Local (Default)', + description: '' +} + +describe('CollectionForm', () => { + beforeEach(() => { + collectionRepository.create = cy.stub().resolves(1) + collectionRepository.getById = cy.stub().resolves(collection) + userRepository.getAuthenticated = cy.stub().resolves(testUser) + }) + + it('should render the form', () => { + cy.mountAuthenticated( + + ) + + cy.findByTestId('collection-form').should('exist') + }) + it('prefills the Host Collection field with current owner collection', () => { + cy.mountAuthenticated( + + ) + + cy.findByLabelText(/^Host Collection/i).should('have.value', COLLECTION_NAME) + }) + + it('pre-fills specific form fields with user data', () => { + cy.mountAuthenticated( + + ) + + cy.findByLabelText(/^Collection Name/i).should('have.value', defaultCollectionName) + + cy.findByLabelText(/^Affiliation/i).should('have.value', testUser.affiliation) + + cy.findByLabelText(/^Email/i).should('have.value', testUser.email) + }) + + it('submit button should be disabled when form has not been touched', () => { + cy.customMount( + + ) + + cy.findByRole('button', { name: 'Create Collection' }).should('be.disabled') + }) + + it('submit button should not be disabled when form has been touched', () => { + cy.customMount( + + ) + + cy.findByLabelText(/^Collection Name/i) + .clear() + .type('New Collection Name') + + cy.findByRole('button', { name: 'Create Collection' }).should('not.be.disabled') + }) + + it('shows error message when form is submitted with empty required fields', () => { + cy.customMount( + + ) + + // Change collection name so submit button is no longer disabled + cy.findByLabelText(/^Collection Name/i) + .clear() + .type('New Collection Name') + + cy.findByRole('button', { name: 'Create Collection' }).click() + + cy.findByText('Category is required').should('exist') + cy.findByText('Identifier is required').should('exist') + }) + + it('shows error message when form is submitted with invalid email', () => { + cy.customMount( + + ) + + cy.findByLabelText(/^Email/i) + .clear() + .type('invalid-email') + + cy.findByRole('button', { name: 'Create Collection' }).click() + + cy.findByText('Email is not a valid email').should('exist') + }) + it('shows error message when form is submitted with invalid identifier', () => { + cy.customMount( + + ) + + cy.findByLabelText(/^Identifier/i).type('invalid identifier') + + cy.findByRole('button', { name: 'Create Collection' }).click() + + cy.findByText(/Identifier is not valid./).should('exist') + }) + + it('should not submit the form when pressing enter key if submit button is not focused', () => { + cy.customMount( + + ) + + // Select a Category option so submit button is not disabled + cy.findByLabelText(/^Category/i).select(1) + + // Focus on the Identifier field that is empty and is required and press enter key + cy.findByLabelText(/^Identifier/i) + .focus() + .type('{enter}') + + // Validation error shouldn't be shown as form wasn't submitted by pressing enter key on Identifier field + cy.findByText('Identifier is required').should('not.exist') + }) + it('should submit the form when pressing enter key if submit button is indeed focused', () => { + cy.customMount( + + ) + + // Select a Category option so submit button is not disabled + cy.findByLabelText(/^Category/i).select(1) + + // To wait until button becomes enabled + cy.wait(100) + + cy.findByRole('button', { name: 'Create Collection' }).focus().type('{enter}') + + // Validation error should be shown as form was submitted by pressing enter key on Identifier field + cy.findByText('Identifier is required').should('exist') + }) + + it('submits a valid form and succeed', () => { + cy.customMount( + + ) + // Accept suggestion + cy.findByRole('button', { name: 'Apply suggestion' }).click() + // Select a Category option + cy.findByLabelText(/^Category/i).select(1) + + cy.findByRole('button', { name: 'Create Collection' }).click() + + cy.findByText('Error').should('not.exist') + cy.findByText('Success!').should('exist') + }) + + it('submits a valid form and fails', () => { + collectionRepository.create = cy.stub().rejects(new Error('Error creating collection')) + + cy.customMount( + + ) + + // Accept suggestion + cy.findByRole('button', { name: 'Apply suggestion' }).click() + // Select a Category option + cy.findByLabelText(/^Category/i).select(1) + + cy.findByRole('button', { name: 'Create Collection' }).click() + + cy.findByText('Error').should('exist') + cy.findByText(/Error creating collection/).should('exist') + cy.findByText('Success!').should('not.exist') + }) + + it('cancel button is clickable', () => { + cy.customMount( + + ) + + cy.findByText(/Cancel/i).click() + }) + + describe('IdentifierField suggestion functionality', () => { + it('should show to apply an identifier suggestion', () => { + cy.customMount( + + ) + + const aliasSuggestion = collectionNameToAlias(defaultCollectionName) + + cy.findByText(/Psst... try this/).should('exist') + cy.findByText(/Psst... try this/).should('include.text', aliasSuggestion) + + cy.findByRole('button', { name: 'Apply suggestion' }).should('exist') + }) + + it('should apply suggestion when clicking the button and hide suggestion', () => { + cy.customMount( + + ) + + const aliasSuggestion = collectionNameToAlias(defaultCollectionName) + + cy.findByRole('button', { name: 'Apply suggestion' }).click() + + cy.findByLabelText(/^Identifier/i).should('have.value', aliasSuggestion) + + cy.findByText(/Psst... try this/).should('not.exist') + }) + + it('should not show suggestion when identifier is already the suggestion', () => { + cy.customMount( + + ) + + cy.findByText(/Psst... try this/).should('not.exist') + }) + + it('should not show suggestion if Collection Name is empty', () => { + cy.customMount( + + ) + + cy.findByText(/Psst... try this/).should('not.exist') + }) + }) + + describe('ContactsField functionality', () => { + it('should add a new contact field when clicking the add button', () => { + cy.customMount( + + ) + + cy.findByLabelText('Add Email').click() + + cy.findAllByLabelText('Add Email').should('exist').should('have.length', 2) + cy.findByLabelText('Remove Email').should('exist') + }) + + it('should remove a contact field when clicking the remove button', () => { + cy.customMount( + + ) + cy.findAllByLabelText('Add Email').should('exist').should('have.length', 2) + cy.findByLabelText('Remove Email').should('exist') + + cy.findByLabelText('Remove Email').click() + + cy.findByLabelText('Add Email').should('exist') + cy.findByLabelText('Remove Email').should('not.exist') + }) + }) +}) diff --git a/tests/component/sections/create-collection/CreateCollection.spec.tsx b/tests/component/sections/create-collection/CreateCollection.spec.tsx new file mode 100644 index 000000000..e70bd849b --- /dev/null +++ b/tests/component/sections/create-collection/CreateCollection.spec.tsx @@ -0,0 +1,67 @@ +import { CollectionRepository } from '../../../../src/collection/domain/repositories/CollectionRepository' +import { CreateCollection } from '../../../../src/sections/create-collection/CreateCollection' +import { UserRepository } from '../../../../src/users/domain/repositories/UserRepository' +import { CollectionMother } from '../../collection/domain/models/CollectionMother' +import { UserMother } from '../../users/domain/models/UserMother' + +const collectionRepository: CollectionRepository = {} as CollectionRepository + +const COLLECTION_NAME = 'Collection Name' +const collection = CollectionMother.create({ name: COLLECTION_NAME }) + +const testUser = UserMother.create() +const userRepository: UserRepository = {} as UserRepository + +describe('CreateCollection', () => { + beforeEach(() => { + collectionRepository.create = cy.stub().resolves(1) + collectionRepository.getById = cy.stub().resolves(collection) + userRepository.getAuthenticated = cy.stub().resolves(testUser) + }) + + it('should show loading skeleton while loading the owner collection', () => { + cy.customMount( + + ) + + cy.findByTestId('create-collection-skeleton').should('exist') + }) + + it('should render the correct breadcrumbs', () => { + cy.customMount( + + ) + + cy.findByRole('link', { name: 'Root' }).should('exist') + + cy.get('li[aria-current="page"]') + .should('exist') + .should('have.text', 'Create Collection') + .should('have.class', 'active') + }) + + it('should show page not found when owner collection does not exist', () => { + collectionRepository.getById = cy.stub().resolves(null) + + cy.customMount( + + ) + + cy.findByText('Page Not Found').should('exist') + }) + + it('pre-fills specific form fields with user data', () => { + cy.mountAuthenticated( + + ) + + cy.findByLabelText(/^Collection Name/i).should( + 'have.value', + `${testUser.displayName} Collection` + ) + + cy.findByLabelText(/^Affiliation/i).should('have.value', testUser.affiliation) + + cy.findByLabelText(/^Email/i).should('have.value', testUser.email) + }) +}) diff --git a/tests/e2e-integration/e2e/sections/create-collection/CreateCollection.spec.tsx b/tests/e2e-integration/e2e/sections/create-collection/CreateCollection.spec.tsx new file mode 100644 index 000000000..093871cb8 --- /dev/null +++ b/tests/e2e-integration/e2e/sections/create-collection/CreateCollection.spec.tsx @@ -0,0 +1,24 @@ +import { TestsUtils } from '../../../shared/TestsUtils' + +describe('Create Collection', () => { + before(() => { + TestsUtils.setup() + }) + + beforeEach(() => { + TestsUtils.login() + }) + + it('navigates to the collection page after submitting a valid form', () => { + cy.visit('/spa/collections/root/create') + + cy.findByLabelText(/^Identifier/i).type('some-alias') + + cy.findByLabelText(/^Category/i).select(1) + + cy.findByRole('button', { name: 'Create Collection' }).click() + + cy.findByRole('heading', { name: 'Dataverse Admin Collection' }).should('exist') + cy.findByText('Success!').should('exist') + }) +})