Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@
"colorbrewer": "^1.6.1",
"dompurify": "^3.4.0",
"geojson-vt": "^4.0.2",
"immutability-helper": "^3.1.1",
"jspdf": "^4.0.0",
"lodash.debounce": "^4.0.8",
"lodash.xor": "^4.5.0",
Expand Down
4 changes: 2 additions & 2 deletions src/reducers/modal.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ActionType } from '../constants/actions';
import { ViewerAction } from '../actions/defs';
import { DEFAULT_MODAL_POSITION, DEFAULT_MODAL_SIZE, IModalParameters, IModalReducerState } from '../api/common';
import update from "immutability-helper";
import { immutableUpdate } from "../utils/immutable";

function tryRestoreModalSizeAndPosition(modal: IModalParameters, prevModal?: Partial<Pick<IModalParameters, "size" | "position">>) {
if (prevModal?.position) {
Expand Down Expand Up @@ -43,7 +43,7 @@ export function modalReducer(state = MODAL_INITIAL_STATE, action: ViewerAction):
}
}
}
const newState = update(state, newData);
const newState = immutableUpdate(state, newData);
return newState;
}
case ActionType.MODAL_SHOW_URL:
Expand Down
4 changes: 2 additions & 2 deletions src/reducers/toolbar.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { IToolbarReducerState, IDOMElementMetrics } from "../api/common";
import update from 'immutability-helper';
import { immutableUpdate } from "../utils/immutable";
import { ActionType } from '../constants/actions';
import { ViewerAction } from '../actions/defs';
import { isElementState } from './template';
Expand Down Expand Up @@ -27,7 +27,7 @@ function mergeFlyoutState(flyoutId: string, state: any, flyoutPayload: any, flyo
}
}
}
const newState = update(state, updateSpec);
const newState = immutableUpdate(state, updateSpec);
return newState;
}

Expand Down
96 changes: 96 additions & 0 deletions src/utils/immutable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Checks if a value is a plain object (not null, array, or primitive).
*
* @hidden
* @since 0.15
*/
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/**
* Checks if a spec object contains a recognized command key ($merge or $set).
*
* @hidden
* @since 0.15
*/
function isCommandSpec(value: Record<string, unknown>): boolean {
return "$merge" in value || "$set" in value;
}

/**
* Applies an immutable update to state given a command spec, supporting only the
* {@code $merge} and {@code $set} commands (the only ones used by this codebase).
*
* @param state - The source state (never mutated)
* @param spec - The update specification
* @returns A new state object with the spec applied
*
* @example
* ```ts
* // $merge: shallow merge into an existing object
* const s1 = immutableUpdate({ a: 1, b: 2 }, { "$merge": { b: 3, c: 4 } });
* // s1 → { a: 1, b: 3, c: 4 }
*
* // $set: replace entirely
* const s2 = immutableUpdate({ a: 1 }, { "$set": { x: 10 } });
* // s2 → { x: 10 }
* ```
*
* @hidden
* @since 0.15
*/
export function immutableUpdate(state: any, spec: any): any {
// If spec is not a plain object, return as-is (defensive)
if (!isPlainObject(spec)) {
return state;
}

// If spec itself is a command, apply it directly
if (isCommandSpec(spec as Record<string, unknown>)) {
if ("$set" in (spec as Record<string, unknown>)) {
return (spec as Record<string, unknown>).$set;
}
if ("$merge" in (spec as Record<string, unknown>)) {
const mergeVal = (spec as Record<string, unknown>).$merge;
if (isPlainObject(state)) {
return { ...state, ...(mergeVal as Record<string, unknown>) };
}
return mergeVal;
}
return state;
}

// Otherwise, walk each key in the spec and recurse
const keys = Object.keys(spec);
if (keys.length === 0) {
return state;
}

let result = state;
for (const key of keys) {
const subSpec = spec[key];
if (isPlainObject(subSpec) && isCommandSpec(subSpec as Record<string, unknown>)) {
// Apply command to result[key]
const prevValue = result != null ? result[key] : undefined;
result = {
...(result ?? {}),
[key]: immutableUpdate(prevValue, subSpec)
};
} else if (isPlainObject(subSpec)) {
// Nested keys — recurse into result[key]
const prevValue = result != null ? result[key] : undefined;
result = {
...(result ?? {}),
[key]: immutableUpdate(prevValue, subSpec)
};
} else {
// Scalar value — set directly
result = {
...(result ?? {}),
[key]: subSpec
};
}
}
return result;
}
22 changes: 22 additions & 0 deletions test/reducers/toolbar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,28 @@ describe("reducers/mouse", () => {
const newState = toolbarReducer(initialState.toolbar, action);
expect(newState).toBe(initialState.toolbar);
});
it("closes other flyouts when opening a new one", () => {
const initialState = createInitialState();
const stateWithFlyouts = {
...initialState.toolbar,
flyouts: {
"flyoutA": { open: true, metrics: { posX: 0, posY: 0, width: 100, height: 50 } },
"flyoutB": { open: false, metrics: null }
}
};
const action: any = {
type: ActionType.FLYOUT_OPEN,
payload: {
flyoutId: "flyoutB",
metrics: { posX: 50, posY: 25, width: 80, height: 60 }
}
};
const newState = toolbarReducer(stateWithFlyouts as any, action);
// Target flyout should be open
expect((newState.flyouts["flyoutB"] as any).open).toBe(true);
// Other flyout should be closed
expect((newState.flyouts["flyoutA"] as any).open).toBe(false);
});
});

