diff --git a/ui/src/pages/ContributePage/DatasetTopActions/CreateDatasetLayout/CreateDatasetLayout.tsx b/ui/src/common/components/FormModal/FormModal.tsx similarity index 85% rename from ui/src/pages/ContributePage/DatasetTopActions/CreateDatasetLayout/CreateDatasetLayout.tsx rename to ui/src/common/components/FormModal/FormModal.tsx index 9d5c5b4..6f9e21c 100644 --- a/ui/src/pages/ContributePage/DatasetTopActions/CreateDatasetLayout/CreateDatasetLayout.tsx +++ b/ui/src/common/components/FormModal/FormModal.tsx @@ -16,14 +16,15 @@ import type { ReactNode } from 'react'; import { Button, Flex, Modal } from '@mantine/core'; -interface CreateDatasetLayoutProps { +interface FormModalProps { onClose: () => void; onSubmit: () => void; title: string; children: ReactNode; + submitTitle?: string; } -export function CreateDatasetLayout({ onClose, onSubmit, title, children }: Readonly) { +export function FormModal({ onClose, onSubmit, title, children, submitTitle }: Readonly) { return ( Cancel - + diff --git a/ui/src/pages/ContributePage/DatasetTopActions/CreateDatasetFromFile/CreateDatasetFromFile.tsx b/ui/src/pages/ContributePage/DatasetTopActions/CreateDatasetFromFile/CreateDatasetFromFile.tsx index f2301ec..c48bb4a 100644 --- a/ui/src/pages/ContributePage/DatasetTopActions/CreateDatasetFromFile/CreateDatasetFromFile.tsx +++ b/ui/src/pages/ContributePage/DatasetTopActions/CreateDatasetFromFile/CreateDatasetFromFile.tsx @@ -18,7 +18,7 @@ import { useCallback, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { selectOrderedGroupsList } from 'store/groups/groups.selectors'; import { useForm, yupResolver } from '@mantine/form'; -import { CreateDatasetLayout } from '../CreateDatasetLayout/CreateDatasetLayout'; +import { FormModal } from 'common/components/FormModal/FormModal'; import { type CreateDatasetFromFileFormValues, createDatasetFromFileSchema } from './createDatasetFromFile.schema'; import type { CreateDatasetFromFilePayload } from 'store/datasets/datasets.types'; import { createDatasetFromFile } from 'store/datasets/datasets.thunks'; @@ -59,10 +59,11 @@ export function CreateDatasetFromFile({ onClose }: Readonly ) { disabled={isLoading} {...form.getInputProps('description')} /> - + ); } diff --git a/ui/src/pages/DatasetPage/ReactionList/CreateReactionMenu/CreateReactionFromFile/CreateReactionFromFile.module.scss b/ui/src/pages/DatasetPage/ReactionList/CreateReactionMenu/CreateReactionFromFile/CreateReactionFromFile.module.scss new file mode 100644 index 0000000..47396e8 --- /dev/null +++ b/ui/src/pages/DatasetPage/ReactionList/CreateReactionMenu/CreateReactionFromFile/CreateReactionFromFile.module.scss @@ -0,0 +1,18 @@ +/* + * Copyright 2024 Open Reaction Database Project Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.fileInput { + margin: 30px 0 40px; +} diff --git a/ui/src/pages/DatasetPage/ReactionList/CreateReactionMenu/CreateReactionFromFile/CreateReactionFromFile.tsx b/ui/src/pages/DatasetPage/ReactionList/CreateReactionMenu/CreateReactionFromFile/CreateReactionFromFile.tsx new file mode 100644 index 0000000..68f7071 --- /dev/null +++ b/ui/src/pages/DatasetPage/ReactionList/CreateReactionMenu/CreateReactionFromFile/CreateReactionFromFile.tsx @@ -0,0 +1,69 @@ +/* + * Copyright 2024 Open Reaction Database Project Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useCallback } from 'react'; +import { FileInput } from '@mantine/core'; +import { useForm, yupResolver } from '@mantine/form'; +import { FormModal } from 'common/components/FormModal/FormModal'; +import { importReactionFromFile } from 'store/reactions/reactions.thunks'; +import { useAppDispatch } from 'store/useAppDispatch'; +import { type CreateReactionFromFileFormValues, createReactionFromFileSchema } from './createReactionFromFile.schema'; +import { type ImportReactionFromFilePayload } from 'store/reactions/reactions.types'; +import classes from './CreateReactionFromFile.module.scss'; + +interface CreateReactionFromFileProps { + onClose: () => void; +} + +export function CreateReactionFromFile({ onClose }: Readonly) { + const dispatch = useAppDispatch(); + + const form = useForm< + CreateReactionFromFileFormValues, + (values: CreateReactionFromFileFormValues) => ImportReactionFromFilePayload + >({ + mode: 'controlled', + transformValues: values => ({ + file: values.file as File, + }), + validate: yupResolver(createReactionFromFileSchema), + }); + + const onSubmit = useCallback( + (values: ImportReactionFromFilePayload) => { + dispatch(importReactionFromFile(values)); + }, + [dispatch], + ); + + return ( + + + + ); +} diff --git a/ui/src/pages/DatasetPage/ReactionList/CreateReactionMenu/CreateReactionFromFile/createReactionFromFile.schema.ts b/ui/src/pages/DatasetPage/ReactionList/CreateReactionMenu/CreateReactionFromFile/createReactionFromFile.schema.ts new file mode 100644 index 0000000..13a3d2b --- /dev/null +++ b/ui/src/pages/DatasetPage/ReactionList/CreateReactionMenu/CreateReactionFromFile/createReactionFromFile.schema.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2024 Open Reaction Database Project Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as yup from 'yup'; + +const MAX_FILE_SIZE = 1024 * 1024 * 15; + +const MAX_FILE_SIZE_MB = (MAX_FILE_SIZE / 1024 / 1024).toFixed(2); + +export const createReactionFromFileSchema = yup.object({ + file: yup + .mixed() + .required('Reaction file is required') + .label('File') + .test({ + message: `Filesize cannot exceed ${MAX_FILE_SIZE_MB} MB`, + test: value => { + const file = value as File; + return file?.size < MAX_FILE_SIZE; + }, + }), +}); + +export type CreateReactionFromFileFormValues = yup.InferType; diff --git a/ui/src/pages/DatasetPage/ReactionList/CreateReactionMenu/CreateReactionMenu.tsx b/ui/src/pages/DatasetPage/ReactionList/CreateReactionMenu/CreateReactionMenu.tsx new file mode 100644 index 0000000..bfb7601 --- /dev/null +++ b/ui/src/pages/DatasetPage/ReactionList/CreateReactionMenu/CreateReactionMenu.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2024 Open Reaction Database Project Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useCallback } from 'react'; +import { Button, Menu } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { createEmptyReaction } from 'store/reactions/reactions.thunks'; +import { useAppDispatch } from 'store/useAppDispatch'; +import { AddCircleIcon, ChevronDownIcon } from 'common/icons'; +import { CreateReactionFromFile } from './CreateReactionFromFile/CreateReactionFromFile'; +import classes from './createReactionMenu.module.scss'; + +export function CreateReactionMenu() { + const dispatch = useAppDispatch(); + const [importFromFileOpened, { open: openImportFromFile, close: closeImportFromFile }] = useDisclosure(false); + + const handleReactionCreate = useCallback(() => dispatch(createEmptyReaction()), [dispatch]); + + return ( + <> + + + + + + From Scratch + Import from File + + + + {importFromFileOpened && } + + ); +} diff --git a/ui/src/pages/DatasetPage/ReactionList/CreateReactionMenu/createReactionMenu.module.scss b/ui/src/pages/DatasetPage/ReactionList/CreateReactionMenu/createReactionMenu.module.scss new file mode 100644 index 0000000..ed06c33 --- /dev/null +++ b/ui/src/pages/DatasetPage/ReactionList/CreateReactionMenu/createReactionMenu.module.scss @@ -0,0 +1,42 @@ +/* + * Copyright 2024 Open Reaction Database Project Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.dropdown { + width: 135px; + border: none; + box-shadow: 0 4px 24px 0 #00000014; +} + +.menuItem { + padding: 10px 12px; + border-radius: 8px; +} + +.buttonRoot { + border-radius: 8px; + + &:active { + transform: none; + } + + &[data-expanded='true'] { + outline: 3px solid #3c78d899; + } +} + +.buttonInner { + gap: 6px; + font-weight: 400; +} diff --git a/ui/src/pages/DatasetPage/ReactionList/ReactionList.tsx b/ui/src/pages/DatasetPage/ReactionList/ReactionList.tsx index c20e5e0..2f90729 100644 --- a/ui/src/pages/DatasetPage/ReactionList/ReactionList.tsx +++ b/ui/src/pages/DatasetPage/ReactionList/ReactionList.tsx @@ -16,13 +16,14 @@ import { useCallback } from 'react'; import { Pagination } from 'common/components/Pagination/Pagination'; import { ReactionCard } from './ReactionCard/ReactionCard'; -import { Button, Flex, Paper, Title } from '@mantine/core'; -import classes from './reactionsList.module.scss'; -import { AddCircleIcon, EmptyIcon } from 'common/icons'; +import { Flex, Paper, Title } from '@mantine/core'; +import { EmptyIcon } from 'common/icons'; import { useSelector } from 'react-redux'; import { selectReactionsOrder, selectReactionsPagination } from 'store/reactions/reactions.selectors'; import { getReactionsPage } from 'store/reactions/reactions.thunks'; import { useAppDispatch } from 'store/useAppDispatch'; +import { CreateReactionMenu } from './CreateReactionMenu/CreateReactionMenu'; +import classes from './reactionsList.module.scss'; export function ReactionList() { const dispatch = useAppDispatch(); @@ -60,12 +61,7 @@ export function ReactionList() { {pagination.total} - + {!hasReactions && ( diff --git a/ui/src/pages/DatasetPage/ReactionList/reactionsList.module.scss b/ui/src/pages/DatasetPage/ReactionList/reactionsList.module.scss index e66c18b..d8335ff 100644 --- a/ui/src/pages/DatasetPage/ReactionList/reactionsList.module.scss +++ b/ui/src/pages/DatasetPage/ReactionList/reactionsList.module.scss @@ -25,10 +25,6 @@ font-weight: 400; } -.buttonSection { - margin-right: 10px; -} - .emptyText { color: var(--color-text-secondary-1); } diff --git a/ui/src/store/reactions/reactions.actions.ts b/ui/src/store/reactions/reactions.actions.ts index a0f5b76..d125f38 100644 --- a/ui/src/store/reactions/reactions.actions.ts +++ b/ui/src/store/reactions/reactions.actions.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { createActionFactory } from '../../common/store'; -import type { ReactionWrapper } from './reactions.types'; +import type { ImportReactionFromFilePayload, ReactionWrapper } from './reactions.types'; import type { CurrentPage, Pages } from '../../common/types'; const { createAsyncAction } = createActionFactory('reactions'); @@ -26,3 +26,9 @@ export const getReactionPageActions = createAsyncAction, Pa export const getReactionActions = createAsyncAction<{ datasetId: number; reactionId: number }, ReactionWrapper>('get'); export const renameReactionActions = createAsyncAction<{ reactionId: number; name: string }, ReactionWrapper>('rename'); + +export const createEmptyReactionActions = createAsyncAction('create_empty'); + +export const importReactionFromFileActions = createAsyncAction( + 'import_from_file', +); diff --git a/ui/src/store/reactions/reactions.reducer.ts b/ui/src/store/reactions/reactions.reducer.ts index 4847058..5fefb46 100644 --- a/ui/src/store/reactions/reactions.reducer.ts +++ b/ui/src/store/reactions/reactions.reducer.ts @@ -15,9 +15,11 @@ */ import { combineReducers, createReducer, isAnyOf } from '@reduxjs/toolkit'; import { + createEmptyReactionActions, getReactionActions, getReactionPageActions, getReactionsListActions, + importReactionFromFileActions, renameReactionActions, } from './reactions.actions'; import { itemsById } from 'common/utils'; @@ -33,10 +35,18 @@ const activeDatasetId = createReducer(0, builder => { }); const reactionsById = createReducer>({}, builder => { - builder.addMatcher(isAnyOf(getReactionActions.success, renameReactionActions.success), (state, action) => ({ - ...state, - [getReactionId(action.payload)]: action.payload, - })); + builder.addMatcher( + isAnyOf( + getReactionActions.success, + renameReactionActions.success, + createEmptyReactionActions.success, + importReactionFromFileActions.success, + ), + (state, action) => ({ + ...state, + [getReactionId(action.payload)]: action.payload, + }), + ); builder.addMatcher(isAnyOf(getReactionsListActions.success, getReactionPageActions.success), (_, action) => itemsById(action.payload.items, getReactionId), ); @@ -58,6 +68,11 @@ const pagination = createReducer(emptyPagination, builder => { total: action.payload.total, pages: action.payload.pages, })); + builder.addMatcher(isAnyOf(createEmptyReactionActions.success, importReactionFromFileActions.success), state => ({ + ...state, + total: state.total + 1, + pages: Math.ceil((state.total + 1) / state.size), + })); }); export const reactionsReducer = combineReducers({ diff --git a/ui/src/store/reactions/reactions.thunks.ts b/ui/src/store/reactions/reactions.thunks.ts index feb4e11..821633e 100644 --- a/ui/src/store/reactions/reactions.thunks.ts +++ b/ui/src/store/reactions/reactions.thunks.ts @@ -13,11 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createThunk } from '../../common/store'; +import { createThunk, createThunkWithExplicitResult } from '../../common/store'; import { + createEmptyReactionActions, getReactionActions, getReactionPageActions, getReactionsListActions, + importReactionFromFileActions, renameReactionActions, } from './reactions.actions'; import axiosInstance from '../../common/config/axiosConfig'; @@ -25,6 +27,7 @@ import type { Pages } from '../../common/types'; import type { ReactionResponse, ReactionWrapper } from './reactions.types'; import ordSchema from 'ord-schema'; import { selectActiveDatasetId, selectReactionsPagination } from './reactions.selectors'; +import { navigate } from 'wouter/use-browser-location'; const parseReaction = ({ binpb, ...rest }: ReactionResponse): ReactionWrapper => ({ ...rest, @@ -69,3 +72,30 @@ export const renameReaction = createThunk(renameReactionActions, async (_d, getS }); return renameReactionActions.success(parseReaction(result.data)); }); + +export const createEmptyReaction = createThunkWithExplicitResult( + createEmptyReactionActions, + async (dispatch, getState) => { + const datasetId = selectActiveDatasetId(getState()); + + const result = await axiosInstance.post(`/datasets/${datasetId}/reactions`, {}); + const reaction = parseReaction(result.data); + dispatch(createEmptyReactionActions.success(reaction)); + navigate(`/dataset/${datasetId}/reaction/${reaction.id}`); + }, +); + +export const importReactionFromFile = createThunkWithExplicitResult( + importReactionFromFileActions, + async (dispatch, getState, { file }) => { + const datasetId = selectActiveDatasetId(getState()); + + const formData = new FormData(); + formData.append('file', file); + + const result = await axiosInstance.post(`/datasets/${datasetId}/reactions/upload`, formData); + const reaction = parseReaction(result.data); + dispatch(importReactionFromFileActions.success(reaction)); + navigate(`/dataset/${datasetId}/reaction/${reaction.id}`); + }, +); diff --git a/ui/src/store/reactions/reactions.types.ts b/ui/src/store/reactions/reactions.types.ts index 10c2bc1..087ede4 100644 --- a/ui/src/store/reactions/reactions.types.ts +++ b/ui/src/store/reactions/reactions.types.ts @@ -27,3 +27,7 @@ export type Reaction = ReturnType { data: Reaction; } + +export interface ImportReactionFromFilePayload { + file: File; +}