feat: annotation layers CRUD list view (#11432)

This commit is contained in:
Moriah Kreeger 2020-10-28 15:45:07 -07:00 committed by GitHub
parent 52294c836a
commit e9dba18466
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 442 additions and 17 deletions

View File

@ -0,0 +1,22 @@
<!--
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.
-->
<svg width="119" height="76" viewBox="0 0 119 76" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M83.1952 1.36598L103 24V62C103 64.2091 101.209 66 99 66H20C17.7909 66 16 64.2091 16 62V24L35.8048 1.36598C36.5643 0.497921 37.6616 0 38.8151 0H80.1849C81.3384 0 82.4357 0.497922 83.1952 1.36598ZM101 26V62C101 63.1046 100.105 64 99 64H20C18.8954 64 18 63.1046 18 62V26H35.25C37.8734 26 40 28.1266 40 30.75C40 34.4779 43.0221 37.5 46.75 37.5H72.25C75.9779 37.5 79 34.4779 79 30.75C79 28.1266 81.1266 26 83.75 26H101ZM100.342 24L81.6901 2.68299C81.3103 2.24896 80.7617 2 80.1849 2H38.8151C38.2383 2 37.6897 2.24896 37.3099 2.68299L18.6575 24H35.25C38.9779 24 42 27.0221 42 30.75C42 33.3734 44.1266 35.5 46.75 35.5H72.25C74.8734 35.5 77 33.3734 77 30.75C77 27.0221 80.0221 24 83.75 24H100.342Z" fill="#D1D1D1"/>
<path d="M16 53.2891C6.07439 55.7012 0 58.9396 0 62.4999C0 69.9557 26.6391 75.9999 59.5 75.9999C92.3609 75.9999 119 69.9557 119 62.4999C119 58.9396 112.926 55.7012 103 53.2891V61.9999C103 64.209 101.209 65.9999 99 65.9999H20C17.7909 65.9999 16 64.209 16 61.9999V53.2891Z" fill="#F2F2F2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

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

View File

@ -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<T extends object = any> {
cardSortSelectOptions?: Array<CardSortSelectOption>;
defaultViewMode?: ViewModeType;
highlightRowId?: number;
emptyState?: {
message?: string;
slot?: React.ReactNode;
};
}
function ListView<T extends object = any>({
@ -229,6 +243,7 @@ function ListView<T extends object = any>({
cardSortSelectOptions,
defaultViewMode = 'card',
highlightRowId,
emptyState = {},
}: ListViewProps<T>) {
const {
getTableProps,
@ -368,29 +383,36 @@ function ListView<T extends object = any>({
)}
{!loading && rows.length === 0 && (
<EmptyWrapper>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
<Empty
image={<EmptyImage />}
description={emptyState.message || 'No Data'}
>
{emptyState.slot || null}
</Empty>
</EmptyWrapper>
)}
</div>
</div>
<div className="pagination-container">
<Pagination
totalPages={pageCount || 0}
currentPage={pageCount ? pageIndex + 1 : 0}
onChange={(p: number) => gotoPage(p - 1)}
hideFirstAndLastPageLinks
/>
<div className="row-count-container">
{!loading &&
t(
'%s-%s of %s',
pageSize * pageIndex + (rows.length && 1),
pageSize * pageIndex + rows.length,
count,
)}
{rows.length > 0 && (
<div className="pagination-container">
<Pagination
totalPages={pageCount || 0}
currentPage={pageCount ? pageIndex + 1 : 0}
onChange={(p: number) => gotoPage(p - 1)}
hideFirstAndLastPageLinks
/>
<div className="row-count-container">
{!loading &&
t(
'%s-%s of %s',
pageSize * pageIndex + (rows.length && 1),
pageSize * pageIndex + rows.length,
count,
)}
</div>
</div>
</div>
)}
</ListViewStyles>
);
}

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 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 = () => (
<CssTemplatesList user={user} />
</ErrorBoundary>
</Route>
<Route path="/annotationlayermodelview/list/">
<ErrorBoundary>
<AnnotationLayersList user={user} />
</ErrorBoundary>
</Route>
<Route path="/annotationmodelview/:annotationLayerId/annotation/">
<ErrorBoundary>
<AnnotationList user={user} />

View File

@ -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<AnnotationLayerObject>(
'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<AnnotationLayerObject | null>(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 <ActionsBar actions={actions as ActionProps[]} />;
},
Header: t('Actions'),
id: 'actions',
disableSortBy: true,
hidden: !canEdit && !canDelete,
size: 'xl',
},
],
[canDelete, canCreate],
);
const subMenuButtons: SubMenuProps['buttons'] = [];
if (canCreate) {
subMenuButtons.push({
name: (
<>
<i className="fa fa-plus" /> {t('Annotation Layer')}
</>
),
buttonStyle: 'primary',
onClick: () => {
handleAnnotationLayerEdit(null);
},
});
}
const EmptyStateButton = (
<Button
buttonStyle="primary"
onClick={() => {
handleAnnotationLayerEdit(null);
}}
>
<>
<i className="fa fa-plus" /> {t('Annotation Layer')}
</>
</Button>
);
const emptyState = {
message: 'No annotation layers yet',
slot: EmptyStateButton,
};
return (
<>
<SubMenu name={t('Annotation Layers')} buttons={subMenuButtons} />
<ListView<AnnotationLayerObject>
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);

View File

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

View File

@ -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()

View File

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