From 01ddbd0697a7ba7a1f7ba2032880b2df8b527f96 Mon Sep 17 00:00:00 2001 From: Moriah Kreeger Date: Fri, 30 Oct 2020 15:51:46 -0700 Subject: [PATCH] feat: annotation layers modal + filters (#11494) --- .../AnnotationLayerModal_spec.jsx | 89 ++++++ .../AnnotationLayersList_spec.jsx | 31 ++- .../src/components/ListView/ListView.tsx | 2 +- .../annotationlayers/AnnotationLayerModal.tsx | 255 ++++++++++++++++++ .../annotationlayers/AnnotationLayersList.tsx | 76 ++++-- .../src/views/CRUD/annotationlayers/types.ts | 33 +++ superset/annotation_layers/api.py | 4 + tests/annotation_layers/api_tests.py | 1 + 8 files changed, 457 insertions(+), 34 deletions(-) create mode 100644 superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayerModal_spec.jsx create mode 100644 superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayerModal.tsx create mode 100644 superset-frontend/src/views/CRUD/annotationlayers/types.ts diff --git a/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayerModal_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayerModal_spec.jsx new file mode 100644 index 0000000000..ed927bf5df --- /dev/null +++ b/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayerModal_spec.jsx @@ -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(, { + 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(); + }); +}); diff --git a/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayersList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayersList_spec.jsx index 4ff3871cca..c224bd2e86 100644 --- a/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayersList_spec.jsx +++ b/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayersList_spec.jsx @@ -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(, { 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)"`, + ); + }); }); diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx index 3c33e4c720..5f1f6fc521 100644 --- a/superset-frontend/src/components/ListView/ListView.tsx +++ b/superset-frontend/src/components/ListView/ListView.tsx @@ -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 { diff --git a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayerModal.tsx b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayerModal.tsx new file mode 100644 index 0000000000..77d99d29c8 --- /dev/null +++ b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayerModal.tsx @@ -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 = ({ + addDangerToast, + onLayerAdd, + onHide, + show, + layer = null, +}) => { + const [disableSave, setDisableSave] = useState(true); + const [ + currentLayer, + setCurrentLayer, + ] = useState(); + const [isHidden, setIsHidden] = useState(true); + const isEditMode = layer !== null; + + // annotation layer fetch logic + const { + state: { loading, resource }, + fetchResource, + createResource, + updateResource, + } = useSingleViewResource( + '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 + | React.ChangeEvent, + ) => { + 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 ( + + {isEditMode ? ( + + ) : ( + + )} + {isEditMode + ? t('Edit Annotation Layer Properties') + : t('Add Annotation Layer')} + + } + > + +

{t('Basic Information')}

+
+ +
+ {t('annotation layer name')} + * +
+ +
+ +
{t('description')}
+