diff --git a/.github/workflows/deploy_dev.yml b/.github/workflows/deploy_dev.yml index fa31374..726bbde 100644 --- a/.github/workflows/deploy_dev.yml +++ b/.github/workflows/deploy_dev.yml @@ -76,4 +76,4 @@ jobs: service: '${{ env.SERVICE }}' region: '${{ env.REGION }}' image: "${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.SERVICE }}/${{ env.IMAGE_NAME }}:${{ github.sha }}" - env_vars: VERSION=${VERSION} + env_vars: VERSION=${VERSION} \ No newline at end of file diff --git a/.github/workflows/deploy_prod.yml b/.github/workflows/deploy_prod.yml index bf8ee08..4aa01e2 100644 --- a/.github/workflows/deploy_prod.yml +++ b/.github/workflows/deploy_prod.yml @@ -76,4 +76,4 @@ jobs: service: '${{ env.SERVICE }}' region: '${{ env.REGION }}' image: "${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.SERVICE }}/${{ env.IMAGE_NAME }}:${{ github.sha }}" - env_vars: VERSION=${VERSION} + env_vars: VERSION=${VERSION} \ No newline at end of file diff --git a/.github/workflows/deploy_qa.yml b/.github/workflows/deploy_qa.yml index 3f061f9..e611272 100644 --- a/.github/workflows/deploy_qa.yml +++ b/.github/workflows/deploy_qa.yml @@ -77,4 +77,4 @@ jobs: service: '${{ env.SERVICE }}' region: '${{ env.REGION }}' image: "${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.SERVICE }}/${{ env.IMAGE_NAME }}:${{ github.sha }}" - env_vars: VERSION=${VERSION} + env_vars: VERSION=${VERSION} \ No newline at end of file diff --git a/.github/workflows/dispatch_deploy.yml b/.github/workflows/dispatch_deploy.yml index 69dab86..1145a77 100644 --- a/.github/workflows/dispatch_deploy.yml +++ b/.github/workflows/dispatch_deploy.yml @@ -86,4 +86,4 @@ jobs: service: ${{ env.SERVICE }} region: '${{ env.REGION }}' image: ${{ env.DOCKER_TAG }} - env_vars: ${{ github.event.inputs.version }} + env_vars: ${{ github.event.inputs.version }} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 8ffc7b5..fff13a4 100755 --- a/docs/README.md +++ b/docs/README.md @@ -1,45 +1,13 @@ # Map Dragon -This application will support several user groups in submitting and validating their data, managing and tracking data definitions, and aligning data to standardized terms. -## Getting Started -### Vite React App +Map Dragon is a tool for data mappers that enables users to import and store data dictionaries, search and map across multiple ontologies simultaneously, and export mapped data. This will standardize terminology for improved use in the AnVIL Data Explorer, and improve cross-platform querying. -This is a React application bootstrapped with Vite. +MapDragon operates on GCP and has been tested for use in Chrome and Microsoft Edge. We do not recommend using MapDragon on other browsers. -### Prerequisites +## Resources -Make sure you have Node.js and npm installed on your machine. - -### Installation - -1. Clone the repository: - - ```sh - git clone git@github.com:NIH-NCPI/map-dragon.git - -2. Navigate into the project directory: - - ```sh - cd map-dragon -3. Install dependencies - - ```sh - npm i - -### Development -1. To start the development server, run: - ```sh - npm run dev - -### Dependencies -This project utilizes the following dependencies: - -+ React: A JavaScript library for building user interfaces -+ Vite: A front-end tooling for web development -+ Ant Design: A design component library -+ Sass: A CSS preprocessor -+ React OAuth2 | Google: A React library for Google OAuth -+ jwt-decode: A JWT decoder -+ Papa Parse: A CSV parser +- [User Guide](https://docs.google.com/document/d/1nzJacOXqxbY-7EuynXdsvPCGp0tmkc_rm2x56T97PXY/edit?usp=sharing) +- [Onboarding Slides](https://docs.google.com/presentation/d/1Hm1ZXmNlhaHlJ0LIb9WlZ4sH2CTpOJQw94K0_Ul-W40/edit?usp=sharing) + These slides have been used to provide an overview to groups utilizing Map Dragon for their mapping needs. diff --git a/src/App.jsx b/src/App.jsx index 6363339..c7cbd46 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -6,8 +6,6 @@ import { GoogleOAuthProvider } from '@react-oauth/google'; export const myContext = createContext(); function App() { - const searchUrl = import.meta.env.VITE_SEARCH_ENDPOINT; - const monarchUrl = import.meta.env.VITE_MONARCH_SEARCH; const vocabUrl = import.meta.env.VITE_VOCAB_ENDPOINT; const clientId = import.meta.env.VITE_CLIENT_ID; const mapDragonVersion = import.meta.env.VITE_MAPDRAGON_VERSION; @@ -37,18 +35,18 @@ function App() { const [selectedKey, setSelectedKey] = useState(null); const [user, setUser] = useState(null); const [ontologyForPagination, setOntologyForPagination] = useState([]); + const [ucumCodes, setUcumCodes] = useState([]); message.config({ top: '25vh', }); + return ( diff --git a/src/App.scss b/src/App.scss index 38d8058..e0ce145 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1,11 +1,9 @@ -body{ - - background: url('./assets/Background.png'); - background-size: 100vw; - background-repeat: no-repeat; - background-position: calc(50% - 50px) 50%; - background-attachment: fixed; - +body { + background: url('./assets/Background.png'); + background-size: 100vw; + background-repeat: no-repeat; + background-position: calc(50% - 50px) 50%; + background-attachment: fixed; } .approuter_div { display: flex; @@ -16,13 +14,11 @@ body{ .outlet_div { position: relative; - background-color: rgba(255,255,255,.3); + background-color: rgba(255, 255, 255, 0.3); } .header { z-index: 100; position: sticky; - top:0; + top: 0; } - - diff --git a/src/AppRouter.jsx b/src/AppRouter.jsx index a4039a1..ee10833 100644 --- a/src/AppRouter.jsx +++ b/src/AppRouter.jsx @@ -60,24 +60,24 @@ export const AppRouter = () => { > } /> }> - } /> - } /> + }> + } /> + } /> - } /> - } /> - : - } - /> - } /> - } - /> - }> - }> + } /> + } /> + : + } + /> + } /> + } + /> + }> apiPreferencesTerm ? setApiPreferencesTerm(data) : setApiPreferences(data); @@ -66,6 +73,16 @@ export function SearchContextRoot() { prefTypeKey, searchText, setSearchText, + checkedOntologies, + setCheckedOntologies, + entriesPerPage, + moreAvailable, + setMoreAvailable, + resultsCount, + setResultsCount, + + selectedApi, + setSelectedApi, }; return ( diff --git a/src/components/About/About.jsx b/src/components/About/About.jsx index 8e68818..da3ca05 100644 --- a/src/components/About/About.jsx +++ b/src/components/About/About.jsx @@ -54,7 +54,7 @@ export const About = () => { {loading ? ( ) : ( -
+

About

diff --git a/src/components/About/About.scss b/src/components/About/About.scss index 8377e8e..90351b0 100644 --- a/src/components/About/About.scss +++ b/src/components/About/About.scss @@ -1,3 +1,8 @@ +.about_container { + margin: -26.5vh 10vw 10vh 10vw; + line-height: 1.5rem; +} + .about_description { width: 500px; } diff --git a/src/components/Manager/FetchManager.jsx b/src/components/Manager/FetchManager.jsx index 374d1cd..b8c5ab3 100644 --- a/src/components/Manager/FetchManager.jsx +++ b/src/components/Manager/FetchManager.jsx @@ -1,5 +1,3 @@ -import { ontologyReducer } from './Utilitiy'; - // Fetches all elements at an endpoint export const getAll = (vocabUrl, name, navigate) => { return fetch(`${vocabUrl}/${name}`, { @@ -169,24 +167,25 @@ export const getOntologies = vocabUrl => { }; export const olsFilterOntologiesSearch = ( - searchUrl, + vocabUrl, query, ontologiesToSearch, page, entriesPerPage, pageStart, selectedBoxes, - setTotalCount, setResults, - setFilteredResultsCount, setResultsCount, setLoading, results, - setFacetCounts + setMoreAvailable, + apiToSearch, + notification ) => { setLoading(true); + return fetch( - `${searchUrl}q=${query}&ontology=${ontologiesToSearch}&rows=${entriesPerPage}&start=${pageStart}`, + `${vocabUrl}/ontology_search?keyword=${query}&selected_ontologies=${ontologiesToSearch}&selected_api=${apiToSearch}&results_per_page=${entriesPerPage}&start_index=${pageStart}`, { method: 'GET', headers: { @@ -194,37 +193,36 @@ export const olsFilterOntologiesSearch = ( }, } ) - .then(res => res.json()) + .then(res => { + if (res.ok) { + return res.json(); + } else { + notification.error({ + message: 'Error', + description: `An error occurred searching for ${query}.`, + }); + } + }) .then(data => { - // filters results through the ontologyReducer function (defined in Manager/Utility.jsx) - - let res = ontologyReducer(data?.response?.docs); // if the page > 0 (i.e. if this is not the first batch of results), the new results // are concatenated to the old if (selectedBoxes) { - res.results = res.results.filter( - d => !selectedBoxes.some(box => box.obo_id === d.obo_id) + data.results = data?.results?.filter( + d => !selectedBoxes.some(box => box.code === d.code) ); } - if (page > 0 && results.length > 0) { - res.results = results.concat(res.results); - - // Apply filtering to remove results with obo_id in selectedBoxes - } else { - // Set the total number of search results for pagination - setTotalCount(data.response.numFound); + if (page > 0 && results?.length > 0) { + data.results = results?.concat(data.results); } + const addedApi = data?.results.map(result => ({ + ...result, + api: apiToSearch, + })); + setResults(addedApi); + setMoreAvailable(data.more_results_available); - //the results are set to res (the filtered, concatenated results) - - setResults(res.results); - setFilteredResultsCount( - prevState => prevState + res?.filteredResults?.length - ); - // resultsCount is set to the length of the filtered, concatenated results for pagination - setResultsCount(res.results.length); - setFacetCounts(data?.facet_counts?.facet_fields?.ontologyPreferredPrefix); + setResultsCount(data?.results?.length); }) .finally(() => setLoading(false)); }; @@ -265,19 +263,62 @@ export const getFiltersByCode = ( }) .then(data => { setUnformattedPref(data); - const codeToSearch = Object.keys(data)?.[0]; - const ols = data?.[codeToSearch]?.api_preference?.ols; + const codeToSearch = Object.keys(data)?.[0]; + const apiPreferences = + data[codeToSearch]?.api_preference ?? data[codeToSearch]; + const updatedPreferences = Object.entries(apiPreferences).reduce( + (acc, [key, values]) => { + acc[key] = values.map(value => value.toUpperCase()); + return acc; + }, + {} + ); - if (Array.isArray(ols)) { - // If ols in api_preference is an array, use it as is - setApiPreferencesCode(ols); // Set state to the array - } else if (typeof ols === 'string') { - // If ols in api_preference is a string, split it into an array - const splitOntologies = ols.split(','); - setApiPreferencesCode(splitOntologies); // Set state to the array - } else { - setApiPreferencesCode([]); // Fallback if no ols found - } + setApiPreferencesCode(updatedPreferences); }); }; + +export const ontologyFilterCodeSubmit = ( + apiPreferencesCode, + preferenceType, + prefTypeKey, + mappingProp, + vocabUrl, + table, + terminology +) => { + const apiPreference = { api_preference: {} }; + if ( + apiPreferencesCode && + (!preferenceType[prefTypeKey]?.api_preference?.[0] || + JSON.stringify( + Object.values(preferenceType[prefTypeKey]?.api_preference)[0]?.sort() + ) !== JSON.stringify(apiPreferencesCode?.sort())) + ) { + apiPreference.api_preference = apiPreferencesCode; + const fetchUrl = `${vocabUrl}/${ + !table ? `Terminology/${terminology?.id}` : `Table/${table?.id}` + }/filter/${mappingProp}`; + fetch(fetchUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(apiPreference), + }) + .then(res => { + if (res.ok) { + return res.json(); + } else { + throw new Error('An unknown error occurred.'); + } + }) + .catch(error => { + if (error) { + notification.error({ + message: 'Error', + description: 'An error occurred saving the ontology preferences.', + }); + } + }); + } +}; diff --git a/src/components/Manager/MappingsFunctions/AssignMappings.jsx b/src/components/Manager/MappingsFunctions/AssignMappings.jsx index 187f8de..bee2527 100644 --- a/src/components/Manager/MappingsFunctions/AssignMappings.jsx +++ b/src/components/Manager/MappingsFunctions/AssignMappings.jsx @@ -5,6 +5,7 @@ import { AssignMappingsCheckboxes } from './AssignMappingsCheckboxes'; import { ModalSpinner } from '../Spinner'; import { MappingContext } from '../../../Contexts/MappingContext'; import { SearchContext } from '../../../Contexts/SearchContext'; +import { ontologyFilterCodeSubmit } from '../FetchManager'; export const AssignMappings = ({ setSelectedKey, @@ -16,7 +17,13 @@ export const AssignMappings = ({ const [form] = Form.useForm(); const { vocabUrl, user } = useContext(myContext); - const { prefTerminologies, setApiResults } = useContext(SearchContext); + const { + prefTerminologies, + setApiResults, + preferenceType, + prefTypeKey, + apiPreferencesCode, + } = useContext(SearchContext); const { setMapping, idsForSelect, setIdsForSelect } = useContext(MappingContext); const [terminologiesToMap, setTerminologiesToMap] = useState([]); @@ -63,10 +70,10 @@ export const AssignMappings = ({ code: item.code, display: item.display, description: Array.isArray(item.description) - ? item.description[0] + ? item.description?.map(d => d).join(',') : item.description, system: item.system, - mapping_relationship: idsForSelect[item.obo_id || item.code], + mapping_relationship: idsForSelect[item.code], })); const mappingsDTO = { mappings: selectedMappings, @@ -97,6 +104,15 @@ export const AssignMappings = ({ message.success('Changes saved successfully.'); }) .finally(() => setLoading(false)); + ontologyFilterCodeSubmit( + apiPreferencesCode, + preferenceType, + prefTypeKey, + mappingProp, + vocabUrl, + null, + terminology + ); }; return ( @@ -134,11 +150,13 @@ export const AssignMappings = ({ )} diff --git a/src/components/Manager/MappingsFunctions/AssignMappingsCheckboxes.jsx b/src/components/Manager/MappingsFunctions/AssignMappingsCheckboxes.jsx index b123733..1ad19d2 100644 --- a/src/components/Manager/MappingsFunctions/AssignMappingsCheckboxes.jsx +++ b/src/components/Manager/MappingsFunctions/AssignMappingsCheckboxes.jsx @@ -1,25 +1,163 @@ -import { useEffect, useState } from 'react'; -import { Checkbox, Form, Tooltip } from 'antd'; -import { ellipsisString } from '../Utilitiy'; -import { APISearchBar } from '../../Projects/Terminologies/APISearchBar'; -import { DisplaySelected } from './DisplaySelected'; -import { APIResults } from '../../Projects/Terminologies/APIResults'; +import { Checkbox, Form, Input, notification, Tooltip } from 'antd'; +import { useContext, useEffect, useRef, useState } from 'react'; +import { myContext } from '../../../App'; +import { ellipsisString, systemsMatch } from '../Utility'; +import { ModalSpinner, ResultsSpinner } from '../Spinner'; +import { MappingContext } from '../../../Contexts/MappingContext'; +import { SearchContext } from '../../../Contexts/SearchContext'; +import { olsFilterOntologiesSearch } from '../FetchManager'; +import { OntologyCheckboxes } from './OntologyCheckboxes'; +import { MappingRelationship } from './MappingRelationship'; export const AssignMappingsCheckboxes = ({ terminologiesToMap, - form, + setTerminologiesToMap, selectedBoxes, setSelectedBoxes, - searchProp, + mappingProp, + form, + terminology, }) => { + const { vocabUrl } = useContext(myContext); + const { + apiPreferences, + defaultOntologies, + setApiPreferencesCode, + apiPreferencesCode, + unformattedPref, + setUnformattedPref, + prefTerminologies, + setApiResults, + ontologyApis, + entriesPerPage, + moreAvailable, + setMoreAvailable, + resultsCount, + setResultsCount, + selectedApi, + setSelectedApi, + preferenceType, + prefTypeKey, + } = useContext(SearchContext); + + const [page, setPage] = useState(0); + const [loading, setLoading] = useState(true); + const [loadingResults, setLoadingResults] = useState(false); + const [results, setResults] = useState([]); + const [lastCount, setLastCount] = useState(0); //save last count as count of the results before you fetch data again + const [inputValue, setInputValue] = useState(mappingProp); //Sets the value of the search bar + const [currentSearchProp, setCurrentSearchProp] = useState(mappingProp); const [active, setActive] = useState(null); - const [displaySelectedMappings, setDisplaySelectedMappings] = useState([]); const [allCheckboxes, setAllCheckboxes] = useState([]); - const [loading, setLoading] = useState(false); + const { + setSelectedMappings, + displaySelectedMappings, + setDisplaySelectedMappings, + } = useContext(MappingContext); + + let ref = useRef(); + const { Search } = Input; + + const fetchTerminologies = () => { + setLoading(true); + const fetchPromises = prefTerminologies?.map(pref => + fetch(`${vocabUrl}/${pref?.reference}`).then(response => response.json()) + ); + + Promise.all(fetchPromises) + .then(results => { + // Once all fetch calls are resolved, set the combined data + setTerminologiesToMap(results); + }) + .catch(error => { + notification.error({ + message: 'Error', + description: 'An error occurred. Please try again.', + }); + }) + .finally(() => setLoading(false)); + }; + + // since the code is passed through searchProp, the '!!' forces it to be evaluated as a boolean. + // if there is a searchProp being passed, it evaluates to true and runs the search function. + // inputValue and currentSearchProp for the search bar is set to the passed searchProp. + // The function is run when the code changes. useEffect(() => { - setActive(terminologiesToMap?.[0]?.id); - }, [terminologiesToMap]); + setInputValue(mappingProp); + setCurrentSearchProp(mappingProp); + setPage(0); + if (!!mappingProp) { + getCodeFilters(); + } + }, [mappingProp]); + + const getCodeFilters = () => { + setLoading(true); + return fetch( + `${vocabUrl}/Terminology/${terminology.id}/filter/${mappingProp}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ) + .then(res => { + if (res.ok) { + return res.json(); + } else { + notification.error({ + message: 'Error', + description: 'An error occurred loading the ontology preferences.', + }); + } + }) + .then(data => { + setUnformattedPref(data); + + const codeToSearch = Object.keys(data)?.[0]; + const apiPreferences = + data[codeToSearch]?.api_preference ?? data[codeToSearch]; + const updatedPreferences = Object.entries(apiPreferences).reduce( + (acc, [key, values]) => { + acc[key] = values.map(value => value.toUpperCase()); + return acc; + }, + {} + ); + + setApiPreferencesCode(updatedPreferences); + }); + }; + + const codeToSearch = Object.keys(unformattedPref)?.[0]; + const savedApiPreferences = unformattedPref?.[codeToSearch]?.api_preference; + const apiPreferenceKeys = Object?.keys(savedApiPreferences ?? {}); + + useEffect(() => { + apiPreferenceKeys?.length > 0 + ? setSelectedApi(apiPreferenceKeys[0]) + : setSelectedApi(ontologyApis?.[0]?.api_id || null); + }, [mappingProp]); + + useEffect(() => { + if (apiPreferencesCode !== undefined) { + fetchResults(0, mappingProp); + } + }, [mappingProp]); + + useEffect(() => { + if (apiPreferencesCode !== undefined) { + fetchResults(page, currentSearchProp); + } + }, [page, selectedApi]); + + useEffect(() => { + if (prefTerminologies.length > 0) { + fetchTerminologies(); + } + }, []); useEffect(() => { setAllCheckboxes( @@ -27,12 +165,156 @@ export const AssignMappingsCheckboxes = ({ ); }, [active]); + useEffect(() => { + setActive(terminologiesToMap?.[0]?.id); + }, [terminologiesToMap]); + // The '!!' forces currentSearchProp to be evaluated as a boolean. + // If there is a currentSearchProp in the search bar, it evaluates to true and runs the search function. + // The function is run when the query changes and when the preferred ontology changes. + // If there are preferred terminologies, it runs when the OLS search bar is clicked (i.e. active) + useEffect(() => { + if ( + prefTerminologies.length > 0 && + active === 'search' && + !!currentSearchProp && + apiPreferencesCode !== undefined + ) { + fetchResults(page, currentSearchProp); + } else if ( + prefTerminologies.length === 0 && + !!currentSearchProp && + apiPreferencesCode !== undefined + ) { + fetchResults(page, currentSearchProp); + } + }, [currentSearchProp, apiPreferencesCode, active]); + + /* Pagination is handled via a "View More" link at the bottom of the page. + Each click on the "View More" link makes an API call to fetch the next 15 results. + This useEffect moves the scroll bar on the modal to the first index of the new batch of results. + Because the content is in a modal and not the window, the closest class name to the modal is used for the location of the ref. */ + useEffect(() => { + if (results?.length > 0 && page > 0 && ref.current) { + const container = ref.current.closest('.ant-modal-body'); + const scrollTop = ref.current.offsetTop - container.offsetTop; + container.scrollTop = scrollTop; + } + }, [results]); + // Sets the value of the selected_mappings in the form to the checkboxes that are selected useEffect(() => { form.setFieldsValue({ - selected_terminologies: selectedBoxes, + selected_mappings: selectedBoxes, }); }, [selectedBoxes, form]); + // sets the code to null on dismount. + useEffect( + () => () => { + form.resetFields; + setSelectedMappings([]); + setDisplaySelectedMappings([]); + setSelectedBoxes([]); + setApiResults([]); + }, + [] + ); + + const handleSearch = query => { + setCurrentSearchProp(query); + setPage(0); + }; + + const apiForSearch = selectedApi ?? apiPreferenceKeys[0]; + // The function that makes the API call to search for the passed code. + const fetchResults = (page, query) => { + if (!!!query) { + return undefined; + } + + /* The OLS API returns 10 results by default unless specified otherwise. The fetch call includes a specified + number of results to return per page (entriesPerPage) and a calculation of the first index to start the results + on each new batch of results (pageStart, calculated as the number of the page * the number of entries per page */ + const pageStart = page * entriesPerPage; + if ( + // If there are api preferences and one of them is in apiPreferencesCode + preferenceType[prefTypeKey]?.api_preference + ) { + const apiPreferenceOntologies = () => { + // Check if apiPreferencesCode contains the key dynamically (based on selectedApi) + if ( + apiForSearch in apiPreferencesCode && + apiPreferencesCode[apiForSearch]?.length > 0 + ) { + // Return the preferred ontologies for the matched key (apiPreferenceKeys[0]) + return apiPreferencesCode[apiPreferenceKeys[0]] + .join(',') + .toUpperCase(); + } else { + // If no preferred ontologies, use the default ontologies + return defaultOntologies; + } + }; + + //fetch call to search API with either preferred or default ontologies + return olsFilterOntologiesSearch( + vocabUrl, + query, + apiPreferencesCode[selectedApi]?.length > 0 + ? apiPreferencesCode?.[selectedApi]?.map(sa => sa?.toUpperCase()) + : apiPreferenceOntologies(), + page, + entriesPerPage, + pageStart, + selectedBoxes, + setResults, + setResultsCount, + loading ? setLoading : setLoadingResults, + results, + setMoreAvailable, + selectedApi !== undefined ? selectedApi : apiPreferenceKeys[0], + notification + ); + } else + return olsFilterOntologiesSearch( + vocabUrl, + query, + apiPreferencesCode[selectedApi]?.length > 0 + ? apiPreferencesCode?.[selectedApi]?.map(sa => sa?.toUpperCase()) + : defaultOntologies, + page, + entriesPerPage, + pageStart, + selectedBoxes, + setResults, + setResultsCount, + loading ? setLoading : setLoadingResults, + results, + setMoreAvailable, + selectedApi !== undefined ? selectedApi : apiPreferenceKeys[0], + notification + ); + }; + // the 'View More' pagination onClick increments the page. The search function is triggered to run on page change in the useEffect. + const handleViewMore = e => { + e.preventDefault(); + setPage(prevPage => prevPage + 1); + }; + + // Sets the inputValue to the value of the search bar. + const handleChange = e => { + setInputValue(e.target.value); + }; + + // If the checkbox is checked, it adds the object to the selectedBoxes array + // If it is unchecked, it filters it out of the selectedBoxes array. + const onCheckboxChange = (event, code) => { + if (event.target.checked) { + setSelectedBoxes(prevState => [...prevState, code]); + } else { + setSelectedBoxes(prevState => prevState.filter(val => val !== code)); + } + }; + const onSelectedChange = checkedValues => { const selected = JSON.parse(checkedValues?.[checkedValues.length - 1]); @@ -47,6 +329,124 @@ export const AssignMappingsCheckboxes = ({ setDisplaySelectedMappings(prevState => [...prevState, selected]); }; + // The display for the checkboxes. The index is set to the count of the results before you fetch the new batch of results + // again + 1, to move the scrollbar to the first result of the new batch. + const newSearchDisplay = (d, index) => { + index === lastCount + 1; + return ( + <> +
+
+
+
+ {d?.display} +
+ +
+ {d?.description?.length > 100 ? ( + + {ellipsisString(d?.description?.map(d => d).join(','), '100')} + + ) : ( + ellipsisString(d?.description?.map(d => d).join(','), '100') + )}{' '} +
+
+ + ); + }; + + const selectedTermsDisplay = (d, index) => { + return ( + <> +
+
+
+
+
+ {d?.display} +
+
+
+ + {d?.code} + + {d.api && ( + + ({d?.api?.toUpperCase()}) + + )} +
+
+ +
+
+
+ {d?.description?.length > 85 ? ( + + {ellipsisString( + Array.isArray(d.description) + ? d.description?.map(d => d).join(',') + : d.description, + '85' + )} + + ) : ( + ellipsisString( + Array.isArray(d.description) + ? d.description?.map(d => d).join(',') + : d.description, + '85' + ) + )} +
+
+
+ + ); + }; + + // Creates a Set that excludes the mappings that have already been selected. + // Then filteres the existing mappings out of the results to only display results that have not yet been selected. + const getFilteredResults = () => { + const codesToExclude = new Set([ + ...displaySelectedMappings?.map(m => m?.code), + ]); + return results?.filter(r => !codesToExclude?.has(r.code)); + }; + + const filteredResultsArray = getFilteredResults(); + + // Peforms search on Tab key press + const searchOnTab = e => { + if (e.key === 'Tab') { + e.preventDefault(); + handleSearch(e.target.value); + } + }; + const checkBoxDisplay = (item, index) => { return ( <> @@ -76,93 +476,223 @@ export const AssignMappingsCheckboxes = ({ ); }; - const mappedTerminology = () => { - return ( -
- {displaySelectedMappings?.length > 0 && ( - <> -

Selected

- - - )} -

