mirror of
https://github.com/apache/superset.git
synced 2026-06-15 20:49:18 +00:00
Compare commits
28 Commits
chore/lint
...
fix-explor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
235d4ea516 | ||
|
|
860f8cbe0f | ||
|
|
2fad87569c | ||
|
|
c6f54471dc | ||
|
|
7539138702 | ||
|
|
e0b1b557d7 | ||
|
|
bc5a5c2ac5 | ||
|
|
3a562dbe29 | ||
|
|
73b780a28c | ||
|
|
caeb6a6b7c | ||
|
|
19072074c5 | ||
|
|
f2037fa332 | ||
|
|
6c71800436 | ||
|
|
d63308ca37 | ||
|
|
63cceb6a79 | ||
|
|
b8b2bdedf9 | ||
|
|
d5017e60c3 | ||
|
|
2e80f2a473 | ||
|
|
4c2dd63464 | ||
|
|
62302ad8c3 | ||
|
|
ed659958f3 | ||
|
|
36de05fe36 | ||
|
|
a64609f4f3 | ||
|
|
140f0001f2 | ||
|
|
587fe4af63 | ||
|
|
3a3a6536b7 | ||
|
|
4f695e1b4d | ||
|
|
6ba9096870 |
@@ -115,6 +115,10 @@ services:
|
||||
DATABASE_HOST: db-light
|
||||
DATABASE_DB: superset_light
|
||||
POSTGRES_DB: superset_light
|
||||
EXAMPLES_HOST: db-light
|
||||
EXAMPLES_DB: superset_light
|
||||
EXAMPLES_USER: superset
|
||||
EXAMPLES_PASSWORD: superset
|
||||
SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py
|
||||
GITHUB_HEAD_REF: ${GITHUB_HEAD_REF:-}
|
||||
GITHUB_SHA: ${GITHUB_SHA:-}
|
||||
@@ -137,6 +141,10 @@ services:
|
||||
DATABASE_HOST: db-light
|
||||
DATABASE_DB: superset_light
|
||||
POSTGRES_DB: superset_light
|
||||
EXAMPLES_HOST: db-light
|
||||
EXAMPLES_DB: superset_light
|
||||
EXAMPLES_USER: superset
|
||||
EXAMPLES_PASSWORD: superset
|
||||
SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py
|
||||
healthcheck:
|
||||
disable: true
|
||||
@@ -157,6 +165,7 @@ services:
|
||||
BUILD_SUPERSET_FRONTEND_IN_DOCKER: true
|
||||
NPM_RUN_PRUNE: false
|
||||
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
|
||||
DISABLE_TS_CHECKER: "${DISABLE_TS_CHECKER:-true}"
|
||||
# configuring the dev-server to use the host.docker.internal to connect to the backend
|
||||
superset: "http://superset-light:8088"
|
||||
# Webpack dev server must bind to 0.0.0.0 to be accessible from outside the container
|
||||
|
||||
@@ -80,7 +80,7 @@ case "${1}" in
|
||||
;;
|
||||
app)
|
||||
echo "Starting web app (using development server)..."
|
||||
flask run -p $PORT --reload --debugger --without-threads --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*"
|
||||
flask run -p $PORT --reload --debugger --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*"
|
||||
;;
|
||||
app-gunicorn)
|
||||
echo "Starting web app..."
|
||||
|
||||
17
superset-frontend/package-lock.json
generated
17
superset-frontend/package-lock.json
generated
@@ -41438,15 +41438,6 @@
|
||||
"react": ">=16.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz",
|
||||
"integrity": "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-intersection-observer": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-10.0.3.tgz",
|
||||
@@ -52161,7 +52152,7 @@
|
||||
"version": "0.20.4",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@deck.gl/aggregation-layers": "~9.2.9",
|
||||
"@deck.gl/aggregation-layers": "~9.2.11",
|
||||
"@deck.gl/core": "~9.2.5",
|
||||
"@deck.gl/extensions": "~9.2.9",
|
||||
"@deck.gl/geo-layers": "~9.2.5",
|
||||
@@ -52516,8 +52507,7 @@
|
||||
"lodash": "^4.18.1",
|
||||
"prop-types": "*",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-icons": "5.4.0"
|
||||
"react-dom": "^17.0.2"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-table": {
|
||||
@@ -52531,7 +52521,6 @@
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.18.1",
|
||||
"memoize-one": "^5.2.1",
|
||||
"react-icons": "5.4.0",
|
||||
"react-table": "^7.8.0",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"xss": "^1.0.15"
|
||||
@@ -52570,7 +52559,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"d3-cloud": "^1.2.8",
|
||||
"d3-cloud": "^1.2.9",
|
||||
"d3-scale": "^4.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -21,7 +21,11 @@ import { styled, css } from '@apache-superset/core/theme';
|
||||
export const ControlSubSectionHeader = styled.div`
|
||||
${({ theme }) => css`
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
margin-top: ${theme.sizeUnit * 3}px;
|
||||
margin-bottom: ${theme.sizeUnit}px;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: ${theme.colorTextSecondary};
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -319,6 +319,11 @@ export function AsyncAceEditor(
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Style bracket matching to blend with theme */
|
||||
.ace_editor .ace_bracket {
|
||||
border-color: ${token.colorPrimaryBorderHover} !important;
|
||||
}
|
||||
|
||||
/* Adjust cursor color */
|
||||
.ace_editor .ace_cursor {
|
||||
color: ${token.colorPrimaryText} !important;
|
||||
|
||||
@@ -115,6 +115,7 @@ import {
|
||||
PlusSquareOutlined,
|
||||
PlusOutlined,
|
||||
ProfileOutlined,
|
||||
PushpinFilled,
|
||||
PushpinOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ReloadOutlined,
|
||||
@@ -270,6 +271,7 @@ const AntdIcons = {
|
||||
PlusSquareOutlined,
|
||||
PlusOutlined,
|
||||
ProfileOutlined,
|
||||
PushpinFilled,
|
||||
PushpinOutlined,
|
||||
ReloadOutlined,
|
||||
QuestionCircleOutlined,
|
||||
|
||||
@@ -30,10 +30,12 @@ import { debounceFunc } from '../../consts';
|
||||
|
||||
interface StyleCustomControlProps {
|
||||
value: string;
|
||||
htmlSanitization: boolean;
|
||||
}
|
||||
|
||||
const StyleControl = (props: CustomControlConfig<StyleCustomControlProps>) => {
|
||||
const theme = useTheme();
|
||||
const htmlSanitization = props.htmlSanitization ?? true;
|
||||
|
||||
const defaultValue = props?.value
|
||||
? undefined
|
||||
@@ -48,10 +50,16 @@ const StyleControl = (props: CustomControlConfig<StyleCustomControlProps>) => {
|
||||
<ControlHeader>
|
||||
<div>
|
||||
{props.label}
|
||||
<InfoTooltip
|
||||
iconStyle={{ marginLeft: theme.sizeUnit }}
|
||||
tooltip={t('You need to configure HTML sanitization to use CSS')}
|
||||
/>
|
||||
{htmlSanitization && (
|
||||
<InfoTooltip
|
||||
iconStyle={{ marginLeft: theme.sizeUnit }}
|
||||
tooltip={t(
|
||||
'CSS styles may be removed by server-side HTML sanitization. ' +
|
||||
'If styles are not applying, ask your Superset administrator ' +
|
||||
'to adjust the HTML sanitization configuration.',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ControlHeader>
|
||||
<CodeEditor
|
||||
@@ -79,8 +87,9 @@ export const styleControlSetItem: ControlSetItem = {
|
||||
valueKey: null,
|
||||
|
||||
validators: [],
|
||||
mapStateToProps: ({ controls }) => ({
|
||||
mapStateToProps: ({ controls, common }) => ({
|
||||
value: controls?.handlebars_template?.value,
|
||||
htmlSanitization: common?.conf?.HTML_SANITIZATION ?? true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
"lodash": "^4.18.1",
|
||||
"prop-types": "*",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-icons": "5.4.0"
|
||||
"react-dom": "^17.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.0",
|
||||
|
||||
@@ -22,9 +22,11 @@ import { safeHtmlSpan } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FaSort } from 'react-icons/fa';
|
||||
import { FaSortDown as FaSortDesc } from 'react-icons/fa';
|
||||
import { FaSortUp as FaSortAsc } from 'react-icons/fa';
|
||||
import {
|
||||
CaretUpOutlined,
|
||||
CaretDownOutlined,
|
||||
ColumnHeightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
ColorFormatters,
|
||||
getTextColorForBackground,
|
||||
@@ -855,7 +857,7 @@ export class TableRenderer extends Component<
|
||||
|
||||
if (activeSortColumn !== key) {
|
||||
return (
|
||||
<FaSort
|
||||
<ColumnHeightOutlined
|
||||
onClick={() =>
|
||||
this.sortData(key, visibleColKeys, pivotData, maxRowIndex)
|
||||
}
|
||||
@@ -863,7 +865,8 @@ export class TableRenderer extends Component<
|
||||
);
|
||||
}
|
||||
|
||||
const SortIcon = sortingOrder[key] === 'asc' ? FaSortAsc : FaSortDesc;
|
||||
const SortIcon =
|
||||
sortingOrder[key] === 'asc' ? CaretUpOutlined : CaretDownOutlined;
|
||||
return (
|
||||
<SortIcon
|
||||
onClick={() =>
|
||||
@@ -873,7 +876,9 @@ export class TableRenderer extends Component<
|
||||
);
|
||||
};
|
||||
const headerCellFormattedValue =
|
||||
dateFormatters?.[attrName]?.(convertToNumberIfNumeric(colKey[attrIdx])) ?? colKey[attrIdx];
|
||||
dateFormatters?.[attrName]?.(
|
||||
convertToNumberIfNumeric(colKey[attrIdx]),
|
||||
) ?? colKey[attrIdx];
|
||||
const { backgroundColor, color } = getCellColor(
|
||||
[attrName],
|
||||
headerCellFormattedValue,
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.18.1",
|
||||
"memoize-one": "^5.2.1",
|
||||
"react-icons": "5.4.0",
|
||||
"react-table": "^7.8.0",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"xss": "^1.0.15"
|
||||
|
||||
@@ -35,9 +35,11 @@ import {
|
||||
Row,
|
||||
} from 'react-table';
|
||||
import { extent as d3Extent, max as d3Max } from 'd3-array';
|
||||
import { FaSort } from 'react-icons/fa';
|
||||
import { FaSortDown as FaSortDesc } from 'react-icons/fa';
|
||||
import { FaSortUp as FaSortAsc } from 'react-icons/fa';
|
||||
import {
|
||||
CaretUpOutlined,
|
||||
CaretDownOutlined,
|
||||
ColumnHeightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
DataRecord,
|
||||
@@ -221,9 +223,9 @@ function cellBackground({
|
||||
|
||||
function SortIcon<D extends object>({ column }: { column: ColumnInstance<D> }) {
|
||||
const { isSorted, isSortedDesc } = column;
|
||||
let sortIcon = <FaSort />;
|
||||
let sortIcon = <ColumnHeightOutlined />;
|
||||
if (isSorted) {
|
||||
sortIcon = isSortedDesc ? <FaSortDesc /> : <FaSortAsc />;
|
||||
sortIcon = isSortedDesc ? <CaretDownOutlined /> : <CaretUpOutlined />;
|
||||
}
|
||||
return sortIcon;
|
||||
}
|
||||
|
||||
@@ -74,13 +74,16 @@ interface ColumnElementProps {
|
||||
keys?: { type: ColumnKeyTypeType }[];
|
||||
type: string;
|
||||
};
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
const NowrapDiv = styled.div`
|
||||
const ColumnType = styled.div`
|
||||
white-space: nowrap;
|
||||
color: ${({ theme }) => theme.colorTextDescription};
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
`;
|
||||
|
||||
const ColumnElement = ({ column }: ColumnElementProps) => {
|
||||
const ColumnElement = ({ column, actions }: ColumnElementProps) => {
|
||||
let columnName: ReactNode = column.name;
|
||||
let icons;
|
||||
if (column.keys && column.keys.length > 0) {
|
||||
@@ -110,10 +113,9 @@ const ColumnElement = ({ column }: ColumnElementProps) => {
|
||||
<div data-test="col-name">
|
||||
{columnName}
|
||||
{icons}
|
||||
{actions}
|
||||
</div>
|
||||
<NowrapDiv className="text-muted">
|
||||
<small> {column.type}</small>
|
||||
</NowrapDiv>
|
||||
<ColumnType>{column.type}</ColumnType>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -257,6 +257,8 @@ test('returns column keywords among selected tables', async () => {
|
||||
},
|
||||
);
|
||||
|
||||
// Both columns should be present since all cached table metadata
|
||||
// for this database is included in autocomplete
|
||||
await waitFor(() =>
|
||||
expect(result.current).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -268,31 +270,14 @@ test('returns column keywords among selected tables', async () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current).not.toContainEqual(
|
||||
expect(result.current).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: unexpectedColumn,
|
||||
value: unexpectedColumn,
|
||||
score: COLUMN_AUTOCOMPLETE_SCORE,
|
||||
meta: 'column',
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
storeWithSqlLab.dispatch(
|
||||
addTable(
|
||||
{ id: expectQueryEditorId } as any,
|
||||
unexpectedTable,
|
||||
expectCatalog,
|
||||
expectSchema,
|
||||
) as any,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: unexpectedColumn,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('returns long keywords with detail', async () => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useSelector, useDispatch, shallowEqual, useStore } from 'react-redux';
|
||||
import { useDispatch, useStore } from 'react-redux';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { getExtensionsRegistry } from '@superset-ui/core';
|
||||
|
||||
@@ -30,15 +30,10 @@ import {
|
||||
COLUMN_AUTOCOMPLETE_SCORE,
|
||||
SQL_FUNCTIONS_AUTOCOMPLETE_SCORE,
|
||||
} from 'src/SqlLab/constants';
|
||||
import {
|
||||
schemaEndpoints,
|
||||
tableEndpoints,
|
||||
skipToken,
|
||||
} from 'src/hooks/apiResources';
|
||||
import { schemaEndpoints } from 'src/hooks/apiResources';
|
||||
import { api } from 'src/hooks/apiResources/queryApi';
|
||||
import { useDatabaseFunctionsQuery } from 'src/hooks/apiResources/databaseFunctions';
|
||||
import useEffectEvent from 'src/hooks/useEffectEvent';
|
||||
import { SqlLabRootState } from 'src/SqlLab/types';
|
||||
|
||||
type Params = {
|
||||
queryEditorId: string | number;
|
||||
@@ -51,7 +46,6 @@ type Params = {
|
||||
const EMPTY_LIST = [] as typeof sqlKeywords;
|
||||
|
||||
const { useQueryState: useSchemasQueryState } = schemaEndpoints.schemas;
|
||||
const { useQueryState: useTablesQueryState } = tableEndpoints.tables;
|
||||
|
||||
const getHelperText = (value: string) =>
|
||||
value.length > 30 && {
|
||||
@@ -87,16 +81,6 @@ export function useKeywords(
|
||||
},
|
||||
{ skip: skipFetch || !dbId },
|
||||
);
|
||||
const { currentData: tableData } = useTablesQueryState(
|
||||
{
|
||||
dbId,
|
||||
catalog,
|
||||
schema,
|
||||
forceRefresh: false,
|
||||
},
|
||||
{ skip: skipFetch || !dbId || !schema },
|
||||
);
|
||||
|
||||
const { currentData: functionNames, isError } = useDatabaseFunctionsQuery(
|
||||
{ dbId },
|
||||
{ skip: skipFetch || !dbId },
|
||||
@@ -110,41 +94,64 @@ export function useKeywords(
|
||||
}
|
||||
}, [dispatch, isError]);
|
||||
|
||||
const tablesForColumnMetadata = useSelector<SqlLabRootState, string[]>(
|
||||
({ sqlLab }) =>
|
||||
skip
|
||||
? []
|
||||
: (sqlLab?.tables ?? [])
|
||||
.filter(table => table.queryEditorId === queryEditorId)
|
||||
.map(table => table.name),
|
||||
shallowEqual,
|
||||
);
|
||||
|
||||
const store = useStore();
|
||||
const apiState = store.getState()[api.reducerPath];
|
||||
|
||||
// Normalize catalog for comparison (null/undefined both mean "no catalog")
|
||||
const normalizedCatalog = catalog ?? null;
|
||||
|
||||
// Collect all table names from all cached table-list queries for this database/catalog.
|
||||
// This includes tables from any schema the user has expanded in the tree.
|
||||
const allCachedTables = useMemo(() => {
|
||||
if (skipFetch || !dbId || !apiState) return [];
|
||||
const tables: { value: string; label: string; schema: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
const queries = apiState.queries ?? {};
|
||||
for (const entry of Object.values(queries) as any[]) {
|
||||
const arg = entry?.originalArgs;
|
||||
if (
|
||||
arg?.dbId === dbId &&
|
||||
(arg?.catalog ?? null) === normalizedCatalog &&
|
||||
entry?.status === 'fulfilled' &&
|
||||
entry?.data?.options
|
||||
) {
|
||||
for (const table of entry.data.options) {
|
||||
const key = `${arg.schema}.${table.value}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
tables.push({
|
||||
value: table.value,
|
||||
label: table.label ?? table.value,
|
||||
schema: arg.schema,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tables;
|
||||
}, [dbId, normalizedCatalog, apiState, skipFetch]);
|
||||
|
||||
// Collect column names from all cached table-metadata queries for this database/catalog.
|
||||
// This includes columns from any table the user has expanded in the tree.
|
||||
const allColumns = useMemo(() => {
|
||||
if (skipFetch || !dbId || !apiState) return [];
|
||||
const columns = new Set<string>();
|
||||
tablesForColumnMetadata.forEach(table => {
|
||||
tableEndpoints.tableMetadata
|
||||
.select(
|
||||
dbId && schema
|
||||
? {
|
||||
dbId,
|
||||
catalog,
|
||||
schema,
|
||||
table,
|
||||
}
|
||||
: skipToken,
|
||||
)({
|
||||
[api.reducerPath]: apiState,
|
||||
})
|
||||
.data?.columns?.forEach(({ name }) => {
|
||||
columns.add(name);
|
||||
});
|
||||
});
|
||||
const queries = apiState.queries ?? {};
|
||||
for (const entry of Object.values(queries) as any[]) {
|
||||
const arg = entry?.originalArgs;
|
||||
if (
|
||||
entry?.status === 'fulfilled' &&
|
||||
entry?.data?.columns &&
|
||||
arg?.dbId === dbId &&
|
||||
(arg?.catalog ?? null) === normalizedCatalog
|
||||
) {
|
||||
for (const col of entry.data.columns) {
|
||||
columns.add(col.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...columns];
|
||||
}, [dbId, catalog, schema, apiState, tablesForColumnMetadata]);
|
||||
}, [dbId, normalizedCatalog, apiState, skipFetch]);
|
||||
|
||||
const insertMatch = useEffectEvent((editor: Editor, data: any) => {
|
||||
if (data.meta === 'table') {
|
||||
@@ -153,7 +160,7 @@ export function useKeywords(
|
||||
{ id: String(queryEditorId), dbId: dbId as number, tabViewId },
|
||||
data.value,
|
||||
catalog ?? null,
|
||||
schema ?? '',
|
||||
data.schema ?? schema ?? '',
|
||||
false, // Don't auto-expand/switch tabs when adding via autocomplete
|
||||
),
|
||||
);
|
||||
@@ -187,9 +194,10 @@ export function useKeywords(
|
||||
|
||||
const tableKeywords = useMemo(
|
||||
() =>
|
||||
(tableData?.options ?? []).map(({ value, label }) => ({
|
||||
allCachedTables.map(({ value, label, schema: tableSchema }) => ({
|
||||
name: label,
|
||||
value,
|
||||
schema: tableSchema,
|
||||
score: TABLE_AUTOCOMPLETE_SCORE,
|
||||
meta: 'table',
|
||||
completer: {
|
||||
@@ -197,7 +205,7 @@ export function useKeywords(
|
||||
},
|
||||
...getHelperText(value),
|
||||
})),
|
||||
[tableData?.options, insertMatch],
|
||||
[allCachedTables, insertMatch],
|
||||
);
|
||||
|
||||
const columnKeywords = useMemo(
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ModalTrigger } from '@superset-ui/core/components';
|
||||
import CodeSyntaxHighlighter from '@superset-ui/core/components/CodeSyntaxHighlighter';
|
||||
@@ -40,6 +41,12 @@ interface TriggerNodeProps {
|
||||
maxWidth: number;
|
||||
}
|
||||
|
||||
const Title = styled.h4`
|
||||
font-size: ${({ theme }) => theme.fontSizeLG}px;
|
||||
margin: ${({ theme }) => theme.sizeUnit * 2}px 0;
|
||||
font-weight: ${({ theme }) => theme.fontWeightStrong};
|
||||
`;
|
||||
|
||||
const shrinkSql = (sql: string, maxLines: number, maxWidth: number) => {
|
||||
const ssql = sql || '';
|
||||
let lines = ssql.split('\n');
|
||||
@@ -63,14 +70,32 @@ function TriggerNode({ shrink, sql, maxLines, maxWidth }: TriggerNodeProps) {
|
||||
}
|
||||
|
||||
function HighlightSqlModal({ rawSql, sql }: HighlightedSqlModalTypes) {
|
||||
const theme = useTheme();
|
||||
const codeBlockStyle = {
|
||||
border: 1,
|
||||
borderColor: theme.colorBorder,
|
||||
borderStyle: 'solid',
|
||||
backgroundColor: theme.colorBgLayout,
|
||||
fontSize: theme.fontSize * 0.9,
|
||||
padding: theme.sizeUnit * 2,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4>{t('Source SQL')}</h4>
|
||||
<CodeSyntaxHighlighter language="sql">{sql}</CodeSyntaxHighlighter>
|
||||
<div
|
||||
css={css`
|
||||
margin: -${theme.sizeUnit * 6}px;
|
||||
`}
|
||||
>
|
||||
<Title>{t('Source SQL')}</Title>
|
||||
<CodeSyntaxHighlighter language="sql" customStyle={codeBlockStyle}>
|
||||
{sql}
|
||||
</CodeSyntaxHighlighter>
|
||||
{rawSql && rawSql !== sql && (
|
||||
<div>
|
||||
<h4>{t('Executed SQL')}</h4>
|
||||
<CodeSyntaxHighlighter language="sql">{rawSql}</CodeSyntaxHighlighter>
|
||||
<Title>{t('Executed SQL')}</Title>
|
||||
<CodeSyntaxHighlighter language="sql" customStyle={codeBlockStyle}>
|
||||
{rawSql}
|
||||
</CodeSyntaxHighlighter>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ const QueryLimitSelect = ({
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
color="default"
|
||||
variant="text"
|
||||
showMarginRight={false}
|
||||
>
|
||||
|
||||
@@ -31,7 +31,7 @@ const SaveDatasetActionButton = ({
|
||||
}: SaveDatasetActionButtonProps) => (
|
||||
<>
|
||||
<Button
|
||||
color="primary"
|
||||
color="default"
|
||||
variant="text"
|
||||
onClick={() => setShowSave(true)}
|
||||
icon={<Icons.SaveOutlined />}
|
||||
@@ -40,7 +40,7 @@ const SaveDatasetActionButton = ({
|
||||
/>
|
||||
{onSaveAsExplore && (
|
||||
<Button
|
||||
color="primary"
|
||||
color="default"
|
||||
variant="text"
|
||||
onClick={() => onSaveAsExplore?.()}
|
||||
icon={<Icons.TableOutlined />}
|
||||
|
||||
@@ -233,7 +233,7 @@ const SaveQuery = ({
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
buttonStyle={isSaved ? undefined : 'primary'}
|
||||
buttonStyle={isSaved ? 'secondary' : 'primary'}
|
||||
onClick={onSaveWrapper}
|
||||
cta
|
||||
>
|
||||
|
||||
@@ -71,7 +71,7 @@ const ShareSqlLabQuery = ({
|
||||
const tooltip = t('Copy query link to your clipboard');
|
||||
return (
|
||||
<Button
|
||||
color="primary"
|
||||
color="default"
|
||||
variant="text"
|
||||
tooltip={tooltip}
|
||||
css={css`
|
||||
|
||||
@@ -201,7 +201,7 @@ test('display no compatible schema found when schema api throws errors', async (
|
||||
).toBeGreaterThanOrEqual(1),
|
||||
);
|
||||
const select = screen.getByRole('combobox', {
|
||||
name: 'Select schema or type to search schemas',
|
||||
name: 'Select schema',
|
||||
});
|
||||
userEvent.click(select);
|
||||
expect(
|
||||
|
||||
@@ -134,9 +134,9 @@ test('filters schemas when searching', async () => {
|
||||
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();
|
||||
// All schemas are visible (no longer filtered to selected schema)
|
||||
expect(screen.getByText('test_schema')).toBeInTheDocument();
|
||||
expect(screen.getByText('information_schema')).toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
'Enter a part of the object name',
|
||||
|
||||
@@ -16,21 +16,32 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import type { NodeRendererProps } from 'react-arborist';
|
||||
import { Icons, Tooltip, Typography } from '@superset-ui/core/components';
|
||||
import { Icons, 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';
|
||||
import { ActionButton } from '@superset-ui/core/components/ActionButton';
|
||||
import copyTextToClipboard from 'src/utils/copy';
|
||||
import type { TreeNodeData } from './types';
|
||||
|
||||
const StyledColumnNode = styled.div`
|
||||
& > .ant-flex {
|
||||
flex: 1;
|
||||
margin-right: ${({ theme }) => theme.sizeUnit * 1.5}px;
|
||||
margin-right: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.col-copy-action {
|
||||
opacity: 0;
|
||||
flex-shrink: 0;
|
||||
margin-left: ${({ theme }) => theme.sizeUnit}px;
|
||||
}
|
||||
|
||||
&:hover .col-copy-action {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const getOpacity = (disableCheckbox: boolean | undefined) =>
|
||||
@@ -67,12 +78,19 @@ export interface TreeNodeRendererProps extends NodeRendererProps<TreeNodeData> {
|
||||
loadingNodes: Record<string, boolean>;
|
||||
searchTerm: string;
|
||||
catalog: string | null | undefined;
|
||||
fetchLazyTables: (params: FetchLazyTablesParams) => void;
|
||||
pinnedTableKeys: Set<string>;
|
||||
selectStarMap: Record<string, string>;
|
||||
handleRefreshTables: (params: {
|
||||
dbId: number;
|
||||
catalog: string | null | undefined;
|
||||
schema: string;
|
||||
}) => void;
|
||||
handlePinTable: (
|
||||
tableName: string,
|
||||
schemaName: string,
|
||||
catalogName: string | null,
|
||||
) => void;
|
||||
handleUnpinTable: (tableName: string, schemaName: string) => void;
|
||||
}
|
||||
|
||||
const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
@@ -82,9 +100,13 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
loadingNodes,
|
||||
searchTerm,
|
||||
catalog,
|
||||
fetchLazyTables,
|
||||
pinnedTableKeys,
|
||||
selectStarMap,
|
||||
handleRefreshTables,
|
||||
handlePinTable,
|
||||
handleUnpinTable,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { data } = node;
|
||||
const parts = data.id.split(':');
|
||||
const [identifier, _dbId, schema, tableName] = parts;
|
||||
@@ -109,8 +131,9 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
|
||||
if (identifier === 'table') {
|
||||
const TableTypeIcon =
|
||||
data.tableType === 'view' ? Icons.EyeOutlined : Icons.TableOutlined;
|
||||
// Show loading icon with table type icon when loading
|
||||
data.tableType === 'view'
|
||||
? Icons.FunctionOutlined
|
||||
: Icons.TableOutlined;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
@@ -119,15 +142,7 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
</>
|
||||
);
|
||||
}
|
||||
const ExpandIcon = isManuallyOpen
|
||||
? Icons.MinusSquareOutlined
|
||||
: Icons.PlusSquareOutlined;
|
||||
return (
|
||||
<>
|
||||
<ExpandIcon iconSize="l" />
|
||||
<TableTypeIcon iconSize="l" />
|
||||
</>
|
||||
);
|
||||
return <TableTypeIcon iconSize="l" />;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -162,7 +177,24 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
data-selected={node.isSelected}
|
||||
onClick={() => node.select()}
|
||||
>
|
||||
<ColumnElement column={data.columnData} />
|
||||
<ColumnElement
|
||||
column={data.columnData}
|
||||
actions={
|
||||
<span
|
||||
className="col-copy-action"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<ActionButton
|
||||
label={`copy-col-${data.name}`}
|
||||
tooltip={t('Copy column name')}
|
||||
icon={<Icons.CopyOutlined iconSize="m" />}
|
||||
onClick={() =>
|
||||
copyTextToClipboard(() => Promise.resolve(data.name))
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</StyledColumnNode>
|
||||
);
|
||||
}
|
||||
@@ -205,38 +237,94 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
<RefreshLabel
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
fetchLazyTables({
|
||||
dbId: _dbId,
|
||||
handleRefreshTables({
|
||||
dbId: Number(_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.PushpinOutlined iconSize="xl" />
|
||||
</Tooltip>
|
||||
}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handlePinTable(tableName, schema, catalog ?? null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{identifier === 'table' &&
|
||||
(() => {
|
||||
const nodeDbId = Number(_dbId);
|
||||
const tableKey = `${nodeDbId}:${schema}:${tableName}`;
|
||||
const isPinned = pinnedTableKeys.has(tableKey);
|
||||
const selectStar = selectStarMap[tableKey];
|
||||
return (
|
||||
<div
|
||||
className="side-action-container"
|
||||
role="menu"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{isPinned && (
|
||||
<div className="action-static">
|
||||
<ActionButton
|
||||
label={`pinned-${schema}-${tableName}`}
|
||||
icon={
|
||||
<Icons.PushpinFilled
|
||||
iconSize="m"
|
||||
css={css`
|
||||
color: ${theme.colorTextDescription};
|
||||
`}
|
||||
/>
|
||||
}
|
||||
onClick={() => handleUnpinTable(tableName, schema)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="action-hover">
|
||||
{selectStar && (
|
||||
<ActionButton
|
||||
label={`copy-select-${schema}-${tableName}`}
|
||||
tooltip={t('Copy SELECT statement to the clipboard')}
|
||||
icon={<Icons.CopyOutlined iconSize="m" />}
|
||||
onClick={() =>
|
||||
copyTextToClipboard(() => Promise.resolve(selectStar))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ActionButton
|
||||
label={
|
||||
isPinned
|
||||
? `unpin-${schema}-${tableName}`
|
||||
: `pin-${schema}-${tableName}`
|
||||
}
|
||||
tooltip={
|
||||
isPinned
|
||||
? t('Unpin from the result panel')
|
||||
: t('Pin to the result panel')
|
||||
}
|
||||
icon={
|
||||
isPinned ? (
|
||||
<Icons.PushpinFilled iconSize="m" />
|
||||
) : (
|
||||
<Icons.PushpinOutlined iconSize="m" />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
isPinned
|
||||
? handleUnpinTable(tableName, schema)
|
||||
: handlePinTable(tableName, schema, catalog ?? null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ActionButton
|
||||
label={`toggle-${schema}-${tableName}`}
|
||||
icon={
|
||||
isManuallyOpen ? (
|
||||
<Icons.UpOutlined iconSize="m" />
|
||||
) : (
|
||||
<Icons.DownOutlined iconSize="m" />
|
||||
)
|
||||
}
|
||||
onClick={() => node.toggle()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
} 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 { addTable, removeTables } from 'src/SqlLab/actions/sqlLab';
|
||||
import PanelToolbar from 'src/components/PanelToolbar';
|
||||
import { ViewLocations } from 'src/SqlLab/contributions';
|
||||
import TreeNodeRenderer from './TreeNodeRenderer';
|
||||
@@ -64,16 +64,24 @@ const StyledTreeContainer = styled.div`
|
||||
&:hover {
|
||||
background-color: ${({ theme }) => theme.colorBgTextHover};
|
||||
|
||||
.side-action-container {
|
||||
opacity: 1;
|
||||
.action-static {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-selected='true'] {
|
||||
background-color: ${({ theme }) => theme.colorBgTextActive};
|
||||
|
||||
.side-action-container {
|
||||
opacity: 1;
|
||||
.action-static {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,12 +106,21 @@ const StyledTreeContainer = styled.div`
|
||||
}
|
||||
|
||||
.side-action-container {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: ${({ theme }) => theme.sizeUnit * 1.5}px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: ${({ theme }) => theme.zIndexPopupBase};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.action-static {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 0.5}px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -119,19 +136,20 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
);
|
||||
const queryEditor = useQueryEditor(queryEditorId, [
|
||||
'dbId',
|
||||
'schema',
|
||||
'catalog',
|
||||
'tabViewId',
|
||||
]);
|
||||
const { dbId, catalog, schema: selectedSchema } = queryEditor;
|
||||
const { dbId, catalog } = queryEditor;
|
||||
const editorId = queryEditor.tabViewId ?? queryEditor.id;
|
||||
const pinnedTables = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
tables.map(({ queryEditorId, dbId, schema, name, persistData }) => [
|
||||
queryEditor.id === queryEditorId ? `${dbId}:${schema}:${name}` : '',
|
||||
editorId === queryEditorId ? `${dbId}:${schema}:${name}` : '',
|
||||
persistData,
|
||||
]),
|
||||
),
|
||||
[tables, queryEditor.id],
|
||||
[tables, editorId],
|
||||
);
|
||||
|
||||
// Tree data hook - manages schema/table/column data fetching and tree structure
|
||||
@@ -140,21 +158,47 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
isFetching,
|
||||
refetch,
|
||||
loadingNodes,
|
||||
selectStarMap,
|
||||
handleToggle,
|
||||
fetchLazyTables,
|
||||
handleRefreshTables,
|
||||
errorPayload,
|
||||
} = useTreeData({
|
||||
dbId,
|
||||
catalog,
|
||||
selectedSchema,
|
||||
pinnedTables,
|
||||
});
|
||||
|
||||
const pinnedTableKeys = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
tables
|
||||
.filter(({ queryEditorId: qeId }) => editorId === qeId)
|
||||
.map(({ dbId, schema, name }) => `${dbId}:${schema}:${name}`),
|
||||
),
|
||||
[tables, editorId],
|
||||
);
|
||||
|
||||
const handlePinTable = useCallback(
|
||||
(tableName: string, schemaName: string, catalogName: string | null) =>
|
||||
dispatch(addTable(queryEditor, tableName, catalogName, schemaName)),
|
||||
[dispatch, queryEditor],
|
||||
);
|
||||
|
||||
const handleUnpinTable = useCallback(
|
||||
(tableName: string, schemaName: string) => {
|
||||
const table = tables.find(
|
||||
t =>
|
||||
t.queryEditorId === editorId &&
|
||||
t.dbId === dbId &&
|
||||
t.schema === schemaName &&
|
||||
t.name === tableName,
|
||||
);
|
||||
if (table) {
|
||||
dispatch(removeTables([table]));
|
||||
}
|
||||
},
|
||||
[dispatch, tables, editorId, dbId],
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const handleSearchChange = useCallback(
|
||||
({ target }: ChangeEvent<HTMLInputElement>) => setSearchTerm(target.value),
|
||||
@@ -238,14 +282,20 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
loadingNodes={loadingNodes}
|
||||
searchTerm={searchTerm}
|
||||
catalog={catalog}
|
||||
fetchLazyTables={fetchLazyTables}
|
||||
pinnedTableKeys={pinnedTableKeys}
|
||||
selectStarMap={selectStarMap}
|
||||
handleRefreshTables={handleRefreshTables}
|
||||
handlePinTable={handlePinTable}
|
||||
handleUnpinTable={handleUnpinTable}
|
||||
/>
|
||||
),
|
||||
[
|
||||
catalog,
|
||||
fetchLazyTables,
|
||||
pinnedTableKeys,
|
||||
selectStarMap,
|
||||
handleRefreshTables,
|
||||
handlePinTable,
|
||||
handleUnpinTable,
|
||||
loadingNodes,
|
||||
manuallyOpenedNodes,
|
||||
searchTerm,
|
||||
|
||||
@@ -93,7 +93,6 @@ function treeDataReducer(
|
||||
interface UseTreeDataParams {
|
||||
dbId: number | undefined;
|
||||
catalog: string | null | undefined;
|
||||
selectedSchema: string | undefined;
|
||||
pinnedTables: Record<string, TableMetaData | undefined>;
|
||||
}
|
||||
|
||||
@@ -102,8 +101,13 @@ interface UseTreeDataResult {
|
||||
isFetching: boolean;
|
||||
refetch: () => void;
|
||||
loadingNodes: Record<string, boolean>;
|
||||
selectStarMap: Record<string, string>;
|
||||
handleToggle: (id: string, isOpen: boolean) => Promise<void>;
|
||||
fetchLazyTables: ReturnType<typeof useLazyTablesQuery>[0];
|
||||
handleRefreshTables: (params: {
|
||||
dbId: number;
|
||||
catalog: string | null | undefined;
|
||||
schema: string;
|
||||
}) => void;
|
||||
errorPayload: SupersetError | null;
|
||||
}
|
||||
|
||||
@@ -116,7 +120,6 @@ const createEmptyNode = (parentId: string): TreeNodeData => ({
|
||||
const useTreeData = ({
|
||||
dbId,
|
||||
catalog,
|
||||
selectedSchema,
|
||||
pinnedTables,
|
||||
}: UseTreeDataParams): UseTreeDataResult => {
|
||||
// Schema data from API
|
||||
@@ -247,14 +250,48 @@ const useTreeData = ({
|
||||
],
|
||||
);
|
||||
|
||||
// Force-refresh the table list for a schema and update the tree
|
||||
const handleRefreshTables = useCallback(
|
||||
({
|
||||
dbId: refreshDbId,
|
||||
catalog: refreshCatalog,
|
||||
schema,
|
||||
}: {
|
||||
dbId: number;
|
||||
catalog: string | null | undefined;
|
||||
schema: string;
|
||||
}) => {
|
||||
const schemaKey = `${refreshDbId}:${schema}`;
|
||||
const nodeId = `schema:${refreshDbId}:${schema}`;
|
||||
|
||||
dispatch({ type: 'SET_LOADING_NODE', nodeId, loading: true });
|
||||
|
||||
fetchLazyTables({
|
||||
dbId: refreshDbId,
|
||||
catalog: refreshCatalog,
|
||||
schema,
|
||||
forceRefresh: true,
|
||||
})
|
||||
.unwrap()
|
||||
.then(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, loading: false });
|
||||
});
|
||||
},
|
||||
[fetchLazyTables],
|
||||
);
|
||||
|
||||
// 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 data = schemaData?.map(schema => {
|
||||
const schemaKey = `${dbId}:${schema.value}`;
|
||||
const schemaId = `schema:${dbId}:${schema.value}`;
|
||||
const tablesData = tableData?.[schemaKey];
|
||||
@@ -316,22 +353,31 @@ const useTreeData = ({
|
||||
});
|
||||
|
||||
return data ?? [];
|
||||
}, [
|
||||
dbId,
|
||||
schemaData,
|
||||
tableData,
|
||||
tableSchemaData,
|
||||
pinnedTables,
|
||||
selectedSchema,
|
||||
]);
|
||||
}, [dbId, schemaData, tableData, tableSchemaData, pinnedTables]);
|
||||
|
||||
// Map of tableKey -> selectStar SQL from table metadata
|
||||
const selectStarMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
const addEntry = (key: string, meta: TableMetaData | undefined) => {
|
||||
if (meta?.selectStar) {
|
||||
map[key] = meta.selectStar;
|
||||
}
|
||||
};
|
||||
Object.entries(tableSchemaData).forEach(([key, meta]) =>
|
||||
addEntry(key, meta),
|
||||
);
|
||||
Object.entries(pinnedTables).forEach(([key, meta]) => addEntry(key, meta));
|
||||
return map;
|
||||
}, [tableSchemaData, pinnedTables]);
|
||||
|
||||
return {
|
||||
treeData,
|
||||
isFetching,
|
||||
refetch,
|
||||
loadingNodes,
|
||||
selectStarMap,
|
||||
handleToggle,
|
||||
fetchLazyTables,
|
||||
handleRefreshTables,
|
||||
errorPayload,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -29,9 +29,12 @@ import {
|
||||
} from 'spec/helpers/testing-library';
|
||||
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
|
||||
import mockState from 'spec/fixtures/mockState';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
|
||||
import DrillByModal, { DrillByModalProps } from './DrillByModal';
|
||||
|
||||
setupAGGridModules();
|
||||
|
||||
// Mock the isEmbedded function
|
||||
jest.mock('src/dashboard/util/isEmbedded', () => ({
|
||||
isEmbedded: jest.fn(() => false),
|
||||
@@ -406,16 +409,9 @@ describe('Table view with pagination', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that pagination is rendered (there's also a breadcrumb list)
|
||||
const lists = screen.getAllByRole('list');
|
||||
const paginationList = lists.find(list =>
|
||||
list.className?.includes('pagination'),
|
||||
);
|
||||
expect(paginationList).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle pagination in table view', async () => {
|
||||
test('should render data in table view', async () => {
|
||||
await renderModal({
|
||||
column: { column_name: 'state', verbose_name: null },
|
||||
drillByConfig: {
|
||||
@@ -432,19 +428,9 @@ describe('Table view with pagination', () => {
|
||||
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that first page data is shown
|
||||
expect(screen.getByText('State0')).toBeInTheDocument();
|
||||
|
||||
// Check pagination controls exist
|
||||
const nextPageButton = screen.getByTitle('Next Page');
|
||||
expect(nextPageButton).toBeInTheDocument();
|
||||
|
||||
// Click next page
|
||||
userEvent.click(nextPageButton);
|
||||
|
||||
// Verify page changed (State0 should not be visible on page 2)
|
||||
// Check that data is rendered in the grid
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('State0')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('State0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -542,11 +528,12 @@ describe('Table view with pagination', () => {
|
||||
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should show empty state
|
||||
expect(screen.getByText('No data')).toBeInTheDocument();
|
||||
// ag-grid shows its own empty overlay when there are no rows
|
||||
const tableContainer = screen.getByTestId('drill-by-results-table');
|
||||
expect(tableContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle sorting in table view', async () => {
|
||||
test('should render grid in table view', async () => {
|
||||
await renderModal({
|
||||
column: { column_name: 'state', verbose_name: null },
|
||||
drillByConfig: {
|
||||
@@ -563,16 +550,7 @@ describe('Table view with pagination', () => {
|
||||
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find sortable column header
|
||||
const sortableHeaders = screen.getAllByTestId('sort-header');
|
||||
expect(sortableHeaders.length).toBeGreaterThan(0);
|
||||
|
||||
// Click to sort
|
||||
userEvent.click(sortableHeaders[0]);
|
||||
|
||||
// Table should still be rendered without crashes
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,25 +25,12 @@ import {
|
||||
within,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { useResultsTableView } from './useResultsTableView';
|
||||
|
||||
const capturedProps: any[] = [];
|
||||
|
||||
jest.mock(
|
||||
'src/explore/components/DataTablesPane/components/SingleQueryResultPane',
|
||||
() => {
|
||||
const actual = jest.requireActual(
|
||||
'src/explore/components/DataTablesPane/components/SingleQueryResultPane',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
SingleQueryResultPane: (props: any) => {
|
||||
capturedProps.push(props);
|
||||
return actual.SingleQueryResultPane(props);
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
beforeAll(() => {
|
||||
setupAGGridModules();
|
||||
});
|
||||
|
||||
const MOCK_CHART_DATA_RESULT = [
|
||||
{
|
||||
@@ -92,9 +79,9 @@ test('Displays results table for 1 query', () => {
|
||||
);
|
||||
render(result.current, { useRedux: true });
|
||||
expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('sort-header')).toHaveLength(2);
|
||||
expect(screen.getAllByTestId('table-row')).toHaveLength(4);
|
||||
expect(screen.getByText('name')).toBeInTheDocument();
|
||||
expect(screen.getByText('sum__num')).toBeInTheDocument();
|
||||
expect(screen.getByText('Michael')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Displays results for 2 queries', async () => {
|
||||
@@ -102,60 +89,18 @@ test('Displays results for 2 queries', async () => {
|
||||
useResultsTableView(MOCK_CHART_DATA_RESULT, '1__table', true),
|
||||
);
|
||||
render(result.current, { useRedux: true });
|
||||
const getActiveTabElement = () =>
|
||||
document.querySelector('.ant-tabs-tabpane-active') as HTMLElement;
|
||||
|
||||
const tablistElement = screen.getByRole('tablist');
|
||||
expect(tablistElement).toBeInTheDocument();
|
||||
expect(within(tablistElement).getByText('Results 1')).toBeInTheDocument();
|
||||
expect(within(tablistElement).getByText('Results 2')).toBeInTheDocument();
|
||||
|
||||
expect(within(getActiveTabElement()).getByRole('table')).toBeInTheDocument();
|
||||
expect(
|
||||
within(getActiveTabElement()).getAllByTestId('sort-header'),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
within(getActiveTabElement()).getAllByTestId('table-row'),
|
||||
).toHaveLength(4);
|
||||
expect(screen.getByText('Michael')).toBeInTheDocument();
|
||||
|
||||
userEvent.click(screen.getByText('Results 2'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(getActiveTabElement()).getAllByTestId('sort-header'),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
expect(
|
||||
within(getActiveTabElement()).getAllByTestId('table-row'),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('passes isPaginationSticky={false} to SingleQueryResultPane for single query', () => {
|
||||
capturedProps.length = 0;
|
||||
const { result } = renderHook(() =>
|
||||
useResultsTableView(MOCK_CHART_DATA_RESULT.slice(0, 1), '1__table', true),
|
||||
);
|
||||
render(result.current, { useRedux: true });
|
||||
|
||||
expect(capturedProps.length).toBeGreaterThan(0);
|
||||
capturedProps.forEach(props => {
|
||||
expect(props).toMatchObject({
|
||||
isPaginationSticky: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('passes isPaginationSticky={false} to SingleQueryResultPane for multiple queries', () => {
|
||||
capturedProps.length = 0;
|
||||
const { result } = renderHook(() =>
|
||||
useResultsTableView(MOCK_CHART_DATA_RESULT, '1__table', true),
|
||||
);
|
||||
render(result.current, { useRedux: true });
|
||||
|
||||
expect(capturedProps.length).toBeGreaterThanOrEqual(2);
|
||||
capturedProps.forEach(props => {
|
||||
expect(props).toMatchObject({
|
||||
isPaginationSticky: false,
|
||||
});
|
||||
expect(screen.getByText('gender')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('boy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -22,13 +22,12 @@ import { t } from '@apache-superset/core/translation';
|
||||
import { SingleQueryResultPane } from 'src/explore/components/DataTablesPane/components/SingleQueryResultPane';
|
||||
import Tabs from '@superset-ui/core/components/Tabs';
|
||||
|
||||
const DATA_SIZE = 15;
|
||||
|
||||
const PaginationContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
& .pagination-container {
|
||||
bottom: ${-theme.sizeUnit * 4}px;
|
||||
}
|
||||
const ResultContainer = styled.div`
|
||||
${() => css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -42,19 +41,17 @@ export const useResultsTableView = (
|
||||
}
|
||||
if (chartDataResult.length === 1) {
|
||||
return (
|
||||
<PaginationContainer data-test="drill-by-results-table">
|
||||
<ResultContainer data-test="drill-by-results-table">
|
||||
<SingleQueryResultPane
|
||||
colnames={chartDataResult[0].colnames}
|
||||
coltypes={chartDataResult[0].coltypes}
|
||||
rowcount={chartDataResult[0].sql_rowcount}
|
||||
data={chartDataResult[0].data}
|
||||
dataSize={DATA_SIZE}
|
||||
datasourceId={datasourceId}
|
||||
isVisible
|
||||
canDownload={canDownload}
|
||||
isPaginationSticky={false}
|
||||
/>
|
||||
</PaginationContainer>
|
||||
</ResultContainer>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -64,19 +61,17 @@ export const useResultsTableView = (
|
||||
key: `result-tab-${index}`,
|
||||
label: t('Results %s', index + 1),
|
||||
children: (
|
||||
<PaginationContainer>
|
||||
<ResultContainer>
|
||||
<SingleQueryResultPane
|
||||
colnames={res.colnames}
|
||||
coltypes={res.coltypes}
|
||||
data={res.data}
|
||||
rowcount={res.sql_rowcount}
|
||||
dataSize={DATA_SIZE}
|
||||
datasourceId={datasourceId}
|
||||
isVisible
|
||||
canDownload={canDownload}
|
||||
isPaginationSticky={false}
|
||||
/>
|
||||
</PaginationContainer>
|
||||
</ResultContainer>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -214,7 +214,7 @@ test('Refresh should work', async () => {
|
||||
expect(fetchMock.callHistory.calls(schemaApiRoute).length).toBe(0);
|
||||
|
||||
const select = screen.getByRole('combobox', {
|
||||
name: 'Select schema or type to search schemas: public',
|
||||
name: 'Select schema: public',
|
||||
});
|
||||
|
||||
await userEvent.click(select);
|
||||
@@ -331,7 +331,7 @@ test('Should schema select display options', async () => {
|
||||
const props = createProps();
|
||||
render(<DatabaseSelector {...props} />, { useRedux: true, store });
|
||||
const select = screen.getByRole('combobox', {
|
||||
name: 'Select schema or type to search schemas: public',
|
||||
name: 'Select schema: public',
|
||||
});
|
||||
expect(select).toBeInTheDocument();
|
||||
await userEvent.click(select);
|
||||
@@ -379,7 +379,7 @@ test('Sends the correct schema when changing the schema', async () => {
|
||||
rerender(<DatabaseSelector {...props} />);
|
||||
expect(props.onSchemaChange).toHaveBeenCalledTimes(0);
|
||||
const select = screen.getByRole('combobox', {
|
||||
name: 'Select schema or type to search schemas: public',
|
||||
name: 'Select schema: public',
|
||||
});
|
||||
expect(select).toBeInTheDocument();
|
||||
await userEvent.click(select);
|
||||
|
||||
@@ -515,17 +515,12 @@ export function DatabaseSelector({
|
||||
|
||||
function renderSchemaSelect() {
|
||||
if (sqlLabMode) {
|
||||
return renderSelectRow(
|
||||
t('Select schema or type to search schemas'),
|
||||
null,
|
||||
null,
|
||||
{
|
||||
displayValue: currentSchema?.label,
|
||||
disabled: !currentDb || readOnly,
|
||||
loading: loadingSchemas,
|
||||
icon: <Icons.RightOutlined />,
|
||||
},
|
||||
);
|
||||
return renderSelectRow(t('Select schema'), null, null, {
|
||||
displayValue: currentSchema?.label,
|
||||
disabled: !currentDb || readOnly,
|
||||
loading: loadingSchemas,
|
||||
icon: <Icons.RightOutlined />,
|
||||
});
|
||||
}
|
||||
const refreshIcon = !readOnly && (
|
||||
<RefreshLabel
|
||||
@@ -539,13 +534,13 @@ export function DatabaseSelector({
|
||||
{renderSelectRow(
|
||||
t('Schema'),
|
||||
<Select
|
||||
ariaLabel={t('Select schema or type to search schemas')}
|
||||
ariaLabel={t('Select schema')}
|
||||
disabled={!currentDb || readOnly}
|
||||
labelInValue
|
||||
loading={loadingSchemas}
|
||||
name="select-schema"
|
||||
notFoundContent={t('No compatible schema found')}
|
||||
placeholder={t('Select schema or type to search schemas')}
|
||||
placeholder={t('Select schema')}
|
||||
onChange={item => changeSchema(item as SchemaOption)}
|
||||
options={schemaOptions}
|
||||
showSearch
|
||||
|
||||
@@ -99,6 +99,7 @@ export function DatabaseErrorMessage({
|
||||
<ErrorAlert
|
||||
errorType={t('%s Error', extra?.engine_name || t('DB engine'))}
|
||||
message={alertMessage}
|
||||
messagePre
|
||||
description={alertDescription}
|
||||
type={level}
|
||||
descriptionDetails={body}
|
||||
|
||||
@@ -35,6 +35,7 @@ export const ErrorAlert: React.FC<ErrorAlertProps> = ({
|
||||
description,
|
||||
descriptionDetails,
|
||||
descriptionDetailsCollapsed = true,
|
||||
messagePre = false,
|
||||
descriptionPre = true,
|
||||
compact = false,
|
||||
children,
|
||||
@@ -69,13 +70,20 @@ export const ErrorAlert: React.FC<ErrorAlertProps> = ({
|
||||
);
|
||||
};
|
||||
const preStyle = {
|
||||
whiteSpace: 'pre-wrap',
|
||||
whiteSpace: 'pre-wrap' as const,
|
||||
fontFamily: theme.fontFamilyCode,
|
||||
margin: `${theme.sizeUnit}px 0`,
|
||||
};
|
||||
const renderDescription = () => (
|
||||
<div>
|
||||
{message && <div>{message}</div>}
|
||||
{message &&
|
||||
(messagePre ? (
|
||||
<Typography.Paragraph style={preStyle}>
|
||||
{message}
|
||||
</Typography.Paragraph>
|
||||
) : (
|
||||
<div>{message}</div>
|
||||
))}
|
||||
{description && (
|
||||
<Typography.Paragraph
|
||||
style={descriptionPre ? preStyle : {}}
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface ErrorAlertProps {
|
||||
description?: React.ReactNode; // Text shown under the first line, not collapsible
|
||||
descriptionDetails?: React.ReactNode | string; // Text shown under the first line, collapsible
|
||||
descriptionDetailsCollapsed?: boolean; // Hides the collapsible section unless "Show more" is clicked, default true
|
||||
messagePre?: boolean; // Uses pre-style on the message, default false
|
||||
descriptionPre?: boolean; // Uses pre-style to break lines, default true
|
||||
compact?: boolean; // Shows the error icon with tooltip and modal, default false
|
||||
children?: React.ReactNode; // Additional content to show in the modal
|
||||
|
||||
@@ -62,7 +62,7 @@ const PanelToolbar = ({
|
||||
buttonSize="small"
|
||||
aria-label={command?.title}
|
||||
variant="text"
|
||||
color="primary"
|
||||
color="default"
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -140,7 +140,7 @@ const PanelToolbar = ({
|
||||
>
|
||||
<Button
|
||||
showMarginRight={false}
|
||||
color="primary"
|
||||
color="default"
|
||||
variant="text"
|
||||
css={css`
|
||||
padding: 8px;
|
||||
|
||||
@@ -93,7 +93,7 @@ test('renders with default props', async () => {
|
||||
name: 'Select database or type to search databases',
|
||||
});
|
||||
const schemaSelect = screen.getByRole('combobox', {
|
||||
name: 'Select schema or type to search schemas: test_schema',
|
||||
name: 'Select schema: test_schema',
|
||||
});
|
||||
const tableSelect = screen.getByRole('combobox', {
|
||||
name: 'Select table or type to search tables',
|
||||
|
||||
@@ -288,8 +288,15 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
|
||||
<Bar className={cx({ open: filtersOpen })} width={width}>
|
||||
<Header toggleFiltersBar={toggleFiltersBar} />
|
||||
{!isInitialized ? (
|
||||
<div css={{ height }}>
|
||||
<Loading size="s" muted />
|
||||
<div
|
||||
css={{
|
||||
height,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Loading position="inline-centered" size="s" muted />
|
||||
</div>
|
||||
) : (
|
||||
<div css={tabPaneStyle} onScroll={onScroll}>
|
||||
|
||||
@@ -654,7 +654,8 @@ test('reorders filters via keyboard (Space, ArrowDown, Space)', async () => {
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('updates sidebar title when filter name changes', async () => {
|
||||
// eslint-disable-next-line jest/no-disabled-tests -- flaky timeout, see https://github.com/apache/superset/pull/39181
|
||||
test.skip('updates sidebar title when filter name changes', async () => {
|
||||
const nativeFilterConfig = [
|
||||
buildNativeFilter('NATIVE_FILTER-1', 'state', []),
|
||||
buildNativeFilter('NATIVE_FILTER-2', 'country', []),
|
||||
|
||||
@@ -648,6 +648,7 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
|
||||
</span>
|
||||
);
|
||||
|
||||
let isInSubSection = false;
|
||||
const PanelChildren = (
|
||||
<>
|
||||
<StashFormDataContainer
|
||||
@@ -665,8 +666,19 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
|
||||
.filter(Boolean)}
|
||||
/>
|
||||
{isVisible && (
|
||||
<>
|
||||
<div style={{ paddingLeft: theme.sizeUnit * 2 }}>
|
||||
{section.controlSetRows.map((controlSets, i) => {
|
||||
// Detect sub-section header rows (React elements with no name prop)
|
||||
const isSubSectionHeaderRow = controlSets.some(
|
||||
item =>
|
||||
isValidElement(item) &&
|
||||
!(item as React.ReactElement<Record<string, unknown>>).props
|
||||
?.name,
|
||||
);
|
||||
if (isSubSectionHeaderRow) {
|
||||
isInSubSection = true;
|
||||
}
|
||||
|
||||
const renderedControls = controlSets
|
||||
.map(controlItem => {
|
||||
if (!controlItem) {
|
||||
@@ -715,14 +727,23 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
|
||||
if (renderedControls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
// Indent controls within sub-sections for visual hierarchy
|
||||
const paddingLeft =
|
||||
isInSubSection && !isSubSectionHeaderRow
|
||||
? theme.sizeUnit * 3
|
||||
: 0;
|
||||
return paddingLeft ? (
|
||||
<div key={`controlsetrow-${i}`} style={{ paddingLeft }}>
|
||||
<ControlRow controls={renderedControls} />
|
||||
</div>
|
||||
) : (
|
||||
<ControlRow
|
||||
key={`controlsetrow-${i}`}
|
||||
controls={renderedControls}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -206,6 +206,7 @@ export const DataTablesPane = ({
|
||||
<StyledDiv>
|
||||
<SamplesPane
|
||||
datasource={datasource}
|
||||
queryFormData={queryFormData}
|
||||
queryForce={queryForce}
|
||||
isRequest={isRequest.samples}
|
||||
setForceQuery={setForceQuery}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { styled, css } from '@apache-superset/core/theme';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { useMemo } from 'react';
|
||||
import { zip } from 'lodash';
|
||||
import { Select } from 'antd';
|
||||
import {
|
||||
CopyToClipboardButton,
|
||||
FilterInput,
|
||||
@@ -29,10 +30,19 @@ import { getTimeColumns } from 'src/explore/components/DataTableControl/utils';
|
||||
import RowCountLabel from 'src/components/RowCountLabel';
|
||||
import { TableControlsProps } from '../types';
|
||||
|
||||
export const ROW_LIMIT_OPTIONS = [
|
||||
{ value: 100, label: '100 rows' },
|
||||
{ value: 500, label: '500 rows' },
|
||||
{ value: 1000, label: '1k rows' },
|
||||
{ value: 5000, label: '5k rows' },
|
||||
{ value: 10000, label: '10k rows' },
|
||||
];
|
||||
|
||||
export const TableControlsWrapper = styled.div`
|
||||
${({ theme }) => `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: ${theme.sizeUnit * 2}px;
|
||||
padding-bottom: ${theme.sizeUnit * 2}px;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -51,6 +61,9 @@ export const TableControls = ({
|
||||
rowcount,
|
||||
isLoading,
|
||||
canDownload,
|
||||
rowLimit,
|
||||
rowLimitOptions,
|
||||
onRowLimitChange,
|
||||
}: TableControlsProps) => {
|
||||
const originalTimeColumns = getTimeColumns(datasourceId);
|
||||
const formattedTimeColumns = zip<string, GenericDataType>(
|
||||
@@ -76,9 +89,23 @@ export const TableControls = ({
|
||||
css={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`}
|
||||
>
|
||||
<RowCountLabel rowcount={rowcount} loading={isLoading} />
|
||||
{onRowLimitChange && (
|
||||
<Select
|
||||
value={rowLimit}
|
||||
onChange={onRowLimitChange}
|
||||
options={rowLimitOptions}
|
||||
size="small"
|
||||
css={css`
|
||||
min-width: 110px;
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
{(!onRowLimitChange || rowcount < (rowLimit ?? Infinity)) && (
|
||||
<RowCountLabel rowcount={rowcount} loading={isLoading} />
|
||||
)}
|
||||
{canDownload && (
|
||||
<CopyToClipboardButton data={formattedData} columns={columnNames} />
|
||||
)}
|
||||
|
||||
@@ -20,64 +20,96 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ensureIsArray } from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import {
|
||||
TableView,
|
||||
TableSize,
|
||||
EmptyState,
|
||||
Loading,
|
||||
EmptyWrapperType,
|
||||
} from '@superset-ui/core/components';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import {
|
||||
useFilteredTableData,
|
||||
useTableColumns,
|
||||
} from 'src/explore/components/DataTableControl';
|
||||
import { GridTable } from 'src/components/GridTable';
|
||||
import { GridSize } from 'src/components/GridTable/constants';
|
||||
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
|
||||
import { TableControls } from './DataTableControls';
|
||||
import { getDrillPayload } from 'src/components/Chart/DrillDetail/utils';
|
||||
import {
|
||||
useGridColumns,
|
||||
useKeywordFilter,
|
||||
useGridHeight,
|
||||
} from './useGridResultTable';
|
||||
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
|
||||
import { SamplesPaneProps } from '../types';
|
||||
|
||||
const Error = styled.pre`
|
||||
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
|
||||
`;
|
||||
|
||||
const cache = new WeakSet();
|
||||
const GridContainer = styled.div`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const GridSizer = styled.div`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
`;
|
||||
|
||||
const cache = new WeakMap();
|
||||
|
||||
const DEFAULT_ROW_LIMIT = 100;
|
||||
|
||||
export const SamplesPane = ({
|
||||
isRequest,
|
||||
datasource,
|
||||
queryFormData,
|
||||
queryForce,
|
||||
setForceQuery,
|
||||
dataSize = 50,
|
||||
isVisible,
|
||||
canDownload,
|
||||
}: SamplesPaneProps) => {
|
||||
const [filterText, setFilterText] = useState('');
|
||||
const [rowLimit, setRowLimit] = useState(DEFAULT_ROW_LIMIT);
|
||||
const [data, setData] = useState<Record<string, any>[][]>([]);
|
||||
const [colnames, setColnames] = useState<string[]>([]);
|
||||
const [coltypes, setColtypes] = useState<GenericDataType[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [rowcount, setRowCount] = useState<number>(0);
|
||||
const [responseError, setResponseError] = useState<string>('');
|
||||
const { gridHeight, measuredRef } = useGridHeight();
|
||||
const datasourceId = useMemo(
|
||||
() => `${datasource.id}__${datasource.type}`,
|
||||
[datasource],
|
||||
);
|
||||
|
||||
const handleRowLimitChange = useCallback(
|
||||
(limit: number) => {
|
||||
setRowLimit(limit);
|
||||
cache.delete(queryFormData);
|
||||
},
|
||||
[queryFormData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRequest && queryForce) {
|
||||
cache.delete(datasource);
|
||||
cache.delete(queryFormData);
|
||||
}
|
||||
|
||||
if (isRequest && !cache.has(datasource)) {
|
||||
if (isRequest && !cache.has(queryFormData)) {
|
||||
setIsLoading(true);
|
||||
getDatasourceSamples(datasource.type, datasource.id, queryForce, {})
|
||||
const payload =
|
||||
getDrillPayload(
|
||||
queryFormData as Parameters<typeof getDrillPayload>[0],
|
||||
) ?? {};
|
||||
getDatasourceSamples(
|
||||
datasource.type,
|
||||
datasource.id,
|
||||
queryForce,
|
||||
payload,
|
||||
rowLimit,
|
||||
1,
|
||||
)
|
||||
.then(response => {
|
||||
setData(ensureIsArray(response.data));
|
||||
setColnames(ensureIsArray(response.colnames));
|
||||
setColtypes(ensureIsArray(response.coltypes));
|
||||
setRowCount(response.rowcount);
|
||||
setResponseError('');
|
||||
cache.add(datasource);
|
||||
cache.set(queryFormData, true);
|
||||
if (queryForce) {
|
||||
setForceQuery?.(false);
|
||||
}
|
||||
@@ -92,20 +124,10 @@ export const SamplesPane = ({
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [datasource, isRequest, queryForce]);
|
||||
}, [datasource, queryFormData, isRequest, queryForce, rowLimit]);
|
||||
|
||||
// this is to preserve the order of the columns, even if there are integer values,
|
||||
// while also only grabbing the first column's keys
|
||||
const columns = useTableColumns(
|
||||
colnames,
|
||||
coltypes,
|
||||
data,
|
||||
datasourceId,
|
||||
isVisible,
|
||||
{}, // moreConfig
|
||||
true, // allowHTML
|
||||
);
|
||||
const filteredData = useFilteredTableData(filterText, data);
|
||||
const columns = useGridColumns(colnames, coltypes, data);
|
||||
const keywordFilter = useKeywordFilter(filterText);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(input: string) => setFilterText(input),
|
||||
@@ -120,7 +142,7 @@ export const SamplesPane = ({
|
||||
return (
|
||||
<>
|
||||
<TableControls
|
||||
data={filteredData}
|
||||
data={data}
|
||||
columnNames={colnames}
|
||||
columnTypes={coltypes}
|
||||
rowcount={rowcount}
|
||||
@@ -128,6 +150,9 @@ export const SamplesPane = ({
|
||||
onInputChange={handleInputChange}
|
||||
isLoading={isLoading}
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
<Error>{responseError}</Error>
|
||||
</>
|
||||
@@ -142,7 +167,7 @@ export const SamplesPane = ({
|
||||
return (
|
||||
<>
|
||||
<TableControls
|
||||
data={filteredData}
|
||||
data={data}
|
||||
columnNames={colnames}
|
||||
columnTypes={coltypes}
|
||||
rowcount={rowcount}
|
||||
@@ -150,19 +175,22 @@ export const SamplesPane = ({
|
||||
onInputChange={handleInputChange}
|
||||
isLoading={isLoading}
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
<TableView
|
||||
columns={columns}
|
||||
data={filteredData}
|
||||
pageSize={dataSize}
|
||||
noDataText={t('No results')}
|
||||
emptyWrapperType={EmptyWrapperType.Small}
|
||||
className="table-condensed"
|
||||
isPaginationSticky
|
||||
showRowCount={false}
|
||||
size={TableSize.Small}
|
||||
small
|
||||
/>
|
||||
<GridContainer>
|
||||
<GridSizer ref={measuredRef}>
|
||||
<GridTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
height={gridHeight}
|
||||
size={GridSize.Small}
|
||||
externalFilter={keywordFilter}
|
||||
showRowNumber
|
||||
/>
|
||||
</GridSizer>
|
||||
</GridContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,46 +17,52 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useCallback } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { GridTable } from 'src/components/GridTable';
|
||||
import { GridSize } from 'src/components/GridTable/constants';
|
||||
import {
|
||||
TableView,
|
||||
TableSize,
|
||||
EmptyWrapperType,
|
||||
} from '@superset-ui/core/components';
|
||||
import {
|
||||
useFilteredTableData,
|
||||
useTableColumns,
|
||||
} from 'src/explore/components/DataTableControl';
|
||||
useGridColumns,
|
||||
useKeywordFilter,
|
||||
useGridHeight,
|
||||
} from './useGridResultTable';
|
||||
import { TableControls } from './DataTableControls';
|
||||
import { SingleQueryResultPaneProp } from '../types';
|
||||
|
||||
const ResultPaneContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const GridContainer = styled.div`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const GridSizer = styled.div`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
`;
|
||||
|
||||
export const SingleQueryResultPane = ({
|
||||
data,
|
||||
colnames,
|
||||
coltypes,
|
||||
rowcount,
|
||||
datasourceId,
|
||||
dataSize = 50,
|
||||
isVisible,
|
||||
canDownload,
|
||||
columnDisplayNames,
|
||||
isPaginationSticky = true,
|
||||
rowLimit,
|
||||
rowLimitOptions,
|
||||
onRowLimitChange,
|
||||
}: SingleQueryResultPaneProp) => {
|
||||
const [filterText, setFilterText] = useState('');
|
||||
const { gridHeight, measuredRef } = useGridHeight();
|
||||
|
||||
// this is to preserve the order of the columns, even if there are integer values,
|
||||
// while also only grabbing the first column's keys
|
||||
const columns = useTableColumns(
|
||||
colnames,
|
||||
coltypes,
|
||||
data,
|
||||
datasourceId,
|
||||
isVisible,
|
||||
{}, // moreConfig
|
||||
true, // allowHTML
|
||||
columnDisplayNames,
|
||||
);
|
||||
const filteredData = useFilteredTableData(filterText, data);
|
||||
const columns = useGridColumns(colnames, coltypes, data, columnDisplayNames);
|
||||
const keywordFilter = useKeywordFilter(filterText);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(input: string) => setFilterText(input),
|
||||
@@ -64,9 +70,9 @@ export const SingleQueryResultPane = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResultPaneContainer>
|
||||
<TableControls
|
||||
data={filteredData}
|
||||
data={data}
|
||||
columnNames={colnames}
|
||||
columnTypes={coltypes}
|
||||
rowcount={rowcount}
|
||||
@@ -74,19 +80,22 @@ export const SingleQueryResultPane = ({
|
||||
onInputChange={handleInputChange}
|
||||
isLoading={false}
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={rowLimitOptions}
|
||||
onRowLimitChange={onRowLimitChange}
|
||||
/>
|
||||
<TableView
|
||||
columns={columns}
|
||||
size={TableSize.Small}
|
||||
data={filteredData}
|
||||
pageSize={dataSize}
|
||||
noDataText={t('No results')}
|
||||
emptyWrapperType={EmptyWrapperType.Small}
|
||||
className="table-condensed"
|
||||
isPaginationSticky={isPaginationSticky}
|
||||
showRowCount={false}
|
||||
small
|
||||
/>
|
||||
</>
|
||||
<GridContainer>
|
||||
<GridSizer ref={measuredRef}>
|
||||
<GridTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
height={gridHeight}
|
||||
size={GridSize.Small}
|
||||
externalFilter={keywordFilter}
|
||||
showRowNumber
|
||||
/>
|
||||
</GridSizer>
|
||||
</GridContainer>
|
||||
</ResultPaneContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 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, useCallback, useRef, useState } from 'react';
|
||||
import { getTimeFormatter, safeHtmlSpan, TimeFormats } from '@superset-ui/core';
|
||||
import { Constants } from '@superset-ui/core/components';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import type { IRowNode } from 'ag-grid-community';
|
||||
|
||||
const timeFormatter = getTimeFormatter(TimeFormats.DATABASE_DATETIME);
|
||||
|
||||
export function useGridColumns(
|
||||
colnames: string[] | undefined,
|
||||
coltypes: GenericDataType[] | undefined,
|
||||
data: Record<string, any>[] | undefined,
|
||||
columnDisplayNames?: Record<string, string>,
|
||||
) {
|
||||
return useMemo(
|
||||
() =>
|
||||
colnames && data?.length
|
||||
? colnames
|
||||
.filter((column: string) => Object.keys(data[0]).includes(column))
|
||||
.map((key, index) => {
|
||||
const colType = coltypes?.[index];
|
||||
const headerLabel = columnDisplayNames?.[key] ?? key;
|
||||
return {
|
||||
label: key,
|
||||
headerName: headerLabel,
|
||||
render: ({ value }: { value: unknown }) => {
|
||||
if (value === true) {
|
||||
return Constants.BOOL_TRUE_DISPLAY;
|
||||
}
|
||||
if (value === false) {
|
||||
return Constants.BOOL_FALSE_DISPLAY;
|
||||
}
|
||||
if (value === null) {
|
||||
return (
|
||||
<span style={{ color: 'var(--ant-color-text-tertiary)' }}>
|
||||
{Constants.NULL_DISPLAY}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (
|
||||
colType === GenericDataType.Temporal &&
|
||||
typeof value === 'number'
|
||||
) {
|
||||
return timeFormatter(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return safeHtmlSpan(value);
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
};
|
||||
})
|
||||
: [],
|
||||
[colnames, data, coltypes, columnDisplayNames],
|
||||
);
|
||||
}
|
||||
|
||||
export function useKeywordFilter(filterText: string) {
|
||||
return useCallback(
|
||||
(node: IRowNode) => {
|
||||
if (filterText && node.data) {
|
||||
const lowerFilter = filterText.toLowerCase();
|
||||
return Object.values(node.data).some(
|
||||
(value: unknown) =>
|
||||
value != null && String(value).toLowerCase().includes(lowerFilter),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[filterText],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the height of an absolutely-positioned inner element that fills
|
||||
* its relative-positioned parent. Uses a callback ref so the ResizeObserver
|
||||
* is created when the element mounts (which may be after initial render if
|
||||
* the component conditionally renders a loading state first).
|
||||
*/
|
||||
export function useGridHeight(fallbackHeight = 400) {
|
||||
const [gridHeight, setGridHeight] = useState(fallbackHeight);
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const measuredRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
if (!el) return;
|
||||
|
||||
const observer = new ResizeObserver(entries => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
const h = Math.floor(entry.contentRect.height);
|
||||
if (h > 0) {
|
||||
setGridHeight(prev => (prev !== h ? h : prev));
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
observerRef.current = observer;
|
||||
}, []);
|
||||
|
||||
return { gridHeight, measuredRef };
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useEffect, ReactElement, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, ReactElement, useCallback } from 'react';
|
||||
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
@@ -29,7 +29,7 @@ import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
import { ResultsPaneProps, QueryResultInterface } from '../types';
|
||||
import { SingleQueryResultPane } from './SingleQueryResultPane';
|
||||
import { TableControls } from './DataTableControls';
|
||||
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
|
||||
|
||||
const Error = styled.pre`
|
||||
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
|
||||
@@ -53,7 +53,6 @@ export const useResultsPane = ({
|
||||
errorMessage,
|
||||
setForceQuery,
|
||||
isVisible,
|
||||
dataSize = 50,
|
||||
canDownload,
|
||||
columnDisplayNames,
|
||||
}: ResultsPaneProps): ReactElement[] => {
|
||||
@@ -61,6 +60,8 @@ export const useResultsPane = ({
|
||||
queryFormData?.viz_type || queryFormData?.vizType,
|
||||
);
|
||||
|
||||
const chartRowLimit = Number(queryFormData?.row_limit) || 10000;
|
||||
const [rowLimit, setRowLimit] = useState(1000);
|
||||
const [resultResp, setResultResp] = useState<QueryResultInterface[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [responseError, setResponseError] = useState<string>('');
|
||||
@@ -69,12 +70,28 @@ export const useResultsPane = ({
|
||||
|
||||
const noOpInputChange = useCallback(() => {}, []);
|
||||
|
||||
// Never exceed the chart's own row_limit
|
||||
const effectiveRowLimit = Math.min(rowLimit, chartRowLimit);
|
||||
|
||||
const cappedFormData = useMemo(
|
||||
() => ({ ...queryFormData, row_limit: effectiveRowLimit }),
|
||||
[queryFormData, effectiveRowLimit],
|
||||
);
|
||||
|
||||
const handleRowLimitChange = useCallback(
|
||||
(limit: number) => {
|
||||
setRowLimit(limit);
|
||||
cache.delete(cappedFormData);
|
||||
},
|
||||
[cappedFormData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// it's an invalid formData when gets a errorMessage
|
||||
if (errorMessage) return;
|
||||
if (isRequest && cache.has(queryFormData)) {
|
||||
if (isRequest && cache.has(cappedFormData)) {
|
||||
setResultResp(
|
||||
ensureIsArray(cache.get(queryFormData)) as QueryResultInterface[],
|
||||
ensureIsArray(cache.get(cappedFormData)) as QueryResultInterface[],
|
||||
);
|
||||
setResponseError('');
|
||||
if (queryForce) {
|
||||
@@ -82,10 +99,10 @@ export const useResultsPane = ({
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
if (isRequest && !cache.has(queryFormData)) {
|
||||
if (isRequest && !cache.has(cappedFormData)) {
|
||||
setIsLoading(true);
|
||||
getChartDataRequest({
|
||||
formData: queryFormData,
|
||||
formData: cappedFormData,
|
||||
force: queryForce,
|
||||
resultFormat: 'json',
|
||||
resultType: 'results',
|
||||
@@ -94,7 +111,7 @@ export const useResultsPane = ({
|
||||
.then(({ json }) => {
|
||||
setResultResp(ensureIsArray(json.result) as QueryResultInterface[]);
|
||||
setResponseError('');
|
||||
cache.set(queryFormData, json.result);
|
||||
cache.set(cappedFormData, json.result);
|
||||
if (queryForce) {
|
||||
setForceQuery?.(false);
|
||||
}
|
||||
@@ -108,7 +125,7 @@ export const useResultsPane = ({
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [queryFormData, isRequest]);
|
||||
}, [cappedFormData, isRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (errorMessage) {
|
||||
@@ -163,11 +180,13 @@ export const useResultsPane = ({
|
||||
colnames={result.colnames}
|
||||
coltypes={result.coltypes}
|
||||
rowcount={result.rowcount}
|
||||
dataSize={dataSize}
|
||||
datasourceId={queryFormData.datasource}
|
||||
isVisible={isVisible}
|
||||
canDownload={canDownload}
|
||||
columnDisplayNames={columnDisplayNames}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
</StyledDiv>
|
||||
));
|
||||
|
||||
@@ -19,16 +19,16 @@
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { FeatureFlag } from '@superset-ui/core';
|
||||
import * as copyUtils from 'src/utils/copy';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitForElementToBeRemoved,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
|
||||
import { DataTablesPane } from '..';
|
||||
import { createDataTablesPaneProps } from './fixture';
|
||||
|
||||
beforeAll(() => {
|
||||
setupAGGridModules();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('DataTablesPane', () => {
|
||||
// Collapsed/expanded state depends on local storage
|
||||
@@ -175,12 +175,6 @@ describe('DataTablesPane', () => {
|
||||
|
||||
expect(screen.getByText('Action')).toBeVisible();
|
||||
expect(screen.getByText('Horror')).toBeVisible();
|
||||
|
||||
userEvent.type(screen.getByPlaceholderText('Search'), 'hor');
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByText('Action'));
|
||||
expect(screen.getByText('Horror')).toBeVisible();
|
||||
expect(screen.queryByText('Action')).not.toBeInTheDocument();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
|
||||
|
||||
@@ -20,14 +20,18 @@ import fetchMock from 'fetch-mock';
|
||||
import {
|
||||
screen,
|
||||
render,
|
||||
userEvent,
|
||||
waitForElementToBeRemoved,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { ChartMetadata, ChartPlugin, VizType } from '@superset-ui/core';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { ResultsPaneOnDashboard } from '../components';
|
||||
import { createResultsPaneOnDashboardProps } from './fixture';
|
||||
|
||||
beforeAll(() => {
|
||||
setupAGGridModules();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('ResultsPaneOnDashboard', () => {
|
||||
// render and render errorMessage
|
||||
@@ -126,12 +130,12 @@ describe('ResultsPaneOnDashboard', () => {
|
||||
expect(await findByText('Bad request')).toBeVisible();
|
||||
});
|
||||
|
||||
test('force query, render and search', async () => {
|
||||
test('force query, render', async () => {
|
||||
const props = createResultsPaneOnDashboardProps({
|
||||
sliceId: 144,
|
||||
queryForce: true,
|
||||
});
|
||||
const { queryByText, getByPlaceholderText } = render(
|
||||
const { queryByText } = render(
|
||||
<ResultsPaneOnDashboard {...props} setForceQuery={setForceQuery} />,
|
||||
{
|
||||
useRedux: true,
|
||||
@@ -144,11 +148,6 @@ describe('ResultsPaneOnDashboard', () => {
|
||||
expect(queryByText('2 rows')).toBeVisible();
|
||||
expect(queryByText('Action')).toBeVisible();
|
||||
expect(queryByText('Horror')).toBeVisible();
|
||||
|
||||
userEvent.type(getByPlaceholderText('Search'), 'hor');
|
||||
await waitForElementToBeRemoved(() => queryByText('Action'));
|
||||
expect(queryByText('Horror')).toBeVisible();
|
||||
expect(queryByText('Action')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('multiple results pane', async () => {
|
||||
|
||||
@@ -17,19 +17,19 @@
|
||||
* under the License.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import {
|
||||
render,
|
||||
userEvent,
|
||||
waitForElementToBeRemoved,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { SamplesPane } from '../components';
|
||||
import { createSamplesPaneProps } from './fixture';
|
||||
|
||||
beforeAll(() => {
|
||||
setupAGGridModules();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('SamplesPane', () => {
|
||||
fetchMock.post(
|
||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=34',
|
||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=34&per_page=100&page=1',
|
||||
{
|
||||
result: {
|
||||
data: [],
|
||||
@@ -40,7 +40,7 @@ describe('SamplesPane', () => {
|
||||
);
|
||||
|
||||
fetchMock.post(
|
||||
'end:/datasource/samples?force=true&datasource_type=table&datasource_id=35',
|
||||
'end:/datasource/samples?force=true&datasource_type=table&datasource_id=35&per_page=100&page=1',
|
||||
{
|
||||
result: {
|
||||
data: [
|
||||
@@ -56,7 +56,7 @@ describe('SamplesPane', () => {
|
||||
);
|
||||
|
||||
fetchMock.post(
|
||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=36',
|
||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=36&per_page=100&page=1',
|
||||
400,
|
||||
);
|
||||
|
||||
@@ -91,12 +91,12 @@ describe('SamplesPane', () => {
|
||||
expect(await findByText('Error: Bad request')).toBeVisible();
|
||||
});
|
||||
|
||||
test('force query, render and search', async () => {
|
||||
test('force query, render', async () => {
|
||||
const props = createSamplesPaneProps({
|
||||
datasourceId: 35,
|
||||
queryForce: true,
|
||||
});
|
||||
const { queryByText, getByPlaceholderText } = render(
|
||||
const { queryByText } = render(
|
||||
<SamplesPane {...props} setForceQuery={setForceQuery} />,
|
||||
{
|
||||
useRedux: true,
|
||||
@@ -109,10 +109,5 @@ describe('SamplesPane', () => {
|
||||
expect(queryByText('2 rows')).toBeVisible();
|
||||
expect(queryByText('Action')).toBeVisible();
|
||||
expect(queryByText('Horror')).toBeVisible();
|
||||
|
||||
userEvent.type(getByPlaceholderText('Search'), 'hor');
|
||||
await waitForElementToBeRemoved(() => queryByText('Action'));
|
||||
expect(queryByText('Horror')).toBeVisible();
|
||||
expect(queryByText('Action')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,10 @@ export const createSamplesPaneProps = ({
|
||||
({
|
||||
isRequest,
|
||||
datasource: { ...datasource, id: datasourceId },
|
||||
queryFormData: {
|
||||
...queryFormData,
|
||||
datasource: `${datasourceId}__table`,
|
||||
},
|
||||
queryForce,
|
||||
isVisible: true,
|
||||
setForceQuery: jest.fn(),
|
||||
|
||||
@@ -56,10 +56,9 @@ export interface ResultsPaneProps {
|
||||
export interface SamplesPaneProps {
|
||||
isRequest: boolean;
|
||||
datasource: Datasource;
|
||||
queryFormData: LatestQueryFormData;
|
||||
queryForce: boolean;
|
||||
setForceQuery?: SetForceQueryAction;
|
||||
dataSize?: number;
|
||||
// reload OriginalFormattedTimeColumns from localStorage when isVisible is true
|
||||
isVisible: boolean;
|
||||
canDownload: boolean;
|
||||
}
|
||||
@@ -74,6 +73,9 @@ export interface TableControlsProps {
|
||||
isLoading: boolean;
|
||||
rowcount: number;
|
||||
canDownload: boolean;
|
||||
rowLimit?: number;
|
||||
rowLimitOptions?: { value: number; label: string }[];
|
||||
onRowLimitChange?: (limit: number) => void;
|
||||
}
|
||||
|
||||
export interface QueryResultInterface {
|
||||
@@ -86,11 +88,11 @@ export interface QueryResultInterface {
|
||||
export interface SingleQueryResultPaneProp extends QueryResultInterface {
|
||||
// {datasource.id}__{datasource.type}, eg: 1__table
|
||||
datasourceId?: string;
|
||||
dataSize?: number;
|
||||
// reload OriginalFormattedTimeColumns from localStorage when isVisible is true
|
||||
isVisible: boolean;
|
||||
canDownload: boolean;
|
||||
// Optional map of column/metric name -> verbose label
|
||||
columnDisplayNames?: Record<string, string>;
|
||||
isPaginationSticky?: boolean;
|
||||
rowLimit?: number;
|
||||
rowLimitOptions?: { value: number; label: string }[];
|
||||
onRowLimitChange?: (limit: number) => void;
|
||||
}
|
||||
|
||||
@@ -204,7 +204,6 @@ const ExploreChartPanel = ({
|
||||
|
||||
const {
|
||||
ref: chartPanelRef,
|
||||
observerRef: resizeObserverRef,
|
||||
width: chartPanelWidth,
|
||||
height: chartPanelHeight,
|
||||
} = useResizeDetectorByObserver();
|
||||
@@ -378,7 +377,6 @@ const ExploreChartPanel = ({
|
||||
flex-direction: column;
|
||||
padding-top: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
ref={resizeObserverRef}
|
||||
>
|
||||
{vizTypeNeedsDataset && (
|
||||
<Alert
|
||||
@@ -481,7 +479,6 @@ const ExploreChartPanel = ({
|
||||
</div>
|
||||
),
|
||||
[
|
||||
resizeObserverRef,
|
||||
showAlertBanner,
|
||||
errorMessage,
|
||||
onQuery,
|
||||
@@ -533,7 +530,7 @@ const ExploreChartPanel = ({
|
||||
document.body.className += ` ${standaloneClass}`;
|
||||
}
|
||||
return (
|
||||
<div id="app" data-test="standalone-app" ref={resizeObserverRef}>
|
||||
<div id="app" data-test="standalone-app">
|
||||
{standaloneChartBody}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -31,15 +31,16 @@ export default function useResizeDetectorByObserver() {
|
||||
setChartPanelSize({ width, height });
|
||||
}
|
||||
}, []);
|
||||
const { ref: observerRef } = useResizeDetector({
|
||||
// Use targetRef to observe the same element we measure
|
||||
useResizeDetector({
|
||||
refreshMode: 'debounce',
|
||||
refreshRate: 300,
|
||||
onResize,
|
||||
targetRef: ref,
|
||||
});
|
||||
|
||||
return {
|
||||
ref,
|
||||
observerRef,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
|
||||
19
superset-frontend/src/explore/components/SaveModal.tsx
Normal file → Executable file
19
superset-frontend/src/explore/components/SaveModal.tsx
Normal file → Executable file
@@ -93,11 +93,6 @@ export const StyledModal = styled(Modal)`
|
||||
.ant-modal-body {
|
||||
overflow: visible;
|
||||
}
|
||||
i {
|
||||
position: absolute;
|
||||
top: -${({ theme }) => theme.sizeUnit * 5.25}px;
|
||||
left: ${({ theme }) => theme.sizeUnit * 26.75}px;
|
||||
}
|
||||
`;
|
||||
|
||||
class SaveModal extends Component<SaveModalProps, SaveModalState> {
|
||||
@@ -172,17 +167,21 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
|
||||
this.setState({ newSliceName: event.target.value });
|
||||
}
|
||||
|
||||
onDashboardChange = async (dashboard: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}) => {
|
||||
onDashboardChange = async (
|
||||
dashboard:
|
||||
| {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
| undefined,
|
||||
) => {
|
||||
this.setState({
|
||||
dashboard,
|
||||
tabsData: [],
|
||||
selectedTab: undefined,
|
||||
});
|
||||
|
||||
if (typeof dashboard.value === 'number') {
|
||||
if (dashboard && typeof dashboard.value === 'number') {
|
||||
await this.loadTabs(dashboard.value);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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 { render, screen } from 'spec/helpers/testing-library';
|
||||
import { Comparator } from '@superset-ui/chart-controls';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import ConditionalFormattingControl from './ConditionalFormattingControl';
|
||||
import { ConditionalFormattingConfig } from './types';
|
||||
|
||||
const columnOptions = [
|
||||
{ label: 'My Column', value: 'my_col', dataType: GenericDataType.Boolean },
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
columnOptions,
|
||||
verboseMap: {} as Record<string, string>,
|
||||
removeIrrelevantConditions: false,
|
||||
label: 'Conditional Formatting',
|
||||
description: 'Test',
|
||||
name: 'conditional_formatting',
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
|
||||
test('renders "is false" operator label without trailing undefined', () => {
|
||||
const value: ConditionalFormattingConfig[] = [
|
||||
{ column: 'my_col', operator: Comparator.IsFalse, colorScheme: 'colorSuccess' },
|
||||
];
|
||||
render(<ConditionalFormattingControl {...defaultProps} value={value} />);
|
||||
expect(screen.getByText('my_col is false')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders "is true" operator label without trailing undefined', () => {
|
||||
const value: ConditionalFormattingConfig[] = [
|
||||
{ column: 'my_col', operator: Comparator.IsTrue, colorScheme: 'colorSuccess' },
|
||||
];
|
||||
render(<ConditionalFormattingControl {...defaultProps} value={value} />);
|
||||
expect(screen.getByText('my_col is true')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders "is null" operator label without trailing undefined', () => {
|
||||
const value: ConditionalFormattingConfig[] = [
|
||||
{ column: 'my_col', operator: Comparator.IsNull, colorScheme: 'colorSuccess' },
|
||||
];
|
||||
render(<ConditionalFormattingControl {...defaultProps} value={value} />);
|
||||
expect(screen.getByText('my_col is null')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders "is not null" operator label without trailing undefined', () => {
|
||||
const value: ConditionalFormattingConfig[] = [
|
||||
{ column: 'my_col', operator: Comparator.IsNotNull, colorScheme: 'colorSuccess' },
|
||||
];
|
||||
render(<ConditionalFormattingControl {...defaultProps} value={value} />);
|
||||
expect(screen.getByText('my_col is not null')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders verbose column name when available', () => {
|
||||
const value: ConditionalFormattingConfig[] = [
|
||||
{ column: 'my_col', operator: Comparator.IsFalse, colorScheme: 'colorSuccess' },
|
||||
];
|
||||
render(
|
||||
<ConditionalFormattingControl
|
||||
{...defaultProps}
|
||||
verboseMap={{ my_col: 'My Column' }}
|
||||
value={value}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('My Column is false')).toBeInTheDocument();
|
||||
});
|
||||
@@ -136,6 +136,11 @@ const ConditionalFormattingControl = ({
|
||||
return `${targetValueLeft} ${Comparator.LessOrEqual} ${columnName} ${Comparator.LessThan} ${targetValueRight}`;
|
||||
case Comparator.BetweenOrRightEqual:
|
||||
return `${targetValueLeft} ${Comparator.LessThan} ${columnName} ${Comparator.LessOrEqual} ${targetValueRight}`;
|
||||
case Comparator.IsTrue:
|
||||
case Comparator.IsFalse:
|
||||
case Comparator.IsNull:
|
||||
case Comparator.IsNotNull:
|
||||
return `${columnName} ${operator}`;
|
||||
default:
|
||||
return `${columnName} ${operator} ${targetValue}`;
|
||||
}
|
||||
|
||||
@@ -269,6 +269,26 @@ test('will convert from individual comparator to array if the operator changes t
|
||||
).toEqual(Operators.In);
|
||||
});
|
||||
|
||||
test('will preserve boolean false comparator when converting to multi operator', () => {
|
||||
const booleanFalseFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'value',
|
||||
operatorId: Operators.Equals,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation,
|
||||
comparator: false,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
const props = setup({ adhocFilter: booleanFalseFilter });
|
||||
const { onOperatorChange } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
onOperatorChange(Operators.In);
|
||||
expect(
|
||||
props.onChange.mock.calls[props.onChange.mock.calls.length - 1][0]
|
||||
.comparator,
|
||||
).toEqual([false]);
|
||||
});
|
||||
|
||||
test('will convert from array to individual comparators if the operator changes from multi', () => {
|
||||
const props = setup({
|
||||
adhocFilter: simpleMultiAdhocFilter,
|
||||
|
||||
@@ -199,7 +199,7 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
if (MULTI_OPERATORS.has(operatorId)) {
|
||||
newComparator = Array.isArray(currentComparator)
|
||||
? currentComparator
|
||||
: [currentComparator].filter(element => element);
|
||||
: [currentComparator].filter(element => element != null);
|
||||
} else {
|
||||
newComparator = Array.isArray(currentComparator)
|
||||
? currentComparator[0]
|
||||
@@ -396,7 +396,8 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
};
|
||||
|
||||
const comparatorHasValue =
|
||||
comparator &&
|
||||
comparator != null &&
|
||||
comparator !== '' &&
|
||||
(Array.isArray(comparator)
|
||||
? comparator.length > 0
|
||||
: String(comparator).length > 0);
|
||||
|
||||
@@ -70,3 +70,15 @@ test('Should return correct string when subject and operator are valid values',
|
||||
]),
|
||||
).toBe("subject operator 'comparator', 'comparator-2'");
|
||||
});
|
||||
|
||||
test('Should handle boolean false comparator as a string value', () => {
|
||||
expect(getSimpleSQLExpression(params.subject, params.operator, false)).toBe(
|
||||
"subject operator 'FALSE'",
|
||||
);
|
||||
});
|
||||
|
||||
test('Should handle boolean true comparator as a string value', () => {
|
||||
expect(getSimpleSQLExpression(params.subject, params.operator, true)).toBe(
|
||||
"subject operator 'TRUE'",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -458,7 +458,8 @@ export const getSimpleSQLExpression = (
|
||||
isMulti && Array.isArray(comparator) ? comparator[0] : comparator;
|
||||
const comparatorArray = ensureIsArray(comparator);
|
||||
const isString =
|
||||
firstValue !== undefined && Number.isNaN(Number(firstValue));
|
||||
firstValue !== undefined &&
|
||||
(typeof firstValue === 'boolean' || Number.isNaN(Number(firstValue)));
|
||||
const quote = isString ? "'" : '';
|
||||
const [prefix, suffix] = isMulti ? ['(', ')'] : ['', ''];
|
||||
if (comparatorArray.length > 0 && showComparator) {
|
||||
|
||||
@@ -180,7 +180,7 @@ test('should render schema selector, database selector container, and selects',
|
||||
name: 'Select database or type to search databases',
|
||||
});
|
||||
const schemaSelect = screen.getByRole('combobox', {
|
||||
name: 'Select schema or type to search schemas',
|
||||
name: 'Select schema',
|
||||
});
|
||||
expect(databaseSelect).toBeInTheDocument();
|
||||
expect(schemaSelect).toBeInTheDocument();
|
||||
@@ -211,7 +211,7 @@ test('renders list of options when user clicks on schema', async () => {
|
||||
|
||||
// Schema select will be automatically populated if there is only one schema
|
||||
const schemaSelect = screen.getByRole('combobox', {
|
||||
name: /select schema or type to search schemas/i,
|
||||
name: /select schema/i,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(schemaSelect).toBeEnabled();
|
||||
@@ -231,7 +231,7 @@ test('searches for a table name', async () => {
|
||||
userEvent.click(await screen.findByText('test-postgres'));
|
||||
|
||||
const schemaSelect = screen.getByRole('combobox', {
|
||||
name: /select schema or type to search schemas/i,
|
||||
name: /select schema/i,
|
||||
});
|
||||
const tableSelect = screen.getByRole('combobox', {
|
||||
name: /select table or type to search tables/i,
|
||||
@@ -287,7 +287,7 @@ test('renders a warning icon when a table name has a preexisting dataset', async
|
||||
userEvent.click(await screen.findByText('test-postgres'));
|
||||
|
||||
const schemaSelect = screen.getByRole('combobox', {
|
||||
name: /select schema or type to search schemas/i,
|
||||
name: /select schema/i,
|
||||
});
|
||||
const tableSelect = screen.getByRole('combobox', {
|
||||
name: /select table or type to search tables/i,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { useTheme, styled } from '@apache-superset/core/theme';
|
||||
import cx from 'classnames';
|
||||
import { Button, Modal } from '@superset-ui/core/components';
|
||||
import withToasts, {
|
||||
@@ -65,6 +65,7 @@ const TabButton = styled.div`
|
||||
const StyledModal = styled(Modal)`
|
||||
.ant-modal-body {
|
||||
padding: ${({ theme }) => theme.sizeUnit * 6}px;
|
||||
padding-top: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -93,6 +94,15 @@ function QueryPreviewModal({
|
||||
currentQueryId: query.id,
|
||||
fetchData,
|
||||
});
|
||||
const theme = useTheme();
|
||||
const codeBlockStyle = {
|
||||
border: 1,
|
||||
borderColor: theme.colorBorder,
|
||||
borderStyle: 'solid',
|
||||
marginTop: theme.sizeUnit * 4,
|
||||
fontSize: theme.fontSize * 0.75,
|
||||
height: theme.sizeUnit * 100,
|
||||
};
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<'user' | 'executed'>('user');
|
||||
|
||||
@@ -157,6 +167,7 @@ function QueryPreviewModal({
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
language="sql"
|
||||
customStyle={codeBlockStyle}
|
||||
>
|
||||
{(currentTab === 'user' ? sql : executed_sql) || ''}
|
||||
</SyntaxHighlighterCopy>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { FunctionComponent } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { useTheme, styled } from '@apache-superset/core/theme';
|
||||
import { Button, Modal } from '@superset-ui/core/components';
|
||||
import SyntaxHighlighterCopy from 'src/features/queries/SyntaxHighlighterCopy';
|
||||
import withToasts, {
|
||||
@@ -41,6 +41,7 @@ const QueryLabel = styled.div`
|
||||
const StyledModal = styled(Modal)`
|
||||
.ant-modal-body {
|
||||
padding: 24px;
|
||||
padding-top: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -77,6 +78,15 @@ const SavedQueryPreviewModal: FunctionComponent<
|
||||
currentQueryId: savedQuery.id,
|
||||
fetchData,
|
||||
});
|
||||
const theme = useTheme();
|
||||
const codeBlockStyle = {
|
||||
border: 1,
|
||||
borderColor: theme.colorBorder,
|
||||
borderStyle: 'solid',
|
||||
marginTop: theme.sizeUnit * 4,
|
||||
fontSize: theme.fontSize * 0.75,
|
||||
height: theme.sizeUnit * 100,
|
||||
};
|
||||
|
||||
return (
|
||||
<div role="none" onKeyUp={handleKeyPress}>
|
||||
@@ -123,6 +133,7 @@ const SavedQueryPreviewModal: FunctionComponent<
|
||||
language="sql"
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
customStyle={codeBlockStyle}
|
||||
>
|
||||
{savedQuery.sql || ''}
|
||||
</SyntaxHighlighterCopy>
|
||||
|
||||
@@ -189,7 +189,8 @@ test('redirects when no files are provided', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('handles CSV file correctly', async () => {
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
test.skip('handles CSV file correctly', async () => {
|
||||
const fileHandle = createMockFileHandle('test.csv');
|
||||
setupLaunchQueue(fileHandle);
|
||||
|
||||
|
||||
@@ -219,11 +219,15 @@ if (!isDevMode) {
|
||||
|
||||
// TypeScript type checking and .d.ts generation
|
||||
// SWC handles transpilation; this plugin handles type checking separately.
|
||||
// build: true enables project references so .d.ts files are auto-generated.
|
||||
// build: true enables project references so .d.ts files are auto-generated
|
||||
// across the monorepo when editing plugins/packages.
|
||||
// mode: 'write-references' writes .d.ts output (no manual `npm run plugins:build` needed).
|
||||
// Story files are excluded because they import @storybook-shared which resolves
|
||||
// outside plugin rootDir ("src"), causing errors in --build mode.
|
||||
if (isDevMode) {
|
||||
// Set DISABLE_TS_CHECKER=true to skip this plugin entirely (~2-3 GB savings).
|
||||
// Type errors are still caught by pre-commit and CI.
|
||||
const disableTsChecker = ['true', '1'].includes(
|
||||
(process.env.DISABLE_TS_CHECKER || '').toLowerCase(),
|
||||
);
|
||||
if (isDevMode && !disableTsChecker) {
|
||||
plugins.push(
|
||||
new ForkTsCheckerWebpackPlugin({
|
||||
async: true,
|
||||
@@ -535,7 +539,7 @@ const config = {
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
sourceMap: true,
|
||||
sourceMap: !isDevMode,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -619,10 +623,23 @@ const config = {
|
||||
watchOptions: isDevMode
|
||||
? {
|
||||
// Watch all plugin and package source directories
|
||||
ignored: ['**/node_modules', '**/.git', '**/lib', '**/esm', '**/dist'],
|
||||
// Poll less frequently to reduce file handles
|
||||
ignored: [
|
||||
'**/node_modules',
|
||||
'**/.git',
|
||||
'**/lib',
|
||||
'**/esm',
|
||||
'**/dist',
|
||||
'**/.temp_cache',
|
||||
'**/coverage',
|
||||
'**/*.test.*',
|
||||
'**/*.stories.*',
|
||||
'**/cypress-base',
|
||||
'**/*.geojson',
|
||||
],
|
||||
// Poll-based watching is needed in Docker/VM where native fs events
|
||||
// don't propagate from host to container.
|
||||
poll: 2000,
|
||||
// Aggregate changes for 500ms before rebuilding
|
||||
// Aggregate changes before rebuilding
|
||||
aggregateTimeout: 500,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -99,6 +99,7 @@ def get_table_metadata(database: Any, table: Table) -> TableMetadataResponse:
|
||||
"columns": payload_columns,
|
||||
"selectStar": database.select_star(
|
||||
table,
|
||||
show_cols=True if columns else False,
|
||||
indent=True,
|
||||
cols=columns,
|
||||
latest_partition=True,
|
||||
|
||||
@@ -3012,7 +3012,11 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
col_obj = dttm_col
|
||||
elif is_adhoc_column(flt_col):
|
||||
try:
|
||||
sqla_col = self.adhoc_column_to_sqla(flt_col, force_type_check=True)
|
||||
sqla_col = self.adhoc_column_to_sqla(
|
||||
flt_col,
|
||||
force_type_check=True,
|
||||
template_processor=template_processor,
|
||||
)
|
||||
applied_adhoc_filters_columns.append(flt_col)
|
||||
except ColumnNotFoundException:
|
||||
rejected_adhoc_filters_columns.append(flt_col)
|
||||
|
||||
@@ -245,8 +245,22 @@ class ReportSchedule(AuditMixinNullable, ExtraJSONMixin, Model):
|
||||
A tuple of (filter_config, warning_message). If the filter type is
|
||||
unrecognized, returns an empty dict and a warning message.
|
||||
"""
|
||||
# Filter types that require at least one value
|
||||
requires_values = (
|
||||
"filter_time",
|
||||
"filter_timegrain",
|
||||
"filter_timecolumn",
|
||||
"filter_range",
|
||||
)
|
||||
if filter_type in requires_values and not values:
|
||||
warning_msg = (
|
||||
f"Skipping {filter_type} with empty filterValues "
|
||||
f"(filter_id: {native_filter_id})"
|
||||
)
|
||||
logger.warning(warning_msg)
|
||||
return {}, warning_msg
|
||||
|
||||
if filter_type == "filter_time":
|
||||
# For select filters, we need to use the "IN" operator
|
||||
return (
|
||||
{
|
||||
native_filter_id or "": {
|
||||
|
||||
@@ -387,8 +387,7 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
)
|
||||
|
||||
except PlaywrightTimeout:
|
||||
# raise again for the finally block, but handled above
|
||||
pass
|
||||
raise
|
||||
except PlaywrightError:
|
||||
logger.exception(
|
||||
"Encountered an unexpected error when requesting url %s", url
|
||||
|
||||
@@ -100,7 +100,7 @@ class SamplesRequestSchema(Schema):
|
||||
force = fields.Boolean(load_default=False)
|
||||
page = fields.Integer(load_default=1)
|
||||
per_page = fields.Integer(
|
||||
validate=validate.Range(min=1, max=1000),
|
||||
validate=validate.Range(min=1, max=10000),
|
||||
load_default=None,
|
||||
)
|
||||
dashboard_id = fields.Integer(required=False, allow_none=True, load_default=None)
|
||||
|
||||
@@ -798,7 +798,7 @@ def test_get_samples_pagination(test_client, login_as_admin, virtual_dataset):
|
||||
assert rv.json["result"]["total_count"] == 10
|
||||
|
||||
# 2. incorrect per_page
|
||||
per_pages = (current_app.config["SAMPLES_ROW_LIMIT"] + 1, 0, "xx")
|
||||
per_pages = (10001, 0, "xx")
|
||||
for per_page in per_pages:
|
||||
uri = f"/datasource/samples?datasource_id={virtual_dataset.id}&datasource_type=table&per_page={per_page}" # noqa: E501
|
||||
rv = test_client.post(uri, json={})
|
||||
|
||||
@@ -91,7 +91,7 @@ def test_report_generate_native_filter_no_values():
|
||||
native_filter_id = "filter_id"
|
||||
column_name = "column_name"
|
||||
filter_type = "filter_select"
|
||||
values = None
|
||||
values: list[str | None] = []
|
||||
|
||||
result, warning = report_schedule._generate_native_filter(
|
||||
native_filter_id, filter_type, column_name, values
|
||||
@@ -430,17 +430,6 @@ def test_generate_native_filter_range_max_only():
|
||||
assert warning is None
|
||||
|
||||
|
||||
def test_generate_native_filter_range_empty_values():
|
||||
report_schedule = ReportSchedule()
|
||||
result, warning = report_schedule._generate_native_filter(
|
||||
"F5", "filter_range", "price", []
|
||||
)
|
||||
assert result["F5"]["extraFormData"]["filters"] == []
|
||||
assert result["F5"]["filterState"]["label"] == ""
|
||||
assert result["F5"]["filterState"]["value"] == [None, None]
|
||||
assert warning is None
|
||||
|
||||
|
||||
def test_report_generate_native_filter_unknown_filter_type():
|
||||
"""
|
||||
Test the ``_generate_native_filter`` method with an unknown filter type.
|
||||
@@ -526,6 +515,109 @@ def test_get_native_filters_params_unknown_filter_type():
|
||||
assert "filter_unknown_type" in warnings[0]
|
||||
|
||||
|
||||
def test_report_generate_native_filter_time_empty_values():
|
||||
"""
|
||||
Test filter_time with empty values returns empty dict and warning.
|
||||
"""
|
||||
report_schedule = ReportSchedule()
|
||||
result, warning = report_schedule._generate_native_filter(
|
||||
"filter_id", "filter_time", "column_name", []
|
||||
)
|
||||
assert result == {}
|
||||
assert warning is not None
|
||||
assert "filter_time" in warning
|
||||
assert "empty filterValues" in warning
|
||||
assert "filter_id" in warning
|
||||
|
||||
|
||||
def test_report_generate_native_filter_timegrain_empty_values():
|
||||
"""
|
||||
Test filter_timegrain with empty values returns empty dict and warning.
|
||||
"""
|
||||
report_schedule = ReportSchedule()
|
||||
result, warning = report_schedule._generate_native_filter(
|
||||
"filter_id", "filter_timegrain", "column_name", []
|
||||
)
|
||||
assert result == {}
|
||||
assert warning is not None
|
||||
assert "filter_timegrain" in warning
|
||||
assert "empty filterValues" in warning
|
||||
assert "filter_id" in warning
|
||||
|
||||
|
||||
def test_report_generate_native_filter_timecolumn_empty_values():
|
||||
"""
|
||||
Test filter_timecolumn with empty values returns empty dict and warning.
|
||||
"""
|
||||
report_schedule = ReportSchedule()
|
||||
result, warning = report_schedule._generate_native_filter(
|
||||
"filter_id", "filter_timecolumn", "column_name", []
|
||||
)
|
||||
assert result == {}
|
||||
assert warning is not None
|
||||
assert "filter_timecolumn" in warning
|
||||
assert "empty filterValues" in warning
|
||||
assert "filter_id" in warning
|
||||
|
||||
|
||||
def test_report_generate_native_filter_range_empty_values():
|
||||
"""
|
||||
Test filter_range with empty values returns empty dict and warning.
|
||||
"""
|
||||
report_schedule = ReportSchedule()
|
||||
result, warning = report_schedule._generate_native_filter(
|
||||
"filter_id", "filter_range", "column_name", []
|
||||
)
|
||||
assert result == {}
|
||||
assert warning is not None
|
||||
assert "filter_range" in warning
|
||||
assert "empty filterValues" in warning
|
||||
assert "filter_id" in warning
|
||||
|
||||
|
||||
def test_get_native_filters_params_time_filters_empty_values():
|
||||
"""
|
||||
Test get_native_filters_params with time filters having empty values.
|
||||
Should skip those filters and include warnings.
|
||||
"""
|
||||
report_schedule = ReportSchedule()
|
||||
report_schedule.extra = {
|
||||
"dashboard": {
|
||||
"nativeFilters": [
|
||||
{
|
||||
"nativeFilterId": "time_filter",
|
||||
"filterType": "filter_time",
|
||||
"columnName": "time_col",
|
||||
"filterValues": [], # Empty values
|
||||
},
|
||||
{
|
||||
"nativeFilterId": "timegrain_filter",
|
||||
"filterType": "filter_timegrain",
|
||||
"columnName": "grain_col",
|
||||
"filterValues": None, # None values (coerced to [])
|
||||
},
|
||||
{
|
||||
"nativeFilterId": "select_filter",
|
||||
"filterType": "filter_select",
|
||||
"columnName": "select_col",
|
||||
"filterValues": ["value1"], # Valid filter
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result, warnings = report_schedule.get_native_filters_params()
|
||||
# The time filters should be skipped, select filter should be present
|
||||
assert "select_filter" in result
|
||||
assert "time_filter" not in result
|
||||
assert "timegrain_filter" not in result
|
||||
assert "value1" in result
|
||||
# Should have two warnings for the empty time filters
|
||||
assert len(warnings) == 2
|
||||
assert any("filter_time" in w for w in warnings)
|
||||
assert any("filter_timegrain" in w for w in warnings)
|
||||
|
||||
|
||||
def test_get_native_filters_params_missing_filter_id_key():
|
||||
report_schedule = ReportSchedule()
|
||||
report_schedule.extra = {
|
||||
@@ -560,7 +652,7 @@ def test_generate_native_filter_empty_filter_id():
|
||||
def test_generate_native_filter_range_zero_min():
|
||||
"""Zero min_val should produce a two-sided label, not a max-only label."""
|
||||
report_schedule = ReportSchedule()
|
||||
result, warning = report_schedule._generate_native_filter(
|
||||
result, _ = report_schedule._generate_native_filter(
|
||||
"F5", "filter_range", "price", [0, 100]
|
||||
)
|
||||
assert result["F5"]["extraFormData"]["filters"] == [
|
||||
@@ -574,7 +666,7 @@ def test_generate_native_filter_range_zero_min():
|
||||
def test_generate_native_filter_range_zero_max():
|
||||
"""Zero max_val should produce a two-sided label, not a min-only label."""
|
||||
report_schedule = ReportSchedule()
|
||||
result, warning = report_schedule._generate_native_filter(
|
||||
result, _ = report_schedule._generate_native_filter(
|
||||
"F5", "filter_range", "price", [10, 0]
|
||||
)
|
||||
assert result["F5"]["extraFormData"]["filters"] == [
|
||||
@@ -587,7 +679,7 @@ def test_generate_native_filter_range_zero_max():
|
||||
def test_generate_native_filter_range_both_zero():
|
||||
"""Both values zero should produce a two-sided label, not an empty string."""
|
||||
report_schedule = ReportSchedule()
|
||||
result, warning = report_schedule._generate_native_filter(
|
||||
result, _ = report_schedule._generate_native_filter(
|
||||
"F5", "filter_range", "price", [0, 0]
|
||||
)
|
||||
assert result["F5"]["extraFormData"]["filters"] == [
|
||||
|
||||
@@ -530,6 +530,7 @@ class TestWebDriverPlaywrightFallback:
|
||||
"SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000,
|
||||
"SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle",
|
||||
"SCREENSHOT_SELENIUM_HEADSTART": 5,
|
||||
"SCREENSHOT_SELENIUM_ANIMATION_WAIT": 1,
|
||||
"SCREENSHOT_LOCATE_WAIT": 10,
|
||||
"SCREENSHOT_LOAD_WAIT": 10,
|
||||
"SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE": 10,
|
||||
@@ -546,8 +547,10 @@ class TestWebDriverPlaywrightFallback:
|
||||
"http://example.com", "test-element", mock_user
|
||||
)
|
||||
|
||||
# Should handle timeout gracefully and return None
|
||||
assert result is None
|
||||
# page.goto() timeout is caught and logged without aborting; execution
|
||||
# continues to the element waits, which succeed here, so a screenshot
|
||||
# is taken and returned (not None).
|
||||
assert result is not None
|
||||
mock_logger.exception.assert_called()
|
||||
exception_call = mock_logger.exception.call_args[0][0]
|
||||
assert "Web event %s not detected" in exception_call
|
||||
@@ -640,10 +643,10 @@ class TestWebDriverPlaywrightErrorHandling:
|
||||
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
||||
@patch("superset.utils.webdriver.sync_playwright")
|
||||
@patch("superset.utils.webdriver.logger")
|
||||
def test_get_screenshot_logs_multiple_timeouts(
|
||||
def test_get_screenshot_raises_on_element_wait_timeout(
|
||||
self, mock_logger, mock_sync_playwright
|
||||
):
|
||||
"""Test that multiple timeout scenarios are logged appropriately."""
|
||||
"""Test that PlaywrightTimeout propagates when waiting for page elements."""
|
||||
from superset.utils.webdriver import PlaywrightTimeout
|
||||
|
||||
mock_user = MagicMock()
|
||||
@@ -663,9 +666,10 @@ class TestWebDriverPlaywrightErrorHandling:
|
||||
mock_browser.new_context.return_value = mock_context
|
||||
mock_context.new_page.return_value = mock_page
|
||||
|
||||
# Mock locator to raise timeout on element wait
|
||||
# Keep a reference to the exact instance so we can verify identity below.
|
||||
timeout = PlaywrightTimeout()
|
||||
mock_page.locator.return_value = mock_element
|
||||
mock_element.wait_for.side_effect = PlaywrightTimeout()
|
||||
mock_element.wait_for.side_effect = timeout
|
||||
|
||||
with patch("superset.utils.webdriver.app") as mock_app:
|
||||
mock_app.config = {
|
||||
@@ -686,10 +690,15 @@ class TestWebDriverPlaywrightErrorHandling:
|
||||
mock_auth.return_value = mock_context
|
||||
|
||||
driver = WebDriverPlaywright("chrome")
|
||||
result = driver.get_screenshot(
|
||||
"http://example.com", "test-element", mock_user
|
||||
)
|
||||
with pytest.raises(PlaywrightTimeout) as exc_info:
|
||||
driver.get_screenshot(
|
||||
"http://example.com", "test-element", mock_user
|
||||
)
|
||||
|
||||
assert result is None
|
||||
# Should log timeout for element wait
|
||||
assert mock_logger.exception.call_count >= 1
|
||||
# The exact injected instance must propagate — guards against the
|
||||
# fallback alias (PlaywrightTimeout = Exception when playwright is
|
||||
# not installed) accepting unrelated exceptions.
|
||||
assert exc_info.value is timeout
|
||||
mock_logger.exception.assert_any_call(
|
||||
"Timed out requesting url %s", "http://example.com"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user