From d60ef31dca674e66da3e5161b4b65cdaa6e92b4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Giuseppe=20Macr=C3=AC?= Date: Sat, 18 Nov 2023 17:45:38 -0500 Subject: [PATCH] [feat] Introduce Foursquare cloud provider (#2437) Signed-off-by: Giuseppe Macri --- docs/api-reference/cloud-providers/README.md | 21 ++- .../cloud-providers/cloud-provider.md | 22 +-- examples/demo-app/package.json | 1 + examples/demo-app/src/app.js | 4 +- .../dropbox/dropbox-provider.js | 9 +- .../foursquare/foursquare-icon.js | 41 +++++ .../foursquare/foursquare-provider.js | 172 ++++++++++++++++++ .../demo-app/src/cloud-providers/index.js | 26 ++- .../src/constants/default-settings.js | 8 +- examples/demo-app/src/reducers/index.js | 4 +- examples/demo-app/webpack.config.js | 6 +- examples/webpack.config.local.js | 6 +- src/cloud-providers/src/index.ts | 2 +- src/cloud-providers/src/provider.ts | 22 ++- src/components/src/common/flex-container.ts | 5 +- .../cloud-components/cloud-item.spec.tsx | 19 +- .../modals/cloud-components/cloud-item.tsx | 6 +- .../cloud-components/cloud-maps.spec.tsx | 31 +++- .../modals/cloud-components/cloud-maps.tsx | 9 +- src/components/src/modals/cloud-tile.tsx | 29 ++- .../src/modals/load-storage-map.spec.tsx | 17 +- .../src/modals/load-storage-map.tsx | 8 +- .../src/modals/save-map-modal.spec.tsx | 20 -- src/components/src/modals/save-map-modal.tsx | 10 +- .../src/modals/share-map-modal.spec.tsx | 15 +- .../src/side-panel/panel-header.tsx | 7 +- website/webpack.config.js | 50 +++-- 27 files changed, 431 insertions(+), 139 deletions(-) create mode 100644 examples/demo-app/src/cloud-providers/foursquare/foursquare-icon.js create mode 100644 examples/demo-app/src/cloud-providers/foursquare/foursquare-provider.js diff --git a/docs/api-reference/cloud-providers/README.md b/docs/api-reference/cloud-providers/README.md index 1b8a8e733b..726b9180a3 100644 --- a/docs/api-reference/cloud-providers/README.md +++ b/docs/api-reference/cloud-providers/README.md @@ -43,20 +43,21 @@ An instance of the provider is added to array of cloud providers in the file `sr import {Provider} from 'kepler.gl/cloud-providers'; class MyProvider extends Provider { - constructor() { - this.name = 'foo'; - this.displayName = 'My Provider'; - } - // ... other required methods below + constructor() { + this.name = 'foo'; + this.displayName = 'My Provider'; + } + + // ... other required methods below } const myProvider = new MyProvider(); const App = () => - + ``` diff --git a/docs/api-reference/cloud-providers/cloud-provider.md b/docs/api-reference/cloud-providers/cloud-provider.md index 03b5a6b436..434ad758f1 100644 --- a/docs/api-reference/cloud-providers/cloud-provider.md +++ b/docs/api-reference/cloud-providers/cloud-provider.md @@ -23,9 +23,9 @@ The default provider class **Parameters** -- `props` **[object][27]** - - `props.name` **[string][28]** - - `props.displayName` **[string][28]** +- `props` **[object][27]** + - `props.name` **[string][28]** + - `props.displayName` **[string][28]** - `props.icon` **ReactElement** React element - `props.thumbnail` **[object][27]** thumbnail size object - `props.thumbnail.width` **[number][29]** thumbnail width in pixels @@ -111,13 +111,13 @@ Returns **[string][28]** true if a user already logged in Whether this provider support upload map to a private storage. If truthy, user will be displayed with the storage save icon on the top right of the side bar. -Returns **[boolean][31]** +Returns **[boolean][31]** ### hasSharingUrl Whether this provider support share map via a public url, if truthy, user will be displayed with a share map via url under the export map option on the top right of the side bar -Returns **[boolean][31]** +Returns **[boolean][31]** ### listMaps @@ -133,7 +133,7 @@ async listMaps() { title: 'My map', description: 'My first kepler map', imageUrl: 'http://', - lastModification: 1582677787000, + udpatedAt: 1582677787000, privateMap: false, loadParams: {} } @@ -168,7 +168,7 @@ With the option to overwrite already saved map, and upload as private or public **Parameters** -- `param` **[Object][27]** +- `param` **[Object][27]** - `param.mapData` **[Object][27]** the map object - `param.mapData.map` **[Object][27]** {datasets. config, info: {title, description}} - `param.mapData.thumbnail` **[Blob][35]** A thumbnail of current map. thumbnail size can be defined by provider by this.thumbnail @@ -186,10 +186,10 @@ Type: [Object][27] ### Properties -- `map` **[Object][27]** - - `map.datasets` **[Array][32]<[Object][27]>** - - `map.config` **[Object][27]** - - `map.info` **[Object][27]** +- `map` **[Object][27]** + - `map.datasets` **[Array][32]<[Object][27]>** + - `map.config` **[Object][27]** + - `map.info` **[Object][27]** - `format` **[string][28]** one of 'csv': csv file string, 'geojson': geojson object, 'row': row object, 'keplergl': datasets array saved using KeplerGlSchema.save ## Viz diff --git a/examples/demo-app/package.json b/examples/demo-app/package.json index 86e3cfa20d..32544e2e9b 100644 --- a/examples/demo-app/package.json +++ b/examples/demo-app/package.json @@ -10,6 +10,7 @@ "start-local-https": "webpack-dev-server --mode development --https --env.es6 --progress --hot --open" }, "dependencies": { + "@auth0/auth0-spa-js": "^2.1.2", "@carto/toolkit": "0.0.1-rc.18", "@kepler.gl/actions": "^3.0.0-alpha.1", "@kepler.gl/components": "^3.0.0-alpha.1", diff --git a/examples/demo-app/src/app.js b/examples/demo-app/src/app.js index f5e94d7274..e8a7e7f375 100644 --- a/examples/demo-app/src/app.js +++ b/examples/demo-app/src/app.js @@ -30,7 +30,7 @@ import Announcement, {FormLink} from './components/announcement'; import {replaceLoadDataModal} from './factories/load-data-modal'; import {replaceMapControl} from './factories/map-control'; import {replacePanelHeader} from './factories/panel-header'; -import {AUTH_TOKENS, DEFAULT_FEATURE_FLAGS} from './constants/default-settings'; +import {CLOUD_PROVIDERS_CONFIGURATION, DEFAULT_FEATURE_FLAGS} from './constants/default-settings'; import {messages} from './constants/localization'; import { @@ -429,7 +429,7 @@ class App extends Component { {({height, width}) => ( + + + + + ); + } +} diff --git a/examples/demo-app/src/cloud-providers/foursquare/foursquare-provider.js b/examples/demo-app/src/cloud-providers/foursquare/foursquare-provider.js new file mode 100644 index 0000000000..a1d52e0f79 --- /dev/null +++ b/examples/demo-app/src/cloud-providers/foursquare/foursquare-provider.js @@ -0,0 +1,172 @@ +import FSQIcon from './foursquare-icon'; +import {Provider, KEPLER_FORMAT} from 'kepler.gl/cloud-providers'; +import {Auth0Client} from '@auth0/auth0-spa-js'; + +const NAME = 'Foursquare'; +const DISPLAY_NAME = 'Foursquare'; +const APP_NAME = 'Kepler.gl'; + +const FOURSQUARE_PRIVATE_STORAGE_ENABLED = true; +const FOURSQUARE_AUTH_AUDIENCE = 'https://foursquare.com/api/'; +const FOURSQUARE_AUTH_SCOPE = 'openid profile email'; + +// Foursquare stores kepler maps using kepler.gl-raw as ImportSource +const FOURSQUARE_KEPLER_GL_IMPORT_SOURCE = 'kepler.gl-raw'; + +/** + * Converts a FSQ map model to cloud provider map item + * @param model Foursquare Map + * @return {MapItem} Map + */ +function convertFSQModelToMapItem(model) { + return { + id: model.id, + title: model.name, + thumbnail: model.previewReadPath, + updatedAt: model.updatedAt, + description: model.description, + loadParams: { + mapId: model.id + } + }; +} + +function extractMapFromFSQResponse(response) { + const { + latestState: {data} + } = response; + return data; +} + +export default class FoursquareProvider extends Provider { + constructor({clientId, authDomain, apiURL, userMapsURL}) { + super({name: NAME, displayName: DISPLAY_NAME, icon: FSQIcon}); + this.icon = FSQIcon; + this.appName = APP_NAME; + this.apiURL = apiURL; + + const redirect_uri = window.location.origin + window.location.pathname; + + this._auth0 = new Auth0Client({ + domain: authDomain, + clientId: clientId, + scope: FOURSQUARE_AUTH_SCOPE, + authorizationParams: { + redirect_uri, + audience: FOURSQUARE_AUTH_AUDIENCE + }, + cacheLocation: 'localstorage' + }); + + // the domain needs to be passed as input param + this._folderLink = userMapsURL; + this.isNew = true; + } + + hasPrivateStorage() { + return FOURSQUARE_PRIVATE_STORAGE_ENABLED; + } + + async getUser() { + return this._auth0.getUser(); + } + + async login() { + return this._auth0.loginWithPopup(); + } + + async logout() { + return this._auth0.logout({ + // this make sure after logging out the sdk will not redirect the user + openUrl: false + }); + } + + async uploadMap({mapData, options = {}}) { + // TODO: handle replace + const mode = options.overwrite ? 'overwrite' : 'add'; + const method = options.overwrite ? 'PUT' : 'POST' + const {map, thumbnail} = mapData; + + const {title = '', description = '', id} = map.info; + const headers = await this.getHeaders(); + const payload = { + name: title, + description, + importSource: FOURSQUARE_KEPLER_GL_IMPORT_SOURCE, + latestState: { + data: map + } + }; + + const mapResponse = await fetch(`${this.apiURL}/v1/maps${mode === 'overwrite' ? `/${id}` : ''}`, { + method, + headers, + body: JSON.stringify(payload) + }); + + const createMap = await mapResponse.json(); + + await fetch(`${this.apiURL}/v1/maps/${createMap.id}/thumbnail`, { + method: 'PUT', + headers: { + ...headers, + 'Content-Type': 'image/png' + }, + body: thumbnail + }); + + return map; + } + + async listMaps() { + const headers = await this.getHeaders(); + const response = await fetch( + `${this.apiURL}/v1/maps?importSource=${FOURSQUARE_KEPLER_GL_IMPORT_SOURCE}`, + { + method: 'GET', + mode: 'cors', + headers + } + ); + const data = await response.json(); + return data.items.map(convertFSQModelToMapItem); + } + + async downloadMap(loadParams) { + const {mapId} = loadParams; + if (!mapId) { + return Promise.reject('No Map is was provider as part of loadParams'); + } + const headers = await this.getHeaders(); + + const response = await fetch(`${this.apiURL}/v1/maps/${mapId}`, { + method: 'GET', + headers + }); + + const map = await response.json(); + + return Promise.resolve({ + map: extractMapFromFSQResponse(map), + format: KEPLER_FORMAT + }); + } + + getManagementUrl() { + return this._folderLink; + } + + async getAccessToken() { + return this._auth0.getTokenSilently(); + } + + async getHeaders() { + const accessToken = await this.getAccessToken(); + return { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json' + }; + } +} diff --git a/examples/demo-app/src/cloud-providers/index.js b/examples/demo-app/src/cloud-providers/index.js index 0aa2310b3f..9b9f4c9123 100644 --- a/examples/demo-app/src/cloud-providers/index.js +++ b/examples/demo-app/src/cloud-providers/index.js @@ -18,17 +18,39 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import {AUTH_TOKENS} from '../constants/default-settings'; +import {CLOUD_PROVIDERS_CONFIGURATION} from '../constants/default-settings'; import DropboxProvider from './dropbox/dropbox-provider'; import CartoProvider from './carto/carto-provider'; +import FoursquareProvider from './foursquare/foursquare-provider'; + +const { + DROPBOX_CLIENT_ID, + CARTO_CLIENT_ID, + FOURSQUARE_CLIENT_ID, + FOURSQUARE_DOMAIN, + FOURSQUARE_API_URL, + FOURSQUARE_USER_MAPS_URL +} = CLOUD_PROVIDERS_CONFIGURATION; + +console.log('provider index', { + clientId: FOURSQUARE_CLIENT_ID, + authDomain: FOURSQUARE_DOMAIN, + apiURL: FOURSQUARE_API_URL, + userMapsURL: FOURSQUARE_USER_MAPS_URL +}); -const {DROPBOX_CLIENT_ID, CARTO_CLIENT_ID} = AUTH_TOKENS; const DROPBOX_CLIENT_NAME = 'Kepler.gl Demo App'; export const DEFAULT_CLOUD_PROVIDER = 'dropbox'; export const CLOUD_PROVIDERS = [ + new FoursquareProvider({ + clientId: FOURSQUARE_CLIENT_ID, + authDomain: FOURSQUARE_DOMAIN, + apiURL: FOURSQUARE_API_URL, + userMapsURL: FOURSQUARE_USER_MAPS_URL + }), new DropboxProvider(DROPBOX_CLIENT_ID, DROPBOX_CLIENT_NAME), new CartoProvider(CARTO_CLIENT_ID) ]; diff --git a/examples/demo-app/src/constants/default-settings.js b/examples/demo-app/src/constants/default-settings.js index dd9697fd10..8b4e0ebb97 100644 --- a/examples/demo-app/src/constants/default-settings.js +++ b/examples/demo-app/src/constants/default-settings.js @@ -60,9 +60,13 @@ export const DEFAULT_FEATURE_FLAGS = { cloudStorage: true }; -export const AUTH_TOKENS = { +export const CLOUD_PROVIDERS_CONFIGURATION = { MAPBOX_TOKEN: process.env.MapboxAccessToken, // eslint-disable-line DROPBOX_CLIENT_ID: process.env.DropboxClientId, // eslint-disable-line EXPORT_MAPBOX_TOKEN: process.env.MapboxExportToken, // eslint-disable-line - CARTO_CLIENT_ID: process.env.CartoClientId // eslint-disable-line + CARTO_CLIENT_ID: process.env.CartoClientId, // eslint-disable-line + FOURSQUARE_CLIENT_ID: process.env.FoursquareClientId, // eslint-disable-line + FOURSQUARE_DOMAIN: process.env.FoursquareDomain, // eslint-disable-line + FOURSQUARE_API_URL: process.env.FoursquareAPIURL, // eslint-disable-line + FOURSQUARE_USER_MAPS_URL: process.env.FoursquareUserMapsURL // eslint-disable-line }; diff --git a/examples/demo-app/src/reducers/index.js b/examples/demo-app/src/reducers/index.js index ccea8d51f2..a7673ea93a 100644 --- a/examples/demo-app/src/reducers/index.js +++ b/examples/demo-app/src/reducers/index.js @@ -34,7 +34,7 @@ import { SET_SAMPLE_LOADING_STATUS } from '../actions'; -import {AUTH_TOKENS} from '../constants/default-settings'; +import {CLOUD_PROVIDERS_CONFIGURATION} from '../constants/default-settings'; import {generateHashId} from '../utils/strings'; // INITIAL_APP_STATE @@ -84,7 +84,7 @@ const demoReducer = combineReducers({ ...DEFAULT_EXPORT_MAP, [EXPORT_MAP_FORMATS.HTML]: { ...DEFAULT_EXPORT_MAP[[EXPORT_MAP_FORMATS.HTML]], - exportMapboxAccessToken: AUTH_TOKENS.EXPORT_MAPBOX_TOKEN + exportMapboxAccessToken: CLOUD_PROVIDERS_CONFIGURATION.EXPORT_MAPBOX_TOKEN } } }, diff --git a/examples/demo-app/webpack.config.js b/examples/demo-app/webpack.config.js index 8b6ebb3a4b..688892112d 100644 --- a/examples/demo-app/webpack.config.js +++ b/examples/demo-app/webpack.config.js @@ -69,7 +69,11 @@ const CONFIG = { 'MapboxAccessToken', 'DropboxClientId', 'MapboxExportToken', - 'CartoClientId' + 'CartoClientId', + 'FoursquareClientId', + 'FoursquareDomain', + 'FoursquareAPIURL', + 'FoursquareUserMapsURL' ]) ] }; diff --git a/examples/webpack.config.local.js b/examples/webpack.config.local.js index ba6b4912da..382abe48d8 100644 --- a/examples/webpack.config.local.js +++ b/examples/webpack.config.local.js @@ -161,7 +161,11 @@ function makeLocalDevConfig(env, EXAMPLE_DIR = LIB_DIR, externals = {}) { 'MapboxAccessToken', 'DropboxClientId', 'MapboxExportToken', - 'CartoClientId' + 'CartoClientId', + 'FoursquareClientId', + 'FoursquareDomain', + 'FoursquareAPIURL', + 'FoursquareUserMapsURL' ]) ] }; diff --git a/src/cloud-providers/src/index.ts b/src/cloud-providers/src/index.ts index 5d71085e08..600288bfe4 100644 --- a/src/cloud-providers/src/index.ts +++ b/src/cloud-providers/src/index.ts @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -export {default as Provider, FILE_CONFLICT_MSG} from './provider'; +export {default as Provider, FILE_CONFLICT_MSG, KEPLER_FORMAT} from './provider'; // eslint-disable-next-line prettier/prettier export type {MapListItem, Thumbnail, ProviderProps, IconProps} from './provider'; export {default as Upload} from './upload'; diff --git a/src/cloud-providers/src/provider.ts b/src/cloud-providers/src/provider.ts index b72859c9b2..895ec55169 100644 --- a/src/cloud-providers/src/provider.ts +++ b/src/cloud-providers/src/provider.ts @@ -28,7 +28,7 @@ export type MapListItem = { description: string; loadParams: any; imageUrl?: string; - lastModification?: Millisecond; + updatedAt?: Millisecond; privateMap?: boolean; }; @@ -59,7 +59,9 @@ const NAME = 'cloud-provider'; const DISPLAY_NAME = 'Cloud Provider'; const THUMBNAIL = {width: 300, height: 200}; const ICON = Upload; +export const KEPLER_FORMAT = 'keplergl'; export const FILE_CONFLICT_MSG = 'file_conflict'; + /** * The default provider class * @param {object} props @@ -84,6 +86,7 @@ export default class Provider { displayName: string; icon: ComponentType; thumbnail: Thumbnail; + isNew: boolean = false; constructor(props: ProviderProps) { this.name = props.name || NAME; @@ -133,10 +136,10 @@ export default class Provider { /** * This method is called to determine whether user already logged in to this provider * @public - * @returns true if a user already logged in + * @returns {Promise} return the access token if a user already logged in */ - getAccessToken(): boolean { - return true; + getAccessToken(): Promise { + return Promise.reject('You must implement getAccessToken'); } /** @@ -200,7 +203,7 @@ export default class Provider { }: { mapData: MapData; options: ExportFileOptions; - }): Promise { + }): Promise { return Promise.reject('You must implement uploadMap'); } @@ -216,7 +219,7 @@ export default class Provider { * title: 'My map', * description: 'My first kepler map', * imageUrl: 'http://', - * lastModification: 1582677787000, + * updatedAt: 1582677787000, * privateMap: false, * loadParams: {} * } @@ -253,10 +256,13 @@ export default class Provider { * } */ async downloadMap(loadParams): Promise<{map: SavedMap; format: string}> { - // @ts-expect-error - return; + return Promise.reject('You must implement downloadMap'); } + /** + * @return {string} return the storage location url for the current provider + * @public + */ getManagementUrl(): string { throw new Error('You must implement getManagementUrl'); } diff --git a/src/components/src/common/flex-container.ts b/src/components/src/common/flex-container.ts index ffcb026f63..89ddf6549a 100644 --- a/src/components/src/common/flex-container.ts +++ b/src/components/src/common/flex-container.ts @@ -23,12 +23,11 @@ import styled from 'styled-components'; export const FlexContainer = styled.div` display: flex; gap: 8px; + flex-wrap: wrap; `; -export const FlexColContainer = styled.div` - display: flex; +export const FlexColContainer = styled(FlexContainer)` flex-direction: column; - gap: 8px; `; export const FlexContainerGrow = styled(FlexContainer)` diff --git a/src/components/src/modals/cloud-components/cloud-item.spec.tsx b/src/components/src/modals/cloud-components/cloud-item.spec.tsx index 1150c177e4..8a74657bdf 100644 --- a/src/components/src/modals/cloud-components/cloud-item.spec.tsx +++ b/src/components/src/modals/cloud-components/cloud-item.spec.tsx @@ -1,6 +1,4 @@ // @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'; @@ -23,12 +21,16 @@ describe('CloudItem', () => { }); it('renders PrivacyBadge for private maps', () => { - const {getByText} = renderWithTheme( {}} />); + const {getByText} = renderWithTheme( + {}} /> + ); expect(getByText('Private')).toBeInTheDocument(); }); it('does not render PrivacyBadge for public maps', () => { - const {queryByText} = renderWithTheme( {}} />); + const {queryByText} = renderWithTheme( + {}} /> + ); expect(queryByText('Private')).toBeNull(); }); @@ -38,7 +40,9 @@ describe('CloudItem', () => { }); it('displays MapIcon when no thumbnail is provided', () => { - const {getByRole} = renderWithTheme( {}} />); + const {getByRole} = renderWithTheme( + {}} /> + ); expect(getByRole('map-icon')).toBeInTheDocument(); }); @@ -46,7 +50,9 @@ describe('CloudItem', () => { const {getByText} = renderWithTheme( {}} />); expect(getByText('Test Title')).toBeInTheDocument(); expect(getByText('Test Description')).toBeInTheDocument(); - expect(getByText(`Last modified ${moment.utc(mockVis.lastModification).fromNow()}`)).toBeInTheDocument(); + expect( + getByText(`Last modified ${moment.utc(mockVis.lastModification).fromNow()}`) + ).toBeInTheDocument(); }); it('calls onClick when component is clicked', () => { @@ -56,4 +62,3 @@ describe('CloudItem', () => { 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 index e7d57d588b..4079572b93 100644 --- a/src/components/src/modals/cloud-components/cloud-item.tsx +++ b/src/components/src/modals/cloud-components/cloud-item.tsx @@ -137,11 +137,9 @@ export const CloudItem = ({vis, onClick}) => { )} {vis.title} - {vis.description?.length && ( - {vis.description} - )} + {vis.description?.length && {vis.description}} - Last modified {moment.utc(vis.lastModification).fromNow()} + Last modified {moment.utc(vis.updatedAt).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 index 8b884bd283..c3a123a366 100644 --- a/src/components/src/modals/cloud-components/cloud-maps.spec.tsx +++ b/src/components/src/modals/cloud-components/cloud-maps.spec.tsx @@ -12,7 +12,9 @@ describe('CloudMaps Component', () => { it('displays error message when there is an error', () => { const errorMessage = 'Test Error'; - const {getByText} = renderWithTheme(); + const {getByText} = renderWithTheme( + + ); expect(getByText(`Error while fetching maps: ${errorMessage}`)).toBeInTheDocument(); }); @@ -22,8 +24,14 @@ describe('CloudMaps Component', () => { }); 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(); + 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"' }); @@ -33,14 +41,25 @@ describe('CloudMaps Component', () => { }); it('calls onSelectMap when a CloudItem is clicked', () => { - const mockMaps = [{ id: 1, title: 'map' }, { id: 2, title: 'map' }, { id: 3, title: 'map' }]; + 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 {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 index 9387b778f6..c866d497e1 100644 --- a/src/components/src/modals/cloud-components/cloud-maps.tsx +++ b/src/components/src/modals/cloud-components/cloud-maps.tsx @@ -32,6 +32,11 @@ const StyledSpinner = styled.div` } `; +const StyledFlexContainer = styled(FlexContainer)` + justify-content: flex-start; + flex-wrap: wrap; +`; + export const CloudMaps = ({provider, onSelectMap, isLoading, maps, error}) => { if (error) { return
Error while fetching maps: {error.message}
; @@ -46,7 +51,7 @@ export const CloudMaps = ({provider, onSelectMap, isLoading, maps, error}) => { } return ( - + {(maps ?? []).length ? ( maps.map(vis => ( onSelectMap(provider, vis)} vis={vis} /> @@ -56,6 +61,6 @@ export const CloudMaps = ({provider, onSelectMap, isLoading, maps, error}) => { )} - + ); }; diff --git a/src/components/src/modals/cloud-tile.tsx b/src/components/src/modals/cloud-tile.tsx index c435dbf8f5..c086792deb 100644 --- a/src/components/src/modals/cloud-tile.tsx +++ b/src/components/src/modals/cloud-tile.tsx @@ -60,6 +60,7 @@ const StyledTileWrapper = styled.div.attrs({ const StyledBox = styled(CenterVerticalFlexbox)` margin-right: 12px; + position: relative; `; const StyledCloudName = styled.div` @@ -96,6 +97,25 @@ const LogoutButton = ({onClick}: OnClickProps) => ( ); +const NewTag = styled.div` + width: 37px; + height: 19px; + display: flex; + align-content: center; + justify-content: center; + border-radius: 8px; + padding: 4px 8px; + background-color: #EDE9F9; + color: #8863F8; + position: absolute; + left: 35%; + top: -8px + z-index: 500; + font-size: 11px; + line-height: 10px; +; +`; + interface CloudTileProps { actionName?: string | null; // cloud provider class @@ -116,7 +136,7 @@ const CloudTile: React.FC = ({provider, actionName}) => { const isSelected = provider === currentProvider; useEffect(() => { - if (provider.getAccessToken()) { + if (provider) { setError(null); setIsLoading(true); setError(null); @@ -129,6 +149,8 @@ const CloudTile: React.FC = ({provider, actionName}) => { }, [provider]); const onLogin = useCallback(async () => { + setError(null); + setIsLoading(true); try { const user = await provider.login(); setUser(user); @@ -136,6 +158,7 @@ const CloudTile: React.FC = ({provider, actionName}) => { } catch (error) { setError(error as Error); } + setIsLoading(false); }, [provider]); const onSelect = useCallback(async () => { @@ -164,6 +187,10 @@ const CloudTile: React.FC = ({provider, actionName}) => { return ( + {provider.isNew ? ( + New + ) : null} +
{displayName || name} {provider.icon ? : null} diff --git a/src/components/src/modals/load-storage-map.spec.tsx b/src/components/src/modals/load-storage-map.spec.tsx index 3a272e42a9..af715acf2d 100644 --- a/src/components/src/modals/load-storage-map.spec.tsx +++ b/src/components/src/modals/load-storage-map.spec.tsx @@ -63,7 +63,7 @@ describe('LoadStorageMap', () => { cloudProviders: [] })); - const {getByText } = renderWithTheme(); + const {getByText} = renderWithTheme(); expect(DEFAULT_PROVIDER.listMaps).toHaveBeenCalled(); // first show loading icon @@ -72,7 +72,7 @@ describe('LoadStorageMap', () => { // show empty maps await waitFor(() => { expect(getByText('modal.loadStorageMap.noSavedMaps')).toBeInTheDocument(); - }) + }); }); test('renders map list because', async () => { @@ -88,7 +88,7 @@ describe('LoadStorageMap', () => { cloudProviders: [] })); - const {getByText } = renderWithTheme(); + const {getByText} = renderWithTheme(); expect(mapProvider.listMaps).toHaveBeenCalled(); // first show loading icon @@ -99,8 +99,8 @@ describe('LoadStorageMap', () => { DEFAULT_MAPS.forEach(map => { expect(getByText(map.title)).toBeInTheDocument(); expect(getByText(map.description)).toBeInTheDocument(); - }) - }) + }); + }); }); test('trigger onLoadCLoudMap when clicking on a map', async () => { @@ -116,7 +116,7 @@ describe('LoadStorageMap', () => { cloudProviders: [] })); - const {getByText } = renderWithTheme(); + const {getByText} = renderWithTheme(); expect(mapProvider.listMaps).toHaveBeenCalled(); // first show loading icon @@ -138,14 +138,14 @@ describe('LoadStorageMap', () => { 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(); + const {getByText} = renderWithTheme(); expect(rejectableProvider.listMaps).toHaveBeenCalled(); // first show loading icon @@ -157,4 +157,3 @@ describe('LoadStorageMap', () => { }); }); }); - diff --git a/src/components/src/modals/load-storage-map.tsx b/src/components/src/modals/load-storage-map.tsx index 46beda8d60..60ed9791d0 100644 --- a/src/components/src/modals/load-storage-map.tsx +++ b/src/components/src/modals/load-storage-map.tsx @@ -40,12 +40,8 @@ function LoadStorageMapFactory() { setIsLoading(true); provider .listMaps() - .then(maps => { - setMaps(maps) - }) - .catch(error => { - setError(error) - }) + .then(setMaps) + .catch(setError) .finally(() => setIsLoading(false)); } else { setIsLoading(false); diff --git a/src/components/src/modals/save-map-modal.spec.tsx b/src/components/src/modals/save-map-modal.spec.tsx index 1bc6067259..ff836d05ad 100644 --- a/src/components/src/modals/save-map-modal.spec.tsx +++ b/src/components/src/modals/save-map-modal.spec.tsx @@ -1,23 +1,3 @@ -// 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 /** diff --git a/src/components/src/modals/save-map-modal.tsx b/src/components/src/modals/save-map-modal.tsx index 4c51bf8f5f..09b505e085 100644 --- a/src/components/src/modals/save-map-modal.tsx +++ b/src/components/src/modals/save-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, {useMemo} from 'react'; +import React, {useCallback, useMemo} from 'react'; import styled from 'styled-components'; import ImageModalContainer, {ImageModalContainerProps} from './image-modal-container'; import {FlexContainer} from '../common/flex-container'; @@ -207,6 +207,12 @@ function SaveMapModalFactory() { [provider, mapInfo] ); + const confirm = useCallback(() => { + if (provider) { + onConfirm(provider); + } + }, [provider]); + return ( provider && onConfirm(provider)} + confirm={confirm} confirmButton={confirmButton} /> diff --git a/src/components/src/modals/share-map-modal.spec.tsx b/src/components/src/modals/share-map-modal.spec.tsx index 421c65141a..c524761fc4 100644 --- a/src/components/src/modals/share-map-modal.spec.tsx +++ b/src/components/src/modals/share-map-modal.spec.tsx @@ -20,7 +20,6 @@ jest.mock('../hooks/use-cloud-list-provider', () => ({ const ShareMapUrlModal = ShareMapUrlModalFactory(); - const DEFAULT_PROPS = { isProviderLoading: false, onExport: jest.fn(), @@ -28,7 +27,7 @@ const DEFAULT_PROPS = { successInfo: undefined, onUpdateImageSetting: jest.fn(), cleanupExportImage: jest.fn() -} +}; describe('ShareMapModal', () => { afterEach(() => { @@ -125,7 +124,7 @@ describe('ShareMapModal', () => { - ) + ); }); expect(DEFAULT_PROPS.onExport).toHaveBeenCalled(); @@ -133,12 +132,16 @@ describe('ShareMapModal', () => { it('displays share URL when provided', () => { const shareUrl = 'http://example.com'; - const { getByText } = renderWithTheme(); + const {getByText} = renderWithTheme( + + ); expect(getByText('Share Url')).toBeInTheDocument(); }); it('renders errors', () => { - const { getByText } = renderWithTheme(); + const {getByText} = renderWithTheme( + + ); expect(getByText('modal.statusPanel.error')).toBeInTheDocument(); }); -}) +}); diff --git a/src/components/src/side-panel/panel-header.tsx b/src/components/src/side-panel/panel-header.tsx index 3cc92b82c9..0eae456b71 100644 --- a/src/components/src/side-panel/panel-header.tsx +++ b/src/components/src/side-panel/panel-header.tsx @@ -167,7 +167,12 @@ const PanelAction: React.FC = React.memo(({item, showExportDro }, [item, showExportDropdown]); return ( - + {item.label ?

{item.label}

: null} diff --git a/website/webpack.config.js b/website/webpack.config.js index c45b165352..87aa03ffcd 100644 --- a/website/webpack.config.js +++ b/website/webpack.config.js @@ -110,7 +110,11 @@ const COMMON_CONFIG = { DropboxClientId: null, CartoClientId: null, GoogleDriveClientId: null, - MapboxExportToken: null + MapboxExportToken: null, + FoursquareClientId: null, + FoursquareDomain: null, + FoursquareAPIURL: null, + FoursquareUserMapsURL: null }) ], @@ -158,6 +162,17 @@ function logInstruction(msg) { console.log('\x1b[36m%s\x1b[0m', msg); } +function validateEnvVariable(variable, instruction) { + if (!process.env[variable]) { + logError(`Error! ${variable} is not defined`); + logInstruction( + `Make sure to run "export ${variable}=" before deploy the website` + ); + logInstruction(instruction); + throw new Error(`Missing ${variable}`); + } +} + module.exports = env => { env = env || {}; @@ -168,32 +183,13 @@ module.exports = env => { } if (env.prod) { - if (!process.env.MapboxAccessToken) { - logError('Error! MapboxAccessToken is not defined'); - logInstruction( - `Make sure to run "export MapboxAccessToken=" before deploy the website` - ); - logInstruction( - 'You can get the token at https://www.mapbox.com/help/how-access-tokens-work/' - ); - throw new Error('Missing Mapbox Access token'); - } - if (!process.env.DropboxClientId) { - logError('Error! DropboxClientId is not defined'); - logInstruction(`Make sure to run "export DropboxClientId=" before deploy the website`); - logInstruction('You can get the token at https://www.dropbox.com/developers'); - throw new Error('Missing Export DropboxClientId Access token'); - } - if (!process.env.MapboxExportToken) { - logError('Error! MapboxExportToken is not defined'); - logInstruction( - `Make sure to run "export MapboxExportToken=" before deploy the website` - ); - logInstruction( - 'You can get the token at https://www.mapbox.com/help/how-access-tokens-work/' - ); - throw new Error('Missing Export Mapbox Access token, used to generate the single map file'); - } + validateEnvVariable('MapboxAccessToken', 'You can get the token at https://www.mapbox.com/help/how-access-tokens-work/'); + validateEnvVariable('DropboxClientId', 'You can get the token at https://www.dropbox.com/developers'); + validateEnvVariable('MapboxExportToken', 'You can get the token at https://www.mapbox.com/help/how-access-tokens-work/'); + validateEnvVariable('FoursquareClientId','https://location.foursquare.com/developer'); + validateEnvVariable('FoursquareDomain','https://location.foursquare.com/developer'); + validateEnvVariable('FoursquareAPIURL','https://location.foursquare.com/developer'); + validateEnvVariable('FoursquareUserMapsURL','https://location.foursquare.com/developer'); config = addProdConfig(config); }