diff --git a/README.md b/README.md index 8766d669..21aae1f2 100644 --- a/README.md +++ b/README.md @@ -44,19 +44,25 @@ And used like any other component in the containing render() method. ### Using Components Outside of a NEON Domain Portal Core Components are designed to be used throughout the NEON web application platform as well -as on any third party platform. However, in order to work properly outside of NEON, two environment -variable must be set so that components that generate links do so properly. +as on any third party platform. However, in order to work properly outside of NEON, environment +variables must be set to reference the appropriate API endpoints. -**`REACT_APP_NEON_HOST_OVERRIDE`** +#### Development -Set this environment variable to your host without a trailing slash (e.g. "https://myhost.org"). +**`REACT_APP_NEON_API_HOST_OVERRIDE`** -**`REACT_APP_FOREIGN_LOCATION`** +Set this environment variable to the desired API host without a trailing slash (e.g. "https://data.neonscience.org"). Note that this is a build time environment variable and if set within `.env.production` will impact *all* deployments. -Set this environment variable to `true`. +#### Production -The host envvar above is typically reserved for development purposes and will be ignored in -production *unless* the foreign location env var is true. +By default, the production build will use the appropriate production values for the API host. To customize based on runtime environment variables, will need to inject the following object into the DOM prior to the application's initialization (e.g. inject into the static HTML server side or equivalent): + +```javascript +window.NEON_SERVER_DATA = { + NeonPublicAPIHost: 'https://data.neonscience.org', + NeonWebHost: 'https://www.neonscience.org', +}; +``` #### Theming and Contexts Outside of a NEON Domain @@ -92,14 +98,14 @@ wrapping them in additional resources unless the documentation specifically stat * If the entry point is the _same pre- and post-compile_ then use only `main` to point to the common entry point * Use kebab-case for `name` * Use CamelCase for files -6. Add the new component to `src/lib_components/index.ts` +6. If desirable to export the component at the library level, add the new component to `src/lib_components/index.ts` 7. Run `npm run lib` to have the new component picked up and exported with the library ### NOTE: Verify new dependencies! If you have added or modified third-party dependencies then it is important to verify they work from a fresh install before committing changes upstream. -Run `rm -rf node_modules && npm install` and re-run the app to validate a fresh install. This mimics how other apps importing `portal-core-components` will see your changes. +Run `rm -rf node_modules && npm ci` and re-run the app to validate a fresh install. This mimics how other apps importing `portal-core-components` will see your changes. ### Using Workers in Components @@ -158,7 +164,7 @@ Have nodejs. Clone this project from git. In the cloned directory, run: - npm install + npm ci This should pick up everything from the package-lock.json file via the npm repos. diff --git a/jest.config.js b/jest.config.js index 1450ddb7..4cf2f9e6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,8 @@ module.exports = { verbose: true, + roots: [ + '/src/', + ], moduleNameMapper: { 'typeface-inter': '/src/__mocks__/fileMock.js', '\\.(css|less)$': '/src/__mocks__/styleMock.js', @@ -17,6 +20,9 @@ module.exports = { testPathIgnorePatterns: [ '/lib/', ], + modulePathIgnorePatterns: [ + '/lib/', + ], collectCoverage: true, coverageReporters: [ 'lcov', diff --git a/lib/components/DataProductAvailability/StateStorageConverter.d.ts b/lib/components/DataProductAvailability/StateStorageConverter.d.ts deleted file mode 100644 index 7540241e..00000000 --- a/lib/components/DataProductAvailability/StateStorageConverter.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Alter the current state for valid JSON serialization. - * @param currentState The current state - */ -declare const convertStateForStorage: (state: any) => any; -/** - * Restore the state from JSON serialization. - * @param storedState The state read from storage. - */ -declare const convertStateFromStorage: (state: any) => any; -export { convertStateForStorage, convertStateFromStorage }; diff --git a/lib/components/DataProductAvailability/StateStorageConverter.js b/lib/components/DataProductAvailability/StateStorageConverter.js deleted file mode 100644 index 2fedde55..00000000 --- a/lib/components/DataProductAvailability/StateStorageConverter.js +++ /dev/null @@ -1,125 +0,0 @@ -"use strict"; - -Object.defineProperty(exports, "__esModule", { - value: true -}); -exports.convertStateFromStorage = exports.convertStateForStorage = void 0; - -var _cloneDeep = _interopRequireDefault(require("lodash/cloneDeep")); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -/** - * Alter the current state for valid JSON serialization. - * @param currentState The current state - */ -var convertStateForStorage = function convertStateForStorage(state) { - var newState = (0, _cloneDeep.default)(state); // variables - // const { variables: stateVariables } = state; - // Object.keys(stateVariables).forEach((variableKey, index) => { - // const { sites, tables, timeSteps } = stateVariables[variableKey]; - // if (sites instanceof Set && sites.size > 0) { - // newState.variables[variableKey].sites = Array.from(sites); - // } else { - // newState.variables[variableKey].sites = []; - // } - // if (tables instanceof Set && sites.size > 0) { - // newState.variables[variableKey].tables = Array.from(tables); - // } else { - // newState.variables[variableKey].tables = []; - // } - // if (timeSteps instanceof Set && sites.size > 0) { - // newState.variables[variableKey].timeSteps = Array.from(timeSteps); - // } else { - // newState.variables[variableKey].timeSteps = []; - // } - // }); - // // product site variables - // const { sites: productSites } = state.product; - // Object.keys(productSites).forEach((siteKey, index) => { - // const { variables: siteVariables } = productSites[siteKey]; - // if (siteVariables instanceof Set && siteVariables.size > 0) { - // newState.product.sites[siteKey].variables = Array.from(siteVariables); - // } else { - // newState.product.sites[siteKey].variables = []; - // } - // }); - // // available quality flags - // const { availableQualityFlags } = state; - // if (availableQualityFlags instanceof Set) { - // newState.availableQualityFlags = Array.from(availableQualityFlags); - // } else { - // newState.availableQualityFlags = []; - // } - // // available time steps - // const { availableTimeSteps } = state; - // if (availableTimeSteps instanceof Set) { - // newState.availableTimeSteps = Array.from(availableTimeSteps); - // } else { - // newState.availableTimeSteps = []; - // } - - return newState; -}; -/** - * Restore the state from JSON serialization. - * @param storedState The state read from storage. - */ - - -exports.convertStateForStorage = convertStateForStorage; - -var convertStateFromStorage = function convertStateFromStorage(state) { - var newState = (0, _cloneDeep.default)(state); // // graphData data - // const data = state.graphData.data.map((entry: any) => [new Date(entry[0]), entry[1]]); - // newState.graphData.data = data; - // // state variables - // const { variables } = state; - // Object.keys(variables).forEach((key, index) => { - // const { sites, tables, timeSteps } = variables[key]; - // if (Array.isArray(sites)) { - // newState.variables[key].sites = new Set(sites); - // } else { - // newState.variables[key].sites = new Set(); - // } - // if (Array.isArray(tables)) { - // newState.variables[key].tables = new Set(tables); - // } else { - // newState.variables[key].tables = new Set(); - // } - // if (Array.isArray(timeSteps)) { - // newState.variables[key].timeSteps = new Set(timeSteps); - // } else { - // newState.variables[key].timeSteps = new Set(); - // } - // }); - // // product site variables - // const { sites: productSites } = state.product; - // // get the variables for each site - // Object.keys(productSites).forEach((siteKey, index) => { - // const { variables: siteVariables } = productSites[siteKey]; - // if (Array.isArray(siteVariables) && siteVariables.length > 0) { - // newState.product.sites[siteKey].variables = new Set(siteVariables); - // } else { - // newState.product.sites[siteKey].variables = new Set(); - // } - // }); - // // available quality flags - // const { availableQualityFlags } = state; - // if (Array.isArray(availableQualityFlags)) { - // newState.availableQualityFlags = new Set(availableQualityFlags); - // } else { - // newState.availableQualityFlags = new Set(); - // } - // // available quality flags - // const { availableTimeSteps } = state; - // if (Array.isArray(availableTimeSteps)) { - // newState.availableTimeSteps = new Set(availableTimeSteps); - // } else { - // newState.availableTimeSteps = new Set(); - // } - - return newState; -}; - -exports.convertStateFromStorage = convertStateFromStorage; \ No newline at end of file diff --git a/lib/components/DownloadDataContext/StatePersistence.d.ts b/lib/components/DownloadDataContext/StatePersistence.d.ts deleted file mode 100644 index bf1736af..00000000 --- a/lib/components/DownloadDataContext/StatePersistence.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export declare const persistState: (state: object) => void; -export declare const readState: () => object | null; diff --git a/lib/components/DownloadDataContext/StatePersistence.js b/lib/components/DownloadDataContext/StatePersistence.js deleted file mode 100644 index 696c24bf..00000000 --- a/lib/components/DownloadDataContext/StatePersistence.js +++ /dev/null @@ -1,24 +0,0 @@ -"use strict"; - -Object.defineProperty(exports, "__esModule", { - value: true -}); -exports.readState = exports.persistState = void 0; - -var _StateService = _interopRequireDefault(require("../../service/StateService")); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -var key = 'downloadDataContextState'; - -var persistState = function persistState(state) { - _StateService.default.setObject(key, state); -}; - -exports.persistState = persistState; - -var readState = function readState() { - return _StateService.default.getObject(key); -}; - -exports.readState = readState; \ No newline at end of file diff --git a/lib/components/DownloadDataDialog/NeonSignInButton.d.ts b/lib/components/DownloadDataDialog/NeonSignInButton.d.ts deleted file mode 100644 index bea384f5..00000000 --- a/lib/components/DownloadDataDialog/NeonSignInButton.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -/// -export default function NeonSignInButton(): JSX.Element; diff --git a/lib/components/DownloadDataDialog/NeonSignInButton.js b/lib/components/DownloadDataDialog/NeonSignInButton.js deleted file mode 100644 index f62421a6..00000000 --- a/lib/components/DownloadDataDialog/NeonSignInButton.js +++ /dev/null @@ -1,43 +0,0 @@ -"use strict"; - -Object.defineProperty(exports, "__esModule", { - value: true -}); -exports.default = NeonSignInButton; - -var _react = _interopRequireDefault(require("react")); - -var _styles = require("@material-ui/core/styles"); - -var _Button = _interopRequireDefault(require("@material-ui/core/Button")); - -var _NeonEnvironment = _interopRequireDefault(require("../NeonEnvironment/NeonEnvironment")); - -var _signInButtonState = require("./signInButtonState"); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -var useStyles = (0, _styles.makeStyles)(function (theme) { - return { - signInButton: { - margin: theme.spacing(2) - } - }; -}); -var buttonSubject = (0, _signInButtonState.getSignInButtonSubject)(); - -var handleButtonClick = function handleButtonClick() { - // push to the subject to notify subscribers - buttonSubject.next('clicked'); - document.location.href = _NeonEnvironment.default.getFullAuthPath('login'); -}; - -function NeonSignInButton() { - var classes = useStyles(); - return /*#__PURE__*/_react.default.createElement(_Button.default, { - variant: "contained", - className: classes.signInButton, - color: "primary", - onClick: handleButtonClick - }, "Sign In"); -} \ No newline at end of file diff --git a/lib/components/DownloadDataDialog/signInButtonState.d.ts b/lib/components/DownloadDataDialog/signInButtonState.d.ts deleted file mode 100644 index 098b6d43..00000000 --- a/lib/components/DownloadDataDialog/signInButtonState.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Subject } from 'rxjs'; -export declare const getSignInButtonSubject: () => Subject; -export declare const getSignInButtonObservable: () => import("rxjs").Observable; diff --git a/lib/components/DownloadDataDialog/signInButtonState.js b/lib/components/DownloadDataDialog/signInButtonState.js deleted file mode 100644 index b4d9547a..00000000 --- a/lib/components/DownloadDataDialog/signInButtonState.js +++ /dev/null @@ -1,23 +0,0 @@ -"use strict"; - -Object.defineProperty(exports, "__esModule", { - value: true -}); -exports.getSignInButtonObservable = exports.getSignInButtonSubject = void 0; - -var _rxjs = require("rxjs"); - -// observable for sharing button state with other components -var buttonSubject = new _rxjs.Subject(); - -var getSignInButtonSubject = function getSignInButtonSubject() { - return buttonSubject; -}; - -exports.getSignInButtonSubject = getSignInButtonSubject; - -var getSignInButtonObservable = function getSignInButtonObservable() { - return buttonSubject.asObservable(); -}; - -exports.getSignInButtonObservable = getSignInButtonObservable; \ No newline at end of file diff --git a/lib/components/NeonApi/NeonApi.d.ts b/lib/components/NeonApi/NeonApi.d.ts index fff4e712..54b90e28 100644 --- a/lib/components/NeonApi/NeonApi.d.ts +++ b/lib/components/NeonApi/NeonApi.d.ts @@ -5,8 +5,8 @@ export function getTestableItems(): { postJsonObservable?: undefined; } | { getApiTokenHeader: (headers?: Object | undefined) => Object; - getJsonObservable: (url: string, headers?: Object | undefined, includeToken?: boolean) => import("rxjs").Observable; - postJsonObservable: (url: string, body: any, headers?: Object | undefined, includeToken?: boolean) => import("rxjs").Observable | import("rxjs").Observable; + getJsonObservable: (url: string, headers?: Object | undefined, includeToken?: boolean, withCredentials?: boolean) => import("rxjs").Observable; + postJsonObservable: (url: string, body: any, headers?: Object | undefined, includeToken?: boolean, withCredentials?: boolean) => import("rxjs").Observable | import("rxjs").Observable; }; declare namespace NeonApi { function getApiTokenHeader(headers?: Object | undefined): Object; @@ -15,6 +15,7 @@ declare namespace NeonApi { function getJson(url: string, callback: any, errorCallback: any, cancellationSubject$: any, headers?: Object | undefined): import("rxjs").Subscription; function getProductsObservable(): import("rxjs").Observable; function getProductObservable(productCode: string, release?: string): import("rxjs").Observable; + function getProductBundlesObservable(release?: string): import("rxjs").Observable; function getPrototypeDatasetsObservable(): import("rxjs").Observable; function getPrototypeDatasetObservable(uuid: any): import("rxjs").Observable; function getPrototypeManifestRollupObservable(uuid: any): import("rxjs").Observable; diff --git a/lib/components/NeonApi/NeonApi.js b/lib/components/NeonApi/NeonApi.js index 36579511..3e788497 100644 --- a/lib/components/NeonApi/NeonApi.js +++ b/lib/components/NeonApi/NeonApi.js @@ -9,10 +9,14 @@ var _rxjs = require("rxjs"); var _ajax = require("rxjs/ajax"); +var _operators = require("rxjs/operators"); + var _NeonEnvironment = _interopRequireDefault(require("../NeonEnvironment/NeonEnvironment")); var _rxUtil = require("../../util/rxUtil"); +var _typeUtil = require("../../util/typeUtil"); + function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } @@ -41,12 +45,65 @@ var _getApiTokenHeader = function getApiTokenHeader() { return appliedHeaders; }; +/** + * Convenience function to map an ajax request to response + * to match the return signature of ajax.getJSON + */ + + +var mapResponse = (0, _operators.map)(function (x) { + return x.response; +}); + +var getAppliedWithCredentials = function getAppliedWithCredentials() { + var withCredentials = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : undefined; + var appliedWithCredentials = false; + + if (!(0, _typeUtil.exists)(withCredentials) || typeof withCredentials !== 'boolean') { + appliedWithCredentials = _NeonEnvironment.default.requireCors(); + } else { + appliedWithCredentials = withCredentials; + } + + return appliedWithCredentials; +}; +/** + * Gets the RxJS GET AjaxRequest + * @param {string} url The URL to make the API request to + * @param {Object|undefined} headers The headers to add to the request + * @param {boolean} includeToken Option to include the API token in the request + * @param {boolean} withCredentials Option to include credentials with a CORS request + * @return The RxJS GET AjaxRequest + */ + + +var getJsonAjaxRequest = function getJsonAjaxRequest(url) { + var headers = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : undefined; + var includeToken = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : true; + var withCredentials = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : undefined; + var appliedHeaders = headers || {}; + + if (includeToken) { + appliedHeaders = _getApiTokenHeader(appliedHeaders); + } + + var appliedWithCredentials = getAppliedWithCredentials(withCredentials); + return { + url: url, + method: 'GET', + responseType: 'json', + crossDomain: true, + withCredentials: appliedWithCredentials, + headers: _extends({}, appliedHeaders) + }; +}; /** * Gets the RxJS observable for making an API request to the specified URL * with optional headers. * @param {string} url The URL to make the API request to * @param {Object|undefined} headers The headers to add to the request * @param {boolean} includeToken Option to include the API token in the request + * @param {boolean} withCredentials Option to include credentials with a CORS request * @return The RxJS Ajax Observable */ @@ -54,18 +111,14 @@ var _getApiTokenHeader = function getApiTokenHeader() { var _getJsonObservable = function getJsonObservable(url) { var headers = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : undefined; var includeToken = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : true; + var withCredentials = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : undefined; if (typeof url !== 'string' || !url.length) { return (0, _rxjs.of)(null); } - var appliedHeaders = headers || {}; - - if (includeToken) { - appliedHeaders = _getApiTokenHeader(appliedHeaders); - } - - return _ajax.ajax.getJSON(url, appliedHeaders); + var request = getJsonAjaxRequest(url, headers, includeToken, withCredentials); + return mapResponse((0, _ajax.ajax)(request)); }; /** * Gets the RxJS observable for making a POST API request to the specified URL @@ -74,6 +127,7 @@ var _getJsonObservable = function getJsonObservable(url) { * @param {any} body The body to send with the POST request * @param {Object|undefined} headers The headers to add to the request * @param {boolean} includeToken Option to include the API token in the request + * @param {boolean} withCredentials Option to include credentials with a CORS request * @return The RxJS Ajax Observable */ @@ -81,6 +135,7 @@ var _getJsonObservable = function getJsonObservable(url) { var _postJsonObservable = function postJsonObservable(url, body) { var headers = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : undefined; var includeToken = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : true; + var withCredentials = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : undefined; if (typeof url !== 'string' || !url.length) { return (0, _rxjs.of)(null); @@ -92,11 +147,13 @@ var _postJsonObservable = function postJsonObservable(url, body) { appliedHeaders = _getApiTokenHeader(appliedHeaders); } + var appliedWithCredentials = getAppliedWithCredentials(withCredentials); return (0, _ajax.ajax)({ url: url, method: 'POST', - crossDomain: true, responseType: 'json', + crossDomain: true, + withCredentials: appliedWithCredentials, headers: _extends({}, appliedHeaders, { 'Content-Type': 'application/json' }), @@ -186,6 +243,20 @@ var NeonApi = { return _getJsonObservable(path); }, + /** + * Gets the product bundles endpoint RxJS Observable. + * @param {string} release An optional release to scope the bundles. + * @return The RxJS Ajax Observable + */ + getProductBundlesObservable: function getProductBundlesObservable() { + var release = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; + + var root = _NeonEnvironment.default.getFullApiPath('productBundles'); + + var path = (0, _typeUtil.isStringNonEmpty)(release) ? "".concat(root, "?release=").concat(release) : "".concat(root); + return _getJsonObservable(path); + }, + /** * Gets the prototype data endpoint RxJS Observable. * @return The RxJS Ajax Observable @@ -264,7 +335,7 @@ var NeonApi = { * @return The RxJS Ajax Observable */ getArcgisAssetObservable: function getArcgisAssetObservable(feature, siteCode) { - return _getJsonObservable("".concat(_NeonEnvironment.default.getFullApiPath('arcgisAssets'), "/").concat(feature, "/").concat(siteCode)); + return _getJsonObservable("".concat(_NeonEnvironment.default.getFullApiPath('arcgisAssets'), "/").concat(feature, "/").concat(siteCode), undefined, true, false); } }; Object.freeze(NeonApi); diff --git a/lib/components/NeonAuth/AuthService.js b/lib/components/NeonAuth/AuthService.js index 925b5bc9..62af0115 100644 --- a/lib/components/NeonAuth/AuthService.js +++ b/lib/components/NeonAuth/AuthService.js @@ -178,7 +178,8 @@ var AuthService = { var env = _NeonEnvironment.default; var rootPath = (0, _typeUtil.exists)(path) ? path : env.getFullAuthPath('login'); var appliedRedirectUri = (0, _typeUtil.exists)(redirectUriPath) ? redirectUriPath : env.route.getFullRoute(env.getRouterBaseHomePath()); - var href = "".concat(rootPath, "?").concat(REDIRECT_URI, "=").concat(appliedRedirectUri); + var redirectUrl = "".concat(window.location.protocol, "//").concat(window.location.host).concat(appliedRedirectUri); + var href = "".concat(rootPath, "?").concat(REDIRECT_URI, "=").concat(encodeURIComponent(redirectUrl)); window.location.href = href; }, loginSilently: function loginSilently(dispatch, isSsoCheck, path, redirectUriPath) { @@ -220,13 +221,14 @@ var AuthService = { isAuthWorking: false }); AuthService.cancelWorkingResolver(); - }, state.loginCancellationSubject$); + }, state.loginCancellationSubject$, undefined, true); }, logout: function logout(path, redirectUriPath) { var env = _NeonEnvironment.default; var rootPath = (0, _typeUtil.exists)(path) ? path : env.getFullAuthPath('logout'); - var appliedRedirectUri = (0, _typeUtil.exists)(redirectUriPath) ? "".concat(env.getApiHost()).concat(redirectUriPath) : "".concat(env.getApiHost()).concat(env.route.getFullRoute(env.getRouterBaseHomePath())); - var href = "".concat(rootPath, "?").concat(REDIRECT_URI, "=").concat(appliedRedirectUri); + var appliedRedirectUri = (0, _typeUtil.exists)(redirectUriPath) ? redirectUriPath : env.route.getFullRoute(env.getRouterBaseHomePath()); + var redirectUrl = "".concat(window.location.protocol, "//").concat(window.location.host).concat(appliedRedirectUri); + var href = "".concat(rootPath, "?").concat(REDIRECT_URI, "=").concat(encodeURIComponent(redirectUrl)); window.location.href = href; }, logoutSilently: function logoutSilently(dispatch, path, redirectUriPath) { @@ -264,14 +266,14 @@ var AuthService = { isAuthWorking: false }); AuthService.cancelWorkingResolver(); - }, state.logoutCancellationSubject$); + }, state.logoutCancellationSubject$, undefined, true); }, cancellationEmitter: function cancellationEmitter() { state.cancellationSubject$.next(true); state.cancellationSubject$.unsubscribe(); }, fetchUserInfo: function fetchUserInfo(cb, errorCb) { - return (0, _rxUtil.getJson)(_NeonEnvironment.default.getFullAuthPath('userInfo'), cb, errorCb, state.cancellationSubject$); + return (0, _rxUtil.getJson)(_NeonEnvironment.default.getFullAuthPath('userInfo'), cb, errorCb, state.cancellationSubject$, undefined, true); }, fetchUserInfoWithDispatch: function fetchUserInfoWithDispatch(dispatch, refreshSubscription) { return AuthService.fetchUserInfo(function (response) { diff --git a/lib/components/NeonContext/NeonContext.d.ts b/lib/components/NeonContext/NeonContext.d.ts index 4e82a05e..365afe76 100644 --- a/lib/components/NeonContext/NeonContext.d.ts +++ b/lib/components/NeonContext/NeonContext.d.ts @@ -44,6 +44,14 @@ declare namespace Provider { declare function useNeonContextState(): ({ data: { sites: {}; + bundles: { + bundleProducts: {}; + bundleProductsForwardAvailability: {}; + bundleDoiLookup: {}; + splitProducts: {}; + allBundleProducts: {}; + apiResponse: never[]; + }; states: { AL: { name: string; @@ -428,65 +436,6 @@ declare function useNeonContextState(): ({ zoom: number; }; }; - bundles: { - children: { - "DP1.00007.001": string; - "DP1.00010.001": string; - "DP1.00034.001": string; - "DP1.00035.001": string; - "DP1.00036.001": string; - "DP1.00037.001": string; - "DP4.00067.001": string; - "DP1.00099.001": string; - "DP1.00100.001": string; - "DP2.00008.001": string; - "DP2.00009.001": string; - "DP2.00024.001": string; - "DP3.00008.001": string; - "DP3.00009.001": string; - "DP3.00010.001": string; - "DP4.00002.001": string; - "DP4.00007.001": string; - "DP4.00137.001": string; - "DP4.00201.001": string; - "DP1.10102.001": string[]; - "DP1.10099.001": string[]; - "DP1.10053.001": string; - "DP1.10031.001": string; - "DP1.10101.001": string; - "DP1.10080.001": string; - "DP1.10078.001": string; - "DP1.10100.001": string; - "DP1.10008.001": string; - "DP1.00097.001": string; - }; - parents: { - "DP4.00200.001": { - forwardAvailability: boolean; - }; - "DP1.10067.001": { - forwardAvailability: boolean; - }; - "DP1.10026.001": { - forwardAvailability: boolean; - }; - "DP1.10033.001": { - forwardAvailability: boolean; - }; - "DP1.10086.001": { - forwardAvailability: boolean; - }; - "DP1.10047.001": { - forwardAvailability: boolean; - }; - "DP1.00096.001": { - forwardAvailability: boolean; - }; - "DP1.10066.001": { - forwardAvailability: boolean; - }; - }; - }; timeSeriesDataProducts: { productCodes: string[]; }; @@ -505,6 +454,10 @@ declare function useNeonContextState(): ({ status: string; error: null; }; + bundles: { + status: string; + error: null; + }; auth: { status: null; error: null; @@ -524,6 +477,14 @@ declare function useNeonContextState(): ({ } & any[]) | ((() => void) | { data: { sites: {}; + bundles: { + bundleProducts: {}; + bundleProductsForwardAvailability: {}; + bundleDoiLookup: {}; + splitProducts: {}; + allBundleProducts: {}; + apiResponse: never[]; + }; states: { AL: { name: string; @@ -908,65 +869,6 @@ declare function useNeonContextState(): ({ zoom: number; }; }; - bundles: { - children: { - "DP1.00007.001": string; - "DP1.00010.001": string; - "DP1.00034.001": string; - "DP1.00035.001": string; - "DP1.00036.001": string; - "DP1.00037.001": string; - "DP4.00067.001": string; - "DP1.00099.001": string; - "DP1.00100.001": string; - "DP2.00008.001": string; - "DP2.00009.001": string; - "DP2.00024.001": string; - "DP3.00008.001": string; - "DP3.00009.001": string; - "DP3.00010.001": string; - "DP4.00002.001": string; - "DP4.00007.001": string; - "DP4.00137.001": string; - "DP4.00201.001": string; - "DP1.10102.001": string[]; - "DP1.10099.001": string[]; - "DP1.10053.001": string; - "DP1.10031.001": string; - "DP1.10101.001": string; - "DP1.10080.001": string; - "DP1.10078.001": string; - "DP1.10100.001": string; - "DP1.10008.001": string; - "DP1.00097.001": string; - }; - parents: { - "DP4.00200.001": { - forwardAvailability: boolean; - }; - "DP1.10067.001": { - forwardAvailability: boolean; - }; - "DP1.10026.001": { - forwardAvailability: boolean; - }; - "DP1.10033.001": { - forwardAvailability: boolean; - }; - "DP1.10086.001": { - forwardAvailability: boolean; - }; - "DP1.10047.001": { - forwardAvailability: boolean; - }; - "DP1.00096.001": { - forwardAvailability: boolean; - }; - "DP1.10066.001": { - forwardAvailability: boolean; - }; - }; - }; timeSeriesDataProducts: { productCodes: string[]; }; @@ -985,6 +887,10 @@ declare function useNeonContextState(): ({ status: string; error: null; }; + bundles: { + status: string; + error: null; + }; auth: { status: null; error: null; @@ -1005,9 +911,16 @@ declare function useNeonContextState(): ({ declare namespace DEFAULT_STATE { namespace data { export const sites: {}; + export namespace bundles { + const bundleProducts: {}; + const bundleProductsForwardAvailability: {}; + const bundleDoiLookup: {}; + const splitProducts: {}; + const allBundleProducts: {}; + const apiResponse: never[]; + } export { statesJSON as states }; export { domainsJSON as domains }; - export { bundlesJSON as bundles }; export { timeSeriesDataProductsJSON as timeSeriesDataProducts }; export const stateSites: {}; export const domainSites: {}; @@ -1024,6 +937,10 @@ declare namespace DEFAULT_STATE { status: string; error: null; }; + bundles: { + status: string; + error: null; + }; auth: { status: null; error: null; @@ -1057,6 +974,5 @@ declare namespace ProviderPropTypes { } import statesJSON from "../../staticJSON/states.json"; import domainsJSON from "../../staticJSON/domains.json"; -import bundlesJSON from "../../staticJSON/bundles.json"; import timeSeriesDataProductsJSON from "../../staticJSON/timeSeriesDataProducts.json"; import PropTypes from "prop-types"; diff --git a/lib/components/NeonContext/NeonContext.js b/lib/components/NeonContext/NeonContext.js index 0539134f..e96be18e 100644 --- a/lib/components/NeonContext/NeonContext.js +++ b/lib/components/NeonContext/NeonContext.js @@ -23,6 +23,8 @@ var _remoteAssetsMap = _interopRequireDefault(require("../../remoteAssetsMap/rem var _AuthService = _interopRequireDefault(require("../NeonAuth/AuthService")); +var _NeonApi = _interopRequireDefault(require("../NeonApi/NeonApi")); + var _NeonGraphQL = _interopRequireDefault(require("../NeonGraphQL/NeonGraphQL")); var _sites = _interopRequireDefault(require("../../staticJSON/sites.json")); @@ -31,10 +33,12 @@ var _states = _interopRequireDefault(require("../../staticJSON/states.json")); var _domains = _interopRequireDefault(require("../../staticJSON/domains.json")); -var _bundles = _interopRequireDefault(require("../../staticJSON/bundles.json")); - var _timeSeriesDataProducts = _interopRequireDefault(require("../../staticJSON/timeSeriesDataProducts.json")); +var _BundleParser = _interopRequireDefault(require("../../parser/BundleParser")); + +var _typeUtil = require("../../util/typeUtil"); + var _html, _fetches; function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } @@ -71,9 +75,17 @@ exports.FETCH_STATUS = FETCH_STATUS; var DEFAULT_STATE = { data: { sites: {}, + // See for details: interface BundleContext + bundles: { + bundleProducts: {}, + bundleProductsForwardAvailability: {}, + bundleDoiLookup: {}, + splitProducts: {}, + allBundleProducts: {}, + apiResponse: [] + }, states: _states.default, domains: _domains.default, - bundles: _bundles.default, timeSeriesDataProducts: _timeSeriesDataProducts.default, stateSites: {}, // derived when sites is fetched @@ -86,6 +98,10 @@ var DEFAULT_STATE = { status: FETCH_STATUS.AWAITING_CALL, error: null }, + bundles: { + status: FETCH_STATUS.AWAITING_CALL, + error: null + }, auth: { status: null, error: null @@ -165,6 +181,13 @@ var useNeonContextState = function useNeonContextState() { return hookResponse; }; + +var determineContextFetchFinal = function determineContextFetchFinal(state) { + var authFinal = !state.auth.useCore || state.fetches.auth.status === FETCH_STATUS.SUCCESS || state.fetches.auth.status === FETCH_STATUS.ERROR; + var sitesFinal = state.fetches.sites.status === FETCH_STATUS.SUCCESS || state.fetches.sites.status === FETCH_STATUS.ERROR; + var bundlesFinal = state.fetches.bundles.status === FETCH_STATUS.SUCCESS || state.fetches.bundles.status === FETCH_STATUS.ERROR; + return authFinal && sitesFinal && bundlesFinal; +}; /** Reducer */ @@ -192,13 +215,27 @@ var reducer = function reducer(state, action) { case 'fetchSitesSucceeded': newState.fetches.sites.status = FETCH_STATUS.SUCCESS; newState.data.sites = action.sites; - newState.isFinal = !newState.auth.useCore || newState.fetches.auth.status === FETCH_STATUS.SUCCESS || newState.fetches.auth.status === FETCH_STATUS.ERROR; + newState.isFinal = determineContextFetchFinal(newState); return deriveRegionSites(newState); case 'fetchSitesFailed': newState.fetches.sites.status = FETCH_STATUS.ERROR; newState.fetches.sites.error = action.error; - newState.isFinal = !newState.auth.useCore || newState.fetches.auth.status === FETCH_STATUS.SUCCESS || newState.fetches.auth.status === FETCH_STATUS.ERROR; + newState.isFinal = determineContextFetchFinal(newState); + newState.hasError = true; + return newState; + // Actions for handling bundles fetch + + case 'fetchBundlesSucceeded': + newState.fetches.bundles.status = FETCH_STATUS.SUCCESS; + newState.data.bundles = action.bundles; + newState.isFinal = determineContextFetchFinal(newState); + return deriveRegionSites(newState); + + case 'fetchBundlesFailed': + newState.fetches.bundles.status = FETCH_STATUS.ERROR; + newState.fetches.bundles.error = action.error; + newState.isFinal = determineContextFetchFinal(newState); newState.hasError = true; return newState; // Actions for handling auth fetch @@ -219,7 +256,7 @@ var reducer = function reducer(state, action) { newState.fetches.auth.status = FETCH_STATUS.SUCCESS; newState.auth.isAuthenticated = !!action.isAuthenticated; newState.auth.userData = _AuthService.default.parseUserData(action.response); - newState.isFinal = newState.fetches.sites.status === FETCH_STATUS.SUCCESS || newState.fetches.sites.status === FETCH_STATUS.ERROR; + newState.isFinal = determineContextFetchFinal(newState); return newState; case 'fetchAuthFailed': @@ -227,7 +264,7 @@ var reducer = function reducer(state, action) { newState.fetches.auth.error = action.error; newState.auth.isAuthenticated = false; newState.auth.userData = null; - newState.isFinal = newState.fetches.sites.status === FETCH_STATUS.SUCCESS || newState.fetches.sites.status === FETCH_STATUS.ERROR; + newState.isFinal = determineContextFetchFinal(newState); return newState; // Actions for handling remote assets @@ -388,6 +425,33 @@ var Provider = function Provider(props) { return (0, _rxjs.of)(false); })).subscribe(); }, + bundles: function bundles() { + _NeonApi.default.getProductBundlesObservable().pipe((0, _operators.map)(function (response) { + var bundles = _BundleParser.default.parseBundlesResponse(response); + + if (!(0, _typeUtil.existsNonEmpty)(bundles)) { + dispatch({ + type: 'fetchBundlesFailed', + error: 'malformed response' + }); + return (0, _rxjs.of)(false); + } + + var context = _BundleParser.default.parseContext(bundles); + + dispatch({ + type: 'fetchBundlesSucceeded', + bundles: context + }); + return (0, _rxjs.of)(true); + }), (0, _operators.catchError)(function (error) { + dispatch({ + type: 'fetchBundlesFailed', + error: error.message + }); + return (0, _rxjs.of)(false); + })).subscribe(); + }, auth: function auth() { _AuthService.default.fetchUserInfo(function (response) { var isAuthenticated = _AuthService.default.isAuthenticated(response); diff --git a/lib/components/NeonEnvironment/NeonEnvironment.d.ts b/lib/components/NeonEnvironment/NeonEnvironment.d.ts index 78455741..eabf544e 100644 --- a/lib/components/NeonEnvironment/NeonEnvironment.d.ts +++ b/lib/components/NeonEnvironment/NeonEnvironment.d.ts @@ -69,6 +69,7 @@ export interface INeonEnvironment { getFullGraphqlPath: () => string; getFullDownloadApiPath: (path: string) => string; getFullAuthPath: (path: string) => string; + requireCors: () => boolean; } declare const NeonEnvironment: INeonEnvironment; export default NeonEnvironment; diff --git a/lib/components/NeonEnvironment/NeonEnvironment.js b/lib/components/NeonEnvironment/NeonEnvironment.js index d38599d9..3730abde 100644 --- a/lib/components/NeonEnvironment/NeonEnvironment.js +++ b/lib/components/NeonEnvironment/NeonEnvironment.js @@ -83,6 +83,9 @@ var NeonEnvironment = { products: function products() { return '/products'; }, + productBundles: function productBundles() { + return '/products/bundles'; + }, releases: function releases() { return '/releases'; }, @@ -538,6 +541,18 @@ var NeonEnvironment = { getFullGraphqlPath: function getFullGraphqlPath() { var host = NeonEnvironment.getApiHost(); return "".concat(host).concat(NeonEnvironment.getRootGraphqlPath()); + }, + + /** + * Indicates when a CORS request is required + * @returns + */ + requireCors: function requireCors() { + if (window.location.host.includes('localhost')) { + return false; + } + + return !NeonEnvironment.isApiHostValid(window.location.host); } }; Object.freeze(NeonEnvironment); diff --git a/lib/components/NeonGraphQL/NeonGraphQL.d.ts b/lib/components/NeonGraphQL/NeonGraphQL.d.ts index 1e0280db..725ed031 100644 --- a/lib/components/NeonGraphQL/NeonGraphQL.d.ts +++ b/lib/components/NeonGraphQL/NeonGraphQL.d.ts @@ -19,6 +19,7 @@ declare namespace NeonGraphQL { function getGraphqlAjaxRequest(query: string): { method: string; crossDomain: boolean; + withCredentials: boolean; url: string; headers: { 'Content-Type': string; diff --git a/lib/components/NeonGraphQL/NeonGraphQL.js b/lib/components/NeonGraphQL/NeonGraphQL.js index dce00fd6..8257ce11 100644 --- a/lib/components/NeonGraphQL/NeonGraphQL.js +++ b/lib/components/NeonGraphQL/NeonGraphQL.js @@ -102,9 +102,19 @@ var getQueryBody = function getQueryBody() { var getAjaxRequest = function getAjaxRequest(body) { var includeToken = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true; + var withCredentials = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : undefined; + var appliedWithCredentials = false; + + if (!(0, _typeUtil.exists)(withCredentials) || typeof withCredentials !== 'boolean') { + appliedWithCredentials = _NeonEnvironment.default.requireCors(); + } else { + appliedWithCredentials = withCredentials; + } + var request = { method: 'POST', crossDomain: true, + withCredentials: appliedWithCredentials, url: _NeonEnvironment.default.getFullGraphqlPath(), headers: { 'Content-Type': 'application/json' diff --git a/lib/components/NeonPage/NeonPage.js b/lib/components/NeonPage/NeonPage.js index c2a6b1b5..efe598b5 100644 --- a/lib/components/NeonPage/NeonPage.js +++ b/lib/components/NeonPage/NeonPage.js @@ -688,7 +688,7 @@ var NeonPage = function NeonPage(props) { } setFetchNotificationsStatus('fetching'); - (0, _rxUtil.getJson)((0, _liferayNotificationsUtil.getLiferayNotificationsApiPath)(), handleFetchNotificationsSuccess, handleFetchNotificationsError, cancellationSubject$); + (0, _rxUtil.getJson)((0, _liferayNotificationsUtil.getLiferayNotificationsApiPath)(), handleFetchNotificationsSuccess, handleFetchNotificationsError, cancellationSubject$, undefined, true); }, [fetchNotificationsStatus]); // eslint-disable-line react-hooks/exhaustive-deps /** diff --git a/lib/components/SiteMap/svg/icon-site-relocatable-aquatic-selected.svg b/lib/components/SiteMap/svg/icon-site-relocatable-aquatic-selected.svg deleted file mode 100644 index ef5d5abd..00000000 --- a/lib/components/SiteMap/svg/icon-site-relocatable-aquatic-selected.svg +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - diff --git a/lib/components/SiteMap/svg/icon-site-relocatable-aquatic.svg b/lib/components/SiteMap/svg/icon-site-relocatable-aquatic.svg deleted file mode 100644 index 21610b63..00000000 --- a/lib/components/SiteMap/svg/icon-site-relocatable-aquatic.svg +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - diff --git a/lib/components/SiteMap/svg/icon-site-relocatable-terrestrial-selected.svg b/lib/components/SiteMap/svg/icon-site-relocatable-terrestrial-selected.svg deleted file mode 100644 index bf58346f..00000000 --- a/lib/components/SiteMap/svg/icon-site-relocatable-terrestrial-selected.svg +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - diff --git a/lib/components/SiteMap/svg/icon-site-relocatable-terrestrial.svg b/lib/components/SiteMap/svg/icon-site-relocatable-terrestrial.svg deleted file mode 100644 index a03858df..00000000 --- a/lib/components/SiteMap/svg/icon-site-relocatable-terrestrial.svg +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - diff --git a/lib/components/TimeSeriesViewer/TimeSeriesViewerContext.js b/lib/components/TimeSeriesViewer/TimeSeriesViewerContext.js index df46e7e8..cca2f304 100644 --- a/lib/components/TimeSeriesViewer/TimeSeriesViewerContext.js +++ b/lib/components/TimeSeriesViewer/TimeSeriesViewerContext.js @@ -1351,6 +1351,7 @@ var reducer = function reducer(state, action) { case 'selectNoneQualityFlags': newState.selection.isDefault = false; newState.selection.qualityFlags = []; + calcStatus(); return newState; case 'selectToggleQualityFlag': @@ -1713,7 +1714,7 @@ var Provider = function Provider(props) { month: month }); - _ajax.ajax.getJSON(getSiteMonthDataURL(siteCode, month), _NeonApi.default.getApiTokenHeader()).pipe((0, _operators.map)(function (response) { + _NeonApi.default.getJsonObservable(getSiteMonthDataURL(siteCode, month), _NeonApi.default.getApiTokenHeader()).pipe((0, _operators.map)(function (response) { if (response && response.data && response.data.files) { dispatch({ type: 'fetchSiteMonthSucceeded', diff --git a/lib/parser/BundleParser.d.ts b/lib/parser/BundleParser.d.ts new file mode 100644 index 00000000..6e447772 --- /dev/null +++ b/lib/parser/BundleParser.d.ts @@ -0,0 +1,20 @@ +import { AjaxResponse } from 'rxjs/ajax'; +import { ReleaseDataProductBundles } from '../types/neonApi'; +import { BundleContext } from '../types/neonContext'; +export interface IBundleParser { + /** + * Parse the NEON API response to typed internal interface. + * @param response The AJAX response to parse from. + * @return The types internal representation of the API response shape. + */ + parseBundlesResponse: (response: AjaxResponse) => ReleaseDataProductBundles[]; + /** + * Parse the NEON API response shape to the context specific shape + * with helper lookups. + * @param bundles The NEON API bundle response shape. + * @return The context shape for storing bundle information. + */ + parseContext: (bundles: ReleaseDataProductBundles[]) => BundleContext; +} +declare const BundleParser: IBundleParser; +export default BundleParser; diff --git a/lib/parser/BundleParser.js b/lib/parser/BundleParser.js new file mode 100644 index 00000000..6c218a15 --- /dev/null +++ b/lib/parser/BundleParser.js @@ -0,0 +1,94 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +var _typeUtil = require("../util/typeUtil"); + +var BundleParser = { + parseBundlesResponse: function parseBundlesResponse(response) { + if (!(0, _typeUtil.exists)(response)) { + return []; + } + + var data = (0, _typeUtil.resolveAny)(response, 'data'); + + if (!Array.isArray(data)) { + return []; + } + + return data; + }, + parseContext: function parseContext(bundlesResponse) { + var bundles = { + bundleProducts: {}, + bundleProductsForwardAvailability: {}, + bundleDoiLookup: {}, + splitProducts: {}, + allBundleProducts: {}, + apiResponse: [] + }; + + if (!(0, _typeUtil.existsNonEmpty)(bundlesResponse)) { + return bundles; + } + + bundles.apiResponse = bundlesResponse; + bundles.apiResponse.forEach(function (releaseBundles) { + var bundleProducts = {}; + var bundleProductForwardAvailability = {}; + var doiLookup = {}; + var splitLookup = {}; + var release = releaseBundles.release, + dataProductBundles = releaseBundles.dataProductBundles; + dataProductBundles.forEach(function (bundle) { + var bundleProductCode = bundle.productCode, + forwardAvailability = bundle.forwardAvailability, + bundledProducts = bundle.bundledProducts; + bundles.allBundleProducts[bundleProductCode] = true; + + if (forwardAvailability) { + bundleProductForwardAvailability[bundleProductCode] = true; + } + + bundleProducts[bundleProductCode] = []; + bundledProducts.forEach(function (bundledProduct) { + var productCode = bundledProduct.productCode, + isPrimaryBundle = bundledProduct.isPrimaryBundle; + bundles.allBundleProducts[productCode] = true; + bundleProducts[bundleProductCode].push(productCode); + + if (!(0, _typeUtil.exists)(isPrimaryBundle)) { + doiLookup[productCode] = bundleProductCode; + } else if (isPrimaryBundle === true) { + // Type check guard for positive boolean value, not non falsey + doiLookup[productCode] = bundleProductCode; + } // Indicate we've seen this product in more than one bundle + // Must contain a previous entry that's set to false. + + + if (Array.isArray(splitLookup[productCode])) { + splitLookup[productCode].push(bundleProductCode); + } else { + splitLookup[productCode] = [bundleProductCode]; + } + }); + }); + bundles.bundleProducts[release] = bundleProducts; + bundles.bundleProductsForwardAvailability[release] = bundleProductForwardAvailability; + bundles.bundleDoiLookup[release] = doiLookup; + bundles.splitProducts[release] = {}; + Object.keys(splitLookup).forEach(function (key) { + if (Array.isArray(splitLookup[key]) && splitLookup[key].length > 1) { + bundles.splitProducts[release][key] = splitLookup[key]; + } + }); + }); + return bundles; + } +}; +Object.freeze(BundleParser); +var _default = BundleParser; +exports.default = _default; \ No newline at end of file diff --git a/lib/remoteAssets/drupal-footer.html.d.ts b/lib/remoteAssets/drupal-footer.html.d.ts index ab1ce952..dc3f14f0 100644 --- a/lib/remoteAssets/drupal-footer.html.d.ts +++ b/lib/remoteAssets/drupal-footer.html.d.ts @@ -1,2 +1,2 @@ -declare var _default: "\n\n
\n
\n
\n
\n \"NEON\n
\n
\n

