diff --git a/superset-frontend/packages/superset-ui-core/src/components/Timer/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/Timer/index.tsx index f9d9a42ed457..88ddcd1946f6 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Timer/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Timer/index.tsx @@ -35,7 +35,9 @@ export function Timer({ status = 'success', }: TimerProps) { const theme = useTheme(); - const [clockStr, setClockStr] = useState('00:00:00.00'); + const [clockStr, setClockStr] = useState( + startTime && endTime ? fDuration(startTime, endTime) : '00:00:00.00', + ); const timer = useRef>(); useEffect(() => { diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts index fcbfd3ed7a75..14d4e2273b27 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts @@ -316,6 +316,7 @@ export type Query = { errorMessage: string | null; extra: { progress: string | null; + progress_text?: string; errors?: SupersetError[]; }; id: string; diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.ts b/superset-frontend/src/SqlLab/actions/sqlLab.ts index bd1fabd7c7d0..99858a99e647 100644 --- a/superset-frontend/src/SqlLab/actions/sqlLab.ts +++ b/superset-frontend/src/SqlLab/actions/sqlLab.ts @@ -392,7 +392,7 @@ export function startQuery(query: Query, runPreviewOnly?: boolean) { id: query.id ? query.id : nanoid(11), progress: 0, startDttm: now(), - state: query.runAsync ? 'pending' : 'running', + state: 'pending', cached: false, }); return { type: START_QUERY, query, runPreviewOnly } as const; diff --git a/superset-frontend/src/SqlLab/components/QueryStatusBar/QueryStatusBar.test.tsx b/superset-frontend/src/SqlLab/components/QueryStatusBar/QueryStatusBar.test.tsx new file mode 100644 index 000000000000..92a5d900dc5e --- /dev/null +++ b/superset-frontend/src/SqlLab/components/QueryStatusBar/QueryStatusBar.test.tsx @@ -0,0 +1,161 @@ +/** + * 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 { isValidElement } from 'react'; +import { render, screen } from 'spec/helpers/testing-library'; +import { QueryState, type QueryResponse } from '@superset-ui/core'; +import QueryStatusBar from '.'; + +jest.mock('../QueryStateLabel', () => ({ + __esModule: true, + default: ({ query }: { query: { state: QueryState } }) => ( +
{query.state}
+ ), +})); + +const createMockQuery = ( + overrides: Partial = {}, +): QueryResponse => + ({ + id: 'test-query-id', + dbId: 1, + sql: 'SELECT * FROM test', + sqlEditorId: 'test-editor', + tab: 'Test Tab', + ctas: false, + cached: false, + progress: 0, + startDttm: Date.now() - 1000, + endDttm: undefined, + state: QueryState.Running, + tempSchema: null, + tempTable: null, + userId: 1, + executedSql: null, + rows: 0, + queryLimit: 100, + catalog: null, + schema: 'test_schema', + errorMessage: null, + extra: {}, + results: undefined, + ...overrides, + }) as QueryResponse; + +test('is valid element', () => { + const query = createMockQuery(); + expect(isValidElement()).toBe(true); +}); + +test('renders query state label', () => { + const query = createMockQuery({ state: QueryState.Running }); + render(); + expect(screen.getByTestId('query-state-label')).toBeInTheDocument(); + expect(screen.getByText('Query State:')).toBeInTheDocument(); +}); + +test('renders elapsed time section', () => { + const query = createMockQuery({ state: QueryState.Running }); + render(); + expect(screen.getByText('Elapsed:')).toBeInTheDocument(); +}); + +test('renders steps for running query', () => { + const query = createMockQuery({ state: QueryState.Running, progress: 50 }); + render(); + expect(screen.getByText('Validate query')).toBeInTheDocument(); + expect(screen.getByText('Connect to engine')).toBeInTheDocument(); + expect(screen.getByText('Running')).toBeInTheDocument(); + expect(screen.getByText('Download to client')).toBeInTheDocument(); + expect(screen.getByText('Finish')).toBeInTheDocument(); +}); + +test('renders steps for pending query', () => { + const query = createMockQuery({ state: QueryState.Pending }); + render(); + expect(screen.getByText('Validate query')).toBeInTheDocument(); +}); + +test('returns null when query is successful with results', () => { + const query = createMockQuery({ + state: QueryState.Success, + results: { + displayLimitReached: false, + columns: [], + selected_columns: [], + expanded_columns: [], + data: [], + query: { limit: 100 }, + }, + }); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); +}); + +test('displays progress percentage when available', () => { + const query = createMockQuery({ + state: QueryState.Running, + progress: 75, + }); + render(); + expect(screen.getByText('(75%)')).toBeInTheDocument(); +}); + +test('displays progress text when available', () => { + const query = createMockQuery({ + state: QueryState.Running, + progress: 50, + extra: { progress: null, progress_text: 'Processing rows' }, + }); + render(); + expect(screen.getByText('(50%, Processing rows)')).toBeInTheDocument(); +}); + +test('displays only progress text when no percentage', () => { + const query = createMockQuery({ + state: QueryState.Running, + progress: 0, + extra: { progress: null, progress_text: 'Initializing' }, + }); + render(); + expect(screen.getByText('(Initializing)')).toBeInTheDocument(); +}); + +test('renders for failed query state', () => { + const query = createMockQuery({ + state: QueryState.Failed, + errorMessage: 'Query failed', + }); + render(); + expect(screen.getByTestId('query-state-label')).toBeInTheDocument(); +}); + +test('renders for stopped query state', () => { + const query = createMockQuery({ state: QueryState.Stopped }); + render(); + expect(screen.getByTestId('query-state-label')).toBeInTheDocument(); +}); + +test('renders for fetching state', () => { + const query = createMockQuery({ + state: QueryState.Fetching, + progress: 100, + }); + render(); + expect(screen.getByText('Download to client')).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/SqlLab/components/QueryStatusBar/index.tsx b/superset-frontend/src/SqlLab/components/QueryStatusBar/index.tsx new file mode 100644 index 000000000000..69230fea1782 --- /dev/null +++ b/superset-frontend/src/SqlLab/components/QueryStatusBar/index.tsx @@ -0,0 +1,214 @@ +/** + * 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 { FC, useMemo, createContext, useContext, useRef } from 'react'; +import { t, styled } from '@apache-superset/core'; +import { + Flex, + Steps, + type StepsProps, + StyledSpin, + Timer, +} from '@superset-ui/core/components'; +import { QueryResponse, QueryState, usePrevious } from '@superset-ui/core'; +import QueryStateLabel from '../QueryStateLabel'; + +type QueryStatusBarProps = { + query: QueryResponse; +}; + +const STATE_TO_STEP: Record = { + offline: 4, + failed: 4, + pending: 0, + fetching: 3, + running: 2, + stopped: 4, + success: 4, +}; + +const ERROR_STATE = [QueryState.Failed, QueryState.Stopped]; + +const StyledSteps = styled.div` + & .ant-steps { + margin: ${({ theme }) => theme.sizeUnit * 2}px 0; + } +`; + +const ActiveDot = styled.div` + position: relative; + display: flex; + align-items: center; + justify-content: center; + + &::before, + &::after { + content: ''; + position: absolute; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: ${({ theme }) => theme.colorPrimary}; + top: -1px; + opacity: 0; + animation: pulse 2s ease-out infinite; + } + + &::after { + animation-delay: 1s; + } + + @keyframes pulse { + 0% { + transform: scale(0.5); + opacity: 0.8; + } + 100% { + transform: scale(3); + opacity: 0; + } + } +`; + +const progressContext = createContext<[number, string]>([0, '']); + +const ProgressStatus = () => { + const [percent, progressText] = useContext(progressContext); + + return ( + <> + {percent > 0 ? ( + + ({percent}%{progressText && `, ${progressText}`}) + + ) : ( + <>{progressText && ({progressText})} + )} + + ); +}; + +const ProgressSpin = () => { + const [percent] = useContext(progressContext); + return ( + <> + {typeof percent === 'number' && percent > 0 && ( + + )} + + ); +}; + +const customDot: StepsProps['progressDot'] = (dot, { status }) => + status === 'process' ? ( + + + + ) : ( + <>{dot} + ); + +const QueryStatusBar: FC = ({ query }) => { + const steps = [ + { + title: t('Validate query'), + }, + { + title: t('Connect to engine'), + }, + { + title: ( + + {t('Running')} + + + ), + }, + { + title: t('Download to client'), + }, + { + title: t('Finish'), + }, + ]; + + const hasError = useMemo( + () => ERROR_STATE.includes(query.state), + [query.state], + ); + const prevStepRef = useRef(0); + const progress = query.progress > 0 ? query.progress : undefined; + const { progress_text: progressText } = query.extra ?? {}; + const state = + query.state === QueryState.Success && + prevStepRef.current === STATE_TO_STEP[QueryState.Running] && + !query.results + ? QueryState.Fetching + : query.state; + + const currentIndex = STATE_TO_STEP[state] || 0; + const prevStep = usePrevious(currentIndex); + prevStepRef.current = prevStep ?? prevStepRef.current; + + if (query.state === QueryState.Success && query.results) { + return null; + } + + if ( + query.state === QueryState.Failed && + prevStep === STATE_TO_STEP[QueryState.Failed] + ) { + return null; + } + + return ( + + + + {t('Query State')}: + + + + {t('Elapsed')}: + + + + + + + + ); +}; +export default QueryStatusBar; diff --git a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx index a45e38bf0c7b..e12627777f47 100644 --- a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx +++ b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx @@ -35,7 +35,6 @@ import { cachedQuery, failedQueryWithErrors, queries, - runningQuery, stoppedQuery, initialState, user, @@ -85,32 +84,6 @@ const stoppedQueryState = { }, }, }; -const runningQueryState = { - ...initialState, - sqlLab: { - ...initialState.sqlLab, - queries: { - [runningQuery.id]: runningQuery, - }, - }, -}; -const fetchingQueryState = { - ...initialState, - sqlLab: { - ...initialState.sqlLab, - queries: { - [mockedProps.queryId]: { - dbId: 1, - cached: false, - ctas: false, - id: 'ryhHUZCGb', - progress: 100, - state: 'fetching', - startDttm: Date.now() - 500, - }, - }, - }, -}; const cachedQueryState = { ...initialState, sqlLab: { @@ -332,25 +305,6 @@ describe('ResultSet', () => { expect(alert).toBeInTheDocument(); }); - test('should render running/pending/fetching query', async () => { - const { getByTestId } = setup( - { ...mockedProps, queryId: runningQuery.id }, - mockStore(runningQueryState), - ); - const progressBar = getByTestId('progress-bar'); - expect(progressBar).toBeInTheDocument(); - }); - - test('should render fetching w/ 100 progress query', async () => { - const { getByRole, getByText } = setup( - mockedProps, - mockStore(fetchingQueryState), - ); - const loading = getByRole('status'); - expect(loading).toBeInTheDocument(); - expect(getByText('fetching')).toBeInTheDocument(); - }); - test('should render a failed query with an errors object', async () => { const { errors } = failedQueryWithErrors; diff --git a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx index 52290635808a..9bd2337bbf19 100644 --- a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx +++ b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx @@ -36,7 +36,6 @@ import { Tooltip, Input, Label, - Loading, } from '@superset-ui/core/components'; import { CopyToClipboard, @@ -62,7 +61,6 @@ import { import { EXPLORE_CHART_DEFAULT, SqlLabRootState } from 'src/SqlLab/types'; import { mountExploreUrl } from 'src/explore/exploreUtils'; import { postFormData } from 'src/explore/exploreUtils/formData'; -import ProgressBar from '@superset-ui/core/components/ProgressBar'; import { addDangerToast } from 'src/components/MessageToasts/actions'; import { prepareCopyToClipboardTabularData } from 'src/utils/common'; import { getItem, LocalStorageKeys } from 'src/utils/localStorageHelpers'; @@ -90,7 +88,6 @@ import { makeUrl } from 'src/utils/pathUtils'; import ExploreCtasResultsButton from '../ExploreCtasResultsButton'; import ExploreResultsButton from '../ExploreResultsButton'; import HighlightedSql from '../HighlightedSql'; -import QueryStateLabel from '../QueryStateLabel'; import PanelToolbar from 'src/components/PanelToolbar'; import { ViewContribution } from 'src/SqlLab/contributions'; @@ -816,34 +813,20 @@ const ResultSet = ({ } } - let progressBar; - if (query.progress > 0) { - progressBar = ( - - ); - } - const progressMsg = query?.extra?.progress ?? null; return ( - <> - -
{!progressBar && }
- {/* show loading bar whenever progress bar is completed but needs time to render */} -
{query.progress === 100 && }
- -
- {progressMsg && } -
-
{query.progress !== 100 && progressBar}
- {trackingUrl &&
{trackingUrl}
} -
+ + {progressMsg && ( + + )} + {trackingUrl &&
{trackingUrl}
} - +
); }; diff --git a/superset-frontend/src/SqlLab/components/SouthPane/Results.tsx b/superset-frontend/src/SqlLab/components/SouthPane/Results.tsx index 2f3414981517..77345a050736 100644 --- a/superset-frontend/src/SqlLab/components/SouthPane/Results.tsx +++ b/superset-frontend/src/SqlLab/components/SouthPane/Results.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { FC } from 'react'; +import { FC, useMemo } from 'react'; import { shallowEqual, useSelector } from 'react-redux'; import { EmptyState } from '@superset-ui/core/components'; import { t } from '@apache-superset/core'; @@ -26,6 +26,7 @@ import { styled, Alert } from '@apache-superset/core/ui'; import { SqlLabRootState } from 'src/SqlLab/types'; import ResultSet from '../ResultSet'; import { LOCALSTORAGE_MAX_QUERY_AGE_MS } from '../../constants'; +import QueryStatusBar from '../QueryStatusBar'; type Props = { latestQueryId?: string; @@ -53,10 +54,14 @@ const Results: FC = ({ ({ sqlLab: { databases } }: SqlLabRootState) => databases, shallowEqual, ); - const latestQuery = useSelector( - ({ sqlLab: { queries } }: SqlLabRootState) => queries[latestQueryId || ''], + const queries = useSelector( + ({ sqlLab: { queries } }: SqlLabRootState) => queries, shallowEqual, ); + const latestQuery = useMemo( + () => queries[latestQueryId ?? ''], + [queries, latestQueryId], + ); if ( !latestQuery || @@ -72,30 +77,32 @@ const Results: FC = ({ ); } - if ( + const hasNoStoredResults = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) && latestQuery.state === 'success' && !latestQuery.resultsKey && - !latestQuery.results - ) { - return ( - - ); - } + !latestQuery.results; return ( - + <> + + {hasNoStoredResults ? ( + + ) : ( + + )} + ); };