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
24 changes: 23 additions & 1 deletion entities/parameter/config/shapediverStoreParameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ export interface IHistoryEntry {
state: ISessionsHistoryState;
/** The time of the history entry (return value of Date.now()). */
time: number;
/**
* Whether the parameter values in this history entry have been changed
* since the last time a model state or parameter JSON file was created or
* imported. Used to drive the `beforeunload` protection prompt.
*/
unsavedChanges: boolean;
}

/**
Expand Down Expand Up @@ -517,8 +523,24 @@ export interface IShapeDiverStoreParameters {
* Push a state of parameter values to the history at the current index.
* In case the history index is not at the end of the history,
* all history entries after the current index are removed.
*
* @param state
* @param unsavedChanges Whether the new entry represents unsaved parameter
* changes. Defaults to `true` (parameter change). Pass `false` for the
* initial default state entry.
*/
readonly pushHistoryState: (
state: ISessionsHistoryState,
unsavedChanges?: boolean,
) => IHistoryEntry;

/**
* Clear the `unsavedChanges` flag of the current history entry and sync
* `window.history.state` when it matches that entry (by `time`).
* Called after creating or importing a model state, and after creating or
* importing a parameter JSON file.
*/
readonly pushHistoryState: (state: ISessionsHistoryState) => IHistoryEntry;
readonly clearUnsavedChanges: () => void;

/**
* Replace the transient derived namespace state used during history restore.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* @jest-environment jsdom
*/
import {act, renderHook} from "@testing-library/react";
import * as React from "react";
import {useParameterImportExport} from "../useParameterImportExport";

jest.mock("@mantine/core", () => {
const actual = jest.requireActual("@mantine/core");
return {
...actual,
useProps: (_name: string, _defaults: any, props: any) => props ?? {},
};
});

const notificationMock = {
success: jest.fn(),
error: jest.fn(),
warning: jest.fn(),
info: jest.fn(),
};
jest.mock(
"@AppBuilderLib/features/notifications/model/useNotificationStore",
() => ({
useNotificationStore: () => notificationMock,
}),
);

jest.mock("@AppBuilderLib/shared/lib/ErrorReportingContext", () => ({
ErrorReportingContext: React.createContext({captureException: jest.fn()}),
}));

jest.mock("@AppBuilderLib/shared/model/useShapeDiverStorePlatform", () => ({
useShapeDiverStorePlatform: (selector: any) =>
selector({currentModel: undefined}),
}));

const filterAndValidateParameters = jest.fn();
const generateParameterFeedback = jest.fn();
const isImportParameterArray = jest.fn();
jest.mock("@AppBuilderLib/entities/parameter/lib/parametersFilter", () => ({
filterAndValidateParameters: (...a: any[]) =>
filterAndValidateParameters(...a),
generateParameterFeedback: (...a: any[]) => generateParameterFeedback(...a),
isImportParameterArray: (...a: any[]) => isImportParameterArray(...a),
}));

jest.mock("@AppBuilderLib/entities/parameter/lib/parameterStates", () => ({
getParameterStates: () => [],
}));

jest.mock(
"@AppBuilderLib/entities/parameter/lib/resolveParameterExportValue",
() => ({
resolveParameterExportValue: () => "exported-value",
}),
);

// Real parameter store.
import {useShapeDiverStoreParameters} from "../useShapeDiverStoreParameters";

const store = useShapeDiverStoreParameters;

function seedUnsaved() {
store.getState().resetHistory();
store.getState().pushHistoryState({}, false);
store.getState().pushHistoryState({ns: {p: "changed"}});
}

function currentUnsaved() {
const {history, historyIndex} = store.getState();
return history[historyIndex]?.unsavedChanges;
}

