feat: update environment configuration and enhance API integration
- Remove versioning from docker-compose.yml - Enhance Vite configuration to support API proxying for development - Improve error handling and data normalization in API service - Add fallback values in PublicationsTable and ResearcherCard components - Update sync functionality in DashboardPage to handle backend responses more effectively - Refactor mockSyncResearcher to simulate backend response structure
This commit is contained in:
@@ -1,5 +1,3 @@
|
|||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
+16
-6
@@ -1,7 +1,17 @@
|
|||||||
# Base URL of the FastAPI backend (no trailing slash).
|
# URL base del backend FastAPI (sin barra final).
|
||||||
# Example for local dev: http://localhost:8000
|
#
|
||||||
VITE_API_URL=http://localhost:8000
|
# Déjalo VACÍO en desarrollo para que las peticiones pasen por el proxy
|
||||||
|
# de Vite (ver vite.config.js). El proxy reenvía /researchers y /health
|
||||||
|
# al destino indicado en VITE_API_PROXY_TARGET.
|
||||||
|
#
|
||||||
|
# En producción apunta directamente al backend, p. ej.
|
||||||
|
# VITE_API_URL=https://api.midominio.com
|
||||||
|
VITE_API_URL=
|
||||||
|
|
||||||
# Set to "true" while the backend is not yet implemented.
|
# Solo para dev: destino al que el proxy de Vite reenvía las peticiones.
|
||||||
# All API calls will be served by src/services/mocks.js instead of `fetch`.
|
# Cambia a http://backend:8000 si ejecutas el frontend dentro de docker-compose.
|
||||||
VITE_USE_MOCKS=true
|
VITE_API_PROXY_TARGET=http://localhost:8000
|
||||||
|
|
||||||
|
# Pon "true" SOLO si el backend no está disponible y quieres trabajar
|
||||||
|
# con los fixtures de src/services/mocks.js. En producción debe estar a "false".
|
||||||
|
VITE_USE_MOCKS=false
|
||||||
|
|||||||
@@ -59,9 +59,10 @@ export function PublicationsTable({
|
|||||||
const rows = needle
|
const rows = needle
|
||||||
? publications.filter(
|
? publications.filter(
|
||||||
(p) =>
|
(p) =>
|
||||||
p.title.toLowerCase().includes(needle) ||
|
(p.title ?? "").toLowerCase().includes(needle) ||
|
||||||
p.journal.toLowerCase().includes(needle) ||
|
(p.journal ?? "").toLowerCase().includes(needle) ||
|
||||||
String(p.publication_year).includes(needle),
|
String(p.publication_year ?? "").includes(needle) ||
|
||||||
|
(p.doi ?? "").toLowerCase().includes(needle),
|
||||||
)
|
)
|
||||||
: publications;
|
: publications;
|
||||||
return sortPublications(rows, sortKey, sortDir);
|
return sortPublications(rows, sortKey, sortDir);
|
||||||
@@ -153,12 +154,13 @@ export function PublicationsTable({
|
|||||||
{pub.title}
|
{pub.title}
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] text-ink-secondary">
|
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] text-ink-secondary">
|
||||||
{pub.journal}
|
{pub.journal || "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] font-medium text-ink-primary">
|
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] font-medium text-ink-primary">
|
||||||
{pub.publication_year}
|
{pub.publication_year ?? "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
|
{pub.doi ? (
|
||||||
<a
|
<a
|
||||||
href={`https://doi.org/${pub.doi}`}
|
href={`https://doi.org/${pub.doi}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -167,6 +169,11 @@ export function PublicationsTable({
|
|||||||
>
|
>
|
||||||
{pub.doi}
|
{pub.doi}
|
||||||
</a>
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="whitespace-nowrap font-mono text-xs text-ink-tertiary">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
<Badge type={pub.type} />
|
<Badge type={pub.type} />
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function ResearcherCard({ researcher, actions = null }) {
|
|||||||
|
|
||||||
<div className="min-w-[200px] flex-1">
|
<div className="min-w-[200px] flex-1">
|
||||||
<h2 className="mb-1 text-[22px] font-semibold text-ink-primary">
|
<h2 className="mb-1 text-[22px] font-semibold text-ink-primary">
|
||||||
{researcher.name}
|
{researcher.name || "Investigador sin nombre"}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-wrap items-center gap-2.5">
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
<div className="inline-flex items-center gap-1.5">
|
<div className="inline-flex items-center gap-1.5">
|
||||||
@@ -25,10 +25,14 @@ export function ResearcherCard({ researcher, actions = null }) {
|
|||||||
{researcher.orcid_id}
|
{researcher.orcid_id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{researcher.affiliation && (
|
||||||
|
<>
|
||||||
<span className="text-surface-border-strong">·</span>
|
<span className="text-surface-border-strong">·</span>
|
||||||
<span className="text-[13px] text-ink-secondary">
|
<span className="text-[13px] text-ink-secondary">
|
||||||
{researcher.affiliation}
|
{researcher.affiliation}
|
||||||
</span>
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 inline-flex items-center gap-1.5 text-ink-tertiary">
|
<div className="mt-2 inline-flex items-center gap-1.5 text-ink-tertiary">
|
||||||
<ClockIcon />
|
<ClockIcon />
|
||||||
|
|||||||
@@ -83,12 +83,25 @@ export function DashboardPage() {
|
|||||||
async function handleSync() {
|
async function handleSync() {
|
||||||
setSyncStatus("loading");
|
setSyncStatus("loading");
|
||||||
try {
|
try {
|
||||||
const updated = await syncResearcher(orcid);
|
const summary = await syncResearcher(orcid);
|
||||||
if (updated) setResearcher(updated);
|
|
||||||
await loadPublications();
|
if (summary?.status === "error") {
|
||||||
|
throw new Error(summary.message || "El backend rechazó la sincronización.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// El backend devuelve un resumen del SyncJob, no el researcher.
|
||||||
|
// Refrescamos ambos recursos en paralelo.
|
||||||
|
await Promise.all([loadResearcher(), loadPublications()]);
|
||||||
|
|
||||||
setSyncStatus("success");
|
setSyncStatus("success");
|
||||||
|
const total = summary?.total ?? 0;
|
||||||
|
const nuevos = summary?.new_records ?? 0;
|
||||||
|
const actualizados = summary?.updated_records ?? 0;
|
||||||
toast.success("Sincronización completada", {
|
toast.success("Sincronización completada", {
|
||||||
description: "Las publicaciones se han actualizado desde ORCID.",
|
description:
|
||||||
|
total > 0
|
||||||
|
? `${nuevos} nuevas · ${actualizados} actualizadas (${total} total).`
|
||||||
|
: summary?.message ?? "Sin cambios desde la última sincronización.",
|
||||||
});
|
});
|
||||||
setTimeout(() => setSyncStatus("idle"), SUCCESS_FLASH_MS);
|
setTimeout(() => setSyncStatus("idle"), SUCCESS_FLASH_MS);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Thin API client for the FastAPI backend.
|
* Cliente HTTP del frontend contra la API FastAPI.
|
||||||
*
|
*
|
||||||
* Every call returns parsed JSON (or a Blob for file downloads) and throws
|
* Cada función devuelve el JSON ya parseado (o un Blob para descargas)
|
||||||
* an `ApiError` on non-2xx responses so callers can decide how to surface it
|
* y lanza `ApiError` en respuestas no 2xx, de forma que cada pantalla
|
||||||
* (toast, inline error, retry, etc.).
|
* decide cómo mostrarlo (toast, error inline, reintento, …).
|
||||||
*
|
*
|
||||||
* The base URL is injected at build time via `VITE_API_URL`
|
* La URL base se inyecta en build via `VITE_API_URL` (ver `.env.example`).
|
||||||
* (see `.env.example`). During development, leaving it blank falls back to
|
* En desarrollo la dejamos en blanco para que las peticiones pasen por
|
||||||
* same-origin requests, which plays well with a Vite proxy.
|
* el proxy de Vite (ver `vite.config.js`) y así eludir CORS mientras el
|
||||||
|
* backend no lo tenga configurado.
|
||||||
|
*
|
||||||
|
* Contrato real del backend (prefijo de router: `/researchers`):
|
||||||
|
* - POST /researchers/?orcid_id=XXXX-XXXX-XXXX-XXXX (crea/upsert)
|
||||||
|
* - GET /researchers/{orcid_id}
|
||||||
|
* - POST /researchers/{orcid_id}/sync
|
||||||
|
* - GET /researchers/{orcid_id}/publications
|
||||||
|
* - GET /researchers/{orcid_id}/export/sword.xml
|
||||||
|
* - GET /researchers/{orcid_id}/export/sword.zip
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -20,9 +29,9 @@ import {
|
|||||||
const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
|
const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When the backend is not available yet, set `VITE_USE_MOCKS=true` in your
|
* Si el backend no está disponible, poner `VITE_USE_MOCKS=true` en
|
||||||
* `.env.local` to route every call through `mocks.js`. In production this
|
* `.env.local` para servir todas las llamadas desde `mocks.js`.
|
||||||
* flag MUST be unset.
|
* En producción debe estar desactivado.
|
||||||
*/
|
*/
|
||||||
const USE_MOCKS = import.meta.env.VITE_USE_MOCKS === "true";
|
const USE_MOCKS = import.meta.env.VITE_USE_MOCKS === "true";
|
||||||
|
|
||||||
@@ -42,7 +51,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
|
|||||||
signal,
|
signal,
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
...(body ? { "Content-Type": "application/json" } : {}),
|
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||||
...headers,
|
...headers,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -52,6 +61,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
|
|||||||
try {
|
try {
|
||||||
response = await fetch(url, init);
|
response = await fetch(url, init);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
|
if (cause?.name === "AbortError") throw cause;
|
||||||
throw new ApiError("No se pudo contactar con el servidor.", {
|
throw new ApiError("No se pudo contactar con el servidor.", {
|
||||||
status: 0,
|
status: 0,
|
||||||
payload: { cause: String(cause) },
|
payload: { cause: String(cause) },
|
||||||
@@ -63,7 +73,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
|
|||||||
try {
|
try {
|
||||||
payload = await response.json();
|
payload = await response.json();
|
||||||
} catch {
|
} catch {
|
||||||
/* response had no JSON body */
|
/* sin cuerpo JSON */
|
||||||
}
|
}
|
||||||
const detail =
|
const detail =
|
||||||
payload?.detail ?? payload?.message ?? response.statusText ?? "Error";
|
payload?.detail ?? payload?.message ?? response.statusText ?? "Error";
|
||||||
@@ -79,47 +89,86 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───────────────────────────── Mapeos ────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapta el esquema del backend (`pub_year`, campos opcionalmente `null`)
|
||||||
|
* al que espera la UI (`publication_year`, strings seguras para filtrar).
|
||||||
|
*/
|
||||||
|
function normalizePublication(p) {
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
put_code: p.put_code ?? null,
|
||||||
|
title: p.title || "Sin título",
|
||||||
|
journal: p.journal || "",
|
||||||
|
doi: p.doi || "",
|
||||||
|
publication_year: p.pub_year ?? null,
|
||||||
|
type: p.type || null,
|
||||||
|
hash_fingerprint: p.hash_fingerprint ?? null,
|
||||||
|
last_modified: p.last_modified ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/* ───────────────────────────── Endpoints ─────────────────────────────── */
|
/* ───────────────────────────── Endpoints ─────────────────────────────── */
|
||||||
|
|
||||||
/** POST /api/orcid/validate — validates an ORCID iD and returns the researcher. */
|
/**
|
||||||
export function validateOrcid(orcidId, { signal } = {}) {
|
* Asegura que el investigador existe en el backend y devuelve su ficha
|
||||||
|
* completa.
|
||||||
|
*
|
||||||
|
* Como el backend no expone un endpoint de validación puro, hacemos:
|
||||||
|
* 1. POST /researchers/?orcid_id=... (idempotente: crea o devuelve el
|
||||||
|
* existente; valida formato + dígito de control en el servidor).
|
||||||
|
* 2. GET /researchers/{orcid_id} (para recuperar el objeto completo:
|
||||||
|
* name, last_sync_at, etc.).
|
||||||
|
*/
|
||||||
|
export async function validateOrcid(orcidId, { signal } = {}) {
|
||||||
if (USE_MOCKS) return mockValidateOrcid(orcidId);
|
if (USE_MOCKS) return mockValidateOrcid(orcidId);
|
||||||
return request("/api/orcid/validate", {
|
|
||||||
method: "POST",
|
await request(
|
||||||
body: { orcid_id: orcidId },
|
`/researchers/?orcid_id=${encodeURIComponent(orcidId)}`,
|
||||||
signal,
|
{ method: "POST", signal },
|
||||||
});
|
);
|
||||||
|
return request(`/researchers/${encodeURIComponent(orcidId)}`, { signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/researchers/{orcid}/publications — lists ORCID works. */
|
/** GET /researchers/{orcid}/publications — normalizado para la UI. */
|
||||||
export function getPublications(orcidId, { signal } = {}) {
|
export async function getPublications(orcidId, { signal } = {}) {
|
||||||
if (USE_MOCKS) return mockGetPublications(orcidId);
|
if (USE_MOCKS) return mockGetPublications(orcidId);
|
||||||
return request(
|
|
||||||
`/api/researchers/${encodeURIComponent(orcidId)}/publications`,
|
const raw = await request(
|
||||||
|
`/researchers/${encodeURIComponent(orcidId)}/publications`,
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
return Array.isArray(raw) ? raw.map(normalizePublication) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/researchers/{orcid}/sync — triggers ORCID re-harvest. */
|
/**
|
||||||
|
* POST /researchers/{orcid}/sync — dispara el re-harvest desde ORCID.
|
||||||
|
*
|
||||||
|
* El backend devuelve un resumen del job (`{status, message, new_records,
|
||||||
|
* updated_records, total, researcher}`), no el researcher completo.
|
||||||
|
* El caller debe refetch-ear el researcher y sus publicaciones.
|
||||||
|
*/
|
||||||
export function syncResearcher(orcidId, { signal } = {}) {
|
export function syncResearcher(orcidId, { signal } = {}) {
|
||||||
if (USE_MOCKS) return mockSyncResearcher(orcidId);
|
if (USE_MOCKS) return mockSyncResearcher(orcidId);
|
||||||
return request(`/api/researchers/${encodeURIComponent(orcidId)}/sync`, {
|
|
||||||
|
return request(`/researchers/${encodeURIComponent(orcidId)}/sync`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the public export URL so links/anchors can download files directly
|
* Construye la URL pública de exportación para enlaces directos
|
||||||
* without going through `fetch`. Used by the export dropdown.
|
* (sin pasar por `fetch`). La usa el dropdown de exportación.
|
||||||
*/
|
*/
|
||||||
export function getExportUrl(orcidId, format) {
|
export function getExportUrl(orcidId, format) {
|
||||||
return `${BASE_URL}/api/researchers/${encodeURIComponent(orcidId)}/export/sword.${format}`;
|
return `${BASE_URL}/researchers/${encodeURIComponent(orcidId)}/export/sword.${format}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads an export as a Blob (useful when we want to trigger a
|
* Descarga una exportación como Blob (para forzar descarga programática).
|
||||||
* programmatic file download). Falls back to `ApiError` on failure.
|
* Lanza `ApiError` en fallo.
|
||||||
*/
|
*/
|
||||||
export async function downloadExport(orcidId, format, { signal } = {}) {
|
export async function downloadExport(orcidId, format, { signal } = {}) {
|
||||||
if (USE_MOCKS) {
|
if (USE_MOCKS) {
|
||||||
@@ -131,15 +180,17 @@ export async function downloadExport(orcidId, format, { signal } = {}) {
|
|||||||
try {
|
try {
|
||||||
response = await fetch(url, { signal });
|
response = await fetch(url, { signal });
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
|
if (cause?.name === "AbortError") throw cause;
|
||||||
throw new ApiError("No se pudo contactar con el servidor.", {
|
throw new ApiError("No se pudo contactar con el servidor.", {
|
||||||
status: 0,
|
status: 0,
|
||||||
payload: { cause: String(cause) },
|
payload: { cause: String(cause) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ApiError(`No se pudo exportar el fichero ${format.toUpperCase()}.`, {
|
throw new ApiError(
|
||||||
status: response.status,
|
`No se pudo exportar el fichero ${format.toUpperCase()}.`,
|
||||||
});
|
{ status: response.status },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
return { blob, url };
|
return { blob, url };
|
||||||
|
|||||||
@@ -69,10 +69,14 @@ export async function mockGetPublications(/* orcidId */) {
|
|||||||
|
|
||||||
export async function mockSyncResearcher(orcidId) {
|
export async function mockSyncResearcher(orcidId) {
|
||||||
await delay(1800);
|
await delay(1800);
|
||||||
|
// Imita el payload real del backend (resumen del SyncJob, no el researcher).
|
||||||
return {
|
return {
|
||||||
...MOCK_RESEARCHER,
|
status: "ok",
|
||||||
orcid_id: orcidId,
|
message: "Sincronización completada correctamente.",
|
||||||
last_sync_at: new Date().toISOString(),
|
researcher: orcidId,
|
||||||
|
new_records: 0,
|
||||||
|
updated_records: MOCK_PUBLICATIONS.length,
|
||||||
|
total: MOCK_PUBLICATIONS.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,14 @@ export function formatDate(iso) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds researcher initials (max 2 chars) from a full name.
|
* Builds researcher initials (max 2 chars) from a full name.
|
||||||
|
* Si el backend aún no conoce el nombre, devolvemos un guion como
|
||||||
|
* placeholder para no dejar el avatar vacío.
|
||||||
*/
|
*/
|
||||||
export function getInitials(name = "") {
|
export function getInitials(name) {
|
||||||
return name
|
if (!name || typeof name !== "string") return "–";
|
||||||
.trim()
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return "–";
|
||||||
|
return trimmed
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.map((w) => w[0] ?? "")
|
.map((w) => w[0] ?? "")
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
|
|||||||
+21
-2
@@ -1,8 +1,27 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
const proxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:8000'
|
||||||
|
|
||||||
|
return {
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/researchers': {
|
||||||
|
target: proxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/health': {
|
||||||
|
target: proxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user