sadpandajoe commented on code in PR #44276:
URL: https://github.com/apache/superset/pull/44276#discussion_r4130289212


##########
superset-frontend/src/core/dashboard/index.ts:
##########
@@ -0,0 +1,214 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { dashboard as dashboardApi } from '@apache-superset/core';
+import { isNativeFilter, makeApi, SupersetClient } from '@superset-ui/core';
+import type {
+  DataMask,
+  Divider,
+  Filter,
+  JsonObject,
+  QueryFormData,
+} from '@superset-ui/core';
+import { updateComponents } from 'src/dashboard/actions/dashboardLayout';
+import { dashboardInfoChanged } from 'src/dashboard/actions/dashboardInfo';
+import { applySavedFilterChanges } from 'src/dashboard/actions/nativeFilters';
+import type { SaveFilterChangesType } from 
'src/dashboard/components/nativeFilters/FiltersConfigModal/types';
+import { updateDataMask } from 'src/dataMask/actions';
+import {
+  setChartFormData,
+  triggerQuery,
+} from 'src/components/Chart/chartAction';
+import { applyDefaultFormData } from 'src/explore/store';
+import extractUrlParams from 'src/dashboard/util/extractUrlParams';
+import { store, RootState } from 'src/views/store';
+import { navigation } from '../navigation';
+
+const getState = () => store.getState() as RootState;
+
+// The Redux slices below are retained across an in-SPA navigation, so
+// checking them alone can't tell a still-active dashboard from a stale one
+// left over from before the user navigated to another page.
+const isDashboardActive = (): boolean => navigation.getPage() === 'dashboard';
+
+const requireDashboardId = (): number => {
+  const { id } = getState().dashboardInfo;
+  if (!isDashboardActive() || id == null) {

Review Comment:
   `navigation.getPage()` flips to `'dashboard'` as soon as the route changes, 
but `dashboardInfo`/`dashboardLayout`/`nativeFilters`/`charts` keep the 
*previous* dashboard's data until the new one's `HYDRATE_DASHBOARD` completes 
(this file's own sibling, `DashboardPage.tsx`, guards this exact window with 
`hydratedDashboardId === id`). During that window every `dashboard.*` write in 
this file — `setCss`, `updateFilters`, `saveFilters`, `updateLayoutNode`, 
`refreshChart` — passes this gate and still operates on the previous dashboard 
A's id/state even though the browser has already navigated to dashboard B. 
Worse, if a `saveFilters` PUT started against A resolves after B has hydrated, 
`applySavedFilterChanges` merges A's persisted filter list straight into B's 
now-current `nativeFilters`/`dataMask`. Would gating on a "fully hydrated" 
signal (e.g. comparing `dashboardInfo.id` to the routed dashboard id) rather 
than just the route name close this window?



##########
superset-frontend/src/core/dashboard/index.ts:
##########
@@ -0,0 +1,214 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { dashboard as dashboardApi } from '@apache-superset/core';
+import { isNativeFilter, makeApi, SupersetClient } from '@superset-ui/core';
+import type {
+  DataMask,
+  Divider,
+  Filter,
+  JsonObject,
+  QueryFormData,
+} from '@superset-ui/core';
+import { updateComponents } from 'src/dashboard/actions/dashboardLayout';
+import { dashboardInfoChanged } from 'src/dashboard/actions/dashboardInfo';
+import { applySavedFilterChanges } from 'src/dashboard/actions/nativeFilters';
+import type { SaveFilterChangesType } from 
'src/dashboard/components/nativeFilters/FiltersConfigModal/types';
+import { updateDataMask } from 'src/dataMask/actions';
+import {
+  setChartFormData,
+  triggerQuery,
+} from 'src/components/Chart/chartAction';
+import { applyDefaultFormData } from 'src/explore/store';
+import extractUrlParams from 'src/dashboard/util/extractUrlParams';
+import { store, RootState } from 'src/views/store';
+import { navigation } from '../navigation';
+
+const getState = () => store.getState() as RootState;
+
+// The Redux slices below are retained across an in-SPA navigation, so
+// checking them alone can't tell a still-active dashboard from a stale one
+// left over from before the user navigated to another page.
+const isDashboardActive = (): boolean => navigation.getPage() === 'dashboard';
+
+const requireDashboardId = (): number => {
+  const { id } = getState().dashboardInfo;
+  if (!isDashboardActive() || id == null) {
+    throw new Error('No dashboard is currently active');
+  }
+  return id;
+};
+
+const getDashboardId: typeof dashboardApi.getDashboardId = () =>
+  isDashboardActive() ? (getState().dashboardInfo.id ?? undefined) : undefined;
+
+const getLayout: typeof dashboardApi.getLayout = () =>
+  isDashboardActive() ? { ...getState().dashboardLayout.present } : {};
+
+const getActiveTabs: typeof dashboardApi.getActiveTabs = () =>
+  isDashboardActive() ? [...(getState().dashboardState.activeTabs ?? [])] : [];
+
+const updateLayoutNode: typeof dashboardApi.updateLayoutNode = async (
+  nodeId: string,
+  meta: Record<string, unknown>,
+) => {
+  requireDashboardId();
+  const node = getState().dashboardLayout.present[nodeId];
+  if (!node) {
+    throw new Error(`Layout node "${nodeId}" not found`);
+  }
+  // UPDATE_COMPONENTS replaces each keyed entry wholesale (it's not a deep
+  // merge), so the node's other fields must be carried through alongside
+  // the merged meta.
+  store.dispatch(
+    updateComponents({
+      [nodeId]: { ...node, meta: { ...node.meta, ...meta } },
+    }) as any,
+  );
+};
+
+const getCss: typeof dashboardApi.getCss = () =>
+  isDashboardActive() ? (getState().dashboardInfo.css ?? '') : '';
+
+const setCss: typeof dashboardApi.setCss = async (css: string) => {
+  requireDashboardId();
+  store.dispatch(dashboardInfoChanged({ css }));
+};
+
+const getFilters: typeof dashboardApi.getFilters = () => {
+  if (!isDashboardActive()) return [];
+  const { nativeFilters, dataMask } = getState();
+  const filterElements = Object.values(nativeFilters.filters) as Array<
+    Filter | Divider
+  >;
+  return filterElements.filter(isNativeFilter).map(filter => {
+    const mask = dataMask[filter.id];
+    return {
+      id: filter.id,
+      name: filter.name,
+      filterType: filter.filterType,
+      targets: filter.targets,
+      extraFormData: mask?.extraFormData,
+      filterState: mask?.filterState,
+    };
+  });
+};
+
+const updateFilters: typeof dashboardApi.updateFilters = async (
+  updates: dashboardApi.FilterValueUpdate[],
+) => {
+  requireDashboardId();
+  updates.forEach(({ filterId, extraFormData, filterState }) => {
+    const dataMask: DataMask = {};
+    if (extraFormData !== undefined) {
+      dataMask.extraFormData = extraFormData;
+    }
+    if (filterState !== undefined) {
+      dataMask.filterState = filterState;
+    }
+    store.dispatch(updateDataMask(filterId, dataMask));
+  });
+};
+
+const saveFilters: typeof dashboardApi.saveFilters = async (
+  updates: dashboardApi.FilterConfigUpdate[],
+  deletedFilterIds: string[] = [],
+) => {
+  const dashboardId = requireDashboardId();
+  const { filters: currentFilters } = getState().nativeFilters;
+
+  const modified = updates.map(
+    ({ filterId, name, targets, defaultDataMask }) => {
+      const existing = currentFilters[filterId];
+      if (!existing) {
+        throw new Error(`Filter "${filterId}" not found on this dashboard`);
+      }
+      return {
+        ...existing,
+        ...(name !== undefined && { name }),
+        ...(targets !== undefined && { targets }),
+        ...(defaultDataMask !== undefined && { defaultDataMask }),
+      };
+    },
+  ) as SaveFilterChangesType['modified'];
+
+  if (modified.length === 0 && deletedFilterIds.length === 0) {
+    return;
+  }
+
+  const filterChanges: SaveFilterChangesType = {
+    modified,
+    deleted: deletedFilterIds,
+    reordered: [],
+  };
+
+  const putFilters = makeApi<SaveFilterChangesType, { result: Filter[] }>({
+    method: 'PUT',
+    endpoint: `/api/v1/dashboard/${dashboardId}/filters`,
+  });
+  const response = await putFilters(filterChanges);
+  applySavedFilterChanges(
+    store.dispatch,
+    filterChanges,
+    response.result,
+    currentFilters,
+  );
+};
+
+const refreshChart: typeof dashboardApi.refreshChart = async (
+  chartId: number,
+) => {
+  const dashboardId = requireDashboardId();
+  if (!getState().charts[chartId]) {
+    throw new Error(`Chart ${chartId} is not on the current dashboard`);
+  }
+
+  const { json } = await SupersetClient.get({
+    endpoint: `/api/v1/dashboard/${dashboardId}/charts`,
+  });
+  const chartEntity = (
+    json?.result as { id: number; form_data?: JsonObject }[]
+  )?.find(({ id }) => id === chartId);
+  if (!chartEntity?.form_data) {
+    throw new Error(`Could not load chart ${chartId}'s current configuration`);
+  }
+
+  const formData = applyDefaultFormData({
+    ...chartEntity.form_data,
+    url_params: {
+      ...(chartEntity.form_data.url_params as JsonObject),
+      ...extractUrlParams('regular'),
+    },
+  } as Parameters<typeof applyDefaultFormData>[0]) as QueryFormData;
+
+  store.dispatch(setChartFormData(formData, chartId));

Review Comment:
   This updates `charts[chartId].form_data`, but the memoized 
`getFormDataWithExtraFilters` 
(`superset-frontend/src/dashboard/util/charts/getFormDataWithExtraFilters.ts`) 
only invalidates its per-slice cache on changes to 
`dataMask`/`nativeFilters`/`filters`/color/customization — not on the chart's 
own `form_data`. So if a chart's saved metric or viz config changes on the 
server while dashboard filters stay the same, `refreshChart(chartId)` fetches 
and dispatches the new config here, but the `triggerQuery` fired right after 
can still resolve the old cached form data and re-query with the previous 
metric. Should this also bust `getFormDataWithExtraFilters`'s cache entry for 
`chartId`?



##########
superset-frontend/src/core/dashboard/index.ts:
##########
@@ -0,0 +1,214 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { dashboard as dashboardApi } from '@apache-superset/core';
+import { isNativeFilter, makeApi, SupersetClient } from '@superset-ui/core';
+import type {
+  DataMask,
+  Divider,
+  Filter,
+  JsonObject,
+  QueryFormData,
+} from '@superset-ui/core';
+import { updateComponents } from 'src/dashboard/actions/dashboardLayout';
+import { dashboardInfoChanged } from 'src/dashboard/actions/dashboardInfo';
+import { applySavedFilterChanges } from 'src/dashboard/actions/nativeFilters';
+import type { SaveFilterChangesType } from 
'src/dashboard/components/nativeFilters/FiltersConfigModal/types';
+import { updateDataMask } from 'src/dataMask/actions';
+import {
+  setChartFormData,
+  triggerQuery,
+} from 'src/components/Chart/chartAction';
+import { applyDefaultFormData } from 'src/explore/store';
+import extractUrlParams from 'src/dashboard/util/extractUrlParams';
+import { store, RootState } from 'src/views/store';
+import { navigation } from '../navigation';
+
+const getState = () => store.getState() as RootState;
+
+// The Redux slices below are retained across an in-SPA navigation, so
+// checking them alone can't tell a still-active dashboard from a stale one
+// left over from before the user navigated to another page.
+const isDashboardActive = (): boolean => navigation.getPage() === 'dashboard';
+
+const requireDashboardId = (): number => {
+  const { id } = getState().dashboardInfo;
+  if (!isDashboardActive() || id == null) {
+    throw new Error('No dashboard is currently active');
+  }
+  return id;
+};
+
+const getDashboardId: typeof dashboardApi.getDashboardId = () =>
+  isDashboardActive() ? (getState().dashboardInfo.id ?? undefined) : undefined;
+
+const getLayout: typeof dashboardApi.getLayout = () =>
+  isDashboardActive() ? { ...getState().dashboardLayout.present } : {};
+
+const getActiveTabs: typeof dashboardApi.getActiveTabs = () =>
+  isDashboardActive() ? [...(getState().dashboardState.activeTabs ?? [])] : [];
+
+const updateLayoutNode: typeof dashboardApi.updateLayoutNode = async (
+  nodeId: string,
+  meta: Record<string, unknown>,
+) => {
+  requireDashboardId();
+  const node = getState().dashboardLayout.present[nodeId];
+  if (!node) {
+    throw new Error(`Layout node "${nodeId}" not found`);
+  }
+  // UPDATE_COMPONENTS replaces each keyed entry wholesale (it's not a deep
+  // merge), so the node's other fields must be carried through alongside
+  // the merged meta.
+  store.dispatch(
+    updateComponents({
+      [nodeId]: { ...node, meta: { ...node.meta, ...meta } },
+    }) as any,
+  );
+};
+
+const getCss: typeof dashboardApi.getCss = () =>
+  isDashboardActive() ? (getState().dashboardInfo.css ?? '') : '';
+
+const setCss: typeof dashboardApi.setCss = async (css: string) => {
+  requireDashboardId();
+  store.dispatch(dashboardInfoChanged({ css }));
+};
+
+const getFilters: typeof dashboardApi.getFilters = () => {
+  if (!isDashboardActive()) return [];
+  const { nativeFilters, dataMask } = getState();
+  const filterElements = Object.values(nativeFilters.filters) as Array<
+    Filter | Divider
+  >;
+  return filterElements.filter(isNativeFilter).map(filter => {
+    const mask = dataMask[filter.id];
+    return {
+      id: filter.id,
+      name: filter.name,
+      filterType: filter.filterType,
+      targets: filter.targets,
+      extraFormData: mask?.extraFormData,
+      filterState: mask?.filterState,
+    };
+  });
+};
+
+const updateFilters: typeof dashboardApi.updateFilters = async (
+  updates: dashboardApi.FilterValueUpdate[],
+) => {
+  requireDashboardId();
+  updates.forEach(({ filterId, extraFormData, filterState }) => {

Review Comment:
   Unlike `saveFilters`, this never checks `filterId` against the dashboard's 
known native filters before dispatching `updateDataMask`. An unrecognized 
`filterId` (typo, or an id from a filter the caller already removed) still 
lands in Redux `dataMask`, and `getAllActiveFilters` 
(`superset-frontend/src/dashboard/util/activeAllDashboardFilters.ts`) falls 
back to `allSliceIds` — every chart on the dashboard — whenever a `dataMask` 
entry has no matching native filter's `chartsInScope`. A caller's typo would 
silently filter every chart on the dashboard instead of failing or no-op'ing. 
Should this validate `filterId` against `getFilters()`'s ids the same way 
`saveFilters` already does?



##########
superset-frontend/src/core/dashboard/index.ts:
##########
@@ -0,0 +1,214 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { dashboard as dashboardApi } from '@apache-superset/core';
+import { isNativeFilter, makeApi, SupersetClient } from '@superset-ui/core';
+import type {
+  DataMask,
+  Divider,
+  Filter,
+  JsonObject,
+  QueryFormData,
+} from '@superset-ui/core';
+import { updateComponents } from 'src/dashboard/actions/dashboardLayout';
+import { dashboardInfoChanged } from 'src/dashboard/actions/dashboardInfo';
+import { applySavedFilterChanges } from 'src/dashboard/actions/nativeFilters';
+import type { SaveFilterChangesType } from 
'src/dashboard/components/nativeFilters/FiltersConfigModal/types';
+import { updateDataMask } from 'src/dataMask/actions';
+import {
+  setChartFormData,
+  triggerQuery,
+} from 'src/components/Chart/chartAction';
+import { applyDefaultFormData } from 'src/explore/store';
+import extractUrlParams from 'src/dashboard/util/extractUrlParams';
+import { store, RootState } from 'src/views/store';
+import { navigation } from '../navigation';
+
+const getState = () => store.getState() as RootState;
+
+// The Redux slices below are retained across an in-SPA navigation, so
+// checking them alone can't tell a still-active dashboard from a stale one
+// left over from before the user navigated to another page.
+const isDashboardActive = (): boolean => navigation.getPage() === 'dashboard';
+
+const requireDashboardId = (): number => {
+  const { id } = getState().dashboardInfo;
+  if (!isDashboardActive() || id == null) {
+    throw new Error('No dashboard is currently active');
+  }
+  return id;
+};
+
+const getDashboardId: typeof dashboardApi.getDashboardId = () =>
+  isDashboardActive() ? (getState().dashboardInfo.id ?? undefined) : undefined;
+
+const getLayout: typeof dashboardApi.getLayout = () =>
+  isDashboardActive() ? { ...getState().dashboardLayout.present } : {};
+
+const getActiveTabs: typeof dashboardApi.getActiveTabs = () =>
+  isDashboardActive() ? [...(getState().dashboardState.activeTabs ?? [])] : [];
+
+const updateLayoutNode: typeof dashboardApi.updateLayoutNode = async (
+  nodeId: string,
+  meta: Record<string, unknown>,
+) => {
+  requireDashboardId();
+  const node = getState().dashboardLayout.present[nodeId];
+  if (!node) {
+    throw new Error(`Layout node "${nodeId}" not found`);
+  }
+  // UPDATE_COMPONENTS replaces each keyed entry wholesale (it's not a deep
+  // merge), so the node's other fields must be carried through alongside
+  // the merged meta.
+  store.dispatch(
+    updateComponents({
+      [nodeId]: { ...node, meta: { ...node.meta, ...meta } },
+    }) as any,
+  );
+};
+
+const getCss: typeof dashboardApi.getCss = () =>
+  isDashboardActive() ? (getState().dashboardInfo.css ?? '') : '';
+
+const setCss: typeof dashboardApi.setCss = async (css: string) => {
+  requireDashboardId();
+  store.dispatch(dashboardInfoChanged({ css }));
+};
+
+const getFilters: typeof dashboardApi.getFilters = () => {
+  if (!isDashboardActive()) return [];
+  const { nativeFilters, dataMask } = getState();
+  const filterElements = Object.values(nativeFilters.filters) as Array<
+    Filter | Divider
+  >;
+  return filterElements.filter(isNativeFilter).map(filter => {
+    const mask = dataMask[filter.id];
+    return {
+      id: filter.id,
+      name: filter.name,
+      filterType: filter.filterType,
+      targets: filter.targets,
+      extraFormData: mask?.extraFormData,
+      filterState: mask?.filterState,
+    };
+  });
+};
+
+const updateFilters: typeof dashboardApi.updateFilters = async (
+  updates: dashboardApi.FilterValueUpdate[],
+) => {
+  requireDashboardId();
+  updates.forEach(({ filterId, extraFormData, filterState }) => {
+    const dataMask: DataMask = {};
+    if (extraFormData !== undefined) {
+      dataMask.extraFormData = extraFormData;
+    }
+    if (filterState !== undefined) {
+      dataMask.filterState = filterState;
+    }
+    store.dispatch(updateDataMask(filterId, dataMask));
+  });
+};
+
+const saveFilters: typeof dashboardApi.saveFilters = async (
+  updates: dashboardApi.FilterConfigUpdate[],
+  deletedFilterIds: string[] = [],
+) => {
+  const dashboardId = requireDashboardId();
+  const { filters: currentFilters } = getState().nativeFilters;
+
+  const modified = updates.map(
+    ({ filterId, name, targets, defaultDataMask }) => {
+      const existing = currentFilters[filterId];
+      if (!existing) {
+        throw new Error(`Filter "${filterId}" not found on this dashboard`);
+      }
+      return {
+        ...existing,
+        ...(name !== undefined && { name }),
+        ...(targets !== undefined && { targets }),
+        ...(defaultDataMask !== undefined && { defaultDataMask }),
+      };
+    },
+  ) as SaveFilterChangesType['modified'];
+
+  if (modified.length === 0 && deletedFilterIds.length === 0) {
+    return;
+  }
+
+  const filterChanges: SaveFilterChangesType = {
+    modified,
+    deleted: deletedFilterIds,
+    reordered: [],
+  };
+
+  const putFilters = makeApi<SaveFilterChangesType, { result: Filter[] }>({
+    method: 'PUT',
+    endpoint: `/api/v1/dashboard/${dashboardId}/filters`,
+  });
+  const response = await putFilters(filterChanges);
+  applySavedFilterChanges(

Review Comment:
   `saveFilters` builds its baseline from `nativeFilters.filters` (the 
persisted definitions), not from the live `dataMask` a prior `updateFilters` 
call may have written. In the dataMask reducer's 
`updateDataMaskForFilterChanges`, a modified filter's existing live 
`extraFormData`/`filterState` is only preserved when the filter is required 
(`enableEmptyFilter`) or `defaultToFirstItem`; for an ordinary optional filter 
the mask is reset to `filter.defaultDataMask`. So calling 
`updateFilters([{filterId, filterState}])` to set a session-only value, then 
later `saveFilters([{filterId, name}])` for an unrelated rename, silently 
discards that live value and re-queries every chart the filter targets back to 
the default. Should `saveFilters` preserve an existing live mask for a filter 
it isn't otherwise changing the value of?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to