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


##########
superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts:
##########
@@ -239,6 +243,19 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
       moreProps.row_offset = 0;
     }
 
+    const visibleColumnKeys = Array.isArray(ownState?.visibleColumns)
+      ? ownState.visibleColumns.map(String)
+      : [];
+
+    if (isDownloadQuery && visibleColumnKeys.length > 0) {
+      postProcessing.push({
+        operation: 'select',
+        options: {
+          columns: visibleColumnKeys,

Review Comment:
   With time comparison enabled, this sends UI-only keys such as `Main 
<metric>` and `△ <metric>` to the backend `select` operator, but those names 
are only created later in `transformProps`; the DataFrame still has the raw 
metric/offset columns, so the download is rejected as referencing unavailable 
columns. Could the export projection translate the selected display keys to the 
actual post-processing column names, with a regression covering a 
time-comparison download?



##########
superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx:
##########
@@ -843,13 +857,265 @@ export default function TableChart<D extends DataRecord 
= DataRecord>(
     );
   };
 
-  // Compute visible columns before groupHeaderColumns to ensure index 
consistency.
-  // This filters out columns with config.visible === false.
-  const visibleColumnsMeta = useMemo(
+  const selectableColumnsMeta = useMemo(
     () => filteredColumnsMeta.filter(col => col.config?.visible !== false),
     [filteredColumnsMeta],
   );
 
+  const selectableColumnKeys = useMemo(
+    () => selectableColumnsMeta.map(col => String(col.key)),
+    [selectableColumnsMeta],
+  );
+
+  const visibleColumnsStorageKey = useMemo(() => {
+    if (typeof window === 'undefined') {
+      return null;
+    }
+    const pathname = window.location?.pathname || '';
+    if (!pathname.includes(DASHBOARD_PATH_SEGMENT)) {
+      return null;
+    }
+    const match = pathname.match(/\/superset\/dashboard\/(?:p\/)?([^/?]+)/);
+    const dashboardId = match ? match[1] : 'unknown';
+    return `superset.table.visibleColumns:${dashboardId}:${slice_id}`;
+  }, [slice_id]);
+
+  const storedVisibleColumnKeys = useMemo(() => {
+    if (!visibleColumnsStorageKey || typeof window === 'undefined') {
+      return [];
+    }
+    try {
+      const raw = window.localStorage.getItem(visibleColumnsStorageKey);
+      if (!raw) {
+        return [];
+      }
+      const parsed = JSON.parse(raw);
+      return sanitizeVisibleColumnSelection(
+        parsed,
+        new Set(selectableColumnKeys),
+      );
+    } catch {
+      return [];
+    }
+  }, [selectableColumnKeys, visibleColumnsStorageKey]);
+
+  const [selectedVisibleColumnKeys, setSelectedVisibleColumnKeys] = useState<
+    string[]
+  >(() => {
+    const availableKeys = new Set(selectableColumnKeys);
+    const selectedFromOwnState = sanitizeVisibleColumnSelection(
+      serverPaginationData?.visibleColumns,
+      availableKeys,
+    );
+    if (selectedFromOwnState.length > 0) {
+      return selectedFromOwnState;
+    }
+    if (storedVisibleColumnKeys.length > 0) {
+      return storedVisibleColumnKeys;
+    }
+    return selectableColumnKeys;
+  });
+
+  useEffect(() => {
+    setSelectedVisibleColumnKeys(prevSelection => {
+      const availableKeys = new Set(selectableColumnKeys);
+      const sanitizedCurrent = prevSelection.filter(key =>
+        availableKeys.has(key),
+      );
+
+      const selectedFromOwnState = sanitizeVisibleColumnSelection(
+        serverPaginationData?.visibleColumns,
+        availableKeys,
+      );
+      if (selectedFromOwnState.length > 0) {
+        return isEqual(sanitizedCurrent, selectedFromOwnState)
+          ? sanitizedCurrent
+          : selectedFromOwnState;
+      }
+
+      if (storedVisibleColumnKeys.length > 0) {
+        return isEqual(sanitizedCurrent, storedVisibleColumnKeys)
+          ? sanitizedCurrent
+          : storedVisibleColumnKeys;
+      }
+
+      if (sanitizedCurrent.length > 0) {
+        return sanitizedCurrent;
+      }
+
+      return selectableColumnKeys;
+    });
+  }, [
+    selectableColumnKeys,
+    serverPaginationData?.visibleColumns,
+    storedVisibleColumnKeys,
+  ]);
+
+  const persistTableOwnState = useCallback(
+    (updates: Record<string, unknown>) => {
+      const selectedKeys =
+        (updates.visibleColumns as string[] | undefined) ??
+        selectedVisibleColumnKeys;
+      if (visibleColumnsStorageKey && typeof window !== 'undefined') {

Review Comment:
   This writes the current visible-column snapshot on every own-state update, 
including the mount-time `clientView` effect, so merely viewing a chart records 
today's full column list as an explicit preference. When an editor later adds a 
column, returning viewers keep the old non-empty snapshot and silently never 
see or export the new field; could storage be updated only after an actual 
selector change, with a way to return to the default?



##########
superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx:
##########
@@ -843,13 +857,265 @@ export default function TableChart<D extends DataRecord 
= DataRecord>(
     );
   };
 
-  // Compute visible columns before groupHeaderColumns to ensure index 
consistency.
-  // This filters out columns with config.visible === false.
-  const visibleColumnsMeta = useMemo(
+  const selectableColumnsMeta = useMemo(
     () => filteredColumnsMeta.filter(col => col.config?.visible !== false),
     [filteredColumnsMeta],
   );
 
+  const selectableColumnKeys = useMemo(
+    () => selectableColumnsMeta.map(col => String(col.key)),
+    [selectableColumnsMeta],
+  );
+
+  const visibleColumnsStorageKey = useMemo(() => {
+    if (typeof window === 'undefined') {
+      return null;
+    }
+    const pathname = window.location?.pathname || '';
+    if (!pathname.includes(DASHBOARD_PATH_SEGMENT)) {
+      return null;
+    }
+    const match = pathname.match(/\/superset\/dashboard\/(?:p\/)?([^/?]+)/);
+    const dashboardId = match ? match[1] : 'unknown';
+    return `superset.table.visibleColumns:${dashboardId}:${slice_id}`;
+  }, [slice_id]);
+
+  const storedVisibleColumnKeys = useMemo(() => {
+    if (!visibleColumnsStorageKey || typeof window === 'undefined') {
+      return [];
+    }
+    try {
+      const raw = window.localStorage.getItem(visibleColumnsStorageKey);
+      if (!raw) {
+        return [];
+      }
+      const parsed = JSON.parse(raw);
+      return sanitizeVisibleColumnSelection(
+        parsed,
+        new Set(selectableColumnKeys),
+      );
+    } catch {
+      return [];
+    }
+  }, [selectableColumnKeys, visibleColumnsStorageKey]);
+
+  const [selectedVisibleColumnKeys, setSelectedVisibleColumnKeys] = useState<
+    string[]
+  >(() => {
+    const availableKeys = new Set(selectableColumnKeys);
+    const selectedFromOwnState = sanitizeVisibleColumnSelection(
+      serverPaginationData?.visibleColumns,
+      availableKeys,
+    );
+    if (selectedFromOwnState.length > 0) {
+      return selectedFromOwnState;
+    }
+    if (storedVisibleColumnKeys.length > 0) {
+      return storedVisibleColumnKeys;
+    }
+    return selectableColumnKeys;
+  });
+
+  useEffect(() => {
+    setSelectedVisibleColumnKeys(prevSelection => {
+      const availableKeys = new Set(selectableColumnKeys);
+      const sanitizedCurrent = prevSelection.filter(key =>
+        availableKeys.has(key),
+      );
+
+      const selectedFromOwnState = sanitizeVisibleColumnSelection(
+        serverPaginationData?.visibleColumns,
+        availableKeys,
+      );
+      if (selectedFromOwnState.length > 0) {
+        return isEqual(sanitizedCurrent, selectedFromOwnState)
+          ? sanitizedCurrent
+          : selectedFromOwnState;
+      }
+
+      if (storedVisibleColumnKeys.length > 0) {
+        return isEqual(sanitizedCurrent, storedVisibleColumnKeys)
+          ? sanitizedCurrent
+          : storedVisibleColumnKeys;
+      }
+
+      if (sanitizedCurrent.length > 0) {
+        return sanitizedCurrent;
+      }
+
+      return selectableColumnKeys;
+    });
+  }, [
+    selectableColumnKeys,
+    serverPaginationData?.visibleColumns,
+    storedVisibleColumnKeys,
+  ]);
+
+  const persistTableOwnState = useCallback(
+    (updates: Record<string, unknown>) => {
+      const selectedKeys =
+        (updates.visibleColumns as string[] | undefined) ??
+        selectedVisibleColumnKeys;
+      if (visibleColumnsStorageKey && typeof window !== 'undefined') {
+        try {
+          if (selectedKeys?.length) {
+            window.localStorage.setItem(
+              visibleColumnsStorageKey,
+              JSON.stringify(selectedKeys),
+            );
+          } else {
+            window.localStorage.removeItem(visibleColumnsStorageKey);
+          }
+        } catch {
+          // no-op: storage write failures should not block table interactions
+        }
+      }
+      updateTableOwnState(setDataMask, {
+        ...serverPaginationData,
+        ...updates,
+        ...(selectedKeys?.length ? { visibleColumns: selectedKeys } : {}),
+      });
+    },
+    [
+      selectedVisibleColumnKeys,
+      serverPaginationData,
+      setDataMask,
+      visibleColumnsStorageKey,
+    ],
+  );
+
+  useEffect(() => {
+    if (storedVisibleColumnKeys.length === 0) {
+      return;
+    }
+    const selectedFromOwnState = sanitizeVisibleColumnSelection(
+      serverPaginationData?.visibleColumns,
+      new Set(selectableColumnKeys),
+    );
+    if (
+      selectedFromOwnState.length === 0 &&
+      selectedVisibleColumnKeys.length > 0
+    ) {
+      persistTableOwnState({ visibleColumns: selectedVisibleColumnKeys });
+    }
+  }, [
+    persistTableOwnState,
+    selectableColumnKeys,
+    selectedVisibleColumnKeys,
+    serverPaginationData?.visibleColumns,
+    storedVisibleColumnKeys.length,
+  ]);
+
+  const handleVisibleColumnsChange = useCallback(
+    (nextVisibleColumns: string[]) => {
+      if (isEqual(nextVisibleColumns, selectedVisibleColumnKeys)) {
+        return;
+      }
+      setSelectedVisibleColumnKeys(nextVisibleColumns);
+      persistTableOwnState({ visibleColumns: nextVisibleColumns });
+    },
+    [persistTableOwnState, selectedVisibleColumnKeys],
+  );
+
+  const renderColumnSelectDropdown = (): JSX.Element | null => {
+    if (!selectableColumnsMeta.length || !visibleColumnsStorageKey) {
+      return null;
+    }
+
+    const handleOnClick = ({ key }: { key: string }) => {
+      const targetKey = String(key);
+      const isSelected = selectedVisibleColumnKeys.includes(targetKey);
+      const nextVisibleColumns = isSelected
+        ? selectedVisibleColumnKeys.filter(columnKey => columnKey !== 
targetKey)
+        : [...selectedVisibleColumnKeys, targetKey];

Review Comment:
   Re-enabling a middle column appends its key here, while the table still 
renders columns in metadata order and backend `select` preserves this array 
order. After hiding and restoring `B` in `A, B, C`, the dashboard shows `A, B, 
C` but the export becomes `A, C, B`; could the persisted selection be 
normalized back to metadata order?



##########
superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx:
##########
@@ -843,13 +857,265 @@ export default function TableChart<D extends DataRecord 
= DataRecord>(
     );
   };
 
-  // Compute visible columns before groupHeaderColumns to ensure index 
consistency.
-  // This filters out columns with config.visible === false.
-  const visibleColumnsMeta = useMemo(
+  const selectableColumnsMeta = useMemo(
     () => filteredColumnsMeta.filter(col => col.config?.visible !== false),
     [filteredColumnsMeta],
   );
 
+  const selectableColumnKeys = useMemo(
+    () => selectableColumnsMeta.map(col => String(col.key)),
+    [selectableColumnsMeta],
+  );
+
+  const visibleColumnsStorageKey = useMemo(() => {
+    if (typeof window === 'undefined') {
+      return null;
+    }
+    const pathname = window.location?.pathname || '';
+    if (!pathname.includes(DASHBOARD_PATH_SEGMENT)) {
+      return null;
+    }
+    const match = pathname.match(/\/superset\/dashboard\/(?:p\/)?([^/?]+)/);
+    const dashboardId = match ? match[1] : 'unknown';
+    return `superset.table.visibleColumns:${dashboardId}:${slice_id}`;
+  }, [slice_id]);
+
+  const storedVisibleColumnKeys = useMemo(() => {
+    if (!visibleColumnsStorageKey || typeof window === 'undefined') {
+      return [];
+    }
+    try {
+      const raw = window.localStorage.getItem(visibleColumnsStorageKey);
+      if (!raw) {
+        return [];
+      }
+      const parsed = JSON.parse(raw);
+      return sanitizeVisibleColumnSelection(
+        parsed,
+        new Set(selectableColumnKeys),
+      );
+    } catch {
+      return [];
+    }
+  }, [selectableColumnKeys, visibleColumnsStorageKey]);
+
+  const [selectedVisibleColumnKeys, setSelectedVisibleColumnKeys] = useState<
+    string[]
+  >(() => {
+    const availableKeys = new Set(selectableColumnKeys);
+    const selectedFromOwnState = sanitizeVisibleColumnSelection(
+      serverPaginationData?.visibleColumns,
+      availableKeys,
+    );
+    if (selectedFromOwnState.length > 0) {
+      return selectedFromOwnState;
+    }
+    if (storedVisibleColumnKeys.length > 0) {
+      return storedVisibleColumnKeys;
+    }
+    return selectableColumnKeys;
+  });
+
+  useEffect(() => {
+    setSelectedVisibleColumnKeys(prevSelection => {
+      const availableKeys = new Set(selectableColumnKeys);
+      const sanitizedCurrent = prevSelection.filter(key =>
+        availableKeys.has(key),
+      );
+
+      const selectedFromOwnState = sanitizeVisibleColumnSelection(
+        serverPaginationData?.visibleColumns,
+        availableKeys,
+      );
+      if (selectedFromOwnState.length > 0) {
+        return isEqual(sanitizedCurrent, selectedFromOwnState)
+          ? sanitizedCurrent
+          : selectedFromOwnState;
+      }
+
+      if (storedVisibleColumnKeys.length > 0) {
+        return isEqual(sanitizedCurrent, storedVisibleColumnKeys)
+          ? sanitizedCurrent
+          : storedVisibleColumnKeys;
+      }
+
+      if (sanitizedCurrent.length > 0) {
+        return sanitizedCurrent;
+      }
+
+      return selectableColumnKeys;
+    });
+  }, [
+    selectableColumnKeys,
+    serverPaginationData?.visibleColumns,
+    storedVisibleColumnKeys,
+  ]);
+
+  const persistTableOwnState = useCallback(
+    (updates: Record<string, unknown>) => {
+      const selectedKeys =
+        (updates.visibleColumns as string[] | undefined) ??
+        selectedVisibleColumnKeys;
+      if (visibleColumnsStorageKey && typeof window !== 'undefined') {
+        try {
+          if (selectedKeys?.length) {
+            window.localStorage.setItem(
+              visibleColumnsStorageKey,
+              JSON.stringify(selectedKeys),
+            );
+          } else {
+            window.localStorage.removeItem(visibleColumnsStorageKey);
+          }
+        } catch {
+          // no-op: storage write failures should not block table interactions
+        }
+      }
+      updateTableOwnState(setDataMask, {
+        ...serverPaginationData,
+        ...updates,
+        ...(selectedKeys?.length ? { visibleColumns: selectedKeys } : {}),
+      });
+    },
+    [
+      selectedVisibleColumnKeys,
+      serverPaginationData,
+      setDataMask,
+      visibleColumnsStorageKey,
+    ],
+  );
+
+  useEffect(() => {
+    if (storedVisibleColumnKeys.length === 0) {
+      return;
+    }
+    const selectedFromOwnState = sanitizeVisibleColumnSelection(
+      serverPaginationData?.visibleColumns,
+      new Set(selectableColumnKeys),
+    );
+    if (
+      selectedFromOwnState.length === 0 &&
+      selectedVisibleColumnKeys.length > 0
+    ) {
+      persistTableOwnState({ visibleColumns: selectedVisibleColumnKeys });
+    }
+  }, [
+    persistTableOwnState,
+    selectableColumnKeys,
+    selectedVisibleColumnKeys,
+    serverPaginationData?.visibleColumns,
+    storedVisibleColumnKeys.length,
+  ]);
+
+  const handleVisibleColumnsChange = useCallback(
+    (nextVisibleColumns: string[]) => {
+      if (isEqual(nextVisibleColumns, selectedVisibleColumnKeys)) {
+        return;
+      }
+      setSelectedVisibleColumnKeys(nextVisibleColumns);
+      persistTableOwnState({ visibleColumns: nextVisibleColumns });
+    },
+    [persistTableOwnState, selectedVisibleColumnKeys],
+  );
+
+  const renderColumnSelectDropdown = (): JSX.Element | null => {
+    if (!selectableColumnsMeta.length || !visibleColumnsStorageKey) {
+      return null;
+    }
+
+    const handleOnClick = ({ key }: { key: string }) => {
+      const targetKey = String(key);
+      const isSelected = selectedVisibleColumnKeys.includes(targetKey);
+      const nextVisibleColumns = isSelected
+        ? selectedVisibleColumnKeys.filter(columnKey => columnKey !== 
targetKey)
+        : [...selectedVisibleColumnKeys, targetKey];
+
+      // Keep at least one visible column so table structure stays valid.
+      if (!nextVisibleColumns.length) {
+        return;
+      }
+
+      handleVisibleColumnsChange(nextVisibleColumns);
+    };
+
+    return (
+      <Dropdown
+        placement="bottomRight"
+        open={showColumnSelectorDropdown}
+        onOpenChange={(flag: boolean) => {
+          setShowColumnSelectorDropdown(flag);
+        }}
+        menu={{
+          selectable: true,
+          multiple: true,
+          onClick: handleOnClick,
+          selectedKeys: selectedVisibleColumnKeys,
+          items: [
+            {
+              key: 'columns-group',
+              label: (
+                <div
+                  css={css`
+                    max-width: 260px;
+                    padding: 0 ${theme.sizeUnit * 2}px;
+                    color: ${theme.colorText};
+                    font-size: ${theme.fontSizeSM}px;
+                  `}
+                >
+                  {t(
+                    'Choose which columns should be visible in this table on 
the dashboard.',
+                  )}
+                </div>
+              ),
+              type: 'group',
+              children: selectableColumnsMeta.map(column => ({
+                key: String(column.key),
+                label: (
+                  <>
+                    <span
+                      css={css`
+                        color: ${theme.colorText};
+                      `}
+                    >
+                      {column.config?.customColumnName ||
+                        column.originalLabel ||
+                        column.label ||
+                        column.key}
+                    </span>
+                    <span
+                      css={css`
+                        float: right;
+                        font-size: ${theme.fontSizeSM}px;
+                      `}
+                    >
+                      {selectedVisibleColumnKeys.includes(
+                        String(column.key),
+                      ) && <CheckOutlined />}
+                    </span>
+                  </>
+                ),
+              })),
+            },
+          ],
+        }}
+        trigger={['click']}
+      >
+        <span>

Review Comment:
   The dropdown trigger is a plain `span`, so it has no keyboard focus or 
button semantics and keyboard-only dashboard users cannot reach the new Columns 
control. Could this use the project's focusable button control, with keyboard 
coverage for opening the menu?



##########
superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx:
##########
@@ -473,6 +473,38 @@ describe('plugin-chart-table', () => {
         expect(cells[4]).toHaveTextContent('2.47k');
       });
 
+      test('render columns dropdown when visibleColumnsStorageKey is 
provided', async () => {
+        const props = transformProps(testData.advanced);
+        const persistTableOwnState = jest.fn();
+        render(
+          ProviderWrapper({
+            children: (
+              <TableChart
+                {...props}
+                visibleColumnsStorageKey="table_col_key"
+                persistTableOwnState={persistTableOwnState}
+                sticky={false}

Review Comment:
   Agreed—the test injects props that `TableChart` neither declares nor reads, 
while the component derives its dashboard gate from `window.location`, so this 
case cannot exercise the selector. Could it set a dashboard pathname and assert 
the real `setDataMask` or local-storage behavior instead?



-- 
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