From 60a64fcc07461e95e8eb26ebb05bff7dc9c55c1d Mon Sep 17 00:00:00 2001 From: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com> Date: Thu, 30 May 2024 21:15:05 +0530 Subject: [PATCH] feat: Multi Select Variable (#3372) - #3460 - #2015 ## Summary by CodeRabbit - **New Features** - Added multi-select functionality for variables in dashboards. - Introduced a custom value selector component for dashboard settings. - Updated the app to use Quasar's fullscreen module. - **Enhancements** - Improved query verification process in DashboardQueryEditor. - Enhanced filtering logic for variable placeholders and values in queries. - Added "IN" operator support for dashboard queries. - **Bug Fixes** - Fixed issues with handling and displaying selected values in various components. - **Performance Improvements** - Increased size parameter for dashboard panel data loading from 10 to 100. - **Localization** - Added new localization key: "multiSelect" for multiple selection support. - **UI/UX** - Updated styles and key bindings in DrilldownPopUp. - Enhanced display logic for selected values in VariableQueryValueSelector. - **Dependencies** - Updated Quasar package from version ^2.7.5 to ^2.15.4. --------- Co-authored-by: ktx-abhay --- src/common/meta/dashboards/v3/mod.rs | 2 + web/package-lock.json | 8 +- web/package.json | 2 +- .../dashboards/PanelSchemaRenderer.vue | 8 + .../dashboards/VariablesValueSelector.vue | 154 +++++++++------- .../addPanel/DashboardMapQueryBuilder.vue | 5 +- .../addPanel/DashboardQueryBuilder.vue | 6 +- .../addPanel/DashboardQueryEditor.vue | 23 ++- .../addPanel/DashboardSankeyChartBuilder.vue | 5 +- .../dashboards/addPanel/DrilldownPopUp.vue | 23 ++- .../settings/AddSettingVariable.vue | 21 ++- .../settings/VariableCustomValueSelector.vue | 171 ++++++++++++++++++ .../settings/VariableQueryValueSelector.vue | 96 +++++++++- .../dashboard/usePanelDataLoader.ts | 132 +++++++++++--- web/src/composables/useDashboardPanel.ts | 4 +- web/src/locales/languages/en.json | 1 + web/src/main.ts | 3 +- .../Dashboards/RenderDashboardCharts.vue | 2 +- web/src/views/Dashboards/ViewDashboard.vue | 38 ++-- 19 files changed, 571 insertions(+), 133 deletions(-) create mode 100644 web/src/components/dashboards/settings/VariableCustomValueSelector.vue diff --git a/src/common/meta/dashboards/v3/mod.rs b/src/common/meta/dashboards/v3/mod.rs index 74c7713537..137d238a0c 100644 --- a/src/common/meta/dashboards/v3/mod.rs +++ b/src/common/meta/dashboards/v3/mod.rs @@ -274,6 +274,8 @@ pub struct VariableList { pub query_data: Option, pub value: Option, pub options: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub multi_select: Option, } #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/web/package-lock.json b/web/package-lock.json index cac700a049..ca869d4a1b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -30,7 +30,7 @@ "moment-timezone": "^0.5.43", "node-polyfill-webpack-plugin": "^2.0.1", "node-sql-parser": "^4.6.4", - "quasar": "^2.7.5", + "quasar": "^2.15.4", "rollup-plugin-node-polyfills": "^0.2.1", "rollup-plugin-polyfill-node": "^0.10.2", "rudder-sdk-js": "^2.45.0", @@ -11672,9 +11672,9 @@ } }, "node_modules/quasar": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/quasar/-/quasar-2.14.0.tgz", - "integrity": "sha512-hxaQ/yd/qNGgpUrww4fQRSpklKnZN90HXhmhmPE5h+yFCcXreU+JgM3m/Mf6Qv7lMNQZQkYipgZ5Ja41DYkVUQ==", + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/quasar/-/quasar-2.15.4.tgz", + "integrity": "sha512-6Rtj0KrsVA0IV9zMZ6R7U7hOpwLS/6E06hsISVHRPn21KEm3XAwHdvy9xWz5kwqWraHRlcisFSDu/KPL4VQK1w==", "engines": { "node": ">= 10.18.1", "npm": ">= 6.13.4", diff --git a/web/package.json b/web/package.json index e2aeb45347..a61cf0aa5d 100644 --- a/web/package.json +++ b/web/package.json @@ -45,7 +45,7 @@ "moment-timezone": "^0.5.43", "node-polyfill-webpack-plugin": "^2.0.1", "node-sql-parser": "^4.6.4", - "quasar": "^2.7.5", + "quasar": "^2.15.4", "rollup-plugin-node-polyfills": "^0.2.1", "rollup-plugin-polyfill-node": "^0.10.2", "rudder-sdk-js": "^2.45.0", diff --git a/web/src/components/dashboards/PanelSchemaRenderer.vue b/web/src/components/dashboards/PanelSchemaRenderer.vue index 8281cece9c..28d7967e65 100644 --- a/web/src/components/dashboards/PanelSchemaRenderer.vue +++ b/web/src/components/dashboards/PanelSchemaRenderer.vue @@ -422,6 +422,14 @@ export default defineComponent({ // drilldown const replacePlaceholders = (str: any, obj: any) => { + // if the str is same as the key, return it's value(it can be an string or array). + for (const key in obj) { + // ${varName} == str + if (`\$\{${key}\}` == str) { + return obj[key]; + } + } + return str.replace(/\$\{([^}]+)\}/g, function (_: any, key: any) { // Split the key into parts by either a dot or a ["xyz"] pattern and filter out empty strings let parts = key.split(/\.|\["(.*?)"\]/).filter(Boolean); diff --git a/web/src/components/dashboards/VariablesValueSelector.vue b/web/src/components/dashboards/VariablesValueSelector.vue index 013831cfda..33d0997925 100644 --- a/web/src/components/dashboards/VariablesValueSelector.vue +++ b/web/src/components/dashboards/VariablesValueSelector.vue @@ -65,38 +65,11 @@ along with this program. If not, see . >
- - - + />
@@ -111,6 +84,7 @@ import { defineComponent, reactive } from "vue"; import streamService from "../../services/stream"; import { useStore } from "vuex"; import VariableQueryValueSelector from "./settings/VariableQueryValueSelector.vue"; +import VariableCustomValueSelector from "./settings/VariableCustomValueSelector.vue"; import VariableAdHocValueSelector from "./settings/VariableAdHocValueSelector.vue"; import { isInvalidDate } from "@/utils/date"; import { addLabelsToSQlQuery } from "@/utils/query/sqlUtils"; @@ -129,6 +103,7 @@ export default defineComponent({ components: { VariableQueryValueSelector, VariableAdHocValueSelector, + VariableCustomValueSelector, }, setup(props: any, { emit }) { const instance = getCurrentInstance(); @@ -138,7 +113,6 @@ export default defineComponent({ isVariablesLoading: false, values: [], }); - // variables dependency graph let variablesDependencyGraph: any = {}; @@ -167,7 +141,7 @@ export default defineComponent({ // make list of variables using variables config list // set initial variables values from props props?.variablesConfig?.list?.forEach((item: any) => { - const initialValue = + let initialValue = item.type == "dynamic_filters" ? JSON.parse( decodeURIComponent( @@ -177,6 +151,11 @@ export default defineComponent({ ) ?? [] : props.initialVariableValues?.value[item.name] ?? null; + if (item.multiSelect) { + initialValue = Array.isArray(initialValue) + ? initialValue + : [initialValue]; + } const variableData = { ...item, // isLoading is used to check that currently, if the variable is loading(it is used to show the loading icon) @@ -185,7 +164,6 @@ export default defineComponent({ // if parent variable is not loaded or it's value is changed, isVariableLoadingPending will be true isVariableLoadingPending: true, }; - // need to use initial value // also, constant type variable should not be updated if (item.type != "constant") { @@ -399,10 +377,23 @@ export default defineComponent({ variable.isVariableLoadingPending === false ) { // replace it's value in the query if it is dependent on query context - queryContext = queryContext.replace( - `$${variable.name}`, - variable.value - ); + + if (Array.isArray(variable.value)) { + const arrayValues = variable.value + .map((value: any) => { + return `'${value}'`; + }) + .join(", "); + queryContext = queryContext.replace( + `'$${variable.name}'`, + `(${arrayValues})` + ); + } else { + queryContext = queryContext.replace( + `$${variable.name}`, + variable.value + ); + } } // above condition not matched, means variable is not loaded // so, check if it is dependent on query context @@ -451,19 +442,41 @@ export default defineComponent({ value: value.zo_sql_key.toString(), })); - // if the old value exist in dropdown set the old value otherwise set first value of drop down otherwise set blank string value + // Define oldVariableSelectedValues array + let oldVariableSelectedValues: any = []; + if (oldVariablesData[currentVariable.name]) { + oldVariableSelectedValues = Array.isArray( + oldVariablesData[currentVariable.name] + ) + ? oldVariablesData[currentVariable.name] + : [oldVariablesData[currentVariable.name]]; + } + + // if the old value exists in the dropdown, set the old value; otherwise, set the first value of the dropdown; otherwise, set a blank string value if ( oldVariablesData[currentVariable.name] !== undefined || oldVariablesData[currentVariable.name] !== null ) { - currentVariable.value = currentVariable.options.some( - (option: any) => - option.value === oldVariablesData[currentVariable.name] - ) - ? oldVariablesData[currentVariable.name] - : currentVariable.options.length - ? currentVariable.options[0].value - : null; + if (currentVariable.multiSelect) { + const selectedValues = currentVariable.options + .filter((option: any) => + oldVariableSelectedValues.includes(option.value) + ) + .map((option: any) => option.value); + currentVariable.value = + selectedValues.length > 0 + ? selectedValues + : [currentVariable.options[0].value]; // If no option is available, set as the first value + } else { + currentVariable.value = currentVariable.options.some( + (option: any) => + option.value === oldVariablesData[currentVariable.name] + ) + ? oldVariablesData[currentVariable.name] + : currentVariable.options.length + ? currentVariable.options[0].value + : null; + } } else { currentVariable.value = currentVariable.options.length ? currentVariable.options[0].value @@ -475,7 +488,7 @@ export default defineComponent({ } else { // no response hits found // set value as empty string - currentVariable.value = null; + currentVariable.value = currentVariable.multiSelect ? [] : null; // set options as empty array currentVariable.options = []; @@ -502,21 +515,40 @@ export default defineComponent({ break; } case "custom": { - // assign options - currentVariable["options"] = currentVariable?.options; - - // if the old value exist in dropdown set the old value - // otherwise set first value of drop down - // otherwise set null value - let oldVariableObjectSelectedValue = currentVariable.options.find( - (option: any) => - option.value === oldVariablesData[currentVariable.name] - ); - // if the old value exist in dropdown set the old value otherwise set first value of drop down otherwise set blank string value - if (oldVariableObjectSelectedValue) { - currentVariable.value = oldVariableObjectSelectedValue.value; + currentVariable.options = currentVariable?.options; + + // Check if the old value exists and set it + let oldVariableSelectedValues: any = []; + if (oldVariablesData[currentVariable.name]) { + oldVariableSelectedValues = Array.isArray( + oldVariablesData[currentVariable.name] + ) + ? oldVariablesData[currentVariable.name] + : [oldVariablesData[currentVariable.name]]; + } + + // If multiSelect is true, set the value as an array containing old value and selected value + if (currentVariable.multiSelect) { + const selectedValues = currentVariable.options + .filter((option: any) => + oldVariableSelectedValues.includes(option.value) + ) + .map((option: any) => option.value); + currentVariable.value = + // If no option is available, set as the first value or if old value exists, set the old value + selectedValues.length > 0 + ? selectedValues + : [currentVariable.options[0].value] || + oldVariableSelectedValues; } else { - currentVariable.value = currentVariable.options[0]?.value ?? null; + // If multiSelect is false, set the value as a single value from options which is selected + currentVariable.value = + currentVariable.options.find( + (option: any) => option.value === oldVariableSelectedValues[0] + )?.value ?? + (currentVariable.options.length > 0 + ? currentVariable.options[0].value + : null); } resolve(true); diff --git a/web/src/components/dashboards/addPanel/DashboardMapQueryBuilder.vue b/web/src/components/dashboards/addPanel/DashboardMapQueryBuilder.vue index 0ac77c9936..609aa4c834 100644 --- a/web/src/components/dashboards/addPanel/DashboardMapQueryBuilder.vue +++ b/web/src/components/dashboards/addPanel/DashboardMapQueryBuilder.vue @@ -931,7 +931,9 @@ export default defineComponent({ const dashboardVariablesFilterItems = computed(() => (props.dashboardData?.variables?.list ?? []).map((it: any) => ({ label: it.name, - value: "'" + "$" + it.name + "'", + value: it.multiSelect + ? "(" + "$" + "{" + it.name + "}" + ")" + : "'" + "$" + it.name + "'", })) ); @@ -969,6 +971,7 @@ export default defineComponent({ "<=", ">", "<", + "IN", "Contains", "Not Contains", "Is Null", diff --git a/web/src/components/dashboards/addPanel/DashboardQueryBuilder.vue b/web/src/components/dashboards/addPanel/DashboardQueryBuilder.vue index 2783fccd11..85f6a7f33a 100644 --- a/web/src/components/dashboards/addPanel/DashboardQueryBuilder.vue +++ b/web/src/components/dashboards/addPanel/DashboardQueryBuilder.vue @@ -1336,10 +1336,11 @@ export default defineComponent({ const dashboardVariablesFilterItems = computed(() => (props.dashboardData?.variables?.list ?? []).map((it: any) => ({ label: it.name, - value: "'" + "$" + it.name + "'", + value: it.multiSelect + ? "(" + "$" + "{" + it.name + "}" + ")" + : "'" + "$" + it.name + "'", })) ); - return { showXAxis, t, @@ -1364,6 +1365,7 @@ export default defineComponent({ "<=", ">", "<", + "IN", "Contains", "Not Contains", "Is Null", diff --git a/web/src/components/dashboards/addPanel/DashboardQueryEditor.vue b/web/src/components/dashboards/addPanel/DashboardQueryEditor.vue index a0c76f8018..6e022214b9 100644 --- a/web/src/components/dashboards/addPanel/DashboardQueryEditor.vue +++ b/web/src/components/dashboards/addPanel/DashboardQueryEditor.vue @@ -838,11 +838,28 @@ export default defineComponent({ // Get the parsed query try { - dashboardPanelData.meta.parsedQuery = parser.astify( - dashboardPanelData.data.queries[ + let currentQuery = dashboardPanelData.data.queries[ dashboardPanelData.layout.currentQueryIndex ].query - ); + + // replace variables with dummy values to verify query is correct or not + if(/\${[a-zA-Z0-9_-]+:csv}/.test(currentQuery)){ + currentQuery = currentQuery.replaceAll(/\${[a-zA-Z0-9_-]+:csv}/g, "1,2") + } + if(/\${[a-zA-Z0-9_-]+:singlequote}/.test(currentQuery)){ + currentQuery = currentQuery.replaceAll(/\${[a-zA-Z0-9_-]+:singlequote}/g, "'1','2'") + } + if(/\${[a-zA-Z0-9_-]+:doublequote}/.test(currentQuery)){ + currentQuery = currentQuery.replaceAll(/\${[a-zA-Z0-9_-]+:doublequote}/g, '"1","2"') + } + if(/\${[a-zA-Z0-9_-]+:pipe}/.test(currentQuery)){ + currentQuery = currentQuery.replaceAll(/\${[a-zA-Z0-9_-]+:pipe}/g, "1|2") + } + if(/\$(\w+|\{\w+\})/.test(currentQuery)){ + currentQuery = currentQuery.replaceAll(/\$(\w+|\{\w+\})/g, "10") + } + + dashboardPanelData.meta.parsedQuery = parser.astify(currentQuery); } catch (e) { // exit as there is an invalid query dashboardPanelData.meta.errors.queryErrors.push("Invalid SQL Syntax"); diff --git a/web/src/components/dashboards/addPanel/DashboardSankeyChartBuilder.vue b/web/src/components/dashboards/addPanel/DashboardSankeyChartBuilder.vue index 56271b5ec5..573ab59978 100644 --- a/web/src/components/dashboards/addPanel/DashboardSankeyChartBuilder.vue +++ b/web/src/components/dashboards/addPanel/DashboardSankeyChartBuilder.vue @@ -922,7 +922,9 @@ export default defineComponent({ const dashboardVariablesFilterItems = computed(() => (props.dashboardData?.variables?.list ?? []).map((it: any) => ({ label: it.name, - value: "'" + "$" + it.name + "'", + value: it.multiSelect + ? "(" + "$" + "{" + it.name + "}" + ")" + : "'" + "$" + it.name + "'", })) ); @@ -959,6 +961,7 @@ export default defineComponent({ "<=", ">", "<", + "IN", "Contains", "Not Contains", "Is Null", diff --git a/web/src/components/dashboards/addPanel/DrilldownPopUp.vue b/web/src/components/dashboards/addPanel/DrilldownPopUp.vue index 5965cb2bb6..0205c09651 100644 --- a/web/src/components/dashboards/addPanel/DrilldownPopUp.vue +++ b/web/src/components/dashboards/addPanel/DrilldownPopUp.vue @@ -215,7 +215,7 @@ >
{ - if (newData.data.folder && newData.data.dashboard) { + const getvariableNames = async () => { + if ( + drilldownData.value.data.folder && + drilldownData.value.data.dashboard + ) { const folder = store.state.organizationData.folders.find( - (folder: any) => folder.name === newData.data.folder + (folder: any) => folder.name === drilldownData.value.data.folder ); const allDashboardData = await getAllDashboardsByFolderId( @@ -614,7 +620,8 @@ export default defineComponent({ folder.folderId ); const dashboardData = allDashboardData.find( - (dashboard: any) => dashboard.title === newData.data.dashboard + (dashboard: any) => + dashboard.title === drilldownData.value.data.dashboard ); if (dashboardData) { @@ -628,6 +635,12 @@ export default defineComponent({ } else { variableNamesFn.value = []; } + } + }; + + watch(drilldownData.value, async (newData) => { + if (newData.data.folder && newData.data.dashboard) { + await getvariableNames(); } else { variableNamesFn.value = []; } diff --git a/web/src/components/dashboards/settings/AddSettingVariable.vue b/web/src/components/dashboards/settings/AddSettingVariable.vue index f6a4dddc74..4a973173fd 100644 --- a/web/src/components/dashboards/settings/AddSettingVariable.vue +++ b/web/src/components/dashboards/settings/AddSettingVariable.vue @@ -157,6 +157,13 @@ along with this program. If not, see .
+
+ +
. class="operator" data-test="dashboard-query-values-filter-operator-selector" :rules="[(val: any) => !!(val.trim()) || 'Field is required!']" - :options="['=', '!=']" + :options="['=', '!=', 'IN']" /> .
+
+ +
{ variableData.query_data.filter[index].name = filter.name; diff --git a/web/src/components/dashboards/settings/VariableCustomValueSelector.vue b/web/src/components/dashboards/settings/VariableCustomValueSelector.vue new file mode 100644 index 0000000000..605b7d5619 --- /dev/null +++ b/web/src/components/dashboards/settings/VariableCustomValueSelector.vue @@ -0,0 +1,171 @@ + + + + + + + diff --git a/web/src/components/dashboards/settings/VariableQueryValueSelector.vue b/web/src/components/dashboards/settings/VariableQueryValueSelector.vue index d9af312dec..907febbf6a 100644 --- a/web/src/components/dashboards/settings/VariableQueryValueSelector.vue +++ b/web/src/components/dashboards/settings/VariableQueryValueSelector.vue @@ -22,15 +22,7 @@ along with this program. If not, see . outlined dense v-model="selectedValue" - :display-value=" - selectedValue || selectedValue == '' - ? selectedValue == '' - ? '' - : selectedValue - : !variableItem.isLoading - ? '(No Data Found)' - : '' - " + :display-value="displayValue" :label="variableItem?.label || variableItem?.name" :options="fieldsFilteredOptions" input-debounce="0" @@ -44,6 +36,9 @@ along with this program. If not, see . class="textbox col no-case" :loading="variableItem.isLoading" data-test="dashboard-variable-query-value-selector" + :multiple="variableItem.multiSelect" + popup-no-route-dismiss + popup-content-style="z-index: 10001" > + +