diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx index ef470c6e6e236..de56e07b01632 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx @@ -10,7 +10,6 @@ import { get } from 'lodash'; import React from 'react'; import ReactDOM from 'react-dom'; import { KibanaThemeProvider } from '../../../../../../../src/plugins/kibana_react/public'; -import { syncFilterExpression } from '../../../../public/lib/sync_filter_expression'; import { RendererFactory } from '../../../../types'; import { StartInitializer } from '../../../plugin'; import { DropdownFilter } from './component'; @@ -56,14 +55,6 @@ export const dropdownFilterFactory: StartInitializer> = ) { filterExpression = ''; handlers.setFilter(filterExpression); - } else if (filterExpression !== '') { - // NOTE: setFilter() will cause a data refresh, avoid calling unless required - // compare expression and filter, update filter if needed - const { changed, newAst } = syncFilterExpression(config, filterExpression, ['filterGroup']); - - if (changed) { - handlers.setFilter(toExpression(newAst)); - } } const commit = (commitValue: string) => { @@ -79,7 +70,7 @@ export const dropdownFilterFactory: StartInitializer> = arguments: { value: [commitValue], column: [config.column], - filterGroup: [config.filterGroup], + ...(config.filterGroup ? { filterGroup: [config.filterGroup] } : {}), }, }, ], diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/components/time_filter.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/components/time_filter.tsx index a8343eed272b5..ce5c8613fc57b 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/components/time_filter.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/components/time_filter.tsx @@ -32,14 +32,17 @@ export interface FilterMeta { start: string; /** End date string of filtered date range */ end: string; + filterGroup: string; } function getFilterMeta(filter: string): FilterMeta { const ast = fromExpression(filter); + const column = get(ast, 'chain[0].arguments.column[0]') as string; const start = get(ast, 'chain[0].arguments.from[0]') as string; const end = get(ast, 'chain[0].arguments.to[0]') as string; - return { column, start, end }; + const filterGroup = get(ast, 'chain[0].arguments.filterGroup[0]') as string; + return { column, start, end, filterGroup }; } export interface Props { @@ -55,12 +58,14 @@ export interface Props { export const TimeFilter = ({ filter, commit, dateFormat, commonlyUsedRanges = [] }: Props) => { const setFilter = - (column: string) => + (column: string, filterGroup: string) => ({ start, end }: OnTimeChangeProps) => { - commit(`timefilter from="${start}" to=${end} column=${column}`); + const filterExpression = `timefilter from="${start}" to=${end} column=${column}`; + const filterGroupArg = filterGroup ? `filterGroup="${filterGroup}"` : ''; + commit(`${filterExpression} ${filterGroupArg}`); }; - const { column, start, end } = getFilterMeta(filter); + const { column, start, end, filterGroup } = getFilterMeta(filter); return (
@@ -68,7 +73,7 @@ export const TimeFilter = ({ filter, commit, dateFormat, commonlyUsedRanges = [] start={start} end={end} isPaused={false} - onTimeChange={setFilter(column)} + onTimeChange={setFilter(column, filterGroup)} showUpdateButton={false} dateFormat={dateFormat} commonlyUsedRanges={commonlyUsedRanges.length ? commonlyUsedRanges : defaultQuickRanges} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/index.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/index.tsx index 884f960e85558..318f9c7ecaa40 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/index.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/index.tsx @@ -7,10 +7,8 @@ import ReactDOM from 'react-dom'; import React from 'react'; -import { toExpression } from '@kbn/interpreter/common'; import { UI_SETTINGS } from '../../../../../../../src/plugins/data/public'; import { KibanaThemeProvider } from '../../../../../../../src/plugins/kibana_react/public'; -import { syncFilterExpression } from '../../../../public/lib/sync_filter_expression'; import { RendererStrings } from '../../../../i18n'; import { TimeFilter } from './components'; import { StartInitializer } from '../../../plugin'; @@ -46,17 +44,6 @@ export const timeFilterFactory: StartInitializer> = ( if (filterExpression === undefined || filterExpression.indexOf('timefilter') !== 0) { filterExpression = defaultTimeFilterExpression; handlers.setFilter(filterExpression); - } else if (filterExpression !== '') { - // NOTE: setFilter() will cause a data refresh, avoid calling unless required - // compare expression and filter, update filter if needed - const { changed, newAst } = syncFilterExpression(config, filterExpression, [ - 'column', - 'filterGroup', - ]); - - if (changed) { - handlers.setFilter(toExpression(newAst)); - } } ReactDOM.render( diff --git a/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/__snapshots__/filter.component.stories.storyshot b/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/__snapshots__/filter.component.stories.storyshot index 02d7519378ec6..5c463bc26e16d 100644 --- a/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/__snapshots__/filter.component.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/__snapshots__/filter.component.stories.storyshot @@ -433,3 +433,383 @@ exports[`Storyshots components/WorkpadFilters/FilterComponent with custom filter
`; + +exports[`Storyshots components/WorkpadFilters/FilterComponent/filter_views default 1`] = ` +
+
+
+
+

+ Column +

+
+
+
+
+ project +
+
+
+
+

+ Value +

+
+
+
+
+ kibana +
+
+
+
+

+ Type +

+
+
+
+
+ Dropdown +
+
+
+
+

+ Filter group +

+
+
+
+
+
+
+ +
+ + +
+
+
+
+
+
+
+`; + +exports[`Storyshots components/WorkpadFilters/FilterComponent/filter_views time 1`] = ` +
+
+
+
+

+ Column +

+
+
+
+
+ @timestamp +
+
+
+
+

+ From +

+
+
+
+
+ 2011-09-10 14:48:00 +
+
+
+
+

+ To +

+
+
+
+
+ 2011-10-10 14:48:00 +
+
+
+
+

+ Type +

+
+
+
+
+ Time +
+
+
+
+

+ Filter group +

+
+
+
+
+
+
+ +
+ + +
+
+
+
+
+
+
+`; \ No newline at end of file diff --git a/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/__snapshots__/workpad_filters.stories.storyshot b/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/__snapshots__/workpad_filters.stories.storyshot new file mode 100644 index 0000000000000..bd47798a7de7f --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/__snapshots__/workpad_filters.stories.storyshot @@ -0,0 +1,395 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots components/WorkpadFilters/WorkpadFilters default 1`] = ` +
+
+
+
+
+
+
+
+ Group by +
+
+
+
+
+
+ +
+ + +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+`; + +exports[`Storyshots components/WorkpadFilters/WorkpadFilters selected element with group 1`] = ` +
+
+
+
+
+
+
+
+ Group by +
+
+
+
+
+
+ +
+ + +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+`; diff --git a/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/elements.ts b/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/elements.ts index 56df931ffade5..2188d1583c44a 100644 --- a/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/elements.ts +++ b/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/elements.ts @@ -23,13 +23,15 @@ const time1 = { from: moment('1.01.2021 8:15', timeFormat).format(), to: moment('2.01.2021 17:22', timeFormat).format(), }; -const group1 = 'Group 1'; + +export const group1 = 'Group 1'; const time2 = { from: moment('1.10.2021 12:20', timeFormat).format(), to: moment('2.10.2021 12:33', timeFormat).format(), }; -const group2 = 'Group 2'; + +export const group2 = 'Group 2'; export const element: CanvasElement = { id: '0', diff --git a/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/filter.component.stories.tsx b/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/filter.component.stories.tsx index dded39e2b7e1d..4220568bbdd25 100644 --- a/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/filter.component.stories.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/filter.component.stories.tsx @@ -5,11 +5,15 @@ * 2.0. */ +import React, { FC } from 'react'; import { EuiText, EuiTextColor } from '@elastic/eui'; import { storiesOf } from '@storybook/react'; -import React, { FC } from 'react'; -import { FormattedFilterViewInstance } from '../../../../types'; +import { action } from '@storybook/addon-actions'; +import { Filter as FilterType, FormattedFilterViewInstance } from '../../../../types'; +import { createFilledFilterView } from '../../../lib/filter'; import { Filter } from '../filter.component'; +import { filterViews } from '../filter_views'; +import { group1, group2 } from './elements'; const filter: FormattedFilterViewInstance = { type: { @@ -30,6 +34,24 @@ const filter: FormattedFilterViewInstance = { }, }; +const defaultFilter: FilterType = { + id: 0, + type: 'exactly', + column: 'project', + value: 'kibana', + filterGroup: group1, +}; + +const timeFilter: FilterType = { + id: 0, + type: 'time', + column: '@timestamp', + value: { from: '2011-09-10T14:48:00', to: '2011-10-10T14:48:00' }, + filterGroup: group2, +}; + +const groups = [group1, group2]; + const component: FC = ({ value }) => ( @@ -39,15 +61,33 @@ const component: FC = ({ value }) => ( ); storiesOf('components/WorkpadFilters/FilterComponent', module) - .add('default', () => ) + .add('default', () => ) .add('with component field', () => ( - + )) .add('with custom filter fields', () => ( )); + +storiesOf('components/WorkpadFilters/FilterComponent/filter_views', module) + .add('default', () => ( + + )) + .add('time', () => ( + + )); diff --git a/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/filters_group.component.stories.tsx b/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/filters_group.component.stories.tsx index bdeb963dc8832..aac18afc11858 100644 --- a/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/filters_group.component.stories.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/filters_group.component.stories.tsx @@ -10,38 +10,41 @@ import React from 'react'; import moment from 'moment'; import { FiltersGroup } from '../filters_group.component'; import { FiltersGroup as FiltersGroupType } from '../types'; +import { reduxDecorator } from '../../../../storybook'; +import { elements, group1 } from './elements'; const timeFormat = 'MM.dd.YYYY HH:mm'; const filtersGroup: FiltersGroupType = { - name: 'Group 1', + name: group1, filters: [ - { type: 'exactly', column: 'project', value: 'kibana', filterGroup: 'Group 1' }, + { id: '0', type: 'exactly', column: 'project', value: 'kibana', filterGroup: group1 }, { + id: '1', type: 'time', column: '@timestamp', value: { from: moment('1.01.2021 8:15', timeFormat).format(), to: moment('2.01.2021 17:22', timeFormat).format(), }, - filterGroup: 'Group 1', + filterGroup: group1, }, - { type: 'exactly', column: 'country', value: 'US', filterGroup: 'Group 1' }, + { id: '2', type: 'exactly', column: 'country', value: 'US', filterGroup: group1 }, { + id: '3', type: 'time', column: 'time', value: { from: moment('05.21.2021 10:50', timeFormat).format(), to: moment('05.22.2021 4:40', timeFormat).format(), }, - filterGroup: 'Group 1', + filterGroup: group1, }, ], }; storiesOf('components/WorkpadFilters/FiltersGroupComponent', module) + .addDecorator(reduxDecorator({ elements })) .addDecorator((story) =>
{story()}
) .add('default', () => ) - .add('empty group', () => ( - - )); + .add('empty group', () => ); diff --git a/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/workpad_filters.component.stories.tsx b/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/workpad_filters.component.stories.tsx index 8dc062886a12e..c2ab3bcbf0188 100644 --- a/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/workpad_filters.component.stories.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/workpad_filters.component.stories.tsx @@ -12,29 +12,33 @@ import moment from 'moment'; import { WorkpadFilters } from '../workpad_filters.component'; import { FiltersGroup as FiltersGroupType } from '../types'; import { Filter } from '../../../../types'; +import { reduxDecorator } from '../../../../storybook'; +import { elements, group1, group2 } from './elements'; const timeFormat = 'MM.dd.YYYY HH:mm'; const filters: Filter[] = [ - { type: 'exactly', column: 'project', value: 'kibana', filterGroup: 'Group 1' }, + { id: '0', type: 'exactly', column: 'project', value: 'kibana', filterGroup: group1 }, { + id: '1', type: 'time', column: '@timestamp', value: { from: moment('1.01.2021 8:15', timeFormat).format(), to: moment('2.01.2021 17:22', timeFormat).format(), }, - filterGroup: 'Group 1', + filterGroup: group1, }, - { type: 'exactly', column: 'country', value: 'US', filterGroup: 'Group 2' }, + { id: '2', type: 'exactly', column: 'country', value: 'US', filterGroup: group2 }, { + id: '3', type: 'time', column: 'time', value: { from: moment('05.21.2021 10:50', timeFormat).format(), to: moment('05.22.2021 4:40', timeFormat).format(), }, - filterGroup: 'Group 2', + filterGroup: group2, }, ]; @@ -50,6 +54,7 @@ const filtersGroups: FiltersGroupType[] = [ ]; storiesOf('components/WorkpadFilters/WorkpadFiltersComponent', module) + .addDecorator(reduxDecorator({ elements })) .addDecorator((story) => (
@@ -77,7 +82,13 @@ storiesOf('components/WorkpadFilters/WorkpadFiltersComponent', module) filtersGroups={[ { name: null, - filters: filtersGroups.reduce((acc, group) => [...acc, ...group.filters], []), + filters: filtersGroups.reduce( + (acc, group) => [ + ...acc, + ...group.filters.map(({ filterGroup, ...rest }) => ({ ...rest, filterGroup: null })), + ], + [] + ), }, ]} groupFiltersByField={'filterGroup'} diff --git a/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/workpad_filters.stories.tsx b/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/workpad_filters.stories.tsx index b477ac220f6a9..db364791c0931 100644 --- a/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/workpad_filters.stories.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_filters/__stories__/workpad_filters.stories.tsx @@ -20,5 +20,5 @@ storiesOf('components/WorkpadFilters/WorkpadFilters', module)
)) .addDecorator(reduxDecorator({ elements })) - .add('redux: default', () => ) - .add('redux: selected element with group', () => ); + .add('default', () => ) + .add('selected element with group', () => ); diff --git a/x-pack/plugins/canvas/public/components/workpad_filters/filter.component.tsx b/x-pack/plugins/canvas/public/components/workpad_filters/filter.component.tsx index bec6bec090d62..b923e69c41398 100644 --- a/x-pack/plugins/canvas/public/components/workpad_filters/filter.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_filters/filter.component.tsx @@ -7,14 +7,27 @@ import React, { FC } from 'react'; import { EuiDescriptionList, EuiPanel, EuiText } from '@elastic/eui'; -import { FormattedFilterViewInstance } from '../../../types'; +import { + FormattedFilterViewInstance, + Filter as FilterType, + FilterFieldProps, +} from '../../../types'; -interface Props { - filter: FormattedFilterViewInstance; +interface InteractiveFilterProps { + filterView: FormattedFilterViewInstance; + filter: FilterType; + filterGroups: string[]; + updateFilter: (value: any) => void; +} + +interface StaticFilterProps { + filterView: FormattedFilterViewInstance; updateFilter?: (value: any) => void; + filter?: FilterType; + filterGroups?: string[]; } -type CustomComponentProps = Omit & { value: string }; +type Props = InteractiveFilterProps | StaticFilterProps; const titleStyle = { width: '30%', @@ -24,19 +37,17 @@ const descriptionStyle = { width: '70%', }; -const renderElement = ( - Component: FC< - Omit & { onChange?: CustomComponentProps['updateFilter'] } - >, - { updateFilter, ...props }: CustomComponentProps -) => { - return ; -}; +const renderElement = (Component: FC, props: FilterFieldProps) => ( + +); -export const Filter: FC = ({ filter, ...restProps }) => { - const filterView = Object.values(filter).map((filterValue) => { +export const Filter: FC = ({ filterView, ...restProps }) => { + const view = Object.values(filterView).map((filterValue) => { const description = filterValue.component - ? renderElement(filterValue.component, { value: filterValue.formattedValue, ...restProps }) + ? renderElement(filterValue.component, { + ...(restProps as InteractiveFilterProps), + value: filterValue.formattedValue, + }) : filterValue.formattedValue; return { @@ -55,7 +66,7 @@ export const Filter: FC = ({ filter, ...restProps }) => { type="column" className="workpadFilter" compressed - listItems={filterView} + listItems={view} titleProps={{ style: titleStyle, className: 'eui-textBreakWord' }} descriptionProps={{ style: descriptionStyle, className: 'eui-textBreakWord' }} /> diff --git a/x-pack/plugins/canvas/public/components/workpad_filters/filter.tsx b/x-pack/plugins/canvas/public/components/workpad_filters/filter.tsx new file mode 100644 index 0000000000000..2d9f006edcbd4 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_filters/filter.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { useSelector } from 'react-redux'; +import { FormattedFilterViewInstance, Filter as FilterType, State } from '../../../types'; +import { getGlobalFilterGroups } from '../../state/selectors/workpad'; +import { useCanvasFiltersActions } from './hooks'; +import { Filter as Component } from './filter.component'; + +interface Props { + filterView: FormattedFilterViewInstance; + filter: FilterType; +} + +const StaticFilter: FC = (props) => ; + +const InteractiveFilter: FC = (props) => { + const filterGroups = useSelector((state) => getGlobalFilterGroups(state)); + const { updateFilter } = useCanvasFiltersActions(); + + return ; +}; + +export const Filter: FC = (props) => { + const { filterView } = props; + + const isInteractive = Object.values(filterView).some(({ component }) => component); + if (isInteractive) { + return ; + } + + return ; +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_filters/filter_views/default_filter.ts b/x-pack/plugins/canvas/public/components/workpad_filters/filter_views/default_filter.tsx similarity index 51% rename from x-pack/plugins/canvas/public/components/workpad_filters/filter_views/default_filter.ts rename to x-pack/plugins/canvas/public/components/workpad_filters/filter_views/default_filter.tsx index b2686fb660535..563887fd65d60 100644 --- a/x-pack/plugins/canvas/public/components/workpad_filters/filter_views/default_filter.ts +++ b/x-pack/plugins/canvas/public/components/workpad_filters/filter_views/default_filter.tsx @@ -5,8 +5,10 @@ * 2.0. */ +import React, { FC } from 'react'; +import { EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FilterViewSpec } from '../../../../types'; +import { FilterFieldProps, FilterViewSpec } from '../../../../types'; import { formatByKey } from '../utils'; const strings = { @@ -26,6 +28,35 @@ const strings = { i18n.translate('xpack.canvas.workpadFilters.defaultFilter.value', { defaultMessage: 'Value', }), + getNoGroupLabel: () => + i18n.translate('xpack.canvas.workpad_filters.defaultFilter.filterGroupsComponent.noGroup', { + defaultMessage: 'No group', + }), +}; + +const NO_GROUP = 'no_group'; + +const GroupComponent: FC = ({ filter, filterGroups = [], updateFilter }) => { + const { filterGroup } = filter; + + const uniqueGroups = [...new Set([undefined, ...filterGroups])]; + const groups = uniqueGroups.map((group) => ({ + text: group ?? strings.getNoGroupLabel(), + value: group ?? NO_GROUP, + })); + + return ( + { + const selectedGroup = value === NO_GROUP ? null : value; + updateFilter?.({ ...filter, filterGroup: selectedGroup }); + }} + aria-label="Change filter group" + /> + ); }; export const defaultFilter: FilterViewSpec = { @@ -34,6 +65,6 @@ export const defaultFilter: FilterViewSpec = { column: { label: strings.getColumnLabel() }, value: { label: strings.getValueLabel() }, type: { label: strings.getTypeLabel(), formatter: formatByKey('type') }, - filterGroup: { label: strings.getFilterGroupLabel() }, + filterGroup: { label: strings.getFilterGroupLabel(), component: GroupComponent }, }, }; diff --git a/x-pack/plugins/canvas/public/components/workpad_filters/filter_views/time_filter.ts b/x-pack/plugins/canvas/public/components/workpad_filters/filter_views/time_filter.ts index 1dc02f61d05f7..7f42c9250b43f 100644 --- a/x-pack/plugins/canvas/public/components/workpad_filters/filter_views/time_filter.ts +++ b/x-pack/plugins/canvas/public/components/workpad_filters/filter_views/time_filter.ts @@ -37,12 +37,12 @@ const strings = { const { column, type, filterGroup } = defaultFilter.view; const formatTime = (str: string, roundUp: boolean) => { - const moment = dateMath.parse(str, { roundUp }); - if (!moment || !moment.isValid()) { + const m = dateMath.parse(str, { roundUp }); + if (!m || !m.isValid()) { return strings.getInvalidDateLabel(str); } - return moment.format('YYYY-MM-DD HH:mm:ss'); + return m.format('YYYY-MM-DD HH:mm:ss'); }; export const timeFilter: FilterViewSpec = { diff --git a/x-pack/plugins/canvas/public/components/workpad_filters/filters_group.component.tsx b/x-pack/plugins/canvas/public/components/workpad_filters/filters_group.component.tsx index 8ceb60fe7866f..996baeb04f6c9 100644 --- a/x-pack/plugins/canvas/public/components/workpad_filters/filters_group.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_filters/filters_group.component.tsx @@ -7,9 +7,8 @@ import { EuiAccordion } from '@elastic/eui'; import React, { FC } from 'react'; -import { FormattedFilterViewInstance } from '../../../types'; import { createFilledFilterView } from '../../lib/filter'; -import { Filter } from './filter.component'; +import { Filter } from './filter'; import { filterViews } from './filter_views'; import { FiltersGroup as FiltersGroupType } from './types'; @@ -25,13 +24,16 @@ const panelStyle = { export const FiltersGroup: FC = ({ filtersGroup, id }) => { const { name, filters: groupFilters } = filtersGroup; - const filledFilterViews: FormattedFilterViewInstance[] = groupFilters.map((filter) => { + const filledFilterViews = groupFilters.map((filter) => { const filterView = filterViews[filter.type] ?? filterViews.default; - return createFilledFilterView(filterView.view, filter); + return { + filter, + filterView: createFilledFilterView(filterView.view, filter), + }; }); - const filtersComponents = filledFilterViews.map((filter, index) => ( - + const filtersComponents = filledFilterViews.map((filterWithView, index) => ( + )); return ( diff --git a/x-pack/plugins/canvas/public/components/workpad_filters/hooks/index.ts b/x-pack/plugins/canvas/public/components/workpad_filters/hooks/index.ts index 62f2a28130bfa..efe33ad0f139e 100644 --- a/x-pack/plugins/canvas/public/components/workpad_filters/hooks/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_filters/hooks/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { useCanvasFilters } from './use_canvas_filters'; +export { useCanvasFilters, useCanvasFiltersActions } from './use_canvas_filters'; diff --git a/x-pack/plugins/canvas/public/components/workpad_filters/hooks/use_canvas_filters.ts b/x-pack/plugins/canvas/public/components/workpad_filters/hooks/use_canvas_filters.ts index 10643e729d837..cad7d843c7afc 100644 --- a/x-pack/plugins/canvas/public/components/workpad_filters/hooks/use_canvas_filters.ts +++ b/x-pack/plugins/canvas/public/components/workpad_filters/hooks/use_canvas_filters.ts @@ -6,21 +6,44 @@ */ import { fromExpression } from '@kbn/interpreter/common'; -import { shallowEqual, useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import { useCallback } from 'react'; +import deepEqual from 'react-fast-compare'; import { State } from '../../../../types'; import { getFiltersByGroups } from '../../../lib/filter'; -import { adaptCanvasFilter } from '../../../lib/filter_adapters'; -import { getGlobalFilters } from '../../../state/selectors/workpad'; +import { adaptCanvasFilter, adaptFilterToExpression } from '../../../lib/filter_adapters'; +import { getGlobalFiltersWithIds } from '../../../state/selectors/workpad'; +// @ts-expect-error untyped local +import { setFilter } from '../../../state/actions/elements'; const extractExpressionAST = (filtersExpressions: string[]) => fromExpression(filtersExpressions.join(' | ')); export function useCanvasFilters(groups: string[] = [], ungrouped: boolean = false) { - const filterExpressions = useSelector((state: State) => getGlobalFilters(state), shallowEqual); + const filterExpressions = useSelector( + (state: State) => getGlobalFiltersWithIds(state), + deepEqual + ); const filtersByGroups = getFiltersByGroups(filterExpressions, groups, ungrouped); - const expression = extractExpressionAST(filtersByGroups); - const filters = expression.chain.map(adaptCanvasFilter); - + const expression = extractExpressionAST(filtersByGroups.map(({ filter }) => filter)); + const filters = expression.chain.map((filter, index) => + adaptCanvasFilter(filter, filtersByGroups[index].id) + ); return filters; } + +export function useCanvasFiltersActions() { + const dispatch = useDispatch(); + const updateFilter = useCallback( + (filter) => { + const filterExpression = adaptFilterToExpression(filter); + dispatch(setFilter(filterExpression, filter.id)); + }, + [dispatch] + ); + + return { + updateFilter, + }; +} diff --git a/x-pack/plugins/canvas/public/functions/filters.ts b/x-pack/plugins/canvas/public/functions/filters.ts index ca2cede4e8555..e410a37a44fa2 100644 --- a/x-pack/plugins/canvas/public/functions/filters.ts +++ b/x-pack/plugins/canvas/public/functions/filters.ts @@ -15,7 +15,6 @@ import { getGlobalFilters, getWorkpadVariablesAsObject } from '../state/selector import { ExpressionValueFilter } from '../../types'; import { getFunctionHelp } from '../../i18n'; import { InitializeArguments } from '.'; -import { getFiltersByGroups } from '../lib/filter'; export interface Arguments { group: string[]; @@ -36,7 +35,11 @@ function getFiltersByGroup(allFilters: string[], groups?: string[], ungrouped = }); } - return getFiltersByGroups(allFilters, groups); + return allFilters.filter((filter) => { + const ast = fromExpression(filter); + const expGroups: string[] = get(ast, 'chain[0].arguments.filterGroup', []); + return expGroups.length > 0 && expGroups.every((expGroup) => groups.includes(expGroup)); + }); } type FiltersFunction = ExpressionFunctionDefinition< diff --git a/x-pack/plugins/canvas/public/lib/filter.test.ts b/x-pack/plugins/canvas/public/lib/filter.test.ts index bf19bd6ecf4b8..f1ef7ff805700 100644 --- a/x-pack/plugins/canvas/public/lib/filter.test.ts +++ b/x-pack/plugins/canvas/public/lib/filter.test.ts @@ -28,6 +28,7 @@ const formatterFactory = (value: unknown) => () => JSON.stringify(value); const fc: FC = () => null; const simpleFilterValue: FilterType = { + id: 0, type: 'exactly', column: 'project', value: 'kibana', @@ -35,6 +36,7 @@ const simpleFilterValue: FilterType = { }; const filterWithNestedValue: FilterType = { + id: 1, type: 'exactlyNested' as any, column: 'project', value: { nestedField1: 'nestedField1', nestedField2: 'nestedField2' }, @@ -226,15 +228,17 @@ describe('createFilledFilterView', () => { describe('groupFiltersBy', () => { const filters: FilterType[] = [ - { type: 'exactly', column: 'project', value: 'kibana', filterGroup: 'someGroup' }, + { id: 0, type: 'exactly', column: 'project', value: 'kibana', filterGroup: 'someGroup' }, { + id: 1, type: 'time', column: '@timestamp', value: { from: 'some time', to: 'some time' }, filterGroup: 'someGroup2', }, - { type: 'exactly', column: 'country', value: 'US', filterGroup: 'someGroup2' }, + { id: 3, type: 'exactly', column: 'country', value: 'US', filterGroup: 'someGroup2' }, { + id: 4, type: 'time', column: 'time', value: { from: 'some time', to: 'some time' }, @@ -290,10 +294,10 @@ describe('getFiltersByGroups', () => { const group2 = 'Group 2'; const filters = [ - `exactly value="x-pack" column="project1" filterGroup="${group1}"`, - `exactly value="beats" column="project1" filterGroup="${group2}"`, - `exactly value="machine-learning" column="project1"`, - `exactly value="kibana" column="project2" filterGroup="${group2}"`, + { id: '0', filter: `exactly value="x-pack" column="project1" filterGroup="${group1}"` }, + { id: '1', filter: `exactly value="beats" column="project1" filterGroup="${group2}"` }, + { id: '2', filter: `exactly value="machine-learning" column="project1"` }, + { id: '3', filter: `exactly value="kibana" column="project2" filterGroup="${group2}"` }, ]; it('returns all filters related to a specified groups', () => { diff --git a/x-pack/plugins/canvas/public/lib/filter.ts b/x-pack/plugins/canvas/public/lib/filter.ts index 56f6558eff48b..b722ff0f4f0b8 100644 --- a/x-pack/plugins/canvas/public/lib/filter.ts +++ b/x-pack/plugins/canvas/public/lib/filter.ts @@ -8,6 +8,7 @@ import { fromExpression } from '@kbn/interpreter/common'; import { flowRight, get, groupBy } from 'lodash'; import { + CanvasFilterExpression, Filter as FilterType, FilterField, FilterViewInstance, @@ -56,11 +57,11 @@ export const groupFiltersBy = (filters: FilterType[], groupByField: FilterField) }; export const getFiltersByGroups = ( - filters: string[], + filters: CanvasFilterExpression[], groups: string[], ungrouped: boolean = false ) => - filters.filter((filter: string) => { + filters.filter(({ filter }) => { const ast = fromExpression(filter); const expGroups: string[] = get(ast, 'chain[0].arguments.filterGroup', []); if (!groups?.length && ungrouped) { diff --git a/x-pack/plugins/canvas/public/lib/filter_adapters.test.ts b/x-pack/plugins/canvas/public/lib/filter_adapters.test.ts index 5061e47a44347..a2cee09a5127b 100644 --- a/x-pack/plugins/canvas/public/lib/filter_adapters.test.ts +++ b/x-pack/plugins/canvas/public/lib/filter_adapters.test.ts @@ -6,7 +6,12 @@ */ import { ExpressionFunctionAST } from '@kbn/interpreter/common'; -import { adaptCanvasFilter } from './filter_adapters'; +import { Filter } from '../../types'; +import { + adaptCanvasFilter, + adaptFilterToExpression, + tranformObjectToArgs, +} from './filter_adapters'; describe('adaptCanvasFilter', () => { const filterAST: ExpressionFunctionAST = { @@ -20,10 +25,18 @@ describe('adaptCanvasFilter', () => { }, }; + const id = '0'; + it('returns filter when AST arguments consists of arrays with one element', () => { - const resultFilter = { type: 'exactly', column: 'project', filterGroup: null, value: 'kibana' }; + const resultFilter = { + id, + type: 'exactly', + column: 'project', + filterGroup: null, + value: 'kibana', + }; - const filter = adaptCanvasFilter(filterAST); + const filter = adaptCanvasFilter(filterAST, id); expect(filter).toEqual(resultFilter); }); @@ -33,13 +46,14 @@ describe('adaptCanvasFilter', () => { const newFilterAST = { ...filterAST, arguments: { ...rest, ...additionalArguments } }; const resultFilter = { + id, type: 'exactly', column: 'project', filterGroup: null, value: { value1: 'value1', value2: 'value2' }, }; - const filter = adaptCanvasFilter(newFilterAST); + const filter = adaptCanvasFilter(newFilterAST, id); expect(filter).toEqual(resultFilter); }); @@ -47,13 +61,94 @@ describe('adaptCanvasFilter', () => { const { arguments: args, ...rest } = filterAST; const resultFilter = { + id, type: 'exactly', column: null, filterGroup: null, value: null, }; - const filter = adaptCanvasFilter({ ...rest, arguments: {} }); + const filter = adaptCanvasFilter({ ...rest, arguments: {} }, id); expect(filter).toEqual(resultFilter); }); }); + +describe('tranformObjectToArgs', () => { + const obj = { prop1: 1, prop2: 'prop', prop3: null }; + it('should return valid args object for plain values', () => { + expect(tranformObjectToArgs(obj)).toEqual({ + prop1: [1], + prop2: ['prop'], + prop3: [null], + }); + }); + + it('should return valid args object for array values', () => { + const prop3 = [1, '3', null]; + expect(tranformObjectToArgs({ ...obj, prop3 })).toEqual({ + prop1: [1], + prop2: ['prop'], + prop3, + }); + }); + + it('should return empty object if empty object was passed', () => { + expect(tranformObjectToArgs({})).toEqual({}); + }); +}); + +describe('adaptFilterToExpression', () => { + const id = '0'; + + it('should return exactly filter expression', () => { + const filter: Filter = { + id, + type: 'exactly', + column: 'project', + filterGroup: null, + value: 'kibana', + }; + expect(adaptFilterToExpression(filter)).toBe( + `exactly column="project" filterGroup=null value="kibana"` + ); + }); + + it('should return time filter expression', () => { + const filter: Filter = { + id, + type: 'time', + column: '@timestamp', + filterGroup: 'Group 1', + value: { from: '2.10.2021 12:33', to: '2.10.2021 12:33' }, + }; + expect(adaptFilterToExpression(filter)).toEqual( + `timefilter column="@timestamp" filterGroup="Group 1" from="2.10.2021 12:33" to="2.10.2021 12:33"` + ); + }); + + it('should return time filter expression for invalid time', () => { + const filter: Filter = { + id, + type: 'time', + column: '@timestamp', + filterGroup: 'Group 1', + value: { from: 'some time', to: 'some time 2' }, + }; + expect(adaptFilterToExpression(filter)).toEqual( + `timefilter column="@timestamp" filterGroup="Group 1" from="some time" to="some time 2"` + ); + }); + + it('should return expression with random arguments', () => { + const filter: any = { + id, + type: 'exactly', + column: '@timestamp', + filterGroup: 'Group 1', + value: { val1: 'some time', val2: null }, + }; + expect(adaptFilterToExpression(filter)).toEqual( + `exactly column="@timestamp" filterGroup="Group 1" val1="some time" val2=null` + ); + }); +}); diff --git a/x-pack/plugins/canvas/public/lib/filter_adapters.ts b/x-pack/plugins/canvas/public/lib/filter_adapters.ts index 478b0a5302631..e75dd26d49040 100644 --- a/x-pack/plugins/canvas/public/lib/filter_adapters.ts +++ b/x-pack/plugins/canvas/public/lib/filter_adapters.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ExpressionFunctionAST } from '@kbn/interpreter/common'; +import { Ast, ExpressionFunctionAST, toExpression } from '@kbn/interpreter/common'; import { identity } from 'lodash'; import { ExpressionAstArgument, Filter, FilterType } from '../../types'; @@ -14,6 +14,12 @@ const functionToFilter: Record = { exactly: FilterType.exactly, }; +const filterToFunction: Record = { + [FilterType.time]: 'timefilter', + [FilterType.exactly]: 'exactly', + [FilterType.luceneQueryString]: 'lucene', +}; + const defaultFormatter = (arg: ExpressionAstArgument) => arg.toString(); const argToValue = ( @@ -36,13 +42,43 @@ const collectArgs = (args: ExpressionFunctionAST['arguments']) => { ); }; -export function adaptCanvasFilter(filter: ExpressionFunctionAST): Filter { +export function adaptCanvasFilter(filter: ExpressionFunctionAST, id: string | number): Filter { const { function: type, arguments: args } = filter; const { column, filterGroup, value: valueArg, type: typeArg, ...rest } = args ?? {}; return { + id, type: convertFunctionToFilterType(type), column: argToValue(column), filterGroup: argToValue(filterGroup), value: argToValue(valueArg) ?? collectArgs(rest), }; } + +export const tranformObjectToArgs = (obj: Record) => + Object.keys(obj).reduce>>((args, key) => { + const value = obj[key]; + const adaptedValue = Array.isArray(value) ? value : [value]; + return { ...args, [key]: adaptedValue }; + }, {}); + +export function adaptFilterToExpression(filter: Filter) { + const { type, id, value, ...rest } = filter; + const restOfExpression = tranformObjectToArgs({ ...rest }); + const valueArgs = + value !== null && typeof value === 'object' + ? tranformObjectToArgs({ ...value }) + : tranformObjectToArgs({ value }); + + const exprAst: Ast = { + type: 'expression', + chain: [ + { + type: 'function', + function: filterToFunction[type], + arguments: { ...restOfExpression, ...valueArgs }, + }, + ], + }; + + return toExpression(exprAst); +} diff --git a/x-pack/plugins/canvas/public/lib/sync_filter_expression.test.ts b/x-pack/plugins/canvas/public/lib/sync_filter_expression.test.ts new file mode 100644 index 0000000000000..fbbe683c49b06 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/sync_filter_expression.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { syncFilterWithExpr, syncExprWithFilter } from './sync_filter_expression'; + +describe('syncFilterWithExpr', () => { + it('should synchronize filter arguments with expression arguments (exactly)', () => { + const expression = + 'demodata | dropdownControl valueColumn="project" filterColumn="project" filterGroup=null | render'; + const filter = 'exactly column="other_column" value="kibana" filterGroup="Some group"'; + + expect(syncFilterWithExpr(expression, filter)).toEqual( + 'exactly column="project" value="kibana" filterGroup=null' + ); + + const expressionWithouFilterGroup = + 'demodata | dropdownControl valueColumn="project" filterColumn="project" | render'; + + expect(syncFilterWithExpr(expressionWithouFilterGroup, filter)).toEqual( + 'exactly column="project" value="kibana" filterGroup="Some group"' + ); + }); + + it('should synchronize filter arguments with expression arguments (time)', () => { + const expression = 'timefilterControl compact=true column=@timestamp filterGroup=null | render'; + const filter = + 'time column="other_column" from="2021-11-02 17:13:18" to="2021-11-09 17:13:18" filterGroup="Some group"'; + + expect(syncFilterWithExpr(expression, filter)).toEqual( + 'time column="@timestamp" from="2021-11-02 17:13:18" to="2021-11-09 17:13:18" filterGroup=null' + ); + + const expressionWithoutFilterGroup = + 'timefilterControl compact=true column=@timestamp1 filterColumn="project" | render'; + + expect(syncFilterWithExpr(expressionWithoutFilterGroup, filter)).toEqual( + 'time column="@timestamp1" from="2021-11-02 17:13:18" to="2021-11-09 17:13:18" filterGroup="Some group"' + ); + }); +}); + +describe('syncExprWithFilter', () => { + const replaceNewLines = (str: string = '') => str.replace(/\n/g, ' '); + + it('should synchronize expression arguments with filter arguments (exactly)', () => { + const expression = + 'demodata | dropdownControl valueColumn="project" filterColumn="project" filterGroup=null | render'; + const filter = 'exactly column="other_column" value="kibana" filterGroup="Some group"'; + const result = syncExprWithFilter(expression, filter); + + expect(replaceNewLines(result)).toEqual( + 'demodata | dropdownControl valueColumn="project" filterColumn="other_column" filterGroup="Some group" | render' + ); + + const filterWithoutFilterGroup = 'exactly column="other_column" value="kibana"'; + const result2 = syncExprWithFilter(expression, filterWithoutFilterGroup); + + expect(replaceNewLines(result2)).toEqual( + 'demodata | dropdownControl valueColumn="project" filterColumn="other_column" filterGroup=null | render' + ); + }); + + it('should synchronize expression arguments with filter arguments (time)', () => { + const expression = 'timefilterControl compact=true column=@timestamp filterGroup=null | render'; + const filter = + 'time column="other_column" from="2021-11-02 17:13:18" to="2021-11-09 17:13:18" filterGroup="Some group"'; + + const result = syncExprWithFilter(expression, filter); + expect(replaceNewLines(result)).toEqual( + 'timefilterControl compact=true column="other_column" filterGroup="Some group" | render' + ); + + const filterWithoutFilterGroup = + 'time column="other_column" from="2021-11-02 17:13:18" to="2021-11-09 17:13:18" filterGroup=null'; + + const result2 = syncExprWithFilter(expression, filterWithoutFilterGroup); + expect(replaceNewLines(result2)).toEqual( + 'timefilterControl compact=true column="other_column" filterGroup=null | render' + ); + }); +}); diff --git a/x-pack/plugins/canvas/public/lib/sync_filter_expression.ts b/x-pack/plugins/canvas/public/lib/sync_filter_expression.ts index ce89e8e142891..522ecc595849d 100644 --- a/x-pack/plugins/canvas/public/lib/sync_filter_expression.ts +++ b/x-pack/plugins/canvas/public/lib/sync_filter_expression.ts @@ -5,34 +5,83 @@ * 2.0. */ -import { fromExpression } from '@kbn/interpreter/common'; +import { Ast, fromExpression, toExpression } from '@kbn/interpreter/common'; import immutable from 'object-path-immutable'; -import { get } from 'lodash'; - -const { set, del } = immutable; - -export function syncFilterExpression( - config: Record, - filterExpression: string, - fields: string[] = [] -) { - let changed = false; - const filterAst = fromExpression(filterExpression); - - const newAst = fields.reduce((ast, field) => { - const val = get(ast, `chain[0].arguments.${field}[0]`); - - if (val !== config[field]) { - changed = true; - if (!config[field]) { - // remove value if not in expression - return del(ast, `chain.0.arguments.${field}`); - } - return set(ast, `chain.0.arguments.${field}.0`, config[field]); - } - - return ast; - }, filterAst); - - return { changed, newAst }; -} + +const { merge } = immutable; + +const exactlyRemapSchema = { column: 'filterColumn', filterGroup: 'filterGroup' }; +const timeRemapSchema = { column: 'column', filterGroup: 'filterGroup' }; + +const remappingSchemas: Record> = { + dropdownControl: exactlyRemapSchema, + exactly: exactlyRemapSchema, + timefilterControl: timeRemapSchema, +}; + +const filterExpressionNames = ['dropdownControl', 'timefilterControl', 'exactly']; + +const swapPropsWithValues = (record: Record) => + Object.keys(record).reduce( + (updatedRecord, row) => ({ ...updatedRecord, [record[row]]: row }), + {} + ); + +const remapArguments = ( + argsToRemap: Record, + remappingSchema: Record +) => + Object.keys(remappingSchema).reduce((remappedArgs, argName) => { + const argsKey = remappingSchema[argName]; + return { + ...remappedArgs, + ...(argsToRemap[argsKey] ? { [argName]: argsToRemap[argsKey] } : {}), + }; + }, {}); + +export const syncFilterWithExpr = (expression: string, filter: string) => { + const filterAst = fromExpression(filter); + const expressionAst = fromExpression(expression); + const filterExpressionAst = expressionAst.chain.find(({ function: fn }) => + filterExpressionNames.includes(fn) + ); + + if (!filterExpressionAst) { + return filter; + } + + const remappedArgs = remapArguments( + filterExpressionAst.arguments, + remappingSchemas[filterExpressionAst.function] ?? remappingSchemas.exactly + ); + + const updatedFilterAst = merge(filterAst, `chain.0.arguments`, remappedArgs); + return toExpression(updatedFilterAst); +}; + +export const syncExprWithFilter = (expression: string, filter: string) => { + const filterAst = fromExpression(filter); + const expressionAst = fromExpression(expression); + const filterExpressionAstIndex = expressionAst.chain.findIndex(({ function: fn }) => + filterExpressionNames.includes(fn) + ); + + if (filterExpressionAstIndex === -1) { + return expression; + } + + const filterExpressionAst = expressionAst.chain[filterExpressionAstIndex]; + const filterAstArgs = filterAst.chain[0].arguments ?? {}; + + const remappingSchema = + remappingSchemas[filterExpressionAst.function] ?? remappingSchemas.exactly; + const remappedArgs = remapArguments(filterAstArgs, swapPropsWithValues(remappingSchema)); + + const updatedExpressionAst = merge( + expressionAst, + `chain.${filterExpressionAstIndex}.arguments`, + remappedArgs + ); + + return toExpression(updatedExpressionAst); +}; diff --git a/x-pack/plugins/canvas/public/state/actions/elements.js b/x-pack/plugins/canvas/public/state/actions/elements.js index c8d322163b54f..d3e3fa331cd69 100644 --- a/x-pack/plugins/canvas/public/state/actions/elements.js +++ b/x-pack/plugins/canvas/public/state/actions/elements.js @@ -16,11 +16,13 @@ import { getNodeById, getNodes, getSelectedPageIndex, + getPageWithElementId, } from '../selectors/workpad'; import { getValue as getResolvedArgsValue } from '../selectors/resolved_args'; import { getDefaultElement } from '../defaults'; import { ErrorStrings } from '../../../i18n'; import { runInterpreter, interpretAst } from '../../lib/run_interpreter'; +import { syncFilterWithExpr, syncExprWithFilter } from '../../lib/sync_filter_expression'; import { subMultitree } from '../../lib/aeroelastic/functional'; import { pluginServices } from '../../services'; import { selectToplevelNodes } from './transient'; @@ -30,6 +32,9 @@ const { actionsElements: strings } = ErrorStrings; const { set, del } = immutable; +export const setExpressionAction = 'setExpression'; +export const setFilterAction = 'setFilter'; + export function getSiblingContext(state, elementId, checkIndex) { const prevContextPath = [elementId, 'expressionContext', checkIndex]; const prevContextValue = getResolvedArgsValue(state, prevContextPath); @@ -269,22 +274,28 @@ export const removeElements = createThunk( ); export const setFilter = createThunk( - 'setFilter', - ({ dispatch }, filter, elementId, doRender = true) => { - const _setFilter = createAction('setFilter'); - dispatch(_setFilter({ filter, elementId })); - + setFilterAction, + ({ dispatch }, filter, elementId, doRender = true, needSync = true) => { + const _setFilter = createAction(setFilterAction); + dispatch(_setFilter({ filter, elementId, needSync })); if (doRender === true) { dispatch(fetchAllRenderables()); } } ); -export const setExpression = createThunk('setExpression', setExpressionFn); -function setExpressionFn({ dispatch, getState }, expression, elementId, pageId, doRender = true) { +export const setExpression = createThunk(setExpressionAction, setExpressionFn); +function setExpressionFn( + { dispatch, getState }, + expression, + elementId, + pageId, + doRender = true, + needSync = true +) { // dispatch action to update the element in state - const _setExpression = createAction('setExpression'); - dispatch(_setExpression({ expression, elementId, pageId })); + const _setExpression = createAction(setExpressionAction); + dispatch(_setExpression({ expression, elementId, pageId, needSync })); // read updated element from state and fetch renderable const updatedElement = getNodeById(getState(), elementId, pageId); @@ -305,17 +316,57 @@ function setExpressionFn({ dispatch, getState }, expression, elementId, pageId, } } -const setAst = createThunk('setAst', ({ dispatch }, ast, element, pageId, doRender = true) => { +export const syncFilterWithExpression = createThunk( + 'syncFilterWithExpression', + ({ dispatch, getState }, expression, elementId) => { + const pageId = getPageWithElementId(getState().persistent.workpad, elementId); + const element = getNodeById(getState(), elementId, pageId); + + if (element.filter) { + const updatedFilter = syncFilterWithExpr(expression, element.filter); + dispatch(setFilter(updatedFilter, elementId, true, false)); + } + } +); + +export const syncExpressionWithFilter = createThunk( + 'syncExpressionWithFilter', + ({ dispatch, getState }, filter, elementId) => { + const pageId = getPageWithElementId(getState().persistent.workpad, elementId); + const element = getNodeById(getState(), elementId, pageId); + if (element.filter) { + const updatedExpression = syncExprWithFilter(element.expression, filter); + dispatch(setExpression(updatedExpression, elementId, pageId, true, false)); + } + } +); + +const setAstPlain = (ast, element, pageId, doRender) => { try { const expression = toExpression(ast); - dispatch(setExpression(expression, element.id, pageId, doRender)); + return { + expression, + elementId: element.id, + pageId, + doRender, + }; } catch (err) { const notifyService = pluginServices.getServices().notify; notifyService.error(err); // TODO: remove this, may have been added just to cause a re-render, but why? - dispatch(setExpression(element.expression, element.id, pageId, doRender)); + return { + expression: element.expression, + elementId: element.id, + pageId, + doRender, + }; } +}; + +const setAst = createThunk('setAst', ({ dispatch }, ast, element, pageId, doRender = true) => { + const res = setAstPlain(ast, element, pageId, doRender); + dispatch(setExpression(...Object.values(res))); }); // index here is the top-level argument in the expression. for example in the expression @@ -369,7 +420,6 @@ export const setArgumentAtIndex = createThunk('setArgumentAtIndex', ({ dispatch if (valueIndex != null) { selector += '.' + valueIndex; } - const newElement = set(element, selector, value); const newAst = get(newElement, ['ast', 'chain', index]); dispatch(setAstAtIndex(index, newAst, element, pageId)); diff --git a/x-pack/plugins/canvas/public/state/middleware/elements_sync.test.ts b/x-pack/plugins/canvas/public/state/middleware/elements_sync.test.ts new file mode 100644 index 0000000000000..f63aa6ee431cd --- /dev/null +++ b/x-pack/plugins/canvas/public/state/middleware/elements_sync.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { elementsSyncMiddleware } from './elements_sync'; +import { mockMiddleware } from './test_helpers'; +// @ts-expect-error +import * as actions from '../actions/elements'; +// @ts-expect-error +import { setExpressionAction, setFilterAction } from '../actions/elements'; + +const expression = 'some expression'; +const filter = 'some filter'; + +describe('elementsSyncMiddleware', () => { + const syncFilterWithExprReturnVal = { type: 'syncFilter' }; + const syncExprWithFilterReturnVal = { type: 'syncExpression' }; + + let mockedElementsSyncMiddleware = {} as any; + beforeEach(() => { + mockedElementsSyncMiddleware = mockMiddleware(elementsSyncMiddleware); + actions.syncFilterWithExpression = jest.fn().mockReturnValue(syncFilterWithExprReturnVal); + actions.syncExpressionWithFilter = jest.fn().mockReturnValue(syncExprWithFilterReturnVal); + }); + + it('should pass any action', () => { + const { next, invoke, store } = mockedElementsSyncMiddleware; + const action = { type: 'any', payload: { some: 'prop', type: 'any' } }; + + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.dispatch).toHaveBeenCalledTimes(0); + }); + + it('should dispatch syncFilter on setExpression, if need to sync', () => { + const { next, invoke, store } = mockedElementsSyncMiddleware; + const action = { + type: setExpressionAction, + payload: { type: setExpressionAction, expression, elementId: 1, needSync: true }, + }; + + invoke(action); + expect(next).toHaveBeenCalledWith(action); + + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(store.dispatch).toHaveBeenCalledWith(syncFilterWithExprReturnVal); + }); + + it('should not dispatch syncFilter on setExpression, if no need to sync', () => { + const { next, invoke, store } = mockedElementsSyncMiddleware; + const action = { + type: setExpressionAction, + payload: { type: setExpressionAction, expression, elementId: 1 }, + }; + + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.dispatch).toHaveBeenCalledTimes(0); + }); + + it('should dispatch syncExpression on setFilter, if need to sync', () => { + const { next, invoke, store } = mockedElementsSyncMiddleware; + const action = { + type: setFilterAction, + payload: { type: setFilterAction, filter, elementId: 1, needSync: true }, + }; + + invoke(action); + expect(next).toHaveBeenCalledWith(action); + + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(store.dispatch).toHaveBeenCalledWith(syncExprWithFilterReturnVal); + }); + + it('should not dispatch syncExpression on setFilter, if no need to sync', () => { + const { next, invoke, store } = mockedElementsSyncMiddleware; + const action = { + type: setFilterAction, + payload: { type: setFilterAction, filter, elementId: 1 }, + }; + + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.dispatch).toHaveBeenCalledTimes(0); + }); +}); diff --git a/x-pack/plugins/canvas/public/state/middleware/elements_sync.ts b/x-pack/plugins/canvas/public/state/middleware/elements_sync.ts new file mode 100644 index 0000000000000..91e2697f4d706 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/middleware/elements_sync.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AnyAction, Dispatch, Middleware } from 'redux'; + +import { + setExpressionAction, + setFilterAction, + syncFilterWithExpression, + syncExpressionWithFilter, + // @ts-expect-error +} from '../actions/elements'; + +export const elementsSyncMiddleware: Middleware = + ({ dispatch }) => + (next: Dispatch) => + (action: AnyAction) => { + next(action); + + switch (action.type) { + case setExpressionAction: + if (action.payload.needSync) { + dispatch(syncFilterWithExpression(...Object.values(action.payload))); + } + break; + case setFilterAction: + if (action.payload.needSync) { + dispatch(syncExpressionWithFilter(...Object.values(action.payload))); + } + break; + } + }; diff --git a/x-pack/plugins/canvas/public/state/middleware/index.js b/x-pack/plugins/canvas/public/state/middleware/index.js index fbed2fbb3741b..0dc6551cdeea5 100644 --- a/x-pack/plugins/canvas/public/state/middleware/index.js +++ b/x-pack/plugins/canvas/public/state/middleware/index.js @@ -12,9 +12,17 @@ import { inFlight } from './in_flight'; import { workpadUpdate } from './workpad_update'; import { elementStats } from './element_stats'; import { resolvedArgs } from './resolved_args'; +import { elementsSyncMiddleware } from './elements_sync'; const middlewares = [ - applyMiddleware(thunkMiddleware, elementStats, resolvedArgs, inFlight, workpadUpdate), + applyMiddleware( + thunkMiddleware, + elementStats, + resolvedArgs, + inFlight, + workpadUpdate, + elementsSyncMiddleware + ), ]; // compose with redux devtools, if extension is installed diff --git a/x-pack/plugins/canvas/public/state/middleware/test_helpers/index.ts b/x-pack/plugins/canvas/public/state/middleware/test_helpers/index.ts new file mode 100644 index 0000000000000..ebeb3b4e6ccb2 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/middleware/test_helpers/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { mockMiddleware } from './mock_middleware'; diff --git a/x-pack/plugins/canvas/public/state/middleware/test_helpers/mock_middleware.ts b/x-pack/plugins/canvas/public/state/middleware/test_helpers/mock_middleware.ts new file mode 100644 index 0000000000000..47484e3c2d6fc --- /dev/null +++ b/x-pack/plugins/canvas/public/state/middleware/test_helpers/mock_middleware.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { PayloadAction } from '@reduxjs/toolkit'; +import { AnyAction, Middleware } from 'redux'; + +export const mockMiddleware = (middleware: Middleware) => { + const store = { + getState: jest.fn(() => ({})), + dispatch: jest.fn(), + }; + + const next = jest.fn(); + + const invoke = (action: PayloadAction) => middleware(store)(next)(action); + + return { store, next, invoke }; +}; diff --git a/x-pack/plugins/canvas/public/state/reducers/elements.js b/x-pack/plugins/canvas/public/state/reducers/elements.js index 2be1cf519ee13..c98bd714d24f0 100644 --- a/x-pack/plugins/canvas/public/state/reducers/elements.js +++ b/x-pack/plugins/canvas/public/state/reducers/elements.js @@ -9,6 +9,7 @@ import { handleActions } from 'redux-actions'; import immutable from 'object-path-immutable'; import { get } from 'lodash'; import * as actions from '../actions/elements'; +import { getPageWithElementId } from '../selectors/workpad'; const { assign, push, del, set } = immutable; @@ -88,24 +89,16 @@ const trimElement = ({ id, position, expression, filter }) => ({ ...(filter !== void 0 && { filter }), }); -const getPageWithElementId = (workpad, elementId) => { - const matchingPage = workpad.pages.find((page) => - page.elements.map((element) => element.id).includes(elementId) - ); - - if (matchingPage) { - return matchingPage.id; - } - - return undefined; -}; - export const elementsReducer = handleActions( { // TODO: This takes the entire element, which is not necessary, it could just take the id. [actions.setExpression]: (workpadState, { payload }) => { const { expression, pageId, elementId } = payload; - return assignNodeProperties(workpadState, pageId, elementId, { expression }); + let elementPageId = pageId; + if (!pageId) { + elementPageId = getPageWithElementId(workpadState, elementId); + } + return assignNodeProperties(workpadState, elementPageId, elementId, { expression }); }, [actions.setFilter]: (workpadState, { payload }) => { const { filter, elementId } = payload; diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.ts b/x-pack/plugins/canvas/public/state/selectors/workpad.ts index c419862e76379..1472fab0cda09 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.ts +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.ts @@ -17,6 +17,7 @@ import { CanvasElement, CanvasVariable, ResolvedArgType, + CanvasFilterExpression, } from '../../../types'; import { ExpressionContext, @@ -215,6 +216,17 @@ export function getGlobalFilters(state: State): string[] { }, []); } +export function getGlobalFiltersWithIds(state: State) { + return getAllElements(state).reduce((acc, el) => { + // check that a filter is defined + if (el.filter != null && el.filter.length) { + return acc.concat({ id: el.id, filter: el.filter }); + } + + return acc; + }, []); +} + type OnValueFunction = ( argValue: ExpressionAstArgument, argNames?: string, @@ -527,3 +539,18 @@ export function getRenderedWorkpadExpressions(state: State) { return expressions; } + +export const getPageWithElementId = ( + workpad: State['persistent']['workpad'], + elementId: string +) => { + const matchingPage = workpad.pages.find((page) => + page.elements.map((element) => element.id).includes(elementId) + ); + + if (matchingPage) { + return matchingPage.id; + } + + return undefined; +}; diff --git a/x-pack/plugins/canvas/types/filters.ts b/x-pack/plugins/canvas/types/filters.ts index 8529b37e40b1b..7f4cbb214c0c2 100644 --- a/x-pack/plugins/canvas/types/filters.ts +++ b/x-pack/plugins/canvas/types/filters.ts @@ -33,7 +33,12 @@ export type CanvasExactlyFilter = ExpressionValueFilter & { export type CanvasFilter = CanvasTimeFilter | CanvasExactlyFilter | CanvasLuceneFilter; +export interface CanvasFilterExpression { + id: string; + filter: string; +} export interface Filter { + id: string | number; type: keyof typeof FilterType; column: string | null; value: unknown; @@ -57,7 +62,7 @@ export interface FormattedFilterViewField { } export type FilterViewInstance = Record< - keyof Filter, + keyof Omit, SimpleFilterViewField | ComplexFilterViewField >; @@ -70,3 +75,10 @@ export type FlattenFilterViewInstance = Record; export type FormattedFilterViewInstance = Record; export type FilterField = 'column' | 'type' | 'filterGroup'; + +export interface FilterFieldProps { + value: string; + updateFilter: (filter: Filter) => void; + filter: Filter; + filterGroups: string[]; +}