diff --git a/packages/design-system/src/lib/components/form/Form.tsx b/packages/design-system/src/lib/components/form/Form.tsx index 162a9cb70..065f18657 100644 --- a/packages/design-system/src/lib/components/form/Form.tsx +++ b/packages/design-system/src/lib/components/form/Form.tsx @@ -5,13 +5,14 @@ import { FormGroupWithMultipleFields } from './form-group-multiple-fields/FormGr import { FormInputGroup } from './form-group/form-input-group/FormInputGroup' interface FormProps { + className?: string validated?: boolean onSubmit?: (event: FormEvent) => void } -function Form({ validated, onSubmit, children }: PropsWithChildren) { +function Form({ validated, onSubmit, children, className }: PropsWithChildren) { return ( - + {children} ) diff --git a/packages/design-system/src/lib/components/form/form-group/form-element/FormInput.tsx b/packages/design-system/src/lib/components/form/form-group/form-element/FormInput.tsx index 6712c86c4..7f2cbe9bc 100644 --- a/packages/design-system/src/lib/components/form/form-group/form-element/FormInput.tsx +++ b/packages/design-system/src/lib/components/form/form-group/form-element/FormInput.tsx @@ -8,17 +8,19 @@ interface FormInputProps extends React.HTMLAttributes { type?: 'text' | 'email' | 'password' readOnly?: boolean withinMultipleFieldsGroup?: boolean + name?: string } export function FormInput({ type = 'text', + name, readOnly, withinMultipleFieldsGroup, ...props }: FormInputProps) { return ( - + ) } diff --git a/packages/design-system/src/lib/index.ts b/packages/design-system/src/lib/index.ts index 0fcae5a91..0badda8a0 100644 --- a/packages/design-system/src/lib/index.ts +++ b/packages/design-system/src/lib/index.ts @@ -23,3 +23,4 @@ export { Icon } from './components/icon/Icon' export { IconName } from './components/icon/IconName' export { Tooltip } from './components/tooltip/Tooltip' export { Pagination } from './components/pagination/Pagination' +export { RequiredInputSymbol } from './components/form/required-input-symbol/RequiredInputSymbol' diff --git a/public/locales/en/createDataset.json b/public/locales/en/createDataset.json new file mode 100644 index 000000000..2e0d509ff --- /dev/null +++ b/public/locales/en/createDataset.json @@ -0,0 +1,22 @@ +{ + "pageTitle": "Create Dataset", + "metadataTip": { + "title": "Metadata Tip", + "content": "After adding the dataset, click the Edit Dataset button to add more metadata." + }, + "requiredFields": "Asterisks indicate required fields", + "datasetForm": { + "title": "Title", + "status": { + "submitting": "Submitting...", + "success": "Form submitted successfully!", + "failed": "Error: Submission failed." + } + }, + "datasetFormStates": { + "submitting": "Form Submitting", + "submissionSuccess": "Form submission successful" + }, + "saveButton": "Save Dataset", + "cancelButton": "Cancel" +} diff --git a/src/Router.tsx b/src/Router.tsx index 73deb4eef..ddaddee95 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -2,6 +2,8 @@ import { createBrowserRouter, RouterProvider } from 'react-router-dom' import { Layout } from './sections/layout/Layout' import { Route } from './sections/Route.enum' import { DatasetFactory } from './sections/dataset/DatasetFactory' +import { PageNotFound } from './sections/page-not-found/PageNotFound' +import { CreateDatasetFactory } from './sections/create-dataset/CreateDatasetFactory' import { FileFactory } from './sections/file/FileFactory' import { HomeFactory } from './sections/home/HomeFactory' @@ -10,6 +12,7 @@ const router = createBrowserRouter( { path: '/', element: , + errorElement: , children: [ { path: Route.HOME, @@ -19,6 +22,10 @@ const router = createBrowserRouter( path: Route.DATASETS, element: DatasetFactory.create() }, + { + path: Route.CREATE_DATASET, + element: CreateDatasetFactory.create() + }, { path: Route.FILES, element: FileFactory.create() diff --git a/src/sections/assets/logo.svg b/src/assets/logo.svg similarity index 100% rename from src/sections/assets/logo.svg rename to src/assets/logo.svg diff --git a/src/sections/assets/variables.scss b/src/assets/variables.scss similarity index 100% rename from src/sections/assets/variables.scss rename to src/assets/variables.scss diff --git a/src/dataset/domain/models/DatasetFormFields.ts b/src/dataset/domain/models/DatasetFormFields.ts new file mode 100644 index 000000000..91356a2b0 --- /dev/null +++ b/src/dataset/domain/models/DatasetFormFields.ts @@ -0,0 +1,3 @@ +export interface DatasetFormFields { + createDatasetTitle: string +} diff --git a/src/dataset/domain/models/DatasetValidationResponse.ts b/src/dataset/domain/models/DatasetValidationResponse.ts new file mode 100644 index 000000000..136f7b2d4 --- /dev/null +++ b/src/dataset/domain/models/DatasetValidationResponse.ts @@ -0,0 +1,6 @@ +import { DatasetFormFields } from '../models/DatasetFormFields' + +export interface DatasetValidationResponse { + isValid: boolean + errors: Record +} diff --git a/src/dataset/domain/repositories/DatasetRepository.ts b/src/dataset/domain/repositories/DatasetRepository.ts index 0cc96a13d..d2bda4eae 100644 --- a/src/dataset/domain/repositories/DatasetRepository.ts +++ b/src/dataset/domain/repositories/DatasetRepository.ts @@ -2,10 +2,13 @@ import { Dataset } from '../models/Dataset' import { TotalDatasetsCount } from '../models/TotalDatasetsCount' import { DatasetPaginationInfo } from '../models/DatasetPaginationInfo' import { DatasetPreview } from '../models/DatasetPreview' +import { DatasetFormFields } from '../models/DatasetFormFields' export interface DatasetRepository { getByPersistentId: (persistentId: string, version?: string) => Promise getByPrivateUrlToken: (privateUrlToken: string) => Promise getAll: (paginationInfo: DatasetPaginationInfo) => Promise getTotalDatasetsCount: () => Promise + // Created as placeholder for https://github.com/IQSS/dataverse-frontend/pull/251 + createDataset: (fields: DatasetFormFields) => Promise } diff --git a/src/dataset/domain/useCases/createDataset.ts b/src/dataset/domain/useCases/createDataset.ts new file mode 100644 index 000000000..96c21ad17 --- /dev/null +++ b/src/dataset/domain/useCases/createDataset.ts @@ -0,0 +1,17 @@ +import { DatasetFormFields } from '../models/DatasetFormFields' +import { DatasetRepository } from '../repositories/DatasetRepository' +import { DatasetJSDataverseRepository } from '../../infrastructure/repositories/DatasetJSDataverseRepository' + +const repo = new DatasetJSDataverseRepository() +function createDatasetMockHelper( + datasetRepository: DatasetRepository, + formFieldsToSubmit: DatasetFormFields +): Promise { + return datasetRepository.createDataset(formFieldsToSubmit).catch((error: Error) => { + throw new Error(error.message) + }) +} + +export function createDataset(fields: DatasetFormFields): Promise { + return createDatasetMockHelper(repo, fields) +} diff --git a/src/dataset/domain/useCases/validateDataset.ts b/src/dataset/domain/useCases/validateDataset.ts new file mode 100644 index 000000000..a3548cd36 --- /dev/null +++ b/src/dataset/domain/useCases/validateDataset.ts @@ -0,0 +1,19 @@ +import { DatasetFormFields } from '../models/DatasetFormFields' +import { DatasetValidationResponse } from '../models/DatasetValidationResponse' +const NAME_REQUIRED = 'Name is required' + +export function validateDataset(fieldsToSubmit: DatasetFormFields) { + const errors: Record = { + createDatasetTitle: undefined + } + + if (!fieldsToSubmit.createDatasetTitle) { + errors.createDatasetTitle = NAME_REQUIRED + } + + const validationResponse: DatasetValidationResponse = { + isValid: Object.values(errors).every((error) => error === undefined), + errors + } + return validationResponse +} diff --git a/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts b/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts index ca5794a6b..c59257f4e 100644 --- a/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts +++ b/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts @@ -23,6 +23,7 @@ import { TotalDatasetsCount } from '../../domain/models/TotalDatasetsCount' import { DatasetPaginationInfo } from '../../domain/models/DatasetPaginationInfo' import { DatasetPreview } from '../../domain/models/DatasetPreview' import { JSDatasetPreviewMapper } from '../mappers/JSDatasetPreviewMapper' +import { DatasetFormFields } from '../../domain/models/DatasetFormFields' const includeDeaccessioned = true @@ -135,6 +136,15 @@ export class DatasetJSDataverseRepository implements DatasetRepository { }) } + createDataset(fields: DatasetFormFields): Promise { + const returnMsg = 'Form Data Submitted: ' + JSON.stringify(fields) + return new Promise((resolve) => { + setTimeout(() => { + resolve(returnMsg) + }, 1000) + }) + } + versionToVersionId(version?: string): string | undefined { if (version === 'DRAFT') { return ':draft' diff --git a/src/sections/Route.enum.ts b/src/sections/Route.enum.ts index f2d93694e..247b42b20 100644 --- a/src/sections/Route.enum.ts +++ b/src/sections/Route.enum.ts @@ -4,5 +4,6 @@ export enum Route { LOG_IN = '/loginpage.xhtml?redirectPage=%2Fdataverse.xhtml', LOG_OUT = '/', DATASETS = '/datasets', + CREATE_DATASET = '/datasets/create', FILES = '/files' } diff --git a/src/sections/create-dataset/CreateDatasetFactory.tsx b/src/sections/create-dataset/CreateDatasetFactory.tsx new file mode 100644 index 000000000..f28f860c7 --- /dev/null +++ b/src/sections/create-dataset/CreateDatasetFactory.tsx @@ -0,0 +1,8 @@ +import { CreateDatasetForm } from './CreateDatasetForm' +import { ReactElement } from 'react' + +export class CreateDatasetFactory { + static create(): ReactElement { + return + } +} diff --git a/src/sections/create-dataset/CreateDatasetForm.tsx b/src/sections/create-dataset/CreateDatasetForm.tsx new file mode 100644 index 000000000..e7cb5b93a --- /dev/null +++ b/src/sections/create-dataset/CreateDatasetForm.tsx @@ -0,0 +1,87 @@ +import { ChangeEvent, FormEvent, MouseEvent, useEffect } from 'react' +import { Alert, Button, Col, Form, Row } from '@iqss/dataverse-design-system' +import { useTranslation } from 'react-i18next' +import { RequiredFieldText } from '../shared/form/RequiredFieldText/RequiredFieldText' +import { SeparationLine } from '../shared/layout/SeparationLine/SeparationLine' +import { useCreateDatasetForm, SubmissionStatusEnums } from './useCreateDatasetForm' +import styles from '/src/sections/dataset/Dataset.module.scss' +import { useLoading } from '../loading/LoadingContext' + +export function CreateDatasetForm() { + const { isLoading, setIsLoading } = useLoading() + const { formErrors, submissionStatus, updateFormData, submitFormData, cancelFormSubmit } = + useCreateDatasetForm() + + const { t } = useTranslation('createDataset') + + const handleCreateDatasetFieldChange = (event: ChangeEvent) => { + const { name, value } = event.target + updateFormData({ [name]: value }) + } + + const handleCreateDatasetSubmit = (event: FormEvent) => { + event.preventDefault() + submitFormData() + } + + const handleFormCancel = (event: MouseEvent) => { + event.preventDefault() + cancelFormSubmit() + } + useEffect(() => { + setIsLoading(false) + }, [isLoading]) + + return ( +
+
+

