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
280 changes: 109 additions & 171 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@types/react-syntax-highlighter": "13.5.x",
"@types/react-window": "1.8.x",
"@types/react-window-infinite-loader": "1.0.x",
"@types/semver": "7.7.x",
"@types/sinon": "10.x",
"@types/streamsaver": "2.0.x",
"@types/string-natural-compare": "3.0.x",
Expand Down Expand Up @@ -100,6 +101,7 @@
"redux": "4.0.x",
"redux-logic": "3.x",
"reselect": "4.0.x",
"semver": "7.7.x",
"streamsaver": "2.0.x",
"string-natural-compare": "3.0.x"
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import TutorialTooltip from "./components/TutorialTooltip";
import { Environment } from "./constants";
import useCheckForScreenSizeChange from "./hooks/useCheckForScreenSizeChange";
import useCheckForUpdates from "./hooks/useCheckForUpdates";
import useUpdateHasUsedApp from "./hooks/useUpdateHasUsedApp";
import useLayoutMeasurements from "./hooks/useLayoutMeasurements";
import useUnsavedDataWarning from "./hooks/useUnsavedDataWarning";
import { interaction, selection } from "./state";
Expand Down Expand Up @@ -50,6 +51,7 @@ export default function App(props: AppProps) {
>();

useCheckForUpdates();
useUpdateHasUsedApp();
useUnsavedDataWarning();
useCheckForScreenSizeChange(measuredWidth);

Expand Down
26 changes: 26 additions & 0 deletions packages/core/hooks/useUpdateHasUsedApp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as React from "react";
import { useDispatch, useSelector } from "react-redux";

import { PersistedConfigKeys } from "../services";
import { interaction, selection } from "../state";

export default () => {
const dispatch = useDispatch();
const { persistentConfigService } = useSelector(
interaction.selectors.getPlatformDependentServices
);
React.useEffect(() => {
// Check localstorage cookies
const hasUsedApp = persistentConfigService.get(
PersistedConfigKeys.HasUsedApplicationBefore
);
if (!hasUsedApp) {
// If first time using app, start running tutorials
dispatch(selection.actions.runAllTutorials());

// Mark as true for next time
dispatch(interaction.actions.markAsUsedApplicationBefore());
persistentConfigService.persist(PersistedConfigKeys.HasUsedApplicationBefore, true);
}
}, [dispatch, persistentConfigService]);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import PersistentConfigService, { PersistedConfig } from ".";

export default class PersistentConfigServiceNoop implements PersistentConfigService {
public get() {
return Promise.resolve();
}

public getAll(): PersistedConfig {
return {};
}

public clear() {
return Promise.resolve();
}

public persist() {
return Promise.resolve();
}
}
2 changes: 2 additions & 0 deletions packages/core/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import ExecutionEnvService from "./ExecutionEnvService";
import FileDownloadService from "./FileDownloadService";
import FileViewerService from "./FileViewerService";
import NotificationService from "./NotificationService";
import PersistentConfigService from "./PersistentConfigService";

export { default as AnnotationService } from "./AnnotationService";
export type { default as ApplicationInfoService } from "./ApplicationInfoService";
Expand Down Expand Up @@ -36,4 +37,5 @@ export interface PlatformDependentServices {
frontendInsights: FrontendInsights;
executionEnvService: ExecutionEnvService;
notificationService: NotificationService;
persistentConfigService: PersistentConfigService;
}
2 changes: 2 additions & 0 deletions packages/core/state/interaction/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import FileDownloadServiceNoop from "../../services/FileDownloadService/FileDown
import FileViewerServiceNoop from "../../services/FileViewerService/FileViewerServiceNoop";
import ExecutionEnvServiceNoop from "../../services/ExecutionEnvService/ExecutionEnvServiceNoop";
import { UserSelectedApplication } from "../../services/PersistentConfigService";
import PersistentConfigServiceNoop from "../../services/PersistentConfigService/PersistentConfigServiceNoop";
import NotificationServiceNoop from "../../services/NotificationService/NotificationServiceNoop";
import DatabaseServiceNoop from "../../services/DatabaseService/DatabaseServiceNoop";
import PublicDataset from "../../../web/src/entity/PublicDataset";
Expand Down Expand Up @@ -124,6 +125,7 @@ export const initialState: InteractionStateBranch = {
}),
executionEnvService: new ExecutionEnvServiceNoop(),
notificationService: new NotificationServiceNoop(),
persistentConfigService: new PersistentConfigServiceNoop(),
},
extractMetadataPythonSnippet: { setup: "", code: "" },
convertFilesSnippet: { setup: "", code: "", options: {} },
Expand Down
2 changes: 0 additions & 2 deletions packages/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@
"author": "Allen Institute for Cell Science",
"license": "SEE LICENSE IN LICENSE.txt",
"devDependencies": {
"@types/semver": "7.3.x",
"babel-loader": "8.2.x",
"clean-webpack-plugin": "4.x",
"css-loader": "6.x",
Expand All @@ -88,7 +87,6 @@
"@aics/frontend-insights-plugin-amplitude-node": "0.2.x",
"electron-store": "8.0.x",
"regenerator-runtime": "0.13.x",
"semver": "7.3.x",
"webpack-node-externals": "^3.0.0"
}
}
26 changes: 9 additions & 17 deletions packages/web/src/components/Header/Menu.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { DirectionalHint, PrimaryButton as PrimaryFluent } from "@fluentui/react";
import classNames from "classnames";
import * as React from "react";
import { useDispatch, useSelector } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useDispatch } from "react-redux";
import { Link, useLocation } from "react-router-dom";

