From 9545c9c6e5ca0247f3c6db3f8d70c81e7c69469b Mon Sep 17 00:00:00 2001 From: Maxim Kuznetsov Date: Thu, 7 Dec 2023 15:36:27 +0300 Subject: [PATCH 1/2] feat: Insert layer name fetching --- .../insert-panel/insert-panel.spec.tsx | 51 +++++---- .../insert-panel/insert-panel.tsx | 90 ++++++++++++--- src/redux/slices/layer-names-slice.spec.ts | 105 ++++++++++++++++++ src/redux/slices/layer-names-slice.ts | 71 ++++++++++++ src/redux/store.ts | 2 + src/types.ts | 5 + src/utils/testing-utils/e2e-layers-panel.tsx | 5 + yarn.lock | 5 + 8 files changed, 298 insertions(+), 36 deletions(-) create mode 100644 src/redux/slices/layer-names-slice.spec.ts create mode 100644 src/redux/slices/layer-names-slice.ts diff --git a/src/components/layers-panel/insert-panel/insert-panel.spec.tsx b/src/components/layers-panel/insert-panel/insert-panel.spec.tsx index 27d2bec2..14ccdaf4 100644 --- a/src/components/layers-panel/insert-panel/insert-panel.spec.tsx +++ b/src/components/layers-panel/insert-panel/insert-panel.spec.tsx @@ -1,25 +1,31 @@ import { fireEvent, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { renderWithTheme } from "../../../utils/testing-utils/render-with-theme"; +import { renderWithThemeProviders } from "../../../utils/testing-utils/render-with-theme"; import { InsertPanel } from "./insert-panel"; +import { setupStore } from "../../../redux/store"; const onInsertMock = jest.fn(); const onCancelMock = jest.fn(); -const callRender = (renderFunc, props = {}) => { +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const callRender = (renderFunc, props = {}, store = setupStore()) => { return renderFunc( + />, + store ); }; describe("Insert panel", () => { it("Should render insert panel", () => { - const { container } = callRender(renderWithTheme); + const { container } = callRender(renderWithThemeProviders); expect(container).toBeInTheDocument(); expect(screen.getByText("Test Title")).toBeInTheDocument(); @@ -30,47 +36,47 @@ describe("Insert panel", () => { expect(screen.getByText("Insert")).toBeInTheDocument(); }); - it("Should show name error and url error if they are not provided", () => { - const { container } = callRender(renderWithTheme); + it("Should show name error and url error if they are not provided", async () => { + const { container } = callRender(renderWithThemeProviders); userEvent.click(screen.getByText("Insert")); - + await sleep(200); expect(container).toBeInTheDocument(); expect(screen.getByText("Please enter name")).toBeInTheDocument(); expect(screen.getByText("Invalid URL")).toBeInTheDocument(); }); - it("Should show only url error if Name field is filled in", () => { - callRender(renderWithTheme); + it("Should show only url error if Name field is filled in", async () => { + callRender(renderWithThemeProviders); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const nameInput = document.querySelector("input[name=Name]")!; - fireEvent.change(nameInput, { target: { value: 'test name' } }); + fireEvent.change(nameInput, { target: { value: "test name" } }); userEvent.click(screen.getByText("Insert")); - + await sleep(200); expect(screen.getByText("Invalid URL")).toBeInTheDocument(); expect(screen.queryByText("Please enter name")).toBeNull(); }); - it("Should show URL error if it is not valid", () => { - callRender(renderWithTheme); + it("Should show URL error if it is not valid", async () => { + callRender(renderWithThemeProviders); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const nameInput = document.querySelector("input[name=Name]")!; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const urlInput = document.querySelector("input[name=URL]")!; - fireEvent.change(nameInput, { target: { value: 'test name' } }); - fireEvent.change(urlInput, { target: { value: 'test url' } }); + fireEvent.change(nameInput, { target: { value: "test name" } }); + fireEvent.change(urlInput, { target: { value: "test url" } }); userEvent.click(screen.getByText("Insert")); - + await sleep(200); expect(screen.getByText("Invalid URL")).toBeInTheDocument(); expect(screen.queryByText("Please enter name")).toBeNull(); }); - it("Should insert if everything is good", () => { - callRender(renderWithTheme); + it("Should insert if everything is good", async () => { + callRender(renderWithThemeProviders); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const nameInput = document.querySelector("input[name=Name]")!; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -78,16 +84,17 @@ describe("Insert panel", () => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const tokenInput = document.querySelector("input[name=Token]")!; - fireEvent.change(nameInput, { target: { value: 'test name' } }); - fireEvent.change(urlInput, { target: { value: 'http://123.com' } }); - fireEvent.change(tokenInput, { target: { value: 'test token' } }); + fireEvent.change(nameInput, { target: { value: "test name" } }); + fireEvent.change(urlInput, { target: { value: "http://123.com" } }); + fireEvent.change(tokenInput, { target: { value: "test token" } }); userEvent.click(screen.getByText("Insert")); + await sleep(200); expect(onInsertMock).toBeCalled(); }); it("Should be able to cancel panel", () => { - callRender(renderWithTheme); + callRender(renderWithThemeProviders); userEvent.click(screen.getByText("Cancel")); expect(onCancelMock).toBeCalled(); diff --git a/src/components/layers-panel/insert-panel/insert-panel.tsx b/src/components/layers-panel/insert-panel/insert-panel.tsx index e7140711..b8dd4310 100644 --- a/src/components/layers-panel/insert-panel/insert-panel.tsx +++ b/src/components/layers-panel/insert-panel/insert-panel.tsx @@ -1,13 +1,24 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import styled from "styled-components"; -import { ActionButtonVariant } from "../../../types"; +import { + ActionButtonVariant, + FetchingStatus, + TilesetType, +} from "../../../types"; import { getCurrentLayoutProperty, useAppLayout, } from "../../../utils/hooks/layout"; import { ActionButton } from "../../action-button/action-button"; import { InputText } from "./input-text/input-text"; +import { getTilesetType } from "../../../utils/url-utils"; +import { LoadingSpinner } from "../../loading-spinner/loading-spinner"; +import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; +import { + getLayerNameInfo, + selectLayerNames, +} from "../../../redux/slices/layer-names-slice"; const NO_NAME_ERROR = "Please enter name"; const INVALID_URL_ERROR = "Invalid URL"; @@ -23,6 +34,10 @@ type LayoutProps = { layout: string; }; +type VisibilityProps = { + visible: boolean; +}; + const Container = styled.div` position: relative; display: flex; @@ -65,6 +80,16 @@ const ButtonsWrapper = styled.div` padding: 0 6px; `; +const SpinnerContainer = styled.div` + background: rgba(0, 0, 0, 0.3); + position: absolute; + left: calc(50% - 44px); + top: calc(50% - 44px); + padding: 22px; + border-radius: 8px; + visibility: ${({ visible }) => (visible ? "visible" : "hidden")}; +`; + export const InsertPanel = ({ title, onInsert, @@ -77,14 +102,13 @@ export const InsertPanel = ({ const [nameError, setNameError] = useState(""); const [urlError, setUrlError] = useState(""); + const [isValidateInProgress, setValidateInProgress] = useState(false); + const layerNames = useAppSelector(selectLayerNames); + const dispatch = useAppDispatch(); const validateFields = () => { let isFormValid = true; - - if (!name) { - setNameError(NO_NAME_ERROR); - isFormValid = false; - } + const type = getTilesetType(url); try { new URL(url); @@ -93,16 +117,43 @@ export const InsertPanel = ({ isFormValid = false; } - return isFormValid; - }; - - const handleInsert = (event) => { - const isFormValid = validateFields(); + if ( + (type !== TilesetType.I3S && !name) || + (type === TilesetType.I3S && !name && !layerNames[url]?.name) + ) { + setNameError(NO_NAME_ERROR); + isFormValid = false; + } if (isFormValid) { - onInsert({ name, url, token }); + onInsert({ name: name || layerNames[url]?.name, url, token }); + } + }; + + useEffect(() => { + const type = getTilesetType(url); + if (isValidateInProgress && type === TilesetType.I3S) { + if ( + (layerNames[url] !== undefined && + layerNames[url].status === FetchingStatus.ready) || + name.length > 0 + ) { + setValidateInProgress(false); + validateFields(); + } else if (!layerNames[url]) { + dispatch(getLayerNameInfo({ layerUrl: url, token, type })); + } } + }, [isValidateInProgress, layerNames]); + + const handleInsert = async (event) => { event.preventDefault(); + + if (getTilesetType(url) !== TilesetType.I3S) { + validateFields(); + } else { + setValidateInProgress(true); + } }; const handleInputChange = (event) => { @@ -122,11 +173,19 @@ export const InsertPanel = ({ } }; + const onCancelHandler = () => { + setValidateInProgress(false); + onCancel(); + }; + const layout = useAppLayout(); return ( {title} + + +
- + Cancel Insert diff --git a/src/redux/slices/layer-names-slice.spec.ts b/src/redux/slices/layer-names-slice.spec.ts new file mode 100644 index 00000000..32c3f929 --- /dev/null +++ b/src/redux/slices/layer-names-slice.spec.ts @@ -0,0 +1,105 @@ +import { fetchFile } from "@loaders.gl/core"; +import { FetchingStatus, TilesetType } from "../../types"; +import { setupStore } from "../store"; +import reducer, { + getLayerNameInfo, + selectLayerNames, +} from "./layer-names-slice"; + +jest.mock("@loaders.gl/core"); + +describe("slice: bsl-statistics-summary", () => { + it("Reducer should return the initial state", () => { + expect(reducer(undefined, { type: undefined })).toEqual({ + map: {}, + }); + }); + + it("getLayerNameInfo should put layer name and status to the state", async () => { + (fetchFile as unknown as jest.Mock).mockReturnValue( + new Promise((resolve) => { + resolve({ + text: async () => + JSON.stringify({ + name: "testName", + }), + }); + }) + ); + const store = setupStore(); + await store.dispatch( + getLayerNameInfo({ + layerUrl: "https://testUrl", + token: "testToken", + type: TilesetType.I3S, + }) + ); + const state = store.getState(); + expect(state.layerNames).toEqual({ + map: { + "https://testUrl": { + name: "testName", + status: FetchingStatus.ready, + }, + }, + }); + //test selector + expect(selectLayerNames(state)).toEqual({ + "https://testUrl": { + name: "testName", + status: FetchingStatus.ready, + }, + }); + }); + + it("getLayerNameInfo should put layer empty name and status to the state for no name", async () => { + (fetchFile as unknown as jest.Mock).mockReturnValue( + new Promise((resolve) => { + resolve({ + text: async () => + JSON.stringify({ + noname: "No Name in the layer", + }), + }); + }) + ); + const store = setupStore(); + await store.dispatch( + getLayerNameInfo({ + layerUrl: "https://testUrl", + token: "testToken", + type: TilesetType.I3S, + }) + ); + const state = store.getState(); + expect(state.layerNames).toEqual({ + map: { + "https://testUrl": { + name: "", + status: FetchingStatus.ready, + }, + }, + }); + }); + + it("getLayerNameInfo should put layer empty name and status ready in case of rejected fetching", async () => { + (fetchFile as unknown as jest.Mock).mockRejectedValue("Error"); + const store = setupStore(); + await store.dispatch( + getLayerNameInfo({ + layerUrl: "https://testUrl", + token: "testToken", + type: TilesetType.I3S, + }) + ); + const state = store.getState(); + expect(state.layerNames).toEqual({ + map: { + "https://testUrl": { + name: "", + status: FetchingStatus.ready, + }, + }, + }); + }); +}); diff --git a/src/redux/slices/layer-names-slice.ts b/src/redux/slices/layer-names-slice.ts new file mode 100644 index 00000000..db590d4c --- /dev/null +++ b/src/redux/slices/layer-names-slice.ts @@ -0,0 +1,71 @@ +import { fetchFile } from "@loaders.gl/core"; +import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; +import { RootState } from "../store"; +import { FetchingStatus, TilesetType } from "../../types"; +import { parseTilesetUrlParams } from "../../utils/url-utils"; + +// Define a type for the slice state +interface LayerNamesState { + /** Layers names map */ + map: Record; +} + +type LayerNameInfo = { + /** Layer name and fetching status */ + status: FetchingStatus; + name: string; +}; + +const initialState: LayerNamesState = { + map: {}, +}; + +const layerNamesSlice = createSlice({ + name: "layerNames", + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(getLayerNameInfo.pending, (state, action) => { + state.map[action.meta.arg.layerUrl] = { + name: "", + status: FetchingStatus.pending, + }; + }) + .addCase(getLayerNameInfo.fulfilled, (state, action) => { + state.map[action.payload.layerUrl] = { + name: action.payload.name, + status: FetchingStatus.ready, + }; + }) + .addCase(getLayerNameInfo.rejected, (state, action) => { + state.map[action.meta.arg.layerUrl] = { + name: "", + status: FetchingStatus.ready, + }; + }); + }, +}); + +export const getLayerNameInfo = createAsyncThunk< + { name: string; layerUrl: string }, + { layerUrl: string; type: TilesetType; token: string } +>("getLayerNameInfo", async ({ layerUrl, type, token }) => { + const params = parseTilesetUrlParams(layerUrl, { type, token }); + let url = params.tilesetUrl; + if (params.token) { + const urlObject = new URL(url); + urlObject.searchParams.append("token", params.token); + url = urlObject.href; + } + const dataResponse = await fetchFile(url); + const data = JSON.parse(await dataResponse.text()); + const name = data?.name || ""; + return { name, layerUrl }; +}); + +export const selectLayerNames = ( + state: RootState +): Record => state.layerNames.map; + +export default layerNamesSlice.reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts index 61230baa..714e4b78 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -10,6 +10,7 @@ import debugOptionsSliceReducer from "./slices/debug-options-slice"; import i3sStatsSliceReducer from "./slices/i3s-stats-slice"; import baseMapsSliceReducer from "./slices/base-maps-slice"; import symbolizationSliceReducer from "./slices/symbolization-slice"; +import layerNamesSliceReducer from "./slices/layer-names-slice"; // Create the root reducer separately so we can extract the RootState type const rootReducer = combineReducers({ @@ -20,6 +21,7 @@ const rootReducer = combineReducers({ baseMaps: baseMapsSliceReducer, symbolization: symbolizationSliceReducer, i3sStats: i3sStatsSliceReducer, + layerNames: layerNamesSliceReducer, }); export const setupStore = (preloadedState?: PreloadedState) => { diff --git a/src/types.ts b/src/types.ts index 1a44e9f8..efb4d85d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -367,3 +367,8 @@ export type TilesetMetadata = { hasChildren: boolean; type?: TilesetType; }; + +export enum FetchingStatus { + pending = "pending", + ready = "ready", +} diff --git a/src/utils/testing-utils/e2e-layers-panel.tsx b/src/utils/testing-utils/e2e-layers-panel.tsx index 3bece459..62571d0d 100644 --- a/src/utils/testing-utils/e2e-layers-panel.tsx +++ b/src/utils/testing-utils/e2e-layers-panel.tsx @@ -1,3 +1,7 @@ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export const checkLayersPanel = async ( page, panelId: string, @@ -124,6 +128,7 @@ export const inserAndDeleteLayer = async ( Name: "", }); await page.keyboard.press("Enter"); + await sleep(200); const nameWarning = await insertPanel.$eval( `${panelId} form.insert-form span`, (node) => node.innerText diff --git a/yarn.lock b/yarn.lock index b06ece1e..8e567479 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5163,6 +5163,11 @@ dot-case@^3.0.4: no-case "^3.0.4" tslib "^2.0.3" +dotenv@^16.3.1: + version "16.3.1" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" + integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== + draco3d@1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/draco3d/-/draco3d-1.4.1.tgz#2abdcf7b59caaac50f7e189aec454176c57146b2" From a3a84a97003ffc0750fd47e61d730e8a0faf0830 Mon Sep 17 00:00:00 2001 From: Maxim Kuznetsov Date: Mon, 11 Dec 2023 12:40:30 +0300 Subject: [PATCH 2/2] Update after review --- src/redux/slices/layer-names-slice.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/redux/slices/layer-names-slice.spec.ts b/src/redux/slices/layer-names-slice.spec.ts index 32c3f929..25b29ecd 100644 --- a/src/redux/slices/layer-names-slice.spec.ts +++ b/src/redux/slices/layer-names-slice.spec.ts @@ -8,7 +8,7 @@ import reducer, { jest.mock("@loaders.gl/core"); -describe("slice: bsl-statistics-summary", () => { +describe("slice: layer-names", () => { it("Reducer should return the initial state", () => { expect(reducer(undefined, { type: undefined })).toEqual({ map: {},