Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"react-router-dom": "^6.23.1",
"react-rte": "^0.16.5",
"react-syntax-highlighter": "^15.5.0",
"rxjs": "^7.8.2",
"yup": "^1.6.1"
},
"devDependencies": {
Expand Down
5 changes: 3 additions & 2 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,13 @@ const PageContainer = ({ children }) => {
const ProtectedRoute = ({ children }) => {
const { user } = useContext(GlobalDataContext);
const navigate = useNavigate();
const location = useLocation();

useEffect(() => {
if (!user) {
navigate('/login');
navigate('/login', { state: { from: location.pathname + location.search } });
}
}, [user, navigate]);
}, [user, navigate, location]);

return user ? children : null;
};
Expand Down
10 changes: 10 additions & 0 deletions src/api/apiErrorBus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Subject } from "rxjs";

// Cross-cutting channel for surfacing failed backend requests to the UI. API
// callers report failures here; a single dialog subscribes and shows the
// queried URL plus the error returned by the backend.
const apiError$$ = new Subject();

export const reportApiError = (error) => apiError$$.next(error);

export const apiError$ = apiError$$.asObservable();
46 changes: 42 additions & 4 deletions src/api/endpoints/apiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,36 @@ import termParser from "../../parsers/termParser";
import { jsonldToTriplesAndEdges, PART_OF_IRI } from '../../parsers/hierarchies-parser'
import { buildPredicateGroupsForFocus } from "../../parsers/predicateParser";

// Error enriched with the queried URL + the backend's message, so the UI can
// show a meaningful dialog instead of a bare "HTTP 404".
export interface ApiRequestError extends Error {
url?: string;
status?: number;
body?: string;
}

