You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ta...@apache.org on 2020/10/05 23:22:36 UTC
[incubator-superset] branch master updated: feat: saved query
preview modal (#11135)
This is an automated email from the ASF dual-hosted git repository.
tai pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git
The following commit(s) were added to refs/heads/master by this push:
new 152315d feat: saved query preview modal (#11135)
152315d is described below
commit 152315d0f45f9d2474682f5c7636c58541cbf517
Author: Lily Kuang <li...@preset.io>
AuthorDate: Mon Oct 5 16:21:59 2020 -0700
feat: saved query preview modal (#11135)
---
.../savedquery/SavedQueryPreviewModal_spec.jsx | 139 +++++++++++++++++
superset-frontend/src/common/components/Modal.tsx | 39 +++--
.../src/components/ListView/ListView.tsx | 3 +
.../src/components/ListView/TableCollection.tsx | 6 +-
.../views/CRUD/data/savedquery/SavedQueryList.tsx | 45 +++++-
.../data/savedquery/SavedQueryPreviewModal.tsx | 172 +++++++++++++++++++++
6 files changed, 385 insertions(+), 19 deletions(-)
diff --git a/superset-frontend/spec/javascripts/views/CRUD/data/savedquery/SavedQueryPreviewModal_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/data/savedquery/SavedQueryPreviewModal_spec.jsx
new file mode 100644
index 0000000..4a82609
--- /dev/null
+++ b/superset-frontend/spec/javascripts/views/CRUD/data/savedquery/SavedQueryPreviewModal_spec.jsx
@@ -0,0 +1,139 @@
+/**
+ * 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 from 'react';
+import thunk from 'redux-thunk';
+import configureStore from 'redux-mock-store';
+import fetchMock from 'fetch-mock';
+import { styledMount as mount } from 'spec/helpers/theming';
+import SavedQueryPreviewModal from 'src/views/CRUD/data/savedquery/SavedQueryPreviewModal';
+import Button from 'src/components/Button';
+import Modal from 'src/common/components/Modal';
+import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
+import { act } from 'react-dom/test-utils';
+
+// store needed for withToasts(DatabaseList)
+const mockStore = configureStore([thunk]);
+const store = mockStore({});
+
+const mockqueries = [...new Array(3)].map((_, i) => ({
+ created_by: {
+ id: i,
+ first_name: `user`,
+ last_name: `${i}`,
+ },
+ created_on: `${i}-2020`,
+ database: {
+ database_name: `db ${i}`,
+ id: i,
+ },
+ changed_on_delta_humanized: '1 day ago',
+ db_id: i,
+ description: `SQL for ${i}`,
+ id: i,
+ label: `query ${i}`,
+ schema: 'public',
+ sql: `SELECT ${i} FROM table`,
+ sql_tables: [
+ {
+ catalog: null,
+ schema: null,
+ table: `${i}`,
+ },
+ ],
+}));
+
+const mockedProps = {
+ fetchData: jest.fn(() => {}),
+ openInSqlLab: jest.fn(() => {}),
+ onHide: () => {},
+ queries: mockqueries,
+ savedQuery: mockqueries[1],
+ show: true,
+};
+
+const FETCH_SAVED_QUERY_ENDPOINT = 'glob:*/api/v1/saved_query/*';
+const SAVED_QUERY_PAYLOAD = { result: mockqueries[1] };
+
+fetchMock.get(FETCH_SAVED_QUERY_ENDPOINT, SAVED_QUERY_PAYLOAD);
+
+async function mountAndWait(props = mockedProps) {
+ const mounted = mount(<SavedQueryPreviewModal {...props} />, {
+ context: { store },
+ });
+ await waitForComponentToPaint(mounted);
+
+ return mounted;
+}
+
+describe('SavedQueryPreviewModal', () => {
+ let wrapper;
+
+ beforeAll(async () => {
+ wrapper = await mountAndWait();
+ });
+
+ it('renders', () => {
+ expect(wrapper.find(SavedQueryPreviewModal)).toExist();
+ });
+
+ it('renders a Modal', () => {
+ expect(wrapper.find(Modal)).toExist();
+ });
+
+ it('renders sql from saved query', () => {
+ expect(wrapper.find('pre').text()).toEqual('SELECT 1 FROM table');
+ });
+
+ it('renders buttons with correct text', () => {
+ expect(wrapper.find(Button).contains('Previous')).toBe(true);
+ expect(wrapper.find(Button).contains('Next')).toBe(true);
+ expect(wrapper.find(Button).contains('Open in SQL Lab')).toBe(true);
+ });
+
+ it('handle next save query', () => {
+ const button = wrapper.find('button[data-test="next-saved-query"]');
+ expect(button.props().disabled).toBe(false);
+ act(() => {
+ button.props().onClick(false);
+ });
+ expect(mockedProps.fetchData).toHaveBeenCalled();
+ expect(mockedProps.fetchData.mock.calls[0][0]).toEqual(2);
+ });
+
+ it('handle previous save query', () => {
+ const button = wrapper
+ .find('[data-test="previous-saved-query"]')
+ .find(Button);
+ expect(button.props().disabled).toBe(false);
+ act(() => {
+ button.props().onClick(true);
+ });
+ wrapper.update();
+ expect(mockedProps.fetchData).toHaveBeenCalled();
+ expect(mockedProps.fetchData.mock.calls[0][0]).toEqual(2);
+ });
+
+ it('handle open in sql lab', async () => {
+ act(() => {
+ wrapper.find('[data-test="open-in-sql-lab"]').first().props().onClick();
+ });
+ expect(mockedProps.openInSqlLab).toHaveBeenCalled();
+ expect(mockedProps.openInSqlLab.mock.calls[0][0]).toEqual(1);
+ });
+});
diff --git a/superset-frontend/src/common/components/Modal.tsx b/superset-frontend/src/common/components/Modal.tsx
index 9634cc3..42f7192 100644
--- a/superset-frontend/src/common/components/Modal.tsx
+++ b/superset-frontend/src/common/components/Modal.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
import React from 'react';
+import { isNil } from 'lodash';
import { styled, t } from '@superset-ui/core';
import { Modal as BaseModal } from 'src/common/components';
import Button from 'src/components/Button';
@@ -26,13 +27,14 @@ interface ModalProps {
children: React.ReactNode;
disablePrimaryButton?: boolean;
onHide: () => void;
- onHandledPrimaryAction: () => void;
- primaryButtonName: string;
+ onHandledPrimaryAction?: () => void;
+ primaryButtonName?: string;
primaryButtonType?: 'primary' | 'danger';
show: boolean;
title: React.ReactNode;
width?: string;
centered?: boolean;
+ footer?: React.ReactNode;
}
const StyledModal = styled(BaseModal)`
@@ -96,8 +98,26 @@ export default function Modal({
title,
width,
centered,
+ footer,
...rest
}: ModalProps) {
+ const modalFooter = isNil(footer)
+ ? [
+ <Button key="back" onClick={onHide} cta>
+ {t('Cancel')}
+ </Button>,
+ <Button
+ key="submit"
+ buttonStyle={primaryButtonType}
+ disabled={disablePrimaryButton}
+ onClick={onHandledPrimaryAction}
+ cta
+ >
+ {primaryButtonName}
+ </Button>,
+ ]
+ : footer;
+
return (
<StyledModal
centered={!!centered}
@@ -111,20 +131,7 @@ export default function Modal({
×
</span>
}
- footer={[
- <Button key="back" onClick={onHide} cta>
- {t('Cancel')}
- </Button>,
- <Button
- key="submit"
- buttonStyle={primaryButtonType}
- disabled={disablePrimaryButton}
- onClick={onHandledPrimaryAction}
- cta
- >
- {primaryButtonName}
- </Button>,
- ]}
+ footer={modalFooter}
{...rest}
>
{children}
diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx
index e0e19c5..4085804 100644
--- a/superset-frontend/src/components/ListView/ListView.tsx
+++ b/superset-frontend/src/components/ListView/ListView.tsx
@@ -202,6 +202,7 @@ export interface ListViewProps<T extends object = any> {
renderCard?: (row: T & { loading: boolean }) => React.ReactNode;
cardSortSelectOptions?: Array<CardSortSelectOption>;
defaultViewMode?: ViewModeType;
+ highlightRowId?: number;
}
function ListView<T extends object = any>({
@@ -221,6 +222,7 @@ function ListView<T extends object = any>({
renderCard,
cardSortSelectOptions,
defaultViewMode = 'card',
+ highlightRowId,
}: ListViewProps<T>) {
const {
getTableProps,
@@ -350,6 +352,7 @@ function ListView<T extends object = any>({
rows={rows}
columns={columns}
loading={loading}
+ highlightRowId={highlightRowId}
/>
)}
{!loading && rows.length === 0 && (
diff --git a/superset-frontend/src/components/ListView/TableCollection.tsx b/superset-frontend/src/components/ListView/TableCollection.tsx
index 4dbbc86..41b0018 100644
--- a/superset-frontend/src/components/ListView/TableCollection.tsx
+++ b/superset-frontend/src/components/ListView/TableCollection.tsx
@@ -30,6 +30,7 @@ interface TableCollectionProps {
rows: TableInstance['rows'];
columns: TableInstance['column'][];
loading: boolean;
+ highlightRowId?: number;
}
const Table = styled.table`
@@ -199,6 +200,7 @@ export default function TableCollection({
columns,
rows,
loading,
+ highlightRowId,
}: TableCollectionProps) {
return (
<Table {...getTableProps()} className="table table-hover">
@@ -262,7 +264,9 @@ export default function TableCollection({
<tr
{...row.getRowProps()}
className={cx('table-row', {
- 'table-row-selected': row.isSelected,
+ 'table-row-selected':
+ // @ts-ignore
+ row.isSelected || row.original.id === highlightRowId,
})}
>
{row.cells.map(cell => {
diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx
index 5b0b96d..15b9ba2 100644
--- a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx
+++ b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx
@@ -39,6 +39,7 @@ import DeleteModal from 'src/components/DeleteModal';
import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
import { IconName } from 'src/components/Icon';
import { commonMenuData } from 'src/views/CRUD/data/common';
+import SavedQueryPreviewModal from './SavedQueryPreviewModal';
const PAGE_SIZE = 25;
@@ -48,8 +49,17 @@ interface SavedQueryListProps {
}
type SavedQueryObject = {
+ database: {
+ database_name: string;
+ id: number;
+ };
+ db_id: number;
+ description?: string;
id: number;
label: string;
+ schema: string;
+ sql: string;
+ sql_tables: Array<{ catalog?: string; schema: string; table: string }>;
};
const StyledTableLabel = styled.div`
@@ -85,11 +95,14 @@ function SavedQueryList({
t('saved_queries'),
addDangerToast,
);
-
const [
queryCurrentlyDeleting,
setQueryCurrentlyDeleting,
] = useState<SavedQueryObject | null>(null);
+ const [
+ savedQueryCurrentlyPreviewing,
+ setSavedQueryCurrentlyPreviewing,
+ ] = useState<SavedQueryObject | null>(null);
const canCreate = hasPerm('can_add');
const canEdit = hasPerm('can_edit');
@@ -99,6 +112,21 @@ function SavedQueryList({
window.open(`${window.location.origin}/superset/sqllab?new=true`);
};
+ const handleSavedQueryPreview = (id: number) => {
+ SupersetClient.get({
+ endpoint: `/api/v1/saved_query/${id}`,
+ }).then(
+ ({ json = {} }) => {
+ setSavedQueryCurrentlyPreviewing({ ...json.result });
+ },
+ createErrorHandler(errMsg =>
+ addDangerToast(
+ t('There was an issue previewing the selected query %s', errMsg),
+ ),
+ ),
+ );
+ };
+
const menuData: SubMenuProps = {
activeChild: 'Saved Queries',
...commonMenuData,
@@ -293,7 +321,9 @@ function SavedQueryList({
},
{
Cell: ({ row: { original } }: any) => {
- const handlePreview = () => {}; // openQueryPreviewModal(original); // TODO: open preview modal
+ const handlePreview = () => {
+ handleSavedQueryPreview(original.id);
+ };
const handleEdit = () => {
openInSqlLab(original.id);
};
@@ -410,6 +440,16 @@ function SavedQueryList({
title={t('Delete Query?')}
/>
)}
+ {savedQueryCurrentlyPreviewing && (
+ <SavedQueryPreviewModal
+ fetchData={handleSavedQueryPreview}
+ onHide={() => setSavedQueryCurrentlyPreviewing(null)}
+ savedQuery={savedQueryCurrentlyPreviewing}
+ queries={queries}
+ openInSqlLab={openInSqlLab}
+ show
+ />
+ )}
<ConfirmStatusChange
title={t('Please confirm')}
description={t('Are you sure you want to delete the selected queries?')}
@@ -441,6 +481,7 @@ function SavedQueryList({
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={toggleBulkSelect}
+ highlightRowId={savedQueryCurrentlyPreviewing?.id}
/>
);
}}
diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx
new file mode 100644
index 0000000..8caafea
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx
@@ -0,0 +1,172 @@
+/**
+ * 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, { FunctionComponent, useState, useEffect } from 'react';
+import { styled, t } from '@superset-ui/core';
+import Modal from 'src/common/components/Modal';
+import Button from 'src/components/Button';
+import withToasts from 'src/messageToasts/enhancers/withToasts';
+import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light';
+import sql from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql';
+import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github';
+
+SyntaxHighlighter.registerLanguage('sql', sql);
+
+const QueryTitle = styled.div`
+ color: ${({ theme }) => theme.colors.secondary.light2};
+ font-size: ${({ theme }) => theme.typography.sizes.s - 1}px;
+ margin-bottom: 0;
+ text-transform: uppercase;
+`;
+
+const QueryLabel = styled.div`
+ color: ${({ theme }) => theme.colors.grayscale.dark2};
+ font-size: ${({ theme }) => theme.typography.sizes.m - 1}px;
+ padding: 4px 0 16px 0;
+`;
+
+const StyledModal = styled(Modal)`
+ .ant-modal-content {
+ height: 620px;
+ }
+
+ .ant-modal-body {
+ padding: 24px;
+ }
+
+ pre {
+ font-size: ${({ theme }) => theme.typography.sizes.xs}px;
+ font-weight: ${({ theme }) => theme.typography.weights.normal};
+ line-height: ${({ theme }) => theme.typography.sizes.l}px;
+ height: 375px;
+ border: none;
+ }
+`;
+
+type SavedQueryObject = {
+ id: number;
+ label: string;
+ sql: string;
+};
+
+interface SavedQueryPreviewModalProps {
+ fetchData: (id: number) => {};
+ onHide: () => void;
+ openInSqlLab: (id: number) => {};
+ queries: Array<SavedQueryObject>;
+ savedQuery: SavedQueryObject;
+ show: boolean;
+}
+
+const SavedQueryPreviewModal: FunctionComponent<SavedQueryPreviewModalProps> = ({
+ fetchData,
+ onHide,
+ openInSqlLab,
+ queries,
+ savedQuery,
+ show,
+}) => {
+ const index = queries.findIndex(query => query.id === savedQuery.id);
+ const [currentIndex, setCurrentIndex] = useState(index);
+ const [disbalePrevious, setDisbalePrevious] = useState(false);
+ const [disbaleNext, setDisbaleNext] = useState(false);
+
+ function checkIndex() {
+ if (currentIndex === 0) {
+ setDisbalePrevious(true);
+ } else {
+ setDisbalePrevious(false);
+ }
+
+ if (currentIndex === queries.length - 1) {
+ setDisbaleNext(true);
+ } else {
+ setDisbaleNext(false);
+ }
+ }
+
+ function handleDataChange(previous: boolean) {
+ const offset = previous ? -1 : 1;
+ const index = currentIndex + offset;
+ if (index >= 0 && index < queries.length) {
+ fetchData(queries[index].id);
+ setCurrentIndex(index);
+ checkIndex();
+ }
+ }
+
+ function handleKeyPress(ev: any) {
+ if (currentIndex >= 0 && currentIndex < queries.length) {
+ if (ev.key === 'ArrowDown' || ev.key === 'k') {
+ ev.preventDefault();
+ handleDataChange(false);
+ } else if (ev.key === 'ArrowUp' || ev.key === 'j') {
+ ev.preventDefault();
+ handleDataChange(true);
+ }
+ }
+ }
+
+ useEffect(() => {
+ checkIndex();
+ });
+
+ return (
+ <div role="none" onKeyUp={handleKeyPress}>
+ <StyledModal
+ onHide={onHide}
+ show={show}
+ title={t('Query Preview')}
+ footer={[
+ <Button
+ data-test="previous-saved-query"
+ key="previous-saved-query"
+ disabled={disbalePrevious}
+ onClick={() => handleDataChange(true)}
+ >
+ {t('Previous')}
+ </Button>,
+ <Button
+ data-test="next-saved-query"
+ key="next-saved-query"
+ disabled={disbaleNext}
+ onClick={() => handleDataChange(false)}
+ >
+ {t('Next')}
+ </Button>,
+ <Button
+ data-test="open-in-sql-lab"
+ key="open-in-sql-lab"
+ buttonStyle="primary"
+ onClick={() => openInSqlLab(savedQuery.id)}
+ >
+ {t('Open in SQL Lab')}
+ </Button>,
+ ]}
+ >
+ <QueryTitle>query name</QueryTitle>
+ <QueryLabel>{savedQuery.label}</QueryLabel>
+ <SyntaxHighlighter language="sql" style={github}>
+ {savedQuery.sql}
+ </SyntaxHighlighter>
+ </StyledModal>
+ </div>
+ );
+};
+
+export default withToasts(SavedQueryPreviewModal);