From d01554dbdeccc801a537286a3722321de2581915 Mon Sep 17 00:00:00 2001 From: Linus Pahl Date: Thu, 18 Mar 2021 15:06:29 +0100 Subject: [PATCH] Display aggregation builder in widget focus mode instead of modal. (#10212) * Transforming `WidgetGrid` to function component. * Creating separate component for `WidgetContainer` and using it in `WidgetComponent` instead of `WidgetGrid`. * Add `className` prop for `ReactGridContainer` to be able to style the component with styled-components `styled()`. * Removing `WidgetContainer` from `WidgetComponent` because `WidgetContainer` needs to be a direct child of the `ReactGridLayout`. * Do not replace `WidgetGrid` with widget on widget focus. Instead only render focused widget in widget grid to avoid unmoiunting the widget. This way the focused widget keeps its state on focus. * Implementing `useMemo` for `WidgetGrid`. * Preventing widget resize when widget is focused. * Disabling `WidgetGrid` animation. * Do not unmount any widget on widget focus. Previously on widget focus we unmounted every other widget. With this change we are only changing the style of the focused widget, to ensure we do not loose the state and e.g. the scroll position of other widgets. * Maintaining widget edit status in `WidgetFocusContext`. * Displaying widget edit component inside `Widget` component instead of a modal. * Removing console.logs * Fixing widget toggle edit. * Unifying `WidgetFrame` container paddings with `SearchBar` container paddings. * Removing remaining modal component from `EditWidgetFrame`. * Removing no longer needed widget edit code in `Widget`. * Fixing relation between widget edit and focus mode. * Replacing `EditWidgetFrame.css` with styled components. * Creating separate component for widget actions menu. * Simplifying `WIdgetFocusProvider` logic by creating state based on query params. * Cleanup url if focus query params are not current. * Adding padding for `QueryControls`. * Fixing `WidgetFocusProvider` state query params sync. * Fixing copy to dashboard and move to page widget action. * Fixing and simplifying `WidgetFocusProvider` updates. * Updating and extending `WidgetFocusProvider.test`. * Creating test for `WidgetActionsMenu`. * Removing no longer needed `Widget` tests. * Updating `Widget.test`. * Updating `WidgetActionsMenu`. * Crating wigdet focus tests for `Widget`. * Creating widget focus test for `WidgetActionsMenu`. * Fixing `ExtraWidgetActions.test`. * Fixing naming. * Removing no longer needed `Widget` editing state. * Hiding `WidgetActionsMenu` on widget edit. * Updating type definition in `Widget.test`. * Removing not needed `jest.clearAllMocks` in `WidgetFocusProvider.test`. * Refactoring redundant code in `WidgetFocusProvider`. * Creating more explicit types for `WidgetFocusContext` and `WidgetFocusProvider`. * Removing no longer needed test setup code in `WidgetActionsMenu.test`. * Simplifying `_updateDashboardWithNewSearch` usage in `WidgetActionsMenu`. * Implement separate functions for `WidgetFocusContext` to unset widget focussing and widget editing. * Fixing failing `Widget.test`. --- .../src/views/components/Query.test.jsx | 2 +- .../src/views/components/Search.tsx | 4 +- .../src/views/components/SearchResult.tsx | 2 +- .../src/views/components/WidgetComponent.tsx | 28 +- .../src/views/components/WidgetGrid.jsx | 16 +- .../views/components/WidgetQueryControls.tsx | 92 +++--- .../views/components/actions/FieldActions.tsx | 4 +- .../views/components/actions/ValueActions.tsx | 4 +- .../contexts/WidgetFocusContext.tsx | 29 +- .../contexts/WidgetFocusProvider.test.tsx | 140 ++++++-- .../contexts/WidgetFocusProvider.tsx | 131 ++++++-- .../dashboard/BigDisplayModeConfiguration.tsx | 8 +- .../components/widgets/EditWidgetFrame.css | 49 --- .../components/widgets/EditWidgetFrame.tsx | 111 ++++--- .../widgets/ExtraWidgetActions.test.tsx | 3 +- .../widgets/SaveOrCancelButtons.tsx | 6 +- .../views/components/widgets/Widget.test.tsx | 303 ++++++------------ .../src/views/components/widgets/Widget.tsx | 284 ++++------------ .../widgets/WidgetActionsMenu.test.tsx | 282 ++++++++++++++++ .../components/widgets/WidgetActionsMenu.tsx | 233 ++++++++++++++ .../views/components/widgets/WidgetFrame.jsx | 2 +- .../__snapshots__/Widget.test.tsx.snap | 220 ------------- 22 files changed, 1060 insertions(+), 893 deletions(-) delete mode 100644 graylog2-web-interface/src/views/components/widgets/EditWidgetFrame.css create mode 100644 graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.test.tsx create mode 100644 graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.tsx delete mode 100644 graylog2-web-interface/src/views/components/widgets/__snapshots__/Widget.test.tsx.snap diff --git a/graylog2-web-interface/src/views/components/Query.test.jsx b/graylog2-web-interface/src/views/components/Query.test.jsx index 98cfcde522c6..95add2b4895a 100644 --- a/graylog2-web-interface/src/views/components/Query.test.jsx +++ b/graylog2-web-interface/src/views/components/Query.test.jsx @@ -50,7 +50,7 @@ const widgets = Immutable.Map({ widget1, widget2 }); describe('Query', () => { const SUT = (props) => ( - {} }}> + {}, setWidgetEditing: () => {} }}> { const { focusedWidget } = useContext(WidgetFocusContext); return css` - ${focusedWidget && css` + ${focusedWidget?.id && css` .page-content-grid { display: flex; flex-direction: column; @@ -85,7 +85,7 @@ const SearchArea = styled(PageContentLayout)(() => { width: 100%; /* overflow auto is required to display the message table widget height correctly */ - overflow: ${focusedWidget ? 'auto' : 'visible'}; + overflow: ${focusedWidget?.id ? 'auto' : 'visible'}; } `} `; diff --git a/graylog2-web-interface/src/views/components/SearchResult.tsx b/graylog2-web-interface/src/views/components/SearchResult.tsx index 05ac707f93d6..c88710f96d8c 100644 --- a/graylog2-web-interface/src/views/components/SearchResult.tsx +++ b/graylog2-web-interface/src/views/components/SearchResult.tsx @@ -77,7 +77,7 @@ const SearchResult = React.memo(({ queryId, searches, viewState }: Props) => { const results = searches && searches.result; const widgetMapping = searches && searches.widgetMapping; - const hasFocusedWidget = !!focusedWidget; + const hasFocusedWidget = !!focusedWidget?.id; const currentResults = results ? results.forId(queryId) : undefined; const allFields = fieldTypes.all; diff --git a/graylog2-web-interface/src/views/components/WidgetComponent.tsx b/graylog2-web-interface/src/views/components/WidgetComponent.tsx index ff3421c284c6..cd6834706e6d 100644 --- a/graylog2-web-interface/src/views/components/WidgetComponent.tsx +++ b/graylog2-web-interface/src/views/components/WidgetComponent.tsx @@ -31,6 +31,7 @@ import DrilldownContextProvider from './contexts/DrilldownContextProvider'; type Props = { data: WidgetDataMap, + editing: boolean, errors: WidgetErrorsMap, fields: Immutable.List, onPositionsChange: (position?: WidgetPosition) => void, @@ -43,6 +44,7 @@ type Props = { const WidgetComponent = ({ data, + editing, errors, fields, onPositionsChange = () => undefined, @@ -61,17 +63,18 @@ const WidgetComponent = ({ - + position={position} + title={title} + widget={widget} + width={width} /> @@ -80,15 +83,16 @@ const WidgetComponent = ({ }; WidgetComponent.propTypes = { - widget: PropTypes.object.isRequired, data: PropTypes.object.isRequired, + editing: PropTypes.bool.isRequired, errors: PropTypes.object.isRequired, - widgetDimension: PropTypes.object.isRequired, - title: PropTypes.string.isRequired, - position: PropTypes.object.isRequired, - onPositionsChange: PropTypes.func, fields: PropTypes.object.isRequired, + onPositionsChange: PropTypes.func, onWidgetSizeChange: PropTypes.func, + position: PropTypes.object.isRequired, + title: PropTypes.string.isRequired, + widget: PropTypes.object.isRequired, + widgetDimension: PropTypes.object.isRequired, }; WidgetComponent.defaultProps = { diff --git a/graylog2-web-interface/src/views/components/WidgetGrid.jsx b/graylog2-web-interface/src/views/components/WidgetGrid.jsx index 1af30c7a155a..5dfe55eccba2 100644 --- a/graylog2-web-interface/src/views/components/WidgetGrid.jsx +++ b/graylog2-web-interface/src/views/components/WidgetGrid.jsx @@ -43,10 +43,10 @@ const DashboardWrap = styled.div(({ theme }) => css` height: 100%; `); -const StyledReactGridContainer = styled(ReactGridContainer)(({ focusedWidget }) => ` - height: ${focusedWidget ? '100% !important' : '100%'}; - max-height: ${focusedWidget ? '100%' : 'auto'}; - overflow: ${focusedWidget ? 'hidden' : 'visible'}; +const StyledReactGridContainer = styled(ReactGridContainer)(({ hasFocusedWidget }) => css` + height: ${hasFocusedWidget ? '100% !important' : '100%'}; + max-height: ${hasFocusedWidget ? '100%' : 'auto'}; + overflow: ${hasFocusedWidget ? 'hidden' : 'visible'}; transition: none; `); @@ -95,12 +95,14 @@ const _renderWidgets = ({ const widget = widgets[widgetId]; returnedWidgets.positions[widgetId] = positions[widgetId] || _defaultDimensions(widget.type); const widgetTitle = titles.getIn([TitleTypes.Widget, widget.id], defaultTitle(widget)); - const isFocused = focusedWidget === widgetId; + const isFocused = focusedWidget?.id === widgetId && focusedWidget?.focusing; + const editing = focusedWidget?.id === widgetId && focusedWidget?.editing; returnedWidgets.widgets.push( 0 ? ( - { <> {isGloballyOverridden && } - <> - - - setFieldValue('timerange', nextTimeRange)} - value={values?.timerange} - hasErrorOnMount={!isValid} /> - - - - - {({ field: { name, value, onChange } }) => ( - onChange({ target: { value: newStreams, name } })} /> - )} - - - - - - -
- } /> -
- - - - {({ field: { name, value, onChange } }) => ( - { - onChange({ target: { value: newQuery, name } }); - - return Promise.resolve(newQuery); - }} - onExecute={handleSubmit as () => void} /> - )} - - -
- + + + setFieldValue('timerange', nextTimeRange)} + value={values?.timerange} + hasErrorOnMount={!isValid} /> + + + + + {({ field: { name, value, onChange } }) => ( + onChange({ target: { value: newStreams, name } })} /> + )} + + + + + + +
+ } /> +
+ + + + {({ field: { name, value, onChange } }) => ( + { + onChange({ target: { value: newQuery, name } }); + + return Promise.resolve(newQuery); + }} + onExecute={handleSubmit as () => void} /> + )} + + +
); diff --git a/graylog2-web-interface/src/views/components/actions/FieldActions.tsx b/graylog2-web-interface/src/views/components/actions/FieldActions.tsx index fb77032a7974..81844ba71d75 100644 --- a/graylog2-web-interface/src/views/components/actions/FieldActions.tsx +++ b/graylog2-web-interface/src/views/components/actions/FieldActions.tsx @@ -55,7 +55,7 @@ const FieldElement = styled.span.attrs({ const FieldActions = ({ children, disabled, element, menuContainer, name, type, queryId }: Props) => { const actionContext = useContext(ActionContext); - const { setFocusedWidget } = useContext(WidgetFocusContext); + const { setWidgetFocusing } = useContext(WidgetFocusContext); const allFieldActions = usePluginEntities('fieldActions'); const [open, setOpen] = useState(false); @@ -82,7 +82,7 @@ const FieldActions = ({ children, disabled, element, menuContainer, name, type, const { resetFocus = false } = action; if (resetFocus) { - setFocusedWidget(undefined); + setWidgetFocusing(undefined); } _onMenuToggle(); diff --git a/graylog2-web-interface/src/views/components/actions/ValueActions.tsx b/graylog2-web-interface/src/views/components/actions/ValueActions.tsx index 3ad728f7c220..826d575f9652 100644 --- a/graylog2-web-interface/src/views/components/actions/ValueActions.tsx +++ b/graylog2-web-interface/src/views/components/actions/ValueActions.tsx @@ -44,7 +44,7 @@ type Props = { const ValueActions = ({ children, element, field, menuContainer, queryId, type, value }: Props) => { const actionContext = useContext(ActionContext); - const { setFocusedWidget } = useContext(WidgetFocusContext); + const { unsetWidgetFocusing } = useContext(WidgetFocusContext); const [open, setOpen] = useState(false); const [overflowingComponents, setOverflowingComponents] = useState({}); @@ -68,7 +68,7 @@ const ValueActions = ({ children, element, field, menuContainer, queryId, type, const { resetFocus } = action; if (resetFocus) { - setFocusedWidget(undefined); + unsetWidgetFocusing(); } _onMenuToggle(); diff --git a/graylog2-web-interface/src/views/components/contexts/WidgetFocusContext.tsx b/graylog2-web-interface/src/views/components/contexts/WidgetFocusContext.tsx index 8aa4c518ee39..33e9df40b852 100644 --- a/graylog2-web-interface/src/views/components/contexts/WidgetFocusContext.tsx +++ b/graylog2-web-interface/src/views/components/contexts/WidgetFocusContext.tsx @@ -18,12 +18,35 @@ import * as React from 'react'; import { singleton } from 'views/logic/singleton'; +export type WidgetFocusingState = { + id: string, + editing: false, + focusing: true, +} + +export type WidgetEditingState = { + id: string, + editing: true, + focusing: true, +} + +export type FocusContextState = WidgetFocusingState | WidgetEditingState; + export type WidgetFocusContextType = { - focusedWidget: string | undefined | null, - setFocusedWidget: (focusedWidget: string | undefined | null) => void, + focusedWidget: FocusContextState | undefined, + setWidgetFocusing: (widgetId: string) => void, + setWidgetEditing: (widgetId: string) => void, + unsetWidgetFocusing: () => void, + unsetWidgetEditing: () => void, }; -const defaultContext = { focusedWidget: undefined, setFocusedWidget: () => {} }; +const defaultContext = { + focusedWidget: undefined, + setWidgetFocusing: () => {}, + setWidgetEditing: () => {}, + unsetWidgetFocusing: () => {}, + unsetWidgetEditing: () => {}, +}; const WidgetFocus = React.createContext(defaultContext); diff --git a/graylog2-web-interface/src/views/components/contexts/WidgetFocusProvider.test.tsx b/graylog2-web-interface/src/views/components/contexts/WidgetFocusProvider.test.tsx index 3c1a81de43cb..b80809466d35 100644 --- a/graylog2-web-interface/src/views/components/contexts/WidgetFocusProvider.test.tsx +++ b/graylog2-web-interface/src/views/components/contexts/WidgetFocusProvider.test.tsx @@ -16,15 +16,13 @@ */ import * as React from 'react'; import { Map } from 'immutable'; -import { useContext } from 'react'; -import { render, screen, fireEvent, waitFor } from 'wrappedTestingLibrary'; +import { render } from 'wrappedTestingLibrary'; import { useLocation } from 'react-router-dom'; import { asMock } from 'helpers/mocking'; import { WidgetStore } from 'views/stores/WidgetStore'; import WidgetFocusProvider from 'views/components/contexts/WidgetFocusProvider'; import WidgetFocusContext from 'views/components/contexts/WidgetFocusContext'; -import Widget from 'views/logic/widgets/Widget'; const mockHistoryReplace = jest.fn(); @@ -35,68 +33,146 @@ jest.mock('react-router-dom', () => ({ }), useLocation: jest.fn(() => ({ pathname: '', - search: '?focused=clack', + search: '', })), })); jest.mock('views/stores/WidgetStore', () => ({ WidgetStore: { - getInitialState: jest.fn(() => ({ has: jest.fn(() => true) })), + getInitialState: jest.fn(() => ({ has: jest.fn((widgetId) => widgetId === 'widget-id') })), listen: jest.fn(), }, })); describe('WidgetFocusProvider', () => { - const renderSUT = () => { - const Consumer = () => { - const { setFocusedWidget, focusedWidget } = useContext(WidgetFocusContext); - - return ( - <> - -
{focusedWidget || 'No focus widget set'}
- - ); - }; + beforeEach(() => { + useLocation.mockReturnValue({ + pathname: '', + search: '', + }); + }); + const renderSUT = (consume) => { render( - + + {consume} + , ); }; - it('should update url', async () => { - renderSUT(); - const button = await screen.getByText('Click'); - fireEvent.click(button); + it('should update url on widget focus', () => { + let contextValue; + const consume = (value) => { contextValue = value; }; + + renderSUT(consume); + + contextValue.setWidgetFocusing('widget-id'); + + expect(mockHistoryReplace).toBeCalledWith('?focusedId=widget-id&focusing=true'); + }); + + it('should update url on widget focus close', () => { + useLocation.mockReturnValueOnce({ + pathname: '', + search: '?focusedId=widget-id&focusing=true', + }); + + let contextValue; + const consume = (value) => { contextValue = value; }; + renderSUT(consume); + + contextValue.unsetWidgetFocusing(); + + expect(mockHistoryReplace).toBeCalledWith(''); + }); + + it('should set widget focus based on url', () => { + useLocation.mockReturnValue({ + pathname: '', + search: '?focusedId=widget-id&focusing=true', + }); + + let contextValue; + const consume = (value) => { contextValue = value; }; + renderSUT(consume); + + expect(contextValue.focusedWidget).toEqual({ id: 'widget-id', focusing: true, editing: false }); + }); + + it('should update url on widget edit', () => { + let contextValue; + const consume = (value) => { contextValue = value; }; + renderSUT(consume); + + contextValue.setWidgetEditing('widget-id'); + + expect(mockHistoryReplace).toBeCalledWith('?focusedId=widget-id&editing=true'); + }); - await waitFor(() => { - expect(mockHistoryReplace).toBeCalledWith('?focused=click'); + it('should update url on widget edit close', () => { + useLocation.mockReturnValue({ + pathname: '', + search: '?focusedId=widget-id&editing=true', }); + + let contextValue; + const consume = (value) => { contextValue = value; }; + + renderSUT(consume); + + contextValue.unsetWidgetEditing(); + + expect(mockHistoryReplace).toBeCalledWith(''); }); - it('should set focused widget from url', async () => { - asMock(WidgetStore.getInitialState).mockReturnValue(Map({ clack: Widget.builder().build() })); + it('should set widget edit and focused based on url', () => { + useLocation.mockReturnValue({ + pathname: '', + search: '?focusedId=widget-id&editing=true', + }); + + let contextValue; + const consume = (value) => { contextValue = value; }; + renderSUT(consume); + expect(contextValue.focusedWidget).toEqual({ id: 'widget-id', editing: true, focusing: true }); + }); + + it('should not remove focus query param on widget edit', () => { useLocation.mockReturnValue({ pathname: '', - search: 'focused=clack', + search: '?focusedId=widget-id&focusing=true', }); - renderSUT(); - await screen.findByText('clack'); + let contextValue; + const consume = (value) => { contextValue = value; }; + renderSUT(consume); + + contextValue.setWidgetEditing('widget-id'); + + expect(mockHistoryReplace).toBeCalledWith('?focusedId=widget-id&focusing=true&editing=true'); + + contextValue.unsetWidgetEditing(); + + expect(mockHistoryReplace).toBeCalledWith('?focusedId=widget-id&focusing=true'); }); - it('should not set focused widget from url if the widget does not exist', async () => { + it('should not set focused widget from url and cleanup url if the widget does not exist', () => { asMock(WidgetStore.getInitialState).mockReturnValue(Map()); useLocation.mockReturnValue({ pathname: '', - search: 'focused=clack', + search: '?focusedId=not-existing-widget-id', }); - renderSUT(); - await screen.findByText('No focus widget set'); + let contextValue; + const consume = (value) => { contextValue = value; }; + renderSUT(consume); + + expect(contextValue.focusedWidget).toBe(undefined); + + expect(mockHistoryReplace).toBeCalledWith(''); }); }); diff --git a/graylog2-web-interface/src/views/components/contexts/WidgetFocusProvider.tsx b/graylog2-web-interface/src/views/components/contexts/WidgetFocusProvider.tsx index 95c5b852e398..e6af4dede31e 100644 --- a/graylog2-web-interface/src/views/components/contexts/WidgetFocusProvider.tsx +++ b/graylog2-web-interface/src/views/components/contexts/WidgetFocusProvider.tsx @@ -15,7 +15,8 @@ * . */ import * as React from 'react'; -import { useState, useEffect, useCallback } from 'react'; +import { isEqual } from 'lodash'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useLocation, useHistory } from 'react-router-dom'; import PropTypes from 'prop-types'; import URI from 'urijs'; @@ -23,59 +24,133 @@ import URI from 'urijs'; import { useStore } from 'stores/connect'; import useQuery from 'routing/useQuery'; import { WidgetStore } from 'views/stores/WidgetStore'; -import WidgetFocusContext from 'views/components/contexts/WidgetFocusContext'; -const _syncWithQuery = (query: string, focusedWidget: string) => { - const baseUri = new URI(query) - .removeSearch('focused'); +import WidgetFocusContext, { FocusContextState } from './WidgetFocusContext'; - if (focusedWidget) { - return baseUri.setSearch('focused', focusedWidget).toString(); +type WidgetFocusRequest = { + id: string, + editing: false, + focusing: true, +} + +type WidgetEditRequest = { + id: string, + editing: boolean, + focusing: boolean, +} + +const _clearURI = (query) => new URI(query) + .removeSearch('focusing') + .removeSearch('editing') + .removeSearch('focusedId'); + +const _updateQueryParams = ( + newQueryParams: WidgetFocusRequest | WidgetEditRequest | undefined, + query: string, +) => { + let baseUri = _clearURI(query); + + if (newQueryParams) { + if (newQueryParams.id && (newQueryParams.focusing || newQueryParams.editing)) { + baseUri = baseUri.setSearch('focusedId', newQueryParams.id); + } + + if (newQueryParams.focusing) { + baseUri = baseUri.setSearch('focusing', true); + } + + if (newQueryParams.editing) { + baseUri = baseUri.setSearch('editing', true); + } } return baseUri.toString(); }; -const useFocusWidgetIdFromParam = (focusedWidget, setFocusedWidget, widgets) => { - const { focused: paramFocusedWidget } = useQuery(); - +const useSyncStateWithQueryParams = ({ focusedWidget, focusUriParams, setFocusedWidget, widgets }) => { useEffect(() => { - if (focusedWidget !== paramFocusedWidget) { - if (paramFocusedWidget && !widgets.has(paramFocusedWidget)) { + const nextFocusedWidget = { + id: focusUriParams.id, + editing: focusUriParams.editing, + focusing: focusUriParams.focusing || focusUriParams.editing, + }; + + if (!isEqual(focusedWidget, nextFocusedWidget)) { + if (focusUriParams.id && !widgets.has(focusUriParams.id)) { return; } - setFocusedWidget(paramFocusedWidget); + setFocusedWidget(nextFocusedWidget); } - }, [focusedWidget, paramFocusedWidget, setFocusedWidget, widgets]); + }, [focusedWidget, setFocusedWidget, widgets, focusUriParams]); +}; + +const useCleanupQueryParams = ({ focusUriParams, widgets, query, history }) => { + useEffect(() => { + if ((focusUriParams?.id && !widgets.has(focusUriParams.id)) || (focusUriParams?.id === undefined)) { + const baseURI = _clearURI(query); + + history.replace(baseURI.toString()); + } + }, [focusUriParams, widgets, query, history]); }; const WidgetFocusProvider = ({ children }: { children: React.ReactNode }): React.ReactElement => { const { search, pathname } = useLocation(); const query = pathname + search; const history = useHistory(); - const [focusedWidget, setFocusedWidget] = useState(); + const [focusedWidget, setFocusedWidget] = useState(); const widgets = useStore(WidgetStore); + const params = useQuery(); + const focusUriParams = useMemo(() => ({ + editing: params.editing === 'true', + focusing: params.focusing === 'true', + id: params.focusedId, + }), [params.editing, params.focusing, params.focusedId]); - useFocusWidgetIdFromParam(focusedWidget, setFocusedWidget, widgets); + useSyncStateWithQueryParams({ focusedWidget, setFocusedWidget, widgets, focusUriParams }); - useEffect(() => { - if (focusedWidget && !widgets.has(focusedWidget)) { - setFocusedWidget(undefined); - } - }, [focusedWidget, widgets]); + useCleanupQueryParams({ focusUriParams, widgets, query, history }); - const updateFocus = useCallback((widgetId: string | undefined | null) => { - const newFocus = widgetId === focusedWidget - ? undefined - : widgetId; - const newURI = _syncWithQuery(query, newFocus); + const updateFocusQueryParams = useCallback((newQueryParams: WidgetFocusRequest | WidgetEditRequest | undefined) => { + const newURI = _updateQueryParams( + newQueryParams, + query, + ); history.replace(newURI); - }, [focusedWidget, history, query]); + }, [history, query]); + + const setWidgetFocusing = (widgetId: string) => updateFocusQueryParams({ + id: widgetId, + editing: false, + focusing: true, + }); + + const unsetWidgetFocusing = () => updateFocusQueryParams(undefined); + + const setWidgetEditing = (widgetId: string) => { + updateFocusQueryParams({ + id: widgetId, + editing: true, + focusing: focusUriParams.focusing, + }); + }; + + const unsetWidgetEditing = () => updateFocusQueryParams({ + id: focusUriParams.focusing && focusUriParams.id ? focusUriParams.id : undefined, + editing: false, + focusing: focusUriParams.focusing, + }); return ( - + {children} ); diff --git a/graylog2-web-interface/src/views/components/dashboard/BigDisplayModeConfiguration.tsx b/graylog2-web-interface/src/views/components/dashboard/BigDisplayModeConfiguration.tsx index 187f9b2823b6..3848db5d7e58 100644 --- a/graylog2-web-interface/src/views/components/dashboard/BigDisplayModeConfiguration.tsx +++ b/graylog2-web-interface/src/views/components/dashboard/BigDisplayModeConfiguration.tsx @@ -113,8 +113,8 @@ const ConfigurationModal = ({ onSave, view, show, onClose }: ConfigurationModalP ); }; -const redirectToBigDisplayMode = (view: View, config: UntypedBigDisplayModeQuery, setFocusedWidget): void => { - setFocusedWidget(undefined); +const redirectToBigDisplayMode = (view: View, config: UntypedBigDisplayModeQuery, unsetWidgetFocusing): void => { + unsetWidgetFocusing(); history.push( new URI(Routes.pluginRoute('DASHBOARDS_TV_VIEWID')(view.id)) @@ -146,8 +146,8 @@ type Props = { const BigDisplayModeConfiguration = ({ disabled, show, view }: Props) => { const [showConfigurationModal, setShowConfigurationModal] = useState(show); - const { setFocusedWidget } = useContext(WidgetFocusContext); - const onSave = (config: Configuration) => redirectToBigDisplayMode(view, createQueryFromConfiguration(config, view), setFocusedWidget); + const { unsetWidgetFocusing } = useContext(WidgetFocusContext); + const onSave = (config: Configuration) => redirectToBigDisplayMode(view, createQueryFromConfiguration(config, view), unsetWidgetFocusing); return ( <> diff --git a/graylog2-web-interface/src/views/components/widgets/EditWidgetFrame.css b/graylog2-web-interface/src/views/components/widgets/EditWidgetFrame.css deleted file mode 100644 index f8103715a62c..000000000000 --- a/graylog2-web-interface/src/views/components/widgets/EditWidgetFrame.css +++ /dev/null @@ -1,49 +0,0 @@ -:local(.editWidgetDialog) { - height: 80vh; - width: 80vw; - margin-top: 20px; -} - -:local(.editWidgetControls) { - width: 80vw; -} - -:local(.editWidgetControlsContent) { - padding: 15px 20px 15px 20px; -} - -:local(.gridContainer) { - display: grid; - display: -ms-grid; - height: 100%; - grid-template-columns: 1fr; - -ms-grid-columns: 1fr; - grid-template-rows: auto minmax(200px, 1fr) auto; - -ms-grid-rows: auto minmax(200px, 1fr) auto; - grid-template-areas: "Query-Controls" "Visualization" "Footer"; -} - -:local(.QueryControls) { - grid-area: Query-Controls; - grid-column: 1; - -ms-grid-column: 1; - grid-row: 1; - -ms-grid-row: 1; -} - -:local(.Visualization) { - grid-area: Visualization; - overflow: hidden; - grid-column: 1; - -ms-grid-column: 1; - grid-row: 2; - -ms-grid-row: 2; -} - -:local(.Footer) { - grid-area: Footer; - grid-column: 1; - -ms-grid-column: 1; - grid-row: 3; - -ms-grid-row: 3; -} diff --git a/graylog2-web-interface/src/views/components/widgets/EditWidgetFrame.tsx b/graylog2-web-interface/src/views/components/widgets/EditWidgetFrame.tsx index d90d0f0ff022..22423f744fc0 100644 --- a/graylog2-web-interface/src/views/components/widgets/EditWidgetFrame.tsx +++ b/graylog2-web-interface/src/views/components/widgets/EditWidgetFrame.tsx @@ -17,10 +17,10 @@ import * as React from 'react'; import { useContext } from 'react'; import PropTypes from 'prop-types'; +import styled from 'styled-components'; import moment from 'moment'; import { useStore } from 'stores/connect'; -import { Modal } from 'components/graylog'; import Spinner from 'components/common/Spinner'; import WidgetContext from 'views/components/contexts/WidgetContext'; import QueryEditModeContext from 'views/components/contexts/QueryEditModeContext'; @@ -30,30 +30,48 @@ import { WidgetActions } from 'views/stores/WidgetStore'; import { DEFAULT_TIMERANGE } from 'views/Constants'; import { SearchConfigStore } from 'views/stores/SearchConfigStore'; -import styles from './EditWidgetFrame.css'; - import WidgetQueryControls from '../WidgetQueryControls'; import IfDashboard from '../dashboard/IfDashboard'; import HeaderElements from '../HeaderElements'; import WidgetOverrideElements from '../WidgetOverrideElements'; import SearchBarForm from '../searchbar/SearchBarForm'; -type DialogProps = { - bsClass: string, - className: string, - children: React.ReactNode, -}; - -const EditWidgetDialog = ({ children, ...rest }: DialogProps) => ( - - {children} - -); - -EditWidgetDialog.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.node.isRequired, -}; +const Container = styled.div` + display: grid; + display: -ms-grid; + height: 100%; + grid-template-columns: 1fr; + -ms-grid-columns: 1fr; + grid-template-rows: auto minmax(200px, 1fr) auto; + -ms-grid-rows: auto minmax(200px, 1fr) auto; + grid-template-areas: "Query-Controls" "Visualization" "Footer"; +`; + +const QueryControls = styled.div` + margin-bottom: 10px; + grid-area: Query-Controls; + grid-column: 1; + -ms-grid-column: 1; + grid-row: 1; + -ms-grid-row: 1; +`; + +const Visualization = styled.div` + grid-area: Visualization; + overflow: hidden; + grid-column: 1; + -ms-grid-column: 1; + grid-row: 2; + -ms-grid-row: 2; +`; + +const Footer = styled.div` + grid-area: Footer; + grid-column: 1; + -ms-grid-column: 1; + grid-row: 3; + -ms-grid-row: 3; +`; type Props = { children: Array, @@ -86,36 +104,31 @@ const EditWidgetFrame = ({ children }: Props) => { const _onSubmit = (values) => onSubmit(values, widget); return ( - - -
- - - - - - - - - -
- - {children[0]} - -
-
- - {children[1]} - -
-
-
+ + + + + + + + + + + +
+ + {children[0]} + +
+
+
+ {children[1]} +
+
+
); }; diff --git a/graylog2-web-interface/src/views/components/widgets/ExtraWidgetActions.test.tsx b/graylog2-web-interface/src/views/components/widgets/ExtraWidgetActions.test.tsx index 59ed67dca8fb..ce22b635be18 100644 --- a/graylog2-web-interface/src/views/components/widgets/ExtraWidgetActions.test.tsx +++ b/graylog2-web-interface/src/views/components/widgets/ExtraWidgetActions.test.tsx @@ -84,7 +84,8 @@ describe('ExtraWidgetActions', () => { .toHaveBeenCalledWith(widget, expect.objectContaining({ widgetFocusContext: expect.objectContaining({ focusedWidget: undefined, - setFocusedWidget: expect.any(Function), + setWidgetFocusing: expect.any(Function), + setWidgetEditing: expect.any(Function), }), }))); }); diff --git a/graylog2-web-interface/src/views/components/widgets/SaveOrCancelButtons.tsx b/graylog2-web-interface/src/views/components/widgets/SaveOrCancelButtons.tsx index 28d11cefd3f0..d04647cf5b11 100644 --- a/graylog2-web-interface/src/views/components/widgets/SaveOrCancelButtons.tsx +++ b/graylog2-web-interface/src/views/components/widgets/SaveOrCancelButtons.tsx @@ -18,7 +18,7 @@ import * as React from 'react'; import { useCallback } from 'react'; import { useFormikContext } from 'formik'; -import { Button } from 'components/graylog'; +import { Button, ButtonToolbar } from 'components/graylog'; type Props = { onCancel: () => void, @@ -36,10 +36,10 @@ const SaveOrCancelButtons = ({ onFinish, onCancel }: Props) => { }, [onFinish, handleSubmit, dirty]); return ( - <> + - + ); }; diff --git a/graylog2-web-interface/src/views/components/widgets/Widget.test.tsx b/graylog2-web-interface/src/views/components/widgets/Widget.test.tsx index 5b4afce6973c..0b50b998b96d 100644 --- a/graylog2-web-interface/src/views/components/widgets/Widget.test.tsx +++ b/graylog2-web-interface/src/views/components/widgets/Widget.test.tsx @@ -16,39 +16,27 @@ */ import React from 'react'; import * as Immutable from 'immutable'; -import { render, waitFor, fireEvent } from 'wrappedTestingLibrary'; +import { render, waitFor, fireEvent, screen } from 'wrappedTestingLibrary'; import { Map } from 'immutable'; import mockComponent from 'helpers/mocking/MockComponent'; import mockAction from 'helpers/mocking/MockAction'; -import asMock from 'helpers/mocking/AsMock'; -import Routes from 'routing/Routes'; +import WidgetModel from 'views/logic/widgets/Widget'; import { WidgetActions, Widgets } from 'views/stores/WidgetStore'; import { TitlesActions, TitleTypes } from 'views/stores/TitlesStore'; import WidgetPosition from 'views/logic/widgets/WidgetPosition'; -import WidgetModel from 'views/logic/widgets/Widget'; import View from 'views/logic/views/View'; -import { DashboardsStore } from 'views/stores/DashboardsStore'; import { ViewStore } from 'views/stores/ViewStore'; import type { ViewStoreState } from 'views/stores/ViewStore'; -import { ViewManagementActions } from 'views/stores/ViewManagementStore'; -import SearchActions from 'views/actions/SearchActions'; import Search from 'views/logic/search/Search'; import Query from 'views/logic/queries/Query'; -import CopyWidgetToDashboard from 'views/logic/views/CopyWidgetToDashboard'; import ViewState from 'views/logic/views/ViewState'; -import MessagesWidget from 'views/logic/widgets/MessagesWidget'; -import { loadDashboard } from 'views/logic/views/Actions'; import { TitlesMap } from 'views/stores/TitleTypes'; -import Widget from './Widget'; +import Widget, { Result } from './Widget'; import WidgetContext from '../contexts/WidgetContext'; - -jest.mock('views/actions/SearchActions', () => ({ - create: mockAction(jest.fn()), - get: mockAction(jest.fn()), -})); +import WidgetFocusContext, { WidgetFocusContextType } from '../contexts/WidgetFocusContext'; jest.mock('views/components/search/IfSearch', () => jest.fn(({ children }) => children)); @@ -59,8 +47,6 @@ jest.mock('views/stores/ViewManagementStore', () => ({ }, })); -jest.mock('views/logic/views/CopyWidgetToDashboard', () => jest.fn()); - jest.mock('../searchbar/QueryInput', () => mockComponent('QueryInput')); jest.mock('./WidgetHeader', () => 'widget-header'); @@ -74,7 +60,7 @@ jest.mock('graylog-web-plugin/plugin', () => ({ // eslint-disable-next-line react/prop-types editComponent: ({ onChange }) => { // eslint-disable-next-line react/button-has-type - return ; + return ; }, }, { @@ -86,24 +72,9 @@ jest.mock('graylog-web-plugin/plugin', () => ({ }, })); -jest.mock('views/stores/ChartColorRulesStore', () => ({ - ChartColorRulesStore: {}, -})); - jest.mock('views/stores/WidgetStore'); jest.mock('views/stores/TitlesStore'); jest.mock('./WidgetColorContext', () => ({ children }) => children); -jest.mock('views/logic/views/Actions'); - -jest.mock('views/stores/SearchConfigStore', () => ({ - SearchConfigStore: { - listen: () => jest.fn(), - getInitialState: () => ({ - searchesClusterConfig: {}, - }), - }, - SearchConfigActions: {}, -})); describe('', () => { const widget = WidgetModel.builder().newId() @@ -128,23 +99,6 @@ describe('', () => { dirty: false, }; - const searchDB1 = Search.builder().id('search-1').build(); - const dashboard1 = View.builder().type(View.Type.Dashboard).id('view-1').title('view 1') - .search(searchDB1) - .build(); - const dashboard2 = View.builder().type(View.Type.Dashboard).id('view-2').title('view 2') - .build(); - const dashboardList = [dashboard1, dashboard2]; - const dashboardState = { - list: dashboardList, - pagination: { - total: 2, - page: 1, - per_page: 10, - count: 2, - }, - }; - beforeEach(() => { ViewStore.getInitialState = jest.fn(() => viewStoreState); }); @@ -154,60 +108,83 @@ describe('', () => { jest.resetModules(); }); - const DummyWidget = (props) => ( - - {}} - onSizeChange={() => {}} - title="Widget Title" - position={new WidgetPosition(1, 1, 1, 1)} - {...props} /> - + type DummyWidgetProps = { + widget?: WidgetModel, + errors?: Array<{ description: string }>, + data?: { [key: string]: Result }, + focusedWidget?: WidgetFocusContextType['focusedWidget'], + setWidgetFocusing?: WidgetFocusContextType['setWidgetFocusing'], + setWidgetEditing?: WidgetFocusContextType['setWidgetEditing'], + unsetWidgetFocusing?: WidgetFocusContextType['unsetWidgetFocusing'], + unsetWidgetEditing?: WidgetFocusContextType['unsetWidgetEditing'], + title?: string, + editing?: boolean, + } + + const DummyWidget = ({ + widget: propsWidget = widget, + focusedWidget = undefined, + setWidgetFocusing = () => {}, + setWidgetEditing = () => {}, + unsetWidgetFocusing = () => {}, + unsetWidgetEditing = () => {}, + ...props + }: DummyWidgetProps) => ( + + + {}} + onSizeChange={() => {}} + title="Widget Title" + position={new WidgetPosition(1, 1, 1, 1)} + {...props} /> + + ); it('should render with empty props', () => { - const { baseElement } = render(); + render(); - expect(baseElement).toMatchSnapshot(); + expect(screen.getByTitle('Widget Title')).toBeInTheDocument(); }); it('should render loading widget for widget without data', () => { - const { queryAllByTestId } = render(); + render(); - expect(queryAllByTestId('loading-widget')).toHaveLength(1); + expect(screen.queryAllByTestId('loading-widget')).toHaveLength(1); }); it('should render error widget for widget with one error', () => { - const { queryAllByText } = render(); - const errorWidgets = queryAllByText('The widget has failed: the dungeon collapsed, you die!'); + render(); + const errorWidgets = screen.queryAllByText('The widget has failed: the dungeon collapsed, you die!'); expect(errorWidgets).toHaveLength(1); }); it('should render error widget including all error messages for widget with multiple errors', () => { - const { queryAllByText } = render(( + render(( )); - const errorWidgets1 = queryAllByText('Something is wrong'); + const errorWidgets1 = screen.queryAllByText('Something is wrong'); expect(errorWidgets1).toHaveLength(1); - const errorWidgets2 = queryAllByText('Very wrong'); + const errorWidgets2 = screen.queryAllByText('Very wrong'); expect(errorWidgets2).toHaveLength(1); }); it('should render correct widget visualization for widget with data', () => { - const { queryAllByTestId, queryAllByTitle } = render(); + render(); - expect(queryAllByTestId('loading-widget')).toHaveLength(0); - expect(queryAllByTitle('Widget Title')).toHaveLength(2); + expect(screen.queryAllByTestId('loading-widget')).toHaveLength(0); + expect(screen.queryAllByTitle('Widget Title')).toHaveLength(2); }); it('renders placeholder if widget type is unknown', async () => { @@ -217,21 +194,22 @@ describe('', () => { .config({}) .build(); const UnknownWidget = (props) => ( - {}} - onSizeChange={() => {}} - title="Widget Title" - position={new WidgetPosition(1, 1, 1, 1)} - {...props} /> + {}} + onSizeChange={() => {}} + title="Widget Title" + position={new WidgetPosition(1, 1, 1, 1)} + {...props} /> ); - const { findByText } = render( + + render( , ); - await findByText('Unknown widget'); + await screen.findByText('Unknown widget'); }); it('renders placeholder in edit mode if widget type is unknown', async () => { @@ -253,20 +231,21 @@ describe('', () => { {...props} /> ); - const { findByText } = render( + + render( , ); - await findByText('Unknown widget in edit mode'); + await screen.findByText('Unknown widget in edit mode'); }); it('copies title when duplicating widget', async () => { - const { getByTestId, getByText } = render(); + render(); - const actionToggle = getByTestId('widgetActionDropDown'); + const actionToggle = screen.getByTestId('widgetActionDropDown'); fireEvent.click(actionToggle); - const duplicateBtn = getByText('Duplicate'); + const duplicateBtn = screen.getByText('Duplicate'); WidgetActions.duplicate = mockAction(jest.fn().mockResolvedValue(WidgetModel.builder().id('duplicatedWidgetId').build())); @@ -279,18 +258,36 @@ describe('', () => { }); it('adds cancel action to widget in edit mode', () => { - const { queryAllByText } = render(); - const cancel = queryAllByText('Cancel'); + render(); + const cancel = screen.queryAllByText('Cancel'); expect(cancel).toHaveLength(1); }); + it('updates focus mode, on widget edit cancel', () => { + const mockUnsetWidgetEditing = jest.fn(); + render(); + const cancel = screen.getByText('Cancel'); + fireEvent.click(cancel); + + expect(mockUnsetWidgetEditing).toHaveBeenCalledTimes(1); + }); + + it('updates focus mode, on widget edit save', () => { + const mockUnsetWidgetEditing = jest.fn(); + render(); + const saveButton = screen.getByText('Save'); + fireEvent.click(saveButton); + + expect(mockUnsetWidgetEditing).toHaveBeenCalledTimes(1); + }); + it('does not trigger action when clicking cancel after no changes were made', () => { - const { getByText } = render(); + render(); WidgetActions.updateConfig = mockAction(jest.fn(async () => Immutable.OrderedMap() as Widgets)); - const cancelBtn = getByText('Cancel'); + const cancelBtn = screen.getByText('Cancel'); fireEvent.click(cancelBtn); @@ -303,17 +300,17 @@ describe('', () => { .type('dummy') .config({ foo: 42 }) .build(); - const { getByText } = render(); + render(); WidgetActions.updateConfig = mockAction(jest.fn(async () => Immutable.OrderedMap() as Widgets)); WidgetActions.update = mockAction(jest.fn(async () => Immutable.OrderedMap() as Widgets)); - const onChangeBtn = getByText('Click me'); + const onChangeBtn = screen.getByText('Click me'); fireEvent.click(onChangeBtn); expect(WidgetActions.updateConfig).toHaveBeenCalledWith('widgetId', { foo: 23 }); - const cancelButton = getByText('Cancel'); + const cancelButton = screen.getByText('Cancel'); fireEvent.click(cancelButton); @@ -326,128 +323,20 @@ describe('', () => { .type('dummy') .config({ foo: 42 }) .build(); - const { getByText } = render(); + render(); WidgetActions.updateConfig = mockAction(jest.fn(async () => Immutable.OrderedMap() as Widgets)); WidgetActions.update = mockAction(jest.fn(async () => Immutable.OrderedMap() as Widgets)); - const onChangeBtn = getByText('Click me'); + const onChangeBtn = screen.getByText('Click me'); fireEvent.click(onChangeBtn); expect(WidgetActions.updateConfig).toHaveBeenCalledWith('widgetId', { foo: 23 }); - const saveButton = getByText('Save'); + const saveButton = screen.getByText('Save'); fireEvent.click(saveButton); expect(WidgetActions.update).not.toHaveBeenCalledWith('widgetId', { config: { foo: 42 }, id: 'widgetId', type: 'dummy' }); }); - - it('does not display export action if widget is not a message table', () => { - const dummyWidget = WidgetModel.builder() - .id('widgetId') - .type('dummy') - .config({}) - .build(); - const { getByTestId, queryByText } = render(); - - const actionToggle = getByTestId('widgetActionDropDown'); - - fireEvent.click(actionToggle); - - expect(queryByText('Export')).toBeNull(); - }); - - it('allows export for message tables', () => { - const messagesWidget = MessagesWidget.builder() - .id('widgetId') - .config({}) - .build(); - - const { getByTestId, getByText } = render(); - - const actionToggle = getByTestId('widgetActionDropDown'); - - fireEvent.click(actionToggle); - - const exportButton = getByText('Export'); - - fireEvent.click(exportButton); - - expect(getByText('Export message table search results')).not.toBeNull(); - }); - - describe('copy widget to dashboard', () => { - beforeEach(() => { - // @ts-ignore - DashboardsStore.getInitialState = jest.fn(() => dashboardState); - ViewManagementActions.get = mockAction(jest.fn((async () => Promise.resolve(dashboard1.toJSON())))); - SearchActions.get = mockAction(jest.fn(() => Promise.resolve(searchDB1.toJSON()))); - ViewManagementActions.update = mockAction(jest.fn((view) => Promise.resolve(view))); - SearchActions.create = mockAction(jest.fn(() => Promise.resolve({ search: searchDB1 }))); - Routes.pluginRoute = jest.fn((route) => (id) => `${route}-${id}`); - - asMock(CopyWidgetToDashboard).mockImplementation(() => View.builder() - .search(Search.builder().id('search-id').build()) - .id('new-id').type(View.Type.Dashboard) - .build()); - }); - - const renderAndClick = () => { - const { getByText, getByTestId } = render(); - const actionToggle = getByTestId('widgetActionDropDown'); - - fireEvent.click(actionToggle); - const copyToDashboard = getByText('Copy to Dashboard'); - - fireEvent.click(copyToDashboard); - const view1ListItem = getByText('view 1'); - - fireEvent.click(view1ListItem); - const selectBtn = getByText('Select'); - - fireEvent.click(selectBtn); - }; - - it('should get dashboard from backend', async () => { - renderAndClick(); - await waitFor(() => expect(ViewManagementActions.get).toHaveBeenCalledTimes(1)); - - expect(ViewManagementActions.get).toHaveBeenCalledWith('view-1'); - }); - - it('should get corresponding search to dashboard', async () => { - renderAndClick(); - await waitFor(() => expect(SearchActions.get).toHaveBeenCalledTimes(1)); - - expect(SearchActions.get).toHaveBeenCalledWith('search-1'); - }); - - it('should create new search for dashboard', async () => { - renderAndClick(); - await waitFor(() => expect(SearchActions.create).toHaveBeenCalledTimes(1)); - - expect(SearchActions.create).toHaveBeenCalledWith(Search.builder().id('search-id').parameters([]).queries([]) - .build()); - }); - - it('should update dashboard with new search and widget', async () => { - renderAndClick(); - await waitFor(() => expect(ViewManagementActions.update).toHaveBeenCalledTimes(1)); - - expect(ViewManagementActions.update).toHaveBeenCalledWith( - View.builder() - .search(Search.builder().id('search-1').build()) - .id('new-id').type(View.Type.Dashboard) - .build(), - ); - }); - - it('should redirect to updated dashboard', async () => { - renderAndClick(); - await waitFor(() => expect(loadDashboard).toHaveBeenCalled()); - - expect(loadDashboard).toHaveBeenCalledWith('view-1'); - }); - }); }); diff --git a/graylog2-web-interface/src/views/components/widgets/Widget.tsx b/graylog2-web-interface/src/views/components/widgets/Widget.tsx index f53396657b43..dfd93cd3f2e3 100644 --- a/graylog2-web-interface/src/views/components/widgets/Widget.tsx +++ b/graylog2-web-interface/src/views/components/widgets/Widget.tsx @@ -17,67 +17,37 @@ import * as React from 'react'; import * as Immutable from 'immutable'; import PropTypes from 'prop-types'; -import styled from 'styled-components'; -import { MenuItem } from 'components/graylog'; import connect from 'stores/connect'; -import IfSearch from 'views/components/search/IfSearch'; import { widgetDefinition } from 'views/logic/Widgets'; import { WidgetActions } from 'views/stores/WidgetStore'; -import { TitlesActions, TitleTypes } from 'views/stores/TitlesStore'; -import { ViewActions, ViewStore } from 'views/stores/ViewStore'; +import { TitlesActions } from 'views/stores/TitlesStore'; +import { ViewStore } from 'views/stores/ViewStore'; import type { ViewStoreState } from 'views/stores/ViewStore'; import { RefreshActions } from 'views/stores/RefreshStore'; import FieldTypeMapping from 'views/logic/fieldtypes/FieldTypeMapping'; import WidgetModel from 'views/logic/widgets/Widget'; import WidgetPosition from 'views/logic/widgets/WidgetPosition'; -import SearchActions from 'views/actions/SearchActions'; -import { ViewManagementActions } from 'views/stores/ViewManagementStore'; -import CopyWidgetToDashboard from 'views/logic/views/CopyWidgetToDashboard'; -import View from 'views/logic/views/View'; -import Search from 'views/logic/search/Search'; import AggregationWidgetConfig from 'views/logic/aggregationbuilder/AggregationWidgetConfig'; import type { FieldTypeMappingsList } from 'views/stores/FieldTypesStore'; import type { Rows } from 'views/logic/searchtypes/pivot/PivotHandler'; import type { AbsoluteTimeRange } from 'views/logic/queries/Query'; -import ExportModal from 'views/components/export/ExportModal'; -import MoveWidgetToTab from 'views/logic/views/MoveWidgetToTab'; -import { loadDashboard } from 'views/logic/views/Actions'; -import { IconButton } from 'components/common'; import WidgetFocusContext from 'views/components/contexts/WidgetFocusContext'; import type VisualizationConfig from 'views/logic/aggregationbuilder/visualizations/VisualizationConfig'; import WidgetFrame from './WidgetFrame'; import WidgetHeader from './WidgetHeader'; -import WidgetActionDropdown from './WidgetActionDropdown'; -import WidgetHorizontalStretch from './WidgetHorizontalStretch'; -import MeasureDimensions from './MeasureDimensions'; import EditWidgetFrame from './EditWidgetFrame'; import LoadingWidget from './LoadingWidget'; import ErrorWidget from './ErrorWidget'; import { WidgetErrorsList } from './WidgetPropTypes'; import SaveOrCancelButtons from './SaveOrCancelButtons'; import WidgetColorContext from './WidgetColorContext'; -import CopyToDashboard from './CopyToDashboardForm'; -import MoveWidgetToTabModal from './MoveWidgetToTabModal'; import WidgetErrorBoundary from './WidgetErrorBoundary'; -import ReplaySearchButton from './ReplaySearchButton'; -import ExtraWidgetActions from './ExtraWidgetActions'; +import WidgetActionsMenu from './WidgetActionsMenu'; import CustomPropTypes from '../CustomPropTypes'; -import IfDashboard from '../dashboard/IfDashboard'; import InteractiveContext from '../contexts/InteractiveContext'; -import IfInteractive from '../dashboard/IfInteractive'; - -const WidgetActionsWBar = styled.div` - > * { - margin-right: 5px; - - :last-child { - margin-right: 0; - } - } -`; type Props = { id: string, @@ -94,13 +64,10 @@ type Props = { onSizeChange: () => void, onPositionsChange: () => void, }; + type State = { - editing: boolean, loading: boolean; oldWidget?: WidgetModel, - showCopyToDashboard: boolean, - showExport: boolean, - showMoveWidgetToTab: boolean, }; export type Result = { @@ -131,19 +98,19 @@ const _editComponentForType = (type) => { class Widget extends React.Component { static propTypes = { - id: PropTypes.string.isRequired, - view: CustomPropTypes.CurrentView.isRequired, - widget: PropTypes.instanceOf(WidgetModel).isRequired, data: PropTypes.any, editing: PropTypes.bool, errors: WidgetErrorsList, - height: PropTypes.number, - width: PropTypes.number, fields: PropTypes.any.isRequired, - onSizeChange: PropTypes.func.isRequired, + height: PropTypes.number, + id: PropTypes.string.isRequired, onPositionsChange: PropTypes.func.isRequired, - title: PropTypes.string.isRequired, + onSizeChange: PropTypes.func.isRequired, position: PropTypes.instanceOf(WidgetPosition).isRequired, + title: PropTypes.string.isRequired, + view: CustomPropTypes.CurrentView.isRequired, + widget: PropTypes.instanceOf(WidgetModel).isRequired, + width: PropTypes.number, }; static defaultProps = { @@ -159,11 +126,7 @@ class Widget extends React.Component { const { editing } = props; this.state = { - editing, loading: false, - showCopyToDashboard: false, - showExport: false, - showMoveWidgetToTab: false, }; if (editing) { @@ -171,118 +134,41 @@ class Widget extends React.Component { } } - _onDelete = (widget) => { - const { title } = this.props; - - // eslint-disable-next-line no-alert - if (window.confirm(`Are you sure you want to remove the widget "${title}"?`)) { - WidgetActions.remove(widget.id); - } - }; - - _onDuplicate = (widgetId, setFocusWidget) => { - const { title } = this.props; - - WidgetActions.duplicate(widgetId).then((newWidget) => { - TitlesActions.set(TitleTypes.Widget, newWidget.id, `${title} (copy)`).then(() => { - setFocusWidget(undefined); - }); - }); - }; - - _onToggleCopyToDashboard = () => { - this.setState(({ showCopyToDashboard }) => ({ showCopyToDashboard: !showCopyToDashboard })); - }; - - _onToggleMoveWidgetToTab = () => { - this.setState(({ showMoveWidgetToTab }) => ({ showMoveWidgetToTab: !showMoveWidgetToTab })); - }; - - _updateDashboardWithNewSearch = (dashboard: View, dashboardId: string) => ({ search: newSearch }) => { - const newDashboard = dashboard.toBuilder().search(newSearch).build(); - - ViewManagementActions.update(newDashboard).then(() => loadDashboard(dashboardId)); - }; - - _onMoveWidgetToTab = (widgetId, queryId, keepCopy) => { - const { view } = this.props; - const { view: activeView } = view; - - if (!queryId) { - return; - } - - const newDashboard = MoveWidgetToTab(widgetId, queryId, activeView, keepCopy); - - if (newDashboard) { - SearchActions.create(newDashboard.search).then((searchResponse) => { - const updatedDashboard = newDashboard.toBuilder().search(searchResponse.search).build(); - - ViewActions.update(updatedDashboard).then(() => { - this._onToggleMoveWidgetToTab(); - - ViewActions.selectQuery(queryId).then(() => { - SearchActions.executeWithCurrentState(); - }); - }); - }); - } - }; - - _onCopyToDashboard = (widgetId: string, dashboardId: string | undefined | null): void => { - const { view } = this.props; - const { view: activeView } = view; - - if (!dashboardId) { - return; - } - - const addWidgetToDashboard = (dashboard: View) => (searchJson) => { - const search = Search.fromJSON(searchJson); - const newDashboard = CopyWidgetToDashboard(widgetId, activeView, dashboard.toBuilder().search(search).build()); - - if (newDashboard && newDashboard.search) { - SearchActions.create(newDashboard.search).then(this._updateDashboardWithNewSearch(newDashboard, dashboardId)); - } - }; + _onEdit = (setWidgetFocusing) => { + const { widget } = this.props; - ViewManagementActions.get(dashboardId).then((dashboardJson) => { - const dashboard = View.fromJSON(dashboardJson); + this.setState(() => { + RefreshActions.disable(); + setWidgetFocusing({ id: widget.id, editing: true }); - SearchActions.get(dashboardJson.search_id).then(addWidgetToDashboard(dashboard)); + return { + oldWidget: widget, + }; }); - - this._onToggleCopyToDashboard(); }; _onToggleEdit = () => { - const { widget } = this.props; + const { widget, editing } = this.props; + const { setWidgetEditing, unsetWidgetEditing } = this.context; + + this.setState(() => { + if (editing) { + unsetWidgetEditing(); - this.setState((state) => { - if (state.editing) { return { - editing: false, oldWidget: undefined, }; } RefreshActions.disable(); + setWidgetEditing(widget.id); return { - editing: true, oldWidget: widget, }; }); }; - _onToggleExport = () => { - const { showExport } = this.state; - - this.setState({ - showExport: !showExport, - }); - } - _onCancelEdit = () => { const { oldWidget } = this.state; @@ -307,8 +193,7 @@ class Widget extends React.Component { } if (data) { - const { editing } = this.state; - const { id, widget, height, width, fields, view: { activeQuery: queryId } } = this.props; + const { id, widget, height, width, fields, view: { activeQuery: queryId }, editing } = this.props; const { config, filter } = widget; const VisComponent = _visualizationForType(widget.type); @@ -323,7 +208,7 @@ class Widget extends React.Component { onConfigChange={(newWidgetConfig) => this._onWidgetConfigChange(id, newWidgetConfig)} setLoadingState={this._setLoadingState} title={title} - toggleEdit={this._onToggleEdit} + toggleEdit={() => this._onToggleEdit()} type={widget.type} width={width} id={id} /> @@ -335,25 +220,38 @@ class Widget extends React.Component { // TODO: Clean up different code paths for normal/edit modes render() { - const { id, widget, fields, onSizeChange, title, position, onPositionsChange, view } = this.props; - const { editing, loading, showCopyToDashboard, showExport, showMoveWidgetToTab } = this.state; + const { id, widget, fields, onSizeChange, title, position, onPositionsChange, view, editing } = this.props; + const { loading } = this.state; + const { config } = widget; - const { focusedWidget, setFocusedWidget } = this.context; - const isFocusedWidget = focusedWidget === id; + const { focusedWidget } = this.context; + const isFocused = focusedWidget?.id === id; const visualization = this.visualize(); + const EditComponent = _editComponentForType(widget.type); - if (editing) { - const EditComponent = _editComponentForType(widget.type); - - return ( - - - + return ( + + + + {(interactive) => ( TitlesActions.set('widget', id, newTitle)} - editing={editing} /> + editing={editing}> + {!editing && ( + + )} + + )} + + {editing && ( + { {visualization} - - - - - ); - } - - return ( - - - - {(interactive) => ( - TitlesActions.set('widget', id, newTitle)} - editing={editing}> - - - - - - setFocusedWidget(id)} /> - {!isFocusedWidget && ( - - )} - - Edit - this._onDuplicate(id, setFocusedWidget)}>Duplicate - {widget.isExportable && this._onToggleExport()}>Export} - - Copy to Dashboard - - - Move to Page - - {}} /> - - this._onDelete(widget)}>Delete - - {showCopyToDashboard && ( - - )} - {showExport && } - {showMoveWidgetToTab && ( - - )} - - - - )} - - - - {visualization} - + + + )} + {!editing && ( + + {visualization} + + )} ); diff --git a/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.test.tsx b/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.test.tsx new file mode 100644 index 000000000000..d9513aafbd20 --- /dev/null +++ b/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.test.tsx @@ -0,0 +1,282 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import React from 'react'; +import * as Immutable from 'immutable'; +import { render, waitFor, fireEvent, screen } from 'wrappedTestingLibrary'; +import { Map } from 'immutable'; +import mockAction from 'helpers/mocking/MockAction'; +import asMock from 'helpers/mocking/AsMock'; + +import { WidgetActions } from 'views/stores/WidgetStore'; +import { TitlesActions, TitleTypes } from 'views/stores/TitlesStore'; +import WidgetPosition from 'views/logic/widgets/WidgetPosition'; +import WidgetModel from 'views/logic/widgets/Widget'; +import View from 'views/logic/views/View'; +import { DashboardsStore } from 'views/stores/DashboardsStore'; +import type { ViewStoreState } from 'views/stores/ViewStore'; +import { ViewManagementActions } from 'views/stores/ViewManagementStore'; +import SearchActions from 'views/actions/SearchActions'; +import Search from 'views/logic/search/Search'; +import Query from 'views/logic/queries/Query'; +import CopyWidgetToDashboard from 'views/logic/views/CopyWidgetToDashboard'; +import ViewState from 'views/logic/views/ViewState'; +import MessagesWidget from 'views/logic/widgets/MessagesWidget'; +import { loadDashboard } from 'views/logic/views/Actions'; +import { TitlesMap } from 'views/stores/TitleTypes'; + +import WidgetActionsMenu from './WidgetActionsMenu'; + +import WidgetContext from '../contexts/WidgetContext'; +import WidgetFocusContext, { WidgetFocusContextType } from '../contexts/WidgetFocusContext'; + +jest.mock('views/components/search/IfSearch', () => jest.fn(({ children }) => children)); + +jest.mock('views/logic/views/CopyWidgetToDashboard', () => jest.fn()); + +jest.mock('views/stores/ChartColorRulesStore', () => ({ + ChartColorRulesStore: {}, +})); + +jest.mock('views/logic/views/Actions'); + +describe('', () => { + const widget = WidgetModel.builder().newId() + .type('dummy') + .id('widget-id') + .config({}) + .build(); + + const viewState = ViewState.builder().build(); + const query = Query.builder().id('query-id').build(); + const searchSearch = Search.builder().queries([query]).id('search-1').build(); + const search = View.builder() + .search(searchSearch) + .type(View.Type.Dashboard) + .state(Map({ 'query-id': viewState })) + .id('search-1') + .title('search 1') + .build(); + const viewStoreState: ViewStoreState = { + activeQuery: 'query-id', + view: search, + isNew: false, + dirty: false, + }; + + const searchDB1 = Search.builder().id('search-1').build(); + const dashboard1 = View.builder().type(View.Type.Dashboard).id('view-1').title('view 1') + .search(searchDB1) + .build(); + const dashboard2 = View.builder().type(View.Type.Dashboard).id('view-2').title('view 2') + .build(); + const dashboardList = [dashboard1, dashboard2]; + const dashboardState = { + list: dashboardList, + pagination: { + total: 2, + page: 1, + per_page: 10, + count: 2, + }, + }; + + type DummyWidgetProps = { + widget?: WidgetModel, + focusedWidget?: WidgetFocusContextType['focusedWidget'], + setWidgetFocusing?: WidgetFocusContextType['setWidgetFocusing'], + setWidgetEditing?: WidgetFocusContextType['setWidgetEditing'], + unsetWidgetFocusing?: WidgetFocusContextType['unsetWidgetFocusing'], + unsetWidgetEditing?: WidgetFocusContextType['unsetWidgetEditing'], + title?: string + isFocused?: boolean, + } + + const DummyWidget = ({ + widget: propsWidget = widget, + setWidgetFocusing = () => {}, + setWidgetEditing = () => {}, + unsetWidgetFocusing = () => {}, + unsetWidgetEditing = () => {}, + focusedWidget, + ...props + }: DummyWidgetProps) => ( + + + {}} + title="Widget Title" + view={viewStoreState} + position={new WidgetPosition(1, 1, 1, 1)} + onPositionsChange={() => {}} + {...props} /> + + + ); + + it('is updating widget focus context on focus', () => { + const mockSetWidgetFocusing = jest.fn(); + render(); + + const focusButton = screen.getByTitle('Focus this widget'); + + fireEvent.click(focusButton); + + expect(mockSetWidgetFocusing).toHaveBeenCalledWith('widget-id'); + }); + + it('is updating widget focus context on un-focus', () => { + const mockUnsetWidgetFocusing = jest.fn(); + render(); + + const unfocusButton = screen.getByTitle('Un-focus widget'); + + fireEvent.click(unfocusButton); + + expect(mockUnsetWidgetFocusing).toHaveBeenCalledTimes(1); + }); + + it('copies title when duplicating widget', async () => { + render(); + + const actionToggle = screen.getByTestId('widgetActionDropDown'); + + fireEvent.click(actionToggle); + const duplicateBtn = screen.getByText('Duplicate'); + + WidgetActions.duplicate = mockAction(jest.fn().mockResolvedValue(WidgetModel.builder().id('duplicatedWidgetId').build())); + + TitlesActions.set = mockAction(jest.fn().mockResolvedValue(Immutable.Map() as TitlesMap)); + + fireEvent.click(duplicateBtn); + + await waitFor(() => expect(WidgetActions.duplicate).toHaveBeenCalled()); + await waitFor(() => expect(TitlesActions.set).toHaveBeenCalledWith(TitleTypes.Widget, 'duplicatedWidgetId', 'Dummy Widget (copy)')); + }); + + it('does not display export action if widget is not a message table', () => { + const dummyWidget = WidgetModel.builder() + .id('widgetId') + .type('dummy') + .config({}) + .build(); + const { getByTestId, queryByText } = render(); + + const actionToggle = getByTestId('widgetActionDropDown'); + + fireEvent.click(actionToggle); + + expect(queryByText('Export')).toBeNull(); + }); + + it('allows export for message tables', () => { + const messagesWidget = MessagesWidget.builder() + .id('widgetId') + .config({}) + .build(); + + render(); + + const actionToggle = screen.getByTestId('widgetActionDropDown'); + + fireEvent.click(actionToggle); + + const exportButton = screen.getByText('Export'); + + fireEvent.click(exportButton); + + expect(screen.getByText('Export message table search results')).not.toBeNull(); + }); + + describe('copy widget to dashboard', () => { + beforeEach(() => { + // @ts-ignore + DashboardsStore.getInitialState = jest.fn(() => dashboardState); + ViewManagementActions.get = mockAction(jest.fn((async () => Promise.resolve(dashboard1.toJSON())))); + ViewManagementActions.update = mockAction(jest.fn((view) => Promise.resolve(view))); + SearchActions.get = mockAction(jest.fn(() => Promise.resolve(searchDB1.toJSON()))); + SearchActions.create = mockAction(jest.fn(() => Promise.resolve({ search: searchDB1 }))); + + asMock(CopyWidgetToDashboard).mockImplementation(() => View.builder() + .search(Search.builder().id('search-id').build()) + .id('new-id').type(View.Type.Dashboard) + .build()); + }); + + const renderAndClick = () => { + render(); + const actionToggle = screen.getByTestId('widgetActionDropDown'); + + fireEvent.click(actionToggle); + const copyToDashboard = screen.getByText('Copy to Dashboard'); + + fireEvent.click(copyToDashboard); + const view1ListItem = screen.getByText('view 1'); + + fireEvent.click(view1ListItem); + const selectBtn = screen.getByText('Select'); + + fireEvent.click(selectBtn); + }; + + it('should get dashboard from backend', async () => { + renderAndClick(); + await waitFor(() => expect(ViewManagementActions.get).toHaveBeenCalledTimes(1)); + + expect(ViewManagementActions.get).toHaveBeenCalledWith('view-1'); + }); + + it('should get corresponding search to dashboard', async () => { + renderAndClick(); + await waitFor(() => expect(SearchActions.get).toHaveBeenCalledTimes(1)); + + expect(SearchActions.get).toHaveBeenCalledWith('search-1'); + }); + + it('should create new search for dashboard', async () => { + renderAndClick(); + await waitFor(() => expect(SearchActions.create).toHaveBeenCalledTimes(1)); + + expect(SearchActions.create).toHaveBeenCalledWith(Search.builder().id('search-id').parameters([]).queries([]) + .build()); + }); + + it('should update dashboard with new search and widget', async () => { + renderAndClick(); + await waitFor(() => expect(ViewManagementActions.update).toHaveBeenCalledTimes(1)); + + expect(ViewManagementActions.update).toHaveBeenCalledWith( + View.builder() + .search(Search.builder().id('search-1').build()) + .id('new-id').type(View.Type.Dashboard) + .build(), + ); + }); + + it('should redirect to updated dashboard', async () => { + renderAndClick(); + await waitFor(() => expect(loadDashboard).toHaveBeenCalled()); + + expect(loadDashboard).toHaveBeenCalledWith('view-1'); + }); + }); +}); diff --git a/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.tsx b/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.tsx new file mode 100644 index 000000000000..f777b592bd9a --- /dev/null +++ b/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.tsx @@ -0,0 +1,233 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import * as React from 'react'; +import { useState, useContext, useCallback } from 'react'; +import styled from 'styled-components'; + +import ExportModal from 'views/components/export/ExportModal'; +import MoveWidgetToTab from 'views/logic/views/MoveWidgetToTab'; +import { loadDashboard } from 'views/logic/views/Actions'; +import { IconButton } from 'components/common'; +import { ViewManagementActions } from 'views/stores/ViewManagementStore'; +import WidgetPosition from 'views/logic/widgets/WidgetPosition'; +import { TitlesActions, TitleTypes } from 'views/stores/TitlesStore'; +import { ViewActions } from 'views/stores/ViewStore'; +import View from 'views/logic/views/View'; +import SearchActions from 'views/actions/SearchActions'; +import Search from 'views/logic/search/Search'; +import CopyWidgetToDashboard from 'views/logic/views/CopyWidgetToDashboard'; +import type { ViewStoreState } from 'views/stores/ViewStore'; +import IfSearch from 'views/components/search/IfSearch'; +import { MenuItem } from 'components/graylog'; +import { WidgetActions } from 'views/stores/WidgetStore'; + +import ReplaySearchButton from './ReplaySearchButton'; +import ExtraWidgetActions from './ExtraWidgetActions'; +import CopyToDashboard from './CopyToDashboardForm'; +import MoveWidgetToTabModal from './MoveWidgetToTabModal'; +import WidgetActionDropdown from './WidgetActionDropdown'; +import WidgetHorizontalStretch from './WidgetHorizontalStretch'; + +import IfInteractive from '../dashboard/IfInteractive'; +import IfDashboard from '../dashboard/IfDashboard'; +import WidgetFocusContext from '../contexts/WidgetFocusContext'; +import WidgetContext from '../contexts/WidgetContext'; + +const Container = styled.div` + > * { + margin-right: 5px; + + :last-child { + margin-right: 0; + } + } +`; + +const _updateDashboardWithNewSearch = (dashboard: View, dashboardId: string, newSearch: Search) => { + const newDashboard = dashboard.toBuilder().search(newSearch).build(); + + ViewManagementActions.update(newDashboard).then(() => loadDashboard(dashboardId)); +}; + +const _onCopyToDashboard = ( + view: ViewStoreState, + setShowCopyToDashboard: (show: boolean) => void, + widgetId: string, + dashboardId: string | undefined | null, +): void => { + const { view: activeView } = view; + + if (!dashboardId) { + return; + } + + const addWidgetToDashboard = (dashboard: View) => (searchJson) => { + const search = Search.fromJSON(searchJson); + const newDashboard = CopyWidgetToDashboard(widgetId, activeView, dashboard.toBuilder().search(search).build()); + + if (newDashboard && newDashboard.search) { + SearchActions.create(newDashboard.search).then(({ search: newSearch }) => _updateDashboardWithNewSearch(newDashboard, dashboardId, newSearch)); + } + }; + + ViewManagementActions.get(dashboardId).then((dashboardJson) => { + const dashboard = View.fromJSON(dashboardJson); + + SearchActions.get(dashboardJson.search_id).then(addWidgetToDashboard(dashboard)); + }); + + setShowCopyToDashboard(false); +}; + +const _onMoveWidgetToTab = (view, setShowMoveWidgetToTab, widgetId, queryId, keepCopy) => { + const { view: activeView } = view; + + if (!queryId) { + return; + } + + const newDashboard = MoveWidgetToTab(widgetId, queryId, activeView, keepCopy); + + if (newDashboard) { + SearchActions.create(newDashboard.search).then((searchResponse) => { + const updatedDashboard = newDashboard.toBuilder().search(searchResponse.search).build(); + + ViewActions.update(updatedDashboard).then(() => { + setShowMoveWidgetToTab(false); + + ViewActions.selectQuery(queryId).then(() => { + SearchActions.executeWithCurrentState(); + }); + }); + }); + } +}; + +const _onDelete = (widgetId, title) => { + // eslint-disable-next-line no-alert + if (window.confirm(`Are you sure you want to remove the widget "${title}"?`)) { + WidgetActions.remove(widgetId); + } +}; + +const _onDuplicate = (widgetId, setFocusWidget, title) => { + WidgetActions.duplicate(widgetId).then((newWidget) => { + TitlesActions.set(TitleTypes.Widget, newWidget.id, `${title} (copy)`).then(() => { + setFocusWidget(undefined); + }); + }); +}; + +type Props = { + isFocused: boolean, + onPositionsChange: () => void, + position: WidgetPosition, + title: string, + toggleEdit: () => void + view: ViewStoreState, +}; + +const WidgetActionsMenu = ({ + isFocused, + onPositionsChange, + position, + title, + toggleEdit, + view, +}: Props) => { + const widget = useContext(WidgetContext); + const { setWidgetFocusing, unsetWidgetFocusing } = useContext(WidgetFocusContext); + const [showCopyToDashboard, setShowCopyToDashboard] = useState(false); + const [showExport, setShowExport] = useState(false); + const [showMoveWidgetToTab, setShowMoveWidgetToTab] = useState(false); + + const onDuplicate = () => _onDuplicate(widget.id, setWidgetFocusing, title); + const onCopyToDashboard = useCallback((widgetId, dashboardId) => _onCopyToDashboard(view, setShowCopyToDashboard, widgetId, dashboardId), [view]); + const onMoveWidgetToTab = useCallback((widgetId, queryId, keepCopy) => _onMoveWidgetToTab(view, setShowMoveWidgetToTab, widgetId, queryId, keepCopy), [view]); + + return ( + + + + + + {isFocused && ( + unsetWidgetFocusing()} /> + )} + {!isFocused && ( + <> + setWidgetFocusing(widget.id)} /> + + + )} + + + + Edit + + + Duplicate + + + setShowCopyToDashboard(true)}> + Copy to Dashboard + + + {widget.isExportable && setShowExport(true)}>Export} + + setShowMoveWidgetToTab(true)}> + Move to Page + + + {}} /> + + _onDelete(widget.id, title)}> + Delete + + + + {showCopyToDashboard && ( + setShowCopyToDashboard(false)} /> + )} + + {showExport && ( + setShowExport(false)} /> + )} + + {showMoveWidgetToTab && ( + setShowMoveWidgetToTab(false)} + onSubmit={onMoveWidgetToTab} /> + )} + + + ); +}; + +export default WidgetActionsMenu; diff --git a/graylog2-web-interface/src/views/components/widgets/WidgetFrame.jsx b/graylog2-web-interface/src/views/components/widgets/WidgetFrame.jsx index e4d066c800fe..72b09708df6a 100644 --- a/graylog2-web-interface/src/views/components/widgets/WidgetFrame.jsx +++ b/graylog2-web-interface/src/views/components/widgets/WidgetFrame.jsx @@ -21,7 +21,7 @@ import styled, { css } from 'styled-components'; const WidgetWrap = styled.div(({ theme }) => css` height: inherit; margin: 0; - padding: 20px; + padding: 12px 15px 15px 15px; display: grid; display: -ms-grid; grid-template-rows: auto minmax(10px, 1fr); diff --git a/graylog2-web-interface/src/views/components/widgets/__snapshots__/Widget.test.tsx.snap b/graylog2-web-interface/src/views/components/widgets/__snapshots__/Widget.test.tsx.snap deleted file mode 100644 index 6ca9c9e0042c..000000000000 --- a/graylog2-web-interface/src/views/components/widgets/__snapshots__/Widget.test.tsx.snap +++ /dev/null @@ -1,220 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` should render with empty props 1`] = ` -.c2 { - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-box-pack: center; - -webkit-justify-content: center; - -ms-flex-pack: center; - justify-content: center; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - height: 25px; - width: 25px; - border: 0; - background-color: transparent; - cursor: pointer; - color: #9b9b9b; - font-size: 1.125rem; -} - -.c2:hover { - background-color: #cdcdcd; -} - -.c2:active { - background-color: #b4b4b4; -} - -.c0 { - height: inherit; - margin: 0; - padding: 20px; - display: grid; - display: -ms-grid; - grid-template-rows: auto minmax(10px,1fr); - -ms-grid-rows: auto minmax(10px,1fr); - -ms-grid-columns: 1fr; -} - -.c0 .widget-top { - position: relative; - margin-bottom: -15px; - top: -5px; - font-size: 0.889rem; - line-height: 11px; -} - -.c0 .dc-chart { - float: none; -} - -.c0 .controls { - display: none; - position: relative; - left: -3px; -} - -.c0 .reloading { - margin-right: 2px; - font-weight: bold; - color: #0057a8; - display: none; -} - -.c0 .loading-failed { - color: #ad0707 !important; -} - -.c0 .widget-title { - font-size: 1.125rem; - height: 25px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.c0 .load-error { - color: #ad0707; - margin-right: 5px; -} - -.c0 .widget-update-info { - text-align: left; - float: left; - font-size: 0.889rem; - position: absolute; - bottom: 10px; - width: 130px; -} - -.c0 .configuration dt { - text-transform: capitalize; -} - -.c0 svg { - overflow: hidden; -} - -.c0 .quickvalues-graph { - text-align: center; -} - -.c0 .graph.scatterplot path.line { - display: none; -} - -.c0 .actions { - position: absolute; - right: 15px; - bottom: 10px; -} - -.c0 .actions div { - display: inline-block; - margin-left: 5px; -} - -.c0 .actions button { - padding: 0 5px 0 5px; -} - -.c0 .not-available { - font-size: 2.027rem; -} - -.c0 .loading, -.c0 .not-available { - line-height: 100px; - text-align: center; -} - -.c0 .loading .spinner, -.c0 .not-available .spinner { - vertical-align: middle; -} - -.c1 > * { - margin-right: 5px; -} - -.c1 > *:last-child { - margin-right: 0; -} - - -
-
- -
- - - - - - - - - -
-
-
- -
-
-
- -`;