describe("useParameterImportExport unsavedChanges wiring", () => {
beforeEach(() => {
jest.clearAllMocks();
// jsdom lacks URL.createObjectURL
(URL as any).createObjectURL = jest.fn(() => "blob:fake");
(URL as any).revokeObjectURL = jest.fn();
});

afterEach(() => {
jest.restoreAllMocks();
});

describe("exportParameters", () => {
it("clears unsavedChanges after exporting parameters to JSON", async () => {
seedUnsaved();
expect(currentUnsaved()).toBe(true);

const {result} = renderHook(() => useParameterImportExport("ns"));

await act(async () => {
await result.current.exportParameters();
});

expect(notificationMock.success).toHaveBeenCalled();
expect(currentUnsaved()).toBe(false);
});
});

describe("importParameters", () => {
function installFakeFileInput(fileContents: string) {
const fakeFile = {
text: () => Promise.resolve(fileContents),
};
const realCreate = document.createElement.bind(document);
const spy = jest
.spyOn(document, "createElement")
.mockImplementation((tag: string) => {
if (tag === "input") {
const el = realCreate("input") as HTMLInputElement;
el.click = jest.fn(() => {
// simulate the user selecting a file
Promise.resolve().then(() => {
el.onchange?.({
target: {files: [fakeFile as any]},
} as any);
});
});
return el;
}
return realCreate(tag);
});
return spy;
}

it("clears unsavedChanges after importing a valid parameter JSON file", async () => {
seedUnsaved();
expect(currentUnsaved()).toBe(true);

isImportParameterArray.mockReturnValue(true);
filterAndValidateParameters.mockReturnValue({
hasValidParameters: true,
validParameters: {paramA: 1},
});
generateParameterFeedback.mockReturnValue({
type: "success",
message: "imported",
});

installFakeFileInput(
JSON.stringify({parameters: [{id: "paramA"}]}),
);

const {result} = renderHook(() => useParameterImportExport("ns"));

await act(async () => {
await result.current.importParameters();
});

expect(currentUnsaved()).toBe(false);
});

it("does not clear unsavedChanges when the imported JSON is invalid", async () => {
seedUnsaved();
const before = currentUnsaved();

isImportParameterArray.mockReturnValue(false);

installFakeFileInput(
JSON.stringify({parameters: [{id: "paramA"}]}),
);

const {result} = renderHook(() => useParameterImportExport("ns"));

await act(async () => {
await expect(
result.current.importParameters(),
).rejects.toThrow();
});

expect(currentUnsaved()).toBe(before);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* @jest-environment jsdom
*/
import {useShapeDiverStoreParameters} from "../useShapeDiverStoreParameters";

/**
* Tests for the `unsavedChanges` flag on parameter history entries, which
* drives the `beforeunload` protection prompt (SS-9721).
*
* The store is a singleton created at module load. Each test resets the
* history before asserting.
*/
describe("useShapeDiverStoreParameters unsavedChanges", () => {
const store = useShapeDiverStoreParameters;

beforeEach(() => {
store.getState().resetHistory();
});

it("initial default state entry has unsavedChanges=false", () => {
const entry = store.getState().pushHistoryState({}, false);
expect(entry.unsavedChanges).toBe(false);
expect(store.getState().historyIndex).toBe(0);
expect(store.getState().history[0].unsavedChanges).toBe(false);
});

it("parameter change entries default to unsavedChanges=true", () => {
store.getState().pushHistoryState({}, false);
const entry = store.getState().pushHistoryState({ns: {p: "v"}});
expect(entry.unsavedChanges).toBe(true);
expect(
store.getState().history[store.getState().historyIndex]
.unsavedChanges,
).toBe(true);
});

it("clearUnsavedChanges clears the flag on the current entry", () => {
store.getState().pushHistoryState({}, false);
store.getState().pushHistoryState({ns: {p: "v"}});
expect(
store.getState().history[store.getState().historyIndex]
.unsavedChanges,
).toBe(true);

store.getState().clearUnsavedChanges();

expect(
store.getState().history[store.getState().historyIndex]
.unsavedChanges,
).toBe(false);
});

it("clearUnsavedChanges syncs window.history.state when it matches the current entry", () => {
store.getState().pushHistoryState({}, false);
const entry = store.getState().pushHistoryState({ns: {p: "v"}});
// Mimic historyPusher / useParameterHistory writing the entry into the
// browser history stack.
window.history.replaceState(entry, "");
expect(
(window.history.state as {unsavedChanges?: boolean}).unsavedChanges,
).toBe(true);

store.getState().clearUnsavedChanges();

expect(
(window.history.state as {unsavedChanges?: boolean}).unsavedChanges,
).toBe(false);
expect((window.history.state as {time?: number}).time).toBe(entry.time);
});

it("clearUnsavedChanges does not overwrite unrelated window.history.state", () => {
store.getState().pushHistoryState({}, false);
store.getState().pushHistoryState({ns: {p: "v"}});
const unrelated = {foo: "bar"};
window.history.replaceState(unrelated, "");

store.getState().clearUnsavedChanges();

expect(window.history.state).toEqual(unrelated);
expect(
store.getState().history[store.getState().historyIndex]
.unsavedChanges,
).toBe(false);
});

it("clearUnsavedChanges is a no-op when there is no current entry", () => {
// history is empty after reset
expect(store.getState().historyIndex).toBe(-1);
expect(() => store.getState().clearUnsavedChanges()).not.toThrow();
expect(store.getState().historyIndex).toBe(-1);
});

it("clearUnsavedChanges is a no-op when the flag is already false", () => {
store.getState().pushHistoryState({}, false);
const initialHistory = store.getState().history;
store.getState().clearUnsavedChanges();
// reference unchanged because no mutation was needed
expect(store.getState().history).toBe(initialHistory);
});

it("clearing does not affect earlier entries", () => {
store.getState().pushHistoryState({}, false); // index 0, clean
store.getState().pushHistoryState({ns: {p: "a"}}); // index 1, unsaved
store.getState().pushHistoryState({ns: {p: "b"}}); // index 2, unsaved

store.getState().clearUnsavedChanges();

expect(store.getState().history[0].unsavedChanges).toBe(false);
expect(store.getState().history[1].unsavedChanges).toBe(true);
expect(store.getState().history[2].unsavedChanges).toBe(false);
});

it("a new change after clearing marks the new entry as unsaved", () => {
store.getState().pushHistoryState({}, false);
store.getState().pushHistoryState({ns: {p: "a"}});
store.getState().clearUnsavedChanges();

const entry = store.getState().pushHistoryState({ns: {p: "b"}});
expect(entry.unsavedChanges).toBe(true);
});
});
2 changes: 1 addition & 1 deletion entities/parameter/model/useParameterHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function useParameterHistory(props: Props) {
if (!loaded) return;

const defaultState = getDefaultState();
const entry = pushHistoryState(defaultState);
const entry = pushHistoryState(defaultState, false);
history.replaceState(entry, "", "");

return () => {
Expand Down
22 changes: 15 additions & 7 deletions entities/parameter/model/useParameterImportExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ import {useShapeDiverStoreParameters} from "./useShapeDiverStoreParameters";
* Hook for managing parameter import/export and reset functionality.
*/
export function useParameterImportExport(namespace: string) {
const {batchParameterValueUpdate} = useShapeDiverStoreParameters(
useShallow((state) => ({
batchParameterValueUpdate: state.batchParameterValueUpdate,
})),
);
const {batchParameterValueUpdate, clearUnsavedChanges} =
useShapeDiverStoreParameters(
useShallow((state) => ({
batchParameterValueUpdate: state.batchParameterValueUpdate,
clearUnsavedChanges: state.clearUnsavedChanges,
})),
);

const {currentModel} = useShapeDiverStorePlatform(
useShallow((state) => ({
Expand Down Expand Up @@ -67,7 +69,10 @@ export function useParameterImportExport(namespace: string) {
notifications.success({
message: "Parameter values exported successfully",
});
}, [namespace, currentModel]);

// creating a parameter JSON file persists the current configuration
clearUnsavedChanges();
}, [namespace, currentModel, clearUnsavedChanges]);

/**
* Import parameters from JSON file
Expand Down Expand Up @@ -161,6 +166,9 @@ export function useParameterImportExport(namespace: string) {
[namespace]: validationResult.validParameters,
});

// importing a parameter JSON file reverts the unsaved changes flag
clearUnsavedChanges();

// Provide user feedback
const feedback = generateParameterFeedback(
validationResult,
Expand All @@ -176,7 +184,7 @@ export function useParameterImportExport(namespace: string) {

fileInput.click();
});
}, [namespace, notifications]);
}, [namespace, notifications, clearUnsavedChanges]);

/**
* Reset parameters to default values
Expand Down
Loading