mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
feat(sqllab): treeview table selection ui (#37298)
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
export enum ViewContribution {
|
||||
LeftSidebar = 'sqllab.leftSidebar',
|
||||
RightSidebar = 'sqllab.rightSidebar',
|
||||
Panels = 'sqllab.panels',
|
||||
Editor = 'sqllab.editor',
|
||||
|
||||
Reference in New Issue
Block a user