diff --git a/frontend/package.json b/frontend/package.json index 46c4486b9..632713dbb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "axios": "^0.21.1", "colormap": "^2.3.2", "flexlayout-react": "^0.5.12", + "moment": "^2.29.4", "notistack": "^2.0.3", "plotly.js": "^2.6.0", "qs": "^6.11.2", diff --git a/frontend/src/api/Workspace/index.ts b/frontend/src/api/Workspace/index.ts new file mode 100644 index 000000000..f0492e756 --- /dev/null +++ b/frontend/src/api/Workspace/index.ts @@ -0,0 +1,42 @@ +import axios from 'utils/axios' +import qs from 'qs' +import { ItemsWorkspace, WorkspaceDataDTO } from 'store/slice/Workspace/WorkspaceType' + +export type WorkspacePostDataDTO = { name: string; id?: number } + +export const getWorkspacesApi = async (params: { [key: string]: number }): Promise => { + const paramsNew = qs.stringify(params, { indices: false }) + const response = await axios.get(`/workspaces?${paramsNew}`) + return response.data +} + +export const delWorkspaceApi = async (id: number): Promise => { + const response = await axios.delete(`/workspace/${id}`) + return response.data +} + +export const postWorkspaceApi = async ( + data: WorkspacePostDataDTO, +): Promise => { + const response = await axios.post(`/workspace`, data) + return response.data +} + +export const putWorkspaceApi = async ( + data: WorkspacePostDataDTO, +): Promise => { + const response = await axios.put(`/workspace/${data.id}`, { name: data.name }) + return response.data +} + +export const importWorkspaceApi = async ( + data: Object, +): Promise => { + const response = await axios.post(`/workspace/import`, { todo_dummy: data }) + return response.data +} + +export const exportWorkspaceApi = async (id: number): Promise => { + const response = await axios.get(`/workspace/export/${id}`) + return response.data +} diff --git a/frontend/src/api/users/UsersApiDTO.ts b/frontend/src/api/users/UsersApiDTO.ts index 114847bec..416a90d83 100644 --- a/frontend/src/api/users/UsersApiDTO.ts +++ b/frontend/src/api/users/UsersApiDTO.ts @@ -1,4 +1,5 @@ export type UserDTO = { + id: number uid: string email: string } diff --git a/frontend/src/components/Database/DatabaseCells.tsx b/frontend/src/components/Database/DatabaseCells.tsx index b5ee7a10a..bc62aa494 100644 --- a/frontend/src/components/Database/DatabaseCells.tsx +++ b/frontend/src/components/Database/DatabaseCells.tsx @@ -3,7 +3,6 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import DialogImage from '../common/DialogImage' import { - GridCallbackDetails, GridEnrichedColDef, GridFilterModel, GridSortDirection, @@ -63,24 +62,26 @@ const columns = (handleOpenDialog: (value: string[]) => void) => [ width: 160, filterable: false, sortable: false, - renderCell: (params: { row: DatabaseType }) => ( - params.row?.cell_image_url && handleOpenDialog([params.row.cell_image_url])} - > - {params.row?.cell_image_url && ( + renderCell: (params: { row: DatabaseType }) => { + const { cell_image_url } = params.row + if (!cell_image_url) return null + return ( + handleOpenDialog([cell_image_url])} + > {''} - )} - - ), + + ) + }, }, ] @@ -124,9 +125,9 @@ const DatabaseCells = ({ user }: CellProps) => { const dataParamsFilter = useMemo( () => ({ - brain_area: searchParams.get('brain_area') || '', - cre_driver: searchParams.get('cre_driver') || '', - reporter_line: searchParams.get('reporter_line') || '', + brain_area: searchParams.get('brain_area') || undefined, + cre_driver: searchParams.get('cre_driver') || undefined, + reporter_line: searchParams.get('reporter_line') || undefined, imaging_depth: Number(searchParams.get('imaging_depth')) || undefined, }), [searchParams], @@ -134,13 +135,13 @@ const DatabaseCells = ({ user }: CellProps) => { const fetchApi = () => { const api = !user ? getCellsPublicDatabase : getCellsDatabase - dispatch(api(dataParams)) + dispatch(api({ ...dataParamsFilter, ...dataParams })) } useEffect(() => { fetchApi() //eslint-disable-next-line - }, [dataParams, user]) + }, [dataParams, user, dataParamsFilter]) const handleOpenDialog = (data: string[]) => { setDataDialog({ type: 'image', data }) @@ -180,30 +181,19 @@ const DatabaseCells = ({ user }: CellProps) => { [pagiFilter, getParamsData], ) - const handleFilter = ( - model: GridFilterModel | any, - details: GridCallbackDetails, - ) => { - let filter: string + const handleFilter = (model: GridFilterModel) => { + let filter = '' if (!!model.items[0]?.value) { - //todo multiple filter with version pro. Issue task #55 filter = model.items - .filter((item: { [key: string]: string }) => item.value) + .filter((item) => item.value) .map((item: any) => { return `${item.field}=${item?.value}` }) .join('&') - } else { - filter = '' - } - if (!model.items[0]) { - setParams( - `${filter}&sort=${dataParams.sort[0]}&sort=${dataParams.sort[1]}&${pagiFilter}`, - ) - return } + const { sort } = dataParams setParams( - `${filter}&sort=${dataParams.sort[0]}&sort=${dataParams.sort[1]}&${pagiFilter}`, + `${filter}&sort=${sort[0] || ''}&sort=${sort[1] || ''}&${pagiFilter}`, ) } @@ -219,7 +209,7 @@ const DatabaseCells = ({ user }: CellProps) => { {params.row.graph_urls?.[index]?.[0] ? ( {''} { } const handleChangeAttributes = (event: any) => { - setDataDialog(pre => ({...pre, data: event.target.value})) + setDataDialog((pre) => ({ ...pre, data: event.target.value })) } const getParamsData = () => { @@ -305,23 +304,20 @@ const DatabaseExperiments = ({ user, cellPath }: DatabaseProps) => { [pagiFilter, getParamsData], ) - const handleFilter = ( - model: GridFilterModel | any, - details: GridCallbackDetails, - ) => { - let filter: string + const handleFilter = (model: GridFilterModel) => { + let filter = '' if (!!model.items[0]?.value) { filter = model.items - .filter((item: { [key: string]: string }) => item.value) + .filter((item) => item.value) .map((item: any) => { return `${item.field}=${item?.value}` }) .join('&') - } else { - filter = '' } - const {sort} = dataParams - setParams(`${filter}&sort=${sort[0] || ''}&sort=${sort[1] || ''}&${pagiFilter()}`) + const { sort } = dataParams + setParams( + `${filter}&sort=${sort[0] || ''}&sort=${sort[1] || ''}&${pagiFilter()}`, + ) } const getColumns = useMemo(() => { diff --git a/frontend/src/pages/Workspace/index.tsx b/frontend/src/pages/Workspace/index.tsx index 7d1866b12..68f9b891e 100644 --- a/frontend/src/pages/Workspace/index.tsx +++ b/frontend/src/pages/Workspace/index.tsx @@ -1,239 +1,276 @@ -// import { useEffect } from 'react' -import { useSelector /*, useDispatch */ } from 'react-redux' -import { Box, styled, Button, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material' +import { useSelector, useDispatch } from 'react-redux' import { - DataGrid, - GridColDef, - GridRenderCellParams, - GridRowParams, -} from '@mui/x-data-grid' -import { Link } from 'react-router-dom' + Box, + styled, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Input, + Pagination, +} from '@mui/material' +import { GridRenderCellParams, GridRowParams, DataGrid } from '@mui/x-data-grid' +import { + DataGridPro, + GridEventListener, + GridRowModesModel, + GridRowModel, + GridRowModes, +} from '@mui/x-data-grid-pro' +import { Link, useSearchParams } from 'react-router-dom' +import { selectCurrentUser } from 'store/slice/User/UserSelector' import Loading from '../../components/common/Loading' import { selectIsLoadingWorkspaceList, - // selectWorkspaceList, + selectWorkspaceData, } from 'store/slice/Workspace/WorkspaceSelector' -import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt'; -import CancelIcon from '@mui/icons-material/Cancel'; -import { useState } from "react"; -import GroupsIcon from '@mui/icons-material/Groups'; -import EditIcon from '@mui/icons-material/Edit'; +import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt' +import CancelIcon from '@mui/icons-material/Cancel' +import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react' +import EditIcon from '@mui/icons-material/Edit' +import PeopleOutlineIcon from '@mui/icons-material/PeopleOutline' +import { + delWorkspace, + exportWorkspace, + getWorkspaceList, + importWorkspace, + postWorkspace, + putWorkspace, +} from 'store/slice/Workspace/WorkspacesActions' +import moment from 'moment' type PopupType = { open: boolean handleClose: () => void + handleOkDel?: () => void + setNewWorkSpace?: (name: string) => void + value?: string + handleOkNew?: () => void + handleOkSave?: () => void + error?: string } -const columns = (handleOpenPopupShare: () => void, handleOpenPopupDel: () => void) => ( - [ - { - field: 'id', - headerName: 'ID', - minWidth: 160, - renderCell: (params: GridRenderCellParams) => ( - {params.value} - ), - }, - { - field: 'name', - headerName: 'Workspace Name', - minWidth: 200, - editable: true, - renderCell: (params: GridRenderCellParams) => ( - void, + handleOpenPopupDel: (id: number) => void, + handleDownload: (id: number) => void, + user?: { id: number }, + onEdit?: (id: number) => void, +) => [ + { + field: 'id', + headerName: 'ID', + minWidth: 160, + filterable: false, // todo enable when api complete + sortable: false, // todo enable when api complete + renderCell: (params: GridRenderCellParams) => ( + {params.value} + ), + }, + { + field: 'name', + headerName: 'Workspace Name', + minWidth: 200, + editable: true, + filterable: false, // todo enable when api complete + sortable: false, // todo enable when api complete + renderCell: (params: GridRenderCellParams) => { + const { row, value } = params + return ( + + - {params.value} - {params.row.owner !== "User 2" ? : ""} - - ), - }, - { - field: 'owner', - headerName: 'Owner', - minWidth: 200, - renderCell: (params: GridRenderCellParams) => ( - - {params.value} - {params.value === "User 2" ? : ""} - - ), - }, - { - field: 'created', - headerName: 'Created', - minWidth: 200, - renderCell: (params: GridRenderCellParams) => ( - {params.value} - ), - }, - { - field: 'workflow', - headerName: '', - minWidth: 160, - renderCell: (params: GridRenderCellParams) => ( - - Workflow - - ), - }, - { - field: 'result', - headerName: '', - minWidth: 130, - renderCell: (params: GridRenderCellParams) => ( - - Result - - ), - }, - { - field: 'download', - headerName: '', - minWidth: 90, - renderCell: (params: GridRenderCellParams) => ( - - - - ), - }, - { - field: 'share', - headerName: '', - minWidth: 90, - renderCell: (params: GridRenderCellParams) => ( - params.row.owner !== "User 2" ? - - - : "" - ), - }, - { - field: 'delete', - headerName: '', - minWidth: 130, - renderCell: (params: GridRenderCellParams) => ( - params.row.owner !== "User 2" ? - - Del - : "" - ), - }, - ] -) - -const columnsShare = (handleShareFalse: (parmas: GridRenderCellParams) => void) => ( - [ - { - field: "name", - headerName: "Name", - minWidth: 140, - renderCell: (params: GridRenderCellParams) => ( - {params.row.name} - ), - }, - { - field: "lab", - headerName: "Lab", - minWidth: 280, - renderCell: (params: GridRenderCellParams) => ( - {params.row.email} - ), - }, - { - field: "email", - headerName: "Email", - minWidth: 280, - renderCell: (params: GridRenderCellParams) => ( - {params.row.email} - ), - }, - { - field: "share", - headerName: "", - minWidth: 130, - renderCell: (params: GridRenderCellParams) => { - if(!params.row.share) return "" - return ( - - ) - } - }, - ] -) + {value} + + {row.user?.id === user?.id && ( + onEdit?.(row.id)}> + + + )} + + ) + }, + }, + { + field: 'user', + headerName: 'Owner', + filterable: false, // todo enable when api complete + sortable: false, // todo enable when api complete + minWidth: 200, + renderCell: ( + params: GridRenderCellParams<{ name: string; id: number }>, + ) => ( + + {params.value?.name} + {params.value.id !== user?.id ? : ''} + + ), + }, + { + field: 'created_at', + headerName: 'Created', + minWidth: 200, + filterable: false, // todo enable when api complete + sortable: false, // todo enable when api complete + renderCell: (params: GridRenderCellParams) => ( + {moment(params.value).format('YYYY/MM/DD hh:mm')} + ), + }, + { + field: 'workflow', + headerName: '', + minWidth: 160, + filterable: false, // todo enable when api complete + sortable: false, // todo enable when api complete + renderCell: (params: GridRenderCellParams) => ( + Workflow + ), + }, + { + field: 'result', + headerName: '', + minWidth: 130, + filterable: false, // todo enable when api complete + sortable: false, // todo enable when api complete + renderCell: (_params: GridRenderCellParams) => ( + Result + ), + }, + { + field: 'download', + headerName: '', + minWidth: 90, + filterable: false, // todo enable when api complete + sortable: false, // todo enable when api complete + renderCell: (params: GridRenderCellParams) => ( + handleDownload(params?.row?.id)}> + + + ), + }, + { + field: 'share', + headerName: '', + minWidth: 90, + filterable: false, // todo enable when api complete + sortable: false, // todo enable when api complete + renderCell: (params: GridRenderCellParams) => + params.row?.user?.id === user?.id && ( + + + + ), + }, + { + field: 'delete', + headerName: '', + minWidth: 130, + filterable: false, // todo enable when api complete + sortable: false, // todo enable when api complete + renderCell: (params: GridRenderCellParams) => + params.row?.user_id === user?.id && ( + handleOpenPopupDel(params.row.id)}> + Del + + ), + }, +] -const data = [ +const columnsShare = ( + handleShareFalse: (parmas: GridRenderCellParams) => void, +) => [ { - id: 1, - owner: "User 1", - name: "Name 1", - created: "YYYY/MM/DD HH:MI", - share: false + field: 'name', + headerName: 'Name', + minWidth: 140, + renderCell: (params: GridRenderCellParams) => ( + {params.row.name} + ), }, { - id: 2, - owner: "User 2", - name: "Name 2", - created: "YYYY/MM/DD HH:MI", - share: true + field: 'lab', + headerName: 'Lab', + minWidth: 280, + renderCell: (params: GridRenderCellParams) => ( + {params.row.email} + ), }, { - id: 3, - owner: "User 1", - name: "Name 3", - created: "YYYY/MM/DD HH:MI", - share: true - } + field: 'email', + headerName: 'Email', + minWidth: 280, + renderCell: (params: GridRenderCellParams) => ( + {params.row.email} + ), + }, + { + field: 'share', + headerName: '', + minWidth: 130, + renderCell: (params: GridRenderCellParams) => { + if (!params.row.share) return '' + return ( + + ) + }, + }, ] const dataShare = [ { id: 1, - name: "User 1", - lab: "Labxxxx", - email: "aaaaa@gmail.com", - share: false + name: 'User 1', + lab: 'Labxxxx', + email: 'aaaaa@gmail.com', + share: false, }, { id: 2, - name: "User 2", - lab: "Labxxxx", - email: "aaaaa@gmail.com", - share: true + name: 'User 2', + lab: 'Labxxxx', + email: 'aaaaa@gmail.com', + share: true, }, { id: 3, - name: "User 3", - lab: "Labxxxx", - email: "aaaaa@gmail.com", - share: true - } + name: 'User 3', + lab: 'Labxxxx', + email: 'aaaaa@gmail.com', + share: true, + }, ] -const PopupShare = ({open, handleClose}: PopupType) => { +const PopupShare = ({ open, handleClose }: PopupType) => { const [tableShare, setTableShare] = useState(dataShare) - if(!open) return <> const handleShareTrue = (params: GridRowParams) => { - if(params.row.share) return - const index = tableShare.findIndex(item => item.id === params.id) - setTableShare(pre => { + if (params.row.share) return + const index = tableShare.findIndex((item) => item.id === params.id) + setTableShare((pre) => { pre[index].share = true return pre }) } const handleShareFalse = (params: GridRenderCellParams) => { - const indexSearch = tableShare.findIndex(item => item.id === params.id) + const indexSearch = tableShare.findIndex((item) => item.id === params.id) const newData = tableShare.map((item, index) => { - if(index === indexSearch) return {...item, share: false} + if (index === indexSearch) return { ...item, share: false } return item }) setTableShare(newData) @@ -241,16 +278,12 @@ const PopupShare = ({open, handleClose}: PopupType) => { return ( - + Share Workspace アクセス許可ユーザー { ) } -const PopupDelete = ({open, handleClose}: PopupType) => { - if(!open) return <> +const PopupNew = ({ + open, + handleClose, + value, + setNewWorkSpace, + handleOkNew, + error, +}: PopupType) => { + if (!setNewWorkSpace) return null + const handleName = (event: ChangeEvent) => { + setNewWorkSpace(event.target.value) + } + return ( + + + Create New Workspace + + +
+ {error ? {error} : null} +
+ + + + +
+
+ ) +} +const PopupDelete = ({ open, handleClose, handleOkDel }: PopupType) => { + if (!handleOkDel) return null + return ( - + Do you want delete? - + @@ -286,32 +350,135 @@ const PopupDelete = ({open, handleClose}: PopupType) => { } const Workspaces = () => { - // const dispatch = useDispatch() - // const workspaces = useSelector(selectWorkspaceList) + const dispatch = useDispatch() const loading = useSelector(selectIsLoadingWorkspaceList) - const [openShare, setOpenShare] = useState(false) - const [openDel, setOpenDel] = useState(false) + const data = useSelector(selectWorkspaceData) + const user = useSelector(selectCurrentUser) + const [open, setOpen] = useState({ share: false, del: false, new: false }) + const [idDel, setIdDel] = useState() + const [newWorkspace, setNewWorkSpace] = useState() + const [error, setError] = useState('') + const [initName, setInitName] = useState('') + const [rowModesModel, setRowModesModel] = useState({}) + const [searchParams, setParams] = useSearchParams() + + const offset = searchParams.get('offset') + const limit = searchParams.get('limit') + + const dataParams = useMemo(() => { + return { + offset: Number(offset) || 0, + limit: Number(limit) || 50, + } + //eslint-disable-next-line + }, [offset, limit]) - /* TODO: Add get workspace apis and actions useEffect(() => { - dispatch(getWorkspaceList()) + dispatch(getWorkspaceList(dataParams)) //eslint-disable-next-line - }, []) - */ + }, [dataParams]) + const handleOpenPopupShare = () => { - setOpenShare(false) + setOpen({ ...open, share: true }) } const handleClosePopupShare = () => { - setOpenShare(false) + setOpen({ ...open, share: false }) } - const handleOpenPopupDel = () => { - setOpenDel(true) + const handleOpenPopupDel = (id: number) => { + setIdDel(id) + setOpen({ ...open, del: true }) + } + + const handleOkDel = async () => { + if (!idDel) return + await dispatch(delWorkspace({ id: idDel, params: dataParams })) + setOpen({ ...open, del: false }) } const handleClosePopupDel = () => { - setOpenDel(false) + setOpen({ ...open, del: false }) + } + + const handleOpenPopupNew = () => { + setOpen({ ...open, new: true }) + } + + const handleClosePopupNew = () => { + setOpen({ ...open, new: false }) + setError('') + } + + const onEditName = (id: number) => { + setRowModesModel((pre) => ({ ...pre, [id]: { mode: GridRowModes.Edit } })) + } + + const handleOkNew = async () => { + if (!newWorkspace) { + setError('is not empty') + return + } + await dispatch(postWorkspace({ name: newWorkspace })) + await dispatch(getWorkspaceList(dataParams)) + setOpen({ ...open, new: false }) + setError('') + setNewWorkSpace('') + } + + const onProcessRowUpdateError = (newRow: any) => { + return newRow + } + + const handleFileUpload = async (event: ChangeEvent) => { + dispatch(importWorkspace({})) + } + + const pagi = useCallback( + (page?: number) => { + return `limit=${data.limit}&offset=${page ? page - 1 : data.offset}` + }, + [data?.limit, data?.offset], + ) + + const handlePage = (e: ChangeEvent, page: number) => { + setParams(`&${pagi(page)}`) + } + + const handleDownload = async (id: number) => { + dispatch(exportWorkspace(id)) + } + + const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => { + setRowModesModel(newRowModesModel) + } + + const onRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => { + setInitName(params.row.name) + } + + const onCellClick: GridEventListener<'cellClick'> | undefined = (event) => { + if (event.field === 'name') return + setRowModesModel((pre) => { + const object: GridRowModesModel = {} + Object.keys(pre).forEach(key => { + object[key] = { + mode: GridRowModes.View, ignoreModifications: true + } + }) + return object + }) + } + + const processRowUpdate = async (newRow: GridRowModel) => { + if (!newRow.name) { + alert("Workspace Name cann't empty") + return { ...newRow, name: initName } + } + if (newRow.name === initName) return newRow + await dispatch(putWorkspace({ name: newRow.name, id: newRow.id })) + await dispatch(getWorkspaceList(dataParams)) + return newRow } return ( @@ -319,66 +486,150 @@ const Workspaces = () => { Workspaces + + New + + - Import - New + params.row.user?.id === user?.id} + onProcessRowUpdateError={onProcessRowUpdateError} + onRowEditStop={onRowEditStop} + processRowUpdate={processRowUpdate as any} + hideFooter={true} + /> - params.row.owner === "User 1"} + + + + {loading ? : null} - - ) } const WorkspacesWrapper = styled(Box)(({ theme }) => ({ + margin: 'auto', + width: '90vw', padding: theme.spacing(2), overflow: 'auto', })) const WorkspacesTitle = styled('h1')(({ theme }) => ({})) -const ButtonCustom = styled(Button)(({theme}) => ({ - backgroundColor: "#000000c4", - color: "#FFF", +const ButtonCustom = styled(Button)(({ theme }) => ({ + backgroundColor: '#000000c4', + color: '#FFF', fontSize: 16, padding: theme.spacing(0.5, 1.25), - textTransform: "unset", - "&:hover": { - backgroundColor: "#000000fc", - } + textTransform: 'unset', + '&:hover': { + backgroundColor: '#000000fc', + }, })) -const LinkCustom = styled(Link)(({theme}) => ({ - backgroundColor: "#000000c4", - color: "#FFF", +const LinkCustom = styled(Link)(({ theme }) => ({ + backgroundColor: '#000000c4', + color: '#FFF', fontSize: 16, padding: theme.spacing(0.5, 1.5), - textTransform: "unset", - textDecoration: "unset", + textTransform: 'unset', + textDecoration: 'unset', borderRadius: 5, - "&:hover": { - backgroundColor: "#000000fc", - } + '&:hover': { + backgroundColor: '#000000fc', + }, })) -const DialogCustom = styled(Dialog)(({theme}) => ({ - "& .MuiDialog-container": { - "& .MuiPaper-root": { - width: "70%", - maxWidth: "890px", +const DialogCustom = styled(Dialog)(({ theme }) => ({ + '& .MuiDialog-container': { + '& .MuiPaper-root': { + width: '70%', + maxWidth: '890px', }, }, })) +const ButtonIcon = styled('button')(({ theme }) => ({ + minWidth: '32px', + minHeight: '32px', + width: '32px', + height: '32px', + border: 'none', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + background: 'transparent', + '&:hover': { + background: 'rgb(239 239 239)', + }, +})) + export default Workspaces diff --git a/frontend/src/store/slice/Database/DatabaseSlice.ts b/frontend/src/store/slice/Database/DatabaseSlice.ts index 6f2f28f8d..54b51ed77 100644 --- a/frontend/src/store/slice/Database/DatabaseSlice.ts +++ b/frontend/src/store/slice/Database/DatabaseSlice.ts @@ -1,4 +1,4 @@ -import { createSlice } from '@reduxjs/toolkit' +import { createSlice, isAnyOf } from '@reduxjs/toolkit' import { getExperimentsDatabase, getCellsDatabase, @@ -18,18 +18,18 @@ const initData = { } export type TypeData = { - public: DatabaseDTO, + public: DatabaseDTO private: DatabaseDTO } export const initialState: { - data: TypeData, + data: TypeData loading: boolean type: 'experiment' | 'cell' } = { data: { public: initData, - private: initData + private: initData, }, loading: false, type: 'experiment', @@ -69,34 +69,37 @@ export const databaseSlice = createSlice({ } state.loading = true }) - .addCase(getExperimentsDatabase.fulfilled, (state, action) => { - state.data.private = action.payload - state.loading = false - }) - .addCase(getCellsDatabase.fulfilled, (state, action) => { - state.data.private = action.payload - state.loading = false - }) - .addCase(getExperimentsPublicDatabase.fulfilled, (state, action) => { - state.data.public = action.payload - state.loading = false - }) - .addCase(getCellsPublicDatabase.fulfilled, (state, action) => { - state.data.public = action.payload - state.loading = false - }) - .addCase(getExperimentsDatabase.rejected, (state, action) => { - state.loading = false - }) - .addCase(getCellsDatabase.rejected, (state, action) => { - state.loading = false - }) - .addCase(getExperimentsPublicDatabase.rejected, (state, action) => { - state.loading = false - }) - .addCase(getCellsPublicDatabase.rejected, (state, action) => { - state.loading = false - }) + .addMatcher( + isAnyOf( + getCellsDatabase.fulfilled, + getExperimentsDatabase.fulfilled, + ), + (state, action) => { + state.data.private = action.payload + state.loading = false + }, + ) + .addMatcher( + isAnyOf( + getCellsPublicDatabase.fulfilled, + getExperimentsPublicDatabase.fulfilled, + ), + (state, action) => { + state.data.public = action.payload + state.loading = false + }, + ) + .addMatcher( + isAnyOf( + getExperimentsDatabase.rejected, + getCellsDatabase.rejected, + getExperimentsPublicDatabase.rejected, + getCellsPublicDatabase.rejected, + ), + (state) => { + state.loading = false + }, + ) }, }) diff --git a/frontend/src/store/slice/Workspace/WorkspaceSelector.ts b/frontend/src/store/slice/Workspace/WorkspaceSelector.ts index aa69430ef..6f011300c 100644 --- a/frontend/src/store/slice/Workspace/WorkspaceSelector.ts +++ b/frontend/src/store/slice/Workspace/WorkspaceSelector.ts @@ -1,6 +1,7 @@ import { RootState } from 'store/store' export const selectWorkspace = (state: RootState) => state.workspace +export const selectWorkspaceData = (state: RootState) => state.workspace.workspace export const selectActiveTab = (state: RootState) => state.workspace.currentWorkspace.selectedTab @@ -8,8 +9,5 @@ export const selectActiveTab = (state: RootState) => export const selectCurrentWorkspaceId = (state: RootState) => state.workspace.currentWorkspace.workspaceId -export const selectWorkspaceList = (state: RootState) => - state.workspace.workspaces - export const selectIsLoadingWorkspaceList = (state: RootState) => state.workspace.loading diff --git a/frontend/src/store/slice/Workspace/WorkspaceSlice.ts b/frontend/src/store/slice/Workspace/WorkspaceSlice.ts index ecd8e261a..59c7c254d 100644 --- a/frontend/src/store/slice/Workspace/WorkspaceSlice.ts +++ b/frontend/src/store/slice/Workspace/WorkspaceSlice.ts @@ -1,12 +1,23 @@ -import { PayloadAction, createSlice } from '@reduxjs/toolkit' +import { PayloadAction, createSlice, isAnyOf } from '@reduxjs/toolkit' import { WORKSPACE_SLICE_NAME, Workspace } from './WorkspaceType' import { importExperimentByUid } from '../Experiments/ExperimentsActions' +import { + delWorkspace, + getWorkspaceList, + postWorkspace, + putWorkspace, +} from './WorkspacesActions' const initialState: Workspace = { - workspaces: [{ workspace_id: 'default' }], currentWorkspace: { selectedTab: 0, }, + workspace: { + items: [], + total: 0, + limit: 50, + offset: 0, + }, loading: false, } @@ -27,10 +38,39 @@ export const workspaceSlice = createSlice({ }, }, extraReducers(builder) { - builder.addCase(importExperimentByUid.fulfilled, (state, action) => { - state.currentWorkspace.workspaceId = action.meta.arg.workspaceId - }) - // TODO: add case for set loading on get workspaces pending + builder + .addCase(importExperimentByUid.fulfilled, (state, action) => { + state.currentWorkspace.workspaceId = action.meta.arg.workspaceId + }) + .addCase(getWorkspaceList.fulfilled, (state, action) => { + state.workspace = action.payload + state.loading = false + }) + .addMatcher( + isAnyOf( + getWorkspaceList.rejected, + postWorkspace.fulfilled, + postWorkspace.rejected, + putWorkspace.fulfilled, + putWorkspace.rejected, + delWorkspace.fulfilled, + delWorkspace.rejected, + ), + (state) => { + state.loading = false + }, + ) + .addMatcher( + isAnyOf( + getWorkspaceList.pending, + postWorkspace.pending, + putWorkspace.pending, + delWorkspace.pending, + ), + (state) => { + state.loading = true + }, + ) }, }) diff --git a/frontend/src/store/slice/Workspace/WorkspaceType.ts b/frontend/src/store/slice/Workspace/WorkspaceType.ts index 2bfddf561..ceb42ee03 100644 --- a/frontend/src/store/slice/Workspace/WorkspaceType.ts +++ b/frontend/src/store/slice/Workspace/WorkspaceType.ts @@ -1,7 +1,28 @@ export const WORKSPACE_SLICE_NAME = 'workspace' +export type ItemsWorkspace = { + id: number + name: string + user: { + id: number + name: string + email: string + created_at: string + updated_at: string + }, + created_at: string + updated_at: string +} + +export type WorkspaceDataDTO = { + items: ItemsWorkspace[], + total: number + limit: number + offset: number +} + export type Workspace = { - workspaces: WorkspaceType[] + workspace: WorkspaceDataDTO currentWorkspace: { workspaceId?: string selectedTab: number @@ -9,7 +30,4 @@ export type Workspace = { loading: boolean } -export type WorkspaceType = { - workspace_id: string - // TODO: add fields required for workspace -} +export type WorkspaceParams = { [key: string]: string | undefined | number | string[] | object } diff --git a/frontend/src/store/slice/Workspace/WorkspacesActions.ts b/frontend/src/store/slice/Workspace/WorkspacesActions.ts new file mode 100644 index 000000000..9e5632c7a --- /dev/null +++ b/frontend/src/store/slice/Workspace/WorkspacesActions.ts @@ -0,0 +1,96 @@ +import { createAsyncThunk } from '@reduxjs/toolkit' +import { + WorkspacePostDataDTO, + delWorkspaceApi, + exportWorkspaceApi, + getWorkspacesApi, + importWorkspaceApi, + postWorkspaceApi, + putWorkspaceApi, +} from 'api/Workspace' +import { + ItemsWorkspace, + WorkspaceDataDTO, + WorkspaceParams, + WORKSPACE_SLICE_NAME, +} from './WorkspaceType' + +export const getWorkspaceList = createAsyncThunk< + WorkspaceDataDTO, + { [key: string]: number } +>(`${WORKSPACE_SLICE_NAME}/getWorkspaceList`, async (params, thunkAPI) => { + const { rejectWithValue } = thunkAPI + try { + const response = await getWorkspacesApi(params) + return response + } catch (e) { + return rejectWithValue(e) + } +}) + +export const delWorkspace = createAsyncThunk( + `${WORKSPACE_SLICE_NAME}/delWorkspaceList`, + async (data, thunkAPI) => { + const { rejectWithValue, dispatch } = thunkAPI + try { + const response = await delWorkspaceApi(Number(data.id)) + await dispatch(getWorkspaceList(data.params as { [key: string]: number })) + return response + } catch (e) { + return rejectWithValue(e) + } + }, +) + +export const postWorkspace = createAsyncThunk< + ItemsWorkspace, + WorkspacePostDataDTO +>(`${WORKSPACE_SLICE_NAME}/postWorkspaceList`, async (data, thunkAPI) => { + const { rejectWithValue } = thunkAPI + try { + const response = await postWorkspaceApi(data) + return response + } catch (e) { + return rejectWithValue(e) + } +}) + +export const putWorkspace = createAsyncThunk< + ItemsWorkspace, + WorkspacePostDataDTO +>(`${WORKSPACE_SLICE_NAME}/putWorkspaceList`, async (data, thunkAPI) => { + const { rejectWithValue } = thunkAPI + try { + const response = await putWorkspaceApi(data) + return response + } catch (e) { + return rejectWithValue(e) + } +}) + +export const importWorkspace = createAsyncThunk< + ItemsWorkspace, + { [key: string]: number } +>(`${WORKSPACE_SLICE_NAME}/importWorkspaceList`, async (data, thunkAPI) => { + const { rejectWithValue, dispatch } = thunkAPI + try { + const response = await importWorkspaceApi(data) + await dispatch(getWorkspaceList(data)) + return response + } catch (e) { + return rejectWithValue(e) + } +}) + +export const exportWorkspace = createAsyncThunk( + `${WORKSPACE_SLICE_NAME}/exportWorkspaceList`, + async (id, thunkAPI) => { + const { rejectWithValue } = thunkAPI + try { + const response = await exportWorkspaceApi(id) + return response + } catch (e) { + return rejectWithValue(e) + } + }, +) diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 20355056b..169192f0f 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -9175,6 +9175,11 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +moment@^2.29.4: + version "2.29.4" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" + integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== + mouse-change@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/mouse-change/-/mouse-change-1.4.0.tgz#c2b77e5bfa34a43ce1445c8157a4e4dc9895c14f" diff --git a/studio/app/common/routers/workspace.py b/studio/app/common/routers/workspace.py index a49cd37ff..d96783ceb 100644 --- a/studio/app/common/routers/workspace.py +++ b/studio/app/common/routers/workspace.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from fastapi_pagination import LimitOffsetPage from fastapi_pagination.ext.sqlmodel import paginate -from sqlmodel import Session, select +from sqlmodel import Session, or_, select from studio.app.common import models as common_model from studio.app.common.core.auth.auth_dependencies import get_current_user @@ -33,20 +33,30 @@ def search_workspaces( current_user: User = Depends(get_current_user), ): sort_column = getattr(common_model.Workspace, sortOptions.sort[0] or "id") - return paginate( - session=db, - query=select(common_model.Workspace) + query = ( + select(common_model.Workspace) + .outerjoin( + common_model.WorkspacesShareUser, + common_model.Workspace.id == common_model.WorkspacesShareUser.workspace_id, + ) .filter( - common_model.Workspace.user_id == current_user.id, common_model.Workspace.deleted.is_(False), + or_( + common_model.WorkspacesShareUser.user_id == current_user.id, + common_model.Workspace.user_id == current_user.id, + ), ) .group_by(common_model.Workspace.id) .order_by( sort_column.desc() if sortOptions.sort[1] == SortDirection.desc else sort_column.asc() - ), + ) ) + data = paginate(db, query) + for ws in data.items: + ws.__dict__["user"] = db.query(common_model.User).get(ws.user_id) + return data @router.get( @@ -251,9 +261,9 @@ def update_workspace_share_status( .filter(common_model.WorkspacesShareUser.workspace_id == id) .delete(synchronize_session=False) ) - [ - db.add(common_model.WorkspacesShareUser(workspace_id=id, user_id=user_id)) + db.bulk_save_objects( + common_model.WorkspacesShareUser(workspace_id=id, user_id=user_id) for user_id in data.user_ids - ] + ) db.commit() return True diff --git a/studio/app/common/schemas/workspace.py b/studio/app/common/schemas/workspace.py index a3df2daba..574cb5ba7 100644 --- a/studio/app/common/schemas/workspace.py +++ b/studio/app/common/schemas/workspace.py @@ -9,6 +9,7 @@ class Workspace(BaseModel): id: Optional[int] name: str + user_id: Optional[int] user: Optional[UserInfo] created_at: Optional[datetime] updated_at: Optional[datetime]