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; -} - - -
-
- -
- - - - - - - - - -
-
-
- -
-
-
- -`;