diff --git a/__mocks__/@esri/arcgis-rest-portal.ts b/__mocks__/@esri/arcgis-rest-portal.ts new file mode 100644 index 00000000..a278e2e4 --- /dev/null +++ b/__mocks__/@esri/arcgis-rest-portal.ts @@ -0,0 +1,28 @@ +const mockContent = { + items: [ + { + id: "new-york", + name: "NewYork.slpk", + url: "https://123.com", + created: 123456, + type: "Scene Service", + typeKeywords: "This is a Hosted Service", + title: "New York", + token: "token-https://123.com", + }, + { + id: "turanga-library", + name: "TurangaLibrary.slpk", + url: "https://456.com", + created: 123457, + type: "Scene Service", + typeKeywords: "This is a Hosted Service", + title: "Turanga Library", + token: "token-https://456.com", + }, + ], +}; + +export const getUserContent = async (authentication) => { + return mockContent; +}; diff --git a/__mocks__/@esri/arcgis-rest-request.ts b/__mocks__/@esri/arcgis-rest-request.ts index 391ef811..45ca3611 100644 --- a/__mocks__/@esri/arcgis-rest-request.ts +++ b/__mocks__/@esri/arcgis-rest-request.ts @@ -1,17 +1,22 @@ const mockEmailExpected = "usermail@gmail.com"; const mockSessionExpected = '{"usermail": "usermail"}'; +const mockTokenExpectedPrefix = "token-"; + +const session = { + usermail: mockEmailExpected, + serialize: () => { + return mockSessionExpected; + }, + getUser: async () => { + return { email: mockEmailExpected }; + }, + getToken: async (url: string) => { + return mockTokenExpectedPrefix + url; + }, +}; export class ArcGISIdentityManager { static beginOAuth2 = async () => { - const session = { - usermail: mockEmailExpected, - serialize: () => { - return mockSessionExpected; - }, - getUser: async () => { - return { email: mockEmailExpected }; - }, - }; return session; }; static completeOAuth2 = async () => { @@ -20,7 +25,7 @@ export class ArcGISIdentityManager { static destroy = async () => { return; }; - static deserialize = (session) => { - return { usermail: "usermail" }; + static deserialize = () => { + return session; }; } diff --git a/package.json b/package.json index bd93774c..92225483 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dependencies": { "@esri/arcgis-rest-auth": "^3.7.0", "@esri/arcgis-rest-request": "^4.2.0", + "@esri/arcgis-rest-portal": "^4.4.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/src/components/common.tsx b/src/components/common.tsx index 5fce3c01..1c566be2 100644 --- a/src/components/common.tsx +++ b/src/components/common.tsx @@ -64,8 +64,10 @@ export const PanelContent = styled.div` export const PanelHorizontalLine = styled.div<{ top?: number; bottom?: number; + left?: number; + right?: number; }>` - margin: ${({ top = 24, bottom = 16 }) => `${top}px 16px ${bottom}px 16px`}; + margin: ${({ top = 24, bottom = 16, left = 16, right = 16 }) => `${top}px ${right}px ${bottom}px ${left}px`}; border: 1px solid ${({ theme }) => theme.colors.mainHiglightColorInverted}; border-radius: 1px; background: ${({ theme }) => theme.colors.mainHiglightColorInverted}; diff --git a/src/components/expand-icon/expand-icon.tsx b/src/components/expand-icon/expand-icon.tsx index 45ce62dd..e826462a 100644 --- a/src/components/expand-icon/expand-icon.tsx +++ b/src/components/expand-icon/expand-icon.tsx @@ -47,6 +47,17 @@ const IconButton = styled.div<{ } `; +const IconButtonContainer = styled.div<{ + width: number; + height: number; +}>` + display: flex; + justify-content: center; + align-items: center; + width: ${({ width }) => `${width}px`}} + height: ${({ height }) => `${height}px`}} +`; + type ExpandIconProps = { /** expanded/collapsed */ expandState: ExpandState; @@ -56,6 +67,10 @@ type ExpandIconProps = { fillExpanded?: string; /** icon color for collapsed state */ fillCollapsed?: string; + /** Width of the icon */ + width?: number; + /** Height of the icon */ + height?: number; /** click event handler */ onClick: (e: SyntheticEvent) => void; }; @@ -65,6 +80,8 @@ export const ExpandIcon = ({ fillExpanded, fillCollapsed, collapseDirection = CollapseDirection.top, + width = 8, + height = 12, }: ExpandIconProps) => { return ( - + + + ); }; diff --git a/src/components/layers-panel/arcgis-control-panel.spec.tsx b/src/components/layers-panel/arcgis-control-panel.spec.tsx new file mode 100644 index 00000000..1a7b1028 --- /dev/null +++ b/src/components/layers-panel/arcgis-control-panel.spec.tsx @@ -0,0 +1,150 @@ +import { renderWithThemeProviders } from "../../utils/testing-utils/render-with-theme"; +import { screen, within } from "@testing-library/react"; +import { setupStore } from "../../redux/store"; +import { ArcGisControlPanel } from "./arcgis-control-panel"; +import userEvent from "@testing-library/user-event"; +import { + arcGisLogin, + arcGisLogout, +} from "../../redux/slices/arcgis-auth-slice"; +import { + getAuthenticatedUser, + arcGisRequestLogin, + arcGisCompleteLogin, + arcGisRequestLogout, +} from "../../utils/arcgis"; + +jest.mock("../../utils/arcgis"); + +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 EMAIL_EXPECTED = "usermail@gmail.com"; +let storageUserinfo = ""; + +const onArcGisImportMock = jest.fn(); + +const callRender = (renderFunc, props = {}, store = setupStore()) => { + return renderFunc( + , + store + ); +}; + +describe("Layers Control Panel - ArcGIS auth", () => { + beforeAll(() => { + arcGisRequestLoginMock.mockImplementation(async () => { + storageUserinfo = EMAIL_EXPECTED; + return storageUserinfo; + }); + arcGisCompleteLoginMock.mockImplementation(async () => { + return storageUserinfo; + }); + arcGisRequestLogoutMock.mockImplementation(async () => { + storageUserinfo = ""; + return storageUserinfo; + }); + getAuthenticatedUserMock.mockImplementation(() => { + return storageUserinfo; + }); + }); + + 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(EMAIL_EXPECTED); + 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()); + 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(); + }); +}); diff --git a/src/components/layers-panel/arcgis-control-panel.tsx b/src/components/layers-panel/arcgis-control-panel.tsx new file mode 100644 index 00000000..ec4b07a1 --- /dev/null +++ b/src/components/layers-panel/arcgis-control-panel.tsx @@ -0,0 +1,133 @@ +import { useState } from "react"; +import styled from "styled-components"; +import ImportIcon from "../../../public/icons/import.svg"; +import { AcrGisUser } from "../arcgis-user/arcgis-user"; +import { + arcGisLogin, + arcGisLogout, + selectUser, +} from "../../redux/slices/arcgis-auth-slice"; +import { getArcGisContent } from "../../redux/slices/arcgis-content-slice"; +import { ModalDialog } from "../modal-dialog/modal-dialog"; +import EsriImage from "../../../public/images/esri.svg"; +import { useAppDispatch, useAppSelector } from "../../redux/hooks"; +import { ActionIconButton } from "../action-icon-button/action-icon-button"; +import { ButtonSize } from "../../types"; +import { ArcGisImportPanel } from "./arcgis-import-panel/arcgis-import-panel"; + +type ArcGisControlPanelProps = { + onArcGisImportClick: (layer: { + name: string; + url: string; + token?: string; + }) => void; +}; + +const ActionButtonsContainer = styled.div` + display: flex; + flex-direction: column; + width: 100%; + row-gap: 8px; + margin-top: 8px; +`; + +const ActionIconButtonContainer = styled.div` + display: flex; + flex-direction: row; + justify-content: start; + align-items: center; +`; + +const EsriStyledImage = styled(EsriImage)` + margin-left: 16px; + 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 ArcGisControlPanel = ({ + onArcGisImportClick, +}: ArcGisControlPanelProps) => { + const dispatch = useAppDispatch(); + + const username = useAppSelector(selectUser); + const isLoggedIn = !!username; + + const [showLogoutWarning, setShowLogoutWarning] = useState(false); + const [showArcGisImportPanel, setShowArcGisImportPanel] = useState(false); + + const onArcGisActionClick = () => { + if (isLoggedIn) { + dispatch(getArcGisContent()); + setShowArcGisImportPanel(true); + } else { + dispatch(arcGisLogin()); + } + }; + const onArcGisLogoutClick = () => { + setShowLogoutWarning(true); + }; + + return ( + <> + + + + {isLoggedIn ? "Import from ArcGIS" : "Login to ArcGIS"} + + + + + {isLoggedIn && ( + {username} + )} + + + {showArcGisImportPanel && ( + { + onArcGisImportClick(item); + setShowArcGisImportPanel(false); + }} + onCancel={() => setShowArcGisImportPanel(false)} + /> + )} + + {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/layers-panel/arcgis-import-panel/arcgis-import-panel.spec.tsx b/src/components/layers-panel/arcgis-import-panel/arcgis-import-panel.spec.tsx new file mode 100644 index 00000000..a4b25961 --- /dev/null +++ b/src/components/layers-panel/arcgis-import-panel/arcgis-import-panel.spec.tsx @@ -0,0 +1,146 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithThemeProviders } from "../../../utils/testing-utils/render-with-theme"; +import { ArcGisImportPanel } from "./arcgis-import-panel"; +import { setupStore } from "../../../redux/store"; +import { + selectArcGisContent, + getArcGisContent, +} from "../../../redux/slices/arcgis-content-slice"; +import { getArcGisUserContent } from "../../../utils/arcgis"; + +jest.mock("../../../utils/arcgis"); + +const getArcGisUserContentMock = + getArcGisUserContent as unknown as jest.Mocked; + +const onImportMock = jest.fn(); +const onCancelMock = jest.fn(); + +const CONTENT_EXPECTED = [ + { + id: "123", + name: "123.slpk", + url: "https://123.com", + created: 123453, + title: "City-123", + token: "token-https://123.com", + }, + { + id: "789", + name: "789.slpk", + url: "https://789.com", + created: 123457, + title: "City-789", + token: "token-https://789.com", + }, + { + id: "456", + name: "456.slpk", + url: "https://456.com", + created: 123454, + title: "City-456", + token: "token-https://456.com", + }, +]; + +const callRender = (renderFunc, props = {}, store = setupStore()) => { + return renderFunc( + , + store + ); +}; + +describe("Import panel", () => { + beforeAll(() => { + getArcGisUserContentMock.mockImplementation(async () => { + return CONTENT_EXPECTED; + }); + }); + + it("Should render import panel", async () => { + const store = setupStore(); + await store.dispatch(getArcGisContent()); + const { container } = callRender( + renderWithThemeProviders, + undefined, + store + ); + + expect(container).toBeInTheDocument(); + expect(screen.getByText("Select map to import")).toBeInTheDocument(); + expect(screen.getByText("Import Selected")).toBeInTheDocument(); + expect(screen.getByText("Title")).toBeInTheDocument(); + expect(screen.getByText("Date")).toBeInTheDocument(); + }); + + it("Should close the dialog", async () => { + const store = setupStore(); + callRender(renderWithThemeProviders, undefined, store); + + const cross = document.querySelector("svg"); + cross && userEvent.click(cross); + expect(onCancelMock).toHaveBeenCalledTimes(1); + }); + + it("Should import an item", async () => { + const store = setupStore(); + callRender(renderWithThemeProviders, undefined, store); + await store.dispatch(getArcGisContent()); + + const importSelected = screen.getByText("Import Selected"); + + // No item is selected yet. + importSelected && userEvent.click(importSelected); + expect(onImportMock).toHaveBeenCalledTimes(0); + + // Select an item to import + const row = screen.getByText("City-123"); + row && userEvent.click(row); + + importSelected && userEvent.click(importSelected); + expect(onImportMock).toHaveBeenCalledTimes(1); + }); + + it("Should change the sorting order", async () => { + const store = setupStore(); + callRender(renderWithThemeProviders, undefined, store); + await store.dispatch(getArcGisContent()); + + let state = store.getState(); + let cont = selectArcGisContent(state); + + const title = screen.getByText("Title"); + + title && userEvent.click(title); + state = store.getState(); + cont = selectArcGisContent(state); + expect(cont[0]["id"]).toBe("123"); + + title && userEvent.click(title); + state = store.getState(); + cont = selectArcGisContent(state); + expect(cont[0]["id"]).toBe("789"); + + const date = screen.getByText("Date"); + + date && userEvent.click(date); + state = store.getState(); + cont = selectArcGisContent(state); + expect(cont[0]["id"]).toBe("789"); + + date && userEvent.click(date); + state = store.getState(); + cont = selectArcGisContent(state); + expect(cont[0]["id"]).toBe("123"); + + date && userEvent.click(date); + state = store.getState(); + cont = selectArcGisContent(state); + expect(cont[0]["id"]).toBe("789"); + }); +}); diff --git a/src/components/layers-panel/arcgis-import-panel/arcgis-import-panel.tsx b/src/components/layers-panel/arcgis-import-panel/arcgis-import-panel.tsx new file mode 100644 index 00000000..4af11e31 --- /dev/null +++ b/src/components/layers-panel/arcgis-import-panel/arcgis-import-panel.tsx @@ -0,0 +1,320 @@ +import styled, { css, useTheme } from "styled-components"; +import { RadioButton } from "../../radio-button/radio-button"; +import { useEffect } from "react"; +import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; +import { ModalDialog } from "../../modal-dialog/modal-dialog"; +import { + selectArcGisContent, + selectArcGisContentSelected, + selectSortAscending, + selectSortColumn, + selectStatus, + setSortAscending, + setSortColumn, + setArcGisContentSelected, + resetArcGisContentSelected, +} from "../../../redux/slices/arcgis-content-slice"; +import { + IArcGisContent, + ArcGisContentColumnName, + ExpandState, + CollapseDirection, +} from "../../../types"; +import { LoadingSpinner } from "../../loading-spinner/loading-spinner"; +import { ExpandIcon } from "../../expand-icon/expand-icon"; + +const SpinnerContainer = styled.div<{ visible: boolean }>` + position: absolute; + left: calc(50% - 22px); + top: calc(50% - 22px); + opacity: ${({ visible }) => (visible ? 1 : 0)}; +`; + +const Table = styled.table` + width: 584px; + border-collapse: collapse; +`; + +const TableHeader = styled.thead` + font-style: normal; + font-weight: 500; + font-size: 16px; + line-height: 19px; + color: ${({ theme }) => theme.colors.secondaryFontColor}; + overflow: hidden; +`; + +const TableHeaderCell = styled.th<{ width: number }>` + width: ${({ width }) => `${width}px`}; + padding: 0; +`; + +const TableRowCell = styled.td<{ + width: number; + fontWeight?: number; +}>` + width: ${({ width }) => `${width}px`}; + padding: 0; + ${({ fontWeight }) => + fontWeight !== undefined && + css` + font-weight: ${fontWeight}; + `} +`; + +const CellDiv = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: start; + margin: 8px 0 8px 0; + height: 44px; +`; + +const TableContent = styled.tbody` + font-style: normal; + font-weight: 500; + font-size: 16px; + line-height: 19px; + overflow: auto; + max-height: 300px; +`; + +const TableRow = styled.tr<{ checked: boolean }>` + background: transparent; + cursor: pointer; + border: 0; + border-style: solid; + border-bottom: 1px solid + ${({ theme }) => `${theme.colors.mainHiglightColorInverted}1f`}; + border-radius: 1px; + + ${({ checked }) => + checked && + css` + > * > :first-child { + background: ${({ theme }) => theme.colors.mainHiglightColor}; + } + > :first-child > :first-child { + border-top-left-radius: 8px; + border-bottom-left-radius: 8px; + } + > :last-child > :first-child { + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; + } + + box-shadow: 0px 17px 80px rgba(0, 0, 0, 0.1); + `} + &:hover { + > * > :first-child { + background: ${({ theme }) => theme.colors.mainDimColor}; + } + > :first-child > :first-child { + border-top-left-radius: 8px; + border-bottom-left-radius: 8px; + } + > :last-child > :first-child { + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; + } + + box-shadow: 0px 17px 80px rgba(0, 0, 0, 0.1); + } +`; + +const Radio = styled.div` + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + width: 44px; + height: 44px; +`; + +const TitleCellContainer = styled.div` + display: flex; + flex-direction: row; + justify-content: start; + align-items: center; + gap: 4px; + cursor: pointer; +`; + +const IconContainer = styled.div<{ enabled: boolean }>` + display: flex; + justify-content: center; + align-items: center; + margin-top: 2px; + width: 16px; + height: 16px; + fill: ${({ theme }) => theme.colors.buttonDimIconColor}; + visibility: ${({ enabled }) => (enabled ? "visible" : "hidden")}; +`; + +type Column = { + id: string; + width: number; + fontWeight?: number; + dataColumnName?: ArcGisContentColumnName; + sortDataColumnName?: ArcGisContentColumnName; + columnName?: string; +}; + +const columns: Column[] = [ + { + id: "radio", + width: 44, + }, + { + id: "title", + width: 343, + fontWeight: 700, + dataColumnName: "title", + columnName: "Title", + }, + { + id: "created", + width: 149, + dataColumnName: "createdFormatted", + sortDataColumnName: "created", + columnName: "Date", + }, +]; + +type InsertLayerProps = { + onImport: (object: { name: string; url: string; token?: string }) => void; + onCancel: () => void; +}; + +export const ArcGisImportPanel = ({ onImport, onCancel }: InsertLayerProps) => { + const dispatch = useAppDispatch(); + const arcGisContentArray = useAppSelector(selectArcGisContent); + const arcGisContentSelected = useAppSelector(selectArcGisContentSelected); + const sortAscending = useAppSelector(selectSortAscending); + const sortColumn = useAppSelector(selectSortColumn); + const loadingStatus = useAppSelector(selectStatus); + + const isLoading = loadingStatus === "loading"; + + useEffect(() => { + dispatch(resetArcGisContentSelected()); + }, []); + + const handleImport = () => { + const arcGisItem = arcGisContentArray.find( + (item) => item.id === arcGisContentSelected + ); + if (arcGisItem) { + onImport(arcGisItem); + } + }; + + const onSort = (dataColumnName: ArcGisContentColumnName) => { + if (sortColumn === dataColumnName) { + dispatch(setSortAscending(!sortAscending)); + } else { + dispatch(setSortColumn(dataColumnName)); + } + }; + + const renderHeaderCell = (column: Column): JSX.Element => { + const sortDataColumnName = + column.sortDataColumnName || column.dataColumnName; + return ( + + {typeof sortDataColumnName !== "undefined" && ( + onSort(sortDataColumnName)}> + {column.columnName || ""} + + onSort(sortDataColumnName)} + fillExpanded={theme.colors.buttonDimIconColor} + width={6} + /> + + + )} + + ); + }; + + const renderRowCell = ( + column: Column, + contentItem: IArcGisContent, + isRowSelected: boolean + ): JSX.Element => { + const dataColumnName = column.dataColumnName; + return ( + + + {dataColumnName ? ( + contentItem[dataColumnName] + ) : ( + + { + dispatch(setArcGisContentSelected(contentItem.id)); + }} + /> + + )} + + + ); + }; + + const theme = useTheme(); + return ( + + + + + + + + {columns.map((column: Column) => renderHeaderCell(column))} + + + {arcGisContentArray.map((contentItem) => { + const isRowSelected = arcGisContentSelected === contentItem.id; + + return ( + { + dispatch(setArcGisContentSelected(contentItem.id)); + }} + > + {columns.map((column: Column) => + renderRowCell(column, contentItem, isRowSelected) + )} + + ); + })} + +
+
+ ); +}; diff --git a/src/components/layers-panel/layers-control-panel.spec.tsx b/src/components/layers-panel/layers-control-panel.spec.tsx index eb0dcb1b..a1b1eeb4 100644 --- a/src/components/layers-panel/layers-control-panel.spec.tsx +++ b/src/components/layers-panel/layers-control-panel.spec.tsx @@ -1,11 +1,6 @@ -import { act, screen, within } from "@testing-library/react"; +import { act, screen } 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"; @@ -14,18 +9,10 @@ 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; @@ -33,18 +20,6 @@ const DeleteConfirmationMock = DeleteConfirmation as unknown as jest.Mocked; const LayerOptionsMenuMock = LayerOptionsMenu as unknown as jest.Mocked; -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(); const onDeleteLayerMock = jest.fn(); @@ -84,118 +59,6 @@ beforeAll(() => { 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 store = setupStore(); diff --git a/src/components/layers-panel/layers-control-panel.tsx b/src/components/layers-panel/layers-control-panel.tsx index 1da26ab2..4caf5dfb 100644 --- a/src/components/layers-panel/layers-control-panel.tsx +++ b/src/components/layers-panel/layers-control-panel.tsx @@ -8,22 +8,11 @@ import { } 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[]; @@ -56,6 +45,7 @@ const InsertButtons = styled.div` flex-direction: column; row-gap: 8px; margin-top: 8px; + margin-bottom: 8px; `; const ChildrenContainer = styled.div` @@ -66,32 +56,6 @@ const ChildrenContainer = styled.div` padding-left: 12px; `; -const ActionIconButtonContainer = styled.div` - display: flex; - flex-direction: row; - justify-content: start; - align-items: center; -`; - -const EsriStyledImage = styled(EsriImage)` - margin-left: 16px; - 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, @@ -104,23 +68,10 @@ 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(""); - const onArcGisActionClick = () => { - !isLoggedIn && dispatch(arcGisLogin()); - }; - const onArcGisLogoutClick = () => { - setShowLogoutWarning(true); - }; - const isListItemSelected = ( layer: LayerExample, parentLayer?: LayerExample @@ -248,43 +199,6 @@ export const LayersControlPanel = ({ > Insert scene - - - - - - {!isLoggedIn && "Login to ArcGIS"} - {isLoggedIn && "Import from ArcGIS"} - - - - - {isLoggedIn && ( - {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/layers-panel/layers-panel.tsx b/src/components/layers-panel/layers-panel.tsx index bec8fa9d..17487a49 100644 --- a/src/components/layers-panel/layers-panel.tsx +++ b/src/components/layers-panel/layers-panel.tsx @@ -19,6 +19,7 @@ import { import { CloseButton } from "../close-button/close-button"; import { InsertPanel } from "./insert-panel/insert-panel"; import { LayersControlPanel } from "./layers-control-panel"; +import { ArcGisControlPanel } from "./arcgis-control-panel"; import { MapOptionPanel } from "./map-options-panel"; import { PanelContainer, @@ -207,7 +208,6 @@ export const LayersPanel = ({ ); if (existedLayer) { - setShowLayerInsertPanel(false); setShowExistedError(true); return; } @@ -221,7 +221,6 @@ export const LayersPanel = ({ }; onLayerInsert(newLayer); - setShowLayerInsertPanel(false); }; const prepareLayerExamples = (layers: OperationalLayer[]): LayerExample[] => { @@ -381,6 +380,13 @@ export const LayersPanel = ({ /> )} + + + + + + + {showExistedError && ( setWarningNode(element)}> handleInsertLayer(layer)} + onInsert={(layer) => { + handleInsertLayer(layer); + setShowLayerInsertPanel(false); + }} onCancel={() => setShowLayerInsertPanel(false)} /> diff --git a/src/components/modal-dialog/modal-dialog.tsx b/src/components/modal-dialog/modal-dialog.tsx index e2248bba..c25df8d8 100644 --- a/src/components/modal-dialog/modal-dialog.tsx +++ b/src/components/modal-dialog/modal-dialog.tsx @@ -50,6 +50,7 @@ const IconContainer = styled.div` right: 14px; width: 44px; height: 44px; + cursor: pointer; `; const ContentContainer = styled.div` @@ -70,10 +71,14 @@ const Title = styled.div` line-height: 45px; `; -const ButtonsContainer = styled.div` +type ButtonsContainerProps = { + justify: string; +}; + +const ButtonsContainer = styled.div` display: flex; flex-direction: row; - justify-content: center; + justify-content: ${(props) => props.justify}; margin: 32px; column-gap: 18px; { @@ -93,7 +98,6 @@ type LogoutPanelProps = { }; const CloseCrossButton = styled(CloseIcon)` - cursor: pointer; &:hover { fill: ${({ theme }) => theme.colors.mainDimColorInverted}; } @@ -102,8 +106,8 @@ const CloseCrossButton = styled(CloseIcon)` export const ModalDialog = ({ title, children, - cancelButtonText = "Cancel", - okButtonText = "Ok", + cancelButtonText, + okButtonText, onCancel, onConfirm, }: LogoutPanelProps) => { @@ -114,23 +118,22 @@ export const ModalDialog = ({ - - + + {title} {children} - - - {cancelButtonText} - + + {cancelButtonText && ( + + {cancelButtonText} + + )} {okButtonText} diff --git a/src/pages/auth/auth.tsx b/src/pages/auth/auth.tsx index 2bb257d7..1c6617a8 100644 --- a/src/pages/auth/auth.tsx +++ b/src/pages/auth/auth.tsx @@ -6,7 +6,7 @@ import { useAppLayout, } from "../../utils/hooks/layout"; -import { arcGisCompleteLogin } from "../../utils/arcgis-auth"; +import { arcGisCompleteLogin } from "../../utils/arcgis"; export type LayoutProps = { layout: string; diff --git a/src/pages/comparison/e2e.comparison.spec.ts b/src/pages/comparison/e2e.comparison.spec.ts index dc16bde1..89adc288 100644 --- a/src/pages/comparison/e2e.comparison.spec.ts +++ b/src/pages/comparison/e2e.comparison.spec.ts @@ -282,7 +282,7 @@ describe("Compare - Map Control Panel", () => { // Dropdown button const dropdownButton = await page.$eval( - `${panelId} > :first-child > svg`, + `${panelId} > :first-child > :first-child > svg`, (node) => node.innerHTML ); expect(dropdownButton).toBe(chevronSvgHtml); diff --git a/src/pages/debug-app/e2e.debug-app.spec.ts b/src/pages/debug-app/e2e.debug-app.spec.ts index 22f916bd..a1dedc16 100644 --- a/src/pages/debug-app/e2e.debug-app.spec.ts +++ b/src/pages/debug-app/e2e.debug-app.spec.ts @@ -333,7 +333,7 @@ describe("Debug - Map Control Panel", () => { // Dropdown button const dropdownButton = await page.$eval( - `${panelId} > :first-child > svg`, + `${panelId} > :first-child > :first-child > svg`, (node) => node.innerHTML ); expect(dropdownButton).toBe(chevronSvgHtml); diff --git a/src/pages/viewer-app/e2e.viewer-app.spec.ts b/src/pages/viewer-app/e2e.viewer-app.spec.ts index f34bf9a0..78537766 100644 --- a/src/pages/viewer-app/e2e.viewer-app.spec.ts +++ b/src/pages/viewer-app/e2e.viewer-app.spec.ts @@ -163,7 +163,7 @@ describe("Viewer - Map Control Panel", () => { // Dropdown button const dropdownButton = await page.$eval( - `${panelId} > :first-child > svg`, + `${panelId} > :first-child > :first-child > svg`, (node) => node.innerHTML ); expect(dropdownButton).toBe(chevronSvgHtml); diff --git a/src/redux/slices/arcgis-auth-slice.spec.ts b/src/redux/slices/arcgis-auth-slice.spec.ts index 8023ff9a..62b42483 100644 --- a/src/redux/slices/arcgis-auth-slice.spec.ts +++ b/src/redux/slices/arcgis-auth-slice.spec.ts @@ -10,9 +10,9 @@ import { arcGisRequestLogin, arcGisCompleteLogin, arcGisRequestLogout, -} from "../../utils/arcgis-auth"; +} from "../../utils/arcgis"; -jest.mock("../../utils/arcgis-auth"); +jest.mock("../../utils/arcgis"); const getAuthenticatedUserMock = getAuthenticatedUser as unknown as jest.Mocked; @@ -23,48 +23,48 @@ const arcGisCompleteLoginMock = const arcGisRequestLogoutMock = arcGisRequestLogout as unknown as jest.Mocked; -const mockEmailExpected = "usermail@gmail.com"; -let mockStorageUserinfo = mockEmailExpected; +const EMAIL_EXPECTED = "usermail@gmail.com"; +let storageUserinfo = EMAIL_EXPECTED; describe("slice: arcgis-auth", () => { beforeAll(() => { arcGisRequestLoginMock.mockImplementation(async () => { - mockStorageUserinfo = mockEmailExpected; - return mockStorageUserinfo; + storageUserinfo = EMAIL_EXPECTED; + return storageUserinfo; }); arcGisCompleteLoginMock.mockImplementation(async () => { - return mockStorageUserinfo; + return storageUserinfo; }); arcGisRequestLogoutMock.mockImplementation(async () => { - mockStorageUserinfo = ""; - return mockStorageUserinfo; + storageUserinfo = ""; + return storageUserinfo; }); getAuthenticatedUserMock.mockImplementation(() => { - return mockStorageUserinfo; + return storageUserinfo; }); }); beforeEach(() => { - mockStorageUserinfo = mockEmailExpected; + storageUserinfo = EMAIL_EXPECTED; }); it("Reducer should return the initial state", () => { expect(reducer(undefined, { type: undefined })).toEqual({ - user: mockEmailExpected, + user: EMAIL_EXPECTED, }); }); it("Selector should return the initial state", () => { const store = setupStore(); const state = store.getState(); - expect(selectUser(state)).toEqual(mockEmailExpected); + expect(selectUser(state)).toEqual(EMAIL_EXPECTED); }); 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); + expect(selectUser(state)).toEqual(EMAIL_EXPECTED); }); it("Selector should return the updated value after Logout", async () => { diff --git a/src/redux/slices/arcgis-auth-slice.ts b/src/redux/slices/arcgis-auth-slice.ts index 7e7f6ea6..14e09917 100644 --- a/src/redux/slices/arcgis-auth-slice.ts +++ b/src/redux/slices/arcgis-auth-slice.ts @@ -4,7 +4,7 @@ import { getAuthenticatedUser, arcGisRequestLogin, arcGisRequestLogout, -} from "../../utils/arcgis-auth"; +} from "../../utils/arcgis"; // Define a type for the slice state export interface ArcGisAuthState { diff --git a/src/redux/slices/arcgis-content-slice.spec.ts b/src/redux/slices/arcgis-content-slice.spec.ts new file mode 100644 index 00000000..3b66de69 --- /dev/null +++ b/src/redux/slices/arcgis-content-slice.spec.ts @@ -0,0 +1,162 @@ +import { setupStore } from "../store"; +import reducer, { + selectArcGisContent, + selectArcGisContentSelected, + selectSortAscending, + selectSortColumn, + selectStatus, + setSortAscending, + setSortColumn, + setArcGisContentSelected, + resetArcGisContentSelected, + getArcGisContent, +} from "./arcgis-content-slice"; + +import { getArcGisUserContent } from "../../utils/arcgis"; + +jest.mock("../../utils/arcgis"); +const getArcGisUserContentMock = + getArcGisUserContent as unknown as jest.Mocked; + +const CONTENT_EXPECTED = [ + { + id: "123", + name: "123.slpk", + url: "https://123.com", + created: 123453, + title: "City-123", + token: "token-https://123.com", + }, + { + id: "789", + name: "789.slpk", + url: "https://789.com", + created: 123457, + title: "City-789", + token: "token-https://789.com", + }, + { + id: "456", + name: "456.slpk", + url: "https://456.com", + created: 123454, + title: "City-456", + token: "token-https://456.com", + }, +]; + +const CONTENT_SORTED_EXPECTED = [ + { + id: "123", + name: "123.slpk", + url: "https://123.com", + created: 123453, + title: "City-123", + token: "token-https://123.com", + }, + { + id: "456", + name: "456.slpk", + url: "https://456.com", + created: 123454, + title: "City-456", + token: "token-https://456.com", + }, + { + id: "789", + name: "789.slpk", + url: "https://789.com", + created: 123457, + title: "City-789", + token: "token-https://789.com", + }, +]; + +const INIT_VALUE_EXPECTED = { + arcGisContent: [], + arcGisContentSelected: "", + sortColumn: null, + sortAscending: true, + status: "idle", +}; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe("slice: arcgis-content", () => { + beforeAll(() => { + getArcGisUserContentMock.mockImplementation(async () => { + sleep(10); + return CONTENT_EXPECTED; + }); + }); + + it("Reducer should return the initial state", () => { + expect(reducer(undefined, { type: undefined })).toEqual( + INIT_VALUE_EXPECTED + ); + }); + + it("Selector should return the initial state", () => { + const store = setupStore(); + const state = store.getState(); + expect(selectStatus(state)).toEqual("idle"); + }); + + it("Selector should return 'loading' status", () => { + const store = setupStore(); + /* No await! */ store.dispatch(getArcGisContent()); + const state = store.getState(); + expect(selectStatus(state)).toEqual("loading"); + }); + + it("Selector should return empty string if no content added", () => { + const store = setupStore(); + store.dispatch(setArcGisContentSelected("123")); + const state = store.getState(); + expect(selectArcGisContentSelected(state)).toEqual(""); + }); + + it("Selector should return id of selected item", async () => { + const store = setupStore(); + await store.dispatch(getArcGisContent()); + store.dispatch(setArcGisContentSelected("456")); + const state = store.getState(); + expect(selectArcGisContentSelected(state)).toEqual("456"); + }); + + it("Selector should return empty string if nothing is selected", async () => { + const store = setupStore(); + await store.dispatch(getArcGisContent()); + store.dispatch(setArcGisContentSelected("123")); + store.dispatch(resetArcGisContentSelected()); + const state = store.getState(); + expect(selectArcGisContentSelected(state)).toEqual(""); + }); + + it("Selector should return content received (unsorted)", async () => { + const store = setupStore(); + await store.dispatch(getArcGisContent()); + const state = store.getState(); + expect(selectArcGisContent(state)).toEqual(CONTENT_EXPECTED); + expect(selectStatus(state)).toEqual("idle"); + }); + + it("Selector should return content received (sorted)", async () => { + const store = setupStore(); + store.dispatch(setSortColumn("title")); + store.dispatch(setSortAscending(true)); + + await store.dispatch(getArcGisContent()); + let state = store.getState(); + expect(selectSortColumn(state)).toEqual("title"); + expect(selectSortAscending(state)).toEqual(true); + expect(selectArcGisContent(state)).toEqual(CONTENT_SORTED_EXPECTED); + + store.dispatch(setSortColumn("created")); + store.dispatch(setSortAscending(true)); + state = store.getState(); + expect(selectArcGisContent(state)).toEqual(CONTENT_SORTED_EXPECTED); + }); +}); diff --git a/src/redux/slices/arcgis-content-slice.ts b/src/redux/slices/arcgis-content-slice.ts new file mode 100644 index 00000000..e9f7cf38 --- /dev/null +++ b/src/redux/slices/arcgis-content-slice.ts @@ -0,0 +1,129 @@ +import { createSlice, PayloadAction, createAsyncThunk } from "@reduxjs/toolkit"; +import { IArcGisContent, ArcGisContentColumnName } from "../../types"; +import { RootState } from "../store"; +import { getArcGisUserContent } from "../../utils/arcgis"; + +// Define a type for the slice state +interface ArcGisContentState { + /** Array of user's content items taken from ArcGIS */ + arcGisContent: IArcGisContent[]; + /** Content item selected in UI */ + arcGisContentSelected: string; + /** Column name to sort the list by */ + sortColumn: ArcGisContentColumnName | null; + /** Sort order: 'ascending' - true, 'descending' - false */ + sortAscending: boolean; + /** Content loading status: when in progress - 'loading', otherwise - 'idle' */ + status: "idle" | "loading"; +} + +const initialState: ArcGisContentState = { + arcGisContent: [], + arcGisContentSelected: "", + sortColumn: null, + sortAscending: true, + status: "idle", +}; + +const sortList = (state: ArcGisContentState) => { + const column = state.sortColumn; + if (column) { + state.arcGisContent.sort((a: IArcGisContent, b: IArcGisContent) => { + let ac = a[column]; + let bc = b[column]; + if (ac === undefined || bc === undefined || ac === null || bc === null) { + return 0; + } + if (typeof ac === "string" && typeof bc === "string") { + ac = ac.toLowerCase(); + bc = bc.toLowerCase(); + } + if (ac === bc) { + return 0; + } + const comp = state.sortAscending ? ac > bc : ac < bc; + return comp ? 1 : -1; + }); + } +}; + +const arcGisContentSlice = createSlice({ + name: "arcGisContent", + initialState, + reducers: { + setSortAscending: ( + state: ArcGisContentState, + action: PayloadAction + ) => { + state.sortAscending = action.payload; + sortList(state); + }, + + // Note, sortColumn will never be set to its initial value (null). + // It's done on purpose. We don't support a scenario to get back to the unsorted content list. + setSortColumn: ( + state: ArcGisContentState, + action: PayloadAction + ) => { + state.sortColumn = action.payload; + sortList(state); + }, + + setArcGisContentSelected: ( + state: ArcGisContentState, + action: PayloadAction + ) => { + const item = state.arcGisContent.find( + (item) => item.id === action.payload + ); + if (item) { + state.arcGisContentSelected = action.payload; + } + }, + + resetArcGisContentSelected: (state: ArcGisContentState) => { + state.arcGisContentSelected = ""; + }, + }, + + extraReducers: (builder) => { + builder + .addCase(getArcGisContent.fulfilled, (state, action) => { + state.arcGisContent = [...action.payload]; + sortList(state); + state.status = "idle"; + }) + .addCase(getArcGisContent.pending, (state) => { + state.status = "loading"; + }); + }, +}); + +export const getArcGisContent = createAsyncThunk( + "getArcGisContent", + async (): Promise => { + const response: IArcGisContent[] = await getArcGisUserContent(); + return response; + } +); + +export const selectArcGisContent = (state: RootState): IArcGisContent[] => + state.arcGisContent.arcGisContent; + +export const selectArcGisContentSelected = (state: RootState): string => + state.arcGisContent.arcGisContentSelected; +export const selectSortAscending = (state: RootState): boolean => + state.arcGisContent.sortAscending; +export const selectSortColumn = (state: RootState): ArcGisContentColumnName | null => + state.arcGisContent.sortColumn; +export const selectStatus = (state: RootState): string => + state.arcGisContent.status; + +export const { + setArcGisContentSelected, + resetArcGisContentSelected, + setSortAscending, + setSortColumn, +} = arcGisContentSlice.actions; + +export default arcGisContentSlice.reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts index 4b5b407d..fc89986a 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -11,6 +11,7 @@ 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"; +import arcGisContentSliceReducer from "./slices/arcgis-content-slice"; // Create the root reducer separately so we can extract the RootState type const rootReducer = combineReducers({ @@ -22,6 +23,7 @@ const rootReducer = combineReducers({ symbolization: symbolizationSliceReducer, i3sStats: i3sStatsSliceReducer, arcGisAuth: arcGisAuthSliceReducer, + arcGisContent: arcGisContentSliceReducer, }); export const setupStore = (preloadedState?: PreloadedState) => { diff --git a/src/types.ts b/src/types.ts index 1a44e9f8..c55a1f6f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -367,3 +367,15 @@ export type TilesetMetadata = { hasChildren: boolean; type?: TilesetType; }; + +export interface IArcGisContent { + id: string; + url: string; + name: string; + title: string; + token?: string; + created: number; + createdFormatted: string; +} + +export type ArcGisContentColumnName = keyof IArcGisContent; diff --git a/src/utils/arcgis-auth.spec.ts b/src/utils/arcgis.spec.ts similarity index 51% rename from src/utils/arcgis-auth.spec.ts rename to src/utils/arcgis.spec.ts index 34ebf69d..2a95b573 100644 --- a/src/utils/arcgis-auth.spec.ts +++ b/src/utils/arcgis.spec.ts @@ -2,12 +2,32 @@ import { getAuthenticatedUser, arcGisRequestLogin, arcGisRequestLogout, -} from "./arcgis-auth"; + getArcGisUserContent, +} from "./arcgis"; jest.mock("@esri/arcgis-rest-request"); +jest.mock("@esri/arcgis-rest-portal"); const ARCGIS_REST_USER_INFO = "__ARCGIS_REST_USER_INFO__"; -const mockEmailExpected = "usermail@gmail.com"; +const EMAIL_EXPECTED = "usermail@gmail.com"; +const CONTENT_EXPECTED = [ + { + id: "new-york", + name: "NewYork.slpk", + url: "https://123.com", + created: 123456, + title: "New York", + token: "token-https://123.com", + }, + { + id: "turanga-library", + name: "TurangaLibrary.slpk", + url: "https://456.com", + created: 123457, + title: "Turanga Library", + token: "token-https://456.com", + }, +]; let OLD_ENV = {}; @@ -27,18 +47,24 @@ describe("ArcGIS auth functions", () => { }); it("Should return email of user logged in", () => { - localStorage.setItem(ARCGIS_REST_USER_INFO, mockEmailExpected); + localStorage.setItem(ARCGIS_REST_USER_INFO, EMAIL_EXPECTED); const email = getAuthenticatedUser(); - expect(email).toEqual(mockEmailExpected); + expect(email).toEqual(EMAIL_EXPECTED); }); it("Should request login and return email of user", async () => { const email = await arcGisRequestLogin(); - expect(email).toBe(mockEmailExpected); + expect(email).toBe(EMAIL_EXPECTED); }); it("Should request logout and return empty string", async () => { const email = await arcGisRequestLogout(); expect(email).toBe(""); }); + + it("Should return content with token", async () => { + await arcGisRequestLogin(); + const content = await getArcGisUserContent(); + expect(content).toEqual(CONTENT_EXPECTED); + }); }); diff --git a/src/utils/arcgis-auth.ts b/src/utils/arcgis.ts similarity index 67% rename from src/utils/arcgis-auth.ts rename to src/utils/arcgis.ts index 36d397ca..1f57666c 100644 --- a/src/utils/arcgis-auth.ts +++ b/src/utils/arcgis.ts @@ -1,4 +1,7 @@ import { ArcGISIdentityManager } from "@esri/arcgis-rest-request"; +import { getUserContent, IItem } from "@esri/arcgis-rest-portal"; +import { IArcGisContent } from "../types"; +import { formatTimestamp } from "../utils/format/format-utils"; const ARCGIS_REST_USER_SESSION = "__ARCGIS_REST_USER_SESSION__"; const ARCGIS_REST_USER_INFO = "__ARCGIS_REST_USER_INFO__"; @@ -97,3 +100,49 @@ export const arcGisRequestLogout = async () => { } return await updateSessionInfo(); }; + +class ArcGisContent implements IArcGisContent { + id = ""; + url = ""; + name = ""; + title = ""; + token? = ""; + created = 0; + get createdFormatted(): string { + return formatTimestamp(this.created); + } + + constructor(item: IItem, token: string) { + this.id = item.id; + this.url = item.url || ""; + this.name = item.name || item.title; + this.title = item.title; + this.token = token; + this.created = item.created; + } +} + +/** + * Gets the ArcGIS user's content list. + * @returns The content list containig the necessay info to load the content items. + */ +export const getArcGisUserContent = async (): Promise => { + const contentItems: IArcGisContent[] = []; + const authentication = getArcGisSession(); + if (authentication) { + const content = await getUserContent({ authentication }); + for (const item of content.items) { + if ( + item.url && + item.type === "Scene Service" && + item.typeKeywords && + item.typeKeywords.includes("Hosted Service") + ) { + const token = await authentication.getToken(item.url); + const contentItem: ArcGisContent = new ArcGisContent(item, token); + contentItems.push(contentItem); + } + } + } + return contentItems; +}; diff --git a/src/utils/format/format-utils.spec.ts b/src/utils/format/format-utils.spec.ts index 935f9100..94d83dbb 100644 --- a/src/utils/format/format-utils.spec.ts +++ b/src/utils/format/format-utils.spec.ts @@ -1,4 +1,10 @@ -import { formatBoolean, formatFloatNumber, formatIntValue, formatStringValue } from "./format-utils"; +import { + formatBoolean, + formatFloatNumber, + formatIntValue, + formatStringValue, + formatTimestamp, +} from "./format-utils"; describe("Format Utils", () => { describe("formatStringValue", () => { @@ -54,4 +60,10 @@ describe("Format Utils", () => { expect(result1).toBe("No"); }); }); + + describe("formatTimestamp", () => { + it("Should return date formatted", () => { + expect(formatTimestamp(1704877308897)).toBe("January 10, 2024"); + }); + }); }); diff --git a/src/utils/format/format-utils.ts b/src/utils/format/format-utils.ts index cfeded16..d0a71f64 100644 --- a/src/utils/format/format-utils.ts +++ b/src/utils/format/format-utils.ts @@ -49,3 +49,17 @@ export const formatFloatNumber = (value: number): string => { export const formatBoolean = (value: boolean): string => { return value ? "Yes" : "No"; }; + +/** + * Formats a date according to "en-US" locale. + * @param timestamp - timestamp to convert to string. + * @returns date formatted. + */ +export const formatTimestamp = (timestamp: number): string => { + const formatter = new Intl.DateTimeFormat("en-US", { + month: "long", + day: "2-digit", + year: "numeric", + }); + return formatter.format(timestamp); +} diff --git a/src/utils/testing-utils/e2e-layers-panel.tsx b/src/utils/testing-utils/e2e-layers-panel.tsx index 20c1d604..0ea50430 100644 --- a/src/utils/testing-utils/e2e-layers-panel.tsx +++ b/src/utils/testing-utils/e2e-layers-panel.tsx @@ -110,7 +110,7 @@ export const inserAndDeleteLayer = async ( `${panelId} > :nth-child(4) > :first-child > :nth-child(2) > :first-child` ); await insertButton.click(); - let insertPanel = await page.$(`${panelId} > :nth-child(5)`); + let insertPanel = await page.$(`${panelId} > :nth-child(7)`); // Header const insertPanelHeaderText = await insertPanel.$eval( @@ -156,19 +156,19 @@ export const inserAndDeleteLayer = async ( `${panelId} form.insert-form button[type='submit']` ); await submitInsert.click(); - await page.waitForSelector(`${panelId} > :nth-child(5)`); - const warningPanel = await page.$(`${panelId} > :nth-child(5)`); + await page.waitForSelector(`${panelId} > :nth-child(7)`); + const warningPanel = await page.$(`${panelId} > :nth-child(7)`); const warningText = await warningPanel.$eval( `:first-child > :first-child`, (node) => node.innerText ); expect(warningText).toBe("You are trying to add an existing area to the map"); await expect(page).toClick("button", { text: "Ok" }); - let anyExtraPanel = await page.$(`${panelId} > :nth-child(5)`); + let anyExtraPanel = await page.$(`${panelId} > :nth-child(7)`); expect(anyExtraPanel).toBeNull(); await insertButton.click(); - insertPanel = await page.$(`${panelId} > :nth-child(5)`); + insertPanel = await page.$(`${panelId} > :nth-child(7)`); // Add layer await fillForm(page, `${panelId} form.insert-form`, { @@ -180,7 +180,7 @@ export const inserAndDeleteLayer = async ( `${panelId} form.insert-form button[type='submit']` ); await submitInsert.click(); - anyExtraPanel = await page.$(`${panelId} > :nth-child(5)`); + anyExtraPanel = await page.$(`${panelId} > :nth-child(7)`); expect(anyExtraPanel).toBeNull(); let layers = await page.$$(