feat(sqllab): treeview table selection ui (#37298)

This commit is contained in:
JUST.in DO IT
2026-01-30 11:07:56 -08:00
committed by GitHub
parent 66519c3a85
commit 570cc3e5f8
25 changed files with 1697 additions and 403 deletions

View File

@@ -53,7 +53,11 @@ const StyledContainer = styled.div`
const StyledSidebar = styled.div`
position: relative;
padding: ${({ theme }) => theme.sizeUnit * 2.5}px;
padding: ${({ theme }) => theme.sizeUnit * 2.5}px 0;
margin: 0 ${({ theme }) => theme.sizeUnit * 2.5}px;
flex: 1;
height: 100%;
background-color: ${({ theme }) => theme.colorBgBase};
`;
const ContentWrapper = styled.div`

View File

@@ -63,6 +63,7 @@ export type QueryPayload = {
const Styles = styled.span`
display: contents;
white-space: nowrap;
span[role='img']:not([aria-label='down']) {
display: flex;
margin: 0;

View File

@@ -75,6 +75,15 @@ jest.mock('@superset-ui/core/components/AsyncAceEditor', () => ({
}));
jest.mock('src/SqlLab/components/ResultSet', () => jest.fn());
jest.mock('src/components/DatabaseSelector', () => ({
__esModule: true,
DatabaseSelector: ({ sqlLabMode }: { sqlLabMode?: boolean }) => (
<div data-test="mock-database-selector" data-sqllab-mode={sqlLabMode}>
Mock DatabaseSelector
</div>
),
}));
fetchMock.get('glob:*/api/v1/database/*/function_names/', {
function_names: [],
});

View File

@@ -22,7 +22,6 @@ import {
screen,
userEvent,
waitFor,
within,
} from 'spec/helpers/testing-library';
import SqlEditorLeftBar, {
SqlEditorLeftBarProps,
@@ -31,12 +30,31 @@ import {
table,
initialState,
defaultQueryEditor,
extraQueryEditor1,
extraQueryEditor2,
} from 'src/SqlLab/fixtures';
import type { RootState } from 'src/views/store';
import type { Store } from 'redux';
// Mock TableExploreTree to avoid complex tree rendering in tests
jest.mock('../TableExploreTree', () => ({
__esModule: true,
default: () => (
<div data-test="mock-table-explore-tree">TableExploreTree</div>
),
}));
// Helper to switch from default TreeView to SelectView
const switchToSelectView = async () => {
const changeButton = screen.getByTestId('DatabaseSelector');
// Click Change button to open database selector modal
await userEvent.click(changeButton);
// Verify popup is opened
await waitFor(() => {
expect(screen.getByText('Select Database and Schema')).toBeInTheDocument();
});
};
const mockedProps = {
queryEditorId: defaultQueryEditor.id,
height: 0,
@@ -109,81 +127,25 @@ const renderAndWait = (
}),
);
test('renders a TableElement', async () => {
const { findByText, getAllByTestId } = await renderAndWait(
mockedProps,
undefined,
{
...initialState,
sqlLab: {
...initialState.sqlLab,
tables: [table],
databases: { [mockData.database.id]: { ...mockData.database } },
},
},
);
expect(await findByText(/Database/i)).toBeInTheDocument();
const tableElement = getAllByTestId('table-element');
expect(tableElement.length).toBeGreaterThanOrEqual(1);
});
test('table should be visible when expanded is true', async () => {
const { container, getByText, getByRole, getAllByLabelText } =
await renderAndWait(mockedProps, undefined, {
...initialState,
sqlLab: {
...initialState.sqlLab,
tables: [table],
databases: { [mockData.database.id]: { ...mockData.database } },
},
});
const dbSelect = getByRole('combobox', {
name: 'Select database or type to search databases',
});
const schemaSelect = getByRole('combobox', {
name: 'Select schema or type to search schemas: main',
});
const tableSelect = getAllByLabelText(
/Select table or type to search tables/i,
)[0];
const tableOption = within(tableSelect).getByText(/ab_user/i);
expect(getByText(/Database/i)).toBeInTheDocument();
expect(dbSelect).toBeInTheDocument();
expect(schemaSelect).toBeInTheDocument();
expect(tableSelect).toBeInTheDocument();
expect(tableOption).toBeInTheDocument();
expect(
container.querySelector('.ant-collapse-content-active'),
).toBeInTheDocument();
table.columns.forEach(({ name }) => {
expect(getByText(name)).toBeInTheDocument();
});
});
test('catalog selector should be visible when enabled in the database', async () => {
const { container, getByText, getByRole } = await renderAndWait(
mockedProps,
undefined,
{
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: mockedProps.queryEditorId,
dbId: mockData.database.id,
},
tables: [table],
databases: {
[mockData.database.id]: {
...mockData.database,
allow_multi_catalog: true,
},
const { getByRole } = await renderAndWait(mockedProps, undefined, {
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: mockedProps.queryEditorId,
dbId: mockData.database.id,
},
tables: [table],
databases: {
[mockData.database.id]: {
...mockData.database,
allow_multi_catalog: true,
},
},
},
);
});
await switchToSelectView();
const dbSelect = getByRole('combobox', {
name: 'Select database or type to search databases',
@@ -191,131 +153,9 @@ test('catalog selector should be visible when enabled in the database', async ()
const catalogSelect = getByRole('combobox', {
name: 'Select catalog or type to search catalogs',
});
const schemaSelect = getByRole('combobox', {
name: 'Select schema or type to search schemas',
});
const dropdown = getByText(/Select table/i);
const abUser = getByText(/ab_user/i);
expect(getByText(/Database/i)).toBeInTheDocument();
expect(dbSelect).toBeInTheDocument();
expect(catalogSelect).toBeInTheDocument();
expect(schemaSelect).toBeInTheDocument();
expect(dropdown).toBeInTheDocument();
expect(abUser).toBeInTheDocument();
expect(
container.querySelector('.ant-collapse-content-active'),
).toBeInTheDocument();
table.columns.forEach(({ name }) => {
expect(getByText(name)).toBeInTheDocument();
});
});
test('should toggle the table when the header is clicked', async () => {
const { container } = await renderAndWait(mockedProps, undefined, {
...initialState,
sqlLab: {
...initialState.sqlLab,
tables: [table],
unsavedQueryEditor: {
id: mockedProps.queryEditorId,
dbId: mockData.database.id,
},
databases: {
[mockData.database.id]: {
...mockData.database,
allow_multi_catalog: true,
},
},
},
});
const header = container.querySelector('.ant-collapse-header');
expect(header).toBeInTheDocument();
if (header) {
userEvent.click(header);
}
await waitFor(() =>
expect(
container.querySelector('.ant-collapse-content-inactive'),
).toBeInTheDocument(),
);
});
test('When changing database the schema and table list must be updated', async () => {
const reduxState = {
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: defaultQueryEditor.id,
schema: 'db1_schema',
dbId: mockData.database.id,
},
queryEditors: [
defaultQueryEditor,
{
...extraQueryEditor1,
schema: 'new_schema',
dbId: 2,
},
],
tables: [
{
...table,
dbId: defaultQueryEditor.dbId,
schema: 'db1_schema',
},
{
...table,
dbId: 2,
schema: 'new_schema',
name: 'new_table',
queryEditorId: extraQueryEditor1.id,
},
],
databases: {
[mockData.database.id]: {
...mockData.database,
allow_multi_catalog: true,
},
2: {
id: 2,
database_name: 'new_db',
backend: 'postgresql',
},
},
},
};
const { rerender } = await renderAndWait(mockedProps, undefined, reduxState);
expect(screen.getAllByText(/main/i)[0]).toBeInTheDocument();
expect(screen.getAllByText(/ab_user/i)[0]).toBeInTheDocument();
rerender(
<SqlEditorLeftBar {...mockedProps} queryEditorId={extraQueryEditor1.id} />,
);
const updatedDbSelector = await screen.findAllByText(/new_db/i);
expect(updatedDbSelector[0]).toBeInTheDocument();
const select = screen.getByRole('combobox', {
name: 'Select schema or type to search schemas',
});
userEvent.click(select);
expect(
await screen.findByRole('option', { name: 'main' }),
).toBeInTheDocument();
expect(
await screen.findByRole('option', { name: 'new_schema' }),
).toBeInTheDocument();
userEvent.click(screen.getByText('new_schema'));
const updatedTableSelector = await screen.findAllByText(/new_table/i);
expect(updatedTableSelector[0]).toBeInTheDocument();
});
test('display no compatible schema found when schema api throws errors', async () => {
@@ -351,10 +191,12 @@ test('display no compatible schema found when schema api throws errors', async (
undefined,
reduxState,
);
await switchToSelectView();
await waitFor(() =>
expect(
fetchMock.callHistory.calls('glob:*/api/v1/database/3/schemas/?*'),
).toHaveLength(1),
fetchMock.callHistory.calls('glob:*/api/v1/database/3/schemas/?*').length,
).toBeGreaterThanOrEqual(1),
);
const select = screen.getByRole('combobox', {
name: 'Select schema or type to search schemas',
@@ -384,8 +226,7 @@ test('ignore schema api when current schema is deprecated', async () => {
},
},
});
expect(await screen.findByText(/Database/i)).toBeInTheDocument();
await switchToSelectView();
expect(fetchMock.callHistory.calls()).not.toContainEqual(
expect.arrayContaining([
expect.stringContaining(
@@ -393,8 +234,4 @@ test('ignore schema api when current schema is deprecated', async () => {
),
]),
);
// Deselect the deprecated schema selection
await waitFor(() =>
expect(screen.queryByText(/None/i)).not.toBeInTheDocument(),
);
});

View File

@@ -16,36 +16,35 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useMemo, useState } from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import { SqlLabRootState, Table } from 'src/SqlLab/types';
import { resetState } from 'src/SqlLab/actions/sqlLab';
import {
addTable,
removeTables,
collapseTable,
expandTable,
resetState,
} from 'src/SqlLab/actions/sqlLab';
import { Button, EmptyState, Icons } from '@superset-ui/core/components';
Button,
EmptyState,
Flex,
Icons,
Popover,
Typography,
} from '@superset-ui/core/components';
import { t } from '@apache-superset/core';
import { styled, css } from '@apache-superset/core/ui';
import { TableSelectorMultiple } from 'src/components/TableSelector';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import { noop } from 'lodash';
import TableElement from '../TableElement';
import type { SchemaOption, CatalogOption } from 'src/hooks/apiResources';
import { DatabaseSelector, type DatabaseObject } from 'src/components';
import useDatabaseSelector from '../SqlEditorTopBar/useDatabaseSelector';
import TableExploreTree from '../TableExploreTree';
export interface SqlEditorLeftBarProps {
queryEditorId: string;
}
const StyledScrollbarContainer = styled.div`
flex: 1 1 auto;
overflow: auto;
`;
const LeftBarStyles = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
${({ theme }) => css`
height: 100%;
display: flex;
@@ -53,117 +52,153 @@ const LeftBarStyles = styled.div`
.divider {
border-bottom: 1px solid ${theme.colorSplit};
margin: ${theme.sizeUnit * 4}px 0;
margin: ${theme.sizeUnit * 1}px 0;
}
`}
`;
const StyledDivider = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.colorSplit};
margin: 0 -${({ theme }) => theme.sizeUnit * 2.5}px 0;
`;
const SqlEditorLeftBar = ({ queryEditorId }: SqlEditorLeftBarProps) => {
const { db: userSelectedDb, ...dbSelectorProps } =
useDatabaseSelector(queryEditorId);
const allSelectedTables = useSelector<SqlLabRootState, Table[]>(
({ sqlLab }) =>
sqlLab.tables.filter(table => table.queryEditorId === queryEditorId),
shallowEqual,
);
const dbSelectorProps = useDatabaseSelector(queryEditorId);
const { db, catalog, schema, onDbChange, onCatalogChange, onSchemaChange } =
dbSelectorProps;
const dispatch = useDispatch();
const queryEditor = useQueryEditor(queryEditorId, [
'dbId',
'catalog',
'schema',
'tabViewId',
]);
const [_emptyResultsWithSearch, setEmptyResultsWithSearch] = useState(false);
const { dbId, schema } = queryEditor;
const tables = useMemo(
() =>
allSelectedTables.filter(
table => table.dbId === dbId && table.schema === schema,
),
[allSelectedTables, dbId, schema],
const shouldShowReset = window.location.search === '?reset=1';
// Modal state for Database/Catalog/Schema selector
const [selectorModalOpen, setSelectorModalOpen] = useState(false);
const [modalDb, setModalDb] = useState<DatabaseObject | undefined>(undefined);
const [modalCatalog, setModalCatalog] = useState<
CatalogOption | null | undefined
>(undefined);
const [modalSchema, setModalSchema] = useState<SchemaOption | undefined>(
undefined,
);
noop(_emptyResultsWithSearch); // This is to avoid unused variable warning, can be removed if not needed
const openSelectorModal = useCallback(() => {
setModalDb(db ?? undefined);
setModalCatalog(
catalog ? { label: catalog, value: catalog, title: catalog } : undefined,
);
setModalSchema(
schema ? { label: schema, value: schema, title: schema } : undefined,
);
setSelectorModalOpen(true);
}, [db, catalog, schema]);
const onEmptyResults = useCallback((searchText?: string) => {
setEmptyResultsWithSearch(!!searchText);
const closeSelectorModal = useCallback(() => {
setSelectorModalOpen(false);
}, []);
const selectedTableNames = useMemo(
() => tables?.map(table => table.name) || [],
[tables],
);
const onTablesChange = (
tableNames: string[],
catalogName: string | null,
schemaName: string,
) => {
if (!schemaName) {
return;
const handleModalOk = useCallback(() => {
if (modalDb && modalDb.id !== db?.id) {
onDbChange?.(modalDb);
}
const currentTables = [...tables];
const tablesToAdd = tableNames.filter(name => {
const index = currentTables.findIndex(table => table.name === name);
if (index >= 0) {
currentTables.splice(index, 1);
return false;
}
return true;
});
tablesToAdd.forEach(tableName => {
dispatch(addTable(queryEditor, tableName, catalogName, schemaName));
});
dispatch(removeTables(currentTables));
};
const onToggleTable = (updatedTables: string[]) => {
tables.forEach(table => {
if (!updatedTables.includes(table.id.toString()) && table.expanded) {
dispatch(collapseTable(table));
} else if (
updatedTables.includes(table.id.toString()) &&
!table.expanded
) {
dispatch(expandTable(table));
}
});
};
const shouldShowReset = window.location.search === '?reset=1';
if (modalCatalog?.value !== catalog) {
onCatalogChange?.(modalCatalog?.value);
}
if (modalSchema?.value !== schema) {
onSchemaChange?.(modalSchema?.value ?? '');
}
setSelectorModalOpen(false);
}, [
modalDb,
modalCatalog,
modalSchema,
db,
catalog,
schema,
onDbChange,
onCatalogChange,
onSchemaChange,
]);
const handleResetState = useCallback(() => {
dispatch(resetState());
}, [dispatch]);
const popoverContent = (
<Flex
vertical
gap="middle"
data-test="DatabaseSelector"
css={css`
min-width: 500px;
`}
>
<Typography.Title level={5} style={{ margin: 0 }}>
{t('Select Database and Schema')}
</Typography.Title>
<DatabaseSelector
key={modalDb ? modalDb.id : 'no-db'}
db={modalDb}
emptyState={<EmptyState />}
getDbList={dbSelectorProps.getDbList}
handleError={dbSelectorProps.handleError}
onDbChange={setModalDb}
onCatalogChange={cat =>
setModalCatalog(
cat ? { label: cat, value: cat, title: cat } : undefined,
)
}
catalog={modalCatalog?.value}
onSchemaChange={sch =>
setModalSchema(
sch ? { label: sch, value: sch, title: sch } : undefined,
)
}
schema={modalSchema?.value}
sqlLabMode={false}
/>
<Flex justify="flex-end" gap="small">
<Button
buttonStyle="tertiary"
onClick={e => {
e?.stopPropagation();
closeSelectorModal();
}}
>
{t('Cancel')}
</Button>
<Button
type="primary"
onClick={e => {
e?.stopPropagation();
handleModalOk();
}}
>
{t('Select')}
</Button>
</Flex>
</Flex>
);
return (
<LeftBarStyles data-test="sql-editor-left-bar">
<TableSelectorMultiple
{...dbSelectorProps}
onEmptyResults={onEmptyResults}
emptyState={<EmptyState />}
database={userSelectedDb}
onTableSelectChange={onTablesChange}
tableValue={selectedTableNames}
sqlLabMode
/>
<div className="divider" />
<StyledScrollbarContainer>
{tables.map(table => (
<TableElement
table={table}
key={table.id}
activeKey={tables
.filter(({ expanded }) => expanded)
.map(({ id }) => id)}
onChange={onToggleTable}
/>
))}
</StyledScrollbarContainer>
<Popover
content={popoverContent}
open={selectorModalOpen}
onOpenChange={open => !open && closeSelectorModal()}
placement="bottomLeft"
trigger="click"
>
<DatabaseSelector
key={`db-selector-${db ? db.id : 'no-db'}:${catalog ?? 'no-catalog'}:${
schema ?? 'no-schema'
}`}
{...dbSelectorProps}
emptyState={<EmptyState />}
sqlLabMode
onOpenModal={openSelectorModal}
/>
</Popover>
<StyledDivider />
<TableExploreTree queryEditorId={queryEditorId} />
{shouldShowReset && (
<Button
buttonSize="small"

View File

@@ -38,12 +38,14 @@ const SqlEditorTopBar = ({
defaultSecondaryActions,
}: SqlEditorTopBarProps) => (
<StyledFlex justify="space-between" gap="small" id="js-sql-toolbar">
<Flex flex={1} gap="small" align="center">
<PanelToolbar
viewId={ViewContribution.Editor}
defaultPrimaryActions={defaultPrimaryActions}
defaultSecondaryActions={defaultSecondaryActions}
/>
<Flex gap="small" align="center">
<Flex gap="small" align="center">
<PanelToolbar
viewId={ViewContribution.Editor}
defaultPrimaryActions={defaultPrimaryActions}
defaultSecondaryActions={defaultSecondaryActions}
/>
</Flex>
</Flex>
</StyledFlex>
);

View File

@@ -67,9 +67,9 @@ export default function useDatabaseSelector(queryEditorId: string) {
);
const handleCatalogChange = useCallback(
(catalog: string | null) => {
(catalog?: string | null) => {
if (queryEditor) {
dispatch(queryEditorSetCatalog(queryEditor, catalog));
dispatch(queryEditorSetCatalog(queryEditor, catalog ?? null));
}
},
[dispatch, queryEditor],

View File

@@ -0,0 +1,241 @@
/**
* 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 type { ReactChild } from 'react';
import fetchMock from 'fetch-mock';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
import TableExploreTree from '.';
jest.mock(
'react-virtualized-auto-sizer',
() =>
({ children }: { children: (params: { height: number }) => ReactChild }) =>
children({ height: 500 }),
);
const mockedQueryEditorId = defaultQueryEditor.id;
const mockedDatabase = {
id: 1,
database_name: 'main',
backend: 'mysql',
};
const mockSchemas = ['public', 'information_schema', 'test_schema'];
const mockTables = [
{ label: 'users', value: 'users', type: 'table' },
{ label: 'orders', value: 'orders', type: 'table' },
{ label: 'user_view', value: 'user_view', type: 'view' },
];
const mockColumns = [
{ name: 'id', type: 'INTEGER', keys: [{ type: 'pk' }] },
{ name: 'name', type: 'VARCHAR(255)' },
{ name: 'created_at', type: 'TIMESTAMP' },
];
beforeEach(() => {
fetchMock.get('glob:*/api/v1/database/1/schemas/?*', {
count: mockSchemas.length,
result: mockSchemas,
});
fetchMock.get('glob:*/api/v1/database/1/tables/*', {
count: mockTables.length,
result: mockTables,
});
fetchMock.get('glob:*/api/v1/database/1/table_metadata/*', {
status: 200,
body: {
columns: mockColumns,
},
});
fetchMock.get('glob:*/api/v1/database/1/table_metadata/extra/*', {
status: 200,
body: {},
});
});
afterEach(() => {
jest.clearAllMocks();
fetchMock.clearHistory();
});
const getInitialState = (overrides = {}) => ({
...initialState,
sqlLab: {
...initialState.sqlLab,
databases: {
1: mockedDatabase,
},
queryEditors: [
{
...defaultQueryEditor,
dbId: mockedDatabase.id,
schema: 'public',
},
],
...overrides,
},
});
const renderComponent = (queryEditorId: string = mockedQueryEditorId) =>
render(<TableExploreTree queryEditorId={queryEditorId} />, {
useRedux: true,
initialState: getInitialState(),
});
test('renders schema list from API', async () => {
renderComponent();
await waitFor(() => {
expect(screen.getByText('public')).toBeInTheDocument();
});
});
test('renders search input', async () => {
renderComponent();
await waitFor(() => {
expect(screen.getByText('public')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText(
'Enter a part of the object name',
);
expect(searchInput).toBeInTheDocument();
});
test('filters schemas when searching', async () => {
renderComponent();
await waitFor(() => {
expect(screen.getByText('public')).toBeInTheDocument();
});
// Verify selected schemas are initially visible
expect(screen.queryByText('test_schema')).not.toBeInTheDocument();
expect(screen.queryByText('information_schema')).not.toBeInTheDocument();
const searchInput = screen.getByPlaceholderText(
'Enter a part of the object name',
);
await userEvent.type(searchInput, 'pub');
// After searching, only matching schema should be visible
await waitFor(() => {
// react-arborist filters nodes via searchMatch - non-matching nodes are not rendered
const treeItems = screen.getAllByRole('treeitem');
expect(treeItems).toHaveLength(1);
});
// Verify the filtered schema is visible via the treeitem
const treeItem = screen.getByRole('treeitem');
expect(treeItem).toHaveTextContent('public');
});
test('expands schema node and loads tables', async () => {
renderComponent();
await waitFor(() => {
expect(screen.getByText('public')).toBeInTheDocument();
});
const schemaNode = screen.getByText('public');
await userEvent.click(schemaNode);
await waitFor(() => {
expect(screen.getByText('users')).toBeInTheDocument();
});
expect(screen.getByText('orders')).toBeInTheDocument();
expect(screen.getByText('user_view')).toBeInTheDocument();
});
test('expands table node and loads columns', async () => {
renderComponent();
await waitFor(() => {
expect(screen.getByText('public')).toBeInTheDocument();
});
// Expand schema
const schemaNode = screen.getByText('public');
await userEvent.click(schemaNode);
await waitFor(() => {
expect(screen.getByText('users')).toBeInTheDocument();
});
// Expand table
const tableNode = screen.getByText('users');
await userEvent.click(tableNode);
await waitFor(() => {
expect(screen.getByText('id')).toBeInTheDocument();
});
expect(screen.getByText('name')).toBeInTheDocument();
expect(screen.getByText('created_at')).toBeInTheDocument();
});
test('shows empty state when no schemas match search', async () => {
renderComponent();
await waitFor(() => {
expect(screen.getByText('public')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText(
'Enter a part of the object name',
);
await userEvent.type(searchInput, 'nonexistent');
await waitFor(() => {
expect(screen.queryByText('public')).not.toBeInTheDocument();
});
});
test('shows loading skeleton while fetching schemas', async () => {
fetchMock.get('glob:*/api/v1/database/1/schemas/?*', {
response: new Promise(resolve =>
setTimeout(
() =>
resolve({
count: mockSchemas.length,
result: mockSchemas,
}),
100,
),
),
});
renderComponent();
await waitFor(() => {
expect(screen.getByText('public')).toBeInTheDocument();
});
});
test('renders refresh button for schema list', async () => {
renderComponent();
await waitFor(() => {
expect(screen.getByText('public')).toBeInTheDocument();
});
const refreshButton = screen.getByRole('button', { name: /reload/i });
expect(refreshButton).toBeInTheDocument();
});

View File

@@ -0,0 +1,243 @@
/**
* 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 { css, styled, t } from '@apache-superset/core';
import type { NodeRendererProps } from 'react-arborist';
import { Icons, Tooltip, Typography } from '@superset-ui/core/components';
import RefreshLabel from '@superset-ui/core/components/RefreshLabel';
import ColumnElement from 'src/SqlLab/components/ColumnElement';
import IconButton from 'src/dashboard/components/IconButton';
import type { TreeNodeData, FetchLazyTablesParams } from './types';
const StyledColumnNode = styled.div`
& > .ant-flex {
flex: 1;
margin-right: ${({ theme }) => theme.sizeUnit * 1.5}px;
cursor: default;
}
`;
const getOpacity = (disableCheckbox: boolean | undefined) =>
disableCheckbox ? 0.6 : 1;
const highlightText = (text: string, keyword: string): React.ReactNode => {
if (!keyword) {
return text;
}
const lowerText = text.toLowerCase();
const lowerKeyword = keyword.toLowerCase();
const index = lowerText.indexOf(lowerKeyword);
if (index === -1) {
return text;
}
const beforeStr = text.substring(0, index);
const matchStr = text.substring(index, index + keyword.length);
const afterStr = text.slice(index + keyword.length);
return (
<>
{beforeStr}
<span className="highlighted">{matchStr}</span>
{afterStr}
</>
);
};
export interface TreeNodeRendererProps extends NodeRendererProps<TreeNodeData> {
manuallyOpenedNodes: Record<string, boolean>;
loadingNodes: Record<string, boolean>;
searchTerm: string;
catalog: string | null | undefined;
fetchLazyTables: (params: FetchLazyTablesParams) => void;
handlePinTable: (
tableName: string,
schemaName: string,
catalogName: string | null,
) => void;
}
const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
node,
style,
manuallyOpenedNodes,
loadingNodes,
searchTerm,
catalog,
fetchLazyTables,
handlePinTable,
}) => {
const { data } = node;
const parts = data.id.split(':');
const [identifier, _dbId, schema, tableName] = parts;
// Use manually tracked open state for icon display
// This prevents search auto-expansion from affecting the icon
const isManuallyOpen = manuallyOpenedNodes[data.id] ?? false;
const isLoading = loadingNodes[data.id] ?? false;
const renderIcon = () => {
if (identifier === 'schema') {
// Show loading icon when fetching data for schema
if (isLoading) {
return <Icons.LoadingOutlined iconSize="l" />;
}
return isManuallyOpen ? (
<Icons.FolderOpenOutlined iconSize="l" />
) : (
<Icons.FolderOutlined iconSize="l" />
);
}
if (identifier === 'table') {
const TableTypeIcon =
data.tableType === 'view' ? Icons.EyeOutlined : Icons.TableOutlined;
// Show loading icon with table type icon when loading
if (isLoading) {
return (
<>
<Icons.LoadingOutlined iconSize="l" />
<TableTypeIcon iconSize="l" />
</>
);
}
const ExpandIcon = isManuallyOpen
? Icons.MinusSquareOutlined
: Icons.PlusSquareOutlined;
return (
<>
<ExpandIcon iconSize="l" />
<TableTypeIcon iconSize="l" />
</>
);
}
return null;
};
// Empty placeholder node - no actions allowed
if (data.type === 'empty') {
return (
<div
className="tree-node"
style={{
...style,
opacity: 0.5,
fontStyle: 'italic',
cursor: 'default',
}}
>
<span className="tree-node-icon">
<Icons.FileOutlined iconSize="l" />
</span>
<span className="tree-node-title">{data.name}</span>
</div>
);
}
// Column nodes use ColumnElement
if (identifier === 'column' && data.columnData) {
return (
<StyledColumnNode
className="tree-node"
style={style}
data-selected={node.isSelected}
onClick={() => node.select()}
>
<ColumnElement column={data.columnData} />
</StyledColumnNode>
);
}
return (
<div
className="tree-node"
style={style}
data-selected={node.isSelected}
onClick={e => {
e.stopPropagation();
if (node.isLeaf) {
node.select();
} else {
node.toggle();
}
}}
>
<span
className="tree-node-icon"
css={css`
opacity: ${getOpacity(data.disableCheckbox)};
`}
>
{renderIcon()}
</span>
<Typography.Text
className="tree-node-title"
css={css`
opacity: ${getOpacity(data.disableCheckbox)};
`}
ellipsis={{
tooltip: { title: data.name, placement: 'topLeft' },
}}
>
{highlightText(data.name, searchTerm)}
</Typography.Text>
{identifier === 'schema' && (
<div className="side-action-container" role="menu">
<RefreshLabel
onClick={e => {
e.stopPropagation();
fetchLazyTables({
dbId: _dbId,
catalog,
schema,
forceRefresh: true,
});
}}
tooltipContent={t('Force refresh table list')}
/>
</div>
)}
{identifier === 'table' && (
<div
className="side-action-container"
role="menu"
css={css`
position: inherit;
`}
>
<IconButton
icon={
<Tooltip title={t('Pin to the result panel')}>
<Icons.FolderAddOutlined iconSize="xl" />
</Tooltip>
}
onClick={e => {
e.stopPropagation();
handlePinTable(tableName, schema, catalog ?? null);
}}
/>
</div>
)}
</div>
);
};
export default TreeNodeRenderer;

View File

@@ -0,0 +1,335 @@
/**
* 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 {
useCallback,
useState,
useRef,
type ChangeEvent,
useMemo,
} from 'react';
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { styled, t } from '@apache-superset/core';
import AutoSizer from 'react-virtualized-auto-sizer';
// Due to performance issues with the virtual list in the existing Ant Design (antd)-based tree view,
// it has been replaced with react-arborist solution.
import { Tree, TreeApi, NodeApi } from 'react-arborist';
import {
Icons,
Skeleton,
Input,
Empty,
Button,
} from '@superset-ui/core/components';
import type { SqlLabRootState } from 'src/SqlLab/types';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import { addTable } from 'src/SqlLab/actions/sqlLab';
import PanelToolbar from 'src/components/PanelToolbar';
import { ViewContribution } from 'src/SqlLab/contributions';
import TreeNodeRenderer from './TreeNodeRenderer';
import useTreeData, { EMPTY_NODE_ID_PREFIX } from './useTreeData';
import type { TreeNodeData } from './types';
import { ErrorMessageWithStackTrace } from 'src/components';
type Props = {
queryEditorId: string;
};
const StyledTreeContainer = styled.div`
flex: 1 1 auto;
.tree-node {
display: flex;
align-items: center;
padding: 0 ${({ theme }) => theme.sizeUnit}px;
position: relative;
cursor: pointer;
&:hover {
background-color: ${({ theme }) => theme.colorBgTextHover};
.side-action-container {
opacity: 1;
}
}
&[data-selected='true'] {
background-color: ${({ theme }) => theme.colorBgTextActive};
.side-action-container {
opacity: 1;
}
}
}
.tree-node-icon {
margin-right: ${({ theme }) => theme.sizeUnit}px;
display: flex;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit}px;
}
.tree-node-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.highlighted {
background-color: ${({ theme }) => theme.colorWarningBg};
font-weight: bold;
}
.side-action-container {
opacity: 0;
position: absolute;
right: ${({ theme }) => theme.sizeUnit * 1.5}px;
top: 50%;
transform: translateY(-50%);
z-index: ${({ theme }) => theme.zIndexPopupBase};
}
`;
const ROW_HEIGHT = 28;
const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
const dispatch = useDispatch();
const treeRef = useRef<TreeApi<TreeNodeData>>(null);
const tables = useSelector(
({ sqlLab }: SqlLabRootState) => sqlLab.tables,
shallowEqual,
);
const queryEditor = useQueryEditor(queryEditorId, [
'dbId',
'schema',
'catalog',
]);
const { dbId, catalog, schema: selectedSchema } = queryEditor;
const pinnedTables = useMemo(
() =>
Object.fromEntries(
tables.map(({ queryEditorId, dbId, schema, name, persistData }) => [
queryEditor.id === queryEditorId ? `${dbId}:${schema}:${name}` : '',
persistData,
]),
),
[tables, queryEditor.id],
);
// Tree data hook - manages schema/table/column data fetching and tree structure
const {
treeData,
isFetching,
refetch,
loadingNodes,
handleToggle,
fetchLazyTables,
errorPayload,
} = useTreeData({
dbId,
catalog,
selectedSchema,
pinnedTables,
});
const handlePinTable = useCallback(
(tableName: string, schemaName: string, catalogName: string | null) =>
dispatch(addTable(queryEditor, tableName, catalogName, schemaName)),
[dispatch, queryEditor],
);
const [searchTerm, setSearchTerm] = useState('');
const handleSearchChange = useCallback(
({ target }: ChangeEvent<HTMLInputElement>) => setSearchTerm(target.value),
[],
);
// Track manually opened nodes (not auto-expanded by search)
const [manuallyOpenedNodes, setManuallyOpenedNodes] = useState<
Record<string, boolean>
>({});
// Custom search match function for react-arborist
const searchMatch = useCallback(
(node: NodeApi<TreeNodeData>, term: string): boolean => {
// Empty placeholder nodes should not match search
if (node.data.type === 'empty') return false;
if (!term) return true;
const lowerTerm = term.toLowerCase();
// Check if current node matches
if (node.data.name.toLowerCase().includes(lowerTerm)) {
return true;
}
// Check if any ancestor matches - if so, include this node (child of matching parent)
// if (node.parent && node.parent.isRoot === false && node.parent.isOpen) {
// return true;
// }
let ancestor = node.parent;
while (ancestor && !ancestor.isRoot) {
if (ancestor.data.name.toLowerCase().includes(lowerTerm)) {
return true;
}
ancestor = ancestor.parent;
}
return false;
},
[],
);
// Check if any nodes match the search term
const hasMatchingNodes = useMemo(() => {
if (!searchTerm) return true;
const lowerTerm = searchTerm.toLowerCase();
const checkNode = (node: TreeNodeData): boolean => {
if (node.type === 'empty') return false;
if (node.name.toLowerCase().includes(lowerTerm)) return true;
if (node.children) {
return node.children.some(child => checkNode(child));
}
return false;
};
return treeData.some(node => checkNode(node));
}, [searchTerm, treeData]);
// Node renderer for react-arborist
const renderNode = useCallback(
(props: Parameters<typeof TreeNodeRenderer>[0]) => (
<TreeNodeRenderer
{...props}
manuallyOpenedNodes={manuallyOpenedNodes}
loadingNodes={loadingNodes}
searchTerm={searchTerm}
catalog={catalog}
fetchLazyTables={fetchLazyTables}
handlePinTable={handlePinTable}
/>
),
[
catalog,
fetchLazyTables,
handlePinTable,
loadingNodes,
manuallyOpenedNodes,
searchTerm,
],
);
return (
<>
<PanelToolbar
viewId={ViewContribution.LeftSidebar}
defaultPrimaryActions={
<>
<Button
color="primary"
variant="text"
icon={<Icons.MinusSquareOutlined />}
onClick={() => {
treeRef.current?.closeAll();
setManuallyOpenedNodes({});
}}
tooltip={t('Collapse all')}
/>
<Button
color="primary"
variant="text"
icon={<Icons.ReloadOutlined />}
onClick={() => refetch()}
loading={isFetching}
tooltip={t('Force refresh schema list')}
/>
</>
}
/>
<Input
allowClear
type="text"
className="form-control input-sm"
placeholder={t('Enter a part of the object name')}
onChange={handleSearchChange}
value={searchTerm}
/>
{errorPayload && (
<ErrorMessageWithStackTrace error={errorPayload} source="crud" />
)}
<StyledTreeContainer>
<AutoSizer disableWidth>
{({ height }) => {
if (isFetching) {
return <Skeleton active />;
}
if (searchTerm && !hasMatchingNodes) {
return (
<Empty
description={t('No matching results found')}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
);
}
return (
<Tree<TreeNodeData>
ref={treeRef}
data={treeData}
width="100%"
height={height || 500}
rowHeight={ROW_HEIGHT}
indent={16}
searchTerm={searchTerm}
searchMatch={searchMatch}
disableDrag
disableDrop
openByDefault={false}
initialOpenState={manuallyOpenedNodes}
onToggle={id => {
// Skip empty placeholder nodes
if (id.startsWith(EMPTY_NODE_ID_PREFIX)) {
return;
}
// Track manually opened/closed state
setManuallyOpenedNodes(prev => {
const wasOpen = prev[id] ?? false;
const isNowOpen = !wasOpen;
// Trigger data fetch when opening
if (isNowOpen) {
handleToggle(id, true);
}
return { ...prev, [id]: isNowOpen };
});
}}
>
{renderNode}
</Tree>
);
}}
</AutoSizer>
</StyledTreeContainer>
</>
);
};
export default TableExploreTree;

View File

@@ -0,0 +1,40 @@
/**
* 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 type { ColumnKeyTypeType } from 'src/SqlLab/components/ColumnElement';
export interface TreeNodeData {
id: string;
name: string;
type: 'schema' | 'table' | 'column' | 'empty';
tableType?: string;
columnData?: {
name: string;
keys?: { type: ColumnKeyTypeType }[];
type: string;
};
children?: TreeNodeData[];
disableCheckbox?: boolean;
}
export interface FetchLazyTablesParams {
dbId: string | number;
catalog: string | null | undefined;
schema: string;
forceRefresh: boolean;
}

View File

@@ -0,0 +1,339 @@
/**
* 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 { useMemo, useReducer, useCallback } from 'react';
import { t } from '@apache-superset/core';
import {
Table,
type TableMetaData,
useSchemas,
useLazyTablesQuery,
useLazyTableMetadataQuery,
useLazyTableExtendedMetadataQuery,
} from 'src/hooks/apiResources';
import type { TreeNodeData } from './types';
import { SupersetError } from '@superset-ui/core';
export const EMPTY_NODE_ID_PREFIX = 'empty:';
// Reducer state and actions
interface TreeDataState {
tableData: Record<string, { options: Table[] }>;
tableSchemaData: Record<string, TableMetaData>;
loadingNodes: Record<string, boolean>;
errorPayload: SupersetError | null;
}
type TreeDataAction =
| { type: 'SET_TABLE_DATA'; key: string; data: { options: Table[] } }
| { type: 'SET_TABLE_SCHEMA_DATA'; key: string; data: TableMetaData }
| { type: 'SET_LOADING_NODE'; nodeId: string; loading: boolean }
| { type: 'SET_ERROR'; errorPayload: SupersetError | null };
const initialState: TreeDataState = {
tableData: {},
tableSchemaData: {},
loadingNodes: {},
errorPayload: null,
};
function treeDataReducer(
state: TreeDataState,
action: TreeDataAction,
): TreeDataState {
switch (action.type) {
case 'SET_TABLE_DATA':
return {
...state,
errorPayload: null,
tableData: { ...state.tableData, [action.key]: action.data },
};
case 'SET_TABLE_SCHEMA_DATA':
return {
...state,
tableSchemaData: {
...state.tableSchemaData,
[action.key]: action.data,
},
};
case 'SET_LOADING_NODE':
return {
...state,
loadingNodes: {
...state.loadingNodes,
[action.nodeId]: action.loading,
},
};
case 'SET_ERROR':
return {
...state,
errorPayload: action.errorPayload,
};
default:
return state;
}
}
interface UseTreeDataParams {
dbId: number | undefined;
catalog: string | null | undefined;
selectedSchema: string | undefined;
pinnedTables: Record<string, TableMetaData | undefined>;
}
interface UseTreeDataResult {
treeData: TreeNodeData[];
isFetching: boolean;
refetch: () => void;
loadingNodes: Record<string, boolean>;
handleToggle: (id: string, isOpen: boolean) => Promise<void>;
fetchLazyTables: ReturnType<typeof useLazyTablesQuery>[0];
errorPayload: SupersetError | null;
}
const createEmptyNode = (parentId: string): TreeNodeData => ({
id: `${EMPTY_NODE_ID_PREFIX}${parentId}`,
name: t('No items'),
type: 'empty',
});
const useTreeData = ({
dbId,
catalog,
selectedSchema,
pinnedTables,
}: UseTreeDataParams): UseTreeDataResult => {
// Schema data from API
const {
currentData: schemaData,
isFetching,
refetch,
} = useSchemas({ dbId, catalog: catalog || undefined });
// Lazy query hooks
const [fetchLazyTables] = useLazyTablesQuery();
const [fetchTableMetadata] = useLazyTableMetadataQuery();
const [fetchTableExtendedMetadata] = useLazyTableExtendedMetadataQuery();
// Combined state for table data, schema data, loading nodes, and data version
const [state, dispatch] = useReducer(treeDataReducer, initialState);
const { tableData, tableSchemaData, loadingNodes, errorPayload } = state;
// Handle async loading when node is toggled open
const handleToggle = useCallback(
async (id: string, isOpen: boolean) => {
// Only fetch when opening a node
if (!isOpen) return;
const parts = id.split(':');
const [identifier, databaseId, schema, table] = parts;
const parsedDbId = Number(databaseId);
if (identifier === 'schema') {
const schemaKey = `${parsedDbId}:${schema}`;
if (!tableData?.[schemaKey]) {
// Set loading state
dispatch({ type: 'SET_LOADING_NODE', nodeId: id, loading: true });
// Fetch tables asynchronously
fetchLazyTables(
{
dbId: parsedDbId,
catalog,
schema,
forceRefresh: false,
},
true,
)
.then(({ data }) => {
if (data) {
dispatch({ type: 'SET_TABLE_DATA', key: schemaKey, data });
}
})
.catch(error => {
dispatch({
type: 'SET_ERROR',
errorPayload: error?.errors?.[0] ?? null,
});
})
.finally(() => {
dispatch({
type: 'SET_LOADING_NODE',
nodeId: id,
loading: false,
});
});
}
}
if (identifier === 'table') {
const tableKey = `${parsedDbId}:${schema}:${table}`;
// Check pinnedTables first (it's stable)
if (pinnedTables[tableKey]) return;
if (!tableSchemaData[tableKey]) {
// Set loading state
dispatch({ type: 'SET_LOADING_NODE', nodeId: id, loading: true });
// Fetch metadata asynchronously
Promise.all([
fetchTableMetadata(
{
dbId: parsedDbId,
catalog,
schema,
table,
},
true,
),
fetchTableExtendedMetadata(
{
dbId: parsedDbId,
catalog,
schema,
table,
},
true,
),
])
.then(
([{ data: tableMetadata }, { data: tableExtendedMetadata }]) => {
if (tableMetadata) {
dispatch({
type: 'SET_TABLE_SCHEMA_DATA',
key: tableKey,
data: {
...tableMetadata,
...tableExtendedMetadata,
},
});
}
},
)
.finally(() => {
dispatch({
type: 'SET_LOADING_NODE',
nodeId: id,
loading: false,
});
});
}
}
},
[
catalog,
fetchLazyTables,
fetchTableExtendedMetadata,
fetchTableMetadata,
pinnedTables,
tableData,
tableSchemaData,
],
);
// Build tree data
const treeData = useMemo((): TreeNodeData[] => {
// Filter schemas if a schema is selected, otherwise show all
const filteredSchemaData = selectedSchema
? schemaData?.filter(schema => schema.value === selectedSchema)
: schemaData;
const data = filteredSchemaData?.map(schema => {
const schemaKey = `${dbId}:${schema.value}`;
const schemaId = `schema:${dbId}:${schema.value}`;
const tablesData = tableData?.[schemaKey];
const tables = tablesData?.options;
// Determine children for schema node
let schemaChildren: TreeNodeData[];
if (!tablesData) {
// Not loaded yet - empty array makes it expandable
schemaChildren = [];
} else if (tables && tables.length > 0) {
// Has tables
schemaChildren = tables.map(({ value: tableName, type: tableType }) => {
const tableKey = `${dbId}:${schema.value}:${tableName}`;
const tableId = `table:${dbId}:${schema.value}:${tableName}`;
const columnsData =
tableSchemaData[tableKey] ?? pinnedTables[tableKey];
const columns = columnsData?.columns;
// Determine children for table node
let tableChildren: TreeNodeData[];
if (!columnsData) {
// Not loaded yet
tableChildren = [];
} else if (columns && columns.length > 0) {
// Has columns
tableChildren = columns.map(col => ({
id: `column:${dbId}:${schema.value}:${tableName}:${col.name}`,
name: col.name,
type: 'column' as const,
columnData: col,
}));
} else {
// Loaded but empty
tableChildren = [createEmptyNode(tableId)];
}
return {
id: tableId,
name: tableName,
type: 'table' as const,
tableType,
children: tableChildren,
disableCheckbox: !columnsData,
};
});
} else {
// Loaded but empty
schemaChildren = [createEmptyNode(schemaId)];
}
return {
id: schemaId,
name: schema.label,
type: 'schema' as const,
children: schemaChildren,
disableCheckbox: !tablesData,
};
});
return data ?? [];
}, [
dbId,
schemaData,
tableData,
tableSchemaData,
pinnedTables,
selectedSchema,
]);
return {
treeData,
isFetching,
refetch,
loadingNodes,
handleToggle,
fetchLazyTables,
errorPayload,
};
};
export default useTreeData;

View File

@@ -68,6 +68,7 @@ export const TIME_OPTIONS = [
// SqlEditor layout constants
export const SQL_EDITOR_GUTTER_HEIGHT = 5;
export const SQL_EDITOR_LEFTBAR_WIDTH = 400;
export const SQL_EDITOR_LEFTBAR_COLLAPSED_WIDTH = 56;
export const SQL_EDITOR_RIGHTBAR_WIDTH = 400;
export const SQL_EDITOR_STATUSBAR_HEIGHT = 30;
export const INITIAL_NORTH_PERCENT = 30;

View File

@@ -17,6 +17,7 @@
* under the License.
*/
export enum ViewContribution {
LeftSidebar = 'sqllab.leftSidebar',
RightSidebar = 'sqllab.rightSidebar',
Panels = 'sqllab.panels',
Editor = 'sqllab.editor',