diff --git a/src/App.jsx b/src/App.jsx index 4ab6b92d..8a43ced8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -58,6 +58,7 @@ function MainContent() { const { setUserData, loading } = useContext(GlobalDataContext); const navigate = useNavigate(); + const location = useLocation(); // eslint-disable-next-line no-unused-vars const [existingCookies, setCookie, removeCookie] = useCookies(['session']); @@ -75,7 +76,10 @@ function MainContent() { groupname: userData['groupname'], settings: userData }); - navigate("/"); + // Only redirect to home if user is currently on login/register pages + if (location.pathname === '/login' || location.pathname === '/register') { + navigate("/"); + } } catch (error) { console.error("Error fetching user settings:", error); localStorage.removeItem(API_CONFIG.SESSION_DATA.SETTINGS); @@ -89,7 +93,7 @@ function MainContent() { } })(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [location.pathname]); if (loading) { return ( diff --git a/src/components/Auth/ForgotPassword.jsx b/src/components/Auth/ForgotPassword.jsx index 7c1662ea..a72d8d5a 100644 --- a/src/components/Auth/ForgotPassword.jsx +++ b/src/components/Auth/ForgotPassword.jsx @@ -1,7 +1,7 @@ import * as React from "react"; import { Box, Button, FormControl, Grid, Paper, Typography } from "@mui/material"; import { ArrowBack } from "@mui/icons-material"; -import FormField from "./UI/Formfield"; +import CustomFormField from "../common/CustomFormField"; import { Link } from "react-router-dom"; import { forgotPassword } from "../../api/endpoints/apiService"; @@ -11,7 +11,7 @@ const ForgotPassword = () => { const handleForgotPassword = async () => { try { - await forgotPassword({username: username}); + await forgotPassword({ username: username }); } catch (error) { console.error("Error:", error); setError(error.message); @@ -29,13 +29,15 @@ const ForgotPassword = () => { Forgot password
- setUsername(e.target.value)} - errorMessage={error} - /> + + setUsername(e.target.value)} + errorMessage={error} + /> + - + ) : ( - + { )} - + ) diff --git a/src/components/SingleOrganization/AddNewOntologyDialog.jsx b/src/components/SingleOrganization/AddNewOntologyDialog.jsx index ba1963c0..985bdf35 100644 --- a/src/components/SingleOrganization/AddNewOntologyDialog.jsx +++ b/src/components/SingleOrganization/AddNewOntologyDialog.jsx @@ -2,7 +2,7 @@ import PropTypes from "prop-types"; import AddIcon from '@mui/icons-material/Add'; import Checkbox from "../common/CustomCheckbox"; import StatusDialog from "../common/StatusDialog"; -import CustomInputBox from "../common/CustomInputBox"; +import CustomFormField from "../common/CustomFormField"; import { Stack, Button, Grid, Box } from "@mui/material"; import CustomizedDialog from "../common/CustomizedDialog"; import ImportFileTab from "./../TermEditor/ImportFileTab"; @@ -38,48 +38,48 @@ const AddNewOntologyDialog = ({ open, handleClose }) => { const [newOntologyResponse, setNewOntologyResponse] = useState({ title: "", description: "", - created : false, - message : "Your ontology “Nervous system” has been added. Click 'Go to Ontology' to go see the result, or add a new ontology." + created: false, + message: "Your ontology “Nervous system” has been added. Click 'Go to Ontology' to go see the result, or add a new ontology." }); const [files, setFiles] = useState([]); const [url, setUrl] = useState(''); const [tabValue, setTabValue] = useState(0); const { user } = useContext(GlobalDataContext); - const handleSubmit = async() => { + const handleSubmit = async () => { const groupname = user?.groupname - const retrieved_tokens = await retrieveTokenApi({groupname}) - let token = null; - if ( retrieved_tokens?.length > 0 ){ + const retrieved_tokens = await retrieveTokenApi({ groupname }) + let token = null; + if (retrieved_tokens?.length > 0) { token = retrieved_tokens?.[retrieved_tokens?.length - 1]?.key; } - - if ( token === undefined || token === null) { - const newToken = await getNewTokenApi({groupname}); + + if (token === undefined || token === null) { + const newToken = await getNewTokenApi({ groupname }); token = newToken?.key; } const ontologyName = newOntology?.title + "_" + Math.random().toString(36).substring(2, 10); const title = newOntology?.title; const subjects = files?.[0]?.data?.subjects; - + const result = await createNewOntology({ - groupname, - token, - ontologyName, - title, - subjects, + groupname, + token, + ontologyName, + title, + subjects, }); let ontologyResponseMessage = "Ontology created successfully!" - + if (!result.created) { ontologyResponseMessage = "Failed to create ontology" console.error('❌ Failed to create ontology:', result.error); } setOpenStatusDialog(true); - setNewOntologyResponse({title : newOntology?.title, description : ontologyResponseMessage, message : ontologyResponseMessage, created : result.created}) + setNewOntologyResponse({ title: newOntology?.title, description: ontologyResponseMessage, message: ontologyResponseMessage, created: result.created }) } const handleNewOntologyChange = (e) => { @@ -109,14 +109,14 @@ const AddNewOntologyDialog = ({ open, handleClose }) => { const handleFilesSelected = async (newFiles) => { const fileArray = Array.from(newFiles); - + const readFileContents = (file) => { return new Promise((resolve, reject) => { const reader = new FileReader(); - + reader.onload = () => { let content = reader.result; - + // Try parsing JSON if it's a JSON file if (file.name.endsWith('.json')) { try { @@ -126,7 +126,7 @@ const AddNewOntologyDialog = ({ open, handleClose }) => { content = null; } } - + resolve({ name: file.name, size: (file.size / 1024).toFixed(2), @@ -134,20 +134,20 @@ const AddNewOntologyDialog = ({ open, handleClose }) => { data: content }); }; - + reader.onerror = () => reject(reader.error); reader.readAsText(file); }); }; - + const updatedFiles = await Promise.all(fileArray.map(readFileContents)); - + setFiles(prevFiles => { const prevString = JSON.stringify(prevFiles); const newString = JSON.stringify(updatedFiles); return prevString !== newString ? updatedFiles : prevFiles; }); - }; + }; const handleChangeTabs = (_, newValue) => setTabValue(newValue); @@ -165,41 +165,41 @@ const AddNewOntologyDialog = ({ open, handleClose }) => { } > - - - {tabValue === 0 && ( - - - - - - - - - - - - )} - {tabValue === 1 && } - + + + {tabValue === 0 && ( + + + + + + + + + + + + )} + {tabValue === 1 && } + { const fetchTerms = useCallback( debounce(() => { setLoading(true); - + }, 300), [getMatchTerms] ); @@ -87,7 +87,7 @@ const CreateForkDialog = ({ open, handleClose, onSubmit }) => { useEffect(() => { setLoading(true) }, []); - + return ( { / - diff --git a/src/components/SingleTermView/OntologySearch.jsx b/src/components/SingleTermView/OntologySearch.jsx index 8a576566..73509f88 100644 --- a/src/components/SingleTermView/OntologySearch.jsx +++ b/src/components/SingleTermView/OntologySearch.jsx @@ -15,7 +15,7 @@ import CustomizedRadio from "../common/CustomizedRadio"; import FolderSharedOutlinedIcon from '@mui/icons-material/FolderSharedOutlined'; import { vars } from "../../theme/variables"; -const { brand600, gray50 } = vars; +const { brand600, gray50, gray300, gray400, white, gray700, gray200, paperShadow } = vars; const OPTIONS = [ { label: 'Nervous system1', badge: 'My Organization 1', selected: false }, @@ -31,6 +31,11 @@ const styles = { minWidth: fullWidth ? '100%' : (selectedValue ? '21.75rem' : '11.75rem'), width: fullWidth ? '100%' : 'fit-content', borderRadius: openList ? '0.5rem 0.5rem 0 0' : '0.5rem', + background: white, + "-webkit-text-fill-color": `${gray700} !important`, + "& .MuiSvgIcon-root": { + fill: gray700 + }, }, '&.Mui-focused': { transition: 'width 0.100s ease-in-out', @@ -39,7 +44,7 @@ const styles = { border: `2px solid ${brand600}`, background: gray50, boxShadow: '0px 1px 2px 0px rgba(16, 24, 40, 0.05)', - borderRadius: '.5rem', + borderRadius: '.5rem' }, '& .MuiOutlinedInput-notchedOutline': { border: 0, @@ -71,12 +76,12 @@ const styles = { }, popperBox: { borderRadius: '0.5rem', - border: '1px solid #DADDDC', - boxShadow: '0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03)' + border: `1px solid ${gray200}`, + boxShadow: paperShadow } }; -const OntologySearch = ({ placeholder, fullWidth = false }) => { +const OntologySearch = ({ placeholder, fullWidth = false, disabled }) => { const [searchTerm, setSearchTerm] = React.useState(''); const [openList, setOpenList] = React.useState(false); const [selectedValue, setSelectedValue] = React.useState(null); @@ -115,7 +120,7 @@ const OntologySearch = ({ placeholder, fullWidth = false }) => { }; }, [handleClickOutside]); - const isOptionEqualToValue = useCallback((option, value) => + const isOptionEqualToValue = useCallback((option, value) => option.label === value?.label && option.badge === value?.badge, [] ); @@ -205,6 +210,18 @@ const OntologySearch = ({ placeholder, fullWidth = false }) => { ), }} + sx={{ + "& .MuiOutlinedInput-root.Mui-disabled": { + background: white, + "-webkit-text-fill-color": `${gray400} !important`, + "& .MuiSvgIcon-root": { + fill: gray400 + }, + "& .MuiOutlinedInput-notchedOutline": { + borderColor: gray300 + } + } + }} /> ), [handleInputChange, placeholder, selectedValue]); @@ -215,6 +232,7 @@ const OntologySearch = ({ placeholder, fullWidth = false }) => { disableClearable options={OPTIONS} open={openList} + disabled={disabled} onOpen={handleOpenList} forcePopupIcon={false} onChange={handleChange} @@ -239,6 +257,7 @@ OntologySearch.propTypes = { fullWidth: PropTypes.bool, key: PropTypes.string, children: PropTypes.node, + disabled: PropTypes.bool }; export default OntologySearch; \ No newline at end of file diff --git a/src/components/SingleTermView/OverView/AddPredicateDialog.jsx b/src/components/SingleTermView/OverView/AddPredicateDialog.jsx index 8cac7c80..491534b3 100644 --- a/src/components/SingleTermView/OverView/AddPredicateDialog.jsx +++ b/src/components/SingleTermView/OverView/AddPredicateDialog.jsx @@ -1,9 +1,9 @@ import { useState } from "react"; -import {useQuery} from "../../../helpers"; -import {Box, Grid, Button} from "@mui/material"; +import { useQuery } from "../../../helpers"; +import { Box, Grid, Button } from "@mui/material"; import Typography from "@mui/material/Typography"; import PredicateGroupInput from "./PredicateGroupInput"; -import CustomizedInput from "../../common/CustomizedInput"; +import CustomFormField from "../../common/CustomFormField"; import CustomizedDialog from "../../common/CustomizedDialog"; import AddOutlinedIcon from "@mui/icons-material/AddOutlined"; import CustomSingleSelect from "../../common/CustomSingleSelect"; @@ -12,10 +12,10 @@ import AddPredicateStatusDialog from "./AddPredicateStatusDialog"; import PlaylistAddOutlinedIcon from "@mui/icons-material/PlaylistAddOutlined"; import PropTypes from "prop-types"; -import {vars} from "../../../theme/variables"; -const {gray800} = vars; +import { vars } from "../../../theme/variables"; +const { gray800 } = vars; -const HeaderRightSideContent = ({handleClose, handleOpenAddPredicateStatusDialog, isAllFieldsFilled}) => { +const HeaderRightSideContent = ({ handleClose, handleOpenAddPredicateStatusDialog, isAllFieldsFilled }) => { return ( )} - :last-child": { + borderTop: `1px solid ${gray200}`, + color: error700 + } }} - > - - - - + /> + Download as { {predicates.map((predicate, index) => ( - { https:// - + or diff --git a/src/components/TermEditor/ManualImportTab.jsx b/src/components/TermEditor/ManualImportTab.jsx index fc2996a3..6e7def79 100644 --- a/src/components/TermEditor/ManualImportTab.jsx +++ b/src/components/TermEditor/ManualImportTab.jsx @@ -1,29 +1,30 @@ import PropTypes from "prop-types"; import Checkbox from "../common/CustomCheckbox"; import ExistingIdsSearch from "./ExistingIdsSearch"; -import CustomInputBox from "../common/CustomInputBox"; +import CustomFormField from "../common/CustomFormField"; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import { Box, Grid, Button, FormControlLabel } from "@mui/material"; import CustomAutocompleteBox from "../common/CustomAutocompleteBox"; import { vars } from "../../theme/variables"; -const { gray600, brand700, brand800 } = vars; +const { gray600, brand700, brand800, gray800 } = vars; const ManualImportTab = ({ formState, onInputChange, handleSidebarOpen, matchesChecked, handleMatchesChange, isResultsEmpty, existingIDsOptions, onExistingIDsChange, onSynonymsChange }) => { return ( - + + + )} + + + + {result.definition} + + + ); +}; + +export default function NewTermSidebar({ + open, + loading, + onToggle, + results, + isResultsEmpty, + searchValue, + onResultAction +}) { + if (loading) { + return ; + } + + const sidebarStyles = { + display: 'flex', + flexDirection: 'column', + borderLeft: `1px solid ${gray200}`, + transition: SIDEBAR_STYLES.transition, + p: 3, + maxWidth: open ? SIDEBAR_STYLES.expanded : SIDEBAR_STYLES.collapsed, + overflowY: 'auto', + '::-webkit-scrollbar': { + display: 'none', + }, + scrollbarWidth: 'none', + position: 'relative', + }; + + return ( + {open ? ( - - Potential matches - - - - - - + ) : ( - - - - - - - + )} - {(open && results )&& ( - + + {open && results && ( + {isResultsEmpty ? ( - - - - - - No match found - Add a label to your term to visualize potential matches. - - + ) : ( - <>{results?.map((result) => ( - - {result.label} - - {result.description} - - - ))} + <> + {results?.map((result) => ( + + ))} + )} )} - + ); } + +ExpandedHeader.propTypes = { + onToggle: PropTypes.func.isRequired, +}; + +CollapsedHeader.propTypes = { + onToggle: PropTypes.func.isRequired, +}; + +ResultItem.propTypes = { + result: PropTypes.object.isRequired, + searchValue: PropTypes.string, + onResultAction: PropTypes.func, +}; + NewTermSidebar.propTypes = { open: PropTypes.bool.isRequired, loading: PropTypes.bool.isRequired, onToggle: PropTypes.func.isRequired, results: PropTypes.array.isRequired, isResultsEmpty: PropTypes.bool.isRequired, -}; + searchValue: PropTypes.string.isRequired, + onResultAction: PropTypes.func, +}; \ No newline at end of file diff --git a/src/components/TermEditor/TermDialog.jsx b/src/components/TermEditor/TermDialog.jsx index bca961c7..f2f045ef 100644 --- a/src/components/TermEditor/TermDialog.jsx +++ b/src/components/TermEditor/TermDialog.jsx @@ -9,7 +9,7 @@ import AddNewTermDialogContent from "./AddNewTermDialogContent"; import { Box, Divider, MobileStepper, Stack, Button } from "@mui/material"; import { vars } from "../../theme/variables"; -const { gray100, gray200, gray400, brand700 } = vars; +const { gray100, gray200, gray400 } = vars; const HeaderRightSideContent = ({ activeStep, onContinue, onClose, isContinueButtonDisabled }) => ( @@ -20,13 +20,7 @@ const HeaderRightSideContent = ({ activeStep, onContinue, onClose, isContinueBut steps={3} position="static" activeStep={activeStep} - sx={{ - maxWidth: 64, - flexGrow: 1, - '& .MuiMobileStepper-dots': { gap: '0.75rem' }, - '& .MuiMobileStepper-dot': { margin: 0, backgroundColor: gray200 }, - '& .MuiMobileStepper-dotActive': { backgroundColor: brand700 } - }} + sx={{ maxWidth: 64, flexGrow: 1 }} /> diff --git a/src/components/TermEditor/TermForm.jsx b/src/components/TermEditor/TermForm.jsx index fa412862..36109dcb 100644 --- a/src/components/TermEditor/TermForm.jsx +++ b/src/components/TermEditor/TermForm.jsx @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import CloseIcon from '@mui/icons-material/Close'; -import CustomInputBox from "../common/CustomInputBox"; +import CustomFormField from '../common/CustomFormField'; import { Box, Grid, Autocomplete, Chip, Stack, Typography, TextField } from "@mui/material"; import { vars } from "../../theme/variables"; @@ -72,16 +72,17 @@ const TermForm = ({ formState, data, onInputChange, onAutocompleteChange }) => { - @@ -114,14 +115,15 @@ const TermForm = ({ formState, data, onInputChange, onAutocompleteChange }) => { - @@ -154,38 +156,36 @@ const TermForm = ({ formState, data, onInputChange, onAutocompleteChange }) => { - - - diff --git a/src/components/TermEditor/newTerm/AddNewTermDialog.jsx b/src/components/TermEditor/newTerm/AddNewTermDialog.jsx new file mode 100644 index 00000000..98ca6571 --- /dev/null +++ b/src/components/TermEditor/newTerm/AddNewTermDialog.jsx @@ -0,0 +1,211 @@ +import { useState, useCallback, useContext } from "react"; +import { useNavigate } from "react-router-dom"; +import PropTypes from "prop-types"; +import { + Box, + Divider, + MobileStepper, + Stack, + Button, + FormControlLabel, + Checkbox, + CircularProgress +} from "@mui/material"; +import CustomButton from '../../common/CustomButton'; +import CustomizedDialog from "../../common/CustomizedDialog"; +import OntologySearch from '../../SingleTermView/OntologySearch'; +import FirstStepContent from "./FirstStepContent"; +import SecondStepContent from "./SecondStepContent"; +import StatusStep from "../../common/StatusStep"; +import { GlobalDataContext } from "../../../contexts/DataContext"; +import { createNewEntity } from "../../../api/endpoints/apiService"; +import { getAddTermStatusProps } from '../termStatusProps'; +import { CheckedIcon, UncheckedIcon } from '../../../Icons'; +import { vars } from "../../../theme/variables"; + +const { gray100, gray200, gray400, gray600 } = vars; + +const HeaderRightSideContent = ({ + activeStep, + onContinue, + onClose, + isCreateButtonDisabled +}) => { + const [ontologyChecked, setOntologyChecked] = useState(false); + + const handleOntologyChange = (event) => { + setOntologyChecked(event.target.checked); + }; + + return ( + + {activeStep !== 2 ? ( + <> + } + checkedIcon={} + checked={ontologyChecked} + onChange={handleOntologyChange} + /> + } + sx={{ color: gray600 }} + label="Add to ontology" + /> + + + + + + Cancel + + + + ) : ( + + )} + + ) +}; + +HeaderRightSideContent.propTypes = { + activeStep: PropTypes.number.isRequired, + onContinue: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + isCreateButtonDisabled: PropTypes.bool.isRequired +}; + +const AddNewTermDialog = ({ open, handleClose }) => { + const [activeStep, setActiveStep] = useState(0); + const [addTermResponse] = useState(null); + const [selectedType, setSelectedType] = useState(null); + const [termValue, setTermValue] = useState(""); + const [exactSynonyms, setExactSynonyms] = useState([]); + const [existingIds, setExistingIds] = useState([]); + const [loading, setLoading] = useState(false); + const [hasExactMatch] = useState(false); + const { user } = useContext(GlobalDataContext); + const navigate = useNavigate(); + + const isCreateButtonDisabled = hasExactMatch || termValue === ""; + const statusProps = getAddTermStatusProps(addTermResponse, termValue); + + const handleCancelBtnClick = () => { + handleClose(); + setActiveStep(0); + }; + + const handleTermValueChange = (event) => { + const value = event.target.value; + setTermValue(value); + }; + + const handleTypeChange = (newType) => { + setSelectedType(newType); + }; + + const handleSynonymChange = (event, newValue) => { + setExactSynonyms(newValue); + }; + + const handleExistingIdChange = (event, newValue) => { + setExistingIds(newValue); + }; + + const createNewTerm = useCallback(async () => { + if (!termValue || !selectedType || hasExactMatch) return; + + setLoading(true); + + const token = localStorage.getItem("token"); + const groupName = user?.groupname || "base"; + const body = { + 'rdf-type': selectedType || 'owl:Class', + label: termValue, + exact: [], + }; + + try { + const response = await createNewEntity({ + group: groupName, + data: body, + session: token + }); + navigate(`/terms/${response.term.id.split("/").pop()}`); + } catch (error) { + console.error("Creation failed:", error); + } finally { + setLoading(false); + } + }, [termValue, selectedType, user, hasExactMatch, navigate]); + + if (loading) { + return + + + } + + return ( + + } + sx={{ '& .MuiDialogContent-root': { padding: 0, overflowY: "hidden" } }} + > + {activeStep === 0 && } + {activeStep === 1 && } + {activeStep === 2 && addTermResponse != null && ( + + )} + + ); +}; + +AddNewTermDialog.propTypes = { + open: PropTypes.bool.isRequired, + handleClose: PropTypes.func.isRequired, + searchTerm: PropTypes.string, + forwardPredicateStep: PropTypes.bool +}; + +export default AddNewTermDialog; diff --git a/src/components/TermEditor/newTerm/FirstStepContent.jsx b/src/components/TermEditor/newTerm/FirstStepContent.jsx new file mode 100644 index 00000000..274dd151 --- /dev/null +++ b/src/components/TermEditor/newTerm/FirstStepContent.jsx @@ -0,0 +1,264 @@ +import PropTypes from "prop-types"; +import { useState, useEffect, useContext } from "react"; +import { debounce } from "lodash"; +import { + Box, + Divider, + Stack, + Typography, + Autocomplete, + Chip, + TextField +} from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import { GlobalDataContext } from "../../../contexts/DataContext"; +import CloseIcon from "@mui/icons-material/Close"; +import CustomSingleSelect from "../../common/CustomSingleSelect"; +import CustomFormField from "../../common/CustomFormField"; +import NewTermSidebar from "../NewTermSidebar"; +import { HelpOutlinedIcon } from "../../../Icons"; +import { elasticSearch } from "../../../api/endpoints"; +import { vars } from "../../../theme/variables"; + +const { white, gray300, gray400, gray500, gray600 } = vars; + +const TYPES = ["Term", "Relationship", "Ontology"]; + +const AUTOCOMPLETE_STYLES = { + "& .MuiOutlinedInput-root": { + background: white, + borderColor: gray300, + }, + "& .MuiOutlinedInput-root .MuiAutocomplete-input": { + color: gray500, + fontWeight: 400, + }, + "& .MuiAutocomplete-popupIndicator": { + transform: "none !important", + }, + "& .MuiAutocomplete-tag": { + background: "transparent", + }, +}; + +const SYNONYM_CHIP_STYLES = { + flexDirection: "row !important", + "& .MuiChip-deleteIcon": { + color: `${gray400} !important`, + }, +}; + +const ID_CHIP_STYLES = { + flexDirection: "row !important", + borderRadius: "1rem !important", + "& .MuiChip-deleteIcon": { + color: `${gray400} !important`, + }, +}; + +const FirstStepContent = ({ term, type, existingIds, synonyms, handleTermChange, handleTypeChange, handleExistingIdChange, handleSynonymChange, handleDialogClose }) => { + const [termResults, setTermResults] = useState([]); + const [openSidebar, setOpenSidebar] = useState(true); + const [loading, setLoading] = useState(false); + const [hasExactMatch, setHasExactMatch] = useState(false); + + const [synonymOptions] = useState([]); + const [idOptions] = useState([]); + const { user, updateStoredSearchTerm } = useContext(GlobalDataContext); + const navigate = useNavigate(); + + const searchForMatches = debounce(async (searchTerm, type) => { + if (!searchTerm || !type) { + setTermResults([]); + setHasExactMatch(false); + return; + } + + setLoading(true); + + try { + const response = await elasticSearch(searchTerm, 10); + const rawResults = response.results.results || []; + + const filteredResults = rawResults.filter(result => { + return result.type === type.toLowerCase() || + (result.type === "term") || + (result.type === "relationship") || + (result.type === "ontology"); + }); + + const exactMatch = filteredResults.find(result => + result.label?.toLowerCase() === searchTerm.toLowerCase() && + result.type === type.toLowerCase() + ); + + setHasExactMatch(!!exactMatch); + + const sortedResults = filteredResults.sort((a, b) => { + const aIsExact = a.label?.toLowerCase() === searchTerm.toLowerCase(); + const bIsExact = b.label?.toLowerCase() === searchTerm.toLowerCase(); + + if (aIsExact && !bIsExact) return -1; + if (!aIsExact && bIsExact) return 1; + return 0; + }); + + setTermResults(sortedResults); + } catch (error) { + setTermResults([]); + setHasExactMatch(false); + } finally { + setLoading(false); + } + }, 500); + + const handleSidebarToggle = () => setOpenSidebar(!openSidebar); + + const navigateToExistingTerm = (searchResult) => { + updateStoredSearchTerm(searchResult?.label); + const groupName = user?.groupname || 'base'; + navigate(`/${groupName}/${searchResult?.ilx}/overview`); + handleDialogClose(); + }; + + const renderChips = (values, getTagProps, chipStyles) => { + return values.map((option, index) => ( + } + sx={chipStyles} + {...getTagProps({ index })} + /> + )); + }; + + useEffect(() => { + if (term && type) { + searchForMatches(term, type); + } + return () => { + searchForMatches.cancel(); + setHasExactMatch(false); + setTermResults([]) + }; + }, [term, type, searchForMatches]); + + return ( + + + + How would you like to proceed? + + Link this term to an existing source, either by reusing an existing ID or by specifying an exact synonym. + + + + + + + Exact synonyms + + + Synonyms are especially useful when the term is known by different names. Please enter the type, label and + exact synonym(s) for your object. + + + + + + + + + } + options={synonymOptions} + freeSolo + renderTags={(values, getTagProps) => renderChips(values, getTagProps, SYNONYM_CHIP_STYLES)} + fullWidth + renderInput={(params) => } + sx={AUTOCOMPLETE_STYLES} + /> + + + + + + + + Existing IDs + + + Link to existing identifiers from external databases or ontologies. + + + + } + options={idOptions} + freeSolo + renderTags={(values, getTagProps) => renderChips(values, getTagProps, ID_CHIP_STYLES)} + fullWidth + renderInput={(params) => } + sx={AUTOCOMPLETE_STYLES} + /> + + + + + + ); +}; + +FirstStepContent.propTypes = { + term: PropTypes.string, + type: PropTypes.string, + existingIds: PropTypes.array, + synonyms: PropTypes.array, + handleTermChange: PropTypes.func, + handleTypeChange: PropTypes.func, + handleExistingIdChange: PropTypes.func, + handleSynonymChange: PropTypes.func, + handleDialogClose: PropTypes.func, +}; + +export default FirstStepContent; \ No newline at end of file diff --git a/src/components/TermEditor/newTerm/SecondStepContent.jsx b/src/components/TermEditor/newTerm/SecondStepContent.jsx new file mode 100644 index 00000000..273f75b8 --- /dev/null +++ b/src/components/TermEditor/newTerm/SecondStepContent.jsx @@ -0,0 +1,267 @@ +import React from "react" +import { useState } from "react" +import { Box, Grid, Typography, FormControl, Autocomplete, Chip, TextField, Divider, Button } from "@mui/material" +import AddIcon from "@mui/icons-material/Add" +import CustomFormField from "../../common/CustomFormField" +import PredicateGroupInput from "../../SingleTermView/OverView/PredicateGroupInput" +import { HelpOutlinedIcon } from "../../../Icons" +import { vars } from "../../../theme/variables" + +const { white, gray300, gray500, gray600, gray800 } = vars + +const URI_PREFIX = "http://uri.interlex.org/Interlex/uris/" + +const AUTOCOMPLETE_STYLES = { + "& .MuiOutlinedInput-root": { + background: white, + borderColor: gray300, + }, + "& .MuiOutlinedInput-root .MuiAutocomplete-input": { + color: gray500, + fontWeight: 400, + }, + "& .MuiAutocomplete-popupIndicator": { + transform: "none !important", + }, +} + +const URI_PREFIX_BOX_STYLES = { + fontSize: "1rem", + border: `1px solid ${gray300}`, + borderRadius: "0.5rem", + borderRight: 0, + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + height: "2.5rem", + display: "flex", + justifyContent: "center", + alignItems: "center", + color: gray600, + padding: "0.5rem 0.75rem", +} + +const SecondStepContent = () => { + const [superclass, setSuperclass] = useState("") + const [subclassOf, setSubclassOf] = useState("") + const [definitionUrls, setDefinitionUrls] = useState([]) + const [transitiveProperty, setTransitiveProperty] = useState("") + const [definition, setDefinition] = useState("") + const [comment, setComment] = useState("") + const [searchTerm] = useState("Central Nervous System") + + const [predicates, setPredicates] = useState([ + { + subject: "", + predicate: "", + object: { type: "Object", value: "", isLink: false }, + }, + ]) + + const [urlOptions] = useState([]) + + const handleDefinitionUrlsChange = (event, newValue) => { + setDefinitionUrls(newValue) + } + + const handleAddPredicate = () => { + setPredicates([ + ...predicates, + { + subject: searchTerm, + predicate: "", + object: { type: "Object", value: "", isLink: false }, + }, + ]) + } + + const handlePredicateChange = (index, field, value) => { + const newPredicates = [...predicates] + newPredicates[index] = { ...newPredicates[index], [field]: value } + setPredicates(newPredicates) + } + + const handleRemovePredicate = (index) => { + if (predicates.length > 1) { + setPredicates(predicates.filter((_, i) => i !== index)) + } + } + + return ( + + + + Term metadata overview + + + + setSuperclass(value)} + isEndAdornmentVisible + textFontSize="body1" + labelColor={gray800} + /> + + + + Subclass of + + + {URI_PREFIX} + + setSubclassOf(e.target.value)} + sx={{ + "& .MuiInputBase-input": { + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0 + } + }} + isEndAdornmentVisible + /> + + + + + Is Defined by + } + options={urlOptions} + freeSolo + getOptionLabel={(option) => (typeof option === "string" ? option : option.title)} + renderTags={(values, getTagProps) => + values.map((option, index) => ( + + )) + } + fullWidth + renderInput={(params) => } + sx={AUTOCOMPLETE_STYLES} + /> + + + + setTransitiveProperty(value)} + isEndAdornmentVisible + textFontSize="body1" + labelColor={gray800} + /> + + + + setDefinition(e.target.value)} + placeholder="Enter definition..." + textFontSize="body1" + labelColor={gray800} + /> + + + + setComment(e.target.value)} + placeholder="Enter comment..." + textFontSize="body1" + labelColor={gray800} + /> + + + + + + + + Add new predicate(s) to {searchTerm} + + Add more predicates to further define this term. This is optional and can be done later. + + + + {predicates.map((predicate, index) => ( + + + handlePredicateChange(index, "subject", e.target.value)} + helperText="The subject is prefilled." + textFontSize="body1" + labelColor={gray800} + /> + + + handlePredicateChange(index, "predicate", e.target.value)} + helperText="This is a hint text to help user." + textFontSize="body1" + labelColor={gray800} + /> + + + handlePredicateChange(index, "object", value)} + onRemove={predicates.length > 1 ? () => handleRemovePredicate(index) : undefined} + /> + + + ))} + + + + + + + ) +} + +export default SecondStepContent; diff --git a/src/components/common/CustomButtonGroup.jsx b/src/components/common/CustomButtonGroup.jsx new file mode 100644 index 00000000..8fa5f80e --- /dev/null +++ b/src/components/common/CustomButtonGroup.jsx @@ -0,0 +1,156 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; +import Grow from '@mui/material/Grow'; +import Paper from '@mui/material/Paper'; +import Popper from '@mui/material/Popper'; +import MenuItem from '@mui/material/MenuItem'; +import MenuList from '@mui/material/MenuList'; +import { KeyboardArrowUp, KeyboardArrowDown } from "@mui/icons-material"; +import { vars } from '../../theme/variables'; + +const { white, gray200, gray600, paperShadow } = vars; + +const styles = { + paper: { + borderRadius: "0.5rem", + border: `1px solid ${gray200}`, + backgroundColor: white, + boxShadow: paperShadow, + width: "100%" + }, + menu: { + padding: "0.25rem 0" + }, + menuItem: { + padding: "0.625rem 0.875rem", + fontSize: "0.875rem", + fontWeight: 600, + lineHeight: "1.25rem", + color: gray600, + gap: "0.25rem" + } +} + +const CustomButtonGroup = ({ variant = "contained", options = [], sx }) => { + const [open, setOpen] = React.useState(false); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const anchorRef = React.useRef(null); + + const handleMainButtonClick = () => { + setOpen(false); + if (options[selectedIndex]?.action) { + options[selectedIndex].action(); + } + }; + + const handleMenuItemClick = (event, index) => { + setSelectedIndex(index); + setOpen(false); + }; + + + const handleToggle = () => { + setOpen((prevOpen) => !prevOpen); + }; + + const handleClose = (event) => { + if (anchorRef.current && anchorRef.current.contains(event.target)) { + return; + } + setOpen(false); + }; + + return ( + + + + {options.length > 0 && ( + + )} + + {options.length > 0 && ( + + {({ TransitionProps, placement }) => ( + + + + + {options.map((option, index) => ( + handleMenuItemClick(event, index)} + sx={styles.menuItem} + > + {option.icon} + {option.label} + + ))} + + + + + )} + + )} + + ); +} + +CustomButtonGroup.propTypes = { + variant: PropTypes.oneOf(['contained', 'outlined', 'text']), + options: PropTypes.array, + sx: PropTypes.object, +}; + +CustomButtonGroup.defaultProps = { + variant: 'contained', + options: [], + sx: {}, +}; + +export default CustomButtonGroup; \ No newline at end of file diff --git a/src/components/common/CustomFormField.jsx b/src/components/common/CustomFormField.jsx new file mode 100644 index 00000000..4f6565b1 --- /dev/null +++ b/src/components/common/CustomFormField.jsx @@ -0,0 +1,227 @@ +import PropTypes from "prop-types"; +import { + FormControl, + Box, + InputAdornment, + Typography, + styled +} from "@mui/material"; +import InputBase from '@mui/material/InputBase'; +import ErrorOutlineOutlinedIcon from '@mui/icons-material/ErrorOutlineOutlined'; +import { HelpOutlinedIcon } from "../../Icons"; +import { vars } from "../../theme/variables"; + +const { + gray50, + gray300, + gray500, + brand600, + gray600, + gray700, + gray800, + error300, + error600, + inputErrorBoxShadow +} = vars; + +const StyledInput = styled(InputBase)(({ error }) => ({ + "& .MuiInputBase-input": { + borderRadius: ".5rem", + border: error ? `1px solid ${error300}` : `1px solid ${gray300}`, + fontSize: "1rem", + width: "100%", + padding: ".5rem .75rem", + height: "2.5rem", + color: error ? error600 : gray700, + + "&:focus": { + borderColor: error ? error300 : brand600, + boxShadow: error ? inputErrorBoxShadow : "none", + borderWidth: "2px", + }, + + "&::placeholder": { + color: gray500, + fontSize: "1rem", + }, + + "&.Mui-disabled": { + backgroundColor: gray50, + fontWeight: 400, + opacity: 1, + }, + }, + + "& .MuiInputBase-root": { + position: "relative", + }, + + "& .MuiInputAdornment-root": { + position: "absolute", + right: ".75rem", + top: "50%", + transform: "translateY(-50%)", + pointerEvents: "auto", + }, + + "&.MuiInputBase-adornedEnd .MuiInputBase-input": { + paddingRight: "2.5rem" + } +})); + +const getEndAdornment = (endAdornment, errorMessage, isEndAdornmentVisible) => { + if (endAdornment) { + return endAdornment; + } + + if (errorMessage) { + return ( + + + + ); + } + + if (isEndAdornmentVisible) { + return ( + + + + ); + } + + return null; +}; + +const InputMessage = ({ message, isError = false }) => { + if (!message) return null; + + return ( + + {isError ? `${message.charAt(0).toUpperCase() + message.slice(1)}` : message} + + ); +}; + +const CustomFormField = ({ + label, + value, + onChange, + placeholder, + helperText, + errorMessage, + isEndAdornmentVisible, + isRequired, + endAdornment, + sx, + textFontSize = "body2", + labelColor = gray700, + ...otherProps +}) => { + return ( + + + {label && ( + + + {label} + + + {isRequired && ( + + Required + + )} + + )} + + + + + + + + + + ); +}; + +InputMessage.propTypes = { + message: PropTypes.string, + isError: PropTypes.bool, +}; + +InputMessage.defaultProps = { + isError: false, +}; + +CustomFormField.propTypes = { + label: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + helperText: PropTypes.string, + placeholder: PropTypes.string, + endAdornment: PropTypes.node, + multiline: PropTypes.bool, + rows: PropTypes.number, + minRows: PropTypes.number, + disabled: PropTypes.bool, + errorMessage: PropTypes.string, + isRequired: PropTypes.bool, + isEndAdornmentVisible: PropTypes.bool, + sx: PropTypes.object, + textFontSize: PropTypes.string, + labelColor: PropTypes.string, +}; + +CustomFormField.defaultProps = { + helperText: "", + placeholder: "", + errorMessage: "", + isRequired: false, + isEndAdornmentVisible: false, + textFontSize: "body2", + labelColor: gray700, +}; + +export default CustomFormField; \ No newline at end of file diff --git a/src/components/common/CustomInputBox.jsx b/src/components/common/CustomInputBox.jsx deleted file mode 100644 index 7ad726db..00000000 --- a/src/components/common/CustomInputBox.jsx +++ /dev/null @@ -1,80 +0,0 @@ -import PropTypes from 'prop-types'; -import { HelpOutlinedIcon } from "../../Icons"; -import { Stack, Typography, TextField, InputAdornment } from "@mui/material"; - -import { vars } from "../../theme/variables"; -const { gray50, gray300, gray400, gray600, gray700, gray800, gray900, brand600 } = vars; - -const CustomInputBox = ({ id, name, value, onInputChange, label, isRequired, helperText, placeholder, isEndAdornmentVisible, multiline, rows, sx }) => { - return ( - <> - - {label} - {isRequired && Required} - - - - - ) : null - }} - sx={{ - ...sx, - '& .MuiInputBase-root': { - padding: '0.5rem 0.75rem', - borderRadius: '0.5rem', - boxShadow: '0px 1px 2px 0px rgba(16, 24, 40, 0.05)', - color: isRequired ? gray900 : gray700, - '&.Mui-focused': { - border: `2px solid ${brand600}`, - backgroundColor: gray50, - boxShadow: 'none' - } - }, - '& input': { - padding: 0 - }, - '& fieldset': { borderColor: gray300 }, - '& .MuiInputAdornment-root': { - color: gray400 - }, - '& .MuiFormHelperText-root': { - marginTop: '0.5rem', - mx: 0, - color: gray600, - fontSize: '0.875rem' - } - }} - /> - - ) -} - -CustomInputBox.propTypes = { - id: PropTypes.string, - name: PropTypes.string, - value: PropTypes.string, - onInputChange: PropTypes.func, - label: PropTypes.string, - isRequired: PropTypes.bool, - helperText: PropTypes.string, - placeholder: PropTypes.string, - isEndAdornmentVisible: PropTypes.bool, - multiline: PropTypes.bool, - rows: PropTypes.number, - sx: PropTypes.object -} - -export default CustomInputBox; diff --git a/src/components/common/CustomizedInput.jsx b/src/components/common/CustomizedInput.jsx deleted file mode 100644 index 3dc16359..00000000 --- a/src/components/common/CustomizedInput.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import PropTypes from 'prop-types'; -import {Typography} from "@mui/material"; -import { styled } from '@mui/material/styles'; -import InputBase from '@mui/material/InputBase'; -import FormControl from '@mui/material/FormControl'; - -import {vars} from "../../theme/variables"; -const { gray800, gray300, gray700, brand600, gray500, gray50 }= vars - -const BootstrapInput = styled(InputBase)(() => ({ - '& .MuiInputBase-input': { - borderRadius: '.5rem', - border: `1px solid ${gray300}`, - fontSize: '1rem', - width: '100%', - padding: '.5rem .575rem', - height: '2.5rem', - color: gray700, - '&:focus': { - borderColor: brand600, - boxShadow: 'none', - borderWidth: '2px' - }, - '&::placeholder': { - color: gray500, - fontSize: '1rem', - }, - '&.Mui-disabled': { - backgroundColor: gray50, - fontWeight: 400, - opacity: 1 - } - }, -})); -const CustomizedInput = (props) => { - const {label, value, onChange, placeholder} = props - return ( - <> - {label && - {label} - } - - - - - ); -} - -CustomizedInput.propTypes = { - label: PropTypes.string, - value: PropTypes.string, - onChange: PropTypes.func, - placeholder: PropTypes.string, - sx: PropTypes.object -} - -export default CustomizedInput; diff --git a/src/theme/index.jsx b/src/theme/index.jsx index 7d5fb139..4984e038 100644 --- a/src/theme/index.jsx +++ b/src/theme/index.jsx @@ -613,13 +613,16 @@ const theme = createTheme({ MuiButtonGroup: { styleOverrides: { - outlined: { + root: { borderRadius: "0.5rem", - boxShadow: "0px 1px 2px 0px rgba(16, 24, 40, 0.05)", "& .MuiButton-root": { padding: "0.625rem 0.875rem", - }, + } + }, + outlined: { + boxShadow: "0px 1px 2px 0px rgba(16, 24, 40, 0.05)", + "& .MuiButton-root:focus": { boxShadow: "none", background: gray50, @@ -628,6 +631,13 @@ const theme = createTheme({ borderRightColor: "transparent", }, }, + contained: { + boxShadow: "0px 1px 2px 0px rgba(16, 24, 40, 0.05)", + + "& .MuiButtonGroup-firstButton": { + borderColor: white, + } + } }, }, @@ -850,10 +860,14 @@ const theme = createTheme({ MuiMobileStepper: { styleOverrides: { + dots: { + gap: "0.75rem" + }, dot: { width: ".5rem", height: ".5rem", backgroundColor: gray200, + margin: 0 }, dotActive: { backgroundColor: brand700,