Follow Us:

\n
    \n
  • \n
  • \n
  • \n
  • \n
\n
\n\n
\n\n\n
\n \n \n
\n\n\n
\n
\n\n
\n
\n
\n
\n \n\n
\n\n
\n
\n

Copyright © Battelle, 2019-2020

\n
\n
\n

The National Ecological Observatory Network is a major facility fully funded by the National Science Foundation.

Any opinions, findings and conclusions or recommendations expressed in this material do not necessarily reflect the views of the National Science Foundation.

\n
\n
\n
\n"; +declare var _default: "\n\n
\n
\n
\n
\n \"NEON\n
\n
\n

Follow Us:

\n
    \n
  • \n
  • \n
  • \n
  • \n
  • \n
\n
\n\n
\n\n\n
\n \n \n
\n\n\n
\n
\n\n
\n
\n
\n
\n \n\n
\n\n
\n
\n

Copyright © Battelle, 2019-2020

\n
\n
\n

The National Ecological Observatory Network is a major facility fully funded by the National Science Foundation.

Any opinions, findings and conclusions or recommendations expressed in this material do not necessarily reflect the views of the National Science Foundation.

\n
\n
\n
\n"; export default _default; diff --git a/lib/remoteAssets/drupal-footer.html.js b/lib/remoteAssets/drupal-footer.html.js index 0064892a..d5743256 100644 --- a/lib/remoteAssets/drupal-footer.html.js +++ b/lib/remoteAssets/drupal-footer.html.js @@ -6,6 +6,6 @@ Object.defineProperty(exports, "__esModule", { exports.default = void 0; var html; -var _default = html = "\n\n
\n
\n
\n
\n \"NEON\n
\n
\n

