Repository: AllenInstitute/biofile-finder
Pull request #522: Bugfix/Query UI stuck in loading state
Author: login: aswallace
Created: 2025-05-12T19:20:08Z | Merged: 2025-05-14T22:20:49Z

## Context
closes #517 
When attempting to load a URL with malformed JSON, the `Query` and `FileList` components were getting stuck in a loading state and not showing the error to the user. 

Relatedly, if a user only had a single query and then deleted it, the query panel would also show an eternal loading state because the search params weren't getting removed from the URL (so the `Query` component assumed something was supposed to be loading).

## Changes
JSON loading bug:
- Adds a try/catch around the `decode` step so we can dispatch an error status to the UI
- Checks for reload errors in the Query component so that we stop showing the spinner 

Deleting only query bug:
- Removes query params from URL if there are no more queries left (otherwise, loads the first remaining query in the list)

Also fixes styling for the example table that appears when there are no queries loaded

## Testing
- Manually tested [the url provided in the tagged issue](https://bff.allencell.org/app?c=File+Name%3A0.5%2CBatch%3ANaN%2CRound%3ANaN%2CDate%3ANaN&source=%7B%22name%22%3A%22flyefish_all-data%22%2C%22type%22%3A%22csv%22%2C%22uri%22%3A%22http%3A%2F%2Flocalhost%3A8000%2Fflyefish_all%2Fdata.csv%22%7D&sourceMetadata=%7B%22name%22%3A%22flyefish_all-cols%22%2C%22type%22%3A%22csv%22%2C%22uri%22%3A%22http%3A%2F%2Flocalhost%3A8000%2Fflyefish_all%2Fcolumns.csv%22), plus a few other intentionally malformed URLs
- Added a loading state unit test

## Screenshots 
### JSON loading bug
Before (stuck in loading) vs after (shows error & stops loading)
<img width="400" alt="Screenshot 2025-05-12 at 12 13 51 PM" src="https://github.com/user-attachments/assets/31b0d949-447d-49ec-830c-5b9caf4fcd10" />
<img width="400" alt="Screenshot 2025-05-12 at 12 14 09 PM" src="https://github.com/user-attachments/assets/5a8c7a78-3b8e-4b48-8066-809d634a1f48" />


### Deleting only query bug
Before (query shows loading state forever) vs after (no search params, default empty display) --EDITED for [cd550c9](https://github.com/AllenInstitute/biofile-finder/pull/522/commits/cd550c9c5af2d3f1c2d96c8f4c5d7a66b80f96d9)
<img width="400" alt="Screenshot 2025-05-12 at 12 11 35 PM" src="https://github.com/user-attachments/assets/ab3529fd-7743-4d82-9a26-94261f14a4c1" />
<img width="400" alt="image" src="https://github.com/user-attachments/assets/0d731336-704f-46a2-9fbd-6290f818fb49" />


Linked issue #517: Show errors for parameter parsing issues
## Description

When the URL is misformed, like [this URL](https://bff.allencell.org/app?c=File+Name%3A0.5%2CBatch%3ANaN%2CRound%3ANaN%2CDate%3ANaN&source=%7B%22name%22%3A%22flyefish_all-data%22%2C%22type%22%3A%22csv%22%2C%22uri%22%3A%22http%3A%2F%2Flocalhost%3A8000%2Fflyefish_all%2Fdata.csv%22%7D&sourceMetadata=%7B%22name%22%3A%22flyefish_all-cols%22%2C%22type%22%3A%22csv%22%2C%22uri%22%3A%22http%3A%2F%2Flocalhost%3A8000%2Fflyefish_all%2Fcolumns.csv%22) missing the final closing brace, BFF continues to display the loading animation even once an error appears in the console.

## Expected Behavior

There should be an error displayed to the user.

## Reproduction

Click the link above.

## Environment
_Which version of BioFile Finder has the bug and with what environment?_
- Firefox

Linked issue comments: user: login: aswallace
created_at: 2025-05-14T22:36:28Z
body: This should be fixed on web now. Please let us know if it's still causing you any issues!
author_association: CONTRIBUTOR

Diff:
diff --git a/packages/core/components/DataSourcePrompt/DataSourcePrompt.module.css b/packages/core/components/DataSourcePrompt/DataSourcePrompt.module.css
index 776c4767d..cdeba81b0 100644
--- a/packages/core/components/DataSourcePrompt/DataSourcePrompt.module.css
+++ b/packages/core/components/DataSourcePrompt/DataSourcePrompt.module.css
@@ -53,16 +53,16 @@
     justify-content: left;
 }
 
-.table-example, .table-example > tr > :is(th, td) {
+.table-example, .table-example tr > :is(th, td) {
     border: 2px solid var(--secondary-background-color);
     border-collapse: collapse;
 }
 
-.table-example > tr > :is(th, td) {
+.table-example tr > :is(th, td) {
     padding: 5px;
 }
 
-.light-border.table-example, .light-border.table-example > tr > :is(th, td) {
+.light-border.table-example, .light-border.table-example tr > :is(th, td) {
     border: 2px solid var(--primary-background-color);
 }
 
diff --git a/packages/core/components/FileDetails/FileAnnotationRow.module.css b/packages/core/components/FileDetails/FileAnnotationRow.module.css
index 6d6692f3b..eb31bd4de 100644
--- a/packages/core/components/FileDetails/FileAnnotationRow.module.css
+++ b/packages/core/components/FileDetails/FileAnnotationRow.module.css
@@ -79,6 +79,7 @@
     font-size: 12px;
     font-weight: 700;
     color: var(--aqua);
+    padding: 0 5px;
 }
 
 .expand-button:hover {
@@ -89,5 +90,4 @@
 .expand-button-wrapper {
     display: flex;
     justify-content: flex-end;
-    padding-right: var(--margin);
 }
diff --git a/packages/core/components/QuerySidebar/Query.tsx b/packages/core/components/QuerySidebar/Query.tsx
index 6baa9848d..4e1f2cd84 100644
--- a/packages/core/components/QuerySidebar/Query.tsx
+++ b/packages/core/components/QuerySidebar/Query.tsx
@@ -33,8 +33,10 @@ export default function Query(props: QueryProps) {
 
     const shouldHaveDataSource = !!window.location.search;
     const hasDataSource = !!props.query.parts.sources.length;
+    const hasReloadError = useSelector(selection.selectors.getRequiresDataSourceReload);
     const isLoading =
-        (shouldHaveDataSource && !hasDataSource) || props.query.name === AICS_FMS_DATA_SOURCE_NAME;
+        (shouldHaveDataSource && !hasDataSource && !hasReloadError) ||
+        props.query.name === AICS_FMS_DATA_SOURCE_NAME;
 
     const [isExpanded, setIsExpanded] = React.useState(false);
     React.useEffect(() => {
diff --git a/packages/core/components/QuerySidebar/test/Query.test.tsx b/packages/core/components/QuerySidebar/test/Query.test.tsx
index 96e88f8c6..f98300678 100644
--- a/packages/core/components/QuerySidebar/test/Query.test.tsx
+++ b/packages/core/components/QuerySidebar/test/Query.test.tsx
@@ -1,6 +1,7 @@
-import { configureMockStore } from "@aics/redux-utils";
+import { configureMockStore, mergeState } from "@aics/redux-utils";
 import { render, fireEvent } from "@testing-library/react";
 import { expect } from "chai";
+import { noop } from "lodash";
 import * as React from "react";
 import { Provider } from "react-redux";
 
@@ -44,7 +45,7 @@ describe("<Query />", () => {
         expect(() => getByTestId("expand-button")).to.throw();
     });
 
-    it("renders spinner when loading", () => {
+    it("renders spinner when loading an FMS source", () => {
         // Arrange
         const { store } = configureMockStore({
             state: initialState,
@@ -99,4 +100,78 @@ describe("<Query />", () => {
         // Assert
         expect(() => getByTestId("query-spinner")).to.throw();
     });
+
+    it("renders spinner when loading an external source", () => {
+        // Arrange
+        const { store } = configureMockStore({
+            state: initialState,
+        });
+        Object.defineProperty(window, "location", {
+            value: {
+                search: "mock-query-param=true",
+                assign: noop,
+            },
+        });
+
+        // Act
+        const { getByTestId } = render(
+            <Provider store={store}>
+                <Query
+                    isSelected
+                    query={{
+                        name: "Test Data Source",
+                        parts: {
+                            filters: [],
+                            hierarchy: [],
+                            sources: [],
+                            openFolders: [],
+                        },
+                    }}
+                />
+            </Provider>
+        );
+
+        // Assert
+        expect(getByTestId("query-spinner")).to.exist;
+    });
+
+    it("does not render spinner if there's a data source error", () => {
+        // Arrange
+        const state = mergeState(initialState, {
+            selection: {
+                requiresDataSourceReload: true,
+            },
+        });
+        const { store } = configureMockStore({
+            state,
+        });
+
+        Object.defineProperty(window, "location", {
+            value: {
+                search: "mock-query-param=true",
+                assign: noop,
+            },
+        });
+
+        // Act
+        const { getByTestId } = render(
+            <Provider store={store}>
+                <Query
+                    isSelected
+                    query={{
+                        name: "Test Failing Source",
+                        parts: {
+                            filters: [],
+                            hierarchy: [],
+                            sources: [],
+                            openFolders: [],
+                        },
+                    }}
+                />
+            </Provider>
+        );
+
+        // Assert
+        expect(() => getByTestId("query-spinner")).to.throw();
+    });
 });
diff --git a/packages/core/state/interaction/logics.ts b/packages/core/state/interaction/logics.ts
index 867734d94..74d406e38 100644
--- a/packages/core/state/interaction/logics.ts
+++ b/packages/core/state/interaction/logics.ts
@@ -71,12 +71,22 @@ const initializeApp = createLogic({
         // If there are query args representing a query we can extract that
         // into the query to render (ex. when refreshing a page)
         if (isOnWeb && window.location.search) {
-            dispatch(
-                selection.actions.addQuery({
-                    name: DEFAULT_QUERY_NAME,
-                    parts: SearchParams.decode(window.location.search),
-                })
-            );
+            try {
+                dispatch(
+                    selection.actions.addQuery({
+                        name: DEFAULT_QUERY_NAME,
+                        parts: SearchParams.decode(window.location.search),
+                    })
+                );
+            } catch (err) {
+                // Parsing error from SearchParams.decode
+                dispatch(
+                    selection.actions.addDataSourceReloadError(
+                        DEFAULT_QUERY_NAME,
+                        (err as Error).message
+                    )
+                );
+            }
         } else if (queries.length) {
             dispatch(selection.actions.changeQuery(queries[0]));
         } else if (isAicsEmployee) {
diff --git a/packages/core/state/selection/actions.ts b/packages/core/state/selection/actions.ts
index 8b4230696..a0f82681c 100644
--- a/packages/core/state/selection/actions.ts
+++ b/packages/core/state/selection/actions.ts
@@ -354,6 +354,24 @@ export function removeQuery(queryName: string): RemoveQuery {
     };
 }
 
+/**
+ * RESET_QUERY_FIELDS
+ *
+ * Intention is to reset all query-related state properties to their default values.
+ * Necessary when clearing the last query from the query list
+ */
+export const RESET_QUERY_FIELDS = makeConstant(STATE_BRANCH_NAME, "reset-query-fields");
+
+export interface ResetQueryFields {
+    type: string;
+}
+
+export function resetQueryProperties(): ResetQueryFields {
+    return {
+        type: RESET_QUERY_FIELDS,
+    };
+}
+
 /**
  * REPLACE_DATA_SOURCE
  *
diff --git a/packages/core/state/selection/logics.ts b/packages/core/state/selection/logics.ts
index 0a5e183e0..9e73b2cfa 100644
--- a/packages/core/state/selection/logics.ts
+++ b/packages/core/state/selection/logics.ts
@@ -33,6 +33,7 @@ import {
     REPLACE_DATA_SOURCE,
     ReplaceDataSource,
     REMOVE_QUERY,
+    resetQueryProperties,
     changeDataSources,
     ChangeDataSourcesAction,
     CHANGE_DATA_SOURCES,
@@ -735,6 +736,11 @@ const removeQueryLogic = createLogic({
     type: REMOVE_QUERY,
     async process(deps: ReduxLogicDeps, dispatch, done) {
         const queries = selectionSelectors.getQueries(deps.getState());
+        // If removing the last query, must also reset query-related properties in store
+        // which has a side effect of removing the query params from the URL
+        if (queries.length === 0) {
+            dispatch(resetQueryProperties());
+        }
         dispatch(changeQuery(queries[0]));
         done();
     },
diff --git a/packages/core/state/selection/reducer.ts b/packages/core/state/selection/reducer.ts
index cb9df69a3..cebe0737c 100644
--- a/packages/core/state/selection/reducer.ts
+++ b/packages/core/state/selection/reducer.ts
@@ -21,6 +21,7 @@ import {
     ChangeQuery,
     REMOVE_QUERY,
     RemoveQuery,
+    RESET_QUERY_FIELDS,
     ChangeDataSourcesAction,
     SetSortColumnAction,
     SetFileFiltersAction,
@@ -153,6 +154,18 @@ export default makeReducer<SelectionStateBranch>(
             ...state,
             queries: state.queries.filter((query) => query.name !== action.payload),
         }),
+        [RESET_QUERY_FIELDS]: (state) => ({
+            ...state,
+            annotationHierarchy: initialState.annotationHierarchy,
+            columns: initialState.columns,
+            filters: initialState.filters,
+            fileView: initialState.fileView,
+            openFileFolders: initialState.openFileFolders,
+            shouldShowNullGroups: initialState.shouldShowNullGroups,
+            sortColumn: undefined,
+            dataSources: initialState.dataSources,
+            sourceMetadata: undefined,
+        }),
         [SET_QUERIES]: (state, action: SetQueries) => ({
             ...state,
             queries: action.payload,


Reviews:
user: login: kmitcham
state: APPROVED
author_association: NONE
submitted_at: 2025-05-12T21:12:28Z
user: login: BrianWhitneyAI
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T18:47:56Z
user: login: SeanDuHare
body: Yes!! So glad this is happening
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T18:58:42Z
user: login: aswallace
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T20:16:54Z

Review comments:
diff_hunk: @@ -69,12 +69,22 @@ const initializeApp = createLogic({
         // If there are query args representing a query we can extract that
         // into the query to render (ex. when refreshing a page)
         if (isOnWeb && window.location.search) {
-            dispatch(
-                selection.actions.addQuery({
-                    name: DEFAULT_QUERY_NAME,
-                    parts: SearchParams.decode(window.location.search),
-                })
-            );
+            try {
+                dispatch(
+                    selection.actions.addQuery({
+                        name: DEFAULT_QUERY_NAME,
+                        parts: SearchParams.decode(window.location.search),
+                    })
+                );
+            } catch (err) {
+                // Parsing error from SearchParams.decode
path: packages/core/state/interaction/logics.ts
user: login: BrianWhitneyAI
body: This catches all errors correct? does this comment align with that?
created_at: 2025-05-14T18:47:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -69,12 +69,22 @@ const initializeApp = createLogic({
         // If there are query args representing a query we can extract that
         // into the query to render (ex. when refreshing a page)
         if (isOnWeb && window.location.search) {
-            dispatch(
-                selection.actions.addQuery({
-                    name: DEFAULT_QUERY_NAME,
-                    parts: SearchParams.decode(window.location.search),
-                })
-            );
+            try {
+                dispatch(
+                    selection.actions.addQuery({
+                        name: DEFAULT_QUERY_NAME,
+                        parts: SearchParams.decode(window.location.search),
+                    })
+                );
+            } catch (err) {
+                // Parsing error from SearchParams.decode
path: packages/core/state/interaction/logics.ts
user: login: aswallace
body: Technically no, it only catches the decode error since `addQuery` has its own error handling. I could write this equivalently (which may have been more clear) if I separated `const parts = SearchParams.decode()` as its own step and just wrapped that, but then I'd need to separately prevent `addQuery` from running afterward...
created_at: 2025-05-14T20:16:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: SeanDuHare
created_at: 2025-05-14T18:57:06Z
body: In your "after" it looks like it still has a query selected its just disabled - could we clear that completely?
author_association: CONTRIBUTOR
user: login: aswallace
created_at: 2025-05-14T22:08:55Z
body: > In your "after" it looks like it still has a query selected its just disabled - could we clear that completely?

Realized in looking into this comment that even when resetting the URL/search params, the store was actually persisting the query properties (which is why the disabled query still had the props). So in [cd550c9](https://github.com/AllenInstitute/biofile-finder/pull/522/commits/cd550c9c5af2d3f1c2d96c8f4c5d7a66b80f96d9) I've changed it from modifying `window.location` to instead adding logic that resets all query-related state properties to their default initial values, which fortunately has a side effect of also clearing the url search params. 
author_association: CONTRIBUTOR