From 4e69027441139dcb68b7e12ecbd1d07541e74b99 Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Sat, 17 Jan 2026 01:09:50 -0500 Subject: [PATCH 1/6] fix(store): remove user from localStorage persistence User data should come fresh from the server session via dehydratedAppState on each page load. Persisting it to localStorage could cause stale auth data to override fresh session data. --- src/store/store.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/store/store.ts b/src/store/store.ts index 80f653df2..b13984a70 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -50,7 +50,9 @@ export const createStore = (preloadedState: Partial = {}) => { persist(state, { name: APP_STORAGE_KEY, partialize: (state) => ({ - user: state.user, + // Note: user is NOT persisted - it comes fresh from the server session + // via dehydratedAppState on each page load. Persisting it can cause + // stale auth data to override fresh session data. mode: state.mode, numPerPage: state.numPerPage, settings: state.settings, From ff707eadd90d74a8248900522ba1206152b5b9fe Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Sat, 17 Jan 2026 01:10:00 -0500 Subject: [PATCH 2/6] fix(Select): add menuPortal z-index for dropdown visibility When Select is used inside TabPanels or other stacking contexts, the dropdown menu can render behind other elements. Added menuPortal style with high z-index to support portaled menu rendering. --- src/components/Select/Select.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 55315bfdb..c85ce3a1c 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -124,6 +124,10 @@ function SelectImpl< boxShadow: `0 0 0 1px ${colors.border}`, zIndex: 10, }), + menuPortal: (provided) => ({ + ...provided, + zIndex: 9999, + }), }, 'default.sm': { container: (provided) => ({ From bf3b79fcb3985cc394166252491d585de1a8af84 Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Sat, 17 Jan 2026 01:10:13 -0500 Subject: [PATCH 3/6] fix(mocks): improve MSW development environment support - Add localhost to CSP connect-src when mocking is enabled - Fix mock API base URL to use window origin for service worker interception - Generate valid mock token with proper expiry for authenticated testing - Add user-settings mock handler with sample custom formats --- next.config.mjs | 5 +++- src/api/config.ts | 3 ++- src/middlewares/initSession.ts | 10 ++++--- src/mocks/handlers.ts | 2 ++ src/mocks/handlers/accounts.ts | 3 ++- src/mocks/handlers/user-settings.ts | 42 +++++++++++++++++++++++++++++ 6 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 src/mocks/handlers/user-settings.ts diff --git a/next.config.mjs b/next.config.mjs index 8539df238..99e33f7d1 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -2,13 +2,16 @@ import withBundleAnalyzer from '@next/bundle-analyzer'; import { withSentryConfig } from '@sentry/nextjs'; import nextBuildId from 'next-build-id'; +// Allow http://localhost in development mode for MSW mocking +const devConnectSrc = process.env.NEXT_PUBLIC_API_MOCKING === 'enabled' ? 'http://localhost http://localhost:*' : ''; + const CSP = ` default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://www.google-analytics.com https://www.googletagmanager.com https://www.google.com https://recaptcha.google.com https://www.recaptcha.net https://recaptcha.net https://www.googleadservices.com https://www.googlesyndication.com https://www.gstatic.com https://www.gstatic.cn; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; base-uri 'self'; object-src 'none'; - connect-src 'self' https://*.google-analytics.com https://*.adsabs.harvard.edu https://o1060269.ingest.sentry.io https://scixplorer.org https://*.scixplorer.org https://www.googletagmanager.com https://www.google.com https://recaptcha.google.com https://www.recaptcha.net https://recaptcha.net https://www.gstatic.com https://www.gstatic.cn https://*.googleapis.com https://*.clients6.google.com https://cdn.jsdelivr.net; + connect-src 'self' ${devConnectSrc} https://*.google-analytics.com https://*.adsabs.harvard.edu https://o1060269.ingest.sentry.io https://scixplorer.org https://*.scixplorer.org https://www.googletagmanager.com https://www.google.com https://recaptcha.google.com https://www.recaptcha.net https://recaptcha.net https://www.gstatic.com https://www.gstatic.cn https://*.googleapis.com https://*.clients6.google.com https://cdn.jsdelivr.net; font-src 'self' data: https://cdnjs.cloudflare.com https://fonts.gstatic.com https://cdn.jsdelivr.net; frame-src https://www.youtube-nocookie.com https://www.google.com https://www.google.com/recaptcha/ https://recaptcha.google.com https://www.recaptcha.net https://recaptcha.net; form-action 'self'; diff --git a/src/api/config.ts b/src/api/config.ts index 08864c854..ad32fe4f2 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -7,8 +7,9 @@ import { APP_DEFAULTS } from '@/config'; * Server-side uses API_HOST_SERVER, client-side uses NEXT_PUBLIC_API_HOST_CLIENT. */ const resolveApiBaseUrl = (defaultBaseUrl = ''): string => { + // for mocking requests, use same origin as the app so the service worker can intercept if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled') { - return 'http://localhost'; + return typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8000'; } if (typeof window === 'undefined') { diff --git a/src/middlewares/initSession.ts b/src/middlewares/initSession.ts index bf619084c..d258074fa 100644 --- a/src/middlewares/initSession.ts +++ b/src/middlewares/initSession.ts @@ -65,15 +65,17 @@ const bootstrap = async ( tracingHeaders?: Record, ) => { if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled') { + // Return a properly formed mock token for development + const farFutureTimestamp = String(Math.floor(Date.now() / 1000) + 86400 * 365); // 1 year from now return { token: { - access_token: 'mocked', - username: 'mocked', + access_token: 'mocked-authenticated-token', + username: 'test@example.com', anonymous: false, - expires_at: 'mocked', + expires_at: farFutureTimestamp, }, headers: new Headers({ - 'set-cookie': `${process.env.ADS_SESSION_COOKIE_NAME}=mocked`, + 'set-cookie': `${process.env.ADS_SESSION_COOKIE_NAME}=mocked-session`, }), }; } diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 378d3a350..5532519d0 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -14,6 +14,7 @@ import { librariesHandlers } from '@/mocks/handlers/libraries'; import { notificationsHandlers } from './handlers/notifications'; import { resolverHandlers } from '@/mocks/handlers/resolver'; import { analyticsHandlers } from '@/mocks/handlers/analytics'; +import { userSettingsHandlers } from '@/mocks/handlers/user-settings'; export const handlers = [ ...accountHandlers, @@ -30,6 +31,7 @@ export const handlers = [ ...referenceHandlers, ...searchHandlers, ...userHandlers, + ...userSettingsHandlers, ...librariesHandlers, ...notificationsHandlers, ]; diff --git a/src/mocks/handlers/accounts.ts b/src/mocks/handlers/accounts.ts index 7cab11c13..aabe29677 100644 --- a/src/mocks/handlers/accounts.ts +++ b/src/mocks/handlers/accounts.ts @@ -15,7 +15,8 @@ export const accountHandlers = [ return res(ctx.status(500, 'Server Error')); } - if (scenario === 'bootstrap-authenticated') { + // Default to authenticated for easier development/testing + if (scenario !== 'bootstrap-anonymous') { return res( ctx.status(200), ctx.set('Set-Cookie', 'ads_session=authenticated-session; Domain=example.com; SameSite=None; Secure'), diff --git a/src/mocks/handlers/user-settings.ts b/src/mocks/handlers/user-settings.ts new file mode 100644 index 000000000..06b9b350a --- /dev/null +++ b/src/mocks/handlers/user-settings.ts @@ -0,0 +1,42 @@ +import { rest } from 'msw'; +import { apiHandlerRoute } from '@/mocks/mockHelpers'; +import { ApiTargets } from '@/api/models'; +import { DEFAULT_USER_DATA } from '@/api/user/models'; + +export const userSettingsHandlers = [ + // Site-wide message (usually empty for dev) + rest.get(apiHandlerRoute(ApiTargets.SITE_SIDE_MESSAGE), (req, res, ctx) => { + return res(ctx.status(200), ctx.json({})); + }), + + rest.get(apiHandlerRoute(ApiTargets.USER_DATA), (req, res, ctx) => { + const scenario = req.headers.get('x-test-scenario'); + + if (scenario === 'settings-network-error') { + return res.networkError('failure'); + } + + if (scenario === 'settings-failure') { + return res(ctx.status(500, 'Server Error')); + } + + // Return settings with sample custom formats for testing + return res( + ctx.status(200), + ctx.json({ + ...DEFAULT_USER_DATA, + customFormats: [ + { id: '1', name: 'Author Year Title', code: '%1H %Y %T' }, + { id: '2', name: 'BibCode Only', code: '%R' }, + { id: '3', name: 'Full Citation', code: '%1H:%Y:%q' }, + { id: '4', name: 'Short Reference', code: '%1H %Y' }, + ], + }), + ); + }), + + rest.post(apiHandlerRoute(ApiTargets.USER_DATA), (req, res, ctx) => { + // Echo back the settings that were saved + return res(ctx.status(200), ctx.json(req.body)); + }), +]; From 3be9843b486a2d84fc32ea55e167b7e6157c8d1a Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Sat, 17 Jan 2026 01:10:22 -0500 Subject: [PATCH 4/6] feat: add nuqs for URL state management Added nuqs library and NuqsAdapter wrapper to enable type-safe URL query parameter state management in the application. --- package.json | 1 + pnpm-lock.yaml | 107 ++++++++++++++++++++++++++------------------- src/pages/_app.tsx | 5 ++- 3 files changed, 65 insertions(+), 48 deletions(-) diff --git a/package.json b/package.json index 2d2066c24..c43f7698b 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "next": "16.1.1", "next-build-id": "^3.0.0", "nprogress": "^0.2.0", + "nuqs": "^2.8.6", "pino": "^8.16.2", "pino-pretty": "^10.2.3", "prop-types": "^15.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a999a535..0189a5d05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,6 +182,9 @@ importers: nprogress: specifier: ^0.2.0 version: 0.2.0 + nuqs: + specifier: ^2.8.6 + version: 2.8.6(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) pino: specifier: ^8.16.2 version: 8.21.0 @@ -377,7 +380,7 @@ importers: version: 9.32.0 eslint-config-next: specifier: ^16.1.1 - version: 16.1.1(@typescript-eslint/parser@8.53.0(eslint@9.32.0)(typescript@5.9.3))(eslint@9.32.0)(typescript@5.9.3) + version: 16.1.3(@typescript-eslint/parser@8.53.0(eslint@9.32.0)(typescript@5.9.3))(eslint@9.32.0)(typescript@5.9.3) husky: specifier: ^9.1.6 version: 9.1.7 @@ -710,8 +713,8 @@ packages: '@emnapi/core@1.4.5': resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} - '@emnapi/runtime@1.8.1': - resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} '@emnapi/wasi-threads@1.0.4': resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} @@ -1305,8 +1308,8 @@ packages: '@next/env@16.1.1': resolution: {integrity: sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==} - '@next/eslint-plugin-next@16.1.1': - resolution: {integrity: sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==} + '@next/eslint-plugin-next@16.1.3': + resolution: {integrity: sha512-MqBh3ltFAy0AZCRFVdjVjjeV7nEszJDaVIpDAnkQcn8U9ib6OEwkSnuK6xdYxMGPhV/Y4IlY6RbDipPOpLfBqQ==} '@next/swc-darwin-arm64@16.1.1': resolution: {integrity: sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==} @@ -2075,6 +2078,9 @@ packages: '@sinclair/typebox@0.34.38': resolution: {integrity: sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2412,8 +2418,8 @@ packages: '@types/lodash@4.17.20': resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} - '@types/lodash@4.17.23': - resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} + '@types/lodash@4.17.21': + resolution: {integrity: sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==} '@types/lucene@2.1.7': resolution: {integrity: sha512-i3J0OV0RoJSskOJUa76Hgz09deabWwfJajsUxc1M05HryjPpPEKqtRklKe0+O0XVhdrFIiFO1/SInXpDCacfNA==} @@ -2462,11 +2468,6 @@ packages: peerDependencies: '@types/react': ^18.0.0 - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 - '@types/react-is@17.0.7': resolution: {integrity: sha512-WrTEiT+c6rgq36QApoy0063uAOdltCrhF0QMXLIgYPaTvIdQhAB8hPb5oGGqX18xToElNILS9UprwU6GyINcJg==} @@ -2484,9 +2485,6 @@ packages: '@types/react@18.3.27': resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} - '@types/react@19.2.8': - resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==} - '@types/request-ip@0.0.38': resolution: {integrity: sha512-1yeq8UuK/tUBqLXRY24gjeFvrSNaGNcOcZLQjHlnuw8iu+qE/vTQ64TUcLWorr607NKLfFakdoYEXXHXrLLKCw==} @@ -3018,8 +3016,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.9.14: - resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==} + baseline-browser-mapping@2.9.15: + resolution: {integrity: sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==} hasBin: true better-react-mathjax@2.3.0: @@ -3103,9 +3101,6 @@ packages: caniuse-lite@1.0.30001760: resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} - caniuse-lite@1.0.30001764: - resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==} - chai@4.5.0: resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} engines: {node: '>=4'} @@ -3797,8 +3792,8 @@ packages: engines: {node: '>=6.0'} hasBin: true - eslint-config-next@16.1.1: - resolution: {integrity: sha512-55nTpVWm3qeuxoQKLOjQVciKZJUphKrNM0fCcQHAIOGl6VFXgaqeMfv0aKJhs7QtcnlAPhNVqsqRfRjeKBPIUA==} + eslint-config-next@16.1.3: + resolution: {integrity: sha512-q2Z87VSsoJcv+vgR+Dm8NPRf+rErXcRktuBR5y3umo/j5zLjIWH7rqBCh3X804gUGKbOrqbgsLUkqDE35C93Gw==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -4982,6 +4977,27 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nuqs@2.8.6: + resolution: {integrity: sha512-aRxeX68b4ULmhio8AADL2be1FWDy0EPqaByPvIYWrA7Pm07UjlrICp/VPlSnXJNAG0+3MQwv3OporO2sOXMVGA==} + peerDependencies: + '@remix-run/react': '>=2' + '@tanstack/react-router': ^1 + next: '>=14.2.0' + react: '>=18.2.0 || ^19.0.0-0' + react-router: ^5 || ^6 || ^7 + react-router-dom: ^5 || ^6 || ^7 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@tanstack/react-router': + optional: true + next: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + nwsapi@2.2.20: resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} @@ -6860,7 +6876,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.8.1': + '@emnapi/runtime@1.7.1': dependencies: tslib: 2.8.1 optional: true @@ -7249,7 +7265,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.8.1 + '@emnapi/runtime': 1.7.1 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -7410,7 +7426,7 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.4.5 - '@emnapi/runtime': 1.8.1 + '@emnapi/runtime': 1.7.1 '@tybys/wasm-util': 0.10.0 optional: true @@ -7423,7 +7439,7 @@ snapshots: '@next/env@16.1.1': {} - '@next/eslint-plugin-next@16.1.1': + '@next/eslint-plugin-next@16.1.3': dependencies: fast-glob: 3.3.1 @@ -8275,6 +8291,8 @@ snapshots: '@sinclair/typebox@0.34.38': {} + '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -8338,8 +8356,8 @@ snapshots: '@testing-library/react-hooks@7.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.2 - '@types/react': 19.2.8 - '@types/react-dom': 19.2.3(@types/react@19.2.8) + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) '@types/react-test-renderer': 19.1.0 react: 18.3.1 react-error-boundary: 3.1.4(react@18.3.1) @@ -8669,7 +8687,7 @@ snapshots: '@types/lodash.mergewith@4.6.7': dependencies: - '@types/lodash': 4.17.23 + '@types/lodash': 4.17.21 '@types/lodash.mergewith@4.6.9': dependencies: @@ -8677,7 +8695,7 @@ snapshots: '@types/lodash@4.17.20': {} - '@types/lodash@4.17.23': {} + '@types/lodash@4.17.21': {} '@types/lucene@2.1.7': {} @@ -8723,17 +8741,13 @@ snapshots: dependencies: '@types/react': 18.3.27 - '@types/react-dom@19.2.3(@types/react@19.2.8)': - dependencies: - '@types/react': 19.2.8 - '@types/react-is@17.0.7': dependencies: '@types/react': 17.0.87 '@types/react-test-renderer@19.1.0': dependencies: - '@types/react': 19.2.8 + '@types/react': 18.3.27 '@types/react-transition-group@4.4.12(@types/react@18.3.27)': dependencies: @@ -8750,10 +8764,6 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 - '@types/react@19.2.8': - dependencies: - csstype: 3.2.3 - '@types/request-ip@0.0.38': dependencies: '@types/node': 20.19.9 @@ -9361,7 +9371,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.9.14: {} + baseline-browser-mapping@2.9.15: {} better-react-mathjax@2.3.0(react@18.3.1): dependencies: @@ -9468,8 +9478,6 @@ snapshots: caniuse-lite@1.0.30001760: {} - caniuse-lite@1.0.30001764: {} - chai@4.5.0: dependencies: assertion-error: 1.1.0 @@ -10283,9 +10291,9 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-next@16.1.1(@typescript-eslint/parser@8.53.0(eslint@9.32.0)(typescript@5.9.3))(eslint@9.32.0)(typescript@5.9.3): + eslint-config-next@16.1.3(@typescript-eslint/parser@8.53.0(eslint@9.32.0)(typescript@5.9.3))(eslint@9.32.0)(typescript@5.9.3): dependencies: - '@next/eslint-plugin-next': 16.1.1 + '@next/eslint-plugin-next': 16.1.3 eslint: 9.32.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.32.0)(typescript@5.9.3))(eslint@9.32.0))(eslint@9.32.0) @@ -11612,8 +11620,8 @@ snapshots: dependencies: '@next/env': 16.1.1 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.9.14 - caniuse-lite: 1.0.30001764 + baseline-browser-mapping: 2.9.15 + caniuse-lite: 1.0.30001760 postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -11652,6 +11660,13 @@ snapshots: dependencies: boolbase: 1.0.0 + nuqs@2.8.6(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + dependencies: + '@standard-schema/spec': 1.0.0 + react: 18.3.1 + optionalDependencies: + next: 16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + nwsapi@2.2.20: {} object-assign@4.1.1: {} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index ce75ea532..db7034eae 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -23,6 +23,7 @@ import api from '@/api/api'; import { userKeys } from '@/api/user/user'; import { Providers } from '@/providers'; import { isValidToken } from '@/auth-utils'; +import { NuqsAdapter } from 'nuqs/adapters/next/pages'; if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled' && process.env.NODE_ENV !== 'production') { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -54,7 +55,7 @@ const NectarApp = memo(({ Component, pageProps }: AppProps): ReactElement => { }, [router]); return ( - <> + {BRAND_NAME_FULL} @@ -68,7 +69,7 @@ const NectarApp = memo(({ Component, pageProps }: AppProps): ReactElement => { - + ); }); NectarApp.displayName = 'NectarApp'; From 363e5aacea50eb08efb38224eec1c352c7b98f8a Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Sat, 17 Jan 2026 01:10:33 -0500 Subject: [PATCH 5/6] refactor(export): simplify CitationExporter with URL state persistence Removed XState machine in favor of simpler React state with draft/submitted pattern. Export options now persist to URL for shareability. - Replaced state machine with local useState for draft/submitted values - Added URL parameter parsing and serialization utilities - Custom format templates dropdown now uses portal for proper z-index - Added unit tests for URL param handling - Added e2e test for URL persistence --- .../export-citation/url-persistence.spec.ts | 65 +++ .../CitationExporter.machine.ts | 266 ---------- .../CitationExporter/CitationExporter.tsx | 477 +++++++++++------- .../__tests__/urlParams.test.ts | 165 ++++++ .../components/AuthorCutoffField.tsx | 21 +- .../components/CustomFormatSelect.tsx | 218 +++++--- .../components/FormatSelect.tsx | 14 +- .../components/JournalFormatSelect.tsx | 70 ++- .../components/KeyFormatInput.tsx | 45 +- .../components/MaxAuthorsField.tsx | 82 +-- .../components/RecordField.tsx | 48 +- src/components/CitationExporter/urlParams.ts | 140 +++++ .../CitationExporter/useCitationExporter.ts | 177 ++----- src/pages/search/exportcitation/[format].tsx | 445 ++++++++++------ 14 files changed, 1271 insertions(+), 962 deletions(-) create mode 100644 e2e/tests/export-citation/url-persistence.spec.ts delete mode 100644 src/components/CitationExporter/CitationExporter.machine.ts create mode 100644 src/components/CitationExporter/__tests__/urlParams.test.ts create mode 100644 src/components/CitationExporter/urlParams.ts diff --git a/e2e/tests/export-citation/url-persistence.spec.ts b/e2e/tests/export-citation/url-persistence.spec.ts new file mode 100644 index 000000000..e5338135d --- /dev/null +++ b/e2e/tests/export-citation/url-persistence.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; + +const NECTAR_URL = process.env.NECTAR_URL || process.env.BASE_URL || 'http://127.0.0.1:8000'; + +test.describe('Export Citation URL Persistence', () => { + test('custom format URL should not redirect to bibtex', async ({ page }) => { + // This is the URL that should work - format=custom with customFormat parameter + const customFormatUrl = `${NECTAR_URL}/search/exportcitation/custom?q=star&customFormat=testing`; + + // Navigate to the custom format URL + await page.goto(customFormatUrl); + + // Wait for page to be fully loaded + await page.waitForLoadState('networkidle'); + + // The URL should still contain format=custom, not bibtex + const currentUrl = page.url(); + + expect(currentUrl).toContain('/exportcitation/custom'); + expect(currentUrl).not.toContain('/exportcitation/bibtex'); + }); + + test('custom format URL with all params should preserve them', async ({ page }) => { + // Full URL with all export parameters + const fullUrl = `${NECTAR_URL}/search/exportcitation/custom?q=star&customFormat=testing+testing&authorcutoff=10&maxauthor=10&keyformat=%25R&journalformat=1`; + + await page.goto(fullUrl); + await page.waitForLoadState('networkidle'); + + const currentUrl = page.url(); + + // Format should remain custom + expect(currentUrl).toContain('/exportcitation/custom'); + + // customFormat should be preserved + expect(currentUrl).toContain('customFormat='); + }); + + test('bibtex format URL should remain bibtex', async ({ page }) => { + const bibtexUrl = `${NECTAR_URL}/search/exportcitation/bibtex?q=star`; + + await page.goto(bibtexUrl); + await page.waitForLoadState('networkidle'); + + const currentUrl = page.url(); + + expect(currentUrl).toContain('/exportcitation/bibtex'); + }); + + test('custom format URL should not change after page interaction', async ({ page }) => { + // Navigate to custom format URL + const customFormatUrl = `${NECTAR_URL}/search/exportcitation/custom?q=star&customFormat=MY_CUSTOM_FORMAT`; + + await page.goto(customFormatUrl); + await page.waitForLoadState('networkidle'); + + // Wait a moment for any potential redirect + await page.waitForTimeout(2000); + + // URL should still be custom + const currentUrl = page.url(); + expect(currentUrl).toContain('/exportcitation/custom'); + expect(currentUrl).toContain('customFormat='); + }); +}); diff --git a/src/components/CitationExporter/CitationExporter.machine.ts b/src/components/CitationExporter/CitationExporter.machine.ts deleted file mode 100644 index d071d3afa..000000000 --- a/src/components/CitationExporter/CitationExporter.machine.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { APP_DEFAULTS } from '@/config'; -import { assign, createMachine } from '@xstate/fsm'; -import { equals } from 'ramda'; -import { IUseCitationExporterProps } from './useCitationExporter'; -import { normalizeSolrSort } from '@/utils/common/search'; -import { IDocsEntity } from '@/api/search/types'; -import { ExportApiFormatKey, ExportApiJournalFormat, IExportApiParams } from '@/api/export/types'; - -export interface ICitationExporterState { - records: IDocsEntity['bibcode'][]; - range: [0, number]; - isCustomFormat: boolean; - singleMode: boolean; - prevParams: IExportApiParams; - params: IExportApiParams; -} - -interface SetSingleMode { - type: 'SET_SINGLEMODE'; - payload: ICitationExporterState['singleMode']; -} -interface SetRecords { - type: 'SET_RECORDS'; - payload: ICitationExporterState['records']; -} - -interface SetSort { - type: 'SET_SORT'; - payload: ICitationExporterState['params']['sort']; -} - -interface SetFormat { - type: 'SET_FORMAT'; - payload: string; -} - -interface SetKeyFormat { - type: 'SET_KEY_FORMAT'; - payload: string; -} - -interface SetRange { - type: 'SET_RANGE'; - payload: number; -} - -interface SetAuthorCutoff { - type: 'SET_AUTHOR_CUTOFF'; - payload: number; -} - -interface SetMaxAuthor { - type: 'SET_MAX_AUTHOR'; - payload: number; -} - -interface SetJournalFormat { - type: 'SET_JOURNAL_FORMAT'; - payload: ExportApiJournalFormat; -} - -interface SetIsCustomFormat { - type: 'SET_IS_CUSTOM_FORMAT'; - payload: { isCustomFormat: boolean }; -} - -interface SetCustomFormat { - type: 'SET_CUSTOM_FORMAT'; - payload: string; -} - -export type CitationExporterEvent = - | SetRecords - | SetSingleMode - | SetSort - | SetFormat - | SetRange - | SetAuthorCutoff - | SetMaxAuthor - | SetJournalFormat - | SetIsCustomFormat - | SetCustomFormat - | SetKeyFormat - | { type: 'SUBMIT' } - | { type: 'FORCE_SUBMIT' } - | { type: 'DONE' }; - -export const getMaxAuthor = (format: string) => { - switch (format) { - case ExportApiFormatKey.bibtex: - return APP_DEFAULTS.BIBTEX_DEFAULT_MAX_AUTHOR; - case ExportApiFormatKey.bibtexabs: - return APP_DEFAULTS.BIBTEX_ABS_DEFAULT_MAX_AUTHOR; - default: - return 0; - } -}; - -export const getExportCitationDefaultContext = (props: IUseCitationExporterProps): ICitationExporterState => { - const { - records = [], - format = ExportApiFormatKey.bibtex, - customFormat = '%1H:%Y:%q', - sort = ['date desc'], - keyformat = '%R', - journalformat = ExportApiJournalFormat.AASTeXMacros, - authorcutoff = APP_DEFAULTS.BIBTEX_DEFAULT_AUTHOR_CUTOFF, - singleMode = false, - } = props; - - // maxauthor is different for bibtex and bibtexabs, unless it's set explicitly - const maxauthor = props.maxauthor ?? getMaxAuthor(format); - - const params: IExportApiParams = { - format, - customFormat, - bibcode: records, - sort, - authorcutoff: [authorcutoff], - journalformat: [journalformat], - keyformat: [keyformat], - maxauthor: [maxauthor], - }; - return { - records, - range: [0, records.length], - isCustomFormat: format === ExportApiFormatKey.custom, - singleMode, - prevParams: params, - params, - }; -}; - -export const generateMachine = ({ - format, - customFormat, - keyformat, - journalformat, - authorcutoff, - maxauthor, - records, - singleMode, - sort, -}: IUseCitationExporterProps) => { - return createMachine({ - context: getExportCitationDefaultContext({ - format, - keyformat, - customFormat, - journalformat, - authorcutoff, - maxauthor, - records, - singleMode, - sort, - }), - id: 'citationExporter', - initial: 'idle', - states: { - idle: { - on: { - SET_RECORDS: { - actions: assign({ - records: (_ctx, evt) => evt.payload, - - // set the new records on params, respecting the current range - params: (ctx, evt) => ({ - ...ctx.params, - bibcode: evt.payload.slice(0, ctx.range[1]), - }), - }), - target: 'fetching', - }, - SET_SORT: { - actions: assign({ - params: (ctx, evt) => ({ - ...ctx.params, - sort: normalizeSolrSort(evt.payload), - }), - }), - }, - SET_FORMAT: [ - { - actions: assign({ - params: (ctx, evt) => ({ ...ctx.params, format: evt.payload }), - }), - }, - ], - SET_KEY_FORMAT: { - actions: assign({ - params: (ctx, evt) => ({ - ...ctx.params, - keyformat: [evt.payload], - }), - }), - }, - SET_RANGE: { - actions: assign({ - range: (_ctx, evt) => [0, evt.payload], - params: (ctx, evt) => ({ - ...ctx.params, - bibcode: ctx.records.slice(0, evt.payload <= 0 ? 1 : evt.payload), - }), - }), - }, - SET_AUTHOR_CUTOFF: { - actions: assign({ - params: (ctx, evt) => ({ - ...ctx.params, - authorcutoff: [evt.payload], - }), - }), - }, - SET_MAX_AUTHOR: { - actions: assign({ - params: (ctx, evt) => ({ - ...ctx.params, - maxauthor: [evt.payload], - }), - }), - }, - SET_JOURNAL_FORMAT: { - actions: assign({ - params: (ctx, evt) => ({ - ...ctx.params, - journalformat: [evt.payload], - }), - }), - }, - SET_IS_CUSTOM_FORMAT: { - actions: assign({ - isCustomFormat: (_ctx, evt) => evt.payload.isCustomFormat, - params: (ctx, evt) => ({ - ...ctx.params, - format: evt.payload.isCustomFormat ? ExportApiFormatKey.custom : ExportApiFormatKey.bibtex, - }), - }), - }, - SET_CUSTOM_FORMAT: { - actions: assign({ - params: (ctx, evt) => ({ - ...ctx.params, - customFormat: evt.payload, - }), - }), - }, - SUBMIT: { - target: 'fetching', - cond: (ctx) => !equals(ctx.prevParams, ctx.params), - }, - FORCE_SUBMIT: 'fetching', - }, - }, - fetching: { - on: { - DONE: { - target: 'idle', - actions: assign({ - prevParams: (ctx) => ctx.params, - }), - }, - }, - }, - }, - }); -}; diff --git a/src/components/CitationExporter/CitationExporter.tsx b/src/components/CitationExporter/CitationExporter.tsx index 04c0215c6..8f5db1d9b 100644 --- a/src/components/CitationExporter/CitationExporter.tsx +++ b/src/components/CitationExporter/CitationExporter.tsx @@ -1,9 +1,8 @@ -import { ChevronDownIcon, ChevronRightIcon } from '@chakra-ui/icons'; +import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons'; import { Box, Button, - Collapse, - Divider, + Flex, Grid, GridItem, Stack, @@ -12,14 +11,12 @@ import { TabPanel, TabPanels, Tabs, - useDisclosure, + Text, VStack, } from '@chakra-ui/react'; import { APP_DEFAULTS } from '@/config'; -import { useRouter } from 'next/router'; -import { ChangeEventHandler, Dispatch, HTMLAttributes, ReactElement, useEffect, useState } from 'react'; +import { FormEventHandler, HTMLAttributes, ReactElement, useCallback, useMemo, useState } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; -import { CitationExporterEvent } from './CitationExporter.machine'; import { AuthorCutoffField } from './components/AuthorCutoffField'; import { CustomFormatSelect } from './components/CustomFormatSelect'; import { ErrorFallback } from './components/ErrorFallback'; @@ -31,38 +28,77 @@ import { MaxAuthorsField } from './components/MaxAuthorsField'; import { RecordField } from './components/RecordField'; import { ResultArea } from './components/ResultArea'; import { useCitationExporter } from './useCitationExporter'; -import { noop } from '@/utils/common/noop'; -import { ExportApiFormatKey, ExportApiJournalFormat, IExportApiParams } from '@/api/export/types'; +import { ExportApiFormatKey, ExportApiJournalFormat } from '@/api/export/types'; import { IDocsEntity } from '@/api/search/types'; import { SolrSort } from '@/api/models'; -import { logger } from '@/logger'; import { useExportFormats } from '@/lib/useExportFormats'; export interface ICitationExporterProps extends HTMLAttributes { - singleMode?: boolean; + // Initial values from URL or defaults + format?: string; initialFormat?: string; - authorcutoff?: number; + customFormat?: string; keyformat?: string; journalformat?: ExportApiJournalFormat; + authorcutoff?: number; maxauthor?: number; records?: IDocsEntity['bibcode'][]; + sort?: SolrSort[]; + singleMode?: boolean; + + // Pagination info totalRecords?: number; page?: number; - nextPage?: () => void; hasNextPage?: boolean; - sort?: SolrSort[]; + hasPrevPage?: boolean; + onNextPage?: () => void; + onPrevPage?: () => void; + + // Optional callback when user submits - updates URL with final values + onExportSubmit?: (params: { + format: string; + customFormat: string; + keyformat: string; + journalformat: ExportApiJournalFormat; + authorcutoff: number; + maxauthor: number; + }) => void; } +interface ExportState { + format: string; + customFormat: string; + keyformat: string; + journalformat: ExportApiJournalFormat; + authorcutoff: number; + maxauthor: number; + rangeEnd: number; +} + +const getDefaultMaxAuthor = (format: string, explicitMaxAuthor?: number): number => { + if (explicitMaxAuthor !== undefined) { + return explicitMaxAuthor; + } + switch (format) { + case ExportApiFormatKey.bibtex: + return APP_DEFAULTS.BIBTEX_DEFAULT_MAX_AUTHOR; + case ExportApiFormatKey.bibtexabs: + return APP_DEFAULTS.BIBTEX_ABS_DEFAULT_MAX_AUTHOR; + default: + return 0; + } +}; + /** - * Citation export component + * Citation export component. + * Uses draft/submitted pattern - form edits don't trigger fetch until Submit. */ export const CitationExporter = (props: ICitationExporterProps): ReactElement => { - // early escape here, to skip extra work if nothing is passed - if (props.records.length === 0 || typeof props.records[0] !== 'string') { + const records = props.records ?? []; + if (records.length === 0 || typeof records[0] !== 'string') { return No Records} />; } - // wrap component here with error boundary to capture run-away errors return ( @@ -72,149 +108,172 @@ export const CitationExporter = (props: ICitationExporterProps): ReactElement => const Exporter = (props: ICitationExporterProps): ReactElement => { const { - singleMode = false, - initialFormat = ExportApiFormatKey.bibtex, - authorcutoff, - keyformat, - journalformat, - maxauthor, + format: propsFormat, + initialFormat, + customFormat: propsCustomFormat, + keyformat: propsKeyformat, + journalformat: propsJournalformat, + authorcutoff: propsAuthorcutoff, + maxauthor: propsMaxauthor, records = [], + sort, + singleMode = false, totalRecords = records.length, page = 0, - nextPage = noop, - hasNextPage = true, - sort, + hasNextPage = false, + hasPrevPage = false, + onNextPage, + onPrevPage, + onExportSubmit, ...divProps } = props; - const { data, state, dispatch } = useCitationExporter({ - format: initialFormat, - authorcutoff, - keyformat, - journalformat, - maxauthor, - records, - singleMode, + // Derive initial state from props + const initialState = useMemo((): ExportState => { + const format = propsFormat ?? initialFormat ?? ExportApiFormatKey.bibtex; + return { + format, + customFormat: propsCustomFormat ?? '%1H:%Y:%q', + keyformat: propsKeyformat ?? '%R', + journalformat: propsJournalformat ?? ExportApiJournalFormat.AASTeXMacros, + authorcutoff: propsAuthorcutoff ?? APP_DEFAULTS.BIBTEX_DEFAULT_AUTHOR_CUTOFF, + maxauthor: getDefaultMaxAuthor(format, propsMaxauthor), + rangeEnd: records.length, + }; + }, []); // Only compute once on mount + + // Draft state - what user is editing in the form + const [draft, setDraft] = useState(initialState); + + // Submitted state - what's actually being fetched + const [submitted, setSubmitted] = useState(initialState); + + // Compute bibcodes to export based on SUBMITTED rangeEnd + const bibcodesToExport = records.slice(0, submitted.rangeEnd); + + // Fetch export data using SUBMITTED state (not draft) + const { data, isLoading } = useCitationExporter({ + format: submitted.format, + customFormat: submitted.customFormat, + keyformat: submitted.keyformat, + journalformat: submitted.journalformat, + authorcutoff: submitted.authorcutoff, + maxauthor: submitted.maxauthor, + bibcodes: bibcodesToExport, sort, }); - const ctx = state.context; - const isLoading = state.matches('fetching'); - const router = useRouter(); - - const { isValidFormat } = useExportFormats(); - - // Updates the route when format has changed - useEffect(() => { - if ( - router.query.format !== ctx.params.format && - (state.matches('idle') || (state.matches('fetching') && !singleMode)) - ) { - void router.push( - { - pathname: router.pathname, - query: { ...router.query, format: ctx.params.format }, - }, - null, - { - shallow: true, - }, - ); - } - }, [state.value, router.query, ctx.params.format]); - - // Attempt to parse the url to grab the format, then update it, otherwise allow the server to handle the path - useEffect(() => { - router.beforePopState(({ as }) => { - try { - const format = as.split('?')[0].slice(as.lastIndexOf('/') + 1); - if (isValidFormat(format)) { - dispatch({ type: 'SET_FORMAT', payload: format }); - dispatch('FORCE_SUBMIT'); - return false; - } - } catch (err) { - logger.error({ err, as }, 'Error caught attempting to parse format from url'); - dispatch({ type: 'SET_FORMAT', payload: ExportApiFormatKey.bibtex }); - dispatch('FORCE_SUBMIT'); - } - return true; - }); - return () => router.beforePopState(() => true); - }, [dispatch, router]); - const handleOnSubmit: ChangeEventHandler = (e) => { + // Draft state setters + const setFormat = useCallback((format: string) => { + setDraft((d) => ({ ...d, format })); + }, []); + + const setKeyformat = useCallback((keyformat: string) => { + setDraft((d) => ({ ...d, keyformat })); + }, []); + + const setJournalformat = useCallback((journalformat: ExportApiJournalFormat) => { + setDraft((d) => ({ ...d, journalformat })); + }, []); + + const setAuthorcutoff = useCallback((authorcutoff: number) => { + setDraft((d) => ({ ...d, authorcutoff })); + }, []); + + const setMaxauthor = useCallback((maxauthor: number) => { + setDraft((d) => ({ ...d, maxauthor })); + }, []); + + const setRangeEnd = useCallback((rangeEnd: number) => { + setDraft((d) => ({ ...d, rangeEnd })); + }, []); + + // Submit handler - copies draft to submitted and notifies parent + const handleSubmit: FormEventHandler = (e) => { e.preventDefault(); - dispatch({ type: 'SUBMIT' }); + setSubmitted(draft); + onExportSubmit?.(draft); }; + // Handle tab change between built-in and custom formats const handleTabChange = (index: number) => { - dispatch({ - type: 'SET_IS_CUSTOM_FORMAT', - payload: { isCustomFormat: index === 1 }, - }); + const newFormat = index === 1 ? ExportApiFormatKey.custom : ExportApiFormatKey.bibtex; + setFormat(newFormat); + // Auto-submit on tab change since it's a major format change + setSubmitted((s) => ({ ...s, format: newFormat })); + onExportSubmit?.({ ...draft, format: newFormat }); }; + // Handle custom format submit (from CustomFormatSelect's Submit button) + const handleCustomFormatSubmit = useCallback( + (customFormat: string) => { + const newDraft = { ...draft, customFormat }; + setDraft(newDraft); + setSubmitted(newDraft); + onExportSubmit?.(newDraft); + }, + [draft, onExportSubmit], + ); + + // Determine which tab should be active + const isCustomFormat = draft.format === ExportApiFormatKey.custom; + const tabIndex = isCustomFormat ? 1 : 0; + return ( - <> - Exporting record{ctx.range[1] - ctx.range[0] > 1 ? 's' : ''}{' '} - {ctx.range[0] + 1 + page * APP_DEFAULTS.EXPORT_PAGE_SIZE} to{' '} - {ctx.range[1] + page * APP_DEFAULTS.EXPORT_PAGE_SIZE} (total: {totalRecords.toLocaleString()}) - - {!singleMode && hasNextPage && ( - - )} - + + Showing records {1 + page * APP_DEFAULTS.EXPORT_PAGE_SIZE}– + {draft.rangeEnd + page * APP_DEFAULTS.EXPORT_PAGE_SIZE} of {totalRecords.toLocaleString()} + } isLoading={isLoading} {...divProps} > - + - Built-in Formats - Custom Formats + Standard Formats + Custom Format -
- - - - - {ctx.records.length > 1 && ( - - )} - - - {(!singleMode || - (singleMode && - (ctx.params.format === ExportApiFormatKey.bibtex || - ctx.params.format === ExportApiFormatKey.bibtexabs))) && ( - - )} - - - + + + + + {records.length > 1 && ( + + )} + {(!singleMode || + (singleMode && + (draft.format === ExportApiFormatKey.bibtex || + draft.format === ExportApiFormatKey.bibtexabs))) && ( + + )}
- + @@ -222,85 +281,136 @@ const Exporter = (props: ICitationExporterProps): ReactElement => {
- +
+ {!singleMode && (hasPrevPage || hasNextPage) && ( + + {hasPrevPage && onPrevPage && ( + + )} + {hasNextPage && onNextPage && ( + + )} + + )}
); }; -const AdvancedControls = ({ - dispatch, - params, -}: { - dispatch: Dispatch; - params: IExportApiParams; -}) => { - const { onToggle, isOpen } = useDisclosure(); - - // if default cutoff and max authors are equal, show basic mode, otherwise use advance mode +interface BibTeXOptionsProps { + format: string; + keyformat: string; + journalformat: ExportApiJournalFormat; + authorcutoff: number; + maxauthor: number; + onKeyformatChange: (keyformat: string) => void; + onJournalformatChange: (journalformat: ExportApiJournalFormat) => void; + onAuthorcutoffChange: (authorcutoff: number) => void; + onMaxauthorChange: (maxauthor: number) => void; +} - const [isBasicMode, setIsBasicMode] = useState(params.authorcutoff[0] === params.maxauthor[0]); +/** + * BibTeX-specific options panel. + * Only shown when BibTeX or BibTeX ABS format is selected. + * Options are displayed directly (not hidden behind a collapse). + */ +const BibTeXOptions = ({ + format, + keyformat, + journalformat, + authorcutoff, + maxauthor, + onKeyformatChange, + onJournalformatChange, + onAuthorcutoffChange, + onMaxauthorChange, +}: BibTeXOptionsProps) => { + // Local UI state: basic mode links maxauthor and authorcutoff + const [isBasicMode, setIsBasicMode] = useState(authorcutoff === maxauthor); const toggleMode = () => { setIsBasicMode((prev) => !prev); }; - if (params.format === ExportApiFormatKey.bibtex || params.format === ExportApiFormatKey.bibtexabs) { - return ( - - - - - - - - {isBasicMode ? ( - - - - ) : ( - - - - )} - {!isBasicMode && } - - - - - ); + // In basic mode, when maxauthor changes, also update authorcutoff + const handleMaxauthorChange = useCallback( + (value: number) => { + onMaxauthorChange(value); + if (isBasicMode) { + onAuthorcutoffChange(value); + } + }, + [onMaxauthorChange, onAuthorcutoffChange, isBasicMode], + ); + + if (format !== ExportApiFormatKey.bibtex && format !== ExportApiFormatKey.bibtexabs) { + return null; } - return null; + + return ( + + + BibTeX Options + + + + + + {!isBasicMode && ( + + )} + + + + + + ); }; /** - * Static component for SSR + * Static component for SSR - simplified, no controls */ -const Static = (props: Omit): ReactElement => { - const { records, initialFormat, singleMode, totalRecords, sort, ...divProps } = props; +const Static = ( + props: { + format: string; + records: IDocsEntity['bibcode'][]; + singleMode?: boolean; + totalRecords?: number; + sort?: SolrSort[]; + } & HTMLAttributes, +): ReactElement => { + const { records, format, singleMode, totalRecords = records.length, sort, ...divProps } = props; - const { data, state } = useCitationExporter({ - format: initialFormat, - records, - singleMode: true, + const { data } = useCitationExporter({ + format, + customFormat: '%1H:%Y:%q', + keyformat: '%R', + journalformat: ExportApiJournalFormat.AASTeXMacros, + authorcutoff: APP_DEFAULTS.BIBTEX_DEFAULT_AUTHOR_CUTOFF, + maxauthor: APP_DEFAULTS.BIBTEX_DEFAULT_MAX_AUTHOR, + bibcodes: records, sort, }); - const ctx = state.context; const { getFormatById } = useExportFormats(); - const format = getFormatById(ctx.params.format); + const formatInfo = getFormatById(format); if (singleMode) { return ( - Exporting record in {format.name} format} {...divProps}> - + Exporting record in {formatInfo.name} format} {...divProps}> + ); } @@ -309,13 +419,12 @@ const Static = (props: Omit): React - Exporting record{ctx.range[1] - ctx.range[0] > 1 ? 's' : ''} {ctx.range[0] + 1} to {ctx.range[1]} (total:{' '} - {totalRecords.toLocaleString()}) + Exporting record{records.length > 1 ? 's' : ''} 1 to {records.length} (total: {totalRecords.toLocaleString()}) } {...divProps} > - + ); }; diff --git a/src/components/CitationExporter/__tests__/urlParams.test.ts b/src/components/CitationExporter/__tests__/urlParams.test.ts new file mode 100644 index 000000000..2723e5853 --- /dev/null +++ b/src/components/CitationExporter/__tests__/urlParams.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from 'vitest'; +import { + parseExportUrlParams, + hasExportUrlParams, + serializeExportUrlParams, + exportParamsEqual, + ParsedExportUrlParams, +} from '../urlParams'; +import { ExportApiJournalFormat } from '@/api/export/types'; + +describe('parseExportUrlParams', () => { + it('returns empty object for empty query', () => { + const result = parseExportUrlParams({}); + expect(result).toEqual({}); + }); + + it('parses customFormat string', () => { + const result = parseExportUrlParams({ customFormat: '%1H:%Y:%q' }); + expect(result.customFormat).toBe('%1H:%Y:%q'); + }); + + it('ignores empty customFormat', () => { + const result = parseExportUrlParams({ customFormat: '' }); + expect(result.customFormat).toBeUndefined(); + }); + + it('parses authorcutoff as number', () => { + const result = parseExportUrlParams({ authorcutoff: '10' }); + expect(result.authorcutoff).toBe(10); + }); + + it('handles authorcutoff as number input', () => { + const result = parseExportUrlParams({ authorcutoff: 5 }); + expect(result.authorcutoff).toBe(5); + }); + + it('ignores invalid authorcutoff', () => { + const result = parseExportUrlParams({ authorcutoff: 'abc' }); + expect(result.authorcutoff).toBeUndefined(); + }); + + it('ignores zero or negative authorcutoff', () => { + expect(parseExportUrlParams({ authorcutoff: '0' }).authorcutoff).toBeUndefined(); + expect(parseExportUrlParams({ authorcutoff: '-5' }).authorcutoff).toBeUndefined(); + }); + + it('parses maxauthor as number', () => { + const result = parseExportUrlParams({ maxauthor: '200' }); + expect(result.maxauthor).toBe(200); + }); + + it('parses keyformat string', () => { + const result = parseExportUrlParams({ keyformat: '%R' }); + expect(result.keyformat).toBe('%R'); + }); + + it('parses journalformat enum values', () => { + expect(parseExportUrlParams({ journalformat: '1' }).journalformat).toBe(ExportApiJournalFormat.AASTeXMacros); + expect(parseExportUrlParams({ journalformat: '2' }).journalformat).toBe(ExportApiJournalFormat.Abbreviations); + expect(parseExportUrlParams({ journalformat: '3' }).journalformat).toBe(ExportApiJournalFormat.FullName); + }); + + it('ignores invalid journalformat values', () => { + expect(parseExportUrlParams({ journalformat: '0' }).journalformat).toBeUndefined(); + expect(parseExportUrlParams({ journalformat: '4' }).journalformat).toBeUndefined(); + expect(parseExportUrlParams({ journalformat: 'abc' }).journalformat).toBeUndefined(); + }); + + it('handles array values by taking first element', () => { + const result = parseExportUrlParams({ + customFormat: ['first', 'second'] as unknown as string, + authorcutoff: [10, 20] as unknown as string, + }); + expect(result.customFormat).toBe('first'); + expect(result.authorcutoff).toBe(10); + }); + + it('parses all params together', () => { + const result = parseExportUrlParams({ + customFormat: '%T - %A', + authorcutoff: '5', + maxauthor: '10', + keyformat: '%R', + journalformat: '2', + }); + expect(result).toEqual({ + customFormat: '%T - %A', + authorcutoff: 5, + maxauthor: 10, + keyformat: '%R', + journalformat: ExportApiJournalFormat.Abbreviations, + }); + }); +}); + +describe('hasExportUrlParams', () => { + it('returns false for empty params', () => { + expect(hasExportUrlParams({})).toBe(false); + }); + + it('returns true when any param is present', () => { + expect(hasExportUrlParams({ customFormat: 'test' })).toBe(true); + expect(hasExportUrlParams({ authorcutoff: 5 })).toBe(true); + expect(hasExportUrlParams({ maxauthor: 10 })).toBe(true); + expect(hasExportUrlParams({ keyformat: '%R' })).toBe(true); + expect(hasExportUrlParams({ journalformat: ExportApiJournalFormat.FullName })).toBe(true); + }); + + it('returns true when multiple params are present', () => { + const params: ParsedExportUrlParams = { + customFormat: 'test', + authorcutoff: 5, + }; + expect(hasExportUrlParams(params)).toBe(true); + }); +}); + +describe('serializeExportUrlParams', () => { + it('returns empty object for empty params', () => { + expect(serializeExportUrlParams({})).toEqual({}); + }); + + it('serializes all params to strings', () => { + const result = serializeExportUrlParams({ + customFormat: '%T - %A', + authorcutoff: 5, + maxauthor: 10, + keyformat: '%R', + journalformat: ExportApiJournalFormat.Abbreviations, + }); + expect(result).toEqual({ + customFormat: '%T - %A', + authorcutoff: '5', + maxauthor: '10', + keyformat: '%R', + journalformat: '2', + }); + }); + + it('only includes defined params', () => { + const result = serializeExportUrlParams({ + authorcutoff: 5, + }); + expect(result).toEqual({ authorcutoff: '5' }); + expect(result.customFormat).toBeUndefined(); + }); +}); + +describe('exportParamsEqual', () => { + it('returns true for equal empty params', () => { + expect(exportParamsEqual({}, {})).toBe(true); + }); + + it('returns true for equal params', () => { + const a = { customFormat: 'test', authorcutoff: 5 }; + const b = { customFormat: 'test', authorcutoff: 5 }; + expect(exportParamsEqual(a, b)).toBe(true); + }); + + it('returns false for different params', () => { + expect(exportParamsEqual({ authorcutoff: 5 }, { authorcutoff: 10 })).toBe(false); + expect(exportParamsEqual({ customFormat: 'a' }, { customFormat: 'b' })).toBe(false); + expect(exportParamsEqual({ authorcutoff: 5 }, {})).toBe(false); + }); +}); diff --git a/src/components/CitationExporter/components/AuthorCutoffField.tsx b/src/components/CitationExporter/components/AuthorCutoffField.tsx index 97d31a5f3..9792b4ac5 100644 --- a/src/components/CitationExporter/components/AuthorCutoffField.tsx +++ b/src/components/CitationExporter/components/AuthorCutoffField.tsx @@ -7,28 +7,29 @@ import { NumberInputField, NumberInputStepper, } from '@chakra-ui/react'; -import { Dispatch, ReactElement, useEffect, useState } from 'react'; -import { CitationExporterEvent } from '../CitationExporter.machine'; +import { ReactElement, useEffect, useState } from 'react'; import { DescriptionCollapse } from './DescriptionCollapse'; import { APP_DEFAULTS } from '@/config'; import { useDebounce } from 'use-debounce'; -import { IExportApiParams } from '@/api/export/types'; -export const AuthorCutoffField = (props: { - authorcutoff: IExportApiParams['authorcutoff']; - dispatch: Dispatch; +export interface IAuthorCutoffFieldProps { + authorcutoff: number[] | number; + onAuthorcutoffChange: (authorcutoff: number) => void; label?: string; description?: ReactElement; -}) => { - const { authorcutoff: [authorcutoff] = [], dispatch } = props; +} + +export const AuthorCutoffField = (props: IAuthorCutoffFieldProps) => { + const authorcutoff = Array.isArray(props.authorcutoff) ? props.authorcutoff[0] : props.authorcutoff; + const { onAuthorcutoffChange } = props; const [value, setValue] = useState(authorcutoff); const [debouncedValue] = useDebounce(value, 500); useEffect(() => { if (debouncedValue >= APP_DEFAULTS.MIN_AUTHORCUTOFF && debouncedValue <= APP_DEFAULTS.MAX_AUTHORCUTOFF) { - dispatch({ type: 'SET_AUTHOR_CUTOFF', payload: debouncedValue }); + onAuthorcutoffChange(debouncedValue); } - }, [debouncedValue]); + }, [debouncedValue, onAuthorcutoffChange]); const label = props.label ?? 'Author Cut-off'; diff --git a/src/components/CitationExporter/components/CustomFormatSelect.tsx b/src/components/CitationExporter/components/CustomFormatSelect.tsx index 00aa191a3..d37fe951d 100644 --- a/src/components/CitationExporter/components/CustomFormatSelect.tsx +++ b/src/components/CitationExporter/components/CustomFormatSelect.tsx @@ -1,113 +1,171 @@ -import { ChangeEvent, Dispatch, useEffect, useState } from 'react'; -import { CitationExporterEvent } from '../CitationExporter.machine'; -import { Button, FormControl, FormLabel, Input, Text } from '@chakra-ui/react'; +import { ChangeEvent, useMemo, useState, useEffect } from 'react'; +import { + Button, + FormControl, + FormLabel, + Input, + Text, + InputGroup, + InputRightElement, + IconButton, + Tooltip, + Box, + Collapse, + useDisclosure, +} from '@chakra-ui/react'; +import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'; import { Select } from '@/components/Select'; import { useSession } from '@/lib/useSession'; import { SimpleLink } from '@/components/SimpleLink'; - import { useSettings } from '@/lib/useSettings'; -import { useColorModeColors } from '@/lib/useColorModeColors'; import { DEFAULT_USER_DATA } from '@/api/user/models'; +interface FormatOption { + id: string; + label: string; + value: string; + code: string; +} + export interface ICustomFormatSelectProps { - dispatch: Dispatch; + customFormat: string; + onCustomFormatChange: (customFormat: string) => void; } -export const CustomFormatSelect = ({ dispatch }: ICustomFormatSelectProps) => { +/** + * Custom format selector with improved UX. + * + * Design: The input is always editable. The dropdown acts as a "template picker" + * that populates the input when selected, but users can always modify the value. + */ +export const CustomFormatSelect = ({ customFormat, onCustomFormatChange }: ICustomFormatSelectProps) => { const { isAuthenticated } = useSession(); - const { settings: settingsData } = useSettings({ suspense: false }); + const { isOpen: showTemplates, onToggle: toggleTemplates } = useDisclosure(); const customFormats = settingsData?.customFormats ?? DEFAULT_USER_DATA.customFormats; + const hasSavedFormats = customFormats.length > 0; - const colors = useColorModeColors(); - - // custom formats to options - const customFormatOptions = customFormats - .map((f) => ({ - id: f.id, - label: f.name, - value: f.id, - code: f.code, - })) - .sort((a, b) => (a.label < b.label ? -1 : 1)); - customFormatOptions.unshift({ id: 'new', label: 'Enter Custom Format', value: 'new', code: '%1H:%Y:%q' }); - - // init to user's default - const defaultCustomFormat = - customFormatOptions.length === 1 - ? customFormatOptions[0] - : customFormatOptions.find((o) => o.id === customFormats[0].id); - const [selectedFormatOption, setSelectedFormatOption] = useState(defaultCustomFormat); - - const [formatCode, setFormatCode] = useState(selectedFormatOption.code); - - // initial fetch - useEffect(() => { - if (selectedFormatOption.id !== 'new' || (selectedFormatOption.id === 'new' && formatCode !== '')) { - dispatch({ type: 'SET_CUSTOM_FORMAT', payload: selectedFormatOption.code }); - dispatch({ type: 'SUBMIT' }); - } - }, []); + // Local editing state - synced with prop on mount + const [editingCode, setEditingCode] = useState(customFormat); - // fetch when selection is changed + // Sync local state when prop changes from external source (e.g., URL) useEffect(() => { - setFormatCode(selectedFormatOption.code); - if (selectedFormatOption.id !== 'new' || (selectedFormatOption.id === 'new' && formatCode !== '')) { - dispatch({ type: 'SET_CUSTOM_FORMAT', payload: selectedFormatOption.code }); - dispatch({ type: 'SUBMIT' }); - } - }, [selectedFormatOption]); + setEditingCode(customFormat); + }, [customFormat]); + + // Build template options from saved formats + const templateOptions: FormatOption[] = useMemo(() => { + return customFormats + .map((f) => ({ + id: f.id, + label: f.name, + value: f.id, + code: f.code, + })) + .sort((a, b) => (a.label < b.label ? -1 : 1)); + }, [customFormats]); + + // Find if current code matches a saved template (for display purposes) + const matchingTemplate = useMemo(() => { + return templateOptions.find((opt) => opt.code === editingCode); + }, [editingCode, templateOptions]); - const handleFormatCodeChange = (e: ChangeEvent) => { - setFormatCode(e.target.value); + const handleTemplateSelect = (option: FormatOption) => { + // Populate input with selected template's code + setEditingCode(option.code); }; - const handleSubmitNewCustomCode = () => { - if (formatCode && formatCode !== '') { - dispatch({ type: 'SET_CUSTOM_FORMAT', payload: formatCode }); - dispatch({ type: 'SUBMIT' }); + const handleInputChange = (e: ChangeEvent) => { + setEditingCode(e.target.value); + }; + + const handleSubmit = () => { + if (editingCode.trim()) { + onCustomFormatChange(editingCode.trim()); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && editingCode.trim()) { + e.preventDefault(); + handleSubmit(); } }; return ( <> - Select Custom Format - {isAuthenticated && ( - - To add custom formats to the list, go to{' '} - - Export Settings - + Custom Format String + + + {hasSavedFormats && isAuthenticated && ( + + + : } + size="sm" + variant="ghost" + onClick={toggleTemplates} + /> + + + )} + + {matchingTemplate && ( + + Using template: {matchingTemplate.label} )} - - - - - For more information on custom formats, see{' '} + + + For format syntax help, see{' '} SciX Help diff --git a/src/components/CitationExporter/components/FormatSelect.tsx b/src/components/CitationExporter/components/FormatSelect.tsx index 7d8a2161e..5d52778be 100644 --- a/src/components/CitationExporter/components/FormatSelect.tsx +++ b/src/components/CitationExporter/components/FormatSelect.tsx @@ -1,33 +1,33 @@ -import { Dispatch } from 'react'; -import { CitationExporterEvent } from '../CitationExporter.machine'; import { Select } from '@/components/Select'; import { ExportFormatOption, useExportFormats } from '@/lib/useExportFormats'; export interface IFormatSelectProps { format: string; - dispatch: Dispatch; + onFormatChange: (format: string) => void; isLoading?: boolean; label?: string; } + export const FormatSelect = (props: IFormatSelectProps) => { + const { format, onFormatChange, isLoading, label } = props; const { formatOptionsNoCustom, getFormatOptionById } = useExportFormats(); const handleOnChange = ({ id }: ExportFormatOption) => { - props.dispatch({ type: 'SET_FORMAT', payload: id }); + onFormatChange(id); }; return ( name="format" - label={props.label ?? 'Format'} + label={label ?? 'Format'} hideLabel={false} id="export-format-select" options={formatOptionsNoCustom} - value={getFormatOptionById(props.format)} + value={getFormatOptionById(format)} onChange={handleOnChange} data-testid="export-select" stylesTheme="default" - isDisabled={props.isLoading} + isDisabled={isLoading} /> ); }; diff --git a/src/components/CitationExporter/components/JournalFormatSelect.tsx b/src/components/CitationExporter/components/JournalFormatSelect.tsx index d9d287b80..f809202e9 100644 --- a/src/components/CitationExporter/components/JournalFormatSelect.tsx +++ b/src/components/CitationExporter/components/JournalFormatSelect.tsx @@ -1,10 +1,9 @@ import { Box, FormLabel, OrderedList } from '@chakra-ui/react'; import { Select, SelectOption } from '@/components/Select'; import { values } from 'ramda'; -import { Dispatch, ReactElement, useMemo } from 'react'; -import { CitationExporterEvent } from '../CitationExporter.machine'; +import { ReactElement, useMemo } from 'react'; import { DescriptionCollapse } from './DescriptionCollapse'; -import { ExportApiJournalFormat, IExportApiParams } from '@/api/export/types'; +import { ExportApiJournalFormat } from '@/api/export/types'; type JournalFormatOption = SelectOption; @@ -29,49 +28,46 @@ export const journalFormats: Record }, }; -export const JournalFormatSelect = (props: { - journalformat: IExportApiParams['journalformat']; - dispatch?: Dispatch; - onChange?: (format: ExportApiJournalFormat) => void; +export interface IJournalFormatSelectProps { + journalformat: ExportApiJournalFormat[] | ExportApiJournalFormat; + onChange: (format: ExportApiJournalFormat) => void; label?: string; description?: ReactElement; -}) => { - const { journalformat: [journalformat] = [], dispatch, onChange } = props; +} + +export const JournalFormatSelect = (props: IJournalFormatSelectProps) => { + const journalformat = Array.isArray(props.journalformat) ? props.journalformat[0] : props.journalformat; + const { onChange } = props; const formats = useMemo(() => values(journalFormats), []); const handleOnChange = ({ id }: JournalFormatOption) => { - if (typeof dispatch === 'function') { - dispatch({ type: 'SET_JOURNAL_FORMAT', payload: id }); - } - if (typeof onChange === 'function') { - onChange(id); - } + onChange(id); }; + const labelText = props.label ?? 'Journal Format'; + return ( - + {({ btn, content }) => ( - <> - - name="journalformat" - label={ - - - {props.label ?? 'Journal Format'} {btn} - - {content} - - } - aria-label="Journal Format" - hideLabel={false} - id="journal-format-select" - options={formats} - value={journalFormats[journalformat]} - onChange={handleOnChange} - data-testid="export-select" - stylesTheme="default" - /> - + + name="journalformat" + label={ + + + {labelText} {btn} + + {content} + + } + aria-label={labelText} + hideLabel={false} + id="journal-format-select" + options={formats} + value={journalFormats[journalformat]} + onChange={handleOnChange} + data-testid="export-select" + stylesTheme="default" + /> )} ); diff --git a/src/components/CitationExporter/components/KeyFormatInput.tsx b/src/components/CitationExporter/components/KeyFormatInput.tsx index 8159d6886..8f78c7353 100644 --- a/src/components/CitationExporter/components/KeyFormatInput.tsx +++ b/src/components/CitationExporter/components/KeyFormatInput.tsx @@ -1,42 +1,41 @@ import { Box, Code, FormControl, FormLabel, Input } from '@chakra-ui/react'; -import { ChangeEventHandler, Dispatch, ReactElement, useCallback } from 'react'; -import { CitationExporterEvent } from '../CitationExporter.machine'; +import { ChangeEventHandler, ReactElement, useCallback } from 'react'; import { DescriptionCollapse } from './DescriptionCollapse'; -import { IExportApiParams } from '@/api/export/types'; interface IKeyFormatInputProps { - keyformat: IExportApiParams['keyformat']; - dispatch: Dispatch; + keyformat: string | string[]; + onKeyformatChange: (keyformat: string) => void; label?: string; description?: ReactElement; } export const KeyFormatInput = (props: IKeyFormatInputProps) => { - const { keyformat, dispatch, label } = props; + const { keyformat, onKeyformatChange, label } = props; + const keyformatValue = Array.isArray(keyformat) ? keyformat[0] : keyformat; const handleOnChange: ChangeEventHandler = useCallback( (e) => { - dispatch({ type: 'SET_KEY_FORMAT', payload: e.currentTarget.value }); + onKeyformatChange(e.currentTarget.value); }, - [dispatch], + [onKeyformatChange], ); + const labelText = label ?? 'Key Format'; + return ( - - - {({ btn, content }) => ( - - - - {label ?? 'Key Format'} {btn} - - {content} - - - - )} - - + + {({ btn, content }) => ( + + + + {labelText} {btn} + + {content} + + + + )} + ); }; diff --git a/src/components/CitationExporter/components/MaxAuthorsField.tsx b/src/components/CitationExporter/components/MaxAuthorsField.tsx index e6de449cc..c1ee3ae21 100644 --- a/src/components/CitationExporter/components/MaxAuthorsField.tsx +++ b/src/components/CitationExporter/components/MaxAuthorsField.tsx @@ -8,65 +8,65 @@ import { NumberInputStepper, } from '@chakra-ui/react'; import { APP_DEFAULTS } from '@/config'; -import { Dispatch, ReactElement, useEffect, useState } from 'react'; -import { CitationExporterEvent } from '../CitationExporter.machine'; +import { ReactElement, useEffect, useState } from 'react'; import { DescriptionCollapse } from './DescriptionCollapse'; import { useDebounce } from 'use-debounce'; -import { IExportApiParams } from '@/api/export/types'; -export const MaxAuthorsField = (props: { - maxauthor: IExportApiParams['maxauthor']; - dispatch: Dispatch; +export interface IMaxAuthorsFieldProps { + maxauthor: number[] | number; + onMaxauthorChange: (maxauthor: number) => void; + onAuthorcutoffChange?: (authorcutoff: number) => void; isBasicMode: boolean; label?: string; description?: ReactElement; -}) => { - const { maxauthor: [maxauthor] = [], isBasicMode, dispatch } = props; +} + +export const MaxAuthorsField = (props: IMaxAuthorsFieldProps) => { + const maxauthor = Array.isArray(props.maxauthor) ? props.maxauthor[0] : props.maxauthor; + const { isBasicMode, onMaxauthorChange, onAuthorcutoffChange } = props; const [value, setValue] = useState(maxauthor); const [debouncedValue] = useDebounce(value, 500); // in basic mode, max author and author cutoff are always the same useEffect(() => { if (debouncedValue >= 0 && debouncedValue <= APP_DEFAULTS.MAX_EXPORT_AUTHORS) { - dispatch({ type: 'SET_MAX_AUTHOR', payload: debouncedValue }); - if (isBasicMode) { - dispatch({ type: 'SET_AUTHOR_CUTOFF', payload: debouncedValue }); + onMaxauthorChange(debouncedValue); + if (isBasicMode && onAuthorcutoffChange) { + onAuthorcutoffChange(debouncedValue); } } - }, [debouncedValue]); + }, [debouncedValue, onMaxauthorChange, onAuthorcutoffChange, isBasicMode]); const label = props.label ?? 'Max Authors'; return ( - <> - - {({ btn, content }) => ( - <> - - {label} {btn} - - {content} - { - if (v.length > 0) { - setValue(parseInt(v)); - } - }} - > - - - - - - - - )} - - + + {({ btn, content }) => ( + <> + + {label} {btn} + + {content} + { + if (v.length > 0) { + setValue(parseInt(v)); + } + }} + > + + + + + + + + )} + ); }; diff --git a/src/components/CitationExporter/components/RecordField.tsx b/src/components/CitationExporter/components/RecordField.tsx index 8ea151617..6e10a7a07 100644 --- a/src/components/CitationExporter/components/RecordField.tsx +++ b/src/components/CitationExporter/components/RecordField.tsx @@ -1,47 +1,42 @@ import { - Box, - FormLabel, + HStack, NumberDecrementStepper, NumberIncrementStepper, NumberInput, NumberInputField, NumberInputStepper, + Text, } from '@chakra-ui/react'; -import { Dispatch, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useDebounce } from 'use-debounce'; -import { CitationExporterEvent, ICitationExporterState } from '../CitationExporter.machine'; -import { DescriptionCollapse } from './DescriptionCollapse'; -export const RecordField = (props: { +export interface IRecordFieldProps { records: string[]; - range: ICitationExporterState['range']; - dispatch: Dispatch; -}) => { - const { range, records, dispatch } = props; + range: [number, number]; + onRangeChange: (end: number) => void; +} + +export const RecordField = (props: IRecordFieldProps) => { + const { range, records, onRangeChange } = props; const [value, setValue] = useState(range[1]); const [debouncedValue] = useDebounce(value, 500); useEffect(() => { - dispatch({ type: 'SET_RANGE', payload: debouncedValue }); - }, [debouncedValue]); + onRangeChange(debouncedValue); + }, [debouncedValue, onRangeChange]); return ( - - - {({ btn, content }) => ( - <> - - Limit Records {btn} - - {content} - - )} - + + + Export first + { if (v.length > 0) { setValue(parseInt(v)); @@ -54,8 +49,9 @@ export const RecordField = (props: { - + + of {records.length.toLocaleString()} records + + ); }; - -const description = <>Limit the number of total records retrieved.; diff --git a/src/components/CitationExporter/urlParams.ts b/src/components/CitationExporter/urlParams.ts new file mode 100644 index 000000000..dfeb77f70 --- /dev/null +++ b/src/components/CitationExporter/urlParams.ts @@ -0,0 +1,140 @@ +import { ExportApiJournalFormat } from '@/api/export/types'; + +type QueryValue = string | number | boolean | (string | number)[] | undefined; + +/** + * Parsed and validated export URL parameters with proper types. + */ +export interface ParsedExportUrlParams { + customFormat?: string; + authorcutoff?: number; + maxauthor?: number; + keyformat?: string; + journalformat?: ExportApiJournalFormat; +} + +/** + * Extracts a string value from a query parameter. + */ +const getStringValue = (value: QueryValue): string | undefined => { + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value) && value.length > 0) { + const first = value[0]; + return typeof first === 'string' ? first : String(first); + } + if (typeof value === 'number') { + return String(value); + } + return undefined; +}; + +/** + * Extracts a number value from a query parameter. + */ +const getNumberValue = (value: QueryValue): number | undefined => { + if (typeof value === 'number') { + return value; + } + const str = getStringValue(value); + if (str !== undefined) { + const parsed = parseInt(str, 10); + if (!isNaN(parsed)) { + return parsed; + } + } + return undefined; +}; + +/** + * Parses URL query parameters into typed export options. + * Returns undefined for missing or invalid values. + */ +export const parseExportUrlParams = (query: Record): ParsedExportUrlParams => { + const result: ParsedExportUrlParams = {}; + + // customFormat - string, may contain % characters (URI-decoded by Next.js) + const customFormat = getStringValue(query.customFormat); + if (customFormat && customFormat.length > 0) { + result.customFormat = customFormat; + } + + // authorcutoff - positive integer + const authorcutoff = getNumberValue(query.authorcutoff); + if (authorcutoff !== undefined && authorcutoff > 0) { + result.authorcutoff = authorcutoff; + } + + // maxauthor - positive integer + const maxauthor = getNumberValue(query.maxauthor); + if (maxauthor !== undefined && maxauthor > 0) { + result.maxauthor = maxauthor; + } + + // keyformat - string, typically %R or similar + const keyformat = getStringValue(query.keyformat); + if (keyformat && keyformat.length > 0) { + result.keyformat = keyformat; + } + + // journalformat - enum value (1, 2, or 3) + const journalformat = getNumberValue(query.journalformat); + if (journalformat === 1 || journalformat === 2 || journalformat === 3) { + result.journalformat = journalformat as ExportApiJournalFormat; + } + + return result; +}; + +/** + * Checks if any URL export params are present. + */ +export const hasExportUrlParams = (params: ParsedExportUrlParams): boolean => { + return ( + params.customFormat !== undefined || + params.authorcutoff !== undefined || + params.maxauthor !== undefined || + params.keyformat !== undefined || + params.journalformat !== undefined + ); +}; + +/** + * Serializes export params to URL query string format. + * Only includes params that are defined. + */ +export const serializeExportUrlParams = (params: ParsedExportUrlParams): Record => { + const result: Record = {}; + + if (params.customFormat !== undefined) { + result.customFormat = params.customFormat; + } + if (params.authorcutoff !== undefined) { + result.authorcutoff = String(params.authorcutoff); + } + if (params.maxauthor !== undefined) { + result.maxauthor = String(params.maxauthor); + } + if (params.keyformat !== undefined) { + result.keyformat = params.keyformat; + } + if (params.journalformat !== undefined) { + result.journalformat = String(params.journalformat); + } + + return result; +}; + +/** + * Compares two ParsedExportUrlParams for equality. + */ +export const exportParamsEqual = (a: ParsedExportUrlParams, b: ParsedExportUrlParams): boolean => { + return ( + a.customFormat === b.customFormat && + a.authorcutoff === b.authorcutoff && + a.maxauthor === b.maxauthor && + a.keyformat === b.keyformat && + a.journalformat === b.journalformat + ); +}; diff --git a/src/components/CitationExporter/useCitationExporter.ts b/src/components/CitationExporter/useCitationExporter.ts index 399f6e37a..f45950afd 100644 --- a/src/components/CitationExporter/useCitationExporter.ts +++ b/src/components/CitationExporter/useCitationExporter.ts @@ -1,137 +1,68 @@ -import { useMachine } from '@xstate/react/fsm'; -import { useEffect, useMemo } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; -import { generateMachine, ICitationExporterState } from './CitationExporter.machine'; +import { useMemo } from 'react'; import { purifyString } from '@/utils/common/formatters'; -import { ExportApiJournalFormat, IExportApiParams } from '@/api/export/types'; +import { IExportApiParams, IExportApiResponse, ExportApiJournalFormat } from '@/api/export/types'; +import { useGetExportCitation } from '@/api/export/export'; import { SolrSort } from '@/api/models'; -import { exportCitationKeys, fetchExportCitation, useGetExportCitation } from '@/api/export/export'; +import { IDocsEntity } from '@/api/search/types'; -export interface IUseCitationExporterProps { - records: ICitationExporterState['records']; +export interface UseCitationExporterParams { format: string; - customFormat?: string; - keyformat?: string; - journalformat?: ExportApiJournalFormat; - authorcutoff?: number; - maxauthor?: number; - singleMode: boolean; + customFormat: string; + keyformat: string; + journalformat: ExportApiJournalFormat; + authorcutoff: number; + maxauthor: number; + bibcodes: IDocsEntity['bibcode'][]; sort?: SolrSort[]; } -export const useCitationExporter = ({ - records, - format, - customFormat, - keyformat, - journalformat, - authorcutoff, - maxauthor, - singleMode, - sort, - ...rest -}: IUseCitationExporterProps) => { - const machine = useMemo( - () => - generateMachine({ - format, - keyformat, - customFormat, - journalformat, - authorcutoff, - maxauthor, - records, - singleMode, - sort, - ...rest, - }), - [], - ); - const [state, dispatch] = useMachine(machine); - const queryClient = useQueryClient(); - - // clean params before submitting to API - const params: IExportApiParams = { - ...state.context.params, - keyformat: [purifyString(state.context.params.keyformat[0])], - }; - - // on mount, check the cache to see if we any records for this querykey, if not, we should trigger an initial load - // should usually have an entry since the data will be available from SSR - useEffect(() => { - (async () => { - const queryKey = exportCitationKeys.primary(params); - const cached = queryClient.getQueryData(queryKey); - - if (!cached) { - // no cached value, prefetch and submit it here since we know the params - await queryClient.prefetchQuery({ - queryKey, - queryFn: fetchExportCitation, - meta: { params }, - }); - dispatch('SUBMIT'); - } - })(); - }, []); - - // trigger updates to machine state if incoming props change - useEffect(() => dispatch({ type: 'SET_SINGLEMODE', payload: singleMode }), [singleMode]); - - // watch for format changes - useEffect(() => { - if (format !== params.format) { - dispatch({ type: 'SET_FORMAT', payload: format }); - } - }, [format]); - - // if we're in singleMode and format is changed, trigger a submit - useEffect(() => { - if (singleMode) { - dispatch('SUBMIT'); - } - }, [params.format, singleMode]); - - // watch for changes to records - useEffect(() => { - // naively compare only the first record, this should be enough to determine - // there is a difference - if (records[0] !== state.context.records[0]) { - dispatch({ type: 'SET_RECORDS', payload: records }); - } - }, [records]); - - // watch for changes to sort - useEffect(() => { - if (sort !== params.sort) { - dispatch({ type: 'SET_SORT', payload: sort }); - } - }, [sort]); +export interface UseCitationExporterReturn { + data: IExportApiResponse | undefined; + isLoading: boolean; + isFetching: boolean; + error: Error | null; +} - // main result fetcher, this will not run unless we're in the 'fetching' state - const result = useGetExportCitation(params, { - enabled: state.matches('fetching'), +/** + * Simple data-fetching hook for citation export. + * Takes all params directly - no internal state management. + * State lives in the parent component (page uses nuqs for URL state). + */ +export const useCitationExporter = (params: UseCitationExporterParams): UseCitationExporterReturn => { + // Build API params from input + const apiParams: IExportApiParams = useMemo( + () => ({ + format: params.format, + customFormat: params.customFormat, + bibcode: params.bibcodes, + sort: params.sort, + authorcutoff: [params.authorcutoff], + journalformat: [params.journalformat], + keyformat: [purifyString(params.keyformat)], + maxauthor: [params.maxauthor], + }), + [ + params.format, + params.customFormat, + params.bibcodes, + params.sort, + params.authorcutoff, + params.journalformat, + params.keyformat, + params.maxauthor, + ], + ); - // will re-throw error to allow error boundary to catch + const result = useGetExportCitation(apiParams, { + enabled: params.bibcodes.length > 0, useErrorBoundary: true, - - // do not retry on fail retry: false, }); - useEffect(() => { - if (result.data) { - // derive this state from data, since we don't know if it was fetched from cache or not - dispatch({ type: 'DONE' }); - } - }, [result.data]); - - // safety hatch, in case for some reason we get stuck in fetching mode - useEffect(() => { - if (state.matches('fetching') && result.data) { - dispatch('DONE'); - } - }, [state.value, result.data]); - - return { ...result, state, dispatch }; + return { + data: result.data, + isLoading: result.isLoading, + isFetching: result.isFetching, + error: result.error as Error | null, + }; }; diff --git a/src/pages/search/exportcitation/[format].tsx b/src/pages/search/exportcitation/[format].tsx index 3c6a757ab..9bb77c28a 100644 --- a/src/pages/search/exportcitation/[format].tsx +++ b/src/pages/search/exportcitation/[format].tsx @@ -1,213 +1,328 @@ -import { Alert, AlertIcon, Box, Flex, Heading, HStack } from '@chakra-ui/react'; -import { ChevronLeftIcon } from '@chakra-ui/icons'; +import { Alert, AlertIcon, Box, Button, Flex, Heading, Spinner, Center, Text } from '@chakra-ui/react'; +import { ArrowLeftIcon } from '@chakra-ui/icons'; -import { getExportCitationDefaultContext } from '@/components/CitationExporter/CitationExporter.machine'; import { APP_DEFAULTS, BRAND_NAME_FULL } from '@/config'; -import { useIsClient } from '@/lib/useIsClient'; -import axios from 'axios'; import { GetServerSideProps, NextPage } from 'next'; import Head from 'next/head'; -import { last, map, prop } from 'ramda'; -import { dehydrate, QueryClient } from '@tanstack/react-query'; -import { composeNextGSSP } from '@/ssr-utils'; +import { useRouter } from 'next/router'; +import { last } from 'ramda'; +import { useCallback, useMemo, useState } from 'react'; import { useSettings } from '@/lib/useSettings'; import { useBackToSearchResults } from '@/lib/useBackToSearchResults'; -import { logger } from '@/logger'; import { SimpleLink } from '@/components/SimpleLink'; import { CitationExporter } from '@/components/CitationExporter'; import { JournalFormatMap } from '@/components/Settings'; -import { parseQueryFromUrl } from '@/utils/common/search'; import { unwrapStringValue } from '@/utils/common/formatters'; -import { parseAPIError } from '@/utils/common/parseAPIError'; -import { ExportApiFormatKey } from '@/api/export/types'; +import { ExportApiFormatKey, ExportApiJournalFormat } from '@/api/export/types'; import { IADSApiSearchParams } from '@/api/search/types'; -import { fetchSearchInfinite, searchKeys, useSearchInfinite } from '@/api/search/search'; -import { exportCitationKeys, fetchExportCitation, fetchExportFormats } from '@/api/export/export'; +import { useSearchInfinite } from '@/api/search/search'; +import { useExportFormats } from '@/lib/useExportFormats'; +import { parseQueryFromUrl } from '@/utils/common/search'; +import { composeNextGSSP } from '@/ssr-utils'; -interface IExportCitationPageProps { - format: string; - query: IADSApiSearchParams; - referrer?: string; // this is currently used by the library - error?: { - status?: string; - message?: string; - }; -} +const ExportCitationPage: NextPage = () => { + const router = useRouter(); + const { isValidFormat } = useExportFormats(); -const ExportCitationPage: NextPage = (props) => { - const { format, query, referrer } = props; - const isClient = useIsClient(); + // Get export related user settings for defaults + const { settings } = useSettings({ suspense: false }); - // get export related user settings - const { settings } = useSettings({ - suspense: false, - }); + // Wait for router to be ready before accessing query params + if (!router.isReady) { + return ( +
+ +
+ ); + } - const { keyformat, journalformat, authorcutoff, maxauthor } = - format === ExportApiFormatKey.bibtexabs + // Extract format from URL path + const pathFormat = router.query.format as string; + + // Validate format - support manifest formats and 'custom' + const validFormat = + isValidFormat(pathFormat) || pathFormat === ExportApiFormatKey.custom ? pathFormat : ExportApiFormatKey.bibtex; + + // User settings as defaults based on format + const userDefaults = + validFormat === ExportApiFormatKey.bibtexabs ? { keyformat: settings.bibtexABSKeyFormat, - journalformat: settings.bibtexJournalFormat, + journalformat: JournalFormatMap[settings.bibtexJournalFormat], authorcutoff: parseInt(settings.bibtexABSAuthorCutoff), maxauthor: parseInt(settings.bibtexABSMaxAuthors), } : { keyformat: settings.bibtexKeyFormat, - journalformat: settings.bibtexJournalFormat, + journalformat: JournalFormatMap[settings.bibtexJournalFormat], authorcutoff: parseInt(settings.bibtexAuthorCutoff), maxauthor: parseInt(settings.bibtexMaxAuthors), }; - const { data, fetchNextPage, hasNextPage, error } = useSearchInfinite(query); + // Parse URL params - these override user defaults + const urlCustomFormat = router.query.customFormat as string | undefined; + const urlKeyformat = router.query.keyformat as string | undefined; + const urlJournalformat = router.query.journalformat + ? (parseInt(router.query.journalformat as string, 10) as ExportApiJournalFormat) + : undefined; + const urlAuthorcutoff = router.query.authorcutoff ? parseInt(router.query.authorcutoff as string, 10) : undefined; + const urlMaxauthor = router.query.maxauthor ? parseInt(router.query.maxauthor as string, 10) : undefined; + + return ( + + ); +}; + +interface ExportCitationPageContentProps { + format: string; + customFormat?: string; + keyformat: string; + journalformat: ExportApiJournalFormat; + authorcutoff: number; + maxauthor: number; +} + +const ExportCitationPageContent = ({ + format, + customFormat, + keyformat, + journalformat, + authorcutoff, + maxauthor, +}: ExportCitationPageContentProps) => { + const router = useRouter(); + + // Parse search params with sort postfix for cursor pagination + // Exclude export-specific params so they don't affect the search query key + const { + qid, + referrer, + customFormat: _customFormat, + keyformat: _keyformat, + journalformat: _journalformat, + authorcutoff: _authorcutoff, + maxauthor: _maxauthor, + ...searchQuery + } = parseQueryFromUrl<{ + qid: string; + referrer: string; + customFormat: string; + keyformat: string; + journalformat: string; + authorcutoff: string; + maxauthor: string; + }>(router.asPath, { sortPostfix: 'id asc' }); + + // Build search query params + const searchParams: IADSApiSearchParams = { + rows: APP_DEFAULTS.EXPORT_PAGE_SIZE, + fl: ['bibcode'], + sort: searchQuery.sort ?? APP_DEFAULTS.SORT, + ...(qid ? { q: `docs(${qid})` } : searchQuery), + }; + + const { data, fetchNextPage, hasNextPage, error, isLoading } = useSearchInfinite(searchParams); const { getSearchHref, show: showSearchHref } = useBackToSearchResults(); - // TODO: add more error handling here + // Handle submit - update URL with new params + const handleSubmit = useCallback( + (params: { + format: string; + customFormat: string; + keyformat: string; + journalformat: ExportApiJournalFormat; + authorcutoff: number; + maxauthor: number; + }) => { + // Build new query params, preserving search params + const newQuery: Record = {}; + + // Preserve search-related params + if (router.query.q) { + newQuery.q = router.query.q as string; + } + if (router.query.fq) { + newQuery.fq = router.query.fq as string; + } + if (router.query.fq_database) { + newQuery.fq_database = router.query.fq_database as string; + } + if (router.query.sort) { + newQuery.sort = Array.isArray(router.query.sort) ? router.query.sort.join(',') : router.query.sort; + } + if (router.query.p) { + newQuery.p = router.query.p as string; + } + if (router.query.d) { + newQuery.d = router.query.d as string; + } + if (router.query.qid) { + newQuery.qid = router.query.qid as string; + } + if (router.query.referrer) { + newQuery.referrer = router.query.referrer as string; + } + + // Add export params + if (params.customFormat && params.customFormat !== '%1H:%Y:%q') { + newQuery.customFormat = params.customFormat; + } + if (params.keyformat && params.keyformat !== '%R') { + newQuery.keyformat = params.keyformat; + } + if (params.journalformat !== ExportApiJournalFormat.AASTeXMacros) { + newQuery.journalformat = String(params.journalformat); + } + if (params.authorcutoff !== APP_DEFAULTS.BIBTEX_DEFAULT_AUTHOR_CUTOFF) { + newQuery.authorcutoff = String(params.authorcutoff); + } + if (params.maxauthor !== 0) { + newQuery.maxauthor = String(params.maxauthor); + } + + void router.push( + { + pathname: `/search/exportcitation/${params.format}`, + query: newQuery, + }, + undefined, + { shallow: true }, + ); + }, + [router], + ); + + // Track which page of loaded data to display + const [currentPageIndex, setCurrentPageIndex] = useState(0); + + // Accumulate all records from all loaded pages + const allRecords = useMemo(() => { + if (!data?.pages) { + return []; + } + return data.pages.flatMap((page) => page.response.docs.map((d) => d.bibcode)); + }, [data?.pages]); + + // Get records for the current page view + const currentPageRecords = useMemo(() => { + const start = currentPageIndex * APP_DEFAULTS.EXPORT_PAGE_SIZE; + const end = start + APP_DEFAULTS.EXPORT_PAGE_SIZE; + return allRecords.slice(start, end); + }, [allRecords, currentPageIndex]); + + // Derived pagination state + const totalLoadedPages = data?.pages?.length ?? 0; + const hasPrevPage = currentPageIndex > 0; + const hasNextLoadedPage = currentPageIndex < totalLoadedPages - 1; + + const handlePrevPage = useCallback(() => { + setCurrentPageIndex((prev) => Math.max(0, prev - 1)); + }, []); + + const handleNextPage = useCallback(() => { + if (hasNextLoadedPage) { + // Navigate to next already-loaded page + setCurrentPageIndex((prev) => prev + 1); + } else if (hasNextPage) { + // Load more records and navigate to that page + void fetchNextPage().then(() => { + setCurrentPageIndex((prev) => prev + 1); + }); + } + }, [hasNextLoadedPage, hasNextPage, fetchNextPage]); + + // Only show full page spinner on initial load, not on refetches + if (isLoading && !data) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + + + Export Citations + + + + + {error.message} + + + ); + } + if (!data) { return null; } - const res = last(data?.pages).response; - const records = res.docs.map((d) => d.bibcode); - const numFound = res.numFound; - - const handleNextPage = () => { - void fetchNextPage(); - }; + const numFound = last(data.pages).response.numFound; return ( <> - {`${unwrapStringValue(query?.q)} - ${BRAND_NAME_FULL} Export Citations`} + {`${unwrapStringValue(searchParams?.q)} - ${BRAND_NAME_FULL} Export Citations`} - - - {(referrer || showSearchHref) && ( - - - - )} + + {(referrer || showSearchHref) && ( + + )} + Export Citations - - - {error ? ( - - - {error.message} - - ) : isClient ? ( - - ) : ( - - )} + + {numFound.toLocaleString()} records available + + + + + ); }; -export const getServerSideProps: GetServerSideProps = composeNextGSSP(async (ctx) => { - const { - qid = null, - p, - format, - referrer = null, - ...query - } = parseQueryFromUrl<{ qid: string; format: string }>(ctx.req.url, { sortPostfix: 'id asc' }); - - if (!query && !qid) { - return { - props: { - format, - query, - qid, - referrer, - error: 'No Records', - }, - }; - } - - const queryClient = new QueryClient(); - const params: IADSApiSearchParams = { - rows: APP_DEFAULTS.EXPORT_PAGE_SIZE, - fl: ['bibcode'], - sort: query.sort ?? APP_DEFAULTS.SORT, - ...(qid ? { q: `docs(${qid})` } : query), - }; - - try { - // primary search, this is based on query params - const data = await queryClient.fetchInfiniteQuery({ - queryKey: searchKeys.infinite(params), - queryFn: fetchSearchInfinite, - meta: { params }, - }); - - const formatsData = await queryClient.fetchQuery({ - queryKey: exportCitationKeys.manifest(), - queryFn: fetchExportFormats, - }); - - const formats = map(prop('route'), formatsData).map((r) => r.substring(1)); - - // extract bibcodes to use for export - const records = data.pages[0].response.docs.map((d) => d.bibcode); - - const { params: exportParams } = getExportCitationDefaultContext({ - format: formats.includes(format) ? format : ExportApiFormatKey.bibtex, - records, - singleMode: false, - sort: params.sort, - }); - - // fetch export string, format is pulled from the url - void (await queryClient.prefetchQuery({ - queryKey: exportCitationKeys.primary(exportParams), - queryFn: fetchExportCitation, - meta: { params: exportParams }, - })); - - // react-query infinite queries cannot be serialized by next, currently. - // see https://github.com/tannerlinsley/react-query/issues/3301#issuecomment-1041374043 - - const dehydratedState = JSON.parse(JSON.stringify(dehydrate(queryClient))); - - return { - props: { - format: exportParams.format, - query: params, - referrer, - dehydratedState, - }, - }; - } catch (error) { - logger.error({ msg: 'GSSP error in export citation page', error }); - return { - props: { - query: params, - pageError: parseAPIError(error), - error: axios.isAxiosError(error) ? error.message : 'Unable to fetch data', - }, - }; - } -}); export default ExportCitationPage; + +// Enable server-side session to inject user data for authentication +export const getServerSideProps: GetServerSideProps = composeNextGSSP(); From 3fe107534a4a4b7e2925e0b713ca367af78da33d Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Sat, 17 Jan 2026 01:22:25 -0500 Subject: [PATCH 6/6] refactor(export): simplify conditional logic and extract query params - Simplify redundant singleMode conditional check - Pass scalar values directly to field components instead of wrapping in arrays - Extract preserved search params into memoized helper --- .../CitationExporter/CitationExporter.tsx | 15 +++--- src/pages/search/exportcitation/[format].tsx | 49 +++++++------------ 2 files changed, 25 insertions(+), 39 deletions(-) diff --git a/src/components/CitationExporter/CitationExporter.tsx b/src/components/CitationExporter/CitationExporter.tsx index 8f5db1d9b..fba5c4955 100644 --- a/src/components/CitationExporter/CitationExporter.tsx +++ b/src/components/CitationExporter/CitationExporter.tsx @@ -257,9 +257,8 @@ const Exporter = (props: ICitationExporterProps): ReactElement => { )} {(!singleMode || - (singleMode && - (draft.format === ExportApiFormatKey.bibtex || - draft.format === ExportApiFormatKey.bibtexabs))) && ( + draft.format === ExportApiFormatKey.bibtex || + draft.format === ExportApiFormatKey.bibtexabs) && ( @@ -363,12 +362,10 @@ const BibTeXOptions = ({ BibTeX Options
- - - - {!isBasicMode && ( - - )} + + + + {!isBasicMode && }