import { PrimaryButton, TertiaryButton, useButtonMenu } from "../../../../core/components/Buttons";
import useHelpOptions from "../../../../core/hooks/useHelpOptions";
import { interaction, selection } from "../../../../core/state";

import styles from "./Menu.module.css";

Expand All @@ -15,21 +14,13 @@ import styles from "./Menu.module.css";
*/
export default function Menu() {
const dispatch = useDispatch();
const navigate = useNavigate();
const currentPath = useLocation().pathname;
const isApp: boolean = currentPath == "/app";
const helpMenuOptions = useHelpOptions(dispatch, true, isApp);
const helpMenu = useButtonMenu({
items: helpMenuOptions,
directionalHint: DirectionalHint.bottomAutoEdge,
});
const hasUsedApp = useSelector(interaction.selectors.hasUsedApplicationBefore);
const launchApp = () => {
navigate({ pathname: "/app" });
if (!hasUsedApp) {
dispatch(selection.actions.runAllTutorials());
}
};

return (
<>
Expand Down Expand Up @@ -59,12 +50,13 @@ export default function Menu() {
text="Help"
/>
{currentPath !== "/app" && (
<PrimaryButton
onClick={launchApp}
className={styles.startButton}
title="Get started in the app"
text="LAUNCH APP"
/>
<Link to="app">
<PrimaryButton
className={styles.startButton}
title="Get started in the app"
text="LAUNCH APP"
/>
</Link>
)}
</div>
<div className={styles.smallMenu}>
Expand Down
4 changes: 4 additions & 0 deletions packages/web/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import DatabaseServiceWeb from "./services/DatabaseServiceWeb";
import ExecutionEnvServiceWeb from "./services/ExecutionEnvServiceWeb";
import FileViewerServiceWeb from "./services/FileViewerServiceWeb";
import FileDownloadServiceWeb from "./services/FileDownloadServiceWeb";
import PersistentConfigServiceWeb from "./services/PersistentConfigServiceWeb";
import ErrorPage from "./components/ErrorPage";
import Learn from "./components/Learn";
import Home from "./components/Home";
Expand Down Expand Up @@ -58,6 +59,7 @@ const router = createBrowserRouter(
);

async function asyncRender() {
const persistentConfigService = new PersistentConfigServiceWeb();
const databaseService = new DatabaseServiceWeb();
await databaseService.initialize();

Expand All @@ -70,9 +72,11 @@ async function asyncRender() {
applicationInfoService: new ApplicationInfoServiceWeb(),
fileViewerService: new FileViewerServiceWeb(),
fileDownloadService: new FileDownloadServiceWeb(new S3StorageService()),
persistentConfigService,
}));
const store = createReduxStore({
isOnWeb: true,
persistedConfig: persistentConfigService.getAll(),
platformDependentServices: collectPlatformDependentServices(),
});
render(
Expand Down
81 changes: 81 additions & 0 deletions packages/web/src/services/PersistentConfigServiceWeb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { lt, valid } from "semver";

import {
PersistentConfigService,
PersistedConfig,
PersistedConfigKeys,
} from "../../../core/services";

interface PersistentConfigServiceWebOptions {
clearExistingData?: boolean;
}

// Manually manage version
const CURRENT_VERSION = "1.0.0";
const VERSION_KEY = "BFF_PERSISTED_CONFIG_VERSION";

// Use browser localstorage to persist data between sessions
export default class PersistentConfigServiceWeb implements PersistentConfigService {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other config storage keeps track of versions with schemas right? What do you think about storing the config version whenever this initializes so that we can version the config version (..) style going forward in case we end up changing the config variables a lot in the future? We could then, if the major version has changed, delete(?) the cookies saved so far and start anew. Worth checking if there is some existing convention for how apps do this too if you haven't yet.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense to me! The other version was specific to "electron-store", and I wasn't sure if there was an equivalent I could translate to local storage. I think it's worth doing though, will take a look.

On a similar topic, I didn't include the other cookies that we currently use in desktop, but may be worth discussing if we want any of those to be included in web (e.g., columns?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added version in a776a98

public constructor(options: PersistentConfigServiceWebOptions = {}) {
if (options.clearExistingData) {
localStorage.clear();
}
// Check if localStorage has a stale version number
const storedVersion = localStorage.getItem(VERSION_KEY);
if (storedVersion !== CURRENT_VERSION) {
this.migrate(storedVersion);
// Update to new version
localStorage.setItem(VERSION_KEY, CURRENT_VERSION);
}
}

// Handle version migrations.
// If we deprecate keys in the future, we can use this method to
// remove/update values as needed, e.g.,
// if (gt(oldVersion, "1.0.1")) { // old version > 1.0.1
//. if (localStorage.getItem(PersistedConfigKeys.DeprecatedKey)) {
// localStorage.removeItem(PersistedConfigKeys.DeprecatedKey);
// }
// }
private migrate(oldVersion: string | null) {
// If invalid version, cannot validate stored keys
if (!oldVersion || lt(oldVersion, "0.0.0") || !valid(oldVersion)) {
this.clear();
}
return;
}

// localStorage only stores strings
public get(key: PersistedConfigKeys): string | undefined {
return localStorage.getItem(key) ?? undefined; // prefer undefined over null to match parent class
}

public getAll(): PersistedConfig {
return Object.values(PersistedConfigKeys).reduce(
(config: PersistedConfig, key) => ({
...config,
[key as string]: this.get(key),
}),
{}
);
}

public clear(): void {
localStorage.clear();
}

public persist(config: PersistedConfig): void;
public persist(key: PersistedConfigKeys, value?: string): void;
public persist(arg: PersistedConfigKeys | PersistedConfig, value?: any) {
if (typeof arg === "object") {
Object.entries(arg as PersistedConfig).forEach(([key, value]) => {
this.persist(key as PersistedConfigKeys, value);
});
} else if (value === undefined || value === null) {
localStorage.removeItem(arg);
} else {
// setItem only accepts strings
localStorage.setItem(arg, value);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { expect } from "chai";

import { PersistedConfigKeys } from "../../../../core/services";
import PersistentConfigServiceWeb from "../PersistentConfigServiceWeb";

describe("PersistentConfigServiceWeb", () => {
beforeEach(() => {
localStorage.clear();
});

describe("get", () => {
it("retrieves from localStorage", () => {
// Arrange
const expectedValue = "test value";
const service = new PersistentConfigServiceWeb();
localStorage.setItem(PersistedConfigKeys.HasUsedApplicationBefore, expectedValue);

// Act
const actualValue = service.get(PersistedConfigKeys.HasUsedApplicationBefore);

// Assert
expect(actualValue).to.equal(expectedValue);
});

it("returns undefined when key does not exist", () => {
// Arrange
const service = new PersistentConfigServiceWeb({ clearExistingData: true });

// Act
const actual = service.get(PersistedConfigKeys.ImageJExecutable);

// Assert
expect(actual).to.be.undefined;
});
});

describe("getAll", () => {
// Currently just a smoke test since we only store one key so far in web
it("retrieves all persisted keys", () => {
// Arrange
const service = new PersistentConfigServiceWeb();
localStorage.setItem(PersistedConfigKeys.HasUsedApplicationBefore, "true");

// Act
const persistedKeys = service.getAll();
const keysWithValues = Object.values(persistedKeys).filter((val) => val != undefined);

// Assert
expect(Object.values(persistedKeys).length).to.equal(
Object.values(PersistedConfigKeys).length
);
expect(keysWithValues.length).to.equal(1);
});
});

describe("persist", () => {
it(`persists valid ${PersistedConfigKeys.HasUsedApplicationBefore}`, () => {
// Arrange
const service = new PersistentConfigServiceWeb({ clearExistingData: true });
const expected = "true";

// Act
service.persist(PersistedConfigKeys.HasUsedApplicationBefore, "true");

// Assert
const actual = service.get(PersistedConfigKeys.HasUsedApplicationBefore);
expect(actual).to.equal(expected);
});

it("clears keys when value is undefined", () => {
// Arrange
const service = new PersistentConfigServiceWeb();

// consistency check
service.persist(PersistedConfigKeys.HasUsedApplicationBefore, "true");
const intermediateValue = service.get(PersistedConfigKeys.HasUsedApplicationBefore);
expect(intermediateValue).to.equal("true");

// Act
service.persist(PersistedConfigKeys.HasUsedApplicationBefore, undefined);
const actual = service.get(PersistedConfigKeys.HasUsedApplicationBefore);

// Assert
expect(actual).to.be.undefined;
});
});
});
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// Make sure specific type definition APIs are available on compile.
// Specifically added for WebWorker API, but seems that listing that one
// makes adding ESNext also necessary
"lib": ["WebWorker", "ESNext"],
"lib": ["WebWorker", "ESNext", "DOM"],
Copy link
Copy Markdown
Contributor Author

@aswallace aswallace Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just because of some typescript errors showing up after a recent IDE update; makes this library explicitly available

"module": "ESNext",
"moduleResolution": "Node",
"preserveConstEnums": true,
Expand Down
Loading