Follow Us:

\n
    \n
  • \n
  • \n
  • \n
  • \n
\n
\n\n
\n\n\n
\n \n \n
\n\n\n
\n
\n\n
\n
\n
\n
\n \n\n
\n\n
\n
\n

Copyright © Battelle, 2019-2020

\n
\n
\n

The National Ecological Observatory Network is a major facility fully funded by the National Science Foundation.

Any opinions, findings and conclusions or recommendations expressed in this material do not necessarily reflect the views of the National Science Foundation.

\n
\n
\n
\n"; +var _default = html = "\n\n
\n
\n
\n
\n \"NEON\n
\n
\n

Follow Us:

\n
    \n
  • \n
  • \n
  • \n
  • \n
  • \n
\n
\n\n
\n\n\n
\n \n \n
\n\n\n
\n
\n\n
\n
\n
\n
\n \n\n
\n\n
\n
\n

Copyright © Battelle, 2019-2020

\n
\n
\n

The National Ecological Observatory Network is a major facility fully funded by the National Science Foundation.

Any opinions, findings and conclusions or recommendations expressed in this material do not necessarily reflect the views of the National Science Foundation.

\n
\n
\n
\n"; exports.default = _default; \ No newline at end of file diff --git a/lib/remoteAssets/drupal-theme.css b/lib/remoteAssets/drupal-theme.css index 0d220893..8fb4344d 100644 --- a/lib/remoteAssets/drupal-theme.css +++ b/lib/remoteAssets/drupal-theme.css @@ -948,12 +948,76 @@ background-color: #0073CF; color: white; } #header .select2-selection.select2-selection--multiple .select2-selection__choice .select2-selection__choice__remove, #footer .select2-selection.select2-selection--multiple .select2-selection__choice .select2-selection__choice__remove { - padding: 0.15rem; } - #header button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close), + padding: 0.1875rem; } + #header button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close), #header button.button.form-submit.ui-button, - #header input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res), #header ::-webkit-file-upload-button, #footer button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close), + #header input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res), + #header ::-webkit-file-upload-button, #footer button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close), #footer button.button.form-submit.ui-button, - #footer input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res), #footer ::-webkit-file-upload-button { + #footer input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res), + #footer ::-webkit-file-upload-button { color: #fff; background: #0073CF; border: 1px solid #0073CF; @@ -966,33 +1030,289 @@ padding: 0.75rem 1.125rem; -webkit-appearance: none; transition: all 0.25s; } - #header button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close):hover, + #header button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close):hover, #header button.button.form-submit.ui-button:hover, - #header input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res):hover, #header ::-webkit-file-upload-button:hover, #footer button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close):hover, + #header input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res):hover, + #header ::-webkit-file-upload-button:hover, #footer button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close):hover, #footer button.button.form-submit.ui-button:hover, - #footer input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res):hover, #footer ::-webkit-file-upload-button:hover { + #footer input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res):hover, + #footer ::-webkit-file-upload-button:hover { transition: all 0.25s; background: #0092E2; border: 1px solid #0092E2; box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25), 0px 1px 1px rgba(0, 0, 0, 0.25); } - #header button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close):active, #header button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close):focus, + #header button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close):active, #header button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close):focus, #header button.button.form-submit.ui-button:active, #header button.button.form-submit.ui-button:focus, - #header input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res):active, - #header input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res):focus, #header ::-webkit-file-upload-button:active, #header ::-webkit-file-upload-button:focus, #footer button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close):active, #footer button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close):focus, + #header input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res):active, + #header input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res):focus, + #header ::-webkit-file-upload-button:active, + #header ::-webkit-file-upload-button:focus, #footer button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close):active, #footer button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close):focus, #footer button.button.form-submit.ui-button:active, #footer button.button.form-submit.ui-button:focus, - #footer input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res):active, - #footer input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res):focus, #footer ::-webkit-file-upload-button:active, #footer ::-webkit-file-upload-button:focus { + #footer input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res):active, + #footer input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res):focus, + #footer ::-webkit-file-upload-button:active, + #footer ::-webkit-file-upload-button:focus { transition: all 0.25s; background: #0092E2; box-shadow: 0px 0px 0px 4px #C4C4C4; border: 1px solid #0073CF; } - #header button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close):disabled, + #header button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close):disabled, #header button.button.form-submit.ui-button:disabled, - #header input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res):disabled, #header ::-webkit-file-upload-button:disabled, #footer button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close):disabled, + #header input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res):disabled, + #header ::-webkit-file-upload-button:disabled, #footer button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close):disabled, #footer button.button.form-submit.ui-button:disabled, - #footer input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res):disabled, #footer ::-webkit-file-upload-button:disabled { + #footer input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res):disabled, + #footer ::-webkit-file-upload-button:disabled { background: #D7D9D9; color: #A2A4A3; } #header input[type="radio"]:not(.rlglc-input):not(:checked), #header input[type="radio"]:not(.rlglc-input):checked, #footer input[type="radio"]:not(.rlglc-input):not(:checked), #footer input[type="radio"]:not(.rlglc-input):checked { @@ -2737,7 +3057,7 @@ position: fixed; top: 0; width: 100%; - z-index: 2000; + z-index: 100; box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25), 0px 1px 1px rgba(0, 0, 0, 0.25); } #header .header__inner, #footer .header__inner { height: 120px; diff --git a/lib/service/BundleService.d.ts b/lib/service/BundleService.d.ts new file mode 100644 index 00000000..a3ea4b61 --- /dev/null +++ b/lib/service/BundleService.d.ts @@ -0,0 +1,82 @@ +import { Nullable } from '../types/core'; +import { BundleContext } from '../types/neonContext'; +export interface IBundleService { + /** + * Determines if the product is defined as a container or child within a bundle. + * @param context The context to derive lookups from. + * @param productCode The product code to search for. + * @return True if the product is defined within bundles. + */ + isProductDefined: (context: BundleContext, productCode: string) => boolean; + /** + * Determine the currently active bundle based on release. + * @param release The release to coerce. + * @return The applicable bundle release. + */ + determineBundleRelease: (release: string) => string; + /** + * Gets the set of bundled (container) product codes for the specified release. + * @param context The context to derive lookups from. + * @param release The release to get the bundles for. + */ + getBundledProductCodes: (context: BundleContext, release: string) => string[]; + /** + * Determine if the product is in a bundle for the specified release. + * @param context The context to derive lookups from. + * @param release The release to get the bundles for. + * @param productCode The product code to query with. + * @return True if the product is in a bundle. + */ + isProductInBundle: (context: BundleContext, release: string, productCode: string) => boolean; + /** + * Determines if the product is a bundled product for the specified release. + * @param context The context to derive lookups from. + * @param release The release to get bundles for. + * @param productCode The product code to query with. + * @return True if the product is in a bundle. + */ + isBundledProduct: (context: BundleContext, release: string, productCode: string) => boolean; + /** + * Determines if the product is a split product for the specified release. + * @param context The context to derive lookups from. + * @param release The release to get bundles for. + * @param productCode The product code to query with. + * @return True if the product is a split product. + */ + isSplitProduct: (context: BundleContext, release: string, productCode: string) => boolean; + /** + * Gets the bundle (container) product code for the specified bundled product. + * @param context The context to derive lookups from. + * @param release The release to get bundles for. + * @param productCode The product code to query with. + * @return The bundle product code when available. + */ + getBundleProductCode: (context: BundleContext, release: string, productCode: string) => Nullable; + /** + * Determines if the product should forward availability for the bundle. + * @param context The context to derive lookups from. + * @param release The release to get bundles for. + * @param productCode The product code to query with. + * @param bundleProductCode The bundle product code to query with. + * @return The bundle product code when available. + */ + shouldForwardAvailability: (context: BundleContext, release: string, productCode: string, bundleProductCode: string) => boolean; + /** + * Gets the owning split bundle product code. + * @param context The context to derive lookups from. + * @param release The release to get bundles for. + * @param productCode The product code to query with. + * @return The bundle product code when available. + */ + getSplitProductBundles: (context: BundleContext, release: string, productCode: string) => string[]; + /** + * Gets the set of bundled product codes for the specified bundle product. + * @param context The context to derive lookups from. + * @param release The release to get bundles for. + * @param bundleProductCode The bundle product code to query with. + * @return The bundle product code when available. + */ + getBundledProducts: (context: BundleContext, release: string, bundleProductCode: string) => string[]; +} +declare const BundleService: IBundleService; +export default BundleService; diff --git a/lib/service/BundleService.js b/lib/service/BundleService.js new file mode 100644 index 00000000..3e37d1ac --- /dev/null +++ b/lib/service/BundleService.js @@ -0,0 +1,136 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +var _typeUtil = require("../util/typeUtil"); + +var LATEST_AND_PROVISIONAL = 'LATEST_AND_PROVISIONAL'; + +var getProvReleaseRegex = function getProvReleaseRegex() { + return new RegExp(/^[A-Z]+$/); +}; + +var BundleService = { + isProductDefined: function isProductDefined(context, productCode) { + return context.allBundleProducts[productCode] === true; + }, + determineBundleRelease: function determineBundleRelease(release) { + var regex = getProvReleaseRegex(); + var isLatestProv = false; + + if (!(0, _typeUtil.isStringNonEmpty)(release) || release.localeCompare(LATEST_AND_PROVISIONAL) === 0) { + isLatestProv = true; + } else if (regex) { + var matches = regex.exec(release); + isLatestProv = (0, _typeUtil.exists)(matches) && matches.length > 0; + } + + var appliedRelease = release; + + if (isLatestProv) { + appliedRelease = 'PROVISIONAL'; + } + + return appliedRelease; + }, + getBundledProductCodes: function getBundledProductCodes(context, release) { + if (!(0, _typeUtil.exists)(context) || !(0, _typeUtil.exists)(context.bundleProducts) || !(0, _typeUtil.exists)(context.bundleProducts[release])) { + return []; + } + + return Object.keys(context.bundleProducts[release]); + }, + isProductInBundle: function isProductInBundle(context, release, productCode) { + if (!(0, _typeUtil.exists)(context) || !(0, _typeUtil.exists)(context.bundleDoiLookup) || !(0, _typeUtil.exists)(context.bundleDoiLookup[release])) { + return false; + } + + return (0, _typeUtil.isStringNonEmpty)(context.bundleDoiLookup[release][productCode]); + }, + isBundledProduct: function isBundledProduct(context, release, productCode) { + return BundleService.getBundledProductCodes(context, release).includes(productCode); + }, + isSplitProduct: function isSplitProduct(context, release, productCode) { + if (!(0, _typeUtil.exists)(context) || !(0, _typeUtil.exists)(context.splitProducts) || !(0, _typeUtil.exists)(context.splitProducts[release])) { + return false; + } + + return Array.isArray(context.splitProducts[release][productCode]); + }, + getBundleProductCode: function getBundleProductCode(context, release, productCode) { + if (!(0, _typeUtil.exists)(context) || !(0, _typeUtil.exists)(context.bundleDoiLookup) || !(0, _typeUtil.exists)(context.bundleDoiLookup[release])) { + return null; + } + + var bundledProductCode = context.bundleDoiLookup[release][productCode]; + + if (!(0, _typeUtil.isStringNonEmpty)(bundledProductCode)) { + return null; + } + + return bundledProductCode; + }, + shouldForwardAvailability: function shouldForwardAvailability(context, release, productCode, bundleProductCode) { + var isSplit = BundleService.isSplitProduct(context, release, productCode); + + if (isSplit) { + if (!(0, _typeUtil.exists)(context) || !(0, _typeUtil.exists)(context.splitProducts) || !(0, _typeUtil.exists)(context.splitProducts[release]) || !(0, _typeUtil.exists)(context.bundleProductsForwardAvailability) || !(0, _typeUtil.exists)(context.bundleProductsForwardAvailability[release])) { + return false; + } + + return context.splitProducts[release][productCode].every(function (splitToProduct) { + return context.bundleProductsForwardAvailability[release][splitToProduct]; + }); + } + + if (!(0, _typeUtil.exists)(context) || !(0, _typeUtil.exists)(context.bundleProductsForwardAvailability) || !(0, _typeUtil.exists)(context.bundleProductsForwardAvailability[release])) { + return false; + } + + var bundleShouldForward = context.bundleProductsForwardAvailability[release][bundleProductCode]; + + if (!(0, _typeUtil.exists)(bundleShouldForward)) { + return false; + } + + return bundleShouldForward; + }, + getSplitProductBundles: function getSplitProductBundles(context, release, productCode) { + var isSplit = BundleService.isSplitProduct(context, release, productCode); + + if (!isSplit) { + return []; + } + + if (!(0, _typeUtil.exists)(context) || !(0, _typeUtil.exists)(context.splitProducts) || !(0, _typeUtil.exists)(context.splitProducts[release])) { + return []; + } + + var bundles = context.splitProducts[release][productCode]; + + if (!Array.isArray(bundles)) { + return []; + } + + return bundles; + }, + getBundledProducts: function getBundledProducts(context, release, bundleProductCode) { + if (!(0, _typeUtil.exists)(context) || !(0, _typeUtil.exists)(context.bundleProducts) || !(0, _typeUtil.exists)(context.bundleProducts[release])) { + return []; + } + + var bundle = context.bundleProducts[release][bundleProductCode]; + + if (!Array.isArray(bundle)) { + return []; + } + + return bundle; + } +}; +Object.freeze(BundleService); +var _default = BundleService; +exports.default = _default; \ No newline at end of file diff --git a/lib/service/RouteService.js b/lib/service/RouteService.js index 34442dab..30daf752 100644 --- a/lib/service/RouteService.js +++ b/lib/service/RouteService.js @@ -72,17 +72,17 @@ var RouteService = { getReleaseDetailPath: function getReleaseDetailPath(release) { return "".concat(_NeonEnvironment.default.getWebHost(), "/data-samples/data-management/data-revisions-releases/").concat(release); }, - getTaxonomicListsPath: function getTaxonomicListsPath() { - return "".concat(_NeonEnvironment.default.getApiHost(), "/taxonomic-lists"); + getDataApiPath: function getDataApiPath() { + return "".concat(_NeonEnvironment.default.getApiHost(), "/data-api"); }, - getDataProductCitationDownloadUrl: function getDataProductCitationDownloadUrl() { + getTaxonomicListsPath: function getTaxonomicListsPath() { return (// TODO: replace with web host once switch over happens - _NeonEnvironment.default.getApiHost() + "".concat(_NeonEnvironment.default.getApiHost(), "/taxonomic-lists") ); }, - getDataApiPath: function getDataApiPath() { + getDataProductCitationDownloadUrl: function getDataProductCitationDownloadUrl() { return (// TODO: replace with web host once switch over happens - "".concat(_NeonEnvironment.default.getApiHost(), "/data-api") + _NeonEnvironment.default.getApiHost() ); }, getDataProductExploreSearchPath: function getDataProductExploreSearchPath(query) { diff --git a/lib/service/StateService.d.ts b/lib/service/StateService.d.ts deleted file mode 100644 index 33b7dd89..00000000 --- a/lib/service/StateService.d.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Interface to define a service for persisting state. - */ -export interface IStateService { - /** - * Adds the item to persistent storage. - * @param key The item key. - * @param value The item value. - * @returns void. - */ - setItem: (key: string, value: string) => void; - /** - * Gets the item's value from persistent storage. - * @param key The item key. - * @returns The item's value as a string or null if it does not exist in storage. - */ - getItem: (key: string) => string | null; - /** - * Adds the object to persistent storage. - * @param key The object key. - * @param value The object value. - * @returns void. - */ - setObject: (key: string, value: object) => void; - /** - * Gets the item's value from persistent storage. - * @param key The item key. - * @returns The item's value as a string or null if it does not exist in storage. - */ - getObject: (key: string) => object | null; - /** - * Gets the key at the given index. - * @param index The index of the item in storage. - * @returns The item's key. - */ - key: (index: number) => string | null; - /** - * Removes the item with the given key. - * @param key The item's key. - * @returns void. - */ - removeItem: (key: string) => void; - /** - * Clears storage of all items. - */ - clear: () => void; - /** - * Returns the number of items in storage. - */ - length: (key: string) => number; -} -declare const StateService: IStateService; -export default StateService; diff --git a/lib/service/StateService.js b/lib/service/StateService.js deleted file mode 100644 index e7cfcad5..00000000 --- a/lib/service/StateService.js +++ /dev/null @@ -1,45 +0,0 @@ -"use strict"; - -Object.defineProperty(exports, "__esModule", { - value: true -}); -exports.default = void 0; - -/** - * Interface to define a service for persisting state. - */ -var StateService = { - setItem: function setItem(key, value) { - return sessionStorage.setItem(key, value); - }, - getItem: function getItem(key) { - return sessionStorage.getItem(key); - }, - setObject: function setObject(key, object) { - return sessionStorage.setItem(key, JSON.stringify(object)); - }, - getObject: function getObject(key) { - var value = sessionStorage.getItem(key); - - if (!value) { - return null; - } - - return JSON.parse(value); - }, - key: function key(index) { - return sessionStorage.key(index); - }, - removeItem: function removeItem(key) { - return sessionStorage.removeItem(key); - }, - clear: function clear() { - return sessionStorage.clear(); - }, - length: function length() { - return sessionStorage.length; - } -}; -Object.freeze(StateService); -var _default = StateService; -exports.default = _default; \ No newline at end of file diff --git a/lib/staticJSON/bundles.json b/lib/staticJSON/bundles.json deleted file mode 100644 index 4855c684..00000000 --- a/lib/staticJSON/bundles.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "children": { - "DP1.00007.001": "DP4.00200.001", - "DP1.00010.001": "DP4.00200.001", - "DP1.00034.001": "DP4.00200.001", - "DP1.00035.001": "DP4.00200.001", - "DP1.00036.001": "DP4.00200.001", - "DP1.00037.001": "DP4.00200.001", - "DP4.00067.001": "DP4.00200.001", - "DP1.00099.001": "DP4.00200.001", - "DP1.00100.001": "DP4.00200.001", - "DP2.00008.001": "DP4.00200.001", - "DP2.00009.001": "DP4.00200.001", - "DP2.00024.001": "DP4.00200.001", - "DP3.00008.001": "DP4.00200.001", - "DP3.00009.001": "DP4.00200.001", - "DP3.00010.001": "DP4.00200.001", - "DP4.00002.001": "DP4.00200.001", - "DP4.00007.001": "DP4.00200.001", - "DP4.00137.001": "DP4.00200.001", - "DP4.00201.001": "DP4.00200.001", - "DP1.10102.001": ["DP1.10066.001", "DP1.10067.001"], - "DP1.10099.001": ["DP1.10066.001", "DP1.10067.001"], - "DP1.10053.001": "DP1.10026.001", - "DP1.10031.001": "DP1.10033.001", - "DP1.10101.001": "DP1.10033.001", - "DP1.10080.001": "DP1.10086.001", - "DP1.10078.001": "DP1.10086.001", - "DP1.10100.001": "DP1.10086.001", - "DP1.10008.001": "DP1.10047.001", - "DP1.00097.001": "DP1.00096.001" - }, - "parents": { - "DP4.00200.001": { "forwardAvailability": true }, - "DP1.10067.001": { "forwardAvailability": false }, - "DP1.10026.001": { "forwardAvailability": false }, - "DP1.10033.001": { "forwardAvailability": false }, - "DP1.10086.001": { "forwardAvailability": false }, - "DP1.10047.001": { "forwardAvailability": false }, - "DP1.00096.001": { "forwardAvailability": false }, - "DP1.10066.001": { "forwardAvailability": false } - } -} diff --git a/lib/types/neonApi.d.ts b/lib/types/neonApi.d.ts new file mode 100644 index 00000000..58c7340f --- /dev/null +++ b/lib/types/neonApi.d.ts @@ -0,0 +1,16 @@ +export interface NeonApiResponse { + data: unknown; +} +export interface BundledDataProduct { + productCode: string; + isPrimaryBundle?: boolean; +} +export interface DataProductBundle { + productCode: string; + forwardAvailability: boolean; + bundledProducts: BundledDataProduct[]; +} +export interface ReleaseDataProductBundles { + release: string; + dataProductBundles: DataProductBundle[]; +} diff --git a/lib/types/neonApi.js b/lib/types/neonApi.js new file mode 100644 index 00000000..9a390c31 --- /dev/null +++ b/lib/types/neonApi.js @@ -0,0 +1 @@ +"use strict"; \ No newline at end of file diff --git a/lib/types/neonContext.d.ts b/lib/types/neonContext.d.ts new file mode 100644 index 00000000..8f76dc09 --- /dev/null +++ b/lib/types/neonContext.d.ts @@ -0,0 +1,45 @@ +import { ReleaseDataProductBundles } from './neonApi'; +/** + * NeonContext specific utilization of bundles, derived for quick lookups + * of key properties of bundles. + */ +export interface BundleContext { + /** + * Defines the set of container products that have bundled products, + * keyed by { "RELEASE": { "BUNDLE_PRODUCT_CODE": ["PRODUCT_CODE"] } } + */ + bundleProducts: Record>; + /** + * Defines the set of container products that should forward their + * availability to bundled products, + * keyed by { "RELEASE": { "BUNDLE_PRODUCT_CODE": true } } + */ + bundleProductsForwardAvailability: Record>; + /** + * Defines the set of bundled products, to the container product + * that should provide the DOI for the bundled product. + * { + * "RELEASE": { + * "PRODUCT_CODE": "BUNDLE_PRODUCT_CODE", + * }, + * } + * This lookup can also be utilized to determine whether or not + * a product exists within a bundle. + */ + bundleDoiLookup: Record>; + /** + * Defines the set of products that should be presented as "split", + * defined as products that now exist in more than one product, + * and are therefore defined in two or more "bundles". + * keyed by { "RELEASE": { "SPLIT_PRODUCT_CODE": ["BUNDLED_PRODUCT_CODE"] } } + */ + splitProducts: Record>; + /** + * Defines the set of products that are involved in bundles. + */ + allBundleProducts: Record; + /** + * The raw API response containing the full bundle definition. + */ + apiResponse: ReleaseDataProductBundles[]; +} diff --git a/lib/types/neonContext.js b/lib/types/neonContext.js new file mode 100644 index 00000000..9a390c31 --- /dev/null +++ b/lib/types/neonContext.js @@ -0,0 +1 @@ +"use strict"; \ No newline at end of file diff --git a/lib/util/rxUtil.d.ts b/lib/util/rxUtil.d.ts index caa7fde0..2e2bb226 100644 --- a/lib/util/rxUtil.d.ts +++ b/lib/util/rxUtil.d.ts @@ -1,3 +1,3 @@ -export function getJson(url: string, callback: any, errorCallback: any, cancellationSubject$: any, headers?: Object | undefined): import("rxjs").Subscription; +export function getJson(url: string, callback: any, errorCallback: any, cancellationSubject$: any, headers?: Object | undefined, cors?: boolean): import("rxjs").Subscription; export default getJson; export function forkJoinWithProgress(arrayOfObservables: any): import("rxjs").Observable[]>; diff --git a/lib/util/rxUtil.js b/lib/util/rxUtil.js index 84ddb018..d79c3ae7 100644 --- a/lib/util/rxUtil.js +++ b/lib/util/rxUtil.js @@ -11,6 +11,14 @@ var _ajax = require("rxjs/ajax"); var _operators = require("rxjs/operators"); +var _NeonEnvironment = _interopRequireDefault(require("../components/NeonEnvironment/NeonEnvironment")); + +var _typeUtil = require("./typeUtil"); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } + /** * Convenience method for utiliizing RxJS ajax.getJSON * @param {string} url @@ -18,17 +26,34 @@ var _operators = require("rxjs/operators"); * @param {any} errorCallback * @param {any} cancellationSubject$ * @param {Object|undefined} headers + * @param {boolean} cors * @return RxJS subscription */ var getJson = function getJson(url, callback, errorCallback, cancellationSubject$) { var headers = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : undefined; + var cors = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : false; + var request = { + method: 'GET', + url: url, + responseType: 'json', + headers: _extends({ + Accept: 'application/json' + }, headers) + }; + + if (cors && _NeonEnvironment.default.requireCors()) { + request.crossDomain = true; + request.withCredentials = true; + } + + var rxObs$ = (0, _ajax.ajax)(request).pipe((0, _operators.map)(function (response) { + var appliedResponse = (0, _typeUtil.exists)(response) && (0, _typeUtil.exists)(response.response) ? response.response : response; - var rxObs$ = _ajax.ajax.getJSON(url, headers).pipe((0, _operators.map)(function (response) { if (typeof callback === 'function') { - return (0, _rxjs.of)(callback(response)); + return (0, _rxjs.of)(callback(appliedResponse)); } - return (0, _rxjs.of)(response); + return (0, _rxjs.of)(appliedResponse); }), (0, _operators.catchError)(function (error) { console.error(error); // eslint-disable-line no-console @@ -39,7 +64,6 @@ var getJson = function getJson(url, callback, errorCallback, cancellationSubject return (0, _rxjs.of)(error); }), (0, _operators.takeUntil)(cancellationSubject$)); // Placeholders for subscriber events, handled upstream in observable - return rxObs$.subscribe(function (response) { return response; }, function (error) { diff --git a/lib/util/typeUtil.d.ts b/lib/util/typeUtil.d.ts index b9e391b9..f6627a12 100644 --- a/lib/util/typeUtil.d.ts +++ b/lib/util/typeUtil.d.ts @@ -1,6 +1,14 @@ -import { Nullable } from '../types/core'; +import { Nullable, UnknownRecord } from '../types/core'; declare const exists: (o: any) => boolean; declare const isStringNonEmpty: (o: any) => boolean; declare const isNum: (o: any) => boolean; declare const existsNonEmpty: (o: Nullable) => boolean; +/** + * Resolves any value to a record by + * drilling down nested props to coerce to a usable type. + * @param o the object to interrogate + * @param drillProps array of nested props to drill down to + * @return The coerced inner prop + */ +export declare const resolveAny: (o: never, ...drillProps: string[]) => UnknownRecord; export { exists, isStringNonEmpty, isNum, existsNonEmpty, }; diff --git a/lib/util/typeUtil.js b/lib/util/typeUtil.js index bb3521ce..11160192 100644 --- a/lib/util/typeUtil.js +++ b/lib/util/typeUtil.js @@ -3,7 +3,19 @@ Object.defineProperty(exports, "__esModule", { value: true }); -exports.existsNonEmpty = exports.isNum = exports.isStringNonEmpty = exports.exists = void 0; +exports.existsNonEmpty = exports.isNum = exports.isStringNonEmpty = exports.exists = exports.resolveAny = void 0; + +function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); } + +function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } + +function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } + +function _iterableToArray(iter) { if (typeof Symbol !== "undefined" && iter[Symbol.iterator] != null || iter["@@iterator"] != null) return Array.from(iter); } + +function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) return _arrayLikeToArray(arr); } + +function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; } var exists = function exists(o) { return typeof o !== 'undefined' && o !== null; @@ -26,5 +38,43 @@ exports.isNum = isNum; var existsNonEmpty = function existsNonEmpty(o) { return exists(o) && o.length > 0; }; +/** + * Resolves any value to a record by + * drilling down nested props to coerce to a usable type. + * @param o the object to interrogate + * @param drillProps array of nested props to drill down to + * @return The coerced inner prop + */ + + +exports.existsNonEmpty = existsNonEmpty; + +var resolveAny = function resolveAny(o) { + for (var _len = arguments.length, drillProps = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + drillProps[_key - 1] = arguments[_key]; + } + + if (!exists(o) || !existsNonEmpty(drillProps)) { + return {}; + } + + var curProp = drillProps[0]; + + if (drillProps.length === 1) { + if (!exists(o[curProp])) { + return {}; + } + + return o[curProp]; + } + + var next = o[curProp]; + + if (!exists(next)) { + return {}; + } + + return resolveAny.apply(void 0, [next].concat(_toConsumableArray(drillProps.slice(1, drillProps.length)))); +}; -exports.existsNonEmpty = existsNonEmpty; \ No newline at end of file +exports.resolveAny = resolveAny; \ No newline at end of file diff --git a/lib/workers/generateTimeSeriesGraphData.js b/lib/workers/generateTimeSeriesGraphData.js index 489b71ff..1d23a6dd 100644 --- a/lib/workers/generateTimeSeriesGraphData.js +++ b/lib/workers/generateTimeSeriesGraphData.js @@ -131,6 +131,20 @@ function tickerToIso(ticker) { return includeSeconds ? "".concat(YYYY, "-").concat(MM, "-").concat(DD, "T").concat(hh, ":").concat(mm, ":").concat(ss, "Z") : "".concat(YYYY, "-").concat(MM, "-").concat(DD, "T").concat(hh, ":").concat(mm, "Z"); } +function dateTimeToFloorSeconds(dt) { + if (typeof dt === 'undefined' || dt === null) { + return null; + } + + var d = new Date(dt); + var YYYY = d.getUTCFullYear().toString(); + var MM = (d.getUTCMonth() + 1).toString().padStart(2, '0'); + var DD = d.getUTCDate().toString().padStart(2, '0'); + var hh = d.getUTCHours().toString().padStart(2, '0'); + var mm = d.getUTCMinutes().toString().padStart(2, '0'); + return "".concat(YYYY, "-").concat(MM, "-").concat(DD, "T").concat(hh, ":").concat(mm, ":00Z"); +} + function getNextMonth(month) { if (!monthIsValid(month)) { return null; @@ -179,7 +193,7 @@ exports.getTestableItems = getTestableItems; function generateTimeSeriesGraphData() { var payload = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - var worker = new _paralleljs.default(payload).require(getTimeSteps).require(monthIsValidFormat).require(monthToNumbers).require(monthIsValid).require(monthToTicker).require(tickerIsValid).require(tickerToMonth).require(tickerToIso).require(getNextMonth); + var worker = new _paralleljs.default(payload).require(getTimeSteps).require(monthIsValidFormat).require(monthToNumbers).require(monthIsValid).require(monthToTicker).require(tickerIsValid).require(tickerToMonth).require(tickerToIso).require(dateTimeToFloorSeconds).require(getNextMonth); return worker.spawn(function (inData) { @@ -199,7 +213,23 @@ function generateTimeSeriesGraphData() { qualityFlags = _inData$selection.qualityFlags, sites = _inData$selection.sites, selectedTimeStep = _inData$selection.timeStep, - selectedVariables = _inData$selection.variables; + selectedVariables = _inData$selection.variables; // Optionally toggle to allow a purely positional mapping + // of generated, contiguous timestamp values based on the + // aggregation level, and the actual timestamps coming back in the + // data. Setting this to true makes some inherent assumptions + // about the data that are likely OK in most cases and will be + // more efficient, but can produce incorrect results given irregular + // timestamps in the data. + // Turning this off will resolve to mapping the timestamps from the + // data to the matching timestamp in the generated, + // contiguous timestamp values. + + var ALLOW_POSITIONAL_TS_MAPPING = false; // Optionally toggle to allow truncating series values + // in the case where there are more timestamp values than expected + // based on the generated, contiguous timestamp values for the aggregation + // level. + + var ALLOW_POSITIONAL_TS_TRUNCATION = false; var timeStep = selectedTimeStep === 'auto' ? autoTimeStep : selectedTimeStep; var getQFNullFill = function getQFNullFill() { @@ -208,7 +238,27 @@ function generateTimeSeriesGraphData() { }); }; - var TIME_STEPS = getTimeSteps(); + var TIME_STEPS = getTimeSteps(); // Function to match the searchTime in milliseconds since epoch, + // against a timestamp in the dataset + + var findTimestampIdx = function findTimestampIdx(data, searchTime, startIdx) { + var isodateS = tickerToIso(searchTime, true); + var isodateM = tickerToIso(searchTime, false); + var dateIdx = -1; + + for (var i = startIdx; i < data.length; i += 1) { + var dateTimeVal = data[i]; + var dateTimeValZeroS = dateTimeToFloorSeconds(dateTimeVal); + var matched = dateTimeVal === isodateS || dateTimeValZeroS === isodateS || dateTimeVal === isodateM; + + if (matched) { + dateIdx = i; + break; + } + } + + return dateIdx; + }; /** Validate input (return unmodified state.graphData if anything fails) */ @@ -386,41 +436,52 @@ function generateTimeSeriesGraphData() { var seriesStepCount = posData[month][pkg][timeStep].series[variable].data.length; // Series and month data lengths are identical (as expected): // Stream values directly in without matching timestamps - if (seriesStepCount === monthStepCount) { - posData[month][pkg][timeStep].series[variable].data.forEach(function (d, datumIdx) { - newData[datumIdx + monthIdx][columnIdx] = d; - }); - return; + if (ALLOW_POSITIONAL_TS_MAPPING) { + if (seriesStepCount === monthStepCount) { + posData[month][pkg][timeStep].series[variable].data.forEach(function (d, datumIdx) { + newData[datumIdx + monthIdx][columnIdx] = d; + }); + return; + } } // More series data than month data: // Stream values directly in without matching timestamps, truncate data so as not to // exceed month step count - if (seriesStepCount >= monthStepCount) { - posData[month][pkg][timeStep].series[variable].data.forEach(function (d, datumIdx) { - if (datumIdx >= monthStepCount) { - return; - } + if (ALLOW_POSITIONAL_TS_TRUNCATION) { + if (seriesStepCount >= monthStepCount) { + posData[month][pkg][timeStep].series[variable].data.forEach(function (d, datumIdx) { + if (datumIdx >= monthStepCount) { + return; + } - newData[datumIdx + monthIdx][columnIdx] = d; - }); - return; - } // Series data length is shorter than expected month length: + newData[datumIdx + monthIdx][columnIdx] = d; + }); + return; + } + } // The series data length does not match the expected month length so + // loop through by month steps pulling in series values through timestamp matching // Add what data we have by going through each time step in the month and comparing to // start dates in the data set, null-filling any steps without a corresponding datum // Note that sometimes dates come back with seconds and sometimes without, so for // matching we look for either. + // This assumes a chronological ordering of timestamps in both + // the input series data and generated month ticker timestamps. + // Therefore, if we find a matching timestamp value, we can assume + // that the next value won't be found before the last found index in the series data, + // allowing us to optimize the search in this scenario. - var setSeriesValueByTimestamp = function setSeriesValueByTimestamp(t) { - var isodateS = tickerToIso(newData[t][0].getTime(), true); - var isodateM = tickerToIso(newData[t][0].getTime(), false); - var dataIdx = posData[month][pkg][timeStep].series[dateTimeVariable].data.findIndex(function (dateTimeVal) { - return dateTimeVal === isodateS || dateTimeVal === isodateM; - }); - newData[t][columnIdx] = dataIdx !== -1 ? posData[month][pkg][timeStep].series[variable].data[dataIdx] : null; - }; + var lastIdx = 0; + var dtVarData = posData[month][pkg][timeStep].series[dateTimeVariable].data; for (var _t = monthIdx; _t < monthIdx + monthStepCount; _t += 1) { - setSeriesValueByTimestamp(_t); + var dataIdx = findTimestampIdx(dtVarData, newData[_t][0].getTime(), lastIdx); + + if (dataIdx === -1) { + newData[_t][columnIdx] = null; + } else { + lastIdx = dataIdx + 1; + newData[_t][columnIdx] = posData[month][pkg][timeStep].series[variable].data[dataIdx]; + } } }); // Also for each site/position/month loop through all selected quality flags @@ -442,48 +503,70 @@ function generateTimeSeriesGraphData() { return; } // This site/position/month/qf series exists, so add it into the quality data set - var seriesStepCount = posData[month][pkg][timeStep].series[qf].data.length; + var seriesStepCount = posData[month][pkg][timeStep].series[qf].data.length; // Series and month data lengths are identical as expected so we can stream + // values directly in without matching timestamps - if (seriesStepCount !== monthStepCount) { - // The series data length does not match the expected month length so - // loop through by month steps pulling in series values through timestamp matching - var setQualityValueByTimestamp = function setQualityValueByTimestamp(t) { - var isodate = tickerToIso(newQualityData[t][0].getTime()); - var dataIdx = posData[month][pkg][timeStep].series[dateTimeVariable].data.findIndex(function (dateTimeVal) { - return dateTimeVal === isodate; - }); + if (ALLOW_POSITIONAL_TS_MAPPING) { + if (seriesStepCount === monthStepCount) { + posData[month][pkg][timeStep].series[qf].data.forEach(function (d, datumIdx) { + var t = datumIdx + monthIdx; - if (dataIdx === -1) { - newQualityData[t][columnIdx] = getQFNullFill(); - return; - } + if (!Array.isArray(newQualityData[t][columnIdx])) { + newQualityData[t][columnIdx] = []; + } - var d = typeof posData[month][pkg][timeStep].series[qf].data[dataIdx] !== 'undefined' ? posData[month][pkg][timeStep].series[qf].data[dataIdx] : null; + newQualityData[t][columnIdx][qfIdx] = d; + }); + return; + } + } // More series data than month data: + // Stream values directly in without matching timestamps, truncate data so as not to + // exceed month step count - if (!Array.isArray(newQualityData[t][columnIdx])) { - newQualityData[t][columnIdx] = []; - } + if (ALLOW_POSITIONAL_TS_TRUNCATION) { + if (seriesStepCount > monthStepCount) { + posData[month][pkg][timeStep].series[qf].data.forEach(function (d, datumIdx) { + if (datumIdx >= monthStepCount) { + return; + } - newQualityData[t][columnIdx][qfIdx] = d; - }; + var t = datumIdx + monthIdx; - for (var _t2 = monthIdx; _t2 < monthIdx + monthStepCount; _t2 += 1) { - setQualityValueByTimestamp(_t2); - } + if (!Array.isArray(newQualityData[t][columnIdx])) { + newQualityData[t][columnIdx] = []; + } - return; - } // Series and month data lengths are identical as expected so we can stream - // values directly in without matching timestamps + newQualityData[t][columnIdx][qfIdx] = d; + }); + return; + } + } // The series data length does not match the expected month length so + // loop through by month steps pulling in series values through timestamp matching + // This assumes a chronological ordering of timestamps in both + // the input series data and generated month ticker timestamps. + // Therefore, if we find a matching timestamp value, we can assume + // that the next value won't be found before the last found index in the series data, + // allowing us to optimize the search in this scenario. + + var lastIdx = 0; + var dtVarData = posData[month][pkg][timeStep].series[dateTimeVariable].data; + + for (var _t2 = monthIdx; _t2 < monthIdx + monthStepCount; _t2 += 1) { + var dataIdx = findTimestampIdx(dtVarData, newQualityData[_t2][0].getTime(), lastIdx); + + if (dataIdx === -1) { + newQualityData[_t2][columnIdx] = getQFNullFill(); + } else { + lastIdx = dataIdx + 1; + var d = typeof posData[month][pkg][timeStep].series[qf].data[dataIdx] !== 'undefined' ? posData[month][pkg][timeStep].series[qf].data[dataIdx] : null; - posData[month][pkg][timeStep].series[qf].data.forEach(function (d, datumIdx) { - var t = datumIdx + monthIdx; + if (!Array.isArray(newQualityData[_t2][columnIdx])) { + newQualityData[_t2][columnIdx] = []; + } - if (!Array.isArray(newQualityData[t][columnIdx])) { - newQualityData[t][columnIdx] = []; + newQualityData[_t2][columnIdx][qfIdx] = d; } - - newQualityData[t][columnIdx][qfIdx] = d; - }); + } }); }); }); diff --git a/package-lock.json b/package-lock.json index d4d229d0..f1742f3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,12 @@ { "name": "portal-core-components", - "version": "1.9.0", + "version": "1.10.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "version": "1.9.0", + "name": "portal-core-components", + "version": "1.10.0", "dependencies": { "@date-io/moment": "^1.3.9", "@material-ui/core": "^4.11.3", @@ -6520,10 +6521,14 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001222", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001222.tgz", - "integrity": "sha512-rPmwUK0YMjfMlZVmH6nVB5U3YJ5Wnx3vmT5lnRO3nIKO8bJ+TRWMbGuuiSugDJqESy/lz+1hSrlQEagCtoOAWQ==", - "dev": true + "version": "1.0.30001274", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001274.tgz", + "integrity": "sha512-+Nkvv0fHyhISkiMIjnyjmf5YJcQ1IQHZN6U9TLUMroWR38FNwpsC51Gb68yueafX1V6ifOisInSgP9WJFS13ew==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + } }, "node_modules/canvg": { "version": "3.0.7", @@ -31043,9 +31048,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001222", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001222.tgz", - "integrity": "sha512-rPmwUK0YMjfMlZVmH6nVB5U3YJ5Wnx3vmT5lnRO3nIKO8bJ+TRWMbGuuiSugDJqESy/lz+1hSrlQEagCtoOAWQ==", + "version": "1.0.30001274", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001274.tgz", + "integrity": "sha512-+Nkvv0fHyhISkiMIjnyjmf5YJcQ1IQHZN6U9TLUMroWR38FNwpsC51Gb68yueafX1V6ifOisInSgP9WJFS13ew==", "dev": true }, "canvg": { diff --git a/package.json b/package.json index 5717e220..18703151 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "portal-core-components", - "version": "1.9.0", + "version": "1.10.0", "main": "./lib/index.js", "private": true, "homepage": "http://localhost:3010/core-components", @@ -86,6 +86,7 @@ "start:prod": "mv build core-components && mkdir build && mv core-components/ build/ && npx serve build", "build": "react-scripts build", "test": "npx jest --config jest.config.js", + "test:clear-cache": "npx jest --clearCache", "test:updateSnapshots": "npx jest --config jest.config.js --updateSnapshot", "lint": "(npx eslint src/ --ext .js,.jsx || true) && exit 0", "lint:fix": "(npx eslint --fix src/ --ext .js,.jsx,.ts,.tsx || true) && exit 0", diff --git a/src/__mocks__/NeonContext.js b/src/__mocks__/NeonContext.js index 7e4b2c3c..b04e3a28 100644 --- a/src/__mocks__/NeonContext.js +++ b/src/__mocks__/NeonContext.js @@ -10,12 +10,13 @@ */ import sitesJSON from '../sampleData/sites.json'; +import bundlesJSON from '../sampleData/bundles.json'; import statesJSON from '../lib_components/staticJSON/states.json'; import domainsJSON from '../lib_components/staticJSON/domains.json'; -import bundlesJSON from '../lib_components/staticJSON/bundles.json'; import timeSeriesDataProductsJSON from '../lib_components/staticJSON/timeSeriesDataProducts.json'; import NeonContext from '../lib_components/components/NeonContext/NeonContext'; +import BundleParser from '../lib_components/parser/BundleParser'; jest.mock('../lib_components/components/NeonContext/NeonContext', () => ( { @@ -37,7 +38,9 @@ NeonContext.useNeonContextState.mockReturnValue([ sites: sitesJSON, states: statesJSON, domains: domainsJSON, - bundles: bundlesJSON, + bundles: BundleParser.parseContext( + BundleParser.parseBundlesResponse(bundlesJSON), + ), timeSeriesDataProducts: timeSeriesDataProductsJSON, stateSites: {}, // derived when sites is fetched, needs to be mocked here? domainSites: {}, // derived when sites is fetched, needs to be mocked here? diff --git a/src/__mocks__/ajax.js b/src/__mocks__/ajax.js index f6ea013f..f6f5e481 100644 --- a/src/__mocks__/ajax.js +++ b/src/__mocks__/ajax.js @@ -50,6 +50,11 @@ export function mockAjaxResponse(response = {}) { mockGetJSONAjaxResponse(response); } +export function mockAjaxResponseWrapper(response = {}) { + mockRawAjaxResponse({ response: { ...response }}); + mockGetJSONAjaxResponse({ response: { ...response }}); +} + export function mockAjaxError(error = '') { mockRawAjaxError(error); mockGetJSONAjaxError(error); diff --git a/src/lib_components/components/NeonApi/NeonApi.js b/src/lib_components/components/NeonApi/NeonApi.js index ad46919c..898ea414 100644 --- a/src/lib_components/components/NeonApi/NeonApi.js +++ b/src/lib_components/components/NeonApi/NeonApi.js @@ -1,9 +1,11 @@ import { of } from 'rxjs'; import { ajax } from 'rxjs/ajax'; +import { map } from 'rxjs/operators'; import NeonEnvironment from '../NeonEnvironment/NeonEnvironment'; import { getJson } from '../../util/rxUtil'; +import { exists, isStringNonEmpty } from '../../util/typeUtil'; /** * Gets the API Token header from the environment. @@ -28,20 +30,70 @@ const getApiTokenHeader = (headers = undefined) => { }; /** - * Gets the RxJS observable for making an API request to the specified URL - * with optional headers. + * Convenience function to map an ajax request to response + * to match the return signature of ajax.getJSON + */ +const mapResponse = map((x) => x.response); + +const getAppliedWithCredentials = (withCredentials = undefined) => { + let appliedWithCredentials = false; + if (!exists(withCredentials) || (typeof withCredentials !== 'boolean')) { + appliedWithCredentials = NeonEnvironment.requireCors(); + } else { + appliedWithCredentials = withCredentials; + } + return appliedWithCredentials; +}; + +/** + * Gets the RxJS GET AjaxRequest * @param {string} url The URL to make the API request to * @param {Object|undefined} headers The headers to add to the request * @param {boolean} includeToken Option to include the API token in the request - * @return The RxJS Ajax Observable + * @param {boolean} withCredentials Option to include credentials with a CORS request + * @return The RxJS GET AjaxRequest */ -const getJsonObservable = (url, headers = undefined, includeToken = true) => { - if (typeof url !== 'string' || !url.length) { return of(null); } +const getJsonAjaxRequest = ( + url, + headers = undefined, + includeToken = true, + withCredentials = undefined, +) => { let appliedHeaders = headers || {}; if (includeToken) { appliedHeaders = getApiTokenHeader(appliedHeaders); } - return ajax.getJSON(url, appliedHeaders); + const appliedWithCredentials = getAppliedWithCredentials(withCredentials); + return { + url, + method: 'GET', + responseType: 'json', + crossDomain: true, + withCredentials: appliedWithCredentials, + headers: { + ...appliedHeaders, + }, + }; +}; + +/** + * Gets the RxJS observable for making an API request to the specified URL + * with optional headers. + * @param {string} url The URL to make the API request to + * @param {Object|undefined} headers The headers to add to the request + * @param {boolean} includeToken Option to include the API token in the request + * @param {boolean} withCredentials Option to include credentials with a CORS request + * @return The RxJS Ajax Observable + */ +const getJsonObservable = ( + url, + headers = undefined, + includeToken = true, + withCredentials = undefined, +) => { + if (typeof url !== 'string' || !url.length) { return of(null); } + const request = getJsonAjaxRequest(url, headers, includeToken, withCredentials); + return mapResponse(ajax(request)); }; /** @@ -51,19 +103,28 @@ const getJsonObservable = (url, headers = undefined, includeToken = true) => { * @param {any} body The body to send with the POST request * @param {Object|undefined} headers The headers to add to the request * @param {boolean} includeToken Option to include the API token in the request + * @param {boolean} withCredentials Option to include credentials with a CORS request * @return The RxJS Ajax Observable */ -const postJsonObservable = (url, body, headers = undefined, includeToken = true) => { +const postJsonObservable = ( + url, + body, + headers = undefined, + includeToken = true, + withCredentials = undefined, +) => { if (typeof url !== 'string' || !url.length) { return of(null); } let appliedHeaders = headers || {}; if (includeToken) { appliedHeaders = getApiTokenHeader(appliedHeaders); } + const appliedWithCredentials = getAppliedWithCredentials(withCredentials); return ajax({ url, method: 'POST', - crossDomain: true, responseType: 'json', + crossDomain: true, + withCredentials: appliedWithCredentials, headers: { ...appliedHeaders, 'Content-Type': 'application/json', @@ -138,6 +199,19 @@ const NeonApi = { return getJsonObservable(path); }, + /** + * Gets the product bundles endpoint RxJS Observable. + * @param {string} release An optional release to scope the bundles. + * @return The RxJS Ajax Observable + */ + getProductBundlesObservable: (release = null) => { + const root = NeonEnvironment.getFullApiPath('productBundles'); + const path = isStringNonEmpty(release) + ? `${root}?release=${release}` + : `${root}`; + return getJsonObservable(path); + }, + /** * Gets the prototype data endpoint RxJS Observable. * @return The RxJS Ajax Observable @@ -210,7 +284,12 @@ const NeonApi = { * @return The RxJS Ajax Observable */ getArcgisAssetObservable: (feature, siteCode) => ( - getJsonObservable(`${NeonEnvironment.getFullApiPath('arcgisAssets')}/${feature}/${siteCode}`) + getJsonObservable( + `${NeonEnvironment.getFullApiPath('arcgisAssets')}/${feature}/${siteCode}`, + undefined, + true, + false, + ) ), }; diff --git a/src/lib_components/components/NeonApi/__tests__/NeonApi.js b/src/lib_components/components/NeonApi/__tests__/NeonApi.js index 934cbf22..17e624f2 100644 --- a/src/lib_components/components/NeonApi/__tests__/NeonApi.js +++ b/src/lib_components/components/NeonApi/__tests__/NeonApi.js @@ -4,9 +4,12 @@ import { ajax } from 'rxjs/ajax'; import { getTestableItems } from '../NeonApi'; import NeonEnvironment from '../../NeonEnvironment/NeonEnvironment'; -jest.mock('rxjs/ajax', () => ({ - ajax: jest.fn().mockImplementation((arg1) => arg1), -})); +jest.mock('rxjs/ajax', () => { + const RxAjax = require('rxjs/internal/observable/dom/AjaxObservable'); + return { + ajax: jest.fn().mockImplementation((arg1) => new RxAjax.AjaxObservable(arg1)), + }; +}); ajax.getJSON = jest.fn().mockImplementation((arg1, arg2) => [arg1, arg2]); // Mock some NeonEnvironment functions @@ -15,6 +18,7 @@ jest.mock('../../NeonEnvironment/NeonEnvironment', () => ({ default: { getApiTokenHeader: jest.fn(), getApiToken: jest.fn(), + requireCors: jest.fn(), }, })); @@ -28,12 +32,14 @@ describe('NeonApi', () => { beforeEach(() => { NeonEnvironment.getApiTokenHeader.mockReset(); NeonEnvironment.getApiToken.mockReset(); + NeonEnvironment.requireCors.mockReset(); }); describe('getApiTokenHeader()', () => { test('returns only token headers in object if passed nothing', () => { NeonEnvironment.getApiTokenHeader.mockReturnValue('x-mock-token-header'); NeonEnvironment.getApiToken.mockReturnValue('mockToken123'); + NeonEnvironment.requireCors.mockReturnValue(false); const expectedHeaders = { 'x-mock-token-header': 'mockToken123' }; expect(getApiTokenHeader()).toStrictEqual(expectedHeaders); }); @@ -41,24 +47,29 @@ describe('NeonApi', () => { const headers = { 'x-foo': 'bar' }; NeonEnvironment.getApiTokenHeader.mockReturnValue(null); NeonEnvironment.getApiToken.mockReturnValue(''); + NeonEnvironment.requireCors.mockReturnValue(false); expect(getApiTokenHeader(headers)).toStrictEqual(headers); NeonEnvironment.getApiTokenHeader.mockReturnValue('x-mock-token-header'); NeonEnvironment.getApiToken.mockReturnValue(''); + NeonEnvironment.requireCors.mockReturnValue(false); expect(getApiTokenHeader(headers)).toStrictEqual(headers); NeonEnvironment.getApiTokenHeader.mockReturnValue(''); NeonEnvironment.getApiToken.mockReturnValue('mockToken123'); + NeonEnvironment.requireCors.mockReturnValue(false); expect(getApiTokenHeader(headers)).toStrictEqual(headers); }); test('does not apply token header if already present', () => { const headers = { 'x-foo': 'bar', 'x-mock-token-header': 'otherMockToken123' }; NeonEnvironment.getApiTokenHeader.mockReturnValue('x-mock-token-header'); NeonEnvironment.getApiToken.mockReturnValue('mockToken123'); + NeonEnvironment.requireCors.mockReturnValue(false); expect(getApiTokenHeader(headers)).toStrictEqual(headers); }); test('applies token header if not already present', () => { const headers = { 'x-foo': 'bar' }; NeonEnvironment.getApiTokenHeader.mockReturnValue('x-mock-token-header'); NeonEnvironment.getApiToken.mockReturnValue('mockToken123'); + NeonEnvironment.requireCors.mockReturnValue(false); const expectedHeaders = { 'x-foo': 'bar', 'x-mock-token-header': 'mockToken123', @@ -76,25 +87,6 @@ describe('NeonApi', () => { expect(result.value).toBe(null); }); }); - test('returns ajax.getJSON observable with token if url is valid', () => { - NeonEnvironment.getApiTokenHeader.mockReturnValue('x-mock-token-header'); - NeonEnvironment.getApiToken.mockReturnValue('mockToken123'); - const res = getJsonObservable('test', { 'x-foo': 'bar' }); - expect(res[0]).toBe('test'); - expect(res[1]).toStrictEqual({ - 'x-foo': 'bar', - 'x-mock-token-header': 'mockToken123', - }); - }); - test('returns ajax.getJSON observable without token if url is valid but includeToken is false', () => { - NeonEnvironment.getApiTokenHeader.mockReturnValue('x-mock-token-header'); - NeonEnvironment.getApiToken.mockReturnValue('mockToken123'); - const res = getJsonObservable('test', { 'x-foo': 'bar' }, false); - expect(res[0]).toBe('test'); - expect(res[1]).toStrictEqual({ - 'x-foo': 'bar', - }); - }); }); describe('postJsonObservable()', () => { @@ -106,38 +98,5 @@ describe('NeonApi', () => { expect(result.value).toBe(null); }); }); - test('returns ajax POST observable with token if url is valid', () => { - NeonEnvironment.getApiTokenHeader.mockReturnValue('x-mock-token-header'); - NeonEnvironment.getApiToken.mockReturnValue('mockToken123'); - const res = postJsonObservable('test', { foo: 'bar' }, { 'x-foo': 'bar' }); - expect(res).toStrictEqual({ - url: 'test', - method: 'POST', - crossDomain: true, - responseType: 'json', - headers: { - 'x-foo': 'bar', - 'x-mock-token-header': 'mockToken123', - 'Content-Type': 'application/json', - }, - body: '{"foo":"bar"}', - }); - }); - test('returns ajax POST observable without token if url is valid but includeToken is false', () => { - NeonEnvironment.getApiTokenHeader.mockReturnValue('x-mock-token-header'); - NeonEnvironment.getApiToken.mockReturnValue('mockToken123'); - const res = postJsonObservable('test', { foo: 'bar' }, { 'x-foo': 'bar' }, false); - expect(res).toStrictEqual({ - url: 'test', - method: 'POST', - crossDomain: true, - responseType: 'json', - headers: { - 'x-foo': 'bar', - 'Content-Type': 'application/json', - }, - body: '{"foo":"bar"}', - }); - }); }); }); diff --git a/src/lib_components/components/NeonAuth/AuthService.ts b/src/lib_components/components/NeonAuth/AuthService.ts index 88f98f8c..8fbc7c15 100644 --- a/src/lib_components/components/NeonAuth/AuthService.ts +++ b/src/lib_components/components/NeonAuth/AuthService.ts @@ -309,7 +309,8 @@ const AuthService: IAuthService = { const appliedRedirectUri = exists(redirectUriPath) ? redirectUriPath : env.route.getFullRoute(env.getRouterBaseHomePath()); - const href = `${rootPath}?${REDIRECT_URI}=${appliedRedirectUri}`; + const redirectUrl = `${window.location.protocol}//${window.location.host}${appliedRedirectUri}`; + const href = `${rootPath}?${REDIRECT_URI}=${encodeURIComponent(redirectUrl)}`; window.location.href = href; }, loginSilently: ( @@ -354,6 +355,8 @@ const AuthService: IAuthService = { AuthService.cancelWorkingResolver(); }, state.loginCancellationSubject$, + undefined, + true, ); }, logout: (path?: string, redirectUriPath?: string): void => { @@ -362,9 +365,10 @@ const AuthService: IAuthService = { ? (path as string) : env.getFullAuthPath('logout'); const appliedRedirectUri = exists(redirectUriPath) - ? `${env.getApiHost()}${redirectUriPath}` - : `${env.getApiHost()}${env.route.getFullRoute(env.getRouterBaseHomePath())}`; - const href = `${rootPath}?${REDIRECT_URI}=${appliedRedirectUri}`; + ? redirectUriPath + : env.route.getFullRoute(env.getRouterBaseHomePath()); + const redirectUrl = `${window.location.protocol}//${window.location.host}${appliedRedirectUri}`; + const href = `${rootPath}?${REDIRECT_URI}=${encodeURIComponent(redirectUrl)}`; window.location.href = href; }, logoutSilently: ( @@ -405,6 +409,8 @@ const AuthService: IAuthService = { AuthService.cancelWorkingResolver(); }, state.logoutCancellationSubject$, + undefined, + true, ); }, cancellationEmitter: (): void => { @@ -412,7 +418,14 @@ const AuthService: IAuthService = { state.cancellationSubject$.unsubscribe(); }, fetchUserInfo: (cb: AnyVoidFunc, errorCb: AnyVoidFunc): Subscription => ( - getJson(NeonEnvironment.getFullAuthPath('userInfo'), cb, errorCb, state.cancellationSubject$) + getJson( + NeonEnvironment.getFullAuthPath('userInfo'), + cb, + errorCb, + state.cancellationSubject$, + undefined, + true, + ) ), fetchUserInfoWithDispatch: ( dispatch: Dispatch, diff --git a/src/lib_components/components/NeonContext/NeonContext.jsx b/src/lib_components/components/NeonContext/NeonContext.jsx index b566e49a..94a89533 100644 --- a/src/lib_components/components/NeonContext/NeonContext.jsx +++ b/src/lib_components/components/NeonContext/NeonContext.jsx @@ -14,12 +14,14 @@ import { ajax } from 'rxjs/ajax'; import REMOTE_ASSETS from '../../remoteAssetsMap/remoteAssetsMap'; import AuthService from '../NeonAuth/AuthService'; +import NeonApi from '../NeonApi/NeonApi'; import NeonGraphQL from '../NeonGraphQL/NeonGraphQL'; import sitesJSON from '../../staticJSON/sites.json'; import statesJSON from '../../staticJSON/states.json'; import domainsJSON from '../../staticJSON/domains.json'; -import bundlesJSON from '../../staticJSON/bundles.json'; import timeSeriesDataProductsJSON from '../../staticJSON/timeSeriesDataProducts.json'; +import BundleParser from '../../parser/BundleParser'; +import { existsNonEmpty } from '../../util/typeUtil'; const DRUPAL_HEADER_HTML = REMOTE_ASSETS.DRUPAL_HEADER_HTML.KEY; const DRUPAL_FOOTER_HTML = REMOTE_ASSETS.DRUPAL_FOOTER_HTML.KEY; @@ -34,9 +36,17 @@ export const FETCH_STATUS = { const DEFAULT_STATE = { data: { sites: {}, + // See for details: interface BundleContext + bundles: { + bundleProducts: {}, + bundleProductsForwardAvailability: {}, + bundleDoiLookup: {}, + splitProducts: {}, + allBundleProducts: {}, + apiResponse: [], + }, states: statesJSON, domains: domainsJSON, - bundles: bundlesJSON, timeSeriesDataProducts: timeSeriesDataProductsJSON, stateSites: {}, // derived when sites is fetched domainSites: {}, // derived when sites is fetched @@ -47,6 +57,7 @@ const DEFAULT_STATE = { }, fetches: { sites: { status: FETCH_STATUS.AWAITING_CALL, error: null }, + bundles: { status: FETCH_STATUS.AWAITING_CALL, error: null }, auth: { status: null, error: null }, [DRUPAL_HEADER_HTML]: { status: null, error: null }, [DRUPAL_FOOTER_HTML]: { status: null, error: null }, @@ -109,6 +120,17 @@ const useNeonContextState = () => { return hookResponse; }; +const determineContextFetchFinal = (state) => { + const authFinal = !state.auth.useCore + || ((state.fetches.auth.status === FETCH_STATUS.SUCCESS) + || (state.fetches.auth.status === FETCH_STATUS.ERROR)); + const sitesFinal = (state.fetches.sites.status === FETCH_STATUS.SUCCESS) + || (state.fetches.sites.status === FETCH_STATUS.ERROR); + const bundlesFinal = (state.fetches.bundles.status === FETCH_STATUS.SUCCESS) + || (state.fetches.bundles.status === FETCH_STATUS.ERROR); + return authFinal && sitesFinal && bundlesFinal; +}; + /** Reducer */ @@ -127,16 +149,25 @@ const reducer = (state, action) => { case 'fetchSitesSucceeded': newState.fetches.sites.status = FETCH_STATUS.SUCCESS; newState.data.sites = action.sites; - newState.isFinal = !newState.auth.useCore - || ((newState.fetches.auth.status === FETCH_STATUS.SUCCESS) - || (newState.fetches.auth.status === FETCH_STATUS.ERROR)); + newState.isFinal = determineContextFetchFinal(newState); return deriveRegionSites(newState); case 'fetchSitesFailed': newState.fetches.sites.status = FETCH_STATUS.ERROR; newState.fetches.sites.error = action.error; - newState.isFinal = !newState.auth.useCore - || ((newState.fetches.auth.status === FETCH_STATUS.SUCCESS) - || (newState.fetches.auth.status === FETCH_STATUS.ERROR)); + newState.isFinal = determineContextFetchFinal(newState); + newState.hasError = true; + return newState; + + // Actions for handling bundles fetch + case 'fetchBundlesSucceeded': + newState.fetches.bundles.status = FETCH_STATUS.SUCCESS; + newState.data.bundles = action.bundles; + newState.isFinal = determineContextFetchFinal(newState); + return deriveRegionSites(newState); + case 'fetchBundlesFailed': + newState.fetches.bundles.status = FETCH_STATUS.ERROR; + newState.fetches.bundles.error = action.error; + newState.isFinal = determineContextFetchFinal(newState); newState.hasError = true; return newState; @@ -154,16 +185,14 @@ const reducer = (state, action) => { newState.fetches.auth.status = FETCH_STATUS.SUCCESS; newState.auth.isAuthenticated = !!action.isAuthenticated; newState.auth.userData = AuthService.parseUserData(action.response); - newState.isFinal = (newState.fetches.sites.status === FETCH_STATUS.SUCCESS) - || (newState.fetches.sites.status === FETCH_STATUS.ERROR); + newState.isFinal = determineContextFetchFinal(newState); return newState; case 'fetchAuthFailed': newState.fetches.auth.status = FETCH_STATUS.ERROR; newState.fetches.auth.error = action.error; newState.auth.isAuthenticated = false; newState.auth.userData = null; - newState.isFinal = (newState.fetches.sites.status === FETCH_STATUS.SUCCESS) - || (newState.fetches.sites.status === FETCH_STATUS.ERROR); + newState.isFinal = determineContextFetchFinal(newState); return newState; // Actions for handling remote assets @@ -286,6 +315,24 @@ const Provider = (props) => { }), ).subscribe(); }, + bundles: () => { + NeonApi.getProductBundlesObservable().pipe( + map((response) => { + const bundles = BundleParser.parseBundlesResponse(response); + if (!existsNonEmpty(bundles)) { + dispatch({ type: 'fetchBundlesFailed', error: 'malformed response' }); + return of(false); + } + const context = BundleParser.parseContext(bundles); + dispatch({ type: 'fetchBundlesSucceeded', bundles: context }); + return of(true); + }), + catchError((error) => { + dispatch({ type: 'fetchBundlesFailed', error: error.message }); + return of(false); + }), + ).subscribe(); + }, auth: () => { AuthService.fetchUserInfo( (response) => { diff --git a/src/lib_components/components/NeonContext/StyleGuide.jsx b/src/lib_components/components/NeonContext/StyleGuide.jsx index 466c9222..da38bfb8 100644 --- a/src/lib_components/components/NeonContext/StyleGuide.jsx +++ b/src/lib_components/components/NeonContext/StyleGuide.jsx @@ -44,7 +44,9 @@ const NeonContextStateComponent = () => {
Domains
{JSON.stringify(domains, null, 2)}
Bundles
-
{JSON.stringify(bundles, null, 2)}
+
+        {fetches.bundles.status === 'SUCCESS' ? JSON.stringify(bundles, null, 2) : fetches.bundles.status}
+      
); }; @@ -222,7 +224,9 @@ const NeonContextStateComponent = () => {
Domains
{JSON.stringify(domains, null, 2)}
Bundles
-
{JSON.stringify(bundles, null, 2)}
+
+        {fetches.bundles.status === 'SUCCESS' ? JSON.stringify(bundles, null, 2) : fetches.bundles.status}
+      
); }; diff --git a/src/lib_components/components/NeonContext/__tests__/NeonContext.jsx b/src/lib_components/components/NeonContext/__tests__/NeonContext.jsx index 39aec60a..89b7aeb5 100644 --- a/src/lib_components/components/NeonContext/__tests__/NeonContext.jsx +++ b/src/lib_components/components/NeonContext/__tests__/NeonContext.jsx @@ -138,7 +138,7 @@ describe('NeonContext', () => { ); expect(newState.fetches.sites.status).toBe(FETCH_STATUS.SUCCESS); expect(newState.data.sites).toStrictEqual(sites); - expect(newState.isFinal).toBe(true); + expect(newState.isFinal).toBe(false); expect(newState.hasError).toBe(false); expect(newState.data.stateSites.WA).toStrictEqual(new Set(['SITE_A'])); expect(newState.data.domainSites).toStrictEqual({ D16: new Set(['SITE_A']) }); @@ -151,7 +151,7 @@ describe('NeonContext', () => { expect(newState.fetches.sites.status).toBe(FETCH_STATUS.ERROR); expect(newState.fetches.sites.error).toBe('BAD'); expect(newState.data.sites).toStrictEqual({}); - expect(newState.isFinal).toBe(true); + expect(newState.isFinal).toBe(false); expect(newState.hasError).toBe(true); }); test('fetchHtmlSucceeded', () => { diff --git a/src/lib_components/components/NeonEnvironment/NeonEnvironment.ts b/src/lib_components/components/NeonEnvironment/NeonEnvironment.ts index f9ffda5a..9bbe930b 100644 --- a/src/lib_components/components/NeonEnvironment/NeonEnvironment.ts +++ b/src/lib_components/components/NeonEnvironment/NeonEnvironment.ts @@ -133,6 +133,8 @@ export interface INeonEnvironment { getFullDownloadApiPath: (path: string) => string; getFullAuthPath: (path: string) => string; + + requireCors: () => boolean; } const NeonEnvironment: INeonEnvironment = { @@ -156,6 +158,7 @@ const NeonEnvironment: INeonEnvironment = { prototype: (): string => '/prototype', documents: (): string => '/documents', products: (): string => '/products', + productBundles: (): string => '/products/bundles', releases: (): string => '/releases', sites: (): string => '/sites', locations: (): string => '/locations', @@ -533,6 +536,17 @@ const NeonEnvironment: INeonEnvironment = { const host = NeonEnvironment.getApiHost(); return `${host}${NeonEnvironment.getRootGraphqlPath()}`; }, + + /** + * Indicates when a CORS request is required + * @returns + */ + requireCors: (): boolean => { + if (window.location.host.includes('localhost')) { + return false; + } + return !NeonEnvironment.isApiHostValid(window.location.host); + }, }; Object.freeze(NeonEnvironment); diff --git a/src/lib_components/components/NeonGraphQL/NeonGraphQL.js b/src/lib_components/components/NeonGraphQL/NeonGraphQL.js index f6031098..76a981c0 100644 --- a/src/lib_components/components/NeonGraphQL/NeonGraphQL.js +++ b/src/lib_components/components/NeonGraphQL/NeonGraphQL.js @@ -3,7 +3,7 @@ import { ajax } from 'rxjs/ajax'; import NeonEnvironment from '../NeonEnvironment/NeonEnvironment'; import NeonApi from '../NeonApi/NeonApi'; -import { isStringNonEmpty } from '../../util/typeUtil'; +import { exists, isStringNonEmpty } from '../../util/typeUtil'; export const TYPES = { DATA_PRODUCTS: 'DATA_PRODUCTS', @@ -185,10 +185,17 @@ const getQueryBody = (type = '', dimensionality = '', args = {}) => { return transformQuery(query); }; -const getAjaxRequest = (body, includeToken = true) => { +const getAjaxRequest = (body, includeToken = true, withCredentials = undefined) => { + let appliedWithCredentials = false; + if (!exists(withCredentials) || (typeof withCredentials !== 'boolean')) { + appliedWithCredentials = NeonEnvironment.requireCors(); + } else { + appliedWithCredentials = withCredentials; + } const request = { method: 'POST', crossDomain: true, + withCredentials: appliedWithCredentials, url: NeonEnvironment.getFullGraphqlPath(), headers: { 'Content-Type': 'application/json' }, responseType: 'json', diff --git a/src/lib_components/components/NeonPage/NeonPage.jsx b/src/lib_components/components/NeonPage/NeonPage.jsx index 5d759dcf..af3fdf1b 100644 --- a/src/lib_components/components/NeonPage/NeonPage.jsx +++ b/src/lib_components/components/NeonPage/NeonPage.jsx @@ -590,6 +590,8 @@ const NeonPage = (props) => { handleFetchNotificationsSuccess, handleFetchNotificationsError, cancellationSubject$, + undefined, + true, ); }, [fetchNotificationsStatus]); // eslint-disable-line react-hooks/exhaustive-deps diff --git a/src/lib_components/components/SiteMap/__tests__/FetchLocationUtils.js b/src/lib_components/components/SiteMap/__tests__/FetchLocationUtils.js index 41e6198e..d4cce433 100644 --- a/src/lib_components/components/SiteMap/__tests__/FetchLocationUtils.js +++ b/src/lib_components/components/SiteMap/__tests__/FetchLocationUtils.js @@ -1,5 +1,6 @@ import { mockAjaxResponse, + mockAjaxResponseWrapper, mockAjaxError, } from '../../../../__mocks__/ajax'; @@ -52,11 +53,11 @@ describe('SiteMap - FetchLocationUtils', () => { return expect(fetchDomainHierarchy('D01')).rejects.toBeInstanceOf(Error); }); test('rejects if response from API is not an object with a data attribute', () => { - mockAjaxResponse('response'); + mockAjaxResponseWrapper('response'); return expect(fetchDomainHierarchy('D01')).rejects.toBeInstanceOf(Error); }); test('resolves by passing the fetched response to parseDomainHierarchy', () => { - mockAjaxResponse({ data: 'foo' }); + mockAjaxResponseWrapper({ data: 'foo' }); parseDomainHierarchy.mockImplementation((input) => `parsed: ${input}`); return expect(fetchDomainHierarchy('D01')).resolves.toBe('parsed: foo'); }); @@ -74,11 +75,11 @@ describe('SiteMap - FetchLocationUtils', () => { return expect(fetchSingleLocationREST('GUAN')).rejects.toBeInstanceOf(Error); }); test('rejects if response from API is not an object with a data attribute', () => { - mockAjaxResponse('response'); + mockAjaxResponseWrapper('response'); return expect(fetchSingleLocationREST('GUAN')).rejects.toBeInstanceOf(Error); }); test('resolves by passing the fetched response to parseLocationsArray', () => { - mockAjaxResponse({ data: 'foo' }); + mockAjaxResponseWrapper({ data: 'foo' }); parseLocationsArray.mockImplementation( (input) => ({ GUAN: `parsed: ${input[0]}` }), ); diff --git a/src/lib_components/components/TimeSeriesViewer/StyleGuide.jsx b/src/lib_components/components/TimeSeriesViewer/StyleGuide.jsx index a9893e72..ca153d51 100644 --- a/src/lib_components/components/TimeSeriesViewer/StyleGuide.jsx +++ b/src/lib_components/components/TimeSeriesViewer/StyleGuide.jsx @@ -23,6 +23,8 @@ import NeonGraphQL from '../NeonGraphQL/NeonGraphQL'; import NeonContext from '../NeonContext/NeonContext'; import Theme from '../Theme/Theme'; +import BundleService from '../../service/BundleService'; + import TimeSeriesViewerContext from './TimeSeriesViewerContext'; import TimeSeriesViewer from './TimeSeriesViewer'; import TimeSeriesViewerContainer from './TimeSeriesViewerContainer'; @@ -88,8 +90,7 @@ const AllProductsTimeSeries = () => { product.siteCodes && product.siteCodes.length && productIsIS(product) - && !Object.keys(bundles.parents).includes(product.productCode) - && !Object.keys(bundles.children).includes(product.productCode) + && !BundleService.isProductDefined(bundles, product.productCode) )) .map((product) => ({ productCode: product.productCode, diff --git a/src/lib_components/components/TimeSeriesViewer/TimeSeriesViewerContext.jsx b/src/lib_components/components/TimeSeriesViewer/TimeSeriesViewerContext.jsx index b3a78060..7919d83a 100644 --- a/src/lib_components/components/TimeSeriesViewer/TimeSeriesViewerContext.jsx +++ b/src/lib_components/components/TimeSeriesViewer/TimeSeriesViewerContext.jsx @@ -1051,6 +1051,7 @@ const reducer = (state, action) => { case 'selectNoneQualityFlags': newState.selection.isDefault = false; newState.selection.qualityFlags = []; + calcStatus(); return newState; case 'selectToggleQualityFlag': newState.selection.isDefault = false; @@ -1320,35 +1321,36 @@ const Provider = (props) => { if (!state.product.sites[siteCode].availableMonths.includes(month)) { return; } metaFetchTriggered = true; dispatch({ type: 'fetchSiteMonth', siteCode, month }); - ajax.getJSON(getSiteMonthDataURL(siteCode, month), NeonApi.getApiTokenHeader()).pipe( - map((response) => { - if (response && response.data && response.data.files) { + NeonApi.getJsonObservable(getSiteMonthDataURL(siteCode, month), NeonApi.getApiTokenHeader()) + .pipe( + map((response) => { + if (response && response.data && response.data.files) { + dispatch({ + type: 'fetchSiteMonthSucceeded', + files: response.data.files, + siteCode, + month, + }); + return of(true); + } dispatch({ - type: 'fetchSiteMonthSucceeded', - files: response.data.files, + type: 'fetchSiteMonthFailed', + error: 'malformed response', siteCode, month, }); - return of(true); - } - dispatch({ - type: 'fetchSiteMonthFailed', - error: 'malformed response', - siteCode, - month, - }); - return of(false); - }), - catchError((error) => { - dispatch({ - type: 'fetchSiteMonthFailed', - error: error.message, - siteCode, - month, - }); - return of(false); - }), - ).subscribe(); + return of(false); + }), + catchError((error) => { + dispatch({ + type: 'fetchSiteMonthFailed', + error: error.message, + siteCode, + month, + }); + return of(false); + }), + ).subscribe(); }); }; diff --git a/src/lib_components/parser/BundleParser.ts b/src/lib_components/parser/BundleParser.ts new file mode 100644 index 00000000..5dc3bda5 --- /dev/null +++ b/src/lib_components/parser/BundleParser.ts @@ -0,0 +1,99 @@ +import { AjaxResponse } from 'rxjs/ajax'; + +import { BundledDataProduct, DataProductBundle, ReleaseDataProductBundles } from '../types/neonApi'; +import { BundleContext } from '../types/neonContext'; +import { exists, existsNonEmpty, resolveAny } from '../util/typeUtil'; + +export interface IBundleParser { + /** + * Parse the NEON API response to typed internal interface. + * @param response The AJAX response to parse from. + * @return The types internal representation of the API response shape. + */ + parseBundlesResponse: (response: AjaxResponse) => ReleaseDataProductBundles[]; + /** + * Parse the NEON API response shape to the context specific shape + * with helper lookups. + * @param bundles The NEON API bundle response shape. + * @return The context shape for storing bundle information. + */ + parseContext: (bundles: ReleaseDataProductBundles[]) => BundleContext; +} + +const BundleParser: IBundleParser = { + parseBundlesResponse: (response: AjaxResponse): ReleaseDataProductBundles[] => { + if (!exists(response)) { + return []; + } + const data: unknown = resolveAny(response as never, 'data') as unknown; + if (!Array.isArray(data)) { + return []; + } + return data as ReleaseDataProductBundles[]; + }, + parseContext: (bundlesResponse: ReleaseDataProductBundles[]): BundleContext => { + const bundles: BundleContext = { + bundleProducts: {}, + bundleProductsForwardAvailability: {}, + bundleDoiLookup: {}, + splitProducts: {}, + allBundleProducts: {}, + apiResponse: [], + }; + if (!existsNonEmpty(bundlesResponse)) { + return bundles; + } + bundles.apiResponse = bundlesResponse; + bundles.apiResponse.forEach((releaseBundles: ReleaseDataProductBundles): void => { + const bundleProducts: Record = {}; + const bundleProductForwardAvailability: Record = {}; + const doiLookup: Record = {}; + const splitLookup: Record = {}; + const { release, dataProductBundles } = releaseBundles; + dataProductBundles.forEach((bundle: DataProductBundle): void => { + const { + productCode: bundleProductCode, + forwardAvailability, + bundledProducts, + } = bundle; + bundles.allBundleProducts[bundleProductCode] = true; + if (forwardAvailability) { + bundleProductForwardAvailability[bundleProductCode] = true; + } + bundleProducts[bundleProductCode] = []; + bundledProducts.forEach((bundledProduct: BundledDataProduct): void => { + const { productCode, isPrimaryBundle } = bundledProduct; + bundles.allBundleProducts[productCode] = true; + bundleProducts[bundleProductCode].push(productCode); + if (!exists(isPrimaryBundle)) { + doiLookup[productCode] = bundleProductCode; + } else if (isPrimaryBundle === true) { + // Type check guard for positive boolean value, not non falsey + doiLookup[productCode] = bundleProductCode; + } + // Indicate we've seen this product in more than one bundle + // Must contain a previous entry that's set to false. + if (Array.isArray(splitLookup[productCode])) { + splitLookup[productCode].push(bundleProductCode); + } else { + splitLookup[productCode] = [bundleProductCode]; + } + }); + }); + bundles.bundleProducts[release] = bundleProducts; + bundles.bundleProductsForwardAvailability[release] = bundleProductForwardAvailability; + bundles.bundleDoiLookup[release] = doiLookup; + bundles.splitProducts[release] = {}; + Object.keys(splitLookup).forEach((key: string): void => { + if (Array.isArray(splitLookup[key]) && (splitLookup[key].length > 1)) { + bundles.splitProducts[release][key] = splitLookup[key]; + } + }); + }); + return bundles; + }, +}; + +Object.freeze(BundleParser); + +export default BundleParser; diff --git a/src/lib_components/remoteAssets/drupal-footer.html.js b/src/lib_components/remoteAssets/drupal-footer.html.js index fac77d55..08aa65e5 100644 --- a/src/lib_components/remoteAssets/drupal-footer.html.js +++ b/src/lib_components/remoteAssets/drupal-footer.html.js @@ -14,6 +14,7 @@ export default html = `
  • +
  • @@ -26,7 +27,7 @@ export default html = `

    Get updates on events, opportunities, and how NEON is being used today.

    diff --git a/src/lib_components/remoteAssets/drupal-theme.css b/src/lib_components/remoteAssets/drupal-theme.css index 0d220893..8fb4344d 100644 --- a/src/lib_components/remoteAssets/drupal-theme.css +++ b/src/lib_components/remoteAssets/drupal-theme.css @@ -948,12 +948,76 @@ background-color: #0073CF; color: white; } #header .select2-selection.select2-selection--multiple .select2-selection__choice .select2-selection__choice__remove, #footer .select2-selection.select2-selection--multiple .select2-selection__choice .select2-selection__choice__remove { - padding: 0.15rem; } - #header button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close), + padding: 0.1875rem; } + #header button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close), #header button.button.form-submit.ui-button, - #header input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res), #header ::-webkit-file-upload-button, #footer button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close), + #header input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res), + #header ::-webkit-file-upload-button, #footer button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close), #footer button.button.form-submit.ui-button, - #footer input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res), #footer ::-webkit-file-upload-button { + #footer input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res), + #footer ::-webkit-file-upload-button { color: #fff; background: #0073CF; border: 1px solid #0073CF; @@ -966,33 +1030,289 @@ padding: 0.75rem 1.125rem; -webkit-appearance: none; transition: all 0.25s; } - #header button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close):hover, + #header button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close):hover, #header button.button.form-submit.ui-button:hover, - #header input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res):hover, #header ::-webkit-file-upload-button:hover, #footer button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close):hover, + #header input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res):hover, + #header ::-webkit-file-upload-button:hover, #footer button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close):hover, #footer button.button.form-submit.ui-button:hover, - #footer input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res):hover, #footer ::-webkit-file-upload-button:hover { + #footer input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res):hover, + #footer ::-webkit-file-upload-button:hover { transition: all 0.25s; background: #0092E2; border: 1px solid #0092E2; box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25), 0px 1px 1px rgba(0, 0, 0, 0.25); } - #header button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close):active, #header button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close):focus, + #header button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close):active, #header button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close):focus, #header button.button.form-submit.ui-button:active, #header button.button.form-submit.ui-button:focus, - #header input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res):active, - #header input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res):focus, #header ::-webkit-file-upload-button:active, #header ::-webkit-file-upload-button:focus, #footer button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close):active, #footer button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close):focus, + #header input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res):active, + #header input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res):focus, + #header ::-webkit-file-upload-button:active, + #header ::-webkit-file-upload-button:focus, #footer button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close):active, #footer button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close):focus, #footer button.button.form-submit.ui-button:active, #footer button.button.form-submit.ui-button:focus, - #footer input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res):active, - #footer input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res):focus, #footer ::-webkit-file-upload-button:active, #footer ::-webkit-file-upload-button:focus { + #footer input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res):active, + #footer input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res):focus, + #footer ::-webkit-file-upload-button:active, + #footer ::-webkit-file-upload-button:focus { transition: all 0.25s; background: #0092E2; box-shadow: 0px 0px 0px 4px #C4C4C4; border: 1px solid #0073CF; } - #header button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close):disabled, + #header button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close):disabled, #header button.button.form-submit.ui-button:disabled, - #header input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res):disabled, #header ::-webkit-file-upload-button:disabled, #footer button:not(.paragraphs-dropdown-togagle):not(.erl-edit):not(.ui-button):not(.button--danger):not(.erl-add-content__toggle):not(.trigger):not(.toolbar-item):not(.toolbar-icon):not(.MuiButtonBase-root):not(.MuiLink-root):not(.filter--trigger):not(.filter--trigger-secondary):not(.filter--trigger-tutorials):not(.paragraphs-dropdown-toggle):not(.isDesktop):not(.subNavClose):not(.focusable):not(.mini-arrow):not(.button--search):not(.button__search-close):disabled, + #header input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res):disabled, + #header ::-webkit-file-upload-button:disabled, #footer button:not(.paragraphs-dropdown-togagle) +:not(.erl-edit) +:not(.ui-button) +:not(.button--danger) +:not(.layout-paragraphs-add-content__toggle) +:not(.layout-handle) +:not(.layout-up) +:not(.layout-down) +:not(.layout-handle) +:not(.trigger) +:not(.toolbar-item) +:not(.toolbar-icon) +:not(.MuiButtonBase-root) +:not(.MuiLink-root) +:not(.filter--trigger) +:not(.filter--trigger-secondary) +:not(.filter--trigger-tutorials) +:not(.paragraphs-dropdown-toggle) +:not(.isDesktop) +:not(.subNavClose) +:not(.focusable) +:not(.mini-arrow) +:not(.button--search) +:not(.button__search-close):disabled, #footer button.button.form-submit.ui-button:disabled, - #footer input[type="submit"]:not(.paragraphs-dropdown-action):not(.erl-edit):not(.erl-remove):not(#edit-submit-staff):not(#edit-submit-blog):not(.button--search):not(.search-form__button):not(#edit-submit-flight-res):disabled, #footer ::-webkit-file-upload-button:disabled { + #footer input[type="submit"] +:not(.paragraphs-dropdown-action) +:not(.erl-edit) +:not(.erl-remove) +:not(#edit-submit-staff) +:not(#edit-submit-blog) +:not(.button--search) +:not(.search-form__button) +:not(#edit-submit-flight-res):disabled, + #footer ::-webkit-file-upload-button:disabled { background: #D7D9D9; color: #A2A4A3; } #header input[type="radio"]:not(.rlglc-input):not(:checked), #header input[type="radio"]:not(.rlglc-input):checked, #footer input[type="radio"]:not(.rlglc-input):not(:checked), #footer input[type="radio"]:not(.rlglc-input):checked { @@ -2737,7 +3057,7 @@ position: fixed; top: 0; width: 100%; - z-index: 2000; + z-index: 100; box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25), 0px 1px 1px rgba(0, 0, 0, 0.25); } #header .header__inner, #footer .header__inner { height: 120px; diff --git a/src/lib_components/service/BundleService.ts b/src/lib_components/service/BundleService.ts new file mode 100644 index 00000000..2d8c8084 --- /dev/null +++ b/src/lib_components/service/BundleService.ts @@ -0,0 +1,264 @@ +import { Nullable, Undef } from '../types/core'; +import { BundleContext } from '../types/neonContext'; +import { exists, isStringNonEmpty } from '../util/typeUtil'; + +const LATEST_AND_PROVISIONAL = 'LATEST_AND_PROVISIONAL'; + +const getProvReleaseRegex = (): RegExp => new RegExp(/^[A-Z]+$/); + +export interface IBundleService { + /** + * Determines if the product is defined as a container or child within a bundle. + * @param context The context to derive lookups from. + * @param productCode The product code to search for. + * @return True if the product is defined within bundles. + */ + isProductDefined: (context: BundleContext, productCode: string) => boolean; + /** + * Determine the currently active bundle based on release. + * @param release The release to coerce. + * @return The applicable bundle release. + */ + determineBundleRelease: (release: string) => string; + /** + * Gets the set of bundled (container) product codes for the specified release. + * @param context The context to derive lookups from. + * @param release The release to get the bundles for. + */ + getBundledProductCodes: (context: BundleContext, release: string) => string[]; + /** + * Determine if the product is in a bundle for the specified release. + * @param context The context to derive lookups from. + * @param release The release to get the bundles for. + * @param productCode The product code to query with. + * @return True if the product is in a bundle. + */ + isProductInBundle: ( + context: BundleContext, + release: string, + productCode: string, + ) => boolean; + /** + * Determines if the product is a bundled product for the specified release. + * @param context The context to derive lookups from. + * @param release The release to get bundles for. + * @param productCode The product code to query with. + * @return True if the product is in a bundle. + */ + isBundledProduct: ( + context: BundleContext, + release: string, + productCode: string, + ) => boolean; + /** + * Determines if the product is a split product for the specified release. + * @param context The context to derive lookups from. + * @param release The release to get bundles for. + * @param productCode The product code to query with. + * @return True if the product is a split product. + */ + isSplitProduct: ( + context: BundleContext, + release: string, + productCode: string, + ) => boolean; + /** + * Gets the bundle (container) product code for the specified bundled product. + * @param context The context to derive lookups from. + * @param release The release to get bundles for. + * @param productCode The product code to query with. + * @return The bundle product code when available. + */ + getBundleProductCode: ( + context: BundleContext, + release: string, + productCode: string, + ) => Nullable; + /** + * Determines if the product should forward availability for the bundle. + * @param context The context to derive lookups from. + * @param release The release to get bundles for. + * @param productCode The product code to query with. + * @param bundleProductCode The bundle product code to query with. + * @return The bundle product code when available. + */ + shouldForwardAvailability: ( + context: BundleContext, + release: string, + productCode: string, + bundleProductCode: string, + ) => boolean; + /** + * Gets the owning split bundle product code. + * @param context The context to derive lookups from. + * @param release The release to get bundles for. + * @param productCode The product code to query with. + * @return The bundle product code when available. + */ + getSplitProductBundles: ( + context: BundleContext, + release: string, + productCode: string, + ) => string[]; + /** + * Gets the set of bundled product codes for the specified bundle product. + * @param context The context to derive lookups from. + * @param release The release to get bundles for. + * @param bundleProductCode The bundle product code to query with. + * @return The bundle product code when available. + */ + getBundledProducts: ( + context: BundleContext, + release: string, + bundleProductCode: string, + ) => string[]; +} + +const BundleService: IBundleService = { + isProductDefined: (context: BundleContext, productCode: string): boolean => ( + context.allBundleProducts[productCode] === true + ), + determineBundleRelease: (release: string): string => { + const regex = getProvReleaseRegex(); + let isLatestProv = false; + if (!isStringNonEmpty(release) || (release.localeCompare(LATEST_AND_PROVISIONAL) === 0)) { + isLatestProv = true; + } else if (regex) { + const matches = regex.exec(release); + isLatestProv = exists(matches) && ((matches as RegExpExecArray).length > 0); + } + let appliedRelease = release; + if (isLatestProv) { + appliedRelease = 'PROVISIONAL'; + } + return appliedRelease; + }, + getBundledProductCodes: (context: BundleContext, release: string): string[] => { + if (!exists(context) + || !exists(context.bundleProducts) + || !exists(context.bundleProducts[release])) { + return []; + } + return Object.keys(context.bundleProducts[release]); + }, + isProductInBundle: ( + context: BundleContext, + release: string, + productCode: string, + ): boolean => { + if (!exists(context) + || !exists(context.bundleDoiLookup) + || !exists(context.bundleDoiLookup[release])) { + return false; + } + return isStringNonEmpty(context.bundleDoiLookup[release][productCode]); + }, + isBundledProduct: ( + context: BundleContext, + release: string, + productCode: string, + ): boolean => ( + BundleService.getBundledProductCodes(context, release).includes(productCode) + ), + isSplitProduct: ( + context: BundleContext, + release: string, + productCode: string, + ): boolean => { + if (!exists(context) + || !exists(context.splitProducts) + || !exists(context.splitProducts[release])) { + return false; + } + return Array.isArray(context.splitProducts[release][productCode]); + }, + getBundleProductCode: ( + context: BundleContext, + release: string, + productCode: string, + ): Nullable => { + if (!exists(context) + || !exists(context.bundleDoiLookup) + || !exists(context.bundleDoiLookup[release])) { + return null; + } + const bundledProductCode = context.bundleDoiLookup[release][productCode]; + if (!isStringNonEmpty(bundledProductCode)) { + return null; + } + return bundledProductCode; + }, + shouldForwardAvailability: ( + context: BundleContext, + release: string, + productCode: string, + bundleProductCode: string, + ): boolean => { + const isSplit = BundleService.isSplitProduct(context, release, productCode); + if (isSplit) { + if (!exists(context) + || !exists(context.splitProducts) + || !exists(context.splitProducts[release]) + || !exists(context.bundleProductsForwardAvailability) + || !exists(context.bundleProductsForwardAvailability[release])) { + return false; + } + return context.splitProducts[release][productCode].every( + (splitToProduct: string): boolean => ( + context.bundleProductsForwardAvailability[release][splitToProduct] + ), + ); + } + if (!exists(context) + || !exists(context.bundleProductsForwardAvailability) + || !exists(context.bundleProductsForwardAvailability[release])) { + return false; + } + const bundleShouldForward: Undef = context + .bundleProductsForwardAvailability[release][bundleProductCode]; + if (!exists(bundleShouldForward)) { + return false; + } + return bundleShouldForward; + }, + getSplitProductBundles: ( + context: BundleContext, + release: string, + productCode: string, + ): string[] => { + const isSplit = BundleService.isSplitProduct(context, release, productCode); + if (!isSplit) { + return []; + } + if (!exists(context) + || !exists(context.splitProducts) + || !exists(context.splitProducts[release])) { + return []; + } + const bundles: Undef = context.splitProducts[release][productCode]; + if (!Array.isArray(bundles)) { + return []; + } + return bundles; + }, + getBundledProducts: ( + context: BundleContext, + release: string, + bundleProductCode: string, + ): string[] => { + if (!exists(context) + || !exists(context.bundleProducts) + || !exists(context.bundleProducts[release])) { + return []; + } + const bundle: Undef = context.bundleProducts[release][bundleProductCode]; + if (!Array.isArray(bundle)) { + return []; + } + return bundle; + }, +}; + +Object.freeze(BundleService); + +export default BundleService; diff --git a/src/lib_components/service/RouteService.ts b/src/lib_components/service/RouteService.ts index d4354b83..0aad3860 100644 --- a/src/lib_components/service/RouteService.ts +++ b/src/lib_components/service/RouteService.ts @@ -216,18 +216,19 @@ const RouteService: IRouteService = { getReleaseDetailPath: (release: string): string => ( `${NeonEnvironment.getWebHost()}/data-samples/data-management/data-revisions-releases/${release}` ), + + getDataApiPath: (): string => ( + `${NeonEnvironment.getApiHost()}/data-api` + ), + getTaxonomicListsPath: (): string => ( + // TODO: replace with web host once switch over happens `${NeonEnvironment.getApiHost()}/taxonomic-lists` ), - getDataProductCitationDownloadUrl: (): string => ( // TODO: replace with web host once switch over happens NeonEnvironment.getApiHost() ), - getDataApiPath: (): string => ( - // TODO: replace with web host once switch over happens - `${NeonEnvironment.getApiHost()}/data-api` - ), getDataProductExploreSearchPath: (query: string): string => ( `${RouteService.getDataProductExplorePath()}?search=${encodeURIComponent(query)}` ), diff --git a/src/lib_components/staticJSON/bundles.json b/src/lib_components/staticJSON/bundles.json deleted file mode 100644 index 4855c684..00000000 --- a/src/lib_components/staticJSON/bundles.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "children": { - "DP1.00007.001": "DP4.00200.001", - "DP1.00010.001": "DP4.00200.001", - "DP1.00034.001": "DP4.00200.001", - "DP1.00035.001": "DP4.00200.001", - "DP1.00036.001": "DP4.00200.001", - "DP1.00037.001": "DP4.00200.001", - "DP4.00067.001": "DP4.00200.001", - "DP1.00099.001": "DP4.00200.001", - "DP1.00100.001": "DP4.00200.001", - "DP2.00008.001": "DP4.00200.001", - "DP2.00009.001": "DP4.00200.001", - "DP2.00024.001": "DP4.00200.001", - "DP3.00008.001": "DP4.00200.001", - "DP3.00009.001": "DP4.00200.001", - "DP3.00010.001": "DP4.00200.001", - "DP4.00002.001": "DP4.00200.001", - "DP4.00007.001": "DP4.00200.001", - "DP4.00137.001": "DP4.00200.001", - "DP4.00201.001": "DP4.00200.001", - "DP1.10102.001": ["DP1.10066.001", "DP1.10067.001"], - "DP1.10099.001": ["DP1.10066.001", "DP1.10067.001"], - "DP1.10053.001": "DP1.10026.001", - "DP1.10031.001": "DP1.10033.001", - "DP1.10101.001": "DP1.10033.001", - "DP1.10080.001": "DP1.10086.001", - "DP1.10078.001": "DP1.10086.001", - "DP1.10100.001": "DP1.10086.001", - "DP1.10008.001": "DP1.10047.001", - "DP1.00097.001": "DP1.00096.001" - }, - "parents": { - "DP4.00200.001": { "forwardAvailability": true }, - "DP1.10067.001": { "forwardAvailability": false }, - "DP1.10026.001": { "forwardAvailability": false }, - "DP1.10033.001": { "forwardAvailability": false }, - "DP1.10086.001": { "forwardAvailability": false }, - "DP1.10047.001": { "forwardAvailability": false }, - "DP1.00096.001": { "forwardAvailability": false }, - "DP1.10066.001": { "forwardAvailability": false } - } -} diff --git a/src/lib_components/types/neonApi.ts b/src/lib_components/types/neonApi.ts new file mode 100644 index 00000000..c89ab214 --- /dev/null +++ b/src/lib_components/types/neonApi.ts @@ -0,0 +1,19 @@ +export interface NeonApiResponse { + data: unknown; +} + +export interface BundledDataProduct { + productCode: string; + isPrimaryBundle?: boolean; +} + +export interface DataProductBundle { + productCode: string; + forwardAvailability: boolean; + bundledProducts: BundledDataProduct[]; +} + +export interface ReleaseDataProductBundles { + release: string; + dataProductBundles: DataProductBundle[]; +} diff --git a/src/lib_components/types/neonContext.ts b/src/lib_components/types/neonContext.ts new file mode 100644 index 00000000..ab9cff4e --- /dev/null +++ b/src/lib_components/types/neonContext.ts @@ -0,0 +1,46 @@ +import { ReleaseDataProductBundles } from './neonApi'; + +/** + * NeonContext specific utilization of bundles, derived for quick lookups + * of key properties of bundles. + */ +export interface BundleContext { + /** + * Defines the set of container products that have bundled products, + * keyed by { "RELEASE": { "BUNDLE_PRODUCT_CODE": ["PRODUCT_CODE"] } } + */ + bundleProducts: Record>; + /** + * Defines the set of container products that should forward their + * availability to bundled products, + * keyed by { "RELEASE": { "BUNDLE_PRODUCT_CODE": true } } + */ + bundleProductsForwardAvailability: Record>; + /** + * Defines the set of bundled products, to the container product + * that should provide the DOI for the bundled product. + * { + * "RELEASE": { + * "PRODUCT_CODE": "BUNDLE_PRODUCT_CODE", + * }, + * } + * This lookup can also be utilized to determine whether or not + * a product exists within a bundle. + */ + bundleDoiLookup: Record>; + /** + * Defines the set of products that should be presented as "split", + * defined as products that now exist in more than one product, + * and are therefore defined in two or more "bundles". + * keyed by { "RELEASE": { "SPLIT_PRODUCT_CODE": ["BUNDLED_PRODUCT_CODE"] } } + */ + splitProducts: Record>; + /** + * Defines the set of products that are involved in bundles. + */ + allBundleProducts: Record; + /** + * The raw API response containing the full bundle definition. + */ + apiResponse: ReleaseDataProductBundles[]; +} diff --git a/src/lib_components/util/rxUtil.js b/src/lib_components/util/rxUtil.js index 8b7a1ef6..404aa14d 100644 --- a/src/lib_components/util/rxUtil.js +++ b/src/lib_components/util/rxUtil.js @@ -13,6 +13,9 @@ import { finalize, } from 'rxjs/operators'; +import NeonEnvironment from '../components/NeonEnvironment/NeonEnvironment'; +import { exists } from './typeUtil'; + /** * Convenience method for utiliizing RxJS ajax.getJSON * @param {string} url @@ -20,6 +23,7 @@ import { * @param {any} errorCallback * @param {any} cancellationSubject$ * @param {Object|undefined} headers + * @param {boolean} cors * @return RxJS subscription */ export const getJson = ( @@ -28,13 +32,30 @@ export const getJson = ( errorCallback, cancellationSubject$, headers = undefined, + cors = false, ) => { - const rxObs$ = ajax.getJSON(url, headers).pipe( + const request = { + method: 'GET', + url, + responseType: 'json', + headers: { + Accept: 'application/json', + ...headers, + }, + }; + if (cors && NeonEnvironment.requireCors()) { + request.crossDomain = true; + request.withCredentials = true; + } + const rxObs$ = ajax(request).pipe( map((response) => { + const appliedResponse = (exists(response) && exists(response.response)) + ? response.response + : response; if (typeof callback === 'function') { - return of(callback(response)); + return of(callback(appliedResponse)); } - return of(response); + return of(appliedResponse); }), catchError((error) => { console.error(error); // eslint-disable-line no-console diff --git a/src/lib_components/util/typeUtil.ts b/src/lib_components/util/typeUtil.ts index 61135510..f8b1b567 100644 --- a/src/lib_components/util/typeUtil.ts +++ b/src/lib_components/util/typeUtil.ts @@ -1,10 +1,41 @@ -import { Nullable } from '../types/core'; +import { Nullable, NullableRecord, UnknownRecord } from '../types/core'; const exists = (o: any): boolean => (typeof o !== 'undefined') && (o !== null); const isStringNonEmpty = (o: any): boolean => (typeof o === 'string') && ((o as string).trim().length > 0); const isNum = (o: any): boolean => (typeof o === 'number') && (!Number.isNaN(o)); const existsNonEmpty = (o: Nullable): boolean => exists(o) && ((o as any[]).length > 0); +/** + * Resolves any value to a record by + * drilling down nested props to coerce to a usable type. + * @param o the object to interrogate + * @param drillProps array of nested props to drill down to + * @return The coerced inner prop + */ +export const resolveAny = ( + o: never, + ...drillProps: string[] +): UnknownRecord => { + if (!exists(o) || !existsNonEmpty(drillProps)) { + return {}; + } + const curProp: string = drillProps[0]; + if (drillProps.length === 1) { + if (!exists(o[curProp])) { + return {}; + } + return o[curProp] as UnknownRecord; + } + const next: NullableRecord = o[curProp] as NullableRecord; + if (!exists(next)) { + return {}; + } + return resolveAny( + next as never, + ...drillProps.slice(1, drillProps.length), + ); +}; + export { exists, isStringNonEmpty, diff --git a/src/lib_components/workers/__tests__/generateTimeSeriesGraphData.js b/src/lib_components/workers/__tests__/generateTimeSeriesGraphData.js index 12a7421f..a2067d5b 100644 --- a/src/lib_components/workers/__tests__/generateTimeSeriesGraphData.js +++ b/src/lib_components/workers/__tests__/generateTimeSeriesGraphData.js @@ -451,7 +451,7 @@ describe('generateTimeSeriesGraphData worker', () => { }, q1: { data: [ - 0, 1, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, + 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, ], }, @@ -492,7 +492,7 @@ describe('generateTimeSeriesGraphData worker', () => { q1: { data: [ 0, 1, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, - 0, 0, 0, 0, 0, 1, 1, 1, + 0, 0, 0, 0, 0, 1, 1, 1, null, ], }, }, @@ -522,24 +522,24 @@ describe('generateTimeSeriesGraphData worker', () => { [isoToDate('2015-02-08T00:00:00Z'), 13, 337], [isoToDate('2015-02-09T00:00:00Z'), 17, 347], [isoToDate('2015-02-10T00:00:00Z'), 19, 349], - [isoToDate('2015-02-11T00:00:00Z'), 23, 353], - [isoToDate('2015-02-12T00:00:00Z'), 29, 359], - [isoToDate('2015-02-13T00:00:00Z'), 31, 367], - [isoToDate('2015-02-14T00:00:00Z'), 37, 373], - [isoToDate('2015-02-15T00:00:00Z'), 41, 379], - [isoToDate('2015-02-16T00:00:00Z'), 43, 383], - [isoToDate('2015-02-17T00:00:00Z'), 47, 389], - [isoToDate('2015-02-18T00:00:00Z'), 53, 397], - [isoToDate('2015-02-19T00:00:00Z'), 59, 401], - [isoToDate('2015-02-20T00:00:00Z'), 61, 409], - [isoToDate('2015-02-21T00:00:00Z'), 67, 419], - [isoToDate('2015-02-22T00:00:00Z'), 71, 421], - [isoToDate('2015-02-23T00:00:00Z'), 73, 431], - [isoToDate('2015-02-24T00:00:00Z'), 79, 433], - [isoToDate('2015-02-25T00:00:00Z'), 83, 439], - [isoToDate('2015-02-26T00:00:00Z'), 89, 443], - [isoToDate('2015-02-27T00:00:00Z'), 97, 449], - [isoToDate('2015-02-28T00:00:00Z'), 101, 457], + [isoToDate('2015-02-11T00:00:00Z'), 23, 367], + [isoToDate('2015-02-12T00:00:00Z'), 29, 373], + [isoToDate('2015-02-13T00:00:00Z'), 31, 379], + [isoToDate('2015-02-14T00:00:00Z'), 37, 383], + [isoToDate('2015-02-15T00:00:00Z'), 41, 389], + [isoToDate('2015-02-16T00:00:00Z'), 43, 397], + [isoToDate('2015-02-17T00:00:00Z'), 47, 401], + [isoToDate('2015-02-18T00:00:00Z'), 53, 409], + [isoToDate('2015-02-19T00:00:00Z'), 59, 419], + [isoToDate('2015-02-20T00:00:00Z'), 61, 421], + [isoToDate('2015-02-21T00:00:00Z'), 67, 431], + [isoToDate('2015-02-22T00:00:00Z'), 71, 433], + [isoToDate('2015-02-23T00:00:00Z'), 73, 439], + [isoToDate('2015-02-24T00:00:00Z'), 79, 443], + [isoToDate('2015-02-25T00:00:00Z'), 83, 449], + [isoToDate('2015-02-26T00:00:00Z'), 89, 457], + [isoToDate('2015-02-27T00:00:00Z'), 97, 461], + [isoToDate('2015-02-28T00:00:00Z'), 101, 463], [isoToDate('2015-03-01T00:00:00Z'), null, 0], [isoToDate('2015-03-02T00:00:00Z'), null, 1], [isoToDate('2015-03-03T00:00:00Z'), null, 2], @@ -582,7 +582,7 @@ describe('generateTimeSeriesGraphData worker', () => { [isoToDate('2015-02-07T00:00:00Z'), isoToDate('2015-02-08T00:00:00Z'), [0], [0]], [isoToDate('2015-02-08T00:00:00Z'), isoToDate('2015-02-09T00:00:00Z'), [0], [0]], [isoToDate('2015-02-09T00:00:00Z'), isoToDate('2015-02-10T00:00:00Z'), [0], [0]], - [isoToDate('2015-02-10T00:00:00Z'), isoToDate('2015-02-11T00:00:00Z'), [1], [1]], + [isoToDate('2015-02-10T00:00:00Z'), isoToDate('2015-02-11T00:00:00Z'), [1], [0]], [isoToDate('2015-02-11T00:00:00Z'), isoToDate('2015-02-12T00:00:00Z'), [1], [0]], [isoToDate('2015-02-12T00:00:00Z'), isoToDate('2015-02-13T00:00:00Z'), [1], [0]], [isoToDate('2015-02-13T00:00:00Z'), isoToDate('2015-02-14T00:00:00Z'), [0], [0]], diff --git a/src/lib_components/workers/generateTimeSeriesGraphData.js b/src/lib_components/workers/generateTimeSeriesGraphData.js index 43483e09..b119877a 100644 --- a/src/lib_components/workers/generateTimeSeriesGraphData.js +++ b/src/lib_components/workers/generateTimeSeriesGraphData.js @@ -62,6 +62,17 @@ function tickerToIso(ticker, includeSeconds = true) { : `${YYYY}-${MM}-${DD}T${hh}:${mm}Z`; } +function dateTimeToFloorSeconds(dt) { + if ((typeof dt === 'undefined') || (dt === null)) { return null; } + const d = new Date(dt); + const YYYY = d.getUTCFullYear().toString(); + const MM = (d.getUTCMonth() + 1).toString().padStart(2, '0'); + const DD = d.getUTCDate().toString().padStart(2, '0'); + const hh = d.getUTCHours().toString().padStart(2, '0'); + const mm = d.getUTCMinutes().toString().padStart(2, '0'); + return `${YYYY}-${MM}-${DD}T${hh}:${mm}:00Z`; +} + function getNextMonth(month) { if (!monthIsValid(month)) { return null; } let { y, m } = monthToNumbers(month); @@ -105,6 +116,7 @@ export default function generateTimeSeriesGraphData(payload = {}) { .require(tickerIsValid) .require(tickerToMonth) .require(tickerToIso) + .require(dateTimeToFloorSeconds) .require(getNextMonth); return worker.spawn((inData) => { @@ -125,10 +137,47 @@ export default function generateTimeSeriesGraphData(payload = {}) { variables: selectedVariables, }, } = inData; + // Optionally toggle to allow a purely positional mapping + // of generated, contiguous timestamp values based on the + // aggregation level, and the actual timestamps coming back in the + // data. Setting this to true makes some inherent assumptions + // about the data that are likely OK in most cases and will be + // more efficient, but can produce incorrect results given irregular + // timestamps in the data. + // Turning this off will resolve to mapping the timestamps from the + // data to the matching timestamp in the generated, + // contiguous timestamp values. + const ALLOW_POSITIONAL_TS_MAPPING = false; + // Optionally toggle to allow truncating series values + // in the case where there are more timestamp values than expected + // based on the generated, contiguous timestamp values for the aggregation + // level. + const ALLOW_POSITIONAL_TS_TRUNCATION = false; + const timeStep = selectedTimeStep === 'auto' ? autoTimeStep : selectedTimeStep; const getQFNullFill = () => (qualityFlags || []).map(() => null); const TIME_STEPS = getTimeSteps(); + // Function to match the searchTime in milliseconds since epoch, + // against a timestamp in the dataset + const findTimestampIdx = (data, searchTime, startIdx) => { + const isodateS = tickerToIso(searchTime, true); + const isodateM = tickerToIso(searchTime, false); + let dateIdx = -1; + for (let i = startIdx; i < data.length; i += 1) { + const dateTimeVal = data[i]; + const dateTimeValZeroS = dateTimeToFloorSeconds(dateTimeVal); + const matched = (dateTimeVal === isodateS) + || (dateTimeValZeroS === isodateS) + || (dateTimeVal === isodateM); + if (matched) { + dateIdx = i; + break; + } + } + return dateIdx; + }; + /** Validate input (return unmodified state.graphData if anything fails) */ @@ -315,38 +364,53 @@ export default function generateTimeSeriesGraphData(payload = {}) { const seriesStepCount = posData[month][pkg][timeStep].series[variable].data.length; // Series and month data lengths are identical (as expected): // Stream values directly in without matching timestamps - if (seriesStepCount === monthStepCount) { - posData[month][pkg][timeStep].series[variable].data.forEach((d, datumIdx) => { - newData[datumIdx + monthIdx][columnIdx] = d; - }); - return; + if (ALLOW_POSITIONAL_TS_MAPPING) { + if (seriesStepCount === monthStepCount) { + posData[month][pkg][timeStep].series[variable].data.forEach((d, datumIdx) => { + newData[datumIdx + monthIdx][columnIdx] = d; + }); + return; + } } // More series data than month data: // Stream values directly in without matching timestamps, truncate data so as not to // exceed month step count - if (seriesStepCount >= monthStepCount) { - posData[month][pkg][timeStep].series[variable].data.forEach((d, datumIdx) => { - if (datumIdx >= monthStepCount) { return; } - newData[datumIdx + monthIdx][columnIdx] = d; - }); - return; + if (ALLOW_POSITIONAL_TS_TRUNCATION) { + if (seriesStepCount >= monthStepCount) { + posData[month][pkg][timeStep].series[variable].data.forEach((d, datumIdx) => { + if (datumIdx >= monthStepCount) { return; } + newData[datumIdx + monthIdx][columnIdx] = d; + }); + return; + } } - // Series data length is shorter than expected month length: + // The series data length does not match the expected month length so + // loop through by month steps pulling in series values through timestamp matching // Add what data we have by going through each time step in the month and comparing to // start dates in the data set, null-filling any steps without a corresponding datum // Note that sometimes dates come back with seconds and sometimes without, so for // matching we look for either. - const setSeriesValueByTimestamp = (t) => { - const isodateS = tickerToIso(newData[t][0].getTime(), true); - const isodateM = tickerToIso(newData[t][0].getTime(), false); - const dataIdx = posData[month][pkg][timeStep].series[dateTimeVariable].data - .findIndex((dateTimeVal) => (dateTimeVal === isodateS || dateTimeVal === isodateM)); - newData[t][columnIdx] = dataIdx !== -1 - ? posData[month][pkg][timeStep].series[variable].data[dataIdx] - : null; - }; + // This assumes a chronological ordering of timestamps in both + // the input series data and generated month ticker timestamps. + // Therefore, if we find a matching timestamp value, we can assume + // that the next value won't be found before the last found index in the series data, + // allowing us to optimize the search in this scenario. + let lastIdx = 0; + const dtVarData = posData[month][pkg][timeStep].series[dateTimeVariable].data; for (let t = monthIdx; t < monthIdx + monthStepCount; t += 1) { - setSeriesValueByTimestamp(t); + const dataIdx = findTimestampIdx( + dtVarData, + newData[t][0].getTime(), + lastIdx, + ); + if (dataIdx === -1) { + newData[t][columnIdx] = null; + } else { + lastIdx = dataIdx + 1; + newData[t][columnIdx] = posData[month][pkg][timeStep] + .series[variable] + .data[dataIdx]; + } } }); @@ -370,40 +434,66 @@ export default function generateTimeSeriesGraphData(payload = {}) { } // This site/position/month/qf series exists, so add it into the quality data set const seriesStepCount = posData[month][pkg][timeStep].series[qf].data.length; - if (seriesStepCount !== monthStepCount) { - // The series data length does not match the expected month length so - // loop through by month steps pulling in series values through timestamp matching - const setQualityValueByTimestamp = (t) => { - const isodate = tickerToIso(newQualityData[t][0].getTime()); - const dataIdx = posData[month][pkg][timeStep].series[dateTimeVariable].data - .findIndex((dateTimeVal) => dateTimeVal === isodate); - if (dataIdx === -1) { - newQualityData[t][columnIdx] = getQFNullFill(); - return; - } + // Series and month data lengths are identical as expected so we can stream + // values directly in without matching timestamps + if (ALLOW_POSITIONAL_TS_MAPPING) { + if (seriesStepCount === monthStepCount) { + posData[month][pkg][timeStep].series[qf].data.forEach((d, datumIdx) => { + const t = datumIdx + monthIdx; + if (!Array.isArray(newQualityData[t][columnIdx])) { + newQualityData[t][columnIdx] = []; + } + newQualityData[t][columnIdx][qfIdx] = d; + }); + return; + } + } + // More series data than month data: + // Stream values directly in without matching timestamps, truncate data so as not to + // exceed month step count + if (ALLOW_POSITIONAL_TS_TRUNCATION) { + if (seriesStepCount > monthStepCount) { + posData[month][pkg][timeStep].series[qf].data.forEach((d, datumIdx) => { + if (datumIdx >= monthStepCount) { return; } + const t = datumIdx + monthIdx; + if (!Array.isArray(newQualityData[t][columnIdx])) { + newQualityData[t][columnIdx] = []; + } + newQualityData[t][columnIdx][qfIdx] = d; + }); + return; + } + } + // The series data length does not match the expected month length so + // loop through by month steps pulling in series values through timestamp matching + // This assumes a chronological ordering of timestamps in both + // the input series data and generated month ticker timestamps. + // Therefore, if we find a matching timestamp value, we can assume + // that the next value won't be found before the last found index in the series data, + // allowing us to optimize the search in this scenario. + let lastIdx = 0; + const dtVarData = posData[month][pkg][timeStep].series[dateTimeVariable].data; + for (let t = monthIdx; t < monthIdx + monthStepCount; t += 1) { + const dataIdx = findTimestampIdx( + dtVarData, + newQualityData[t][0].getTime(), + lastIdx, + ); + if (dataIdx === -1) { + newQualityData[t][columnIdx] = getQFNullFill(); + } else { + lastIdx = dataIdx + 1; const d = ( typeof posData[month][pkg][timeStep].series[qf].data[dataIdx] !== 'undefined' - ? posData[month][pkg][timeStep].series[qf].data[dataIdx] : null + ? posData[month][pkg][timeStep].series[qf].data[dataIdx] + : null ); if (!Array.isArray(newQualityData[t][columnIdx])) { newQualityData[t][columnIdx] = []; } newQualityData[t][columnIdx][qfIdx] = d; - }; - for (let t = monthIdx; t < monthIdx + monthStepCount; t += 1) { - setQualityValueByTimestamp(t); } - return; } - // Series and month data lengths are identical as expected so we can stream - // values directly in without matching timestamps - posData[month][pkg][timeStep].series[qf].data.forEach((d, datumIdx) => { - const t = datumIdx + monthIdx; - if (!Array.isArray(newQualityData[t][columnIdx])) { - newQualityData[t][columnIdx] = []; - } - newQualityData[t][columnIdx][qfIdx] = d; - }); }); }); }); diff --git a/src/sampleData/bundles.json b/src/sampleData/bundles.json new file mode 100644 index 00000000..cbeb0d50 --- /dev/null +++ b/src/sampleData/bundles.json @@ -0,0 +1,304 @@ +{ + "data": [ + { + "release": "RELEASE-2021", + "dataProductBundles": [ + { + "productCode": "DP1.00096.001", + "forwardAvailability": false, + "bundledProducts": [ + { + "productCode": "DP1.00097.001" + } + ] + }, + { + "productCode": "DP1.10026.001", + "forwardAvailability": false, + "bundledProducts": [ + { + "productCode": "DP1.10053.001" + } + ] + }, + { + "productCode": "DP1.10033.001", + "forwardAvailability": false, + "bundledProducts": [ + { + "productCode": "DP1.10031.001" + }, + { + "productCode": "DP1.10101.001" + } + ] + }, + { + "productCode": "DP1.10047.001", + "forwardAvailability": false, + "bundledProducts": [ + { + "productCode": "DP1.10008.001" + } + ] + }, + { + "productCode": "DP1.10066.001", + "forwardAvailability": false, + "bundledProducts": [ + { + "productCode": "DP1.10102.001", + "isPrimaryBundle": true + }, + { + "productCode": "DP1.10099.001", + "isPrimaryBundle": true + } + ] + }, + { + "productCode": "DP1.10067.001", + "forwardAvailability": false, + "bundledProducts": [ + { + "productCode": "DP1.10102.001", + "isPrimaryBundle": false + }, + { + "productCode": "DP1.10099.001", + "isPrimaryBundle": false + } + ] + }, + { + "productCode": "DP1.10086.001", + "forwardAvailability": false, + "bundledProducts": [ + { + "productCode": "DP1.10080.001" + }, + { + "productCode": "DP1.10078.001" + }, + { + "productCode": "DP1.10100.001" + } + ] + }, + { + "productCode": "DP4.00200.001", + "forwardAvailability": true, + "bundledProducts": [ + { + "productCode": "DP1.00007.001" + }, + { + "productCode": "DP1.00010.001" + }, + { + "productCode": "DP1.00034.001" + }, + { + "productCode": "DP1.00035.001" + }, + { + "productCode": "DP1.00036.001" + }, + { + "productCode": "DP1.00037.001" + }, + { + "productCode": "DP4.00067.001" + }, + { + "productCode": "DP1.00099.001" + }, + { + "productCode": "DP1.00100.001" + }, + { + "productCode": "DP2.00008.001" + }, + { + "productCode": "DP2.00009.001" + }, + { + "productCode": "DP2.00024.001" + }, + { + "productCode": "DP3.00008.001" + }, + { + "productCode": "DP3.00009.001" + }, + { + "productCode": "DP3.00010.001" + }, + { + "productCode": "DP4.00002.001" + }, + { + "productCode": "DP4.00007.001" + }, + { + "productCode": "DP4.00137.001" + }, + { + "productCode": "DP4.00201.001" + } + ] + } + ] + }, + { + "release": "PROVISIONAL", + "dataProductBundles": [ + { + "productCode": "DP1.00096.001", + "forwardAvailability": false, + "bundledProducts": [ + { + "productCode": "DP1.00097.001" + } + ] + }, + { + "productCode": "DP1.10026.001", + "forwardAvailability": false, + "bundledProducts": [ + { + "productCode": "DP1.10053.001" + } + ] + }, + { + "productCode": "DP1.10033.001", + "forwardAvailability": false, + "bundledProducts": [ + { + "productCode": "DP1.10031.001" + }, + { + "productCode": "DP1.10101.001" + } + ] + }, + { + "productCode": "DP1.10047.001", + "forwardAvailability": false, + "bundledProducts": [ + { + "productCode": "DP1.10008.001" + } + ] + }, + { + "productCode": "DP1.10066.001", + "forwardAvailability": false, + "bundledProducts": [ + { + "productCode": "DP1.10102.001", + "isPrimaryBundle": true + }, + { + "productCode": "DP1.10099.001", + "isPrimaryBundle": true + } + ] + }, + { + "productCode": "DP1.10067.001", + "forwardAvailability": false, + "bundledProducts": [ + { + "productCode": "DP1.10102.001", + "isPrimaryBundle": false + }, + { + "productCode": "DP1.10099.001", + "isPrimaryBundle": false + } + ] + }, + { + "productCode": "DP1.10086.001", + "forwardAvailability": false, + "bundledProducts": [ + { + "productCode": "DP1.10080.001" + }, + { + "productCode": "DP1.10078.001" + }, + { + "productCode": "DP1.10100.001" + } + ] + }, + { + "productCode": "DP4.00200.001", + "forwardAvailability": true, + "bundledProducts": [ + { + "productCode": "DP1.00007.001" + }, + { + "productCode": "DP1.00010.001" + }, + { + "productCode": "DP1.00034.001" + }, + { + "productCode": "DP1.00035.001" + }, + { + "productCode": "DP1.00036.001" + }, + { + "productCode": "DP1.00037.001" + }, + { + "productCode": "DP4.00067.001" + }, + { + "productCode": "DP1.00099.001" + }, + { + "productCode": "DP1.00100.001" + }, + { + "productCode": "DP2.00008.001" + }, + { + "productCode": "DP2.00009.001" + }, + { + "productCode": "DP2.00024.001" + }, + { + "productCode": "DP3.00008.001" + }, + { + "productCode": "DP3.00009.001" + }, + { + "productCode": "DP3.00010.001" + }, + { + "productCode": "DP4.00002.001" + }, + { + "productCode": "DP4.00007.001" + }, + { + "productCode": "DP4.00137.001" + }, + { + "productCode": "DP4.00201.001" + } + ] + } + ] + } + ] +}