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/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/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/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/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..fba5c4955 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,171 @@ 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 || + draft.format === ExportApiFormatKey.bibtex || + draft.format === ExportApiFormatKey.bibtexabs) && ( + + )}
- + @@ -222,85 +280,134 @@ 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 +416,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/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) => ({ 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)); + }), +]; 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'; diff --git a/src/pages/search/exportcitation/[format].tsx b/src/pages/search/exportcitation/[format].tsx index 3c6a757ab..3b540894c 100644 --- a/src/pages/search/exportcitation/[format].tsx +++ b/src/pages/search/exportcitation/[format].tsx @@ -1,213 +1,317 @@ -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 + // Preserve search-related query params across navigation + const preservedSearchParams = useMemo(() => { + const preserved: Record = {}; + const keysToPreserve = ['q', 'fq', 'fq_database', 'p', 'd', 'qid', 'referrer']; + + for (const key of keysToPreserve) { + if (router.query[key]) { + preserved[key] = router.query[key] as string; + } + } + if (router.query.sort) { + preserved.sort = Array.isArray(router.query.sort) ? router.query.sort.join(',') : router.query.sort; + } + return preserved; + }, [router.query]); + + // Handle submit - update URL with new params + const handleSubmit = useCallback( + (params: { + format: string; + customFormat: string; + keyformat: string; + journalformat: ExportApiJournalFormat; + authorcutoff: number; + maxauthor: number; + }) => { + const newQuery: Record = { ...preservedSearchParams }; + + // Add export params only when non-default + 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, preservedSearchParams], + ); + + // 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(); 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,