From e9dba184660ce53e918abcc42cf5872473145bd9 Mon Sep 17 00:00:00 2001 From: Moriah Kreeger Date: Wed, 28 Oct 2020 15:45:07 -0700 Subject: [PATCH] feat: annotation layers CRUD list view (#11432) --- superset-frontend/images/empty.svg | 22 ++ .../AnnotationLayersList_spec.jsx | 101 +++++++ .../src/components/ListView/ListView.tsx | 56 ++-- superset-frontend/src/views/App.tsx | 6 + .../annotationlayers/AnnotationLayersList.tsx | 255 ++++++++++++++++++ superset/annotation_layers/api.py | 4 + superset/views/annotations.py | 11 + tests/annotation_layers/api_tests.py | 4 + 8 files changed, 442 insertions(+), 17 deletions(-) create mode 100644 superset-frontend/images/empty.svg create mode 100644 superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayersList_spec.jsx 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 0000000000..e2c78339ce --- /dev/null +++ b/superset-frontend/images/empty.svg @@ -0,0 +1,22 @@ + + + + + diff --git a/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayersList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayersList_spec.jsx new file mode 100644 index 0000000000..4ff3871cca --- /dev/null +++ b/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayersList_spec.jsx @@ -0,0 +1,101 @@ +/** + * 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 AnnotationLayersList from 'src/views/CRUD/annotationlayers/AnnotationLayersList'; +import SubMenu from 'src/components/Menu/SubMenu'; +import ListView from 'src/components/ListView'; +// import Filters from 'src/components/ListView/Filters'; +// import DeleteModal from 'src/components/DeleteModal'; +// import Button from 'src/components/Button'; +// import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; +import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; +// import { act } from 'react-dom/test-utils'; + +// store needed for withToasts(AnnotationLayersList) +const mockStore = configureStore([thunk]); +const store = mockStore({}); + +const layersInfoEndpoint = 'glob:*/api/v1/annotation_layer/_info*'; +const layersEndpoint = 'glob:*/api/v1/annotation_layer/?*'; +// const layerEndpoint = 'glob:*/api/v1/annotation_layer/*'; +// const templatesRelatedEndpoint = 'glob:*/api/v1/annotation_layer/related/*'; + +const mocklayers = [...new Array(3)].map((_, i) => ({ + changed_on_delta_humanized: `${i} day(s) ago`, + created_by: { + first_name: `user`, + last_name: `${i}`, + }, + created_on: new Date().toISOString, + changed_on: new Date().toISOString, + id: i, + name: `layer ${i}`, + desc: 'layer description', +})); + +fetchMock.get(layersInfoEndpoint, { + permissions: ['can_delete'], +}); +fetchMock.get(layersEndpoint, { + result: mocklayers, + layers_count: 3, +}); + +/* fetchMock.delete(layerEndpoint, {}); +fetchMock.delete(layersEndpoint, {}); + +fetchMock.get(layersRelatedEndpoint, { + created_by: { + count: 0, + result: [], + }, +}); */ + +describe('AnnotationLayersList', () => { + const wrapper = mount(, { context: { store } }); + + beforeAll(async () => { + await waitForComponentToPaint(wrapper); + }); + + it('renders', () => { + expect(wrapper.find(AnnotationLayersList)).toExist(); + }); + + it('renders a SubMenu', () => { + expect(wrapper.find(SubMenu)).toExist(); + }); + + it('renders a ListView', () => { + expect(wrapper.find(ListView)).toExist(); + }); + + it('fetches layers', () => { + const callsQ = fetchMock.calls(/annotation_layer\/\?q/); + expect(callsQ).toHaveLength(1); + expect(callsQ[0][0]).toMatchInlineSnapshot( + `"http://localhost/api/v1/annotation_layer/?q=(order_column:name,order_direction:desc,page:0,page_size:25)"`, + ); + }); +}); diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx index dd365a1529..3c33e4c720 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,36 @@ 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 7f22c1cbb4..103db080a3 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 0000000000..7fdf5824c6 --- /dev/null +++ b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx @@ -0,0 +1,255 @@ +/** + * 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 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 from 'src/components/Button'; + +const PAGE_SIZE = 25; +const MOMENT_FORMAT = 'MMM DD, YYYY'; + +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_layer', + t('annotation layers'), + addDangerToast, + ); + + // TODO: un-comment all instances when modal work begins + /* const [annotationLayerModalOpen, setAnnotationLayerModalOpen] = useState< + boolean + >(false); + const [ + currentAnnotationLayer, + setCurrentAnnotationLayer, + ] = 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 }]; + const columns = useMemo( + () => [ + { + 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: { + 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).format(MOMENT_FORMAT); + }, + 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 } }: 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: () => { + handleAnnotationLayerEdit(null); + }, + }); + } + + 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/annotation_layers/api.py b/superset/annotation_layers/api.py index d6f07e5e0b..f3a6e6e67a 100644 --- a/superset/annotation_layers/api.py +++ b/superset/annotation_layers/api.py @@ -74,7 +74,9 @@ class AnnotationLayerRestApi(BaseSupersetModelRestApi): "created_by.last_name", "changed_by.first_name", "changed_by.last_name", + "changed_on", "changed_on_delta_humanized", + "created_on", ] add_columns = ["name", "descr"] edit_columns = add_columns @@ -86,7 +88,9 @@ class AnnotationLayerRestApi(BaseSupersetModelRestApi): "descr", "created_by.first_name", "changed_by.first_name", + "changed_on", "changed_on_delta_humanized", + "created_on", ] search_filters = {"name": [AnnotationLayerAllTextFilter]} diff --git a/superset/views/annotations.py b/superset/views/annotations.py index 463ef05268..5cbf29d2f1 100644 --- a/superset/views/annotations.py +++ b/superset/views/annotations.py @@ -123,3 +123,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() diff --git a/tests/annotation_layers/api_tests.py b/tests/annotation_layers/api_tests.py index 22ddecdbf0..53658c8438 100644 --- a/tests/annotation_layers/api_tests.py +++ b/tests/annotation_layers/api_tests.py @@ -164,8 +164,10 @@ class TestAnnotationLayerApi(SupersetTestCase): "name", "descr", "created_by", + "created_on", "changed_by", "changed_on_delta_humanized", + "changed_on", ] assert rv.status_code == 200 data = json.loads(rv.data.decode("utf-8")) @@ -186,7 +188,9 @@ class TestAnnotationLayerApi(SupersetTestCase): "descr", "created_by.first_name", "changed_by.first_name", + "changed_on", "changed_on_delta_humanized", + "created_on", ] for order_column in order_columns: