diff --git a/src/api/endpoints/apiActions.ts b/src/api/endpoints/apiActions.ts index 06fe6f0f..2378e1f6 100644 --- a/src/api/endpoints/apiActions.ts +++ b/src/api/endpoints/apiActions.ts @@ -3,8 +3,41 @@ import { customInstance } from '../../../mock/mutator/customClient'; type SecondParameter any> = Parameters[1]; -export const createPostRequest = (endpoint: string, headers : object) => { - return (data?: D, options?: SecondParameter) => { +interface CustomRequestConfig extends AxiosRequestConfig { + handleRedirect?: boolean; +} + +export const createPostRequest = (endpoint: string, headers: object) => { + return async (data?: D, options?: CustomRequestConfig) => { + // Use fetch for handling redirect responses + if (options?.handleRedirect) { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + ...headers, + }, + body: JSON.stringify(data), + credentials: 'include', + redirect: 'manual' + }); + + // Default response handling + const text = await response.text(); + try { + const json = JSON.parse(text); + return { + raw: json, + status: response.status + }; + } catch { + return { + raw: text, + status: response.status + }; + } + } + + // Default axios behavior for normal requests return customInstance( { url: endpoint, diff --git a/src/api/endpoints/apiService.ts b/src/api/endpoints/apiService.ts index dcf93c5a..474f4a0c 100644 --- a/src/api/endpoints/apiService.ts +++ b/src/api/endpoints/apiService.ts @@ -99,9 +99,9 @@ export const getSelectedTermLabel = async (searchTerm: string, group: string = ' return label?.['@value'] || ''; }; - return { - label: label ? getLabelValue(label) : undefined, - actualGroup: group + return { + label: label ? getLabelValue(label) : undefined, + actualGroup: group }; } catch (err: any) { console.error(err.message); @@ -110,7 +110,7 @@ export const getSelectedTermLabel = async (searchTerm: string, group: string = ' try { const fallbackResponse = await createGetRequest(`/base/${searchTerm}.jsonld`)(); const fallbackLabel = fallbackResponse['@graph']?.[0]?.['rdfs:label']; - + const getLabelValue = (label: LabelType): string => { if (typeof label === 'string') return label; if (Array.isArray(label)) { @@ -122,9 +122,9 @@ export const getSelectedTermLabel = async (searchTerm: string, group: string = ' return label?.['@value'] || ''; }; - return { - label: fallbackLabel ? getLabelValue(fallbackLabel) : undefined, - actualGroup: 'base' + return { + label: fallbackLabel ? getLabelValue(fallbackLabel) : undefined, + actualGroup: 'base' }; } catch (fallbackErr: any) { console.error('Fallback request also failed:', fallbackErr.message); @@ -136,50 +136,9 @@ export const getSelectedTermLabel = async (searchTerm: string, group: string = ' }; export const createNewEntity = async ({ group, data, session }: { group: string; data: any; session: string }) => { - try { - const endpoint = `/${group}${API_CONFIG.REAL_API.CREATE_NEW_ENTITY}`; - const response = await createPostRequest( - endpoint, - { "Content-Type": "application/x-www-form-urlencoded" } - )(data); - - // If the response is HTML (a string), extract TMP ID - if (typeof response === "string") { - const match = response.match(/TMP:\d{9}/); - if (match) { - return { - term: { - id: `${match[0]}`, - }, - raw: response, - status: 200, - }; - } - } - - // Otherwise, return response as-is - return response; - } catch (error) { - if (error && typeof error === 'object' && 'response' in error && (error as any).response?.status === 409) { - const match = (error as any)?.response?.data?.existing?.[0]; - if (match) { - return { - term: { - id: `${match}`, - }, - raw: (error as any)?.response, - status: (error as any)?.response?.status, - }; - } - } - - return { - raw: (error as any)?.response, - status: (error as any)?.response?.status, - }; - } - -}; + const endpoint = `/${group}${API_CONFIG.REAL_API.CREATE_NEW_ENTITY}`; + return createPostRequest(endpoint, { 'Content-Type': 'application/json' })(data, { handleRedirect: true }); +} export const createNewOntology = async ({ groupname, @@ -334,10 +293,10 @@ export const getTermDiscussions = async (group: string, variantID: string) => { }; export const getVariant = (group: string, term: string) => { - return createGetRequest(`/${group}/variant/${term}`, "application/json")(); + return createGetRequest(`/${group}/variant/${term}`, "application/json")(); }; -export const getTermPredicates = async ({ +export const getTermPredicates = async ({ groupname, termId, objToSub = true, @@ -398,3 +357,7 @@ export const getTermHierarchies = async ({ return { triples }; }; + +export const checkPotentialMatches = async (group: string, data: any) => { + return createPostRequest(`/${group}${API_CONFIG.REAL_API.CHECK_ENTITY}`, { "Content-Type": "application/json" })(data); +}; \ No newline at end of file diff --git a/src/components/TermEditor/NewTermSidebar.jsx b/src/components/TermEditor/NewTermSidebar.jsx index 2e9088bb..7339377b 100644 --- a/src/components/TermEditor/NewTermSidebar.jsx +++ b/src/components/TermEditor/NewTermSidebar.jsx @@ -1,4 +1,5 @@ import PropTypes from 'prop-types'; +import { useNavigate } from 'react-router-dom'; import { StartIcon, JoinRightIcon } from '../../Icons'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import ArrowOutwardIcon from '@mui/icons-material/ArrowOutward'; @@ -8,9 +9,9 @@ import { IconButton, Tooltip, Stack, - CircularProgress, Chip, - Button + Button, + Skeleton } from '@mui/material'; import { vars } from '../../theme/variables'; @@ -49,17 +50,6 @@ const HOVER_INDICATOR_STYLES = { background: brand600 }; -const LoadingSpinner = () => ( - - - -); - const ExpandedHeader = ({ onToggle }) => ( ( ); -const ResultItem = ({ result, searchValue, onResultAction }) => { +const ResultItemSkeleton = () => ( + + + + + + + + + + + + + +); + +const ResultItem = ({ result, searchValue, onResultAction, user }) => { const isExactMatch = result.label.toLowerCase() === searchValue?.toLowerCase(); + const navigate = useNavigate(); const getItemStyles = () => ({ borderBottom: `1px solid ${gray200}`, position: 'relative', + borderRadius: '0.5rem', + cursor: isExactMatch ? 'default' : 'pointer', ...(isExactMatch && EXACT_MATCH_STYLES), '&:hover': { ...(!isExactMatch && { + backgroundColor: gray200, '&:before': HOVER_INDICATOR_STYLES }) } @@ -150,6 +168,7 @@ const ResultItem = ({ result, searchValue, onResultAction }) => { px={1} py={1.5} gap={1} + onClick={() => !isExactMatch && onResultAction(result)} sx={getItemStyles()} > { height: "auto", '&:hover': { backgroundColor: "transparent" } }} - onClick={() => onResultAction(result)} + onClick={() => navigate(`/${user.groupname}/${result.ilx}`)} > Go to term @@ -211,11 +230,9 @@ export default function NewTermSidebar({ results, isResultsEmpty, searchValue, - onResultAction + onResultAction, + user }) { - if (loading) { - return ; - } const sidebarStyles = { display: 'flex', @@ -223,6 +240,7 @@ export default function NewTermSidebar({ borderLeft: `1px solid ${gray200}`, transition: SIDEBAR_STYLES.transition, p: 3, + minWidth: open ? SIDEBAR_STYLES.expanded : SIDEBAR_STYLES.collapsed, maxWidth: open ? SIDEBAR_STYLES.expanded : SIDEBAR_STYLES.collapsed, overflowY: 'auto', '::-webkit-scrollbar': { @@ -249,7 +267,11 @@ export default function NewTermSidebar({ alignItems="center" gap={1} > - {isResultsEmpty ? ( + {loading ? ( + Array.from(new Array(10)).map((_, index) => ( + + )) + ) : isResultsEmpty ? ( ) : ( <> @@ -259,6 +281,7 @@ export default function NewTermSidebar({ result={result} searchValue={searchValue} onResultAction={onResultAction} + user={user} /> ))} @@ -282,6 +305,7 @@ ResultItem.propTypes = { result: PropTypes.object.isRequired, searchValue: PropTypes.string, onResultAction: PropTypes.func, + user: PropTypes.object, }; NewTermSidebar.propTypes = { @@ -292,4 +316,5 @@ NewTermSidebar.propTypes = { isResultsEmpty: PropTypes.bool.isRequired, searchValue: PropTypes.string.isRequired, onResultAction: PropTypes.func, + user: PropTypes.object, }; \ No newline at end of file diff --git a/src/components/TermEditor/newTerm/AddNewTermDialog.jsx b/src/components/TermEditor/newTerm/AddNewTermDialog.jsx index 7cdd6bc7..53212307 100644 --- a/src/components/TermEditor/newTerm/AddNewTermDialog.jsx +++ b/src/components/TermEditor/newTerm/AddNewTermDialog.jsx @@ -1,5 +1,4 @@ -import { useState, useCallback, useContext } from "react"; -import { useNavigate } from "react-router-dom"; +import { useState, useCallback, useContext, useMemo } from "react"; import PropTypes from "prop-types"; import { Box, @@ -22,6 +21,7 @@ import { createNewEntity } from "../../../api/endpoints/apiService"; import { getAddTermStatusProps } from '../termStatusProps'; import { CheckedIcon, UncheckedIcon } from '../../../Icons'; import { vars } from "../../../theme/variables"; +import { DEFAULT_TYPE } from "../../../constants/types"; const { gray100, gray200, gray400, gray600 } = vars; @@ -30,6 +30,7 @@ const HeaderRightSideContent = ({ onContinue, onClose, isCreateButtonDisabled, + isEditing, userGroupname }) => { const [ontologyChecked, setOntologyChecked] = useState(false); @@ -80,7 +81,7 @@ const HeaderRightSideContent = ({ } }} > - Create new + {isEditing ? 'Edit term' : 'Create new'} @@ -96,22 +97,33 @@ HeaderRightSideContent.propTypes = { onContinue: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, isCreateButtonDisabled: PropTypes.bool.isRequired, + isEditing: PropTypes.bool.isRequired, userGroupname: PropTypes.string }; const AddNewTermDialog = ({ open, handleClose }) => { const [activeStep, setActiveStep] = useState(0); - const [addTermResponse] = useState(null); - const [selectedType, setSelectedType] = useState(null); + const [addTermResponse, setAddTermResponse] = useState(null); + const [selectedType, setSelectedType] = useState(DEFAULT_TYPE); const [termValue, setTermValue] = useState(""); + const [selectedTermValue, setSelectedTermValue] = useState(""); const [exactSynonyms, setExactSynonyms] = useState([]); const [existingIds, setExistingIds] = useState([]); const [loading, setLoading] = useState(false); - const [hasExactMatch] = useState(false); + const [hasExactMatch, setHasExactMatch] = useState(false); + const [isEditing, setIsEditing] = useState(false); const { user } = useContext(GlobalDataContext); - const navigate = useNavigate(); - const isCreateButtonDisabled = hasExactMatch || termValue === ""; + const isCreateButtonDisabled = useMemo(() => { + if (hasExactMatch) return true; + + if (termValue === "") return true; + + if (isEditing && termValue === selectedTermValue) return true; + + return false; + }, [hasExactMatch, termValue, isEditing, selectedTermValue]); + const statusProps = getAddTermStatusProps(addTermResponse, termValue); const handleCancelBtnClick = () => { @@ -119,9 +131,18 @@ const AddNewTermDialog = ({ open, handleClose }) => { setActiveStep(0); }; - const handleTermValueChange = (event) => { - const value = event.target.value; - setTermValue(value); + const handleTermValueChange = (value) => { + const isEventObject = value && typeof value === 'object' && 'target' in value; + const newValue = isEventObject ? value.target.value : value; + setTermValue(newValue); + + if (isEventObject && isEditing) { + if (newValue !== selectedTermValue) { + setIsEditing(true); + } + } else if (isEventObject) { + setIsEditing(false); + } }; const handleTypeChange = (newType) => { @@ -136,6 +157,21 @@ const AddNewTermDialog = ({ open, handleClose }) => { setExistingIds(newValue); }; + const handleExactMatchChange = (value) => { + setHasExactMatch(value) + } + + const handleTermSelection = (result) => { + if (result?.label) { + setSelectedTermValue(result.label); + handleTermValueChange(result.label); + } + } + + const editTerm = useCallback(() => { + console.log("Edit term"); + }, []); + const createNewTerm = useCallback(async () => { if (!termValue || !selectedType || hasExactMatch) return; @@ -145,8 +181,7 @@ const AddNewTermDialog = ({ open, handleClose }) => { const groupName = user?.groupname || "base"; const body = { 'rdf-type': selectedType || 'owl:Class', - label: termValue, - exact: [], + label: termValue }; try { @@ -155,13 +190,25 @@ const AddNewTermDialog = ({ open, handleClose }) => { data: body, session: token }); - navigate(`/terms/${response.term.id.split("/").pop()}`); + + if (response.term && response.term.id) { + setActiveStep(1); + setAddTermResponse(response.term.id); + } } catch (error) { console.error("Creation failed:", error); } finally { setLoading(false); } - }, [termValue, selectedType, user, hasExactMatch, navigate]); + }, [termValue, selectedType, user, hasExactMatch]); + + const handleAction = useCallback(() => { + if (isEditing) { + editTerm(); + } else { + createNewTerm(); + } + }, [isEditing, editTerm, createNewTerm]); if (loading) { return @@ -178,8 +225,9 @@ const AddNewTermDialog = ({ open, handleClose }) => { } @@ -188,15 +236,18 @@ const AddNewTermDialog = ({ open, handleClose }) => { {activeStep === 0 && } - {activeStep === 1 && } + {activeStep === 1 && } {activeStep === 2 && addTermResponse != null && ( )} diff --git a/src/components/TermEditor/newTerm/FirstStepContent.jsx b/src/components/TermEditor/newTerm/FirstStepContent.jsx index 274dd151..cbaf270d 100644 --- a/src/components/TermEditor/newTerm/FirstStepContent.jsx +++ b/src/components/TermEditor/newTerm/FirstStepContent.jsx @@ -1,128 +1,114 @@ import PropTypes from "prop-types"; -import { useState, useEffect, useContext } from "react"; -import { debounce } from "lodash"; +import { useState, useCallback, useContext } from "react"; import { Box, Divider, Stack, Typography, Autocomplete, - Chip, - TextField + TextField, + Chip } 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 CloseIcon from "@mui/icons-material/Close"; 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, +import { TYPES, DEFAULT_TYPE } from "../../../constants/types"; +import { useTermSearch } from "../../../hooks/useTermSearch"; + +const { white, gray300, gray400, gray600, gray700 } = vars; + +const styles = { + autocomplete: { + "& .MuiOutlinedInput-root": { + background: white, + borderColor: gray300, + padding: "0.5rem 0.75rem !important" + }, + "& .MuiOutlinedInput-root .MuiAutocomplete-input": { + color: gray700, + fontWeight: 400, + }, + "& .MuiAutocomplete-popupIndicator": { + transform: "none !important", + }, + "& .MuiAutocomplete-tag": { + background: "transparent", + }, }, - "& .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`, + chip: { + synonym: { + flexDirection: "row !important", + "& .MuiChip-deleteIcon": { + color: `${gray400} !important`, + }, + }, + id: { + flexDirection: "row !important", + borderRadius: "1rem !important", + "& .MuiChip-deleteIcon": { + color: `${gray400} !important`, + }, + } }, + contentBox: { + px: "3.25rem", + pt: "1.75rem", + pb: "2.5rem", + flex: 1, + overflowY: "auto", + display: "flex", + flexDirection: "column", + gap: "2.75rem", + } }; -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) => ( +const FirstStepContent = ({ + term, + type, + hasExactMatch, + existingIds, + synonyms, + isEditing, + handleTermChange, + handleTypeChange, + handleExactMatchChange, + handleExistingIdChange, + handleSynonymChange, + onTermSelect, +}) => { + const [openSidebar, setOpenSidebar] = useState(true) + const { user } = useContext(GlobalDataContext) + + const { loading, searchResults } = useTermSearch({ + term, + type, + synonyms, + isEditing, + onExactMatchChange: handleExactMatchChange, + }) + + const handleSidebarToggle = useCallback(() => { + setOpenSidebar((prev) => !prev) + }, []) + + const handleResultSelect = useCallback( + (result) => { + if (!result?.label) return + + handleTermChange(result.label) + if (onTermSelect) { + onTermSelect(result) + } + }, + [handleTermChange, onTermSelect], + ) + + const renderChips = useCallback((values, getTagProps, chipStyles) => + values.map((option, index) => ( - )); - }; - - useEffect(() => { - if (term && type) { - searchForMatches(term, type); - } - return () => { - searchForMatches.cancel(); - setHasExactMatch(false); - setTermResults([]) - }; - }, [term, type, searchForMatches]); + )), []); return ( - + How would you like to proceed? @@ -199,12 +162,12 @@ const FirstStepContent = ({ term, type, existingIds, synonyms, handleTermChange, value={synonyms} onChange={handleSynonymChange} popupIcon={} - options={synonymOptions} + options={[]} freeSolo - renderTags={(values, getTagProps) => renderChips(values, getTagProps, SYNONYM_CHIP_STYLES)} + renderTags={(values, getTagProps) => renderChips(values, getTagProps, styles.chip.synonym)} fullWidth renderInput={(params) => } - sx={AUTOCOMPLETE_STYLES} + sx={styles.autocomplete} /> @@ -226,12 +189,12 @@ const FirstStepContent = ({ term, type, existingIds, synonyms, handleTermChange, value={existingIds} onChange={handleExistingIdChange} popupIcon={} - options={idOptions} + options={[]} freeSolo - renderTags={(values, getTagProps) => renderChips(values, getTagProps, ID_CHIP_STYLES)} + renderTags={(values, getTagProps) => renderChips(values, getTagProps, styles.chip.id)} fullWidth renderInput={(params) => } - sx={AUTOCOMPLETE_STYLES} + sx={styles.autocomplete} /> @@ -240,25 +203,38 @@ const FirstStepContent = ({ term, type, existingIds, synonyms, handleTermChange, open={openSidebar} loading={loading} onToggle={handleSidebarToggle} - results={termResults} - isResultsEmpty={termResults.length === 0} + results={searchResults} + isResultsEmpty={searchResults.length === 0} searchValue={term} - onResultAction={navigateToExistingTerm} + onResultAction={handleResultSelect} + user={user} + selectedTerm={term} /> - ); -}; + ) +} 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 + type: PropTypes.oneOf(TYPES), + hasExactMatch: PropTypes.bool, + existingIds: PropTypes.arrayOf(PropTypes.string), + synonyms: PropTypes.arrayOf(PropTypes.string), + isEditing: PropTypes.bool, + handleTermChange: PropTypes.func.isRequired, + handleTypeChange: PropTypes.func.isRequired, + handleExactMatchChange: PropTypes.func.isRequired, + handleExistingIdChange: PropTypes.func.isRequired, + handleSynonymChange: PropTypes.func.isRequired, + onTermSelect: PropTypes.func, +} + +FirstStepContent.defaultProps = { + term: "", + type: DEFAULT_TYPE, + hasExactMatch: false, + existingIds: [], + synonyms: [], +} + +export default FirstStepContent diff --git a/src/components/TermEditor/newTerm/SecondStepContent.jsx b/src/components/TermEditor/newTerm/SecondStepContent.jsx index 273f75b8..ca19089e 100644 --- a/src/components/TermEditor/newTerm/SecondStepContent.jsx +++ b/src/components/TermEditor/newTerm/SecondStepContent.jsx @@ -1,5 +1,6 @@ import React from "react" import { useState } from "react" +import PropTypes from "prop-types"; 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" @@ -40,20 +41,18 @@ const URI_PREFIX_BOX_STYLES = { padding: "0.5rem 0.75rem", } -const SecondStepContent = () => { +const SecondStepContent = ({ searchTerm }) => { 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 }, + object: { type: "Object", value: searchTerm, isLink: false }, }, ]) @@ -264,4 +263,8 @@ const SecondStepContent = () => { ) } +SecondStepContent.propTypes = { + searchTerm: PropTypes.string +}; + export default SecondStepContent; diff --git a/src/config.js b/src/config.js index df192802..5fd7143a 100644 --- a/src/config.js +++ b/src/config.js @@ -36,6 +36,7 @@ export const API_CONFIG = { GET_ORGANIZATIONS: "/priv/role-other", LOGOUT: "/priv/logout", USER_RECOVER: "/u/ops/user-recover", + CHECK_ENTITY: "/priv/entity-check", PASSWORD_CHANGE: "/priv/password_change", ORG_TERMS: "/contributions", ORG_ONTOLOGIES: "/ontologies", diff --git a/src/constants/types.js b/src/constants/types.js index 6dab2fd7..a6108cb9 100644 --- a/src/constants/types.js +++ b/src/constants/types.js @@ -3,3 +3,15 @@ export const SEARCH_TYPES = { ORGANIZATION: "organization", ONTOLOGY: "ontology", }; + +export const TERM_TYPES = { + CLASS: 'owl:Class', + ANNOTATION_PROPERTY: 'owl:AnnotationProperty', + OBJECT_PROPERTY: 'owl:ObjectProperty', + CDE: 'TODO:CDE', + FDE: 'TODO:FDE', + PDE: 'TODO:PDE' +}; + +export const TYPES = Object.values(TERM_TYPES); +export const DEFAULT_TYPE = TERM_TYPES.CLASS; diff --git a/src/hooks/useTermSearch.js b/src/hooks/useTermSearch.js new file mode 100644 index 00000000..0f8d114c --- /dev/null +++ b/src/hooks/useTermSearch.js @@ -0,0 +1,90 @@ +import { useState, useEffect, useCallback, useMemo, useContext } from "react" +import { debounce } from "lodash" +import { GlobalDataContext } from "../contexts/DataContext" +import { checkPotentialMatches } from "../api/endpoints/apiService" +import { elasticSearch } from "../api/endpoints" + +export const useTermSearch = ({ term, type, synonyms, isEditing, onExactMatchChange }) => { + const [loading, setLoading] = useState(false) + const [searchResults, setSearchResults] = useState([]) + const [lastSearch, setLastSearch] = useState({ term: "", type: "" }) + const { user } = useContext(GlobalDataContext) + + const searchFunction = useCallback( + async (searchTerm, searchType, synonymList) => { + if (!searchTerm || !searchType || isEditing) { + setSearchResults([]) + return; + } + + // Skip duplicate searches + if (lastSearch.term === searchTerm && lastSearch.type === searchType) { + return; + } + + setLoading(true); + + try { + await checkPotentialMatches(user?.groupname || "base", { + label: searchTerm, + "rdf-type": searchType, + exact: synonymList, + }) + + // No exact matches found, search in elastic + onExactMatchChange(false) + const { results } = await elasticSearch(searchTerm, 30, 0) + + const searchResults = + results?.results?.map((result) => ({ + ...result, + isExactMatch: false, + })) || [] + + setSearchResults(searchResults) + } catch (error) { + if (error?.response?.status === 409 && error?.response?.data?.existing) { + // Handle exact matches + onExactMatchChange(true) + const exactMatches = Object.entries(error.response.data.existing).flatMap( + ([termUri, matches]) => { + const matchList = Array.isArray(matches) ? matches : [matches] + return matchList.map((match) => ({ + ilx: termUri.split("/").pop(), + label: match.object ?? termUri.split("/").pop() ?? "Unknown", + isExactMatch: true, + })) + }, + ) + setSearchResults(exactMatches) + } else { + onExactMatchChange(false) + setSearchResults([]) + } + } finally { + setLoading(false) + setLastSearch({ term: searchTerm, type: searchType }) + } + }, + [user, onExactMatchChange, isEditing, lastSearch], + ) + + const debouncedSearch = useMemo(() => debounce(searchFunction, 500), [searchFunction]) + + useEffect(() => { + if (!term) { + setSearchResults([]) + onExactMatchChange(false) + return + } + + debouncedSearch(term, type, synonyms) + return () => debouncedSearch.cancel() + }, [term, type, synonyms, debouncedSearch, onExactMatchChange]) + + return { + loading, + searchResults, + clearResults: () => setSearchResults([]), + } +}