Repository: dagster-io/dagster
Pull request #28583: [ui] Fix tag filter encoding
Author: login: hellendag
Created: 2025-03-18T18:09:08Z | Merged: 2025-03-18T19:39:00Z

## Summary & Motivation

Repair an issue when filtering on multiple tags. This was reported for Automation filtering specifically, but since this uses `useQueryPersistedState`, the same issue would occur elsewhere.

It is worth noting that since tags are treated as an `AND` filter and not an `OR` filter, so users may still end up with empty result sets that they didn't anticipate. (This is not a bug.)

Tags are defined as `{key: string; value: string}`, but currently, when we encode these in the URL, we use `bracket` indexing in `qs`. Thus, an array of tag objects like this:


...results in an encoded output like this:

```
tags%5B%5D%5Bkey%5D=foo&tags%5B%5D%5Bvalue%5D=bar&tags%5B%5D%5Bkey%5D=zombo&tags%5B%5D%5Bvalue%5D=com
```

This decodes as:

```
tags[][key]=foo&tags[][value]=bar&tags[][key]=zombo&tags[][value]=com
```

Unfortunately, this means that in JS, we end up with an array containing one object, looking like this:

```
tags: [
  key: ['foo', 'zombo'],
  value: ['bar', 'com'],
]
```

The resulting filter behavior is thus incorrect.

To resolve this, use the `indices` behavior for the `arrayFormat` option in `qs.stringify`. This results in an encoded string like this:

```
tags%5B0%5D%5Bkey%5D=foo&tags%5B0%5D%5Bvalue%5D=bar&tags%5B1%5D%5Bkey%5D=zombo&tags%5B1%5D%5Bvalue%5D=com
```

...and a decoded string like this:

```
tags[0][key]=foo&tags[0][value]=bar&tags[1][key]=zombo&tags[1][value]=com
```

This can be property converted into our array of key-value objects, resulting in the desired array of tags.

```
tags: [
  {key: 'foo', value: 'bar'},
  {key: 'zombo', value: 'com'},
]
```

Additionally, I added a step to strip the `__typename` field from the tag definitions so they no longer appear in the URL query params.

## How I Tested These Changes

View Automation page. Select multiple tags to filter on, verify that the URL is updated correctly and decoded correctly for display in the filter.

## Changelog

[ui] Fix filtering for multiple tags on list view pages, including Automations.


Diff:
diff --git a/js_modules/dagster-ui/packages/ui-core/src/automation/MergedAutomationRoot.tsx b/js_modules/dagster-ui/packages/ui-core/src/automation/MergedAutomationRoot.tsx
index b5c1ba848d703..06d79ba68bc52 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/automation/MergedAutomationRoot.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/automation/MergedAutomationRoot.tsx
@@ -114,8 +114,12 @@ export const MergedAutomationRoot = () => {
       repoBuckets,
       useCallback((repoBucket) => {
         return [
-          ...repoBucket.schedules.flatMap((schedule) => schedule.tags),
-          ...repoBucket.sensors.flatMap((sensor) => sensor.tags),
+          ...repoBucket.schedules.flatMap((schedule) =>
+            schedule.tags.map(({key, value}) => ({key, value})),
+          ),
+          ...repoBucket.sensors.flatMap((sensor) =>
+            sensor.tags.map(({key, value}) => ({key, value})),
+          ),
         ];
       }, []),
     ),
diff --git a/js_modules/dagster-ui/packages/ui-core/src/hooks/__tests__/useQueryAndLocalStoragePersistedState.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/hooks/__tests__/useQueryAndLocalStoragePersistedState.test.tsx
index 29eceaf2bf6b1..cc80f4f66e9b4 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/hooks/__tests__/useQueryAndLocalStoragePersistedState.test.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/hooks/__tests__/useQueryAndLocalStoragePersistedState.test.tsx
@@ -90,7 +90,7 @@ describe('useQueryAndLocalStoragePersistedState', () => {
     expect(state).toEqual(new Set(['test', 'test2']));
 
     await waitFor(() => {
-      expect(querySearch).toEqual('?open-nodes%5B%5D=test&open-nodes%5B%5D=test2');
+      expect(querySearch).toEqual('?open-nodes%5B0%5D=test&open-nodes%5B1%5D=test2');
     });
   });
 
diff --git a/js_modules/dagster-ui/packages/ui-core/src/hooks/__tests__/useQueryPersistedState.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/hooks/__tests__/useQueryPersistedState.test.tsx
index 6f571a4bc58ec..503e3fbc9770a 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/hooks/__tests__/useQueryPersistedState.test.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/hooks/__tests__/useQueryPersistedState.test.tsx
@@ -280,7 +280,7 @@ describe('useQueryPersistedState', () => {
     expect(screen.getByText(`Functions Same: true`)).toBeVisible();
   });
 