{t('pageTitle')}

+
+ +
+ + {submissionStatus === SubmissionStatusEnums.IsSubmitting && ( +

{t('datasetForm.status.submitting')}

+ )} + {submissionStatus === SubmissionStatusEnums.SubmitComplete && ( +

{t('datasetForm.status.success')}

+ )} + {submissionStatus === SubmissionStatusEnums.Errored && ( +

{t('datasetForm.status.fail')}

+ )} +
) => { + handleCreateDatasetSubmit(event) + }} + className={'create-dataset-form'}> + + + + {t('datasetForm.title')} + + + {formErrors.createDatasetTitle && {formErrors.createDatasetTitle}} + + + + + {t('metadataTip.content')} + + + + +
+
+ ) +} diff --git a/src/sections/create-dataset/useCreateDatasetForm.tsx b/src/sections/create-dataset/useCreateDatasetForm.tsx new file mode 100644 index 000000000..7cc208629 --- /dev/null +++ b/src/sections/create-dataset/useCreateDatasetForm.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { DatasetFormFields } from '../../dataset/domain/models/DatasetFormFields' +import { createDataset } from '../../dataset/domain/useCases/createDataset' +import { validateDataset } from '../../dataset/domain/useCases/validateDataset' +import { DatasetValidationResponse } from '../../dataset/domain/models/DatasetValidationResponse' +import { useNavigate } from 'react-router-dom' +import { Route } from '../Route.enum' +interface FormContextInterface { + fields: DatasetFormFields +} + +const defaultFormState: DatasetFormFields = { + createDatasetTitle: '' +} + +export enum SubmissionStatusEnums { + NotSubmitted = 'NotSubmitted', + IsSubmitting = 'IsSubmitting', + SubmitComplete = 'SubmitComplete', + Errored = 'Errored' +} + +export function useCreateDatasetForm() { + const [formState, setFormState] = useState({ + fields: defaultFormState + }) + + const [submissionStatus, setSubmissionStatus] = useState( + SubmissionStatusEnums.NotSubmitted + ) + const [formErrors, setFormErrors] = useState>( + { createDatasetTitle: undefined } + ) + + const updateFormData = (updatedFormData: object) => { + setFormState((prevState) => ({ + ...prevState, + fields: { ...prevState.fields, ...updatedFormData } + })) + } + + const submitFormData = () => { + setSubmissionStatus(SubmissionStatusEnums.IsSubmitting) + + const validationResult: DatasetValidationResponse = validateDataset(formState.fields) + + if (validationResult.isValid) { + createDataset(formState.fields) + .then(() => setSubmissionStatus(SubmissionStatusEnums.IsSubmitting)) + .catch(() => setSubmissionStatus(SubmissionStatusEnums.Errored)) + .finally(() => setSubmissionStatus(SubmissionStatusEnums.SubmitComplete)) + } else { + setFormErrors(validationResult.errors) + setSubmissionStatus(SubmissionStatusEnums.Errored) + } + } + const navigate = useNavigate() + const cancelFormSubmit = () => { + const path = Route.HOME + navigate(path) + } + + return { + formState, + formErrors, + submissionStatus, + updateFormData, + submitFormData, + cancelFormSubmit + } +} diff --git a/src/sections/dataset/Dataset.module.scss b/src/sections/dataset/Dataset.module.scss index 991d976ca..f094a2d4e 100644 --- a/src/sections/dataset/Dataset.module.scss +++ b/src/sections/dataset/Dataset.module.scss @@ -12,9 +12,4 @@ .tab-container { padding: 1em 0; -} - -.separation-line { - margin: 1em 0; - border-bottom: 1px solid #dee2e6; } \ No newline at end of file diff --git a/src/sections/dataset/Dataset.tsx b/src/sections/dataset/Dataset.tsx index e05b86f1e..c412ad04d 100644 --- a/src/sections/dataset/Dataset.tsx +++ b/src/sections/dataset/Dataset.tsx @@ -16,6 +16,7 @@ import { useEffect } from 'react' import { DatasetAlerts } from './dataset-alerts/DatasetAlerts' import { useNotImplementedModal } from '../not-implemented/NotImplementedModalContext' import { NotImplementedModal } from '../not-implemented/NotImplementedModal' +import { SeparationLine } from '../shared/layout/SeparationLine/SeparationLine' interface DatasetProps { fileRepository: FileRepository @@ -87,7 +88,7 @@ export function Dataset({ fileRepository }: DatasetProps) { -
+ )} diff --git a/src/sections/layout/Layout.module.scss b/src/sections/layout/Layout.module.scss index 614c68764..1f11e5d85 100644 --- a/src/sections/layout/Layout.module.scss +++ b/src/sections/layout/Layout.module.scss @@ -1,4 +1,4 @@ -@import "src/sections/assets/variables"; +@import "src/assets/variables"; .body-container { min-height: $body-available-height; diff --git a/src/sections/layout/header/Header.tsx b/src/sections/layout/header/Header.tsx index 7121bd1f1..7fd974bc8 100644 --- a/src/sections/layout/header/Header.tsx +++ b/src/sections/layout/header/Header.tsx @@ -1,4 +1,4 @@ -import logo from '../../assets/logo.svg' +import logo from '../../../assets/logo.svg' import { useTranslation } from 'react-i18next' import { Navbar } from '@iqss/dataverse-design-system' import { Route } from '../../Route.enum' diff --git a/src/sections/shared/form/RequiredFieldText/RequiredFieldText.tsx b/src/sections/shared/form/RequiredFieldText/RequiredFieldText.tsx new file mode 100644 index 000000000..592aba4a6 --- /dev/null +++ b/src/sections/shared/form/RequiredFieldText/RequiredFieldText.tsx @@ -0,0 +1,12 @@ +import { RequiredInputSymbol } from '@iqss/dataverse-design-system' +import { useTranslation } from 'react-i18next' + +export function RequiredFieldText() { + const { t } = useTranslation('createDataset') + return ( +

+ + {t('requiredFields')} +

+ ) +} diff --git a/src/sections/shared/layout/SeparationLine/SeparationLine.module.scss b/src/sections/shared/layout/SeparationLine/SeparationLine.module.scss new file mode 100644 index 000000000..75a046a0b --- /dev/null +++ b/src/sections/shared/layout/SeparationLine/SeparationLine.module.scss @@ -0,0 +1,4 @@ +.separation-line { + margin: 1em 0; + border-bottom: 1px solid #dee2e6; +} \ No newline at end of file diff --git a/src/sections/shared/layout/SeparationLine/SeparationLine.tsx b/src/sections/shared/layout/SeparationLine/SeparationLine.tsx new file mode 100644 index 000000000..bbb9f526b --- /dev/null +++ b/src/sections/shared/layout/SeparationLine/SeparationLine.tsx @@ -0,0 +1,4 @@ +import styles from './SeparationLine.module.scss' +export function SeparationLine() { + return
+} diff --git a/src/stories/create-dataset/DatasetCreate.stories.tsx b/src/stories/create-dataset/DatasetCreate.stories.tsx new file mode 100644 index 000000000..476770290 --- /dev/null +++ b/src/stories/create-dataset/DatasetCreate.stories.tsx @@ -0,0 +1,16 @@ +import type { StoryObj, Meta } from '@storybook/react' +import { CreateDatasetForm } from '../../sections/create-dataset/CreateDatasetForm' +import { WithLayout } from '../WithLayout' +import { WithI18next } from '../WithI18next' + +const meta: Meta = { + title: 'Pages/Create Dataset', + component: CreateDatasetForm, + decorators: [WithI18next, WithLayout] +} +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => +} diff --git a/src/stories/dataset/DatasetMockRepository.ts b/src/stories/dataset/DatasetMockRepository.ts index 9ffa083ed..6955cd473 100644 --- a/src/stories/dataset/DatasetMockRepository.ts +++ b/src/stories/dataset/DatasetMockRepository.ts @@ -5,7 +5,7 @@ import { TotalDatasetsCount } from '../../dataset/domain/models/TotalDatasetsCou import { DatasetPaginationInfo } from '../../dataset/domain/models/DatasetPaginationInfo' import { DatasetPreview } from '../../dataset/domain/models/DatasetPreview' import { DatasetPreviewMother } from '../../../tests/component/dataset/domain/models/DatasetPreviewMother' - +import { DatasetFormFields } from '../../dataset/domain/models/DatasetFormFields' export class DatasetMockRepository implements DatasetRepository { // eslint-disable-next-line unused-imports/no-unused-vars getAll(paginationInfo: DatasetPaginationInfo): Promise { @@ -46,4 +46,13 @@ export class DatasetMockRepository implements DatasetRepository { }, 1000) }) } + + createDataset(fields: DatasetFormFields): Promise { + const returnMsg = 'Form Data Submitted: ' + JSON.stringify(fields) + return new Promise((resolve) => { + setTimeout(() => { + resolve(returnMsg) + }, 1000) + }) + } } diff --git a/tests/component/create-dataset/CreateDataset.spec.tsx b/tests/component/create-dataset/CreateDataset.spec.tsx new file mode 100644 index 000000000..0ff327db7 --- /dev/null +++ b/tests/component/create-dataset/CreateDataset.spec.tsx @@ -0,0 +1,26 @@ +import { CreateDatasetForm } from '../../../src/sections/create-dataset/CreateDatasetForm' + +describe('Form component', () => { + it('renders the Create Dataset page and its contents', () => { + cy.customMount() + cy.findByText(/Create Dataset/i).should('exist') + + cy.findByLabelText(/Title/i).should('exist') + + cy.findByText(/Save Dataset/i).should('exist') + + cy.findByText(/Cancel/i).should('exist') + }) + + it('can submit a valid form', () => { + cy.customMount() + cy.findByLabelText(/Title/i) + .should('exist') + .type('Test Dataset Title') + .should('have.attr', 'required', 'required') + .and('have.value', 'Test Dataset Title') + + cy.findByText(/Save Dataset/i).click() + cy.findByText('Form submitted successfully!') + }) +})