mirror of
https://github.com/apache/superset.git
synced 2026-04-07 18:35:15 +00:00
feat: annotations list CRUD view (#11446)
* annotations list CRUD view * comment out modal * update test * fix lint
This commit is contained in:
@@ -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)"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
195
superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx
Normal file
195
superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx
Normal 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);
|
||||
36
superset-frontend/src/views/CRUD/annotation/types.ts
Normal file
36
superset-frontend/src/views/CRUD/annotation/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user