feat: annotations list CRUD view (#11446)

* annotations list CRUD view

* comment out modal

* update test

* fix lint
This commit is contained in:
Lily Kuang
2020-10-28 12:19:50 -07:00
committed by GitHub
parent d4d547c30a
commit e5e35634de
8 changed files with 381 additions and 10 deletions

View File

@@ -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(<AnnotationList {...props} />, {
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)"`,
);
});
});

View File

@@ -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 = () => (
<CssTemplatesList user={user} />
</ErrorBoundary>
</Route>
<Route path="/annotationmodelview/:annotationLayerId/annotation/">
<ErrorBoundary>
<AnnotationList user={user} />
</ErrorBoundary>
</Route>
</Switch>
<ToastPresenter />
</QueryParamProvider>

View File

@@ -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<AnnotationObject>(
`annotation_layer/${annotationLayerId}/annotation`,
t('annotation'),
addDangerToast,
false,
);
// const [annotationModalOpen, setAnnotationModalOpen] = useState<boolean>(
// false,
// );
const [annotationLayerName, setAnnotationLayerName] = useState<string>('');
// const [
// currentAnnotation,
// setCurrentAnnotation,
// ] = useState<AnnotationObject | null>(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 <ActionsBar actions={actions as ActionProps[]} />;
},
Header: t('Actions'),
id: 'actions',
disableSortBy: true,
},
],
[true, true],
);
const subMenuButtons: SubMenuProps['buttons'] = [];
subMenuButtons.push({
name: (
<>
<i className="fa fa-plus" /> {t('Annotation')}
</>
),
buttonStyle: 'primary',
onClick: () => {
// setCurrentAnnotation(null);
// setAnnotationModalOpen(true);
},
});
return (
<>
<SubMenu
name={t(`Annotation Layer ${annotationLayerName}`)}
buttons={subMenuButtons}
/>
{/* <AnnotationModal
addDangerToast={addDangerToast}
annotation={currentAnnotation}
show={annotationModalOpen}
onAnnotationAdd={() => refreshData()}
annnotationLayerId={annotationLayerId}
onHide={() => setAnnotationModalOpen(false)}
/> */}
<ListView<AnnotationObject>
className="css-templates-list-view"
columns={columns}
count={annotationsCount}
data={annotations}
fetchData={fetchData}
initialSort={initialSort}
loading={loading}
pageSize={PAGE_SIZE}
/>
</>
);
}
export default withToasts(AnnotationList);

View File

@@ -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;
};

View File

@@ -37,6 +37,7 @@ export function useListViewResource<D extends object = any>(
resource: string,
resourceLabel: string, // resourceLabel for translations
handleErrorMsg: (errorMsg: string) => void,
infoEnable = true,
) {
const [state, setState] = useState<ListViewResourceState<D>>({
count: 0,
@@ -56,8 +57,9 @@ export function useListViewResource<D extends object = any>(
}
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({

View File

@@ -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]}

View File

@@ -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.

View File

@@ -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("/<pk>/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)