diff --git a/examples/demo-app/src/app.js b/examples/demo-app/src/app.js index 02f5f1a617..f5e94d7274 100644 --- a/examples/demo-app/src/app.js +++ b/examples/demo-app/src/app.js @@ -97,6 +97,15 @@ const GlobalStyle = styled.div` } `; +const CONTAINER_STYLE = { + transition: 'margin 1s, height 1s', + position: 'absolute', + width: '100%', + height: '100%', + left: 0, + top: 0 +}; + class App extends Component { state = { showBanner: false, @@ -416,16 +425,7 @@ class App extends Component { > -
+
{({height, width}) => ( { + const link = this._authLink(); - const handleToken = async e => { - // TODO: add security step to validate which domain the message is coming from - if (authWindow) { - authWindow.close(); - } + const authWindow = window.open(link, '_blank', 'width=1024,height=716'); - window.removeEventListener('message', handleToken); + const handleToken = async event => { + // if user has dev tools this will skip all the react-devtools events + if (!event.data.token) { + return; + } - if (!e.data.token) { - Console.warn('Failed to login to Dropbox'); - return; - } + if (authWindow) { + authWindow.close(); + window.removeEventListener('message', handleToken); + } - this._dropbox.setAccessToken(e.data.token); - // save user name - const user = await this._getUser(); - - if (window.localStorage) { - window.localStorage.setItem( - 'dropbox', - JSON.stringify({ - // dropbox token doesn't expire unless revoked by the user - token: e.data.token, - user, - timestamp: new Date() - }) - ); - } + const {token} = event.data; - if (typeof onCloudLoginSuccess === 'function') { - onCloudLoginSuccess(); - } - }; - - window.addEventListener('message', handleToken); - } + if (!token) { + reject('Failed to login to Dropbox'); + return; + } - async downloadMap(loadParams) { - const token = this.getAccessToken(); - if (!token) { - this.login(() => this.downloadMap(loadParams)); - } - const result = await this._dropbox.filesDownload(loadParams); - const json = await this._readFile(result.fileBlob); + this._dropbox.setAccessToken(token); + // save user name + const user = await this.getUser(); + + if (window.localStorage) { + window.localStorage.setItem( + 'dropbox', + JSON.stringify({ + // dropbox token doesn't expire unless revoked by the user + token: token, + user, + timestamp: new Date() + }) + ); + } - const response = { - map: json, - format: 'keplergl' - }; + resolve(user); + }; - this._loadParam = loadParams; - return response; + window.addEventListener('message', handleToken); + }); } + /** + * returns a list of maps + */ async listMaps() { // list files try { // https://dropbox.github.io/dropbox-sdk-js/Dropbox.html#filesListFolder__anchor const response = await this._dropbox.filesListFolder({ - path: this._path + path: `${this._path}` }); const {pngs, visualizations} = this._parseEntries(response); // https://dropbox.github.io/dropbox-sdk-js/Dropbox.html#filesGetThumbnailBatch__anchor @@ -148,15 +139,14 @@ export default class DropboxProvider extends Provider { ); // append to visualizations - thumbnails && - thumbnails.forEach(thb => { - if (thb['.tag'] === 'success' && thb.thumbnail) { - const matchViz = visualizations[pngs[thb.metadata.id] && pngs[thb.metadata.id].name]; - if (matchViz) { - matchViz.thumbnail = `${IMAGE_URL_PREFIX}${thb.thumbnail}`; - } + (thumbnails || []).forEach(thb => { + if (thb['.tag'] === 'success' && thb.thumbnail) { + const matchViz = visualizations[pngs[thb.metadata.id] && pngs[thb.metadata.id].name]; + if (matchViz) { + matchViz.thumbnail = `${IMAGE_URL_PREFIX}${thb.thumbnail}`; } - }); + } + }); // dropbox returns return Object.values(visualizations).reverse(); @@ -166,49 +156,10 @@ export default class DropboxProvider extends Provider { } } - getUserName() { - // load user from - if (window.localStorage) { - const jsonString = window.localStorage.getItem('dropbox'); - const user = jsonString && JSON.parse(jsonString).user; - return user; - } - return null; - } - - async logout(onCloudLogoutSuccess) { - const token = this._dropbox.getAccessToken(); - - if (token) { - await this._dropbox.authTokenRevoke(); - if (window.localStorage) { - window.localStorage.removeItem('dropbox'); - } - // re instantiate dropbox - this._initializeDropbox(); - onCloudLogoutSuccess(); - } - } - - isEnabled() { - return this.clientId !== null; - } - - hasPrivateStorage() { - return PRIVATE_STORAGE_ENABLED; - } - - hasSharingUrl() { - return SHARING_ENABLED; - } - /** * * @param mapData map data and config in one json object {map: {datasets: Array, config: Object, info: Object} * @param blob json file blob to upload - * @param fileName if blob doesn't contain a file name, this field is used - * @param isPublic define whether the file will be available publicly once uploaded - * @returns {Promise} */ async uploadMap({mapData, options = {}}) { const {isPublic} = options; @@ -221,10 +172,11 @@ export default class DropboxProvider extends Provider { // FileWriteMode: Selects what to do if the file already exists. // Always overwrite if sharing const mode = options.overwrite || isPublic ? 'overwrite' : 'add'; + const path = `${this._path}/${fileName}`; let metadata; try { metadata = await this._dropbox.filesUpload({ - path: `${this._path}/${fileName}`, + path, contents: JSON.stringify(fileContent), mode }); @@ -233,13 +185,15 @@ export default class DropboxProvider extends Provider { throw this.getFileConflictError(); } } + // save a thumbnail image - thumbnail && - (await this._dropbox.filesUpload({ - path: `${this._path}/${fileName}`.replace(/\.json$/, '.png'), + if (thumbnail) { + await this._dropbox.filesUpload({ + path: path.replace(/\.json$/, '.png'), contents: thumbnail, mode - })); + }); + } // keep on create shareUrl if (isPublic) { @@ -252,6 +206,58 @@ export default class DropboxProvider extends Provider { return this._loadParam; } + /** + * download the map content + * @param loadParams + */ + async downloadMap(loadParams) { + const token = this.getAccessToken(); + if (!token) { + this.login(() => this.downloadMap(loadParams)); + } + const result = await this._dropbox.filesDownload(loadParams); + const json = await this._readFile(result.fileBlob); + + const response = { + map: json, + format: 'keplergl' + }; + + this._loadParam = loadParams; + return response; + } + + getUserName() { + // load user from + if (window.localStorage) { + const jsonString = window.localStorage.getItem('dropbox'); + const user = jsonString && JSON.parse(jsonString).user; + return user; + } + return null; + } + + async logout() { + await this._dropbox.authTokenRevoke(); + if (window.localStorage) { + window.localStorage.removeItem('dropbox'); + } + // re instantiate dropbox + this._initializeDropbox(); + } + + isEnabled() { + return this.clientId !== null; + } + + hasPrivateStorage() { + return PRIVATE_STORAGE_ENABLED; + } + + hasSharingUrl() { + return SHARING_ENABLED; + } + /** * Get the share url of current map, this url can be accessed by anyone * @param {boolean} fullUrl @@ -314,22 +320,15 @@ export default class DropboxProvider extends Provider { this._dropbox.setClientId(this.clientId); } - async _getUser() { - let response; - try { - response = await this._dropbox.usersGetCurrentAccount(); - } catch (error) { - Console.warn(error); - return null; - } - + async getUser() { + const response = await this._dropbox.usersGetCurrentAccount(); return this._getUserFromAccount(response); } _handleDropboxError(error) { // dropbox list_folder error if (error && error.error && error.error.error_summary) { - return `Dropbox Error: ${error.error.error_summary}`; + return new Error(`Dropbox Error: ${error.error.error_summary}`); } return error; @@ -422,7 +421,12 @@ export default class DropboxProvider extends Provider { } _getUserFromAccount(response) { - return response ? (response.name && response.name.abbreviated_name) || response.email : null; + const {name} = response; + return { + name: name.display_name, + email: response.email, + abbreviated: name.abbreviated_name + }; } _getThumbnailRequests(pngs) { diff --git a/examples/demo-app/src/cloud-providers/index.js b/examples/demo-app/src/cloud-providers/index.js index 357bb9a2c2..0aa2310b3f 100644 --- a/examples/demo-app/src/cloud-providers/index.js +++ b/examples/demo-app/src/cloud-providers/index.js @@ -24,7 +24,7 @@ import DropboxProvider from './dropbox/dropbox-provider'; import CartoProvider from './carto/carto-provider'; const {DROPBOX_CLIENT_ID, CARTO_CLIENT_ID} = AUTH_TOKENS; -const DROPBOX_CLIENT_NAME = 'Kepler.gl%20(managed%20by%20Uber%20Technologies%2C%20Inc.)'; +const DROPBOX_CLIENT_NAME = 'Kepler.gl Demo App'; export const DEFAULT_CLOUD_PROVIDER = 'dropbox'; diff --git a/examples/demo-app/src/factories/map-control.js b/examples/demo-app/src/factories/map-control.js index a22eaea614..7ba542946c 100644 --- a/examples/demo-app/src/factories/map-control.js +++ b/examples/demo-app/src/factories/map-control.js @@ -21,7 +21,7 @@ import React from 'react'; import styled from 'styled-components'; import { - withState, + withState, MapControlFactory, EffectControlFactory, EffectManagerFactory diff --git a/jest.config.js b/jest.config.js index 52b44d6287..bf8bebee5f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -25,7 +25,11 @@ const config = { testEnvironment: 'jsdom', setupFilesAfterEnv: ['/jest.setup.js'], verbose: true, - testMatch: ['/src/**/*.spec.js', '/test/**/*.spec.js'], + testPathIgnorePatterns: [ + // ignore all dist computed directories + "/.*(/|\\\\)dist(/|\\\\).*" + ], + testMatch: ['/src/**/*.spec.(ts|tsx)', '/src/**/*.spec.js', '/test/**/*.spec.js'], // Per https://jestjs.io/docs/configuration#transformignorepatterns-arraystring, transformIgnorePatterns ignores // node_modules and pnp folders by default so that they are not transpiled // Some libraries (even if transitive) are transitioning to ESM and need additional transpilation. Relevant issues: diff --git a/package.json b/package.json index 013b39e268..cc44b2d84e 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,9 @@ "test": "yarn test-jest && yarn test-tape", "cover-tape": "nyc --reporter=json --report-dir=tape-coverage --reporter=html yarn test-tape", "cover-jest": "yarn jest --coverage --reporter=html --watchAll=false", - "cover-copy": "rm -rf .nyc_output && mkdir .nyc_output && cp tape-coverage/coverage-final.json .nyc_output/tape-coverage-final.json && cp jest-coverage/coverage-final.json .nyc_output/jest-coverage-final.json", + "cover-copy-tape": "yarn nyc merge tape-coverage .nyc_output/coverage.json", + "cover-copy-jest": "yarn nyc merge jest-coverage .nyc_output/coverage.json", + "cover-copy": "rm -rf .nyc_output && mkdir .nyc_output && yarn cover-copy-tape && yarn cover-copy-jest", "cover": "yarn cover-jest && yarn cover-tape && yarn cover-copy && nyc report --reporter=json --reporter=html --reporter=lcov", "start": "NODE_OPTIONS=--openssl-legacy-provider yarn install-and-start -- examples/demo-app start-local", "start:deck": "NODE_OPTIONS=--openssl-legacy-provider yarn install-and-start -- examples/demo-app start-local-deck", diff --git a/src/actions/src/provider-actions.ts b/src/actions/src/provider-actions.ts index cf481c5e88..eb085e0f9d 100644 --- a/src/actions/src/provider-actions.ts +++ b/src/actions/src/provider-actions.ts @@ -21,7 +21,7 @@ import {createAction} from '@reduxjs/toolkit'; import {ACTION_PREFIX} from './action-types'; import {ExportFileOptions, ExportFileToCloudPayload, OnErrorCallBack, OnSuccessCallBack} from '@kepler.gl/types'; -import {MapListItem, Provider} from '@kepler.gl/cloud-providers'; +import {Provider} from '@kepler.gl/cloud-providers'; // eslint-disable-next-line prettier/prettier const assignType = (obj: T): { [K in keyof T]: `${typeof ACTION_PREFIX}${string & K}`; } => obj as any @@ -30,14 +30,10 @@ export const ActionTypes = assignType({ EXPORT_FILE_SUCCESS: `${ACTION_PREFIX}EXPORT_FILE_SUCCESS`, EXPORT_FILE_ERROR: `${ACTION_PREFIX}EXPORT_FILE_ERROR`, RESET_PROVIDER_STATUS: `${ACTION_PREFIX}RESET_PROVIDER_STATUS`, - SET_CLOUD_PROVIDER: `${ACTION_PREFIX}SET_CLOUD_PROVIDER`, POST_SAVE_LOAD_SUCCESS: `${ACTION_PREFIX}POST_SAVE_LOAD_SUCCESS`, LOAD_CLOUD_MAP: `${ACTION_PREFIX}LOAD_CLOUD_MAP`, LOAD_CLOUD_MAP_SUCCESS: `${ACTION_PREFIX}LOAD_CLOUD_MAP_SUCCESS`, LOAD_CLOUD_MAP_ERROR: `${ACTION_PREFIX}LOAD_CLOUD_MAP_ERROR`, - GET_SAVED_MAPS: `${ACTION_PREFIX}GET_SAVED_MAPS`, - GET_SAVED_MAPS_SUCCESS: `${ACTION_PREFIX}GET_SAVED_MAPS_SUCCESS`, - GET_SAVED_MAPS_ERROR: `${ACTION_PREFIX}GET_SAVED_MAPS_ERROR` }); /** @@ -103,15 +99,6 @@ export const resetProviderStatus: () => { type: typeof ActionTypes.RESET_PROVIDER_STATUS; } = createAction(ActionTypes.RESET_PROVIDER_STATUS); -/** SET_CLOUD_PROVIDER */ -export type SetCloudProviderPayload = string | null; -export const setCloudProvider: ( - p: SetCloudProviderPayload -) => { - type: typeof ActionTypes.SET_CLOUD_PROVIDER; - payload: SetCloudProviderPayload; -} = createAction(ActionTypes.SET_CLOUD_PROVIDER, (provider: SetCloudProviderPayload) => ({payload : provider})); - /** LOAD_CLOUD_MAP */ export type LoadCloudMapPayload = { loadParams: any; @@ -157,39 +144,3 @@ export const loadCloudMapError: ( type: typeof ActionTypes.LOAD_CLOUD_MAP_ERROR; payload: LoadCloudMapErrorPayload; } = createAction(ActionTypes.LOAD_CLOUD_MAP_ERROR, (payload: LoadCloudMapErrorPayload) => ({payload})); - -/** GET_SAVED_MAPS */ -export type GetSavedMapsPayload = string; -export const getSavedMaps: ( - p: GetSavedMapsPayload -) => { - type: typeof ActionTypes.GET_SAVED_MAPS; - payload: GetSavedMapsPayload; -} = createAction(ActionTypes.GET_SAVED_MAPS, (provider: GetSavedMapsPayload) => ({payload : provider})); - -/** GET_SAVED_MAPS_SUCCESS */ -export type GetSavedMapsSuccessPayload = { - visualizations: MapListItem[]; - provider: string; -}; -export const getSavedMapsSuccess: ( - p: GetSavedMapsSuccessPayload -) => { - type: typeof ActionTypes.GET_SAVED_MAPS_SUCCESS; - payload: GetSavedMapsSuccessPayload; -} = createAction( - ActionTypes.GET_SAVED_MAPS_SUCCESS, - (payload: GetSavedMapsSuccessPayload) => ({payload}) -); - -/** GET_SAVED_MAPS_ERROR */ -export type GetSavedMapsErrorPayload = { - error: any; - provider: string; -}; -export const getSavedMapsError: ( - p: GetSavedMapsErrorPayload -) => { - type: typeof ActionTypes.GET_SAVED_MAPS_ERROR; - payload: GetSavedMapsErrorPayload; -} = createAction(ActionTypes.GET_SAVED_MAPS_ERROR, payload => ({payload})); diff --git a/src/cloud-providers/src/provider.ts b/src/cloud-providers/src/provider.ts index 46e2c66c65..b72859c9b2 100644 --- a/src/cloud-providers/src/provider.ts +++ b/src/cloud-providers/src/provider.ts @@ -32,6 +32,12 @@ export type MapListItem = { privateMap?: boolean; }; +export type CloudUser = { + name: string; + email: string; + thumbnail?: string; +}; + export type Thumbnail = { width: number; height: number; @@ -78,7 +84,6 @@ export default class Provider { displayName: string; icon: ComponentType; thumbnail: Thumbnail; - getManagementUrl?: () => string; constructor(props: ProviderProps) { this.name = props.name || NAME; @@ -102,7 +107,7 @@ export default class Provider { * @public */ hasSharingUrl(): boolean { - return true; + return false; } /** @@ -137,12 +142,20 @@ export default class Provider { /** * This method is called to get the user name of the current user. It will be displayed in the cloud provider tile. * @public + * @deprecated please use getUser * @returns true if a user already logged in */ getUserName(): string { return ''; } + /** + * return a Promise with the user object + */ + getUser(): Promise { + return Promise.reject('You must implement getUser'); + } + /** * This return a standard error that will trigger the overwrite map modal */ @@ -152,24 +165,20 @@ export default class Provider { /** * This method will be called when user click the login button in the cloud provider tile. - * Upon login success, `onCloudLoginSuccess` has to be called to notify kepler.gl UI - * @param {function} onCloudLoginSuccess - callbacks to be called after login success + * Upon login success and return the user Object {name, email, abbreviated} * @public */ - async login(onCloudLoginSuccess) { - onCloudLoginSuccess(); - return; + async login() { + return Promise.reject(new Error('you must implement the `login` method')); } /** * This method will be called when user click the logout button under the cloud provider tile. - * Upon login success, `onCloudLoginSuccess` has to be called to notify kepler.gl UI - * @param {function} onCloudLogoutSuccess - callbacks to be called after logout success + * Upon login success * @public */ - async logout(onCloudLogoutSuccess: () => void) { - onCloudLogoutSuccess(); - return; + async logout(): Promise { + return Promise.reject(new Error('you must implement the `logout` method')); } /** @@ -192,7 +201,7 @@ export default class Provider { mapData: MapData; options: ExportFileOptions; }): Promise { - return; + return Promise.reject('You must implement uploadMap'); } /** @@ -248,13 +257,17 @@ export default class Provider { return; } + getManagementUrl(): string { + throw new Error('You must implement getManagementUrl'); + } + /** * @typedef {Object} Viz * @property {string} id - An unique id * @property {string} title - The title of the map * @property {string} description - The description of the map * @property {string} imageUrl - The imageUrl of the map - * @property {number} lastModification - An epoch timestamp in milliseconds + * @property {number} updatedAt - An epoch timestamp in milliseconds * @property {boolean} privateMap - Optional, whether if this map is private to the user, or can be accessed by others via URL * @property {*} loadParams - A property to be passed to `downloadMap` * @public diff --git a/src/components/src/common/flex-container.ts b/src/components/src/common/flex-container.ts new file mode 100644 index 0000000000..ffcb026f63 --- /dev/null +++ b/src/components/src/common/flex-container.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2023 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import styled from 'styled-components'; + +export const FlexContainer = styled.div` + display: flex; + gap: 8px; +`; + +export const FlexColContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const FlexContainerGrow = styled(FlexContainer)` + flex-grow: 1; +`; diff --git a/src/components/src/common/modal.tsx b/src/components/src/common/modal.tsx index 3433f8f45e..9fe76b3df8 100644 --- a/src/components/src/common/modal.tsx +++ b/src/components/src/common/modal.tsx @@ -143,22 +143,39 @@ type ModalFooterProps = { confirmButton?: ModalButtonProps; }; +/** + * this method removes the `disabled` property from button props when disabled is set to false + * to avoid issue with the disabled tag + * + * @param props + */ +const processDisabledProperty = (props: ModalButtonProps): ModalButtonProps => { + if (!props.disabled) { + const {disabled, ...newProps} = props; + return newProps; + } + return props; +}; + export const ModalFooter: React.FC = ({ cancel, confirm, cancelButton, confirmButton }) => { - const cancelButtonProps = {...defaultCancelButton, ...cancelButton}; - const confirmButtonProps = {...defaultConfirmButton, ...confirmButton}; + const cancelButtonProps = processDisabledProperty({ + ...defaultCancelButton, + ...cancelButton + }); + const confirmButtonProps = processDisabledProperty({...defaultConfirmButton, ...confirmButton}); return ( diff --git a/src/components/src/common/styled-components.tsx b/src/components/src/common/styled-components.tsx index c7a7812abf..7c3b969d17 100644 --- a/src/components/src/common/styled-components.tsx +++ b/src/components/src/common/styled-components.tsx @@ -215,7 +215,8 @@ export interface ButtonProps { inactive?: boolean; } -export const Button = styled.div.attrs(props => ({ +// this needs to be an actual button to be able to set disabled attribute correctly +export const Button = styled.button.attrs(props => ({ className: classnames('button', props.className) }))` align-items: center; diff --git a/src/components/src/context.tsx b/src/components/src/context.tsx index 20c6b84452..651cc632ec 100644 --- a/src/components/src/context.tsx +++ b/src/components/src/context.tsx @@ -19,6 +19,7 @@ // THE SOFTWARE. import React, {createContext, RefObject, ReactNode, ReactElement} from 'react'; +import {Provider} from '@kepler.gl/cloud-providers'; const identity = state => state; // New Context API only supported after 16.3 @@ -37,6 +38,12 @@ export type FeatureFlagsContextProviderProps = { featureFlags?: FeatureFlags; }; +export type CloudProviderContextType = { + provider: Provider | null; + setProvider: (provider: Provider | null) => void; + cloudProviders: Provider[]; +}; + export const FeatureFlagsContextProvider = ( props: FeatureFlagsContextProviderProps ): ReactElement => ( @@ -45,6 +52,16 @@ export const FeatureFlagsContextProvider = ( ); +/** + * This provides keeps track of the ist cloud providers + * and the current selected one + */ +export const CloudProviderContext = createContext({ + provider: null, + setProvider: () => {}, + cloudProviders: [] +}); + export const RootContext = createContext | null>(null); export default KeplerGlContext; diff --git a/src/components/src/hooks/use-cloud-list-provider.tsx b/src/components/src/hooks/use-cloud-list-provider.tsx new file mode 100644 index 0000000000..57a9785648 --- /dev/null +++ b/src/components/src/hooks/use-cloud-list-provider.tsx @@ -0,0 +1,35 @@ +import React, {PropsWithChildren, useCallback, useContext, useMemo, useRef, useState} from 'react'; +import {CloudProviderContext} from '../context'; +import {Provider} from '@kepler.gl/cloud-providers'; + +type CloudListProviderProps = PropsWithChildren<{ + providers: Provider[]; +}>; + +export const CloudListProvider: React.FC = ({children, providers = []}) => { + const [currentCloudProvider, setCurrentCloudProvider] = useState(null); + const cloudProviders = useRef(providers); + + const setProvider = useCallback( + provider => { + setCurrentCloudProvider(currentCloudProvider === provider ? null : provider); + }, + [currentCloudProvider] + ); + + const value = useMemo( + () => ({ + provider: currentCloudProvider, + setProvider: setProvider, + cloudProviders: cloudProviders.current + }), + [currentCloudProvider, setCurrentCloudProvider] + ); + + return {children}; +}; + +/** + * this hook provides access the CloudList provider context + */ +export const useCloudListProvider = () => useContext(CloudProviderContext); diff --git a/src/components/src/index.ts b/src/components/src/index.ts index 6d9f045ad7..3986b498a2 100644 --- a/src/components/src/index.ts +++ b/src/components/src/index.ts @@ -80,9 +80,9 @@ export {default as LayerPanelFactory} from './side-panel/layer-panel/layer-panel export {default as SingleColorPalette} from './side-panel/layer-panel/single-color-palette'; export { default as LayerConfiguratorFactory, - getLayerConfiguratorProps, + getLayerConfiguratorProps, getLayerDataset, - getVisConfiguratorProps, + getVisConfiguratorProps, } from './side-panel/layer-panel/layer-configurator'; export {default as TextLabelPanelFactory} from './side-panel/layer-panel/text-label-panel'; @@ -392,4 +392,6 @@ export { // hooks export {default as useFeatureFlags} from './hooks/use-feature-flags'; export {default as useDndLayers} from './hooks/use-dnd-layers'; -export {default as useDndEffects} from './hooks/use-dnd-effects'; \ No newline at end of file +export {default as useDndEffects} from './hooks/use-dnd-effects'; +export {CloudListProvider, useCloudListProvider} from './hooks/use-cloud-list-provider'; + diff --git a/src/components/src/kepler-gl.tsx b/src/components/src/kepler-gl.tsx index fc138873c6..a119f9aff3 100644 --- a/src/components/src/kepler-gl.tsx +++ b/src/components/src/kepler-gl.tsx @@ -65,6 +65,7 @@ import NotificationPanelFactory from './notification-panel'; import GeoCoderPanelFactory from './geocoder-panel'; import EffectManagerFactory from './effects/effect-manager'; import DndContextFactory from './dnd-context'; +import {CloudListProvider} from './hooks/use-cloud-list-provider'; import { filterObjectByPredicate, @@ -476,7 +477,10 @@ function KeplerGlFactory( readOnly, // features - featureFlags + featureFlags, + + // cloud providers + cloudProviders = [] } = this.props; const dimensions = this.state.dimensions || {width, height}; @@ -525,55 +529,57 @@ function KeplerGlFactory( - - - - {!uiState.readOnly && !readOnly && } - - {mapContainers} - - - {isExportingImage && } - {/* 1 geocoder: single mode OR split mode and synced viewports */} - {!isViewportDisjointed(this.props) && interactionConfig.geocoder.enabled && ( - - )} - {/* 2 geocoders: split mode and unsynced viewports */} - {isViewportDisjointed(this.props) && - interactionConfig.geocoder.enabled && - mapContainers.map((_mapContainer, index) => ( - + + + + {!uiState.readOnly && !readOnly && } + + {mapContainers} + + + {isExportingImage && } + {/* 1 geocoder: single mode OR split mode and synced viewports */} + {!isViewportDisjointed(this.props) && interactionConfig.geocoder.enabled && ( + + )} + {/* 2 geocoders: split mode and unsynced viewports */} + {isViewportDisjointed(this.props) && + interactionConfig.geocoder.enabled && + mapContainers.map((_mapContainer, index) => ( + + ))} + + - ))} - - + - - - + + diff --git a/src/components/src/modal-container.tsx b/src/components/src/modal-container.tsx index 24f3cb3c93..5af57cbbe5 100644 --- a/src/components/src/modal-container.tsx +++ b/src/components/src/modal-container.tsx @@ -20,12 +20,11 @@ import React, {Component} from 'react'; import {css} from 'styled-components'; -import {createSelector} from 'reselect'; import get from 'lodash.get'; import document from 'global/document'; import ModalDialogFactory from './modals/modal-dialog'; -import {exportHtml, isValidMapInfo, exportMap, exportJson, exportImage} from '@kepler.gl/utils'; +import {exportHtml, exportMap, exportJson, exportImage} from '@kepler.gl/utils'; import { exportData, getFileFormatNames, @@ -166,14 +165,6 @@ export default function ModalContainerFactory( document.removeEventListener('keyup', this._onKeyUp); } - cloudProviders = (props: ModalContainerProps) => props.cloudProviders; - providerWithStorage = createSelector(this.cloudProviders, cloudProviders => - cloudProviders.filter(p => p.hasPrivateStorage()) - ); - providerWithShare = createSelector(this.cloudProviders, cloudProviders => - cloudProviders.filter(p => p.hasSharingUrl()) - ); - _onKeyUp = event => { const keyCode = event.keyCode; if (keyCode === KeyEvent.DOM_VK_ESCAPE) { @@ -239,10 +230,7 @@ export default function ModalContainerFactory( }); }; - _onSaveMap = (overwrite = false) => { - const {currentProvider} = this.props.providerState; - // @ts-ignore - const provider = this.props.cloudProviders.find(p => p.name === currentProvider); + _onSaveMap = (provider, overwrite = false) => { this._exportFileToCloud({ provider, isPublic: false, @@ -355,9 +343,6 @@ export default function ModalContainerFactory( onClose={this._closeModal} onFileUpload={this._onFileUpload} onLoadCloudMap={this._onLoadCloudMap} - cloudProviders={this.providerWithStorage(this.props)} - onSetCloudProvider={this.props.providerActions.setCloudProvider} - getSavedMaps={this.props.providerActions.getSavedMaps} loadFiles={uiState.loadFiles} fileLoading={visState.fileLoading} fileLoadingProgress={visState.fileLoadingProgress} @@ -478,62 +463,40 @@ export default function ModalContainerFactory( exportImage={uiState.exportImage} mapInfo={visState.mapInfo} onSetMapInfo={visStateActions.setMapInfo} - cloudProviders={this.providerWithStorage(this.props)} - onSetCloudProvider={this.props.providerActions.setCloudProvider} cleanupExportImage={uiStateActions.cleanupExportImage} onUpdateImageSetting={uiStateActions.setExportImageSetting} + onCancel={this._closeModal} + onConfirm={provider => this._onSaveMap(provider, false)} /> ); modalProps = { title: 'modal.title.saveMap', cssStyle: '', - footer: true, - onCancel: this._closeModal, - onConfirm: () => this._onSaveMap(false), - confirmButton: { - large: true, - disabled: - uiState.exportImage.processing || - !isValidMapInfo(visState.mapInfo) || - !providerState.currentProvider, - children: 'modal.button.save' - } + footer: false }; break; case OVERWRITE_MAP_ID: template = ( ); modalProps = { title: 'Overwrite Existing File?', cssStyle: smallModalCss, - footer: true, - onConfirm: this._onOverwriteMap, - onCancel: this._closeModal, - confirmButton: { - large: true, - children: 'Yes', - disabled: - uiState.exportImage.processing || - !isValidMapInfo(visState.mapInfo) || - !providerState.currentProvider - } + footer: false }; break; case SHARE_MAP_ID: template = ( diff --git a/src/components/src/modals/cloud-components/cloud-header.tsx b/src/components/src/modals/cloud-components/cloud-header.tsx new file mode 100644 index 0000000000..5a067b4942 --- /dev/null +++ b/src/components/src/modals/cloud-components/cloud-header.tsx @@ -0,0 +1,96 @@ +// Copyright (c) 2023 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import React, {useMemo} from 'react'; +import {Button} from '../../common/styled-components'; +import {ArrowLeft} from '../../common/icons'; +import {FormattedMessage} from '@kepler.gl/localization'; +import styled from 'styled-components'; +import {dataTestIds} from '@kepler.gl/constants'; +import {Provider} from '@kepler.gl/cloud-providers'; + +const StyledStorageHeader = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + font-size: 12px; + line-height: 14px; +`; + +const StyledBackBtn = styled.a` + margin-bottom: 16px; + color: #3a414c; + cursor: pointer; + + &:hover { + font-weight: 500; + } +`; + +const LINK_STYLE = {textDecoration: 'underline'}; + +const Title = styled.span` + font-size: 14px; + line-height: 16px; + font-weight: 500; + margin-bottom: 16px; + + span { + text-transform: capitalize; + } +`; + +type CloudHeaderProps = { + provider: Provider; + onBack: () => void; +}; + +export const CloudHeader: React.FC = ({provider, onBack}) => { + const managementUrl = useMemo(() => provider.getManagementUrl(), [provider]); + return ( +
+ + + + + {managementUrl && ( + + {provider.displayName} + + )} + + + <span>{provider.displayName}</span> + <FormattedMessage id={'modal.loadStorageMap.storageMaps'} /> + +
+ ); +}; diff --git a/src/components/src/modals/cloud-components/cloud-item.spec.tsx b/src/components/src/modals/cloud-components/cloud-item.spec.tsx new file mode 100644 index 0000000000..1150c177e4 --- /dev/null +++ b/src/components/src/modals/cloud-components/cloud-item.spec.tsx @@ -0,0 +1,59 @@ +// @ts-nocheck + +//colocating test next the file +import React from 'react'; +import {fireEvent} from '@testing-library/react'; +import {renderWithTheme} from '../../../../../test/helpers/component-jest-utils'; + +import {CloudItem} from './cloud-item'; +import moment from 'moment'; + +describe('CloudItem', () => { + const mockVis = { + title: 'Test Title', + description: 'Test Description', + lastModification: new Date().toISOString(), + thumbnail: 'test-thumbnail.jpg', + privateMap: true + }; + + it('renders without crashing', () => { + const {getByText} = renderWithTheme( {}} />); + expect(getByText('Test Title')).toBeInTheDocument(); + }); + + it('renders PrivacyBadge for private maps', () => { + const {getByText} = renderWithTheme( {}} />); + expect(getByText('Private')).toBeInTheDocument(); + }); + + it('does not render PrivacyBadge for public maps', () => { + const {queryByText} = renderWithTheme( {}} />); + expect(queryByText('Private')).toBeNull(); + }); + + it('displays correct thumbnail image', () => { + const {getByRole} = renderWithTheme( {}} />); + expect(getByRole('thumbnail-wrapper').style.backgroundImage).toContain('test-thumbnail.jpg'); + }); + + it('displays MapIcon when no thumbnail is provided', () => { + const {getByRole} = renderWithTheme( {}} />); + expect(getByRole('map-icon')).toBeInTheDocument(); + }); + + it('displays title, description, and last modification date', () => { + const {getByText} = renderWithTheme( {}} />); + expect(getByText('Test Title')).toBeInTheDocument(); + expect(getByText('Test Description')).toBeInTheDocument(); + expect(getByText(`Last modified ${moment.utc(mockVis.lastModification).fromNow()}`)).toBeInTheDocument(); + }); + + it('calls onClick when component is clicked', () => { + const onClickMock = jest.fn(); + const {getByText} = renderWithTheme(); + fireEvent.click(getByText('Test Title')); + expect(onClickMock).toHaveBeenCalled(); + }); +}); + diff --git a/src/components/src/modals/cloud-components/cloud-item.tsx b/src/components/src/modals/cloud-components/cloud-item.tsx new file mode 100644 index 0000000000..e7d57d588b --- /dev/null +++ b/src/components/src/modals/cloud-components/cloud-item.tsx @@ -0,0 +1,148 @@ +// Copyright (c) 2023 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import moment from 'moment/moment'; +import React from 'react'; +import styled from 'styled-components'; +import {Base} from '../../common/icons'; + +const MapIcon = props => { + return ( +
+ {props.children} + + + +
+ ); +}; + +const PrivacyBadge = ({privateMap}) => ( + {privateMap ? 'Private' : 'Public'} +); + +const StyledVisualizationItem = styled.div` + flex: 0 0 auto; + width: 208px; + display: flex; + flex-direction: column; + padding: 16px 8px; + color: #3a414c; + cursor: pointer; + font-size: 12px; + line-height: 18px; + border: 1px solid transparent; + + &:hover { + .vis_item-icon, + .vis_item-thumb, + .vis_item-description, + .vis_item-modification-date { + opacity: 1; + } + border: 1px solid #bbbbbb; + } + + .vis_item-icon, + .vis_item-thumb, + .vis_item-description, + .vis_item-modification-date { + opacity: 0.9; + transition: opacity 0.4s ease; + } + + .vis_item-icon { + position: relative; + flex: 0 0 108px; + background-color: #6a7484; + border-radius: 4px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + } + + .vis_item-thumb { + position: relative; + flex: 0 0 108px; + background-size: cover; + background-position: center; + border-radius: 4px; + } + + .vis_item-privacy { + position: absolute; + top: 0; + left: 0; + padding: 3px 6px; + border-radius: 4px 0; + background-color: rgba(58, 65, 76, 0.7); + color: #fff; + font-size: 11px; + line-height: 18px; + } + + .vis_item-title { + margin-top: 16px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .vis_item-description { + flex: 1 1 auto; + margin-top: 8px; + } + + .vis_item-modification-date { + margin-top: 16px; + flex: 1 0 auto; + color: #6a7484; + line-height: 15px; + } +`; + +export const CloudItem = ({vis, onClick}) => { + const thumbnailStyle = {backgroundImage: `url(${vis.thumbnail})`}; + return ( + + {vis.thumbnail ? ( +
+ {vis.hasOwnProperty('privateMap') ? : null} +
+ ) : ( + + {vis.hasOwnProperty('privateMap') ? : null} + + )} + {vis.title} + {vis.description?.length && ( + {vis.description} + )} + + Last modified {moment.utc(vis.lastModification).fromNow()} + +
+ ); +}; diff --git a/src/components/src/modals/cloud-components/cloud-maps.spec.tsx b/src/components/src/modals/cloud-components/cloud-maps.spec.tsx new file mode 100644 index 0000000000..8b884bd283 --- /dev/null +++ b/src/components/src/modals/cloud-components/cloud-maps.spec.tsx @@ -0,0 +1,46 @@ +// @ts-nocheck +import React from 'react'; +import {fireEvent} from '@testing-library/react'; +import {renderWithTheme} from '../../../../../test/helpers/component-jest-utils'; +import {CloudMaps} from './cloud-maps'; + +describe('CloudMaps Component', () => { + it('renderWithThemes without crashing', () => { + const {getByText} = renderWithTheme(); + expect(getByText(/noSavedMaps/i)).toBeInTheDocument(); + }); + + it('displays error message when there is an error', () => { + const errorMessage = 'Test Error'; + const {getByText} = renderWithTheme(); + expect(getByText(`Error while fetching maps: ${errorMessage}`)).toBeInTheDocument(); + }); + + it('displays loading spinner when isLoading is true', () => { + const {getByText} = renderWithTheme(); + expect(getByText('modal.loadingDialog.loading')).toBeInTheDocument(); // Ensure your spinner has 'data-testid="loading-spinner"' + }); + + it('renderWithThemes correct number of CloudItems based on maps prop', () => { + const mockMaps = [{ id: 1, title: 'map' }, { id: 2, title: 'map' }, { id: 3, title: 'map' }]; + const {getAllByText} = renderWithTheme(); + expect(getAllByText('map')).toHaveLength(mockMaps.length); // Ensure your CloudItem has 'data-testid="cloud-item"' + }); + + it('displays message when there are no maps', () => { + const {getByText} = renderWithTheme(); + expect(getByText(/noSavedMaps/i)).toBeInTheDocument(); + }); + + it('calls onSelectMap when a CloudItem is clicked', () => { + const mockMaps = [{ id: 1, title: 'map' }, { id: 2, title: 'map' }, { id: 3, title: 'map' }]; + const onSelectMap = jest.fn(); + const provider = 'testProvider'; + const {getAllByText} = renderWithTheme(); + + const firstItem = getAllByText('map')[0]; + fireEvent.click(firstItem); + expect(onSelectMap).toHaveBeenCalledWith(provider, mockMaps[0]); + }); +}); + diff --git a/src/components/src/modals/cloud-components/cloud-maps.tsx b/src/components/src/modals/cloud-components/cloud-maps.tsx new file mode 100644 index 0000000000..9387b778f6 --- /dev/null +++ b/src/components/src/modals/cloud-components/cloud-maps.tsx @@ -0,0 +1,61 @@ +// Copyright (c) 2023 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import React from 'react'; +import styled from 'styled-components'; +import LoadingDialog from '../loading-dialog'; +import {FormattedMessage} from '@kepler.gl/localization'; +import {CloudItem} from './cloud-item'; +import {FlexContainer} from '../../common/flex-container'; + +const StyledSpinner = styled.div` + text-align: center; + span { + margin: 0 auto; + } +`; + +export const CloudMaps = ({provider, onSelectMap, isLoading, maps, error}) => { + if (error) { + return
Error while fetching maps: {error.message}
; + } + + if (isLoading) { + return ( + + + + ); + } + + return ( + + {(maps ?? []).length ? ( + maps.map(vis => ( + onSelectMap(provider, vis)} vis={vis} /> + )) + ) : ( +
+ +
+ )} +
+ ); +}; diff --git a/src/components/src/modals/cloud-components/provider-loading.tsx b/src/components/src/modals/cloud-components/provider-loading.tsx new file mode 100644 index 0000000000..b5775dff11 --- /dev/null +++ b/src/components/src/modals/cloud-components/provider-loading.tsx @@ -0,0 +1,38 @@ +// Copyright (c) 2023 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import React from 'react'; +import LoadingDialog from '../loading-dialog'; +import styled from 'styled-components'; + +const StyledSpinner = styled.div` + text-align: center; + span { + margin: 0 auto; + } +`; + +export const ProviderLoading = () => { + return ( + + + + ); +}; diff --git a/src/components/src/modals/provider-modal-container.tsx b/src/components/src/modals/cloud-components/provider-select.tsx similarity index 50% rename from src/components/src/modals/provider-modal-container.tsx rename to src/components/src/modals/cloud-components/provider-select.tsx index 6a8baf34a9..a8c4132fca 100644 --- a/src/components/src/modals/provider-modal-container.tsx +++ b/src/components/src/modals/cloud-components/provider-select.tsx @@ -18,45 +18,33 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import React, {Component} from 'react'; +import CloudTile from '../cloud-tile'; +import React from 'react'; +import styled from 'styled-components'; import {Provider} from '@kepler.gl/cloud-providers'; -import {SetCloudProviderPayload} from '@kepler.gl/actions'; +import {dataTestIds} from '@kepler.gl/constants'; -export type ProviderModalContainerProps = { - cloudProviders?: Provider[]; - currentProvider?: string | null; - onSetCloudProvider: (provider: SetCloudProviderPayload) => void; - children?: React.ReactNode; -}; - -/** - * A wrapper component in modals contain cloud providers. - * It set default provider by checking which provider has logged in - * @component - */ -export default class ProviderModalContainer extends Component { - static defaultProps = { - cloudProviders: [], - currentProvider: null - }; - - componentDidMount() { - this._setDefaultProvider(); - } +const StyledProviderSection = styled.div.attrs({ + className: 'provider-selection' +})` + display: flex; + gap: 8px; +`; - _setDefaultProvider() { - if (!this.props.currentProvider && this.props.cloudProviders?.length) { - const connected = this.props.cloudProviders?.find( - p => typeof p.getAccessToken === 'function' && p.getAccessToken() - ); - - if (connected && typeof this.props.onSetCloudProvider === 'function') { - this.props.onSetCloudProvider(connected.name); - } - } - } +type ProviderSelectProps = { + cloudProviders: Provider[]; +}; - render() { - return <>{this.props.children}; - } -} +export const ProviderSelect: React.FC = ({cloudProviders = []}) => ( +
+ {cloudProviders.length ? ( + + {cloudProviders.map(provider => ( + + ))} + + ) : ( +

No storage provider available

+ )} +
+); diff --git a/src/components/src/modals/cloud-tile.tsx b/src/components/src/modals/cloud-tile.tsx index 35e41d4c25..c435dbf8f5 100644 --- a/src/components/src/modals/cloud-tile.tsx +++ b/src/components/src/modals/cloud-tile.tsx @@ -18,12 +18,13 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import React from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import styled from 'styled-components'; import {Logout, Login} from '../common/icons'; import {CenterVerticalFlexbox, Button, CheckMark} from '../common/styled-components'; -import LoadingSpinner from '../common/loading-spinner'; import {Provider} from '@kepler.gl/cloud-providers'; +import {useCloudListProvider} from '../hooks/use-cloud-list-provider'; +import {CloudUser} from '@kepler.gl/cloud-providers/src/provider'; interface StyledTileWrapperProps { selected?: boolean; @@ -78,7 +79,7 @@ const StyledUserName = styled.div` `; interface OnClickProps { - onClick?: React.MouseEventHandler; + onClick?: React.MouseEventHandler; } const LoginButton = ({onClick}: OnClickProps) => ( @@ -95,85 +96,86 @@ const LogoutButton = ({onClick}: OnClickProps) => ( ); -interface ActionButtonProps { - isConnected?: boolean; - actionName?: string | null; - isReady?: boolean; -} - -const ActionButton = ({isConnected, actionName = null, isReady}: ActionButtonProps) => - isConnected && actionName ? ( - - ) : null; - interface CloudTileProps { - onSelect?: React.MouseEventHandler; - // default to login - onConnect?: (() => void) | null; - // default to logout - onLogout?: (() => void) | null; - // action name actionName?: string | null; // cloud provider class - cloudProvider: Provider; - // function to take after login or logout - onSetCloudProvider: (providerName: string | null) => void; - // whether provider is selected as currentProvider - isSelected?: boolean; - // whether user has logged in - isConnected?: boolean; - isReady?: boolean; + provider: Provider; } -const CloudTile: React.FC = ({ - // action when click on the tile - onSelect, - // default to login - onConnect = null, - // default to logout - onLogout = null, - // action name - actionName = null, - // cloud provider class - cloudProvider, - // function to take after login or logout - onSetCloudProvider, - // whether provider is selected as currentProvider - isSelected, - // whether user has logged in - isConnected, - - isReady = true -}) => { - const userName = - typeof cloudProvider.getUserName === 'function' ? cloudProvider.getUserName() : null; - - const onClickConnect = - typeof onConnect === 'function' - ? onConnect - : () => cloudProvider.login(() => onSetCloudProvider(cloudProvider.name)); - - const onClickLogout = - typeof onLogout === 'function' - ? onLogout - : () => cloudProvider.logout(() => (isSelected ? onSetCloudProvider(null) : null)); +/** + * this component display a provider and allows users to select and set the current provider + * @param provider + * @param actionName + * @constructor + */ +const CloudTile: React.FC = ({provider, actionName}) => { + const [user, setUser] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const {provider: currentProvider, setProvider} = useCloudListProvider(); + const isSelected = provider === currentProvider; + + useEffect(() => { + if (provider.getAccessToken()) { + setError(null); + setIsLoading(true); + setError(null); + provider + .getUser() + .then(user => setUser(user)) + .catch(setError) + .finally(() => setIsLoading(false)); + } + }, [provider]); + + const onLogin = useCallback(async () => { + try { + const user = await provider.login(); + setUser(user); + setProvider(provider); + } catch (error) { + setError(error as Error); + } + }, [provider]); + + const onSelect = useCallback(async () => { + if (isLoading) { + return; + } + if (!user) { + await onLogin(); + } + setProvider(provider); + }, [setProvider, provider, user, isLoading]); + + const onLogout = useCallback(async () => { + setIsLoading(true); + try { + await provider.logout(); + } catch (error) { + setError(error as Error); + } + setIsLoading(false); + setUser(null); + setProvider(null); + }, [provider]); + + const {displayName, name} = provider; return ( - - {cloudProvider.displayName || cloudProvider.name} - {cloudProvider.icon ? : null} - - {userName && {userName}} - {isSelected && } + + {displayName || name} + {provider.icon ? : null} + {isLoading ? ( +
Loading ...
+ ) : ( + <>{user ? {actionName || user.name} : null} + )} + {isSelected ? : null}
- {isConnected ? ( - - ) : ( - - )} + {user ? : } + {error ?
{error.message}
: null}
); }; diff --git a/src/components/src/modals/error-display.tsx b/src/components/src/modals/error-display.tsx index 0405683f1b..3159de6a76 100644 --- a/src/components/src/modals/error-display.tsx +++ b/src/components/src/modals/error-display.tsx @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import React from 'react'; +import React, {useMemo} from 'react'; import ErrorBoundary from '../common/error-boundary'; import NotificationItemFactory from '../notification-panel/notification-item'; const NotificationItem = NotificationItemFactory(); @@ -27,17 +27,21 @@ interface ErrorDisplayProps { error: string; } -const ErrorDisplay: React.FC = ({error}) => ( - - - -); +const ErrorDisplay: React.FC = ({error}) => { + const notification = useMemo( + () => ({ + type: 'error', + message: error, + id: 'cloud-export-error' + }), + [error] + ); + + return ( + + + + ); +}; export default ErrorDisplay; diff --git a/src/components/src/modals/image-modal-container.tsx b/src/components/src/modals/image-modal-container.tsx index 8c53acd5d4..dd4083deb4 100644 --- a/src/components/src/modals/image-modal-container.tsx +++ b/src/components/src/modals/image-modal-container.tsx @@ -25,16 +25,14 @@ import {MAP_THUMBNAIL_DIMENSION, EXPORT_IMG_RATIOS} from '@kepler.gl/constants'; import {SetExportImageSettingUpdaterAction} from '@kepler.gl/actions'; import {Provider} from '@kepler.gl/cloud-providers'; -/** @typedef {import('./image-modal-container').ImageModalContainerProps} ImageModalContainerProps */ - export type ImageModalContainerProps = { - cloudProviders?: Provider[]; - currentProvider?: string | null; + provider?: Provider | null; onUpdateImageSetting: (newSetting: SetExportImageSettingUpdaterAction['payload']) => void; cleanupExportImage: () => void; children?: React.ReactNode; }; +// TODO: this should be turned into a custom hook /** * A wrapper component in modals contain a image preview of the map with cloud providers * It sets export image size based on provider thumbnail size @@ -43,8 +41,7 @@ export type ImageModalContainerProps = { const ImageModalContainer: React.FC = ({ onUpdateImageSetting, cleanupExportImage, - cloudProviders, - currentProvider, + provider, children }) => { useEffect(() => { @@ -55,10 +52,8 @@ const ImageModalContainer: React.FC = ({ }, [onUpdateImageSetting, cleanupExportImage]); useEffect(() => { - if (currentProvider && cloudProviders && cloudProviders.length) { - const provider = cloudProviders.find(p => p.name === currentProvider); - - if (provider && provider.thumbnail) { + if (provider) { + if (provider.thumbnail) { onUpdateImageSetting({ mapW: get(provider, ['thumbnail', 'width']) || MAP_THUMBNAIL_DIMENSION.width, mapH: get(provider, ['thumbnail', 'height']) || MAP_THUMBNAIL_DIMENSION.height, @@ -74,13 +69,9 @@ const ImageModalContainer: React.FC = ({ legend: false }); } - }, [currentProvider, cloudProviders, onUpdateImageSetting]); + }, [provider, onUpdateImageSetting]); return <>{children}; }; -ImageModalContainer.defaultProps = { - cloudProviders: [] -}; - export default ImageModalContainer; diff --git a/src/components/src/modals/load-data-modal.tsx b/src/components/src/modals/load-data-modal.tsx index c03f6d42a7..051d9a08ba 100644 --- a/src/components/src/modals/load-data-modal.tsx +++ b/src/components/src/modals/load-data-modal.tsx @@ -30,8 +30,6 @@ import LoadingDialog from './loading-dialog'; import {LOADING_METHODS} from '@kepler.gl/constants'; import {FileLoading, FileLoadingProgress, LoadFiles} from '@kepler.gl/types'; -import {Provider} from '@kepler.gl/cloud-providers'; -import {SetCloudProviderPayload, ProviderActions, ActionHandler} from '@kepler.gl/actions'; /** @typedef {import('./load-data-modal').LoadDataModalProps} LoadDataModalProps */ @@ -68,10 +66,7 @@ type LoadDataModalProps = { /** Set to true if app wants to do its own file filtering */ disableExtensionFilter?: boolean; onClose?: (...args: any) => any; - cloudProviders?: Provider[]; - onSetCloudProvider: (provider: SetCloudProviderPayload) => void; - getSavedMaps: ActionHandler; loadFiles: LoadFiles; fileLoadingProgress: FileLoadingProgress; }; diff --git a/src/components/src/modals/load-storage-map.spec.tsx b/src/components/src/modals/load-storage-map.spec.tsx new file mode 100644 index 0000000000..3a272e42a9 --- /dev/null +++ b/src/components/src/modals/load-storage-map.spec.tsx @@ -0,0 +1,160 @@ +// @ts-nocheck +import React from 'react'; +import {fireEvent, waitFor} from '@testing-library/react'; +import LoadStorageMapFactory from './load-storage-map'; +import {renderWithTheme} from 'test/helpers/component-jest-utils'; +import {useCloudListProvider} from '../hooks/use-cloud-list-provider'; +import {dataTestIds} from '@kepler.gl/constants'; + +const LoadStorageMap = LoadStorageMapFactory(); + +const DEFAULT_MAPS = [ + { + id: '1234', + title: 'first map', + description: 'description 1', + loadParams: { + id: '1234' + } + }, + { + id: '5678', + title: 'second map', + description: 'description 2', + loadParams: { + id: '5678' + } + } +]; + +const DEFAULT_PROVIDER = { + name: 'test provider', + icon: jest.fn(), + getManagementUrl: jest.fn().mockImplementation(() => 'provider.url'), + listMaps: jest.fn().mockResolvedValue([]) +}; + +const DEFAULT_PROPS = { + onLoadCloudMap: jest.fn() +}; + +jest.mock('../hooks/use-cloud-list-provider', () => ({ + useCloudListProvider: jest.fn().mockImplementation(() => ({ + provider: null, + setProvider: jest.fn(), + cloudProviders: [] + })) +})); + +describe('LoadStorageMap', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders provider select and no cloud components when provider is set to null', () => { + const {getByTestId} = renderWithTheme(); + expect(getByTestId(dataTestIds.providerSelect)).toBeInTheDocument(); + }); + + test('renders empty map list because fetchmaps return empty array', async () => { + useCloudListProvider.mockImplementation(() => ({ + provider: DEFAULT_PROVIDER, + setProvider: jest.fn(), + cloudProviders: [] + })); + + const {getByText } = renderWithTheme(); + expect(DEFAULT_PROVIDER.listMaps).toHaveBeenCalled(); + + // first show loading icon + expect(getByText('modal.loadingDialog.loading')).toBeInTheDocument(); + + // show empty maps + await waitFor(() => { + expect(getByText('modal.loadStorageMap.noSavedMaps')).toBeInTheDocument(); + }) + }); + + test('renders map list because', async () => { + const mapProvider = { + name: 'test provider', + icon: jest.fn(), + getManagementUrl: jest.fn().mockImplementation(() => 'provider.url'), + listMaps: jest.fn().mockResolvedValue(DEFAULT_MAPS) + }; + useCloudListProvider.mockImplementation(() => ({ + provider: mapProvider, + setProvider: jest.fn(), + cloudProviders: [] + })); + + const {getByText } = renderWithTheme(); + expect(mapProvider.listMaps).toHaveBeenCalled(); + + // first show loading icon + expect(getByText('modal.loadingDialog.loading')).toBeInTheDocument(); + + // show empty maps + await waitFor(() => { + DEFAULT_MAPS.forEach(map => { + expect(getByText(map.title)).toBeInTheDocument(); + expect(getByText(map.description)).toBeInTheDocument(); + }) + }) + }); + + test('trigger onLoadCLoudMap when clicking on a map', async () => { + const mapProvider = { + name: 'test provider', + icon: jest.fn(), + getManagementUrl: jest.fn().mockImplementation(() => 'provider.url'), + listMaps: jest.fn().mockResolvedValue(DEFAULT_MAPS) + }; + useCloudListProvider.mockImplementation(() => ({ + provider: mapProvider, + setProvider: jest.fn(), + cloudProviders: [] + })); + + const {getByText } = renderWithTheme(); + expect(mapProvider.listMaps).toHaveBeenCalled(); + + // first show loading icon + expect(getByText('modal.loadingDialog.loading')).toBeInTheDocument(); + + // click on a map + await waitFor(() => { + const map = DEFAULT_MAPS[0]; + // if the component doesn't exist this will throw an exception + const mapTitleComponent = getByText(map.title); + fireEvent.click(mapTitleComponent); + expect(DEFAULT_PROPS.onLoadCloudMap).toHaveBeenCalled(); + }); + }); + + test('renders errors because fetchmaps rejects', async () => { + const rejectableProvider = { + name: 'test provider', + icon: jest.fn(), + getManagementUrl: jest.fn().mockImplementation(() => 'provider.url'), + listMaps: jest.fn().mockRejectedValue(new Error('timeout')) + } + useCloudListProvider.mockImplementation(() => ({ + provider: rejectableProvider, + setProvider: jest.fn(), + cloudProviders: [] + })); + + const {getByText } = renderWithTheme(); + expect(rejectableProvider.listMaps).toHaveBeenCalled(); + + // first show loading icon + expect(getByText('modal.loadingDialog.loading')).toBeInTheDocument(); + + // show empty maps + await waitFor(() => { + expect(getByText('Error while fetching maps: timeout')).toBeInTheDocument(); + }); + }); +}); + diff --git a/src/components/src/modals/load-storage-map.tsx b/src/components/src/modals/load-storage-map.tsx index 27232252da..46beda8d60 100644 --- a/src/components/src/modals/load-storage-map.tsx +++ b/src/components/src/modals/load-storage-map.tsx @@ -18,397 +18,71 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import React, {Component} from 'react'; -import styled from 'styled-components'; -import moment from 'moment'; - -import LoadingDialog from './loading-dialog'; -import {Button} from '../common/styled-components'; -import CloudTile from './cloud-tile'; -import {Base} from '../common/icons'; -import {ArrowLeft} from '../common/icons'; -import ProviderModalContainer from './provider-modal-container'; -import {FormattedMessage} from '@kepler.gl/localization'; -import {MapListItem, Provider} from '@kepler.gl/cloud-providers'; - -const StyledProviderSection = styled.div.attrs({ - className: 'provider-selection' -})` - display: flex; -`; - -const StyledSpinner = styled.div` - text-align: center; - span { - margin: 0 auto; - } -`; - -const StyledVisualizationSection = styled.div` - display: flex; - flex-direction: column; - align-items: stretch; -`; - -const StyledStorageHeader = styled.div` - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - margin-bottom: 16px; - font-size: 12px; - line-height: 14px; -`; - -const StyledBackBtn = styled.a` - margin-bottom: 16px; - color: #3a414c; - cursor: pointer; - - &:hover { - font-weight: 500; - } -`; - -const StyledProviderVisSection = styled.div` - flex: 1 1 auto; - background-color: #f8f8f9; - padding: 20px 24px; - min-height: 280px; - - .title { - font-size: 14px; - line-height: 16px; - font-weight: 500; - margin-bottom: 16px; - - span { - text-transform: capitalize; - } - } -`; - -const StyledSeparator = styled.hr` - border: solid #bfbfbf; - border-width: 0 0 1px 0; - margin-bottom: 16px; -`; - -const StyledVisualizationList = styled.div` - display: flex; - flex-flow: row wrap; - align-items: stretch; - justify-content: space-between; -`; - -const StyledVisualizationItem = styled.div` - flex: 0 0 auto; - width: 208px; - display: flex; - flex-direction: column; - padding: 16px 8px; - color: #3a414c; - cursor: pointer; - font-size: 12px; - line-height: 18px; - - &:hover { - .vis_item-icon, - .vis_item-thumb, - .vis_item-description, - .vis_item-modification-date { - opacity: 1; - } - } - - .vis_item-icon, - .vis_item-thumb, - .vis_item-description, - .vis_item-modification-date { - opacity: 0.9; - transition: opacity 0.4s ease; - } - - .vis_item-icon { - position: relative; - flex: 0 0 108px; - background-color: #6a7484; - border-radius: 4px; - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - } - - .vis_item-thumb { - position: relative; - flex: 0 0 108px; - background-size: cover; - background-position: center; - border-radius: 4px; - } - - .vis_item-privacy { - position: absolute; - top: 0; - left: 0; - padding: 3px 6px; - border-radius: 4px 0; - background-color: rgba(58, 65, 76, 0.7); - color: #fff; - font-size: 11px; - line-height: 18px; - } - - .vis_item-title { - margin-top: 16px; - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .vis_item-description { - flex: 1 1 auto; - margin-top: 8px; - } - - .vis_item-modification-date { - margin-top: 16px; - flex: 1 0 auto; - color: #6a7484; - line-height: 15px; - } -`; - -type MapIconPorps = React.DetailedHTMLProps, HTMLDivElement>; - -const MapIcon: React.FC = props => { - return ( -
- {props.children} - - - -
- ); -}; - -interface PrivacyBadgeProps { - privateMap?: boolean; -} - -const PrivacyBadge: React.FC = ({privateMap}) => ( - {privateMap ? 'Private' : 'Public'} -); - -interface Visualization extends MapListItem { - thumbnail?: Blob; -} - -interface VisualizationItemProps { - onClick?: React.MouseEventHandler; - vis: Visualization; -} - -const VisualizationItem: React.FC = ({vis, onClick}) => { - return ( - - {vis.thumbnail ? ( -
- {vis.hasOwnProperty('privateMap') ? : null} -
- ) : ( - - {vis.hasOwnProperty('privateMap') ? : null} - - )} - {vis.title} - {vis.description && vis.description.length && ( - {vis.description} - )} - - Last modified {moment.utc(vis.lastModification).fromNow()} - -
- ); -}; - -interface ProviderSelectProps { - cloudProviders: Provider[]; - onSelect: (name: string) => void; - onSetCloudProvider: () => void; - currentProvider?: string; -} - -export const ProviderSelect: React.FC = ({ - cloudProviders = [], - onSelect, - onSetCloudProvider, - currentProvider -}) => - cloudProviders.length ? ( - - {cloudProviders.map(provider => ( - onSelect(provider.name)} - onSetCloudProvider={onSetCloudProvider} - cloudProvider={provider} - isSelected={provider.name === currentProvider} - isConnected={Boolean(provider.getAccessToken && provider.getAccessToken())} - /> - ))} - - ) : ( -

No storage provider available

- ); - -interface LoadStorageMapProps { - cloudProviders: Provider[]; - onSetCloudProvider; - currentProvider?: string; - getSavedMaps: (provider?: Provider) => void; - onLoadCloudMap: (opts: {loadParams: any; provider?: Provider}) => void; - visualizations: Visualization[]; - isProviderLoading?: boolean; -} - -function LoadStorageMapFactory(): React.ComponentType { - class LoadStorageMap extends Component { - state = { - showProviderSelect: true - }; - - componentDidMount() { - this._getSavedMaps(); - } - - componentDidUpdate(prevProps) { - if (prevProps.currentProvider !== this.props.currentProvider) { - this._getSavedMaps(); - } - } - - _getProvider = () => { - const {currentProvider, cloudProviders} = this.props; - return (cloudProviders || []).find(p => p.name === currentProvider); - }; - - _getSavedMaps() { - const provider = this._getProvider(); +import React, {useCallback, useState, useEffect} from 'react'; +import {CloudHeader} from './cloud-components/cloud-header'; +import {CloudMaps} from './cloud-components/cloud-maps'; +import {useCloudListProvider} from '../hooks/use-cloud-list-provider'; +import {ProviderSelect} from './cloud-components/provider-select'; +import {FlexColContainer} from '../common/flex-container'; +import {Provider, MapListItem} from '@kepler.gl/cloud-providers'; + +function LoadStorageMapFactory() { + const LoadStorageMap = ({onLoadCloudMap}) => { + const {provider: currentProvider, setProvider, cloudProviders} = useCloudListProvider(); + const [isLoading, setIsLoading] = useState(false); + const [maps, setMaps] = useState(null); + const [error, setError] = useState(null); + + const setProviderInfo = useCallback((provider: Provider | null) => { + setMaps(null); + setError(null); if (provider) { - this.props.getSavedMaps(provider); - this.setState({showProviderSelect: false}); + setIsLoading(true); + provider + .listMaps() + .then(maps => { + setMaps(maps) + }) + .catch(error => { + setError(error) + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); } - } + }, []); + + useEffect(() => { + setProviderInfo(currentProvider); + }, [currentProvider]); - _onLoadCloudMap(provider: Provider | undefined, vis: Visualization) { - this.props.onLoadCloudMap({ - loadParams: vis.loadParams, + const onSelectMap = useCallback((provider, map) => { + onLoadCloudMap({ + loadParams: map.loadParams, provider }); - } - - _clickBack = () => { - this.setState({showProviderSelect: true}); - }; - - _selectProvider = providerName => { - this.props.onSetCloudProvider(providerName); - const provider = (this.props.cloudProviders || []).find(p => p.name === providerName); - this.props.getSavedMaps(provider); - this.setState({showProviderSelect: false}); - }; - - render() { - const { - visualizations, - cloudProviders, - currentProvider, - isProviderLoading, - onSetCloudProvider - } = this.props; - - const provider = this._getProvider(); - - return ( - - {this.state.showProviderSelect ? ( - + {!currentProvider ? ( + + ) : ( + <> + setProvider(null)} /> + - ) : ( - <> - {isProviderLoading && ( - - - - )} - {!isProviderLoading && visualizations && ( - - - - - - {provider?.getManagementUrl && ( - - - - )} - - - - {currentProvider} - - - - - {visualizations.length ? ( - visualizations.map(vis => ( - this._onLoadCloudMap(provider, vis)} - vis={vis} - /> - )) - ) : ( -
- -
- )} -
-
-
- )} - - )} -
- ); - } - } + + )} + + ); + }; + return LoadStorageMap; } diff --git a/src/components/src/modals/overwrite-map-modal.tsx b/src/components/src/modals/overwrite-map-modal.tsx index 1ff6b91759..618e715b75 100644 --- a/src/components/src/modals/overwrite-map-modal.tsx +++ b/src/components/src/modals/overwrite-map-modal.tsx @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import React from 'react'; +import React, {useMemo} from 'react'; import styled from 'styled-components'; import {CenterVerticalFlexbox} from '../common/styled-components'; import {UploadAnimation} from './status-panel'; @@ -26,8 +26,8 @@ import {FormattedMessage} from '@kepler.gl/localization'; import ImageModalContainer, {ImageModalContainerProps} from './image-modal-container'; import {Provider} from '@kepler.gl/cloud-providers'; import {cleanupExportImage as cleanupExportImageAction} from '@kepler.gl/actions'; - -/** @typedef {import('./overwrite-map-modal').OverwriteMapModalProps} OverwriteMapModalProps */ +import {useCloudListProvider} from '../hooks/use-cloud-list-provider'; +import {ModalFooter} from '../common/modal'; const StyledMsg = styled.div` margin-top: 24px; @@ -51,13 +51,19 @@ const StyledOverwriteMapModal = styled(CenterVerticalFlexbox)` type OverwriteMapModalProps = { mapSaved: string | null; title: string; - cloudProviders: Provider[]; isProviderLoading: boolean; - currentProvider: string | null; // callbacks onUpdateImageSetting: ImageModalContainerProps['onUpdateImageSetting']; cleanupExportImage: typeof cleanupExportImageAction; + onConfirm: (provider: Provider) => void; + onCancel: () => void; +}; + +const CONFIRM_BUTTON = { + large: true, + children: 'Yes', + disabled: false }; const OverwriteMapModalFactory = () => { @@ -67,17 +73,25 @@ const OverwriteMapModalFactory = () => { const OverwriteMapModal: React.FC = ({ mapSaved, title, - currentProvider, - cloudProviders, isProviderLoading, onUpdateImageSetting, - cleanupExportImage + cleanupExportImage, + onCancel, + onConfirm }) => { - const provider = cloudProviders.find(cp => cp.name === currentProvider); + const {provider} = useCloudListProvider(); + + const confirmButton = useMemo( + () => ({ + ...CONFIRM_BUTTON, + disabled: !provider + }), + [provider] + ); + return ( @@ -101,6 +115,11 @@ const OverwriteMapModalFactory = () => { )} + provider && onConfirm(provider)} + confirmButton={confirmButton} + /> ); }; diff --git a/src/components/src/modals/save-map-modal.spec.tsx b/src/components/src/modals/save-map-modal.spec.tsx new file mode 100644 index 0000000000..1bc6067259 --- /dev/null +++ b/src/components/src/modals/save-map-modal.spec.tsx @@ -0,0 +1,197 @@ +// Copyright (c) 2023 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// @ts-nocheck + +/** + * I decided to move the next to the actual file because it makes it + * extremely easier to mock adn test features. + * It's easier to mock items with jest using the relative path + * rather than trying to mock imports like @kepler.gl/components + * which creates several side effects. + * Colocating tests is much easier + */ + +import React from 'react'; +import {fireEvent} from '@testing-library/react'; +import SaveMapModalFactory from './save-map-modal'; +import {renderWithTheme} from 'test/helpers/component-jest-utils'; +import {useCloudListProvider} from '../hooks/use-cloud-list-provider'; +import {dataTestIds} from '@kepler.gl/constants/src'; + +const SaveMapModal = SaveMapModalFactory(); + +const DEFAULT_PROS = { + mapInfo: { + title: 'Test Map', + description: 'test' + }, + exportImage: jest.fn(), + isProviderLoading: false, + providerError: null, + onUpdateImageSetting: jest.fn(), + cleanupExportImage: jest.fn(), + onSetMapInfo: jest.fn(), + onCancel: jest.fn(), + onConfirm: jest.fn() +}; + +const UNDEFINED_MAP_TITLE_PROPS = { + ...DEFAULT_PROS, + mapInfo: { + description: undefined, + title: undefined + } +}; + +const DEFAULT_PROVIDER = { + name: 'test provider', + icon: jest.fn(), + getManagementUrl: jest.fn().mockImplementation(() => 'provider.url') +}; + +jest.mock('../hooks/use-cloud-list-provider', () => ({ + useCloudListProvider: jest.fn().mockImplementation(() => ({ + provider: null, + setProvider: jest.fn(), + cloudProviders: [] + })) +})); + +describe('SaveMapModal', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders SaveMapModal component with provider set to null and map title set', () => { + const {getByText} = renderWithTheme(); + const confirmButton = getByText('modal.button.save'); + expect(confirmButton).toBeInTheDocument(); + expect(confirmButton).toBeDisabled(); + }); + + test('renders SaveMapModal component with provider set to null and map title not set', () => { + const {getByText} = renderWithTheme(); + const confirmButton = getByText('modal.button.save'); + expect(confirmButton).toBeInTheDocument(); + expect(confirmButton).toBeDisabled(); + }); + + test('renders SaveMapModal component with provider correctly set and map title not set', () => { + useCloudListProvider.mockImplementation(() => ({ + provider: DEFAULT_PROVIDER, + setProvider: jest.fn(), + cloudProviders: [] + })); + const {getByText} = renderWithTheme(); + const confirmButton = getByText('modal.button.save'); + expect(confirmButton).toBeInTheDocument(); + expect(confirmButton).toBeDisabled(); + }); + + test('renders SaveMapModal component with provider correctly set and map title set', () => { + useCloudListProvider.mockImplementation(() => ({ + provider: DEFAULT_PROVIDER, + setProvider: jest.fn(), + cloudProviders: [] + })); + const {getByText} = renderWithTheme(); + const confirmButton = getByText('modal.button.save'); + expect(confirmButton).toBeInTheDocument(); + expect(confirmButton).toBeEnabled(); + }); + + test('calls onCancel when cancel button is clicked', () => { + const {getByText} = renderWithTheme(); + fireEvent.click(getByText('modal.button.defaultCancel')); + expect(DEFAULT_PROS.onCancel).toHaveBeenCalled(); + }); + + test('calls onConfirm with provider when confirm button is clicked', () => { + useCloudListProvider.mockImplementation(() => ({ + provider: DEFAULT_PROVIDER, + setProvider: jest.fn(), + cloudProviders: [] + })); + const {getByText} = renderWithTheme(); + const confirmButton = getByText('modal.button.save'); + fireEvent.click(confirmButton); + expect(DEFAULT_PROS.onConfirm).toHaveBeenCalled(); + }); + + test('does not render loading animation when isProviderLoading is true', () => { + const {queryAllByTestId} = renderWithTheme(); + expect(queryAllByTestId(dataTestIds.providerLoading)).toHaveLength(0); + }); + + test('renders loading animation when isProviderLoading is true', () => { + const {getByTestId} = renderWithTheme( + + ); + expect(getByTestId(dataTestIds.providerLoading)).toBeInTheDocument(); + }); + + test('renders no error if provider error is undefined', () => { + const {queryAllByText} = renderWithTheme(); + expect(queryAllByText('modal.statusPanel.error')).toHaveLength(0); + }); + + test('displays provider error message when providerError is present', () => { + const {getByText} = renderWithTheme( + + ); + expect(getByText('modal.statusPanel.error')).toBeInTheDocument(); + }); + + test('call onSetMapInfo upon typing map (provider is set)', () => { + useCloudListProvider.mockImplementation(() => ({ + provider: DEFAULT_PROVIDER, + setProvider: jest.fn(), + cloudProviders: [] + })); + const {getByTestId, getByPlaceholderText} = renderWithTheme( + + ); + + const mapInfoPanel = getByTestId(dataTestIds.providerMapInfoPanel); + expect(mapInfoPanel).toBeInTheDocument(); + + const titleInput = getByPlaceholderText('Type map title'); + expect(titleInput).toBeInTheDocument(); + + fireEvent.change(titleInput, {target: {value: 'first kepler map'}}); + expect(DEFAULT_PROS.onSetMapInfo).toHaveBeenCalledWith({title: 'first kepler map'}); + }); + + test('call onUpdateImageSetting', () => { + useCloudListProvider.mockImplementation(() => ({ + provider: DEFAULT_PROVIDER, + setProvider: jest.fn(), + cloudProviders: [] + })); + + renderWithTheme(); + + // first time the component mount + expect(DEFAULT_PROS.onUpdateImageSetting).toHaveBeenCalledWith({ + exporting: true + }); + }); +}); diff --git a/src/components/src/modals/save-map-modal.tsx b/src/components/src/modals/save-map-modal.tsx index 6154a67cb6..4c51bf8f5f 100644 --- a/src/components/src/modals/save-map-modal.tsx +++ b/src/components/src/modals/save-map-modal.tsx @@ -18,14 +18,18 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import React from 'react'; +import React, {useMemo} from 'react'; import styled from 'styled-components'; -import CloudTile from './cloud-tile'; import ImageModalContainer, {ImageModalContainerProps} from './image-modal-container'; -import ProviderModalContainer, {ProviderModalContainerProps} from './provider-modal-container'; +import {FlexContainer} from '../common/flex-container'; import StatusPanel, {UploadAnimation} from './status-panel'; - -import {MAP_THUMBNAIL_DIMENSION, MAP_INFO_CHARACTER, ExportImage} from '@kepler.gl/constants'; +import {ProviderSelect} from './cloud-components/provider-select'; +import { + MAP_THUMBNAIL_DIMENSION, + MAP_INFO_CHARACTER, + ExportImage, + dataTestIds +} from '@kepler.gl/constants'; import { StyledModalContent, @@ -40,8 +44,8 @@ import {FormattedMessage} from '@kepler.gl/localization'; import {MapInfo} from '@kepler.gl/types'; import {Provider} from '@kepler.gl/cloud-providers'; import {setMapInfo, cleanupExportImage as cleanupExportImageAction} from '@kepler.gl/actions'; - -/** @typedef {import('./save-map-modal').SaveMapModalProps} SaveMapModalProps */ +import {ModalFooter} from '../common/modal'; +import {useCloudListProvider} from '../hooks/use-cloud-list-provider'; const StyledSaveMapModal = styled.div.attrs({ className: 'save-map-modal' @@ -75,6 +79,7 @@ const StyledSaveMapModal = styled.div.attrs({ `; const nop = _ => {}; +const TEXT_AREA_LIGHT_STYLE = {resize: 'none'}; type CharacterLimits = { title?: number; @@ -84,17 +89,16 @@ type CharacterLimits = { type SaveMapModalProps = { mapInfo: MapInfo; exportImage: ExportImage; - cloudProviders: Provider[]; isProviderLoading: boolean; - currentProvider?: string | null; - providerError?: any; + providerError?: Error; characterLimits?: CharacterLimits; // callbacks - onSetCloudProvider: ProviderModalContainerProps['onSetCloudProvider']; onUpdateImageSetting: ImageModalContainerProps['onUpdateImageSetting']; cleanupExportImage: typeof cleanupExportImageAction; onSetMapInfo: typeof setMapInfo; + onConfirm: (provider: Provider) => void; + onCancel: () => void; }; type MapInfoPanelProps = Pick & { @@ -105,173 +109,173 @@ type MapInfoPanelProps = Pick }; export const MapInfoPanel: React.FC = ({ - mapInfo = {description: '', title: ''}, + mapInfo, characterLimits, onChangeInput -}) => ( -
- -
Name*
-
- onChangeInput('title', e)} - placeholder="Type map title" - /> -
-
- -
-
Description
-
(optional)
-
-
- onChangeInput('description', e)} - placeholder="Type map description" - /> +}) => { + const {description = '', title = ''} = mapInfo; + return ( +
+ +
Name*
+
+ onChangeInput('title', e)} + placeholder="Type map title" + /> +
+
+ + +
Description
+
(optional)
+
+
+ onChangeInput('description', e)} + placeholder="Type map description" + /> +
+ Number(characterLimits?.description) + } + > + {description.length}/{characterLimits?.description || MAP_INFO_CHARACTER.description}{' '} + characters + +
+
+ ); +}; + +const SaveMapHeader = ({cloudProviders}) => { + return ( + +
+
+ +
+
+ +
- Number(characterLimits?.description) - } - > - {mapInfo.description.length}/ - {characterLimits?.description || MAP_INFO_CHARACTER.description} characters - - -
-); + + + ); +}; + +const STYLED_EXPORT_SECTION_STYLE = {margin: '2px 0'}; +const PROVIDER_MANAGER_URL_STYLE = {textDecoration: 'underline'}; function SaveMapModalFactory() { - /** - * @type {React.FunctionComponent} - */ const SaveMapModal: React.FC = ({ mapInfo, exportImage, - characterLimits = {}, - cloudProviders, + characterLimits = MAP_INFO_CHARACTER, isProviderLoading, - currentProvider, providerError, - onSetCloudProvider, - onUpdateImageSetting, + onUpdateImageSetting = nop, cleanupExportImage, - onSetMapInfo + onSetMapInfo, + onCancel, + onConfirm }) => { + const {provider, cloudProviders} = useCloudListProvider(); + const onChangeInput = ( key: string, {target: {value}}: React.ChangeEvent ) => { onSetMapInfo({[key]: value}); }; - const provider = currentProvider ? cloudProviders.find(p => p.name === currentProvider) : null; + + const confirmButton = useMemo( + () => ({ + large: true, + disabled: Boolean(!(provider && mapInfo.title)), + children: 'modal.button.save' + }), + [provider, mapInfo] + ); return ( - - - - - -
-
- -
-
- -
-
-
- {cloudProviders.map(cloudProvider => ( - onSetCloudProvider(cloudProvider.name)} - onSetCloudProvider={onSetCloudProvider} - cloudProvider={cloudProvider} - isSelected={cloudProvider.name === currentProvider} - isConnected={Boolean( - cloudProvider.getAccessToken && cloudProvider.getAccessToken() - )} + + + + {provider && ( + <> + {provider.getManagementUrl ? ( + + + + ) : null} + +
+ - ))} -
-
- {provider && provider.getManagementUrl && ( - -
- + {isProviderLoading ? ( +
+ +
+ ) : ( + + )} - )} - -
- -
- {isProviderLoading ? ( -
- -
- ) : ( - - )} -
- {providerError ? ( - - ) : null} - - - - + + )} + {providerError ? ( + + ) : null} + + + provider && onConfirm(provider)} + confirmButton={confirmButton} + /> + ); }; - SaveMapModal.defaultProps = { - characterLimits: MAP_INFO_CHARACTER, - cloudProviders: [], - providerError: null, - isProviderLoading: false, - onSetCloudProvider: nop, - onUpdateImageSetting: nop - }; - return SaveMapModal; } diff --git a/src/components/src/modals/share-map-modal.spec.tsx b/src/components/src/modals/share-map-modal.spec.tsx new file mode 100644 index 0000000000..421c65141a --- /dev/null +++ b/src/components/src/modals/share-map-modal.spec.tsx @@ -0,0 +1,144 @@ +// @ts-nocheck +import React from 'react'; +import {useCloudListProvider} from '../hooks/use-cloud-list-provider'; +import {renderWithTheme} from 'test/helpers/component-jest-utils'; +import ShareMapUrlModalFactory from './share-map-modal'; +import {dataTestIds} from '@kepler.gl/constants'; +import {act} from '@testing-library/react'; +import {ThemeProvider} from 'styled-components'; +import {IntlProvider} from 'react-intl'; +import {theme} from '@kepler.gl/styles'; +import {messages} from '@kepler.gl/localization'; + +jest.mock('../hooks/use-cloud-list-provider', () => ({ + useCloudListProvider: jest.fn().mockImplementation(() => ({ + provider: null, + setProvider: jest.fn(), + cloudProviders: [] + })) +})); + +const ShareMapUrlModal = ShareMapUrlModalFactory(); + + +const DEFAULT_PROPS = { + isProviderLoading: false, + onExport: jest.fn(), + providerError: null, + successInfo: undefined, + onUpdateImageSetting: jest.fn(), + cleanupExportImage: jest.fn() +} + +describe('ShareMapModal', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders only list of providers', () => { + const {getByText, queryByTestId} = renderWithTheme(); + expect(getByText('modal.shareMap.title')).toBeInTheDocument(); + expect(queryByTestId(dataTestIds.providerShareMap)).toBeNull(); + }); + + test('renders list of provider and sharing section', () => { + const mapProvider = { + name: 'test provider', + icon: jest.fn(), + getManagementUrl: jest.fn().mockImplementation(() => 'provider.url'), + listMaps: jest.fn().mockResolvedValue([]), + hasSharingUrl: jest.fn().mockImplementation(() => true) + }; + useCloudListProvider.mockImplementation(() => ({ + provider: mapProvider, + setProvider: jest.fn(), + cloudProviders: [] + })); + + const {getByText, getByTestId} = renderWithTheme(); + expect(getByText('modal.shareMap.title')).toBeInTheDocument(); + expect(getByTestId(dataTestIds.providerShareMap)).toBeInTheDocument(); + }); + + test('renders loading when isLoading is set to true', () => { + const mapProvider = { + name: 'test provider', + icon: jest.fn(), + getManagementUrl: jest.fn().mockImplementation(() => 'provider.url'), + listMaps: jest.fn().mockResolvedValue([]), + hasSharingUrl: jest.fn().mockImplementation(() => true) + }; + useCloudListProvider.mockImplementation(() => ({ + provider: mapProvider, + setProvider: jest.fn(), + cloudProviders: [] + })); + + const providerLoadingProps = { + ...DEFAULT_PROPS, + isProviderLoading: true + }; + + const {getByText} = renderWithTheme(); + expect(getByText('modal.statusPanel.mapUploading')).toBeInTheDocument(); + }); + + test('calls onExport when provider is set correctly', () => { + const mapProvider = { + name: 'test provider', + icon: jest.fn(), + getManagementUrl: jest.fn().mockImplementation(() => 'provider.url'), + listMaps: jest.fn().mockResolvedValue([]), + hasSharingUrl: jest.fn().mockImplementation(() => true) + }; + useCloudListProvider.mockImplementation(() => ({ + provider: mapProvider, + setProvider: jest.fn(), + cloudProviders: [] + })); + + renderWithTheme(); + + expect(DEFAULT_PROPS.onExport).toHaveBeenCalled(); + }); + + test('calls onExport after provider was updated', () => { + const {rerender} = renderWithTheme(); + + const mapProvider = { + name: 'test provider', + icon: jest.fn(), + getManagementUrl: jest.fn().mockImplementation(() => 'provider.url'), + listMaps: jest.fn().mockResolvedValue([]), + hasSharingUrl: jest.fn().mockImplementation(() => true) + }; + useCloudListProvider.mockImplementation(() => ({ + provider: mapProvider, + setProvider: jest.fn(), + cloudProviders: [] + })); + + act(() => { + rerender( + + + + + + ) + }); + + expect(DEFAULT_PROPS.onExport).toHaveBeenCalled(); + }); + + it('displays share URL when provided', () => { + const shareUrl = 'http://example.com'; + const { getByText } = renderWithTheme(); + expect(getByText('Share Url')).toBeInTheDocument(); + }); + + it('renders errors', () => { + const { getByText } = renderWithTheme(); + expect(getByText('modal.statusPanel.error')).toBeInTheDocument(); + }); +}) diff --git a/src/components/src/modals/share-map-modal.tsx b/src/components/src/modals/share-map-modal.tsx index 4c4621df6f..df60ccf841 100644 --- a/src/components/src/modals/share-map-modal.tsx +++ b/src/components/src/modals/share-map-modal.tsx @@ -18,12 +18,11 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import React, {useState} from 'react'; +import React, {useEffect, useState} from 'react'; import styled, {ThemeProvider} from 'styled-components'; import {CopyToClipboard} from 'react-copy-to-clipboard'; import {themeLT} from '@kepler.gl/styles'; import ImageModalContainer, {ImageModalContainerProps} from './image-modal-container'; -import ProviderModalContainer from './provider-modal-container'; import { StyledModalContent, @@ -31,14 +30,13 @@ import { InputLight, Button } from '../common/styled-components'; -import CloudTile from './cloud-tile'; import StatusPanel from './status-panel'; import {FormattedMessage} from '@kepler.gl/localization'; +import {useCloudListProvider} from '../hooks/use-cloud-list-provider'; +import {ProviderSelect} from './cloud-components/provider-select'; import {Provider} from '@kepler.gl/cloud-providers'; -import { - cleanupExportImage as cleanupExportImageAction, - SetCloudProviderPayload -} from '@kepler.gl/actions'; +import {cleanupExportImage as cleanupExportImageAction} from '@kepler.gl/actions'; +import {dataTestIds} from '@kepler.gl/constants'; export const StyledInputLabel = styled.label` font-size: 12px; @@ -88,21 +86,34 @@ const nop = () => {}; const StyledShareMapModal = styled(StyledModalContent)` padding: 24px 72px 40px 72px; margin: 0 -72px -40px -72px; + display: flex; + flex-direction: column; `; const StyledInnerDiv = styled.div` min-height: 500px; `; +const UNDERLINE_TEXT_DECORATION_STYLE = {textDecoration: 'underline'}; + +const ShareMapHeader = ({cloudProviders}) => { + return ( + +
+
+ +
+
+ +
+ ); +}; + interface ShareMapUrlModalFactoryProps { isProviderLoading?: boolean; - isReady?: boolean; onExport?: (provider: Provider) => void; - cloudProviders?: Provider[]; - currentProvider: string | null; providerError?: string; successInfo?: {shareUrl?: string; folderLink?: string}; - onSetCloudProvider?: (provider: SetCloudProviderPayload) => void; onUpdateImageSetting: ImageModalContainerProps['onUpdateImageSetting']; cleanupExportImage: typeof cleanupExportImageAction; } @@ -110,34 +121,32 @@ interface ShareMapUrlModalFactoryProps { export default function ShareMapUrlModalFactory() { const ShareMapUrlModal: React.FC = ({ isProviderLoading = false, - isReady, onExport = nop, - cloudProviders = [], - currentProvider = null, providerError = null, successInfo = {}, - onSetCloudProvider = nop, onUpdateImageSetting = nop, cleanupExportImage }) => { + const {provider, cloudProviders} = useCloudListProvider(); const {shareUrl, folderLink} = successInfo; - const provider = currentProvider ? cloudProviders.find(p => p.name === currentProvider) : null; + + useEffect(() => { + if (provider) { + onExport(provider); + } + }, [provider]); return ( - - - - + + + {provider?.hasSharingUrl() ? ( +
@@ -162,26 +171,12 @@ export default function ShareMapUrlModalFactory() {
-
- {cloudProviders.map(cloudProvider => ( - onExport(cloudProvider)} - onSetCloudProvider={onSetCloudProvider} - cloudProvider={cloudProvider} - actionName="Upload" - isSelected={cloudProvider.name === currentProvider} - isConnected={Boolean(cloudProvider.getAccessToken())} - isReady={isReady} - /> - ))} -
{isProviderLoading || providerError ? ( ) : null} {shareUrl && ( @@ -197,21 +192,18 @@ export default function ShareMapUrlModalFactory() { href={folderLink} target="_blank" rel="noopener noreferrer" - style={{textDecoration: 'underline'}} + style={UNDERLINE_TEXT_DECORATION_STYLE} > - + {provider.name} )}
)} - - - + ) : null} + + ); }; diff --git a/src/components/src/side-panel.tsx b/src/components/src/side-panel.tsx index 4cb0d60580..5c2655a9ea 100644 --- a/src/components/src/side-panel.tsx +++ b/src/components/src/side-panel.tsx @@ -114,7 +114,7 @@ export default function SidePanelFactory( const { appName, appWebsite, - availableProviders, + availableProviders = {}, datasets, filters, layers, @@ -173,22 +173,12 @@ export default function SidePanelFactory( const onShowAddDataModal = useCallback(() => toggleModal(ADD_DATA_ID), [toggleModal]); const onShowAddMapStyleModal = useCallback(() => toggleModal(ADD_MAP_STYLE_ID), [toggleModal]); const onRemoveDataset = useCallback(dataId => openDeleteModal(dataId), [openDeleteModal]); - const onSaveToStorage = useMemo(() => (hasStorage ? onClickSaveToStorage : null), [ - hasStorage, - onClickSaveToStorage - ]); - const onSaveAsToStorage = useMemo( - () => (hasStorage && mapSaved ? onClickSaveAsToStorage : null), - [hasStorage, mapSaved, onClickSaveAsToStorage] - ); + const currentPanel = useMemo(() => panels.find(({id}) => id === activeSidePanel) || null, [ activeSidePanel, panels ]); - const onShareMap = useMemo(() => (hasShare ? onClickShareMap : null), [ - hasShare, - onClickShareMap - ]); + const customPanelProps = useMemo(() => getCustomPanelProps(props), [props]); const PanelComponent = currentPanel?.component; @@ -210,10 +200,10 @@ export default function SidePanelFactory( onExportImage={onClickExportImage} onExportData={onClickExportData} onExportMap={onClickExportMap} - onSaveMap={onSaveMap} - onSaveToStorage={onSaveToStorage} - onSaveAsToStorage={onSaveAsToStorage} - onShareMap={onShareMap} + onSaveMap={hasStorage ? onSaveMap : undefined} + onSaveToStorage={hasStorage ? onClickSaveToStorage : null} + onSaveAsToStorage={hasStorage && mapSaved ? onClickSaveAsToStorage : null} + onShareMap={hasShare ? onClickShareMap : null} /> {/* the next two components should be moved into one */} {/* but i am keeping them because of backward compatibility */} @@ -267,7 +257,6 @@ export default function SidePanelFactory( SidePanel.defaultProps = { panels: fullPanels, - availableProviders: {}, mapInfo: {} }; diff --git a/src/components/src/side-panel/panel-header.tsx b/src/components/src/side-panel/panel-header.tsx index 775daf4504..3cc92b82c9 100644 --- a/src/components/src/side-panel/panel-header.tsx +++ b/src/components/src/side-panel/panel-header.tsx @@ -167,7 +167,7 @@ const PanelAction: React.FC = React.memo(({item, showExportDro }, [item, showExportDropdown]); return ( - + {item.label ?

{item.label}

: null} diff --git a/src/components/src/types.ts b/src/components/src/types.ts index 1db4f51792..8d8660d6ab 100644 --- a/src/components/src/types.ts +++ b/src/components/src/types.ts @@ -37,7 +37,7 @@ export type SidePanelProps = { mapStateActions: typeof MapStateActions; mapStyleActions: typeof MapStyleActions; uiState: UiState; - availableProviders: {[k: string]: {hasShare?: boolean; hasStorage?: boolean}}; + availableProviders: {hasShare?: boolean; hasStorage?: boolean}; mapSaved?: string | null; panels?: SidePanelItem[]; onSaveMap?: () => void; diff --git a/src/constants/src/default-settings.ts b/src/constants/src/default-settings.ts index 3e06ca5ff1..7b332cfa66 100644 --- a/src/constants/src/default-settings.ts +++ b/src/constants/src/default-settings.ts @@ -1176,7 +1176,12 @@ export const dataTestIds: Record = { removeLayerAction: 'remove-layer-action', layerPanel: 'layer-panel', sortableEffectItem: 'sortable-effect-item', - staticEffectItem: 'static-effect-item' + staticEffectItem: 'static-effect-item', + providerLoading: 'provider-loading', + providerMapInfoPanel: 'provider-map-info-panel', + providerSelect: 'provider-select', + cloudHeader: 'cloud-header', + providerShareMap: 'provider-share-map' }; // Effects diff --git a/src/layers/src/layer-utils.ts b/src/layers/src/layer-utils.ts index 47648809f1..4c93f9b5b1 100644 --- a/src/layers/src/layer-utils.ts +++ b/src/layers/src/layer-utils.ts @@ -118,13 +118,15 @@ export function getHoveredObjectFromArrow( return prev; }, {}); - return hoveredFeature ? { - ...hoveredFeature, - properties: { - ...properties, - index: objectInfo.index - } - } : null; + return hoveredFeature + ? { + ...hoveredFeature, + properties: { + ...properties, + index: objectInfo.index + } + } + : null; } return null; } diff --git a/src/localization/src/translations/en.ts b/src/localization/src/translations/en.ts index 5af2058556..e49df61b77 100644 --- a/src/localization/src/translations/en.ts +++ b/src/localization/src/translations/en.ts @@ -337,6 +337,7 @@ export default { namingTitle: '3. Name your style' }, shareMap: { + title: 'Share Map', shareUriTitle: 'Share Map Url', shareUriSubtitle: 'Generate a map url to share with others', cloudTitle: 'Cloud storage', diff --git a/src/reducers/src/layer-utils.ts b/src/reducers/src/layer-utils.ts index 82fd3a551d..579241a4ae 100644 --- a/src/reducers/src/layer-utils.ts +++ b/src/reducers/src/layer-utils.ts @@ -182,7 +182,12 @@ export function getLayerHoverProp({ const layer = layers[overlay.props.idx]; // NOTE: for binary format GeojsonLayer, deck will return object=null but hoverInfo.index >= 0 - if ((object || hoverInfo.index >= 0) && layer && layer.getHoverData && layersToRender[layer.id]) { + if ( + (object || hoverInfo.index >= 0) && + layer && + layer.getHoverData && + layersToRender[layer.id] + ) { // if layer is visible and have hovered data const { config: {dataId} diff --git a/src/reducers/src/provider-state-updaters.ts b/src/reducers/src/provider-state-updaters.ts index baf5048ebb..e4f7420761 100644 --- a/src/reducers/src/provider-state-updaters.ts +++ b/src/reducers/src/provider-state-updaters.ts @@ -25,16 +25,13 @@ import { EXPORT_FILE_TO_CLOUD_TASK, ACTION_TASK, DELAY_TASK, - LOAD_CLOUD_MAP_TASK, - GET_SAVED_MAPS_TASK + LOAD_CLOUD_MAP_TASK } from '@kepler.gl/tasks'; import { exportFileSuccess, exportFileError, postSaveLoadSuccess, loadCloudMapSuccess, - getSavedMapsSuccess, - getSavedMapsError, loadCloudMapError, resetProviderStatus, removeNotification, @@ -382,72 +379,3 @@ export const resetProviderStatusUpdater = (state: ProviderState): ProviderState isCloudMapLoading: false, successInfo: {} }); - -/** - * Set current cloudProvider - */ -export const setCloudProviderUpdater = ( - state: ProviderState, - action: ActionPayload -): ProviderState => ({ - ...state, - isProviderLoading: false, - providerError: null, - successInfo: {}, - currentProvider: action.payload -}); - -export const getSavedMapsUpdater = ( - state: ProviderState, - action: ActionPayload -): ProviderState => { - const provider = action.payload; - if (!_validateProvider(provider, 'listMaps')) { - return state; - } - - const getSavedMapsTask = GET_SAVED_MAPS_TASK(provider).bimap( - // success - visualizations => getSavedMapsSuccess({visualizations, provider}), - // error - error => getSavedMapsError({error, provider}) - ); - - return withTask( - { - ...state, - isProviderLoading: true - }, - getSavedMapsTask - ); -}; - -export const getSavedMapsSuccessUpdater = ( - state: ProviderState, - action: ActionPayload -): ProviderState => ({ - ...state, - isProviderLoading: false, - visualizations: action.payload.visualizations -}); - -export const getSavedMapsErrorUpdater = ( - state: ProviderState, - action: ActionPayload -): ProviderState => { - const message = - getError(action.payload.error) || `Error getting saved maps from ${state.currentProvider}`; - - Console.warn(action.payload.error); - - const newState = { - ...state, - currentProvider: null, - isProviderLoading: false - }; - - return withTask( - newState, - createGlobalNotificationTasks({type: 'error', message, delayClose: false}) - ); -}; diff --git a/src/reducers/src/provider-state.ts b/src/reducers/src/provider-state.ts index 6120f4bc0b..a33c93016d 100644 --- a/src/reducers/src/provider-state.ts +++ b/src/reducers/src/provider-state.ts @@ -31,14 +31,10 @@ const actionHandler = { [ActionTypes.EXPORT_FILE_SUCCESS]: providerStateUpdaters.exportFileSuccessUpdater, [ActionTypes.EXPORT_FILE_ERROR]: providerStateUpdaters.exportFileErrorUpdater, [ActionTypes.RESET_PROVIDER_STATUS]: providerStateUpdaters.resetProviderStatusUpdater, - [ActionTypes.SET_CLOUD_PROVIDER]: providerStateUpdaters.setCloudProviderUpdater, [ActionTypes.POST_SAVE_LOAD_SUCCESS]: providerStateUpdaters.postSaveLoadSuccessUpdater, [ActionTypes.LOAD_CLOUD_MAP]: providerStateUpdaters.loadCloudMapUpdater, [ActionTypes.LOAD_CLOUD_MAP_SUCCESS]: providerStateUpdaters.loadCloudMapSuccessUpdater, - [ActionTypes.LOAD_CLOUD_MAP_ERROR]: providerStateUpdaters.loadCloudMapErrorUpdater, - [ActionTypes.GET_SAVED_MAPS]: providerStateUpdaters.getSavedMapsUpdater, - [ActionTypes.GET_SAVED_MAPS_SUCCESS]: providerStateUpdaters.getSavedMapsSuccessUpdater, - [ActionTypes.GET_SAVED_MAPS_ERROR]: providerStateUpdaters.getSavedMapsErrorUpdater + [ActionTypes.LOAD_CLOUD_MAP_ERROR]: providerStateUpdaters.loadCloudMapErrorUpdater }; // construct provider-state reducer diff --git a/src/tasks/src/index.ts b/src/tasks/src/index.ts index bbbc21841c..688a15baf4 100644 --- a/src/tasks/src/index.ts +++ b/src/tasks/src/index.ts @@ -65,11 +65,6 @@ export const LOAD_CLOUD_MAP_TASK = Task.fromPromise( 'LOAD_CLOUD_MAP_TASK' ); -export const GET_SAVED_MAPS_TASK = Task.fromPromise( - provider => provider.listMaps(), - - 'GET_SAVED_MAPS_TASK' -); /** * task to dispatch a function as a task */ diff --git a/test/browser/components/modals/index.js b/test/browser/components/modals/index.js index 3bd5bea011..2b612d2a10 100644 --- a/test/browser/components/modals/index.js +++ b/test/browser/components/modals/index.js @@ -19,9 +19,5 @@ // THE SOFTWARE. import './data-table-modal-test'; -import './save-map-modal-test'; -import './share-map-modal-test'; - import './export-image-modal-test'; import './load-data-modal-test'; -import './load-storage-map-test'; diff --git a/test/browser/components/modals/load-storage-map-test.js b/test/browser/components/modals/load-storage-map-test.js deleted file mode 100644 index 037e13592c..0000000000 --- a/test/browser/components/modals/load-storage-map-test.js +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) 2023 Uber Technologies, Inc. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import React from 'react'; -import test from 'tape'; -import {mountWithTheme} from 'test/helpers/component-utils'; -import sinon from 'sinon'; -import {LoadStorageMapFactory, appInjector} from '@kepler.gl/components'; - -import MockProvider from 'test/helpers/mock-provider'; - -const mockProvider = new MockProvider(); - -const LoadStorageMap = appInjector.get(LoadStorageMapFactory); - -test('Components -> LoadStorageMap.mount', t => { - // mount - const getSavedMaps = sinon.spy(); - - let wrapper; - t.doesNotThrow(() => { - wrapper = mountWithTheme( - {}} - /> - ); - }, 'Show not fail without props'); - - t.equal(wrapper.find('.provider-selection').length, 1, 'should render ProviderSelect'); - - t.end(); -}); - -test('Components -> LoadStorageMap.mount', t => { - // mount - const getSavedMaps = sinon.spy(); - - let wrapper; - t.doesNotThrow(() => { - wrapper = mountWithTheme( - {}} - /> - ); - }, 'Show not fail without props'); - - t.equal(wrapper.find('.provider-selection').length, 0, 'should not render ProviderSelect'); - - t.deepEqual( - getSavedMaps.args, - [[mockProvider]], - 'should call getSavedMaps when mount with mockProvider' - ); - t.end(); -}); diff --git a/test/browser/components/modals/save-map-modal-test.js b/test/browser/components/modals/save-map-modal-test.js deleted file mode 100644 index 897b058b66..0000000000 --- a/test/browser/components/modals/save-map-modal-test.js +++ /dev/null @@ -1,208 +0,0 @@ -// Copyright (c) 2023 Uber Technologies, Inc. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import React from 'react'; -import test from 'tape'; -import {mountWithTheme, IntlWrapper} from 'test/helpers/component-utils'; -import sinon from 'sinon'; -import {SaveMapModalFactory, CloudTile, ImagePreview} from '@kepler.gl/components'; -import MockProvider from 'test/helpers/mock-provider'; - -const mockProvider = new MockProvider(); -const SaveMapModal = SaveMapModalFactory(); - -test('Components -> SaveMapModal.mount', t => { - const onUpdateImageSetting = sinon.spy(); - const onSetCloudProvider = sinon.spy(); - - // mount - t.doesNotThrow(() => { - mountWithTheme( - - - - ); - }, 'Show not fail without props'); - t.ok(onUpdateImageSetting.calledTwice, 'should call onUpdateImageSetting twice when mount'); - t.deepEqual( - onUpdateImageSetting.args, - [[{exporting: true}], [{mapW: 100, mapH: 60, ratio: 'CUSTOM', legend: false}]], - 'should call onUpdateImageSetting when mount' - ); - t.ok(onSetCloudProvider.notCalled, 'should not call onSetCloudProvider when mount'); - - t.end(); -}); - -test('Components -> SaveMapModal.mount with providers', t => { - const onSetCloudProvider = sinon.spy(); - - // mount - t.doesNotThrow(() => { - mountWithTheme( - - {}} - onSetCloudProvider={onSetCloudProvider} - cloudProviders={[mockProvider]} - /> - - ); - }, 'Show not fail mount props'); - t.ok(onSetCloudProvider.calledWithExactly('taro'), 'should set default provider when mount'); - - const wrapper = mountWithTheme( - - {}} - onSetCloudProvider={onSetCloudProvider} - cloudProviders={[mockProvider]} - currentProvider="hello" - /> - - ); - t.ok(onSetCloudProvider.calledOnce, 'should not set default provider if it is already set'); - - t.ok(wrapper.find(CloudTile).length === 1, 'should render 1 cloud provider'); - t.ok(wrapper.find(ImagePreview).length === 1, 'should render 1 ImagePreview'); - - t.end(); -}); - -test('Components -> SaveMapModal on change input', t => { - const onSetMapInfo = sinon.spy(); - const eventObj = {target: {value: 'taro'}}; - - let wrapper; - // mount - t.doesNotThrow(() => { - wrapper = mountWithTheme( - - {}} onSetMapInfo={onSetMapInfo} /> - - ); - }, 'Show not fail mount props'); - - wrapper.find('input#map-title').simulate('change', eventObj); - - t.ok(onSetMapInfo.calledWithExactly({title: 'taro'}), 'should set map title'); - - wrapper.find('textarea#map-description').simulate('change', eventObj); - - t.ok(onSetMapInfo.calledWithExactly({description: 'taro'}), 'should set map description'); - - t.end(); -}); - -test('Components -> SaveMapModal on click provider', t => { - const onSetCloudProvider = sinon.spy(); - const login = sinon.spy(); - const logout = sinon.spy(); - mockProvider.logout = logout; - - const mockProvider2 = { - getAccessToken: () => false, - name: 'blue', - login - }; - - let wrapper; - // mount - t.doesNotThrow(() => { - wrapper = mountWithTheme( - - {}} - onUpdateImageSetting={() => {}} - /> - - ); - }, 'Show not fail mount props'); - - t.equal(wrapper.find('.provider-tile__wrapper').length, 2, 'should render 1 provider tile'); - - // click taro to select - wrapper - .find('.provider-tile__wrapper') - .at(0) - .simulate('click'); - t.ok(onSetCloudProvider.calledWithExactly('taro'), 'should call onSetCloudProvider with taro'); - - // click blue to login - wrapper - .find('.provider-tile__wrapper') - .at(1) - .simulate('click'); - t.ok(login.calledOnce, 'should call login'); - - // call onSuccess after login to set current provider - const onSuccess = login.args[0][0]; - onSuccess(); - t.ok(onSetCloudProvider.calledWithExactly('blue'), 'should call onSetCloudProvider with blue'); - - // click taro to logout - wrapper - .find('.logout-button') - .at(0) - .simulate('click'); - t.ok(logout.calledOnce, 'should call logout'); - - // call onSuccess after login to set current provider - const onSuccessLogout = logout.args[0][0]; - onSuccessLogout(); - t.ok(onSetCloudProvider.calledWithExactly(null), 'should call onSetCloudProvider with null'); - - t.end(); -}); - -test('Components -> SaveMapModal.intl', t => { - let wrapper; - // mount English version - t.doesNotThrow(() => { - wrapper = mountWithTheme( - - {}} onSetMapInfo={() => {}} /> - - ); - }, 'Show not fail mount props'); - - t.equal(wrapper.find('.title').text(), 'Cloud storage'); - - // mount Finnish version - t.doesNotThrow(() => { - wrapper = mountWithTheme( - - {}} onSetMapInfo={() => {}} /> - - ); - }, 'Show not fail mount props'); - - t.equal(wrapper.find('.title').text(), 'Pilvitallennus'); - - t.end(); -}); diff --git a/test/browser/components/modals/share-map-modal-test.js b/test/browser/components/modals/share-map-modal-test.js deleted file mode 100644 index c4b54ce95c..0000000000 --- a/test/browser/components/modals/share-map-modal-test.js +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright (c) 2023 Uber Technologies, Inc. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import React from 'react'; -import test from 'tape'; -import {IntlWrapper, mountWithTheme} from 'test/helpers/component-utils'; -import sinon from 'sinon'; -import {ShareMapUrlModalFactory, SharingUrl, CloudTile, StatusPanel} from '@kepler.gl/components'; -const ShareMapUrlModal = ShareMapUrlModalFactory(); - -test('Components -> ShareMapUrlModal.mount', t => { - const onSetCloudProvider = sinon.spy(); - - // mount - t.doesNotThrow(() => { - mountWithTheme( - - - - ); - }, 'Show not fail without props'); - t.ok(onSetCloudProvider.notCalled, 'should not call onSetCloudProvider when mount'); - - t.end(); -}); - -test('Components -> ShareMapUrlModal.mount with providers', t => { - const onSetCloudProvider = sinon.spy(); - - const mockProvider = { - getAccessToken: () => true, - name: 'taro' - }; - // mount - t.doesNotThrow(() => { - mountWithTheme( - - - - ); - }, 'Show not fail mount props'); - t.ok(onSetCloudProvider.calledWithExactly('taro'), 'should set default provider when mount'); - - const wrapper = mountWithTheme( - - - - ); - t.ok(onSetCloudProvider.calledOnce, 'should not set default provider if it is already set'); - - t.ok(wrapper.find(CloudTile).length === 1, 'should render 1 cloud provider'); - t.ok(wrapper.find(StatusPanel).length === 0, 'should not render StatusPanel'); - - t.end(); -}); - -test('Components -> ShareMapUrlModal.mount with isLoading', t => { - const mockProvider = { - getAccessToken: () => true, - name: 'taro' - }; - // mount - let wrapper; - t.doesNotThrow(() => { - wrapper = mountWithTheme( - - {}} - cloudProviders={[mockProvider]} - /> - - ); - }, 'Show not fail mount with isProviderLoading'); - - t.ok( - wrapper.find(StatusPanel).length === 1, - 'should render StatusPanel when isProviderLoading=true' - ); - - wrapper = mountWithTheme( - - {}} - cloudProviders={[mockProvider]} - /> - - ); - t.ok(wrapper.find(StatusPanel).length === 1, 'should render StatusPanel when error'); - t.equal(wrapper.find('.notification-item--message').length, 1, 'should render 1 message'); - t.equal( - wrapper - .find('.notification-item--message') - .find('p') - .text(), - 'something is wrong', - 'should render error msg' - ); - t.end(); -}); - -test('Components -> ShareMapUrlModal.mount with SharingUrl', t => { - const shareUrl = 'http://taro-and-blue'; - const mockProvider = { - getAccessToken: () => true, - name: 'taro' - }; - - // mount - let wrapper; - t.doesNotThrow(() => { - wrapper = mountWithTheme( - - {}} - /> - - ); - }, 'Show not fail mount with successInfo'); - - t.ok(wrapper.find(SharingUrl).length === 1, 'should render SharingUrl when loading'); - - t.equal( - wrapper - .find(SharingUrl) - .find('input') - .props().value, - shareUrl, - 'should render with successInfo.shareUrl' - ); - - t.end(); -}); diff --git a/test/browser/components/side-panel/side-panel-test.js b/test/browser/components/side-panel/side-panel-test.js index 6ca16c74a4..acf26f4ef8 100644 --- a/test/browser/components/side-panel/side-panel-test.js +++ b/test/browser/components/side-panel/side-panel-test.js @@ -58,6 +58,7 @@ import {InitialState} from 'test/helpers/mock-state'; // Constants import {EXPORT_DATA_ID, EXPORT_MAP_ID, EXPORT_IMAGE_ID} from '@kepler.gl/constants'; +import {debug} from 'webpack'; // default props from initial state const defaultProps = { diff --git a/test/node/reducers/provider-state-test.js b/test/node/reducers/provider-state-test.js index 8fe669b180..7e64576081 100644 --- a/test/node/reducers/provider-state-test.js +++ b/test/node/reducers/provider-state-test.js @@ -347,22 +347,3 @@ test('#providerStateReducer -> RESET_PROVIDER_STATUS', t => { t.end(); }); - -test('#providerStateReducer -> SET_CLOUD_PROVIDER', t => { - const nextState = reducer(undefined, setCloudProvider('blue')); - t.deepEqual( - nextState, - { - isProviderLoading: false, - isCloudMapLoading: false, - providerError: null, - currentProvider: 'blue', - successInfo: {}, - mapSaved: null, - initialState: {}, - visualizations: [] - }, - 'Should setCloudProvider' - ); - t.end(); -});