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)