diff --git a/configHelper.sh b/configHelper.sh
index ed32065a3..8bdc397ce 100755
--- a/configHelper.sh
+++ b/configHelper.sh
@@ -79,6 +79,7 @@ prompt_required GLOBUS_CLIENT_SECRET "Enter the Globus Social Auth Secret" "6aWj
prompt_required SOLR_URL "Enter the SOLR URL \(default: https://esgf-node.llnl.gov/esg-search\)" "https://esgf-node.llnl.gov/esg-search" "https://esgf-node.llnl.gov/esg-search" "The URL at which the SOLR endpoint can be reached."
prompt_optional AUTHENTICATION_METHOD "Enter the Globus Authentication Method" "globus" "Which authentication method to enable for user sign in on the frontend."
prompt_optional FOOTER_TEXT "Enter the Footer Text (if it's a single line of markdown). If you need a more complex footer, skip for now and you can update the overlay file with your desired footer markdown afterwards." "Privacy & Legal Notice: [https://www.llnl.gov/disclaimer.html](https://www.llnl.gov/disclaimer.html)" "Text to display in the footer of the frontend. Useful for adding a link to the terms of service or other legal information. The string should be formatted as MarkDown and will be rendered as such."
+prompt_optional GLOBUS_NODES "Enter a list of Globus enabled nodes (comma-separated)" "[ 'node1', 'node2' ]" "A comma-separated list of Globus enabled nodes."
# Prompt for Keycloak deployment
echo "(Optional) Do you wish to add Keycloak social auth settings? (yes/no)"
@@ -122,6 +123,7 @@ services:
METAGRID_SOCIAL_AUTH_GLOBUS_SECRET: $GLOBUS_CLIENT_SECRET
METAGRID_AUTHENTICATION_METHOD: $AUTHENTICATION_METHOD
METAGRID_FOOTER_TEXT: $FOOTER_TEXT
+ METAGRID_GLOBUS_NODES: $GLOBUS_NODES
EOF
if [ "$USE_KEYCLOAK" == "yes" ]; then
diff --git a/docs/docs/contributors/frontend_development.md b/docs/docs/contributors/frontend_development.md
index fb85347af..8a472d328 100644
--- a/docs/docs/contributors/frontend_development.md
+++ b/docs/docs/contributors/frontend_development.md
@@ -39,19 +39,18 @@ Adapted from sources:
```scaffold
frontend
-├── Dockerfile
├── public
+│ ├── changelog
+│ │ ├── ...previous changelogs
+│ │ └── v1.3.4.md
+│ ├── messages
+│ │ └── metagrid_messages.md
│ ├── favicon.ico
│ ├── index.html
-│ └── manifest.json
+│ ├── manifest.json
+│ └── robots.txt
├── src
│ ├── api
-│ │ ├── mock
-│ │ │ ├── fixtures.ts
-│ │ │ ├── server-handlers.test.ts
-│ │ │ ├── server-handlers.ts
-│ │ │ ├── server.ts
-│ │ │ └── setup-env.ts
│ │ ├── index.test.ts
│ │ ├── index.ts
│ │ └── routes.ts
@@ -69,9 +68,11 @@ frontend
│ │ └── utils.ts
│ ├── components
│ │ ├── App
+│ │ │ ├── recoil
+│ │ │ │ └── atoms.ts
│ │ │ ├── App.css
-│ │ │ ├── App.tsx
-│ │ │ └── App.test.tsx
+│ │ │ ├── App.test.tsx
+│ │ │ └── App.tsx
│ │ ├── Cart
│ │ └── ...
│ ├── contexts
@@ -87,8 +88,22 @@ frontend
│ │ ├── keycloak
│ │ │ └── index.ts
│ ├── test
-│ │ └── custom-render.tsx
-│ ├── env.ts
+│ │ ├── __mocks__
+│ │ │ ├── assetFileMock.js
+│ │ │ ├── js-pkce.ts
+│ │ │ ├── keycloak-js.tsx
+│ │ │ └── ReactMarkdownMock.tsx
+│ │ ├── mock
+│ │ │ ├── fixtures.ts
+│ │ │ ├── mockStorage.ts
+│ │ │ ├── server-handlers.test.ts
+│ │ │ ├── server-handlers.ts
+│ │ │ ├── server.ts
+│ │ │ └── setup-env.ts
+│ │ ├── custom-render.tsx
+│ │ └── jestTestFunction.tsx
+│ ├── types
+│ │ └── globals.ts
│ ├── index.css
│ ├── index.tsx
│ └── setupTests.ts
@@ -97,23 +112,22 @@ frontend
├── .gitignore
├── .prettierignore
├── .prettierrc
-├── docker-compose.prod.yml
-├── docker-compose.yml
+├── Dockerfile
+├── index.html
+├── Makefile
+├── messageData.json
+├── nginx.conf
├── package.json
├── README.md
├── tsconfig.json
+├── vite.config.js
└── yarn.lock
```
- `Dockerfile` - The Dockerfile used by docker compose for the frontend
-- `public/` - stores static files used before app is compiled [https://create-react-app.dev/docs/using-the-public-folder/#when-to-use-the-public-folder](https://create-react-app.dev/docs/using-the-public-folder/#when-to-use-the-public-folder)
+- `public/` - stores static files used before app is compiled
- `src/` - where dynamic files reside, the **bulk of your work is done here**
- `api/` - contains API related files
- - `mock/` - API mocking using [_mock-service-worker_](https://mswjs.io/docs/) package to avoid making real requests in test suites. More info [here](https://kentcdodds.com/blog/stop-mocking-fetch)
- - `fixtures.ts` - stores objects that resemble API response data
- - `server-handlers.ts` - handles requests to routes by mapping fixtures as responses to each route endpoint
- - `server.ts` - sets up mock service worker server with server-handlers for tests. Essentially, it creates a mock server that intercepts all requests and handle it as if it were a real server
- - `setup-envs.ts` - imports the mock service worker server to all tests before initialization
- `index.ts` - contains promise-based HTTP client request functions to APIs, references `routes.ts` for API URL endpoints
- `routes.ts` - contains routes to APIs and error-handling
- `assets/` - stores assets used when the app is compiled
@@ -123,14 +137,21 @@ frontend
- `contexts/` - stores React [Context](https://reactjs.org/docs/context.html) components, such as for authentication state
- `lib/` - stores initialized instances of third party library that are exported for use in the codebase (e.g. Axios, Keycloak)
- `test/` - contains related files and functions shared among tests
+ - `__mocks__/` - Directory containing mock versions of required dependencies to ensure they work with tests
+ - `js-pkce.ts` - A mock of the js-pkce.ts library used for Globus transfer steps
+ - `keycloak-js.tsx` - A mock of the keycloak-js library used for Keycloak authentication
+ - `mock/` - API mocking using [_mock-service-worker_](https://mswjs.io/docs/) package to avoid making real requests in test suites. More info [here](https://kentcdodds.com/blog/stop-mocking-fetch)
+ - `fixtures.ts` - stores objects that resemble API response data
+ - `mockStorage.ts` - functions and code that handles persistent storage requests for the test suite
+ - `server-handlers.ts` - handles requests to routes by mapping fixtures as responses to each route endpoint
+ - `server.ts` - sets up mock service worker server with server-handlers for tests. Essentially, it creates a mock server that intercepts all requests and handle it as if it were a real server
- `custom-render.tsx` - wraps the react-testing-library render method with contexts from `/context`
+ - `jestTestFunctions.tsx` - contains a set of helper functions that are used by various tests
- `setupTests.ts` - configuration for additional test environment settings for jest
- `.dockerignore` - files and folders to ignore when building docker containers
-- `eslintrc.js` - configuration file for ESLint
+- `.eslintrc.js` - configuration file for ESLint
- `.prettierignore` - files and folders to ignore when running prettier
- `.prettierrc` - configuration file for prettier
-- `docker-compose.prod.yml` - the production overlay for docker-compose
-- `docker-compose.yml` - the local development config for docker-compose
- `tsconfig.json` - configuration file for TypeScript
- `yarn.lock` - the purpose of a lock file is to lock down the versions of the dependencies specified in a package.json file. This means that in a yarn.lock file, there is an identifier for every dependency and sub dependency that is used for a project
diff --git a/frontend/public/changelog/v1.0.10.md b/frontend/public/changelog/v1.0.10.md
index 855d22f8b..9ef149b16 100644
--- a/frontend/public/changelog/v1.0.10.md
+++ b/frontend/public/changelog/v1.0.10.md
@@ -4,11 +4,11 @@ This update includes several improvements, bug fixes and enhancements.
**Changes**
-1. Bugfix: limited wget script dataset count
-2. Fix logout redirect issue
-3. Added a Federated Nodes link (under the ESGF logo)
-4. Updated Project website links
-5. Updated React to version 18, migrated frontend components to a newer version, and other frontend package updates
-6. Updated the compactness of the search table and adjusted styling to improve display of important feature columns
+1. Bugfix: limited wget script dataset count.
+2. Fix logout redirect issue.
+3. Added a Federated Nodes link (under the ESGF logo).
+4. Updated Project website links.
+5. Updated React to version 18, migrated frontend components to a newer version, and other frontend package updates.
+6. Updated the compactness of the search table and adjusted styling to improve display of important feature columns.
7. Added Globus Auth.
-8. Updated keycloak version to 19 and updated configurations
+8. Updated keycloak version to 19 and updated configurations.
diff --git a/frontend/public/changelog/v1.0.8.md b/frontend/public/changelog/v1.0.8.md
index f1d809fae..06e7b3409 100644
--- a/frontend/public/changelog/v1.0.8.md
+++ b/frontend/public/changelog/v1.0.8.md
@@ -6,10 +6,10 @@ This is the 'Messages' update! Moving forward, when a new Metagrid version is re
1. Added new notification drawer on the right which provides admins a way to communicate with users information relevant to Metagrid. Markdown docs can be displayed and content modified at run-time will be shown.
2. Created new Welcome dialog for first time users which includes buttons to start feature tours or view latest changes.
-3. Created Change Log dialog that allows users to see details about latest update
-4. Refactored the Joyride tours to improve ease and reliability of future updates
-5. Updated test suite to handle latest major package updates and modifications
-6. Migrated to the react-router-dom major version 6
-7. Upgraded to Django 4.1.7 and upgraded various backend dependencies
-8. Added support for backend url settings
-9. Updated various minor frontend dependencies including keycloak-js
+3. Created Change Log dialog that allows users to see details about latest update.
+4. Refactored the Joyride tours to improve ease and reliability of future updates.
+5. Updated test suite to handle latest major package updates and modifications.
+6. Migrated to the react-router-dom major version 6.
+7. Upgraded to Django 4.1.7 and upgraded various backend dependencies.
+8. Added support for backend url settings.
+9. Updated various minor frontend dependencies including keycloak-js.
diff --git a/frontend/public/changelog/v1.0.9.md b/frontend/public/changelog/v1.0.9.md
index f27d14cee..ecb578c85 100644
--- a/frontend/public/changelog/v1.0.9.md
+++ b/frontend/public/changelog/v1.0.9.md
@@ -13,7 +13,7 @@ This is the 'Globus Transfer' update! You now have the ability to use the Globus
- Provided notifications and logic to alert users when they try to transfer a dataset that is not Globus Ready
- Added ability to store recent Globus transfer tasks as they are submitted, for later reference
- After a successful transfer, users can now click a link and view the submitted task on the Globus site
-3. Utilized new functions that take advantage of Django's session storage, for persistent storage of needed data
-4. Introduced the use or Recoil and shared state among various components, which will allow improved flexibility for adding features moving forward
-5. Several updates to packages and refactoring of code to improve code base and application reliability
-6. Bug fixes and minor improvements to the User Interface
+3. Utilized new functions that take advantage of Django's session storage, for persistent storage of needed data.
+4. Introduced the use or Recoil and shared state among various components, which will allow improved flexibility for adding features moving forward.
+5. Several updates to packages and refactoring of code to improve code base and application reliability.
+6. Bug fixes and minor improvements to the User Interface.
diff --git a/frontend/public/changelog/v1.1.0.md b/frontend/public/changelog/v1.1.0.md
index 271e9c59c..43c155a1e 100644
--- a/frontend/public/changelog/v1.1.0.md
+++ b/frontend/public/changelog/v1.1.0.md
@@ -7,6 +7,6 @@ This update includes several improvements, additional features, bug fixes and en
1. Added ability to select a managed endpoint and obtain required scopes and permissions to successfully perform a transfer.
2. Added feature to allow users to copy a list of the available facet options when selecting facet filters. The list is copied as text to the user's clipboard and include the result count to match the frontend display.
3. Updated the search page to no longer use a button to confirm selected project. CMIP6 project will be selected by default.
-4. Upgrade `antd` frontend library to `v5`
-5. Upgrade `django` backend library to `4.2.10`
+4. Upgrade `antd` frontend library to `v5`.
+5. Upgrade `django` backend library to `4.2.10`.
6. **Bugfix** - support larger Globus Transfer dataset counts.
diff --git a/frontend/public/changelog/v1.1.1-pre.md b/frontend/public/changelog/v1.1.1-pre.md
index 2f5ef6840..ac12f494c 100644
--- a/frontend/public/changelog/v1.1.1-pre.md
+++ b/frontend/public/changelog/v1.1.1-pre.md
@@ -4,5 +4,5 @@ This update includes a quick bug fix to address issues related to saved searches
**Changes**
-1. Fixed bug related to saved search where the save action would fail if logged-in due to csrf tokens
-2. Fixed issue where clicking saved search wouldn't restore the saved search in the search page
+1. Fixed bug related to saved search where the save action would fail if logged-in due to csrf tokens.
+2. Fixed issue where clicking saved search wouldn't restore the saved search in the search page.
diff --git a/frontend/public/changelog/v1.1.2-pre.md b/frontend/public/changelog/v1.1.2-pre.md
index dcd293ac2..d80343e0b 100644
--- a/frontend/public/changelog/v1.1.2-pre.md
+++ b/frontend/public/changelog/v1.1.2-pre.md
@@ -4,4 +4,4 @@ This update includes several improvements, bug fixes and enhancements.
**Changes**
-1. Added Globus endpoint pop-up that allows users to search and save collections and paths for transfers
+1. Added Globus endpoint pop-up that allows users to search and save collections and paths for transfers.
diff --git a/frontend/public/changelog/v1.1.3-pre.md b/frontend/public/changelog/v1.1.3-pre.md
index 71b9f56b1..369b60a3d 100644
--- a/frontend/public/changelog/v1.1.3-pre.md
+++ b/frontend/public/changelog/v1.1.3-pre.md
@@ -1,4 +1,4 @@
## Summary
1. **bugfix** affecting Wget script requests after clearing/repopulating the data cart.
-2. Additonal reliability with more unit tests completed
+2. Additonal reliability with more unit tests completed.
diff --git a/frontend/public/changelog/v1.2.0.md b/frontend/public/changelog/v1.2.0.md
index 46359da20..f6668a4e0 100644
--- a/frontend/public/changelog/v1.2.0.md
+++ b/frontend/public/changelog/v1.2.0.md
@@ -1,6 +1,6 @@
## Summary
-1. Several bugfixes, typo fixes and minor enhancements
-2. Updates to the CI configuration and full test suite upgrade
-3. Additional reliability with most unit tests completed and passing
-4. Several package upgrades for backend and frontend
+1. Several bugfixes, typo fixes and minor enhancements.
+2. Updates to the CI configuration and full test suite upgrade.
+3. Additional reliability with most unit tests completed and passing.
+4. Several package upgrades for backend and frontend.
diff --git a/frontend/public/changelog/v1.3.0.md b/frontend/public/changelog/v1.3.0.md
index 4d67fa485..e5ab9acef 100644
--- a/frontend/public/changelog/v1.3.0.md
+++ b/frontend/public/changelog/v1.3.0.md
@@ -2,6 +2,6 @@
1. Updates to the deployment of Metagrid
- Configuration for frontend is pulled from backend
-2. Footer can now be customized and modified to satisfy requirements for different deployment sites
+2. Footer can now be customized and modified to satisfy requirements for different deployment sites.
3. Added Dark Mode with the option to switch between Light and Dark themes.
2. Minor bugfixes.
diff --git a/frontend/public/changelog/v1.3.1.md b/frontend/public/changelog/v1.3.1.md
index 105c21e13..6c9c6ef6a 100644
--- a/frontend/public/changelog/v1.3.1.md
+++ b/frontend/public/changelog/v1.3.1.md
@@ -1,7 +1,7 @@
## Summary
-1. Updates to the Traefik deployment of Metagrid to work with the changes from v1.3.0s
-2. Improvements and additional support for Helm deployments
-3. Updates to Joyride Tutorial styling, to match selected themes
+1. Updates to the Traefik deployment of Metagrid to work with the changes from v1.3.0s.
+2. Improvements and additional support for Helm deployments.
+3. Updates to Joyride Tutorial styling, to match selected themes.
4. Updated the link to obs4MIPS project and renamed buttons to use 'Data Info' instead of 'Website'.
4. Minor bugfixes.
diff --git a/frontend/public/changelog/v1.3.4.md b/frontend/public/changelog/v1.3.4.md
index d7687106e..c180f49ce 100644
--- a/frontend/public/changelog/v1.3.4.md
+++ b/frontend/public/changelog/v1.3.4.md
@@ -1,6 +1,7 @@
## Summary
-1. Updated the Globus path handling to work with the newest Globus changes
-2. Bug fix for Globus transfers in Firefox
-3. Globus login permissions may be requested, to help permissions with some endpoints.
-4. Minor updates to Helm deployment
+1. Updated the Globus path handling to work with the newest Globus changes.
+2. Bug fix for Globus transfers in Firefox.
+3. Minor updates to Helm deployment.
+4. Updated to how session storage is used to persist variables between redirects.
+5. Updated the cart summary to display information on selected datasets
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index 4c238b48b..68880a4c2 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -9,7 +9,6 @@ import 'setimmediate'; // Added because in Jest 27, setImmediate is not defined,
import humps from 'humps';
import queryString from 'query-string';
import { AxiosResponse } from 'axios';
-import PKCE from 'js-pkce';
import axios from '../lib/axios';
import {
RawUserCart,
@@ -30,11 +29,6 @@ import {
import { RawUserAuth, RawUserInfo } from '../contexts/types';
import apiRoutes, { ApiRoute, HTTPCodeType } from './routes';
import { GlobusEndpointSearchResults } from '../components/Globus/types';
-import GlobusStateKeys from '../components/Globus/recoil/atom';
-
-// Reference: https://github.com/bpedroza/js-pkce
-export const REQUESTED_SCOPES =
- 'openid profile email urn:globus:auth:scope:transfer.api.globus.org:all';
export interface ResponseError extends Error {
status?: number;
@@ -647,19 +641,6 @@ export const saveSessionValues = async (data: { key: string; value: unknown }[])
await Promise.all(saveFuncs);
};
-// Creates an auth object using desired authentication scope
-export async function createGlobusAuthObject(): Promise {
- const authScope = await loadSessionValue(GlobusStateKeys.globusAuth);
-
- return new PKCE({
- client_id: window.METAGRID.GLOBUS_CLIENT_ID, // Update this using your native client ID
- redirect_uri: `${window.location.origin}/cart/items`, // Update this if you are deploying this anywhere else (Globus Auth will redirect back here once you have logged in)
- authorization_endpoint: 'https://auth.globus.org/v2/oauth2/authorize', // No changes needed
- token_endpoint: 'https://auth.globus.org/v2/oauth2/token', // No changes needed
- requested_scopes: authScope || REQUESTED_SCOPES, // Update with any scopes you would need, e.g. transfer
- });
-}
-
export const startSearchGlobusEndpoints = async (
searchText: string
): Promise => {
diff --git a/frontend/src/common/DataBundlePersister.test.ts b/frontend/src/common/DataBundlePersister.test.ts
new file mode 100644
index 000000000..b470167d0
--- /dev/null
+++ b/frontend/src/common/DataBundlePersister.test.ts
@@ -0,0 +1,168 @@
+import { mockFunction } from '../test/jestTestFunctions';
+import { tempStorageGetMock, tempStorageSetMock } from '../test/mock/mockStorage';
+import DataBundlePersister from './DataBundlePersister';
+
+const mockLoadValue = mockFunction((key: unknown) => {
+ return Promise.resolve(tempStorageGetMock(key as string));
+});
+
+const mockSaveValue = mockFunction((key: unknown, value: unknown) => {
+ tempStorageSetMock(key as string, value);
+ return Promise.resolve({
+ msg: 'Updated temporary storage.',
+ data_key: key,
+ });
+});
+
+jest.mock('../api/index', () => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const originalModule = jest.requireActual('../api/index');
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ return {
+ __esModule: true,
+ ...originalModule,
+ loadSessionValue: (key: string) => {
+ return mockLoadValue(key);
+ },
+ saveSessionValue: (key: string, value: unknown) => {
+ return mockSaveValue(key, value);
+ },
+ };
+});
+
+describe('DataBundlePersister', () => {
+ const db = DataBundlePersister.Instance;
+
+ beforeEach(() => {
+ // Reset the mock storage before each test
+ db.initializeDataStore({});
+ });
+
+ it('returns the DataPersistor singleton instance', () => {
+ expect(db).toBeInstanceOf(DataBundlePersister);
+ expect(DataBundlePersister.Instance).toBeInstanceOf(DataBundlePersister);
+ });
+
+ it('should add a new variable', () => {
+ const key = 'testKey';
+ const value = 'testValue';
+ db.addVar(key, value);
+
+ expect(db.get(key, null)).toBe(value);
+ });
+
+ it('should set and get a variable', () => {
+ const key = 'testKey';
+ const value = 'testValue';
+ db.set(key, value);
+
+ expect(db.get(key, null)).toBe(value);
+ });
+
+ it('should save and load data', async () => {
+ const key = 'testKey';
+ const value = 'testValue';
+ db.set(key, value);
+
+ await db.saveAll();
+ // Flush persistor to simulate a redirect
+ db.initializeDataStore({});
+ expect(db.get(key, null)).toBe(null);
+
+ await db.loadAll();
+ expect(db.get(key, null)).toBe(value);
+ });
+
+ it('should set and save a variable', async () => {
+ const key = 'testKey';
+ const value = 'testValue';
+ await db.setAndSave(key, value);
+
+ expect(db.get(key, null)).toBe(value);
+
+ // Flush persistor to simulate a redirect
+ db.initializeDataStore({});
+ expect(db.get(key, null)).toBe(null);
+
+ await db.loadAll();
+ expect(db.get(key, null)).toBe(value);
+ });
+
+ it('should initialize data store', () => {
+ const dataStore = {
+ testKey: {
+ key: 'testKey',
+ value: 'testValue',
+ setter: jest.fn(),
+ },
+ };
+ db.initializeDataStore(dataStore);
+
+ expect(db.get('testKey', null)).toBe('testValue');
+ });
+
+ it('should not update the value if it is the same as the current value', () => {
+ const key = 'testKey';
+ const value = 'testValue';
+ const setterFunc = jest.fn();
+ db.addVar(key, value, setterFunc);
+
+ db.set(key, value);
+
+ expect(setterFunc).not.toHaveBeenCalled();
+ });
+
+ it('should handle non-existent keys gracefully', () => {
+ const key = 'nonExistentKey';
+ const defaultValue = 'defaultValue';
+
+ expect(db.get(key, defaultValue)).toBe(defaultValue);
+ });
+
+ it('should handle loading from an empty session storage', async () => {
+ tempStorageSetMock(DataBundlePersister.DEFAULT_KEY, null);
+ await db.loadAll();
+
+ expect(Object.keys(db.peekAtDataStore()).length).toBe(0);
+ });
+
+ it('should handle saving when data store is empty', async () => {
+ await expect(db.saveAll()).resolves.not.toThrow();
+ });
+
+ it('should update the value if it is different from the current value', () => {
+ const key = 'testKey';
+ const initialValue = 'initialValue';
+ const newValue = 'newValue';
+ db.addVar(key, initialValue);
+
+ const setterSpy = jest.spyOn(db.peekAtDataStore()[key], 'setter');
+ db.set(key, newValue);
+
+ expect(setterSpy).toHaveBeenCalledWith(newValue);
+ expect(db.get(key, null)).toBe(newValue);
+ });
+
+ it('should handle adding a variable with an existing key', () => {
+ const key = 'testKey';
+ const initialValue = 'initialValue';
+ const newValue = 'newValue';
+ db.addVar(key, initialValue);
+ db.addVar(key, newValue);
+
+ expect(db.get(key, null)).toBe(initialValue);
+ });
+
+ it('should handle adding a variable with a setter function', () => {
+ const key = 'testKey';
+ const value = 'testValue';
+ const setterFunc = jest.fn();
+ db.addVar(key, value, setterFunc);
+
+ expect(db.get(key, null)).toBe(value);
+
+ db.set(key, 'newValue');
+ expect(db.get(key, null)).toBe('newValue');
+ });
+});
diff --git a/frontend/src/common/DataBundlePersister.ts b/frontend/src/common/DataBundlePersister.ts
new file mode 100644
index 000000000..f3a357efb
--- /dev/null
+++ b/frontend/src/common/DataBundlePersister.ts
@@ -0,0 +1,121 @@
+import { SetterOrUpdater } from 'recoil';
+import { loadSessionValue, saveSessionValue } from '../api';
+
+type DataVar = {
+ value: T;
+ setter: (value: T) => void;
+};
+
+export default class DataBundlePersister {
+ private static instance: DataBundlePersister;
+
+ private BUNDLED_DATA_STORE: {
+ [key: string]: DataVar;
+ } = {};
+
+ private constructor() {
+ this.BUNDLED_DATA_STORE = {};
+ }
+
+ public static readonly DEFAULT_KEY = 'dataBundleBlob';
+
+ public static get Instance(): DataBundlePersister {
+ if (!this.instance) {
+ this.instance = new this();
+ }
+ return this.instance;
+ }
+
+ initializeDataStore(dataStore: { [key: string]: DataVar }): void {
+ this.BUNDLED_DATA_STORE = dataStore;
+ }
+
+ peekAtDataStore(): { [key: string]: DataVar } {
+ return this.BUNDLED_DATA_STORE;
+ }
+
+ addVar(
+ key: string,
+ defaultVal: T,
+ setterFunc: SetterOrUpdater | undefined = undefined
+ ): void {
+ // Create setter function
+ const setter = (newValue: T): void => {
+ // Update the value only if it is different from the previous value
+ // This is to avoid calling the setter function multiple times
+ const currentValue = this.BUNDLED_DATA_STORE[key].value;
+ if (currentValue !== newValue) {
+ this.BUNDLED_DATA_STORE[key].value = newValue;
+ if (setterFunc) {
+ setterFunc(newValue);
+ }
+ }
+ };
+
+ let valueToSet: T = defaultVal;
+ if (Object.hasOwn(this.BUNDLED_DATA_STORE, key)) {
+ valueToSet = this.BUNDLED_DATA_STORE[key].value as T;
+ }
+
+ // Update data store if the key does not exist
+ this.BUNDLED_DATA_STORE[key] = {
+ value: valueToSet,
+ setter,
+ } as DataVar;
+ }
+
+ set(key: string, value: T): void {
+ if (Object.hasOwn(this.BUNDLED_DATA_STORE, key)) {
+ this.BUNDLED_DATA_STORE[key].setter(value);
+ } else {
+ this.addVar(key, value);
+ }
+ }
+
+ async setAndSave(key: string, value: T): Promise {
+ this.set(key, value);
+ await this.saveAll();
+ }
+
+ get(key: string, defaultVal: T): T {
+ if (Object.hasOwn(this.BUNDLED_DATA_STORE, key)) {
+ return this.BUNDLED_DATA_STORE[key].value as T;
+ }
+ return defaultVal;
+ }
+
+ async saveAll(): Promise {
+ if (Object.keys(this.BUNDLED_DATA_STORE).length === 0) {
+ return;
+ }
+
+ // Create a bundle of all the data
+ const dataBundle: { [key: string]: unknown } = {};
+ Object.entries(this.BUNDLED_DATA_STORE).forEach(([key, value]) => {
+ dataBundle[key] = value.value;
+ });
+
+ // Save the bundle to session storage
+ await saveSessionValue(DataBundlePersister.DEFAULT_KEY, JSON.stringify(dataBundle));
+ }
+
+ async loadAll(): Promise {
+ // console.info('Loading data bundle...');
+ // Load the bundle from session storage
+ const loadedJSON: string | null = await loadSessionValue(
+ DataBundlePersister.DEFAULT_KEY
+ );
+
+ // Parse the loaded JSON
+ const dataBundle = loadedJSON
+ ? (JSON.parse(loadedJSON) as { [key: string]: DataVar })
+ : null;
+
+ if (dataBundle) {
+ // Update the data store with the parsed values
+ Object.entries(dataBundle).forEach(([key, value]) => {
+ this.set(key, value);
+ });
+ }
+ }
+}
diff --git a/frontend/src/common/DataPersister.test.ts b/frontend/src/common/DataPersister.test.ts
deleted file mode 100644
index c4f9026ac..000000000
--- a/frontend/src/common/DataPersister.test.ts
+++ /dev/null
@@ -1,144 +0,0 @@
-import { mockFunction, tempStorageGetMock, tempStorageSetMock } from '../test/jestTestFunctions';
-import { DataPersister } from './DataPersister';
-
-const mockLoadValue = mockFunction((key: unknown) => {
- return Promise.resolve(tempStorageGetMock(key as string));
-});
-
-const mockSaveValue = mockFunction((key: unknown, value: unknown) => {
- tempStorageSetMock(key as string, value);
- return Promise.resolve({
- msg: 'Updated temporary storage.',
- data_key: key,
- });
-});
-
-jest.mock('../api/index', () => {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
- const originalModule = jest.requireActual('../api/index');
-
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return
- return {
- __esModule: true,
- ...originalModule,
- loadSessionValue: (key: string) => {
- return mockLoadValue(key);
- },
- saveSessionValue: (key: string, value: unknown) => {
- return mockSaveValue(key, value);
- },
- };
-});
-
-describe('Test DataPersister Class', () => {
- const persistor = DataPersister.Instance;
-
- const persistentStore = {};
-
- // Initialize persistor
- persistor.initializeDataStore(persistentStore);
-
- const setterStore: { val: string | number } = { val: 0 };
- const loaderStore = { load: 789 };
-
- it('returns the DataPersistor singleton instance', () => {
- expect(persistor).toBeInstanceOf(DataPersister);
-
- expect(DataPersister.Instance).toBeInstanceOf(DataPersister);
- });
-
- it('tests addNewVar function of DataPersistor', () => {
- // Add a test variable
- persistor.addNewVar(
- 'testVar',
- 123,
- (val: number) => {
- setterStore.val = val;
- },
- () => {
- return new Promise((resolve) => {
- resolve(loaderStore.load);
- });
- }
- );
-
- // Check that variable exists and has default value
- expect(persistor.getValue('testVar')).toBeTruthy();
- expect(persistor.getValue('testVar')).toEqual(123);
-
- // If adding variable with same key, shouldn't do anything
- persistor.addNewVar('testVar', 'test', (val: string) => {
- setterStore.val = val;
- });
- // The default value of 'test' should not exist
- expect(persistor.getValue('testVar')).not.toEqual('test');
- // The value should remain the default of 123
- expect(persistor.getValue('testVar')).toEqual(123);
-
- // adds a variable using a default loader function
- persistor.addNewVar('testVar2', 'testVal', (val: string) => {
- setterStore.val = val;
- });
- const val = persistor.loadValue('testVar2');
- expect(val).resolves.toEqual('testVal');
- });
-
- it('test setValue function', async () => {
- // adds a variable using a default loader function
- persistor.addNewVar('testVarSet', 123, (val: number) => {
- setterStore.val = val;
- });
- // Call setValue, with save option on
- await persistor.setValue('testVarSet', 456, true);
-
- // Check that testVar has set the value and called the setter function
- expect(persistor.getValue('testVarSet')).toEqual(456);
- expect(setterStore.val).toEqual(456);
-
- // Call setValue again but don't save
- await persistor.setValue('testVarSet', 678, false);
- expect(persistor.getValue('testVarSet')).toEqual(678);
- expect(await persistor.loadValue('testVarSet')).toEqual(456);
-
- // If setValue uses non-existent variable name, get should return null
- await persistor.setValue('nonExistentVar', 123, true);
- expect(persistor.getValue('nonExistentVar')).toBeNull();
- });
-
- it('test saveAllValues function', async () => {
- // adds some variables with default loader function
- persistor.addNewVar('testVar1', 1, () => {});
- persistor.addNewVar('testVar2', 2, () => {});
- persistor.addNewVar('testVar3', 3, () => {});
-
- // Update values but don't save
- await persistor.setValue('testVar1', 10, false);
- await persistor.setValue('testVar2', 20, false);
- await persistor.setValue('testVar3', 30, false);
-
- // Verify that values weren't saved by loading var1
- expect(await persistor.loadValue('testVar1')).toEqual(1);
-
- // Now save all values
- await persistor.saveAllValues();
-
- // Verify updated values are now loaded with var2
- expect(await persistor.loadValue('testVar2')).toEqual(20);
- });
-
- it('test loadValue function', () => {
- // Original variable should load from loader
- expect(persistor.loadValue('testVar')).resolves.toEqual(loaderStore.load);
-
- // TestVar2 calls default load session value function
- expect(persistor.loadValue('testVar2')).resolves.toEqual('testVal');
-
- // Load val returns null if the variable doesn't exist
- expect(persistor.loadValue('nonVar')).resolves.toBeNull();
- });
-
- it('test load all values function', async () => {
- const results = await persistor.loadAllValues();
- expect(results).toEqual(undefined);
- });
-});
diff --git a/frontend/src/common/DataPersister.ts b/frontend/src/common/DataPersister.ts
deleted file mode 100644
index 6625c3397..000000000
--- a/frontend/src/common/DataPersister.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import { SetterOrUpdater } from 'recoil';
-import { loadSessionValue, saveSessionValue } from '../api';
-
-export type PersistData = {
- loader: () => Promise;
- saver: () => Promise;
- setter: (value: T) => void;
- value: T;
-};
-
-export class DataPersister {
- private static instance: DataPersister;
-
- private PERSISTENT_STORE: {
- [key: string]: PersistData;
- } = {};
-
- private constructor() {
- this.PERSISTENT_STORE = {};
- }
-
- public static get Instance(): DataPersister {
- if (!this.instance) {
- this.instance = new this();
- }
- return this.instance;
- }
-
- initializeDataStore(dataStore: { [key: string]: PersistData }): void {
- this.PERSISTENT_STORE = dataStore;
- }
-
- addNewVar(
- varKey: string,
- defaultVal: T,
- setterFunc: SetterOrUpdater | ((val: T) => void),
- loaderFunc?: () => Promise
- ): void {
- if (Object.hasOwn(this.PERSISTENT_STORE, varKey)) {
- return;
- }
-
- const loader = async (): Promise => {
- let val: T | null = null;
- if (loaderFunc) {
- val = await loaderFunc();
- } else {
- val = await loadSessionValue(varKey);
- }
-
- this.PERSISTENT_STORE[varKey].value = val || defaultVal;
- setterFunc(val || defaultVal);
-
- return val || defaultVal;
- };
-
- const saver = async (): Promise => {
- await saveSessionValue(varKey, this.PERSISTENT_STORE[varKey].value as T);
- };
-
- const setter = (val: T): void => {
- this.PERSISTENT_STORE[varKey].value = val;
- setterFunc(val);
- };
-
- const newVar = { loader, saver, setter, value: defaultVal };
- this.PERSISTENT_STORE[varKey] = newVar as PersistData;
- }
-
- getValue(varKey: string): T | null {
- if (this.PERSISTENT_STORE[varKey]) {
- return this.PERSISTENT_STORE[varKey].value as T;
- }
- return null;
- }
-
- async loadValue(varKey: string): Promise {
- if (this.PERSISTENT_STORE[varKey]) {
- return this.PERSISTENT_STORE[varKey].loader() as Promise;
- }
- return null;
- }
-
- async setValue(varKey: string, value: T, save: boolean): Promise {
- if (this.PERSISTENT_STORE[varKey]) {
- this.PERSISTENT_STORE[varKey].setter(value);
-
- if (save) {
- await this.PERSISTENT_STORE[varKey].saver();
- }
- }
- }
-
- async loadAllValues(): Promise {
- const loadFuncs: Promise[] = [];
- Object.values(this.PERSISTENT_STORE).forEach((persistVar) => {
- loadFuncs.push(persistVar.loader());
- });
-
- await Promise.all(loadFuncs);
- }
-
- async saveAllValues(): Promise {
- const saveFuncs: Promise[] = [];
- Object.values(this.PERSISTENT_STORE).forEach((persistVar) => {
- saveFuncs.push(persistVar.saver());
- });
-
- await Promise.all(saveFuncs);
- }
-}
diff --git a/frontend/src/common/reactJoyrideSteps.test.ts b/frontend/src/common/reactJoyrideSteps.test.ts
index f6f2192c7..103332c73 100644
--- a/frontend/src/common/reactJoyrideSteps.test.ts
+++ b/frontend/src/common/reactJoyrideSteps.test.ts
@@ -1,5 +1,4 @@
import {
- getCurrentAppPage,
delay,
elementExists,
elementHasState,
@@ -8,8 +7,8 @@ import {
createCartItemsTour,
createSearchCardTour,
createNodeStatusTour,
+ defaultTarget,
} from './reactJoyrideSteps';
-import { AppPage } from './types';
import { mockConfig } from '../test/jestTestFunctions';
describe('Test reactJoyrideStep util functions', () => {
@@ -22,34 +21,6 @@ describe('Test reactJoyrideStep util functions', () => {
expect(nextTime - time).toBeLessThan(1000);
});
- it('returns appropriate page name based on window location', () => {
- expect(getCurrentAppPage()).toEqual(-1);
-
- // eslint-disable-next-line
- window = Object.create(window);
- const url = 'https://test.com/search';
- Object.defineProperty(window, 'location', {
- value: {
- href: url,
- pathname: 'testing/search',
- },
- writable: true,
- });
- expect(window.location.href).toEqual(url);
- expect(window.location.pathname).toEqual('testing/search');
-
- // Test page names
- expect(getCurrentAppPage()).toEqual(AppPage.Main);
- window.location.pathname = 'testing/cart/items';
- expect(getCurrentAppPage()).toEqual(AppPage.Cart);
- window.location.pathname = 'testing/cart/searches';
- expect(getCurrentAppPage()).toEqual(AppPage.SavedSearches);
- window.location.pathname = 'testing/cart/nodes';
- expect(getCurrentAppPage()).toEqual(AppPage.NodeStatus);
- window.location.pathname = 'testing/bad';
- expect(getCurrentAppPage()).toEqual(-1);
- });
-
it('returns true if an element exists, otherwise false', () => {
const newElement = document.createElement('div');
newElement.className = 'testElement';
@@ -111,4 +82,8 @@ describe('Test reactJoyrideStep util functions', () => {
// Made the main table empty
document.getElementById('root')?.appendChild(mainTable);
});
+
+ it('defaultTarget should have correct selector', () => {
+ expect(defaultTarget.selector()).toBe('#root .navbar-logo');
+ });
});
diff --git a/frontend/src/common/reactJoyrideSteps.ts b/frontend/src/common/reactJoyrideSteps.ts
index b94478890..2a81c7a7c 100644
--- a/frontend/src/common/reactJoyrideSteps.ts
+++ b/frontend/src/common/reactJoyrideSteps.ts
@@ -2,23 +2,6 @@ import { JoyrideTour } from './JoyrideTour';
import { TargetObject } from './TargetObject';
import { AppPage } from './types';
-export const getCurrentAppPage = (): number => {
- const { pathname } = window.location;
- if (pathname.endsWith('/search') || pathname.includes('/search/')) {
- return AppPage.Main;
- }
- if (pathname.endsWith('/cart/items')) {
- return AppPage.Cart;
- }
- if (pathname.endsWith('/nodes')) {
- return AppPage.NodeStatus;
- }
- if (pathname.endsWith('/cart/searches')) {
- return AppPage.SavedSearches;
- }
- return -1;
-};
-
export const delay = (ms: number): Promise => {
return new Promise((res) => {
setTimeout(res, ms);
@@ -150,7 +133,8 @@ export const cartTourTargets = {
datasetBtn: new TargetObject(),
libraryBtn: new TargetObject(),
downloadAllType: new TargetObject(),
- downloadAllBtn: new TargetObject(),
+ downloadWgetBtn: new TargetObject(),
+ downloadTransferBtn: new TargetObject(),
globusCollectionDropdown: new TargetObject(),
removeItemsBtn: new TargetObject(),
};
@@ -683,7 +667,7 @@ export const createCartItemsTour = (setCurrentPage: (page: number) => void): Joy
tour
.addNextStep(
cartTourTargets.cartSummary.selector(),
- 'This shows a summary of all the datasets in the cart. From here you can see the total datasets, files and total file size at a glance. Note: The summary is visible to both the data cart and search library.'
+ "This shows a summary of all the datasets you've added and selected in the cart. From here you can see the number of datasets, files and file size of both the cart and your selected datasets at a glance. Note: The summary is visible to both the data cart and search library."
)
.addNextStep(
'.ant-table-container',
@@ -730,7 +714,7 @@ export const createCartItemsTour = (setCurrentPage: (page: number) => void): Joy
'top-start'
)
.addNextStep(
- cartTourTargets.downloadAllBtn.selector(),
+ cartTourTargets.downloadTransferBtn.selector(),
'After selecting your collection, click this button to start the download for your selected cart items.',
'top-start',
/* istanbul ignore next */
@@ -889,7 +873,7 @@ export const createSearchCardTour = (setCurrentPage: (page: number) => void): Jo
tour
.addNextStep(
cartTourTargets.cartSummary.selector(),
- 'This shows a summary of all the datasets in the data cart. The summary is visible to both the data cart and search library.'
+ "This shows a summary of all the datasets you've added and selected in the data cart. The summary is visible to both the data cart and search library."
)
.addNextStep(
savedSearchTourTargets.savedSearches.selector(),
diff --git a/frontend/src/common/utils.test.tsx b/frontend/src/common/utils.test.tsx
index 009cf112d..60815ba37 100644
--- a/frontend/src/common/utils.test.tsx
+++ b/frontend/src/common/utils.test.tsx
@@ -2,12 +2,14 @@ import { render } from '@testing-library/react';
import React from 'react';
import { MessageInstance } from 'antd/es/message/interface';
import { message } from 'antd';
+import { atom, RecoilRoot, useRecoilState } from 'recoil';
import { rawProjectFixture } from '../test/mock/fixtures';
import { UserSearchQueries, UserSearchQuery } from '../components/Cart/types';
import { ActiveSearchQuery, RawSearchResult, RawSearchResults } from '../components/Search/types';
import {
combineCarts,
formatBytes,
+ getCurrentAppPage,
getSearchFromUrl,
getUrlFromSearch,
objectHasKey,
@@ -17,7 +19,9 @@ import {
showNotice,
splitStringByChar,
unsavedLocalSearches,
+ localStorageEffect,
} from './utils';
+import { AppPage } from './types';
describe('Test objectIsEmpty', () => {
it('returns true with empty object', () => {
@@ -339,6 +343,36 @@ describe('Test unsavedLocal searches', () => {
});
});
+describe('Test getCurrentAppPage', () => {
+ it('returns appropriate page name based on window location', () => {
+ expect(getCurrentAppPage()).toEqual(-1);
+
+ // eslint-disable-next-line
+ window = Object.create(window);
+ const url = 'https://test.com/search';
+ Object.defineProperty(window, 'location', {
+ value: {
+ href: url,
+ pathname: 'testing/search',
+ },
+ writable: true,
+ });
+ expect(window.location.href).toEqual(url);
+ expect(window.location.pathname).toEqual('testing/search');
+
+ // Test page names
+ expect(getCurrentAppPage()).toEqual(AppPage.Main);
+ window.location.pathname = 'testing/cart/items';
+ expect(getCurrentAppPage()).toEqual(AppPage.Cart);
+ window.location.pathname = 'testing/cart/searches';
+ expect(getCurrentAppPage()).toEqual(AppPage.SavedSearches);
+ window.location.pathname = 'testing/cart/nodes';
+ expect(getCurrentAppPage()).toEqual(AppPage.NodeStatus);
+ window.location.pathname = 'testing/bad';
+ expect(getCurrentAppPage()).toEqual(-1);
+ });
+});
+
describe('Test show notices function', () => {
// Creating a test component to render the messages and verify they're rendered
type Props = { testFunc: (msgApi: MessageInstance) => void };
@@ -417,3 +451,29 @@ describe('Test show notices function', () => {
expect(await findByText('An unknown error has occurred.')).toBeTruthy();
});
});
+
+describe('Test localStorageEffect', () => {
+ const key = 'testKey';
+ const defaultVal = 'defaultValue';
+
+ const testAtom = atom({
+ key: 'testAtom',
+ default: defaultVal,
+ effects_UNSTABLE: [localStorageEffect(key, defaultVal)],
+ });
+
+ const TestComponent: React.FC = () => {
+ const [value] = useRecoilState(testAtom);
+ return
{value}
;
+ };
+
+ it('sets to default value when JSON.parse throws an error', () => {
+ localStorage.setItem(key, 'invalid JSON');
+ const { getByText } = render(
+
+
+
+ );
+ expect(getByText(defaultVal)).toBeTruthy();
+ });
+});
diff --git a/frontend/src/common/utils.ts b/frontend/src/common/utils.ts
index c43bab2e4..ecb04fc23 100644
--- a/frontend/src/common/utils.ts
+++ b/frontend/src/common/utils.ts
@@ -1,5 +1,6 @@
import { CSSProperties, ReactNode } from 'react';
import { MessageInstance } from 'antd/es/message/interface';
+import { AtomEffect } from 'recoil';
import { UserSearchQueries, UserSearchQuery } from '../components/Cart/types';
import { ActiveFacets } from '../components/Facets/types';
import {
@@ -11,8 +12,10 @@ import {
VersionType,
} from '../components/Search/types';
import messageDisplayData from '../components/Messaging/messageDisplayData';
+import { AppPage } from './types';
export type NotificationType = 'success' | 'info' | 'warning' | 'error';
+
export async function showNotice(
msgApi: MessageInstance,
content: React.ReactNode | string,
@@ -76,6 +79,23 @@ export async function showError(
await showNotice(msgApi, msg, { duration: 5, type: 'error' });
}
+export const getCurrentAppPage = (): number => {
+ const { pathname } = window.location;
+ if (pathname.endsWith('/search') || pathname.includes('/search/')) {
+ return AppPage.Main;
+ }
+ if (pathname.endsWith('/cart/items')) {
+ return AppPage.Cart;
+ }
+ if (pathname.endsWith('/nodes')) {
+ return AppPage.NodeStatus;
+ }
+ if (pathname.endsWith('/cart/searches')) {
+ return AppPage.SavedSearches;
+ }
+ return -1;
+};
+
/**
* Checks if an object is empty.
*/
@@ -92,6 +112,27 @@ export const objectHasKey = (
key: string | number
): boolean => Object.prototype.hasOwnProperty.call(obj, key);
+export const localStorageEffect = (key: string, defaultVal: T): AtomEffect => ({
+ setSelf,
+ onSet,
+}) => {
+ const savedValue = localStorage.getItem(key);
+ if (savedValue != null) {
+ try {
+ const parsedValue = JSON.parse(savedValue) as T;
+ setSelf(parsedValue);
+ } catch (error) {
+ setSelf(defaultVal);
+ }
+ } else {
+ setSelf(defaultVal);
+ }
+
+ onSet((newValue) => {
+ localStorage.setItem(key, JSON.stringify(newValue));
+ });
+};
+
/**
* For a record's 'xlink' attribute, it will be split into an array of
* three strings.
diff --git a/frontend/src/components/App/App.test.tsx b/frontend/src/components/App/App.test.tsx
index 037d4e83d..7496f9b99 100644
--- a/frontend/src/components/App/App.test.tsx
+++ b/frontend/src/components/App/App.test.tsx
@@ -313,10 +313,16 @@ describe('User cart', () => {
const cartSummary = await screen.findByTestId('summary');
expect(cartSummary).toBeTruthy();
- const numDatasetsField = await within(cartSummary).findByText('Number of Datasets:');
- const numFilesText = await within(cartSummary).findByText('Number of Files:');
- expect(numDatasetsField.textContent).toEqual('Number of Datasets: 1');
- expect(numFilesText.textContent).toEqual('Number of Files: 2');
+ const numDatasetsField = await within(cartSummary).findByText('Total Number of Datasets:');
+ const numFilesText = await within(cartSummary).findByText('Total Number of Files:');
+ expect(numDatasetsField.textContent).toEqual('Total Number of Datasets: 1');
+ expect(numFilesText.textContent).toEqual('Total Number of Files: 2');
+ const numSelectedDatasetsField = await within(cartSummary).findByText(
+ 'Selected Number of Datasets:'
+ );
+ const numSelectedFilesText = await within(cartSummary).findByText('Selected Number of Files:');
+ expect(numSelectedDatasetsField.textContent).toEqual('Selected Number of Datasets: 0');
+ expect(numSelectedFilesText.textContent).toEqual('Selected Number of Files: 0');
// Check "Remove All Items" button renders with cart > 0 items and click it
const clearCartBtn = await screen.findByTestId('clear-cart-button');
@@ -330,8 +336,8 @@ describe('User cart', () => {
await userEvent.click(confirmBtn);
// Check number of datasets and files are now 0
- expect(numDatasetsField.textContent).toEqual('Number of Datasets: 0');
- expect(numFilesText.textContent).toEqual('Number of Files: 0');
+ expect(numDatasetsField.textContent).toEqual('Total Number of Datasets: 0');
+ expect(numFilesText.textContent).toEqual('Total Number of Files: 0');
// Check empty alert renders
const emptyAlert = await screen.findByText('Your cart is empty');
@@ -392,10 +398,17 @@ describe('User cart', () => {
const cartSummary = await screen.findByTestId('summary');
expect(cartSummary).toBeTruthy();
- const numDatasetsField = await within(cartSummary).findByText('Number of Datasets:');
- const numFilesText = await within(cartSummary).findByText('Number of Files:');
- expect(numDatasetsField.textContent).toEqual('Number of Datasets: 1');
- expect(numFilesText.textContent).toEqual('Number of Files: 2');
+ const numDatasetsField = await within(cartSummary).findByText('Total Number of Datasets:');
+ const numFilesText = await within(cartSummary).findByText('Total Number of Files:');
+ expect(numDatasetsField.textContent).toEqual('Total Number of Datasets: 1');
+ expect(numFilesText.textContent).toEqual('Total Number of Files: 2');
+
+ const numSelectedDatasetsField = await within(cartSummary).findByText(
+ 'Selected Number of Datasets:'
+ );
+ const numSelectedFilesText = await within(cartSummary).findByText('Selected Number of Files:');
+ expect(numSelectedDatasetsField.textContent).toEqual('Selected Number of Datasets: 0');
+ expect(numSelectedFilesText.textContent).toEqual('Selected Number of Files: 0');
// Check "Remove All Items" button renders with cart > 0 items and click it
const clearCartBtn = await screen.findByTestId('clear-cart-button');
@@ -410,8 +423,8 @@ describe('User cart', () => {
await userEvent.click(confirmBtn);
// Check number of datasets and files are now 0
- expect(numDatasetsField.textContent).toEqual('Number of Datasets: 0');
- expect(numFilesText.textContent).toEqual('Number of Files: 0');
+ expect(numDatasetsField.textContent).toEqual('Total Number of Datasets: 0');
+ expect(numFilesText.textContent).toEqual('Total Number of Files: 0');
// Check empty alert renders
const emptyAlert = await screen.findByText('Your cart is empty');
diff --git a/frontend/src/components/App/App.tsx b/frontend/src/components/App/App.tsx
index 026979ed7..57c879bfa 100644
--- a/frontend/src/components/App/App.tsx
+++ b/frontend/src/components/App/App.tsx
@@ -66,7 +66,7 @@ import StartPopup from '../Messaging/StartPopup';
import startupDisplayData from '../Messaging/messageDisplayData';
import './App.css';
import { miscTargets } from '../../common/reactJoyrideSteps';
-import { isDarkModeAtom } from './recoil/atoms';
+import isDarkModeAtom from './recoil/atoms';
import Footer from '../Footer/Footer';
const bodySider = {
diff --git a/frontend/src/components/App/recoil/atoms.test.tsx b/frontend/src/components/App/recoil/atoms.test.tsx
new file mode 100644
index 000000000..8e95528e7
--- /dev/null
+++ b/frontend/src/components/App/recoil/atoms.test.tsx
@@ -0,0 +1,78 @@
+import { RecoilRoot, useRecoilState } from 'recoil';
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import userEvent from '@testing-library/user-event';
+import isDarkModeAtom from './atoms';
+
+const TestComponent = (): React.ReactNode => {
+ const [isDarkMode, setIsDarkMode] = useRecoilState(isDarkModeAtom);
+ return (
+
+ {isDarkMode.toString()}
+
+
+
+ );
+};
+
+describe('isDarkModeAtom', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('should initialize with default value', () => {
+ render(
+
+
+
+ );
+
+ const isDarkMode = screen.getByTestId('isDarkMode');
+ expect(isDarkMode.textContent).toBe('false');
+ });
+
+ it('should persist value to localStorage', async () => {
+ render(
+
+
+
+ );
+
+ const setDarkModeTrueButton = await screen.findByTestId('setDarkModeTrue');
+ await userEvent.click(setDarkModeTrueButton);
+
+ expect(localStorage.getItem('isDarkMode')).toBe('true');
+ });
+
+ it('should read value from localStorage', () => {
+ localStorage.setItem('isDarkMode', 'true');
+
+ render(
+
+
+
+ );
+
+ const isDarkMode = screen.getByTestId('isDarkMode');
+ expect(isDarkMode.textContent).toBe('true');
+ });
+
+ it('should reset value and remove from localStorage', async () => {
+ render(
+
+
+
+ );
+ const setDarkModeTrueButton = await screen.findByTestId('setDarkModeTrue');
+ await userEvent.click(setDarkModeTrueButton);
+
+ const setDarkModeFalseButton = await screen.findByTestId('setDarkModeFalse');
+ await userEvent.click(setDarkModeFalseButton);
+
+ expect(localStorage.getItem('isDarkMode')).toBe('false');
+ });
+});
diff --git a/frontend/src/components/App/recoil/atoms.ts b/frontend/src/components/App/recoil/atoms.ts
index 2a9e59b0c..6be783ec3 100644
--- a/frontend/src/components/App/recoil/atoms.ts
+++ b/frontend/src/components/App/recoil/atoms.ts
@@ -1,6 +1,6 @@
import { atom, AtomEffect } from 'recoil';
-export const localStorageEffect = (key: string): AtomEffect => ({ setSelf, onSet }) => {
+const darkModeStorageEffect = (key: string): AtomEffect => ({ setSelf, onSet }) => {
const savedValue = localStorage.getItem(key);
if (savedValue != null) {
setSelf(savedValue === 'true');
@@ -9,19 +9,15 @@ export const localStorageEffect = (key: string): AtomEffect => ({ setSe
setSelf(mediaQuery.matches);
}
- onSet((newValue, _, isReset) => {
- if (isReset) {
- localStorage.removeItem(key);
- } else {
- localStorage.setItem(key, newValue.toString());
- }
+ onSet((newValue) => {
+ localStorage.setItem(key, newValue.toString());
});
};
-export const isDarkModeAtom = atom({
+const isDarkModeAtom = atom({
key: 'isDarkMode',
default: false,
- effects: [localStorageEffect('isDarkMode')],
+ effects: [darkModeStorageEffect('isDarkMode')],
});
export default isDarkModeAtom;
diff --git a/frontend/src/components/Cart/Items.tsx b/frontend/src/components/Cart/Items.tsx
index d5122b3f5..383096fdf 100644
--- a/frontend/src/components/Cart/Items.tsx
+++ b/frontend/src/components/Cart/Items.tsx
@@ -8,9 +8,8 @@ import Button from '../General/Button';
import Table from '../Search/Table';
import { RawSearchResults } from '../Search/types';
import DatasetDownload from '../Globus/DatasetDownload';
-import CartStateKeys, { cartItemSelections } from './recoil/atoms';
+import { cartItemSelections } from './recoil/atoms';
import { NodeStatusArray } from '../NodeStatus/types';
-import { DataPersister } from '../../common/DataPersister';
const styles: CSSinJS = {
summary: {
@@ -32,8 +31,6 @@ export type Props = {
nodeStatus?: NodeStatusArray;
};
-const dp: DataPersister = DataPersister.Instance;
-
const Items: React.FC> = ({
userCart,
onUpdateCart,
@@ -41,10 +38,9 @@ const Items: React.FC> = ({
nodeStatus,
}) => {
const [itemSelections, setItemSelections] = useRecoilState(cartItemSelections);
- dp.addNewVar(CartStateKeys.cartItemSelections, [], setItemSelections);
- const handleRowSelect = async (selectedRows: RawSearchResults | []): Promise => {
- await dp.setValue(CartStateKeys.cartItemSelections, selectedRows, true);
+ const handleRowSelect = (selectedRows: RawSearchResults): void => {
+ setItemSelections(selectedRows);
};
return (
diff --git a/frontend/src/components/Cart/Summary.test.tsx b/frontend/src/components/Cart/Summary.test.tsx
index 61e333ace..948820cde 100644
--- a/frontend/src/components/Cart/Summary.test.tsx
+++ b/frontend/src/components/Cart/Summary.test.tsx
@@ -1,8 +1,10 @@
import React from 'react';
import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { rawSearchResultFixture, userCartFixture } from '../../test/mock/fixtures';
import Summary, { Props } from './Summary';
import customRender from '../../test/custom-render';
+import CartStateKeys from './recoil/atoms';
const defaultProps: Props = {
userCart: userCartFixture(),
@@ -16,11 +18,11 @@ test('renders component', async () => {
it('shows the correct number of datasets and files', async () => {
customRender();
// Shows number of files
- const numDatasetsField = await screen.findByText('Number of Datasets:');
- const numFilesText = await screen.findByText('Number of Files:');
+ const numDatasetsField = await screen.findByText('Total Number of Datasets:');
+ const numFilesText = await screen.findByText('Total Number of Files:');
- expect(numDatasetsField.textContent).toEqual('Number of Datasets: 3');
- expect(numFilesText.textContent).toEqual('Number of Files: 8');
+ expect(numDatasetsField.textContent).toEqual('Total Number of Datasets: 3');
+ expect(numFilesText.textContent).toEqual('Total Number of Files: 8');
});
it('renders component with correct calculations when a dataset doesn"t have size or number_of_files attributes', async () => {
@@ -36,9 +38,74 @@ it('renders component with correct calculations when a dataset doesn"t have size
/>
);
// Shows number of files
- const numDatasetsField = await screen.findByText('Number of Datasets:');
- const numFilesText = await screen.findByText('Number of Files:');
+ const numDatasetsField = await screen.findByText('Total Number of Datasets:');
+ const numFilesText = await screen.findByText('Total Number of Files:');
- expect(numDatasetsField.textContent).toEqual('Number of Datasets: 2');
- expect(numFilesText.textContent).toEqual('Number of Files: 3');
+ expect(numDatasetsField.textContent).toEqual('Total Number of Datasets: 2');
+ expect(numFilesText.textContent).toEqual('Total Number of Files: 3');
+});
+
+it('shows the correct number of datasets and files when cart is empty', async () => {
+ customRender();
+ const numDatasetsField = await screen.findByText('Total Number of Datasets:');
+ const numFilesText = await screen.findByText('Total Number of Files:');
+
+ expect(numDatasetsField.textContent).toEqual('Total Number of Datasets: 0');
+ expect(numFilesText.textContent).toEqual('Total Number of Files: 0');
+});
+
+describe('shows the correct selected datasets and files', () => {
+ it('when no items are selected', async () => {
+ customRender();
+ const numSelectedDatasetsField = await screen.findByText('Selected Number of Datasets:');
+ const numSelectedFilesText = await screen.findByText('Selected Number of Files:');
+
+ expect(numSelectedDatasetsField.textContent).toEqual('Selected Number of Datasets: 0');
+ expect(numSelectedFilesText.textContent).toEqual('Selected Number of Files: 0');
+ });
+
+ it('when items are selected', async () => {
+ localStorage.setItem(
+ CartStateKeys.cartItemSelections,
+ JSON.stringify([rawSearchResultFixture(), rawSearchResultFixture(), rawSearchResultFixture()])
+ );
+ customRender();
+ const numSelectedDatasetsField = await screen.findByText('Selected Number of Datasets:');
+ const numSelectedFilesText = await screen.findByText('Selected Number of Files:');
+
+ expect(numSelectedDatasetsField.textContent).toEqual('Selected Number of Datasets: 3');
+ expect(numSelectedFilesText.textContent).toEqual('Selected Number of Files: 9');
+ });
+});
+
+it('renders task submit history when tasks are present', async () => {
+ const taskItems = [
+ {
+ taskId: '1',
+ submitDate: '2023-01-01',
+ taskStatusURL: 'http://example.com/task/1',
+ },
+ ];
+ localStorage.setItem('globusTaskItems', JSON.stringify(taskItems));
+ customRender();
+
+ const taskHistoryTitle = await screen.findByText('Task Submit History');
+ expect(taskHistoryTitle).toBeTruthy();
+});
+
+it('clears all tasks when clear button is clicked', async () => {
+ const taskItems = [
+ {
+ taskId: '1',
+ submitDate: '2023-01-01',
+ taskStatusURL: 'http://example.com/task/1',
+ },
+ ];
+ localStorage.setItem('globusTaskItems', JSON.stringify(taskItems));
+ const { getByTestId } = customRender();
+
+ const clearButton = getByTestId('clear-all-submitted-globus-tasks');
+ await userEvent.click(clearButton);
+
+ expect(screen.queryByText('Task Submit History')).toBeNull();
});
diff --git a/frontend/src/components/Cart/Summary.tsx b/frontend/src/components/Cart/Summary.tsx
index e4f123085..3c3bc8078 100644
--- a/frontend/src/components/Cart/Summary.tsx
+++ b/frontend/src/components/Cart/Summary.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { Card, Collapse, Divider, List, Typography } from 'antd';
-import { useRecoilState } from 'recoil';
+import { useRecoilState, useRecoilValue } from 'recoil';
import Button from '../General/Button';
import cartImg from '../../assets/img/cart.svg';
import folderImg from '../../assets/img/folder.svg';
@@ -10,8 +10,8 @@ import { formatBytes } from '../../common/utils';
import { RawSearchResult, RawSearchResults } from '../Search/types';
import { UserCart } from './types';
import { GlobusTaskItem } from '../Globus/types';
-import GlobusStateKeys, { globusTaskItems } from '../Globus/recoil/atom';
-import { DataPersister } from '../../common/DataPersister';
+import { globusTaskItems } from '../Globus/recoil/atom';
+import { cartItemSelections } from './recoil/atoms';
const styles: CSSinJS = {
headerContainer: { display: 'flex', justifyContent: 'center' },
@@ -28,33 +28,47 @@ const styles: CSSinJS = {
};
export type Props = {
- userCart: UserCart | [];
+ userCart: UserCart;
};
const { Title, Link } = Typography;
-const dp: DataPersister = DataPersister.Instance;
const Summary: React.FC> = ({ userCart }) => {
+ const cartItems = useRecoilValue(cartItemSelections);
const [taskItems, setTaskItems] = useRecoilState(globusTaskItems);
- dp.addNewVar(GlobusStateKeys.globusTaskItems, [], setTaskItems);
+
+ let numSelectedFiles = 0;
+ let totalSelectedDataSize = '0';
+ if (cartItems.length > 0) {
+ numSelectedFiles = cartItems.reduce(
+ (acc: number, dataset: RawSearchResult) => acc + (dataset.number_of_files || 0),
+ 0
+ );
+
+ const rawDataSize = cartItems.reduce(
+ (acc: number, dataset: RawSearchResult) => acc + (dataset.size || 0),
+ 0
+ );
+ totalSelectedDataSize = formatBytes(rawDataSize);
+ }
let numFiles = 0;
let totalDataSize = '0';
if (userCart.length > 0) {
- numFiles = (userCart as RawSearchResults).reduce(
+ numFiles = userCart.reduce(
(acc: number, dataset: RawSearchResult) => acc + (dataset.number_of_files || 0),
0
);
- const rawDataSize = (userCart as RawSearchResults).reduce(
+ const rawDataSize = userCart.reduce(
(acc: number, dataset: RawSearchResult) => acc + (dataset.size || 0),
0
);
totalDataSize = formatBytes(rawDataSize);
}
- const clearAllTasks = async (): Promise => {
- await dp.setValue(GlobusStateKeys.globusTaskItems, [], true);
+ const clearAllTasks = (): void => {
+ setTaskItems([]);
};
return (
@@ -71,13 +85,25 @@ const Summary: React.FC> = ({ userCart }) => {
- Number of Datasets: {userCart.length}
+ Total Number of Datasets: {userCart.length}
+
+
+ Total Number of Files: {numFiles}
+
+
+ Total Cart File Size: {totalDataSize}
+
+
+
+
+
+ Selected Number of Datasets: {cartItems.length}
- Number of Files: {numFiles}
+ Selected Number of Files: {numSelectedFiles}
- Total File Size: {totalDataSize}
+ Selected Files Size: {totalSelectedDataSize}
diff --git a/frontend/src/components/Cart/recoil/atoms.ts b/frontend/src/components/Cart/recoil/atoms.ts
index 9d1931759..8b165fb18 100644
--- a/frontend/src/components/Cart/recoil/atoms.ts
+++ b/frontend/src/components/Cart/recoil/atoms.ts
@@ -1,5 +1,6 @@
import { atom } from 'recoil';
import { RawSearchResults } from '../../Search/types';
+import { localStorageEffect } from '../../../common/utils';
enum CartStateKeys {
cartItemSelections = 'cartItemSelections',
@@ -9,11 +10,13 @@ enum CartStateKeys {
export const cartDownloadIsLoading = atom({
key: CartStateKeys.cartDownloadIsLoading,
default: false,
+ effects: [localStorageEffect(CartStateKeys.cartDownloadIsLoading, false)],
});
export const cartItemSelections = atom({
key: CartStateKeys.cartItemSelections,
default: [],
+ effects: [localStorageEffect(CartStateKeys.cartItemSelections, [])],
});
export default CartStateKeys;
diff --git a/frontend/src/components/Globus/DatasetDownload.test.tsx b/frontend/src/components/Globus/DatasetDownload.test.tsx
index 80c221677..a6cd89132 100644
--- a/frontend/src/components/Globus/DatasetDownload.test.tsx
+++ b/frontend/src/components/Globus/DatasetDownload.test.tsx
@@ -4,30 +4,31 @@ import { within, screen } from '@testing-library/react';
import customRender from '../../test/custom-render';
import { rest, server } from '../../test/mock/server';
import { getSearchFromUrl } from '../../common/utils';
-import { ActiveSearchQuery } from '../Search/types';
+import { ActiveSearchQuery, RawSearchResults } from '../Search/types';
import {
globusReadyNode,
+ initRecoilValue,
makeCartItem,
mockConfig,
mockFunction,
openDropdownList,
- tempStorageGetMock,
- tempStorageSetMock,
+ printElementContents,
} from '../../test/jestTestFunctions';
import App from '../App/App';
-import { GlobusEndpoint, GlobusTokenResponse } from './types';
+import { GlobusEndpoint, GlobusTaskItem, GlobusTokenResponse } from './types';
import GlobusStateKeys from './recoil/atom';
import CartStateKeys from '../Cart/recoil/atoms';
import {
globusEndpointFixture,
globusAccessTokenFixture,
globusTransferTokenFixture,
+ globusAuthScopeFixure,
} from '../../test/mock/fixtures';
import apiRoutes from '../../api/routes';
import DatasetDownloadForm, { GlobusGoals } from './DatasetDownload';
-import getGlobusTransferToken from './utils';
-import { DataPersister } from '../../common/DataPersister';
-import { saveSessionValue } from '../../api';
+import DataBundlePersister from '../../common/DataBundlePersister';
+import { tempStorageGetMock, tempStorageSetMock } from '../../test/mock/mockStorage';
+import { AppPage } from '../../common/types';
const activeSearch: ActiveSearchQuery = getSearchFromUrl('project=test1');
@@ -62,8 +63,22 @@ jest.mock('../../api/index', () => {
};
});
+jest.mock('../../common/utils', () => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const originalModule = jest.requireActual('../../common/utils');
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ return {
+ __esModule: true,
+ ...originalModule,
+ getCurrentAppPage: () => {
+ return AppPage.Cart;
+ },
+ };
+});
+
// Create fixtures to use in tests
-const dp = DataPersister.Instance;
+const db = DataBundlePersister.Instance;
const testEndpointPath = 'testPathValid';
const testEndpointId = 'endpoint1';
@@ -89,6 +104,7 @@ const validEndpointWithPathSet = globusEndpointFixture(
const defaultTestConfig = {
renderFullApp: false,
+ authenticated: false,
globusEnabledNodes: [globusReadyNode],
globusGoals: GlobusGoals.None,
testUrlState: { authTokensUrlReady: false, endpointPathUrlReady: false },
@@ -96,6 +112,8 @@ const defaultTestConfig = {
itemSelections: [makeCartItem('globusReadyItem1', true), makeCartItem('globusReadyItem2', true)],
savedEndpoints: [validEndpointNoPathSet, validEndpointWithPathSet],
chosenEndpoint: validEndpointWithPathSet as GlobusEndpoint | null,
+ cartDownloadIsLoading: false,
+ globusTaskItems: [] as GlobusTaskItem[],
};
function setEndpointUrl(endpointId?: string, path?: string | null): void {
@@ -132,29 +150,39 @@ async function initializeComponentForTest(testConfig?: typeof defaultTestConfig)
// Set names of the globus enabled nodes
mockConfig.GLOBUS_NODES = config.globusEnabledNodes;
- // Set the Globus Goals
- tempStorageSetMock(GlobusStateKeys.globusTransferGoalsState, config.globusGoals);
+ // Set the auth scope by default
+ db.set(GlobusStateKeys.globusAuth, globusAuthScopeFixure);
// Set the auth token state
if (config.tokensReady.access) {
- dp.addNewVar(GlobusStateKeys.accessToken, globusAccessTokenFixture, () => {});
+ db.set(GlobusStateKeys.accessToken, globusAccessTokenFixture);
}
if (config.tokensReady.transfer) {
- dp.addNewVar(GlobusStateKeys.transferToken, globusTransferTokenFixture, () => {});
+ const transferToken = { ...globusTransferTokenFixture };
+ transferToken.created_on = Math.floor(Date.now() / 1000);
+
+ db.set(GlobusStateKeys.transferToken, transferToken);
}
+ // Set the Globus Goals
+ initRecoilValue(GlobusStateKeys.globusTransferGoalsState, config.globusGoals);
+ initRecoilValue(CartStateKeys.cartDownloadIsLoading, config.cartDownloadIsLoading);
+
// Set the selected cart items
- tempStorageSetMock(CartStateKeys.cartItemSelections, config.itemSelections);
+ initRecoilValue(CartStateKeys.cartItemSelections, config.itemSelections);
// Set the saved endpoints
- tempStorageSetMock(GlobusStateKeys.savedGlobusEndpoints, config.savedEndpoints);
+ initRecoilValue(GlobusStateKeys.savedGlobusEndpoints, config.savedEndpoints);
+
+ // Set the globus task items
+ initRecoilValue(GlobusStateKeys.globusTaskItems, config.globusTaskItems);
// Default display name if no endpoint is chosen
let displayName = 'Select Globus Collection';
// Set the chosen endpoint and display name if it's not null
if (config.chosenEndpoint !== null) {
- tempStorageSetMock(GlobusStateKeys.userChosenEndpoint, config.chosenEndpoint);
+ db.set(GlobusStateKeys.userChosenEndpoint, config.chosenEndpoint);
// If setup has endpoint chosen, set display name to know when component is loaded
displayName = config.chosenEndpoint.display_name;
@@ -167,6 +195,8 @@ async function initializeComponentForTest(testConfig?: typeof defaultTestConfig)
setEndpointUrl(config.chosenEndpoint.id, config.chosenEndpoint.path);
}
+ db.saveAll();
+
// Finally render the component
if (config.renderFullApp) {
customRender();
@@ -201,7 +231,7 @@ async function initializeComponentForTest(testConfig?: typeof defaultTestConfig)
beforeEach(() => {
// Ensure persistent storage is clear before each test
- dp.initializeDataStore({});
+ db.initializeDataStore({});
});
describe('DatasetDownload form tests', () => {
@@ -228,10 +258,12 @@ describe('DatasetDownload form tests', () => {
await user.click(wgetOption);
// Start wget download
- const downloadBtn = await screen.findByTestId('downloadDatasetBtn');
+ const downloadBtn = await screen.findByTestId('downloadDatasetWgetBtn');
expect(downloadBtn).toBeTruthy();
await user.click(downloadBtn);
+ printElementContents(undefined);
+
// Expect download success message to show
const notice = await screen.findByText('Wget script downloaded successfully!', {
exact: false,
@@ -256,7 +288,7 @@ describe('DatasetDownload form tests', () => {
await user.click(wgetOption);
// Start wget download
- const downloadBtn = await screen.findByTestId('downloadDatasetBtn');
+ const downloadBtn = await screen.findByTestId('downloadDatasetWgetBtn');
expect(downloadBtn).toBeTruthy();
await user.click(downloadBtn);
@@ -272,7 +304,7 @@ describe('DatasetDownload form tests', () => {
});
// Click Transfer button
- const globusTransferBtn = await screen.findByTestId('downloadDatasetBtn');
+ const globusTransferBtn = await screen.findByTestId('downloadDatasetTransferBtn');
expect(globusTransferBtn).toBeTruthy();
await user.click(globusTransferBtn);
@@ -299,7 +331,7 @@ describe('DatasetDownload form tests', () => {
});
// Select transfer button and click it
- const globusTransferBtn = await screen.findByTestId('downloadDatasetBtn');
+ const globusTransferBtn = await screen.findByTestId('downloadDatasetTransferBtn');
expect(globusTransferBtn).toBeTruthy();
await user.click(globusTransferBtn);
@@ -321,7 +353,7 @@ describe('DatasetDownload form tests', () => {
});
// Click Transfer button
- const globusTransferBtn = await screen.findByTestId('downloadDatasetBtn');
+ const globusTransferBtn = await screen.findByTestId('downloadDatasetTransferBtn');
expect(globusTransferBtn).toBeTruthy();
await user.click(globusTransferBtn);
@@ -345,7 +377,7 @@ describe('DatasetDownload form tests', () => {
});
// Click Transfer button
- const globusTransferBtn = await screen.findByTestId('downloadDatasetBtn');
+ const globusTransferBtn = await screen.findByTestId('downloadDatasetTransferBtn');
expect(globusTransferBtn).toBeTruthy();
await user.click(globusTransferBtn);
@@ -369,7 +401,7 @@ describe('DatasetDownload form tests', () => {
});
// Click Transfer button
- const globusTransferBtn = await screen.findByTestId('downloadDatasetBtn');
+ const globusTransferBtn = await screen.findByTestId('downloadDatasetTransferBtn');
expect(globusTransferBtn).toBeTruthy();
await user.click(globusTransferBtn);
@@ -394,7 +426,7 @@ describe('DatasetDownload form tests', () => {
});
// Click Transfer button
- const globusTransferBtn = await screen.findByTestId('downloadDatasetBtn');
+ const globusTransferBtn = await screen.findByTestId('downloadDatasetTransferBtn');
expect(globusTransferBtn).toBeTruthy();
await user.click(globusTransferBtn);
@@ -406,10 +438,8 @@ describe('DatasetDownload form tests', () => {
expect(warningPopup).toBeTruthy();
// Click OK at the popup to proceed with globus transfer
- await user.click(await screen.findByText('Ok'));
-
- // If clicking 'OK' the non-globus-ready items should be removed, leaving only 1
- // expect(dp.getValue(CartStateKeys.cartItemSelections)?.length).toEqual(1);
+ const okBtn = await screen.findByText('Ok');
+ await user.click(okBtn);
// Begin the transfer
const transferPopup = await screen.findByText('Globus download initiated successfully!');
@@ -424,7 +454,7 @@ describe('DatasetDownload form tests', () => {
});
// Click Transfer button
- const globusTransferBtn = await screen.findByTestId('downloadDatasetBtn');
+ const globusTransferBtn = await screen.findByTestId('downloadDatasetTransferBtn');
expect(globusTransferBtn).toBeTruthy();
await user.click(globusTransferBtn);
@@ -443,7 +473,7 @@ describe('DatasetDownload form tests', () => {
});
// Click Transfer button
- const globusTransferBtn = await screen.findByTestId('downloadDatasetBtn');
+ const globusTransferBtn = await screen.findByTestId('downloadDatasetTransferBtn');
expect(globusTransferBtn).toBeTruthy();
await user.click(globusTransferBtn);
@@ -461,7 +491,7 @@ describe('DatasetDownload form tests', () => {
});
// Click Transfer button
- const globusTransferBtn = await screen.findByTestId('downloadDatasetBtn');
+ const globusTransferBtn = await screen.findByTestId('downloadDatasetTransferBtn');
expect(globusTransferBtn).toBeTruthy();
await user.click(globusTransferBtn);
@@ -479,12 +509,13 @@ describe('DatasetDownload form tests', () => {
testUrlState: { authTokensUrlReady: true, endpointPathUrlReady: false },
});
- const accessToken = dp.getValue(GlobusStateKeys.accessToken);
- const transferToken = (await dp.getValue(GlobusStateKeys.transferToken)) as GlobusTokenResponse;
+ const accessToken = db.get(GlobusStateKeys.accessToken, '');
+ const transferToken = db.get(GlobusStateKeys.transferToken, null);
if (transferToken && transferToken.created_on) {
- transferToken.created_on = 0; // Resets the token's time for comparison equality
+ transferToken.created_on = 1000; // Resets the token's time for comparison equality
}
+
expect(accessToken).toEqual(globusAccessTokenFixture);
expect(transferToken).toEqual(globusTransferTokenFixture);
@@ -497,7 +528,7 @@ describe('DatasetDownload form tests', () => {
testUrlState: { authTokensUrlReady: false, endpointPathUrlReady: true },
});
- const userEndpoint = dp.getValue(GlobusStateKeys.userChosenEndpoint);
+ const userEndpoint = db.get(GlobusStateKeys.userChosenEndpoint, null);
expect(userEndpoint?.path).toEqual(testEndpointPath);
});
@@ -509,7 +540,7 @@ describe('DatasetDownload form tests', () => {
});
// Click Transfer button
- const globusTransferBtn = await screen.findByTestId('downloadDatasetBtn');
+ const globusTransferBtn = await screen.findByTestId('downloadDatasetTransferBtn');
expect(globusTransferBtn).toBeTruthy();
await user.click(globusTransferBtn);
@@ -525,7 +556,7 @@ describe('DatasetDownload form tests', () => {
await initializeComponentForTest();
// Click Transfer button
- const globusTransferBtn = await screen.findByTestId('downloadDatasetBtn');
+ const globusTransferBtn = await screen.findByTestId('downloadDatasetTransferBtn');
expect(globusTransferBtn).toBeTruthy();
await user.click(globusTransferBtn);
@@ -540,7 +571,7 @@ describe('DatasetDownload form tests', () => {
await initializeComponentForTest();
// Click Transfer button
- const globusTransferBtn = await screen.findByTestId('downloadDatasetBtn');
+ const globusTransferBtn = await screen.findByTestId('downloadDatasetTransferBtn');
expect(globusTransferBtn).toBeTruthy();
await user.click(globusTransferBtn);
@@ -561,7 +592,7 @@ describe('DatasetDownload form tests', () => {
await initializeComponentForTest();
// Click Transfer button
- const globusTransferBtn = await screen.findByTestId('downloadDatasetBtn');
+ const globusTransferBtn = await screen.findByTestId('downloadDatasetTransferBtn');
expect(globusTransferBtn).toBeTruthy();
await user.click(globusTransferBtn);
@@ -579,7 +610,7 @@ describe('DatasetDownload form tests', () => {
await initializeComponentForTest();
// Click Transfer button
- const globusTransferBtn = await screen.findByTestId('downloadDatasetBtn');
+ const globusTransferBtn = await screen.findByTestId('downloadDatasetTransferBtn');
expect(globusTransferBtn).toBeTruthy();
await user.click(globusTransferBtn);
@@ -597,7 +628,7 @@ describe('DatasetDownload form tests', () => {
await initializeComponentForTest();
// Click Transfer button
- const globusTransferBtn = await screen.findByTestId('downloadDatasetBtn');
+ const globusTransferBtn = await screen.findByTestId('downloadDatasetTransferBtn');
expect(globusTransferBtn).toBeTruthy();
await user.click(globusTransferBtn);
@@ -613,8 +644,7 @@ describe('DatasetDownload form tests', () => {
globusGoals: GlobusGoals.DoGlobusTransfer,
testUrlState: { authTokensUrlReady: false, endpointPathUrlReady: true },
});
-
- expect(dp.getValue(GlobusStateKeys.userChosenEndpoint)?.path).toEqual(
+ expect(db.get(GlobusStateKeys.userChosenEndpoint, null)?.path).toEqual(
testEndpointPath
);
});
@@ -672,7 +702,9 @@ describe('DatasetDownload form tests', () => {
await user.click(saveBtn);
// Current user chosen endpoint should be undefined
- expect(dp.getValue(GlobusStateKeys.userChosenEndpoint)).toBeUndefined();
+ expect(
+ db.get(GlobusStateKeys.userChosenEndpoint, undefined)
+ ).toBeUndefined();
// Open endpoint dropdown and select the endpoint
await openDropdownList(user, selectEndpoint);
@@ -683,9 +715,9 @@ describe('DatasetDownload form tests', () => {
await user.click(lcPublicOption);
// The user chosen endpoint should now be LC Public
- expect(dp.getValue(GlobusStateKeys.userChosenEndpoint)?.display_name).toEqual(
- 'LC Public'
- );
+ expect(
+ db.get(GlobusStateKeys.userChosenEndpoint, null)?.display_name
+ ).toEqual('LC Public');
});
it('Performs sets the path of already saved endpoint.', async () => {
@@ -738,62 +770,61 @@ describe('DatasetDownload form tests', () => {
});
it('displays 10 tasks at most in the submit history', async () => {
- tempStorageSetMock(GlobusStateKeys.globusTaskItems, [
- {
- submitDate: '11/30/2023, 3:10:00 PM',
- taskId: '0123456',
- taskStatusURL: 'https://app.globus.org/activity/0123456/overview',
- },
- {
- submitDate: '11/30/2023, 3:15:00 PM',
- taskId: '2345678',
- taskStatusURL: 'https://app.globus.org/activity/2345678/overview',
- },
- {
- submitDate: '11/30/2023, 3:20:00 PM',
- taskId: '3456789',
- taskStatusURL: 'https://app.globus.org/activity/3456789/overview',
- },
- {
- submitDate: '11/30/2023, 3:25:00 PM',
- taskId: '4567891',
- taskStatusURL: 'https://app.globus.org/activity/4567891/overview',
- },
- {
- submitDate: '11/30/2023, 3:30:00 PM',
- taskId: '5678910',
- taskStatusURL: 'https://app.globus.org/activity/5678910/overview',
- },
- {
- submitDate: '11/30/2023, 3:35:00 PM',
- taskId: '6789101',
- taskStatusURL: 'https://app.globus.org/activity/6789101/overview',
- },
- {
- submitDate: '11/30/2023, 3:40:00 PM',
- taskId: '7891011',
- taskStatusURL: 'https://app.globus.org/activity/7891011/overview',
- },
- {
- submitDate: '11/30/2023, 3:45:00 PM',
- taskId: '8910111',
- taskStatusURL: 'https://app.globus.org/activity/8910111/overview',
- },
- {
- submitDate: '11/30/2023, 3:50:00 PM',
- taskId: '9101112',
- taskStatusURL: 'https://app.globus.org/activity/9101112/overview',
- },
- {
- submitDate: '11/30/2023, 3:55:00 PM',
- taskId: '1011121',
- taskStatusURL: 'https://app.globus.org/activity/1011121/overview',
- },
- ]);
-
await initializeComponentForTest({
...defaultTestConfig,
renderFullApp: true,
+ globusTaskItems: [
+ {
+ submitDate: '3/4/2025, 3:55:00 PM',
+ taskId: '1011121',
+ taskStatusURL: 'https://app.globus.org/activity/1011121/overview',
+ },
+ {
+ submitDate: '3/4/2025, 3:50:00 PM',
+ taskId: '9101112',
+ taskStatusURL: 'https://app.globus.org/activity/9101112/overview',
+ },
+ {
+ submitDate: '3/4/2025, 3:45:00 PM',
+ taskId: '8910111',
+ taskStatusURL: 'https://app.globus.org/activity/8910111/overview',
+ },
+ {
+ submitDate: '3/4/2025, 3:40:00 PM',
+ taskId: '7891011',
+ taskStatusURL: 'https://app.globus.org/activity/7891011/overview',
+ },
+ {
+ submitDate: '3/4/2025, 3:35:00 PM',
+ taskId: '6789101',
+ taskStatusURL: 'https://app.globus.org/activity/6789101/overview',
+ },
+ {
+ submitDate: '3/4/2025, 3:30:00 PM',
+ taskId: '5678910',
+ taskStatusURL: 'https://app.globus.org/activity/5678910/overview',
+ },
+ {
+ submitDate: '3/4/2025, 3:25:00 PM',
+ taskId: '4567891',
+ taskStatusURL: 'https://app.globus.org/activity/4567891/overview',
+ },
+ {
+ submitDate: '3/4/2025, 3:20:00 PM',
+ taskId: '3456789',
+ taskStatusURL: 'https://app.globus.org/activity/3456789/overview',
+ },
+ {
+ submitDate: '3/4/2025, 3:15:00 PM',
+ taskId: '2345678',
+ taskStatusURL: 'https://app.globus.org/activity/2345678/overview',
+ },
+ {
+ submitDate: '3/4/2025, 3:10:00 PM',
+ taskId: '0123456',
+ taskStatusURL: 'https://app.globus.org/activity/0123456/overview',
+ },
+ ],
});
// Expand submit history
@@ -810,7 +841,7 @@ describe('DatasetDownload form tests', () => {
expect(taskItems).toHaveLength(10);
// Select transfer button and click it
- const globusTransferBtn = await screen.findByTestId('downloadDatasetBtn');
+ const globusTransferBtn = await screen.findByTestId('downloadDatasetTransferBtn');
expect(globusTransferBtn).toBeTruthy();
await user.click(globusTransferBtn);
@@ -825,7 +856,7 @@ describe('DatasetDownload form tests', () => {
expect(taskItemsNow).toHaveLength(10);
// The last task should have been popped, and first should be 2nd
- expect(taskItemsNow[1].innerHTML).toEqual('Submitted: 11/30/2023, 3:10:00 PM');
+ expect(taskItemsNow[1].innerHTML).toEqual('Submitted: 3/4/2025, 3:55:00 PM');
});
it('shows the Manage Collections tour', async () => {
@@ -886,27 +917,26 @@ describe('DatasetDownload form tests', () => {
});
it('removes all tasks when clicking the Clear All button', async () => {
- tempStorageSetMock(GlobusStateKeys.globusTaskItems, [
- {
- submitDate: '11/30/2023, 3:10:00 PM',
- taskId: '0123456',
- taskStatusURL: 'https://app.globus.org/activity/0123456/overview',
- },
- {
- submitDate: '11/30/2023, 3:15:00 PM',
- taskId: '2345678',
- taskStatusURL: 'https://app.globus.org/activity/2345678/overview',
- },
- {
- submitDate: '11/30/2023, 3:20:00 PM',
- taskId: '3456789',
- taskStatusURL: 'https://app.globus.org/activity/3456789/overview',
- },
- ]);
-
await initializeComponentForTest({
...defaultTestConfig,
renderFullApp: true,
+ globusTaskItems: [
+ {
+ submitDate: '3/4/2025, 3:20:00 PM',
+ taskId: '3456789',
+ taskStatusURL: 'https://app.globus.org/activity/3456789/overview',
+ },
+ {
+ submitDate: '3/4/2025, 3:15:00 PM',
+ taskId: '2345678',
+ taskStatusURL: 'https://app.globus.org/activity/2345678/overview',
+ },
+ {
+ submitDate: '3/4/2025, 3:10:00 PM',
+ taskId: '0123456',
+ taskStatusURL: 'https://app.globus.org/activity/0123456/overview',
+ },
+ ],
});
// Expand submit history
@@ -928,35 +958,81 @@ describe('DatasetDownload form tests', () => {
const taskItemsNow = screen.queryAllByText('Submitted: ', { exact: false });
expect(taskItemsNow).toHaveLength(0);
});
-});
-describe('getGlobusTransferToken function', () => {
- const validToken = {
- id_token: '',
- resource_server: '',
- other_tokens: { refresh_token: 'something', transfer_token: 'something' },
- created_on: Math.floor(Date.now() / 1000),
- expires_in: Math.floor(Date.now() / 1000) + 100,
- access_token: '',
- refresh_expires_in: 0,
- refresh_token: 'something',
- scope: 'openid profile email offline_access urn:globus:auth:scope:transfer.api.globus.org:all',
- token_type: '',
- };
+ it('shows a confirmation dialog when Reset Tokens is clicked', async () => {
+ await initializeComponentForTest();
- it('should return token when valid', async () => {
- await saveSessionValue(GlobusStateKeys.transferToken, validToken);
- expect(await getGlobusTransferToken()).toBe(validToken);
- });
+ // Open the dropdown menu
+ const globusTransferDropdown = await within(
+ await screen.findByTestId('downloadTypeSelector')
+ ).findByRole('combobox');
+ await openDropdownList(user, globusTransferDropdown);
+
+ // Select Globus
+ const globusOption = (await screen.findAllByText(/Globus/i))[1];
+ expect(globusOption).toBeTruthy();
+ await user.click(globusOption);
+
+ // Open the transfer button menu
+ const transferButtonMenu = (await screen.findByTestId('downloadDatasetTransferBtns'))
+ .lastElementChild;
+ expect(transferButtonMenu).toBeTruthy();
+ if (transferButtonMenu) {
+ await user.click(transferButtonMenu);
+ }
- it('should return null when token is expired', async () => {
- const expiredToken = { ...validToken, expires_in: -1 };
- await saveSessionValue(GlobusStateKeys.transferToken, expiredToken);
- expect(await getGlobusTransferToken()).toBe(null);
+ // Click Reset Tokens
+ const resetTokensOption = await screen.findByText('Reset Tokens');
+ expect(resetTokensOption).toBeTruthy();
+ await user.click(resetTokensOption);
+
+ // Expect confirmation dialog to show
+ const confirmationDialog = await screen.findByText(
+ /If you haven't performed a Globus transfer in a while, or you ran into some issues, it may help to get new tokens./i
+ );
+ expect(confirmationDialog).toBeTruthy();
});
- it('should return null when no token is set', async () => {
- await saveSessionValue(GlobusStateKeys.transferToken, null);
- expect(await getGlobusTransferToken()).toBe(null);
+ it('resets tokens when Reset Tokens confirmation dialog Ok is clicked', async () => {
+ await initializeComponentForTest();
+
+ // Open the dropdown menu
+ const globusTransferDropdown = await within(
+ await screen.findByTestId('downloadTypeSelector')
+ ).findByRole('combobox');
+ await openDropdownList(user, globusTransferDropdown);
+
+ // Select Globus
+ const globusOption = (await screen.findAllByText(/Globus/i))[1];
+ expect(globusOption).toBeTruthy();
+ await user.click(globusOption);
+
+ // Open the transfer button menu
+ const transferButtonMenu = (await screen.findByTestId('downloadDatasetTransferBtns'))
+ .lastElementChild;
+ expect(transferButtonMenu).toBeTruthy();
+ if (transferButtonMenu) {
+ await user.click(transferButtonMenu);
+ }
+
+ // Click Reset Tokens
+ const resetTokensOption = await screen.findByText('Reset Tokens');
+ expect(resetTokensOption).toBeTruthy();
+ await user.click(resetTokensOption);
+
+ // Confirm reset tokens
+ const okButton = await screen.findByText('Ok');
+ expect(okButton).toBeTruthy();
+ await user.click(okButton);
+
+ // Expect tokens to be reset
+ const accessToken = db.get(GlobusStateKeys.accessToken, null);
+ const transferToken = db.get(GlobusStateKeys.transferToken, null);
+ expect(accessToken).toBeNull();
+ expect(transferToken).toBeNull();
+
+ // Expect reset notice to show
+ const resetNotice = await screen.findByText('Globus Auth tokens reset!', { exact: false });
+ expect(resetNotice).toBeTruthy();
});
});
diff --git a/frontend/src/components/Globus/DatasetDownload.tsx b/frontend/src/components/Globus/DatasetDownload.tsx
index 1a3c14a34..f6f7ef537 100644
--- a/frontend/src/components/Globus/DatasetDownload.tsx
+++ b/frontend/src/components/Globus/DatasetDownload.tsx
@@ -6,6 +6,7 @@ import {
Card,
Collapse,
Divider,
+ Dropdown,
Input,
Modal,
Select,
@@ -17,14 +18,11 @@ import {
} from 'antd';
import React, { useEffect } from 'react';
import { useRecoilState } from 'recoil';
+import PKCE from 'js-pkce';
import {
- saveSessionValue,
fetchWgetScript,
ResponseError,
startSearchGlobusEndpoints,
- saveSessionValues,
- REQUESTED_SCOPES,
- createGlobusAuthObject,
SubmissionResult,
} from '../../api';
import {
@@ -33,8 +31,8 @@ import {
manageCollectionsTourTargets,
} from '../../common/reactJoyrideSteps';
import { RawSearchResults } from '../Search/types';
-import CartStateKeys, { cartDownloadIsLoading, cartItemSelections } from '../Cart/recoil/atoms';
-import GlobusStateKeys, { globusTaskItems } from './recoil/atom';
+import { cartDownloadIsLoading, cartItemSelections } from '../Cart/recoil/atoms';
+import GlobusStateKeys, { globusSavedEndpoints, globusTaskItems } from './recoil/atom';
import {
GlobusTokenResponse,
GlobusTaskItem,
@@ -42,15 +40,19 @@ import {
GlobusEndpointSearchResults,
GlobusEndpoint,
} from './types';
-import { showError, showNotice } from '../../common/utils';
-import { DataPersister } from '../../common/DataPersister';
+import { getCurrentAppPage, showError, showNotice } from '../../common/utils';
import { RawTourState, ReactJoyrideContext } from '../../contexts/ReactJoyrideContext';
-import getGlobusTransferToken from './utils';
import axios from '../../lib/axios';
import apiRoutes from '../../api/routes';
+import DataBundlePersister from '../../common/DataBundlePersister';
+import { AppPage } from '../../common/types';
const globusRedirectUrl = `${window.location.origin}/cart/items`;
+// Reference: https://github.com/bpedroza/js-pkce
+export const REQUESTED_SCOPES =
+ 'openid profile email urn:globus:auth:scope:transfer.api.globus.org:all';
+
type AlertModalState = {
onCancelAction: () => void;
onOkAction: () => void;
@@ -70,7 +72,7 @@ export enum GlobusGoals {
const downloadOptions = ['Globus', 'wget'];
// The persistent, static, data storage singleton
-const dp: DataPersister = DataPersister.Instance;
+const db: DataBundlePersister = DataBundlePersister.Instance;
function redirectToNewURL(newUrl: string): void {
window.location.replace(newUrl);
@@ -106,26 +108,20 @@ const DatasetDownloadForm: React.FC> = () => {
const [downloadIsLoading, setDownloadIsLoading] = useRecoilState(cartDownloadIsLoading);
// Persistent vars
- dp.addNewVar(GlobusStateKeys.accessToken, null, () => {});
- dp.addNewVar(GlobusStateKeys.transferToken, null, () => {}, getGlobusTransferToken);
-
const [taskItems, setTaskItems] = useRecoilState(globusTaskItems);
- dp.addNewVar(GlobusStateKeys.globusTaskItems, [], setTaskItems);
-
const [itemSelections, setItemSelections] = useRecoilState(cartItemSelections);
- dp.addNewVar(CartStateKeys.cartItemSelections, [], setItemSelections);
-
- const [savedGlobusEndpoints, setSavedGlobusEndpoints] = React.useState(
- dp.getValue(GlobusStateKeys.savedGlobusEndpoints) || []
+ const [savedGlobusEndpoints, setSavedGlobusEndpoints] = useRecoilState(
+ globusSavedEndpoints
);
- dp.addNewVar(GlobusStateKeys.savedGlobusEndpoints, [], setSavedGlobusEndpoints);
- const [chosenGlobusEndpoint, setChosenGlobusEndpoint] = React.useState(
- dp.getValue(GlobusStateKeys.userChosenEndpoint)
- );
- dp.addNewVar(GlobusStateKeys.userChosenEndpoint, null, setChosenGlobusEndpoint);
+ db.addVar(GlobusStateKeys.accessToken, null);
+ db.addVar(GlobusStateKeys.globusAuth, null);
+ db.addVar(GlobusStateKeys.transferToken, null);
// Component internal state
+ const [chosenGlobusEndpoint, setChosenGlobusEndpoint] = React.useState();
+ db.addVar(GlobusStateKeys.userChosenEndpoint, null, setChosenGlobusEndpoint);
+
const [varsLoaded, setVarsLoaded] = React.useState(false);
const [loadingPage, setLoadingPage] = React.useState(false);
@@ -165,25 +161,87 @@ const DatasetDownloadForm: React.FC> = () => {
async function resetTokens(): Promise {
setCurrentGoal(GlobusGoals.None);
setLoadingPage(false);
- await saveSessionValues([
- { key: GlobusStateKeys.accessToken, value: null },
- { key: GlobusStateKeys.globusAuth, value: null },
- { key: GlobusStateKeys.transferToken, value: null },
- ]);
+
+ db.set(GlobusStateKeys.accessToken, null);
+ db.set(GlobusStateKeys.globusAuth, REQUESTED_SCOPES);
+ db.set(GlobusStateKeys.transferToken, null);
+ await db.saveAll();
+ }
+
+ // Creates an auth object using desired authentication scope
+ function createGlobusAuthObject(): PKCE {
+ const authScope = db.get(GlobusStateKeys.globusAuth, REQUESTED_SCOPES);
+
+ return new PKCE({
+ client_id: window.METAGRID.GLOBUS_CLIENT_ID, // Update this using your native client ID
+ redirect_uri: `${window.location.origin}/cart/items`, // Update this if you are deploying this anywhere else (Globus Auth will redirect back here once you have logged in)
+ authorization_endpoint: 'https://auth.globus.org/v2/oauth2/authorize', // No changes needed
+ token_endpoint: 'https://auth.globus.org/v2/oauth2/token', // No changes needed
+ requested_scopes: authScope, // Update with any scopes you would need, e.g. transfer
+ });
}
function getGlobusTokens(): [GlobusTokenResponse | null, string | null] {
- const accessToken = dp.getValue(GlobusStateKeys.accessToken);
- const transferToken = dp.getValue(GlobusStateKeys.transferToken);
+ const accessToken = db.get(GlobusStateKeys.accessToken, '');
+ const transferToken = db.get(GlobusStateKeys.transferToken, null);
return [transferToken, accessToken];
}
- const handleWgetDownload = async (): Promise => {
+ async function getUrlAuthTokens(): Promise {
+ try {
+ const url = window.location.href;
+ const pkce = createGlobusAuthObject(); // Create pkce with saved scope
+ const tokenResponse = (await pkce.exchangeForAccessToken(url)) as GlobusTokenResponse;
+
+ /* istanbul ignore else */
+ if (tokenResponse) {
+ /* istanbul ignore else */
+ if (tokenResponse.access_token) {
+ db.set(GlobusStateKeys.accessToken, tokenResponse.access_token);
+ } else {
+ db.set(GlobusStateKeys.accessToken, null);
+ }
+
+ // Try to find and get the transfer token
+ /* istanbul ignore else */
+ if (tokenResponse.other_tokens) {
+ const otherTokens: GlobusTokenResponse[] = [
+ ...(tokenResponse.other_tokens as GlobusTokenResponse[]),
+ ];
+ otherTokens.forEach((tokenBlob) => {
+ /* istanbul ignore else */
+ if (
+ tokenBlob.resource_server &&
+ tokenBlob.resource_server === 'transfer.api.globus.org'
+ ) {
+ const newTransferToken = { ...tokenBlob };
+ newTransferToken.created_on = Math.floor(Date.now() / 1000);
+
+ db.set(GlobusStateKeys.transferToken, newTransferToken);
+ }
+ });
+ } else {
+ db.set(GlobusStateKeys.transferToken, null);
+ }
+ await db.saveAll();
+ }
+ } catch (error: unknown) {
+ /* istanbul ignore next */
+ showError(messageApi, 'Error occured when obtaining transfer permissions.');
+ await resetTokens();
+ } finally {
+ // This isn't strictly necessary but it ensures no code reuse.
+ sessionStorage.removeItem('pkce_code_verifier');
+ sessionStorage.removeItem('pkce_state');
+ }
+ }
+
+ const handleWgetDownload = (): void => {
const cleanedSelections = itemSelections.filter((item) => {
return item !== undefined && item !== null;
});
- await dp.setValue(CartStateKeys.cartItemSelections, cleanedSelections, true);
+ setItemSelections(cleanedSelections);
const ids = cleanedSelections.map((item) => item.id);
showNotice(messageApi, 'The wget script is generating, please wait momentarily.', {
@@ -218,15 +276,17 @@ const DatasetDownloadForm: React.FC> = () => {
});
};
- const handleGlobusDownload = (
- globusTransferToken: GlobusTokenResponse,
- accessToken: string,
- endpoint: GlobusEndpoint
- ): void => {
+ const handleGlobusDownload = (endpoint: GlobusEndpoint): void => {
+ const [globusTransferToken, accessToken] = getGlobusTokens();
+
+ // Cancel the download if tokens are not ready
+ if (globusTransferToken === null || accessToken === null) {
+ return;
+ }
+
setDownloadIsLoading(true);
- const cartSelections = dp.getValue(CartStateKeys.cartItemSelections);
- const ids = cartSelections?.map((item) => (item ? item.id : '')) ?? [];
+ const ids = itemSelections?.map((item) => (item ? item.id : '')) ?? [];
axios
.post(
@@ -243,8 +303,7 @@ const DatasetDownloadForm: React.FC> = () => {
return resp.data;
})
.then(async (resp) => {
- await dp.setValue(CartStateKeys.cartItemSelections, [], true);
-
+ setItemSelections([]);
const newTasks = resp.successes.map((submission) => {
const taskId = submission.task_id as string;
return {
@@ -256,7 +315,7 @@ const DatasetDownloadForm: React.FC> = () => {
const nMostRecentTasks = [...newTasks, ...taskItems].slice(0, MAX_TASK_LIST_LENGTH);
- await dp.setValue(GlobusStateKeys.globusTaskItems, nMostRecentTasks, true);
+ setTaskItems(nMostRecentTasks);
switch (resp.status) {
case 200:
@@ -369,7 +428,7 @@ const DatasetDownloadForm: React.FC> = () => {
setCurrentGoal(GlobusGoals.None);
} else {
setAlertPopupState({ ...alertPopupState, show: false });
- await dp.setValue(CartStateKeys.cartItemSelections, globusReadyItems, true);
+ setItemSelections(globusReadyItems);
setCurrentGoal(GlobusGoals.DoGlobusTransfer);
await performStepsForGlobusGoals();
}
@@ -402,6 +461,45 @@ const DatasetDownloadForm: React.FC> = () => {
}
};
+ const updateScopes = (): void => {
+ // Save the endpoint in the list
+
+ // Create list of endpoints that require data access scope
+ const dataAccessEndpoints: GlobusEndpoint[] = [];
+ savedGlobusEndpoints.forEach((endpoint) => {
+ if (endpoint.entity_type === 'GCSv5_mapped_collection' && endpoint.subscription_id) {
+ dataAccessEndpoints.push(endpoint);
+ }
+ });
+
+ // Previous scope
+ const oldScope = db.get(GlobusStateKeys.globusAuth, REQUESTED_SCOPES);
+ let newScope = REQUESTED_SCOPES;
+ if (dataAccessEndpoints.length > 0) {
+ // Create a new scope string
+ const DATA_ACCESS_SCOPE = `${dataAccessEndpoints
+ .reduce((acc, endpoint) => {
+ const ACCESS_SCOPE = `*https://auth.globus.org/scopes/${endpoint.id}/data_access`;
+ return `${acc + ACCESS_SCOPE} `;
+ }, 'urn:globus:auth:scope:transfer.api.globus.org:all[')
+ .trimEnd()}]`;
+ newScope = newScope.concat(' ', DATA_ACCESS_SCOPE);
+ }
+
+ // Reset tokens if the SCOPES changed
+ if (oldScope !== newScope) {
+ db.set(GlobusStateKeys.accessToken, null);
+ db.set(GlobusStateKeys.transferToken, null);
+ db.set(GlobusStateKeys.globusAuth, newScope);
+ }
+ };
+
+ const saveGlobusEndpoint = (newEndpoint: GlobusEndpoint): void => {
+ // Add the endpoint to the list
+ const newEndpointsList = [...savedGlobusEndpoints, newEndpoint];
+ setSavedGlobusEndpoints(newEndpointsList);
+ };
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const changeGlobusEndpoint = async (value: string): Promise => {
if (value === '') {
@@ -415,14 +513,10 @@ const DatasetDownloadForm: React.FC> = () => {
(endpoint: GlobusEndpoint) => endpoint.id === value
);
- if (checkEndpoint?.entity_type === 'GCSv5_mapped_collection' && checkEndpoint.subscription_id) {
- const DATA_ACCESS_SCOPE = `urn:globus:auth:scope:transfer.api.globus.org:all[*https://auth.globus.org/scopes/${value}/data_access]`;
- const SCOPES = REQUESTED_SCOPES.concat(' ', DATA_ACCESS_SCOPE);
-
- await saveSessionValue(GlobusStateKeys.globusAuth, SCOPES);
- }
-
- await dp.setValue(GlobusStateKeys.userChosenEndpoint, checkEndpoint, true);
+ await db.setAndSave(
+ GlobusStateKeys.userChosenEndpoint,
+ checkEndpoint
+ );
};
const searchGlobusEndpoints = async (value: string): Promise => {
@@ -473,21 +567,30 @@ const DatasetDownloadForm: React.FC> = () => {
}
};
- function tokensReady(
- accessToken: string | null,
- globusTransferToken: GlobusTokenResponse | null
- ): boolean {
- if (accessToken && globusTransferToken) {
- return true;
+ function tokensReady(): boolean {
+ const [globusTransferToken, accessToken] = getGlobusTokens();
+
+ // Test if the current transfer token is expired
+ let globusTokenReady = false;
+ if (globusTransferToken && globusTransferToken.expires_in) {
+ const createTime = globusTransferToken.created_on;
+ const lifeTime = globusTransferToken.expires_in;
+ const expires = createTime + lifeTime;
+ const curTime = Math.floor(Date.now() / 1000);
+
+ if (curTime <= expires) {
+ globusTokenReady = true;
+ }
}
- return false;
+
+ return accessToken !== null && globusTokenReady;
}
function getCurrentGoal(): GlobusGoals {
const urlParams = new URLSearchParams(window.location.search);
-
+ const curPage = getCurrentAppPage();
// If cancelled key is in URL, set goal to none
- if (urlParams.has('cancelled')) {
+ if (urlParams.has('cancelled') || curPage !== AppPage.Cart) {
setCurrentGoal(GlobusGoals.None);
return GlobusGoals.None;
}
@@ -504,61 +607,13 @@ const DatasetDownloadForm: React.FC> = () => {
localStorage.setItem(GlobusStateKeys.globusTransferGoalsState, goal);
}
- async function getUrlAuthTokens(): Promise {
- try {
- const url = window.location.href;
- const pkce = await createGlobusAuthObject(); // Create pkce with saved scope
- const tokenResponse = (await pkce.exchangeForAccessToken(url)) as GlobusTokenResponse;
-
- /* istanbul ignore else */
- if (tokenResponse) {
- /* istanbul ignore else */
- if (tokenResponse.access_token) {
- await dp.setValue(GlobusStateKeys.accessToken, tokenResponse.access_token, true);
- } else {
- await dp.setValue(GlobusStateKeys.accessToken, null, true);
- }
-
- // Try to find and get the transfer token
- /* istanbul ignore else */
- if (tokenResponse.other_tokens) {
- const otherTokens: GlobusTokenResponse[] = [
- ...(tokenResponse.other_tokens as GlobusTokenResponse[]),
- ];
- otherTokens.forEach(async (tokenBlob) => {
- /* istanbul ignore else */
- if (
- tokenBlob.resource_server &&
- tokenBlob.resource_server === 'transfer.api.globus.org'
- ) {
- const newTransferToken = { ...tokenBlob };
- newTransferToken.created_on = Math.floor(Date.now() / 1000);
-
- await dp.setValue(GlobusStateKeys.transferToken, newTransferToken, true);
- }
- });
- } else {
- await dp.setValue(GlobusStateKeys.transferToken, null, true);
- }
- }
- } catch (error: unknown) {
- /* istanbul ignore next */
- showError(messageApi, 'Error occured when obtaining transfer permissions.');
- await resetTokens();
- } finally {
- // This isn't strictly necessary but it ensures no code reuse.
- sessionStorage.removeItem('pkce_code_verifier');
- sessionStorage.removeItem('pkce_state');
- }
- }
-
async function redirectToSelectGlobusEndpointPath(): Promise {
const endpointSearchURL = `https://app.globus.org/helpers/browse-collections?action=${globusRedirectUrl}&method=GET&cancelurl=${globusRedirectUrl}?cancelled&filelimit=0`;
setLoadingPage(true);
- await dp.saveAllValues();
+ await db.saveAll();
setLoadingPage(false);
- const chosenEndpoint = dp.getValue(GlobusStateKeys.userChosenEndpoint);
+ const chosenEndpoint = db.get(GlobusStateKeys.userChosenEndpoint, null);
if (chosenEndpoint) {
redirectToNewURL(`${endpointSearchURL}&origin_id=${chosenEndpoint.id}`);
@@ -571,10 +626,10 @@ const DatasetDownloadForm: React.FC> = () => {
sessionStorage.removeItem('pkce_code_verifier');
sessionStorage.removeItem('pkce_state');
- const pkce = await createGlobusAuthObject();
+ const pkce = createGlobusAuthObject();
const authUrl: string = pkce.authorizeUrl();
setLoadingPage(true);
- await dp.saveAllValues();
+ await db.saveAll();
setLoadingPage(false);
redirectToNewURL(authUrl);
}
@@ -582,8 +637,7 @@ const DatasetDownloadForm: React.FC> = () => {
async function endDownloadSteps(): Promise {
setDownloadIsLoading(false);
setLoadingPage(true);
- await dp.setValue(GlobusStateKeys.userChosenEndpoint, null, true);
- await dp.saveAllValues();
+ await db.setAndSave(GlobusStateKeys.userChosenEndpoint, undefined);
setLoadingPage(false);
setCurrentGoal(GlobusGoals.None);
redirectToRootUrl();
@@ -594,12 +648,12 @@ const DatasetDownloadForm: React.FC> = () => {
if (!varsLoaded) {
setVarsLoaded(true);
- await dp.loadAllValues();
+ await db.loadAll();
+ // eslint-disable-next-line no-console
}
// Obtain URL params if applicable
const urlParams = new URLSearchParams(window.location.search);
- const tUrlReady = tokenUrlReady(urlParams);
const eUrlReady = endpointUrlReady(urlParams);
// If globusGoal state is none, do nothing
@@ -611,39 +665,6 @@ const DatasetDownloadForm: React.FC> = () => {
return;
}
- const [transferToken, accessToken] = getGlobusTokens();
- const tknsReady = tokensReady(accessToken, transferToken);
-
- // Get tokens if they aren't ready
- if (!tknsReady) {
- // If auth token urls are ready, update related tokens
- if (tUrlReady) {
- // Token URL is ready get tokens
- await getUrlAuthTokens();
- await dp.saveAllValues();
- redirectToRootUrl();
- return;
- }
-
- if (!alertPopupState.show) {
- setAlertPopupState({
- onCancelAction: () => {
- setCurrentGoal(GlobusGoals.None);
- setLoadingPage(false);
- setAlertPopupState({ ...alertPopupState, show: false });
- },
- onOkAction: async () => {
- await loginWithGlobus();
- },
- show: true,
- content: 'You will be redirected to obtain globus tokens. Continue?',
- });
- }
- return;
- }
-
- const savedEndpoints: GlobusEndpoint[] =
- dp.getValue(GlobusStateKeys.savedGlobusEndpoints) || [];
// Goal is to set the path for chosen endpoint
if (goal === GlobusGoals.SetEndpointPath) {
// If endpoint urls are ready, update related values
@@ -654,7 +675,7 @@ const DatasetDownloadForm: React.FC> = () => {
setCurrentGoal(GlobusGoals.None);
}
- const updatedEndpointList = savedEndpoints.map((endpoint) => {
+ const updatedEndpointList = savedGlobusEndpoints.map((endpoint) => {
if (endpoint && endpoint.id === endpointId) {
return { ...endpoint, path };
}
@@ -662,18 +683,18 @@ const DatasetDownloadForm: React.FC> = () => {
});
// Set path for endpoint
- await dp.setValue(GlobusStateKeys.savedGlobusEndpoints, updatedEndpointList, true);
+ setSavedGlobusEndpoints(updatedEndpointList);
// If endpoint was updated, set it as chosen endpoint
const updatedEndpoint = updatedEndpointList.find(
(endpoint: GlobusEndpoint) => endpoint.id === endpointId
);
if (updatedEndpoint) {
- await dp.setValue(GlobusStateKeys.userChosenEndpoint, updatedEndpoint, true);
+ db.set(GlobusStateKeys.userChosenEndpoint, updatedEndpoint);
}
setCurrentGoal(GlobusGoals.None);
- await dp.saveAllValues();
+ await db.saveAll();
redirectToRootUrl();
return;
}
@@ -697,8 +718,9 @@ const DatasetDownloadForm: React.FC> = () => {
// Goal is to perform a transfer
if (goal === GlobusGoals.DoGlobusTransfer) {
- const chosenEndpoint: GlobusEndpoint | null = dp.getValue(
- GlobusStateKeys.userChosenEndpoint
+ const chosenEndpoint: GlobusEndpoint | null = db.get(
+ GlobusStateKeys.userChosenEndpoint,
+ null
);
// If there is no chosen endpoint, give notice
@@ -723,6 +745,41 @@ const DatasetDownloadForm: React.FC> = () => {
return;
}
+ // Update scopes
+ updateScopes();
+
+ const tknsReady = tokensReady();
+
+ // Get tokens if they aren't ready
+ if (!tknsReady) {
+ const tUrlReady = tokenUrlReady(urlParams);
+
+ // If auth token urls are ready, update related tokens
+ if (tUrlReady) {
+ // Token URL is ready get tokens
+ await getUrlAuthTokens();
+ redirectToRootUrl();
+ return;
+ }
+
+ if (!alertPopupState.show) {
+ setAlertPopupState({
+ onCancelAction: () => {
+ setCurrentGoal(GlobusGoals.None);
+ setLoadingPage(false);
+ setAlertPopupState({ ...alertPopupState, show: false });
+ },
+ onOkAction: async () => {
+ await loginWithGlobus();
+ },
+ show: true,
+ content: 'You will be redirected to obtain globus tokens. Continue?',
+ });
+ }
+
+ return;
+ }
+
// If endpoint urls are ready, update related values
if (eUrlReady) {
const path = urlParams.get('origin_path');
@@ -730,53 +787,39 @@ const DatasetDownloadForm: React.FC> = () => {
if (path === null) {
setCurrentGoal(GlobusGoals.None);
}
- const updatedEndpoint = savedEndpoints.find((endpoint) => {
+ const updatedEndpoint = savedGlobusEndpoints.find((endpoint) => {
return endpoint.id === endpointId;
});
if (updatedEndpoint) {
- await dp.setValue(
- GlobusStateKeys.userChosenEndpoint,
- {
- ...updatedEndpoint,
- path,
- } as GlobusEndpoint,
- true
- );
+ db.set(GlobusStateKeys.userChosenEndpoint, {
+ ...updatedEndpoint,
+ path,
+ } as GlobusEndpoint);
} else {
- await dp.setValue(
- GlobusStateKeys.userChosenEndpoint,
- {
- canonical_name: '',
- contact_email: '',
- display_name: 'Unsaved Collection',
- entity_type: '',
- id: endpointId,
- owner_id: '',
- owner_string: '',
- path,
- subscription_id: '',
- } as GlobusEndpoint,
- true
- );
+ db.set(GlobusStateKeys.userChosenEndpoint, {
+ canonical_name: '',
+ contact_email: '',
+ display_name: 'Unsaved Collection',
+ entity_type: '',
+ id: endpointId,
+ owner_id: '',
+ owner_string: '',
+ path,
+ subscription_id: '',
+ } as GlobusEndpoint);
}
- await dp.saveAllValues();
+ await db.saveAll();
setLoadingPage(false);
- setTimeout(() => {
- redirectToRootUrl();
- }, 750);
+ redirectToRootUrl();
return;
}
// Check chosen endpoint path is ready
if (chosenEndpoint.path) {
setCurrentGoal(GlobusGoals.None);
- handleGlobusDownload(
- transferToken as GlobusTokenResponse,
- accessToken as string,
- chosenEndpoint
- );
+ handleGlobusDownload(chosenEndpoint);
} else {
// Setting endpoint path
setLoadingPage(false);
@@ -800,17 +843,51 @@ const DatasetDownloadForm: React.FC> = () => {
}
const downloadBtnTooltip = (): string => {
+ const chosenEndpoint = db.get(GlobusStateKeys.userChosenEndpoint, null);
+
if (itemSelections.length === 0) {
return 'Please select at least one dataset to download in your cart above.';
}
if (selectedDownloadType === 'Globus') {
- if (!chosenGlobusEndpoint || savedGlobusEndpoints.length === 0) {
+ if (!chosenEndpoint || savedGlobusEndpoints.length === 0) {
return 'Please select a Globus Collection.';
}
}
return '';
};
+ const globusTransferButtonMenu = [
+ {
+ key: '1',
+ label: 'Reset Tokens',
+ danger: true,
+ disabled: !tokensReady(),
+ onClick: () => {
+ const newAlertPopupState: AlertModalState = {
+ content:
+ "If you haven't performed a Globus transfer in a while, or you ran into some issues, it may help to get new tokens. Click 'Ok' if you wish to to reset tokens.",
+
+ onCancelAction: () => {
+ setAlertPopupState({ ...alertPopupState, show: false });
+ },
+ onOkAction: async () => {
+ await resetTokens();
+ setAlertPopupState({ ...alertPopupState, show: false });
+ showNotice(messageApi, 'Globus Auth tokens reset!', {
+ duration: 3,
+ type: 'info',
+ });
+ },
+ show: true,
+ };
+
+ if (!alertPopupState.show) {
+ setAlertPopupState(newAlertPopupState);
+ }
+ },
+ },
+ ];
+
useEffect(() => {
const initializePage = async (): Promise => {
setLoadingPage(true);
@@ -903,25 +980,47 @@ const DatasetDownloadForm: React.FC> = () => {
}
>
)}
-
-
-
+ {selectedDownloadType === 'Globus' ? (
+
+ {
+ handleDownloadForm('Globus');
+ }}
+ disabled={
+ itemSelections.length === 0 ||
+ !chosenGlobusEndpoint ||
+ savedGlobusEndpoints.length === 0
+ }
+ loading={downloadIsLoading}
+ menu={{ items: globusTransferButtonMenu }}
+ >
+
- If you need help on Globus Transfers, please visit this page for more information:
+ If you need help on Globus Transfers (
+ following successful submission to Globus), please visit this page for more
+ information:
https://app.globus.org/help
diff --git a/frontend/src/contexts/ReactJoyrideContext.test.tsx b/frontend/src/contexts/ReactJoyrideContext.test.tsx
index b8ddc35fb..21ec22fdd 100644
--- a/frontend/src/contexts/ReactJoyrideContext.test.tsx
+++ b/frontend/src/contexts/ReactJoyrideContext.test.tsx
@@ -1,10 +1,11 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
-import { getCurrentAppPage, TourTitles } from '../common/reactJoyrideSteps';
+import { TourTitles } from '../common/reactJoyrideSteps';
import { AppPage } from '../common/types';
import Support from '../components/Support';
import customRender from '../test/custom-render';
+import { getCurrentAppPage } from '../common/utils';
const user = userEvent.setup();
diff --git a/frontend/src/contexts/ReactJoyrideContext.tsx b/frontend/src/contexts/ReactJoyrideContext.tsx
index cb7d1b462..b2f72d63c 100644
--- a/frontend/src/contexts/ReactJoyrideContext.tsx
+++ b/frontend/src/contexts/ReactJoyrideContext.tsx
@@ -3,9 +3,9 @@ import Joyride, { ACTIONS, CallBackProps, EVENTS, STATUS } from 'react-joyride';
import { useNavigate } from 'react-router-dom';
import { useRecoilState } from 'recoil';
import { JoyrideTour } from '../common/JoyrideTour';
-import { getCurrentAppPage } from '../common/reactJoyrideSteps';
import { AppPage } from '../common/types';
-import { isDarkModeAtom } from '../components/App/recoil/atoms';
+import isDarkModeAtom from '../components/App/recoil/atoms';
+import { getCurrentAppPage } from '../common/utils';
export type RawTourState = {
getTour: JoyrideTour;
diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts
index 942d1c6d2..8eb10000b 100644
--- a/frontend/src/setupTests.ts
+++ b/frontend/src/setupTests.ts
@@ -7,13 +7,10 @@ import { cleanup } from '@testing-library/react';
import { TextEncoder } from 'util';
import { server } from './test/mock/server';
import messageDisplayData from './components/Messaging/messageDisplayData';
-import {
- mockConfig,
- originalGlobusEnabledNodes,
- sessionStorageMock,
-} from './test/jestTestFunctions';
+import { mockConfig, originalGlobusEnabledNodes } from './test/jestTestFunctions';
import 'cross-fetch/polyfill';
import 'mock-match-media/jest-setup';
+import { sessionStorageMock } from './test/mock/mockStorage';
jest.setTimeout(60000);
diff --git a/frontend/src/test/__mocks__/js-pkce.ts b/frontend/src/test/__mocks__/js-pkce.ts
index 8d3528ca2..2f55b26d9 100644
--- a/frontend/src/test/__mocks__/js-pkce.ts
+++ b/frontend/src/test/__mocks__/js-pkce.ts
@@ -2,7 +2,7 @@
import { GlobusTokenResponse } from '../../components/Globus/types';
import { globusTokenResponseFixture } from '../mock/fixtures';
-import { tempStorageGetMock } from '../jestTestFunctions';
+import { tempStorageGetMock } from '../mock/mockStorage';
class PKCE {
client_id = '';
diff --git a/frontend/src/test/jestTestFunctions.tsx b/frontend/src/test/jestTestFunctions.tsx
index 291a945d2..c40ebc809 100644
--- a/frontend/src/test/jestTestFunctions.tsx
+++ b/frontend/src/test/jestTestFunctions.tsx
@@ -13,6 +13,7 @@ import { NotificationType, getSearchFromUrl } from '../common/utils';
import { RawSearchResult } from '../components/Search/types';
import { rawSearchResultFixture } from './mock/fixtures';
import { FrontendConfig } from '../common/types';
+import { tempStorageGetMock } from './mock/mockStorage';
// https://www.mikeborozdin.com/post/changing-jest-mocks-between-tests
export const originalGlobusEnabledNodes = [
@@ -36,32 +37,6 @@ export const mockConfig: FrontendConfig = {
export const activeSearch = getSearchFromUrl();
-export const sessionStorageMock = (() => {
- let store: { [key: string]: unknown } = {};
-
- return {
- getItem(key: string): T {
- return store[key] as T;
- },
-
- setItem(key: string, value: T): void {
- store[key] = value;
- },
-
- clear() {
- store = {};
- },
-
- removeItem(key: string) {
- delete store[key];
- },
-
- getAll() {
- return store;
- },
- };
-})();
-
// This will get a mock value from temp storage to use for keycloak
export const mockKeycloakToken = mockFunction(() => {
const loginFixture = tempStorageGetMock('keycloakFixture');
@@ -78,23 +53,13 @@ export const mockKeycloakToken = mockFunction(() => {
};
});
-export function tempStorageGetMock(key: string): T {
- const value = sessionStorageMock.getItem(key);
- // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
- return value;
-}
-
-export function tempStorageSetMock(key: string, value: T): void {
- sessionStorageMock.setItem(key, value);
-}
-
export function mockFunction unknown>(
fn: T
): jest.MockedFunction {
return fn as jest.MockedFunction;
}
-export function printElementContents(element: HTMLElement | undefined): void {
+export function printElementContents(element: HTMLElement | undefined = undefined): void {
screen.debug(element, Number.POSITIVE_INFINITY);
}
@@ -147,8 +112,8 @@ export async function showNoticeStatic(
}
}
-export const globusReadyNode = 'nodeIsGlobusReady';
-export const nodeNotGlobusReady = 'nodeIsNotGlobusReady';
+export const globusReadyNode: string = 'nodeIsGlobusReady';
+export const nodeNotGlobusReady: string = 'nodeIsNotGlobusReady';
export function makeCartItem(id: string, globusReady: boolean): RawSearchResult {
return rawSearchResultFixture({
@@ -182,6 +147,10 @@ export async function openDropdownList(user: UserEvent, dropdown: HTMLElement):
await user.click(dropdown);
}
+export function initRecoilValue(key: string, value: T): void {
+ localStorage.setItem(key, JSON.stringify(value));
+}
+
export async function addSearchRowsAndGoToCart(
user: UserEvent,
rows?: HTMLElement[]
diff --git a/frontend/src/test/mock/fixtures.ts b/frontend/src/test/mock/fixtures.ts
index 478d4b92c..bf0f26699 100644
--- a/frontend/src/test/mock/fixtures.ts
+++ b/frontend/src/test/mock/fixtures.ts
@@ -260,6 +260,8 @@ export const parsedNodeStatusFixture = (): NodeStatusArray => [
},
];
+export const globusAuthScopeFixure =
+ 'openid profile email urn:globus:auth:scope:transfer.api.globus.org:all urn:globus:auth:scope:transfer.api.globus.org:all[*https://auth.globus.org/scopes/id1234567/data_access *https://auth.globus.org/scopes/id2345678/data_access]';
export const globusAccessTokenFixture = 'validAccessToken';
export const globusTransferTokenFixture: GlobusTokenResponse = {
access_token: globusAccessTokenFixture,
@@ -273,7 +275,7 @@ export const globusTransferTokenFixture: GlobusTokenResponse = {
refresh_token: 'refreshToken',
transfer_token: 'transferToken',
},
- created_on: 10000,
+ created_on: 1000,
expires_in: 11000,
error: '',
};
diff --git a/frontend/src/test/mock/mockStorage.ts b/frontend/src/test/mock/mockStorage.ts
new file mode 100644
index 000000000..26b410fea
--- /dev/null
+++ b/frontend/src/test/mock/mockStorage.ts
@@ -0,0 +1,35 @@
+export const sessionStorageMock = (() => {
+ let store: { [key: string]: unknown } = {};
+
+ return {
+ getItem(key: string): T {
+ return store[key] as T;
+ },
+
+ setItem(key: string, value: T): void {
+ store[key] = value;
+ },
+
+ clear() {
+ store = {};
+ },
+
+ removeItem(key: string) {
+ delete store[key];
+ },
+
+ getAll() {
+ return store;
+ },
+ };
+})();
+
+export function tempStorageGetMock(key: string): T {
+ const value = sessionStorageMock.getItem(key);
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+ return value;
+}
+
+export function tempStorageSetMock(key: string, value: T): void {
+ sessionStorageMock.setItem(key, value);
+}
diff --git a/frontend/src/test/mock/server-handlers.ts b/frontend/src/test/mock/server-handlers.ts
index 4f0aabd7d..3dc425572 100644
--- a/frontend/src/test/mock/server-handlers.ts
+++ b/frontend/src/test/mock/server-handlers.ts
@@ -19,7 +19,7 @@ import {
userSearchQueriesFixture,
userSearchQueryFixture,
} from './fixtures';
-import { tempStorageGetMock, tempStorageSetMock } from '../jestTestFunctions';
+import { tempStorageGetMock, tempStorageSetMock } from './mockStorage';
const handlers = [
rest.post(apiRoutes.keycloakAuth.path, async (_req, res, ctx) =>