describe(ActionType.FLYOUT_CLOSE, () => {
Expand Down
190 changes: 190 additions & 0 deletions test/utils/immutable.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { describe, it, expect } from "vitest";
import { immutableUpdate } from "../../src/utils/immutable";

describe("utils/immutable", () => {
describe("immutableUpdate", () => {
it("returns state unchanged for empty spec", () => {
const state = { a: 1, b: 2 };
const result = immutableUpdate(state, {});
expect(result).toEqual(state);
});

describe("$merge", () => {
it("merges new properties into an object at top level", () => {
const state = { a: 1, b: 2 };
const result = immutableUpdate(state, { "$merge": { b: 3, c: 4 } });
expect(result).toEqual({ a: 1, b: 3, c: 4 });
});

it("does not mutate the original object", () => {
const state = { a: 1, b: 2 };
immutableUpdate(state, { "$merge": { b: 3 } });
expect(state).toEqual({ a: 1, b: 2 });
});

it("merges nested one level deep", () => {
const state = {
flyouts: {
"flyoutA": { open: true, metrics: { posX: 0, posY: 0 } }
}
};
const spec = {
flyouts: {
"flyoutA": {
"$merge": { open: false, metrics: null }
}
}
};
const result = immutableUpdate(state, spec);
expect(result).toEqual({
flyouts: {
"flyoutA": { open: false, metrics: null }
}
});
});

it("handles $merge on nested key that did not previously exist", () => {
const state: any = {};
const spec = {
flyouts: {
"flyoutB": {
"$merge": { open: true }
}
}
};
const result = immutableUpdate(state, spec);
expect(result).toEqual({
flyouts: {
"flyoutB": { open: true }
}
});
});
});

describe("$set", () => {
it("replaces the value entirely at top level", () => {
const state = { a: 1, b: 2 };
const result = immutableUpdate(state, { "$set": { x: 10, y: 20 } });
expect(result).toEqual({ x: 10, y: 20 });
});

it("does not mutate the original object", () => {
const state = { a: 1 };
immutableUpdate(state, { "$set": { b: 2 } });
expect(state).toEqual({ a: 1 });
});

it("replaces nested value entirely", () => {
const state = {
flyouts: {
"flyoutA": { open: true }
}
};
const spec = {
flyouts: {
"flyoutA": {
"$set": { open: false, metrics: null, componentName: "X" }
}
}
};
const result = immutableUpdate(state, spec);
expect(result).toEqual({
flyouts: {
"flyoutA": { open: false, metrics: null, componentName: "X" }
}
});
});

it("creates the key if it does not exist", () => {
const state: any = {};
const spec = {
flyouts: {
"newFlyout": {
"$set": { open: true, componentName: "Hello" }
}
}
};
const result = immutableUpdate(state, spec);
expect(result).toEqual({
flyouts: {
"newFlyout": { open: true, componentName: "Hello" }
}
});
});
});

describe("combined spec (multiple keys)", () => {
it("applies multiple commands to different keys at once", () => {
const state = {
flyouts: {
"flyoutA": { open: true, metrics: { posX: 0, posY: 0 } },
"flyoutB": { open: false, metrics: null }
}
};
const spec = {
flyouts: {
"flyoutA": {
"$merge": { open: false }
},
"flyoutB": {
"$merge": { open: true, metrics: { posX: 50, posY: 25 } }
}
}
};
const result = immutableUpdate(state, spec);
expect(result).toEqual({
flyouts: {
"flyoutA": { open: false, metrics: { posX: 0, posY: 0 } },
"flyoutB": { open: true, metrics: { posX: 50, posY: 25 } }
}
});
});

it("does not mutate original when applying multiple commands", () => {
const state = {
flyouts: {
"flyoutA": { open: true },
"flyoutB": { open: false }
}
};
immutableUpdate(state, {
flyouts: {
"flyoutA": { "$merge": { open: false } },
"flyoutB": { "$merge": { open: true } }
}
});
expect(state).toEqual({
flyouts: {
"flyoutA": { open: true },
"flyoutB": { open: false }
}
});
});
});

describe("modal use-case ($merge on modal sub-key)", () => {
it("applies $merge to a nested modal property", () => {
const state = {
"Google": {
modal: { title: "Google", backdrop: true, size: [400, 500], position: [100, 100] },
name: "Google"
}
};
const spec = {
"Google": {
modal: {
"$merge": { size: [800, 600], position: [120, 140] }
}
}
};
const result = immutableUpdate(state, spec);
expect(result).toEqual({
"Google": {
modal: { title: "Google", backdrop: true, size: [800, 600], position: [120, 140] },
name: "Google"
}
});
});
});
});
});
8 changes: 0 additions & 8 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8159,13 +8159,6 @@ __metadata:
languageName: node
linkType: hard

"immutability-helper@npm:^3.1.1":
version: 3.1.1
resolution: "immutability-helper@npm:3.1.1"
checksum: 10c0/daf4f3a696b8735c5d2c9b1bac42908b66bfc18ea5484bccf6658f3e622e1486663b5ef781e1a407ee81183e16942e8b2596cc859ea94d522ba07731c2845f0e
languageName: node
linkType: hard

"import-fresh@npm:^3.2.1":
version: 3.3.1
resolution: "import-fresh@npm:3.3.1"
Expand Down Expand Up @@ -10222,7 +10215,6 @@ __metadata:
docsify-cli: "npm:4.4.4"
dompurify: "npm:^3.4.0"
geojson-vt: "npm:^4.0.2"
immutability-helper: "npm:^3.1.1"
jest-image-snapshot: "npm:6.5.2"
jsdom: "npm:^26.1.0"
jsonfile: "npm:6.2.1"
Expand Down
Loading