2023-12-12 16:41:50 -06:00
|
|
|
import uFuzzy from '@leeoniya/ufuzzy';
|
2020-03-10 02:53:41 -05:00
|
|
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
2023-12-12 16:41:50 -06:00
|
|
|
import { cloneDeep, isString } from 'lodash';
|
2022-04-22 08:33:13 -05:00
|
|
|
|
2023-07-19 06:56:14 -05:00
|
|
|
import { containsSearchFilter } from '@grafana/data';
|
|
|
|
|
2020-03-10 02:53:41 -05:00
|
|
|
import { applyStateChanges } from '../../../../core/utils/applyStateChanges';
|
2022-01-17 05:48:26 -06:00
|
|
|
import { ALL_VARIABLE_VALUE } from '../../constants';
|
2022-04-22 08:33:13 -05:00
|
|
|
import { isMulti, isQuery } from '../../guard';
|
|
|
|
import { VariableOption, VariableWithOptions } from '../../types';
|
2020-03-10 02:53:41 -05:00
|
|
|
|
|
|
|
export interface ToggleOption {
|
2021-03-29 01:14:43 -05:00
|
|
|
option?: VariableOption;
|
2020-03-10 02:53:41 -05:00
|
|
|
forceSelect: boolean;
|
|
|
|
clearOthers: boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface OptionsPickerState {
|
2020-03-23 07:45:08 -05:00
|
|
|
id: string;
|
2020-03-10 02:53:41 -05:00
|
|
|
selectedValues: VariableOption[];
|
2020-07-08 04:05:20 -05:00
|
|
|
queryValue: string;
|
2020-03-10 02:53:41 -05:00
|
|
|
highlightIndex: number;
|
|
|
|
options: VariableOption[];
|
|
|
|
multi: boolean;
|
|
|
|
}
|
|
|
|
|
2022-02-17 23:06:04 -06:00
|
|
|
export const initialOptionPickerState: OptionsPickerState = {
|
2020-03-23 07:45:08 -05:00
|
|
|
id: '',
|
2020-03-10 02:53:41 -05:00
|
|
|
highlightIndex: -1,
|
2020-07-08 04:05:20 -05:00
|
|
|
queryValue: '',
|
2020-03-10 02:53:41 -05:00
|
|
|
selectedValues: [],
|
|
|
|
options: [],
|
|
|
|
multi: false,
|
|
|
|
};
|
|
|
|
|
2020-05-14 01:09:21 -05:00
|
|
|
export const OPTIONS_LIMIT = 1000;
|
|
|
|
|
2023-12-12 16:41:50 -06:00
|
|
|
const ufuzzy = new uFuzzy({
|
|
|
|
intraMode: 1,
|
|
|
|
intraIns: 1,
|
|
|
|
intraSub: 1,
|
|
|
|
intraTrn: 1,
|
|
|
|
intraDel: 1,
|
|
|
|
});
|
|
|
|
|
2020-06-01 08:06:51 -05:00
|
|
|
const optionsToRecord = (options: VariableOption[]): Record<string, VariableOption> => {
|
|
|
|
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);
|
|
|
|
|
2021-01-20 00:59:48 -06:00
|
|
|
state.options = state.options.map((option) => {
|
2020-06-01 08:06:51 -05:00
|
|
|
if (!isString(option.value)) {
|
|
|
|
return option;
|
|
|
|
}
|
|
|
|
|
|
|
|
const selected = !!selectedOptions[option.value];
|
|
|
|
|
|
|
|
if (option.selected === selected) {
|
|
|
|
return option;
|
|
|
|
}
|
|
|
|
|
|
|
|
return { ...option, selected };
|
|
|
|
});
|
2020-09-15 02:52:45 -05:00
|
|
|
state.options = applyLimit(state.options);
|
2020-03-10 02:53:41 -05:00
|
|
|
return state;
|
|
|
|
};
|
|
|
|
|
|
|
|
const applyLimit = (options: VariableOption[]): VariableOption[] => {
|
|
|
|
if (!Array.isArray(options)) {
|
|
|
|
return [];
|
|
|
|
}
|
2020-06-01 08:06:51 -05:00
|
|
|
if (options.length <= OPTIONS_LIMIT) {
|
|
|
|
return options;
|
|
|
|
}
|
|
|
|
return options.slice(0, OPTIONS_LIMIT);
|
2020-03-10 02:53:41 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
const updateDefaultSelection = (state: OptionsPickerState): OptionsPickerState => {
|
2020-06-01 08:06:51 -05:00
|
|
|
const { options, selectedValues } = state;
|
|
|
|
|
|
|
|
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) {
|
2021-01-20 00:59:48 -06:00
|
|
|
state.selectedValues = selectedValues.filter((option) => option.value !== ALL_VARIABLE_VALUE);
|
2020-03-10 02:53:41 -05:00
|
|
|
}
|
|
|
|
return state;
|
|
|
|
};
|
|
|
|
|
2023-08-28 07:38:32 -05:00
|
|
|
// Utility function to select all options except 'ALL_VARIABLE_VALUE'
|
|
|
|
const selectAllOptions = (options: VariableOption[]) =>
|
|
|
|
options
|
|
|
|
.filter((option) => option.value !== ALL_VARIABLE_VALUE)
|
|
|
|
.map((option) => ({
|
|
|
|
...option,
|
|
|
|
selected: true,
|
|
|
|
}));
|
|
|
|
|
2020-03-10 02:53:41 -05:00
|
|
|
const optionsPickerSlice = createSlice({
|
|
|
|
name: 'templating/optionsPicker',
|
2022-02-17 23:06:04 -06:00
|
|
|
initialState: initialOptionPickerState,
|
2020-03-10 02:53:41 -05:00
|
|
|
reducers: {
|
2021-02-17 23:21:35 -06:00
|
|
|
showOptions: (state, action: PayloadAction<VariableWithOptions>): OptionsPickerState => {
|
|
|
|
const { query, options } = action.payload;
|
2020-03-10 02:53:41 -05:00
|
|
|
|
|
|
|
state.highlightIndex = -1;
|
|
|
|
state.options = cloneDeep(options);
|
2020-06-04 06:44:48 -05:00
|
|
|
state.id = action.payload.id;
|
2020-03-10 02:53:41 -05:00
|
|
|
state.queryValue = '';
|
2021-02-17 23:21:35 -06:00
|
|
|
state.multi = false;
|
|
|
|
|
|
|
|
if (isMulti(action.payload)) {
|
|
|
|
state.multi = action.payload.multi ?? false;
|
|
|
|
}
|
2020-03-10 02:53:41 -05:00
|
|
|
|
|
|
|
if (isQuery(action.payload)) {
|
|
|
|
const { queryValue } = action.payload;
|
|
|
|
const queryHasSearchFilter = containsSearchFilter(query);
|
|
|
|
state.queryValue = queryHasSearchFilter && queryValue ? queryValue : '';
|
|
|
|
}
|
|
|
|
|
2021-01-20 00:59:48 -06:00
|
|
|
state.selectedValues = state.options.filter((option) => option.selected);
|
2020-06-01 08:06:51 -05:00
|
|
|
return applyStateChanges(state, updateDefaultSelection, updateOptions);
|
2020-03-10 02:53:41 -05:00
|
|
|
},
|
|
|
|
hideOptions: (state, action: PayloadAction): OptionsPickerState => {
|
2022-02-17 23:06:04 -06:00
|
|
|
return { ...initialOptionPickerState };
|
2020-03-10 02:53:41 -05:00
|
|
|
},
|
|
|
|
toggleOption: (state, action: PayloadAction<ToggleOption>): OptionsPickerState => {
|
2020-06-01 08:06:51 -05:00
|
|
|
const { option, clearOthers, forceSelect } = action.payload;
|
|
|
|
const { multi, selectedValues } = state;
|
|
|
|
|
2021-03-29 01:14:43 -05:00
|
|
|
if (option) {
|
2021-10-19 08:13:17 -05:00
|
|
|
const selected = !selectedValues.find((o) => o.value === option.value && o.text === option.text);
|
2021-03-29 01:14:43 -05:00
|
|
|
|
|
|
|
if (option.value === ALL_VARIABLE_VALUE || !multi || clearOthers) {
|
|
|
|
if (selected || forceSelect) {
|
|
|
|
state.selectedValues = [{ ...option, selected: true }];
|
|
|
|
} else {
|
|
|
|
state.selectedValues = [];
|
|
|
|
}
|
2022-08-05 07:44:52 -05:00
|
|
|
|
2021-03-29 01:14:43 -05:00
|
|
|
return applyStateChanges(state, updateDefaultSelection, updateAllSelection, updateOptions);
|
|
|
|
}
|
2022-08-05 07:44:52 -05:00
|
|
|
|
2021-03-29 01:14:43 -05:00
|
|
|
if (forceSelect || selected) {
|
|
|
|
state.selectedValues.push({ ...option, selected: true });
|
|
|
|
return applyStateChanges(state, updateDefaultSelection, updateAllSelection, updateOptions);
|
2020-03-10 02:53:41 -05:00
|
|
|
}
|
|
|
|
|
2021-10-19 08:13:17 -05:00
|
|
|
state.selectedValues = selectedValues.filter((o) => o.value !== option.value && o.text !== option.text);
|
2021-03-29 01:14:43 -05:00
|
|
|
} else {
|
|
|
|
state.selectedValues = [];
|
2020-06-01 08:06:51 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
return applyStateChanges(state, updateDefaultSelection, updateAllSelection, updateOptions);
|
2020-03-10 02:53:41 -05:00
|
|
|
},
|
|
|
|
moveOptionsHighlight: (state, action: PayloadAction<number>): OptionsPickerState => {
|
|
|
|
let nextIndex = state.highlightIndex + action.payload;
|
|
|
|
|
|
|
|
if (nextIndex < 0) {
|
2023-01-19 02:53:14 -06:00
|
|
|
nextIndex = -1;
|
2020-03-10 02:53:41 -05:00
|
|
|
} else if (nextIndex >= state.options.length) {
|
|
|
|
nextIndex = state.options.length - 1;
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
highlightIndex: nextIndex,
|
|
|
|
};
|
|
|
|
},
|
2023-08-28 07:38:32 -05:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Toggle the 'All' option or clear selections in the Options Picker dropdown.
|
|
|
|
* 1. If 'All' is configured but not selected, and some other options are selected, it deselects all other options and selects only 'All'.
|
|
|
|
* 2. If only 'All' is selected, it deselects 'All' and selects all other available options.
|
|
|
|
* 3. If some options are selected but 'All' is not configured in the variable,
|
|
|
|
* it clears all selections and defaults to the current behavior for scenarios where 'All' is not configured.
|
|
|
|
* 4. If no options are selected, it selects all available options.
|
|
|
|
*/
|
2020-03-10 02:53:41 -05:00
|
|
|
toggleAllOptions: (state, action: PayloadAction): OptionsPickerState => {
|
2023-08-28 07:38:32 -05:00
|
|
|
// Check if 'All' option is configured by the user and if it's selected in the dropdown
|
|
|
|
const isAllSelected = state.selectedValues.find((option) => option.value === ALL_VARIABLE_VALUE);
|
|
|
|
const allOptionConfigured = state.options.find((option) => option.value === ALL_VARIABLE_VALUE);
|
|
|
|
|
|
|
|
// If 'All' option is not selected from the dropdown, but some options are, clear all options and select 'All'
|
|
|
|
if (state.selectedValues.length > 0 && !!allOptionConfigured && !isAllSelected) {
|
2020-06-01 08:06:51 -05:00
|
|
|
state.selectedValues = [];
|
2023-08-28 07:38:32 -05:00
|
|
|
|
|
|
|
state.selectedValues.push({
|
|
|
|
text: allOptionConfigured.text ?? 'All',
|
|
|
|
value: allOptionConfigured.value,
|
|
|
|
selected: true,
|
|
|
|
});
|
|
|
|
|
2020-06-01 08:06:51 -05:00
|
|
|
return applyStateChanges(state, updateOptions);
|
|
|
|
}
|
|
|
|
|
2023-08-28 07:38:32 -05:00
|
|
|
// If 'All' option is the only one selected in the dropdown, unselect "All" and select each one of the other options.
|
|
|
|
if (isAllSelected && state.selectedValues.length === 1) {
|
|
|
|
state.selectedValues = selectAllOptions(state.options);
|
|
|
|
return applyStateChanges(state, updateOptions);
|
|
|
|
}
|
2020-03-10 02:53:41 -05:00
|
|
|
|
2023-08-28 07:38:32 -05:00
|
|
|
// If some options are selected, but 'All' is not configured by the user, clear the selection and let the
|
|
|
|
// current behavior when "All" does not exist and user clear the selected items.
|
|
|
|
if (state.selectedValues.length > 0 && !allOptionConfigured) {
|
|
|
|
state.selectedValues = [];
|
|
|
|
return applyStateChanges(state, updateOptions);
|
|
|
|
}
|
|
|
|
|
|
|
|
// If no options are selected and 'All' is not selected, select all options
|
|
|
|
state.selectedValues = selectAllOptions(state.options);
|
2020-06-01 08:06:51 -05:00
|
|
|
return applyStateChanges(state, updateOptions);
|
2020-03-10 02:53:41 -05:00
|
|
|
},
|
2023-08-28 07:38:32 -05:00
|
|
|
|
2020-03-10 02:53:41 -05:00
|
|
|
updateSearchQuery: (state, action: PayloadAction<string>): OptionsPickerState => {
|
|
|
|
state.queryValue = action.payload;
|
|
|
|
return state;
|
|
|
|
},
|
|
|
|
updateOptionsAndFilter: (state, action: PayloadAction<VariableOption[]>): OptionsPickerState => {
|
2023-12-12 16:41:50 -06:00
|
|
|
const needle = state.queryValue.trim();
|
|
|
|
|
|
|
|
let opts: VariableOption[] = [];
|
|
|
|
|
|
|
|
if (needle === '') {
|
|
|
|
opts = action.payload;
|
|
|
|
} else {
|
|
|
|
// with current API, not seeing a way to cache this on state using action.payload's uniqueness
|
|
|
|
// since it's recreated and includes selected state on each item :(
|
|
|
|
const haystack = action.payload.map(({ text }) => (Array.isArray(text) ? text.toString() : text));
|
|
|
|
|
|
|
|
const [idxs, info, order] = ufuzzy.search(haystack, needle, 5);
|
2020-03-10 02:53:41 -05:00
|
|
|
|
2023-12-12 16:41:50 -06:00
|
|
|
if (idxs?.length) {
|
|
|
|
if (info && order) {
|
|
|
|
opts = order.map((idx) => action.payload[info.idx[idx]]);
|
|
|
|
} else {
|
|
|
|
opts = idxs!.map((idx) => action.payload[idx]);
|
|
|
|
}
|
|
|
|
|
|
|
|
// always sort $__all to the top, even if exact match exists?
|
2024-03-28 18:18:02 -05:00
|
|
|
opts.sort((a, b) => (a.value === ALL_VARIABLE_VALUE ? -1 : 0) - (b.value === ALL_VARIABLE_VALUE ? -1 : 0));
|
2023-12-12 16:41:50 -06:00
|
|
|
}
|
|
|
|
}
|
2020-03-10 02:53:41 -05:00
|
|
|
|
2023-03-08 05:36:06 -06:00
|
|
|
state.highlightIndex = 0;
|
2020-05-14 01:09:21 -05:00
|
|
|
|
2024-03-28 18:18:02 -05:00
|
|
|
if (needle !== '') {
|
|
|
|
// top ranked match index
|
|
|
|
let firstMatchIdx = opts.findIndex((o) => o.value !== ALL_VARIABLE_VALUE);
|
|
|
|
|
|
|
|
// if there's no match or no exact match, prepend as-typed option
|
|
|
|
if (firstMatchIdx === -1 || opts[firstMatchIdx].value !== needle) {
|
|
|
|
opts.unshift({
|
|
|
|
selected: false,
|
|
|
|
text: '> ' + needle,
|
|
|
|
value: needle,
|
|
|
|
});
|
|
|
|
|
|
|
|
// if no match at all, select as-typed, else select best match
|
|
|
|
state.highlightIndex = firstMatchIdx === -1 ? 0 : firstMatchIdx + 1;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
state.options = opts;
|
|
|
|
|
2020-06-01 08:06:51 -05:00
|
|
|
return applyStateChanges(state, updateDefaultSelection, updateOptions);
|
2020-03-10 02:53:41 -05:00
|
|
|
},
|
|
|
|
updateOptionsFromSearch: (state, action: PayloadAction<VariableOption[]>): OptionsPickerState => {
|
2020-09-15 02:52:45 -05:00
|
|
|
state.options = action.payload;
|
2020-03-10 02:53:41 -05:00
|
|
|
state.highlightIndex = 0;
|
|
|
|
|
2020-06-01 08:06:51 -05:00
|
|
|
return applyStateChanges(state, updateDefaultSelection, updateOptions);
|
2020-03-10 02:53:41 -05:00
|
|
|
},
|
2022-02-17 23:06:04 -06:00
|
|
|
cleanPickerState: () => initialOptionPickerState,
|
2020-03-10 02:53:41 -05:00
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
export const {
|
|
|
|
toggleOption,
|
|
|
|
showOptions,
|
|
|
|
hideOptions,
|
|
|
|
moveOptionsHighlight,
|
|
|
|
toggleAllOptions,
|
|
|
|
updateSearchQuery,
|
|
|
|
updateOptionsAndFilter,
|
|
|
|
updateOptionsFromSearch,
|
2021-02-02 02:13:39 -06:00
|
|
|
cleanPickerState,
|
2020-03-10 02:53:41 -05:00
|
|
|
} = optionsPickerSlice.actions;
|
|
|
|
|
|
|
|
export const optionsPickerReducer = optionsPickerSlice.reducer;
|