├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── .travis.yml ├── README.md ├── package.json ├── public ├── favicon.ico ├── images │ └── Banner9.jpg ├── index.html ├── manifest.json └── redirect.html ├── screenshot.png ├── src ├── App.css ├── App.js ├── App.test.js ├── __snapshots__ │ └── App.test.js.snap ├── components │ ├── AgoSearch.js │ ├── AgoSearch.test.js │ ├── AppNav.js │ ├── ExtentsMap.js │ ├── ExtentsMap.test.js │ ├── ItemsLayout.js │ ├── ItemsTable.js │ ├── ItemsTable.test.js │ ├── UserMenu.js │ └── UserMenu.test.js ├── config │ ├── environment.js │ └── map.js ├── index.js ├── reducers │ ├── app.js │ └── app.test.js ├── routes │ ├── Home.js │ └── Items.js ├── serviceWorker.js ├── setupTests.js └── utils │ ├── map.js │ ├── map.test.js │ ├── search.js │ ├── search.test.js │ ├── session.js │ └── session.test.js └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "react-app", 4 | "prettier/react", 5 | "plugin:prettier/recommended" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | # logs 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # app 27 | # this is copied from node_modules by a script 28 | public/auth.umd.min.js 29 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "stable" 4 | cache: 5 | directories: 6 | - node_modules 7 | script: 8 | - yarn test 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Create ArcGIS App 2 | 3 | An example of how to use the ArcGIS platform in an application built with [create-react-app]. 4 | 5 | ![App screenshot](./screenshot.png) 6 | 7 | [View it live!](https://create-arcgis-app.surge.sh/) 8 | 9 | This application uses [arcgis-rest-js](https://esri.github.io/arcgis-rest-js/) to authenticate users and search for items and the [ArcGIS API for JavaScript](https://developers.arcgis.com/javascript/) (via [esri-loader]) to show the extents of those items on a map. 10 | 11 | This is a [React] port of [ambitious-arcgis-app-2018](https://github.com/mjuniper/ambitious-arcgis-app-2018/). See that repository for more information on the motivation behind this application. 12 | 13 | See [next-arcgis-app](https://github.com/tomwayson/next-arcgis-app) for a port of the same application built with [Next.js](https://nextjs.org/) instead of create-react-app. 14 | 15 | See below for instructions on how to run and modify this application locally after cloning the repository. 16 | 17 | ## Available Scripts 18 | 19 | In the project directory, you can run: 20 | 21 | ### `yarn start` 22 | 23 | Runs the app in the development mode.
24 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 25 | 26 | The page will reload if you make edits.
27 | You will also see any lint errors in the console. 28 | 29 | **NOTE**: in order to see linting rules in your editor, you will need to install eslint globally (i.e. `npm i -g eslint`). 30 | 31 | ### `yarn test` 32 | 33 | Launches the test runner in the interactive watch mode.
34 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 35 | 36 | ### `yarn run build` 37 | 38 | Builds the app for production to the `build` folder.
39 | It correctly bundles React in production mode and optimizes the build for the best performance. 40 | 41 | The build is minified and the filenames include the hashes.
42 | Your app is ready to be deployed! 43 | 44 | ### `yarn run deploy` 45 | 46 | Deploy the built application to https://surge.sh/. You will need to update this script in package.json to point to your own surge domain. 47 | 48 | See the section about [deploying to surge](https://facebook.github.io/create-react-app/docs/deployment#surge-https-surgesh) for more information. 49 | 50 | ### `yarn run eject` 51 | 52 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 53 | 54 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 55 | 56 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 57 | 58 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 59 | 60 | ## Learn More 61 | 62 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 63 | 64 | To learn React, check out the [React documentation](https://reactjs.org/). 65 | 66 | ### Code Splitting 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 69 | 70 | ### Analyzing the Bundle Size 71 | 72 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 73 | 74 | ### Making a Progressive Web App 75 | 76 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 77 | 78 | ### Advanced Configuration 79 | 80 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 81 | 82 | ### Deployment 83 | 84 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 85 | 86 | ### `yarn run build` fails to minify 87 | 88 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 89 | 90 | [create-react-app]: https://facebook.github.io/create-react-app/ 91 | [arcgis]: https://www.arcgis.com/ 92 | [esri-loader]: https://github.com/Esri/esri-loader 93 | [react]: https://reactjs.org/ 94 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-arcgis-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "description": "An example of how to use the ArcGIS platform in an application created with crete-react-app.", 6 | "dependencies": { 7 | "@esri/arcgis-rest-auth": "^2.0.0", 8 | "@esri/arcgis-rest-portal": "^2.0.0", 9 | "@esri/arcgis-rest-request": "^2.0.0", 10 | "bootstrap": "^4.3.1", 11 | "esri-loader": "^2.9.2", 12 | "js-cookie": "^2.2.0", 13 | "react": "^16.8.6", 14 | "react-arcgis-hub": "^0.1.0", 15 | "react-dom": "^16.8.6", 16 | "react-router-dom": "^5.0.0", 17 | "reactstrap": "^8.0.0" 18 | }, 19 | "devDependencies": { 20 | "eslint-config-prettier": "^4.2.0", 21 | "eslint-config-react-app": "^4.0.0", 22 | "eslint-plugin-flowtype": "^3.6.1", 23 | "eslint-plugin-import": "^2.17.2", 24 | "eslint-plugin-jsx-a11y": "^6.2.1", 25 | "eslint-plugin-prettier": "^3.0.1", 26 | "eslint-plugin-react": "^7.12.4", 27 | "husky": "^2.1.0", 28 | "jest-dom": "^3.1.4", 29 | "lint-staged": "^8.1.5", 30 | "node-sass": "^4.12.0", 31 | "prettier": "^1.17.0", 32 | "react-scripts": "3.0.0", 33 | "react-testing-library": "^7.0.0" 34 | }, 35 | "scripts": { 36 | "start": "react-scripts start", 37 | "prebuild": "npm run copy:auth", 38 | "copy:auth": "cp ./node_modules/@esri/arcgis-rest-auth/dist/umd/auth.umd.min.js ./public/auth.umd.min.js", 39 | "build": "react-scripts build", 40 | "predeploy": "npm run build && mv build/index.html build/200.html", 41 | "deploy": "surge ./build create-arcgis-app.surge.sh", 42 | "test": "react-scripts test", 43 | "eject": "react-scripts eject" 44 | }, 45 | "browserslist": [ 46 | ">0.2%", 47 | "not dead", 48 | "not ie <= 11", 49 | "not op_mini all" 50 | ], 51 | "husky": { 52 | "hooks": { 53 | "pre-commit": "lint-staged" 54 | } 55 | }, 56 | "lint-staged": { 57 | "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [ 58 | "prettier --single-quote --write", 59 | "git add" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomwayson/create-arcgis-app/041c84d330f3873f18a3e738f42cea6ecc9fcd2c/public/favicon.ico -------------------------------------------------------------------------------- /public/images/Banner9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomwayson/create-arcgis-app/041c84d330f3873f18a3e738f42cea6ecc9fcd2c/public/images/Banner9.jpg -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 25 | Create ArcGIS App 26 | 27 | 28 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "ArcGIS App", 3 | "name": "Ambitious ArcGIS App", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /public/redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomwayson/create-arcgis-app/041c84d330f3873f18a3e738f42cea6ecc9fcd2c/screenshot.png -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | /* global styles */ 2 | body { 3 | padding-top: 3.5rem; 4 | } 5 | 6 | /* index */ 7 | .jumbotron { 8 | background: linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2)), 9 | url(/images/Banner9.jpg) center top/cover no-repeat; 10 | } 11 | 12 | /* items */ 13 | .search-form-inline { 14 | margin-top: 5px; 15 | } 16 | 17 | /* map */ 18 | .extents-map { 19 | height: 300px; 20 | } 21 | .extents-map.esri-view { 22 | margin-bottom: 20px; 23 | } 24 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useReducer, useEffect, useCallback } from 'react'; 2 | import { Route } from 'react-router-dom'; 3 | import './App.css'; 4 | import { signIn, signOut } from './utils/session'; 5 | import { actionTypes, appReducer } from './reducers/app'; 6 | import AppNav from './components/AppNav'; 7 | import UserMenu from './components/UserMenu'; 8 | import Home from './routes/Home'; 9 | import Items from './routes/Items'; 10 | 11 | function App({ previousSession, title }) { 12 | // use a reducer for state to help manage the interdependent 13 | // and asynchronous nature of session and user state 14 | const [state, dispatch] = useReducer(appReducer, { 15 | // TODO: add title to state? 16 | session: previousSession 17 | }); 18 | // NOTE: when storing objects like session or user in state 19 | // React uses the Object.is() comparison algorithm to detect changes 20 | // and changes to the object's properties won't trigger a re-render 21 | // which is fine for this application, b/c we don't mutate their properties 22 | const { session, user } = state; 23 | // (re)fetch user info when the session is first initialized or has updated 24 | useEffect(() => { 25 | if (session && !user) { 26 | // fetch user info and make available to the app as state 27 | session.getUser().then(newUser => { 28 | dispatch({ type: actionTypes.setUser, user: newUser }); 29 | }); 30 | } 31 | }, [session]); 32 | // use memoized callback functions for event handlers 33 | // see: https://reactjs.org/docs/hooks-reference.html#usecallback 34 | const onSignIn = useCallback(() => { 35 | // make session available to the app once the user signs in 36 | signIn().then(newSession => { 37 | dispatch({ type: actionTypes.setSession, session: newSession }); 38 | }); 39 | // NOTE: I'm not sure if [dispatch] is needed, but the above docs say: 40 | // "every value referenced inside the callback should also appear in the inputs array." 41 | }, [dispatch]); 42 | const onSignOut = useCallback(() => { 43 | // signal to app that the user has signed out by clearing user & session 44 | dispatch({ type: actionTypes.signOut }); 45 | // clear the cookie, etc. 46 | signOut(); 47 | // NOTE: I'm not sure if [] is needed, but in theory 48 | // it causes this callback to only be created once per component instance 49 | }, []); 50 | // NOTE: we bind the user menu and render it here 51 | // and pass it to the nav menu in order to avoid prop drilling 52 | // see: https://reactjs.org/docs/context.html#before-you-use-context 53 | const userMenu = ( 54 | 55 | ); 56 | return ( 57 | <> 58 | 59 |
60 | {/* 61 | NOTE: we use render instead of component here so that we can pass in the session 62 | to the items route in addition to the other props passed in by react-router 63 | see: https://reacttraining.com/react-router/web/api/Route/render-func 64 | */} 65 | } 69 | /> 70 | } 73 | /> 74 |
75 | 76 | ); 77 | } 78 | 79 | export default App; 80 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, wait } from 'react-testing-library'; 3 | import { BrowserRouter as Router } from 'react-router-dom'; 4 | import App from './App'; 5 | 6 | describe('smoke tests', function() { 7 | describe('w/o a previous session', function() { 8 | const title = 'Test Title'; 9 | it('renders app title and sign in button', () => { 10 | const { getAllByText } = render( 11 | 12 | 13 | 14 | ); 15 | expect(getAllByText(title)[0]).toBeInTheDocument(); 16 | expect(getAllByText('Sign In')[0]).toBeInTheDocument(); 17 | }); 18 | it('matches snapshot', () => { 19 | const { asFragment } = render( 20 | 21 | 22 | 23 | ); 24 | expect(asFragment()).toMatchSnapshot(); 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/__snapshots__/App.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`smoke tests w/o a previous session matches snapshot 1`] = ` 4 | 5 | 66 |
69 |
72 |

75 | Test Title 76 |

77 |
80 |
83 | 88 |
91 | 97 |
98 |
99 |
100 |
101 |
102 |
103 | `; 104 | -------------------------------------------------------------------------------- /src/components/AgoSearch.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | function inputGroupClass(size) { 4 | const sizeClass = size ? `input-group-${size}` : ''; 5 | return `input-group ${sizeClass}`.trim(); 6 | } 7 | 8 | function AgoSearch({ q, size, onSearch, className }) { 9 | // use a copy of the search term so that we don't immediately update bound URL parameters 10 | const [searchCopy, setSearchCopy] = useState(q || ''); 11 | function onChange(e) { 12 | // hold onto a copy of the search term 13 | setSearchCopy(e.target.value); 14 | } 15 | function onSubmit(e) { 16 | // don't actually submit the form 17 | e.preventDefault(); 18 | if (onSearch) { 19 | // call search function that was passed in as a prop 20 | onSearch(searchCopy); 21 | } 22 | } 23 | const formClassName = `search-form${className ? ' ' + className : ''}`; 24 | return ( 25 |
26 |
27 | 33 |
34 | 37 |
38 |
39 |
40 | ); 41 | } 42 | 43 | export default AgoSearch; 44 | -------------------------------------------------------------------------------- /src/components/AgoSearch.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent } from 'react-testing-library'; 3 | import AgoSearch from './AgoSearch'; 4 | 5 | describe('components', function() { 6 | describe('AgoSearch', function() { 7 | it('should pass q to input and call onSearch when button is clicked', function() { 8 | const q = 'initial q'; 9 | // test double for the search handler 10 | const onSearch = jest.fn(); 11 | // render component to the page 12 | const { getByPlaceholderText, getByText } = render( 13 | 14 | ); 15 | // initial dom state 16 | const input = getByPlaceholderText('search for items'); 17 | expect(input.value).toBe(q); 18 | let newQ = 'new q'; 19 | // change the value and click the search button 20 | fireEvent.change(input, { target: { value: newQ } }); 21 | getByText('Search').click(); 22 | expect(onSearch).toBeCalledWith(newQ); 23 | expect(onSearch).toHaveBeenCalledTimes(1); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/components/AppNav.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | import { 4 | Collapse, 5 | Navbar, 6 | NavbarToggler, 7 | NavbarBrand, 8 | Nav, 9 | NavItem 10 | } from 'reactstrap'; 11 | 12 | function AppNav({ title, userMenu }) { 13 | const [isOpen, setIsOpen] = useState(false); 14 | function toggle() { 15 | setIsOpen(!isOpen); 16 | } 17 | return ( 18 | 19 | {title} 20 | 21 | 22 | 34 | 37 | 38 | 39 | ); 40 | } 41 | 42 | export default AppNav; 43 | -------------------------------------------------------------------------------- /src/components/ExtentsMap.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import config from '../config/map'; 3 | import { loadMap, showItemsOnMap } from '../utils/map'; 4 | 5 | class ExtentsMap extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | // a ref to the DOM node where we want to create the map 9 | // see: https://reactjs.org/docs/refs-and-the-dom.html 10 | this.mapNode = React.createRef(); 11 | } 12 | 13 | // show items on the map w/ the symbol and popupTemplate from the config 14 | showItems() { 15 | const { symbol, popupTemplate } = config.itemExtents; 16 | showItemsOnMap(this._view, this.props.items, symbol, popupTemplate); 17 | } 18 | 19 | // react lifecycle methods 20 | // wait until after the component is added to the DOM before creating the map 21 | componentDidMount() { 22 | // create a map at this element's DOM node 23 | loadMap(this.mapNode.current, config.options).then(view => { 24 | // hold onto a reference to the map view 25 | // NOTE: we don't use props/state for this b/c we don't want to trigger a re-render 26 | // see https://medium.freecodecamp.org/where-do-i-belong-a-guide-to-saving-react-component-data-in-state-store-static-and-this-c49b335e2a00#978c 27 | this._view = view; 28 | // show the initial items on the map 29 | this.showItems(); 30 | }); 31 | } 32 | componentDidUpdate(prevProps) { 33 | if (prevProps.items !== this.props.items && this._view) { 34 | this.showItems(); 35 | } 36 | } 37 | // destroy the map before this component is removed from the DOM 38 | componentWillUnmount() { 39 | if (this._view) { 40 | this._view.container = null; 41 | delete this._view; 42 | } 43 | } 44 | render() { 45 | return
; 46 | } 47 | } 48 | 49 | export default ExtentsMap; 50 | -------------------------------------------------------------------------------- /src/components/ExtentsMap.test.js: -------------------------------------------------------------------------------- 1 | import { loadMap, showItemsOnMap } from '../utils/map'; 2 | import React from 'react'; 3 | import { render, wait } from 'react-testing-library'; 4 | import ExtentsMap from './ExtentsMap'; 5 | import config from '../config/map'; 6 | 7 | jest.mock('../utils/map'); 8 | 9 | // mock item search response 10 | function mockItems() { 11 | return [ 12 | { 13 | title: 'Item 1', 14 | snippet: 'Item 1 snippet', 15 | extent: [[-75.5596, 38.9285], [-73.9024, 41.3576]] 16 | }, 17 | { 18 | title: 'Item 2', 19 | snippet: 'Item 2 snippet', 20 | extent: [[-74, 39], [-73, 40]] 21 | }, 22 | { 23 | title: 'Item 3', 24 | snippet: 'Item 3 snippet', 25 | extent: [[-53.2316, -79.8433], [180, 79.8433]] 26 | } 27 | ]; 28 | } 29 | 30 | describe('components', function() { 31 | describe('ExtentsMap', function() { 32 | it('should render', function() { 33 | // stub the loadMap() function and have it return a mock view 34 | // to ensure the ArcGIS API is not loaded and a map is not rendered 35 | const mockView = {}; 36 | loadMap.mockResolvedValue(mockView); 37 | // mock showItemsOnMap() 38 | showItemsOnMap.mockReturnValue(undefined); 39 | // first render component to the page w/o items 40 | const { getByTestId, rerender } = render(); 41 | // validate that the the DOM node was rendered 42 | expect(getByTestId('map')).toBeInTheDocument(); 43 | // validate that loadMap() was called w/ a DOM node and map options 44 | expect(loadMap.mock.calls.length).toBe(1); 45 | const loadMapArgs = loadMap.mock.calls[0]; 46 | expect(loadMapArgs[0]).toBeInstanceOf(HTMLDivElement); 47 | expect(loadMapArgs[1]).toEqual(config.options); 48 | // wait (one tick) for mocked loadMap() to resolve 49 | return wait().then(() => { 50 | // then update the items 51 | const items = mockItems(); 52 | rerender(); 53 | // validate that showItemsOnMap() was called exactly twice 54 | expect(showItemsOnMap.mock.calls.length).toBe(2); 55 | // w/ the correct arguments 56 | const { symbol, popupTemplate } = config.itemExtents; 57 | expect(showItemsOnMap.mock.calls[0]).toEqual([ 58 | mockView, 59 | undefined, 60 | symbol, 61 | popupTemplate 62 | ]); 63 | expect(showItemsOnMap.mock.calls[1]).toEqual([ 64 | mockView, 65 | items, 66 | symbol, 67 | popupTemplate 68 | ]); 69 | }); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/components/ItemsLayout.js: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { ItemPager } from 'react-arcgis-hub'; 3 | import AgoSearch from './AgoSearch'; 4 | import ExtentsMap from './ExtentsMap'; 5 | import ItemsTable from './ItemsTable'; 6 | 7 | function ItemsLayout({ results, total, num, q, start, onParamsChange }) { 8 | // use memoized callbacks for action handlers 9 | // NOTE: it's probably fine to just use inline fns instead 10 | // see: https://reactjs.org/docs/hooks-faq.html#are-hooks-slow-because-of-creating-functions-in-render 11 | const onSearch = useCallback( 12 | newQ => { 13 | if (onParamsChange) { 14 | onParamsChange(newQ); 15 | } 16 | }, 17 | [onParamsChange] 18 | ); 19 | const changePage = useCallback( 20 | page => { 21 | if (onParamsChange) { 22 | // calculate next start record based on the number of records per page 23 | const start = (page - 1) * num + 1; 24 | onParamsChange(q, start); 25 | } 26 | }, 27 | [q, num] 28 | ); 29 | // compute current page number based on start record 30 | // and the number of records per page 31 | const pageNumber = (start - 1) / num + 1; 32 | return ( 33 | <> 34 |
35 |
36 |

37 | Your search for "{q}" yielded {total} items 38 |

39 |
40 |
41 | 47 |
48 |
49 |
50 |
51 | 52 | 53 | 59 |
60 |
61 | 62 | ); 63 | } 64 | 65 | export default ItemsLayout; 66 | -------------------------------------------------------------------------------- /src/components/ItemsTable.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function ItemsTable({ items }) { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {items && 15 | items.map(item => { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | })} 24 | 25 |
TitleTypeOwner
{item.title}{item.type}{item.owner}
26 | ); 27 | } 28 | 29 | export default ItemsTable; 30 | -------------------------------------------------------------------------------- /src/components/ItemsTable.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-testing-library'; 3 | import ItemsTable from './ItemsTable'; 4 | 5 | describe('components', function() { 6 | describe('ItemsTable', function() { 7 | it('should render with no items', function() { 8 | // render component to the page 9 | const { getByText } = render(); 10 | expect(getByText('Title')).toBeInTheDocument(); 11 | expect(getByText('Type')).toBeInTheDocument(); 12 | expect(getByText('Owner')).toBeInTheDocument(); 13 | }); 14 | it('should render a table row for each item', function() { 15 | const items = [ 16 | { id: '3aef', title: 'Item 1', type: 'Web Map', owner: 'tomwayson' }, 17 | { 18 | id: '4aef', 19 | title: 'Item 2', 20 | type: 'Web Mapping Application', 21 | owner: 'mjuniper' 22 | }, 23 | { 24 | id: '5aef', 25 | title: 'Item 3', 26 | type: 'Feature Service', 27 | owner: 'dbouwman' 28 | } 29 | ]; 30 | // render component to the page 31 | const { getByText } = render(); 32 | expect(getByText('Item 1')).toBeInTheDocument(); 33 | expect(getByText('Item 2')).toBeInTheDocument(); 34 | expect(getByText('Item 3')).toBeInTheDocument(); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/components/UserMenu.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | NavItem, 4 | Button, 5 | UncontrolledDropdown, 6 | DropdownToggle, 7 | DropdownMenu, 8 | DropdownItem 9 | } from 'reactstrap'; 10 | 11 | function UserMenu({ currentUser, onSignIn, onSignOut }) { 12 | if (!currentUser) { 13 | // show sign in link 14 | return ( 15 | 16 | 19 | 20 | ); 21 | } 22 | // show user menu 23 | return ( 24 | 25 | 26 | {currentUser.fullName} 27 | 28 | 29 | Sign Out 30 | 31 | 32 | ); 33 | } 34 | 35 | export default UserMenu; 36 | -------------------------------------------------------------------------------- /src/components/UserMenu.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-testing-library'; 3 | import UserMenu from './UserMenu'; 4 | 5 | describe('components', function() { 6 | describe('UserMenu', function() { 7 | it('should render sign in link w/ no user', function() { 8 | // render component to the page 9 | const { getByText, queryByText } = render(); 10 | expect(getByText('Sign In')).toBeInTheDocument(); 11 | expect(queryByText('Tom Wayson')).toBeNull(); 12 | }); 13 | it('should render full name w/ a user', function() { 14 | const mockUser = { 15 | fullName: 'Tom Wayson' 16 | }; 17 | // render component to the page 18 | const { getByText, queryByText } = render( 19 | 20 | ); 21 | expect(queryByText('Sign In')).toBeNull(); 22 | expect(getByText('Tom Wayson')).toBeInTheDocument(); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/config/environment.js: -------------------------------------------------------------------------------- 1 | // NOTE: this is a good place to store any variables 2 | // that change depending on the application's environment 3 | // see https://facebook.github.io/create-react-app/docs/adding-custom-environment-variables 4 | 5 | // defaults that all environments fall back to 6 | const env = { 7 | // OAuth settings 8 | // see: https://esri.github.io/arcgis-rest-js/guides/browser-authentication/ 9 | // NOTE: the item for this application can be seen here: 10 | // http://www.arcgis.com/home/item.html?id=30d50f3d1d864ddc8b71ee06cfed242d 11 | // when deploying to your own domain, you should first 12 | // create your own item and register the URL where it will be deployed 13 | // and then use that application's App ID as the clientId below 14 | clientId: 'wy6Km7vd1dv6c4EG', 15 | portal: 'https://www.arcgis.com/sharing/rest', 16 | // app cookies will be prefixed with this, ex: caa_session 17 | cookiePrefix: 'caa' 18 | // NOTE: currently the application assumes that it will be deployed to the server's root 19 | // if it needs to be deployed to a subfolder, we'd need to add a variable here for that 20 | // see: https://facebook.github.io/create-react-app/docs/deployment#building-for-relative-paths 21 | }; 22 | // to change any of the above on a per environment basis, do something like 23 | // use different cookies for different environments 24 | const nodeEnv = process.env.NODE_ENV; 25 | if (nodeEnv === 'development') { 26 | env.cookiePrefix = 'caa_dev'; 27 | } 28 | if (nodeEnv === 'test') { 29 | env.cookiePrefix = 'caa_test'; 30 | } 31 | 32 | export default env; 33 | -------------------------------------------------------------------------------- /src/config/map.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // map initialization options 3 | options: { 4 | basemap: 'gray' 5 | }, 6 | // item extents graphics on map 7 | itemExtents: { 8 | symbol: { 9 | color: [51, 122, 183, 0.125], 10 | outline: { 11 | color: [51, 122, 183, 1], 12 | width: 1, 13 | type: 'simple-line', 14 | style: 'solid' 15 | }, 16 | type: 'simple-fill', 17 | style: 'solid' 18 | }, 19 | popupTemplate: { 20 | title: '{title}', 21 | content: '{snippet}' 22 | } 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // NOTE: this file serves as the client application's entry point 2 | // this is a good place to do any work that would be 3 | // done differently on the server (for server-rendered apps) 4 | // Examples: 5 | // - select the router component to render 6 | // - read data (i.e. session) from cookies or local storage 7 | // - detect the user's locale 8 | 9 | import React from 'react'; 10 | import ReactDOM from 'react-dom'; 11 | import 'bootstrap/dist/css/bootstrap.css'; 12 | import { BrowserRouter as Router } from 'react-router-dom'; 13 | import App from './App'; 14 | import { restoreSession } from './utils/session'; 15 | import * as serviceWorker from './serviceWorker'; 16 | 17 | // read the user's previous session (if any) from a cookie 18 | // and pass that into the application 19 | const previousSession = restoreSession(); 20 | // NOTE: this is set in public/index.html 21 | const title = document.title; 22 | ReactDOM.render( 23 | 24 | 25 | , 26 | document.getElementById('root') 27 | ); 28 | 29 | // If you want your app to work offline and load faster, you can change 30 | // unregister() to register() below. Note this comes with some pitfalls. 31 | // Learn more about service workers: http://bit.ly/CRA-PWA 32 | serviceWorker.unregister(); 33 | -------------------------------------------------------------------------------- /src/reducers/app.js: -------------------------------------------------------------------------------- 1 | export const actionTypes = { 2 | setSession: 'SET_SESSION', 3 | setUser: 'SET_USER', 4 | signOut: 'SIGN_OUT' 5 | }; 6 | 7 | export function appReducer(state, action) { 8 | switch (action.type) { 9 | case actionTypes.setSession: 10 | return { ...state, session: action.session, user: null }; 11 | case actionTypes.setUser: 12 | // first validate that user matches session username 13 | const user = action.user; 14 | const sessionUsername = state.session && state.session.username; 15 | if (user.username !== sessionUsername) { 16 | throw new Error( 17 | `Invalid user for session with username: '${sessionUsername}'.` 18 | ); 19 | } 20 | return { ...state, user: action.user }; 21 | case actionTypes.signOut: 22 | return { ...state, session: null, user: null }; 23 | default: 24 | throw new Error(`Invalid app action: '${action.type}'.`); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/reducers/app.test.js: -------------------------------------------------------------------------------- 1 | import { actionTypes, appReducer } from './app'; 2 | 3 | describe('app reducer', function() { 4 | let session, user; 5 | beforeEach(function() { 6 | // set mock session & user 7 | session = { 8 | username: 'userone' 9 | }; 10 | user = { 11 | username: 'userone', 12 | fullName: 'User One' 13 | }; 14 | }); 15 | test('set session', function() { 16 | const signedOutState = {}; 17 | const nextState = appReducer(signedOutState, { 18 | type: actionTypes.setSession, 19 | session 20 | }); 21 | expect(nextState.session).toEqual(session); 22 | expect(nextState.user).toBeNull(); 23 | }); 24 | test('set user', function() { 25 | const signedInNoUserState = { session }; 26 | const nextState = appReducer(signedInNoUserState, { 27 | type: actionTypes.setUser, 28 | user 29 | }); 30 | expect(nextState.session).toEqual(session); 31 | expect(nextState.user).toEqual(user); 32 | }); 33 | test('set invalid user', function() { 34 | const signedInNoUserState = { session }; 35 | const otherUser = { username: 'usertwo', fullName: 'User Two' }; 36 | expect(() => { 37 | appReducer(signedInNoUserState, { 38 | type: actionTypes.setUser, 39 | user: otherUser 40 | }); 41 | }).toThrowError(/Invalid user for session with username: 'userone'./); 42 | }); 43 | test('sign out', function() { 44 | const signedInState = { session, user }; 45 | const nextState = appReducer(signedInState, { type: actionTypes.signOut }); 46 | expect(nextState.session).toBeNull(); 47 | expect(nextState.user).toBeNull(); 48 | }); 49 | test('invalid action type', function() { 50 | expect(() => { 51 | appReducer({}, { type: 'notvalid' }); 52 | }).toThrowError(/Invalid app action: 'notvalid'/); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/routes/Home.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Redirect } from 'react-router-dom'; 3 | import AgoSearch from '../components/AgoSearch'; 4 | 5 | function Home({ title }) { 6 | const [searchTerm, setSearchTerm] = useState(); 7 | if (searchTerm) { 8 | // pass the search term to the items page 9 | return ( 10 | 17 | ); 18 | } 19 | // otherwise render the search form 20 | return ( 21 |
22 |

{title}

23 | {/* update the state, which triggers a re-render & redirect */} 24 | 25 |
26 | ); 27 | } 28 | 29 | export default Home; 30 | -------------------------------------------------------------------------------- /src/routes/Items.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Alert } from 'reactstrap'; 3 | import { searchItems } from '@esri/arcgis-rest-portal'; 4 | import { parseSearch } from '../utils/search'; 5 | import ItemsLayout from '../components/ItemsLayout'; 6 | 7 | // NOTE: `location` and `history` are passed in by react-router 8 | // see: https://tylermcginnis.com/react-router-programmatically-navigate/ 9 | function Items({ history, location, session }) { 10 | // initialize state 11 | // NOTE: we're using a single state variable b/c all pieces of state change together 12 | // this is *not* the same as the legacy setState() API 13 | // see: https://reactjs.org/docs/hooks-faq.html#should-i-use-one-or-many-state-variables 14 | const initialState = { error: undefined, results: [], total: 0 }; 15 | const [state, setState] = useState(initialState); 16 | // parse search params out of query string w/ defaults 17 | const search = location && location.search; 18 | const { num, q, start } = parseSearch(search); 19 | // (re)execute search whenever the query params or session changes 20 | useEffect(() => { 21 | console.log('in: ' + q); 22 | if (!q) { 23 | // invalid search term, emulate an empty response rather than sending a request 24 | setState({ ...initialState }); 25 | } else { 26 | // execute search and update state 27 | searchItems({ num: num, q: q, start: start, authentication: session }) 28 | .then(({ results, total }) => { 29 | setState({ error: null, results, total }); 30 | }) 31 | .catch(e => { 32 | setState({ error: e.message || e, results: [], total: 0 }); 33 | }); 34 | } 35 | }, [search, session]); 36 | // an inline function for action handler, and yes, thats OK 37 | // see: https://reactjs.org/docs/hooks-faq.html#are-hooks-slow-because-of-creating-functions-in-render 38 | // and: https://cdb.reacttraining.com/react-inline-functions-and-performance-bdff784f5578 39 | function onParamsChange(newQ, newStart) { 40 | // update the route query params after the user either 41 | // submits the inline search form or links to a new page 42 | let path = `${location.pathname}?q=${newQ}`; 43 | if (newStart) { 44 | path = `${path}&start=${newStart}`; 45 | } 46 | history.push(path); 47 | } 48 | // render 49 | const { error, results, total } = state; 50 | if (error) { 51 | return {error}; 52 | } 53 | if (!results) { 54 | // TODO: better loading state 55 | return 'loading...'; 56 | } 57 | // render the items page 58 | return ( 59 | 67 | ); 68 | } 69 | 70 | export default Items; 71 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read http://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit http://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See http://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // react-testing-library renders your components to document.body, 2 | // this will ensure they're removed after each test. 3 | import 'react-testing-library/cleanup-after-each'; 4 | // this adds jest-dom's custom assertions 5 | import 'jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/utils/map.js: -------------------------------------------------------------------------------- 1 | import { loadModules } from 'esri-loader'; 2 | 3 | // NOTE: module, not global scope 4 | let _Graphic; 5 | 6 | // lazy load the ArcGIS API modules and CSS 7 | // then create a new map view at an element 8 | export function loadMap(element, mapOptions) { 9 | return loadModules(['esri/Map', 'esri/views/MapView', 'esri/Graphic'], { 10 | css: true 11 | }).then(([Map, MapView, Graphic]) => { 12 | if (!element) { 13 | // component or app was likely destroyed 14 | return; 15 | } 16 | // hold onto the graphic class for later use 17 | _Graphic = Graphic; 18 | // create the Map 19 | const map = new Map(mapOptions); 20 | // show the map at the element 21 | let view = new MapView({ 22 | map, 23 | container: element, 24 | zoom: 2 25 | }); 26 | // wait for the view to load 27 | return view.when(() => { 28 | // prevent zooming with the mouse-wheel 29 | view.on('mouse-wheel', function(evt) { 30 | evt.stopPropagation(); 31 | }); 32 | // return a reference to the view 33 | return view; 34 | }); 35 | }); 36 | } 37 | 38 | export function showItemsOnMap(view, items, symbol, popupTemplate) { 39 | if (!_Graphic) { 40 | throw new Error('You must load a map before creating new graphics'); 41 | } 42 | if (!view || !view.ready) { 43 | return; 44 | } 45 | // clear any existing graphics (if any) 46 | view.graphics.removeAll(); 47 | if (!items) { 48 | return; 49 | } 50 | // convert items to graphics and add to the view 51 | items.forEach(item => { 52 | const graphicJson = itemToGraphicJson(item, symbol, popupTemplate); 53 | view.graphics.add(new _Graphic(graphicJson)); 54 | }); 55 | } 56 | 57 | export function itemToGraphicJson(item, symbol, popupTemplate) { 58 | const geometry = coordsToExtent(item.extent); 59 | return { geometry, symbol, attributes: item, popupTemplate }; 60 | } 61 | 62 | // expect [[-53.2316, -79.8433], [180, 79.8433]] or [] 63 | function coordsToExtent(coords) { 64 | if (coords && coords.length === 2) { 65 | return { 66 | type: 'extent', 67 | xmin: coords[0][0], 68 | ymin: coords[0][1], 69 | xmax: coords[1][0], 70 | ymax: coords[1][1], 71 | spatialReference: { 72 | wkid: 4326 73 | } 74 | }; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/utils/map.test.js: -------------------------------------------------------------------------------- 1 | import { loadMap, itemToGraphicJson } from './map'; 2 | 3 | describe('utils', function() { 4 | describe('map', function() { 5 | describe('loadMap', function() { 6 | // TODO: write a meaningful test of loadMap() 7 | let result = typeof loadMap === 'function'; 8 | expect(result).toBeTruthy(); 9 | }); 10 | describe('itemToGraphicJson', function() { 11 | it('it works', function() { 12 | const item = { 13 | extent: [[-53.2316, -79.8433], [180, 79.8433]], 14 | title: 'Test Item', 15 | snippet: 'this is a test item' 16 | }; 17 | let result = itemToGraphicJson(item); 18 | expect(result.geometry).toEqual({ 19 | type: 'extent', 20 | xmin: -53.2316, 21 | ymin: -79.8433, 22 | xmax: 180, 23 | ymax: 79.8433, 24 | spatialReference: { 25 | wkid: 4326 26 | } 27 | }); 28 | expect(result.attributes).toEqual(item); 29 | }); 30 | it('it handles invalid coords', function() { 31 | const item = { 32 | title: 'Item with no extent', 33 | snippet: 'sometimes items do not have extents' 34 | }; 35 | let result = itemToGraphicJson(item); 36 | expect(result.attributes).toEqual(item); 37 | }); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/utils/search.js: -------------------------------------------------------------------------------- 1 | /** 2 | * parse search params out of query string w/ defaults 3 | * @param {string} search 4 | */ 5 | export function parseSearch(search) { 6 | const defaults = { 7 | num: 10, 8 | start: 1 9 | }; 10 | // NOTE: URLSearchParams() only works in real browsers, 11 | // for IE support use https://www.npmjs.com/package/query-string 12 | const params = search && new URLSearchParams(search); 13 | if (!params) { 14 | return defaults; 15 | } 16 | const num = params.get('num'); 17 | const start = params.get('start'); 18 | return { 19 | num: num ? parseInt(num) : defaults.num, 20 | q: params.get('q'), 21 | start: start ? parseInt(start) : defaults.start 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/search.test.js: -------------------------------------------------------------------------------- 1 | import { parseSearch } from './search'; 2 | describe('utils', function() { 3 | describe('search', function() { 4 | describe('parseSearch', function() { 5 | it('returns defaults when there are no params', function() { 6 | let result = parseSearch(''); 7 | expect(result).toEqual({ 8 | num: 10, 9 | start: 1 10 | }); 11 | }); 12 | it('overrides defaults when there are params', function() { 13 | let result = parseSearch('?q=test&start=11'); 14 | expect(result).toEqual({ 15 | num: 10, 16 | q: 'test', 17 | start: 11 18 | }); 19 | }); 20 | it('returns q & defaults when there is only the q param', function() { 21 | let result = parseSearch('?q=test'); 22 | expect(result).toEqual({ 23 | num: 10, 24 | q: 'test', 25 | start: 1 26 | }); 27 | }); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/utils/session.js: -------------------------------------------------------------------------------- 1 | import { UserSession } from '@esri/arcgis-rest-auth'; 2 | import env from '../config/environment'; 3 | import * as Cookies from 'js-cookie'; 4 | 5 | const SESSION_COOKIE_NAME = `${env.cookiePrefix}_session`; 6 | 7 | /** 8 | * sign in using OAuth pop up 9 | */ 10 | export function signIn() { 11 | const { clientId, portal } = env; 12 | return UserSession.beginOAuth2({ 13 | clientId, 14 | portal, 15 | popup: true, 16 | redirectUri: `${window.location.origin}/redirect.html` 17 | }).then(session => { 18 | // save session for next time the user loads the app 19 | saveSession(session); 20 | return session; 21 | }); 22 | } 23 | 24 | /** 25 | * make sure the user is not logged in the next time they load the app 26 | */ 27 | export function signOut() { 28 | deleteSession(); 29 | } 30 | 31 | /** 32 | * restore a previously saved session 33 | */ 34 | export function restoreSession() { 35 | const serializedSession = Cookies.get(SESSION_COOKIE_NAME); 36 | const session = 37 | serializedSession && UserSession.deserialize(serializedSession); 38 | return session; 39 | } 40 | 41 | // save session & user for next time the user loads the app 42 | function saveSession(session) { 43 | // get expiration from session 44 | const expires = session.tokenExpires; 45 | Cookies.set(SESSION_COOKIE_NAME, session.serialize(), { expires }); 46 | } 47 | 48 | // delete a previously saved session 49 | function deleteSession() { 50 | Cookies.remove(SESSION_COOKIE_NAME); 51 | } 52 | -------------------------------------------------------------------------------- /src/utils/session.test.js: -------------------------------------------------------------------------------- 1 | import { UserSession } from '@esri/arcgis-rest-auth'; 2 | import * as Cookies from 'js-cookie'; 3 | import { restoreSession, signIn, signOut } from './session'; 4 | 5 | jest.mock('js-cookie'); 6 | jest.mock('@esri/arcgis-rest-auth'); 7 | 8 | describe('utils', function() { 9 | describe('session', function() { 10 | describe('restoreSession', function() { 11 | beforeEach(function() { 12 | Cookies.get.mockClear(); 13 | }); 14 | describe('when no cookie', function() { 15 | it('reads the cookie but does not deserialize', function() { 16 | Cookies.get.mockReturnValueOnce(undefined); 17 | restoreSession(); 18 | expect(Cookies.get.mock.calls.length).toBe(1); 19 | expect(Cookies.get.mock.calls[0][0]).toBe('caa_test_session'); 20 | expect(UserSession.deserialize.mock.calls.length).toBe(0); 21 | }); 22 | }); 23 | describe('when cookie exists', function() { 24 | it('reads the cookie and deserializes it', function() { 25 | Cookies.get.mockReturnValueOnce('notarealsession'); 26 | restoreSession(); 27 | expect(Cookies.get.mock.calls.length).toBe(1); 28 | expect(Cookies.get.mock.calls[0][0]).toBe('caa_test_session'); 29 | expect(UserSession.deserialize.mock.calls.length).toBe(1); 30 | expect(UserSession.deserialize.mock.calls[0][0]).toBe( 31 | 'notarealsession' 32 | ); 33 | }); 34 | }); 35 | }); 36 | describe('signIn', function() { 37 | it('begins the OAuth flow and sets the cookie', function() { 38 | const serialize = jest.fn(() => 'notarealsession'); 39 | const tokenExpires = new Date(2019, 1, 1); 40 | UserSession.beginOAuth2.mockResolvedValue({ serialize, tokenExpires }); 41 | return signIn().then(result => { 42 | expect(Cookies.set.mock.calls.length).toBe(1); 43 | expect(Cookies.set.mock.calls[0]).toEqual([ 44 | 'caa_test_session', 45 | 'notarealsession', 46 | { expires: tokenExpires } 47 | ]); 48 | }); 49 | }); 50 | }); 51 | describe('signOut', function() { 52 | it('deletes the cookie', function() { 53 | signOut(); 54 | expect(Cookies.remove.mock.calls.length).toBe(1); 55 | expect(Cookies.remove.mock.calls[0][0]).toBe('caa_test_session'); 56 | }); 57 | }); 58 | }); 59 | }); 60 | --------------------------------------------------------------------------------