diff --git a/CHANGES.md b/CHANGES.md index 24e8d7eb6e3..2f23860e107 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,7 @@ - Add `token` to `ArcGisMapServerCatalogItem`, `ArcGisMapServerCatalogGroup`, `ArcGisFeatureServerCatalogItem`, `ArcGisFeatureServerCatalogGroup`, `ArcGisImageServerCatalogItem`, `I3SCatalogItem` and `ArcGisCatalogGroup` - if defined, it will be added to the `token` parameter for all ArcGIS Rest API requests. - Added `tokenUrl` to `ArcGisImageServerCatalogItem`, and tweaked behaviour in `ArcGisMapServerCatalogItem` and `ArcGisImageServerCatalogItem` so that if both `token` and `tokenUrl` are defined, then `tokenUrl` will be used. This allows the token to be refreshed if needed. - WMTS read URL from operations metadata #7371 +- Add `workbenchControlFlags` trait to all catalog members for enabling or disabling workbench controls. - Add `` custom component to open Map settings panel from template code (like short report, feature info etc). - Add UI to show toast messages. - [The next improvement] diff --git a/lib/ReactViews/Map/MapNavigation/Items/MyLocation.ts b/lib/ReactViews/Map/MapNavigation/Items/MyLocation.ts index fcf12d0cd81..e1228f47444 100644 --- a/lib/ReactViews/Map/MapNavigation/Items/MyLocation.ts +++ b/lib/ReactViews/Map/MapNavigation/Items/MyLocation.ts @@ -122,7 +122,7 @@ export class MyLocation extends MapNavigationItemController { coordinates: [longitude, latitude] }, properties: { - title: t("location.location"), + title: t("location.location"), longitude: longitude, latitude: latitude } diff --git a/lib/ReactViews/Workbench/Controls/ViewingControls.tsx b/lib/ReactViews/Workbench/Controls/ViewingControls.tsx index 4b0db877b79..36f21d41c8a 100644 --- a/lib/ReactViews/Workbench/Controls/ViewingControls.tsx +++ b/lib/ReactViews/Workbench/Controls/ViewingControls.tsx @@ -4,19 +4,19 @@ import { observer } from "mobx-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; +import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; import createGuid from "terriajs-cesium/Source/Core/createGuid"; import defined from "terriajs-cesium/Source/Core/defined"; -import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; import SplitDirection from "terriajs-cesium/Source/Scene/SplitDirection"; import { Category, DataSourceAction } from "../../../Core/AnalyticEvents/analyticEvents"; +import TerriaError from "../../../Core/TerriaError"; import filterOutUndefined from "../../../Core/filterOutUndefined"; import getDereferencedIfExists from "../../../Core/getDereferencedIfExists"; import getPath from "../../../Core/getPath"; import isDefined from "../../../Core/isDefined"; -import TerriaError from "../../../Core/TerriaError"; import CatalogMemberMixin, { getName } from "../../../ModelMixins/CatalogMemberMixin"; @@ -26,13 +26,13 @@ import MappableMixin from "../../../ModelMixins/MappableMixin"; import SearchableItemMixin from "../../../ModelMixins/SearchableItemMixin"; import TimeVarying from "../../../ModelMixins/TimeVarying"; import CameraView from "../../../Models/CameraView"; -import addUserCatalogMember from "../../../Models/Catalog/addUserCatalogMember"; import SplitItemReference from "../../../Models/Catalog/CatalogReferences/SplitItemReference"; +import addUserCatalogMember from "../../../Models/Catalog/addUserCatalogMember"; import CommonStrata from "../../../Models/Definition/CommonStrata"; -import hasTraits from "../../../Models/Definition/hasTraits"; import Model, { BaseModel } from "../../../Models/Definition/Model"; -import getAncestors from "../../../Models/getAncestors"; +import hasTraits from "../../../Models/Definition/hasTraits"; import { ViewingControl } from "../../../Models/ViewingControls"; +import getAncestors from "../../../Models/getAncestors"; import ViewState from "../../../ReactViewModels/ViewState"; import AnimatedSpinnerIcon from "../../../Styled/AnimatedSpinnerIcon"; import Box from "../../../Styled/Box"; @@ -44,6 +44,11 @@ import SplitterTraits from "../../../Traits/TraitsClasses/SplitterTraits"; import { exportData } from "../../Preview/ExportData"; import LazyItemSearchTool from "../../Tools/ItemSearchTool/LazyItemSearchTool"; import WorkbenchButton from "../WorkbenchButton"; +import { + WorkbenchControls, + enableAllControls, + isControlEnabled +} from "./WorkbenchControls"; const BoxViewingControl = styled(Box).attrs({ centered: true, @@ -91,10 +96,11 @@ const ViewingControlMenuButton = styled(RawButton).attrs({ interface PropsType { viewState: ViewState; item: BaseModel; + controls?: WorkbenchControls; } const ViewingControls: React.FC = observer((props) => { - const { viewState, item } = props; + const { viewState, item, controls = enableAllControls } = props; const { t } = useTranslation(); const [isMenuOpen, setIsOpen] = useState(false); const [isMapZoomingToCatalogItem, setIsMapZoomingToCatalogItem] = @@ -328,11 +334,15 @@ const ViewingControls: React.FC = observer((props) => { return sortBy( uniqBy([...itemViewingControls, ...globalViewingControls], "id"), "name" - ); - }, [item, viewState.globalViewingControlOptions]); + ).filter(({ id }) => { + // Exclude disabled controls + return isControlEnabled(controls, id); + }); + }, [item, controls, viewState.globalViewingControlOptions]); const renderViewingControlsMenu = () => { const canSplit = + controls.compare && !item.terria.configParameters.disableSplitter && hasTraits(item, SplitterTraits, "splitDirection") && hasTraits(item, SplitterTraits, "disableSplitter") && @@ -376,7 +386,8 @@ const ViewingControls: React.FC = observer((props) => { ) : null} - {viewState.useSmallScreenInterface === false && + {controls.difference && + viewState.useSmallScreenInterface === false && DiffableMixin.isMixedInto(item) && !item.isShowingDiff && item.canDiffImages ? ( @@ -392,7 +403,8 @@ const ViewingControls: React.FC = observer((props) => { ) : null} - {viewState.useSmallScreenInterface === false && + {controls.exportData && + viewState.useSmallScreenInterface === false && ExportableMixin.isMixedInto(item) && item.canExportData ? (
  • @@ -407,7 +419,8 @@ const ViewingControls: React.FC = observer((props) => {
  • ) : null} - {viewState.useSmallScreenInterface === false && + {controls.search && + viewState.useSmallScreenInterface === false && SearchableItemMixin.isMixedInto(item) && item.canSearch ? (
  • @@ -464,6 +477,7 @@ const ViewingControls: React.FC = observer((props) => { onClick={zoomTo} title={t("workbench.zoomToTitle")} disabled={ + !controls.idealZoom || // disabled if the item cannot be zoomed to or if a zoom is already in progress (MappableMixin.isMixedInto(item) && item.disableZoomTo) || isMapZoomingToCatalogItem === true @@ -483,7 +497,8 @@ const ViewingControls: React.FC = observer((props) => { title={t("workbench.previewItemTitle")} iconElement={() => } disabled={ - CatalogMemberMixin.isMixedInto(item) && item.disableAboutData + !controls.aboutData || + (CatalogMemberMixin.isMixedInto(item) && item.disableAboutData) } > {t("workbench.previewItem")} diff --git a/lib/ReactViews/Workbench/Controls/WorkbenchControls.ts b/lib/ReactViews/Workbench/Controls/WorkbenchControls.ts new file mode 100644 index 00000000000..6c60a3f44d0 --- /dev/null +++ b/lib/ReactViews/Workbench/Controls/WorkbenchControls.ts @@ -0,0 +1,87 @@ +/** + * Static and dynamic flags for enabling/disabling controls in the Workbench + */ +export type WorkbenchControls = { + // When true, disable all controls by default. You can then selectively + // enable/disable flags individually to override the default. + disableAll: boolean; + + compare: boolean; // Flag for compare tool also known as splitter + difference: boolean; // Flag for difference tool + idealZoom: boolean; + aboutData: boolean; + exportData: boolean; + search: boolean; // Flag for item search tool + opacity: boolean; + scaleWorkbench: boolean; + timer: boolean; + chartItems: boolean; + filter: boolean; + dateTime: boolean; + timeFilter: boolean; + selectableDimensions: boolean; + colorScaleRange: boolean; + shortReport: boolean; + legend: boolean; + + [dynamicControl: string]: boolean | undefined; +}; + +export const enableAllControls: WorkbenchControls = { + disableAll: false, + + compare: true, // Flag for compare tool also known as splitter + difference: true, // Flag for difference tool + idealZoom: true, + aboutData: true, + exportData: true, + search: true, // Flag for item search tool + opacity: true, + scaleWorkbench: true, + timer: true, + chartItems: true, + filter: true, + dateTime: true, + timeFilter: true, + selectableDimensions: true, + colorScaleRange: true, + shortReport: true, + legend: true +}; + +export const disableAllControls: WorkbenchControls = { + disableAll: true, + + compare: false, // Flag for compare tool also known as splitter + difference: false, // Flag for difference tool + idealZoom: false, + aboutData: false, + exportData: false, + search: false, // Flag for item search tool + opacity: false, + scaleWorkbench: false, + timer: false, + chartItems: false, + filter: false, + dateTime: false, + timeFilter: false, + selectableDimensions: false, + colorScaleRange: false, + shortReport: false, + legend: false +}; + +/** + * Check if a control is enabled in the given controls object + * + * @param controls WorkbenchControls object + * @param controlName Either one of the static keys defined by @type {WorkbenchControls} or the id of a dynamic control, eg: "table-styling" + */ +export function isControlEnabled( + controls: WorkbenchControls, + controlName: string +): boolean { + return controlName in controls + ? !!controls[controlName] + : !controls.disableAll; +} diff --git a/lib/ReactViews/Workbench/Controls/WorkbenchItemControls.tsx b/lib/ReactViews/Workbench/Controls/WorkbenchItemControls.tsx index ee7c5423a4b..f7a1eaff54a 100644 --- a/lib/ReactViews/Workbench/Controls/WorkbenchItemControls.tsx +++ b/lib/ReactViews/Workbench/Controls/WorkbenchItemControls.tsx @@ -1,10 +1,11 @@ import { observer } from "mobx-react"; import { FC } from "react"; +import { isJsonObject } from "../../../Core/Json"; import TerriaError from "../../../Core/TerriaError"; -import { Complete } from "../../../Core/TypeModifiers"; +import CatalogMemberMixin from "../../../ModelMixins/CatalogMemberMixin"; import DiscretelyTimeVaryingMixin from "../../../ModelMixins/DiscretelyTimeVaryingMixin"; -import hasTraits from "../../../Models/Definition/hasTraits"; import { BaseModel } from "../../../Models/Definition/Model"; +import hasTraits from "../../../Models/Definition/hasTraits"; import { DEFAULT_PLACEMENT, SelectableDimension @@ -25,66 +26,45 @@ import DimensionSelectorSection from "./SelectableDimensionSection"; import ShortReport from "./ShortReport"; import TimerSection from "./TimerSection"; import ViewingControls from "./ViewingControls"; - -type WorkbenchControls = { - viewingControls?: boolean; - opacity?: boolean; - scaleWorkbench?: boolean; - splitter?: boolean; - timer?: boolean; - chartItems?: boolean; - filter?: boolean; - dateTime?: boolean; - timeFilter?: boolean; - selectableDimensions?: boolean; - colorScaleRange?: boolean; - shortReport?: boolean; - legend?: boolean; -}; +import { + WorkbenchControls, + disableAllControls, + enableAllControls +} from "./WorkbenchControls"; type WorkbenchItemControlsProps = { item: BaseModel; viewState: ViewState; - /** Flag to show each control - defaults to all true */ - controls?: WorkbenchControls; -}; + /** + * Disable viewing controls menua + */ + disableViewingControlsMenu?: boolean; -export const defaultControls: Complete = { - viewingControls: true, - opacity: true, - scaleWorkbench: true, - splitter: true, - timer: true, - chartItems: true, - filter: true, - dateTime: true, - timeFilter: true, - selectableDimensions: true, - colorScaleRange: true, - shortReport: true, - legend: true -}; - -export const hideAllControls: Complete = { - viewingControls: false, - opacity: false, - scaleWorkbench: false, - splitter: false, - timer: false, - chartItems: false, - filter: false, - dateTime: false, - timeFilter: false, - selectableDimensions: false, - colorScaleRange: false, - shortReport: false, - legend: false + /** + * Flags to show/hide controls, disableAll=true will disable all controls by default + */ + controls?: Partial; }; const WorkbenchItemControls: FC = observer( - ({ item, viewState, controls: controlsWithoutDefaults }) => { - // Apply controls from props on top of defaultControls - const controls = { ...defaultControls, ...controlsWithoutDefaults }; + ({ + item, + viewState, + controls: propsControls = {}, + disableViewingControlsMenu + }) => { + const itemControls = + CatalogMemberMixin.isMixedInto(item) && + isJsonObject(item.workbenchControlFlags) + ? (item.workbenchControlFlags as Partial) + : undefined; + + // disable/enable all controls, props controls overrides item controls + const disableAll = !!(propsControls.disableAll ?? itemControls?.disableAll); + const controls = disableAll + ? { ...disableAllControls, ...itemControls, ...propsControls, disableAll } + : { ...enableAllControls, ...itemControls, ...propsControls, disableAll }; + const { generatedControls, error } = generateControls(viewState, item); if (error) { @@ -93,22 +73,26 @@ const WorkbenchItemControls: FC = observer( return ( <> - {controls?.viewingControls ? ( - - ) : null} - {controls?.opacity ? : null} - {controls?.scaleWorkbench ? : null} - {controls?.timer ? : null} - {controls?.splitter ? : null} - {controls?.chartItems ? : null} - {controls?.filter ? : null} - {controls?.dateTime && DiscretelyTimeVaryingMixin.isMixedInto(item) ? ( + {disableViewingControlsMenu ? null : ( + + )} + {controls.opacity ? : null} + {controls.scaleWorkbench ? : null} + {controls.timer ? : null} + {controls.compare ? : null} + {controls.chartItems ? : null} + {controls.filter ? : null} + {controls.dateTime && DiscretelyTimeVaryingMixin.isMixedInto(item) ? ( ) : null} - {controls?.timeFilter ? ( + {controls.timeFilter ? ( ) : null} - {controls?.selectableDimensions ? ( + {controls.selectableDimensions ? ( ) : null} { @@ -120,7 +104,7 @@ const WorkbenchItemControls: FC = observer( } {/* TODO: remove min max props and move the checks to ColorScaleRangeSection to keep this component simple. */} - {controls?.colorScaleRange && + {controls.colorScaleRange && hasTraits( item, WebMapServiceCatalogItemTraits, @@ -137,9 +121,9 @@ const WorkbenchItemControls: FC = observer( maxValue={item.colorScaleMaximum} /> )} - {controls?.shortReport ? : null} - {controls?.legend ? : null} - {controls?.selectableDimensions ? ( + {controls.shortReport ? : null} + {controls.legend ? : null} + {controls.selectableDimensions ? ( ) : null} { diff --git a/lib/ReactViews/Workflow/SelectableDimensionWorkflow.tsx b/lib/ReactViews/Workflow/SelectableDimensionWorkflow.tsx index 8d6460641bf..0ac416d546b 100644 --- a/lib/ReactViews/Workflow/SelectableDimensionWorkflow.tsx +++ b/lib/ReactViews/Workflow/SelectableDimensionWorkflow.tsx @@ -4,11 +4,9 @@ import { FC } from "react"; import { useTranslation } from "react-i18next"; import { getName } from "../../ModelMixins/CatalogMemberMixin"; import { filterSelectableDimensions } from "../../Models/SelectableDimensions/SelectableDimensions"; -import SelectableDimension from "../SelectableDimensions/SelectableDimension"; import { useViewState } from "../Context"; -import WorkbenchItemControls, { - hideAllControls -} from "../Workbench/Controls/WorkbenchItemControls"; +import SelectableDimension from "../SelectableDimensions/SelectableDimension"; +import WorkbenchItemControls from "../Workbench/Controls/WorkbenchItemControls"; import { Panel } from "./Panel"; import { PanelMenu } from "./PanelMenu"; import WorkflowPanel from "./WorkflowPanel"; @@ -45,8 +43,9 @@ const SelectableDimensionWorkflow: FC = observer(() => { | JsonObject; } /* eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging */ diff --git a/test/ReactViews/Workbench/Controls/WorkbenchItemControlsSpec.tsx b/test/ReactViews/Workbench/Controls/WorkbenchItemControlsSpec.tsx new file mode 100644 index 00000000000..28f735d7afa --- /dev/null +++ b/test/ReactViews/Workbench/Controls/WorkbenchItemControlsSpec.tsx @@ -0,0 +1,245 @@ +import { fireEvent, screen } from "@testing-library/dom"; +import GeoJsonCatalogItem from "../../../../lib/Models/Catalog/CatalogItems/GeoJsonCatalogItem"; +import WebMapServiceCatalogItem from "../../../../lib/Models/Catalog/Ows/WebMapServiceCatalogItem"; +import CommonStrata from "../../../../lib/Models/Definition/CommonStrata"; +import updateModelFromJson from "../../../../lib/Models/Definition/updateModelFromJson"; +import Terria from "../../../../lib/Models/Terria"; +import TableStylingWorkflow from "../../../../lib/Models/Workflows/TableStylingWorkflow"; +import ViewState from "../../../../lib/ReactViewModels/ViewState"; +import { WorkbenchControls } from "../../../../lib/ReactViews/Workbench/Controls/WorkbenchControls"; +import WorkbenchItemControls from "../../../../lib/ReactViews/Workbench/Controls/WorkbenchItemControls"; +import { renderWithContexts } from "../../withContext"; + +describe("WorkbenchItemControls", function () { + let viewState: ViewState; + let item: WebMapServiceCatalogItem; + + beforeEach(function () { + const terria = new Terria({ + baseUrl: "./" + }); + viewState = new ViewState({ + terria: terria, + catalogSearchProvider: undefined + }); + + item = new WebMapServiceCatalogItem("test-item", terria); + }); + + it("renders controls", function () { + renderWithContexts( + , + viewState + ); + + const aboutData = screen.queryByTitle("workbench.previewItemTitle"); + expect((aboutData as HTMLButtonElement).disabled).toBe(false); + + const opacity = screen.queryByText("workbench.opacity"); + expect(opacity).toBeVisible(); + }); + + describe("disableViewingControlsMenu", function () { + it("when not set, renders the viewing controls menu", function () { + renderWithContexts( + , + viewState + ); + + const opacity = screen.queryByText("workbench.opacity"); + expect(opacity).toBeVisible(); + }); + + it("when true, should not render the viewing controls menu", function () { + renderWithContexts( + , + viewState + ); + + // About data is part of viewing controls menu + const aboutData = screen.queryByText("workbench.previewItem"); + expect(aboutData).toBeNull(); + }); + }); + + describe("control flags", function () { + it("can be used to selectively turn off controls", function () { + renderWithContexts( + , + viewState + ); + + const compare = screen.queryByText("workbench.splitItemTitle"); + expect(compare).toBeNull(); + + const opacity = screen.queryByText("workbench.opacity"); + expect(opacity).toBeNull(); + }); + + it("can be used to disable a dynamic control like a selectable dimension workflow", function () { + const item = new GeoJsonCatalogItem( + "test-geojson-item", + viewState.terria + ); + + const { rerender } = renderWithContexts( + , + viewState + ); + + openViewingControlsMenu(); + + // Test that the edit style option is rendered + let editStyle = screen.queryByText("models.tableData.editStyle"); + expect(editStyle).toBeVisible(); + + rerender( + + ); + + openViewingControlsMenu(); + + // Test that the edit style option is not rendered + editStyle = screen.queryByText("models.tableData.editStyle"); + expect(editStyle).toBeNull("edit style menu option must not be rendered"); + }); + + describe("when disableAll is true", function () { + it("turns off all controls", function () { + renderWithContexts( + , + viewState + ); + + const aboutData = screen.queryByTitle("workbench.previewItemTitle"); + expect((aboutData as HTMLButtonElement).disabled).toBe(true); + + const compare = screen.queryByText("workbench.splitItemTitle"); + expect(compare).toBeNull(); + + const opacity = screen.queryByText("workbench.opacity"); + expect(opacity).toBeNull(); + }); + + it("shows Remove button in viewing controls menu so that the item can be removed from the workbench even when all other controls are disabled", async function () { + renderWithContexts( + , + viewState + ); + + openViewingControlsMenu(); + + // Test that the remove button is rendered + const remove = screen.queryByTitle("workbench.removeFromMapTitle"); + expect(remove).toBeVisible(); + }); + + it("can selectively enable some controls while the rest are disabled", function () { + renderWithContexts( + , + viewState + ); + + const aboutData = screen.queryByTitle("workbench.previewItemTitle"); + expect((aboutData as HTMLButtonElement).disabled).toBe(true); + + const compare = screen.queryByText("workbench.splitItemTitle"); + expect(compare).toBeNull(); + + const opacity = screen.queryByText("workbench.opacity"); + expect(opacity).toBeVisible(); + }); + }); + + describe("setting controls through item traits", function () { + it("control flags can be set using item traits", function () { + updateModelFromJson(item, CommonStrata.user, { + workbenchControlFlags: { + disableAll: true, + opacity: true + } + }); + + const controls = + item.workbenchControlFlags as any as Partial; + expect(controls.disableAll).toBe(true); + + renderWithContexts( + , + viewState + ); + + const aboutData = screen.queryByTitle("workbench.previewItemTitle"); + expect((aboutData as HTMLButtonElement).disabled).toBe(true); + + const opacity = screen.queryByText("workbench.opacity"); + expect(opacity).toBeVisible(); + }); + + it("controls set from item traits must not override props controls", function () { + updateModelFromJson(item, CommonStrata.user, { + workbenchControlFlags: { + disableAll: true, + opacity: true + } + }); + + renderWithContexts( + , + viewState + ); + + const aboutData = screen.queryByTitle("workbench.previewItemTitle"); + expect((aboutData as HTMLButtonElement).disabled).toBe(false); + + const opacity = screen.queryByText("workbench.opacity"); + expect(opacity).toBeNull(); + }); + }); + }); +}); + +function openViewingControlsMenu() { + // Find and open viewing controls menu + const menuOpenBtn = screen.queryByTitle("workbench.showMoreActionsTitle"); + expect(menuOpenBtn).toBeDefined("viewing controls menu button is defined"); + fireEvent.click(menuOpenBtn!); +}