diff --git a/superset-frontend/spec/javascripts/views/CRUD/annotation/AnnotationList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/annotation/AnnotationList_spec.jsx new file mode 100644 index 00000000000..80c356209b6 --- /dev/null +++ b/superset-frontend/spec/javascripts/views/CRUD/annotation/AnnotationList_spec.jsx @@ -0,0 +1,113 @@ +/** + * 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 AnnotationList from 'src/views/CRUD/annotation/AnnotationList'; +import SubMenu from 'src/components/Menu/SubMenu'; +import ListView from 'src/components/ListView'; +import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; + +// store needed for withToasts(AnnotationList) +const mockStore = configureStore([thunk]); +const store = mockStore({}); + +const annotationsEndpoint = 'glob:*/api/v1/annotation_layer/*/annotation*'; +const annotationLayerEndpoint = 'glob:*/api/v1/annotation_layer/*'; + +const mockannotation = [...new Array(3)].map((_, i) => ({ + changed_on_delta_humanized: `${i} day(s) ago`, + created_by: { + first_name: `user`, + id: i, + }, + changed_by: { + first_name: `user`, + id: i, + }, + end_dttm: new Date().toISOString, + id: i, + long_descr: `annotation ${i} description`, + short_descr: `annotation ${i} label`, + start_dttm: new Date().toISOString, +})); + +fetchMock.get(annotationsEndpoint, { + ids: [2, 0, 1], + result: mockannotation, + count: 3, +}); + +fetchMock.get(annotationLayerEndpoint, { + id: 1, + result: { descr: 'annotations test 0', name: 'Test 0' }, +}); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts + useParams: () => ({ annotationLayerId: '1' }), +})); + +async function mountAndWait(props) { + const mounted = mount(, { + context: { store }, + }); + await waitForComponentToPaint(mounted); + + return mounted; +} + +describe('AnnotationList', () => { + let wrapper; + + beforeAll(async () => { + wrapper = await mountAndWait(); + }); + + it('renders', () => { + expect(wrapper.find(AnnotationList)).toExist(); + }); + + it('renders a SubMenu', () => { + expect(wrapper.find(SubMenu)).toExist(); + }); + + it('renders a ListView', () => { + expect(wrapper.find(ListView)).toExist(); + }); + + it('fetches annotation layer', () => { + const callsQ = fetchMock.calls(/annotation_layer\/1/); + expect(callsQ).toHaveLength(3); + expect(callsQ[2][0]).toMatchInlineSnapshot( + `"http://localhost/api/v1/annotation_layer/1"`, + ); + }); + + it('fetches annotations', () => { + const callsQ = fetchMock.calls(/annotation_layer\/1\/annotation/); + expect(callsQ).toHaveLength(2); + expect(callsQ[0][0]).toMatchInlineSnapshot( + `"http://localhost/api/v1/annotation_layer/1/annotation/?q=(order_column:short_descr,order_direction:desc,page:0,page_size:25)"`, + ); + }); +}); diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index 4985a5bca38..7f22c1cbb44 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 AnnotationList from 'src/views/CRUD/annotation/AnnotationList'; import messageToastReducer from '../messageToasts/reducers'; import { initEnhancer } from '../reduxUtils'; @@ -103,6 +104,11 @@ const App = () => ( + + + + + diff --git a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx b/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx new file mode 100644 index 00000000000..77c2467b90a --- /dev/null +++ b/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx @@ -0,0 +1,195 @@ +/** + * 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, useEffect, useCallback } from 'react'; +import { useParams } from 'react-router-dom'; +import { t, SupersetClient } from '@superset-ui/core'; +import moment from 'moment'; +import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; +import ListView from 'src/components/ListView'; +import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu'; +import getClientErrorObject from 'src/utils/getClientErrorObject'; +import withToasts from 'src/messageToasts/enhancers/withToasts'; +import { IconName } from 'src/components/Icon'; +import { useListViewResource } from 'src/views/CRUD/hooks'; + +import { AnnotationObject } from './types'; +// import AnnotationModal from './AnnotationModal'; + +const PAGE_SIZE = 25; + +interface AnnotationListProps { + addDangerToast: (msg: string) => void; +} + +function AnnotationList({ addDangerToast }: AnnotationListProps) { + const { annotationLayerId }: any = useParams(); + const { + state: { + loading, + resourceCount: annotationsCount, + resourceCollection: annotations, + }, + // hasPerm, + fetchData, + // refreshData, + } = useListViewResource( + `annotation_layer/${annotationLayerId}/annotation`, + t('annotation'), + addDangerToast, + false, + ); + // const [annotationModalOpen, setAnnotationModalOpen] = useState( + // false, + // ); + const [annotationLayerName, setAnnotationLayerName] = useState(''); + // const [ + // currentAnnotation, + // setCurrentAnnotation, + // ] = useState(null); + + // function handleAnnotationEdit(annotation: AnnotationObject) { + // setCurrentAnnotation(annotation); + // setAnnotationModalOpen(true); + // } + + const fetchAnnotationLayer = useCallback( + async function fetchAnnotationLayer() { + try { + const response = await SupersetClient.get({ + endpoint: `/api/v1/annotation_layer/${annotationLayerId}`, + }); + setAnnotationLayerName(response.json.result.name); + } catch (response) { + await getClientErrorObject(response).then(({ error }: any) => { + addDangerToast(error.error || error.statusText || error); + }); + } + }, + [annotationLayerId], + ); + + // get the owners of this slice + useEffect(() => { + fetchAnnotationLayer(); + }, [fetchAnnotationLayer]); + + const initialSort = [{ id: 'short_descr', desc: true }]; + const columns = useMemo( + () => [ + { + accessor: 'short_descr', + Header: t('Label'), + }, + { + accessor: 'long_descr', + Header: t('Description'), + }, + { + Cell: ({ + row: { + original: { start_dttm: startDttm }, + }, + }: any) => moment(new Date(startDttm)).format('ll'), + Header: t('Start'), + accessor: 'start_dttm', + }, + { + Cell: ({ + row: { + original: { end_dttm: endDttm }, + }, + }: any) => moment(new Date(endDttm)).format('ll'), + Header: t('End'), + accessor: 'end_dttm', + }, + { + Cell: () => { + const handleEdit = () => {}; // handleAnnotationEdit(original); + const handleDelete = () => {}; // openDatabaseDeleteModal(original); + const actions = [ + { + label: 'edit-action', + tooltip: t('Edit Annotation'), + placement: 'bottom', + icon: 'edit' as IconName, + onClick: handleEdit, + }, + { + label: 'delete-action', + tooltip: t('Delete Annotation'), + placement: 'bottom', + icon: 'trash' as IconName, + onClick: handleDelete, + }, + ]; + return ; + }, + Header: t('Actions'), + id: 'actions', + disableSortBy: true, + }, + ], + [true, true], + ); + + const subMenuButtons: SubMenuProps['buttons'] = []; + + subMenuButtons.push({ + name: ( + <> + {t('Annotation')} + + ), + buttonStyle: 'primary', + onClick: () => { + // setCurrentAnnotation(null); + // setAnnotationModalOpen(true); + }, + }); + + return ( + <> + + {/* refreshData()} + annnotationLayerId={annotationLayerId} + onHide={() => setAnnotationModalOpen(false)} + /> */} + + className="css-templates-list-view" + columns={columns} + count={annotationsCount} + data={annotations} + fetchData={fetchData} + initialSort={initialSort} + loading={loading} + pageSize={PAGE_SIZE} + /> + + ); +} + +export default withToasts(AnnotationList); diff --git a/superset-frontend/src/views/CRUD/annotation/types.ts b/superset-frontend/src/views/CRUD/annotation/types.ts new file mode 100644 index 00000000000..3c2ebc2947e --- /dev/null +++ b/superset-frontend/src/views/CRUD/annotation/types.ts @@ -0,0 +1,36 @@ +/** + * 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. + */ +type user = { + id: number; + first_name: string; + last_name: string; +}; + +export type AnnotationObject = { + changed_by?: user; + changed_on_delta_humanized?: string; + created_by?: user; + end_dttm?: string; + id?: number; + json_metadata?: string; + long_descr?: string; + short_descr?: string; + start_dttm?: string; + label: string; +}; diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index 29fb969ac51..9c2386f1755 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -37,6 +37,7 @@ export function useListViewResource( resource: string, resourceLabel: string, // resourceLabel for translations handleErrorMsg: (errorMsg: string) => void, + infoEnable = true, ) { const [state, setState] = useState>({ count: 0, @@ -56,8 +57,9 @@ export function useListViewResource( } useEffect(() => { + const infoParam = infoEnable ? '_info?q=(keys:!(permissions))' : ''; SupersetClient.get({ - endpoint: `/api/v1/${resource}/_info?q=(keys:!(permissions))`, + endpoint: `/api/v1/${resource}/${infoParam}`, }).then( ({ json: infoJson = {} }) => { updateState({ diff --git a/superset/annotation_layers/annotations/api.py b/superset/annotation_layers/annotations/api.py index 4ddab2e19d7..34c62554860 100644 --- a/superset/annotation_layers/annotations/api.py +++ b/superset/annotation_layers/annotations/api.py @@ -79,14 +79,15 @@ class AnnotationRestApi(BaseSupersetModelRestApi): "layer.name", ] list_columns = [ - "short_descr", - "created_by.id", - "created_by.first_name", - "changed_by.id", "changed_by.first_name", + "changed_by.id", "changed_on_delta_humanized", - "start_dttm", + "created_by.first_name", + "created_by.id", "end_dttm", + "long_descr", + "short_descr", + "start_dttm", ] add_columns = [ "short_descr", @@ -99,12 +100,13 @@ class AnnotationRestApi(BaseSupersetModelRestApi): edit_model_schema = AnnotationPutSchema() edit_columns = add_columns order_columns = [ - "short_descr", - "created_by.first_name", "changed_by.first_name", "changed_on_delta_humanized", - "start_dttm", + "created_by.first_name", "end_dttm", + "long_descr", + "short_descr", + "start_dttm", ] search_filters = {"short_descr": [AnnotationAllTextFilter]} diff --git a/superset/config.py b/superset/config.py index 73b5d7ab708..4a3afca0077 100644 --- a/superset/config.py +++ b/superset/config.py @@ -318,6 +318,7 @@ DEFAULT_FEATURE_FLAGS: Dict[str, bool] = { "DISPLAY_MARKDOWN_HTML": True, # When True, this escapes HTML (rather than rendering it) in Markdown components "ESCAPE_MARKDOWN_HTML": False, + "SIP_34_ANNOTATIONS_UI": False, } # Set the default view to card/grid view if thumbnail support is enabled. diff --git a/superset/views/annotations.py b/superset/views/annotations.py index 03473bd6008..463ef052688 100644 --- a/superset/views/annotations.py +++ b/superset/views/annotations.py @@ -17,12 +17,17 @@ from typing import Any, Dict from flask_appbuilder import CompactCRUDMixin +from flask_appbuilder.api import expose from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.security.decorators import has_access from flask_babel import lazy_gettext as _ from wtforms.validators import StopValidation +from superset import app from superset.constants import RouteMethod +from superset.extensions import feature_flag_manager from superset.models.annotations import Annotation, AnnotationLayer +from superset.typing import FlaskResponse from superset.views.base import SupersetModelView @@ -48,7 +53,7 @@ class AnnotationModelView( SupersetModelView, CompactCRUDMixin ): # pylint: disable=too-many-ancestors datamodel = SQLAInterface(Annotation) - include_route_methods = RouteMethod.CRUD_SET + include_route_methods = RouteMethod.CRUD_SET | {"annotation"} list_title = _("Annotations") show_title = _("Show Annotation") @@ -92,6 +97,17 @@ class AnnotationModelView( def pre_update(self, item: "AnnotationModelView") -> None: self.pre_add(item) + @expose("//annotation/", methods=["GET"]) + @has_access + def annotation(self, pk: int) -> FlaskResponse: # pylint: disable=unused-argument + 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() + class AnnotationLayerModelView(SupersetModelView): # pylint: disable=too-many-ancestors datamodel = SQLAInterface(AnnotationLayer)