diff --git a/.env b/.env deleted file mode 100644 index 756b2061..00000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -REACT_APP_ARCGIS_REST_CLIENT_ID=abcdef diff --git a/.env-template b/.env-template new file mode 100644 index 00000000..8f85508b --- /dev/null +++ b/.env-template @@ -0,0 +1 @@ +REACT_APP_ARCGIS_REST_CLIENT_ID=abcd diff --git a/.gitignore b/.gitignore index eb7b4d44..8a67cf5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ build/ coverage/ +.env diff --git a/__mocks__/@esri/arcgis-rest-request.ts b/__mocks__/@esri/arcgis-rest-request.ts new file mode 100644 index 00000000..391ef811 --- /dev/null +++ b/__mocks__/@esri/arcgis-rest-request.ts @@ -0,0 +1,26 @@ +const mockEmailExpected = "usermail@gmail.com"; +const mockSessionExpected = '{"usermail": "usermail"}'; + +export class ArcGISIdentityManager { + static beginOAuth2 = async () => { + const session = { + usermail: mockEmailExpected, + serialize: () => { + return mockSessionExpected; + }, + getUser: async () => { + return { email: mockEmailExpected }; + }, + }; + return session; + }; + static completeOAuth2 = async () => { + return; + }; + static destroy = async () => { + return; + }; + static deserialize = (session) => { + return { usermail: "usermail" }; + }; +} diff --git a/package.json b/package.json index 9938aaa7..bd93774c 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "author": "", "license": "ISC", "dependencies": { + "@esri/arcgis-rest-auth": "^3.7.0", + "@esri/arcgis-rest-request": "^4.2.0", "@fortawesome/fontawesome-svg-core": "^1.2.36", "@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/react-fontawesome": "^0.1.17", diff --git a/public/icons/download-2.svg b/public/icons/download-2.svg deleted file mode 100644 index bdfae70b..00000000 --- a/public/icons/download-2.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/app.tsx b/src/app.tsx index 43e316f1..a709d385 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -164,6 +164,7 @@ export const App = () => { } /> } /> } /> + } /> { return renderFunc( + {...props} + /> ); }; -describe("Plus Button", () => { - it("Should render small Plus button", () => { - const { container } = callRender(renderWithTheme, { children: 'Test Button' }); +describe("ActionIconButton", () => { + it("Should render small Plus icon in the button", () => { + const { container } = callRender(renderWithTheme, { + children: "Test Button", + }); expect(container).toBeInTheDocument(); - const button = screen.getByText('Test Button'); - const buttonHeight = getComputedStyle(button.previousSibling as Element).getPropertyValue("height"); - expect(buttonHeight).toEqual('24px'); + const button = screen.getByText("Test Button"); + const buttonHeight = getComputedStyle( + button.previousSibling as Element + ).getPropertyValue("height"); + expect(buttonHeight).toEqual("24px"); userEvent.click(button); expect(onClickMock).toHaveBeenCalled(); }); - it("Should render Big Plus button", () => { - const { container } = callRender(renderWithTheme, { children: 'Test Button', size: ButtonSize.Big }); + it("Should render Big Plus icon in the button", () => { + const { container } = callRender(renderWithTheme, { + children: "Test Button", + size: ButtonSize.Big, + }); expect(container).toBeInTheDocument(); - const button = screen.getByText('Test Button'); - const buttonHeight = getComputedStyle(button.previousSibling as Element).getPropertyValue("height"); - expect(buttonHeight).toEqual('40px'); + const button = screen.getByText("Test Button"); + const buttonHeight = getComputedStyle( + button.previousSibling as Element + ).getPropertyValue("height"); + expect(buttonHeight).toEqual("40px"); userEvent.click(button); expect(onClickMock).toHaveBeenCalled(); }); - }); diff --git a/src/components/action-icon-button/action-icon-button.tsx b/src/components/action-icon-button/action-icon-button.tsx index 8e2b6433..c30690d3 100644 --- a/src/components/action-icon-button/action-icon-button.tsx +++ b/src/components/action-icon-button/action-icon-button.tsx @@ -1,10 +1,12 @@ import React from "react"; -import styled, { StyledComponent, DefaultTheme, useTheme } from "styled-components"; +import styled, { + StyledComponent, + DefaultTheme, + useTheme, +} from "styled-components"; import { ButtonSize } from "../../types"; -import { - color_brand_tertiary, -} from "../../constants/colors"; +import { color_brand_tertiary } from "../../constants/colors"; const Button = styled.div<{ grayed?: boolean }>` display: flex; @@ -23,11 +25,10 @@ const Button = styled.div<{ grayed?: boolean }>` } > :nth-child(2) { - color: ${({ theme, grayed }) => ( - grayed - ? theme.colors.actionIconButtonTextDisabledColorHover - : color_brand_tertiary - )}; + color: ${({ theme, grayed }) => + grayed + ? theme.colors.actionIconButtonTextDisabledColorHover + : color_brand_tertiary}; } } `; @@ -37,27 +38,25 @@ const ButtonText = styled.div<{ grayed?: boolean }>` font-weight: 500; font-size: 16px; line-height: 19px; - color: ${({ theme, grayed }) => ( + color: ${({ theme, grayed }) => grayed ? theme.colors.actionIconButtonTextDisabledColor - : color_brand_tertiary - )}; - `; + : color_brand_tertiary}; +`; -const IconContainer = styled.div<{ buttonSize: number, grayed?: boolean }>` +const IconContainer = styled.div<{ buttonSize: number; grayed?: boolean }>` display: flex; justify-content: center; align-items: center; border-radius: 4px; width: ${(props) => (props.buttonSize === ButtonSize.Big ? "40px" : "24px")}; height: ${(props) => (props.buttonSize === ButtonSize.Big ? "40px" : "24px")}; - - background: ${({ theme, grayed }) => ( + + background: ${({ theme, grayed }) => grayed ? theme.colors.actionIconButtonDisabledBG - : `${color_brand_tertiary}66` - )}; - `; + : `${color_brand_tertiary}66`}; +`; type ActionIconButtonProps = { children?: React.ReactNode; @@ -67,18 +66,30 @@ type ActionIconButtonProps = { onClick?: () => void; }; -export const ActionIconButton = ({ Icon, style, size, children, onClick }: ActionIconButtonProps) => { +export const ActionIconButton = ({ + Icon, + style, + size, + children, + onClick, +}: ActionIconButtonProps) => { const grayed = style === "disabled"; const theme = useTheme(); return ( - diff --git a/src/components/arcgis-user/arcgis-user.spec.tsx b/src/components/arcgis-user/arcgis-user.spec.tsx index 172d3ab7..e2fa389f 100644 --- a/src/components/arcgis-user/arcgis-user.spec.tsx +++ b/src/components/arcgis-user/arcgis-user.spec.tsx @@ -6,25 +6,21 @@ import { AcrGisUser } from "./arcgis-user"; const onClickMock = jest.fn(); const callRender = (renderFunc, props) => { - return renderFunc( - - ); + return renderFunc(); }; describe("AcrGisUser", () => { it("Should render Logout button", () => { - const { container } = callRender(renderWithTheme, { children: 'Test Button' }); + const { container } = callRender(renderWithTheme, { + children: "Test Button", + }); expect(container).toBeInTheDocument(); - const button = screen.getByText('Test Button'); + const button = screen.getByText("Test Button"); const buttonIcon = button.nextSibling as Element; - const buttonHeight = getComputedStyle(buttonIcon).getPropertyValue( - "height" - ); - expect(buttonHeight).toEqual('17px'); + const buttonHeight = + getComputedStyle(buttonIcon).getPropertyValue("height"); + expect(buttonHeight).toEqual("17px"); userEvent.click(buttonIcon); expect(onClickMock).toHaveBeenCalled(); }); - }); diff --git a/src/components/arcgis-user/arcgis-user.tsx b/src/components/arcgis-user/arcgis-user.tsx index ad00d99e..8cfb94ca 100644 --- a/src/components/arcgis-user/arcgis-user.tsx +++ b/src/components/arcgis-user/arcgis-user.tsx @@ -1,8 +1,6 @@ import styled from "styled-components"; import LogoutIcon from "../../../public/icons/logout.svg"; -import { - color_canvas_secondary_inverted, -} from "../../constants/colors"; +import { color_canvas_secondary_inverted } from "../../constants/colors"; const Container = styled.div` display: flex; @@ -18,9 +16,7 @@ const UserInfo = styled.div` font-weight: 500; font-size: 14px; line-height: 17px; - color: ${({ theme }) => ( - theme.colors.logoutButtonTextColor - )}; + color: ${({ theme }) => theme.colors.logoutButtonTextColor}; `; const IconButton = styled.div` @@ -31,7 +27,7 @@ const IconButton = styled.div` cursor: pointer; &:hover { > * { - stroke: ${({ theme }) => (theme.colors.logoutButtonIconColorHover)}; + stroke: ${({ theme }) => theme.colors.logoutButtonIconColorHover}; } } `; @@ -46,14 +42,11 @@ type ArcGisUserProps = { onClick?: () => void; }; -export const AcrGisUser = ({ - children, - onClick, -}: ArcGisUserProps) => { +export const AcrGisUser = ({ children, onClick }: ArcGisUserProps) => { return ( {children} - + diff --git a/src/components/common.tsx b/src/components/common.tsx index 785213fc..5fce3c01 100644 --- a/src/components/common.tsx +++ b/src/components/common.tsx @@ -26,8 +26,7 @@ export const PanelContainer = styled.div` display: flex; flex-direction: column; width: 359px; - background: ${({ theme }) => theme.colors.mainCanvasColor}; - opacity: ${({ theme }) => (theme.name === Theme.Dark ? 0.9 : 1)}; + background: ${({ theme }) => `${theme.colors.mainCanvasColor}${theme.name === Theme.Dark ? 'e6' : 'ff'}` }; border-radius: 8px; padding-bottom: 26px; position: relative; diff --git a/src/components/layers-panel/layers-control-panel.spec.tsx b/src/components/layers-panel/layers-control-panel.spec.tsx index 8d07588a..eb0dcb1b 100644 --- a/src/components/layers-panel/layers-control-panel.spec.tsx +++ b/src/components/layers-panel/layers-control-panel.spec.tsx @@ -1,17 +1,31 @@ -import { act, screen } from "@testing-library/react"; -import { renderWithTheme } from "../../utils/testing-utils/render-with-theme"; +import { act, screen, within } from "@testing-library/react"; +import { renderWithThemeProviders } from "../../utils/testing-utils/render-with-theme"; import { LayersControlPanel } from "./layers-control-panel"; +import userEvent from "@testing-library/user-event"; +import { + arcGisLogin, + arcGisLogout, +} from "../../redux/slices/arcgis-auth-slice"; // Mocked components import { ActionIconButton } from "../action-icon-button/action-icon-button"; import { DeleteConfirmation } from "./delete-confirmation"; import { LayerOptionsMenu } from "./layer-options-menu/layer-options-menu"; import { ListItem } from "./list-item/list-item"; +import { setupStore } from "../../redux/store"; + +import { + getAuthenticatedUser, + arcGisRequestLogin, + arcGisCompleteLogin, + arcGisRequestLogout, +} from "../../utils/arcgis-auth"; jest.mock("./list-item/list-item"); jest.mock("../action-icon-button/action-icon-button"); jest.mock("./delete-confirmation"); jest.mock("./layer-options-menu/layer-options-menu"); +jest.mock("../../utils/arcgis-auth"); const ListItemMock = ListItem as unknown as jest.Mocked; const PlusButtonMock = ActionIconButton as unknown as jest.Mocked; @@ -19,18 +33,17 @@ const DeleteConfirmationMock = DeleteConfirmation as unknown as jest.Mocked; const LayerOptionsMenuMock = LayerOptionsMenu as unknown as jest.Mocked; -beforeAll(() => { - ListItemMock.mockImplementation((props) => ( -
{`ListItem-${props.id}`}
- )); - PlusButtonMock.mockImplementation(({ children, onClick }) => ( -
{children}
- )); - DeleteConfirmationMock.mockImplementation(() => ( -
Delete Conformation
- )); - LayerOptionsMenuMock.mockImplementation(() =>
Layers Options
); -}); +const getAuthenticatedUserMock = + getAuthenticatedUser as unknown as jest.Mocked; +const arcGisRequestLoginMock = + arcGisRequestLogin as unknown as jest.Mocked; +const arcGisCompleteLoginMock = + arcGisCompleteLogin as unknown as jest.Mocked; +const arcGisRequestLogoutMock = + arcGisRequestLogout as unknown as jest.Mocked; + +const mockEmailExpected = "usermail@gmail.com"; +let mockStorageUserinfo = ""; const onInsertLayerMock = jest.fn(); const onInsertSceneMock = jest.fn(); @@ -39,7 +52,7 @@ const onSelectLayerMock = jest.fn(); const onLayerSettingsClickMock = jest.fn(); const onPointToLayerMock = jest.fn(); -const callRender = (renderFunc, props = {}) => { +const callRender = (renderFunc, props = {}, store = setupStore()) => { return renderFunc( { onPointToLayer={onPointToLayerMock} deleteLayer={onDeleteLayerMock} {...props} - /> + />, + store ); }; +beforeAll(() => { + ListItemMock.mockImplementation((props) => ( +
{`ListItem-${props.id}`}
+ )); + PlusButtonMock.mockImplementation(({ children, onClick }) => ( +
{children}
+ )); + DeleteConfirmationMock.mockImplementation(() => ( +
Delete Conformation
+ )); + LayerOptionsMenuMock.mockImplementation(() =>
Layers Options
); +}); + +describe("Layers Control Panel - ArcGIS auth", () => { + beforeAll(() => { + arcGisRequestLoginMock.mockImplementation(async () => { + mockStorageUserinfo = mockEmailExpected; + return mockStorageUserinfo; + }); + arcGisCompleteLoginMock.mockImplementation(async () => { + return mockStorageUserinfo; + }); + arcGisRequestLogoutMock.mockImplementation(async () => { + mockStorageUserinfo = ""; + return mockStorageUserinfo; + }); + getAuthenticatedUserMock.mockImplementation(() => { + return mockStorageUserinfo; + }); + }); + + it("Should render ArcGIS Login button", async () => { + const store = setupStore(); + // Let's Log out... + await store.dispatch(arcGisLogout()); + const { container } = callRender( + renderWithThemeProviders, + undefined, + store + ); + expect(container).toBeInTheDocument(); + expect(arcGisRequestLogoutMock).toHaveBeenCalledTimes(1); + + // We are in the "Logged out" state, so the "Log in" button should be there. + const loginButton = await screen.findByText("Login to ArcGIS"); + expect(loginButton).toBeInTheDocument(); + loginButton && userEvent.click(loginButton); + expect(arcGisRequestLoginMock).toHaveBeenCalledTimes(1); + + const importButton = screen.queryByText("Import from ArcGIS"); + expect(importButton).not.toBeInTheDocument(); + }); + + it("Should render ArcGIS Import and Logout buttons", async () => { + const store = setupStore(); + // Let's Log in... + await store.dispatch(arcGisLogin()); + const { container } = callRender( + renderWithThemeProviders, + undefined, + store + ); + expect(container).toBeInTheDocument(); + expect(arcGisRequestLoginMock).toHaveBeenCalledTimes(1); + + // We are in the "Logged in" state, so the "Log in" button should NOT be there. + const importButton = await screen.findByText("Import from ArcGIS"); + expect(importButton).toBeInTheDocument(); + + const logoutUserInfo = await screen.findByText(mockEmailExpected); + expect(logoutUserInfo).toBeInTheDocument(); + + const loginButton = screen.queryByText("Login to ArcGIS"); + expect(loginButton).not.toBeInTheDocument(); + }); + + it("Should respond to action on the ArcGIS Login button", async () => { + const store = setupStore(); + // Let's Log out... + await store.dispatch(arcGisLogout()); + const { container } = callRender( + renderWithThemeProviders, + undefined, + store + ); + expect(container).toBeInTheDocument(); + + const loginButton = screen.getByText("Login to ArcGIS"); + loginButton && userEvent.click(loginButton); + expect(arcGisRequestLoginMock).toHaveBeenCalledTimes(1); + + const importButton = await screen.findByText("Import from ArcGIS"); + expect(importButton).toBeInTheDocument(); + + const loginButtonHidden = screen.queryByText("Login to ArcGIS"); + expect(loginButtonHidden).not.toBeInTheDocument(); + }); + + it("Should respond to action on ArcGIS Logout button", async () => { + const store = setupStore(); + // Let's Log in... + await store.dispatch(arcGisLogin()); + const { container } = callRender( + renderWithThemeProviders, + undefined, + store + ); + + const logoutButton = await screen.findByTestId("userinfo-button"); + logoutButton && userEvent.click(logoutButton); + + const modalDialog = await screen.findByTestId("modal-dialog-content"); + expect(modalDialog).toContainHTML("Are you sure you want to log out?"); + + const cancelButton = within(modalDialog).getByText("Log out"); + cancelButton && userEvent.click(cancelButton); + + const modalDialogHidden = screen.queryByTestId("modal-dialog-content"); + expect(modalDialogHidden).not.toBeInTheDocument(); + + const loginButton = await screen.findByText("Login to ArcGIS"); + expect(loginButton).toBeInTheDocument(); + }); +}); + describe("Layers Control Panel", () => { it("Should render LayersControlPanel without layers", () => { - const { container } = callRender(renderWithTheme); + const store = setupStore(); + const { container } = callRender( + renderWithThemeProviders, + undefined, + store + ); expect(container).toBeInTheDocument(); // Insert Buttons should be present @@ -68,37 +212,42 @@ describe("Layers Control Panel", () => { }); it("Should render LayersControlPanel with layers", () => { - const { container } = callRender(renderWithTheme, { - layers: [ - { id: "first", name: "first name", url: "https://first-url.com" }, - { id: "second", name: "second name", url: "https://second-url.com" }, - { - id: "third", - name: "third name", - url: "", - layers: [ - { - id: "fourth", - name: "fourth name", - url: "https://fourth-url.com", - }, - { id: "fith", name: "fith name", url: "https://fith-url.com" }, - { - id: "sixth", - name: "sixth name", - url: "https://sixth-url.com", - layers: [ - { - id: "seventh", - name: "seventh name", - url: "https://seventh-url.com", - }, - ], - }, - ], - }, - ], - }); + const store = setupStore(); + const { container } = callRender( + renderWithThemeProviders, + { + layers: [ + { id: "first", name: "first name", url: "https://first-url.com" }, + { id: "second", name: "second name", url: "https://second-url.com" }, + { + id: "third", + name: "third name", + url: "", + layers: [ + { + id: "fourth", + name: "fourth name", + url: "https://fourth-url.com", + }, + { id: "fith", name: "fith name", url: "https://fith-url.com" }, + { + id: "sixth", + name: "sixth name", + url: "https://sixth-url.com", + layers: [ + { + id: "seventh", + name: "seventh name", + url: "https://seventh-url.com", + }, + ], + }, + ], + }, + ], + }, + store + ); expect(container).toBeInTheDocument(); expect(screen.getByText("ListItem-first")).toBeInTheDocument(); @@ -111,7 +260,7 @@ describe("Layers Control Panel", () => { }); it("Should be able to call functions", () => { - const { container } = callRender(renderWithTheme, { + const { container } = callRender(renderWithThemeProviders, { layers: [ { id: "first", name: "first name", mapUrl: "https://first-url.com" }, ], @@ -139,7 +288,7 @@ describe("Layers Control Panel", () => { }); it("Should render conformation panel", () => { - callRender(renderWithTheme, { + callRender(renderWithThemeProviders, { layers: [ { id: "first", name: "first name", mapUrl: "https://first-url.com" }, // Candidate to delete diff --git a/src/components/layers-panel/layers-control-panel.tsx b/src/components/layers-panel/layers-control-panel.tsx index bf377d81..1da26ab2 100644 --- a/src/components/layers-panel/layers-control-panel.tsx +++ b/src/components/layers-panel/layers-control-panel.tsx @@ -1,20 +1,29 @@ import { Fragment, ReactEventHandler, useState } from "react"; import styled from "styled-components"; - -import { SelectionState, LayerExample, LayerViewState, ListItemType } from "../../types"; - +import { + SelectionState, + LayerExample, + LayerViewState, + ListItemType, +} from "../../types"; import { ListItem } from "./list-item/list-item"; import PlusIcon from "../../../public/icons/plus.svg"; import ImportIcon from "../../../public/icons/import.svg"; import EsriImage from "../../../public/images/esri.svg"; import { ActionIconButton } from "../action-icon-button/action-icon-button"; import { AcrGisUser } from "../arcgis-user/arcgis-user"; - import { DeleteConfirmation } from "./delete-confirmation"; import { LayerOptionsMenu } from "./layer-options-menu/layer-options-menu"; import { handleSelectAllLeafsInGroup } from "../../utils/layer-utils"; import { ButtonSize } from "../../types"; import { PanelHorizontalLine } from "../common"; +import { + arcGisLogin, + arcGisLogout, + selectUser, +} from "../../redux/slices/arcgis-auth-slice"; +import { useAppDispatch, useAppSelector } from "../../redux/hooks"; +import { ModalDialog } from "../modal-dialog/modal-dialog"; type LayersControlPanelProps = { layers: LayerExample[]; @@ -69,6 +78,20 @@ const EsriStyledImage = styled(EsriImage)` fill: ${({ theme }) => theme.colors.esriImageColor}; `; +const TextInfo = styled.div` + font-style: normal; + font-weight: 500; + font-size: 16px; + line-height: 19px; +`; + +const TextUser = styled.div` + font-style: normal; + font-weight: 700; + font-size: 16px; + line-height: 19px; +`; + export const LayersControlPanel = ({ layers, type, @@ -81,16 +104,22 @@ export const LayersControlPanel = ({ onPointToLayer, deleteLayer, }: LayersControlPanelProps) => { + const dispatch = useAppDispatch(); + + const username = useAppSelector(selectUser); + const isLoggedIn = !!username; + + const [showLogoutWarning, setShowLogoutWarning] = useState(false); const [settingsLayerId, setSettingsLayerId] = useState(""); const [showLayerSettings, setShowLayerSettings] = useState(false); const [layerToDeleteId, setLayerToDeleteId] = useState(""); - /// Stab { - const username = 'Michael-g'; - const [isLoggedIn, setIsLoggedIn] = useState(false); - const onArcGisActionClick = () => { !isLoggedIn && setIsLoggedIn(true) }; - const onArcGisLogoutClick = () => { setIsLoggedIn(false) }; - /// Stab } + const onArcGisActionClick = () => { + !isLoggedIn && dispatch(arcGisLogin()); + }; + const onArcGisLogoutClick = () => { + setShowLogoutWarning(true); + }; const isListItemSelected = ( layer: LayerExample, @@ -101,23 +130,31 @@ export const LayersControlPanel = ({ let selectedState = SelectionState.unselected; if (!childLayers.length) { - selectedState = selectedLayerIds.includes(layer.id) ? SelectionState.selected : SelectionState.unselected; + selectedState = selectedLayerIds.includes(layer.id) + ? SelectionState.selected + : SelectionState.unselected; } if (childLayers.length && !parentLayer) { - selectedState = groupLeafs.some((leaf) => selectedLayerIds.includes(leaf.id)) ? SelectionState.selected : SelectionState.unselected; + selectedState = groupLeafs.some((leaf) => + selectedLayerIds.includes(leaf.id) + ) + ? SelectionState.selected + : SelectionState.unselected; } if (childLayers.length && parentLayer) { const isAllChildLayersSelected = !groupLeafs.some( - (leaf) => !selectedLayerIds.includes(leaf.id)); - const isAnyChildLayerSelected = groupLeafs.some( - (leaf) => selectedLayerIds.includes(leaf.id)); + (leaf) => !selectedLayerIds.includes(leaf.id) + ); + const isAnyChildLayerSelected = groupLeafs.some((leaf) => + selectedLayerIds.includes(leaf.id) + ); if (isAllChildLayersSelected) { selectedState = SelectionState.selected; } else if (isAnyChildLayerSelected) { - selectedState = SelectionState.indeterminate + selectedState = SelectionState.indeterminate; } } @@ -197,17 +234,30 @@ export const LayersControlPanel = ({ {renderLayers(layers)} - + Insert layer - + Insert scene - + {!isLoggedIn && "Login to ArcGIS"} {isLoggedIn && "Import from ArcGIS"} @@ -215,9 +265,25 @@ export const LayersControlPanel = ({ {isLoggedIn && ( - - {username} - + {username} + )} + + {showLogoutWarning && ( + { + dispatch(arcGisLogout()); + setShowLogoutWarning(false); + }} + onCancel={() => { + setShowLogoutWarning(false); + }} + > + Are you sure you want to log out? + You are logged in as + {username} + )} diff --git a/src/components/modal-dialog/modal-dialog.spec.tsx b/src/components/modal-dialog/modal-dialog.spec.tsx new file mode 100644 index 00000000..849f5657 --- /dev/null +++ b/src/components/modal-dialog/modal-dialog.spec.tsx @@ -0,0 +1,44 @@ +import { screen, within } from "@testing-library/react"; +import { renderWithThemeProviders } from "../../utils/testing-utils/render-with-theme"; +import { ModalDialog } from "./modal-dialog"; +import userEvent from "@testing-library/user-event"; +import { setupStore } from "../../redux/store"; + +const onCancel = jest.fn(); +const onConfirm = jest.fn(); + +const callRender = (renderFunc, store = setupStore()) => { + return renderFunc( + + <> + , + store + ); +}; + +describe("ModalDialog", () => { + it("Should render dialog", async () => { + const store = setupStore(); + const { container } = callRender(renderWithThemeProviders, store); + expect(container).toBeInTheDocument(); + const dialog = screen.getByTestId("modal-dialog-content"); + expect(dialog).toBeInTheDocument(); + + const title = within(dialog).getByText("Test Title"); + expect(title).toBeInTheDocument(); + + const okButton = within(dialog).getByText("Log out"); + okButton && userEvent.click(okButton); + expect(onConfirm).toHaveBeenCalledTimes(1); + + const cancelButton = within(dialog).getByText("Exit"); + cancelButton && userEvent.click(cancelButton); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/modal-dialog/modal-dialog.tsx b/src/components/modal-dialog/modal-dialog.tsx new file mode 100644 index 00000000..e2248bba --- /dev/null +++ b/src/components/modal-dialog/modal-dialog.tsx @@ -0,0 +1,140 @@ +import styled, { useTheme } from "styled-components"; +import { ActionButton } from "../action-button/action-button"; +import { ActionButtonVariant } from "../../types"; +import CloseIcon from "../../../public/icons/close.svg"; +import { color_brand_primary } from "../../constants/colors"; + +const Overlay = styled.div` + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background: ${color_brand_primary}80; + z-index: 103; +`; + +const WrapperContainer = styled.div` + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + visibility: hidden; + z-index: 104; +`; + +const Container = styled.div` + position: absolute; + display: flex; + flex-direction: column; + border-radius: 8px; + background: ${({ theme }) => theme.colors.mainHelpPanelColor}; + visibility: visible; + z-index: 105; +`; + +const IconContainer = styled.div` + position: absolute; + display: flex; + justify-content: center; + align-items: center; + border-radius: 8px; + top: 13px; + right: 14px; + width: 44px; + height: 44px; +`; + +const ContentContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: start; + height: 100%; + width: 100%; + margin: 32px 32px 0 32px; + row-gap: 16px; + color: ${({ theme }) => theme.colors.fontColor}; +`; + +const Title = styled.div` + font-style: normal; + font-weight: 700; + font-size: 32px; + line-height: 45px; +`; + +const ButtonsContainer = styled.div` + display: flex; + flex-direction: row; + justify-content: center; + margin: 32px; + column-gap: 18px; + { + > * { + width: 180px; + } + } +`; + +type LogoutPanelProps = { + title: string; + children: JSX.Element | JSX.Element[]; + okButtonText?: string; + cancelButtonText?: string; + onCancel: () => void; + onConfirm: () => void; +}; + +const CloseCrossButton = styled(CloseIcon)` + cursor: pointer; + &:hover { + fill: ${({ theme }) => theme.colors.mainDimColorInverted}; + } +`; + +export const ModalDialog = ({ + title, + children, + cancelButtonText = "Cancel", + okButtonText = "Ok", + onCancel, + onConfirm, +}: LogoutPanelProps) => { + const theme = useTheme(); + + return ( + <> + + + + + + + + {title} + {children} + + + + {cancelButtonText} + + {okButtonText} + + + + + ); +}; diff --git a/src/pages/auth/auth.tsx b/src/pages/auth/auth.tsx new file mode 100644 index 00000000..2bb257d7 --- /dev/null +++ b/src/pages/auth/auth.tsx @@ -0,0 +1,44 @@ +import { useEffect } from "react"; +import Background from "../../../public/images/tools-background.webp"; +import styled from "styled-components"; +import { + getCurrentLayoutProperty, + useAppLayout, +} from "../../utils/hooks/layout"; + +import { arcGisCompleteLogin } from "../../utils/arcgis-auth"; + +export type LayoutProps = { + layout: string; +}; + +const AuthContainer = styled.div` + display: flex; + flex-direction: column; + width: 100%; + overflow: auto; + overflow-x: hidden; + background: url(${Background}); + background-attachment: fixed; + background-size: cover; + + height: ${getCurrentLayoutProperty({ + desktop: "calc(100vh - 65px)", + tablet: "calc(100vh - 65px)", + mobile: "calc(100vh - 58px)", + })}; + + margin-top: ${getCurrentLayoutProperty({ + desktop: "65px", + tablet: "65px", + mobile: "58px", + })}; +`; + +export const AuthApp = () => { + const layout = useAppLayout(); + useEffect(() => { + arcGisCompleteLogin(); + }, []); + return ; +}; diff --git a/src/pages/auth/e2e.auth.spec.ts b/src/pages/auth/e2e.auth.spec.ts new file mode 100644 index 00000000..7f486212 --- /dev/null +++ b/src/pages/auth/e2e.auth.spec.ts @@ -0,0 +1,23 @@ +import puppeteer from "puppeteer"; + +describe("Auth page Default View", () => { + let browser; + let page; + + beforeAll(async () => { + browser = await puppeteer.launch(); + page = await browser.newPage(); + await page.setViewport({ width: 1366, height: 768 }); + await page.goto("http://localhost:3000"); + }); + + afterAll(() => browser.close()); + + it("Should render the page", async () => { + const authPageUrl = "http://localhost:3000/auth"; + + await page.goto(authPageUrl); + const currentUrl = page.url(); + expect(currentUrl).toBe(authPageUrl); + }); +}); diff --git a/src/pages/index.ts b/src/pages/index.ts index 870791a3..ae493050 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -2,4 +2,5 @@ export { Dashboard } from "./dashboard/dashboard"; export { ViewerApp } from "./viewer-app/viewer-app"; export { DebugApp } from "./debug-app/debug-app"; export { Comparison } from './comparison/comparison'; +export { AuthApp } from './auth/auth'; diff --git a/src/redux/slices/arcgis-auth-slice.spec.ts b/src/redux/slices/arcgis-auth-slice.spec.ts new file mode 100644 index 00000000..8023ff9a --- /dev/null +++ b/src/redux/slices/arcgis-auth-slice.spec.ts @@ -0,0 +1,76 @@ +import { setupStore } from "../store"; +import reducer, { + selectUser, + arcGisLogin, + arcGisLogout, +} from "./arcgis-auth-slice"; + +import { + getAuthenticatedUser, + arcGisRequestLogin, + arcGisCompleteLogin, + arcGisRequestLogout, +} from "../../utils/arcgis-auth"; + +jest.mock("../../utils/arcgis-auth"); + +const getAuthenticatedUserMock = + getAuthenticatedUser as unknown as jest.Mocked; +const arcGisRequestLoginMock = + arcGisRequestLogin as unknown as jest.Mocked; +const arcGisCompleteLoginMock = + arcGisCompleteLogin as unknown as jest.Mocked; +const arcGisRequestLogoutMock = + arcGisRequestLogout as unknown as jest.Mocked; + +const mockEmailExpected = "usermail@gmail.com"; +let mockStorageUserinfo = mockEmailExpected; + +describe("slice: arcgis-auth", () => { + beforeAll(() => { + arcGisRequestLoginMock.mockImplementation(async () => { + mockStorageUserinfo = mockEmailExpected; + return mockStorageUserinfo; + }); + arcGisCompleteLoginMock.mockImplementation(async () => { + return mockStorageUserinfo; + }); + arcGisRequestLogoutMock.mockImplementation(async () => { + mockStorageUserinfo = ""; + return mockStorageUserinfo; + }); + getAuthenticatedUserMock.mockImplementation(() => { + return mockStorageUserinfo; + }); + }); + + beforeEach(() => { + mockStorageUserinfo = mockEmailExpected; + }); + + it("Reducer should return the initial state", () => { + expect(reducer(undefined, { type: undefined })).toEqual({ + user: mockEmailExpected, + }); + }); + + it("Selector should return the initial state", () => { + const store = setupStore(); + const state = store.getState(); + expect(selectUser(state)).toEqual(mockEmailExpected); + }); + + it("Selector should return the updated value after Login", async () => { + const store = setupStore(); + await store.dispatch(arcGisLogin()); + const state = store.getState(); + expect(selectUser(state)).toEqual(mockEmailExpected); + }); + + it("Selector should return the updated value after Logout", async () => { + const store = setupStore(); + await store.dispatch(arcGisLogout()); + const state = store.getState(); + expect(selectUser(state)).toEqual(""); + }); +}); diff --git a/src/redux/slices/arcgis-auth-slice.ts b/src/redux/slices/arcgis-auth-slice.ts new file mode 100644 index 00000000..7e7f6ea6 --- /dev/null +++ b/src/redux/slices/arcgis-auth-slice.ts @@ -0,0 +1,44 @@ +import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; +import { RootState } from "../store"; +import { + getAuthenticatedUser, + arcGisRequestLogin, + arcGisRequestLogout, +} from "../../utils/arcgis-auth"; + +// Define a type for the slice state +export interface ArcGisAuthState { + user: string; +} + +// "lazy initializer" +const initialState = () => { + return { user: getAuthenticatedUser() }; +}; + +const arcGisAuthSlice = createSlice({ + name: "arcGisAuth", + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(arcGisLogin.fulfilled, (state, action) => { + state.user = action.payload || ""; + }) + .addCase(arcGisLogout.fulfilled, (state) => { + state.user = ""; + }); + }, +}); + +export const arcGisLogin = createAsyncThunk("arcGisLogin", async () => { + return await arcGisRequestLogin(); +}); + +export const arcGisLogout = createAsyncThunk("arcGisLogout", async () => { + return await arcGisRequestLogout(); +}); + +export const selectUser = (state: RootState): string => state.arcGisAuth.user; + +export default arcGisAuthSlice.reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts index 61230baa..4b5b407d 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -10,6 +10,7 @@ import debugOptionsSliceReducer from "./slices/debug-options-slice"; import i3sStatsSliceReducer from "./slices/i3s-stats-slice"; import baseMapsSliceReducer from "./slices/base-maps-slice"; import symbolizationSliceReducer from "./slices/symbolization-slice"; +import arcGisAuthSliceReducer from "./slices/arcgis-auth-slice"; // Create the root reducer separately so we can extract the RootState type const rootReducer = combineReducers({ @@ -20,6 +21,7 @@ const rootReducer = combineReducers({ baseMaps: baseMapsSliceReducer, symbolization: symbolizationSliceReducer, i3sStats: i3sStatsSliceReducer, + arcGisAuth: arcGisAuthSliceReducer, }); export const setupStore = (preloadedState?: PreloadedState) => { diff --git a/src/utils/arcgis-auth.spec.ts b/src/utils/arcgis-auth.spec.ts new file mode 100644 index 00000000..34ebf69d --- /dev/null +++ b/src/utils/arcgis-auth.spec.ts @@ -0,0 +1,44 @@ +import { + getAuthenticatedUser, + arcGisRequestLogin, + arcGisRequestLogout, +} from "./arcgis-auth"; + +jest.mock("@esri/arcgis-rest-request"); + +const ARCGIS_REST_USER_INFO = "__ARCGIS_REST_USER_INFO__"; +const mockEmailExpected = "usermail@gmail.com"; + +let OLD_ENV = {}; + +beforeAll(() => { + OLD_ENV = process.env; +}); + +describe("ArcGIS auth functions", () => { + beforeEach(() => { + jest.resetModules(); // Clear the cache + process.env = { ...OLD_ENV }; + process.env.REACT_APP_ARCGIS_REST_CLIENT_ID = "CLIENT_ID"; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it("Should return email of user logged in", () => { + localStorage.setItem(ARCGIS_REST_USER_INFO, mockEmailExpected); + const email = getAuthenticatedUser(); + expect(email).toEqual(mockEmailExpected); + }); + + it("Should request login and return email of user", async () => { + const email = await arcGisRequestLogin(); + expect(email).toBe(mockEmailExpected); + }); + + it("Should request logout and return empty string", async () => { + const email = await arcGisRequestLogout(); + expect(email).toBe(""); + }); +}); diff --git a/src/utils/arcgis-auth.ts b/src/utils/arcgis-auth.ts new file mode 100644 index 00000000..36d397ca --- /dev/null +++ b/src/utils/arcgis-auth.ts @@ -0,0 +1,99 @@ +import { ArcGISIdentityManager } from "@esri/arcgis-rest-request"; + +const ARCGIS_REST_USER_SESSION = "__ARCGIS_REST_USER_SESSION__"; +const ARCGIS_REST_USER_INFO = "__ARCGIS_REST_USER_INFO__"; + +const updateSessionInfo = async ( + session?: ArcGISIdentityManager +): Promise => { + let email = ""; + if (session) { + localStorage.setItem(ARCGIS_REST_USER_SESSION, session.serialize()); + const user = await session.getUser(); + email = user.email || ""; + localStorage.setItem(ARCGIS_REST_USER_INFO, email); + } else { + localStorage.removeItem(ARCGIS_REST_USER_SESSION); + localStorage.removeItem(ARCGIS_REST_USER_INFO); + } + return email; +}; + +function getArcGisSession(): ArcGISIdentityManager | undefined { + let session; + const itemString = localStorage.getItem(ARCGIS_REST_USER_SESSION); + if (itemString) { + session = ArcGISIdentityManager.deserialize(itemString); + } + return session; +} + +/** + * Gets the redirection URL and the client ID to use in the ArcGIS authentication workflow. + * @returns the redirection URL and the client ID. + */ +const getAuthOptions = () => { + const port = window.location.port ? `:${window.location.port}` : ""; + const options = { + redirectUri: `${window.location.protocol}//${window.location.hostname}${port}/auth`, + clientId: process.env.REACT_APP_ARCGIS_REST_CLIENT_ID || "", + popup: true, + pkce: true, + }; + + if (!options.clientId) { + console.error("The ClientId is not defined in .env file."); + } + return options; +}; + +/** + * Gets the email of the currently logged in user. + * @returns the user's email or an empty string if the user is not logged in. + */ +export const getAuthenticatedUser = (): string => { + return localStorage.getItem(ARCGIS_REST_USER_INFO) || ""; +}; + +/** + * Makes an ArcGIS login request by opening a popup dialog. + * @returns email of the user logged in or an empty string if the user is not logged in. + */ +export const arcGisRequestLogin = async () => { + let email = ""; + + const options = getAuthOptions(); + if (options.clientId) { + let session: ArcGISIdentityManager | undefined; + try { + session = await ArcGISIdentityManager.beginOAuth2(options); + } finally { + // In case of an exception the session is not set. + // So the following call will remove any session stored in the local storage. + email = await updateSessionInfo(session); + } + } + return email; +}; + +/** + * Completes the ArcGIS login request started by {@link arcGisRequestLogin}. + */ +export const arcGisCompleteLogin = async () => { + const options = getAuthOptions(); + if (options.clientId) { + ArcGISIdentityManager.completeOAuth2(options); + } +}; + +/** + * Makes an ArcGIS logout request. + * @returns empty string + */ +export const arcGisRequestLogout = async () => { + const session = getArcGisSession(); + if (session) { + await ArcGISIdentityManager.destroy(session); + } + return await updateSessionInfo(); +}; diff --git a/webpack.dev.config.js b/webpack.dev.config.js index 3b952f7d..a5288e44 100644 --- a/webpack.dev.config.js +++ b/webpack.dev.config.js @@ -100,6 +100,7 @@ module.exports = (env) => { devServer: { open: true, port: 3000, + server: 'https', client: { overlay: { errors: true,