diff --git a/superset-frontend/src/components/Chart/chartAction.js b/superset-frontend/src/components/Chart/chartAction.js index 8f451444f4633..4ccf6a23cda1f 100644 --- a/superset-frontend/src/components/Chart/chartAction.js +++ b/superset-frontend/src/components/Chart/chartAction.js @@ -481,7 +481,6 @@ export function exploreJSON( return Promise.all([ chartDataRequestCaught, dispatch(triggerQuery(false, key)), - dispatch(updateQueryFormData(formData, key)), ...annotationLayers.map(annotation => dispatch( runAnnotationQuery({ @@ -595,3 +594,17 @@ export function refreshChart(chartKey, force, dashboardId) { ); }; } + +export const getDatasetSamples = async (datasetId, force) => { + const endpoint = `/api/v1/dataset/${datasetId}/samples?force=${force}`; + try { + const response = await SupersetClient.get({ endpoint }); + return response.json.result; + } catch (err) { + const clientError = await getClientErrorObject(err); + throw new Error( + clientError.message || clientError.error || t('Sorry, an error occurred'), + { cause: err }, + ); + } +}; diff --git a/superset-frontend/src/components/Chart/chartActions.test.js b/superset-frontend/src/components/Chart/chartActions.test.js index 7c7af00a4b2e4..08840fd6db6ae 100644 --- a/superset-frontend/src/components/Chart/chartActions.test.js +++ b/superset-frontend/src/components/Chart/chartActions.test.js @@ -105,8 +105,8 @@ describe('chart actions', () => { const actionThunk = actions.postChartFormData({}); return actionThunk(dispatch).then(() => { - // chart update, trigger query, update form data, success - expect(dispatch.callCount).toBe(5); + // chart update, trigger query, success + expect(dispatch.callCount).toBe(4); expect(fetchMock.calls(MOCK_URL)).toHaveLength(1); expect(dispatch.args[0][0].type).toBe(actions.CHART_UPDATE_STARTED); }); @@ -116,43 +116,32 @@ describe('chart actions', () => { const actionThunk = actions.postChartFormData({}); return actionThunk(dispatch).then(() => { // chart update, trigger query, update form data, success - expect(dispatch.callCount).toBe(5); + expect(dispatch.callCount).toBe(4); expect(fetchMock.calls(MOCK_URL)).toHaveLength(1); expect(dispatch.args[1][0].type).toBe(actions.TRIGGER_QUERY); }); }); - it('should dispatch UPDATE_QUERY_FORM_DATA action with the query', () => { - const actionThunk = actions.postChartFormData({}); - return actionThunk(dispatch).then(() => { - // chart update, trigger query, update form data, success - expect(dispatch.callCount).toBe(5); - expect(fetchMock.calls(MOCK_URL)).toHaveLength(1); - expect(dispatch.args[2][0].type).toBe(actions.UPDATE_QUERY_FORM_DATA); - }); - }); - it('should dispatch logEvent async action', () => { const actionThunk = actions.postChartFormData({}); return actionThunk(dispatch).then(() => { - // chart update, trigger query, update form data, success - expect(dispatch.callCount).toBe(5); + // chart update, trigger query, success + expect(dispatch.callCount).toBe(4); expect(fetchMock.calls(MOCK_URL)).toHaveLength(1); - expect(typeof dispatch.args[3][0]).toBe('function'); - dispatch.args[3][0](dispatch); - expect(dispatch.callCount).toBe(6); - expect(dispatch.args[5][0].type).toBe(LOG_EVENT); + dispatch.args[2][0](dispatch); + expect(dispatch.callCount).toBe(5); + expect(dispatch.args[4][0].type).toBe(LOG_EVENT); }); }); it('should dispatch CHART_UPDATE_SUCCEEDED action upon success', () => { const actionThunk = actions.postChartFormData({}); return actionThunk(dispatch).then(() => { - // chart update, trigger query, update form data, success - expect(dispatch.callCount).toBe(5); + // chart update, trigger query, success + expect(dispatch.callCount).toBe(4); expect(fetchMock.calls(MOCK_URL)).toHaveLength(1); - expect(dispatch.args[4][0].type).toBe(actions.CHART_UPDATE_SUCCEEDED); + expect(dispatch.args[3][0].type).toBe(actions.CHART_UPDATE_SUCCEEDED); }); }); @@ -168,8 +157,8 @@ describe('chart actions', () => { return actionThunk(dispatch).then(() => { // chart update, trigger query, update form data, fail expect(fetchMock.calls(MOCK_URL)).toHaveLength(1); - expect(dispatch.callCount).toBe(5); - expect(dispatch.args[4][0].type).toBe(actions.CHART_UPDATE_FAILED); + expect(dispatch.callCount).toBe(4); + expect(dispatch.args[3][0].type).toBe(actions.CHART_UPDATE_FAILED); setupDefaultFetchMock(); }); }); @@ -185,9 +174,9 @@ describe('chart actions', () => { const actionThunk = actions.postChartFormData({}, false, timeoutInSec); return actionThunk(dispatch).then(() => { - // chart update, trigger query, update form data, fail - expect(dispatch.callCount).toBe(5); - const updateFailedAction = dispatch.args[4][0]; + // chart update, trigger query, fail + expect(dispatch.callCount).toBe(4); + const updateFailedAction = dispatch.args[3][0]; expect(updateFailedAction.type).toBe(actions.CHART_UPDATE_FAILED); expect(updateFailedAction.queriesResponse[0].error).toBe('misc error'); diff --git a/superset-frontend/src/dashboard/components/Dashboard.test.jsx b/superset-frontend/src/dashboard/components/Dashboard.test.jsx index a881d0cdadf93..c76e1e3518219 100644 --- a/superset-frontend/src/dashboard/components/Dashboard.test.jsx +++ b/superset-frontend/src/dashboard/components/Dashboard.test.jsx @@ -48,6 +48,7 @@ describe('Dashboard', () => { removeSliceFromDashboard() {}, triggerQuery() {}, logEvent() {}, + updateQueryFormData() {}, }, initMessages: [], dashboardState, diff --git a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx index cb95e29fd091c..16445a2e3ed02 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx @@ -26,6 +26,8 @@ import { screen, waitForElementToBeRemoved, } from 'spec/helpers/testing-library'; +import { DatasourceType } from '@superset-ui/core'; +import { exploreActions } from 'src/explore/actions/exploreActions'; import { DataTablesPane } from '.'; const createProps = () => ({ @@ -62,6 +64,16 @@ const createProps = () => ({ colnames: [], }, ], + datasource: { + id: 0, + name: '', + type: DatasourceType.Table, + columns: [], + metrics: [], + columnFormats: {}, + verboseMap: {}, + }, + actions: exploreActions, }); describe('DataTablesPane', () => { diff --git a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx new file mode 100644 index 0000000000000..77258f7e92f9b --- /dev/null +++ b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.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 React, { + useCallback, + useEffect, + useMemo, + useState, + MouseEvent, +} from 'react'; +import { styled, t, useTheme } from '@superset-ui/core'; +import Icons from 'src/components/Icons'; +import Tabs from 'src/components/Tabs'; +import { + getItem, + setItem, + LocalStorageKeys, +} from 'src/utils/localStorageHelpers'; +import { ResultsPane, SamplesPane, TableControlsWrapper } from './components'; +import { DataTablesPaneProps } from './types'; + +enum ResultTypes { + Results = 'results', + Samples = 'samples', +} + +const SouthPane = styled.div` + ${({ theme }) => ` + position: relative; + background-color: ${theme.colors.grayscale.light5}; + z-index: 5; + overflow: hidden; + + .ant-tabs { + height: 100%; + } + + .ant-tabs-content-holder { + height: 100%; + } + + .ant-tabs-content { + height: 100%; + } + + .ant-tabs-tabpane { + display: flex; + flex-direction: column; + height: 100%; + + .table-condensed { + height: 100%; + overflow: auto; + margin-bottom: ${theme.gridUnit * 4}px; + + .table { + margin-bottom: ${theme.gridUnit * 2}px; + } + } + + .pagination-container > ul[role='navigation'] { + margin-top: 0; + } + } + `} +`; + +export const DataTablesPane = ({ + queryFormData, + datasource, + queryForce, + onCollapseChange, + ownState, + errorMessage, + actions, +}: DataTablesPaneProps) => { + const theme = useTheme(); + const [activeTabKey, setActiveTabKey] = useState(ResultTypes.Results); + const [isRequest, setIsRequest] = useState>({ + results: getItem(LocalStorageKeys.is_datapanel_open, false), + samples: false, + }); + const [panelOpen, setPanelOpen] = useState( + getItem(LocalStorageKeys.is_datapanel_open, false), + ); + + useEffect(() => { + setItem(LocalStorageKeys.is_datapanel_open, panelOpen); + }, [panelOpen]); + + useEffect(() => { + if (!panelOpen) { + setIsRequest({ + results: false, + samples: false, + }); + } + + if (panelOpen && activeTabKey === ResultTypes.Results) { + setIsRequest({ + results: true, + samples: false, + }); + } + + if (panelOpen && activeTabKey === ResultTypes.Samples) { + setIsRequest({ + results: false, + samples: true, + }); + } + }, [panelOpen, activeTabKey]); + + const handleCollapseChange = useCallback( + (isOpen: boolean) => { + onCollapseChange(isOpen); + setPanelOpen(isOpen); + }, + [onCollapseChange], + ); + + const handleTabClick = useCallback( + (tabKey: string, e: MouseEvent) => { + if (!panelOpen) { + handleCollapseChange(true); + } else if (tabKey === activeTabKey) { + e.preventDefault(); + handleCollapseChange(false); + } + setActiveTabKey(tabKey); + }, + [activeTabKey, handleCollapseChange, panelOpen], + ); + + const CollapseButton = useMemo(() => { + const caretIcon = panelOpen ? ( + + ) : ( + + ); + return ( + + {panelOpen ? ( + handleCollapseChange(false)} + > + {caretIcon} + + ) : ( + handleCollapseChange(true)} + > + {caretIcon} + + )} + + ); + }, [handleCollapseChange, panelOpen, theme.colors.grayscale.base]); + + return ( + + + + + + + + + + + ); +}; diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx new file mode 100644 index 0000000000000..738a20c3b95ec --- /dev/null +++ b/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx @@ -0,0 +1,75 @@ +/** + * 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 React, { useMemo } from 'react'; +import { css, styled } from '@superset-ui/core'; +import { + CopyToClipboardButton, + FilterInput, + RowCount, +} from 'src/explore/components/DataTableControl'; +import { applyFormattingToTabularData } from 'src/utils/common'; +import { useOriginalFormattedTimeColumns } from 'src/explore/components/useOriginalFormattedTimeColumns'; + +export const TableControlsWrapper = styled.div` + ${({ theme }) => ` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: ${theme.gridUnit * 2}px; + + span { + flex-shrink: 0; + } + `} +`; + +export const TableControls = ({ + data, + datasourceId, + onInputChange, + columnNames, + isLoading, +}: { + data: Record[]; + datasourceId?: string; + onInputChange: (input: string) => void; + columnNames: string[]; + isLoading: boolean; +}) => { + const originalFormattedTimeColumns = + useOriginalFormattedTimeColumns(datasourceId); + const formattedData = useMemo( + () => applyFormattingToTabularData(data, originalFormattedTimeColumns), + [data, originalFormattedTimeColumns], + ); + return ( + + +
+ + +
+
+ ); +}; diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/ResultsPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/ResultsPane.tsx new file mode 100644 index 0000000000000..b334a980b52b1 --- /dev/null +++ b/superset-frontend/src/explore/components/DataTablesPane/components/ResultsPane.tsx @@ -0,0 +1,173 @@ +/** + * 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 React, { useState, useEffect } from 'react'; +import { ensureIsArray, GenericDataType, styled, t } from '@superset-ui/core'; +import Loading from 'src/components/Loading'; +import { EmptyStateMedium } from 'src/components/EmptyState'; +import TableView, { EmptyWrapperType } from 'src/components/TableView'; +import { + useFilteredTableData, + useTableColumns, +} from 'src/explore/components/DataTableControl'; +import { useOriginalFormattedTimeColumns } from 'src/explore/components/useOriginalFormattedTimeColumns'; +import { getChartDataRequest } from 'src/components/Chart/chartAction'; +import { getClientErrorObject } from 'src/utils/getClientErrorObject'; +import { TableControls } from './DataTableControls'; +import { ResultsPaneProps } from '../types'; + +const Error = styled.pre` + margin-top: ${({ theme }) => `${theme.gridUnit * 4}px`}; +`; + +const cache = new WeakSet(); + +export const ResultsPane = ({ + isRequest, + queryFormData, + queryForce, + ownState, + errorMessage, + actions, + dataSize = 50, +}: ResultsPaneProps) => { + const [filterText, setFilterText] = useState(''); + const [data, setData] = useState[][]>([]); + const [colnames, setColnames] = useState([]); + const [coltypes, setColtypes] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [responseError, setResponseError] = useState(''); + + useEffect(() => { + // it's an invalid formData when gets a errorMessage + if (errorMessage) return; + if (isRequest && !cache.has(queryFormData)) { + setIsLoading(true); + getChartDataRequest({ + formData: queryFormData, + force: queryForce, + resultFormat: 'json', + resultType: 'results', + ownState, + }) + .then(({ json }) => { + const { colnames, coltypes } = json.result[0]; + // Only displaying the first query is currently supported + if (json.result.length > 1) { + // todo: move these code to the backend, shouldn't loop by row in FE + const data: any[] = []; + json.result.forEach((item: { data: any[] }) => { + item.data.forEach((row, i) => { + if (data[i] !== undefined) { + data[i] = { ...data[i], ...row }; + } else { + data[i] = row; + } + }); + }); + setData(data); + setColnames(colnames); + setColtypes(coltypes); + } else { + setData(ensureIsArray(json.result[0].data)); + setColnames(colnames); + setColtypes(coltypes); + } + setResponseError(''); + cache.add(queryFormData); + if (queryForce && actions) { + actions.setForceQuery(false); + } + }) + .catch(response => { + getClientErrorObject(response).then(({ error, message }) => { + setResponseError(error || message || t('Sorry, an error occurred')); + }); + }) + .finally(() => { + setIsLoading(false); + }); + } + }, [queryFormData, isRequest]); + + const originalFormattedTimeColumns = useOriginalFormattedTimeColumns( + queryFormData.datasource, + ); + // 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, + queryFormData.datasource, + originalFormattedTimeColumns, + ); + const filteredData = useFilteredTableData(filterText, data); + + if (errorMessage) { + const title = t('Run a query to display results'); + return ; + } + + if (isLoading) { + return ; + } + + if (responseError) { + return ( + <> + setFilterText(input)} + isLoading={isLoading} + /> + {responseError} + + ); + } + + if (data.length === 0) { + const title = t('No results were returned for this query'); + return ; + } + + return ( + <> + setFilterText(input)} + isLoading={isLoading} + /> + + + ); +}; diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx new file mode 100644 index 0000000000000..de3ef919f6b6c --- /dev/null +++ b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx @@ -0,0 +1,143 @@ +/** + * 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 React, { useState, useEffect, useMemo } from 'react'; +import { GenericDataType, styled, t } from '@superset-ui/core'; +import Loading from 'src/components/Loading'; +import { EmptyStateMedium } from 'src/components/EmptyState'; +import TableView, { EmptyWrapperType } from 'src/components/TableView'; +import { + useFilteredTableData, + useTableColumns, +} from 'src/explore/components/DataTableControl'; +import { useOriginalFormattedTimeColumns } from 'src/explore/components/useOriginalFormattedTimeColumns'; +import { getDatasetSamples } from 'src/components/Chart/chartAction'; +import { TableControls } from './DataTableControls'; +import { SamplesPaneProps } from '../types'; + +const Error = styled.pre` + margin-top: ${({ theme }) => `${theme.gridUnit * 4}px`}; +`; + +const cache = new WeakSet(); + +export const SamplesPane = ({ + isRequest, + datasource, + queryForce, + actions, + dataSize = 50, +}: SamplesPaneProps) => { + const [filterText, setFilterText] = useState(''); + const [data, setData] = useState[][]>([]); + const [colnames, setColnames] = useState([]); + const [coltypes, setColtypes] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [responseError, setResponseError] = useState(''); + const datasourceId = useMemo( + () => `${datasource.id}__${datasource.type}`, + [datasource], + ); + + useEffect(() => { + if (isRequest && queryForce) { + cache.delete(datasource); + } + + if (isRequest && !cache.has(datasource)) { + setIsLoading(true); + getDatasetSamples(datasource.id, queryForce) + .then(response => { + setData(response.data); + setColnames(response.colnames); + setColtypes(response.coltypes); + setResponseError(''); + cache.add(datasource); + if (queryForce && actions) { + actions.setForceQuery(false); + } + }) + .catch(error => { + setResponseError(`${error.name}: ${error.message}`); + }) + .finally(() => { + setIsLoading(false); + }); + } + }, [datasource, isRequest, queryForce]); + + const originalFormattedTimeColumns = + useOriginalFormattedTimeColumns(datasourceId); + // 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, + originalFormattedTimeColumns, + ); + const filteredData = useFilteredTableData(filterText, data); + + if (isLoading) { + return ; + } + + if (responseError) { + return ( + <> + setFilterText(input)} + isLoading={isLoading} + /> + {responseError} + + ); + } + + if (data.length === 0) { + const title = t('No samples were returned for this dataset'); + return ; + } + + return ( + <> + setFilterText(input)} + isLoading={isLoading} + /> + + + ); +}; diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/index.ts b/superset-frontend/src/explore/components/DataTablesPane/components/index.ts new file mode 100644 index 0000000000000..41623cb572083 --- /dev/null +++ b/superset-frontend/src/explore/components/DataTablesPane/components/index.ts @@ -0,0 +1,21 @@ +/** + * 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. + */ +export { ResultsPane } from './ResultsPane'; +export { SamplesPane } from './SamplesPane'; +export { TableControls, TableControlsWrapper } from './DataTableControls'; diff --git a/superset-frontend/src/explore/components/DataTablesPane/index.ts b/superset-frontend/src/explore/components/DataTablesPane/index.ts new file mode 100644 index 0000000000000..603cf71e6ff5d --- /dev/null +++ b/superset-frontend/src/explore/components/DataTablesPane/index.ts @@ -0,0 +1,20 @@ +/** + * 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. + */ +export { DataTablesPane } from './DataTablesPane'; +export * from './components'; diff --git a/superset-frontend/src/explore/components/DataTablesPane/index.tsx b/superset-frontend/src/explore/components/DataTablesPane/index.tsx deleted file mode 100644 index efa904fd9877c..0000000000000 --- a/superset-frontend/src/explore/components/DataTablesPane/index.tsx +++ /dev/null @@ -1,532 +0,0 @@ -/** - * 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 React, { - useCallback, - useEffect, - useMemo, - useState, - MouseEvent, -} from 'react'; -import { - css, - ensureIsArray, - GenericDataType, - JsonObject, - styled, - t, - useTheme, -} from '@superset-ui/core'; -import Icons from 'src/components/Icons'; -import Tabs from 'src/components/Tabs'; -import Loading from 'src/components/Loading'; -import { EmptyStateMedium } from 'src/components/EmptyState'; -import TableView, { EmptyWrapperType } from 'src/components/TableView'; -import { getChartDataRequest } from 'src/components/Chart/chartAction'; -import { getClientErrorObject } from 'src/utils/getClientErrorObject'; -import { - getItem, - setItem, - LocalStorageKeys, -} from 'src/utils/localStorageHelpers'; -import { - CopyToClipboardButton, - FilterInput, - RowCount, - useFilteredTableData, - useTableColumns, -} from 'src/explore/components/DataTableControl'; -import { applyFormattingToTabularData } from 'src/utils/common'; -import { useOriginalFormattedTimeColumns } from '../useOriginalFormattedTimeColumns'; - -const RESULT_TYPES = { - results: 'results' as const, - samples: 'samples' as const, -}; - -const getDefaultDataTablesState = (value: any) => ({ - [RESULT_TYPES.results]: value, - [RESULT_TYPES.samples]: value, -}); - -const DATA_TABLE_PAGE_SIZE = 50; - -const TableControlsWrapper = styled.div` - ${({ theme }) => ` - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: ${theme.gridUnit * 2}px; - - span { - flex-shrink: 0; - } - `} -`; - -const SouthPane = styled.div` - ${({ theme }) => ` - position: relative; - background-color: ${theme.colors.grayscale.light5}; - z-index: 5; - overflow: hidden; - - .ant-tabs { - height: 100%; - } - - .ant-tabs-content-holder { - height: 100%; - } - - .ant-tabs-content { - height: 100%; - } - - .ant-tabs-tabpane { - display: flex; - flex-direction: column; - height: 100%; - - .table-condensed { - height: 100%; - overflow: auto; - margin-bottom: ${theme.gridUnit * 4}px; - - .table { - margin-bottom: ${theme.gridUnit * 2}px; - } - } - - .pagination-container > ul[role='navigation'] { - margin-top: 0; - } - } - `} -`; - -const Error = styled.pre` - margin-top: ${({ theme }) => `${theme.gridUnit * 4}px`}; -`; - -interface DataTableProps { - columnNames: string[]; - columnTypes: GenericDataType[] | undefined; - datasource: string | undefined; - filterText: string; - data: object[] | undefined; - isLoading: boolean; - error: string | undefined; - errorMessage: React.ReactElement | undefined; - type: 'results' | 'samples'; -} - -const DataTable = ({ - columnNames, - columnTypes, - datasource, - filterText, - data, - isLoading, - error, - errorMessage, - type, -}: DataTableProps) => { - const originalFormattedTimeColumns = - useOriginalFormattedTimeColumns(datasource); - // 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( - columnNames, - columnTypes, - data, - datasource, - originalFormattedTimeColumns, - ); - const filteredData = useFilteredTableData(filterText, data); - - if (isLoading) { - return ; - } - if (error) { - return {error}; - } - if (data) { - if (data.length === 0) { - const title = - type === 'samples' - ? t('No samples were returned for this query') - : t('No results were returned for this query'); - return ; - } - return ( - - ); - } - if (errorMessage) { - const title = - type === 'samples' - ? t('Run a query to display samples') - : t('Run a query to display results'); - return ; - } - return null; -}; - -const TableControls = ({ - data, - datasourceId, - onInputChange, - columnNames, - isLoading, -}: { - data: Record[]; - datasourceId?: string; - onInputChange: (input: string) => void; - columnNames: string[]; - isLoading: boolean; -}) => { - const originalFormattedTimeColumns = - useOriginalFormattedTimeColumns(datasourceId); - const formattedData = useMemo( - () => applyFormattingToTabularData(data, originalFormattedTimeColumns), - [data, originalFormattedTimeColumns], - ); - return ( - - -
- - -
-
- ); -}; - -export const DataTablesPane = ({ - queryFormData, - queryForce, - onCollapseChange, - chartStatus, - ownState, - errorMessage, - queriesResponse, -}: { - queryFormData: Record; - queryForce: boolean; - chartStatus: string; - ownState?: JsonObject; - onCollapseChange: (isOpen: boolean) => void; - errorMessage?: JSX.Element; - queriesResponse: Record; -}) => { - const theme = useTheme(); - const [data, setData] = useState(getDefaultDataTablesState(undefined)); - const [isLoading, setIsLoading] = useState(getDefaultDataTablesState(true)); - const [columnNames, setColumnNames] = useState(getDefaultDataTablesState([])); - const [columnTypes, setColumnTypes] = useState(getDefaultDataTablesState([])); - const [error, setError] = useState(getDefaultDataTablesState('')); - const [filterText, setFilterText] = useState(getDefaultDataTablesState('')); - const [activeTabKey, setActiveTabKey] = useState( - RESULT_TYPES.results, - ); - const [isRequestPending, setIsRequestPending] = useState( - getDefaultDataTablesState(false), - ); - const [panelOpen, setPanelOpen] = useState( - getItem(LocalStorageKeys.is_datapanel_open, false), - ); - - const getData = useCallback( - (resultType: 'samples' | 'results') => { - setIsLoading(prevIsLoading => ({ - ...prevIsLoading, - [resultType]: true, - })); - return getChartDataRequest({ - formData: queryFormData, - force: queryForce, - resultFormat: 'json', - resultType, - ownState, - }) - .then(({ json }) => { - // Only displaying the first query is currently supported - if (json.result.length > 1) { - const data: any[] = []; - json.result.forEach((item: { data: any[] }) => { - item.data.forEach((row, i) => { - if (data[i] !== undefined) { - data[i] = { ...data[i], ...row }; - } else { - data[i] = row; - } - }); - }); - setData(prevData => ({ - ...prevData, - [resultType]: data, - })); - } else { - setData(prevData => ({ - ...prevData, - [resultType]: json.result[0].data, - })); - } - - const colNames = ensureIsArray(json.result[0].colnames); - - setColumnNames(prevColumnNames => ({ - ...prevColumnNames, - [resultType]: colNames, - })); - setColumnTypes(prevColumnTypes => ({ - ...prevColumnTypes, - [resultType]: json.result[0].coltypes || [], - })); - setIsLoading(prevIsLoading => ({ - ...prevIsLoading, - [resultType]: false, - })); - setError(prevError => ({ - ...prevError, - [resultType]: undefined, - })); - }) - .catch(response => { - getClientErrorObject(response).then(({ error, message }) => { - setError(prevError => ({ - ...prevError, - [resultType]: error || message || t('Sorry, an error occurred'), - })); - setIsLoading(prevIsLoading => ({ - ...prevIsLoading, - [resultType]: false, - })); - }); - }); - }, - [queryFormData, columnNames], - ); - useEffect(() => { - setItem(LocalStorageKeys.is_datapanel_open, panelOpen); - }, [panelOpen]); - - useEffect(() => { - setIsRequestPending(prevState => ({ - ...prevState, - [RESULT_TYPES.results]: true, - })); - }, [queryFormData]); - - useEffect(() => { - setIsRequestPending(prevState => ({ - ...prevState, - [RESULT_TYPES.samples]: true, - })); - }, [queryFormData?.datasource]); - - useEffect(() => { - if (queriesResponse && chartStatus === 'success') { - const { colnames } = queriesResponse[0]; - setColumnNames(prevColumnNames => ({ - ...prevColumnNames, - [RESULT_TYPES.results]: colnames ?? [], - })); - } - }, [queriesResponse, chartStatus]); - - useEffect(() => { - if (panelOpen && isRequestPending[RESULT_TYPES.results]) { - if (errorMessage) { - setIsRequestPending(prevState => ({ - ...prevState, - [RESULT_TYPES.results]: false, - })); - setIsLoading(prevIsLoading => ({ - ...prevIsLoading, - [RESULT_TYPES.results]: false, - })); - return; - } - if (chartStatus === 'loading') { - setIsLoading(prevIsLoading => ({ - ...prevIsLoading, - [RESULT_TYPES.results]: true, - })); - } else { - setIsRequestPending(prevState => ({ - ...prevState, - [RESULT_TYPES.results]: false, - })); - getData(RESULT_TYPES.results); - } - } - if ( - panelOpen && - isRequestPending[RESULT_TYPES.samples] && - activeTabKey === RESULT_TYPES.samples - ) { - setIsRequestPending(prevState => ({ - ...prevState, - [RESULT_TYPES.samples]: false, - })); - getData(RESULT_TYPES.samples); - } - }, [ - panelOpen, - isRequestPending, - getData, - activeTabKey, - chartStatus, - errorMessage, - ]); - - const handleCollapseChange = useCallback( - (isOpen: boolean) => { - onCollapseChange(isOpen); - setPanelOpen(isOpen); - }, - [onCollapseChange], - ); - - const handleTabClick = useCallback( - (tabKey: string, e: MouseEvent) => { - if (!panelOpen) { - handleCollapseChange(true); - } else if (tabKey === activeTabKey) { - e.preventDefault(); - handleCollapseChange(false); - } - setActiveTabKey(tabKey); - }, - [activeTabKey, handleCollapseChange, panelOpen], - ); - - const CollapseButton = useMemo(() => { - const caretIcon = panelOpen ? ( - - ) : ( - - ); - return ( - - {panelOpen ? ( - handleCollapseChange(false)} - > - {caretIcon} - - ) : ( - handleCollapseChange(true)} - > - {caretIcon} - - )} - - ); - }, [handleCollapseChange, panelOpen, theme.colors.grayscale.base]); - - return ( - - - - - setFilterText(prevState => ({ - ...prevState, - [RESULT_TYPES.results]: input, - })) - } - isLoading={isLoading[RESULT_TYPES.results]} - /> - - - - - setFilterText(prevState => ({ - ...prevState, - [RESULT_TYPES.samples]: input, - })) - } - isLoading={isLoading[RESULT_TYPES.samples]} - /> - - - - - ); -}; diff --git a/superset-frontend/src/explore/components/DataTablesPane/types.ts b/superset-frontend/src/explore/components/DataTablesPane/types.ts new file mode 100644 index 0000000000000..aa71326aa4b71 --- /dev/null +++ b/superset-frontend/src/explore/components/DataTablesPane/types.ts @@ -0,0 +1,48 @@ +/** + * 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 { Datasource, JsonObject, QueryFormData } from '@superset-ui/core'; +import { ExploreActions } from 'src/explore/actions/exploreActions'; + +export interface DataTablesPaneProps { + queryFormData: QueryFormData; + datasource: Datasource; + queryForce: boolean; + ownState?: JsonObject; + onCollapseChange: (isOpen: boolean) => void; + errorMessage?: JSX.Element; + actions: ExploreActions; +} + +export interface ResultsPaneProps { + isRequest: boolean; + queryFormData: QueryFormData; + queryForce: boolean; + ownState?: JsonObject; + errorMessage?: React.ReactElement; + actions?: ExploreActions; + dataSize?: number; +} + +export interface SamplesPaneProps { + isRequest: boolean; + datasource: Datasource; + queryForce: boolean; + actions?: ExploreActions; + dataSize?: number; +} diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx index 540a444ab0473..b76796bfde97c 100644 --- a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx +++ b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx @@ -97,6 +97,7 @@ const createProps = () => ({ fetchFaveStar: jest.fn(), saveFaveStar: jest.fn(), redirectSQLLab: jest.fn(), + updateQueryFormData: jest.fn(), }, user: { userId: 1, diff --git a/superset-frontend/src/explore/components/ExploreChartPanel.jsx b/superset-frontend/src/explore/components/ExploreChartPanel.jsx index 49ed20848d687..ff8a46b3c5149 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel.jsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel.jsx @@ -198,6 +198,7 @@ const ExploreChartPanel = ({ undefined, ownState, ); + actions.updateQueryFormData(formData, chart.id); }, [actions, chart.id, formData, ownState, timeout]); const onCollapseChange = useCallback(isOpen => { @@ -388,11 +389,11 @@ const ExploreChartPanel = ({ )} diff --git a/superset-frontend/src/explore/components/ExploreChartPanel.test.jsx b/superset-frontend/src/explore/components/ExploreChartPanel.test.jsx index a779773052e69..557b2149e01e1 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel.test.jsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel.test.jsx @@ -17,12 +17,12 @@ * under the License. */ import React from 'react'; +import userEvent from '@testing-library/user-event'; import { render, screen } from 'spec/helpers/testing-library'; import ChartContainer from 'src/explore/components/ExploreChartPanel'; const createProps = (overrides = {}) => ({ sliceName: 'Trend Line', - vizType: 'line', height: '500px', actions: {}, can_overwrite: false, @@ -30,9 +30,29 @@ const createProps = (overrides = {}) => ({ containerId: 'foo', width: '500px', isStarred: false, - chartIsStale: false, - chart: {}, - form_data: {}, + vizType: 'histogram', + chart: { + id: 1, + latestQueryFormData: { + viz_type: 'histogram', + datasource: '49__table', + slice_id: 318, + url_params: {}, + granularity_sqla: 'time_start', + time_range: 'No filter', + all_columns_x: ['age'], + adhoc_filters: [], + row_limit: 10000, + groupby: null, + color_scheme: 'supersetColors', + label_colors: {}, + link_length: '25', + x_axis_label: 'age', + y_axis_label: 'count', + }, + chartStatus: 'rendered', + queriesResponse: [{ is_cached: true }], + }, ...overrides, }); @@ -83,4 +103,37 @@ describe('ChartContainer', () => { screen.getByText('Required control values have been removed'), ).toBeVisible(); }); + + it('should render cached button and call expected actions', () => { + const setForceQuery = jest.fn(); + const postChartFormData = jest.fn(); + const updateQueryFormData = jest.fn(); + const props = createProps({ + actions: { + setForceQuery, + postChartFormData, + updateQueryFormData, + }, + }); + render(, { useRedux: true }); + + const cached = screen.queryByText('Cached'); + expect(cached).toBeInTheDocument(); + + userEvent.click(cached); + expect(setForceQuery).toHaveBeenCalledTimes(1); + expect(postChartFormData).toHaveBeenCalledTimes(1); + expect(updateQueryFormData).toHaveBeenCalledTimes(1); + }); + + it('should hide cached button', () => { + const props = createProps({ + chart: { + chartStatus: 'rendered', + queriesResponse: [{ is_cached: false }], + }, + }); + render(, { useRedux: true }); + expect(screen.queryByText('Cached')).not.toBeInTheDocument(); + }); }); diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index 2a3c45cf9ee8a..fc5703a2adaa2 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -267,6 +267,7 @@ function ExploreViewContainer(props) { const onQuery = useCallback(() => { props.actions.setForceQuery(false); props.actions.triggerQuery(true, props.chart.id); + props.actions.updateQueryFormData(props.form_data, props.chart.id); addHistory(); setLastQueriedControls(props.controls); }, [props.controls, addHistory, props.actions, props.chart.id]); diff --git a/superset-frontend/src/explore/components/useOriginalFormattedTimeColumns.ts b/superset-frontend/src/explore/components/useOriginalFormattedTimeColumns.ts index b51fef617889d..140cd3ba6223d 100644 --- a/superset-frontend/src/explore/components/useOriginalFormattedTimeColumns.ts +++ b/superset-frontend/src/explore/components/useOriginalFormattedTimeColumns.ts @@ -22,6 +22,6 @@ import { ExplorePageState } from '../reducers/getInitialState'; export const useOriginalFormattedTimeColumns = (datasourceId?: string) => useSelector(state => datasourceId - ? state.explore.originalFormattedTimeColumns?.[datasourceId] ?? [] + ? state?.explore?.originalFormattedTimeColumns?.[datasourceId] ?? [] : [], ); diff --git a/superset/datasets/api.py b/superset/datasets/api.py index 6a2e64536e22b..313634766c001 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -21,8 +21,9 @@ from typing import Any from zipfile import is_zipfile, ZipFile +import simplejson import yaml -from flask import g, request, Response, send_file +from flask import g, make_response, request, Response, send_file from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_babel import ngettext @@ -62,7 +63,7 @@ get_delete_ids_schema, get_export_ids_schema, ) -from superset.utils.core import parse_boolean_string +from superset.utils.core import json_int_dttm_ser, parse_boolean_string from superset.views.base import DatasourceFilter, generate_download_headers from superset.views.base_api import ( BaseSupersetModelRestApi, @@ -811,7 +812,14 @@ def samples(self, pk: int) -> Response: try: force = parse_boolean_string(request.args.get("force")) rv = SamplesDatasetCommand(g.user, pk, force).run() - return self.response(200, result=rv) + response_data = simplejson.dumps( + {"result": rv}, + default=json_int_dttm_ser, + ignore_nan=True, + ) + resp = make_response(response_data, 200) + resp.headers["Content-Type"] = "application/json; charset=utf-8" + return resp except DatasetNotFoundError: return self.response_404() except DatasetForbiddenError: