feat: annotation layers modal + filters (#11494)

This commit is contained in:
Moriah Kreeger 2020-10-30 15:51:46 -07:00 committed by GitHub
parent 5d9560c408
commit 01ddbd0697
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 457 additions and 34 deletions

View File

@ -0,0 +1,89 @@
/**
* 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 AnnotationLayerModal from 'src/views/CRUD/annotationlayers/AnnotationLayerModal';
import Modal from 'src/common/components/Modal';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import { styledMount as mount } from 'spec/helpers/theming';
const mockData = { id: 1, name: 'test', descr: 'test description' };
const FETCH_ANNOTATION_LAYER_ENDPOINT = 'glob:*/api/v1/annotation_layer/*';
const ANNOTATION_LAYER_PAYLOAD = { result: mockData };
fetchMock.get(FETCH_ANNOTATION_LAYER_ENDPOINT, ANNOTATION_LAYER_PAYLOAD);
const mockStore = configureStore([thunk]);
const store = mockStore({});
const mockedProps = {
addDangerToast: () => {},
onLayerAdd: jest.fn(() => []),
onHide: () => {},
show: true,
layer: mockData,
};
async function mountAndWait(props = mockedProps) {
const mounted = mount(<AnnotationLayerModal show {...props} />, {
context: { store },
});
await waitForComponentToPaint(mounted);
return mounted;
}
describe('AnnotationLayerModal', () => {
let wrapper;
beforeAll(async () => {
wrapper = await mountAndWait();
});
it('renders', () => {
expect(wrapper.find(AnnotationLayerModal)).toExist();
});
it('renders a Modal', () => {
expect(wrapper.find(Modal)).toExist();
});
it('renders add header when no layer is included', async () => {
const addWrapper = await mountAndWait({});
expect(
addWrapper.find('[data-test="annotation-layer-modal-title"]').text(),
).toEqual('Add Annotation Layer');
});
it('renders edit header when layer prop is included', () => {
expect(
wrapper.find('[data-test="annotation-layer-modal-title"]').text(),
).toEqual('Edit Annotation Layer Properties');
});
it('renders input element for name', () => {
expect(wrapper.find('input[name="name"]')).toExist();
});
it('renders textarea element for description', () => {
expect(wrapper.find('textarea[name="descr"]')).toExist();
});
});

View File

@ -23,14 +23,15 @@ import fetchMock from 'fetch-mock';
import { styledMount as mount } from 'spec/helpers/theming';
import AnnotationLayersList from 'src/views/CRUD/annotationlayers/AnnotationLayersList';
import AnnotationLayerModal from 'src/views/CRUD/annotationlayers/AnnotationLayerModal';
import SubMenu from 'src/components/Menu/SubMenu';
import ListView from 'src/components/ListView';
// import Filters from 'src/components/ListView/Filters';
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';
import { act } from 'react-dom/test-utils';
// store needed for withToasts(AnnotationLayersList)
const mockStore = configureStore([thunk]);
@ -39,7 +40,7 @@ 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 layersRelatedEndpoint = 'glob:*/api/v1/annotation_layer/related/*';
const mocklayers = [...new Array(3)].map((_, i) => ({
changed_on_delta_humanized: `${i} day(s) ago`,
@ -63,14 +64,14 @@ fetchMock.get(layersEndpoint, {
});
/* fetchMock.delete(layerEndpoint, {});
fetchMock.delete(layersEndpoint, {});
fetchMock.delete(layersEndpoint, {}); */
fetchMock.get(layersRelatedEndpoint, {
created_by: {
count: 0,
result: [],
},
}); */
});
describe('AnnotationLayersList', () => {
const wrapper = mount(<AnnotationLayersList />, { context: { store } });
@ -91,6 +92,10 @@ describe('AnnotationLayersList', () => {
expect(wrapper.find(ListView)).toExist();
});
it('renders a modal', () => {
expect(wrapper.find(AnnotationLayerModal)).toExist();
});
it('fetches layers', () => {
const callsQ = fetchMock.calls(/annotation_layer\/\?q/);
expect(callsQ).toHaveLength(1);
@ -98,4 +103,20 @@ describe('AnnotationLayersList', () => {
`"http://localhost/api/v1/annotation_layer/?q=(order_column:name,order_direction:desc,page:0,page_size:25)"`,
);
});
it('renders Filters', () => {
expect(wrapper.find(Filters)).toExist();
});
it('searches', async () => {
const filtersWrapper = wrapper.find(Filters);
act(() => {
filtersWrapper.find('[name="name"]').first().props().onSubmit('foo');
});
await waitForComponentToPaint(wrapper);
expect(fetchMock.lastCall()[0]).toMatchInlineSnapshot(
`"http://localhost/api/v1/annotation_layer/?q=(filters:!((col:name,opr:ct,value:foo)),order_column:name,order_direction:desc,page:0,page_size:25)"`,
);
});
});

View File

@ -38,7 +38,6 @@ import {
import { ListViewError, useListViewState } from './utils';
const ListViewStyles = styled.div`
background: ${({ theme }) => theme.colors.grayscale.light5};
text-align: center;
.superset-list-view {
@ -58,6 +57,7 @@ const ListViewStyles = styled.div`
}
}
.body {
background: ${({ theme }) => theme.colors.grayscale.light5};
}
.ant-empty {

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, { FunctionComponent, useState, useEffect } from 'react';
import { styled, t } from '@superset-ui/core';
import { useSingleViewResource } from 'src/views/CRUD/hooks';
import Icon from 'src/components/Icon';
import Modal from 'src/common/components/Modal';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import { AnnotationLayerObject } from './types';
interface AnnotationLayerModalProps {
addDangerToast: (msg: string) => void;
layer?: AnnotationLayerObject | null;
onLayerAdd?: (layer?: AnnotationLayerObject) => void;
onHide: () => void;
show: boolean;
}
const StyledAnnotationLayerTitle = styled.div`
margin: ${({ theme }) => theme.gridUnit * 2}px auto
${({ theme }) => theme.gridUnit * 4}px auto;
`;
const StyledIcon = styled(Icon)`
margin: auto ${({ theme }) => theme.gridUnit * 2}px auto 0;
`;
const LayerContainer = styled.div`
margin-bottom: ${({ theme }) => theme.gridUnit * 10}px;
.control-label {
margin-bottom: ${({ theme }) => theme.gridUnit * 2}px;
}
.required {
margin-left: ${({ theme }) => theme.gridUnit / 2}px;
color: ${({ theme }) => theme.colors.error.base};
}
textarea,
input[type='text'] {
padding: ${({ theme }) => theme.gridUnit * 1.5}px
${({ theme }) => theme.gridUnit * 2}px;
border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
border-radius: ${({ theme }) => theme.gridUnit}px;
width: 50%;
}
input,
textarea {
flex: 1 1 auto;
}
textarea {
width: 100%;
height: 160px;
resize: none;
}
input::placeholder,
textarea::placeholder {
color: ${({ theme }) => theme.colors.grayscale.light1};
}
`;
const AnnotationLayerModal: FunctionComponent<AnnotationLayerModalProps> = ({
addDangerToast,
onLayerAdd,
onHide,
show,
layer = null,
}) => {
const [disableSave, setDisableSave] = useState<boolean>(true);
const [
currentLayer,
setCurrentLayer,
] = useState<AnnotationLayerObject | null>();
const [isHidden, setIsHidden] = useState<boolean>(true);
const isEditMode = layer !== null;
// annotation layer fetch logic
const {
state: { loading, resource },
fetchResource,
createResource,
updateResource,
} = useSingleViewResource<AnnotationLayerObject>(
'annotation_layer',
t('annotation_layer'),
addDangerToast,
);
// Functions
const hide = () => {
setIsHidden(true);
onHide();
};
const onSave = () => {
if (isEditMode) {
// Edit
if (currentLayer && currentLayer.id) {
const update_id = currentLayer.id;
delete currentLayer.id;
delete currentLayer.created_by;
updateResource(update_id, currentLayer).then(() => {
if (onLayerAdd) {
onLayerAdd();
}
hide();
});
}
} else if (currentLayer) {
// Create
createResource(currentLayer).then(() => {
if (onLayerAdd) {
onLayerAdd();
}
hide();
});
}
};
const onTextChange = (
event:
| React.ChangeEvent<HTMLTextAreaElement>
| React.ChangeEvent<HTMLInputElement>,
) => {
const { target } = event;
const data = {
...currentLayer,
name: currentLayer ? currentLayer.name : '',
descr: currentLayer ? currentLayer.descr : '',
};
data[target.name] = target.value;
setCurrentLayer(data);
};
const validate = () => {
if (currentLayer && currentLayer.name.length) {
setDisableSave(false);
} else {
setDisableSave(true);
}
};
// Initialize
if (
isEditMode &&
(!currentLayer ||
!currentLayer.id ||
(layer && layer.id !== currentLayer.id) ||
(isHidden && show))
) {
if (layer && layer.id !== null && !loading) {
const id = layer.id || 0;
fetchResource(id).then(() => {
setCurrentLayer(resource);
});
}
} else if (
!isEditMode &&
(!currentLayer || currentLayer.id || (isHidden && show))
) {
setCurrentLayer({
name: '',
descr: '',
});
}
// Validation
useEffect(() => {
validate();
}, [
currentLayer ? currentLayer.name : '',
currentLayer ? currentLayer.descr : '',
]);
// Show/hide
if (isHidden && show) {
setIsHidden(false);
}
return (
<Modal
disablePrimaryButton={disableSave}
onHandledPrimaryAction={onSave}
onHide={hide}
primaryButtonName={isEditMode ? t('Save') : t('Add')}
show={show}
width="55%"
title={
<h4 data-test="annotation-layer-modal-title">
{isEditMode ? (
<StyledIcon name="edit-alt" />
) : (
<StyledIcon name="plus-large" />
)}
{isEditMode
? t('Edit Annotation Layer Properties')
: t('Add Annotation Layer')}
</h4>
}
>
<StyledAnnotationLayerTitle>
<h4>{t('Basic Information')}</h4>
</StyledAnnotationLayerTitle>
<LayerContainer>
<div className="control-label">
{t('annotation layer name')}
<span className="required">*</span>
</div>
<input
name="name"
onChange={onTextChange}
type="text"
value={currentLayer?.name}
/>
</LayerContainer>
<LayerContainer>
<div className="control-label">{t('description')}</div>
<textarea
name="descr"
value={currentLayer?.descr}
placeholder={t('Description (this can be seen in the list)')}
onChange={onTextChange}
/>
</LayerContainer>
</Modal>
);
};
export default withToasts(AnnotationLayerModal);

View File

@ -17,18 +17,19 @@
* under the License.
*/
import React, { useMemo } from 'react';
// import React, { useMemo, useState } 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 { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils';
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 ListView, { Filters } from 'src/components/ListView';
import Button from 'src/components/Button';
import AnnotationLayerModal from './AnnotationLayerModal';
import { AnnotationLayerObject } from './types';
const PAGE_SIZE = 25;
const MOMENT_FORMAT = 'MMM DD, YYYY';
@ -38,23 +39,6 @@ interface AnnotationLayersListProps {
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,
@ -63,29 +47,28 @@ function AnnotationLayersList({
state: { loading, resourceCount: layersCount, resourceCollection: layers },
hasPerm,
fetchData,
// refreshData,
refreshData,
} = useListViewResource<AnnotationLayerObject>(
'annotation_layer',
t('annotation layers'),
addDangerToast,
);
// TODO: un-comment all instances when modal work begins
/* const [annotationLayerModalOpen, setAnnotationLayerModalOpen] = useState<
const [annotationLayerModalOpen, setAnnotationLayerModalOpen] = useState<
boolean
>(false);
const [
currentAnnotationLayer,
setCurrentAnnotationLayer,
] = useState<AnnotationLayerObject | null>(null); */
] = 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);
setCurrentAnnotationLayer(layer);
setAnnotationLayerModalOpen(true);
}
const initialSort = [{ id: 'name', desc: true }];
@ -215,6 +198,36 @@ function AnnotationLayersList({
});
}
const filters: Filters = useMemo(
() => [
{
Header: t('Created By'),
id: 'created_by',
input: 'select',
operator: 'rel_o_m',
unfilteredLabel: 'All',
fetchSelects: createFetchRelated(
'annotation_layer',
'created_by',
createErrorHandler(errMsg =>
t(
'An error occurred while fetching dataset datasource values: %s',
errMsg,
),
),
),
paginate: true,
},
{
Header: t('Search'),
id: 'name',
input: 'search',
operator: 'ct',
},
],
[],
);
const EmptyStateButton = (
<Button
buttonStyle="primary"
@ -236,13 +249,20 @@ function AnnotationLayersList({
return (
<>
<SubMenu name={t('Annotation Layers')} buttons={subMenuButtons} />
<AnnotationLayerModal
addDangerToast={addDangerToast}
layer={currentAnnotationLayer}
onLayerAdd={() => refreshData()}
onHide={() => setAnnotationLayerModalOpen(false)}
show={annotationLayerModalOpen}
/>
<ListView<AnnotationLayerObject>
className="annotation-layers-list-view"
columns={columns}
count={layersCount}
data={layers}
fetchData={fetchData}
// filters={filters}
filters={filters}
initialSort={initialSort}
loading={loading}
pageSize={PAGE_SIZE}

View File

@ -0,0 +1,33 @@
/**
* 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 CreatedByUser = {
id: number;
first_name: string;
last_name: string;
};
export type AnnotationLayerObject = {
id?: number;
changed_on_delta_humanized?: string;
created_on?: string;
created_by?: CreatedByUser;
changed_by?: CreatedByUser;
name: string;
descr: string;
};

View File

@ -57,6 +57,7 @@ class AnnotationLayerRestApi(BaseSupersetModelRestApi):
datamodel = SQLAInterface(AnnotationLayer)
include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
RouteMethod.RELATED,
"bulk_delete", # not using RouteMethod since locally defined
}
class_permission_name = "AnnotationLayerModelView"
@ -64,10 +65,12 @@ class AnnotationLayerRestApi(BaseSupersetModelRestApi):
allow_browser_login = True
show_columns = [
"id",
"name",
"descr",
]
list_columns = [
"id",
"name",
"descr",
"created_by.first_name",
@ -94,6 +97,7 @@ class AnnotationLayerRestApi(BaseSupersetModelRestApi):
]
search_filters = {"name": [AnnotationLayerAllTextFilter]}
allowed_rel_fields = {"created_by"}
apispec_parameter_schemas = {
"get_delete_ids_schema": get_delete_ids_schema,

View File

@ -125,6 +125,7 @@ class TestAnnotationLayerApi(SupersetTestCase):
assert rv.status_code == 200
expected_result = {
"id": annotation_layer.id,
"name": "name1",
"descr": "descr1",
}