From 75a2a6e8c2bb464d7b9a0df96302194a00da1505 Mon Sep 17 00:00:00 2001 From: riahk Date: Thu, 22 Oct 2020 14:55:24 -0700 Subject: [PATCH 1/4] Annotation Layers routing + basic list view + new empty state --- superset-frontend/images/empty.svg | 22 ++ .../src/components/ListView/ListView.tsx | 58 +++-- superset-frontend/src/views/App.tsx | 6 + .../annotationlayers/AnnotationLayersList.tsx | 245 ++++++++++++++++++ superset/views/annotations.py | 12 + 5 files changed, 326 insertions(+), 17 deletions(-) create mode 100644 superset-frontend/images/empty.svg create mode 100644 superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx diff --git a/superset-frontend/images/empty.svg b/superset-frontend/images/empty.svg new file mode 100644 index 000000000000..e2c78339ce64 --- /dev/null +++ b/superset-frontend/images/empty.svg @@ -0,0 +1,22 @@ + + + + + diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx index dd365a1529a8..e25d9a993c62 100644 --- a/superset-frontend/src/components/ListView/ListView.tsx +++ b/superset-frontend/src/components/ListView/ListView.tsx @@ -20,6 +20,7 @@ import { t, styled } from '@superset-ui/core'; import React, { useEffect, useState } from 'react'; import { Alert } from 'react-bootstrap'; import { Empty } from 'src/common/components'; +import { ReactComponent as EmptyImage } from 'images/empty.svg'; import cx from 'classnames'; import Button from 'src/components/Button'; import Icon from 'src/components/Icon'; @@ -37,6 +38,7 @@ import { import { ListViewError, useListViewState } from './utils'; const ListViewStyles = styled.div` + background: ${({ theme }) => theme.colors.grayscale.light5}; text-align: center; .superset-list-view { @@ -57,6 +59,14 @@ const ListViewStyles = styled.div` } .body { } + + .ant-empty { + padding-bottom: 160px; + + .ant-empty-image { + height: auto; + } + } } .pagination-container { @@ -209,6 +219,10 @@ export interface ListViewProps { cardSortSelectOptions?: Array; defaultViewMode?: ViewModeType; highlightRowId?: number; + emptyState?: { + message?: string; + slot?: React.ReactNode; + }; } function ListView({ @@ -229,6 +243,7 @@ function ListView({ cardSortSelectOptions, defaultViewMode = 'card', highlightRowId, + emptyState = {}, }: ListViewProps) { const { getTableProps, @@ -368,29 +383,38 @@ function ListView({ )} {!loading && rows.length === 0 && ( - + + } + description={emptyState.message || 'No Data'} + > + {emptyState.slot || null} + )} -
- gotoPage(p - 1)} - hideFirstAndLastPageLinks - /> -
- {!loading && - t( - '%s-%s of %s', - pageSize * pageIndex + (rows.length && 1), - pageSize * pageIndex + rows.length, - count, - )} + {rows.length > 0 && ( +
+ gotoPage(p - 1)} + hideFirstAndLastPageLinks + /> +
+ {!loading && + t( + '%s-%s of %s', + pageSize * pageIndex + (rows.length && 1), + pageSize * pageIndex + rows.length, + count, + )} +
-
+ )} ); } diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index 7f22c1cbb446..103db080a343 100644 --- a/superset-frontend/src/views/App.tsx +++ b/superset-frontend/src/views/App.tsx @@ -34,6 +34,7 @@ import DatasetList from 'src/views/CRUD/data/dataset/DatasetList'; import DatabaseList from 'src/views/CRUD/data/database/DatabaseList'; import SavedQueryList from 'src/views/CRUD/data/savedquery/SavedQueryList'; import CssTemplatesList from 'src/views/CRUD/csstemplates/CssTemplatesList'; +import AnnotationLayersList from 'src/views/CRUD/annotationlayers/AnnotationLayersList'; import AnnotationList from 'src/views/CRUD/annotation/AnnotationList'; import messageToastReducer from '../messageToasts/reducers'; @@ -104,6 +105,11 @@ const App = () => ( + + + + + diff --git a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx new file mode 100644 index 000000000000..3d6cd4179727 --- /dev/null +++ b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx @@ -0,0 +1,245 @@ +/** + * 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, useState } from 'react'; +import { t } from '@superset-ui/core'; +import moment from 'moment'; +import { useListViewResource } from 'src/views/CRUD/hooks'; +import withToasts from 'src/messageToasts/enhancers/withToasts'; +import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu'; +import { IconName } from 'src/components/Icon'; +import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; +// import ListView, { Filters } from 'src/components/ListView'; +import ListView from 'src/components/ListView'; +import Button, { OnClickHandler } from 'src/components/Button'; + +const PAGE_SIZE = 25; + +interface AnnotationLayersListProps { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; +} + +// TODO: move to separate types file +type CreatedByUser = { + id: number; + first_name: string; + last_name: string; +}; + +type AnnotationLayerObject = { + id?: number; + changed_on_delta_humanized?: string; + created_on?: string; + created_by?: CreatedByUser; + changed_by?: CreatedByUser; + name?: string; + desc?: string; +}; + +function AnnotationLayersList({ + addDangerToast, + addSuccessToast, +}: AnnotationLayersListProps) { + /*const { + state: { + loading, + resourceCount: layersCount, + resourceCollection: layers, + }, + hasPerm, + fetchData, + refreshData, + } = useListViewResource( + 'annotation_layers', + t('annotation layers'), + addDangerToast, + );*/ + const [annotationLayerModalOpen, setAnnotationLayerModalOpen] = useState( + false, + ); + const [ + currentAnnotationLayer, + setCurrentAnnotationLayer, + ] = useState(null); + + // TEST DATA TODO: rm when api is up + const layers = []; + const layersCount = 0; + const loading = false; + const fetchData = () => {}; + + // TODO: switch back to use hasPerm once api is up + const canCreate = true; // hasPerm('can_add'); + const canEdit = true; // hasPerm('can_edit'); + const canDelete = true; // hasPerm('can_delete'); + + function handleAnnotationLayerEdit(layer: AnnotationLayerObject) { + setCurrentAnnotationLayer(layer); + setAnnotationLayerModalOpen(true); + } + + const initialSort = [{ id: 'name', desc: true }]; + const columns = useMemo( + () => [ + { + accessor: 'name', + Header: t('Name'), + }, + { + Cell: ({ + row: { + original: { created_on: createdOn }, + }, + }: any) => { + const date = new Date(createdOn); + const utc = new Date( + Date.UTC( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds(), + ), + ); + + return moment(utc).fromNow(); + }, + Header: t('Created On'), + accessor: 'created_on', + size: 'xl', + }, + { + accessor: 'created_by', + disableSortBy: true, + Header: t('Created By'), + Cell: ({ + row: { + original: { created_by: createdBy }, + }, + }: any) => + createdBy ? `${createdBy.first_name} ${createdBy.last_name}` : '', + size: 'xl', + }, + { + Cell: ({ + row: { + original: { changed_on_delta_humanized: changedOn }, + }, + }: any) => changedOn, + Header: t('Last Modified'), + accessor: 'changed_on_delta_humanized', + size: 'xl', + }, + { + Cell: ({ row: { original } }: any) => { + const handleEdit = () => handleAnnotationLayerEdit(original); + const handleDelete = () => {}; // openAnnotationLayerDeleteModal(original); + + const actions = [ + canEdit + ? { + label: 'edit-action', + tooltip: t('Edit template'), + placement: 'bottom', + icon: 'edit' as IconName, + onClick: handleEdit, + } + : null, + canDelete + ? { + label: 'delete-action', + tooltip: t('Delete template'), + placement: 'bottom', + icon: 'trash' as IconName, + onClick: handleDelete, + } + : null, + ].filter(item => !!item); + + return ; + }, + Header: t('Actions'), + id: 'actions', + disableSortBy: true, + hidden: !canEdit && !canDelete, + size: 'xl', + }, + ], + [canDelete, canCreate], + ); + + const subMenuButtons: SubMenuProps['buttons'] = []; + + if (canCreate) { + subMenuButtons.push({ + name: ( + <> + {t('Annotation Layer')} + + ), + buttonStyle: 'primary', + onClick: () => { + setCurrentAnnotationLayer(null); + setAnnotationLayerModalOpen(true); + }, + }); + } + + const EmptyStateButton = ( + + ); + + const emptyState = { + message: 'No annotation layers yet', + slot: EmptyStateButton, + }; + + return ( + <> + + + className="annotation-layers-list-view" + columns={columns} + count={layersCount} + data={layers} + fetchData={fetchData} + // filters={filters} + initialSort={initialSort} + loading={loading} + pageSize={PAGE_SIZE} + emptyState={emptyState} + /> + + ); +} + +export default withToasts(AnnotationLayersList); diff --git a/superset/views/annotations.py b/superset/views/annotations.py index 463ef0526889..29e19dab433b 100644 --- a/superset/views/annotations.py +++ b/superset/views/annotations.py @@ -29,6 +29,7 @@ from superset.models.annotations import Annotation, AnnotationLayer from superset.typing import FlaskResponse from superset.views.base import SupersetModelView +from superset.typing import FlaskResponse class StartEndDttmValidator: # pylint: disable=too-few-public-methods @@ -123,3 +124,14 @@ class AnnotationLayerModelView(SupersetModelView): # pylint: disable=too-many-a add_columns = edit_columns label_columns = {"name": _("Name"), "descr": _("Description")} + + @expose("/list/") + @has_access + def list(self) -> FlaskResponse: + if not ( + app.config["ENABLE_REACT_CRUD_VIEWS"] + and feature_flag_manager.is_feature_enabled("SIP_34_ANNOTATIONS_UI") + ): + return super().list() + + return super().render_app_template() From 011cdf0ac69f1097c47fbecc45b2c21253f9d8c6 Mon Sep 17 00:00:00 2001 From: riahk Date: Mon, 26 Oct 2020 14:15:24 -0700 Subject: [PATCH 2/4] hook up to api --- .../src/components/ListView/ListView.tsx | 4 +- .../annotationlayers/AnnotationLayersList.tsx | 98 ++++++++++--------- superset/annotation_layers/api.py | 4 + tests/annotation_layers/api_tests.py | 4 + 4 files changed, 63 insertions(+), 47 deletions(-) diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx index e25d9a993c62..3c33e4c72023 100644 --- a/superset-frontend/src/components/ListView/ListView.tsx +++ b/superset-frontend/src/components/ListView/ListView.tsx @@ -384,9 +384,7 @@ function ListView({ {!loading && rows.length === 0 && ( - } + image={} description={emptyState.message || 'No Data'} > {emptyState.slot || null} diff --git a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx index 3d6cd4179727..7fdf5824c610 100644 --- a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx +++ b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx @@ -17,7 +17,8 @@ * under the License. */ -import React, { useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; +// import React, { useMemo, useState } from 'react'; import { t } from '@superset-ui/core'; import moment from 'moment'; import { useListViewResource } from 'src/views/CRUD/hooks'; @@ -27,9 +28,10 @@ import { IconName } from 'src/components/Icon'; import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; // import ListView, { Filters } from 'src/components/ListView'; import ListView from 'src/components/ListView'; -import Button, { OnClickHandler } from 'src/components/Button'; +import Button from 'src/components/Button'; const PAGE_SIZE = 25; +const MOMENT_FORMAT = 'MMM DD, YYYY'; interface AnnotationLayersListProps { addDangerToast: (msg: string) => void; @@ -57,42 +59,33 @@ function AnnotationLayersList({ addDangerToast, addSuccessToast, }: AnnotationLayersListProps) { - /*const { - state: { - loading, - resourceCount: layersCount, - resourceCollection: layers, - }, + const { + state: { loading, resourceCount: layersCount, resourceCollection: layers }, hasPerm, fetchData, - refreshData, + // refreshData, } = useListViewResource( - 'annotation_layers', + 'annotation_layer', t('annotation layers'), addDangerToast, - );*/ - const [annotationLayerModalOpen, setAnnotationLayerModalOpen] = useState( - false, ); + + // TODO: un-comment all instances when modal work begins + /* const [annotationLayerModalOpen, setAnnotationLayerModalOpen] = useState< + boolean + >(false); const [ currentAnnotationLayer, setCurrentAnnotationLayer, - ] = useState(null); - - // TEST DATA TODO: rm when api is up - const layers = []; - const layersCount = 0; - const loading = false; - const fetchData = () => {}; - - // TODO: switch back to use hasPerm once api is up - const canCreate = true; // hasPerm('can_add'); - const canEdit = true; // hasPerm('can_edit'); - const canDelete = true; // hasPerm('can_delete'); - - function handleAnnotationLayerEdit(layer: AnnotationLayerObject) { - setCurrentAnnotationLayer(layer); - setAnnotationLayerModalOpen(true); + ] = useState(null); */ + + const canCreate = hasPerm('can_add'); + const canEdit = hasPerm('can_edit'); + const canDelete = hasPerm('can_delete'); + + function handleAnnotationLayerEdit(layer: AnnotationLayerObject | null) { + // setCurrentAnnotationLayer(layer); + // setAnnotationLayerModalOpen(true); } const initialSort = [{ id: 'name', desc: true }]; @@ -102,6 +95,35 @@ function AnnotationLayersList({ accessor: 'name', Header: t('Name'), }, + { + accessor: 'descr', + Header: t('Description'), + }, + { + Cell: ({ + row: { + original: { changed_on: changedOn }, + }, + }: any) => { + const date = new Date(changedOn); + const utc = new Date( + Date.UTC( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds(), + ), + ); + + return moment(utc).format(MOMENT_FORMAT); + }, + Header: t('Last Modified'), + accessor: 'changed_on', + size: 'xl', + }, { Cell: ({ row: { @@ -121,7 +143,7 @@ function AnnotationLayersList({ ), ); - return moment(utc).fromNow(); + return moment(utc).format(MOMENT_FORMAT); }, Header: t('Created On'), accessor: 'created_on', @@ -139,16 +161,6 @@ function AnnotationLayersList({ createdBy ? `${createdBy.first_name} ${createdBy.last_name}` : '', size: 'xl', }, - { - Cell: ({ - row: { - original: { changed_on_delta_humanized: changedOn }, - }, - }: any) => changedOn, - Header: t('Last Modified'), - accessor: 'changed_on_delta_humanized', - size: 'xl', - }, { Cell: ({ row: { original } }: any) => { const handleEdit = () => handleAnnotationLayerEdit(original); @@ -198,8 +210,7 @@ function AnnotationLayersList({ ), buttonStyle: 'primary', onClick: () => { - setCurrentAnnotationLayer(null); - setAnnotationLayerModalOpen(true); + handleAnnotationLayerEdit(null); }, }); } @@ -208,8 +219,7 @@ function AnnotationLayersList({