Add global state modal (#1377)

## Launch Checklist

This adds the ability to edit the global state.
I think it deserves a modal of its own since I don't think it should be
part of other modals...
Here are some images:
<img width="1274" height="254" alt="image"
src="https://github.com/user-attachments/assets/4b6f2564-6c71-47da-9f8c-3bd2b97e1163"
/>

Initial dialog with no variable:
<img width="640" height="254" alt="image"
src="https://github.com/user-attachments/assets/b813b540-cae9-4c80-b2c0-4d965c022cb8"
/>
After you click add a few times:
<img width="640" height="254" alt="image"
src="https://github.com/user-attachments/assets/125cb978-90dc-4047-9694-b0ffc6eaa469"
/>

The state is updated as you change thing in the dialog.
I didn't complicated it to select the type of the variable, but this can
be added later of if there's a requirement to do so, I meant to keep it
simple for now.

 - [x] Briefly describe the changes in this PR.
- [x] Include before/after visuals or gifs if this PR includes visual
changes.
 - [x] Write tests for all new functionality.
 - [x] Add an entry to `CHANGELOG.md` under the `## main` section.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Harel M
2025-09-14 11:13:52 +03:00
committed by GitHub
parent a322afdcee
commit 69143ea5d6
14 changed files with 254 additions and 20 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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<any, AppState> {
shortcuts: false,
export: false,
debug: false,
globalState: false,
},
maplibreGlDebugOptions: {
showTileBoundaries: false,
@@ -213,6 +216,12 @@ export default class App extends React.Component<any, AppState> {
this.toggleModal("settings");
}
},
{
key: "g",
handler: () => {
this.toggleModal("globalState");
}
},
{
key: "i",
handler: () => {
@@ -911,39 +920,45 @@ export default class App extends React.Component<any, AppState> {
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}
/>
<ModalShortcuts
isOpen={this.state.isOpen.shortcuts}
onOpenToggle={this.toggleModal.bind(this, "shortcuts")}
onOpenToggle={() => this.toggleModal("shortcuts")}
/>
<ModalSettings
mapStyle={this.state.mapStyle}
onStyleChanged={this.onStyleChanged}
onChangeMetadataProperty={this.onChangeMetadataProperty}
isOpen={this.state.isOpen.settings}
onOpenToggle={this.toggleModal.bind(this, "settings")}
onOpenToggle={() => this.toggleModal("settings")}
/>
<ModalExport
mapStyle={this.state.mapStyle}
onStyleChanged={this.onStyleChanged}
isOpen={this.state.isOpen.export}
onOpenToggle={this.toggleModal.bind(this, "export")}
onOpenToggle={() => this.toggleModal("export")}
fileHandle={this.state.fileHandle}
onSetFileHandle={this.onSetFileHandle}
/>
<ModalOpen
isOpen={this.state.isOpen.open}
onStyleOpen={this.openStyle}
onOpenToggle={this.toggleModal.bind(this, "open")}
onOpenToggle={() => this.toggleModal("open")}
fileHandle={this.state.fileHandle}
/>
<ModalSources
mapStyle={this.state.mapStyle}
onStyleChanged={this.onStyleChanged}
isOpen={this.state.isOpen.sources}
onOpenToggle={this.toggleModal.bind(this, "sources")}
onOpenToggle={() => this.toggleModal("sources")}
/>
<ModalGlobalState
mapStyle={this.state.mapStyle}
onStyleChanged={this.onStyleChanged}
isOpen={this.state.isOpen.globalState}
onOpenToggle={() => this.toggleModal("globalState")}
/>
</div>;

View File

@@ -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<AppToolbarInternalProps> {
<MdSettings />
<IconText>{t("Style Settings")}</IconText>
</ToolbarAction>
<ToolbarAction wdKey="nav:global-state" onClick={this.props.onToggleModal.bind(this, "globalState")}>
<MdPublic />
<IconText>{t("Global State")}</IconText>
</ToolbarAction>
<ToolbarSelect wdKey="nav:inspect">
<MdFindInPage />

View File

@@ -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<DocProps> {
!Array.isArray(values)
);
const sdkSupportToJsx = (value: string) => {
const supportValue = value.toLowerCase();
if (supportValue.startsWith("https://")) {
return <a href={supportValue} target="_blank" rel="noreferrer">{"#" + supportValue.split("/").pop()}</a>;
}
return value;
};
return (
<>
{doc &&
@@ -74,7 +81,7 @@ export default class Doc extends React.Component<DocProps> {
<td>{key}</td>
{Object.keys(headers).map((k) => {
if (Object.prototype.hasOwnProperty.call(supportObj, k)) {
return <td key={k}>{supportObj[k as keyof typeof headers]}</td>;
return <td key={k}>{sdkSupportToJsx(supportObj[k as keyof typeof headers])}</td>;
}
else {
return <td key={k}>no</td>;

View File

@@ -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<ModalInternalProps> {
}
setTimeout(() => {
this.props.onOpenToggle(false);
this.props.onOpenToggle();
}, 0);
};

View File

@@ -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<string, SourceSpecification & {layers: string[]}>;
} & WithTranslation;
@@ -50,7 +50,7 @@ class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddSt
changedLayers.push(layer as LayerSpecification);
this.setState({ error: null }, () => {
this.props.onLayersChange(changedLayers);
this.props.onOpenToggle(false);
this.props.onOpenToggle();
});
};

View File

@@ -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: {

View File

@@ -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;

View File

@@ -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<ModalGlobalStateInternalProps> = (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<string, SchemaSpecification> = {};
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) => (
<tr key={index}>
<td>
<FieldString
label={props.t("Key")}
value={variable.key}
onChange={(value) => onChangeVariableKey(index, value || "")}
data-wd-key={"global-state-variable-key:" + index}
/>
</td>
<td>
<FieldString
label={props.t("Value")}
value={variable.value}
onChange={(value) => onChangeVariableValue(index, value || "")}
data-wd-key={"global-state-variable-value:" + index}
/>
</td>
<td style={{ verticalAlign: "middle"}}>
<InputButton
onClick={() => onRemoveVariable(index)}
title={props.t("Remove variable")}
data-wd-key="global-state-remove-variable"
>
<MdDelete />
</InputButton>
</td>
</tr>
));
return (
<Modal
data-wd-key="modal:global-state"
isOpen={props.isOpen}
onOpenToggle={props.onOpenToggle}
title={props.t("Global State Variables")}
>
{variables.length === 0 &&
<div>
<p>{props.t("No global state variables defined. Add variables to create reusable values in your style.")}</p>
<div key="doc" className="maputnik-doc-inline">
<Doc fieldSpec={latest.$root.state} />
</div>
</div>
}
{variables.length > 0 &&
<table>
<thead>
</thead>
<tbody>
{variableFields}
</tbody>
</table>
}
<div>
<InputButton
onClick={onAddVariable}
data-wd-key="global-state-add-variable"
>
<PiListPlusBold />
{props.t("Add Variable")}
</InputButton>
</div>
</Modal>
);
};
const ModalGlobalState = withTranslation()(ModalGlobalStateInternal);
export default ModalGlobalState;

View File

@@ -45,7 +45,7 @@ class PublicStyle extends React.Component<PublicStyleProps> {
type ModalOpenInternalProps = {
isOpen: boolean
onOpenToggle(...args: unknown[]): unknown
onOpenToggle(): void
onStyleOpen(...args: unknown[]): unknown
fileHandle: FileSystemFileHandle | null
} & WithTranslation;

View File

@@ -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<ModalSettingsInternalProps> {

View File

@@ -6,7 +6,7 @@ import Modal from "./Modal";
type ModalShortcutsInternalProps = {
isOpen: boolean
onOpenToggle(...args: unknown[]): unknown
onOpenToggle(): void
} & WithTranslation;

View File

@@ -273,7 +273,7 @@ class AddSource extends React.Component<AddSourceProps, AddSourceState> {
type ModalSourcesInternalProps = {
mapStyle: StyleSpecificationWithId
isOpen: boolean
onOpenToggle(...args: unknown[]): unknown
onOpenToggle(): void
onStyleChanged: OnStyleChangedCallback
} & WithTranslation;