Skip to content

Commit

Permalink
Feat/undo redo action (#15704)
Browse files Browse the repository at this point in the history
* create undo redo slice, add undo redo buttons

* add initial buffer to redux Provider

* No initial buffer in store

* fix tests

* add undo redo tests

* add undo redo tests

* fix test. add changelog

* add license

* use dispatch in handlers. Fix bug with dashboard page

* Create action section in sidebar

* fix bug on redo widget creation on focused widget

* simplify handlers

* Renaming buffer to revisions

* fix ts errors

* remove duplication of dispatch

* fix cycling dependencies

* Fix undo redo tests

---------

Co-authored-by: Dennis Oelkers <[email protected]>
  • Loading branch information
maxiadlovskii and dennisoelkers authored Jul 14, 2023
1 parent b2bcce0 commit 32e24eb
Show file tree
Hide file tree
Showing 22 changed files with 607 additions and 86 deletions.
4 changes: 4 additions & 0 deletions changelog/unreleased/issue-15713.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type = "added"
message = "Create undo/redo actions in search"

pulls = ["15704"]
44 changes: 33 additions & 11 deletions graylog2-web-interface/src/components/PluggableStoreProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,17 @@ import type View from 'views/logic/views/View';
import type { QueryId } from 'views/logic/queries/Query';
import type { QuerySet } from 'views/logic/search/Search';
import type SearchExecutionState from 'views/logic/search/SearchExecutionState';
import type { UndoRedoState } from 'views/logic/slices/undoRedoSlice';

type Props = {
initialQuery: QueryId,
isNew: boolean,
view: View,
executionState: SearchExecutionState,
undoRedoState?: UndoRedoState,
}

const PluggableStoreProvider = ({ initialQuery, children, isNew, view, executionState }: React.PropsWithChildren<Props>) => {
const PluggableStoreProvider = ({ initialQuery, children, isNew, view, executionState, undoRedoState }: React.PropsWithChildren<Props>) => {
const reducers = usePluginEntities('views.reducers');
const activeQuery = useMemo(() => {
const queries: QuerySet = view?.search?.queries ?? Immutable.Set();
Expand All @@ -44,16 +46,32 @@ const PluggableStoreProvider = ({ initialQuery, children, isNew, view, execution

return queries.first()?.id;
}, [initialQuery, view?.search?.queries]);
const initialState = useMemo(() => ({
view: { view, isDirty: false, isNew, activeQuery },
searchExecution: {
widgetsToSearch: undefined,
executionState,
isLoading: false,
result: undefined,
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [executionState, isNew, view]);
const initialState = useMemo(() => {
const undoRedo = undoRedoState ? {
undoRedo: undoRedoState,
} : {};

return ({
view: {
view,
isDirty:
false,
isNew,
activeQuery,
},
searchExecution: {
widgetsToSearch: undefined,
executionState,
isLoading:
false,
result:
undefined,
},
...undoRedo,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[executionState, isNew, view]);
const store = useMemo(() => createStore(reducers, initialState), [initialState, reducers]);

return (
Expand All @@ -63,4 +81,8 @@ const PluggableStoreProvider = ({ initialQuery, children, isNew, view, execution
);
};

PluggableStoreProvider.defaultProps = {
undoRedoState: undefined,
};

export default PluggableStoreProvider;
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { selectHighlightingRules } from 'views/logic/slices/highlightSelectors';
import HighlightingRule from 'views/logic/views/formatting/highlighting/HighlightingRule';
import { StaticColor } from 'views/logic/views/formatting/highlighting/HighlightingColor';
import ViewsBindings from 'views/bindings';
import { undoRedoSliceReducer } from 'views/logic/slices/undoRedoSlice';

jest.mock('stores/event-notifications/EventNotificationsStore', () => ({
EventNotificationsActions: {
Expand Down Expand Up @@ -93,6 +94,7 @@ describe('<EventInfoBar />', () => {
'views.reducers': [
{ key: 'view', reducer: viewSliceReducer },
{ key: 'searchExecution', reducer: searchExecutionSliceReducer },
{ key: 'undoRedo', reducer: undoRedoSliceReducer },
],
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { searchExecutionSliceReducer } from 'views/logic/slices/searchExecutionS
import type Search from 'views/logic/search/Search';
import View from 'views/logic/views/View';
import SearchExecutionState from 'views/logic/search/SearchExecutionState';
import { undoRedoSliceReducer } from 'views/logic/slices/undoRedoSlice';

import WidgetContext from './contexts/WidgetContext';
import WidgetQueryControls from './WidgetQueryControls';
Expand Down Expand Up @@ -108,6 +109,7 @@ describe('WidgetQueryControls pluggable controls', () => {
'views.reducers': [
{ key: 'view', reducer: viewSliceReducer },
{ key: 'searchExecution', reducer: searchExecutionSliceReducer },
{ key: 'undoRedo', reducer: undoRedoSliceReducer },
],
'views.components.searchBar': [
() => ({
Expand Down
26 changes: 17 additions & 9 deletions graylog2-web-interface/src/views/components/sidebar/NavItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,29 @@ import styled, { css } from 'styled-components';
import Icon from 'components/common/Icon';
import type { IconName } from 'components/common/Icon';

type Props = {
export type NavItemProps = {
isSelected: boolean,
title: string,
icon: IconName,
onClick: () => void,
showTitleOnHover: boolean,
showTitleOnHover?: boolean,
sidebarIsPinned: boolean,
disabled?: boolean,
};

type ContainerProps = {
isSelected: boolean,
sidebarIsPinned: boolean,
disabled: boolean,
};

const Container = styled.div<ContainerProps>(({ theme: { colors, fonts }, isSelected, sidebarIsPinned }) => css`
const Container = styled.div<ContainerProps>(({ theme: { colors, fonts }, isSelected, sidebarIsPinned, disabled }) => css`
position: relative;
z-index: 4; /* to render over SidebarNav::before */
width: 100%;
height: 40px;
text-align: center;
cursor: pointer;
cursor: ${disabled ? 'not-allowed' : 'pointer'};
font-size: ${fonts.size.h3};
color: ${colors.variant.darkest.default};
background: ${isSelected ? colors.gray[90] : colors.global.contentBackground};
Expand Down Expand Up @@ -84,14 +86,16 @@ type IconWrapProps = {
showTitleOnHover: boolean,
isSelected: boolean,
sidebarIsPinned: boolean,
$disabled: boolean,
}
const IconWrap = styled.span<IconWrapProps>(({ showTitleOnHover, isSelected, theme: { colors }, sidebarIsPinned }) => `
const IconWrap = styled.span<IconWrapProps>(({ showTitleOnHover, isSelected, $disabled, theme: { colors }, sidebarIsPinned }) => css`
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
position: relative;
opacity: ${$disabled ? 0.65 : 1};
:hover {
+ div {
Expand Down Expand Up @@ -141,15 +145,17 @@ const Title = styled.div(({ theme: { colors, fonts } }) => css`
}
`);

const NavItem = ({ isSelected, title, icon, onClick, showTitleOnHover, sidebarIsPinned }: Props) => (
const NavItem = ({ isSelected, title, icon, onClick, showTitleOnHover, sidebarIsPinned, disabled }: NavItemProps) => (
<Container aria-label={title}
isSelected={isSelected}
onClick={onClick}
onClick={!disabled ? onClick : undefined}
title={showTitleOnHover ? '' : title}
sidebarIsPinned={sidebarIsPinned}>
sidebarIsPinned={sidebarIsPinned}
disabled={disabled}>
<IconWrap showTitleOnHover={showTitleOnHover}
isSelected={isSelected}
sidebarIsPinned={sidebarIsPinned}>
sidebarIsPinned={sidebarIsPinned}
$disabled={disabled}>
<Icon name={icon} />
</IconWrap>
{(showTitleOnHover && !isSelected) && <Title><span>{title}</span></Title>}
Expand All @@ -162,11 +168,13 @@ NavItem.propTypes = {
showTitleOnHover: PropTypes.bool,
sidebarIsPinned: PropTypes.bool.isRequired,
title: PropTypes.string.isRequired,
disabled: PropTypes.bool,
};

NavItem.defaultProps = {
isSelected: false,
showTitleOnHover: true,
disabled: false,
};

export default NavItem;
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,16 @@ import useViewType from 'views/hooks/useViewType';
import useActiveQueryId from 'views/hooks/useActiveQueryId';
import useViewTitle from 'views/hooks/useViewTitle';
import useViewMetadata from 'views/hooks/useViewMetadata';
import TestStoreProvider from 'views/test/TestStoreProvider';
import { loadViewsPlugin, unloadViewsPlugin } from 'views/test/testViewsPlugin';

import Sidebar from './Sidebar';

jest.mock('util/AppConfig', () => ({
gl2AppPathPrefix: jest.fn(() => ''),
rootTimeZone: jest.fn(() => 'America/Chicago'),
gl2ServerUrl: jest.fn(() => undefined),
isCloud: jest.fn(() => false),
}));

jest.mock('views/hooks/useViewType');
Expand Down Expand Up @@ -68,6 +71,18 @@ describe('<Sidebar />', () => {

const TestComponent = () => <div id="martian">Marc Watney</div>;

const renderSidebar = () => render(
<TestStoreProvider>
<Sidebar results={queryResult}>
<TestComponent />
</Sidebar>
</TestStoreProvider>,
);

beforeAll(loadViewsPlugin);

afterAll(unloadViewsPlugin);

beforeEach(() => {
asMock(useActiveQueryId).mockReturnValue(queryId);
asMock(useViewMetadata).mockReturnValue(viewMetaData);
Expand All @@ -76,23 +91,15 @@ describe('<Sidebar />', () => {
it('should render and open when clicking on header', async () => {
asMock(useViewTitle).mockReturnValue(viewMetaData.title);

render(
<Sidebar results={queryResult}>
<TestComponent />
</Sidebar>,
);
renderSidebar();

fireEvent.click(await screen.findByTitle(/open sidebar/i));

await screen.findByText(viewMetaData.title);
});

it('should render with a description about the query results', async () => {
render(
<Sidebar results={queryResult}>
<TestComponent />
</Sidebar>,
);
renderSidebar();

fireEvent.click(await screen.findByTitle(/open sidebar/i));

Expand All @@ -102,11 +109,7 @@ describe('<Sidebar />', () => {
it('should render summary and description of a view', async () => {
asMock(useViewType).mockReturnValue(View.Type.Dashboard);

render((
<Sidebar results={queryResult}>
<TestComponent />
</Sidebar>
));
renderSidebar();

fireEvent.click(await screen.findByTitle(/open sidebar/i));

Expand All @@ -118,11 +121,7 @@ describe('<Sidebar />', () => {
asMock(useViewType).mockReturnValue(View.Type.Dashboard);
asMock(useViewMetadata).mockReturnValue({ ...viewMetaData, description: undefined, summary: undefined });

render((
<Sidebar results={queryResult}>
<TestComponent />
</Sidebar>
));
renderSidebar();

fireEvent.click(await screen.findByTitle(/open sidebar/i));

Expand All @@ -134,11 +133,7 @@ describe('<Sidebar />', () => {
asMock(useViewType).mockReturnValue(View.Type.Search);
asMock(useViewMetadata).mockReturnValue({ ...viewMetaData, description: undefined, summary: undefined });

render((
<Sidebar results={queryResult}>
<TestComponent />
</Sidebar>
));
renderSidebar();

fireEvent.click(await screen.findByTitle(/open sidebar/i));

Expand All @@ -149,11 +144,7 @@ describe('<Sidebar />', () => {
it('should render a summary and description, for a saved search', async () => {
asMock(useViewType).mockReturnValue(View.Type.Search);

render((
<Sidebar results={queryResult}>
<TestComponent />
</Sidebar>
));
renderSidebar();

fireEvent.click(await screen.findByTitle(/open sidebar/i));

Expand All @@ -164,11 +155,7 @@ describe('<Sidebar />', () => {
it('should not render a summary and description, if the view is an ad hoc search', async () => {
asMock(useViewMetadata).mockReturnValue({ ...viewMetaData, id: undefined });

render(
<Sidebar results={queryResult}>
<TestComponent />
</Sidebar>,
);
renderSidebar();

fireEvent.click(await screen.findByTitle(/open sidebar/i));

Expand All @@ -179,23 +166,15 @@ describe('<Sidebar />', () => {
});

it('should render widget create options', async () => {
render(
<Sidebar results={queryResult}>
<TestComponent />
</Sidebar>,
);
renderSidebar();

fireEvent.click(await screen.findByLabelText('Create'));

await screen.findByText('Predefined Aggregation');
});

it('should render passed children', async () => {
render(
<Sidebar results={queryResult}>
<TestComponent />
</Sidebar>,
);
renderSidebar();

fireEvent.click(await screen.findByLabelText('Fields'));

Expand All @@ -205,11 +184,7 @@ describe('<Sidebar />', () => {
it('should close a section when clicking on its title', async () => {
asMock(useViewType).mockReturnValue(View.Type.Search);

render((
<Sidebar results={queryResult}>
<TestComponent />
</Sidebar>
));
renderSidebar();

fireEvent.click(await screen.findByLabelText('Description'));

Expand All @@ -221,11 +196,7 @@ describe('<Sidebar />', () => {
});

it('should close an active section when clicking on its navigation item', async () => {
render(
<Sidebar results={queryResult}>
<TestComponent />
</Sidebar>,
);
renderSidebar();

fireEvent.click(await screen.findByLabelText('Fields'));

Expand Down
Loading

0 comments on commit 32e24eb

Please sign in to comment.