diff --git a/.run/Python tests in tests.run.xml b/.run/Python tests in tests.run.xml index 2fd8345de..2f33fbef9 100644 --- a/.run/Python tests in tests.run.xml +++ b/.run/Python tests in tests.run.xml @@ -3,9 +3,10 @@ - + + - + @@ -14,4 +15,4 @@ - + \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 84f54d38b..f40c4172a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -39,6 +39,7 @@ VisualizationDataDBViewList, VisualizationDataFreezeDB, ) +from app.models.project import ProjectDB from app.routers import ( authentication, authorization, @@ -66,6 +67,7 @@ thumbnails, users, visualization, + projects, ) # setup loggers @@ -250,6 +252,11 @@ prefix="/public_thumbnails", tags=["public_thumbnails"], ) +api_router.include_router( + projects.router, + prefix="/projects", + tags=["projects"], +) api_router.include_router( licenses.router, prefix="/licenses", @@ -315,6 +322,7 @@ async def startup_beanie(): ThumbnailFreezeDB, ThumbnailDBViewList, LicenseDB, + ProjectDB, ], recreate_views=True, ) diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 000000000..8239847ed --- /dev/null +++ b/backend/app/models/project.py @@ -0,0 +1,42 @@ +from datetime import datetime +from enum import Enum, auto +from typing import List, Optional + +import pymongo +from app.models.authorization import AuthorizationDB, RoleType +from app.models.groups import GroupOut +from app.models.users import UserOut +from beanie import Document, PydanticObjectId, View +from pydantic import BaseModel, Field + + +class Member(BaseModel): + user: UserOut + editor: bool = False + + +class ProjectBase(BaseModel): + id: PydanticObjectId = Field(default_factory=PydanticObjectId, alias="_id") + name: str + description: Optional[str] = None + created: datetime = Field(default_factory=datetime.utcnow) + modified: datetime = Field(default_factory=datetime.utcnow) + dataset_ids: Optional[List[PydanticObjectId]] = [] + folder_ids: Optional[List[PydanticObjectId]] = [] + file_ids: Optional[List[PydanticObjectId]] = [] + creator: UserOut + users: List[Member] = [] + + +class ProjectDB(Document, ProjectBase): + class Settings: + name = "projects" + + +class ProjectIn(ProjectBase): + pass + + +class ProjectOut(ProjectDB): + class Config: + fields = {"id": "id"} diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py new file mode 100644 index 000000000..fe8ffddba --- /dev/null +++ b/backend/app/routers/projects.py @@ -0,0 +1,326 @@ +import datetime +import hashlib +import io +import os +import shutil +import tempfile +import zipfile +from collections.abc import Iterable, Mapping +from typing import List, Optional +from app.models.pages import Paged, _construct_page_metadata, _get_page_query + + +from app import dependencies +from app.config import settings +from app.models.users import UserDB, UserOut, UserIn +from app.models.datasets import DatasetDB +from app.models.files import FileDB +from app.models.folders import FolderDB +from app.models.project import ( + ProjectBase, + ProjectDB, + ProjectIn, + ProjectOut, + Member, +) +from app.keycloak_auth import get_current_user, get_token, get_user +from beanie import PydanticObjectId +from beanie.operators import And, Or +from bson import ObjectId, json_util +from elasticsearch import Elasticsearch +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile +from fastapi.responses import StreamingResponse +from fastapi.security import HTTPBearer +from minio import Minio +from pika.adapters.blocking_connection import BlockingChannel +from pymongo import DESCENDING +from rocrate.model.person import Person +from rocrate.rocrate import ROCrate + +router = APIRouter() +security = HTTPBearer() + +clowder_bucket = os.getenv("MINIO_BUCKET_NAME", "clowder") + + +@router.post("", response_model=ProjectOut) +async def save_project( + project_in: ProjectIn, + user=Depends(get_current_user), + es: Elasticsearch = Depends(dependencies.get_elasticsearchclient), +): + project = ProjectDB(**project_in.dict()) + await project.insert() + + # TODO Add new entry to elasticsearch + return project.dict() + + +@router.post("/{project_id}/add_dataset/{dataset_id}", response_model=ProjectOut) +async def add_dataset( + project_id: str, + dataset_id: str, +): + if ( + project := await ProjectDB.find_one( + Or( + ProjectDB.id == PydanticObjectId(project_id), + ) + ) + ) is not None: + if ( + dataset := await DatasetDB.find_one( + Or( + DatasetDB.id == PydanticObjectId(dataset_id), + ) + ) + ) is not None: + if dataset_id not in project.dataset_ids: + project.dataset_ids.append(PydanticObjectId(dataset_id)) + await project.replace() + return project.dict() + raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found") + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + + +@router.post("/{project_id}/remove_dataset/{dataset_id}", response_model=ProjectOut) +async def remove_dataset( + project_id: str, + dataset_id: str, +): + if ( + project := await ProjectDB.find_one( + Or( + ProjectDB.id == PydanticObjectId(project_id), + ) + ) + ) is not None: + if ( + dataset := await DatasetDB.find_one( + Or( + DatasetDB.id == PydanticObjectId(dataset_id), + ) + ) + ) is not None: + if dataset_id in project.dataset_ids: + project.dataset_ids.remove(PydanticObjectId(dataset_id)) + await project.replace() + return project.dict() + else: + return project.dict() + raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found") + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + + +@router.post("/{project_id}/add_folder/{folder_id}", response_model=ProjectOut) +async def add_folder( + project_id: str, + folder_id: str, +): + if ( + project := await ProjectDB.find_one( + Or( + ProjectDB.id == PydanticObjectId(project_id), + ) + ) + ) is not None: + if ( + folder := await FolderDB.find_one( + Or( + FolderDB.id == FolderDB(PydanticObjectId(folder_id)), + ) + ) + ) is not None: + if folder_id not in project.folder_ids: + project.folder_ids.append(PydanticObjectId(folder_id)) + await project.replace() + return project.dict() + raise HTTPException(status_code=404, detail=f"Folder {folder_id} not found") + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + + +@router.post("/{project_id}/remove_folder/{folder_id}", response_model=ProjectOut) +async def remove_folder( + project_id: str, + folder_id: str, +): + if ( + project := await ProjectDB.find_one( + Or( + ProjectDB.id == PydanticObjectId(project_id), + ) + ) + ) is not None: + if ( + folder := await FolderDB.find_one( + Or( + FolderDB.id == FolderDB(PydanticObjectId(folder_id)), + ) + ) + ) is not None: + if folder_id in project.folder_ids: + project.folder_ids.remove(PydanticObjectId(folder_id)) + await project.replace() + return project.dict() + else: + return project.dict() + raise HTTPException(status_code=404, detail=f"Folder {folder_id} not found") + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + + +@router.post("/{project_id}/add_file/{file_id}", response_model=ProjectOut) +async def add_file( + project_id: str, + file_id: str, +): + if ( + project := await ProjectDB.find_one( + Or( + ProjectDB.id == PydanticObjectId(project_id), + ) + ) + ) is not None: + if ( + file := await FolderDB.find_one( + Or( + FileDB.id == FileDB(PydanticObjectId(file_id)), + ) + ) + ) is not None: + if file_id not in project.file_ids: + project.file_ids.append(PydanticObjectId(file_id)) + await project.replace() + return project.dict() + raise HTTPException(status_code=404, detail=f"File {file_id} not found") + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + + +@router.post("/{project_id}/remove_file/{file_id}", response_model=ProjectOut) +async def remove_file( + project_id: str, + file_id: str, +): + if ( + project := await ProjectDB.find_one( + Or( + ProjectDB.id == PydanticObjectId(project_id), + ) + ) + ) is not None: + if ( + file := await FolderDB.find_one( + Or( + FileDB.id == FileDB(PydanticObjectId(file_id)), + ) + ) + ) is not None: + if file_id in project.file_ids: + project.file_ids.remove(PydanticObjectId(file_id)) + await project.replace() + return project.dict() + else: + return project.dict() + raise HTTPException(status_code=404, detail=f"File {file_id} not found") + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + + +@router.get("", response_model=Paged) +async def get_projects( + user_id=Depends(get_user), + skip: int = 0, + limit: int = 10, + mine: bool = False, + enable_admin: bool = False, +): + # TODO check if the current user is a member OR creator + projects_and_count = await ProjectDB.aggregate( + [_get_page_query(skip, limit, sort_field="email", ascending=True)], + ).to_list() + + page_metadata = _construct_page_metadata(projects_and_count, skip, limit) + # TODO have to change _id this way otherwise it won't work + # TODO need to research if there is other pydantic trick to make it work + + page = Paged( + metadata=page_metadata, + data=[ + ProjectOut(id=item.pop("_id"), **item) + for item in projects_and_count[0]["data"] + ], + ) + + return page.dict() + + +@router.get("/{project_id}", response_model=ProjectOut) +async def get_project( + project_id: str, +): + if ( + project := await ProjectDB.find_one( + Or( + ProjectDB.id == PydanticObjectId(project_id), + ) + ) + ) is not None: + return project.dict() + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + + +@router.delete("/{project_id}", response_model=ProjectOut) +async def delete_project( + project_id: str, +): + if (project := await ProjectDB.get(PydanticObjectId(project_id))) is not None: + await project.delete() + return project.dict() # TODO: Do we need to return what we just deleted? + else: + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + + +@router.post("/{project_id}/add_member/{username}", response_model=ProjectOut) +async def add_member( + project_id: str, + username: str, + role: Optional[str] = None, +): + """Add a new user to a group.""" + if (user := await UserDB.find_one(UserDB.email == username)) is not None: + new_member = Member(user=UserOut(**user.dict())) + if (project := await ProjectDB.get(PydanticObjectId(project_id))) is not None: + found_already = False + for u in project.users: + if u.user.email == username: + found_already = True + break + if not found_already: + # If user is already in the group, skip directly to returning the group + # else add role and attach this member + project.users.append(new_member) + await project.replace() + return project.dict() + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + raise HTTPException(status_code=404, detail=f"User {username} not found") + + +@router.post("/{project_id}/remove_member/{username}", response_model=ProjectOut) +async def remove_member( + project_id: str, + username: str, +): + """Remove a user from a group.""" + + if (project := await ProjectDB.get(PydanticObjectId(project_id))) is not None: + # Is the user actually in the group already? + found_user = None + for u in project.users: + if u.user.email == username: + found_user = u + if not found_user: + # TODO: User wasn't in group, should this throw an error instead? Either way, the user is removed... + return project + # Update group itself + project.users.remove(found_user) + await project.replace() + return project.dict() + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") diff --git a/backend/app/tests/test_projects.py b/backend/app/tests/test_projects.py new file mode 100644 index 000000000..e96420b26 --- /dev/null +++ b/backend/app/tests/test_projects.py @@ -0,0 +1,67 @@ +from app.config import settings +from app.tests.utils import ( + create_dataset, + create_group, + create_user, + get_user_token, + user_alt, + create_project, +) +from fastapi.testclient import TestClient + +member_alt = {"user": user_alt, "editor": False} + + +def test_create_project(client: TestClient, headers: dict): + create_project(client, headers) + + +def test_get_project(client: TestClient, headers: dict): + project_id = create_project(client, headers).get("id") + response = client.get( + f"{settings.API_V2_STR}/projects/{project_id}", headers=headers + ) + assert response.status_code == 200 + assert response.json().get("id") is not None + + +def test_delete_project(client: TestClient, headers: dict): + project_id = create_project(client, headers).get("id") + response = client.delete( + f"{settings.API_V2_STR}/projects/{project_id}", headers=headers + ) + assert response.status_code == 200 + + +def test_add_member(client: TestClient, headers: dict): + new_project = create_project(client, headers) + project_id = new_project.get("id") + + create_user(client, headers) + new_project["users"].append(member_alt) + + response = client.post( + f"{settings.API_V2_STR}/projects/{project_id}/add_member/{member_alt['user']['email']}", + headers=headers, + ) + + assert response.status_code == 200 + assert response.json().get("id") is not None + for user in response.json().get("users"): + assert user.get("user").get("email") == member_alt["user"]["email"] + + +def test_add_dataset(client: TestClient, headers: dict): + new_project = create_project(client, headers) + project_id = new_project.get("id") + + dataset_id = create_dataset(client, headers).get("id") + + response = client.post( + f"{settings.API_V2_STR}/projects/{project_id}/add_dataset/{dataset_id}", + headers=headers, + ) + + assert response.status_code == 200 + assert response.json().get("id") is not None + assert dataset_id in response.json().get("dataset_ids") diff --git a/backend/app/tests/utils.py b/backend/app/tests/utils.py index 395d2a676..e6fedec52 100644 --- a/backend/app/tests/utils.py +++ b/backend/app/tests/utils.py @@ -61,6 +61,8 @@ }, } +project_example = {"name": "test_project", "description": "This project is a test"} + extractor_info_v1_example = { "@context": "http://clowder.ncsa.illinois.edu/contexts/extractors.jsonld", "name": "test.extractor_info_v1_example", @@ -176,6 +178,18 @@ def create_dataset_with_custom_license(client: TestClient, headers: dict): return response.json() +def create_project(client: TestClient, headers: dict): + """Creates a test dataset and returns the JSON.""" + response = client.post( + f"{settings.API_V2_STR}/projects", + headers=headers, + json=project_example, + ) + assert response.status_code == 200 + assert response.json().get("id") is not None + return response.json() + + def upload_file( client: TestClient, headers: dict, diff --git a/frontend/src/actions/project.js b/frontend/src/actions/project.js new file mode 100644 index 000000000..9950d6328 --- /dev/null +++ b/frontend/src/actions/project.js @@ -0,0 +1,103 @@ +// import { V2 } from "../openapi"; +// import { handleErrors } from "./common"; + +export const RECEIVE_PROJECTS = "RECEIVE_PROJECTS"; + +export function fetchProjects(skip = 0, limit = 12) { + return (dispatch) => { + dispatch({ + type: RECEIVE_PROJECTS, + projects: { + metadata: { + total_count: 3, + skip: skip, + limit: limit, + }, + data: [ + { + id: "60f9f8c8c23f5c45d8f0e0e2", + name: "Sample Project", + description: "A description of the sample project", + created: "2024-07-29T12:00:00Z", + modified: "2024-07-29T12:00:00Z", + dataset_ids: [ + "669ea731d559628438e5785d", + "669ea746d559628438e5788f", + ], + folder_ids: ["66a085640c20e43f5c50b059"], + file_ids: ["669fcf4c78f3222201e18a0f"], + creator: { + id: "60f9f8c8c23f5c45d8f0e0c6", + first_name: "Chen", + last_name: "Wang", + email: "cwang138@illinois.edu", + }, + }, + { + id: "60f9f8c8c23f5c45d8f0e0d1", + name: "Sample Project 2", + description: "A description of the second sample project", + created: "2024-07-28T12:00:00Z", + modified: "2024-07-28T12:00:00Z", + dataset_ids: ["669fcf3978f3222201e18a0d"], + folder_ids: [ + "66a085640c20e43f5c50b059", + "66a80284cf77abbb78b4435f", + ], + file_ids: ["669ea735d559628438e57865", "669ea733d559628438e57862"], + creator: { + id: "669ea726d559628438e57841", + first_name: "Chen", + last_name: "Wang", + email: "cwang138@illinois.edu", + }, + }, + { + id: "60f9f8c8c23f5c45d8f0e0e2", + name: "Sample Project 3", + description: "A description of the third sample project", + created: "2024-07-27T12:00:00Z", + modified: "2024-07-27T12:00:00Z", + dataset_ids: [], + folder_ids: ["66a80284cf77abbb78b4435f"], + file_ids: [], + creator: { + id: "669ea726d559628438e57841", + first_name: "Chen", + last_name: "Wang", + email: "cwang138@illinois.edu", + }, + }, + ], + }, + receivedAt: Date.now(), + }); + }; +} + +export const RECEIVE_PROJECT = "RECEIVE_PROJECT"; + +export function fetchProject(id) { + return (dispatch) => { + dispatch({ + type: RECEIVE_PROJECT, + project: { + id: id, + name: "Sample Project", + description: "A description of the sample project", + created: "2024-07-29T12:00:00Z", + modified: "2024-07-29T12:00:00Z", + dataset_ids: ["669ea731d559628438e5785d", "669ea746d559628438e5788f"], + folder_ids: ["66a085640c20e43f5c50b059"], + file_ids: ["669fcf4c78f3222201e18a0f"], + creator: { + id: "60f9f8c8c23f5c45d8f0e0c6", + first_name: "Chen", + last_name: "Wang", + email: "cwang138@illinois.edu", + }, + }, + receivedAt: Date.now(), + }); + }; +} diff --git a/frontend/src/app.config.ts b/frontend/src/app.config.ts index 7c325852c..de17e8a9f 100644 --- a/frontend/src/app.config.ts +++ b/frontend/src/app.config.ts @@ -25,6 +25,7 @@ interface Config { rawDataVisualizationThreshold: number; defaultFolderFilePerPage: number; defaultDatasetPerPage: number; + defaultProjectPerPage: number; defaultGroupPerPage: number; defaultUserPerPage: number; defaultApikeyPerPage: number; @@ -91,6 +92,7 @@ config["streamingBytes"] = 1024 * 10; // 10 MB? config["rawDataVisualizationThreshold"] = 1024 * 1024 * 10; // 10 MB config["defaultDatasetPerPage"] = 12; +config["defaultProjectPerPage"] = 12; config["defaultFolderFilePerPage"] = 5; config["defaultGroupPerPage"] = 5; config["defaultUserPerPage"] = 5; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index a96716a61..1e1299d6a 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -35,7 +35,11 @@ import { getAdminModeStatus as getAdminModeStatusAction, toggleAdminMode as toggleAdminModeAction, } from "../actions/user"; -import { AdminPanelSettings, SavedSearch } from "@mui/icons-material"; +import { + AdminPanelSettings, + SavedSearch, + Collections, +} from "@mui/icons-material"; import ManageAccountsIcon from "@mui/icons-material/ManageAccounts"; import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings"; import { Footer } from "./navigation/Footer"; @@ -346,6 +350,16 @@ export default function PersistentDrawerLeft(props) { + + + + + + + + + + diff --git a/frontend/src/components/datasets/DatasetTableEntry.tsx b/frontend/src/components/datasets/DatasetTableEntry.tsx new file mode 100644 index 000000000..8581168b2 --- /dev/null +++ b/frontend/src/components/datasets/DatasetTableEntry.tsx @@ -0,0 +1,67 @@ +import React, { useEffect, useState } from "react"; + +import TableRow from "@mui/material/TableRow"; +import TableCell from "@mui/material/TableCell"; +import { Dataset } from "@mui/icons-material"; +import { Button } from "@mui/material"; +import { parseDate } from "../../utils/common"; +import { generateThumbnailUrl } from "../../utils/visualization"; +import { MoreHoriz } from "@material-ui/icons"; + +export function DatsetTableEntry(props) { + const { iconStyle, selectDataset, dataset } = props; + const [thumbnailUrl, setThumbnailUrl] = useState(""); + + useEffect(() => { + let url = ""; + if (dataset.thumbnail_id) { + url = generateThumbnailUrl(dataset.thumbnail_id); + } + setThumbnailUrl(url); + }, [dataset]); + + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + return ( + + + {dataset.thumbnail_id ? ( + + ) : ( + + )} + selectDataset(dataset.id)}> + {dataset.name} + + + {parseDate(dataset.created)} + - + - + + + + + + + ); +} diff --git a/frontend/src/components/folders/FolderTableEntry.tsx b/frontend/src/components/folders/FolderTableEntry.tsx new file mode 100644 index 000000000..d34d0b134 --- /dev/null +++ b/frontend/src/components/folders/FolderTableEntry.tsx @@ -0,0 +1,44 @@ +import React from "react"; + +import TableRow from "@mui/material/TableRow"; +import TableCell from "@mui/material/TableCell"; +import { Folder } from "@mui/icons-material"; +import { Button } from "@mui/material"; +import { parseDate } from "../../utils/common"; +import { MoreHoriz } from "@material-ui/icons"; + +export function FolderTableEntry(props) { + const { iconStyle, selectFolder, folder } = props; + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + return ( + + + + selectFolder(folder)}>{folder.name} + + {parseDate(folder.created)} + - + - + + + + + + + ); +} diff --git a/frontend/src/components/projects/Project.tsx b/frontend/src/components/projects/Project.tsx new file mode 100644 index 000000000..f61db07ea --- /dev/null +++ b/frontend/src/components/projects/Project.tsx @@ -0,0 +1,215 @@ +import React, { ChangeEvent, useEffect, useState } from "react"; +import { + Box, + Grid, + Pagination, + Snackbar, + Tab, + Tabs, + Typography, +} from "@mui/material"; +import { useParams } from "react-router-dom"; +import { RootState } from "../../types/data"; +import { useDispatch, useSelector } from "react-redux"; +import { fetchProject } from "../../actions/project"; + +import { a11yProps, TabPanel } from "../tabs/TabComponent"; +import ProjectTable from "../projects/ProjectTable"; +import Layout from "../Layout"; +// import { ActionsMenuGroup } from "../datasets/ActionsMenuGroup"; +import { ProjectDetails } from "./ProjectDetails"; +import { FormatListBulleted, InsertDriveFile } from "@material-ui/icons"; +import AssessmentIcon from "@mui/icons-material/Assessment"; +import HistoryIcon from "@mui/icons-material/History"; +import ShareIcon from "@mui/icons-material/Share"; +import BuildIcon from "@mui/icons-material/Build"; +import { TabStyle } from "../../styles/Styles"; +import { ErrorModal } from "../errors/ErrorModal"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import config from "../../app.config"; + +export const Project = (): JSX.Element => { + // Path parameter + const { projectId } = useParams<{ projectId: string }>(); + + // Redux connect equivalent + const dispatch = useDispatch(); + const getProject = (projectId: string) => dispatch(fetchProject(projectId)); + const project = useSelector((state: RootState) => state.project.project); + + // State + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + const [errorOpen, setErrorOpen] = useState(false); + const [snackBarOpen, setSnackBarOpen] = useState(false); + const [snackBarMessage, setSnackBarMessage] = useState(""); + const [paths, setPaths] = useState([]); + const [currPageNum, setCurrPageNum] = useState(1); + const [limit] = useState(config.defaultFolderFilePerPage); + + useEffect(() => { + if (projectId) { + getProject(projectId); + } + }, [projectId]); + + // Breadcrumb + useEffect(() => { + if (project) { + const tmpPaths = [ + { + name: project.name, + url: `/projects/${projectId}`, + }, + ]; + setPaths(tmpPaths); + } + }, [project, projectId]); + + const handleTabChange = ( + _event: React.ChangeEvent<{}>, + newTabIndex: number + ) => { + setSelectedTabIndex(newTabIndex); + }; + + const handlePageChange = (_: ChangeEvent, value: number) => { + setCurrPageNum(value); + }; + + return ( + + {/* Error Message dialog */} + + { + setSnackBarOpen(false); + setSnackBarMessage(""); + }} + message={snackBarMessage} + /> + + {/* Title */} + + + + + + {project?.name ?? "Loading..."} + + + {project?.description ?? "Loading project details..."} + + + + + + {/* Actions */} + + {/**/} + + + + + + } + iconPosition="start" + sx={TabStyle} + label="Resources" + {...a11yProps(0)} + /> + } + iconPosition="start" + sx={TabStyle} + label="User Metadata" + {...a11yProps(1)} + disabled={false} + /> + } + iconPosition="start" + sx={TabStyle} + label="Machine Metadata" + {...a11yProps(2)} + disabled={false} + /> + } + iconPosition="start" + sx={TabStyle} + label="Analysis" + {...a11yProps(3)} + disabled={false} + /> + } + iconPosition="start" + sx={TabStyle} + label="Extraction History" + {...a11yProps(4)} + disabled={false} + /> + } + iconPosition="start" + sx={TabStyle} + label="Visualizations" + {...a11yProps(5)} + disabled={false} + /> + } + iconPosition="start" + sx={TabStyle} + label="Access Control" + {...a11yProps(6)} + disabled={false} + /> + + + + + + + + + {/* Content for User Metadata */} + + + {/* Content for Machine Metadata */} + + + {/* Content for Analysis */} + + + {/* Content for Extraction History */} + + + {/* Content for Visualizations */} + + + {/* Content for Access Control */} + + + + + + + + ); +}; diff --git a/frontend/src/components/projects/ProjectCard.tsx b/frontend/src/components/projects/ProjectCard.tsx new file mode 100644 index 000000000..0bfcde7cf --- /dev/null +++ b/frontend/src/components/projects/ProjectCard.tsx @@ -0,0 +1,121 @@ +import React from "react"; +import Card from "@mui/material/Card"; +import CardActions from "@mui/material/CardActions"; +import CardContent from "@mui/material/CardContent"; +import Typography from "@mui/material/Typography"; +import { Link } from "react-router-dom"; +import { parseDate } from "../../utils/common"; +import { + Box, + CardActionArea, + CardHeader, + IconButton, + Tooltip, +} from "@mui/material"; +import { + Download, + Folder, + InsertDriveFile, + Dataset, +} from "@mui/icons-material"; +import config from "../../app.config"; + +type ProjectCardProps = { + id?: string; + name?: string; + author?: string; + created?: string | Date; + description?: string; + numFiles?: number; + numFolders?: number; + numDatasets?: number; +}; + +export default function ProjectCard(props: ProjectCardProps) { + const { + id, + name, + author, + created, + description, + numFiles, + numFolders, + numDatasets, + } = props; + + const formattedCreated = parseDate(created, "PP"); + const subheader = `${formattedCreated} \u00B7 ${author}`; + + return ( + + + + + + {description} + + + + + + + + + + {numFiles ?? 0} + + + + + + + + {numFolders ?? 0} + + + + + + + + {numDatasets ?? 0} + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/projects/ProjectDetails.tsx b/frontend/src/components/projects/ProjectDetails.tsx new file mode 100644 index 000000000..9f86f1ab6 --- /dev/null +++ b/frontend/src/components/projects/ProjectDetails.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { Box, Typography } from "@mui/material"; +import { parseDate } from "../../utils/common"; +import { StackedList } from "../util/StackedList"; + +export function ProjectDetails(props) { + const { id, created, modified, creator } = props.details; + + const details = new Map< + string, + { value: string | undefined; info?: string } + >(); + if (creator && creator.first_name && creator.last_name) { + details.set("Owner", { + value: `${creator.first_name} ${creator.last_name}`, + }); + } + details.set("Created", { + value: parseDate(created), + info: "Date and time of project creation", + }); + details.set("Updated", { + value: parseDate(modified), + info: "Date and time of project modification", + }); + details.set("Project identifier", { value: id }); + + return ( + + + Details + + + + ); +} diff --git a/frontend/src/components/projects/ProjectTable.tsx b/frontend/src/components/projects/ProjectTable.tsx new file mode 100644 index 000000000..204fc7b94 --- /dev/null +++ b/frontend/src/components/projects/ProjectTable.tsx @@ -0,0 +1,156 @@ +import React, { useEffect, useState } from "react"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import Paper from "@mui/material/Paper"; +import { useNavigate } from "react-router-dom"; +import { Button } from "@mui/material"; +import FolderIcon from "@mui/icons-material/Folder"; +import { theme } from "../../theme"; +import { parseDate } from "../../utils/common"; +import { FilesTableFileEntry } from "../files/FilesTableFileEntry"; +import { useDispatch } from "react-redux"; +import FolderMenu from "./FolderMenu"; +import { AuthWrapper } from "../auth/AuthWrapper"; +import { FrozenWrapper } from "../auth/FrozenWrapper"; +import { V2 } from "../../openapi"; +import { DatsetTableEntry } from "../datasets/DatasetTableEntry"; +import { FolderTableEntry } from "../folders/FolderTableEntry"; + +const iconStyle = { + verticalAlign: "middle", + color: theme.palette.primary.main, +}; + +export default function ProjectTable(props) { + const { project } = props; + + // useNavigate hook for navigation + const navigate = useNavigate(); + const selectDataset = (selectedDatasetId) => { + navigate(`/datasets/${selectedDatasetId}`); + }; + const selectFile = (selectedFileId) => { + navigate(`/files/${selectedFileId}`); + }; + const selectFolder = (selectedFolder) => { + if (selectedFolder) + navigate( + `/datasets/${selectedFolder.dataset_id}?folder=${selectedFolder.id}` + ); + }; + + const dispatch = useDispatch(); + + const [datasets, setDatasets] = useState([]); + const [folders, setFolders] = useState([]); + const [files, setFiles] = useState([]); + + useEffect(() => { + // hack the folders for now + setFolders([ + { + id: "66a085640c20e43f5c50b059", + name: "20240723233900", + dataset_id: "669fcf3978f3222201e18a0d", + parent_folder: null, + creator: { + email: "cwang138@illinois.edu", + first_name: "Chen", + last_name: "Wang", + id: "669ea726d559628438e57841", + admin: true, + admin_mode: true, + read_only_user: false, + }, + created: "2024-07-24T04:39:00.710+0000", + modified: "2024-07-24T04:39:00.710+0000", + object_type: "folder", + }, + ]); + }, []); + + useEffect(() => { + if (project) { + if (project.dataset_ids) { + listDatasets(project.dataset_ids); + } + if (project.file_ids) { + listFiles(project.file_ids); + } + } + }, [project]); + + const listDatasets = async (datasetIds) => { + try { + const fetchedDatasets = await Promise.all( + datasetIds.map(async (id) => { + return V2.DatasetsService.getDatasetApiV2DatasetsDatasetIdGet(id); + }) + ); + setDatasets(fetchedDatasets); + } catch (error) { + console.error("Error fetching datasets:", error); + } + }; + + const listFiles = async (fileIds) => { + try { + const fetchedFiles = await Promise.all( + fileIds.map(async (id) => { + return V2.FilesService.getFileSummaryApiV2FilesFileIdSummaryGet(id); + }) + ); + setFiles(fetchedFiles); + } catch (error) { + console.error("Error fetching files:", error); + } + }; + + return ( + + + + + Name + Created + Size + Type + + + + + {datasets.map((item) => ( + + ))} + {folders.map((item) => ( + + ))} + {files.map((item) => ( + + ))} + + + + ); +} diff --git a/frontend/src/components/projects/Projects.tsx b/frontend/src/components/projects/Projects.tsx new file mode 100644 index 000000000..aa8042733 --- /dev/null +++ b/frontend/src/components/projects/Projects.tsx @@ -0,0 +1,111 @@ +import React, { ChangeEvent, useEffect, useState } from "react"; +import { Box, Button, Grid, Pagination } from "@mui/material"; + +import { useDispatch, useSelector } from "react-redux"; +import { fetchProjects } from "../../actions/project"; + +import ProjectCard from "./ProjectCard"; +import Layout from "../Layout"; +import { Link as RouterLink } from "react-router-dom"; +import { ErrorModal } from "../errors/ErrorModal"; +import config from "../../app.config"; +import { RootState } from "../../types/data"; + +export const Projects = (): JSX.Element => { + // Redux connect equivalent + const dispatch = useDispatch(); + const listProjects = (skip: number | undefined, limit: number | undefined) => + dispatch(fetchProjects(skip, limit)); + const projects = useSelector( + (state: RootState) => state.project.projects.data + ); + const pageMetadata = useSelector( + (state: RootState) => state.project.projects.metadata + ); + + const [currPageNum, setCurrPageNum] = useState(1); + const [limit] = useState(config.defaultProjectPerPage); + const [errorOpen, setErrorOpen] = useState(false); + + // Admin mode will fetch all projects + useEffect(() => { + listProjects((currPageNum - 1) * limit, limit); + }, [currPageNum, limit]); + + // pagination + const handlePageChange = (_: ChangeEvent, value: number) => { + const newSkip = (value - 1) * limit; + setCurrPageNum(value); + listProjects(newSkip, limit); + }; + + return ( + + {/*Error Message dialogue*/} + + + + + {projects !== undefined ? ( + projects.map((project) => { + return ( + + + + ); + }) + ) : ( + <>> + )} + {projects.length === 0 ? ( + + + + Nobody has created any projects on this instance. Click + below to create a project! + + + Create Project + + + + ) : ( + <>> + )} + + {projects.length !== 0 ? ( + + + + ) : ( + <>> + )} + + + + ); +}; diff --git a/frontend/src/openapi/v2/index.ts b/frontend/src/openapi/v2/index.ts index 256f73491..24365640b 100644 --- a/frontend/src/openapi/v2/index.ts +++ b/frontend/src/openapi/v2/index.ts @@ -5,6 +5,8 @@ export { ApiError } from './core/ApiError'; export { CancelablePromise } from './core/CancelablePromise'; export { OpenAPI } from './core/OpenAPI'; +export type { app__models__groups__Member } from './models/app__models__groups__Member'; +export type { app__models__project__Member } from './models/app__models__project__Member'; export type { AuthorizationBase } from './models/AuthorizationBase'; export type { AuthorizationMetadata } from './models/AuthorizationMetadata'; export type { AuthorizationOut } from './models/AuthorizationOut'; @@ -50,7 +52,6 @@ export type { LicenseIn } from './models/LicenseIn'; export type { LicenseOption } from './models/LicenseOption'; export type { LicenseOut } from './models/LicenseOut'; export type { LocalFileIn } from './models/LocalFileIn'; -export type { Member } from './models/Member'; export type { MetadataAgent } from './models/MetadataAgent'; export type { MetadataConfig } from './models/MetadataConfig'; export type { MetadataDefinitionIn } from './models/MetadataDefinitionIn'; @@ -65,6 +66,8 @@ export type { MetadataRequiredForItems } from './models/MetadataRequiredForItems export type { MongoDBRef } from './models/MongoDBRef'; export type { Paged } from './models/Paged'; export type { PageMetadata } from './models/PageMetadata'; +export type { ProjectIn } from './models/ProjectIn'; +export type { ProjectOut } from './models/ProjectOut'; export type { Repository } from './models/Repository'; export { RoleType } from './models/RoleType'; export type { SearchCriteria } from './models/SearchCriteria'; @@ -96,6 +99,7 @@ export { LicensesService } from './services/LicensesService'; export { ListenersService } from './services/ListenersService'; export { LoginService } from './services/LoginService'; export { MetadataService } from './services/MetadataService'; +export { ProjectsService } from './services/ProjectsService'; export { PublicDatasetsService } from './services/PublicDatasetsService'; export { PublicElasticsearchService } from './services/PublicElasticsearchService'; export { PublicFilesService } from './services/PublicFilesService'; diff --git a/frontend/src/openapi/v2/models/GroupBase.ts b/frontend/src/openapi/v2/models/GroupBase.ts index 1c1504b9f..e76425662 100644 --- a/frontend/src/openapi/v2/models/GroupBase.ts +++ b/frontend/src/openapi/v2/models/GroupBase.ts @@ -2,10 +2,10 @@ /* tslint:disable */ /* eslint-disable */ -import type { Member } from './Member'; +import type { app__models__groups__Member } from './app__models__groups__Member'; export type GroupBase = { name: string; description?: string; - users?: Array; + users?: Array; } diff --git a/frontend/src/openapi/v2/models/GroupIn.ts b/frontend/src/openapi/v2/models/GroupIn.ts index bb97af6d5..5b0ee67ed 100644 --- a/frontend/src/openapi/v2/models/GroupIn.ts +++ b/frontend/src/openapi/v2/models/GroupIn.ts @@ -2,10 +2,10 @@ /* tslint:disable */ /* eslint-disable */ -import type { Member } from './Member'; +import type { app__models__groups__Member } from './app__models__groups__Member'; export type GroupIn = { name: string; description?: string; - users?: Array; + users?: Array; } diff --git a/frontend/src/openapi/v2/models/GroupOut.ts b/frontend/src/openapi/v2/models/GroupOut.ts index 854b1c078..78fb1f8f4 100644 --- a/frontend/src/openapi/v2/models/GroupOut.ts +++ b/frontend/src/openapi/v2/models/GroupOut.ts @@ -2,7 +2,7 @@ /* tslint:disable */ /* eslint-disable */ -import type { Member } from './Member'; +import type { app__models__groups__Member } from './app__models__groups__Member'; /** * Document Mapping class. @@ -23,7 +23,7 @@ export type GroupOut = { modified?: string; name: string; description?: string; - users?: Array; + users?: Array; id?: string; views?: number; } diff --git a/frontend/src/openapi/v2/models/ProjectIn.ts b/frontend/src/openapi/v2/models/ProjectIn.ts new file mode 100644 index 000000000..2733684f1 --- /dev/null +++ b/frontend/src/openapi/v2/models/ProjectIn.ts @@ -0,0 +1,19 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { app__models__project__Member } from './app__models__project__Member'; +import type { UserOut } from './UserOut'; + +export type ProjectIn = { + _id?: string; + name: string; + description?: string; + created?: string; + modified?: string; + dataset_ids?: Array; + folder_ids?: Array; + file_ids?: Array; + creator: UserOut; + users?: Array; +} diff --git a/frontend/src/openapi/v2/models/ProjectOut.ts b/frontend/src/openapi/v2/models/ProjectOut.ts new file mode 100644 index 000000000..448f5027f --- /dev/null +++ b/frontend/src/openapi/v2/models/ProjectOut.ts @@ -0,0 +1,32 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { app__models__project__Member } from './app__models__project__Member'; +import type { UserOut } from './UserOut'; + +/** + * Document Mapping class. + * + * Fields: + * + * - `id` - MongoDB document ObjectID "_id" field. + * Mapped to the PydanticObjectId class + * + * Inherited from: + * + * - Pydantic BaseModel + * - [UpdateMethods](https://roman-right.github.io/beanie/api/interfaces/#aggregatemethods) + */ +export type ProjectOut = { + id?: string; + name: string; + description?: string; + created?: string; + modified?: string; + dataset_ids?: Array; + folder_ids?: Array; + file_ids?: Array; + creator: UserOut; + users?: Array; +} diff --git a/frontend/src/openapi/v2/models/Member.ts b/frontend/src/openapi/v2/models/app__models__groups__Member.ts similarity index 78% rename from frontend/src/openapi/v2/models/Member.ts rename to frontend/src/openapi/v2/models/app__models__groups__Member.ts index 9d67800af..6eef8cf15 100644 --- a/frontend/src/openapi/v2/models/Member.ts +++ b/frontend/src/openapi/v2/models/app__models__groups__Member.ts @@ -4,7 +4,7 @@ import type { UserOut } from './UserOut'; -export type Member = { +export type app__models__groups__Member = { user: UserOut; editor?: boolean; } diff --git a/frontend/src/openapi/v2/models/app__models__project__Member.ts b/frontend/src/openapi/v2/models/app__models__project__Member.ts new file mode 100644 index 000000000..2167310de --- /dev/null +++ b/frontend/src/openapi/v2/models/app__models__project__Member.ts @@ -0,0 +1,10 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { UserOut } from './UserOut'; + +export type app__models__project__Member = { + user: UserOut; + editor?: boolean; +} diff --git a/frontend/src/openapi/v2/services/ProjectsService.ts b/frontend/src/openapi/v2/services/ProjectsService.ts new file mode 100644 index 000000000..47607f3c0 --- /dev/null +++ b/frontend/src/openapi/v2/services/ProjectsService.ts @@ -0,0 +1,265 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Paged } from '../models/Paged'; +import type { ProjectIn } from '../models/ProjectIn'; +import type { ProjectOut } from '../models/ProjectOut'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { request as __request } from '../core/request'; + +export class ProjectsService { + + /** + * Get Projects + * @param skip + * @param limit + * @param mine + * @param enableAdmin + * @returns Paged Successful Response + * @throws ApiError + */ + public static getProjectsApiV2ProjectsGet( + skip?: number, + limit: number = 10, + mine: boolean = false, + enableAdmin: boolean = false, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/projects`, + query: { + 'skip': skip, + 'limit': limit, + 'mine': mine, + 'enable_admin': enableAdmin, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Save Project + * @param requestBody + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static saveProjectApiV2ProjectsPost( + requestBody: ProjectIn, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects`, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Add Dataset + * @param projectId + * @param datasetId + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static addDatasetApiV2ProjectsProjectIdAddDatasetDatasetIdPost( + projectId: string, + datasetId: string, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects/${projectId}/add_dataset/${datasetId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Remove Dataset + * @param projectId + * @param datasetId + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static removeDatasetApiV2ProjectsProjectIdRemoveDatasetDatasetIdPost( + projectId: string, + datasetId: string, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects/${projectId}/remove_dataset/${datasetId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Add Folder + * @param projectId + * @param folderId + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static addFolderApiV2ProjectsProjectIdAddFolderFolderIdPost( + projectId: string, + folderId: string, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects/${projectId}/add_folder/${folderId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Remove Folder + * @param projectId + * @param folderId + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static removeFolderApiV2ProjectsProjectIdRemoveFolderFolderIdPost( + projectId: string, + folderId: string, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects/${projectId}/remove_folder/${folderId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Add File + * @param projectId + * @param fileId + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static addFileApiV2ProjectsProjectIdAddFileFileIdPost( + projectId: string, + fileId: string, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects/${projectId}/add_file/${fileId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Remove File + * @param projectId + * @param fileId + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static removeFileApiV2ProjectsProjectIdRemoveFileFileIdPost( + projectId: string, + fileId: string, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects/${projectId}/remove_file/${fileId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Get Project + * @param projectId + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static getProjectApiV2ProjectsProjectIdGet( + projectId: string, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/projects/${projectId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Delete Project + * @param projectId + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static deleteProjectApiV2ProjectsProjectIdDelete( + projectId: string, + ): CancelablePromise { + return __request({ + method: 'DELETE', + path: `/api/v2/projects/${projectId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Add Member + * Add a new user to a group. + * @param projectId + * @param username + * @param role + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static addMemberApiV2ProjectsProjectIdAddMemberUsernamePost( + projectId: string, + username: string, + role?: string, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects/${projectId}/add_member/${username}`, + query: { + 'role': role, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Remove Member + * Remove a user from a group. + * @param projectId + * @param username + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static removeMemberApiV2ProjectsProjectIdRemoveMemberUsernamePost( + projectId: string, + username: string, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects/${projectId}/remove_member/${username}`, + errors: { + 422: `Validation Error`, + }, + }); + } + +} \ No newline at end of file diff --git a/frontend/src/reducers/index.ts b/frontend/src/reducers/index.ts index c1543db60..f92f3b386 100644 --- a/frontend/src/reducers/index.ts +++ b/frontend/src/reducers/index.ts @@ -12,10 +12,12 @@ import group from "./group"; import visualization from "./visualization"; import publicVisualization from "./public_visualization"; import feeds from "./feeds"; +import project from "./project"; const rootReducer = combineReducers({ file: file, dataset: dataset, + project: project, publicDataset: publicDataset, publicFile: publicFile, folder: folder, diff --git a/frontend/src/reducers/project.ts b/frontend/src/reducers/project.ts new file mode 100644 index 000000000..8a0d491ed --- /dev/null +++ b/frontend/src/reducers/project.ts @@ -0,0 +1,29 @@ +import { RECEIVE_PROJECT, RECEIVE_PROJECTS } from "../actions/project"; +import { DataAction } from "../types/action"; +import { DatasetState } from "../types/data"; + +// @ts-ignore +const defaultState: DatasetState = { + projects: { + metadata: {}, + data: [], + }, + project: {}, +}; + +const dataset = (state = defaultState, action: DataAction) => { + switch (action.type) { + case RECEIVE_PROJECT: + return Object.assign({}, state, { + project: action.project, + }); + case RECEIVE_PROJECTS: + return Object.assign({}, state, { + projects: action.projects, + }); + default: + return state; + } +}; + +export default dataset; diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index cd9defe5b..fcb205bb3 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -45,6 +45,8 @@ import { MetadataDefinitionEntry } from "./components/metadata/MetadataDefinitio import { Feeds } from "./components/listeners/Feeds"; import { AllListeners } from "./components/listeners/AllListeners"; import { FeedEntry } from "./components/listeners/FeedEntry"; +import { Projects } from "./components/projects/Projects"; +import { Project } from "./components/projects/Project"; // https://dev.to/iamandrewluca/private-route-in-react-router-v6-lg5 const PrivateRoute = (props): JSX.Element => { @@ -130,6 +132,22 @@ export const AppRoutes = (): JSX.Element => { ) : ( } /> )} + + + + } + /> + + + + } + />
+ Nobody has created any projects on this instance. Click + below to create a project! +