Codes

- {active === 'search' ? ( - - ) : ( - <> - - - !displaySelectedMappings.some( - dsm => checkbox.code === dsm.code - ) - ) - .map((code, index) => ({ - value: JSON.stringify({ - code: code.code, - display: code.display, - description: code.description, - system: code.system, - }), - label: checkBoxDisplay(code, index), - }))} - onChange={onSelectedChange} - /> - - - )} - - ); - }; return ( <> -
- {searchProp} -
- {terminologiesToMap?.length && ( -
-
- {terminologiesToMap.map((term, i) => ( -
setActive(term.id)} - > - {term.name} +
+ <> + {loading === false ? ( + <> +
+
+

{mappingProp}

+
+ {!prefTerminologies.length > 0 && ( + + )} +
+
+ {/* ant.design form displaying the checkboxes with the search results. */} +
+
+
+
+ {terminologiesToMap?.length > 0 && ( +
+
+
+ setActive(terminologiesToMap?.[0]?.id) + } + > + Preferred Terminologies +
+
+ {terminologiesToMap.map((term, i) => ( +
setActive(term.id)} + > + {term.name} +
+ ))} +
+
setActive('search')} + className={ + active === 'search' + ? 'active_term' + : 'inactive_term' + } + > + +
+
+
+ )} + {((prefTerminologies.length > 0 && + active === 'search') || + prefTerminologies.length === 0) && ( + + )} +
+
+
+ {loadingResults ? ( + + ) : ( + <> + {displaySelectedMappings?.length > 0 && ( + +
+ {displaySelectedMappings?.map((sm, i) => ( + onCheckboxChange(e, sm)} + checked={selectedBoxes.some( + box => box.code === sm.code + )} + value={sm} + > + {selectedTermsDisplay(sm, i)} + + ))} +
+
+ )} + {active === 'search' ? ( + results?.length > 0 ? ( + <> + + {filteredResultsArray?.length > 0 && ( + { + return { + value: JSON.stringify({ + code: d.code, + display: d.display, + description: d.description + ?.map(d => d) + .join(','), + system: d.system, + api: d.api, + }), + label: newSearchDisplay( + d, + index + ), + }; + } + )} + onChange={onSelectedChange} + /> + )} + + + ) : ( +

No results found

+ ) + ) : ( + + + !displaySelectedMappings.some( + dsm => checkbox.code === dsm.code + ) + ) + .map((code, index) => ({ + value: JSON.stringify({ + code: code.code, + display: code.display, + description: code.description, + system: code.system, + }), + label: checkBoxDisplay(code, index), + }))} + onChange={onSelectedChange} + /> + + )} + + )} +
+
+
+
+ {((prefTerminologies.length > 0 && active === 'search') || + prefTerminologies.length === 0) && ( +
+ {/* 'View More' pagination */} + {moreAvailable && !loadingResults && ( + { + handleViewMore(e); + setLastCount(resultsCount); + }} + > + View More + + )} +
+ )} +
- ))} - -
-
{mappedTerminology()}
-
- )} + + ) : ( +
+ +
+ )} + +
); }; diff --git a/src/components/Manager/MappingsFunctions/DisplaySelected.jsx b/src/components/Manager/MappingsFunctions/DisplaySelected.jsx deleted file mode 100644 index 39c43de..0000000 --- a/src/components/Manager/MappingsFunctions/DisplaySelected.jsx +++ /dev/null @@ -1,87 +0,0 @@ -import { Checkbox, Form, Tooltip } from 'antd'; -import { ellipsisString } from '../Utilitiy'; -import { MappingRelationship } from './MappingRelationship'; - -export const DisplaySelected = ({ - displaySelectedMappings, - selectedBoxes, - setSelectedBoxes, -}) => { - const selectedCodesDisplay = (selected, index) => { - return ( - <> -
-
-
-
{selected?.code}
-
{selected?.display || selected?.label}
-
- -
-
-
- {selected?.description?.length > 85 ? ( - - {ellipsisString( - Array.isArray(selected.description) - ? selected.description[0] - : selected.description, - '85' - )} - - ) : ( - ellipsisString( - Array.isArray(selected.description) - ? selected.description[0] - : selected.description, - '85' - ) - )} -
-
-
- - ); - }; - - const onCheckboxChange = (event, code) => { - if (event.target.checked) { - setSelectedBoxes(prevState => [...prevState, code]); - } else { - setSelectedBoxes(prevState => prevState.filter(val => val !== code)); - } - }; - - return ( - <> - {displaySelectedMappings?.length > 0 && ( - <> - -
- {displaySelectedMappings?.map((selected, i) => ( - box?.code === selected?.code - )} - value={selected} - onChange={e => onCheckboxChange(e, selected)} - > - {selectedCodesDisplay(selected, i)} - - ))} -
-
- - )} - - ); -}; diff --git a/src/components/Manager/MappingsFunctions/EditMappingsLabel.jsx b/src/components/Manager/MappingsFunctions/EditMappingsLabel.jsx index a379fd8..731467b 100644 --- a/src/components/Manager/MappingsFunctions/EditMappingsLabel.jsx +++ b/src/components/Manager/MappingsFunctions/EditMappingsLabel.jsx @@ -1,6 +1,6 @@ import { useContext } from 'react'; import { MappingContext } from '../../../Contexts/MappingContext'; -import { ellipsisString } from '../Utilitiy'; +import { ellipsisString } from '../Utility'; import { MappingRelationship } from './MappingRelationship'; import { Tooltip } from 'antd'; diff --git a/src/components/Manager/MappingsFunctions/FilterAPI.jsx b/src/components/Manager/MappingsFunctions/FilterAPI.jsx index 281151a..5a272e8 100644 --- a/src/components/Manager/MappingsFunctions/FilterAPI.jsx +++ b/src/components/Manager/MappingsFunctions/FilterAPI.jsx @@ -6,7 +6,6 @@ import { FilterOntology } from './FilterOntology'; export const FilterAPI = ({ form, - selectedOntologies, setSelectedOntologies, selectedBoxes, setSelectedBoxes, @@ -15,41 +14,19 @@ export const FilterAPI = ({ ontologyApis, active, setActive, - currentPage, - setCurrentPage, - pageSize, - setPageSize, paginatedOntologies, apiPreferences, table, terminology, + existingOntologies, + setExistingOntologies, + flattenedFilters, }) => { const { vocabUrl } = useContext(myContext); const [ontology, setOntology] = useState([]); const [loading, setLoading] = useState(false); const [tableLoading, setTableLoading] = useState(false); - // The selected ontology filters that have already been selected - const existingFilters = Object.values(apiPreferences?.self || {}).flat(); - - // Flattens the existingFilters into a single array - const flattenedFilters = existingFilters - .flatMap(item => - Object.keys(item).map(key => - item[key].map(value => ({ - api: key, - })) - ) - ) - .flat(); - - // The initial value for the form. The checkboxes for the filters that have already been selected will be checked by default - const initialChecked = flattenedFilters?.map(ef => - JSON.stringify({ - ontology: ef, - }) - ); - // Fetches the active ontologyAPI each time the active API changes useEffect(() => { active && getOntologyApiById(); @@ -116,6 +93,7 @@ export const FilterAPI = ({ ); }; + return loading ? ( ) : ( @@ -129,18 +107,7 @@ export const FilterAPI = ({ APIs
- - - { - return { - value: JSON.stringify({ api_preference: api?.api_id }), - label: checkboxDisplay(api, index), - }; - })} - /> - + {ontologyApis.map((api, index) => checkboxDisplay(api, index))}
{tableLoading ? ( @@ -151,20 +118,18 @@ export const FilterAPI = ({ )}
diff --git a/src/components/Manager/MappingsFunctions/FilterOntology.jsx b/src/components/Manager/MappingsFunctions/FilterOntology.jsx index 167d012..739b19a 100644 --- a/src/components/Manager/MappingsFunctions/FilterOntology.jsx +++ b/src/components/Manager/MappingsFunctions/FilterOntology.jsx @@ -15,6 +15,9 @@ export const FilterOntology = ({ paginatedOntologies, table, terminology, + existingOntologies, + setExistingOntologies, + flattenedFilters, }) => { const { Search } = Input; const [allCheckboxes, setAllCheckboxes] = useState([]); @@ -57,6 +60,13 @@ export const FilterOntology = ({ } }; + const onExistingChange = (checkedValues, api) => { + setExistingOntologies(prevState => ({ + ...prevState, + [api]: checkedValues, + })); + }; + const onSelectedChange = checkedValues => { if (checkedValues.length > 0) { const selected = JSON.parse(checkedValues[0]); @@ -102,6 +112,26 @@ export const FilterOntology = ({ ); }; + const selectedCheckBoxDisplay = (ont, i) => { + return ( + <> +
+
+
+
+ {ont?.curie}{' '} + + ({ont?.api?.toUpperCase()}) + +
+
+
{ont?.ontology_title}
+
+
+ + ); + }; + const existingDisplay = (ont, i) => { return ( <> @@ -116,29 +146,6 @@ export const FilterOntology = ({ ); }; - // The first key is different depending if it's coming from a table or terminology. This dynamically gets the first key - - const existingFilters = Object.values( - preferenceType[prefTypeKey] || {} - )?.flat(); - - const flattenedFilters = existingFilters - .flatMap(item => - Object.keys(item).map(key => - item[key].map(value => ({ - api: key, - ontology: value, - })) - ) - ) - .flat(); - - const initialChecked = flattenedFilters?.map(ef => - JSON.stringify({ - ontology: ef, - }) - ); - return ( <>
@@ -153,11 +160,14 @@ export const FilterOntology = ({ ) && ( <>
-

Ontology Filters

{' '} - +

Ontology Filters

+
{flattenedFilters?.length > 0 && ( - { - return { - value: JSON.stringify({ - ontology: ff, - }), - label: existingDisplay(ff, index), - }; - })} - /> +
+ {Array.from(new Set(flattenedFilters.map(ff => ff.api))).map( + api => { + const apiOntologies = flattenedFilters.filter( + ff => ff.api === api + ); + if (apiOntologies.length > 0) { + const options = apiOntologies.map((ff, index) => ({ + value: ff.ontology, + label: existingDisplay(ff, index), + })); + + return ( +
+
+ {api.toUpperCase()} +
+ + onExistingChange(checkedValues, api) + } + /> +
+ ); + } + return null; + } + )} +
)}
)} {displaySelectedOntologies.length > 0 && ( <> -

Selected

-
+
{displaySelectedOntologies?.map((selected, i) => ( onCheckboxChange(e, selected)} > - {checkBoxDisplay(selected, i)} + {selectedCheckBoxDisplay(selected, i)} ))}
)} - {(Object.keys(preferenceType?.self?.api_preference || {}).some( - key => preferenceType?.self?.api_preference[key]?.length > 0 - ) || - displaySelectedOntologies.length > 0) &&

Ontologies

} + { +export const FilterReset = ({ table, terminology, setExistingOntologies }) => { const { confirm } = Modal; - const { tableId } = useParams(); const { user, vocabUrl } = useContext(myContext); const { preferenceTypeSet } = useContext(SearchContext); @@ -33,6 +32,7 @@ export const FilterReset = ({ table, terminology }) => { .then(res => { if (res.ok) { return res.json().then(data => { + setExistingOntologies([]); message.success('Ontology filters deleted successfully.'); }); } else { diff --git a/src/components/Manager/MappingsFunctions/FilterSelect.jsx b/src/components/Manager/MappingsFunctions/FilterSelect.jsx index 5de5553..3c1d86d 100644 --- a/src/components/Manager/MappingsFunctions/FilterSelect.jsx +++ b/src/components/Manager/MappingsFunctions/FilterSelect.jsx @@ -6,11 +6,9 @@ import { FilterAPI } from './FilterAPI'; import { getOntologies } from '../FetchManager'; import { ModalSpinner } from '../Spinner'; import { SearchContext } from '../../../Contexts/SearchContext'; -import { useParams } from 'react-router-dom'; export const FilterSelect = ({ component, table, terminology }) => { const [form] = Form.useForm(); - const { tableId } = useParams(); const [addFilter, setAddFilter] = useState(false); const [currentPage, setCurrentPage] = useState(1); @@ -34,6 +32,33 @@ export const FilterSelect = ({ component, table, terminology }) => { setSearchText, } = useContext(SearchContext); + useEffect(() => { + setExistingOntologies(initialChecked); + }, [addFilter]); + + const existingFilters = Object.values( + preferenceType[prefTypeKey] || {} + )?.flat(); + + const flattenedFilters = existingFilters + .flatMap(item => + Object.keys(item).map(key => + item[key].map(value => ({ + api: key, + ontology: value, + })) + ) + ) + .flat(); + + let initialChecked = {}; + Array.from(new Set(flattenedFilters.map(ff => ff.api))).forEach(api => { + initialChecked[api] = flattenedFilters + .filter(ff => ff.api === api) + .map(item => item.ontology); + }); + + const [existingOntologies, setExistingOntologies] = useState(initialChecked); // Gets the ontologyAPIs on first load, automatically sets active to the first of the list to display on the page useEffect(() => { setLoading(true); @@ -80,52 +105,29 @@ export const FilterSelect = ({ component, table, terminology }) => { // If there is an api in api_preferences that is not included with the ontology_code, it's added to apiPreference with an empty array const handleSubmit = values => { setLoading(true); - const apiPreference = { + const apiPreferenceDTO = { api_preference: {}, + editor: user?.email, }; - if (values?.ontologies?.length > 0) { - // If there are ontologies, populate apiPreference with them - values.ontologies.forEach(({ ontology_code, api }) => { - if (!apiPreference.api_preference[api]) { - apiPreference.api_preference[api] = []; - } - if (!apiPreference.api_preference[api].includes(ontology_code)) { - apiPreference.api_preference[api].push(ontology_code); - } - }); - } else { - // If no ontologies are provided, initialize api preferences from selected_apis - values?.selected_apis?.forEach(item => { - const apiObj = JSON.parse(item); - const apiName = apiObj.api_preference; - apiPreference.api_preference[apiName] = []; // Create an empty array for each api_preference - }); - } - - // Now handle the existing_filters - if (values?.existing_filters?.length > 0) { - values.existing_filters.forEach(item => { - const apiObj = JSON.parse(item); // Parse the JSON string - const apiName = apiObj.ontology.api; // Extract the API - const ontologyCode = apiObj.ontology.ontology; // Extract the ontology code - - // Ensure the apiName exists in apiPreference.api_preference - if (!apiPreference.api_preference[apiName]) { - apiPreference.api_preference[apiName] = []; // Initialize if not already present - } + Object.keys(existingOntologies).forEach(api => { + apiPreferenceDTO.api_preference[api] = existingOntologies[api]; + }); - // Add the ontologyCode to the corresponding api, if it's not already included - if (!apiPreference.api_preference[apiName].includes(ontologyCode)) { - apiPreference.api_preference[apiName].push(ontologyCode); - } - }); - } + selectedBoxes.forEach(box => { + const api = box.api; + const ontology_code = box.ontology_code; - const apiPreferenceDTO = { - api_preference: apiPreference?.api_preference, - editor: user.email, - }; + // If the api already exists, merge with the existing ones + if (apiPreferenceDTO.api_preference[api]) { + apiPreferenceDTO.api_preference[api] = [ + ...new Set([...apiPreferenceDTO.api_preference[api], ontology_code]), + ]; + } else { + // Otherwise, create a new entry for that api + apiPreferenceDTO.api_preference[api] = [ontology_code]; + } + }); const method = Object.keys(preferenceType[prefTypeKey]?.api_preference || {}).length === @@ -295,7 +297,6 @@ export const FilterSelect = ({ component, table, terminology }) => { ) : ( { ontologyApis={ontologyApis} active={active} setActive={setActive} - searchText={searchText} - setSearchText={setSearchText} - currentPage={currentPage} - setCurrentPage={setCurrentPage} - pageSize={pageSize} - setPageSize={setPageSize} paginatedOntologies={paginatedOntologies} apiPreferences={apiPreferences} table={table} terminology={terminology} + existingOntologies={existingOntologies} + setExistingOntologies={setExistingOntologies} + flattenedFilters={flattenedFilters} /> )} diff --git a/src/components/Manager/MappingsFunctions/GetMappingsModal.jsx b/src/components/Manager/MappingsFunctions/GetMappingsModal.jsx index 424e43e..2e316a0 100644 --- a/src/components/Manager/MappingsFunctions/GetMappingsModal.jsx +++ b/src/components/Manager/MappingsFunctions/GetMappingsModal.jsx @@ -9,14 +9,17 @@ import { } from 'antd'; import { useContext, useEffect, useRef, useState } from 'react'; import { myContext } from '../../../App'; -import { ellipsisString, systemsMatch } from '../Utilitiy'; -import { ModalSpinner } from '../Spinner'; +import { ellipsisString, systemsMatch } from '../Utility'; +import { ModalSpinner, ResultsSpinner, SmallSpinner } from '../Spinner'; import { MappingContext } from '../../../Contexts/MappingContext'; import { SearchContext } from '../../../Contexts/SearchContext'; -import { getFiltersByCode, olsFilterOntologiesSearch } from '../FetchManager'; +import { + getFiltersByCode, + olsFilterOntologiesSearch, + ontologyFilterCodeSubmit, +} from '../FetchManager'; import { OntologyCheckboxes } from './OntologyCheckboxes'; -import { OntologyFilterCodeSubmit } from './OntologyFilterCodeSubmit'; -import { OntologyFilterCodeSubmitTerm } from './OntologyFilterCodeSubmitTerm'; + import { MappingRelationship } from './MappingRelationship'; export const GetMappingsModal = ({ @@ -32,26 +35,30 @@ export const GetMappingsModal = ({ }) => { const [form] = Form.useForm(); const { Search } = Input; - const { searchUrl, vocabUrl, setSelectedKey, user } = useContext(myContext); + const { vocabUrl, setSelectedKey, user } = useContext(myContext); const { preferenceType, defaultOntologies, - setFacetCounts, setApiPreferencesCode, apiPreferencesCode, + unformattedPref, setUnformattedPref, prefTypeKey, ontologyApis, setPrefTerminologies, + entriesPerPage, + moreAvailable, + setMoreAvailable, + setResultsCount, + resultsCount, + selectedApi, + setSelectedApi, } = useContext(SearchContext); const [page, setPage] = useState(0); - const entriesPerPage = 1000; const [loading, setLoading] = useState(true); + const [loadingResults, setLoadingResults] = useState(false); const [results, setResults] = useState([]); - const [totalCount, setTotalCount] = useState(); - const [resultsCount, setResultsCount] = useState(); const [lastCount, setLastCount] = useState(0); //save last count as count of the results before you fetch data again - const [filteredResultsCount, setFilteredResultsCount] = useState(0); const [inputValue, setInputValue] = useState(searchProp); //Sets the value of the search bar const [currentSearchProp, setCurrentSearchProp] = useState(searchProp); @@ -69,6 +76,16 @@ export const GetMappingsModal = ({ // inputValue and currentSearchProp for the search bar is set to the passed searchProp. // The function is run when the code changes. + const codeToSearch = Object.keys(unformattedPref)?.[0]; + const apiPreferences = unformattedPref?.[codeToSearch]?.api_preference; + const apiPreferenceKeys = Object?.keys(apiPreferences ?? {}); + + useEffect(() => { + apiPreferenceKeys?.length > 0 + ? setSelectedApi(apiPreferenceKeys[0]) + : setSelectedApi(ontologyApis?.[0]?.api_id || null); + }, [searchProp]); + useEffect(() => { setInputValue(searchProp); setCurrentSearchProp(searchProp); @@ -97,7 +114,7 @@ export const GetMappingsModal = ({ if (apiPreferencesCode !== undefined) { fetchResults(page, currentSearchProp); } - }, [page]); + }, [page, selectedApi]); // The '!!' forces currentSearchProp to be evaluated as a boolean. // If there is a currentSearchProp in the search bar, it evaluates to true and runs the search function. @@ -138,11 +155,18 @@ export const GetMappingsModal = ({ [] ); + useEffect(() => { + if (selectedApi && apiPreferencesCode !== undefined) { + fetchResults(page, currentSearchProp); + } + }, []); + const onClose = () => { setPage(0); setApiPreferencesCode(undefined); setSelectedKey(null); setPrefTerminologies([]); + setSelectedApi(undefined); }; // Sets currentSearchProp to the value of the search bar and sets page to 0. @@ -151,16 +175,18 @@ export const GetMappingsModal = ({ setPage(0); }; + const apiForSearch = selectedApi ?? apiPreferenceKeys[0]; + // Function to send a PUT call to update the mappings. // Each mapping in the mappings array being edited is JSON.parsed and pushed to the blank mappings array. // The mappings are turned into objects in the mappings array. const handleSubmit = values => { const selectedMappings = selectedBoxes?.map(item => ({ - code: item.obo_id, - display: item.label, - description: item.description[0], - system: systemsMatch(item.obo_id.split(':')[0], ontologyApis), - mapping_relationship: idsForSelect[item.obo_id], + code: item.code, + display: item.display, + description: item.description?.map(d => d).join(','), + system: systemsMatch(item.code.split(':')[0], ontologyApis), + mapping_relationship: idsForSelect[item.code], })); const mappingsDTO = { @@ -168,7 +194,7 @@ export const GetMappingsModal = ({ editor: user.email, }; - setLoading(true); + setLoadingResults(true); fetch( `${vocabUrl}/${componentString}/${component.id}/mapping/${mappingProp}?user_input=true&user=${user?.email}`, { @@ -205,26 +231,17 @@ export const GetMappingsModal = ({ } return error; }) - .finally(() => setLoading(false)); - table - ? OntologyFilterCodeSubmit( - apiPreferencesCode, - preferenceType, - prefTypeKey, - mappingProp, - vocabUrl, - table - ) - : OntologyFilterCodeSubmitTerm( - apiPreferencesCode, - preferenceType, - prefTypeKey, - mappingProp, - vocabUrl, - terminology - ); + .finally(() => setLoadingResults(false)); + ontologyFilterCodeSubmit( + apiPreferencesCode, + preferenceType, + prefTypeKey, + mappingProp, + vocabUrl, + table, + terminology + ); }; - const fetchResults = (page, query) => { if (!!!query) { return undefined; @@ -235,53 +252,62 @@ export const GetMappingsModal = ({ const pageStart = page * entriesPerPage; if ( - //If there are api preferences and one of them is OLS, it gets the preferred ontologies - preferenceType[prefTypeKey]?.api_preference && - 'ols' in preferenceType[prefTypeKey]?.api_preference + // If there are api preferences and one of them is in apiPreferencesCode + preferenceType[prefTypeKey]?.api_preference ) { const apiPreferenceOntologies = () => { - if (preferenceType[prefTypeKey]?.api_preference?.ols) { - return preferenceType[prefTypeKey].api_preference.ols.join(','); + // Check if apiPreferencesCode contains the key dynamically (based on selectedApi) + if ( + apiForSearch in apiPreferencesCode && + apiPreferencesCode[apiForSearch]?.length > 0 + ) { + // Return the preferred ontologies for the matched key (apiPreferenceKeys[0]) + return apiPreferencesCode[apiPreferenceKeys[0]] + .join(',') + .toUpperCase(); } else { - // else if there are no preferred ontologies, it uses the default ontologies + // If no preferred ontologies, use the default ontologies return defaultOntologies; } }; - //fetch call to search OLS with either preferred or default ontologies + + //fetch call to search API with either preferred or default ontologies return olsFilterOntologiesSearch( - searchUrl, + vocabUrl, query, - apiPreferencesCode?.length > 0 - ? apiPreferencesCode + apiPreferencesCode[selectedApi]?.length > 0 + ? apiPreferencesCode?.[selectedApi]?.map(sa => sa?.toUpperCase()) : apiPreferenceOntologies(), page, entriesPerPage, pageStart, selectedBoxes, - setTotalCount, setResults, - setFilteredResultsCount, setResultsCount, - setLoading, + loading ? setLoading : setLoadingResults, results, - setFacetCounts + setMoreAvailable, + selectedApi !== undefined ? selectedApi : apiPreferenceKeys[0], + notification ); } else return olsFilterOntologiesSearch( - searchUrl, + vocabUrl, query, - apiPreferencesCode?.length > 0 ? apiPreferencesCode : defaultOntologies, + apiPreferencesCode[selectedApi]?.length > 0 + ? apiPreferencesCode?.[selectedApi]?.map(sa => sa?.toUpperCase()) + : defaultOntologies, page, entriesPerPage, pageStart, selectedBoxes, - setTotalCount, setResults, - setFilteredResultsCount, setResultsCount, - setLoading, + loading ? setLoading : setLoadingResults, results, - setFacetCounts + setMoreAvailable, + selectedApi !== undefined ? selectedApi : apiPreferenceKeys[0], + notification ); }; @@ -307,15 +333,25 @@ export const GetMappingsModal = ({
- {d.label} + {d.display}
- -
{ellipsisString(d?.description[0], '120')}
+
+ {ellipsisString(d?.description?.map(d => d).join(','), '120')} +
@@ -329,18 +365,27 @@ export const GetMappingsModal = ({
- {d?.label} + {d?.display}
- - {d?.obo_id} + + {d?.code} + + ({d?.api?.toUpperCase()}) +
-
{ellipsisString(d?.description?.[0], '100')}
+
+ {ellipsisString(d?.description?.map(d => d).join(','), '100')} +
@@ -363,8 +408,8 @@ export const GetMappingsModal = ({ }; const onSelectedChange = checkedValues => { const selected = JSON.parse(checkedValues?.[0]); - const selectedMapping = results.find( - result => result.obo_id === selected.code + const selectedMapping = results?.find( + result => result.code === selected.code ); // Updates selectedMappings and displaySelectedMappings to include the new selected items @@ -382,7 +427,7 @@ export const GetMappingsModal = ({ // Filters out the selected checkboxes from the results being displayed const updatedResults = results.filter( - result => result.obo_id !== selected.code + result => result.code !== selected.code ); setResults(updatedResults); }; @@ -393,7 +438,7 @@ export const GetMappingsModal = ({ const codesToExclude = new Set([ ...displaySelectedMappings?.map(m => m?.code), ]); - return results.filter(r => !codesToExclude?.has(r.obo_id)); + return results?.filter(r => !codesToExclude?.has(r.code)); }; const filteredResultsArray = getFilteredResults(); @@ -458,85 +503,82 @@ export const GetMappingsModal = ({
+
- {displaySelectedMappings?.length > 0 && ( - -
- {displaySelectedMappings?.map((sm, i) => ( - box.obo_id === sm.obo_id - )} - value={sm} - onChange={e => onCheckboxChange(e, sm, i)} - > - {selectedTermsDisplay(sm, i)} - - ))} -
-
- )} - {results?.length > 0 ? ( + {loadingResults ? ( + + ) : ( <> - - {filteredResultsArray?.length > 0 ? ( - { - return { - value: JSON.stringify({ - code: d.obo_id, - display: d.label, - description: d.description[0], - system: systemsMatch( - d?.obo_id?.split(':')[0], - ontologyApis - ), - }), - label: checkBoxDisplay(d, index), - }; - } + {displaySelectedMappings?.length > 0 && ( + +
+ {displaySelectedMappings?.map((sm, i) => ( + box.code === sm.code + )} + value={sm} + onChange={e => onCheckboxChange(e, sm, i)} + > + {selectedTermsDisplay(sm, i)} + + ))} +
+
+ )} + {results?.length > 0 ? ( + <> + + {filteredResultsArray?.length > 0 ? ( + { + return { + value: JSON.stringify({ + code: d.code, + display: d.display, + description: d.description + ?.map(d => d) + .join(','), + system: d?.system, + }), + label: checkBoxDisplay(d, index), + }; + } + )} + onChange={onSelectedChange} + /> + ) : ( + '' )} - onChange={onSelectedChange} - /> - ) : ( - '' - )} - {' '} +
{' '} + + ) : ( +

No results found

+ )} - ) : ( -

No results found

)}
- {/* 'View More' pagination displaying the number of results being displayed - out of the total number of results. Because of the filter to filter out the duplicates, - there is a tooltip informing the user that redundant entries have been removed to explain any - inconsistencies in results numbers per page. */} - - Displaying {resultsCount} -  of {totalCount} - - {resultsCount < totalCount - filteredResultsCount && ( + {/* 'View More' pagination */} + + {moreAvailable && !loadingResults && ( { diff --git a/src/components/Manager/MappingsFunctions/MappingRelationship.jsx b/src/components/Manager/MappingsFunctions/MappingRelationship.jsx index 1ccbcd1..11f367c 100644 --- a/src/components/Manager/MappingsFunctions/MappingRelationship.jsx +++ b/src/components/Manager/MappingsFunctions/MappingRelationship.jsx @@ -6,10 +6,10 @@ export const MappingRelationship = ({ mapping }) => { const { relationshipOptions, idsForSelect, setIdsForSelect } = useContext(MappingContext); - const handleSelectChange = (obo_id, value) => { + const handleSelectChange = (code, value) => { setIdsForSelect(prev => ({ ...prev, - [obo_id]: value, + [code]: value, })); }; @@ -25,14 +25,14 @@ export const MappingRelationship = ({ mapping }) => { options={options} defaultValue={mapping?.mapping_relationship || undefined} style={{ - width: 120, + width: 'fit-content', }} placeholder="Relationship" popupMatchSelectWidth={false} allowClear - value={idsForSelect[mapping.obo_id || mapping.code]} + value={idsForSelect[mapping.code || mapping.code]} onChange={value => - handleSelectChange(mapping.obo_id || mapping.code, value) + handleSelectChange(mapping.code || mapping.code, value) } onClick={e => e.preventDefault()} /> diff --git a/src/components/Manager/MappingsFunctions/MappingReset.jsx b/src/components/Manager/MappingsFunctions/MappingReset.jsx deleted file mode 100644 index f93ab9f..0000000 --- a/src/components/Manager/MappingsFunctions/MappingReset.jsx +++ /dev/null @@ -1,647 +0,0 @@ -import { Checkbox, Form, Input, notification, Tooltip } from 'antd'; -import { useContext, useEffect, useRef, useState } from 'react'; -import { myContext } from '../../../App'; -import { ellipsisString, ontologyReducer, systemsMatch } from '../Utilitiy'; -import { ModalSpinner } from '../Spinner'; -import { MappingContext } from '../../../Contexts/MappingContext'; -import { getFiltersByCode, olsFilterOntologiesSearch } from '../FetchManager'; -import { SearchContext } from '../../../Contexts/SearchContext'; -import { OntologyCheckboxes } from './OntologyCheckboxes'; -import { MappingRelationship } from './MappingRelationship'; - -export const MappingReset = ({ - searchProp, - mappingDesc, - setEditMappings, - form, - onClose, - mappingProp, - table, - terminology, -}) => { - const { searchUrl, vocabUrl } = useContext(myContext); - const { - apiPreferences, - defaultOntologies, - setFacetCounts, - setApiPreferencesCode, - apiPreferencesCode, - setUnformattedPref, - ontologyApis, - prefTerminologies, - } = useContext(SearchContext); - const [page, setPage] = useState(0); - const entriesPerPage = 1000; - const [loading, setLoading] = useState(true); - const [results, setResults] = useState([]); - const [totalCount, setTotalCount] = useState(); - const [resultsCount, setResultsCount] = useState(); // - const [lastCount, setLastCount] = useState(0); //save last count as count of the results before you fetch data again - const [filteredResultsCount, setFilteredResultsCount] = useState(0); - const [inputValue, setInputValue] = useState(searchProp); //Sets the value of the search bar - const [currentSearchProp, setCurrentSearchProp] = useState(searchProp); - const [terminologiesToMap, setTerminologiesToMap] = useState([]); - const [active, setActive] = useState(null); - const [allCheckboxes, setAllCheckboxes] = useState([]); - - const { - setSelectedMappings, - displaySelectedMappings, - setDisplaySelectedMappings, - selectedBoxes, - setSelectedBoxes, - } = useContext(MappingContext); - let ref = useRef(); - const { Search } = Input; - - const fetchTerminologies = () => { - setLoading(true); - const fetchPromises = prefTerminologies?.map(pref => - fetch(`${vocabUrl}/${pref?.reference}`).then(response => response.json()) - ); - - Promise.all(fetchPromises) - .then(results => { - // Once all fetch calls are resolved, set the combined data - setTerminologiesToMap(results); - }) - .catch(error => { - notification.error({ - message: 'Error', - description: 'An error occurred. Please try again.', - }); - }) - .finally(() => setLoading(false)); - }; - - // since the code is passed through searchProp, the '!!' forces it to be evaluated as a boolean. - // if there is a searchProp being passed, it evaluates to true and runs the search function. - // inputValue and currentSearchProp for the search bar is set to the passed searchProp. - // The function is run when the code changes. - useEffect(() => { - setInputValue(searchProp); - setCurrentSearchProp(searchProp); - setPage(0); - if (!!searchProp) { - getFiltersByCode( - vocabUrl, - mappingProp, - setApiPreferencesCode, - notification, - setUnformattedPref, - table, - terminology, - setLoading - ); - } - }, [searchProp]); - - useEffect(() => { - if (apiPreferencesCode !== undefined) { - fetchResults(0, searchProp); - } - }, [searchProp]); - - useEffect(() => { - if (apiPreferencesCode !== undefined) { - fetchResults(page, currentSearchProp); - } - }, [page]); - - useEffect(() => { - if (prefTerminologies.length > 0) { - fetchTerminologies(); - } - }, []); - - useEffect(() => { - setAllCheckboxes( - terminologiesToMap.find(term => term.id === active)?.codes ?? [] - ); - }, [active]); - - useEffect(() => { - setActive(terminologiesToMap?.[0]?.id); - }, [terminologiesToMap]); - - // The '!!' forces currentSearchProp to be evaluated as a boolean. - // If there is a currentSearchProp in the search bar, it evaluates to true and runs the search function. - // The function is run when the query changes and when the preferred ontology changes. - // If there are preferred terminologies, it runs when the OLS search bar is clicked (i.e. active) - useEffect(() => { - if ( - prefTerminologies.length > 0 && - active === 'search' && - !!currentSearchProp && - apiPreferencesCode !== undefined - ) { - fetchResults(page, currentSearchProp); - } else if ( - prefTerminologies.length === 0 && - !!currentSearchProp && - apiPreferencesCode !== undefined - ) { - fetchResults(page, currentSearchProp); - } - }, [currentSearchProp, apiPreferencesCode, active]); - - /* Pagination is handled via a "View More" link at the bottom of the page. - Each click on the "View More" link makes an API call to fetch the next 15 results. - This useEffect moves the scroll bar on the modal to the first index of the new batch of results. - Because the content is in a modal and not the window, the closest class name to the modal is used for the location of the ref. */ - useEffect(() => { - if (results?.length > 0 && page > 0 && ref.current) { - const container = ref.current.closest('.ant-modal-body'); - const scrollTop = ref.current.offsetTop - container.offsetTop; - container.scrollTop = scrollTop; - } - }, [results]); - - // Sets the value of the selected_mappings in the form to the checkboxes that are selected - useEffect(() => { - form.setFieldsValue({ - selected_mappings: selectedBoxes, - }); - }, [selectedBoxes, form]); - - // sets the code to null on dismount. - useEffect( - () => () => { - onClose(); - setEditMappings(null); - setSelectedMappings([]); - setDisplaySelectedMappings([]); - setSelectedBoxes([]); - }, - [] - ); - - // Sets currentSearchProp to the value of the search bar and sets page to 0. - const handleSearch = query => { - setCurrentSearchProp(query); - setPage(0); - }; - - // The function that makes the API call to search for the passed code. - const fetchResults = (page, query) => { - if (!!!query) { - return undefined; - } - setLoading(true); - - /* The OLS API returns 10 results by default unless specified otherwise. The fetch call includes a specified - number of results to return per page (entriesPerPage) and a calculation of the first index to start the results - on each new batch of results (pageStart, calculated as the number of the page * the number of entries per page */ - const pageStart = page * entriesPerPage; - if ( - //If there are api preferences and one of them is OLS, it gets the preferred ontologies - apiPreferences?.self?.api_preference && - 'ols' in apiPreferences?.self?.api_preference - ) { - const apiPreferenceOntologies = () => { - if (apiPreferences?.self?.api_preference?.ols) { - return apiPreferences.self.api_preference.ols.join(','); - } else { - // else if there are no preferred ontologies, it uses the default ontologies - return defaultOntologies; - } - }; - //fetch call to search OLS with either preferred or default ontologies - return olsFilterOntologiesSearch( - searchUrl, - query, - apiPreferencesCode?.length > 0 - ? apiPreferencesCode - : apiPreferenceOntologies(), - page, - entriesPerPage, - pageStart, - selectedBoxes, - setTotalCount, - setResults, - setFilteredResultsCount, - setResultsCount, - setLoading, - results, - setFacetCounts - ); - } else - return olsFilterOntologiesSearch( - searchUrl, - query, - apiPreferencesCode?.length > 0 ? apiPreferencesCode : defaultOntologies, - page, - entriesPerPage, - pageStart, - selectedBoxes, - setTotalCount, - setResults, - setFilteredResultsCount, - setResultsCount, - setLoading, - results, - setFacetCounts - ); - }; - - // the 'View More' pagination onClick increments the page. The search function is triggered to run on page change in the useEffect. - const handleViewMore = e => { - e.preventDefault(); - setPage(prevPage => prevPage + 1); - }; - - // The display for the checkboxes. The index is set to the count of the results before you fetch the new batch of results - // again + 1, to move the scrollbar to the first result of the new batch. - const checkBoxDisplay = (item, index) => { - return ( - <> -
-
-
-
-
{item.code}
-
-
{item.display}
-
- {item?.description?.length > 85 ? ( - - {ellipsisString(item?.description, '85')} - - ) : ( - ellipsisString(item?.description, '85') - )} -
-
-
-
- - ); - }; - - const handleChange = e => { - setInputValue(e.target.value); - }; - - // The display for the checkboxes. The index is set to the count of the results before you fetch the new batch of results - // again + 1, to move the scrollbar to the first result of the new batch. - const newSearchDisplay = (d, index) => { - index === lastCount + 1; - return ( - <> -
-
-
-
- {d?.label} -
- -
- {d?.description?.length > 100 ? ( - - {ellipsisString(d?.description[0], '100')} - - ) : ( - ellipsisString(d?.description[0], '100') - )}{' '} -
-
- - ); - }; - - const selectedTermsDisplay = (d, index) => { - return ( - <> -
-
-
-
-
- {d?.display || d?.label} -
-
-
- {d?.code || ( - - {d?.obo_id} - - )} -
-
- -
-
-
- {d?.description?.length > 85 ? ( - - {ellipsisString( - Array.isArray(d.description) - ? d.description[0] - : d.description, - '85' - )} - - ) : ( - ellipsisString( - Array.isArray(d.description) - ? d.description[0] - : d.description, - '85' - ) - )} -
-
-
- - ); - }; - // If the checkbox is checked, it adds the object to the selectedBoxes array - // If it is unchecked, it filters it out of the selectedBoxes array. - const onCheckboxChange = (event, code) => { - if (event.target.checked) { - setSelectedBoxes(prevState => [...prevState, code]); - } else { - setSelectedBoxes(prevState => prevState.filter(val => val !== code)); - } - }; - - const onSelectedChange = checkedValues => { - const selected = JSON.parse(checkedValues?.[checkedValues.length - 1]); - - // Adds the selectedMappings to the selectedBoxes to ensure they are checked - setSelectedBoxes(prevState => { - const updated = [...prevState, selected]; - // Sets the values for the form to the selectedMappings checkboxes that are checked - form.setFieldsValue({ selected_mappings: updated }); - return updated; - }); - - setDisplaySelectedMappings(prevState => [...prevState, selected]); - }; - - // Creates a Set that excludes the mappings that have already been selected. - // Then filteres the existing mappings out of the results to only display results that have not yet been selected. - const getFilteredResults = () => { - const codesToExclude = new Set([ - ...displaySelectedMappings?.map(m => m?.code), - ]); - return results.filter(r => !codesToExclude?.has(r.obo_id)); - }; - - const filteredResultsArray = getFilteredResults(); - // Peforms search on Tab key press - const searchOnTab = e => { - if (e.key === 'Tab') { - e.preventDefault(); - handleSearch(e.target.value); - } - }; - return ( - <> -
- <> - {loading === false ? ( - <> -
-
-

{searchProp}

-
- {!prefTerminologies.length > 0 && ( - - )} -
- {mappingDesc} -
- {/* ant.design form displaying the checkboxes with the search results. */} -
-
-
-
- {terminologiesToMap?.length > 0 && ( -
-
-
- setActive(terminologiesToMap?.[0]?.id) - } - > - Preferred Terminologies -
-
- {terminologiesToMap.map((term, i) => ( -
setActive(term.id)} - > - {term.name} -
- ))} -
-
setActive('search')} - className={ - active === 'search' - ? 'active_term' - : 'inactive_term' - } - > - -
-
-
- )} - {((prefTerminologies.length > 0 && - active === 'search') || - prefTerminologies.length === 0) && ( - - )} -
-
- {displaySelectedMappings?.length > 0 && ( - -
- {displaySelectedMappings?.map((sm, i) => ( - onCheckboxChange(e, sm)} - checked={ - active === 'search' - ? selectedBoxes.some( - box => box.obo_id === sm.obo_id - ) - : selectedBoxes.some( - box => box.code === sm.code - ) - } - value={sm} - > - {selectedTermsDisplay(sm, i)} - - ))} -
-
- )} - {(prefTerminologies.length > 0 && - active === 'search') || - prefTerminologies.length === 0 ? ( - results?.length > 0 ? ( - <> - - {filteredResultsArray?.length > 0 && ( - { - return { - value: JSON.stringify({ - code: d.obo_id, - display: d.label, - description: d.description[0], - system: systemsMatch( - d?.obo_id?.split(':')[0], - ontologyApis - ), - }), - label: newSearchDisplay(d, index), - }; - } - )} - onChange={onSelectedChange} - /> - )} - - - ) : ( -

No results found

- ) - ) : ( - - - !displaySelectedMappings.some( - dsm => checkbox.code === dsm.code - ) - ) - .map((code, index) => ({ - value: JSON.stringify({ - code: code.code, - display: code.display, - description: code.description, - system: code.system, - }), - label: checkBoxDisplay(code, index), - }))} - onChange={onSelectedChange} - /> - - )} -
-
-
- {((prefTerminologies.length > 0 && active === 'search') || - prefTerminologies.length === 0) && ( -
- {/* 'View More' pagination displaying the number of results being displayed - out of the total number of results. Because of the filter to filter out the duplicates, - there is a tooltip informing the user that redundant entries have been removed to explain any - inconsistencies in results numbers per page. */} - - Displaying {resultsCount} -  of {totalCount} - - {resultsCount < totalCount - filteredResultsCount && ( - { - handleViewMore(e); - setLastCount(resultsCount); - }} - > - View More - - )} -
- )} -
-
- - ) : ( -
- -
- )} - -
- - ); -}; diff --git a/src/components/Manager/MappingsFunctions/MappingSearch.jsx b/src/components/Manager/MappingsFunctions/MappingSearch.jsx index b21d66f..a73aa98 100644 --- a/src/components/Manager/MappingsFunctions/MappingSearch.jsx +++ b/src/components/Manager/MappingsFunctions/MappingSearch.jsx @@ -1,13 +1,12 @@ import { Checkbox, Form, Input, notification, Tooltip } from 'antd'; import { useContext, useEffect, useRef, useState } from 'react'; import { myContext } from '../../../App'; -import { ellipsisString, ontologyReducer, systemsMatch } from '../Utilitiy'; -import { ModalSpinner } from '../Spinner'; +import { ellipsisString, systemsMatch } from '../Utility'; +import { ModalSpinner, ResultsSpinner } from '../Spinner'; import { MappingContext } from '../../../Contexts/MappingContext'; import { SearchContext } from '../../../Contexts/SearchContext'; import { getFiltersByCode, olsFilterOntologiesSearch } from '../FetchManager'; import { OntologyCheckboxes } from './OntologyCheckboxes'; -import { APISearchBar } from '../../Projects/Terminologies/APISearchBar'; import { MappingRelationship } from './MappingRelationship'; export const MappingSearch = ({ @@ -20,28 +19,35 @@ export const MappingSearch = ({ mappingDesc, terminology, table, + preferenceType, + prefTypeKey, + loadingResults, + setLoadingResults, }) => { - const { searchUrl, vocabUrl } = useContext(myContext); + const { vocabUrl } = useContext(myContext); const { apiPreferences, defaultOntologies, - setFacetCounts, setApiPreferencesCode, apiPreferencesCode, + unformattedPref, setUnformattedPref, prefTerminologies, setApiResults, ontologyApis, + entriesPerPage, + moreAvailable, + setMoreAvailable, + resultsCount, + setResultsCount, + selectedApi, + setSelectedApi, } = useContext(SearchContext); const [page, setPage] = useState(0); - const entriesPerPage = 1000; const [loading, setLoading] = useState(true); const [results, setResults] = useState([]); - const [totalCount, setTotalCount] = useState(); - const [resultsCount, setResultsCount] = useState(); // const [lastCount, setLastCount] = useState(0); //save last count as count of the results before you fetch data again - const [filteredResultsCount, setFilteredResultsCount] = useState(0); const [inputValue, setInputValue] = useState(searchProp); //Sets the value of the search bar const [currentSearchProp, setCurrentSearchProp] = useState(searchProp); const [terminologiesToMap, setTerminologiesToMap] = useState([]); @@ -55,6 +61,7 @@ export const MappingSearch = ({ setDisplaySelectedMappings, selectedBoxes, setSelectedBoxes, + existingMappings, } = useContext(MappingContext); let ref = useRef(); @@ -101,6 +108,16 @@ export const MappingSearch = ({ } }, [searchProp]); + const codeToSearch = Object.keys(unformattedPref)?.[0]; + const savedApiPreferences = unformattedPref?.[codeToSearch]?.api_preference; + const apiPreferenceKeys = Object?.keys(savedApiPreferences ?? {}); + + useEffect(() => { + apiPreferenceKeys?.length > 0 + ? setSelectedApi(apiPreferenceKeys[0]) + : setSelectedApi(ontologyApis?.[0]?.api_id || null); + }, [searchProp]); + useEffect(() => { if (apiPreferencesCode !== undefined) { fetchResults(0, searchProp); @@ -111,7 +128,7 @@ export const MappingSearch = ({ if (apiPreferencesCode !== undefined) { fetchResults(page, currentSearchProp); } - }, [page]); + }, [page, selectedApi]); useEffect(() => { if (prefTerminologies.length > 0) { @@ -184,66 +201,75 @@ export const MappingSearch = ({ setCurrentSearchProp(query); setPage(0); }; + const apiForSearch = selectedApi ?? apiPreferenceKeys[0]; // The function that makes the API call to search for the passed code. const fetchResults = (page, query) => { if (!!!query) { return undefined; } - setLoading(true); /* The OLS API returns 10 results by default unless specified otherwise. The fetch call includes a specified number of results to return per page (entriesPerPage) and a calculation of the first index to start the results on each new batch of results (pageStart, calculated as the number of the page * the number of entries per page */ const pageStart = page * entriesPerPage; if ( - //If there are api preferences and one of them is OLS, it gets the preferred ontologies - apiPreferences?.self?.api_preference && - 'ols' in apiPreferences?.self?.api_preference + // If there are api preferences and one of them is in apiPreferencesCode + preferenceType?.[prefTypeKey]?.api_preference ) { const apiPreferenceOntologies = () => { - if (apiPreferences?.self?.api_preference?.ols) { - return apiPreferences.self.api_preference.ols.join(','); + // Check if apiPreferencesCode contains the key dynamically (based on selectedApi) + if ( + apiForSearch in apiPreferencesCode && + apiPreferencesCode[apiForSearch]?.length > 0 + ) { + // Return the preferred ontologies for the matched key (apiPreferenceKeys[0]) + return apiPreferencesCode[apiPreferenceKeys[0]] + .join(',') + .toUpperCase(); } else { - // else if there are no preferred ontologies, it uses the default ontologies + // If no preferred ontologies, use the default ontologies return defaultOntologies; } }; - //fetch call to search OLS with either preferred or default ontologies + + //fetch call to search API with either preferred or default ontologies return olsFilterOntologiesSearch( - searchUrl, + vocabUrl, query, - apiPreferencesCode?.length > 0 - ? apiPreferencesCode + apiPreferencesCode[selectedApi]?.length > 0 + ? apiPreferencesCode?.[selectedApi]?.map(sa => sa?.toUpperCase()) : apiPreferenceOntologies(), page, entriesPerPage, pageStart, selectedBoxes, - setTotalCount, setResults, - setFilteredResultsCount, setResultsCount, - setLoading, + loading ? setLoading : setLoadingResults, results, - setFacetCounts + setMoreAvailable, + selectedApi !== undefined ? selectedApi : apiPreferenceKeys[0], + notification ); } else return olsFilterOntologiesSearch( - searchUrl, + vocabUrl, query, - apiPreferencesCode?.length > 0 ? apiPreferencesCode : defaultOntologies, + apiPreferencesCode[selectedApi]?.length > 0 + ? apiPreferencesCode?.[selectedApi]?.map(sa => sa?.toUpperCase()) + : defaultOntologies, page, entriesPerPage, pageStart, selectedBoxes, - setTotalCount, setResults, - setFilteredResultsCount, setResultsCount, - setLoading, + loading ? setLoading : setLoadingResults, results, - setFacetCounts + setMoreAvailable, + selectedApi !== undefined ? selectedApi : apiPreferenceKeys[0], + notification ); }; // the 'View More' pagination onClick increments the page. The search function is triggered to run on page change in the useEffect. @@ -258,7 +284,8 @@ export const MappingSearch = ({ }; const onExistingChange = checkedValues => { - setExistingMappings(checkedValues); + const parsedValues = checkedValues.map(cv => JSON.parse(cv)); + setExistingMappings(parsedValues); }; // If the checkbox is checked, it adds the object to the selectedBoxes array @@ -301,11 +328,11 @@ export const MappingSearch = ({
- {d?.label} + {d?.display}
- @@ -315,10 +342,10 @@ export const MappingSearch = ({ title={d?.description} placement="topRight" > - {ellipsisString(d?.description[0], '100')} + {ellipsisString(d?.description?.map(d => d).join(','), '100')} ) : ( - ellipsisString(d?.description[0], '100') + ellipsisString(d?.description?.map(d => d).join(','), '100') )}{' '}
@@ -333,15 +360,20 @@ export const MappingSearch = ({
-
- {d?.display || d?.label} -
+ {d?.display}
- {d?.code || ( - - {d?.obo_id} - + + {d?.code} + + {d?.api && ( + + ({d?.api?.toUpperCase()}) + )}
@@ -357,7 +389,7 @@ export const MappingSearch = ({ > {ellipsisString( Array.isArray(d.description) - ? d.description[0] + ? d.description?.map(d => d).join(',') : d.description, '85' )} @@ -365,7 +397,7 @@ export const MappingSearch = ({ ) : ( ellipsisString( Array.isArray(d.description) - ? d.description[0] + ? d.description?.map(d => d).join(',') : d.description, '85' ) @@ -415,8 +447,12 @@ export const MappingSearch = ({ ); }; + // Sets existingMappings to the mappings that have already been mapped to pass them to the body of the PUT call on save. + useEffect(() => { + setExistingMappings(mappingsForSearch); + }, []); // Iterates through the array of previously selected mappings. Returns a JSON stringified object to use as default checked values separate from the search results. - const initialChecked = mappingsForSearch.map(m => + const initialChecked = existingMappings.map(m => JSON.stringify({ code: m?.code, display: m?.display, @@ -425,11 +461,6 @@ export const MappingSearch = ({ }) ); - // Sets existingMappings to the mappings that have already been mapped to pass them to the body of the PUT call on save. - useEffect(() => { - setExistingMappings(mappingsForSearch); - }, []); - // Creates a Set that excludes the mappings that have already been selected. // Then filteres the existing mappings out of the results to only display results that have not yet been selected. const getFilteredResults = () => { @@ -437,7 +468,7 @@ export const MappingSearch = ({ ...mappingsForSearch?.map(m => m?.code), ...displaySelectedMappings?.map(m => m?.code), ]); - return results.filter(r => !codesToExclude?.has(r.obo_id)); + return results?.filter(r => !codesToExclude?.has(r.code)); }; const filteredResultsArray = getFilteredResults(); @@ -457,7 +488,7 @@ export const MappingSearch = ({
-
{item.code}
+
{item.code}
{item.display}
@@ -564,69 +595,14 @@ export const MappingSearch = ({
- {mappingsForSearch?.length > 0 && ( - - { - return { - value: JSON.stringify({ - code: d.code, - display: d.display, - description: d.description, - system: d.system, - }), - label: existingMappingDisplay(d, index), - }; - })} - onChange={onExistingChange} - /> - - )} - {displaySelectedMappings?.length > 0 && ( - -
- {displaySelectedMappings?.map((sm, i) => ( - onCheckboxChange(e, sm)} - checked={ - active === 'search' - ? selectedBoxes.some( - box => box.obo_id === sm.obo_id - ) - : selectedBoxes.some( - box => box.code === sm.code - ) - } - value={sm} - > - {selectedTermsDisplay(sm, i)} - - ))} -
-
- )} - {(prefTerminologies.length > 0 && - active === 'search') || - prefTerminologies.length === 0 ? ( - results?.length > 0 ? ( - <> + {loadingResults ? ( + + ) : ( + <> + {mappingsForSearch?.length > 0 && ( - {filteredResultsArray?.length > 0 && ( - { - return { - value: JSON.stringify({ - code: d.obo_id, - display: d.label, - description: d.description[0], - system: systemsMatch( - d?.obo_id?.split(':')[0], - ontologyApis - ), - }), - label: newSearchDisplay(d, index), - }; + { + return { + value: JSON.stringify({ + code: d.code, + display: d.display, + description: d.description, + system: d.system, + }), + label: existingMappingDisplay( + d, + index + ), + }; + } + )} + onChange={onExistingChange} + /> + + )} + {displaySelectedMappings?.length > 0 && ( + +
+ {displaySelectedMappings?.map((sm, i) => ( + onCheckboxChange(e, sm)} + checked={ + active === 'search' + ? selectedBoxes.some( + box => box.code === sm.code + ) + : selectedBoxes.some( + box => box.code === sm.code + ) } - )} - onChange={onSelectedChange} - /> - )} + value={sm} + > + {selectedTermsDisplay(sm, i)} + + ))} +
- - ) : ( -

No results found

- ) - ) : ( - - - !displaySelectedMappings.some( - dsm => checkbox.code === dsm.code + )} + {(prefTerminologies.length > 0 && + active === 'search') || + prefTerminologies.length === 0 ? ( + results?.length > 0 ? ( + <> + + {filteredResultsArray?.length > 0 && ( + { + return { + value: JSON.stringify({ + code: d.code, + display: d.display, + description: d.description + ?.map(d => d) + .join(','), + system: d?.system, + api: d.api, + }), + label: newSearchDisplay( + d, + index + ), + }; + } + )} + onChange={onSelectedChange} + /> + )} + + + ) : ( +

No results found

+ ) + ) : ( + + + !displaySelectedMappings.some( + dsm => checkbox.code === dsm.code + ) ) - ) - .map((code, index) => ({ - value: JSON.stringify({ - code: code.code, - display: code.display, - description: code.description, - system: code.system, - }), - label: checkBoxDisplay(code, index), - }))} - onChange={onSelectedChange} - /> - + .map((code, index) => ({ + value: JSON.stringify({ + code: code.code, + display: code.display, + description: code.description, + system: code.system, + }), + label: checkBoxDisplay(code, index), + }))} + onChange={onSelectedChange} + /> +
+ )} + )}
@@ -700,18 +745,9 @@ export const MappingSearch = ({ {((prefTerminologies.length > 0 && active === 'search') || prefTerminologies.length === 0) && (
- {/* 'View More' pagination displaying the number of results being displayed - out of the total number of results. Because of the filter to filter out the duplicates, - there is a tooltip informing the user that redundant entries have been removed to explain any - inconsistencies in results numbers per page. */} - - Displaying {resultsCount} -  of {totalCount} - - {resultsCount < totalCount - filteredResultsCount && ( + {/* 'View More' pagination */} + + {moreAvailable && !loadingResults && ( { diff --git a/src/components/Manager/MappingsFunctions/MappingsFunctions.scss b/src/components/Manager/MappingsFunctions/MappingsFunctions.scss index ac09520..ad5fb7c 100644 --- a/src/components/Manager/MappingsFunctions/MappingsFunctions.scss +++ b/src/components/Manager/MappingsFunctions/MappingsFunctions.scss @@ -50,7 +50,7 @@ .ontology_form_pref { width: 13vw; padding-right: 20px; - height: 44%; + height: 42%; overflow: auto; position: fixed; } @@ -72,10 +72,12 @@ .active_term { background-color: $darkBlue; color: white; + transition: all 0.3s linear; } .inactive_term { background-color: $lightBlue; + transition: all 0.3s linear; } .hidden_term { @@ -103,11 +105,11 @@ .inactive_selected_api { padding: 0 5px; width: 10.5vw; + cursor: pointer; } .active_selected_api { background-color: rgb(233, 237, 240); - border-bottom: 1px solid lightgrey; border-bottom: 1px solid rgb(238, 238, 255); border-right: 1px solid rgb(238, 238, 255); } @@ -144,7 +146,7 @@ .api_onto_search_bar, .onto_search_bar { - margin-bottom: 8px; + margin: 8px 0; } .api_onto_search_bar { @@ -178,3 +180,44 @@ color: gray; margin: 6px 0 0 0; } + +.api_wrapper { + padding-bottom: 5px; + display: flex; + align-items: center; +} + +.api_header { + font-weight: 600; + width: 4em; +} + +.ontologies_header { + margin: -5px 0 5px 0; +} + +.bottom_border_div { + border-bottom: solid 1px #eeee; +} + +.display_selected_api { + color: black; + font: { + size: 0.8rem; + } + margin: 0 0 0 5px; +} + +.api_ontology_prefix { + /* Commented out to potentially come back to for styling ontology prefix*/ + color: black; + // background-color: $lightBlue; + // padding: 0px 8px; + // border-radius: 5px; + // font-weight: 400; + // border: 1px solid #6aa6e3; +} + +.api_ontology_code { + font-weight: 600; +} diff --git a/src/components/Manager/MappingsFunctions/OntologyCheckboxes.jsx b/src/components/Manager/MappingsFunctions/OntologyCheckboxes.jsx index cd59049..e9c9ba7 100644 --- a/src/components/Manager/MappingsFunctions/OntologyCheckboxes.jsx +++ b/src/components/Manager/MappingsFunctions/OntologyCheckboxes.jsx @@ -1,5 +1,5 @@ -import { Checkbox, Form, Input } from 'antd'; -import { ontologyCounts } from '../Utilitiy'; +import { Checkbox, Form, Input, Radio } from 'antd'; +import { ontologyCounts } from '../Utility'; import { useContext, useEffect, useState } from 'react'; import { SearchContext } from '../../../Contexts/SearchContext'; import './MappingsFunctions.scss'; @@ -13,31 +13,86 @@ export const OntologyCheckboxes = ({ preferenceType }) => { prefTypeKey, active, prefTerminologies, + checkedOntologies, + setCheckedOntologies, + unformattedPref, + selectedApi, + setSelectedApi, } = useContext(SearchContext); const { Search } = Input; - const [checkedOntologies, setCheckedOntologies] = useState([]); + let allApiPreferences = {}; + + const codeToSearch = Object.keys(unformattedPref)?.[0]; + const apiPreferences = + unformattedPref[codeToSearch]?.api_preference ?? + unformattedPref[codeToSearch]; + const apiPreferenceKeys = Object.keys(apiPreferences); + + apiPreferenceKeys?.forEach(key => { + // Dynamically assigns the api values to allApiPreferences variable + allApiPreferences[key] = apiPreferences[key]; + }); + const [searchText, setSearchText] = useState(''); - const defaultOntologies = ['mondo', 'hp', 'maxo', 'ncit']; + const defaultOntologies = + selectedApi === 'ols' ? ['MONDO', 'HP', 'MAXO', 'NCIT'] : ['SNOMEDCT_US']; + + //If there are no preferences set for an API, it sets them to default ontologies + if ( + apiPreferencesCode && + !apiPreferencesCode.hasOwnProperty('umls') && + selectedApi === 'umls' + ) { + apiPreferencesCode.umls = defaultOntologies; + } + if ( + apiPreferencesCode && + !apiPreferencesCode.hasOwnProperty('ols') && + selectedApi === 'ols' + ) { + apiPreferencesCode.ols = defaultOntologies; + } + + const options = + ontologyApis && + ontologyApis.map((aap, index) => ({ + value: aap.api_id, + label: aap.api_id.toUpperCase(), + })); + + const defaultApi = + Object.keys(allApiPreferences).length > 0 + ? Object.keys(allApiPreferences)[0] + : options[0]?.value; + useEffect(() => { + setSelectedApi(defaultApi); + }, []); let processedApiPreferencesCode; // Ensures the data sent to the API is in the correct format. // If apiPreferencesCode is an array, sets processedApiPreferencesCode equal to it. // If it is a comma-separated string, it splits it by the commas and adds them to an array - if (Array.isArray(apiPreferencesCode)) { - processedApiPreferencesCode = apiPreferencesCode; - } else if (typeof apiPreferencesCode === 'string') { - processedApiPreferencesCode = apiPreferencesCode.split(','); + if (Array.isArray(apiPreferencesCode?.[selectedApi])) { + processedApiPreferencesCode = apiPreferencesCode[selectedApi]; + } else if (typeof apiPreferencesCode?.[selectedApi] === 'string') { + processedApiPreferencesCode = apiPreferencesCode[selectedApi].split(','); } let existingOntologies; // Checks if apiPreferencesCode exists and is non-empty, if so, assigns processedApiPreferencesCode to existingOntologies - if (Array.isArray(apiPreferencesCode) && apiPreferencesCode.length > 0) { - existingOntologies = processedApiPreferencesCode; + if ( + Array.isArray(apiPreferencesCode?.[selectedApi]) && + apiPreferencesCode[selectedApi].length > 0 + ) { + existingOntologies = processedApiPreferencesCode?.map(pap => + pap.toUpperCase() + ); } + // Checks if preferenceType[prefTypeKey].api_preference exists and is non-empty, if so, assigns the values to existingOntologies else if ( preferenceType && @@ -55,22 +110,48 @@ export const OntologyCheckboxes = ({ preferenceType }) => { } useEffect(() => { - setCheckedOntologies(existingOntologies); - }, [preferenceType]); + setCheckedOntologies(existingOntologies.map(eo => eo.toUpperCase())); + }, [preferenceType, selectedApi]); const onCheckboxChange = e => { const { value, checked } = e.target; - setCheckedOntologies(existingOntologies => { - const newCheckedOntologies = Array.isArray(existingOntologies) - ? checked - ? [...existingOntologies, value] - : existingOntologies.filter(key => key !== value) - : []; + setCheckedOntologies(prevCheckedOntologies => { + // If checked, adds the value to checkedOntologies, otherwise filters out the ones not checked + const updatedCheckedOntologies = checked + ? [...prevCheckedOntologies, value.toUpperCase()] + : prevCheckedOntologies.filter( + ontology => ontology !== value.toUpperCase() + ); + + return updatedCheckedOntologies; + }); + + // Sets apiPreferencesCode to checkedOntologies + setApiPreferencesCode(prevApiPreferences => { + const updatedApiPreferences = { ...prevApiPreferences }; + + // Adds in the api if it doesn't already exist in apiPreferencesCode + if (!updatedApiPreferences[selectedApi]) { + updatedApiPreferences[selectedApi] = []; + } - setApiPreferencesCode(newCheckedOntologies); + // If checkbox is checked, adds the value to apiPreferencesCode for the respective api + if (checked) { + updatedApiPreferences[selectedApi] = [ + ...new Set([ + ...updatedApiPreferences[selectedApi], + value.toUpperCase(), + ]), + ]; + } else { + // If unchecked, removes the value from apiPreferencesCode for the respective api + updatedApiPreferences[selectedApi] = updatedApiPreferences[ + selectedApi + ].filter(ontology => ontology !== value.toUpperCase()); + } - return newCheckedOntologies; + return updatedApiPreferences; }); }; @@ -95,10 +176,16 @@ export const OntologyCheckboxes = ({ preferenceType }) => { return acc; }, {}); - // Build the new data structure - const countsResult = Object.keys(sortedData[0]?.ontologies).map(key => { - return { [key]: countsMap[key] || 0, api: sortedData[0]?.api_id }; - }); + // Builds data structure adding the ontology's api to the ontology object, based on the api + // that is selected in the radio button + const countsResult = sortedData + ?.filter(sd => sd?.api_id === selectedApi) + .map(sd => + Object.keys(sd?.ontologies).map(key => { + return { [key]: countsMap[key] || 0, api: sd?.api_id }; + }) + ) + .flat(); const checkedOntologiesArray = Array.isArray(checkedOntologies) ? checkedOntologies @@ -107,7 +194,7 @@ export const OntologyCheckboxes = ({ preferenceType }) => { const getFilteredItems = searchText => { const filtered = countsResult?.filter(item => { const key = Object.keys(item)[0]; - return key.startsWith(searchText); + return key?.toUpperCase().startsWith(searchText?.toUpperCase()); }); return filtered; }; @@ -120,6 +207,14 @@ export const OntologyCheckboxes = ({ preferenceType }) => { : 'ontology_form_pref' } > + setSelectedApi(e.target.value)} + /> { >
{getFilteredItems(searchText) - ?.sort((a, b) => { - const aKey = Object.keys(a)[0]; // gets the key from the first object in array - const bKey = Object.keys(b)[0]; // gets the key from the second object in array - const aValue = a[aKey]; // gets the value from the first key - const bValue = b[bKey]; // gets the value from the second key - - // checks if one or both keys are in apiPreferencesCode - const aInPreferences = apiPreferencesCode?.includes(aKey); - const bInPreferences = apiPreferencesCode?.includes(bKey); - - // If one of them is in apiPreferencesCode and the other isn't, prioritizes the one in apiPreferencesCode - if (aInPreferences && !bInPreferences) return -1; // if a is in apiPreferencesCode, it comes before b - if (!aInPreferences && bInPreferences) return 1; //if b is in apiPreferencesCode, it comes before a - - // If both are in apiPreferencesCode or both are not, sorts by value - return bValue - aValue; + .sort((a, b) => { + const key1 = Object.keys(a)[0].toUpperCase(); + const key2 = Object.keys(b)[0].toUpperCase(); + + const selectedOnts1 = existingOntologies?.includes(key1); + const selectedOnts2 = existingOntologies?.includes(key2); + + // If both are in existingOntologies, they stay in their relative order + // If only one is in existingOntologies, it is displayed to the top + return selectedOnts2 - selectedOnts1; }) - .map((fc, i) => { - const key = Object.keys(fc)[0]; - const value = fc[key]; + .map((item, i) => { + const key = Object.keys(item)[0]; + const value = item[key]; return ( {`${key.toUpperCase()} ${value !== 0 ? `(${value})` : ''}`} diff --git a/src/components/Manager/MappingsFunctions/OntologyFilterCodeSubmit.jsx b/src/components/Manager/MappingsFunctions/OntologyFilterCodeSubmit.jsx deleted file mode 100644 index e1c0fc5..0000000 --- a/src/components/Manager/MappingsFunctions/OntologyFilterCodeSubmit.jsx +++ /dev/null @@ -1,48 +0,0 @@ -import { notification } from 'antd'; - -export const OntologyFilterCodeSubmit = ( - apiPreferencesCode, - preferenceType, - prefTypeKey, - mappingProp, - vocabUrl, - table -) => { - const apiPreference = { - api_preference: { 'ols': [] }, - }; - - if ( - apiPreferencesCode && - (!preferenceType[prefTypeKey]?.api_preference?.[0] || - JSON.stringify( - Object.values(preferenceType[prefTypeKey]?.api_preference)[0]?.sort() - ) !== JSON.stringify(apiPreferencesCode?.sort())) - ) { - apiPreference.api_preference.ols = apiPreferencesCode; - - fetch(`${vocabUrl}/Table/${table.id}/filter/${mappingProp}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(apiPreference), - }) - .then(res => { - if (res.ok) { - return res.json(); - } else { - throw new Error('An unknown error occurred.'); - } - }) - .catch(error => { - if (error) { - notification.error({ - message: 'Error', - description: 'An error occurred saving the ontology preferences.', - }); - } - return error; - }); - } -}; diff --git a/src/components/Manager/MappingsFunctions/OntologyFilterCodeSubmitTerm.jsx b/src/components/Manager/MappingsFunctions/OntologyFilterCodeSubmitTerm.jsx deleted file mode 100644 index b294c51..0000000 --- a/src/components/Manager/MappingsFunctions/OntologyFilterCodeSubmitTerm.jsx +++ /dev/null @@ -1,47 +0,0 @@ -import { notification } from 'antd'; - -export const OntologyFilterCodeSubmitTerm = ( - apiPreferencesCode, - preferenceType, - prefTypeKey, - mappingProp, - vocabUrl, - terminology -) => { - const apiPreference = { - api_preference: { 'ols': [] }, - }; - - if ( - apiPreferencesCode && - (!preferenceType[prefTypeKey]?.api_preference || - JSON.stringify( - Object.values(preferenceType[prefTypeKey]?.api_preference)[0]?.sort() - ) !== JSON.stringify(apiPreferencesCode?.sort())) - ) { - apiPreference.api_preference.ols = apiPreferencesCode; - - fetch(`${vocabUrl}/Terminology/${terminology.id}/filter/${mappingProp}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(apiPreference), - }) - .then(res => { - if (res.ok) { - return res.json(); - } else { - throw new Error('An unknown error occurred.'); - } - }) - .catch(error => { - if (error) { - notification.error({ - message: 'Error', - description: 'An error occurred saving the ontology preferences.', - }); - } - }); - } -}; diff --git a/src/components/Manager/SearchManager.jsx b/src/components/Manager/SearchManager.jsx deleted file mode 100644 index 2337c1f..0000000 --- a/src/components/Manager/SearchManager.jsx +++ /dev/null @@ -1,23 +0,0 @@ -// Function that performs the search using the OLS API -export const requestSearch = ( - searchUrl, - query, - rowCount, - firstRowdescription -) => { - fetch( - `${searchUrl}q=${query}&ontology=mondo,hp,maxo,ncit&rows=${rowCount}&start=${firstRowdescription}`, - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - } - ).then(res => { - if (res.ok) { - return res.json(); - } else { - throw new Error('An unknown error occurred.'); - } - }); -}; diff --git a/src/components/Manager/Spinner.jsx b/src/components/Manager/Spinner.jsx index e525afe..de6ddcf 100644 --- a/src/components/Manager/Spinner.jsx +++ b/src/components/Manager/Spinner.jsx @@ -51,3 +51,13 @@ export const OntologySpinner = () => {
); }; + +export const ResultsSpinner = () => { + return ( +
+
+ +
+
+ ); +}; diff --git a/src/components/Manager/Spinner.scss b/src/components/Manager/Spinner.scss index a3833b8..faed5a9 100644 --- a/src/components/Manager/Spinner.scss +++ b/src/components/Manager/Spinner.scss @@ -1,7 +1,6 @@ @import '../Styling/Variables'; .loading_screen { - margin-top: $navHeight; display: flex; flex-direction: row; align-items: center; @@ -46,3 +45,20 @@ display: flex; flex-direction: row; } + +.results_spinner_container { + display: flex; + width: 40rem; + height: 15rem; + justify-content: center; + align-items: center; +} + +.results_spinner { + vertical-align: center; + text-align: center; + padding: 1vw; + width: 100px; + background-color: rgb(79, 79, 79, 0.1); + border: 1px solid rgb(79, 79, 79, 0.4); +} diff --git a/src/components/Manager/Utilitiy.jsx b/src/components/Manager/Utility.jsx similarity index 93% rename from src/components/Manager/Utilitiy.jsx rename to src/components/Manager/Utility.jsx index 06dfbbe..d445d77 100644 --- a/src/components/Manager/Utilitiy.jsx +++ b/src/components/Manager/Utility.jsx @@ -11,8 +11,6 @@ export const ellipsisString = (str, num) => { /* The results from the API sometimes show duplicate entries for codes that were imported from other ontologies. We only want to display the codes from their source ontologies, not the imported duplicates. This function ensures the curie in the code id matches the ontology prefix of the object. */ -export const ontologyFilter = d => - d.filter(d => d?.obo_id.split(':')[0] === d?.ontology_prefix); export const ontologyReducer = d => d.reduce( @@ -47,7 +45,7 @@ export const ontologyCounts = arr => { while (i < arr.length) { if (isNaN(arr[i])) { - // If element in array, it not a number (i.e. it's a string), it sets it as the key + // If element in array, is not a number (i.e. it's a string), it sets it as the key const key = arr[i]; const value = arr[i + 1]; // Gets the first number after the string result.push({ [key]: value }); // Pushes the key (string) and value (number) pair to the result array diff --git a/src/components/Nav/NavBar.jsx b/src/components/Nav/NavBar.jsx index 2aa7411..00753f2 100644 --- a/src/components/Nav/NavBar.jsx +++ b/src/components/Nav/NavBar.jsx @@ -1,7 +1,7 @@ import { NavLink, useNavigate } from 'react-router-dom'; import './NavBar.scss'; import { Login } from '../Auth/Login'; -import { useContext, useState } from 'react' +import { useContext, useState } from 'react'; import { myContext } from '../../App'; import { RequiredLogin } from '../Auth/RequiredLogin'; @@ -13,11 +13,11 @@ export const NavBar = () => { navigate(routeTo); }; const login = RequiredLogin({ handleSuccess: handleSuccess }); - const handleLogin = (route) => { + const handleLogin = route => { setRouteTo(route); login(); - } - + }; + return ( <> + ); }; diff --git a/src/components/Projects/DataDictionaries/DDDetails.jsx b/src/components/Projects/DataDictionaries/DDDetails.jsx index ef70ea6..21e0da2 100644 --- a/src/components/Projects/DataDictionaries/DDDetails.jsx +++ b/src/components/Projects/DataDictionaries/DDDetails.jsx @@ -15,7 +15,7 @@ import { } from 'antd'; import './DDStyling.scss'; import { getById } from '../../Manager/FetchManager'; -import { ellipsisString } from '../../Manager/Utilitiy'; +import { ellipsisString } from '../../Manager/Utility'; import { SettingsDropdown } from '../../Manager/Dropdown/SettingsDropdown'; import { EditDDDetails } from './EditDDDetails'; import { UploadTable } from '../Tables/UploadTable'; diff --git a/src/components/Projects/Studies/StudyDetails.jsx b/src/components/Projects/Studies/StudyDetails.jsx index eec70b7..d46f0b4 100644 --- a/src/components/Projects/Studies/StudyDetails.jsx +++ b/src/components/Projects/Studies/StudyDetails.jsx @@ -6,7 +6,7 @@ import './StudyStyling.scss'; import { getById, handleUpdate } from '../../Manager/FetchManager'; import { Row, Col, Divider, Skeleton, Card, Form, notification } from 'antd'; -import { ellipsisString } from '../../Manager/Utilitiy'; +import { ellipsisString } from '../../Manager/Utility'; import { SettingsDropdownStudy } from '../../Manager/Dropdown/SettingsDropdownStudy'; import { EditStudyDetails } from './EditStudyDetails'; import { DeleteStudy } from './DeleteStudy'; diff --git a/src/components/Projects/Studies/StudyList.jsx b/src/components/Projects/Studies/StudyList.jsx index ffd7fdf..078f528 100644 --- a/src/components/Projects/Studies/StudyList.jsx +++ b/src/components/Projects/Studies/StudyList.jsx @@ -8,7 +8,7 @@ import { Spinner } from '../../Manager/Spinner'; import { getAll } from '../../Manager/FetchManager'; import { Row, Col, Card, notification, Skeleton } from 'antd'; import { AddStudy } from './AddStudy'; -import { ellipsisString } from '../../Manager/Utilitiy'; +import { ellipsisString } from '../../Manager/Utility'; import { RequiredLogin } from '../../Auth/RequiredLogin'; const { Meta } = Card; @@ -32,7 +32,7 @@ export const StudyList = () => { if (error) { notification.error({ message: 'Error', - description: 'An error occurred. Please try again.', + description: 'An error occurred loading studies.', }); } return error; diff --git a/src/components/Projects/Tables/AddVariable.jsx b/src/components/Projects/Tables/AddVariable.jsx index 8b22e62..54cf7db 100644 --- a/src/components/Projects/Tables/AddVariable.jsx +++ b/src/components/Projects/Tables/AddVariable.jsx @@ -89,6 +89,8 @@ export const AddVariable = ({ table, setTable }) => { }} maskClosable={false} closeIcon={false} + cancelButtonProps={{ disabled: loading }} + okButtonProps={{ disabled: loading }} > {loading ? ( diff --git a/src/components/Projects/Tables/DataTypeNumerical.jsx b/src/components/Projects/Tables/DataTypeNumerical.jsx index 4836348..1ea30e4 100644 --- a/src/components/Projects/Tables/DataTypeNumerical.jsx +++ b/src/components/Projects/Tables/DataTypeNumerical.jsx @@ -1,8 +1,20 @@ -import { Form, Input, InputNumber, Space } from 'antd'; +import { Form, Input, InputNumber, Select, Space } from 'antd'; +import { getById } from '../../Manager/FetchManager'; +import { useContext, useEffect, useState } from 'react'; +import { myContext } from '../../../App'; export const DataTypeNumerical = ({ form, type }) => { - // Validation function to ensure values are numbers and min is less than max + const { ucumCodes } = useContext(myContext); + + const options = ucumCodes.map((uc, i) => { + return { + key: i, + value: `ucum:${uc.code}`, + label: uc.display, + }; + }); + // Validation function to ensure values are numbers and min is less than max const validateMinMax = () => { const min = parseFloat(form.getFieldValue('min')); const max = parseFloat(form.getFieldValue('max')); @@ -75,11 +87,24 @@ export const DataTypeNumerical = ({ form, type }) => { /> - { + const labelMatch = (option?.label ?? '') + .toLowerCase() + .includes(input.toLowerCase()); + const valueMatch = (option?.value ?? '') + .toLowerCase() + .includes(input.toLowerCase()); + return valueMatch || labelMatch; }} - placeholder="Units" + options={options} /> diff --git a/src/components/Projects/Tables/EditDataTypeNumerical.jsx b/src/components/Projects/Tables/EditDataTypeNumerical.jsx index 1c88578..c619f6a 100644 --- a/src/components/Projects/Tables/EditDataTypeNumerical.jsx +++ b/src/components/Projects/Tables/EditDataTypeNumerical.jsx @@ -1,6 +1,18 @@ -import { Form, Input, InputNumber, Space } from 'antd'; +import { Form, InputNumber, Select, Space } from 'antd'; +import { useContext } from 'react'; +import { myContext } from '../../../App'; + +export const EditDataTypeNumerical = ({ type, form, tableData }) => { + const { ucumCodes } = useContext(myContext); + + const options = ucumCodes.map((uc, i) => { + return { + key: i, + value: `ucum:${uc.code}`, + label: uc.display, + }; + }); -export const EditDataTypeNumerical = ({ type, form }) => { // Validation function to ensure values are numbers and min is less than max const validateMinMax = () => { const min = parseFloat(form.getFieldValue('min')); @@ -82,11 +94,25 @@ export const EditDataTypeNumerical = ({ type, form }) => { /> - { + const labelMatch = (option?.label ?? '') + .toLowerCase() + .includes(input.toLowerCase()); + const valueMatch = (option?.value ?? '') + .toLowerCase() + .includes(input.toLowerCase()); + return labelMatch || valueMatch; }} - placeholder="Units" + options={options} /> diff --git a/src/components/Projects/Tables/EditDataTypeSubForm.jsx b/src/components/Projects/Tables/EditDataTypeSubForm.jsx index 3b61b49..d07b141 100644 --- a/src/components/Projects/Tables/EditDataTypeSubForm.jsx +++ b/src/components/Projects/Tables/EditDataTypeSubForm.jsx @@ -41,7 +41,7 @@ function EditDataTypeSubForm({ type, form, editRow, tableData }) { return ( <> {type === 'INTEGER' || type === 'QUANTITY' ? ( - + ) : ( type === 'ENUMERATION' && (!terminologyLoading ? ( diff --git a/src/components/Projects/Tables/EditMappingsTableModal.jsx b/src/components/Projects/Tables/EditMappingsTableModal.jsx index aac6938..8c209e6 100644 --- a/src/components/Projects/Tables/EditMappingsTableModal.jsx +++ b/src/components/Projects/Tables/EditMappingsTableModal.jsx @@ -5,10 +5,9 @@ import { ModalSpinner } from '../../Manager/Spinner'; import { MappingContext } from '../../../Contexts/MappingContext'; import { MappingSearch } from '../../Manager/MappingsFunctions/MappingSearch'; import { ResetTableMappings } from './ResetTableMappings'; -import { systemsMatch } from '../../Manager/Utilitiy'; -import { getById } from '../../Manager/FetchManager'; +import { systemsMatch } from '../../Manager/Utility'; +import { getById, ontologyFilterCodeSubmit } from '../../Manager/FetchManager'; import { SearchContext } from '../../../Contexts/SearchContext'; -import { OntologyFilterCodeSubmit } from '../../Manager/MappingsFunctions/OntologyFilterCodeSubmit'; import { EditMappingsLabel } from '../../Manager/MappingsFunctions/EditMappingsLabel'; export const EditMappingsTableModal = ({ @@ -26,11 +25,15 @@ export const EditMappingsTableModal = ({ const [reset, setReset] = useState(false); const [mappingsForSearch, setMappingsForSearch] = useState([]); const [editSearch, setEditSearch] = useState(false); + const [loadingResults, setLoadingResults] = useState(false); + const { setSelectedMappings, setDisplaySelectedMappings, setShowOptions, idsForSelect, + existingMappings, + selectedBoxes, } = useContext(MappingContext); const { apiPreferencesCode, @@ -38,6 +41,7 @@ export const EditMappingsTableModal = ({ preferenceType, prefTypeKey, ontologyApis, + setSelectedApi, } = useContext(SearchContext); useEffect(() => { @@ -53,6 +57,7 @@ export const EditMappingsTableModal = ({ setSelectedKey(null); setApiPreferencesCode(undefined); setShowOptions(false); + setSelectedApi(undefined); }; const fetchMappings = () => { @@ -105,7 +110,6 @@ export const EditMappingsTableModal = ({ options.push({ value: val, label: , - // label: editMappingsLabel(m, index), }); }); // termMappings are set to the mappings array. Options are set to the options array. @@ -181,27 +185,25 @@ export const EditMappingsTableModal = ({ // The existing and new mappings are JSON.parsed and combined into one mappings array to be passed into the body of the PUT call. const editUpdatedMappings = values => { setLoading(true); - const selectedMappings = values?.selected_mappings?.map(item => ({ + + const selectedMappings = selectedBoxes?.map(item => ({ code: item.code, display: item.display, description: item.description, - system: - item.system || systemsMatch(item.obo_id.split(':')[0], ontologyApis), + system: systemsMatch(item.code.split(':')[0], ontologyApis), mapping_relationship: idsForSelect[item.code], })); - const mappingsDTO = { - mappings: [ - ...(values.existing_mappings?.map(v => { - const parsedMapping = JSON.parse(v); - if (idsForSelect[parsedMapping.code]) { - parsedMapping.mapping_relationship = - idsForSelect[parsedMapping.code]; - } - return parsedMapping; - }) ?? []), - ...(selectedMappings ?? []), - ], + const preexistingMappings = existingMappings?.map(item => ({ + code: item.code, + display: item.display, + description: item.description, + system: systemsMatch(item?.code?.split(':')[0], ontologyApis), + mapping_relationship: idsForSelect[item.code], + })); + + const mappingsDTO = { + mappings: [...(preexistingMappings ?? []), ...(selectedMappings ?? [])], editor: user.email, }; @@ -235,13 +237,14 @@ export const EditMappingsTableModal = ({ return error; }) .finally(() => setLoading(false)); - OntologyFilterCodeSubmit( + ontologyFilterCodeSubmit( apiPreferencesCode, preferenceType, prefTypeKey, mappingProp, vocabUrl, - table + table, + null ); }; @@ -348,6 +351,10 @@ export const EditMappingsTableModal = ({ component={table} mappingProp={editMappings?.code} table={table} + preferenceType={preferenceType} + prefTypeKey={prefTypeKey} + loadingResults={loadingResults} + setLoadingResults={setLoadingResults} /> )} diff --git a/src/components/Projects/Tables/TableDetails.jsx b/src/components/Projects/Tables/TableDetails.jsx index fdaf8ec..6370b4e 100644 --- a/src/components/Projects/Tables/TableDetails.jsx +++ b/src/components/Projects/Tables/TableDetails.jsx @@ -31,13 +31,21 @@ import { SettingsDropdownTable } from '../../Manager/Dropdown/SettingsDropdownTa import { RequiredLogin } from '../../Auth/RequiredLogin'; import { FilterSelect } from '../../Manager/MappingsFunctions/FilterSelect'; import { SearchContext } from '../../../Contexts/SearchContext'; -import { ellipsisString, mappingTooltip } from '../../Manager/Utilitiy'; +import { ellipsisString, mappingTooltip } from '../../Manager/Utility'; export const TableDetails = () => { const [form] = Form.useForm(); - const { vocabUrl, edit, setEdit, table, setTable, user } = - useContext(myContext); + const { + vocabUrl, + edit, + setEdit, + table, + setTable, + user, + ucumCodes, + setUcumCodes, + } = useContext(myContext); const { apiPreferences, setApiPreferences } = useContext(SearchContext); const { getMappings, @@ -75,7 +83,6 @@ export const TableDetails = () => { }, [table, mapping, pageSize]); const updateMappings = (mapArr, mappingCode) => { - // setLoading(true); const mappingsDTO = { mappings: mapArr, editor: user.email, @@ -111,74 +118,88 @@ export const TableDetails = () => { }); } return error; - }) - .finally(() => setLoading(false)); + }); }; // fetches the table and sets 'table' to the response useEffect(() => { + tableApiCalls(); + }, []); + + const tableApiCalls = async () => { setLoading(true); getById(vocabUrl, 'Table', tableId) .then(data => { - if (data === null) { - navigate('/404'); - } else { - setTable(data); - if (data) { - getById(vocabUrl, 'Table', `${tableId}/mapping`) - .then(data => setMapping(data.codes)) - .catch(error => { - if (error) { - console.log(error, 'error'); - - notification.error({ - message: 'Error', - description: 'An error occurred loading mappings.', - }); - } - return error; - }) - .then(() => - getById( - vocabUrl, - 'Terminology', - 'ftd-concept-map-relationship' - ).then(data => setRelationshipOptions(data.codes)) - ) - .then(() => - fetch(`${vocabUrl}/Table/${tableId}/filter/self`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }) - ) - .then(res => { - if (res.ok) { - return res.json(); - } else { - throw new Error('An unknown error occurred.'); - } + setTable(data); + if (data) { + getById(vocabUrl, 'Table', `${tableId}/mapping`) + .then(data => setMapping(data.codes)) + .catch(error => { + if (error) { + console.log(error, 'error'); + + notification.error({ + message: 'Error', + description: 'An error occurred loading mappings.', + }); + } + return error; + }) + .then(() => + getById( + vocabUrl, + 'Terminology', + 'ftd-concept-map-relationship' + ).then(data => setRelationshipOptions(data.codes)) + ) + .then(() => + fetch(`${vocabUrl}/Table/${tableId}/filter/self`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, }) - .then(data => { - setApiPreferences(data); - }); - } else { - setLoading(false); - } + ) + .then(res => { + if (res.ok) { + return res.json(); + } else { + throw new Error('An unknown error occurred.'); + } + }) + .then(data => { + setApiPreferences(data); + }) + .finally(() => setLoading(false)); + } else { + setLoading(false); } }) .catch(error => { if (error) { notification.error({ message: 'Error', - description: 'An error occurred loading the ontology preferences.', + description: 'An error occurred loading the table.', }); + setLoading(false); } return error; }) .finally(() => setLoading(false)); - }, []); + }; + + useEffect(() => { + table && tableTypes(); + }, [table]); + + const tableTypes = () => { + const varTypes = table?.variables?.map(tv => tv.data_type); + if (varTypes?.includes('INTEGER' || 'QUANTITY')) { + getById(vocabUrl, 'Terminology', 'ucum-common').then(data => + setUcumCodes(data.codes) + ); + } + }; // sets table to an empty object on dismount useEffect( @@ -260,8 +281,8 @@ It then shows the mappings as table data and alows the user to delete a mapping item => item?.code === variable?.code ); if (variableMappings && variableMappings.mappings?.length) { - return variableMappings.mappings.map(code => ( -
+ return variableMappings.mappings.map((code, i) => ( +
{ + const unit = variable?.units?.split(':')[1]; + const foundType = ucumCodes.filter(item => item.code === unit); + return foundType.length > 0 ? foundType[0].display : variable?.units; + }; // data for the table columns. Each table has an array of variables. Each variable has a name, description, and data type. // The integer and quantity data types include additional details. // The enumeration data type includes a reference to a terminology, which includes further codes with the capability to match the @@ -311,7 +337,7 @@ It then shows the mappings as table data and alows the user to delete a mapping data_type: variable.data_type, min: variable.min, max: variable.max, - units: variable.units, + units: findType(variable), enumeration: variable.data_type === 'ENUMERATION' && ( ); }; + +// const tableApiCalls = async () => { +// const tableData = await getById(vocabUrl, 'Table', tableId); + +// if (!tableData) { +// navigate('/404'); +// } else { +// setTable(tableData); + +// const [tableMappings, tableFilters, mappingRelationships] = +// await Promise.all([ +// getById(vocabUrl, 'Table', `${tableId}/mapping`), +// fetch(`${vocabUrl}/Table/${tableId}/filter/self`, { +// method: 'GET', +// headers: { +// 'Content-Type': 'application/json', +// }, +// }), +// getById(vocabUrl, 'Terminology', 'ftd-concept-map-relationship'), +// ]); + +// if (tableMappings) { +// setMapping(tableMappings.codes); +// } + +// if (tableFilters.ok) { +// setApiPreferences(tableFilters.json()); +// } else { +// throw new Error('An unknown error occurred.'); +// } +// if (mappingRelationships) { +// setRelationshipOptions(mappingRelationships.codes); +// } +// } +// setLoading(false); +// }; diff --git a/src/components/Projects/Terminologies/APIResults.jsx b/src/components/Projects/Terminologies/APIResults.jsx index eea526d..97a70ea 100644 --- a/src/components/Projects/Terminologies/APIResults.jsx +++ b/src/components/Projects/Terminologies/APIResults.jsx @@ -74,16 +74,16 @@ export const APIResults = ({
- {d?.description[0]?.length > 85 ? ( + {d?.description?.map(d => d).join(',')?.length > 85 ? ( - {ellipsisString(d?.description[0], '85')} + {ellipsisString(d?.description?.map(d => d).join(','), '85')} ) : ( - ellipsisString(d?.description[0], '85') + ellipsisString(d?.description?.map(d => d).join(','), '85') )}
@@ -114,7 +114,7 @@ export const APIResults = ({ value: JSON.stringify({ code: d.obo_id, display: d.label, - description: d.description[0], + description: d.description?.map(d => d).join(','), system: systemsMatch( d?.obo_id.split(':')[0], ontologyApis diff --git a/src/components/Projects/Terminologies/APISearchBar.jsx b/src/components/Projects/Terminologies/APISearchBar.jsx deleted file mode 100644 index 05441dc..0000000 --- a/src/components/Projects/Terminologies/APISearchBar.jsx +++ /dev/null @@ -1,106 +0,0 @@ -import { Input } from 'antd'; -import { useContext, useEffect, useState } from 'react'; -import { myContext } from '../../../App'; -import { ontologyReducer } from '../../Manager/Utilitiy'; -import { SearchContext } from '../../../Contexts/SearchContext'; - -export const APISearchBar = ({ - active, - setActive, - searchProp, - selectedBoxes, - setLoading, -}) => { - const { Search } = Input; - const { searchUrl } = useContext(myContext); - const { - apiResults, - setApiResults, - setApiResultsCount, - apiPage, - setApiPage, - setApiTotalCount, - } = useContext(SearchContext); - const [inputValue, setInputValue] = useState(searchProp); - const entriesPerPage = 1000; - const [currentSearchProp, setCurrentSearchProp] = useState(searchProp); - const [filteredResultsCount, setFilteredResultsCount] = useState(0); - - const handleSearch = query => { - setCurrentSearchProp(query); - setApiPage(0); - }; - - const handleChange = e => { - setInputValue(e.target.value); - }; - - useEffect(() => { - if (!!currentSearchProp) { - active === 'search' && fetchResults(apiPage, currentSearchProp); - } - }, [active, apiPage, currentSearchProp]); - - const fetchResults = (page, query) => { - if (!!!query) { - return undefined; - } - setLoading(true); - /* The OLS API returns 10 results by default unless specified otherwise. The fetch call includes a specified - number of results to return per page (entriesPerPage) and a calculation of the first index to start the results - on each new batch of results (pageStart, calculated as the number of the page * the number of entries per page */ - const pageStart = apiPage * entriesPerPage; - return fetch( - `${searchUrl}q=${query}&ontology=mondo,hp,maxo,ncit&rows=${entriesPerPage}&start=${pageStart}`, - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - } - ) - .then(res => res.json()) - .then(data => { - // filters results through the ontologyReducer function (defined in Manager/Utility.jsx) - - let res = ontologyReducer(data?.response?.docs); - // if the page > 0 (i.e. if this is not the first batch of results), the new results - // are concatenated to the old - if (selectedBoxes) { - res.results = res.results.filter( - d => !selectedBoxes.some(box => box.obo_id === d.obo_id) - ); - } - - if (apiPage > 0 && apiResults.length > 0) { - res.results = apiResults.concat(res.results); - - // Apply filtering to remove results with obo_id in selectedBoxes - } else { - // Set the total number of search results for pagination - setApiTotalCount(data.response.numFound); - } - - //the results are set to res (the filtered, concatenated results) - - setApiResults(res.results); - setFilteredResultsCount(res?.filteredResults?.length); - // resultsCount is set to the length of the filtered, concatenated results for pagination - setApiResultsCount(res.results.length); - }) - .then(() => setLoading(false)); - }; - - return ( -
setActive('search')} - className={active === 'search' ? 'active_term' : 'inactive_term'} - > - -
- ); -}; diff --git a/src/components/Projects/Terminologies/AssignMappingsViaButton.jsx b/src/components/Projects/Terminologies/AssignMappingsViaButton.jsx index 4b539b7..875dbb9 100644 --- a/src/components/Projects/Terminologies/AssignMappingsViaButton.jsx +++ b/src/components/Projects/Terminologies/AssignMappingsViaButton.jsx @@ -5,6 +5,7 @@ import { myContext } from '../../../App'; import { SearchContext } from '../../../Contexts/SearchContext'; import { MappingContext } from '../../../Contexts/MappingContext'; import { ModalSpinner } from '../../Manager/Spinner'; +import { ontologyFilterCodeSubmit } from '../../Manager/FetchManager'; export const AssignMappingsViaButton = ({ assignMappingsViaButton, @@ -14,7 +15,13 @@ export const AssignMappingsViaButton = ({ const [form] = Form.useForm(); const { vocabUrl, user } = useContext(myContext); - const { prefTerminologies, setApiResults } = useContext(SearchContext); + const { + prefTerminologies, + setApiResults, + preferenceType, + prefTypeKey, + apiPreferencesCode, + } = useContext(SearchContext); const { setMapping, idsForSelect, setIdsForSelect } = useContext(MappingContext); const [terminologiesToMap, setTerminologiesToMap] = useState([]); @@ -57,10 +64,10 @@ export const AssignMappingsViaButton = ({ code: item.code, display: item.display, description: Array.isArray(item.description) - ? item.description[0] + ? item.description?.map(d => d).join(',') : item.description, system: item.system, - mapping_relationship: idsForSelect[item.obo_id || item.code], + mapping_relationship: idsForSelect[item.code], })); const mappingsDTO = { mappings: selectedMappings, @@ -91,6 +98,15 @@ export const AssignMappingsViaButton = ({ message.success('Changes saved successfully.'); }) .finally(() => setLoading(false)); + ontologyFilterCodeSubmit( + apiPreferencesCode, + preferenceType, + prefTypeKey, + assignMappingsViaButton?.code, + vocabUrl, + null, + terminology + ); }; return ( @@ -127,13 +143,15 @@ export const AssignMappingsViaButton = ({ )} diff --git a/src/components/Projects/Terminologies/EditMappingModal.jsx b/src/components/Projects/Terminologies/EditMappingModal.jsx index 92b2259..a449c17 100644 --- a/src/components/Projects/Terminologies/EditMappingModal.jsx +++ b/src/components/Projects/Terminologies/EditMappingModal.jsx @@ -12,10 +12,9 @@ import { myContext } from '../../../App'; import { ModalSpinner } from '../../Manager/Spinner'; import { MappingSearch } from '../../Manager/MappingsFunctions/MappingSearch'; import { ResetMappings } from './ResetMappings'; -import { systemsMatch } from '../../Manager/Utilitiy'; -import { getById } from '../../Manager/FetchManager'; +import { systemsMatch } from '../../Manager/Utility'; +import { getById, ontologyFilterCodeSubmit } from '../../Manager/FetchManager'; import { SearchContext } from '../../../Contexts/SearchContext'; -import { OntologyFilterCodeSubmitTerm } from '../../Manager/MappingsFunctions/OntologyFilterCodeSubmitTerm'; import { MappingRelationship } from '../../Manager/MappingsFunctions/MappingRelationship'; import { MappingContext } from '../../../Contexts/MappingContext'; import { EditMappingsLabel } from '../../Manager/MappingsFunctions/EditMappingsLabel'; @@ -32,7 +31,8 @@ export const EditMappingsModal = ({ const [termMappings, setTermMappings] = useState([]); const [options, setOptions] = useState([]); const { vocabUrl, setSelectedKey, user } = useContext(myContext); - const { setShowOptions, idsForSelect } = useContext(MappingContext); + const { setShowOptions, idsForSelect, selectedBoxes, existingMappings } = + useContext(MappingContext); const { apiPreferencesCode, setApiPreferencesCode, @@ -41,6 +41,7 @@ export const EditMappingsModal = ({ ontologyApis, } = useContext(SearchContext); const [loading, setLoading] = useState(false); + const [loadingResults, setLoadingResults] = useState(false); const [reset, setReset] = useState(false); const [mappingsForSearch, setMappingsForSearch] = useState([]); const [editSearch, setEditSearch] = useState(false); @@ -183,27 +184,24 @@ export const EditMappingsModal = ({ // The existing and new mappings are JSON.parsed combined into one mappings array to be passed into the body of the PUT call. const editUpdatedMappings = values => { setLoading(true); - const selectedMappings = values?.selected_mappings?.map(item => ({ + const selectedMappings = selectedBoxes?.map(item => ({ code: item.code, display: item.display, description: item.description, - system: - item.system || systemsMatch(item.obo_id.split(':')[0], ontologyApis), + system: systemsMatch(item.code.split(':')[0], ontologyApis), mapping_relationship: idsForSelect[item.code], })); - const mappingsDTO = { - mappings: [ - ...(values.existing_mappings?.map(v => { - const parsedMapping = JSON.parse(v); - if (idsForSelect[parsedMapping.code]) { - parsedMapping.mapping_relationship = - idsForSelect[parsedMapping.code]; - } - return parsedMapping; - }) ?? []), - ...(selectedMappings ?? []), - ], + const preexistingMappings = existingMappings?.map(item => ({ + code: item.code, + display: item.display, + description: item.description, + system: systemsMatch(item?.code?.split(':')[0], ontologyApis), + mapping_relationship: idsForSelect[item.code], + })); + + const mappingsDTO = { + mappings: [...(preexistingMappings ?? []), ...(selectedMappings ?? [])], editor: user.email, }; @@ -240,12 +238,13 @@ export const EditMappingsModal = ({ return error; }) .finally(() => setLoading(false)); - OntologyFilterCodeSubmitTerm( + ontologyFilterCodeSubmit( apiPreferencesCode, preferenceType, prefTypeKey, editMappings.code, vocabUrl, + null, terminology ); }; @@ -359,6 +358,10 @@ export const EditMappingsModal = ({ mappingProp={editMappings?.code} mappingDesc={editMappings?.description ?? 'No description'} terminology={terminology} + preferenceType={preferenceType} + prefTypeKey={prefTypeKey} + loadingResults={loadingResults} + setLoadingResults={setLoadingResults} /> )} diff --git a/src/components/Projects/Terminologies/SelectPreferredTerminologies.jsx b/src/components/Projects/Terminologies/SelectPreferredTerminologies.jsx index 1062e83..51cfa6e 100644 --- a/src/components/Projects/Terminologies/SelectPreferredTerminologies.jsx +++ b/src/components/Projects/Terminologies/SelectPreferredTerminologies.jsx @@ -1,6 +1,6 @@ import { Checkbox, Form, Input, Tooltip } from 'antd'; import { Link } from 'react-router-dom'; -import { ellipsisString } from '../../Manager/Utilitiy'; +import { ellipsisString } from '../../Manager/Utility'; import { useContext, useEffect, useState } from 'react'; import { myContext } from '../../../App'; import { SearchContext } from '../../../Contexts/SearchContext'; @@ -158,8 +158,8 @@ export const SelectPreferredTerminologies = ({
{preferredData?.length > 0 && ( <> -

Preferred

0 && ( <> -

Selected

- { const navigate = useNavigate(); const updateMappings = (mapArr, mappingCode) => { - // setLoading(true); const mappingsDTO = { mappings: mapArr, - editor: user.email, + editor: user?.email, }; fetch( @@ -128,8 +127,7 @@ export const Terminology = () => { }); } return error; - }) - .finally(() => setLoading(false)); + }); }; /* The terminology may have numerous codes. The API call to fetch the mappings returns all mappings for the terminology. @@ -180,11 +178,11 @@ It then shows the mappings as table data and alows the user to delete a mapping ); if (variableMappings && variableMappings.mappings?.length) { - return variableMappings.mappings.map(code => ( -
+ return variableMappings.mappings.map((code, i) => ( +
{ + termApiCalls(); + }, []); + + const termApiCalls = async () => { setLoading(true); - getById(vocabUrl, 'Terminology', terminologyId, navigate) - .then(data => { - if (data === null) { - navigate('/404'); - } else { - setTerminology(data); - if (data) { - fetch(`${vocabUrl}/Terminology/${data?.id}/filter`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }) - .then(res => { - if (res.ok) { - return res.json(); - } else { - throw new Error('An unknown error occurred.'); - } - }) - .then(data => { - setApiPreferencesTerm(data); - }); - getById( - vocabUrl, - 'Terminology', - `${terminologyId}/mapping?user_input=True&user=${user?.email}` - ) - .then(data => setMapping(data.codes)) - .catch(error => { - if (error) { - notification.error({ - message: 'Error', - description: 'An error occurred loading mappings.', - }); - } - return error; - }) - .then(() => - getById( - vocabUrl, - 'Terminology', - 'ftd-concept-map-relationship' - ).then(data => setRelationshipOptions(data.codes)) - ) - .then(() => - getById( - vocabUrl, - 'Terminology', - `${terminologyId}/preferred_terminology` - ) - .then(data => setPrefTerminologies(data?.references)) - .catch(error => { - if (error) { - notification.error({ - message: 'Error', - description: - 'An error occurred loading preferred terminologies.', - }); - } - return error; - }) - ); - } else { - setLoading(false); + try { + const terminologyData = await getById( + vocabUrl, + 'Terminology', + terminologyId, + navigate + ); + setTerminology(terminologyData); + + if (terminologyData) { + const filterResponse = await fetch( + `${vocabUrl}/Terminology/${terminologyData?.id}/filter`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, } + ); + + if (!filterResponse.ok) { + throw new Error('An unknown error occurred.'); } - }) - .catch(error => { - if (error) { - notification.error({ - message: 'Error', - description: - 'An error occurred loading the the ontology preferences.', - }); - } - return error; - }) - .finally(() => setLoading(false)); - }, []); + const filterData = await filterResponse.json(); + setApiPreferencesTerm(filterData); + + const mappingsData = await getById( + vocabUrl, + 'Terminology', + `${terminologyId}/mapping?user_input=True&user=${user?.email}` + ); + setMapping(mappingsData.codes); + + const relationshipData = await getById( + vocabUrl, + 'Terminology', + 'ftd-concept-map-relationship' + ); + setRelationshipOptions(relationshipData.codes); + + const prefTerminologyData = await getById( + vocabUrl, + 'Terminology', + `${terminologyId}/preferred_terminology` + ); + setPrefTerminologies(prefTerminologyData?.references); + } + } catch (error) { + notification.error({ + message: 'Error', + description: error.message || 'An error occurred loading data.', + }); + } finally { + setLoading(false); + } + }; // columns for the ant.design table const columns = [ diff --git a/src/components/Projects/Terminologies/Terminology.scss b/src/components/Projects/Terminologies/Terminology.scss index 353bd4f..eda8099 100644 --- a/src/components/Projects/Terminologies/Terminology.scss +++ b/src/components/Projects/Terminologies/Terminology.scss @@ -250,3 +250,7 @@ a.terminology_link:hover { visibility: visible; } } + +.pref_group { + border-bottom: solid 1px #eeee; +} diff --git a/src/components/Projects/Terminologies/TerminologyList.jsx b/src/components/Projects/Terminologies/TerminologyList.jsx index 743d806..8b1309e 100644 --- a/src/components/Projects/Terminologies/TerminologyList.jsx +++ b/src/components/Projects/Terminologies/TerminologyList.jsx @@ -1,4 +1,4 @@ -import { Button, Input, Space, Table } from 'antd'; +import { Button, Input, notification, Space, Table } from 'antd'; import { SearchOutlined } from '@ant-design/icons'; import { useContext, useEffect, useRef, useState } from 'react'; import { myContext } from '../../../App'; @@ -33,6 +33,15 @@ export const TerminologyList = () => { .then(data => { setTerms(data); }) + .catch(error => { + if (error) { + notification.error({ + message: 'Error', + description: 'An error occurred loading terminologies.', + }); + } + return error; + }) .finally(() => setLoading(false)); localStorage.setItem('pageSize', pageSize); }, [pageSize]); diff --git a/src/components/Search/SearchResults.jsx b/src/components/Search/SearchResults.jsx index 1c03825..c906914 100644 --- a/src/components/Search/SearchResults.jsx +++ b/src/components/Search/SearchResults.jsx @@ -1,58 +1,58 @@ import { useEffect, useRef, useState, useContext } from 'react'; -import { Pagination, notification } from 'antd'; +import { notification } from 'antd'; import { myContext } from '../../App'; import { useNavigate, useParams } from 'react-router-dom'; import './SearchResults.scss'; import { SearchSpinner } from '../Manager/Spinner'; +import { SearchContext } from '../../Contexts/SearchContext'; export const SearchResults = () => { const [buttonDisabled, setButtonDisabled] = useState(true); - const { results, setResults, searchUrl } = useContext(myContext); + const { + defaultOntologies, + entriesPerPage, + moreAvailable, + setMoreAvailable, + resultsCount, + setResultsCount, + } = useContext(SearchContext); + const { results, setResults, vocabUrl } = useContext(myContext); + + const [page, setPage] = useState(0); //page number for search results pagination + const [loading, setLoading] = useState(true); + const [lastCount, setLastCount] = useState(0); //save last count as count of the results before you fetch data again - const [page, setPage] = useState(1); //page number for search results pagination /* useParams() gets the search term param from the address bar, which was placed there from the input field in OntologySearch.jsx */ - const [rows, setRows] = useState(20); //number of rows displayed in each page of search results - const [current, setCurrent] = useState(1); //the page of search results currently displayed - const [loading, setLoading] = useState(true); const { query } = useParams(); const navigate = useNavigate(); const ref = useRef(); + const pageref = useRef(); + const pageStart = page * entriesPerPage; useEffect(() => { document.title = 'Map Dragon'; }, []); - // sets the page and current page to the page number of the paginator - const onChange = page => { - setCurrent(page); - setPage(page); - }; - - /* updates the number of rows on the result page and the current page displayed - if user changes the number of results displayed per page */ - const onShowSizeChange = (current, rows) => { - setCurrent(current); - setRows(rows); - }; // calls the search function when there is a change in the rows, page, or query useEffect(() => { - descriptionResults(rows, page); - }, [rows, page, query]); + requestSearch(); + }, [page, query]); - // defines parameters for the search function. rows = the number of results returned in the search - // the OLS API specifies index number of the search result to start the return for each page of results - // index begins at 0. (page - 1) * rows calculates the index number of the first result to be displayed on each page - const descriptionResults = (rows, page) => { - return requestSearch(rows, (page - 1) * rows); - }; + useEffect(() => { + if (results?.length > 0 && page > 0 && pageref.current) { + const container = pageref.current.closest('.search_result'); + const scrollTop = pageref.current.offsetTop - container.offsetTop; + container.scrollTop = scrollTop; + } + }, [results]); // API request to OLS ontology search with the rows and index of the first search per page as props. // The response is set to the 'results'. Loading is set to false. - const requestSearch = (rowCount, firstRowStart) => { + const requestSearch = () => { setLoading(true); fetch( - `${searchUrl}q=${query}&ontology=mondo,hp,maxo,ncit&rows=${rowCount}&start=${firstRowStart}`, + `${vocabUrl}/ontology_search?keyword=${query}&selected_ontologies=${defaultOntologies}&selected_api=ols&results_per_page=${entriesPerPage}&start_index=${pageStart}`, { method: 'GET', headers: { @@ -70,7 +70,16 @@ export const SearchResults = () => { }); } }) - .then(data => setResults(data.response)) + .then(data => { + if (page > 0 && results?.length > 0) { + data.results = results?.concat(data.results); + } + + setResults(data.results); + setMoreAvailable(data.more_results_available); + + setResultsCount(data?.results?.length); + }) .finally(() => setLoading(false)); }; @@ -79,10 +88,16 @@ The user is then redirected to the search page, which completes the search for t const searchOnEnter = e => { if (e.key === 'Enter') { if (ref.current.value) { - setPage(1), setCurrent(1), navigate(`/search/${ref.current.value}`); + setPage(0), navigate(`/search/${ref.current.value}`); } } }; + + const handleViewMore = e => { + e.preventDefault(); + setPage(prevPage => prevPage + 1); + }; + return ( <>
@@ -115,9 +130,7 @@ The user is then redirected to the search page, which completes the search for t the value is transposed into the address bar */ if (ref.current.value) { - setPage(1), - setCurrent(1), - navigate(`/search/${ref.current.value}`); + setPage(1), navigate(`/search/${ref.current.value}`); } }} > @@ -139,19 +152,24 @@ The user is then redirected to the search page, which completes the search for t
{/* if the length of the results array is greater than 0 (i.e. there is a return with results for the search term), the results are displayed. */} - {results?.docs?.length > 0 ? ( - results?.docs.map((d, index) => { + {results?.length > 0 ? ( + results?.map((d, index) => { return ( <> -
+
- {d.label} + {d.display}
@@ -176,22 +194,16 @@ The user is then redirected to the search page, which completes the search for t )} {/* if loading has completed and search results were found, the paginator is displayed */} - {loading === false && results?.numFound > 0 ? ( -
- `${range[0]}-${range[1]} of ${total} items` //displays the range of results out of the total number of results - } - /> -
- ) : ( - '' + {moreAvailable && ( + { + handleViewMore(e); + setLastCount(resultsCount); + }} + > + View More + )}
diff --git a/src/components/Search/SearchResults.scss b/src/components/Search/SearchResults.scss index 2fb7cb8..f828925 100644 --- a/src/components/Search/SearchResults.scss +++ b/src/components/Search/SearchResults.scss @@ -3,7 +3,7 @@ .results_page_container { background: white; - margin: 0 30vw 0 5vw; + margin: 10px 30vw 0 5vw; padding: 1rem; } @@ -26,14 +26,17 @@ } .search_field_container { - position: sticky; + position: fixed; top: 1rem; padding: 10px 0 10px 0; padding-top: 10rem; background: white; + width: 63vw; + margin-top: 10px; } .search_results { + margin-top: 90px; background-color: none; z-index: 1; } @@ -42,12 +45,12 @@ div.search_result:nth-child(2) { padding-top: 2rem; } - .search_results_header { padding-bottom: 10px; - position: sticky; - top: 15rem; + position: fixed; + top: 8.5rem; background: white; + margin-top: 10px; h2 { margin: 0; @@ -58,7 +61,6 @@ div.search_result:nth-child(2) { display: flex; flex-direction: row; align-items: center; - } #search_input_results { diff --git a/src/test.txt b/src/test.txt deleted file mode 100644 index 8f182a4..0000000 --- a/src/test.txt +++ /dev/null @@ -1 +0,0 @@ -uat deploy test \ No newline at end of file