diff --git a/nginx/default.conf b/nginx/default.conf index 4570eb71..151263e1 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -30,6 +30,26 @@ server { add_header Access-Control-Allow-Credentials true always; } + # Specific handler for entity-new: converts 303 → 200 + JSON so JS can read the location + location ~ ^/([^/]+)/priv/entity-new$ { + proxy_pass https://uri.olympiangods.org; + proxy_set_header Host uri.olympiangods.org; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Content-Type application/json; + proxy_set_header Authorization $http_authorization; + proxy_set_header Cookie $http_cookie; + proxy_ssl_verify off; + + proxy_intercept_errors on; + error_page 303 = @handle_entity_redirect; + + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Credentials true always; + add_header Access-Control-Expose-Headers X-Redirect-Location always; + } + location ~ ^/([^/]+)/priv/(.*) { proxy_pass https://uri.olympiangods.org; proxy_set_header Host uri.olympiangods.org; @@ -37,7 +57,7 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_ssl_verify off; - + # CORS headers add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Credentials true always; @@ -57,8 +77,8 @@ server { add_header Access-Control-Allow-Credentials true always; } - # Handle PATCH/PUT/POST requests to ilx_ endpoints (for bulk term editing) - location ~ ^/[^/]+/ilx_[^/]+$ { + # Handle PATCH/PUT/POST requests to ilx_/tmp_ endpoints + location ~ ^/[^/]+/(ilx|tmp)_[^/]+$ { proxy_pass https://uri.olympiangods.org; proxy_set_header Host uri.olympiangods.org; proxy_set_header X-Real-IP $remote_addr; @@ -169,6 +189,17 @@ server { add_header Access-Control-Allow-Credentials true always; } + # Convert entity-new 303 → 200 + JSON body with redirect location + location @handle_entity_redirect { + internal; + default_type application/json; + add_header X-Redirect-Location $upstream_http_location always; + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Credentials true always; + add_header Access-Control-Expose-Headers X-Redirect-Location always; + return 200 '{"location":"$upstream_http_location"}'; + } + # Handle 303 redirects for spec endpoint location @handle_303 { internal; diff --git a/src/api/endpoints/apiService.ts b/src/api/endpoints/apiService.ts index d0a1a960..c57c7f57 100644 --- a/src/api/endpoints/apiService.ts +++ b/src/api/endpoints/apiService.ts @@ -67,6 +67,18 @@ interface JsonLdResponse { const BASE_EXTENSION = "jsonld"; +// Deduplicate concurrent GET fetches for the same URL. +// All callers that request the same in-flight URL share one network request. +const inflight = new Map>(); + +const fetchOnce = (url: string, fetcher: () => Promise): Promise => { + const existing = inflight.get(url); + if (existing) return existing; + const p = fetcher().finally(() => inflight.delete(url)); + inflight.set(url, p); + return p; +}; + export const login = createPostRequest(API_CONFIG.REAL_API.SIGNIN, { "Content-Type": "application/x-www-form-urlencoded" }) export const register = createPostRequest(API_CONFIG.REAL_API.NEWUSER_ILX, { "Content-Type": "application/x-www-form-urlencoded" }) @@ -92,6 +104,11 @@ export const getOrganizationsCuries = (group: string) => { return createGetRequest(endpoint, "application/json")(); }; +export const addOrganizationCuries = (group: string, curies: Record) => { + const endpoint = `/${group}${API_CONFIG.REAL_API.ORG_CURIES}`; + return createPostRequest(endpoint, { "Content-Type": "application/json" })(curies); +}; + export const getOrganizationsTerms = (group: string) => { const endpoint = `/${group}${API_CONFIG.REAL_API.ORG_TERMS}`; return createGetRequest(endpoint, "application/json")(); @@ -155,7 +172,8 @@ export const changePassword = (group: string, data: { username: string; currentP export const getSelectedTermLabel = async (searchTerm: string, group: string = 'base'): Promise<{ label: string | undefined; actualGroup: string }> => { try { - const response = await createGetRequest(`/${group}/${searchTerm}.jsonld`)(); + const primaryUrl = `/${group}/${searchTerm}.jsonld`; + const response = await fetchOnce(primaryUrl, () => createGetRequest(primaryUrl)()); const label = response['@graph']?.[0]?.['rdfs:label']; @@ -179,7 +197,8 @@ export const getSelectedTermLabel = async (searchTerm: string, group: string = ' // If the request fails and we're not already trying 'base', try with 'base' as fallback if (group !== 'base') { try { - const fallbackResponse = await createGetRequest(`/base/${searchTerm}.jsonld`)(); + const fallbackUrl = `/base/${searchTerm}.jsonld`; + const fallbackResponse = await fetchOnce(fallbackUrl, () => createGetRequest(fallbackUrl)()); const fallbackLabel = fallbackResponse['@graph']?.[0]?.['rdfs:label']; const getLabelValue = (label: LabelType): string => { @@ -206,10 +225,115 @@ export const getSelectedTermLabel = async (searchTerm: string, group: string = ' } }; -export const createNewEntity = async ({ group, data, session }: { group: string; data: any; session: string }) => { +export const createNewEntity = async ({ group, data }: { group: string; data: any; session?: string }): Promise<{ termId: string | null; raw: string; status: number }> => { const endpoint = `/${group}${API_CONFIG.REAL_API.CREATE_NEW_ENTITY}`; - return createPostRequest(endpoint, { 'Content-Type': 'application/json' })(data, { handleRedirect: true }); -} + + // Vite proxy converts 303 → 200 + JSON { location } so fetch can read the redirect target. + const resp = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'include', + }); + + const xRedirect = resp.headers.get('x-redirect-location'); + let raw = ''; + try { raw = await resp.text(); } catch { /* ignore */ } + + // Prefer the custom header set by the proxy + const target = xRedirect || ''; + if (target) { + const m = target.match(/((?:tmp|ilx)_\d+)/i); + if (m) return { termId: m[1], raw: target, status: resp.status }; + } + + // Fallback: proxy sent JSON { location: "..." } + try { + const json = JSON.parse(raw); + const loc: string = json?.location || ''; + const m = loc.match(/((?:tmp|ilx)_\d+)/i); + if (m) return { termId: m[1], raw: loc, status: resp.status }; + } catch { /* not JSON */ } + + // Last resort: scan raw body for the ID pattern + const hrefMatch = raw.match(/href="[^"]*\/((?:tmp|ilx)_\d+)[^"]*"/i); + const textMatch = raw.match(/((?:tmp|ilx)_\d+)/i); + const termId = hrefMatch ? hrefMatch[1] : (textMatch ? textMatch[1] : null); + + return { termId, raw, status: resp.status }; +}; + +export const patchTermPredicates = async ({ + group, + termId, + add, + del = [], +}: { + group: string; + termId: string; + add: [string, string, { type: string; value: string }][]; + del?: any[]; +}): Promise<{ ok: boolean }> => { + const resp = await fetch(`/${group}/${termId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ add, del }), + }); + return { ok: resp.ok }; +}; + +export const createFork = async ( + groupname: string, + termId: string, + sourceGroup: string, + termLabel: string +): Promise<{ ok: boolean; status: number }> => { + const sourceIri = `${API_CONFIG.INTERLEX_URL}/${sourceGroup}/${termId}`; + const synonymIri = "http://uri.interlex.org/base/readable/synonym"; + const resp = await fetch(`/${groupname}/${termId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + add: [[sourceIri, synonymIri, { type: "literal", value: termLabel }]], + del: [], + }), + }); + return { ok: resp.ok, status: resp.status }; +}; + +export const addEntityToOntology = async ({ + group, + ontologyUri, + termId, +}: { + group: string; + ontologyUri: string; + termId: string; +}): Promise<{ success: boolean; status?: number; url?: string; body?: string; error?: string }> => { + const specUrl = ontologyUri.replace(API_CONFIG.INTERLEX_URL, API_CONFIG.BASE_URL); + const termIri = `${API_CONFIG.OLYMPIAN_GODS}/${group}/${termId}`; + + try { + const resp = await fetch(specUrl, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ add: [termIri], del: [] }), + redirect: 'manual', + }); + const ok = resp.ok || resp.status === 0; + if (!ok) { + let body = ''; + try { body = await resp.text(); } catch { /* ignore */ } + return { success: false, status: resp.status, url: specUrl, body }; + } + return { success: true, status: resp.status, url: specUrl }; + } catch (error: any) { + return { success: false, url: specUrl, error: error?.message || String(error) }; + } +}; export const createNewOntology = async ({ groupname, @@ -312,15 +436,17 @@ export const retrieveTokenApi = ({ groupname }: { groupname: string }) => { export const forgotPassword = createPostRequest(API_CONFIG.REAL_API.USER_RECOVER, { "Content-Type": "application/x-www-form-urlencoded" }) export const getMatchTerms = async (group: string, term: string, filters = {}) => { + const primaryUrl = `/${group}/${term}.${BASE_EXTENSION}`; try { - const response = await createGetRequest(`/${group}/${term}.${BASE_EXTENSION}`, "application/json")(); + const response = await fetchOnce(primaryUrl, () => createGetRequest(primaryUrl, "application/json")()); return termParser(response, term); } catch (err: any) { console.error(err.message); // If the request fails and we're not already trying 'base', try with 'base' as fallback if (group !== 'base') { try { - const fallbackResponse = await createGetRequest(`/base/${term}.${BASE_EXTENSION}`, "application/json")(); + const fallbackUrl = `/base/${term}.${BASE_EXTENSION}`; + const fallbackResponse = await fetchOnce(fallbackUrl, () => createGetRequest(fallbackUrl, "application/json")()); return termParser(fallbackResponse, term); } catch (fallbackErr: any) { console.error('Fallback request also failed:', fallbackErr.message); @@ -332,15 +458,17 @@ export const getMatchTerms = async (group: string, term: string, filters = {}) = }; export const getRawData = async (group: string, termID: string, format: string) => { + const primaryUrl = `/${group}/${termID}.${format}`; try { - const response = await createGetRequest(`/${group}/${termID}.${format}`, "application/json")(); + const response = await fetchOnce(primaryUrl, () => createGetRequest(primaryUrl, "application/json")()); return response; } catch (err: any) { console.error(err.message); // If the request fails and we're not already trying 'base', try with 'base' as fallback if (group !== 'base') { try { - const fallbackResponse = await createGetRequest(`/base/${termID}.${format}`, "application/json")(); + const fallbackUrl = `/base/${termID}.${format}`; + const fallbackResponse = await fetchOnce(fallbackUrl, () => createGetRequest(fallbackUrl, "application/json")()); return fallbackResponse; } catch (fallbackErr: any) { console.error('Fallback request also failed:', fallbackErr.message); @@ -362,7 +490,7 @@ export const getVersions = async (group: string, term: string) => { // A single version snapshot of a term, identified by its identity-graph hash. // Returns { prefixes, triples: [[subject, predicate, object], ...] }. export const getTermVersion = async (group: string, term: string, identityGraph: string) => { - return createGetRequest(`/${group}/${term}/versions/${identityGraph}`, "application/json")(); + return createGetRequest(`/${group}/${term}/versions/${identityGraph}`, "application/ld+json")(); }; export const getTermDiscussions = async (group: string, variantID: string) => { diff --git a/src/components/CurieEditor/CurieEditorDialog.jsx b/src/components/CurieEditor/CurieEditorDialog.jsx index ec857e2e..0ea04180 100644 --- a/src/components/CurieEditor/CurieEditorDialog.jsx +++ b/src/components/CurieEditor/CurieEditorDialog.jsx @@ -1,7 +1,7 @@ import * as React from "react"; import PropTypes from 'prop-types'; import { EditNoteIcon } from "../../Icons"; -import { Box, Button } from "@mui/material"; +import { Box, Button, Snackbar, Alert } from "@mui/material"; import StatusDialog from "../common/StatusDialog"; import CustomizedDialog from "../common/CustomizedDialog"; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; @@ -21,16 +21,22 @@ const HeaderRightSideContent = ({ handleClose, onSaveCuries }) => { const CurieEditorDialog = ({ open, handleClose, onSubmit, children, isFromOrganization }) => { const [openStatusDialog, setOpenStatusDialog] = React.useState(false); + const [saveError, setSaveError] = React.useState(null); - const handleSaveCuries = () => { - onSubmit(); - setOpenStatusDialog(true); + const handleSaveCuries = async () => { + setSaveError(null); + try { + await onSubmit(); + setOpenStatusDialog(true); + } catch (err) { + setSaveError(err?.body || err?.message || 'Failed to save curies. Please try again.'); + } } const handleCloseStatusDialog = () => { setOpenStatusDialog(false) } - + const handleStatusDialogActionButtonClick = () => { setOpenStatusDialog(false); } @@ -59,6 +65,16 @@ const CurieEditorDialog = ({ open, handleClose, onSubmit, children, isFromOrgani finishButtonEndIcon={} actionButtonStartIcon={} /> + setSaveError(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSaveError(null)} sx={{ width: '100%' }}> + {saveError} + + ) } diff --git a/src/components/CurieEditor/CuriesTabPanel.jsx b/src/components/CurieEditor/CuriesTabPanel.jsx index 7ad92f60..27e5a49c 100644 --- a/src/components/CurieEditor/CuriesTabPanel.jsx +++ b/src/components/CurieEditor/CuriesTabPanel.jsx @@ -4,7 +4,7 @@ import CustomTable from "../common/CustomTable"; import { getComparator, stableSort } from "../../utils"; import AddOutlinedIcon from '@mui/icons-material/AddOutlined'; import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'; -import { Box, TableRow, TableCell, IconButton, TextField, ClickAwayListener, CircularProgress } from "@mui/material"; +import { Box, TableRow, TableCell, IconButton, TextField, ClickAwayListener, CircularProgress, Tooltip } from "@mui/material"; import { vars } from "../../theme/variables"; const { gray600, brand500, gray100, gray300, gray700 } = vars; @@ -48,14 +48,13 @@ const namespaceCellStyle = { }; const CuriesTabPanel = (props) => { - const { curieValue, error, loading, rows, editMode, onCurieAmountChange, onAddRow, onDeleteRow, onChangeRow } = props; - const [rowIndex, setRowIndex] = React.useState(-1); + const { curieValue, error, loading, rows, editMode, onCurieAmountChange, onAddRow, onChangeRow } = props; + const [rowId, setRowId] = React.useState(null); const [columnIndex, setColumnIndex] = React.useState(-1); const [order, setOrder] = React.useState('asc'); const [orderBy, setOrderBy] = React.useState('prefix'); const sortedRows = React.useMemo(() => { - // Ensure rows is always an array and apply natural sorting by default const safeRows = Array.isArray(rows) ? rows : []; return stableSort(safeRows, getComparator(order, orderBy)); }, [rows, order, orderBy]); @@ -65,7 +64,7 @@ const CuriesTabPanel = (props) => { }, [rows, onCurieAmountChange]); const handleExit = () => { - setRowIndex(-1); + setRowId(null); setColumnIndex(-1); } @@ -97,56 +96,52 @@ const CuriesTabPanel = (props) => { )} - {Array.isArray(sortedRows) && sortedRows.map((row, index) => { + {Array.isArray(sortedRows) && sortedRows.map((row) => { + const isEditingPrefix = rowId === row._id && columnIndex === 0 && editMode; + const isEditingNamespace = rowId === row._id && columnIndex === 1 && editMode; return ( - + { setRowIndex(index); setColumnIndex(0); }} - sx={{ border: rowIndex === index && columnIndex === 0 && editMode ? `2px solid ${brand500} !important` : 'inherit', ...prefixCellStyle }} + onClick={() => { setRowId(row._id); setColumnIndex(0); }} + sx={{ border: isEditingPrefix ? `2px solid ${brand500} !important` : 'inherit', ...prefixCellStyle }} > - { - rowIndex === index && columnIndex === 0 && editMode ? - onChangeRow(e, index, "prefix", curieValue)} - sx={fieldStyle} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleExit(); - } - }} - /> : row.prefix + {isEditingPrefix ? + onChangeRow(e, row._id, "prefix", curieValue)} + sx={fieldStyle} + onKeyDown={(e) => { if (e.key === "Enter") handleExit(); }} + /> : row.prefix } { setRowIndex(index); setColumnIndex(1); }} - sx={{ border: rowIndex === index && columnIndex === 1 && editMode ? `2px solid ${brand500} !important` : 'inherit', ...namespaceCellStyle }} + onClick={() => { setRowId(row._id); setColumnIndex(1); }} + sx={{ border: isEditingNamespace ? `2px solid ${brand500} !important` : 'inherit', ...namespaceCellStyle }} > - { - rowIndex === index && columnIndex === 1 && editMode ? - onChangeRow(e, index, "namespace", curieValue)} - sx={fieldStyle} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleExit(); - } - }} - /> : row.namespace + {isEditingNamespace ? + onChangeRow(e, row._id, "namespace", curieValue)} + sx={fieldStyle} + onKeyDown={(e) => { if (e.key === "Enter") handleExit(); }} + /> : row.namespace } {editMode && ( - onDeleteRow(curieValue, row.prefix, row.namespace)}> - - + + + + + + + )} diff --git a/src/components/CurieEditor/index.jsx b/src/components/CurieEditor/index.jsx index 4e72e5fb..c8215c0c 100644 --- a/src/components/CurieEditor/index.jsx +++ b/src/components/CurieEditor/index.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo, useContext, useCallback } from "react"; +import { useState, useContext } from "react"; import { Box, Typography, Grid } from "@mui/material"; import CustomButton from "../common/CustomButton"; import BasicTabs from "../common/CustomTabs"; @@ -6,136 +6,79 @@ import CurieEditorDialog from "./CurieEditorDialog"; import CuriesTabPanel from "./CuriesTabPanel"; import { EditNoteIcon } from "../../Icons"; import { vars } from "../../theme/variables"; -import { getOrganizationsCuries } from "../../api/endpoints/apiService"; +import { addOrganizationCuries } from "../../api/endpoints/apiService"; import { GlobalDataContext } from "../../contexts/DataContext"; -import debounce from 'lodash/debounce'; const { gray600, gray700 } = vars; -// Helper function to transform curies response similar to SingleOrganization -const transformCuriesResponse = (response) => { - let curiesObject; - if (Array.isArray(response) && response.length > 0) { - // If response is an array, take the first item - curiesObject = response[0]; - } else if (response && typeof response === 'object') { - // If response is a direct object, use it directly - curiesObject = response; - } - - if (curiesObject && Object.keys(curiesObject).length > 0) { - // Convert object to array of {prefix, namespace} objects - return Object.entries(curiesObject).map(([prefix, namespace]) => ({ - prefix, - namespace - })); - } - return []; -}; - -const newRowObj = { prefix: '', namespace: '' }; +let newRowCounter = 0; const curiesTabs = ["My curies", "Curated", "Latest"]; -const curieValues = ["base", "curated", "latest"] +const curieValues = ["base", "curated", "latest"]; const CurieEditor = () => { - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [curies, setCuries] = useState({ base: [], curated: [], latest: [] }); + const { user, curies, curiesLoading, setCuriesData } = useContext(GlobalDataContext); + + const [localCuries, setLocalCuries] = useState({ base: [], curated: [], latest: [] }); const [tabValue, setTabValue] = useState(0); const [curieAmount, setCurieAmount] = useState(0); - const [openCurieEditor, setOpenCurieEditor] = React.useState(false); - - const { user } = useContext(GlobalDataContext); + const [openCurieEditor, setOpenCurieEditor] = useState(false); + + const handleClickCurieEditor = () => { + // Snapshot context curies into local state when opening dialog + setLocalCuries(curies); + setOpenCurieEditor(true); + }; - const fetchCuries = useCallback(async (type) => { - try { - let data = []; - - if (type === 'base') { - // "My curies" tab - get curies from user's groupname - if (user?.groupname) { - const response = await getOrganizationsCuries(user.groupname).catch(error => { - if (error?.response?.status === 501) { - console.warn(`Curies endpoint not implemented yet (501) for ${user.groupname}, using empty array`); - return [{}]; // Return array with empty object to match expected structure - } - throw error; - }); - data = transformCuriesResponse(response); - } - } else if (type === 'curated') { - // "Curated" tab - get curies from "base" groupname - const response = await getOrganizationsCuries('base').catch(error => { - if (error?.response?.status === 501) { - console.warn('Curies endpoint not implemented yet (501) for base, using empty array'); - return [{}]; // Return array with empty object to match expected structure - } - throw error; - }); - data = transformCuriesResponse(response); - } else if (type === 'latest') { - // "Latest" tab - get curies from "base" groupname (same as curated) - const response = await getOrganizationsCuries('base').catch(error => { - if (error?.response?.status === 501) { - console.warn('Curies endpoint not implemented yet (501) for base, using empty array'); - return [{}]; // Return array with empty object to match expected structure - } - throw error; - }); - data = transformCuriesResponse(response); - } - - setCuries(prev => ({ ...prev, [type]: data })); - } catch (error) { - console.error(`Error fetching curies for ${type}:`, error); - setError(error); - // Set empty array on error to prevent UI issues - setCuries(prev => ({ ...prev, [type]: [] })); - } finally { - setLoading(false); - } - }, [user]); + const handleCloseCurieEditor = () => setOpenCurieEditor(false); + const handleChangeTabs = (event, newValue) => setTabValue(newValue); + const handleCurieAmountChange = (value) => setCurieAmount(value); const handleAddNewCurieRow = (curieValue) => { - setCuries(prev => ({ ...prev, [curieValue]: [newRowObj, ...prev[curieValue]] })); + newRowCounter += 1; + setLocalCuries(prev => ({ + ...prev, + [curieValue]: [{ prefix: '', namespace: '', _id: `new_${newRowCounter}` }, ...prev[curieValue]] + })); }; - const handleDeleteCurieRow = (curieValue, rowPrefix, rowNamespace) => { - console.log("DELETE: connect to delete method") - setCuries(prev => ({ + const handleDeleteCurieRow = (curieValue, rowId) => { + setLocalCuries(prev => ({ ...prev, - [curieValue]: prev[curieValue].filter(row => row.prefix !== rowPrefix && row.namespace !== rowNamespace) + [curieValue]: prev[curieValue].filter(row => row._id !== rowId) })); }; - const debouncedUpdateRows = useMemo( - () => debounce((curieValue, updatedRows) => { - setCuries(prev => ({ ...prev, [curieValue]: updatedRows })); - }, 2000), - [] - ); - - const handleInputChangeCurieRow = (e, rowIndex, columnName, curieValue) => { - console.log("UPDATE: here connect to update method") - const updatedRows = curies[curieValue].map((row, index) => index === rowIndex ? { ...row, [columnName]: e.target.value } : row); - debouncedUpdateRows(curieValue, updatedRows); + const handleInputChangeCurieRow = (e, rowId, columnName, curieValue) => { + const value = e.target.value; + setLocalCuries(prev => ({ + ...prev, + [curieValue]: prev[curieValue].map(row => row._id === rowId ? { ...row, [columnName]: value } : row) + })); }; - const handleCurieAmountChange = (value) => setCurieAmount(value); - const handleClickCurieEditor = () => setOpenCurieEditor(true); - const handleCloseCurieEditor = () => setOpenCurieEditor(false); - const handleChangeTabs = (event, newValue) => setTabValue(newValue); - - const handleSubmit = () => { - console.log("POST: here connect to post method") - } - - useEffect(() => { - fetchCuries('base'); - fetchCuries('curated'); - fetchCuries('latest'); - }, [fetchCuries]); + const handleSubmit = async () => { + if (!user?.groupname) return; + const newRows = localCuries.base.filter(row => row._id?.startsWith('new_')); + if (newRows.length === 0) return; + const payload = newRows.reduce((acc, { prefix, namespace }) => { + if (prefix && namespace) acc[prefix] = namespace; + return acc; + }, {}); + if (Object.keys(payload).length === 0) return; + + await addOrganizationCuries(user.groupname, payload); + + // Re-stamp saved rows as existing, then sync to context + const updatedBase = localCuries.base.map(row => + row._id?.startsWith('new_') && payload[row.prefix] + ? { ...row, _id: `existing_${row.prefix}_${row.namespace}` } + : row + ); + const updatedCuries = { ...localCuries, base: updatedBase }; + setLocalCuries(updatedCuries); + setCuriesData(updatedCuries); + }; return ( <> @@ -160,8 +103,8 @@ const CurieEditor = () => { tabValue === index && ( @@ -179,10 +122,10 @@ const CurieEditor = () => { { ); -} +}; export default CurieEditor; diff --git a/src/components/Dashboard/Variants/VariantCard.jsx b/src/components/Dashboard/Variants/VariantCard.jsx index e59027aa..a5081f84 100644 --- a/src/components/Dashboard/Variants/VariantCard.jsx +++ b/src/components/Dashboard/Variants/VariantCard.jsx @@ -1,14 +1,21 @@ import PropTypes from 'prop-types'; +import { useNavigate } from 'react-router-dom'; import {Box, Chip, Grid, Stack, Typography} from "@mui/material"; import { vars } from "../../../theme/variables"; const {gray500, gray700, gray200, brand600, brand200, brand50 } = vars; const VariantCard = ({term}) => { + const navigate = useNavigate(); + + const handleClick = () => { + const parts = term.iri?.split('/'); + const group = parts?.[parts.length - 2] || 'base'; + navigate(`/${group}/${term.id}/overview`); + }; + return ( - + mockApi; const Variants = ({handleOpenEditBulkTerms}) => { + const { user } = useContext(GlobalDataContext); + const groupname = user?.groupname; const [loading, setLoading] = useState(false); const [numberOfVisiblePages, setNumberOfVisiblePages] = useState(8); const [listView, setListView] = useState('list'); const [terms, setTerms] = useState([]); const [page, setPage] = useState(1); const [slicedTerms, setSlicedTerms] = useState([]); - const { getUserTerms } = useMockApi(); const handleNumberOfPagesChange = (v) => { setNumberOfVisiblePages(v); setPage(1); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - const fetchTerms = useCallback( - debounce(async (searchTerm) => { - getUserTerms("base", searchTerm).then(data => { - const parsedData = termParser(data, searchTerm); - setTerms(parsedData.results) - setPage(1); - setLoading(false) - }).catch(err => { - console.log(err); - setLoading(false) - }) - }, 500), - [getUserTerms] - ); + const fetchTerms = useCallback(async () => { + if (!groupname) return; + setLoading(true); + getOrganizationsTerms(groupname).then(data => { + const seen = new Set(); + const parsed = (Array.isArray(data) ? data : []) + .filter(([iri]) => { + if (seen.has(iri)) return false; + seen.add(iri); + return true; + }) + .map(([iri, label, lastModified]) => { + const idMatch = iri.match(/((?:tmp|ilx)_\d+)/i); + const termId = idMatch ? idMatch[1] : iri; + const status = termId.toLowerCase().startsWith('tmp_') ? 'draft' : 'published'; + return { id: termId, iri, label, lastModified, status }; + }); + setTerms(parsed); + setPage(1); + setLoading(false); + }).catch(err => { + console.log(err); + setLoading(false); + }); + }, [groupname]); useEffect(() => { - setLoading(true) - fetchTerms('a'); + fetchTerms(); }, [fetchTerms]); useEffect(() => { diff --git a/src/components/Header/index.jsx b/src/components/Header/index.jsx index b5f25007..a9595b57 100644 --- a/src/components/Header/index.jsx +++ b/src/components/Header/index.jsx @@ -310,7 +310,7 @@ const Header = () => { action: handleNewOntologyDialogOpen }, { - label: 'Bulk add terms', + label: 'Bulk edit terms', icon: , action: handleOpenEditBulkTerms } diff --git a/src/components/SingleTermView/CreateForkDialog.jsx b/src/components/SingleTermView/CreateForkDialog.jsx index dd8725f4..495a235d 100644 --- a/src/components/SingleTermView/CreateForkDialog.jsx +++ b/src/components/SingleTermView/CreateForkDialog.jsx @@ -1,135 +1,149 @@ -import { useState } from "react"; -import PropTypes from "prop-types"; -import StatusDialog from "../common/StatusDialog"; -import CustomizedDialog from "../common/CustomizedDialog"; -import { Box, Button, Grid, Typography } from "@mui/material"; -import CustomSingleSelect from "../common/CustomSingleSelect"; -import SearchTermsData from "../../static/SearchTermsData.json" -import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; - -import { vars } from "../../theme/variables"; -const { gray800, gray600, gray500, gray700 } = vars; - -const HeaderRightSideContent = ({ handleClose, onSaveFork }) => { - return ( - - - - - ) -} - -HeaderRightSideContent.propTypes = { - handleClose: PropTypes.func, - onSaveFork: PropTypes.func -} - -// eslint-disable-next-line no-unused-vars -const CreateForkDialog = ({ formState, open, handleClose, onInputChange }) => { - const [openStatusDialog, setOpenStatusDialog] = useState(false); - const [newTerm, setNewTerm] = useState('label') - const handleSaveFork = () => { - setOpenStatusDialog(true); - handleClose() - } - const handleTermChange = (index, field, value) => { - // newTerms[index][field] = value; - setNewTerm(value) - }; - const updatedColumnsArray = SearchTermsData.termsColumns.map(item => ({ - ...item, - value: item.id - })); - const handleCloseStatusDialog = () => { - setOpenStatusDialog(false) - } - const handleStatusDialogActionButtonClick = () => { - setOpenStatusDialog(false); - } - return ( - <> - - } - > - - - Add a fork to Central Nervous System - - - - - - Owner - - Required - - - - - - / - - - - - - Fork name - - - - - Central nervous system - - - - - - By default the fork name is the same as the curated. It’s possible to personalise it. - - - - } - /> - - ); -}; - -CreateForkDialog.propTypes = { - formState: PropTypes.object, - open: PropTypes.bool, - handleClose: PropTypes.func, - onInputChange: PropTypes.func -} - -export default CreateForkDialog; +import { useState } from "react"; +import PropTypes from "prop-types"; +import { useNavigate } from "react-router-dom"; +import CustomizedDialog from "../common/CustomizedDialog"; +import FeatureNotAvailableDialog from "../common/FeatureNotAvailableDialog"; +import { Box, Button, Grid, Typography, CircularProgress, Alert } from "@mui/material"; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import { createFork } from "../../api/endpoints/apiService"; + +import { vars } from "../../theme/variables"; +const { gray800, gray600, gray500 } = vars; + +const HeaderRightSideContent = ({ handleClose, onSaveFork, isSaving }) => ( + + + + +); + +HeaderRightSideContent.propTypes = { + handleClose: PropTypes.func, + onSaveFork: PropTypes.func, + isSaving: PropTypes.bool, +}; + +const CreateForkDialog = ({ open, handleClose, user, searchTerm, termLabel, group }) => { + const navigate = useNavigate(); + const [ownerNotSupportedOpen, setOwnerNotSupportedOpen] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const groupname = user?.groupname || ''; + const displayLabel = termLabel || searchTerm || ''; + + const handleSaveFork = async () => { + if (!groupname || !searchTerm) return; + setIsSaving(true); + setSaveError(null); + try { + const result = await createFork(groupname, searchTerm, group || 'base', displayLabel); + if (result.ok) { + handleClose(); + navigate(`/${groupname}/${searchTerm}/overview`); + } else { + setSaveError(`Fork creation failed (status ${result.status}). Please try again.`); + } + } catch (e) { + setSaveError(e?.message || 'An unexpected error occurred.'); + } finally { + setIsSaving(false); + } + }; + + return ( + <> + + } + > + + + Fork "{displayLabel}" under your account + + + {saveError && ( + {saveError} + )} + + + + + + Owner + + Required + + setOwnerNotSupportedOpen(true)} + sx={{ + cursor: 'pointer', + border: '1px solid', + borderColor: 'grey.300', + borderRadius: '0.5rem', + padding: '0.5rem 0.75rem', + backgroundColor: 'grey.50', + userSelect: 'none', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }} + > + {groupname} + + + + + + / + + + + + Fork name + + + {displayLabel} + + + + + + The fork will be created under your account with the same term identifier. + + + + + setOwnerNotSupportedOpen(false)} + title='Custom fork owner not supported' + message='Selecting a different fork owner is not yet supported. Forks can only be created under your own account.' + /> + + ); +}; + +CreateForkDialog.propTypes = { + open: PropTypes.bool, + handleClose: PropTypes.func, + user: PropTypes.object, + searchTerm: PropTypes.string, + termLabel: PropTypes.string, + group: PropTypes.string, +}; + +export default CreateForkDialog; diff --git a/src/components/SingleTermView/History/HistoryPanel.jsx b/src/components/SingleTermView/History/HistoryPanel.jsx index b2fe31d0..d0120cd3 100644 --- a/src/components/SingleTermView/History/HistoryPanel.jsx +++ b/src/components/SingleTermView/History/HistoryPanel.jsx @@ -2,39 +2,32 @@ import React from "react"; import PropTypes from 'prop-types'; import HistoryItem from "./HistoryItem"; import { Box, List, CircularProgress } from "@mui/material"; -import { getVersions } from "../../../api/endpoints/apiService"; import { vars } from "../../../theme/variables"; const { gray50 } = vars; -const HistoryPanel = ({ searchTerm, group = "base" }) => { - const [versions, setVersions] = React.useState([]); - const [loading, setLoading] = React.useState(true); +const HistoryPanel = ({ /*searchTerm, group = "base",*/ versionsData, versionsLoading }) => { + const versions = React.useMemo(() => { + if (!versionsData?.versions) return []; + return versionsData.versions.map(version => { + const oldestAppearance = [...version.appears_in].sort((a, b) => + new Date(a.first_seen.replace(',', '.')) - new Date(b.first_seen.replace(',', '.')) + )[0]; - React.useEffect(() => { - getVersions(group, searchTerm).then(data => { - const oldestEntries = data.versions.map(version => { - const oldestAppearance = [...version.appears_in].sort((a, b) => - new Date(a.first_seen.replace(',', '.')) - new Date(b.first_seen.replace(',', '.')) - )[0]; + const uriParts = oldestAppearance.uri.split('http://uri.interlex.org/')[1].split('/'); + const forkName = uriParts[0]; - const uriParts = oldestAppearance.uri.split('http://uri.interlex.org/')[1].split('/'); - const forkName = uriParts[0]; + return { + date: oldestAppearance.first_seen, + fork: forkName, + identityGraph: version["identity-graph"] + }; + }).sort((a, b) => + new Date(a.date.replace(',', '.')) - new Date(b.date.replace(',', '.')) + ); + }, [versionsData]); - return { - date: oldestAppearance.first_seen, - fork: forkName, - identityGraph: version["identity-graph"] - }; - }).sort((a, b) => - new Date(a.date.replace(',', '.')) - new Date(b.date.replace(',', '.')) - ); - setVersions(oldestEntries); - setLoading(false); - }); - }, [group, searchTerm]); - - if (loading) { + if (versionsLoading) { return @@ -68,7 +61,9 @@ const HistoryPanel = ({ searchTerm, group = "base" }) => { HistoryPanel.propTypes = { searchTerm: PropTypes.string, - group: PropTypes.string + group: PropTypes.string, + versionsData: PropTypes.object, + versionsLoading: PropTypes.bool, } export default HistoryPanel; diff --git a/src/components/SingleTermView/OverView/Details.jsx b/src/components/SingleTermView/OverView/Details.jsx index b1a3d23e..beabd92f 100644 --- a/src/components/SingleTermView/OverView/Details.jsx +++ b/src/components/SingleTermView/OverView/Details.jsx @@ -31,6 +31,7 @@ const Details = ({ loading, data, jsonData }) => { const getSynonymGroups = () => { const synonyms = processExistingIds(data?.synonym); + const synonymSet = new Set(synonyms); const graph = jsonData?.["@graph"]; const focusNode = Array.isArray(graph) ? graph.find(n => String(n?.["@type"] || "").toLowerCase().includes("class")) || null @@ -39,7 +40,7 @@ const Details = ({ loading, data, jsonData }) => { const relatedArr = Array.isArray(relatedRaw) ? relatedRaw : relatedRaw ? [relatedRaw] : []; const related = relatedArr .map(v => (typeof v === "string" ? v : v?.["@value"] || null)) - .filter(Boolean); + .filter(v => v && !synonymSet.has(v)); return { synonyms, related }; }; @@ -118,18 +119,20 @@ const Details = ({ loading, data, jsonData }) => { - - - - Existing IDs - - - {data?.existingID && (processExistingIds(data?.existingID).map((id) => - } onClick={() => handleChipClick(id)} /> - ))} - - - + {processExistingIds(data?.existingID).length > 0 && ( + + + + Existing IDs + + + {processExistingIds(data?.existingID).map((id) => + } onClick={() => handleChipClick(id)} /> + )} + + + + )} @@ -174,26 +177,30 @@ const Details = ({ loading, data, jsonData }) => { - - - - Originally submitted by - - - {data?.submittedBy} - - - - - - - Last modified by - - - {data?.lastModifiedBy} - - - + {data?.submittedBy && ( + + + + Originally submitted by + + + {data?.submittedBy} + + + + )} + {data?.lastModifiedBy && ( + + + + Last modified by + + + {data?.lastModifiedBy} + + + + )} diff --git a/src/components/SingleTermView/OverView/OverView.jsx b/src/components/SingleTermView/OverView/OverView.jsx index 5542fb7e..028b27a2 100644 --- a/src/components/SingleTermView/OverView/OverView.jsx +++ b/src/components/SingleTermView/OverView/OverView.jsx @@ -2,7 +2,7 @@ import { Box, Divider, Grid, Snackbar, Alert } from "@mui/material"; import PropTypes from "prop-types"; import RawDataViewer from "./RawDataViewer"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { getMatchTerms, getRawData, @@ -11,7 +11,7 @@ import { getTermVersion, } from "../../../api/endpoints/apiService"; import termParser from "../../../parsers/termParser"; -import { versionSnapshotToJsonLd } from "../../../parsers/versionParser"; +import { adaptVersionJsonLd } from "../../../parsers/versionAdapter"; import { patchEndpointsIlx } from "../../../api/endpoints/interLexURIStructureAPI"; import { focusNodeFromJsonLd, @@ -19,7 +19,7 @@ import { resolveStoredObject, } from "../../../parsers/predicateMutations"; import { buildPredicateGroupsForFocus } from "../../../parsers/predicateParser"; -import { shortenIri, getObjectInputKind } from "../../../configuration/predicateConfig"; +import { shortenIri, getObjectInputKind, buildExpandContext } from "../../../configuration/predicateConfig"; import { toHierarchyOptionsFromTriples, buildChildrenTreeFromTriples, @@ -31,6 +31,7 @@ import { DetailsSection, HierarchySection, PredicatesSection } from "./OverviewS import { emitPredicateRowUpdate, makeRowKey } from "./predicateMutationBus"; import { reportApiError } from "../../../api/apiErrorBus"; import ApiErrorDialog from "../../common/ApiErrorDialog"; +import { GlobalDataContext } from "../../../contexts/DataContext"; // Reserved minimum heights while a section loads, so content arriving in one // section can't shove a section the user is already scrolled to. @@ -103,12 +104,12 @@ const mergePredicates = ({ versionHash, jsonData, predicateGroups, focusCurie, s return dedupePredicateGroups([...freshLiterals, ...transitive]); }; -// Fetch + parse both hierarchy directions for a focus id (always from "base"). -const fetchHierarchiesData = async (curieLike) => { +// Fetch + parse both hierarchy directions for a focus id. +const fetchHierarchiesData = async (curieLike, group) => { const termId = toILX(curieLike); const [childRes, superRes] = await Promise.all([ - getTermHierarchies({ groupname: "base", termId, objToSub: true }), - getTermHierarchies({ groupname: "base", termId, objToSub: false }), + getTermHierarchies({ groupname: group, termId, objToSub: true }), + getTermHierarchies({ groupname: group, termId, objToSub: false }), ]); const childTriples = childRes?.triples || []; const superTriples = superRes?.triples || []; @@ -141,6 +142,7 @@ const interpretPatchResult = (res) => { }; const OverView = ({ searchTerm, isCodeViewVisible = false, selectedDataFormat, group = "base", versionHash }) => { + const { curies } = useContext(GlobalDataContext); // Per-instance rxjs streams; each section subscribes to its own. const storeRef = useRef(); if (!storeRef.current) storeRef.current = createOverviewStore(); @@ -217,7 +219,7 @@ const OverView = ({ searchTerm, isCodeViewVisible = false, selectedDataFormat, g } store.hierarchy$.next({ ...store.hierarchy$.getValue(), loading: true }); - fetchHierarchiesData(sv.id) + fetchHierarchiesData(sv.id, group) .then((res) => { if (isStale()) return; store.hierarchy$.next({ loading: false, ...res }); @@ -236,7 +238,7 @@ const OverView = ({ searchTerm, isCodeViewVisible = false, selectedDataFormat, g groupsReadyRef.current = false; store.predicates$.next({ ...store.predicates$.getValue(), loading: true, focusId: sv.id }); - getTermPredicates({ groupname: "base", termId: toILX(sv.id) }) + getTermPredicates({ groupname: group, termId: toILX(sv.id) }) .then((groups) => { if (isStale()) return; groupsRef.current = groups || []; @@ -334,7 +336,7 @@ const OverView = ({ searchTerm, isCodeViewVisible = false, selectedDataFormat, g return; } store.hierarchy$.next({ ...store.hierarchy$.getValue(), loading: true }); - fetchHierarchiesData(sv.id) + fetchHierarchiesData(sv.id, group) .then((res) => { if (!isStale()) store.hierarchy$.next({ loading: false, ...res }); }) @@ -353,19 +355,10 @@ const OverView = ({ searchTerm, isCodeViewVisible = false, selectedDataFormat, g (async () => { try { - // Borrow the live head @context (richer curie set) when reachable. - let headContext; - try { - const head = await getRawData(group, searchTerm, "jsonld"); - headContext = head?.["@context"]; - } catch { - /* fall back to the parser's default context */ - } - - const snapshot = await getTermVersion(group, searchTerm, versionHash); + const raw = await getTermVersion(group, searchTerm, versionHash); if (isStale()) return; - const jsonld = versionSnapshotToJsonLd(snapshot, versionHash, headContext); + const jsonld = adaptVersionJsonLd(raw); const first = termParser(jsonld, searchTerm)?.results?.[0] || null; store.details$.next({ loading: false, data: first, jsonData: jsonld }); const sv = { id: first?.id || searchTerm, label: first?.label || searchTerm }; @@ -422,7 +415,7 @@ const OverView = ({ searchTerm, isCodeViewVisible = false, selectedDataFormat, g } if (focus?.id) { try { - const groups = await getTermPredicates({ groupname: "base", termId: toILX(focus.id) }); + const groups = await getTermPredicates({ groupname: group, termId: toILX(focus.id) }); groupsRef.current = groups || []; groupsReadyRef.current = true; maybePushPredicates(focus, null); @@ -443,7 +436,10 @@ const OverView = ({ searchTerm, isCodeViewVisible = false, selectedDataFormat, g // with the base @context. GET-first guarantees the predicate IRIs // round-trip. const baseDoc = await getRawData("base", patchId, "jsonld"); - const context = baseDoc?.["@context"] || jsonData?.["@context"] || {}; + const jsonLdContext = baseDoc?.["@context"] || jsonData?.["@context"] || {}; + // Merge known-term shorthands (e.g. "definition" → IAO IRI) under the + // JSON-LD context so bare predicate names expand to full IRIs. + const context = { ...buildExpandContext(curies?.base ?? []), ...jsonLdContext }; const node = focusNodeFromJsonLd(baseDoc) || focusNodeFromJsonLd(jsonData); const subject = mutation.subject || node?.["@id"]; if (!subject) { @@ -477,7 +473,7 @@ const OverView = ({ searchTerm, isCodeViewVisible = false, selectedDataFormat, g if (isEdit) emitPredicateRowUpdate({ rowKey, status: "error" }); } }, - [store, group, searchTerm, reloadAfterMutation] + [store, group, searchTerm, reloadAfterMutation, curies] ); const onMutate = versionHash ? undefined : handlePredicateMutation; diff --git a/src/components/SingleTermView/OverView/Predicates.jsx b/src/components/SingleTermView/OverView/Predicates.jsx index 8168963a..67767d6a 100644 --- a/src/components/SingleTermView/OverView/Predicates.jsx +++ b/src/components/SingleTermView/OverView/Predicates.jsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useContext } from "react"; import PropTypes from "prop-types"; import ExpandIcon from "@mui/icons-material/Expand"; import RemoveIcon from "@mui/icons-material/Remove"; @@ -16,14 +16,25 @@ import { ADDABLE_PREDICATES, getObjectInputKind, } from "../../../configuration/predicateConfig"; +import { GlobalDataContext } from "../../../contexts/DataContext"; import { vars } from "../../../theme/variables"; const { gray800, gray300, gray700 } = vars; const Predicates = ({ data, isGraphVisible, loading, focusId, group, onMutate }) => { + const { curies } = useContext(GlobalDataContext); + + const predicateOptions = React.useMemo(() => { + const base = curies?.base ?? []; + if (base.length > 0) { + return base.map(({ prefix }) => ({ title: prefix, label: prefix })); + } + return ADDABLE_PREDICATES; + }, [curies]); + const [toggleButtonValue, setToggleButtonValue] = React.useState("expand"); const [adding, setAdding] = React.useState(false); - const [newPredicate, setNewPredicate] = React.useState(ADDABLE_PREDICATES[0].title); + const [newPredicate, setNewPredicate] = React.useState(() => (predicateOptions[0]?.title ?? ADDABLE_PREDICATES[0].title)); const [newValue, setNewValue] = React.useState(""); const predicates = React.useMemo(() => (Array.isArray(data) ? data : []), [data]); @@ -32,7 +43,7 @@ const Predicates = ({ data, isGraphVisible, loading, focusId, group, onMutate }) const objectKind = getObjectInputKind(newPredicate); - const startAdd = () => { setNewValue(""); setNewPredicate(ADDABLE_PREDICATES[0].title); setAdding(true); }; + const startAdd = () => { setNewValue(""); setNewPredicate(predicateOptions[0]?.title ?? ""); setAdding(true); }; const cancelAdd = () => { setAdding(false); setNewValue(""); }; const confirmAdd = () => { const value = newValue.trim(); @@ -83,7 +94,7 @@ const Predicates = ({ data, isGraphVisible, loading, focusId, group, onMutate }) '& .MuiOutlinedInput-notchedOutline': { borderColor: gray300 }, }} > - {ADDABLE_PREDICATES.map((p) => ( + {predicateOptions.map((p) => ( {p.label} ))} diff --git a/src/components/SingleTermView/Variants/VariantsPanel.jsx b/src/components/SingleTermView/Variants/VariantsPanel.jsx index 59c92927..a51ea11d 100644 --- a/src/components/SingleTermView/Variants/VariantsPanel.jsx +++ b/src/components/SingleTermView/Variants/VariantsPanel.jsx @@ -3,14 +3,13 @@ import PropTypes from 'prop-types'; import { Box, CircularProgress } from '@mui/material'; import VariantsTable from './VariantsTable'; import ErrorModal from '../../common/ErrorModal'; -import { getVersions } from '../../../api/endpoints/apiService'; const headCells = [ { id: 'fork', label: 'Fork' }, { id: 'title', label: 'Title' }, { id: 'firstSeen', label: 'First seen' }, { id: 'tripleCount', label: 'Triples' }, - { id: 'identityGraph', label: 'Identity graph' }, + { id: 'identityRecord', label: 'Identity record' }, { id: 'action_buttons', label: '', sortable: false, width: '3.5rem' } ]; @@ -32,58 +31,36 @@ const mapVersionsToRows = (data) => { (a, b) => parseBackendDate(a.first_seen) - parseBackendDate(b.first_seen) )[0]; - const identityGraph = version['identity-graph']; + const identityRecord = version['identity-record']; return { - id: identityGraph, + id: identityRecord, fork: forkFromUri(oldest?.uri), title: oldest?.title ?? '', firstSeen: oldest?.first_seen ? parseBackendDate(oldest.first_seen).toLocaleString() : '', tripleCount: version.triple_count ?? 0, - identityGraph: identityGraph ? `${identityGraph.slice(0, 12)}…` : '', + identityRecord: identityRecord ? `${identityRecord.slice(0, 12)}…` : '', viri: oldest?.viri ?? '' }; }); }; -const VariantsPanel = ({ searchTerm, group = "base" }) => { - const [variants, setVariants] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); +const VariantsPanel = ({ searchTerm, group = "base", versionsData, versionsLoading, versionsError, onDismissError }) => { + const variants = React.useMemo(() => mapVersionsToRows(versionsData), [versionsData]); - React.useEffect(() => { - let active = true; - setLoading(true); - setError(null); - getVersions(group, searchTerm) - .then(data => { - if (active) setVariants(mapVersionsToRows(data)); - }) - .catch(err => { - if (active) { - setError(err); - setVariants([]); - } - }) - .finally(() => { - if (active) setLoading(false); - }); - return () => { active = false; }; - }, [group, searchTerm]); - - if (loading) { + if (versionsLoading) { return } - if (error) { + if (versionsError) { return setError(null)} + onClose={onDismissError || (() => {})} title="Failed to load variants" - error={error} + error={versionsError} /> } @@ -100,7 +77,11 @@ const VariantsPanel = ({ searchTerm, group = "base" }) => { VariantsPanel.propTypes = { searchTerm: PropTypes.string, - group: PropTypes.string + group: PropTypes.string, + versionsData: PropTypes.object, + versionsLoading: PropTypes.bool, + versionsError: PropTypes.any, + onDismissError: PropTypes.func, } export default VariantsPanel; diff --git a/src/components/SingleTermView/Variants/VariantsTable.jsx b/src/components/SingleTermView/Variants/VariantsTable.jsx index 395e967a..8e507322 100644 --- a/src/components/SingleTermView/Variants/VariantsTable.jsx +++ b/src/components/SingleTermView/Variants/VariantsTable.jsx @@ -5,6 +5,7 @@ import { TableCell, TableContainer, TableRow, Paper, Chip, Typography, IconButton, Pagination, PaginationItem } from '@mui/material'; +import { Link } from 'react-router-dom'; import CustomTableHead from './CustomTableHead'; import {getComparator, stableSort} from "../../../helpers"; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; @@ -85,14 +86,14 @@ const VariantsTable = ({ rows, headCells, group, term }) => { {row.firstSeen} {row.tripleCount} - {row.identityGraph} + {row.identityRecord} diff --git a/src/components/SingleTermView/index.jsx b/src/components/SingleTermView/index.jsx index d3bb2d81..83cede42 100644 --- a/src/components/SingleTermView/index.jsx +++ b/src/components/SingleTermView/index.jsx @@ -10,7 +10,8 @@ import { Menu, MenuItem, CircularProgress, - Alert + Alert, + Snackbar } from "@mui/material"; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import ToggleButton from '@mui/material/ToggleButton'; @@ -48,6 +49,9 @@ import TermDialog from "../TermEditor/TermDialog"; import FeatureNotAvailableDialog from "../common/FeatureNotAvailableDialog"; import { GlobalDataContext } from "../../contexts/DataContext"; import { getRawData } from "../../api/endpoints"; +import { getVersions, addEntityToOntology, getOntologyTerms } from "../../api/endpoints/apiService"; +import { reportApiError } from "../../api/apiErrorBus"; +import ApiErrorDialog from "../common/ApiErrorDialog"; import { useTermData } from "../../hooks/useTermData"; const { gray200, gray600, error700 } = vars; @@ -107,10 +111,16 @@ const SingleTermView = () => { // Use the optimized term data hook instead of manual fetching const { termData, actualGroup, isUsingFallback, isLoadingTerm } = useTermData(term, group); + const [versionsData, setVersionsData] = useState(null); + const [versionsLoading, setVersionsLoading] = useState(true); + const [versionsError, setVersionsError] = useState(null); + const clearVersionsError = useCallback(() => setVersionsError(null), []); + // Remove redundant query logic - use term from URL params directly const searchTerm = term; const openDataFormatMenu = Boolean(dataFormatAnchorEl); - const { storedSearchTerm, updateStoredSearchTerm, user, activeOntology } = useContext(GlobalDataContext); + const { storedSearchTerm, updateStoredSearchTerm, user, activeOntology, setOntologyData } = useContext(GlobalDataContext); + const [ontologySnackbar, setOntologySnackbar] = useState(null); // { severity, message } // Whether the term currently in view is a member of the active ontology. const hasActiveOntology = !!activeOntology; @@ -251,6 +261,17 @@ const SingleTermView = () => { } }, [termData, updateStoredSearchTerm]); + useEffect(() => { + if (!actualGroup || !searchTerm) return; + let active = true; + setVersionsLoading(true); + setVersionsError(null); + getVersions(actualGroup, searchTerm) + .then(data => { if (active) { setVersionsData(data); setVersionsLoading(false); } }) + .catch(err => { if (active) { setVersionsError(err); setVersionsLoading(false); } }); + return () => { active = false; }; + }, [actualGroup, searchTerm]); + // Optimize tab URL synchronization useEffect(() => { const newTabValue = tabMapping[tab] !== undefined ? tabMapping[tab] : 0; @@ -259,11 +280,11 @@ const SingleTermView = () => { setTabValue(newTabValue); } - // If no tab is specified in URL, redirect to overview - if (!tab && group && term) { + // If no tab is specified in URL (and not a version view), redirect to overview + if (!tab && !versionHash && group && term) { navigate(`/${group}/${term}/overview`, { replace: true }); } - }, [tab, tabMapping, navigate, group, term, tabValue]); + }, [tab, tabMapping, navigate, group, term, tabValue, versionHash]); const isItFork = actualGroup === 'base' ? false : true; // Use actualGroup instead of group @@ -273,15 +294,15 @@ const SingleTermView = () => { case 0: return ; case 1: - return ; + return ; case 2: - return ; + return ; case 3: return ; default: return ; } - }, [tabValue, searchTerm, isCodeViewVisible, selectedDataFormat, actualGroup, versionHash]); + }, [tabValue, searchTerm, isCodeViewVisible, selectedDataFormat, actualGroup, versionHash, versionsData, versionsLoading, versionsError, clearVersionsError]); // Memoize the toggle button group for overview tab const toggleButtonGroup = useMemo(() => { @@ -320,9 +341,24 @@ const SingleTermView = () => { ); }, [tabValue, isCodeViewVisible, selectedDataFormat, toggleButtonValue, onToggleButtonChange]); - const handleAddToActiveOntology = () => { - handleOpenFeatureNotAvailableDialog(); - }; + const handleAddToActiveOntology = useCallback(async () => { + if (!activeOntology || !actualGroup || !searchTerm) return; + const ontologyUri = activeOntology.description || activeOntology.url; + const result = await addEntityToOntology({ group: actualGroup, ontologyUri, termId: searchTerm }); + if (result.success) { + setOntologySnackbar({ severity: 'success', message: `Term added to "${activeOntology.label}".` }); + getOntologyTerms(ontologyUri) + .then(terms => setOntologyData({ ...activeOntology, terms })) + .catch(() => {}); + } else { + reportApiError({ + context: `Add term to ontology "${activeOntology.label}"`, + url: result.url || ontologyUri, + status: result.status, + message: result.body || result.error || 'Request failed with no body.', + }); + } + }, [activeOntology, actualGroup, searchTerm, setOntologyData]); const handleCreateFork = () => { handleOpenFeatureNotAvailableDialog(); @@ -465,10 +501,14 @@ const SingleTermView = () => { {/* TODO: Re-enable when merge request feature is implemented */} - + {/* Feature Not Available Dialog */} @@ -476,6 +516,17 @@ const SingleTermView = () => { open={featureNotAvailableDialog} onClose={handleCloseFeatureNotAvailableDialog} /> + + setOntologySnackbar(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setOntologySnackbar(null)} severity={ontologySnackbar?.severity} sx={{ width: '100%' }}> + {ontologySnackbar?.message} + + ) } diff --git a/src/components/TermEditor/TermDialog.jsx b/src/components/TermEditor/TermDialog.jsx index f2f045ef..55853d4d 100644 --- a/src/components/TermEditor/TermDialog.jsx +++ b/src/components/TermEditor/TermDialog.jsx @@ -45,7 +45,7 @@ const HeaderRightSideContent = ({ activeStep, onContinue, onClose, isContinueBut ); -const TermDialog = ({ open, handleClose, searchTerm, forwardPredicateStep }) => { +const TermDialog = ({ open, handleClose, searchTerm, group, forwardPredicateStep }) => { const [activeStep, setActiveStep] = useState(0); const [areMatchesChecked, setAreMatchesChecked] = useState(false); @@ -82,6 +82,7 @@ const TermDialog = ({ open, handleClose, searchTerm, forwardPredicateStep }) => ) : ( @@ -108,6 +109,7 @@ TermDialog.propTypes = { open: PropTypes.bool.isRequired, handleClose: PropTypes.func.isRequired, searchTerm: PropTypes.string, + group: PropTypes.string, forwardPredicateStep: PropTypes.bool }; diff --git a/src/components/TermEditor/TermDialogContent.jsx b/src/components/TermEditor/TermDialogContent.jsx index 4fea1780..537d8fc3 100644 --- a/src/components/TermEditor/TermDialogContent.jsx +++ b/src/components/TermEditor/TermDialogContent.jsx @@ -1,10 +1,10 @@ -import { debounce } from 'lodash'; import TermForm from "./TermForm"; import PropTypes from 'prop-types'; import TermSidebar from "./TermSidebar"; import StatusStep from "../common/StatusStep"; import AddPredicatesStep from "./AddPredicatesStep"; -import { elasticSearch } from "../../api/endpoints"; +import { getRawData } from "../../api/endpoints/apiService"; +import termParser from "../../parsers/termParser"; import { getTermStatusProps } from "./termStatusProps"; import { Box, Stack, Typography, Chip } from "@mui/material"; import AddOutlinedIcon from "@mui/icons-material/AddOutlined"; @@ -13,7 +13,7 @@ import { useState, useEffect, useMemo, useCallback } from "react"; import { vars } from "../../theme/variables"; const { success600, success700 } = vars; -const TermDialogContent = ({ activeStep, searchTerm, onReset }) => { +const TermDialogContent = ({ activeStep, searchTerm, group, onReset }) => { const [loading, setLoading] = useState(true); const [openSidebar, setOpenSidebar] = useState(true); // eslint-disable-next-line no-unused-vars @@ -33,20 +33,19 @@ const TermDialogContent = ({ activeStep, searchTerm, onReset }) => { comment: '' }); - // eslint-disable-next-line react-hooks/exhaustive-deps - const fetchTerms = useCallback( - debounce(async(searchTerm) => { - setLoading(true); - if (searchTerm) { - const data = await elasticSearch(searchTerm); - setData(data); - setLoading(false); - } else { - setLoading(false); - } - }, 300), - [] - ); + const fetchTermData = useCallback(async (term, grp) => { + if (!term || !grp) return; + setLoading(true); + try { + const raw = await getRawData(grp, term, 'jsonld'); + const parsed = termParser(raw, term)?.results?.[0] || null; + setData(parsed); + } catch (e) { + console.error('Error fetching term data for dialog:', e); + } finally { + setLoading(false); + } + }, []); const handleSidebarToggle = () => setOpenSidebar(!openSidebar); const handleFormInputChange = (e) => { @@ -68,19 +67,19 @@ const TermDialogContent = ({ activeStep, searchTerm, onReset }) => { }; useEffect(() => { - fetchTerms(searchTerm); - return () => { - fetchTerms.cancel(); - }; - }, [searchTerm, fetchTerms]); + fetchTermData(searchTerm, group); + }, [searchTerm, group, fetchTermData]); useEffect(() => { if (data) { + const subClassOf = Array.isArray(data.subClassOf) ? data.subClassOf[0] : (data.subClassOf || ''); setFormState((prevState) => ({ ...prevState, + label: data.label || prevState.label, synonyms: data.synonym || [], existingIds: data.existingID || [], - description: data.description || '' + description: data.description || '', + superclass: subClassOf, })); } }, [data]); @@ -150,6 +149,7 @@ const TermDialogContent = ({ activeStep, searchTerm, onReset }) => { TermDialogContent.propTypes = { activeStep: PropTypes.number.isRequired, searchTerm: PropTypes.string.isRequired, + group: PropTypes.string, onReset: PropTypes.func.isRequired } diff --git a/src/components/TermEditor/TermForm.jsx b/src/components/TermEditor/TermForm.jsx index 36109dcb..ce0015d5 100644 --- a/src/components/TermEditor/TermForm.jsx +++ b/src/components/TermEditor/TermForm.jsx @@ -63,10 +63,7 @@ const styles = { }; const TermForm = ({ formState, data, onInputChange, onAutocompleteChange }) => { - - if (!data) { - return
No data available
; - } + const safeData = data ?? {}; return ( @@ -93,7 +90,7 @@ const TermForm = ({ formState, data, onInputChange, onAutocompleteChange }) => { option} value={formState?.synonyms} @@ -133,7 +130,7 @@ const TermForm = ({ formState, data, onInputChange, onAutocompleteChange }) => { option} value={formState?.existingIds} diff --git a/src/components/TermEditor/newTerm/AddNewTermDialog.jsx b/src/components/TermEditor/newTerm/AddNewTermDialog.jsx index 53212307..58e37c81 100644 --- a/src/components/TermEditor/newTerm/AddNewTermDialog.jsx +++ b/src/components/TermEditor/newTerm/AddNewTermDialog.jsx @@ -1,4 +1,5 @@ -import { useState, useCallback, useContext, useMemo } from "react"; +import { useState, useCallback, useContext, useMemo, useRef } from "react"; +import { useNavigate } from "react-router-dom"; import PropTypes from "prop-types"; import { Box, @@ -17,7 +18,9 @@ import FirstStepContent from "./FirstStepContent"; import SecondStepContent from "./SecondStepContent"; import StatusStep from "../../common/StatusStep"; import { GlobalDataContext } from "../../../contexts/DataContext"; -import { createNewEntity } from "../../../api/endpoints/apiService"; +import { createNewEntity, addEntityToOntology, patchTermPredicates } from "../../../api/endpoints/apiService"; +import { buildExpandContext } from "../../../configuration/predicateConfig"; +import { expandIri } from "../../../parsers/predicateMutations"; import { getAddTermStatusProps } from '../termStatusProps'; import { CheckedIcon, UncheckedIcon } from '../../../Icons'; import { vars } from "../../../theme/variables"; @@ -25,39 +28,46 @@ import { DEFAULT_TYPE } from "../../../constants/types"; const { gray100, gray200, gray400, gray600 } = vars; +const STEP_BUTTON_LABEL = ['Create new', 'Continue', 'Continue']; + const HeaderRightSideContent = ({ activeStep, onContinue, onClose, + onFinish, isCreateButtonDisabled, isEditing, - userGroupname + userGroupname, + ontologyChecked, + onOntologyChange, }) => { - const [ontologyChecked, setOntologyChecked] = useState(false); - const handleOntologyChange = (event) => { - setOntologyChecked(event.target.checked); + onOntologyChange(event.target.checked); }; return ( {activeStep !== 2 ? ( <> - } - checkedIcon={} - checked={ontologyChecked} - onChange={handleOntologyChange} + {activeStep === 0 && ( + <> + } + checkedIcon={} + checked={ontologyChecked} + onChange={handleOntologyChange} + /> + } + sx={{ color: gray600 }} + label="Add to ontology" /> - } - sx={{ color: gray600 }} - label="Add to ontology" - /> - - + + + + )} - {isEditing ? 'Edit term' : 'Create new'} + {activeStep === 0 && isEditing ? 'Edit term' : STEP_BUTTON_LABEL[activeStep]}
) : ( - + )} ) @@ -96,14 +106,19 @@ HeaderRightSideContent.propTypes = { activeStep: PropTypes.number.isRequired, onContinue: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, + onFinish: PropTypes.func.isRequired, isCreateButtonDisabled: PropTypes.bool.isRequired, isEditing: PropTypes.bool.isRequired, - userGroupname: PropTypes.string + userGroupname: PropTypes.string, + ontologyChecked: PropTypes.bool.isRequired, + onOntologyChange: PropTypes.func.isRequired, }; const AddNewTermDialog = ({ open, handleClose }) => { + const navigate = useNavigate(); const [activeStep, setActiveStep] = useState(0); - const [addTermResponse, setAddTermResponse] = useState(null); + const [addTermResponse, setAddTermResponse] = useState(null); // termId string (e.g. tmp_000000146) + const [addTermStatus, setAddTermStatus] = useState(null); // synthetic { status } for StatusStep const [selectedType, setSelectedType] = useState(DEFAULT_TYPE); const [termValue, setTermValue] = useState(""); const [selectedTermValue, setSelectedTermValue] = useState(""); @@ -112,7 +127,9 @@ const AddNewTermDialog = ({ open, handleClose }) => { const [loading, setLoading] = useState(false); const [hasExactMatch, setHasExactMatch] = useState(false); const [isEditing, setIsEditing] = useState(false); - const { user } = useContext(GlobalDataContext); + const [ontologyChecked, setOntologyChecked] = useState(false); + const secondStepRef = useRef(null); + const { user, activeOntology, curies } = useContext(GlobalDataContext); const isCreateButtonDisabled = useMemo(() => { if (hasExactMatch) return true; @@ -124,11 +141,13 @@ const AddNewTermDialog = ({ open, handleClose }) => { return false; }, [hasExactMatch, termValue, isEditing, selectedTermValue]); - const statusProps = getAddTermStatusProps(addTermResponse, termValue); + const statusProps = getAddTermStatusProps(addTermStatus, termValue); const handleCancelBtnClick = () => { handleClose(); setActiveStep(0); + setAddTermResponse(null); + setAddTermStatus(null); }; const handleTermValueChange = (value) => { @@ -177,7 +196,6 @@ const AddNewTermDialog = ({ open, handleClose }) => { setLoading(true); - const token = localStorage.getItem("token"); const groupName = user?.groupname || "base"; const body = { 'rdf-type': selectedType || 'owl:Class', @@ -185,36 +203,88 @@ const AddNewTermDialog = ({ open, handleClose }) => { }; try { - const response = await createNewEntity({ - group: groupName, - data: body, - session: token - }); - - if (response.term && response.term.id) { - setActiveStep(1); - setAddTermResponse(response.term.id); + const response = await createNewEntity({ group: groupName, data: body }); + + if (!response.termId) { + console.error("Creation failed: no term ID in response", response.raw); + return; } + + if (ontologyChecked && activeOntology?.description) { + await addEntityToOntology({ + group: groupName, + ontologyUri: activeOntology.description, + termId: response.termId, + }); + } + + setAddTermResponse(response.termId); + setAddTermStatus({ status: 200 }); + setActiveStep(1); } catch (error) { console.error("Creation failed:", error); } finally { setLoading(false); } - }, [termValue, selectedType, user, hasExactMatch]); + }, [termValue, selectedType, user, hasExactMatch, ontologyChecked, activeOntology]); + + const handleFinish = useCallback(() => { + const groupName = user?.groupname || "base"; + handleClose(); + setActiveStep(0); + setAddTermResponse(null); + setAddTermStatus(null); + if (addTermResponse) { + navigate(`/${groupName}/${addTermResponse}`); + } + }, [user, addTermResponse, handleClose, navigate]); + + const patchNewTerm = useCallback(async () => { + const groupName = user?.groupname || "base"; + const termId = addTermResponse; + const formData = secondStepRef.current?.getFormData?.(); + + const termIri = `http://uri.interlex.org/${groupName}/${termId}`; + const ctx = buildExpandContext(curies?.base ?? []); + const triples = []; + + if (formData?.definition) { + triples.push([termIri, expandIri('definition', ctx), { type: 'literal', value: formData.definition }]); + } + if (formData?.comment) { + triples.push([termIri, expandIri('rdfs:comment', ctx), { type: 'literal', value: formData.comment }]); + } + for (const p of formData?.predicates ?? []) { + if (p.predicate && p.object?.value) { + const subject = p.subject || termIri; + const pred = expandIri(p.predicate, ctx); + triples.push([subject, pred, { type: p.object.isLink ? 'uri' : 'literal', value: p.object.value }]); + } + } + + if (triples.length > 0) { + setLoading(true); + try { + await patchTermPredicates({ group: groupName, termId, add: triples }); + } catch (error) { + console.error("PATCH failed:", error); + } finally { + setLoading(false); + } + } + + setActiveStep(2); + }, [user, addTermResponse, curies]); const handleAction = useCallback(() => { - if (isEditing) { + if (activeStep === 1) { + patchNewTerm(); + } else if (isEditing) { editTerm(); } else { createNewTerm(); } - }, [isEditing, editTerm, createNewTerm]); - - if (loading) { - return - - - } + }, [activeStep, isEditing, editTerm, createNewTerm, patchNewTerm]); return ( { activeStep={activeStep} onClose={handleCancelBtnClick} onContinue={handleAction} - isCreateButtonDisabled={isCreateButtonDisabled} + onFinish={handleFinish} + isCreateButtonDisabled={isCreateButtonDisabled || loading} isEditing={isEditing} userGroupname={user?.groupname} + ontologyChecked={ontologyChecked} + onOntologyChange={setOntologyChecked} /> } sx={{ '& .MuiDialogContent-root': { padding: 0, overflowY: "hidden" } }} > - {activeStep === 0 && } - {activeStep === 1 && } - {activeStep === 2 && addTermResponse != null && ( + {loading ? ( + + + + ) : activeStep === 0 ? ( + + ) : activeStep === 1 ? ( + + ) : ( )} diff --git a/src/components/TermEditor/newTerm/SecondStepContent.jsx b/src/components/TermEditor/newTerm/SecondStepContent.jsx index ca19089e..830b4d88 100644 --- a/src/components/TermEditor/newTerm/SecondStepContent.jsx +++ b/src/components/TermEditor/newTerm/SecondStepContent.jsx @@ -1,5 +1,4 @@ -import React from "react" -import { useState } from "react" +import React, { useState, forwardRef, useImperativeHandle } from "react" import PropTypes from "prop-types"; import { Box, Grid, Typography, FormControl, Autocomplete, Chip, TextField, Divider, Button } from "@mui/material" import AddIcon from "@mui/icons-material/Add" @@ -41,7 +40,7 @@ const URI_PREFIX_BOX_STYLES = { padding: "0.5rem 0.75rem", } -const SecondStepContent = ({ searchTerm }) => { +const SecondStepContent = forwardRef(({ searchTerm }, ref) => { const [superclass, setSuperclass] = useState("") const [subclassOf, setSubclassOf] = useState("") const [definitionUrls, setDefinitionUrls] = useState([]) @@ -56,6 +55,10 @@ const SecondStepContent = ({ searchTerm }) => { }, ]) + useImperativeHandle(ref, () => ({ + getFormData: () => ({ definition, comment, predicates }), + }), [definition, comment, predicates]) + const [urlOptions] = useState([]) const handleDefinitionUrlsChange = (event, newValue) => { @@ -261,8 +264,9 @@ const SecondStepContent = ({ searchTerm }) => {
) -} +}) +SecondStepContent.displayName = 'SecondStepContent'; SecondStepContent.propTypes = { searchTerm: PropTypes.string }; diff --git a/src/configuration/predicateConfig.ts b/src/configuration/predicateConfig.ts index 80d1ddcd..bff83750 100644 --- a/src/configuration/predicateConfig.ts +++ b/src/configuration/predicateConfig.ts @@ -81,6 +81,23 @@ const KNOWN_TERMS: Record = { "http://uri.interlex.org/base/ilx_0737162": "ilx.anno.hasRelatedSynonym", }; +// Reverse of KNOWN_TERMS: shortname -> full IRI (for predicate expansion before PATCH). +const KNOWN_TERMS_REVERSE: Record = Object.fromEntries( + Object.entries(KNOWN_TERMS).map(([iri, name]) => [name, iri]) +); + +// Build an expandIri-compatible context from the app's curies list. +// Merges KNOWN_PREFIXES + KNOWN_TERMS_REVERSE + caller-supplied curies. +export const buildExpandContext = ( + curies: Array<{ prefix: string; namespace: string }> = [] +): Record => { + const ctx: Record = {}; + for (const [prefix, base] of KNOWN_PREFIXES) ctx[prefix] = base; + for (const { prefix, namespace } of curies) ctx[prefix] = namespace; + Object.assign(ctx, KNOWN_TERMS_REVERSE); + return ctx; +}; + // Shorten a full predicate IRI to its curie; pass through curies/non-IRIs. export const shortenIri = (iri: string): string => { if (!iri || !/^https?:\/\//i.test(iri)) return iri; diff --git a/src/contexts/DataContext.jsx b/src/contexts/DataContext.jsx index 0e44f54b..756c3ff1 100644 --- a/src/contexts/DataContext.jsx +++ b/src/contexts/DataContext.jsx @@ -1,9 +1,27 @@ import PropTypes from 'prop-types'; -import {createContext, useState, useEffect} from "react"; +import { createContext, useState, useEffect, useRef } from "react"; import { API_CONFIG } from '../config'; +import { getOrganizationsCuries } from '../api/endpoints/apiService'; const GlobalDataContext = createContext(); +const transformCuriesResponse = (response) => { + let curiesObject; + if (Array.isArray(response) && response.length > 0) { + curiesObject = response[0]; + } else if (response && typeof response === 'object') { + curiesObject = response; + } + if (curiesObject && Object.keys(curiesObject).length > 0) { + return Object.entries(curiesObject).map(([prefix, namespace]) => ({ + prefix, + namespace, + _id: `existing_${prefix}_${namespace}` + })); + } + return []; +}; + const GlobalDataProvider = ({ children }) => { const [user, setUser] = useState(null); const [activeOntology, setActiveOntology] = useState(null); @@ -14,46 +32,62 @@ const GlobalDataProvider = ({ children }) => { const [storedSearchTerm, setStoredSearchTerm] = useState(""); const [ontologiesRefreshKey, setOntologiesRefreshKey] = useState(0); const [loading, setLoading] = useState(true); + const [curies, setCuries] = useState({ base: [], curated: [], latest: [] }); + const [curiesLoading, setCuriesLoading] = useState(true); + const userCuriesLoaded = useRef(false); - // Bump to signal consumers (e.g. OntologySearch) to re-fetch the ontology list. const refreshOntologies = () => setOntologiesRefreshKey((key) => key + 1); - const setOntologyData = (ontology) => { - setActiveOntology(ontology); - }; - - const setOrganizationFiltersData = (filters) => { - setSearchOrganizationFilters(filters); - }; - - const setTypeFiltersData = (filters) => { - setSearchTypeFilter(filters); - }; - - const setPredicatesSingleTermData = (filters) => { - setPredicatesSingleTermState(filters); - }; - - const setEditBulkSearchData = (filters) => { - setEditBulkSearchFilters(filters); - }; - - const setUserData = (user) => { - setUser(user); - } - - const updateStoredSearchTerm = (value) => { - setStoredSearchTerm(value) - } + const setOntologyData = (ontology) => setActiveOntology(ontology); + const setOrganizationFiltersData = (filters) => setSearchOrganizationFilters(filters); + const setTypeFiltersData = (filters) => setSearchTypeFilter(filters); + const setPredicatesSingleTermData = (filters) => setPredicatesSingleTermState(filters); + const setEditBulkSearchData = (filters) => setEditBulkSearchFilters(filters); + const setUserData = (user) => setUser(user); + const updateStoredSearchTerm = (value) => setStoredSearchTerm(value); + const setCuriesData = (newCuries) => setCuries(newCuries); useEffect(() => { const userSettings = localStorage.getItem(API_CONFIG.SESSION_DATA.SETTINGS); - - if(userSettings) { - setUser(JSON.parse(userSettings)) + if (userSettings) { + setUser(JSON.parse(userSettings)); } + setLoading(false); + }, []); - setLoading(false) - }, []) + // Fetch /base/curies once — populates Curated + Latest, and "My curies" fallback when unauthenticated + useEffect(() => { + let cancelled = false; + setCuriesLoading(true); + getOrganizationsCuries('base') + .catch(err => err?.response?.status === 501 ? [{}] : Promise.reject(err)) + .then(r => { + if (cancelled) return; + const data = transformCuriesResponse(r); + setCuries(prev => ({ + base: userCuriesLoaded.current ? prev.base : data, + curated: data, + latest: data + })); + }) + .catch(err => console.error('Error fetching base curies:', err)) + .finally(() => { if (!cancelled) setCuriesLoading(false); }); + return () => { cancelled = true; }; + }, []); + + // Fetch user's own curies when authenticated + useEffect(() => { + if (!user?.groupname) return; + let cancelled = false; + getOrganizationsCuries(user.groupname) + .catch(err => err?.response?.status === 501 ? [{}] : Promise.reject(err)) + .then(r => { + if (cancelled) return; + userCuriesLoaded.current = true; + setCuries(prev => ({ ...prev, base: transformCuriesResponse(r) })); + }) + .catch(err => console.error('Error fetching user curies:', err)); + return () => { cancelled = true; }; + }, [user?.groupname]); const dataContextValue = { user, @@ -72,7 +106,10 @@ const GlobalDataProvider = ({ children }) => { updateStoredSearchTerm, ontologiesRefreshKey, refreshOntologies, - loading + loading, + curies, + curiesLoading, + setCuriesData, }; return ( diff --git a/src/parsers/termParser.tsx b/src/parsers/termParser.tsx index f6797438..283c0652 100644 --- a/src/parsers/termParser.tsx +++ b/src/parsers/termParser.tsx @@ -1,6 +1,7 @@ import { Term, Terms } from "./../model/frontend/terms"; import { termKeys, termPredicates } from "../configuration/model"; import { defaultTermFiltersSections } from "../configuration/filters"; +import { shortenIri } from "../configuration/predicateConfig"; /** * Takes in raw term data object from server and formats it into Term object @@ -14,9 +15,12 @@ export const getTerm = (data) => { let predicates = {}; let isAboutValue = null; + // "isAbout" may appear as a curie (base context) or as its full IRI (fork minimal context) + const ISABOUT_IRI = "http://purl.obolibrary.org/obo/IAO_0000136"; data?.["@graph"]?.forEach((object) => { - if (object["isAbout"]) { - isAboutValue = object["isAbout"]?.["@id"] || object["isAbout"]; + const raw = object["isAbout"] || object[ISABOUT_IRI]; + if (raw) { + isAboutValue = raw?.["@id"] || raw; } }); @@ -27,9 +31,21 @@ export const getTerm = (data) => { } }); + // Fork jsonld often has no isAbout wrapper — fall back to first owl:Class node + if (!matchedObject) { + data?.["@graph"]?.forEach((object) => { + if (!matchedObject && object["@type"] === "owl:Class") { + matchedObject = object; + } + }); + } + + if (!matchedObject) return term; + const keys = Object.keys(matchedObject); keys.forEach((key) => { - const predicate = key; + // Fork jsonld uses full IRIs as keys; normalize to curies before lookup + const predicate = shortenIri(key) || key; if (termPredicates[predicate]) { let value = matchedObject[key]; let dataToStore = value; diff --git a/src/parsers/versionAdapter.ts b/src/parsers/versionAdapter.ts new file mode 100644 index 00000000..6f003c44 --- /dev/null +++ b/src/parsers/versionAdapter.ts @@ -0,0 +1,44 @@ +import { shortenIri } from '../configuration/predicateConfig'; + +// Normalize @type to the curie string termParser/buildPredicateGroupsForFocus expect. +// The versions endpoint returns it as an array of full IRIs. +const normalizeType = (type: any): string | undefined => { + if (!type) return undefined; + const raw = Array.isArray(type) ? type[0] : type; + return shortenIri(raw) || raw; +}; + +// Unwrap expanded JSON-LD value objects to plain scalars. +// Regular getRawData returns compacted JSON-LD (context applied) so values are +// plain strings already. The versions endpoint returns expanded JSON-LD, so +// string values arrive as { "@value": "Brain" } — termParser only handles @id, +// not @value, causing React to receive objects as children. +const unwrapValue = (v: any): any => { + if (v == null) return v; + if (Array.isArray(v)) return v.map(unwrapValue); + if (typeof v === 'object' && '@value' in v) return v['@value']; + return v; +}; + +// Adapt the raw JSON-LD array from getTermVersion into the shape the pipeline expects: +// { "@graph": [{ "@id": ..., "@type": "owl:Class", ... }] } +// - @type: array of full IRIs → single curie string +// - property values: { "@value": x } → x (termParser handles @id objects already) +// - predicate keys (full IRIs) left as-is — termParser calls shortenIri on each key +export const adaptVersionJsonLd = (raw: any): any => { + const nodes: any[] = Array.isArray(raw) ? raw : raw != null ? [raw] : []; + const graph = nodes.map((node) => { + const out: Record = {}; + for (const key of Object.keys(node)) { + if (key === '@type') { + out['@type'] = normalizeType(node['@type']); + } else if (key === '@id') { + out['@id'] = node['@id']; + } else { + out[key] = unwrapValue(node[key]); + } + } + return out; + }); + return { '@graph': graph }; +}; diff --git a/vite.config.js b/vite.config.js index d87193b8..5a52a038 100644 --- a/vite.config.js +++ b/vite.config.js @@ -96,22 +96,23 @@ export default defineConfig({ console.log('Request:', proxyReq); }); proxy.on('proxyRes', (proxyRes, req, res) => { - console.log('Received response', res); console.log('Received Response from the Target:', proxyRes.statusCode, req.url); const location = proxyRes.headers['location']; - console.log('Received location', location); + const origin = req.headers.origin; if (proxyRes.statusCode === 303 && location) { - // Prevent browser from seeing the actual Location - delete proxyRes.headers['location']; - // Inject the location into a custom header we can use in Axios + // Convert 303 → 200 + JSON body so fetch() can read the location. + // (redirect: 'manual' returns opaque status-0 response; headers inaccessible.) + res.setHeader('Content-Type', 'application/json'); res.setHeader('X-Redirect-Location', location); + if (origin) res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Expose-Headers', 'X-Redirect-Location'); + res.writeHead(200); + res.end(JSON.stringify({ location })); + return; } - // Required for credentialed CORS - const origin = req.headers.origin; - if (origin) { - res.setHeader('Access-Control-Allow-Origin', origin); - } + if (origin) res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader('Access-Control-Expose-Headers', 'X-Redirect-Location'); }); @@ -327,8 +328,8 @@ export default defineConfig({ }); }, }, - // Pattern for ilx_ endpoints (bulk term editing) - matches any group - '^/[^/]+/ilx_[^/]+$': { + // Pattern for ilx_/tmp_ endpoints (bulk term editing + newly created terms) + '^/[^/]+/(ilx|tmp)_[^/]+$': { target: 'https://uri.olympiangods.org', changeOrigin: true, secure: false,