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;
} }
......
...@@ -15,7 +15,7 @@ import { ...@@ -15,7 +15,7 @@ import {
updateSearchQuery, updateSearchQuery,
} from './reducer'; } from './reducer';
import { reducerTester } from '../../../../../test/core/redux/reducerTester'; import { reducerTester } from '../../../../../test/core/redux/reducerTester';
import { QueryVariableModel, VariableTag } from '../../../templating/types'; import { QueryVariableModel, VariableTag, VariableOption } from '../../../templating/types';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../../state/types'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../../state/types';
const getVariableTestContext = (extend: Partial<OptionsPickerState>) => { const getVariableTestContext = (extend: Partial<OptionsPickerState>) => {
...@@ -30,32 +30,17 @@ const getVariableTestContext = (extend: Partial<OptionsPickerState>) => { ...@@ -30,32 +30,17 @@ const getVariableTestContext = (extend: Partial<OptionsPickerState>) => {
describe('optionsPickerReducer', () => { describe('optionsPickerReducer', () => {
describe('when toggleOption is dispatched', () => { describe('when toggleOption is dispatched', () => {
const opsAll = [ const opsAll = [
{ text: 'All', value: '$__all', selected: true }, { text: '$__all', value: '$__all', selected: true },
{ text: 'A', value: 'A', selected: false }, { text: 'A', value: 'A', selected: false },
{ text: 'B', value: 'B', selected: false }, { text: 'B', value: 'B', selected: false },
]; ];
const opsA = [ const opsA = [
{ text: 'All', value: '$__all', selected: false }, { text: '$__all', value: '$__all', selected: false },
{ text: 'A', value: 'A', selected: true }, { text: 'A', value: 'A', selected: true },
{ text: 'B', value: 'B', selected: false }, { text: 'B', value: 'B', selected: false },
]; ];
const opsB = [
{ text: 'All', value: '$__all', selected: false },
{ text: 'A', value: 'A', selected: false },
{ text: 'B', value: 'B', selected: true },
];
const opsAB = [ const opsAB = [
{ text: 'All', value: '$__all', selected: false }, { text: '$__all', value: '$__all', selected: false },
{ text: 'A', value: 'A', selected: true },
{ text: 'B', value: 'B', selected: true },
];
const opA = { text: 'A', selected: true, value: 'A' };
const opANot = { text: 'A', selected: false, value: 'A' };
const opASel = [{ text: 'A', value: 'A', selected: true }];
const opBSel = [{ text: 'B', value: 'B', selected: true }];
const opAllSel = [{ text: 'All', value: '$__all', selected: true }];
const opABSel = [
{ text: 'A', value: 'A', selected: true }, { text: 'A', value: 'A', selected: true },
{ text: 'B', value: 'B', selected: true }, { text: 'B', value: 'B', selected: true },
]; ];
...@@ -66,125 +51,114 @@ describe('optionsPickerReducer', () => { ...@@ -66,125 +51,114 @@ describe('optionsPickerReducer', () => {
forceSelect: any; forceSelect: any;
clearOthers: any; clearOthers: any;
option: any; option: any;
expOps: any; expectSelected: any;
expSel: any;
}) => { }) => {
const { initialState } = getVariableTestContext({ options: args.options, multi: args.multi }); const { initialState } = getVariableTestContext({
const payload = { forceSelect: args.forceSelect, clearOthers: args.clearOthers, option: args.option }; options: args.options,
multi: args.multi,
selectedValues: args.options.filter((o: any) => o.selected),
});
const payload = {
forceSelect: args.forceSelect,
clearOthers: args.clearOthers,
option: { text: args.option, value: args.option, selected: true },
};
const expectedAsRecord: any = args.expectSelected.reduce((all: any, current: any) => {
all[current] = current;
return all;
}, {});
reducerTester<OptionsPickerState>() reducerTester<OptionsPickerState>()
.givenReducer(optionsPickerReducer, cloneDeep(initialState)) .givenReducer(optionsPickerReducer, cloneDeep(initialState))
.whenActionIsDispatched(toggleOption(payload)) .whenActionIsDispatched(toggleOption(payload))
.thenStateShouldEqual({ .thenStateShouldEqual({
...initialState, ...initialState,
selectedValues: args.expSel, selectedValues: args.expectSelected.map((value: any) => ({ value, text: value, selected: true })),
options: args.expOps, options: args.options.map((option: any) => {
return { ...option, selected: !!expectedAsRecord[option.value] };
}),
}); });
}; };
describe('toggleOption for multi value variable', () => { describe('toggleOption for multi value variable', () => {
const multi = true; const multi = true;
describe('and options with All selected', () => { describe('and value All is selected in options', () => {
const options = opsAll; const options = opsAll;
it.each` it.each`
option | forceSelect | clearOthers | expOps | expSel option | forceSelect | clearOthers | expectSelected
${opANot} | ${true} | ${false} | ${opsA} | ${opASel} ${'A'} | ${true} | ${false} | ${['A']}
${opANot} | ${false} | ${false} | ${opsA} | ${opASel} ${'A'} | ${false} | ${false} | ${['A']}
${opANot} | ${true} | ${true} | ${opsA} | ${opASel} ${'A'} | ${true} | ${true} | ${['A']}
${opANot} | ${false} | ${true} | ${opsA} | ${opASel} ${'A'} | ${false} | ${true} | ${['A']}
${opA} | ${true} | ${false} | ${opsA} | ${opASel} ${'B'} | ${true} | ${false} | ${['B']}
${opA} | ${false} | ${false} | ${opsAll} | ${opAllSel} ${'B'} | ${false} | ${false} | ${['B']}
${opA} | ${true} | ${true} | ${opsA} | ${opASel} ${'B'} | ${true} | ${true} | ${['B']}
${opA} | ${false} | ${true} | ${opsAll} | ${opAllSel} ${'B'} | ${false} | ${true} | ${['B']}
${'$__all'} | ${true} | ${false} | ${['$__all']}
${'$__all'} | ${false} | ${false} | ${['$__all']}
${'$__all'} | ${true} | ${true} | ${['$__all']}
${'$__all'} | ${false} | ${true} | ${['$__all']}
`( `(
'when toggleOption is dispatched and option: $option, forceSelect: $forceSelect, clearOthers: $clearOthers, expOps: $expOps, expSel: $expSel', 'and we toggle $option with options: { forceSelect: $forceSelect, clearOthers: $clearOthers } we expect $expectSelected to be selected',
({ option, forceSelect, clearOthers, expOps, expSel }) => ({ option, forceSelect, clearOthers, expectSelected }) =>
expectToggleOptionState({ expectToggleOptionState({
options, options,
multi, multi,
option, option,
clearOthers, clearOthers,
forceSelect, forceSelect,
expOps, expectSelected,
expSel,
}) })
); );
}); });
describe('and options with A selected', () => { describe('and value A is selected in options', () => {
const options = opsA; const options = opsA;
it.each` it.each`
option | forceSelect | clearOthers | expOps | expSel option | forceSelect | clearOthers | expectSelected
${opANot} | ${true} | ${false} | ${opsA} | ${opASel} ${'A'} | ${true} | ${false} | ${['A']}
${opANot} | ${false} | ${false} | ${opsA} | ${opASel} ${'A'} | ${false} | ${false} | ${['$__all']}
${opANot} | ${true} | ${true} | ${opsA} | ${opASel} ${'A'} | ${true} | ${true} | ${['A']}
${opANot} | ${false} | ${true} | ${opsA} | ${opASel} ${'A'} | ${false} | ${true} | ${['$__all']}
${opA} | ${true} | ${false} | ${opsA} | ${opASel} ${'B'} | ${true} | ${true} | ${['B']}
${opA} | ${false} | ${false} | ${opsAll} | ${opAllSel} ${'B'} | ${false} | ${true} | ${['B']}
${opA} | ${true} | ${true} | ${opsA} | ${opASel} ${'B'} | ${true} | ${false} | ${['A', 'B']}
${opA} | ${false} | ${true} | ${opsAll} | ${opAllSel} ${'B'} | ${false} | ${false} | ${['A', 'B']}
`( `(
'when toggleOption is dispatched and option: $option, forceSelect: $forceSelect, clearOthers: $clearOthers, expOps: $expOps, expSel: $expSel', 'and we toggle $option with options: { forceSelect: $forceSelect, clearOthers: $clearOthers } we expect $expectSelected to be selected',
({ option, forceSelect, clearOthers, expOps, expSel }) => ({ option, forceSelect, clearOthers, expectSelected }) =>
expectToggleOptionState({ expectToggleOptionState({
options, options,
multi, multi,
option, option,
clearOthers, clearOthers,
forceSelect, forceSelect,
expOps, expectSelected,
expSel,
}) })
); );
}); });
describe('and options with B selected', () => {
const options = opsB; describe('and values A + B is selected in options', () => {
it.each`
option | forceSelect | clearOthers | expOps | expSel
${opANot} | ${true} | ${false} | ${opsAB} | ${opABSel}
${opANot} | ${false} | ${false} | ${opsAB} | ${opABSel}
${opANot} | ${true} | ${true} | ${opsA} | ${opASel}
${opANot} | ${false} | ${true} | ${opsA} | ${opASel}
${opA} | ${true} | ${false} | ${opsAB} | ${opABSel}
${opA} | ${false} | ${false} | ${opsB} | ${opBSel}
${opA} | ${true} | ${true} | ${opsA} | ${opASel}
${opA} | ${false} | ${true} | ${opsAll} | ${opAllSel}
`(
'when toggleOption is dispatched and option: $option, forceSelect: $forceSelect, clearOthers: $clearOthers, expOps: $expOps, expSel: $expSel',
({ option, forceSelect, clearOthers, expOps, expSel }) =>
expectToggleOptionState({
options,
multi,
option,
clearOthers,
forceSelect,
expOps,
expSel,
})
);
});
describe('and options with A + B selected', () => {
const options = opsAB; const options = opsAB;
it.each` it.each`
option | forceSelect | clearOthers | expOps | expSel option | forceSelect | clearOthers | expectSelected
${opANot} | ${true} | ${false} | ${opsAB} | ${opABSel} ${'A'} | ${true} | ${false} | ${['A', 'B']}
${opANot} | ${false} | ${false} | ${opsAB} | ${opABSel} ${'A'} | ${false} | ${false} | ${['B']}
${opANot} | ${true} | ${true} | ${opsA} | ${opASel} ${'A'} | ${true} | ${true} | ${['A']}
${opANot} | ${false} | ${true} | ${opsA} | ${opASel} ${'A'} | ${false} | ${true} | ${['$__all']}
${opA} | ${true} | ${false} | ${opsAB} | ${opABSel} ${'B'} | ${true} | ${true} | ${['B']}
${opA} | ${false} | ${false} | ${opsB} | ${opBSel} ${'B'} | ${false} | ${true} | ${['$__all']}
${opA} | ${true} | ${true} | ${opsA} | ${opASel} ${'B'} | ${true} | ${false} | ${['A', 'B']}
${opA} | ${false} | ${true} | ${opsAll} | ${opAllSel} ${'B'} | ${false} | ${false} | ${['A']}
`( `(
'when toggleOption is dispatched and option: $option, forceSelect: $forceSelect, clearOthers: $clearOthers, expOps: $expOps, expSel: $expSel', 'and we toggle $option with options: { forceSelect: $forceSelect, clearOthers: $clearOthers } we expect $expectSelected to be selected',
({ option, forceSelect, clearOthers, expOps, expSel }) => ({ option, forceSelect, clearOthers, expectSelected }) =>
expectToggleOptionState({ expectToggleOptionState({
options, options,
multi, multi,
option, option,
clearOthers, clearOthers,
forceSelect, forceSelect,
expOps, expectSelected,
expSel,
}) })
); );
}); });
...@@ -192,87 +166,92 @@ describe('optionsPickerReducer', () => { ...@@ -192,87 +166,92 @@ describe('optionsPickerReducer', () => {
describe('toggleOption for single value variable', () => { describe('toggleOption for single value variable', () => {
const multi = false; const multi = false;
describe('and options with All selected', () => { describe('and value All is selected in options', () => {
const options = opsAll; const options = opsAll;
it.each` it.each`
option | forceSelect | clearOthers | expOps | expSel option | forceSelect | clearOthers | expectSelected
${opANot} | ${true} | ${false} | ${opsA} | ${opASel} ${'A'} | ${true} | ${false} | ${['A']}
${opANot} | ${false} | ${false} | ${opsA} | ${opASel} ${'A'} | ${false} | ${false} | ${['A']}
${opANot} | ${true} | ${true} | ${opsA} | ${opASel} ${'A'} | ${true} | ${true} | ${['A']}
${opANot} | ${false} | ${true} | ${opsA} | ${opASel} ${'A'} | ${false} | ${true} | ${['A']}
${opA} | ${true} | ${false} | ${opsA} | ${opASel} ${'B'} | ${true} | ${false} | ${['B']}
${opA} | ${false} | ${false} | ${opsA} | ${opASel} ${'B'} | ${false} | ${false} | ${['B']}
${opA} | ${true} | ${true} | ${opsA} | ${opASel} ${'B'} | ${true} | ${true} | ${['B']}
${opA} | ${false} | ${true} | ${opsA} | ${opASel} ${'B'} | ${false} | ${true} | ${['B']}
${'$__all'} | ${true} | ${false} | ${['$__all']}
${'$__all'} | ${false} | ${false} | ${['$__all']}
${'$__all'} | ${true} | ${true} | ${['$__all']}
${'$__all'} | ${false} | ${true} | ${['$__all']}
`( `(
'when toggleOption is dispatched and option: $option, forceSelect: $forceSelect, clearOthers: $clearOthers, expOps: $expOps, expSel: $expSel', 'and we toggle $option with options: { forceSelect: $forceSelect, clearOthers: $clearOthers } we expect $expectSelected to be selected',
({ option, forceSelect, clearOthers, expOps, expSel }) => ({ option, forceSelect, clearOthers, expectSelected }) =>
expectToggleOptionState({ expectToggleOptionState({
options, options,
multi, multi,
option, option,
clearOthers, clearOthers,
forceSelect, forceSelect,
expOps, expectSelected,
expSel,
}) })
); );
}); });
describe('and options with A selected', () => { describe('and value A is selected in options', () => {
const options = opsA; const options = opsA;
it.each` it.each`
option | forceSelect | clearOthers | expOps | expSel option | forceSelect | clearOthers | expectSelected
${opANot} | ${true} | ${false} | ${opsA} | ${opASel} ${'A'} | ${true} | ${false} | ${['A']}
${opANot} | ${false} | ${false} | ${opsA} | ${opASel} ${'A'} | ${false} | ${false} | ${['$__all']}
${opANot} | ${true} | ${true} | ${opsA} | ${opASel} ${'A'} | ${true} | ${true} | ${['A']}
${opANot} | ${false} | ${true} | ${opsA} | ${opASel} ${'A'} | ${false} | ${true} | ${['$__all']}
${opA} | ${true} | ${false} | ${opsA} | ${opASel} ${'B'} | ${true} | ${false} | ${['B']}
${opA} | ${false} | ${false} | ${opsA} | ${opASel} ${'B'} | ${false} | ${false} | ${['B']}
${opA} | ${true} | ${true} | ${opsA} | ${opASel} ${'B'} | ${true} | ${true} | ${['B']}
${opA} | ${false} | ${true} | ${opsA} | ${opASel} ${'B'} | ${false} | ${true} | ${['B']}
`( `(
'when toggleOption is dispatched and option: $option, forceSelect: $forceSelect, clearOthers: $clearOthers, expOps: $expOps, expSel: $expSel', 'and we toggle $option with options: { forceSelect: $forceSelect, clearOthers: $clearOthers } we expect $expectSelected to be selected',
({ option, forceSelect, clearOthers, expOps, expSel }) => ({ option, forceSelect, clearOthers, expectSelected }) =>
expectToggleOptionState({ expectToggleOptionState({
options, options,
multi, multi,
option, option,
clearOthers, clearOthers,
forceSelect, forceSelect,
expOps, expectSelected,
expSel,
})
);
});
describe('and options with B selected', () => {
const options = opsB;
it.each`
option | forceSelect | clearOthers | expOps | expSel
${opANot} | ${true} | ${false} | ${opsA} | ${opASel}
${opANot} | ${false} | ${false} | ${opsA} | ${opASel}
${opANot} | ${true} | ${true} | ${opsA} | ${opASel}
${opANot} | ${false} | ${true} | ${opsA} | ${opASel}
${opA} | ${true} | ${false} | ${opsA} | ${opASel}
${opA} | ${false} | ${false} | ${opsA} | ${opASel}
${opA} | ${true} | ${true} | ${opsA} | ${opASel}
${opA} | ${false} | ${true} | ${opsA} | ${opASel}
`(
'when toggleOption is dispatched and option: $option, forceSelect: $forceSelect, clearOthers: $clearOthers, expOps: $expOps, expSel: $expSel',
({ option, forceSelect, clearOthers, expOps, expSel }) =>
expectToggleOptionState({
options,
multi,
option,
clearOthers,
forceSelect,
expOps,
expSel,
}) })
); );
}); });
}); });
}); });
describe('when showOptions is dispatched', () => {
it('then correct values should be selected', () => {
const { initialState } = getVariableTestContext({});
const payload = {
type: 'query',
query: '',
options: [
{ text: 'All', value: '$__all', selected: false },
{ text: 'A', value: 'A', selected: false },
{ text: 'B', value: 'B', selected: true },
],
multi: false,
id: '0',
} as QueryVariableModel;
reducerTester<OptionsPickerState>()
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
.whenActionIsDispatched(showOptions(payload))
.thenStateShouldEqual({
...initialState,
options: payload.options,
id: payload.id!,
multi: payload.multi,
selectedValues: [{ text: 'B', value: 'B', selected: true }],
queryValue: '',
});
});
});
describe('when showOptions is dispatched and picker has queryValue and variable has searchFilter', () => { describe('when showOptions is dispatched and picker has queryValue and variable has searchFilter', () => {
it('then state should be correct', () => { it('then state should be correct', () => {
const query = '*.__searchFilter'; const query = '*.__searchFilter';
...@@ -395,6 +374,43 @@ describe('optionsPickerReducer', () => { ...@@ -395,6 +374,43 @@ describe('optionsPickerReducer', () => {
}); });
}); });
describe('when toggleTag is dispatched when tag is selected', () => {
it('then state should be correct', () => {
const { initialState } = getVariableTestContext({
tags: [
{ text: 'All A:s', selected: true, values: ['A', 'AA', 'AAA'] },
{ text: 'All B:s', selected: false, values: ['B', 'BB', 'BBB'] },
{ text: 'All C:s', selected: false, values: ['C', 'CC', 'CCC'] },
],
options: [
{ text: 'A', selected: true, value: 'A' },
{ text: 'AA', selected: true, value: 'AA' },
{ text: 'AAA', selected: true, value: 'AAA' },
{ text: 'B', selected: false, value: 'B' },
],
});
const payload: VariableTag = { text: 'All A:s', selected: true, values: ['A', 'AA', 'AAA'] };
reducerTester<OptionsPickerState>()
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
.whenActionIsDispatched(toggleTag(payload))
.thenStateShouldEqual({
...initialState,
options: [
{ text: 'A', selected: false, value: 'A' },
{ text: 'AA', selected: false, value: 'AA' },
{ text: 'AAA', selected: false, value: 'AAA' },
{ text: 'B', selected: false, value: 'B' },
],
tags: [
{ text: 'All A:s', selected: false, values: ['A', 'AA', 'AAA'] },
{ text: 'All B:s', selected: false, values: ['B', 'BB', 'BBB'] },
{ text: 'All C:s', selected: false, values: ['C', 'CC', 'CCC'] },
],
selectedValues: [],
});
});
});
describe('when toggleTag is dispatched and ALL is previous selected', () => { describe('when toggleTag is dispatched and ALL is previous selected', () => {
it('then state should be correct', () => { it('then state should be correct', () => {
const { initialState } = getVariableTestContext({ const { initialState } = getVariableTestContext({
...@@ -554,12 +570,14 @@ describe('optionsPickerReducer', () => { ...@@ -554,12 +570,14 @@ describe('optionsPickerReducer', () => {
}); });
describe('when toggleAllOptions is dispatched', () => { describe('when toggleAllOptions is dispatched', () => {
it('then state should be correct', () => { it('should toggle all values to true', () => {
const { initialState } = getVariableTestContext({ const { initialState } = getVariableTestContext({
options: [ options: [
{ text: 'All', value: '$__all', selected: false },
{ text: 'A', value: 'A', selected: false }, { text: 'A', value: 'A', selected: false },
{ text: 'B', value: 'B', selected: false }, { text: 'B', value: 'B', selected: false },
], ],
selectedValues: [],
multi: true, multi: true,
}); });
...@@ -569,15 +587,67 @@ describe('optionsPickerReducer', () => { ...@@ -569,15 +587,67 @@ describe('optionsPickerReducer', () => {
.thenStateShouldEqual({ .thenStateShouldEqual({
...initialState, ...initialState,
options: [ options: [
{ text: 'All', value: '$__all', selected: true },
{ text: 'A', value: 'A', selected: true }, { text: 'A', value: 'A', selected: true },
{ text: 'B', value: 'B', selected: true }, { text: 'B', value: 'B', selected: true },
], ],
selectedValues: [ selectedValues: [
{ text: 'All', value: '$__all', selected: true },
{ text: 'A', value: 'A', selected: true }, { text: 'A', value: 'A', selected: true },
{ text: 'B', value: 'B', selected: true }, { text: 'B', value: 'B', selected: true },
], ],
}); });
}); });
it('should toggle all values to false when $_all is selected', () => {
const { initialState } = getVariableTestContext({
options: [
{ text: 'All', value: '$__all', selected: true },
{ text: 'A', value: 'A', selected: false },
{ text: 'B', value: 'B', selected: false },
],
selectedValues: [{ text: 'All', value: '$__all', selected: true }],
multi: true,
});
reducerTester<OptionsPickerState>()
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
.whenActionIsDispatched(toggleAllOptions())
.thenStateShouldEqual({
...initialState,
options: [
{ text: 'All', value: '$__all', selected: false },
{ text: 'A', value: 'A', selected: false },
{ text: 'B', value: 'B', selected: false },
],
selectedValues: [],
});
});
it('should toggle all values to false when a option is selected', () => {
const { initialState } = getVariableTestContext({
options: [
{ text: 'All', value: '$__all', selected: false },
{ text: 'A', value: 'A', selected: false },
{ text: 'B', value: 'B', selected: true },
],
selectedValues: [{ text: 'B', value: 'B', selected: true }],
multi: true,
});
reducerTester<OptionsPickerState>()
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
.whenActionIsDispatched(toggleAllOptions())
.thenStateShouldEqual({
...initialState,
options: [
{ text: 'All', value: '$__all', selected: false },
{ text: 'A', value: 'A', selected: false },
{ text: 'B', value: 'B', selected: false },
],
selectedValues: [],
});
});
}); });
describe('when updateOptionsAndFilter is dispatched and searchFilter exists', () => { describe('when updateOptionsAndFilter is dispatched and searchFilter exists', () => {
...@@ -636,6 +706,123 @@ describe('optionsPickerReducer', () => { ...@@ -636,6 +706,123 @@ describe('optionsPickerReducer', () => {
}); });
}); });
describe('when value is selected and filter is applied but then removed', () => {
it('then state should be correct', () => {
const searchQuery = 'A';
const options: VariableOption[] = [
{ text: 'All', value: '$__all', selected: false },
{ text: 'A', value: 'A', selected: false },
{ text: 'B', value: 'B', selected: false },
];
const { initialState } = getVariableTestContext({
options,
});
reducerTester<OptionsPickerState>()
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
.whenActionIsDispatched(toggleOption({ option: options[2], forceSelect: false, clearOthers: false }))
.thenStateShouldEqual({
...initialState,
options: [
{ text: 'All', value: '$__all', selected: false },
{ text: 'A', value: 'A', selected: false },
{ text: 'B', value: 'B', selected: true },
],
selectedValues: [{ text: 'B', value: 'B', selected: true }],
})
.whenActionIsDispatched(updateSearchQuery(searchQuery))
.thenStateShouldEqual({
...initialState,
options: [
{ text: 'All', value: '$__all', selected: false },
{ text: 'A', value: 'A', selected: false },
{ text: 'B', value: 'B', selected: true },
],
selectedValues: [{ text: 'B', value: 'B', selected: true }],
queryValue: searchQuery,
})
.whenActionIsDispatched(updateOptionsAndFilter(options))
.thenStateShouldEqual({
...initialState,
options: [
{ text: 'All', value: '$__all', selected: false },
{ text: 'A', value: 'A', selected: false },
],
selectedValues: [{ text: 'B', value: 'B', selected: true }],
queryValue: searchQuery,
highlightIndex: 0,
})
.whenActionIsDispatched(updateSearchQuery(''))
.thenStateShouldEqual({
...initialState,
options: [
{ text: 'All', value: '$__all', selected: false },
{ text: 'A', value: 'A', selected: false },
],
selectedValues: [{ text: 'B', value: 'B', selected: true }],
queryValue: '',
highlightIndex: 0,
})
.whenActionIsDispatched(updateOptionsAndFilter(options))
.thenStateShouldEqual({
...initialState,
options: [
{ text: 'All', value: '$__all', selected: false },
{ text: 'A', value: 'A', selected: false },
{ text: 'B', value: 'B', selected: true },
],
selectedValues: [{ text: 'B', value: 'B', selected: true }],
queryValue: '',
highlightIndex: 0,
});
});
});
describe('when value is toggled back and forth', () => {
it('then state should be correct', () => {
const options: VariableOption[] = [
{ text: 'All', value: '$__all', selected: false },
{ text: 'A', value: 'A', selected: false },
{ text: 'B', value: 'B', selected: false },
];
const toggleOptionAction = toggleOption({
option: options[2],
forceSelect: false,
clearOthers: false,
});
const { initialState } = getVariableTestContext({
options,
});
reducerTester<OptionsPickerState>()
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
.whenActionIsDispatched(toggleOptionAction)
.thenStateShouldEqual({
...initialState,
options: [
{ text: 'All', value: '$__all', selected: false },
{ text: 'A', value: 'A', selected: false },
{ text: 'B', value: 'B', selected: true },
],
selectedValues: [{ text: 'B', value: 'B', selected: true }],
})
.whenActionIsDispatched(toggleOptionAction)
.thenStateShouldEqual({
...initialState,
options: [
{ text: 'All', value: '$__all', selected: true },
{ text: 'A', value: 'A', selected: false },
{ text: 'B', value: 'B', selected: false },
],
selectedValues: [{ text: 'All', value: '$__all', selected: true }],
});
});
});
describe('when updateOptionsFromSearch is dispatched and variable has searchFilter', () => { describe('when updateOptionsFromSearch is dispatched and variable has searchFilter', () => {
it('then state should be correct', () => { it('then state should be correct', () => {
const searchQuery = '__searchFilter'; const searchQuery = '__searchFilter';
......
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;
}
o.selected = selected;
return o;
} }
o.selected = forceSelect ? true : multi ? !option.selected : true; return applyStateChanges(state, updateDefaultSelection, updateAllSelection, updateOptions);
return o; }
});
state.options = newOptions; if (forceSelect || selected) {
return applyStateChanges(state, updateDefaultSelection, updateSelectedValues); state.selectedValues.push({ ...option, selected: true });
return applyStateChanges(state, updateDefaultSelection, updateAllSelection, updateOptions);
}
state.selectedValues = selectedValues.filter(o => o.value !== option.value);
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