// Read a failed Response body and turn it into a clean, short message. Backend
// errors are often small HTML pages, so pull the <p> text / strip tags.
const buildRequestError = async (resp: Response, url: string): Promise<ApiRequestError> => {
let raw = "";
try {
raw = await resp.text();
} catch {
/* body not readable */
}
const para = raw.match(/<p>([\s\S]*?)<\/p>/i);
let message = (para ? para[1] : raw.replace(/<[^>]*>/g, " "))
.replace(/&#34;/g, '"').replace(/&quot;/g, '"').replace(/&amp;/g, "&")
.replace(/\s+/g, " ").trim();
if (message.length > 400) message = `${message.slice(0, 400)}…`;

const err = new Error(`HTTP ${resp.status}`) as ApiRequestError;
err.url = url;
err.status = resp.status;
err.body = message;
return err;
};

export interface LoginRequest {
username: string
password: string
Expand Down Expand Up @@ -361,10 +391,14 @@ export const getTermPredicates = async ({
headers: { Accept: "application/ld+json" },
credentials: "include",
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
if (!resp.ok) throw await buildRequestError(resp, url);
const ct = resp.headers.get("content-type") || "";
if (!/application\/(ld\+json|json)/i.test(ct)) {
throw new Error(`Server did not return JSON-LD (content-type: ${ct || "n/a"})`);
const err = new Error(`Server did not return JSON-LD (content-type: ${ct || "n/a"})`) as ApiRequestError;
err.url = url;
err.status = resp.status;
err.body = `Expected JSON-LD but received content-type: ${ct || "n/a"}`;
throw err;
}
const jsonld = await resp.json();

Expand All @@ -391,10 +425,14 @@ export const getTermHierarchies = async ({
headers: { Accept: "application/ld+json" },
credentials: "include",
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
if (!resp.ok) throw await buildRequestError(resp, url);
const ct = resp.headers.get("content-type") || "";
if (!/application\/(ld\+json|json)/i.test(ct)) {
throw new Error(`Server did not return JSON-LD (content-type: ${ct || "n/a"})`);
const err = new Error(`Server did not return JSON-LD (content-type: ${ct || "n/a"})`) as ApiRequestError;
err.url = url;
err.status = resp.status;
err.body = `Expected JSON-LD but received content-type: ${ct || "n/a"}`;
throw err;
}
const jsonld = await resp.json();

Expand Down
10 changes: 6 additions & 4 deletions src/components/Auth/Login.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { requestUserSettings } from "./utils";
import Checkbox from "@mui/material/Checkbox";
import PasswordField from "./UI/PasswordField";
import { ArrowBack } from "@mui/icons-material";
import { Link, useNavigate } from "react-router-dom";
import { Link, useNavigate, useLocation } from "react-router-dom";
import { login } from "../../api/endpoints/apiService";
import { GlobalDataContext } from "../../contexts/DataContext";
import { CheckedIcon, UncheckedIcon, OrcidIcon } from "../../Icons";
Expand Down Expand Up @@ -51,6 +51,8 @@ const Login = () => {

const { setUserData } = React.useContext(GlobalDataContext);
const navigate = useNavigate();
const location = useLocation();
const redirectTo = location.state?.from || "/";

React.useEffect(() => {
let eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
Expand Down Expand Up @@ -109,7 +111,7 @@ const Login = () => {
settings: userData
});
closePopups();
navigate("/", { replace: true });
navigate(redirectTo, { replace: true });
} catch (error) {
console.error("Error fetching user settings:", error);
removeCookie('session', { path: '/' });
Expand Down Expand Up @@ -185,7 +187,7 @@ const Login = () => {
settings: userData
});
closePopups();
navigate("/", { replace: true });
navigate(redirectTo, { replace: true });
} catch (error) {
console.error("Error fetching user settings:", error);
removeCookie('session', { path: '/' });
Expand All @@ -201,7 +203,7 @@ const Login = () => {
setUserData({ name: orcid_meta.name, id: orcid_meta.orcid });
}
closePopups();
navigate("/", { replace: true });
navigate(redirectTo, { replace: true });
}
} catch (error) {
console.error("Login error:", error);
Expand Down
181 changes: 90 additions & 91 deletions src/components/Dashboard/EditBulkTerms/EditBulkTermsDialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import CustomizedDialog from "../../common/CustomizedDialog";
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import SearchTermsData from "../../../static/SearchTermsData.json";
import { patchTerm } from "../../../api/endpoints";
import { patchEndpointsIlx } from "../../../api/endpoints/interLexURIStructureAPI";
import { expandIri } from "../../../parsers/predicateMutations";
import { GlobalDataContext } from "../../../contexts/DataContext";

const initialSearchConditions = { attribute: '', value: '', condition: 'where', relation: SearchTermsData.objectOptions[0].value }
Expand Down Expand Up @@ -76,8 +77,8 @@ const EditBulkTermsDialog = ({ open, handleClose, activeStep, setActiveStep }) =
const [ontologyTerms, setOntologyTerms] = useState([]);
const [ontologyAttributes, setOntologyAttributes] = useState([]);
const [selectedOntology, setSelectedOntology] = useState(null);
// eslint-disable-next-line no-unused-vars
const [originalTerms, setOriginalTerms] = useState([]);
const [jsonLdContext, setJsonLdContext] = useState({});
const [batchUpdateResults, setBatchUpdateResults] = useState(null);
const [isUpdating, setIsUpdating] = useState(false);

Expand All @@ -88,124 +89,121 @@ const EditBulkTermsDialog = ({ open, handleClose, activeStep, setActiveStep }) =
}
}, [open, activeOntology, selectedOntology]);

// Helper function to replace "base" with user groupname in term data
const replaceBaseWithUserGroup = (obj, userGroupname) => {
if (!obj || !userGroupname) return obj;

const replaceInValue = (value) => {
if (typeof value === 'string') {
return value.replace(/\bbase\b/g, userGroupname);
} else if (Array.isArray(value)) {
return value.map(replaceInValue);
} else if (value && typeof value === 'object') {
return replaceBaseWithUserGroup(value, userGroupname);
const rawNodeItemToRdfObject = (item, context) => {
if (item == null) return null;
if (typeof item === 'string') return { type: 'literal', value: item };
if (typeof item === 'object') {
if (item['@id']) return { type: 'uri', value: expandIri(item['@id'], context) };
if (item['@value'] != null) {
const obj = { type: 'literal', value: String(item['@value']) };
if (item['@language']) obj.lang = item['@language'];
if (item['@type']) obj.datatype = item['@type'];
return obj;
}
return value;
};

const result = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = replaceInValue(value);
return { type: 'literal', value: JSON.stringify(item) };
}
return result;
return { type: 'literal', value: String(item) };
};

const parseValueToRdfObjects = (value, context) => {
if (value == null || value === '') return [];
try {
const arr = JSON.parse('[' + value + ']');
if (Array.isArray(arr)) {
return arr.map(item => rawNodeItemToRdfObject(item, context)).filter(Boolean);
}
} catch (_e) { /* not valid JSON array, fall through to plain literal */ }
return [{ type: 'literal', value: String(value) }];
};

const performBatchUpdate = async (termsToUpdate = null) => {
setIsUpdating(true);

// Use provided terms or all ontology terms

const terms = termsToUpdate || ontologyTerms;

const results = {
successful: [],
failed: [],
total: terms.length
};
const results = { successful: [], failed: [], total: terms.length };

try {
// Store original terms before starting updates (only if not retrying)
if (!termsToUpdate) {
setOriginalTerms([...ontologyTerms]);
}
const originalByIri = {};
originalTerms.forEach(t => { originalByIri[t['@id']] = t; });

// Process each term
try {
for (const term of terms) {
const termIri = term['@id'];
let termId = termIri;
if (termId && termId.includes('/')) termId = termId.split('/').pop();
const group = user?.groupname || 'base';

try {
// Extract the group and term ID from the term
let termId = term.id || term['@id'];

// If termId is in full URI format, extract just the ID part
if (termId && termId.includes('/')) {
termId = termId.split('/').pop();
const originalTerm = originalByIri[termIri];
if (!originalTerm) {
results.failed.push({ termId, error: 'Original term not found', term });
continue;
}

// Use user's groupname instead of 'base'
const group = user?.groupname || 'base';

// Create the JSON-LD payload with the current term data, replacing base with user groupname
let jsonLdPayload = {
"@context": term["@context"] || {
"@vocab": `http://uri.interlex.org/${group}/`,
"owl": "http://www.w3.org/2002/07/owl#",
"rdfs": "http://www.w3.org/2000/01/rdf-schema#"
},
"@id": term['@id'] || termId,
...term
};

// Replace any "base" references with user's groupname in the payload
if (user?.groupname && user.groupname !== 'base') {
jsonLdPayload = replaceBaseWithUserGroup(jsonLdPayload, user.groupname);
const add = [];
const del = [];
const allKeys = new Set([
...Object.keys(term),
...Object.keys(originalTerm),
].filter(k => k !== '@id' && k !== '_rawNode'));

for (const predicate of allKeys) {
const oldValue = originalTerm[predicate];
const newValue = term[predicate];
if (oldValue === newValue) continue;
if (!oldValue && !newValue) continue;

const expandedPredicate = expandIri(predicate, jsonLdContext);
const rawNode = originalTerm['_rawNode'];

if (oldValue != null) {
const rawVal = rawNode ? rawNode[predicate] : null;
if (rawVal != null) {
const rawArr = Array.isArray(rawVal) ? rawVal : [rawVal];
rawArr.forEach(item => {
const rdfObj = rawNodeItemToRdfObject(item, jsonLdContext);
if (rdfObj) del.push([termIri, expandedPredicate, rdfObj]);
});
} else {
del.push([termIri, expandedPredicate, { type: 'literal', value: String(oldValue) }]);
}
}

if (newValue != null && newValue !== '') {
parseValueToRdfObjects(newValue, jsonLdContext).forEach(rdfObj => {
add.push([termIri, expandedPredicate, rdfObj]);
});
}
}

// Send PATCH request
const response = await patchTerm(group, termId, jsonLdPayload);

if (response.status === 200 || response.status === 201) {
results.successful.push({
termId,
term: response.term,
status: response.status
});
} else {
results.failed.push({
termId,
error: `HTTP ${response.status}`,
term
});
if (add.length === 0 && del.length === 0) {
results.successful.push({ termId, status: 'unchanged' });
continue;
}

await patchEndpointsIlx(group, termId, { data: { add, del } });
results.successful.push({ termId, status: 200 });
} catch (error) {
console.error(`Failed to update term ${term.id}:`, error);
results.failed.push({
termId: term.id,
error: error.message || 'Unknown error',
term
});
console.error(`Failed to update term ${termId}:`, error);
results.failed.push({ termId, error: error.message || 'Unknown error', term });
}
}

// If retrying, merge with existing results
if (termsToUpdate && batchUpdateResults) {
setBatchUpdateResults({
successful: [...batchUpdateResults.successful, ...results.successful],
failed: results.failed, // Replace failed list with new attempt results
failed: results.failed,
total: batchUpdateResults.total
});
} else {
setBatchUpdateResults(results);
}
} catch (error) {
console.error('Batch update failed:', error);
const failureResults = {
setBatchUpdateResults({
successful: termsToUpdate && batchUpdateResults ? batchUpdateResults.successful : [],
failed: terms.map(term => ({
termId: term.id,
error: error.message || 'Batch operation failed',
term
})),
failed: terms.map(t => ({ termId: t['@id'], error: error.message || 'Batch operation failed', term: t })),
total: termsToUpdate && batchUpdateResults ? batchUpdateResults.total : terms.length
};
setBatchUpdateResults(failureResults);
});
} finally {
setIsUpdating(false);
}
Expand Down Expand Up @@ -269,9 +267,9 @@ const EditBulkTermsDialog = ({ open, handleClose, activeStep, setActiveStep }) =
>
<>
{
activeStep === 0 && <SearchTerms
searchConditions={searchConditions}
setSearchConditions={setSearchConditions}
activeStep === 0 && <SearchTerms
searchConditions={searchConditions}
setSearchConditions={setSearchConditions}
initialSearchConditions={initialSearchConditions}
ontologyTerms={ontologyTerms}
setOntologyTerms={setOntologyTerms}
Expand All @@ -280,6 +278,7 @@ const EditBulkTermsDialog = ({ open, handleClose, activeStep, setActiveStep }) =
selectedOntology={selectedOntology}
setSelectedOntology={setSelectedOntology}
setOriginalTerms={setOriginalTerms}
setJsonLdContext={setJsonLdContext}
/>
}
{
Expand Down
Loading
Loading