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 3c0306a87..0605fb016 100644 --- a/packages/design-system/CHANGELOG.md +++ b/packages/design-system/CHANGELOG.md @@ -14,8 +14,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - **FormGroup:** ability to clone children wrapped by react fragments. - **FormCheckbox:** ability to forward react ref to input and export FormCheckboxProps interface. - **FormInput:** ability to forward react ref to input and export FormInputProps interface. -- **FormSelect:** ability to forward react ref to input, add `isInvalid` `isValid` & `disabled` props and export - FormSelectProps interface. +- **FormSelect:** ability to forward react ref to input, add `isInvalid` `isValid` & `disabled` props and export FormSelectProps interface. - **FormTextArea:** ability to forward react ref to input and export FormTextAreaProps interface. - **FormFeedback:** remove `span: 9` from styles. - **FormGroup:** controlId is now optional. @@ -27,8 +26,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - **Accordion:** ability to forward react ref. - **DynamicFieldsButtons:** Removed from design system. - **FormGroupWithMultipleFields:** remove withDynamicFields prop and remove logic to handle adding or removing fields. -- **FormGroup:** remove the required and fieldIndex props, remove the cloning of child elements to pass them the - withinMultipleFieldsGroup and required props. +- **FormGroup:** remove the required and fieldIndex props, remove the cloning of child elements to pass them the withinMultipleFieldsGroup and required props. - **FormFeedback:** remove withinMultipleFieldsGroup prop. - **FormInput:** remove withinMultipleFieldsGroup prop. - **FormLabel:** remove withinMultipleFieldsGroup prop extend interface to accept ColProps. @@ -43,56 +41,42 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - **ProgressBar:** NEW progress bar element to show progress. - **FormRadioGroup:** NEW radio group element to show radio buttons. - **FormRadio:** NEW radio element to show radio button. +- **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) ### Bug Fixes -- **Icon-only Button:** add - aria-label ([1f17e84](https://github.com/IQSS/dataverse-frontend/commit/1f17e84edf50c6780f8854f28e214386d9b5dc05)) -- **ButtonGroup:** add - className ([c295c7b](https://github.com/IQSS/dataverse-frontend/commit/c295c7b914759c37f705b511381dc3e878f55684)) -- **ButtonGroup:** fix styles when using - tooltips ([3edfaef](https://github.com/IQSS/dataverse-frontend/commit/3edfaef4f931a6a0b511b09d2a3326371c867f6d)) -- **Tooltip:** avoid layout shift on - hover ([a52c3dc](https://github.com/IQSS/dataverse-frontend/commit/a52c3dc972642f6b4e39ef1ed795300a8c5e6528)) -- **Table:** vertically align cells content to the - middle ([0f3a335](https://github.com/IQSS/dataverse-frontend/commit/0f3a3352afb3de77d34c634473c46502a415a20b)) -- **Table:** change alignment - cells ([fcf89a0](https://github.com/IQSS/dataverse-frontend/commit/fcf89a078ed2d09eac0f3d6673e45efc3445fabe)) -- **styles:** remove absolute path from design system bootstrap - imports ([2dddb07](https://github.com/IQSS/dataverse-frontend/commit/2dddb07e11b6d0abf8ac70c70d991173463cc5eb)) -- **Storybook:** set fixed package.json - dependencies ([c58dfd1](https://github.com/IQSS/dataverse-frontend/commit/c58dfd143e4ac46dc3507ffe737b663530fd3f35)) -- **Storybook:** accessibility violation - fixed ([1aa62b0](https://github.com/IQSS/dataverse-frontend/commit/1aa62b0e7f9108f132995c501836baae0811870a)) -- **Icons:** fix icons not - appearing ([72d6bb5](https://github.com/IQSS/dataverse-frontend/commit/72d6bb5fcc518f50fbf2543f0a33a5d0561dbbc5)) -- **DropdownButton:** refactor to also work as a - select ([c1171c1](https://github.com/IQSS/dataverse-frontend/commit/c1171c1c0e149fc81811d3469ec046f6b6c3f928)) -- **Dropdown:** add disabled - property ([d4a32f1](https://github.com/IQSS/dataverse-frontend/commit/d4a32f10ea6d9e94f7e149886f1044e68afc53dd)) -- **DropdownButtonItem:** add disabled - property ([0f2a626](https://github.com/IQSS/dataverse-frontend/commit/0f2a626c7201c90b35ec05823e56efc21be82bcd)) -- **ButtonGroup:** add - HTMLAttributes ([821d38f](https://github.com/IQSS/dataverse-frontend/commit/821d38ff53a73dc4f478854e275781d933d920b5)) -- **Button:** add type - attribute ([b2c31a7](https://github.com/IQSS/dataverse-frontend/commit/b2c31a7c230c07522d8fce539fa28fafaf26dc95)) -- **Form Inputs:** allow buttons inside form - inputs ([0000a4a](https://github.com/IQSS/dataverse-frontend/commit/0000a4a8fd75d63d8b49e0963698d387e081f5de)) +- **Icon-only Button:** add aria-label ([1f17e84](https://github.com/IQSS/dataverse-frontend/commit/1f17e84edf50c6780f8854f28e214386d9b5dc05)) +- **ButtonGroup:** add className ([c295c7b](https://github.com/IQSS/dataverse-frontend/commit/c295c7b914759c37f705b511381dc3e878f55684)) +- **ButtonGroup:** fix styles when using tooltips ([3edfaef](https://github.com/IQSS/dataverse-frontend/commit/3edfaef4f931a6a0b511b09d2a3326371c867f6d)) +- **Tooltip:** avoid layout shift on hover ([a52c3dc](https://github.com/IQSS/dataverse-frontend/commit/a52c3dc972642f6b4e39ef1ed795300a8c5e6528)) +- **Table:** vertically align cells content to the middle ([0f3a335](https://github.com/IQSS/dataverse-frontend/commit/0f3a3352afb3de77d34c634473c46502a415a20b)) +- **Table:** change alignment cells ([fcf89a0](https://github.com/IQSS/dataverse-frontend/commit/fcf89a078ed2d09eac0f3d6673e45efc3445fabe)) +- **styles:** remove absolute path from design system bootstrap imports ([2dddb07](https://github.com/IQSS/dataverse-frontend/commit/2dddb07e11b6d0abf8ac70c70d991173463cc5eb)) +- **Storybook:** set fixed package.json dependencies ([c58dfd1](https://github.com/IQSS/dataverse-frontend/commit/c58dfd143e4ac46dc3507ffe737b663530fd3f35)) +- **Storybook:** accessibility violation fixed ([1aa62b0](https://github.com/IQSS/dataverse-frontend/commit/1aa62b0e7f9108f132995c501836baae0811870a)) +- **Icons:** fix icons not appearing ([72d6bb5](https://github.com/IQSS/dataverse-frontend/commit/72d6bb5fcc518f50fbf2543f0a33a5d0561dbbc5)) +- **DropdownButton:** refactor to also work as a select ([c1171c1](https://github.com/IQSS/dataverse-frontend/commit/c1171c1c0e149fc81811d3469ec046f6b6c3f928)) +- **Dropdown:** add disabled property ([d4a32f1](https://github.com/IQSS/dataverse-frontend/commit/d4a32f10ea6d9e94f7e149886f1044e68afc53dd)) +- **DropdownButtonItem:** add disabled property ([0f2a626](https://github.com/IQSS/dataverse-frontend/commit/0f2a626c7201c90b35ec05823e56efc21be82bcd)) +- **ButtonGroup:** add HTMLAttributes ([821d38f](https://github.com/IQSS/dataverse-frontend/commit/821d38ff53a73dc4f478854e275781d933d920b5)) +- **Button:** add type attribute ([b2c31a7](https://github.com/IQSS/dataverse-frontend/commit/b2c31a7c230c07522d8fce539fa28fafaf26dc95)) +- **Form Inputs:** allow buttons inside form inputs ([0000a4a](https://github.com/IQSS/dataverse-frontend/commit/0000a4a8fd75d63d8b49e0963698d387e081f5de)) ### Features -- **Breadcrumb:** add linkAs - prop ([f9c5f8a](https://github.com/IQSS/dataverse-frontend/commit/f9c5f8a896b2fb67c025cb90b6f971b529e2a3ef)) -- **Pagination:** add Pagination component to the design - system ([0274ca4](https://github.com/IQSS/dataverse-frontend/commit/0274ca4581eb6d3d4e11880af1a6eee390e1a7b8)) -- **OverlayTrigger:** add OverlayTrigger to the Design - System ([203c1ec](https://github.com/IQSS/dataverse-frontend/commit/203c1ecbf195379363559ab4e5c3d93f3710aa82)) -- **DropdownHeader:** add DropdownHeader to the Design - System ([1ed14be](https://github.com/IQSS/dataverse-frontend/commit/1ed14bebb021363e6490812eb05c834926ffb2d9)) -- **DropdownSeparator:** add DropdownSeparator to the design - system ([b4ce154](https://github.com/IQSS/dataverse-frontend/commit/b4ce154a9df880b6b5dfa993bf86c12ffbc926d2)) +- **Breadcrumb:** add linkAs prop ([f9c5f8a](https://github.com/IQSS/dataverse-frontend/commit/f9c5f8a896b2fb67c025cb90b6f971b529e2a3ef)) +- **Pagination:** add Pagination component to the design system ([0274ca4](https://github.com/IQSS/dataverse-frontend/commit/0274ca4581eb6d3d4e11880af1a6eee390e1a7b8)) +- **OverlayTrigger:** add OverlayTrigger to the Design System ([203c1ec](https://github.com/IQSS/dataverse-frontend/commit/203c1ecbf195379363559ab4e5c3d93f3710aa82)) +- **DropdownHeader:** add DropdownHeader to the Design System ([1ed14be](https://github.com/IQSS/dataverse-frontend/commit/1ed14bebb021363e6490812eb05c834926ffb2d9)) +- **DropdownSeparator:** add DropdownSeparator to the design system ([b4ce154](https://github.com/IQSS/dataverse-frontend/commit/b4ce154a9df880b6b5dfa993bf86c12ffbc926d2)) # [1.0.1](https://github.com/IQSS/dataverse-frontend/compare/@iqss/dataverse-design-system@1.0.0...@iqss/dataverse-design-system@1.0.1) (2023-07-06) 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/public/locales/en/uploadDatasetFiles.json b/public/locales/en/uploadDatasetFiles.json index 29cbc3838..7e4e8d558 100644 --- a/public/locales/en/uploadDatasetFiles.json +++ b/public/locales/en/uploadDatasetFiles.json @@ -1,6 +1,64 @@ { "breadcrumbActionItem": "Upload files", "cancel": "Cancel upload", - "info": "Drag and drop files here.", - "select": "Select files to add" + "info": "Drag and drop files and/or directories here.", + "select": "Select files to add", + "delete": "Delete", + "save": "Save", + "saveUploaded": "Save uploaded files", + "uploadedFileSize": "Uploaded file size", + "restricted": "Restricted", + "tags": { + "documentation": "Documentation", + "data": "Data", + "code": "Code", + "editTagOptions": "Edit tag options", + "customFileTag": "Custom file tag", + "creatingNewTag": "Creating a new tag will add it as a tag option for all files in this dataset.", + "addNewTag": "Add new file tag...", + "availableTagOptions": "Available tag options: ", + "apply": "Apply", + "close": "Close", + "addCustomTag": "Add new custom file tag..." + }, + "restriction": { + "restrictAccess": "Restrict Access", + "restrictionInfoP1": "Restricting limits access to published files. People who want to use the restricted files can request access by default. If you disable request access, you must add information about access to the Terms of Access field.", + "restrictionInfoP2": "Learn about restricting files and dataset access in the User Guide.", + "termsOfAccess": "Terms of Access for Restricted Files", + "enableAccessRequest": "Enable access request", + "saveChanges": "Save Changes", + "cancelChanges": "Cancel Changes" + }, + "fileForm": { + "fileName": "File name", + "filePath": "File path", + "description": "Description", + "tags": "Tags", + "selectTags": "Select tags", + "editTagOptions": "Edit tag options", + "plus": "Add new tag option" + }, + "filesHeader": { + "save": "Save", + "cancel": "Cancel", + "editFiles": "Edit files", + "restrict": "Restrict", + "unrestrict": "Unrestrict", + "filesUploaded_one": "{{count}} file uploaded", + "filesUploaded_other": "{{count}} files uploaded", + "filesSelected_one": "{{count}} file selected", + "filesSelected_other": "{{count}} files selected", + "deleteSelected": "Delete selected", + "addTagsToSelected": "Add tags to selected", + "addTags": "Add tags", + "selectAll": "Select all" + }, + "addTags": { + "title": "Add tags to selected files", + "customTag": "Custom tag", + "selectTags": "Select tags to add", + "saveChanges": "Save Changes", + "cancelChanges": "Cancel Changes" + } } 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/files/domain/repositories/File.ts b/src/files/domain/models/FileHolder.ts similarity index 100% rename from src/files/domain/repositories/File.ts rename to src/files/domain/models/FileHolder.ts diff --git a/src/files/domain/models/FileUploadState.ts b/src/files/domain/models/FileUploadState.ts index c2e9e3ffd..854cb3035 100644 --- a/src/files/domain/models/FileUploadState.ts +++ b/src/files/domain/models/FileUploadState.ts @@ -2,15 +2,26 @@ import { FileSize, FileSizeUnit } from './FileMetadata' export interface FileUploadState { progress: number + storageId?: string progressHidden: boolean fileSizeString: string + fileSize: number + fileLastModified: number failed: boolean done: boolean removed: boolean + fileName: string + fileDir: string + fileType: string + key: string + description?: string + tags: string[] + restricted: boolean } export interface FileUploaderState { state: Map + uploaded: FileUploadState[] } export class FileUploadTools { @@ -20,19 +31,28 @@ export class FileUploadTools { const key = this.key(file) const newValue: FileUploadState = { progress: 0, + storageId: undefined, progressHidden: true, fileSizeString: new FileSize(file.size, FileSizeUnit.BYTES).toString(), + fileSize: file.size, + fileLastModified: file.lastModified, failed: false, done: false, - removed: false + removed: false, + fileName: file.name, + fileDir: this.toDir(file.webkitRelativePath), + fileType: file.type, + key: key, + tags: [], + restricted: false } newState.set(key, newValue) }) - return { state: newState } + return { state: newState, uploaded: this.toUploaded(newState) } } static key(file: File): string { - return file.webkitRelativePath + file.name + return file.webkitRelativePath ? file.webkitRelativePath : file.name } static get(file: File, state: FileUploaderState): FileUploadState { @@ -44,28 +64,50 @@ export class FileUploadTools { progress: 0, progressHidden: true, fileSizeString: new FileSize(file.size, FileSizeUnit.BYTES).toString(), + fileSize: file.size, + fileLastModified: file.lastModified, failed: false, done: false, - removed: false + removed: false, + fileName: file.name, + fileDir: this.toDir(file.webkitRelativePath), + fileType: file.type, + key: this.key(file), + tags: [], + restricted: false } } static progress(file: File, now: number, oldState: FileUploaderState): FileUploaderState { - const [newState, newValue] = this.toNewState(file, oldState) - newValue.progress = now - return newState + const fileUploadState = oldState.state.get(this.key(file)) + if (fileUploadState) { + fileUploadState.progress = now + } + return { state: oldState.state, uploaded: this.toUploaded(oldState.state) } + } + + static storageId(file: File, id: string, oldState: FileUploaderState): FileUploaderState { + const fileUploadState = oldState.state.get(this.key(file)) + if (fileUploadState) { + fileUploadState.storageId = id + } + return { state: oldState.state, uploaded: this.toUploaded(oldState.state) } } static failed(file: File, oldState: FileUploaderState): FileUploaderState { - const [newState, newValue] = this.toNewState(file, oldState) - newValue.failed = true - return newState + const fileUploadState = oldState.state.get(this.key(file)) + if (fileUploadState) { + fileUploadState.failed = true + } + return { state: oldState.state, uploaded: this.toUploaded(oldState.state) } } static done(file: File, oldState: FileUploaderState): FileUploaderState { - const [newState, newValue] = this.toNewState(file, oldState) - newValue.done = true - return newState + const fileUploadState = oldState.state.get(this.key(file)) + if (fileUploadState) { + fileUploadState.done = true + } + return { state: oldState.state, uploaded: this.toUploaded(oldState.state) } } static removed(file: File, oldState: FileUploaderState): FileUploaderState { @@ -80,12 +122,31 @@ export class FileUploadTools { return newState } + static delete(file: File, oldState: FileUploaderState): FileUploaderState { + oldState.state.delete(this.key(file)) + return { state: oldState.state, uploaded: this.toUploaded(oldState.state) } + } + private static toNewState( file: File, oldState: FileUploaderState ): [FileUploaderState, FileUploadState] { const newValue = this.get(file, oldState) oldState.state.set(this.key(file), newValue) - return [{ state: oldState.state }, newValue] + return [{ state: oldState.state, uploaded: this.toUploaded(oldState.state) }, newValue] + } + + private static toUploaded(state: Map): FileUploadState[] { + return Array.from(state.values()) + .filter((x) => !x.removed && x.done) + .sort((a, b) => (a.fileDir + a.fileName).localeCompare(b.fileDir + b.fileName)) + } + + private static toDir(relativePath: string): string { + const parts = relativePath.split('/') + if (parts.length > 0) { + return parts.slice(0, parts.length - 1).join('/') + } + return relativePath } } diff --git a/src/files/domain/repositories/FileRepository.ts b/src/files/domain/repositories/FileRepository.ts index 8673e423e..9c2c30478 100644 --- a/src/files/domain/repositories/FileRepository.ts +++ b/src/files/domain/repositories/FileRepository.ts @@ -6,7 +6,8 @@ import { DatasetVersion, DatasetVersionNumber } from '../../../dataset/domain/mo import { FilePaginationInfo } from '../models/FilePaginationInfo' import { FilePreview } from '../models/FilePreview' import { FilesWithCount } from '../models/FilesWithCount' -import { FileHolder } from './File' +import { FileHolder } from '../models/FileHolder' +import { FileUploadState } from '../models/FileUploadState' export interface FileRepository { getAllByDatasetPersistentId: ( @@ -38,6 +39,13 @@ export interface FileRepository { datasetId: number | string, file: FileHolder, progress: (now: number) => void, - abortController: AbortController + abortController: AbortController, + storageIdSetter: (storageId: string) => void + ) => Promise + addUploadedFiles: (datasetId: number | string, files: FileUploadState[]) => Promise + addUploadedFile: ( + datasetId: number | string, + file: FileHolder, + storageId: string ) => Promise } diff --git a/src/files/domain/useCases/addUploadedFiles.ts b/src/files/domain/useCases/addUploadedFiles.ts new file mode 100644 index 000000000..b1ef42065 --- /dev/null +++ b/src/files/domain/useCases/addUploadedFiles.ts @@ -0,0 +1,31 @@ +import { FileUploadState } from '../models/FileUploadState' +import { FileRepository } from '../repositories/FileRepository' + +export function addUploadedFiles( + fileRepository: FileRepository, + datasetId: number | string, + files: FileUploadState[], + done: () => void +): void { + fileRepository + .addUploadedFiles(datasetId, files) + .catch((error: Error) => { + throw new Error(error.message) + }) + .finally(done) +} + +export function addUploadedFile( + fileRepository: FileRepository, + datasetId: number | string, + file: File, + storageId: string, + done: () => void +): void { + fileRepository + .addUploadedFile(datasetId, { file: file }, storageId) + .catch((error: Error) => { + throw new Error(error.message) + }) + .finally(done) +} diff --git a/src/files/domain/useCases/uploadFile.ts b/src/files/domain/useCases/uploadFile.ts index 978c45b95..38da23724 100644 --- a/src/files/domain/useCases/uploadFile.ts +++ b/src/files/domain/useCases/uploadFile.ts @@ -6,12 +6,13 @@ export function uploadFile( file: File, done: () => void, failed: () => void, - progress: (now: number) => void + progress: (now: number) => void, + storageIdSetter: (storageId: string) => void ): () => void { const controller = new AbortController() fileRepository - .uploadFile(datasetId, { file: file }, progress, controller) - .then(() => done()) - .catch(() => failed()) + .uploadFile(datasetId, { file: file }, progress, controller, storageIdSetter) + .then(done) + .catch(failed) return () => controller.abort() } diff --git a/src/files/infrastructure/FileJSDataverseRepository.ts b/src/files/infrastructure/FileJSDataverseRepository.ts index 269af7855..89d6fea4b 100644 --- a/src/files/infrastructure/FileJSDataverseRepository.ts +++ b/src/files/infrastructure/FileJSDataverseRepository.ts @@ -14,6 +14,8 @@ import { getFileDataTables, getFileDownloadCount, getFileUserPermissions, + uploadFile as jsUploadFile, + addUploadedFileToDataset, ReadError } from '@iqss/dataverse-client-javascript' import { FileCriteria } from '../domain/models/FileCriteria' @@ -29,7 +31,8 @@ import { JSFileMetadataMapper } from './mappers/JSFileMetadataMapper' import { FilePermissions } from '../domain/models/FilePermissions' import { JSFilePermissionsMapper } from './mappers/JSFilePermissionsMapper' import { FilesWithCount } from '../domain/models/FilesWithCount' -import { FileHolder } from '../domain/repositories/File' +import { FileHolder } from '../domain/models/FileHolder' +import { FileUploadState } from '../domain/models/FileUploadState' const includeDeaccessioned = true @@ -284,12 +287,26 @@ export class FileJSDataverseRepository implements FileRepository { } uploadFile( - _datasetId: number | string, - _file: FileHolder, - _progress: (now: number) => void, - _abortController: AbortController + datasetId: number | string, + file: FileHolder, + progress: (now: number) => void, + abortController: AbortController, + storageIdSetter: (storageId: string) => void ): Promise { - // TODO: - return new Promise(() => {}) + return jsUploadFile + .execute(datasetId, file.file, progress, abortController) + .then(storageIdSetter) + .catch((error: ReadError) => { + throw new Error(error.message) + }) + } + + addUploadedFiles(_datasetId: number | string, _files: FileUploadState[]): Promise { + // TODO: not yet implemented + return new Promise(() => {}) + } + + addUploadedFile(datasetId: number | string, file: FileHolder, storageId: string): Promise { + return addUploadedFileToDataset.execute(datasetId, file.file, storageId) } } 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/sections/upload-dataset-files/FileUploader.module.scss b/src/sections/upload-dataset-files/FileUploader.module.scss index 6299fb9c2..3e76ca70b 100644 --- a/src/sections/upload-dataset-files/FileUploader.module.scss +++ b/src/sections/upload-dataset-files/FileUploader.module.scss @@ -9,10 +9,37 @@ grid-template-columns: repeat(4, auto); } +.uploaded_files { + display: grid; + grid-template-columns: 4rem repeat(3, auto) 3.5rem; +} + +.selected_file>div { + margin-bottom: 0.5rem; + padding: 0.5rem; + background-color: #ffc !important; +} + +.file:nth-of-type(even)>div { + margin-bottom: 0.5rem; + padding: 0.5rem; + background-color: #f8f9fc; +} + +.file:nth-of-type(odd)>div { + margin-bottom: 0.5rem; + padding: 0.5rem; + background-color: white; +} + .file { display: contents; } +.selected_file { + display: contents; +} + .file_name { padding: 0.25em; } @@ -28,13 +55,11 @@ } .cancel_upload { - padding: 0.25em; + padding-block: 0.1rem; } .upload_progress { - width: 10em; - margin-top: 0.25em; - padding: 0.25em; + min-width: 10em; } .icon { @@ -47,3 +72,14 @@ font-size: 1.3em; text-align: center; } + +.selected_files_checkbox { + display: inline-flex; + align-items: center; +} + +.save_btn { + display: flex; + justify-content: flex-end; + padding-top: 1rem; +} diff --git a/src/sections/upload-dataset-files/FileUploader.tsx b/src/sections/upload-dataset-files/FileUploader.tsx index c544b909e..2807722a3 100644 --- a/src/sections/upload-dataset-files/FileUploader.tsx +++ b/src/sections/upload-dataset-files/FileUploader.tsx @@ -12,6 +12,7 @@ export interface FileUploaderProps { selectText: string fileUploaderState: FileUploaderState cancelUpload: (file: File) => void + cleanFileState: (file: File) => void } export function FileUploader({ @@ -20,7 +21,8 @@ export function FileUploader({ info, selectText, fileUploaderState, - cancelUpload + cancelUpload, + cleanFileState }: FileUploaderProps) { const theme = useTheme() const [files, setFiles] = useState([]) @@ -32,6 +34,7 @@ export function FileUploader({ const selectedFilesArray = Array.from(selectedFiles) const selectedFilesSet = new Set(selectedFilesArray.map((x) => FileUploadTools.key(x))) const alreadyAddedFiltered = alreadyAdded.filter( + /* istanbul ignore next */ (x) => !selectedFilesSet.has(FileUploadTools.key(x)) ) return [...alreadyAddedFiltered, ...selectedFilesArray] @@ -46,22 +49,19 @@ export function FileUploader({ } // waiting on the possibility to test folder drop: https://github.com/cypress-io/cypress/issues/19696 - const addFromDir = /* istanbul ignore next */ (dir: FileSystemDirectoryEntry) => { + const addFromDir = (dir: FileSystemDirectoryEntry) => { + /* istanbul ignore next */ const reader = dir.createReader() - reader.readEntries( - /* istanbul ignore next */ (entries) => { - entries.forEach( - /* istanbul ignore next */ (entry) => { - if (entry.isFile) { - const fse = entry as FileSystemFileEntry - fse.file(/* istanbul ignore next */ (f) => addFile(f)) - } else if (entry.isDirectory) { - addFromDir(entry as FileSystemDirectoryEntry) - } - } - ) - } - ) + reader.readEntries((entries) => { + entries.forEach((entry) => { + if (entry.isFile) { + const fse = entry as FileSystemFileEntry + fse.file((f) => addFile(f)) + } else if (entry.isDirectory) { + addFromDir(entry as FileSystemDirectoryEntry) + } + }) + }) } const handleChange: ChangeEventHandler = (event) => { @@ -76,6 +76,7 @@ export function FileUploader({ setBackgroundColor(theme.color.primaryTextColor) } + /* istanbul ignore next */ const handleDragOver: DragEventHandler = (event) => { event.preventDefault() setBackgroundColor(theme.color.infoBoxColor) @@ -104,16 +105,21 @@ export function FileUploader({ } } - const handleRemoveFile = (f: File) => { - cancelUpload(f) - setFiles((newFiles) => - newFiles.filter((x) => !FileUploadTools.get(x, fileUploaderState).removed) - ) - } - useEffect(() => { upload(files) - }, [files, fileUploaderState, upload]) + }, [files, upload]) + + useEffect(() => { + setFiles((newFiles) => + newFiles.filter((x) => { + const res = !FileUploadTools.get(x, fileUploaderState).removed + if (!res) { + cleanFileState(x) + } + return res + }) + ) + }, [fileUploaderState, cleanFileState]) const inputRef = useRef(null) @@ -124,14 +130,15 @@ export function FileUploader({ {selectText} - +
+ data-testid="drag-and-drop" + style={{ backgroundColor: bgColor }}>
- {file.webkitRelativePath} - {file.name} + {file.webkitRelativePath ? file.webkitRelativePath : file.name}
{FileUploadTools.get(file, fileUploaderState).fileSizeString} @@ -168,7 +174,7 @@ export function FileUploader({ variant="secondary" {...{ size: 'sm' }} withSpacing - onClick={() => handleRemoveFile(file)}> + onClick={() => cancelUpload(file)}>
diff --git a/src/sections/upload-dataset-files/UploadDatasetFiles.tsx b/src/sections/upload-dataset-files/UploadDatasetFiles.tsx index 1efc09eb1..7a21e05ed 100644 --- a/src/sections/upload-dataset-files/UploadDatasetFiles.tsx +++ b/src/sections/upload-dataset-files/UploadDatasetFiles.tsx @@ -6,8 +6,10 @@ import { useDataset } from '../dataset/DatasetContext' import { PageNotFound } from '../page-not-found/PageNotFound' import { BreadcrumbsGenerator } from '../shared/hierarchy/BreadcrumbsGenerator' import { FileUploader } from './FileUploader' -import { FileUploadTools } from '../../files/domain/models/FileUploadState' +import { FileUploadState, FileUploadTools } from '../../files/domain/models/FileUploadState' import { uploadFile } from '../../files/domain/useCases/uploadFile' +import { UploadedFiles } from './uploaded-files-list/UploadedFiles' +import { addUploadedFile, addUploadedFiles } from '../../files/domain/useCases/addUploadedFiles' interface UploadDatasetFilesProps { fileRepository: FileRepository @@ -19,7 +21,6 @@ export const UploadDatasetFiles = ({ fileRepository: fileRepository }: UploadDat const { t } = useTranslation('uploadDatasetFiles') const [fileUploaderState, setState] = useState(FileUploadTools.createNewState([])) const [uploadingToCancelMap, setUploadingToCancelMap] = useState(new Map void>()) - const [uploadFinished, setUploadFinished] = useState(new Set()) const [semaphore, setSemaphore] = useState(new Set()) const sleep = (delay: number) => new Promise((res) => setTimeout(res, delay)) @@ -43,7 +44,6 @@ export const UploadDatasetFiles = ({ fileRepository: fileRepository }: UploadDat const fileUploadFinished = (file: File) => { const key = FileUploadTools.key(file) - setUploadFinished((x) => x.add(key)) setUploadingToCancelMap((x) => { x.delete(key) return x @@ -51,11 +51,18 @@ export const UploadDatasetFiles = ({ fileRepository: fileRepository }: UploadDat releaseSemaphore(file) } + const canUpload = (file: File) => + !uploadingToCancelMap.has(FileUploadTools.key(file)) && + !FileUploadTools.get(file, fileUploaderState).failed && + !FileUploadTools.get(file, fileUploaderState).done + const uploadOneFile = (file: File) => { - const key = FileUploadTools.key(file) - if (uploadingToCancelMap.has(key) || uploadFinished.has(key)) { + // sanity check: should not happen + /* istanbul ignore next */ + if (!canUpload(file)) { return } + const key = FileUploadTools.key(file) setState(FileUploadTools.showProgressBar(file, fileUploaderState)) const cancel = uploadFile( fileRepository, @@ -64,40 +71,84 @@ export const UploadDatasetFiles = ({ fileRepository: fileRepository }: UploadDat () => { setState(FileUploadTools.done(file, fileUploaderState)) fileUploadFinished(file) + addUploadedFile( + fileRepository, + dataset?.persistentId as string, + file, + FileUploadTools.get(file, fileUploaderState).storageId as string, + () => {} + ) }, () => { setState(FileUploadTools.failed(file, fileUploaderState)) fileUploadFinished(file) }, - (now) => setState(FileUploadTools.progress(file, now, fileUploaderState)) + (now) => setState(FileUploadTools.progress(file, now, fileUploaderState)), + (storageId) => setState(FileUploadTools.storageId(file, storageId, fileUploaderState)) ) setUploadingToCancelMap((x) => x.set(key, cancel)) } const upload = async (files: File[]) => { for (const file of files) { - const key = FileUploadTools.key(file) - if (!uploadingToCancelMap.has(key) && !uploadFinished.has(key)) { + if (canUpload(file)) { await acquireSemaphore(file) uploadOneFile(file) } } } - const cancelUpload = (file: File) => { + const cleanup = (file: File) => { const key = FileUploadTools.key(file) const cancel = uploadingToCancelMap.get(key) if (cancel) { cancel() - releaseSemaphore(file) } setUploadingToCancelMap((x) => { x.delete(key) return x }) + releaseSemaphore(file) + } + + const cancelUpload = (file: File) => { + cleanup(file) setState(FileUploadTools.removed(file, fileUploaderState)) } + const updateFiles = (fileUploadState: FileUploadState[]) => { + setState((x) => { + fileUploadState.forEach((file) => { + x.state.set(file.key, file) + }) + return { state: x.state, uploaded: x.uploaded } + }) + } + + const cleanFileState = (file: File) => { + cleanup(file) + setState(FileUploadTools.delete(file, fileUploaderState)) + } + + const cleanAllState = () => { + setState((x) => { + Array.from(x.state.values()).forEach((fileUploadState) => { + fileUploadState.removed = true + }) + return { state: x.state, uploaded: x.uploaded } + }) + } + + const addFiles = (state: FileUploadState[]) => { + setIsLoading(true) + const done = () => setIsLoading(false) + addUploadedFiles(fileRepository, dataset?.persistentId as string, state, done) + cleanAllState() + } + + const saveDisabled = () => + Array.from(fileUploaderState.state.values()).some((x) => !(x.failed || x.done || x.removed)) + useEffect(() => { setIsLoading(isLoading) }, [isLoading, setIsLoading]) @@ -125,6 +176,15 @@ export const UploadDatasetFiles = ({ fileRepository: fileRepository }: UploadDat selectText={t('select')} fileUploaderState={fileUploaderState} cancelUpload={cancelUpload} + cleanFileState={cleanFileState} + /> + diff --git a/src/sections/upload-dataset-files/uploaded-files-list/UploadedFiles.tsx b/src/sections/upload-dataset-files/uploaded-files-list/UploadedFiles.tsx new file mode 100644 index 000000000..afd54715d --- /dev/null +++ b/src/sections/upload-dataset-files/uploaded-files-list/UploadedFiles.tsx @@ -0,0 +1,188 @@ +import { Button, Card, Form } from '@iqss/dataverse-design-system' +import cn from 'classnames' +import { X } from 'react-bootstrap-icons' +import { FileUploadState } from '../../../files/domain/models/FileUploadState' +import styles from '../FileUploader.module.scss' +import { FormEvent, useState } from 'react' +import { FileForm } from './file-form/FileForm' +import { FilesHeader } from './files-header/FilesHeader' +import { RestrictionModal, RestrictionModalResult } from './restriction-modal/RestrictionModal' +import { useTranslation } from 'react-i18next' +import { AddTagsModal, AddTagsModalResult } from './add-tags-modal/AddTagsModal' + +interface DatasetFilesProps { + fileUploadState: FileUploadState[] + cancelTitle: string + saveDisabled: boolean + updateFiles: (file: FileUploadState[]) => void + cleanup: () => void + addFiles: (fileUploadState: FileUploadState[]) => void +} + +export function UploadedFiles({ + fileUploadState, + cancelTitle, + saveDisabled, + updateFiles, + cleanup, + addFiles +}: DatasetFilesProps) { + const { t } = useTranslation('uploadDatasetFiles') + const [selected, setSelected] = useState(new Set()) + const [filesToRestrict, setFilesToRestrict] = useState([]) + const [filesToAddTagsTo, setFilesToAddTagsTo] = useState([]) + const [tagOptions, setTagOptions] = useState([ + t('tags.documentation'), + t('tags.data'), + t('tags.code') + ]) + const [terms, setTerms] = useState('') + const [requestAccess, setRequestAccess] = useState(true) + const [showRestrictionModal, setShowRestrictionModal] = useState(false) + const [showAddTagsModal, setShowAddTagsModal] = useState(false) + const updateFilesRestricted = (files: FileUploadState[], restricted: boolean) => { + if (restricted) { + setFilesToRestrict(files) + setShowRestrictionModal(true) + } else { + files.forEach((file) => (file.restricted = false)) + updateFiles(files) + } + } + const restrict = (res: RestrictionModalResult) => { + if (res.saved) { + setTerms(res.terms) + setRequestAccess(res.requestAccess) + filesToRestrict.forEach((file) => (file.restricted = true)) + updateFiles(filesToRestrict) + } + setShowRestrictionModal(false) + } + const addTags = (res: AddTagsModalResult) => { + if (res.saved) { + const files = filesToAddTagsTo.map((file) => { + res.tags.forEach((t) => { + if (!file.tags.includes(t)) file.tags.push(t) + }) + return file + }) + updateFiles(files) + } + setShowAddTagsModal(false) + } + const deleteFile = (file: FileUploadState) => { + file.removed = true + setSelected((x) => { + x.delete(file) + return x + }) + updateFiles([file]) + } + const updateSelected = (file: FileUploadState) => { + setSelected((current) => { + if (current.has(file)) { + current.delete(file) + } else { + current.add(file) + } + return new Set(current) + }) + } + const save = () => { + addFiles(fileUploadState) + cleanup() + } + + return ( + <> + + + + + ) +} diff --git a/src/sections/upload-dataset-files/uploaded-files-list/add-tags-modal/AddTagsModal.module.scss b/src/sections/upload-dataset-files/uploaded-files-list/add-tags-modal/AddTagsModal.module.scss new file mode 100644 index 000000000..bbf815fe6 --- /dev/null +++ b/src/sections/upload-dataset-files/uploaded-files-list/add-tags-modal/AddTagsModal.module.scss @@ -0,0 +1,26 @@ +@import "node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module"; + +.add_tags_form { + padding-block: 0.5em; +} + +.tags { + display: flex; +} + +.tags_select{ + width: 100%; +} + +.tag_options { + padding-block: 0.5em; +} + +.tag_info { + padding-block: 0.5em; + color: $dv-subtext-color; +} + +.apply_button { + height: 2.4rem; +} diff --git a/src/sections/upload-dataset-files/uploaded-files-list/add-tags-modal/AddTagsModal.tsx b/src/sections/upload-dataset-files/uploaded-files-list/add-tags-modal/AddTagsModal.tsx new file mode 100644 index 000000000..03df1aca7 --- /dev/null +++ b/src/sections/upload-dataset-files/uploaded-files-list/add-tags-modal/AddTagsModal.tsx @@ -0,0 +1,135 @@ +import { Button, Col, Form, Modal, Badge } from '@iqss/dataverse-design-system' +import styles from './AddTagsModal.module.scss' +import { FormEvent, useState, KeyboardEvent, useId } from 'react' +import { useTranslation } from 'react-i18next' +import { Plus, X } from 'react-bootstrap-icons' + +interface AddTagsModalProps { + show: boolean + availableTags: string[] + setTagOptions: (newTags: string[]) => void + update: (res: AddTagsModalResult) => void +} + +export interface AddTagsModalResult { + saved: boolean + tags: string[] +} + +export function AddTagsModal({ show, availableTags, setTagOptions, update }: AddTagsModalProps) { + const { t } = useTranslation('uploadDatasetFiles') + const [tagsToAdd, setTagsToAdd] = useState([]) + const [newCustomTag, setNewCustomTag] = useState('') + const handleClose = (saved: boolean) => { + update({ saved: saved, tags: tagsToAdd }) + setTagsToAdd([]) + } + const tagsSelectId = useId() + const addTagOption = () => { + if (newCustomTag && !availableTags.includes(newCustomTag)) { + setTagOptions([...availableTags, newCustomTag]) + setTagsToAdd((current) => [...current, newCustomTag]) + setNewCustomTag('') + } + } + const handleEnter = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + addTagOption() + event.preventDefault() + } + } + return ( + handleClose(false)} size="lg"> + + {t('addTags.title')} + + +
+
+ + + {t('fileForm.tags')} + + +
+ {tagsToAdd.map((tagName) => ( + setTagsToAdd(tagsToAdd.filter((x) => x !== tagName))} + data-testid="tag-to-add"> + + {tagName} + + + + ))} +
+ +
+ + + {t('tags.customFileTag')} + + +
{t('tags.creatingNewTag')}
+
+ ) => + setNewCustomTag(event.currentTarget.value) + } + /> + +
+
+ {t('tags.availableTagOptions')} +
+ {availableTags.map((o) => ( + { + if (!tagsToAdd.includes(o)) setTagsToAdd([...tagsToAdd, o]) + }} + data-testid={o}> + + {o} + + + + ))} +
+
+ +
+
+
+
+ + + + +
+ ) +} diff --git a/src/sections/upload-dataset-files/uploaded-files-list/file-form/FileForm.module.scss b/src/sections/upload-dataset-files/uploaded-files-list/file-form/FileForm.module.scss new file mode 100644 index 000000000..952d71834 --- /dev/null +++ b/src/sections/upload-dataset-files/uploaded-files-list/file-form/FileForm.module.scss @@ -0,0 +1,17 @@ +.file_form { + padding: 0.25em; +} + +.tags { + display: flex; + gap: 0.5rem; +} + +.edit_tags_btn { + margin: 0; + padding-inline: 8px; +} + +.tags_select{ + width: 100%; +} diff --git a/src/sections/upload-dataset-files/uploaded-files-list/file-form/FileForm.tsx b/src/sections/upload-dataset-files/uploaded-files-list/file-form/FileForm.tsx new file mode 100644 index 000000000..783743443 --- /dev/null +++ b/src/sections/upload-dataset-files/uploaded-files-list/file-form/FileForm.tsx @@ -0,0 +1,142 @@ +import { Badge, Button, Col, Form } from '@iqss/dataverse-design-system' +import { FileUploadState } from '../../../../files/domain/models/FileUploadState' +import styles from './FileForm.module.scss' +import { FormEvent, useState, KeyboardEvent, useId } from 'react' +import { useTranslation } from 'react-i18next' +import { Plus, X } from 'react-bootstrap-icons' + +interface FileFormProps { + file: FileUploadState + availableTags: string[] + updateFiles: (file: FileUploadState[]) => void + setTagOptions: (tags: string[]) => void +} + +export function FileForm({ file, availableTags, updateFiles, setTagOptions }: FileFormProps) { + const { t } = useTranslation('uploadDatasetFiles') + const [tag, setTag] = useState('') + const tagsSelectId = useId() + const updateFileName = (file: FileUploadState, updated: string) => { + file.fileName = updated + updateFiles([file]) + } + const updateFileDir = (file: FileUploadState, updated: string) => { + file.fileDir = updated + updateFiles([file]) + } + const updateFileDescription = (file: FileUploadState, updated: string) => { + file.description = updated + updateFiles([file]) + } + const toggleTag = (file: FileUploadState, tag: string) => { + if (file.tags.includes(tag)) { + delete file.tags[file.tags.indexOf(tag)] + } else { + file.tags.push(tag) + } + updateFiles([file]) + } + const handleEnter = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + addTagOption() + event.preventDefault() + } + } + const addTagOption = () => { + if (tag && !availableTags.includes(tag)) { + setTagOptions([...availableTags, tag]) + file.tags.push(tag) + setTag('') + updateFiles([file]) + } + } + + return ( +
+
+ + + {t('fileForm.fileName')} + + + ) => + updateFileName(file, event.currentTarget.value) + } + /> + + + + + {t('fileForm.filePath')} + + + ) => + updateFileDir(file, event.currentTarget.value) + } + /> + + + + + {t('fileForm.description')} + + + ) => + updateFileDescription(file, event.currentTarget.value) + } + /> + + + + + {t('fileForm.tags')} + + +
+
+ {availableTags.map((o) => ( + toggleTag(file, o)}> + + {o} + {file.tags.includes(o) ? : } + + + ))} +
+
+ ) => + setTag(event.currentTarget.value) + } + /> + +
+
+ +
+
+
+ ) +} diff --git a/src/sections/upload-dataset-files/uploaded-files-list/files-header/FilesHeader.module.scss b/src/sections/upload-dataset-files/uploaded-files-list/files-header/FilesHeader.module.scss new file mode 100644 index 000000000..47760326c --- /dev/null +++ b/src/sections/upload-dataset-files/uploaded-files-list/files-header/FilesHeader.module.scss @@ -0,0 +1,25 @@ +.icon_pencil { + margin-right: 0.3rem; + margin-bottom: 0.2rem; +} + +.selected_files_checkbox { + display: inline-flex; + align-items: center; + padding-right: 2rem; + padding-left: 0.5rem; +} + +.selected_files_info { + position: absolute; + right: 0; + display: inline-flex; + align-items: center; + justify-content: flex-end; +} + +.uploaded_files_info { + display: inline-flex; + align-items: center; + padding-left: 1em; +} diff --git a/src/sections/upload-dataset-files/uploaded-files-list/files-header/FilesHeader.tsx b/src/sections/upload-dataset-files/uploaded-files-list/files-header/FilesHeader.tsx new file mode 100644 index 000000000..2152a8442 --- /dev/null +++ b/src/sections/upload-dataset-files/uploaded-files-list/files-header/FilesHeader.tsx @@ -0,0 +1,115 @@ +import { + Button, + Card, + DropdownButton, + DropdownButtonItem, + Form +} from '@iqss/dataverse-design-system' +import { PencilFill } from 'react-bootstrap-icons' +import { FileUploadState } from '../../../../files/domain/models/FileUploadState' +import styles from './FilesHeader.module.scss' +import { Dispatch, SetStateAction, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface FilesHeaderProps { + fileUploadState: FileUploadState[] + selected: Set + setSelected: Dispatch>> + saveDisabled: boolean + updateFiles: (file: FileUploadState[]) => void + cleanup: () => void + addFiles: (fileUploadState: FileUploadState[]) => void + updateFilesRestricted: (fileUploadState: FileUploadState[], restricted: boolean) => void + showAddTagsModal: () => void +} + +export function FilesHeader({ + fileUploadState, + selected, + setSelected, + saveDisabled, + updateFiles, + cleanup, + addFiles, + updateFilesRestricted, + showAddTagsModal +}: FilesHeaderProps) { + const { t } = useTranslation('uploadDatasetFiles') + const [saving, setSaving] = useState(false) + const [selectAllChecked, setSelectAllChecked] = useState(false) + const save = () => { + setSaving(true) + addFiles(fileUploadState) + cleanup() + setSaving(false) + } + const all = () => { + setSelected((current) => { + if (current.size === fileUploadState.length) { + return new Set() + } else { + return new Set(fileUploadState) + } + }) + } + const updateRestriction = (restrict: boolean) => { + updateFilesRestricted(Array.from(selected), restrict) + } + const deleteSelected = () => { + const res = Array.from(selected).map((x) => { + x.removed = true + return x + }) + setSelected(new Set()) + updateFiles(res) + } + + useEffect(() => { + setSelectAllChecked(selected.size === fileUploadState.length) + }, [selected, fileUploadState]) + + return ( + + + + + + + + {t('filesHeader.filesUploaded', { count: fileUploadState.length })} + + + + ) +} diff --git a/src/sections/upload-dataset-files/uploaded-files-list/restriction-modal/RestrictionModal.module.scss b/src/sections/upload-dataset-files/uploaded-files-list/restriction-modal/RestrictionModal.module.scss new file mode 100644 index 000000000..d2367d31b --- /dev/null +++ b/src/sections/upload-dataset-files/uploaded-files-list/restriction-modal/RestrictionModal.module.scss @@ -0,0 +1,12 @@ +@import "node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module"; + +.restriction_form { + padding-top: 0.5em; + padding-bottom: 0.5em; +} + +.restriction_info { + padding-top: 0.5em; + padding-bottom: 0.5em; + color: $dv-subtext-color; +} \ No newline at end of file diff --git a/src/sections/upload-dataset-files/uploaded-files-list/restriction-modal/RestrictionModal.tsx b/src/sections/upload-dataset-files/uploaded-files-list/restriction-modal/RestrictionModal.tsx new file mode 100644 index 000000000..ea5ebf6c5 --- /dev/null +++ b/src/sections/upload-dataset-files/uploaded-files-list/restriction-modal/RestrictionModal.tsx @@ -0,0 +1,90 @@ +import { Button, Col, Form, Modal } from '@iqss/dataverse-design-system' +import styles from './RestrictionModal.module.scss' +import { FormEvent, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface RestrictionModalProps { + defaultRequestAccess: boolean + defaultTerms: string + show: boolean + update: (res: RestrictionModalResult) => void +} + +export interface RestrictionModalResult { + saved: boolean + terms: string + requestAccess: boolean +} + +export function RestrictionModal({ + defaultRequestAccess, + defaultTerms, + show, + update +}: RestrictionModalProps) { + const { t } = useTranslation('uploadDatasetFiles') + const [terms, setTerms] = useState(defaultTerms) + const [requestAccess, setRequestAccess] = useState(defaultRequestAccess) + const handleClose = (saved: boolean) => + update({ saved: saved, terms: terms, requestAccess: requestAccess }) + + return ( + handleClose(false)} size="lg"> + + {t('restriction.restrictAccess')} + + +
+
+

{t('restriction.restrictionInfoP1')}

+

{t('restriction.restrictionInfoP2')}

+
+
+ + + {t('restriction.restrictAccess')} + + + ) => + setRequestAccess(event.currentTarget.checked) + } + /> + + + + + {t('restriction.termsOfAccess')} + + + ) => + setTerms(event.currentTarget.value) + } + /> + + +
+
+
+ + + + +
+ ) +} 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/src/stories/file/FileMockFailedUploadRepository.ts b/src/stories/file/FileMockFailedUploadRepository.ts index 112153558..8b0687d0b 100644 --- a/src/stories/file/FileMockFailedUploadRepository.ts +++ b/src/stories/file/FileMockFailedUploadRepository.ts @@ -1,13 +1,14 @@ import { FileRepository } from '../../files/domain/repositories/FileRepository' import { FileMockRepository } from './FileMockRepository' -import { FileHolder } from '../../files/domain/repositories/File' +import { FileHolder } from '../../files/domain/models/FileHolder' export class FileMocFailedRepository extends FileMockRepository implements FileRepository { uploadFile( _datasetId: number | string, _file: FileHolder, _progress: (now: number) => void, - _abortController: AbortController + _abortController: AbortController, + _storageIdSetter: (storageId: string) => void ): Promise { return Promise.reject(new Error('fail')) } diff --git a/src/stories/file/FileMockRepository.ts b/src/stories/file/FileMockRepository.ts index 9c9cb092c..48e7064b9 100644 --- a/src/stories/file/FileMockRepository.ts +++ b/src/stories/file/FileMockRepository.ts @@ -12,7 +12,8 @@ import { File } from '../../files/domain/models/File' import { FilePreview } from '../../files/domain/models/FilePreview' import { FakerHelper } from '../../../tests/component/shared/FakerHelper' import { FilesWithCount } from '../../files/domain/models/FilesWithCount' -import { FileHolder } from '../../files/domain/repositories/File' +import { FileHolder } from '../../files/domain/models/FileHolder' +import { FileUploadState } from '../../files/domain/models/FileUploadState' export class FileMockRepository implements FileRepository { constructor(public readonly fileMock?: File) {} @@ -87,7 +88,8 @@ export class FileMockRepository implements FileRepository { _datasetId: number | string, _file: FileHolder, progress: (now: number) => void, - abortController: AbortController + abortController: AbortController, + storageIdSetter: (storageId: string) => void ): Promise { let t: NodeJS.Timeout const sleep = (delay: number) => new Promise((res) => (t = setTimeout(res, delay))) @@ -100,7 +102,28 @@ export class FileMockRepository implements FileRepository { progress(now) //console.log(FileUploadTools.key(_file.file) + ': ' + String(now)) } + storageIdSetter('some-storage-identifier') } return res() } + + addUploadedFiles(_datasetId: number | string, _files: FileUploadState[]): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve() + }, FakerHelper.loadingTimout()) + }) + } + + addUploadedFile( + _datasetId: number | string, + _file: FileHolder, + _storageId: string + ): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve() + }, FakerHelper.loadingTimout()) + }) + } } 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/component/sections/upload-dataset-files/UploadDatasetFiles.spec.tsx b/tests/component/sections/upload-dataset-files/UploadDatasetFiles.spec.tsx index 4b16b5a8f..7de43e55a 100644 --- a/tests/component/sections/upload-dataset-files/UploadDatasetFiles.spec.tsx +++ b/tests/component/sections/upload-dataset-files/UploadDatasetFiles.spec.tsx @@ -63,7 +63,8 @@ describe('UploadDatasetFiles', () => { ) cy.findByText('Select files to add').should('exist') - cy.findByText('Drag and drop files here.').should('exist') + cy.findByTestId('drag-and-drop').as('dnd') + cy.get('@dnd').should('exist') }) it('renders the files being uploaded', () => { @@ -111,10 +112,6 @@ describe('UploadDatasetFiles', () => { cy.findAllByTitle('Cancel upload').should('exist') cy.findAllByRole('progressbar').should('exist') cy.findByText('Select files to add').should('exist') - cy.findByText('Drag and drop files here.').should('not.exist') - // wait for upload to finish - cy.findByText('users2.json').should('not.exist') - cy.findByText('users3.json').should('not.exist') }) it('renders file upload by clicking add button', () => { @@ -180,8 +177,7 @@ describe('UploadDatasetFiles', () => { cy.findAllByRole('progressbar').should('have.length', 2) cy.findByText('Select files to add').should('exist') // wait for upload to finish - cy.findByText('users3.json').should('not.exist') - cy.findByText('users1.json').should('not.exist') + cy.findByText('Cancel').should('exist') cy.get('@dnd').selectFile( { fileName: 'users1.json', contents: [{ name: 'John Doe the 1st' }] }, { action: 'drag-drop' } @@ -190,8 +186,8 @@ describe('UploadDatasetFiles', () => { { fileName: 'users3.json', contents: [{ name: 'John Doe the 3rd' }] }, { action: 'drag-drop' } ) - cy.findByText('users3.json').should('not.exist') - cy.findByText('users1.json').should('not.exist') + cy.findByText('users3.json').should('have.length', 1) + cy.findByText('users1.json').should('have.length', 1) }) it('prevents double uploads', () => { @@ -270,8 +266,256 @@ describe('UploadDatasetFiles', () => { }) cy.findByText('users20.json').should('exist') cy.findAllByRole('progressbar').should('have.length', 6) - cy.findAllByTitle('Cancel upload').should('have.length', 20) - cy.findAllByRole('progressbar').should('have.length', 6) cy.findByText('Select files to add').should('exist') }) + + it('saves uploaded files', () => { + const testDataset = DatasetMother.create() + + mountWithDataset(, testDataset) + + cy.findByTestId('drag-and-drop').as('dnd') + cy.get('@dnd').should('exist') + + cy.get('@dnd').selectFile( + { fileName: 'users1.json', contents: [{ name: 'John Doe the 1st' }] }, + { action: 'drag-drop' } + ) + cy.get('@dnd').selectFile( + { fileName: 'users2.json', contents: [{ name: 'John Doe the 2nd' }] }, + { action: 'drag-drop' } + ) + cy.findByText('users1.json').should('exist') + cy.findByText('users2.json').should('exist') + cy.findAllByTitle('Cancel upload').should('have.length', 2) + cy.findAllByRole('progressbar').should('have.length', 2) + cy.findByText('Select files to add').should('exist') + // wait for upload to finish + cy.findByText('Cancel').should('exist') + cy.findAllByTitle('Save').click() + cy.findByText('users1.json').should('not.exist') + cy.findByText('users2.json').should('not.exist') + cy.get('input[value="users1.json"]').should('not.exist') + cy.get('input[value="users2.json"]').should('not.exist') + }) + + it('saves uploaded files 2', () => { + const testDataset = DatasetMother.create() + + mountWithDataset(, testDataset) + + cy.findByTestId('drag-and-drop').as('dnd') + cy.get('@dnd').should('exist') + + cy.get('@dnd').selectFile( + { fileName: 'users1.json', contents: [{ name: 'John Doe the 1st' }] }, + { action: 'drag-drop' } + ) + cy.get('@dnd').selectFile( + { fileName: 'users2.json', contents: [{ name: 'John Doe the 2nd' }] }, + { action: 'drag-drop' } + ) + cy.findByText('users1.json').should('exist') + cy.findByText('users2.json').should('exist') + cy.findAllByTitle('Cancel upload').should('have.length', 2) + cy.findAllByRole('progressbar').should('have.length', 2) + cy.findByText('Select files to add').should('exist') + // wait for upload to finish + cy.findByText('Cancel').should('exist') + cy.findAllByTitle('Save uploaded files').click() + cy.findByText('users1.json').should('not.exist') + cy.findByText('users2.json').should('not.exist') + cy.get('input[value="users1.json"]').should('not.exist') + cy.get('input[value="users2.json"]').should('not.exist') + }) + + it('cancels saving uploaded files', () => { + const testDataset = DatasetMother.create() + + mountWithDataset(, testDataset) + + cy.findByTestId('drag-and-drop').as('dnd') + cy.get('@dnd').should('exist') + + cy.get('@dnd').selectFile( + { fileName: 'users1.json', contents: [{ name: 'John Doe the 1st' }] }, + { action: 'drag-drop' } + ) + cy.get('@dnd').selectFile( + { fileName: 'users2.json', contents: [{ name: 'John Doe the 2nd' }] }, + { action: 'drag-drop' } + ) + cy.findByText('users1.json').should('exist') + cy.findByText('users2.json').should('exist') + cy.findAllByTitle('Cancel upload').should('have.length', 2) + cy.findAllByRole('progressbar').should('have.length', 2) + cy.findByText('Select files to add').should('exist') + // wait for upload to finish + cy.findByText('Cancel').should('exist') + cy.findByText('Cancel').click() + cy.findByText('users1.json').should('not.exist') + cy.findByText('users2.json').should('not.exist') + cy.get('input[value="users1.json"]').should('not.exist') + cy.get('input[value="users2.json"]').should('not.exist') + }) + + it('deletes uploaded files', () => { + const testDataset = DatasetMother.create() + + mountWithDataset(, testDataset) + + cy.findByTestId('drag-and-drop').as('dnd') + cy.get('@dnd').should('exist') + + cy.get('@dnd').selectFile( + { fileName: 'users1.json', contents: [{ name: 'John Doe the 1st' }] }, + { action: 'drag-drop' } + ) + cy.get('@dnd').selectFile( + { fileName: 'users2.json', contents: [{ name: 'John Doe the 2nd' }] }, + { action: 'drag-drop' } + ) + cy.findByText('users1.json').should('exist') + cy.findByText('users2.json').should('exist') + cy.findAllByTitle('Cancel upload').should('have.length', 2) + cy.findAllByRole('progressbar').should('have.length', 2) + cy.findByText('Select files to add').should('exist') + // wait for upload to finish + cy.findByText('Cancel').should('exist') + cy.findAllByTitle('Delete').first().parent().click() + cy.findByText('users1.json').should('not.exist') + cy.get('input[value="users1.json"]').should('not.exist') + cy.get('input[value="users2.json"]').should('exist') + }) + + it('restrict uploaded file', () => { + const testDataset = DatasetMother.create() + + mountWithDataset(, testDataset) + + cy.findByTestId('drag-and-drop').as('dnd') + cy.get('@dnd').should('exist') + + cy.get('@dnd').selectFile( + { fileName: 'users1.json', contents: [{ name: 'John Doe the 1st' }] }, + { action: 'drag-drop' } + ) + cy.get('@dnd').selectFile( + { fileName: 'users2.json', contents: [{ name: 'John Doe the 2nd' }] }, + { action: 'drag-drop' } + ) + // wait for upload to finish + cy.findByText('2 files uploaded').should('exist') + cy.get('[type="checkbox"]').last().click() + cy.findByText('Save Changes').first().click() + cy.get('[type="checkbox"]').last().should('be.checked') + cy.get('[type="checkbox"]').last().click() + cy.get('[type="checkbox"]').last().should('not.be.checked') + cy.get('[type="checkbox"]').first().click() + cy.findByText('Edit files').first().click() + cy.findByText('Restrict').first().click() + cy.findByText('Save Changes').first().click() + cy.get('[type="checkbox"]').last().should('be.checked') + cy.findByText('Edit files').first().click() + cy.findByText('Unrestrict').first().click() + cy.get('[type="checkbox"]').last().should('not.be.checked') + cy.findByText('Edit files').first().click() + cy.findByText('Restrict').first().click() + cy.findByLabelText('Close').click() + cy.get('[type="checkbox"]').last().should('not.be.checked') + cy.findByText('Edit files').first().click() + cy.findByText('Restrict').first().click() + cy.get('[type="checkbox"]').last().click() + cy.get('textarea').last().type('Hello, World!') + cy.findByText('Save Changes').first().click() + cy.get('[type="checkbox"]').last().should('be.checked') + cy.findByText('Edit files').first().click() + cy.findByText('Restrict').first().click() + cy.findByTitle('Cancel Changes').click() + cy.get('[type="checkbox"]').last().should('be.checked') + cy.get('[type="checkbox"]').first().click() + cy.get('[type="checkbox"]').first().click() + cy.findByText('Edit files').first().click() + cy.findByTitle('Delete selected').click() + cy.get('input[value="users1.json"]').should('not.exist') + cy.get('input[value="users2.json"]').should('not.exist') + }) + + it('edit tags', () => { + const testDataset = DatasetMother.create() + + mountWithDataset(, testDataset) + + cy.findByTestId('drag-and-drop').as('dnd') + cy.get('@dnd').should('exist') + + cy.get('@dnd').selectFile( + { fileName: 'users1.json', contents: [{ name: 'John Doe the 1st' }] }, + { action: 'drag-drop' } + ) + // wait for upload to finish + cy.findByText('1 file uploaded').should('exist') + cy.get('input[placeholder="Add new custom file tag..."]').first().type('Hello, World!') + cy.findByTestId('add-custom-tag').click() + cy.findByText('Hello, World!').should('exist') + cy.findByText('Hello, World!').click() + cy.findByText('Hello, World!').click() + cy.get('input[placeholder="Add new custom file tag..."]').first().type('Hello, World 2!{enter}') + cy.get('input[type=text]').first().type('Hello, World!') + cy.get('input[placeholder="File path"]').first().type('Hello, World!') + cy.get('textarea').first().type('Hello, World!') + }) + + it('click test', () => { + const testDataset = DatasetMother.create() + + mountWithDataset(, testDataset) + + cy.findByTestId('drag-and-drop').as('dnd') + cy.get('@dnd').should('exist') + + cy.get('@dnd').selectFile( + { fileName: 'users1.json', contents: [{ name: 'John Doe the 1st' }] }, + { action: 'drag-drop' } + ) + // wait for upload to finish + cy.findByText('1 file uploaded').should('exist') + cy.findByTestId('select_file_checkbox').first().click() + cy.findByTestId('select_file_checkbox').first().click() + }) + + it('add tags', () => { + const testDataset = DatasetMother.create() + + mountWithDataset(, testDataset) + + cy.findByTestId('drag-and-drop').as('dnd') + cy.get('@dnd').should('exist') + + cy.get('@dnd').selectFile( + { fileName: 'users1.json', contents: [{ name: 'John Doe the 1st' }] }, + { action: 'drag-drop' } + ) + // wait for upload to finish + cy.findByText('1 file uploaded').should('exist') + cy.get('[type="checkbox"]').first().click() + cy.findByText('Edit files').first().click() + cy.findByText('Add tags').first().click() + cy.findByTitle('Custom tag').type('Hello, World!') + cy.findByText('Apply').click() + cy.findByTitle('Cancel Changes').click() + cy.findByText('Edit files').first().click() + cy.findByText('Add tags').first().click() + cy.findByTitle('Custom tag').type('Hello, World 2!{enter}') + cy.findByLabelText('Close').click() + cy.findByText('Edit files').first().click() + cy.findByText('Add tags').first().click() + cy.findByTitle('Custom tag').type('Hello, World 3!{enter}') + cy.findByTestId('tag-to-add').first().click() + cy.findByTestId('Data').click() + cy.findByTestId('Data').click() + cy.findByTestId('Data').click() + cy.findByTestId('Hello, World 3!').click() + cy.findByTitle('Save Changes').click() + }) }) 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') + }) +})