diff --git a/web-console/src/views/query-view/__snapshots__/query-view.spec.tsx.snap b/web-console/src/views/query-view/__snapshots__/query-view.spec.tsx.snap index a4823ee6582d..d8a68d91d8b2 100644 --- a/web-console/src/views/query-view/__snapshots__/query-view.spec.tsx.snap +++ b/web-console/src/views/query-view/__snapshots__/query-view.spec.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`sql view matches snapshot 1`] = ` +exports[`QueryView matches snapshot 1`] = `
@@ -63,6 +63,7 @@ exports[`sql view matches snapshot 1`] = ` liveQueryMode="auto" onLiveQueryModeChange={[Function]} /> +
`; -exports[`sql view matches snapshot with query 1`] = ` +exports[`QueryView matches snapshot with query 1`] = `
@@ -140,6 +141,7 @@ exports[`sql view matches snapshot with query 1`] = ` liveQueryMode="auto" onLiveQueryModeChange={[Function]} /> +
diff --git a/web-console/src/views/query-view/query-extra-info/query-extra-info.spec.tsx b/web-console/src/views/query-view/query-extra-info/query-extra-info.spec.tsx index 9c430735f07c..118c9cf5b782 100644 --- a/web-console/src/views/query-view/query-extra-info/query-extra-info.spec.tsx +++ b/web-console/src/views/query-view/query-extra-info/query-extra-info.spec.tsx @@ -22,7 +22,7 @@ import React from 'react'; import { QueryExtraInfo } from './query-extra-info'; -describe('query extra info', () => { +describe('QueryExtraInfo', () => { it('matches snapshot', () => { const queryExtraInfo = ( { 'e3ee781b-c0b6-4385-9d99-a8a1994bebac', ).changeQueryDuration(8000)} onDownload={() => {}} + onLoadMore={() => {}} /> ); diff --git a/web-console/src/views/query-view/query-extra-info/query-extra-info.tsx b/web-console/src/views/query-view/query-extra-info/query-extra-info.tsx index 6b1bb2b3b0f5..50debcc3b616 100644 --- a/web-console/src/views/query-view/query-extra-info/query-extra-info.tsx +++ b/web-console/src/views/query-view/query-extra-info/query-extra-info.tsx @@ -29,7 +29,7 @@ import { import { IconNames } from '@blueprintjs/icons'; import copy from 'copy-to-clipboard'; import { QueryResult } from 'druid-query-toolkit'; -import React from 'react'; +import React, { MouseEvent } from 'react'; import { AppToaster } from '../../../singletons'; import { pluralIfNeeded } from '../../../utils'; @@ -39,20 +39,29 @@ import './query-extra-info.scss'; export interface QueryExtraInfoProps { queryResult: QueryResult; onDownload: (filename: string, format: string) => void; + onLoadMore: () => void; } export const QueryExtraInfo = React.memo(function QueryExtraInfo(props: QueryExtraInfoProps) { - const { queryResult, onDownload } = props; + const { queryResult, onDownload, onLoadMore } = props; + const wrapQueryLimit = queryResult.getSqlOuterLimit(); + const hasMoreResults = queryResult.getNumResults() === wrapQueryLimit; - function handleQueryInfoClick() { - const id = queryResult.queryId || queryResult.sqlQueryId; - if (!id) return; + function handleQueryInfoClick(e: MouseEvent) { + if (e.altKey) { + if (hasMoreResults) { + onLoadMore(); + } + } else { + const id = queryResult.queryId || queryResult.sqlQueryId; + if (!id) return; - copy(id, { format: 'text/plain' }); - AppToaster.show({ - message: 'Query ID copied to clipboard', - intent: Intent.SUCCESS, - }); + copy(id, { format: 'text/plain' }); + AppToaster.show({ + message: 'Query ID copied to clipboard', + intent: Intent.SUCCESS, + }); + } } function handleDownload(format: string) { @@ -71,13 +80,9 @@ export const QueryExtraInfo = React.memo(function QueryExtraInfo(props: QueryExt ); - const wrapQueryLimit = queryResult.getSqlOuterLimit(); - let resultCount: string; - if (wrapQueryLimit && queryResult.getNumResults() === wrapQueryLimit) { - resultCount = `${queryResult.getNumResults() - 1}+ results`; - } else { - resultCount = pluralIfNeeded(queryResult.getNumResults(), 'result'); - } + const resultCount = hasMoreResults + ? `${queryResult.getNumResults() - 1}+ results` + : pluralIfNeeded(queryResult.getNumResults(), 'result'); let tooltipContent: JSX.Element | undefined; if (queryResult.queryId) { diff --git a/web-console/src/views/query-view/query-output/query-output.scss b/web-console/src/views/query-view/query-output/query-output.scss index b6ec4a332880..1340c09ede25 100644 --- a/web-console/src/views/query-view/query-output/query-output.scss +++ b/web-console/src/views/query-view/query-output/query-output.scss @@ -20,6 +20,11 @@ @import '../../../blueprint-overrides/common/colors'; .query-output { + &.more-results .-totalPages { + // Hide the total page counter as it can be confusing due to the auto limit + display: none; + } + .ReactTable { position: absolute; top: 0; diff --git a/web-console/src/views/query-view/query-output/query-output.spec.tsx b/web-console/src/views/query-view/query-output/query-output.spec.tsx index 6cabc98f1739..8f61ec680ae0 100644 --- a/web-console/src/views/query-view/query-output/query-output.spec.tsx +++ b/web-console/src/views/query-view/query-output/query-output.spec.tsx @@ -48,7 +48,8 @@ ORDER BY "Count" DESC`); false, true, ).attachQuery({}, parsedQuery)} - onQueryChange={() => null} + onQueryChange={() => {}} + onLoadMore={() => {}} /> ); diff --git a/web-console/src/views/query-view/query-output/query-output.tsx b/web-console/src/views/query-view/query-output/query-output.tsx index 668af761f0ba..bd3622da7edf 100644 --- a/web-console/src/views/query-view/query-output/query-output.tsx +++ b/web-console/src/views/query-view/query-output/query-output.tsx @@ -18,6 +18,7 @@ import { Icon, Menu, MenuItem, Popover } from '@blueprintjs/core'; import { IconName, IconNames } from '@blueprintjs/icons'; +import classNames from 'classnames'; import { QueryResult, SqlExpression, @@ -27,7 +28,7 @@ import { trimString, } from 'druid-query-toolkit'; import * as JSONBig from 'json-bigint-native'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import ReactTable from 'react-table'; import { BracedText, TableCell } from '../../../components'; @@ -60,42 +61,54 @@ interface Pagination { pageSize: number; } +function changePage(pagination: Pagination, page: number): Pagination { + return deepSet(pagination, 'page', page); +} + function getNumericColumnBraces( - queryResult: QueryResult | undefined, + queryResult: QueryResult, pagination: Pagination, ): Record { const numericColumnBraces: Record = {}; - if (queryResult) { - const index = pagination.page * pagination.pageSize; - const rows = queryResult.rows.slice(index, index + pagination.pageSize); - if (rows.length) { - const numColumns = queryResult.header.length; - for (let c = 0; c < numColumns; c++) { - const brace = filterMap(rows, row => - typeof row[c] === 'number' ? String(row[c]) : undefined, - ); - if (rows.length === brace.length) { - numericColumnBraces[c] = brace; - } + + const index = pagination.page * pagination.pageSize; + const rows = queryResult.rows.slice(index, index + pagination.pageSize); + if (rows.length) { + const numColumns = queryResult.header.length; + for (let c = 0; c < numColumns; c++) { + const brace = filterMap(rows, row => + typeof row[c] === 'number' ? String(row[c]) : undefined, + ); + if (rows.length === brace.length) { + numericColumnBraces[c] = brace; } } } + return numericColumnBraces; } export interface QueryOutputProps { - queryResult?: QueryResult; + queryResult: QueryResult; onQueryChange: (query: SqlQuery, run?: boolean) => void; + onLoadMore: () => void; runeMode: boolean; } export const QueryOutput = React.memo(function QueryOutput(props: QueryOutputProps) { - const { queryResult, onQueryChange, runeMode } = props; - const parsedQuery = queryResult ? queryResult.sqlQuery : undefined; + const { queryResult, onQueryChange, onLoadMore, runeMode } = props; + const parsedQuery = queryResult.sqlQuery; const [pagination, setPagination] = useState({ page: 0, pageSize: 20 }); const [showValue, setShowValue] = useState(); const [renamingColumn, setRenamingColumn] = useState(-1); + // Reset page to 0 if number of results changes + useEffect(() => { + if (pagination.page) { + setPagination(changePage(pagination, 0)); + } + }, [queryResult.rows.length]); + function hasFilterOnHeader(header: string, headerIndex: number): boolean { if (!parsedQuery || !parsedQuery.isRealOutputColumnAtSelectIndex(headerIndex)) return false; @@ -371,18 +384,32 @@ export const QueryOutput = React.memo(function QueryOutput(props: QueryOutputPro } } + const outerLimit = queryResult.getSqlOuterLimit(); + const hasMoreResults = queryResult.rows.length === outerLimit; + + function changePagination(pagination: Pagination) { + if ( + hasMoreResults && + Math.floor(queryResult.rows.length / pagination.pageSize) === pagination.page // on the last page + ) { + onLoadMore(); + } + setPagination(pagination); + } + const numericColumnBraces = getNumericColumnBraces(queryResult, pagination); return ( -
+
setPagination(deepSet(pagination, 'page', page))} - onPageSizeChange={(pageSize, page) => setPagination({ page, pageSize })} + onPageChange={page => changePagination(changePage(pagination, page))} + onPageSizeChange={(pageSize, page) => changePagination({ page, pageSize })} sortable={false} - columns={(queryResult ? queryResult.header : []).map((column, i) => { + ofText={hasMoreResults ? '' : 'of'} + columns={queryResult.header.map((column, i) => { const h = column.name; return { Header: diff --git a/web-console/src/views/query-view/query-timer/__snapshots__/query-timer.spec.tsx.snap b/web-console/src/views/query-view/query-timer/__snapshots__/query-timer.spec.tsx.snap new file mode 100644 index 000000000000..32f461ee2382 --- /dev/null +++ b/web-console/src/views/query-view/query-timer/__snapshots__/query-timer.spec.tsx.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`QueryTimer matches snapshot 1`] = ` +
+ 1.85s + +
+`; diff --git a/web-console/src/views/query-view/query-timer/query-timer.scss b/web-console/src/views/query-view/query-timer/query-timer.scss new file mode 100644 index 000000000000..d6c8bf30842f --- /dev/null +++ b/web-console/src/views/query-view/query-timer/query-timer.scss @@ -0,0 +1,23 @@ +/* + * 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. + */ + +.query-timer { + line-height: 30px; + white-space: nowrap; + pointer-events: none; +} diff --git a/web-console/src/views/query-view/query-timer/query-timer.spec.tsx b/web-console/src/views/query-view/query-timer/query-timer.spec.tsx new file mode 100644 index 000000000000..bc3a1e2031b1 --- /dev/null +++ b/web-console/src/views/query-view/query-timer/query-timer.spec.tsx @@ -0,0 +1,42 @@ +/* + * 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 } from '@testing-library/react'; +import React from 'react'; + +import { QueryTimer } from './query-timer'; + +describe('QueryTimer', () => { + beforeEach(() => { + let nowCalls = 0; + jest.spyOn(Date, 'now').mockImplementation(() => { + return 1619201218452 + 2000 * nowCalls++; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('matches snapshot', () => { + const queryTimer = ; + + const { container } = render(queryTimer); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/web-console/src/views/query-view/query-timer/query-timer.tsx b/web-console/src/views/query-view/query-timer/query-timer.tsx new file mode 100644 index 000000000000..8f5970a42cfc --- /dev/null +++ b/web-console/src/views/query-view/query-timer/query-timer.tsx @@ -0,0 +1,46 @@ +/* + * 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 { Button } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import React, { useState } from 'react'; + +import { useInterval } from '../../../hooks'; + +import './query-timer.scss'; + +// This is roughly the time in ms that it takes the component to mount and unmount, without this the timer appears to over count a little bit +const FUDGE_OFFSET = 150; + +export const QueryTimer = React.memo(function QueryTimer() { + const [startTime] = useState(Date.now() + FUDGE_OFFSET); + const [currentTime, setCurrentTime] = useState(Date.now()); + + useInterval(() => { + setCurrentTime(Date.now()); + }, 25); + + const elapsed = currentTime - startTime; + if (elapsed <= 0) return null; + return ( +
+ {`${(elapsed / 1000).toFixed(2)}s`} +
+ ); +}); diff --git a/web-console/src/views/query-view/query-view.scss b/web-console/src/views/query-view/query-view.scss index 6fb0fb876e4b..7d0369d44ef8 100644 --- a/web-console/src/views/query-view/query-view.scss +++ b/web-console/src/views/query-view/query-view.scss @@ -77,7 +77,8 @@ $nav-width: 250px; } } - .query-extra-info { + .query-extra-info, + .query-timer { position: absolute; right: 0; top: 0; diff --git a/web-console/src/views/query-view/query-view.spec.tsx b/web-console/src/views/query-view/query-view.spec.tsx index 5c87dcada9a4..9e806de33ec9 100644 --- a/web-console/src/views/query-view/query-view.spec.tsx +++ b/web-console/src/views/query-view/query-view.spec.tsx @@ -21,7 +21,7 @@ import React from 'react'; import { QueryView } from './query-view'; -describe('sql view', () => { +describe('QueryView', () => { it('matches snapshot', () => { const sqlView = shallow(); expect(sqlView).toMatchSnapshot(); diff --git a/web-console/src/views/query-view/query-view.tsx b/web-console/src/views/query-view/query-view.tsx index fd37afacb7a9..53dc1d9cf695 100644 --- a/web-console/src/views/query-view/query-view.tsx +++ b/web-console/src/views/query-view/query-view.tsx @@ -62,6 +62,7 @@ import { QueryError } from './query-error/query-error'; import { QueryExtraInfo } from './query-extra-info/query-extra-info'; import { QueryInput } from './query-input/query-input'; import { QueryOutput } from './query-output/query-output'; +import { QueryTimer } from './query-timer/query-timer'; import { RunButton } from './run-button/run-button'; import './query-view.scss'; @@ -241,7 +242,7 @@ export class QueryView extends React.PureComponent { + private readonly handleDownload = (filename: string, format: string) => { const { queryResultState } = this.state; const queryResult = queryResultState.data; if (!queryResult) return; @@ -358,6 +360,15 @@ export class QueryView extends React.PureComponent { + this.setState( + ({ wrapQueryLimit }) => ({ + wrapQueryLimit: wrapQueryLimit ? wrapQueryLimit * 10 : undefined, + }), + this.handleRun, + ); + }; + renderExplainDialog() { const { explainDialogOpen, explainResultState } = this.state; if (explainResultState.loading || !explainDialogOpen) return; @@ -495,8 +506,13 @@ export class QueryView extends React.PureComponent + )} + {queryResultState.loading && }
@@ -505,6 +521,7 @@ export class QueryView extends React.PureComponent )} {queryResultState.error && ( @@ -527,9 +544,15 @@ export class QueryView extends React.PureComponent -

- Enter a query and click Run -

+ {emptyQuery ? ( +

+ Enter a query and click Run +

+ ) : ( +

+ Click Run to execute the query +

+ )}
)}