-  it('correctly encodes arrays, using bracket syntax', async () => {
+  it('correctly encodes arrays, using `indices` syntax', async () => {
     const TestArray = () => {
       const [state, setState] = useQueryPersistedState<{value: string[]}>({defaults: {value: []}});
       return (
@@ -300,16 +300,58 @@ describe('useQueryPersistedState', () => {
 
     await userEvent.click(screen.getByText(`[]`));
     await waitFor(() => {
-      expect(querySearch).toEqual('?value%5B%5D=Added0');
+      expect(querySearch).toEqual('?value%5B0%5D=Added0');
     });
     await userEvent.click(screen.getByText(`["Added0"]`));
     await userEvent.click(screen.getByText(`["Added0","Added1"]`));
     await waitFor(() => {
-      expect(querySearch).toEqual('?value%5B%5D=Added0&value%5B%5D=Added1&value%5B%5D=Added2');
+      expect(querySearch).toEqual('?value%5B0%5D=Added0&value%5B1%5D=Added1&value%5B2%5D=Added2');
     });
   });
 
-  it('correctly encodes arrays alongside other values, using bracket syntax', async () => {
+  it('correctly encodes arrays of objects, using `indices` syntax', async () => {
+    const TestArray = () => {
+      const [state, setState] = useQueryPersistedState<{value: {foo: string; bar: string}[]}>({
+        defaults: {value: []},
+      });
+
+      return (
+        <div
+          onClick={() =>
+            setState({
+              value: [...state.value, {foo: `len${state.value.length}`, bar: 'baz'}],
+            })
+          }
+        >
+          {JSON.stringify(state.value)}
+        </div>
+      );
+    };
+
+    let querySearch: string | undefined;
+    render(
+      <MemoryRouter initialEntries={['/page']}>
+        <TestArray />
+        <Route path="*" render={({location}) => (querySearch = location.search) && <span />} />
+      </MemoryRouter>,
+    );
+
+    await userEvent.click(screen.getByText(`[]`));
+    await waitFor(() => {
+      expect(querySearch).toEqual('?value%5B0%5D%5Bfoo%5D=len0&value%5B0%5D%5Bbar%5D=baz');
+    });
+    await userEvent.click(screen.getByText(`[{"foo":"len0","bar":"baz"}]`));
+    await userEvent.click(
+      screen.getByText(`[{"foo":"len0","bar":"baz"},{"foo":"len1","bar":"baz"}]`),
+    );
+    await waitFor(() => {
+      expect(querySearch).toEqual(
+        '?value%5B0%5D%5Bfoo%5D=len0&value%5B0%5D%5Bbar%5D=baz&value%5B1%5D%5Bfoo%5D=len1&value%5B1%5D%5Bbar%5D=baz&value%5B2%5D%5Bfoo%5D=len2&value%5B2%5D%5Bbar%5D=baz',
+      );
+    });
+  });
+
+  it('correctly encodes arrays alongside other values, using `indices` syntax', async () => {
     const TestArray = () => {
       const [state, setState] = useQueryPersistedState<{hello: boolean; items: string[]}>({
         defaults: {hello: false, items: []},
@@ -337,14 +379,14 @@ describe('useQueryPersistedState', () => {
     await userEvent.click(screen.getByText(`{"hello":false,"items":[]}`));
 
     await waitFor(() => {
-      expect(querySearch).toEqual('?hello=true&items%5B%5D=Added0');
+      expect(querySearch).toEqual('?hello=true&items%5B0%5D=Added0');
     });
     await userEvent.click(screen.getByText(`{"hello":true,"items":["Added0"]}`));
     await userEvent.click(screen.getByText(`{"hello":true,"items":["Added0","Added1"]}`));
 
     await waitFor(() => {
       expect(querySearch).toEqual(
-        '?hello=true&items%5B%5D=Added0&items%5B%5D=Added1&items%5B%5D=Added2',
+        '?hello=true&items%5B0%5D=Added0&items%5B1%5D=Added1&items%5B2%5D=Added2',
       );
     });
   });
@@ -433,7 +475,9 @@ describe('useQueryPersistedState', () => {
       </MemoryRouter>,
     );
 
-    push!('/page?');
+    act(() => {
+      push!('/page?');
+    });
 
     await userEvent.click(screen.getByText(`one`));
     await waitFor(() => {
diff --git a/js_modules/dagster-ui/packages/ui-core/src/hooks/useQueryPersistedState.tsx b/js_modules/dagster-ui/packages/ui-core/src/hooks/useQueryPersistedState.tsx
index 63cccfe5a3f1d..19333808c7d8d 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/hooks/useQueryPersistedState.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/hooks/useQueryPersistedState.tsx
@@ -114,7 +114,7 @@ export function useQueryPersistedState<T extends QueryPersistedDataType>(
       // the `replace` so that we surface any unwanted loops during development.
       if (process.env.NODE_ENV !== 'production' || !areQueriesEqual(currentQueryString, next)) {
         currentQueryString = next;
-        const nextPath = `${history.location.pathname}?${qs.stringify(next, {arrayFormat: 'brackets'})}`;
+        const nextPath = `${history.location.pathname}?${qs.stringify(next, {arrayFormat: 'indices'})}`;
         if (behavior === 'replace') {
           history.replace(nextPath);
         } else {


Reviews:
user: login: hellendag
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T18:23:19Z
user: login: hellendag
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T18:23:35Z
user: login: hellendag
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T18:23:47Z
user: login: hellendag
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T18:23:55Z
user: login: salazarm
body: nice find
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T18:45:32Z

Review comments:
diff_hunk: @@ -114,8 +114,12 @@ export const MergedAutomationRoot = () => {
       repoBuckets,
       useCallback((repoBucket) => {
         return [
-          ...repoBucket.schedules.flatMap((schedule) => schedule.tags),
-          ...repoBucket.sensors.flatMap((sensor) => sensor.tags),
+          ...repoBucket.schedules.flatMap((schedule) =>
+            schedule.tags.map(({key, value}) => ({key, value})),
+          ),
+          ...repoBucket.sensors.flatMap((sensor) =>
+            sensor.tags.map(({key, value}) => ({key, value})),
+          ),
path: js_modules/dagster-ui/packages/ui-core/src/automation/MergedAutomationRoot.tsx
user: login: hellendag
body: Remove the `__typename` key from the tags so that they don't end up encoded in the URL.
created_at: 2025-03-18T18:23:18Z
author_association: MEMBER
diff_hunk: @@ -300,16 +300,58 @@ describe('useQueryPersistedState', () => {
 
     await userEvent.click(screen.getByText(`[]`));
     await waitFor(() => {
-      expect(querySearch).toEqual('?value%5B%5D=Added0');
+      expect(querySearch).toEqual('?value%5B0%5D=Added0');
     });
     await userEvent.click(screen.getByText(`["Added0"]`));
     await userEvent.click(screen.getByText(`["Added0","Added1"]`));
     await waitFor(() => {
-      expect(querySearch).toEqual('?value%5B%5D=Added0&value%5B%5D=Added1&value%5B%5D=Added2');
+      expect(querySearch).toEqual('?value%5B0%5D=Added0&value%5B1%5D=Added1&value%5B2%5D=Added2');
     });
   });
 
-  it('correctly encodes arrays alongside other values, using bracket syntax', async () => {
+  it('correctly encodes arrays of objects, using `indices` syntax', async () => {
path: js_modules/dagster-ui/packages/ui-core/src/hooks/__tests__/useQueryPersistedState.test.tsx
user: login: hellendag
body: New spec to match the tag encoding case.
created_at: 2025-03-18T18:23:35Z
author_association: MEMBER
diff_hunk: @@ -433,7 +475,9 @@ describe('useQueryPersistedState', () => {
       </MemoryRouter>,
     );
 
-    push!('/page?');
+    act(() => {
+      push!('/page?');
+    });
path: js_modules/dagster-ui/packages/ui-core/src/hooks/__tests__/useQueryPersistedState.test.tsx
user: login: hellendag
body: Eliminate an `act` warning, unrelated to the rest of the PR.
created_at: 2025-03-18T18:23:47Z
author_association: MEMBER
diff_hunk: @@ -114,7 +114,7 @@ export function useQueryPersistedState<T extends QueryPersistedDataType>(
       // the `replace` so that we surface any unwanted loops during development.
       if (process.env.NODE_ENV !== 'production' || !areQueriesEqual(currentQueryString, next)) {
         currentQueryString = next;
-        const nextPath = `${history.location.pathname}?${qs.stringify(next, {arrayFormat: 'brackets'})}`;
+        const nextPath = `${history.location.pathname}?${qs.stringify(next, {arrayFormat: 'indices'})}`;
path: js_modules/dagster-ui/packages/ui-core/src/hooks/useQueryPersistedState.tsx
user: login: hellendag
body: The actual bugfix.
created_at: 2025-03-18T18:23:55Z
author_association: MEMBER

PR comments:
user: login: hellendag
created_at: 2025-03-18T18:09:29Z
body: * **#28583** <a href="https://app.graphite.dev/github/pr/dagster-io/dagster/28583?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/dagster-io/dagster/28583?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `master`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-03-18T18:11:56Z
body: Deploy preview for _dagit-core-storybook_ ready!

✅ Preview
https://dagit-core-storybook-afvm6b167-elementl.vercel.app
https://dish-fe-802-filtering-issue.core-storybook.dagster-docs.io

Built with commit e387c9d937a058a33ffae4ef40b09e9f174d3183.
This pull request is being automatically deployed with [vercel-action](https://github.com/marketplace/actions/vercel-action)
author_association: CONTRIBUTOR
user: login: hellendag
created_at: 2025-03-18T19:38:44Z
body: ### Merge activity

* **Mar 18, 2:38 PM CDT**: A user started a stack merge that includes this pull request via [Graphite](https://app.graphite.dev/github/pr/dagster-io/dagster/28583).
* **Mar 18, 2:39 PM CDT**: A user merged this pull request with [Graphite](https://app.graphite.dev/github/pr/dagster-io/dagster/28583).

author_association: MEMBER