Commit 13787294 by Marcus Andersson Committed by GitHub

Variables: fix so the variable picker will remember selected options between filtering (#25119)

* added tests to verify flow.

* refactoring picker reducer.

* made all the tests green.

* removed console.log's

* fixed toggle all and making sure the correct values are set on picker open.

* added more tets.

* refactored and added table tests.

* fixed so we select values from selectedValues instead of options.

* fixed so you can navigate and select even after you have filtered a variable.

* adding tests to verify flows when toggling by highlight.

* fixed so enter always selects value before closing.

* improved the code for tags.
parent 8f72d621
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
toggleTag, toggleTag,
updateOptionsAndFilter, updateOptionsAndFilter,
updateSearchQuery, updateSearchQuery,
moveOptionsHighlight,
} from './reducer'; } from './reducer';
import { import {
commitChangesToVariable, commitChangesToVariable,
...@@ -220,7 +221,7 @@ describe('options picker actions', () => { ...@@ -220,7 +221,7 @@ describe('options picker actions', () => {
] = actions; ] = actions;
const expectedNumberOfActions = 6; const expectedNumberOfActions = 6;
expect(toggleOptionAction).toEqual(toggleOption({ option: options[1], forceSelect: false, clearOthers })); expect(toggleOptionAction).toEqual(toggleOption({ option: options[1], forceSelect: true, clearOthers }));
expect(setCurrentValue).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option }))); expect(setCurrentValue).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
expect(changeQueryValue).toEqual( expect(changeQueryValue).toEqual(
changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })) changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' }))
...@@ -329,6 +330,45 @@ describe('options picker actions', () => { ...@@ -329,6 +330,45 @@ describe('options picker actions', () => {
}); });
}); });
describe('when commitChangesToVariable is dispatched with changes and list of options is filtered', () => {
it('then correct actions are dispatched', async () => {
const options = [createOption('A'), createOption('B'), createOption('C')];
const variable = createVariable({ options, includeAll: false });
const clearOthers = false;
const tester = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getRootReducer())
.whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable })))
.whenActionIsDispatched(showOptions(variable))
.whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers))
.whenActionIsDispatched(toggleOptionByHighlight(clearOthers))
.whenActionIsDispatched(filterOrSearchOptions('C'))
.whenAsyncActionIsDispatched(commitChangesToVariable(), true);
const option = {
...createOption('A'),
selected: true,
value: ['A'],
tags: [] as any[],
};
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
const [setCurrentValue, changeQueryValue, updateOption, locationAction, hideAction] = actions;
const expectedNumberOfActions = 5;
expect(setCurrentValue).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
expect(changeQueryValue).toEqual(
changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: 'C' }))
);
expect(updateOption).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
expect(locationAction).toEqual(updateLocation({ query: { 'var-Constant': ['A'] } }));
expect(hideAction).toEqual(hideOptions());
return actions.length === expectedNumberOfActions;
});
});
});
describe('when toggleOptionByHighlight is dispatched with changes', () => { describe('when toggleOptionByHighlight is dispatched with changes', () => {
it('then correct actions are dispatched', async () => { it('then correct actions are dispatched', async () => {
const options = [createOption('A'), createOption('B'), createOption('C')]; const options = [createOption('A'), createOption('B'), createOption('C')];
...@@ -354,6 +394,42 @@ describe('options picker actions', () => { ...@@ -354,6 +394,42 @@ describe('options picker actions', () => {
}); });
}); });
describe('when toggleOptionByHighlight is dispatched with changes selected from a filtered options list', () => {
it('then correct actions are dispatched', async () => {
const options = [createOption('A'), createOption('B'), createOption('BC'), createOption('BD')];
const variable = createVariable({ options, includeAll: false });
const clearOthers = false;
const tester = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getRootReducer())
.whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable })))
.whenActionIsDispatched(showOptions(variable))
.whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers))
.whenActionIsDispatched(toggleOptionByHighlight(clearOthers), true)
.whenActionIsDispatched(filterOrSearchOptions('B'))
.whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers))
.whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers))
.whenActionIsDispatched(toggleOptionByHighlight(clearOthers));
const optionA = createOption('A');
const optionBC = createOption('BD');
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
const [toggleOptionA, filterOnB, updateAndFilter, firstMoveDown, secondMoveDown, toggleOptionBC] = actions;
const expectedNumberOfActions = 6;
expect(toggleOptionA).toEqual(toggleOption({ option: optionA, forceSelect: false, clearOthers }));
expect(filterOnB).toEqual(updateSearchQuery('B'));
expect(updateAndFilter).toEqual(updateOptionsAndFilter(variable.options));
expect(firstMoveDown).toEqual(moveOptionsHighlight(1));
expect(secondMoveDown).toEqual(moveOptionsHighlight(1));
expect(toggleOptionBC).toEqual(toggleOption({ option: optionBC, forceSelect: false, clearOthers }));
return actions.length === expectedNumberOfActions;
});
});
});
describe('when toggleAndFetchTag is dispatched with values', () => { describe('when toggleAndFetchTag is dispatched with values', () => {
it('then correct actions are dispatched', async () => { it('then correct actions are dispatched', async () => {
const options = [createOption('A'), createOption('B'), createOption('C')]; const options = [createOption('A'), createOption('B'), createOption('C')];
......
import debounce from 'lodash/debounce'; import { debounce, trim } from 'lodash';
import { StoreState, ThunkDispatch, ThunkResult } from 'app/types'; import { StoreState, ThunkDispatch, ThunkResult } from 'app/types';
import { import {
QueryVariableModel, QueryVariableModel,
...@@ -38,7 +38,7 @@ export const navigateOptions = (key: NavigationKey, clearOthers: boolean): Thunk ...@@ -38,7 +38,7 @@ export const navigateOptions = (key: NavigationKey, clearOthers: boolean): Thunk
} }
if (key === NavigationKey.selectAndClose) { if (key === NavigationKey.selectAndClose) {
dispatch(toggleOptionByHighlight(clearOthers)); dispatch(toggleOptionByHighlight(clearOthers, true));
return await dispatch(commitChangesToVariable()); return await dispatch(commitChangesToVariable());
} }
...@@ -54,12 +54,16 @@ export const navigateOptions = (key: NavigationKey, clearOthers: boolean): Thunk ...@@ -54,12 +54,16 @@ export const navigateOptions = (key: NavigationKey, clearOthers: boolean): Thunk
}; };
}; };
export const filterOrSearchOptions = (searchQuery: string): ThunkResult<void> => { export const filterOrSearchOptions = (searchQuery = ''): ThunkResult<void> => {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const { id } = getState().templating.optionsPicker; const { id, queryValue } = getState().templating.optionsPicker;
const { query, options } = getVariable<VariableWithOptions>(id!, getState()); const { query, options } = getVariable<VariableWithOptions>(id!, getState());
dispatch(updateSearchQuery(searchQuery)); dispatch(updateSearchQuery(searchQuery));
if (trim(queryValue) === trim(searchQuery)) {
return;
}
if (containsSearchFilter(query)) { if (containsSearchFilter(query)) {
return searchForOptionsWithDebounce(dispatch, getState, searchQuery); return searchForOptionsWithDebounce(dispatch, getState, searchQuery);
} }
...@@ -88,12 +92,11 @@ export const commitChangesToVariable = (): ThunkResult<void> => { ...@@ -88,12 +92,11 @@ export const commitChangesToVariable = (): ThunkResult<void> => {
}; };
}; };
export const toggleOptionByHighlight = (clearOthers: boolean): ThunkResult<void> => { export const toggleOptionByHighlight = (clearOthers: boolean, forceSelect = false): ThunkResult<void> => {
return (dispatch, getState) => { return (dispatch, getState) => {
const { id, highlightIndex } = getState().templating.optionsPicker; const { highlightIndex, options } = getState().templating.optionsPicker;
const variable = getVariable<VariableWithMultiSupport>(id, getState()); const option = options[highlightIndex];
const option = variable.options[highlightIndex]; dispatch(toggleOption({ option, forceSelect, clearOthers }));
dispatch(toggleOption({ option, forceSelect: false, clearOthers }));
}; };
}; };
...@@ -155,20 +158,20 @@ const searchForOptions = async (dispatch: ThunkDispatch, getState: () => StoreSt ...@@ -155,20 +158,20 @@ const searchForOptions = async (dispatch: ThunkDispatch, getState: () => StoreSt
const searchForOptionsWithDebounce = debounce(searchForOptions, 500); const searchForOptionsWithDebounce = debounce(searchForOptions, 500);
function mapToCurrent(picker: OptionsPickerState): VariableOption | undefined { function mapToCurrent(picker: OptionsPickerState): VariableOption | undefined {
const { options, queryValue: searchQuery, multi } = picker; const { options, selectedValues, queryValue: searchQuery, multi } = picker;
if (options.length === 0 && searchQuery && searchQuery.length > 0) { if (options.length === 0 && searchQuery && searchQuery.length > 0) {
return { text: searchQuery, value: searchQuery, selected: false }; return { text: searchQuery, value: searchQuery, selected: false };
} }
if (!multi) { if (!multi) {
return options.find(o => o.selected); return selectedValues.find(o => o.selected);
} }
const texts: string[] = []; const texts: string[] = [];
const values: string[] = []; const values: string[] = [];
for (const option of options) { for (const option of selectedValues) {
if (!option.selected) { if (!option.selected) {
continue; continue;
} }
......
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { cloneDeep } from 'lodash'; import { cloneDeep, isString, trim } from 'lodash';
import { VariableOption, VariableTag, VariableWithMultiSupport } from '../../../templating/types'; import { VariableOption, VariableTag, VariableWithMultiSupport } from '../../../templating/types';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../../state/types'; import { ALL_VARIABLE_VALUE } from '../../state/types';
import { isQuery } from '../../guard'; import { isQuery } from '../../guard';
import { applyStateChanges } from '../../../../core/utils/applyStateChanges'; import { applyStateChanges } from '../../../../core/utils/applyStateChanges';
import { containsSearchFilter } from '../../../templating/utils'; import { containsSearchFilter } from '../../../templating/utils';
...@@ -43,8 +43,41 @@ const getTags = (model: VariableWithMultiSupport) => { ...@@ -43,8 +43,41 @@ const getTags = (model: VariableWithMultiSupport) => {
return []; return [];
}; };
const updateSelectedValues = (state: OptionsPickerState): OptionsPickerState => { const optionsToRecord = (options: VariableOption[]): Record<string, VariableOption> => {
state.selectedValues = state.options.filter(o => o.selected); if (!Array.isArray(options)) {
return {};
}
return options.reduce((all: Record<string, VariableOption>, option) => {
if (isString(option.value)) {
all[option.value] = option;
}
return all;
}, {});
};
const updateOptions = (state: OptionsPickerState): OptionsPickerState => {
if (!Array.isArray(state.options)) {
state.options = [];
return state;
}
const selectedOptions = optionsToRecord(state.selectedValues);
state.selectedValues = Object.values(selectedOptions);
state.options = state.options.map(option => {
if (!isString(option.value)) {
return option;
}
const selected = !!selectedOptions[option.value];
if (option.selected === selected) {
return option;
}
return { ...option, selected };
});
return state; return state;
}; };
...@@ -52,13 +85,31 @@ const applyLimit = (options: VariableOption[]): VariableOption[] => { ...@@ -52,13 +85,31 @@ const applyLimit = (options: VariableOption[]): VariableOption[] => {
if (!Array.isArray(options)) { if (!Array.isArray(options)) {
return []; return [];
} }
return options.slice(0, Math.min(options.length, OPTIONS_LIMIT)); if (options.length <= OPTIONS_LIMIT) {
return options;
}
return options.slice(0, OPTIONS_LIMIT);
}; };
const updateDefaultSelection = (state: OptionsPickerState): OptionsPickerState => { const updateDefaultSelection = (state: OptionsPickerState): OptionsPickerState => {
const { options } = state; const { options, selectedValues } = state;
if (options.length > 0 && options.filter(o => o.selected).length === 0) {
options[0].selected = true; if (options.length === 0 || selectedValues.length > 0) {
return state;
}
if (!options[0] || options[0].value !== ALL_VARIABLE_VALUE) {
return state;
}
state.selectedValues = [{ ...options[0], selected: true }];
return state;
};
const updateAllSelection = (state: OptionsPickerState): OptionsPickerState => {
const { selectedValues } = state;
if (selectedValues.length > 1) {
state.selectedValues = selectedValues.filter(option => option.value !== ALL_VARIABLE_VALUE);
} }
return state; return state;
}; };
...@@ -83,33 +134,33 @@ const optionsPickerSlice = createSlice({ ...@@ -83,33 +134,33 @@ const optionsPickerSlice = createSlice({
state.queryValue = queryHasSearchFilter && queryValue ? queryValue : ''; state.queryValue = queryHasSearchFilter && queryValue ? queryValue : '';
} }
return applyStateChanges(state, updateSelectedValues); state.selectedValues = state.options.filter(option => option.selected);
return applyStateChanges(state, updateDefaultSelection, updateOptions);
}, },
hideOptions: (state, action: PayloadAction): OptionsPickerState => { hideOptions: (state, action: PayloadAction): OptionsPickerState => {
return { ...initialState }; return { ...initialState };
}, },
toggleOption: (state, action: PayloadAction<ToggleOption>): OptionsPickerState => { toggleOption: (state, action: PayloadAction<ToggleOption>): OptionsPickerState => {
const { option, forceSelect, clearOthers } = action.payload; const { option, clearOthers, forceSelect } = action.payload;
const { multi } = state; const { multi, selectedValues } = state;
const newOptions: VariableOption[] = state.options.map(o => { const selected = !selectedValues.find(o => o.value === option.value);
if (o.value !== option.value) {
let selected = o.selected; if (option.value === ALL_VARIABLE_VALUE || !multi || clearOthers) {
if (o.text === ALL_VARIABLE_TEXT || option.text === ALL_VARIABLE_TEXT) { if (selected || forceSelect) {
selected = false; state.selectedValues = [{ ...option, selected: true }];
} else if (!multi) { } else {
selected = false; state.selectedValues = [];
} else if (clearOthers) { }
selected = false; return applyStateChanges(state, updateDefaultSelection, updateAllSelection, updateOptions);
} }
o.selected = selected;
return o; if (forceSelect || selected) {
} state.selectedValues.push({ ...option, selected: true });
o.selected = forceSelect ? true : multi ? !option.selected : true; return applyStateChanges(state, updateDefaultSelection, updateAllSelection, updateOptions);
return o; }
});
state.options = newOptions; state.selectedValues = selectedValues.filter(o => o.value !== option.value);
return applyStateChanges(state, updateDefaultSelection, updateSelectedValues); return applyStateChanges(state, updateDefaultSelection, updateAllSelection, updateOptions);
}, },
toggleTag: (state, action: PayloadAction<VariableTag>): OptionsPickerState => { toggleTag: (state, action: PayloadAction<VariableTag>): OptionsPickerState => {
const tag = action.payload; const tag = action.payload;
...@@ -133,20 +184,21 @@ const optionsPickerSlice = createSlice({ ...@@ -133,20 +184,21 @@ const optionsPickerSlice = createSlice({
return t; return t;
}); });
state.options = state.options.map(option => { const availableOptions = optionsToRecord(state.options);
if (option.value === ALL_VARIABLE_VALUE && selected === true) {
option.selected = false;
}
if (values.indexOf(option.value) === -1) { if (!selected) {
return option; state.selectedValues = state.selectedValues.filter(
option => !isString(option.value) || !availableOptions[option.value]
);
return applyStateChanges(state, updateDefaultSelection, updateOptions);
} }
option.selected = selected; const optionsFromTag = values
return option; .filter(value => value !== ALL_VARIABLE_VALUE && !!availableOptions[value])
}); .map(value => ({ selected, value, text: value }));
return applyStateChanges(state, updateDefaultSelection, updateSelectedValues); state.selectedValues.push.apply(state.selectedValues, optionsFromTag);
return applyStateChanges(state, updateDefaultSelection, updateOptions);
}, },
moveOptionsHighlight: (state, action: PayloadAction<number>): OptionsPickerState => { moveOptionsHighlight: (state, action: PayloadAction<number>): OptionsPickerState => {
let nextIndex = state.highlightIndex + action.payload; let nextIndex = state.highlightIndex + action.payload;
...@@ -163,20 +215,24 @@ const optionsPickerSlice = createSlice({ ...@@ -163,20 +215,24 @@ const optionsPickerSlice = createSlice({
}; };
}, },
toggleAllOptions: (state, action: PayloadAction): OptionsPickerState => { toggleAllOptions: (state, action: PayloadAction): OptionsPickerState => {
const selected = !state.options.find(option => option.selected); if (state.selectedValues.length > 0) {
state.options = state.options.map(option => ({ state.selectedValues = [];
return applyStateChanges(state, updateOptions);
}
state.selectedValues = state.options.map(option => ({
...option, ...option,
selected, selected: true,
})); }));
return applyStateChanges(state, updateSelectedValues); return applyStateChanges(state, updateOptions);
}, },
updateSearchQuery: (state, action: PayloadAction<string>): OptionsPickerState => { updateSearchQuery: (state, action: PayloadAction<string>): OptionsPickerState => {
state.queryValue = action.payload; state.queryValue = action.payload;
return state; return state;
}, },
updateOptionsAndFilter: (state, action: PayloadAction<VariableOption[]>): OptionsPickerState => { updateOptionsAndFilter: (state, action: PayloadAction<VariableOption[]>): OptionsPickerState => {
const searchQuery = (state.queryValue ?? '').toLowerCase(); const searchQuery = trim((state.queryValue ?? '').toLowerCase());
const filteredOptions = action.payload.filter(option => { const filteredOptions = action.payload.filter(option => {
const text = Array.isArray(option.text) ? option.text.toString() : option.text; const text = Array.isArray(option.text) ? option.text.toString() : option.text;
...@@ -186,13 +242,13 @@ const optionsPickerSlice = createSlice({ ...@@ -186,13 +242,13 @@ const optionsPickerSlice = createSlice({
state.options = applyLimit(filteredOptions); state.options = applyLimit(filteredOptions);
state.highlightIndex = 0; state.highlightIndex = 0;
return applyStateChanges(state, updateSelectedValues); return applyStateChanges(state, updateDefaultSelection, updateOptions);
}, },
updateOptionsFromSearch: (state, action: PayloadAction<VariableOption[]>): OptionsPickerState => { updateOptionsFromSearch: (state, action: PayloadAction<VariableOption[]>): OptionsPickerState => {
state.options = applyLimit(action.payload); state.options = applyLimit(action.payload);
state.highlightIndex = 0; state.highlightIndex = 0;
return applyStateChanges(state, updateSelectedValues); return applyStateChanges(state, updateDefaultSelection, updateOptions);
}, },
}, },
}); });
......
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { trim } from 'lodash';
import { NavigationKey } from '../types'; import { NavigationKey } from '../types';
export interface Props { export interface Props {
...@@ -17,15 +16,9 @@ export class VariableInput extends PureComponent<Props> { ...@@ -17,15 +16,9 @@ export class VariableInput extends PureComponent<Props> {
}; };
onChange = (event: React.ChangeEvent<HTMLInputElement>) => { onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (this.shouldUpdateValue(event.target.value)) {
this.props.onChange(event.target.value); this.props.onChange(event.target.value);
}
}; };
private shouldUpdateValue(value: string) {
return trim(value ?? '').length > 0 || trim(this.props.value ?? '').length > 0;
}
render() { render() {
return ( return (
<input <input
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment