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')} + + + + ); +}; + +export default withToasts(AnnotationLayerModal); diff --git a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx index 7fdf5824c6..6589277e97 100644 --- a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx +++ b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx @@ -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( '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(null); */ + ] = useState(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 = ( + refreshData()} + onHide={() => setAnnotationLayerModalOpen(false)} + show={annotationLayerModalOpen} + /> className="annotation-layers-list-view" columns={columns} count={layersCount} data={layers} fetchData={fetchData} - // filters={filters} + filters={filters} initialSort={initialSort} loading={loading} pageSize={PAGE_SIZE} diff --git a/superset-frontend/src/views/CRUD/annotationlayers/types.ts b/superset-frontend/src/views/CRUD/annotationlayers/types.ts new file mode 100644 index 0000000000..2e9855e980 --- /dev/null +++ b/superset-frontend/src/views/CRUD/annotationlayers/types.ts @@ -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; +}; diff --git a/superset/annotation_layers/api.py b/superset/annotation_layers/api.py index f3a6e6e67a..31fa7a0d7b 100644 --- a/superset/annotation_layers/api.py +++ b/superset/annotation_layers/api.py @@ -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, diff --git a/tests/annotation_layers/api_tests.py b/tests/annotation_layers/api_tests.py index 53658c8438..dd3be4b371 100644 --- a/tests/annotation_layers/api_tests.py +++ b/tests/annotation_layers/api_tests.py @@ -125,6 +125,7 @@ class TestAnnotationLayerApi(SupersetTestCase): assert rv.status_code == 200 expected_result = { + "id": annotation_layer.id, "name": "name1", "descr": "descr1", }