diff --git a/CHANGELOG.md b/CHANGELOG.md index 198b46af..38ee059a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Add support for hillshade's color arrays and relief-color elevation expression - Change layers icons to make them a bit more distinct - Remove `@mdi` packages in favor of `react-icons` +- Added global state modal to allow editing the global state - _...Add new stuff here..._ ### 🐞 Bug fixes diff --git a/cypress/e2e/modals.cy.ts b/cypress/e2e/modals.cy.ts index 04c0cf9e..a80e85a2 100644 --- a/cypress/e2e/modals.cy.ts +++ b/cypress/e2e/modals.cy.ts @@ -304,6 +304,57 @@ describe("modals", () => { it("toggle"); }); + describe("global state", () => { + beforeEach(() => { + when.click("nav:global-state"); + }); + + it("add variable", () => { + when.click("global-state-add-variable"); + then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + state: { key1: { default: "value" } }, + }); + }); + + + it("add multiple variables", () => { + when.click("global-state-add-variable"); + when.click("global-state-add-variable"); + when.click("global-state-add-variable"); + then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + state: { key1: { default: "value" }, key2: { default: "value" }, key3: { default: "value" } }, + }); + }); + + it("remove variable", () => { + when.click("global-state-add-variable"); + when.click("global-state-add-variable"); + when.click("global-state-add-variable"); + when.click("global-state-remove-variable", 0); + then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + state: { key2: { default: "value" }, key3: { default: "value" } }, + }); + }); + + it("edit variable key", () => { + when.click("global-state-add-variable"); + when.setValue("global-state-variable-key:0", "mykey"); + when.typeKeys("{enter}"); + then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + state: { mykey: { default: "value" } }, + }); + }); + + it("edit variable value", () => { + when.click("global-state-add-variable"); + when.setValue("global-state-variable-value:0", "myvalue"); + when.typeKeys("{enter}"); + then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + state: { key1: { default: "myvalue" } }, + }); + }); + }); + describe("Handle localStorage QuotaExceededError", () => { it("handles quota exceeded error when opening style from URL", () => { // Clear localStorage to start fresh diff --git a/src/components/App.tsx b/src/components/App.tsx index 2447aed8..10d3b2d7 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -25,6 +25,7 @@ import ModalSources from "./modals/ModalSources"; import ModalOpen from "./modals/ModalOpen"; import ModalShortcuts from "./modals/ModalShortcuts"; import ModalDebug from "./modals/ModalDebug"; +import ModalGlobalState from "./modals/ModalGlobalState"; import {downloadGlyphsMetadata, downloadSpriteMetadata} from "../libs/metadata"; import style from "../libs/style"; @@ -126,6 +127,7 @@ type AppState = { shortcuts: boolean export: boolean debug: boolean + globalState: boolean } fileHandle: FileSystemFileHandle | null }; @@ -164,6 +166,7 @@ export default class App extends React.Component { shortcuts: false, export: false, debug: false, + globalState: false, }, maplibreGlDebugOptions: { showTileBoundaries: false, @@ -213,6 +216,12 @@ export default class App extends React.Component { this.toggleModal("settings"); } }, + { + key: "g", + handler: () => { + this.toggleModal("globalState"); + } + }, { key: "i", handler: () => { @@ -911,39 +920,45 @@ export default class App extends React.Component { onChangeMaplibreGlDebug={this.onChangeMaplibreGlDebug} onChangeOpenlayersDebug={this.onChangeOpenlayersDebug} isOpen={this.state.isOpen.debug} - onOpenToggle={this.toggleModal.bind(this, "debug")} + onOpenToggle={() => this.toggleModal("debug")} mapView={this.state.mapView} /> this.toggleModal("shortcuts")} /> this.toggleModal("settings")} /> this.toggleModal("export")} fileHandle={this.state.fileHandle} onSetFileHandle={this.onSetFileHandle} /> this.toggleModal("open")} fileHandle={this.state.fileHandle} /> this.toggleModal("sources")} + /> + this.toggleModal("globalState")} /> ; diff --git a/src/components/AppToolbar.tsx b/src/components/AppToolbar.tsx index d3359174..f034cbab 100644 --- a/src/components/AppToolbar.tsx +++ b/src/components/AppToolbar.tsx @@ -9,7 +9,8 @@ import { MdHelpOutline, MdFindInPage, MdLanguage, - MdSave + MdSave, + MdPublic } from "react-icons/md"; import pkgJson from "../../package.json"; //@ts-ignore @@ -236,6 +237,10 @@ class AppToolbarInternal extends React.Component { {t("Style Settings")} + + + {t("Global State")} + diff --git a/src/components/Doc.tsx b/src/components/Doc.tsx index acc84d98..c8387efd 100644 --- a/src/components/Doc.tsx +++ b/src/components/Doc.tsx @@ -3,8 +3,7 @@ import React from "react"; const headers = { js: "JS", android: "Android", - ios: "iOS", - macos: "macOS", + ios: "iOS" }; type DocProps = { @@ -37,6 +36,14 @@ export default class Doc extends React.Component { !Array.isArray(values) ); + const sdkSupportToJsx = (value: string) => { + const supportValue = value.toLowerCase(); + if (supportValue.startsWith("https://")) { + return {"#" + supportValue.split("/").pop()}; + } + return value; + }; + return ( <> {doc && @@ -74,7 +81,7 @@ export default class Doc extends React.Component { {key} {Object.keys(headers).map((k) => { if (Object.prototype.hasOwnProperty.call(supportObj, k)) { - return {supportObj[k as keyof typeof headers]}; + return {sdkSupportToJsx(supportObj[k as keyof typeof headers])}; } else { return no; diff --git a/src/components/modals/Modal.tsx b/src/components/modals/Modal.tsx index 9e2b9277..f66c9a9e 100644 --- a/src/components/modals/Modal.tsx +++ b/src/components/modals/Modal.tsx @@ -8,7 +8,7 @@ type ModalInternalProps = PropsWithChildren & { "data-wd-key"?: string isOpen: boolean title: string - onOpenToggle(value: boolean): unknown + onOpenToggle(): void underlayClickExits?: boolean className?: string } & WithTranslation; @@ -26,7 +26,7 @@ class ModalInternal extends React.Component { } setTimeout(() => { - this.props.onOpenToggle(false); + this.props.onOpenToggle(); }, 0); }; diff --git a/src/components/modals/ModalAdd.tsx b/src/components/modals/ModalAdd.tsx index bbf80305..7869bfc2 100644 --- a/src/components/modals/ModalAdd.tsx +++ b/src/components/modals/ModalAdd.tsx @@ -14,7 +14,7 @@ type ModalAddInternalProps = { layers: LayerSpecification[] onLayersChange(layers: LayerSpecification[]): unknown isOpen: boolean - onOpenToggle(open: boolean): unknown + onOpenToggle(): void // A dict of source id's and the available source layers sources: Record; } & WithTranslation; @@ -50,7 +50,7 @@ class ModalAddInternal extends React.Component { this.props.onLayersChange(changedLayers); - this.props.onOpenToggle(false); + this.props.onOpenToggle(); }); }; diff --git a/src/components/modals/ModalDebug.tsx b/src/components/modals/ModalDebug.tsx index 53db78f3..5f1314cf 100644 --- a/src/components/modals/ModalDebug.tsx +++ b/src/components/modals/ModalDebug.tsx @@ -9,7 +9,7 @@ type ModalDebugInternalProps = { renderer: string onChangeMaplibreGlDebug(key: string, checked: boolean): unknown onChangeOpenlayersDebug(key: string, checked: boolean): unknown - onOpenToggle(value: boolean): unknown + onOpenToggle(): void maplibreGlDebugOptions?: object openlayersDebugOptions?: object mapView: { diff --git a/src/components/modals/ModalExport.tsx b/src/components/modals/ModalExport.tsx index becb89e1..fa3e7a1c 100644 --- a/src/components/modals/ModalExport.tsx +++ b/src/components/modals/ModalExport.tsx @@ -22,7 +22,7 @@ type ModalExportInternalProps = { mapStyle: StyleSpecificationWithId onStyleChanged: OnStyleChangedCallback isOpen: boolean - onOpenToggle(...args: unknown[]): unknown + onOpenToggle(): void onSetFileHandle(fileHandle: FileSystemFileHandle | null): unknown fileHandle: FileSystemFileHandle | null } & WithTranslation; diff --git a/src/components/modals/ModalGlobalState.tsx b/src/components/modals/ModalGlobalState.tsx new file mode 100644 index 00000000..35d0db89 --- /dev/null +++ b/src/components/modals/ModalGlobalState.tsx @@ -0,0 +1,155 @@ +import React from "react"; +import { withTranslation, type WithTranslation } from "react-i18next"; +import { MdDelete } from "react-icons/md"; +import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json"; + +import Modal from "./Modal"; +import FieldString from "../FieldString"; +import InputButton from "../InputButton"; +import { PiListPlusBold } from "react-icons/pi"; +import { type StyleSpecificationWithId } from "../../libs/definitions"; +import { type SchemaSpecification } from "maplibre-gl"; +import Doc from "../Doc"; + +type ModalGlobalStateInternalProps = { + mapStyle: StyleSpecificationWithId; + isOpen: boolean; + onStyleChanged(style: StyleSpecificationWithId): void; + onOpenToggle(): void +} & WithTranslation; + +type GlobalStateVariable = { + key: string; + value: any; +}; + +const ModalGlobalStateInternal: React.FC = (props) => { + const getGlobalStateVariables = (): GlobalStateVariable[] => { + const style = props.mapStyle; + const globalState = style.state || {}; + + return Object.entries(globalState).map(([key, value]) => ({ + key, + value: value.default + })); + }; + + const setGlobalStateVariables = (variables: GlobalStateVariable[]) => { + const style = { ...props.mapStyle }; + + // Create the globalState object from the variables array + const globalState: Record = {}; + for (const variable of variables) { + if (variable.key.trim() !== "") { + globalState[variable.key] = { + default: variable.value + }; + } + } + + style.state = Object.keys(globalState).length > 0 ? globalState : undefined; + + props.onStyleChanged(style); + }; + + const onAddVariable = () => { + const variables = getGlobalStateVariables(); + let index = 1; + while (variables.find(v => v.key === `key${index}`)) { + index++; + } + variables.push({ key: `key${index}`, value: "value" }); + setGlobalStateVariables(variables); + }; + + const onRemoveVariable = (index: number) => { + const variables = getGlobalStateVariables(); + variables.splice(index, 1); + setGlobalStateVariables(variables); + }; + + const onChangeVariableKey = (index: number, newKey: string) => { + const variables = getGlobalStateVariables(); + variables[index].key = newKey || ""; + setGlobalStateVariables(variables); + }; + + const onChangeVariableValue = (index: number, newValue: string) => { + const variables = getGlobalStateVariables(); + + variables[index].value = newValue || ""; + setGlobalStateVariables(variables); + }; + + const variables = getGlobalStateVariables(); + + const variableFields = variables.map((variable, index) => ( + + + onChangeVariableKey(index, value || "")} + data-wd-key={"global-state-variable-key:" + index} + /> + + + onChangeVariableValue(index, value || "")} + data-wd-key={"global-state-variable-value:" + index} + /> + + + onRemoveVariable(index)} + title={props.t("Remove variable")} + data-wd-key="global-state-remove-variable" + > + + + + + )); + + return ( + + + {variables.length === 0 && +
+

{props.t("No global state variables defined. Add variables to create reusable values in your style.")}

+
+ +
+
+ } + {variables.length > 0 && + + + + + {variableFields} + +
+ } +
+ + + {props.t("Add Variable")} + +
+
+ ); +}; + +const ModalGlobalState = withTranslation()(ModalGlobalStateInternal); +export default ModalGlobalState; diff --git a/src/components/modals/ModalOpen.tsx b/src/components/modals/ModalOpen.tsx index f7e7cc4c..e8f07d00 100644 --- a/src/components/modals/ModalOpen.tsx +++ b/src/components/modals/ModalOpen.tsx @@ -45,7 +45,7 @@ class PublicStyle extends React.Component { type ModalOpenInternalProps = { isOpen: boolean - onOpenToggle(...args: unknown[]): unknown + onOpenToggle(): void onStyleOpen(...args: unknown[]): unknown fileHandle: FileSystemFileHandle | null } & WithTranslation; diff --git a/src/components/modals/ModalSettings.tsx b/src/components/modals/ModalSettings.tsx index cab6ec81..43717332 100644 --- a/src/components/modals/ModalSettings.tsx +++ b/src/components/modals/ModalSettings.tsx @@ -19,7 +19,7 @@ type ModalSettingsInternalProps = { onStyleChanged: OnStyleChangedCallback onChangeMetadataProperty(...args: unknown[]): unknown isOpen: boolean - onOpenToggle(...args: unknown[]): unknown + onOpenToggle(): void } & WithTranslation; class ModalSettingsInternal extends React.Component { diff --git a/src/components/modals/ModalShortcuts.tsx b/src/components/modals/ModalShortcuts.tsx index 16d3ab70..09f0ecd8 100644 --- a/src/components/modals/ModalShortcuts.tsx +++ b/src/components/modals/ModalShortcuts.tsx @@ -6,7 +6,7 @@ import Modal from "./Modal"; type ModalShortcutsInternalProps = { isOpen: boolean - onOpenToggle(...args: unknown[]): unknown + onOpenToggle(): void } & WithTranslation; diff --git a/src/components/modals/ModalSources.tsx b/src/components/modals/ModalSources.tsx index 91a1e5a5..83755382 100644 --- a/src/components/modals/ModalSources.tsx +++ b/src/components/modals/ModalSources.tsx @@ -273,7 +273,7 @@ class AddSource extends React.Component { type ModalSourcesInternalProps = { mapStyle: StyleSpecificationWithId isOpen: boolean - onOpenToggle(...args: unknown[]): unknown + onOpenToggle(): void onStyleChanged: OnStyleChangedCallback } & WithTranslation;