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.$$(