Skip to content

Commit 7a558f7

Browse files
fix(sqllab): enhance table explore tree with schema pinning, column sorting, and table schema refresh (#39396)
Co-authored-by: Michael S. Molina <michael.s.molina@gmail.com> (cherry picked from commit be68040)
1 parent 3b7ddf8 commit 7a558f7

4 files changed

Lines changed: 294 additions & 69 deletions

File tree

superset-frontend/src/SqlLab/components/TableExploreTree/TreeNodeRenderer.tsx

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export interface TreeNodeRendererProps extends NodeRendererProps<TreeNodeData> {
7979
searchTerm: string;
8080
catalog: string | null | undefined;
8181
pinnedTableKeys: Set<string>;
82+
pinnedSchemas: Set<string>;
8283
selectStarMap: Record<string, string>;
8384
handleRefreshTables: (params: {
8485
dbId: number;
@@ -91,6 +92,11 @@ export interface TreeNodeRendererProps extends NodeRendererProps<TreeNodeData> {
9192
catalogName: string | null,
9293
) => void;
9394
handleUnpinTable: (tableName: string, schemaName: string) => void;
95+
handlePinSchema: (schemaName: string) => void;
96+
handleUnpinSchema: (schemaName: string) => void;
97+
refreshTableSchema: (id: string) => void;
98+
sortedTables: Record<string, boolean>;
99+
toggleSortColumns: (tableId: string) => void;
94100
}
95101

96102
const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
@@ -101,19 +107,23 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
101107
searchTerm,
102108
catalog,
103109
pinnedTableKeys,
110+
pinnedSchemas,
104111
selectStarMap,
105112
handleRefreshTables,
106113
handlePinTable,
107114
handleUnpinTable,
115+
handlePinSchema,
116+
handleUnpinSchema,
117+
refreshTableSchema,
118+
sortedTables,
119+
toggleSortColumns,
108120
}) => {
109121
const theme = useTheme();
110122
const { data } = node;
111123
const parts = data.id.split(':');
112124
const [identifier, _dbId, schema, tableName] = parts;
113125

114-
// Use manually tracked open state for icon display
115-
// This prevents search auto-expansion from affecting the icon
116-
const isManuallyOpen = manuallyOpenedNodes[data.id] ?? false;
126+
const isManuallyOpen = node.isOpen && !node.data.disableCheckbox;
117127
const isLoading = loadingNodes[data.id] ?? false;
118128

119129
const renderIcon = () => {
@@ -135,12 +145,7 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
135145
? Icons.FunctionOutlined
136146
: Icons.TableOutlined;
137147
if (isLoading) {
138-
return (
139-
<>
140-
<Icons.LoadingOutlined iconSize="l" />
141-
<TableTypeIcon iconSize="l" />
142-
</>
143-
);
148+
return <Icons.LoadingOutlined iconSize="l" />;
144149
}
145150
return <TableTypeIcon iconSize="l" />;
146151
}
@@ -233,7 +238,27 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
233238
{highlightText(data.name, searchTerm)}
234239
</Typography.Text>
235240
{identifier === 'schema' && (
236-
<div className="side-action-container" role="menu">
241+
<div
242+
className="side-action-container"
243+
role="menu"
244+
onClick={e => e.stopPropagation()}
245+
>
246+
{pinnedSchemas.has(schema) && (
247+
<div className="action-static">
248+
<ActionButton
249+
label={`pinned-schema-${schema}`}
250+
icon={
251+
<Icons.PushpinFilled
252+
iconSize="m"
253+
css={css`
254+
color: ${theme.colorTextDescription};
255+
`}
256+
/>
257+
}
258+
onClick={() => handleUnpinSchema(schema)}
259+
/>
260+
</div>
261+
)}
237262
<div className="action-hover">
238263
<RefreshLabel
239264
onClick={e => {
@@ -246,6 +271,30 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
246271
}}
247272
tooltipContent={t('Force refresh table list')}
248273
/>
274+
<ActionButton
275+
label={
276+
pinnedSchemas.has(schema)
277+
? `unpin-schema-${schema}`
278+
: `pin-schema-${schema}`
279+
}
280+
tooltip={
281+
pinnedSchemas.has(schema)
282+
? t('Unpin from top')
283+
: t('Pin to top')
284+
}
285+
icon={
286+
pinnedSchemas.has(schema) ? (
287+
<Icons.PushpinFilled iconSize="m" />
288+
) : (
289+
<Icons.PushpinOutlined iconSize="m" />
290+
)
291+
}
292+
onClick={() =>
293+
pinnedSchemas.has(schema)
294+
? handleUnpinSchema(schema)
295+
: handlePinSchema(schema)
296+
}
297+
/>
249298
</div>
250299
</div>
251300
)}
@@ -288,6 +337,31 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
288337
}
289338
/>
290339
)}
340+
<ActionButton
341+
label={`sort-cols-${schema}-${tableName}`}
342+
tooltip={
343+
sortedTables[data.id]
344+
? t('Sort by original table order')
345+
: t('Sort columns alphabetically')
346+
}
347+
icon={
348+
<Icons.SortAscendingOutlined
349+
iconSize="m"
350+
css={css`
351+
color: ${sortedTables[data.id]
352+
? theme.colorPrimary
353+
: 'inherit'};
354+
`}
355+
/>
356+
}
357+
onClick={() => toggleSortColumns(data.id)}
358+
/>
359+
<ActionButton
360+
label={`refresh-schema-${schema}-${tableName}`}
361+
tooltip={t('Refresh table schema')}
362+
icon={<Icons.SyncOutlined iconSize="m" />}
363+
onClick={() => refreshTableSchema(data.id)}
364+
/>
291365
<ActionButton
292366
label={
293367
isPinned

superset-frontend/src/SqlLab/components/TableExploreTree/index.tsx

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ import {
4141
import type { SqlLabRootState } from 'src/SqlLab/types';
4242
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
4343
import { addTable, removeTables } from 'src/SqlLab/actions/sqlLab';
44+
import {
45+
getItem,
46+
setItem,
47+
LocalStorageKeys,
48+
} from 'src/utils/localStorageHelpers';
4449
import PanelToolbar from 'src/components/PanelToolbar';
4550
import { ViewLocations } from 'src/SqlLab/contributions';
4651
import TreeNodeRenderer from './TreeNodeRenderer';
@@ -126,6 +131,36 @@ const StyledTreeContainer = styled.div`
126131

127132
const ROW_HEIGHT = 28;
128133

134+
const getPinnedSchemasStorageKey = (
135+
dbId: number | undefined,
136+
catalog: string | null | undefined,
137+
): string => `${dbId ?? ''}:${catalog ?? ''}`;
138+
139+
const getPinnedSchemasFromStorage = (
140+
dbId: number | undefined,
141+
catalog: string | null | undefined,
142+
): Set<string> => {
143+
if (!dbId) return new Set();
144+
const stored = getItem(LocalStorageKeys.SqllabPinnedSchemas, {});
145+
const key = getPinnedSchemasStorageKey(dbId, catalog);
146+
const schemas = stored[key];
147+
return Array.isArray(schemas) ? new Set<string>(schemas) : new Set();
148+
};
149+
150+
const savePinnedSchemasToStorage = (
151+
dbId: number | undefined,
152+
catalog: string | null | undefined,
153+
schemas: Set<string>,
154+
) => {
155+
if (!dbId) return;
156+
const stored = getItem(LocalStorageKeys.SqllabPinnedSchemas, {});
157+
const key = getPinnedSchemasStorageKey(dbId, catalog);
158+
setItem(LocalStorageKeys.SqllabPinnedSchemas, {
159+
...stored,
160+
[key]: [...schemas],
161+
});
162+
};
163+
129164
const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
130165
const dispatch = useDispatch();
131166
const theme = useTheme();
@@ -161,6 +196,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
161196
selectStarMap,
162197
handleToggle,
163198
handleRefreshTables,
199+
refreshTableSchema,
164200
errorPayload,
165201
} = useTreeData({
166202
dbId,
@@ -199,6 +235,83 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
199235
},
200236
[dispatch, tables, editorId, dbId],
201237
);
238+
const [pinnedSchemas, setPinnedSchemas] = useState<Set<string>>(() =>
239+
getPinnedSchemasFromStorage(dbId, catalog),
240+
);
241+
242+
const previousDbIdRef = useRef<number | undefined>(dbId);
243+
const previousCatalogRef = useRef<string | null | undefined>(catalog);
244+
245+
// Single effect handles both loading and persisting pinned schemas.
246+
// Using refs to detect source changes avoids the race condition where the
247+
// persist branch would run with stale pinnedSchemas right after a dbId/catalog
248+
// change, corrupting the new source's stored pins.
249+
useEffect(() => {
250+
const dbChanged = previousDbIdRef.current !== dbId;
251+
const catalogChanged = previousCatalogRef.current !== catalog;
252+
253+
if (dbChanged || catalogChanged) {
254+
previousDbIdRef.current = dbId;
255+
previousCatalogRef.current = catalog;
256+
setPinnedSchemas(getPinnedSchemasFromStorage(dbId, catalog));
257+
return;
258+
}
259+
260+
savePinnedSchemasToStorage(dbId, catalog, pinnedSchemas);
261+
}, [dbId, catalog, pinnedSchemas]);
262+
263+
const handlePinSchema = useCallback((schemaName: string) => {
264+
setPinnedSchemas(prev => new Set([...prev, schemaName]));
265+
}, []);
266+
267+
const handleUnpinSchema = useCallback((schemaName: string) => {
268+
setPinnedSchemas(prev => {
269+
const next = new Set(prev);
270+
next.delete(schemaName);
271+
return next;
272+
});
273+
}, []);
274+
275+
const sortedTreeData = useMemo(() => {
276+
if (pinnedSchemas.size === 0) return treeData;
277+
const pinned = treeData.filter(node => pinnedSchemas.has(node.name));
278+
const rest = treeData.filter(node => !pinnedSchemas.has(node.name));
279+
return [...pinned, ...rest];
280+
}, [treeData, pinnedSchemas]);
281+
282+
const [sortedTables, setSortedTables] = useState<Record<string, boolean>>({});
283+
284+
useEffect(() => {
285+
setSortedTables({});
286+
}, [dbId, catalog]);
287+
288+
const toggleSortColumns = useCallback((tableId: string) => {
289+
setSortedTables(prev => ({ ...prev, [tableId]: !prev[tableId] }));
290+
}, []);
291+
292+
const displayTreeData = useMemo(() => {
293+
const activeSorted = Object.keys(sortedTables).filter(
294+
id => sortedTables[id],
295+
);
296+
if (activeSorted.length === 0) return sortedTreeData;
297+
298+
const sortedSet = new Set(activeSorted);
299+
return sortedTreeData.map(schemaNode => ({
300+
...schemaNode,
301+
children: schemaNode.children?.map(tableNode => {
302+
if (tableNode.type !== 'table' || !sortedSet.has(tableNode.id)) {
303+
return tableNode;
304+
}
305+
const { children } = tableNode;
306+
if (!children || children.length <= 1) return tableNode;
307+
return {
308+
...tableNode,
309+
children: [...children].sort((a, b) => a.name.localeCompare(b.name)),
310+
};
311+
}),
312+
}));
313+
}, [sortedTreeData, sortedTables]);
314+
202315
const [searchTerm, setSearchTerm] = useState('');
203316
const handleSearchChange = useCallback(
204317
({ target }: ChangeEvent<HTMLInputElement>) => setSearchTerm(target.value),
@@ -270,8 +383,8 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
270383
return false;
271384
};
272385

273-
return treeData.some(node => checkNode(node));
274-
}, [searchTerm, treeData]);
386+
return displayTreeData.some(node => checkNode(node));
387+
}, [searchTerm, displayTreeData]);
275388

276389
// Node renderer for react-arborist
277390
const renderNode = useCallback(
@@ -283,19 +396,31 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
283396
searchTerm={searchTerm}
284397
catalog={catalog}
285398
pinnedTableKeys={pinnedTableKeys}
399+
pinnedSchemas={pinnedSchemas}
286400
selectStarMap={selectStarMap}
287401
handleRefreshTables={handleRefreshTables}
288402
handlePinTable={handlePinTable}
289403
handleUnpinTable={handleUnpinTable}
404+
handlePinSchema={handlePinSchema}
405+
handleUnpinSchema={handleUnpinSchema}
406+
refreshTableSchema={refreshTableSchema}
407+
sortedTables={sortedTables}
408+
toggleSortColumns={toggleSortColumns}
290409
/>
291410
),
292411
[
293412
catalog,
294413
pinnedTableKeys,
414+
pinnedSchemas,
295415
selectStarMap,
296416
handleRefreshTables,
297417
handlePinTable,
298418
handleUnpinTable,
419+
handlePinSchema,
420+
handleUnpinSchema,
421+
refreshTableSchema,
422+
sortedTables,
423+
toggleSortColumns,
299424
loadingNodes,
300425
manuallyOpenedNodes,
301426
searchTerm,
@@ -369,7 +494,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
369494
return (
370495
<Tree<TreeNodeData>
371496
ref={treeRef}
372-
data={treeData}
497+
data={displayTreeData}
373498
width="100%"
374499
height={height || 500}
375500
rowHeight={ROW_HEIGHT}

0 commit comments

Comments
 (0)