diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/group_risk_score_panel.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/group_risk_score_panel.vue
index fa3f06cd0792d906df3795349c2cefa01156d0dd..ebf298f8d810e2fd788425e231afc8a919401ff4 100644
--- a/ee/app/assets/javascripts/security_dashboard/components/shared/group_risk_score_panel.vue
+++ b/ee/app/assets/javascripts/security_dashboard/components/shared/group_risk_score_panel.vue
@@ -1,12 +1,18 @@
diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/security_dashboard_filtered_search/filtered_search.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/security_dashboard_filtered_search/filtered_search.vue
index 596b334c0819d2af461fbd8391b8196d849d8de1..34d5f93fb4bfbdcb4cbcd2e444f2bb2ed13b7966 100644
--- a/ee/app/assets/javascripts/security_dashboard/components/shared/security_dashboard_filtered_search/filtered_search.vue
+++ b/ee/app/assets/javascripts/security_dashboard/components/shared/security_dashboard_filtered_search/filtered_search.vue
@@ -2,6 +2,7 @@
import { nextTick } from 'vue';
import { GlFilteredSearch } from '@gitlab/ui';
import { isEqual } from 'lodash';
+import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { ALL_ID } from 'ee/security_dashboard/components/shared/filters/constants';
export default {
@@ -26,7 +27,7 @@ export default {
const { initialValue, newFilters } = this.tokens.reduce(
(acc, token) => {
const paramValue = params.get(token.type);
- const data = paramValue?.split(',').filter(Boolean);
+ const data = paramValue?.split(',').filter((i) => i !== '');
if (data?.length > 0) {
acc.newFilters[token.type] = data;
@@ -62,17 +63,16 @@ export default {
const params = this.tokens.reduce((acc, { type }) => {
const filterValue = this.filters[type];
if (filterValue?.length > 0) {
- acc.set(type, filterValue.join(','));
+ acc[type] = filterValue.join(',');
} else {
- acc.delete(type);
+ acc[type] = undefined;
}
return acc;
- }, new URLSearchParams(window.location.search));
+ }, {});
+ // Passing true for last param to make sure params are decoded
+ const url = setUrlParams(params, window.location.href, false, false, true);
- const newUrl = params.toString()
- ? `${window.location.pathname}?${params.toString()}`
- : window.location.pathname;
- window.history.pushState({}, '', newUrl);
+ updateHistory({ url, replace: true });
},
async handleTokenComplete({ type }) {
// Need to wait for `this.value` to have been updated
diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel.vue
index 1ae8c4496f2dc22245d03af9831a4c9b763d0f17..3155b9c135a6823fd2c291563a9dd7100f849b0d 100644
--- a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel.vue
+++ b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel.vue
@@ -2,6 +2,7 @@
import ExtendedDashboardPanel from '~/vue_shared/components/customizable_dashboard/extended_dashboard_panel.vue';
import { s__ } from '~/locale';
import { formatDate, getDateInPast } from '~/lib/utils/datetime_utility';
+import { readFromUrl, writeToUrl } from 'ee/security_dashboard/utils/panel_state_url_sync';
import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue';
import projectVulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/project_vulnerabilities_over_time.query.graphql';
import groupVulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/group_vulnerabilities_over_time.query.graphql';
@@ -16,6 +17,10 @@ const TIME_PERIODS = {
NINETY_DAYS: { key: 'ninetyDays', startDays: 90, endDays: 61 },
};
+const PANEL_ID = 'vulnerabilitiesOverTime';
+const GROUP_BY_DEFAULT = 'severity';
+const TIME_PERIOD_DEFAULT = 30;
+
const SCOPE_CONFIG = {
project: {
query: projectVulnerabilitiesOverTime,
@@ -56,17 +61,27 @@ export default {
data() {
return {
fetchError: false,
- groupedBy: 'severity',
- selectedTimePeriod: 30,
+ groupedBy: readFromUrl({
+ panelId: PANEL_ID,
+ paramName: 'groupBy',
+ defaultValue: GROUP_BY_DEFAULT,
+ }),
+ selectedTimePeriod: readFromUrl({
+ panelId: PANEL_ID,
+ paramName: 'timePeriod',
+ defaultValue: TIME_PERIOD_DEFAULT,
+ }),
+ severity: readFromUrl({
+ panelId: PANEL_ID,
+ paramName: 'severity',
+ defaultValue: [],
+ }),
isLoading: false,
chartData: {
thirtyDays: [],
sixtyDays: [],
ninetyDays: [],
},
- panelLevelFilters: {
- severity: [],
- },
};
},
computed: {
@@ -76,7 +91,7 @@ export default {
combinedFilters() {
return {
...this.filters,
- ...this.panelLevelFilters,
+ severity: this.severity,
};
},
hasChartData() {
@@ -93,7 +108,7 @@ export default {
},
baseQueryVariables() {
const baseVariables = {
- severity: this.panelLevelFilters.severity,
+ severity: this.severity,
includeBySeverity: this.groupedBy === 'severity',
includeByReportType: this.groupedBy === 'reportType',
fullPath: this.fullPath,
@@ -121,9 +136,31 @@ export default {
deep: true,
immediate: true,
},
- selectedTimePeriod() {
+ selectedTimePeriod(value) {
+ writeToUrl({
+ panelId: PANEL_ID,
+ paramName: 'timePeriod',
+ value,
+ defaultValue: TIME_PERIOD_DEFAULT,
+ });
this.fetchChartData();
},
+ groupedBy(value) {
+ writeToUrl({
+ panelId: PANEL_ID,
+ paramName: 'groupBy',
+ value,
+ defaultValue: GROUP_BY_DEFAULT,
+ });
+ },
+ severity(value) {
+ writeToUrl({
+ panelId: PANEL_ID,
+ paramName: 'severity',
+ value,
+ defaultValue: [],
+ });
+ },
},
methods: {
async fetchChartData() {
@@ -173,7 +210,7 @@ export default {
>
-
+
diff --git a/ee/app/assets/javascripts/security_dashboard/utils/panel_state_url_sync.js b/ee/app/assets/javascripts/security_dashboard/utils/panel_state_url_sync.js
new file mode 100644
index 0000000000000000000000000000000000000000..123cf00214827eecd247d5746f48dc1a46836881
--- /dev/null
+++ b/ee/app/assets/javascripts/security_dashboard/utils/panel_state_url_sync.js
@@ -0,0 +1,71 @@
+import { isEqual } from 'lodash';
+import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
+
+/**
+ * Creates a panel-specific parameter name to avoid conflicts
+ *
+ * @param {Object} options - The options object
+ * @param {String} options.panelId - The unique panel identifier
+ * @param {String} options.paramName - The parameter name
+ * @returns {String} The prefixed parameter name
+ */
+export function getPanelParamName({ panelId, paramName }) {
+ return `${panelId}.${paramName}`;
+}
+
+/**
+ * Reads a value from URL query parameters
+ *
+ * @param {Object} options - The options object
+ * @param {String} options.panelId - The id of the panel
+ * @param {String} options.paramName - The parameter name to read
+ * @param {*} options.defaultValue - Default value if parameter doesn't exist
+ * @returns {*} The parameter value or default
+ */
+export function readFromUrl({ panelId, paramName, defaultValue }) {
+ const panelParamName = getPanelParamName({ panelId, paramName });
+ const params = new URLSearchParams(window.location.search);
+ const value = params.get(panelParamName);
+
+ if (value === null || value === '') {
+ return defaultValue;
+ }
+
+ // Handle arrays (comma-separated values)
+ if (Array.isArray(defaultValue)) {
+ return value.split(',').filter((i) => i !== '');
+ }
+
+ // Handle numbers
+ if (typeof defaultValue === 'number') {
+ const parsed = parseInt(value, 10);
+ return Number.isNaN(parsed) ? defaultValue : parsed;
+ }
+
+ // Handle strings
+ return value;
+}
+
+/**
+ * Writes a value to URL query parameters
+ *
+ * @param {Object} options - The options object
+ * @param {String} options.panelId - The id of the panel
+ * @param {String} options.paramName - The parameter name to write
+ * @param {*} options.value - The value to write
+ * @param {*} options.defaultValue - The default value (used to determine if we should remove the param)
+ */
+
+export function writeToUrl({ panelId, paramName, value, defaultValue }) {
+ const panelParamName = getPanelParamName({ panelId, paramName });
+
+ const isDefault = isEqual(value, defaultValue);
+ const stringValue = Array.isArray(value) ? value.join(',') : String(value);
+ const params = {};
+
+ params[panelParamName] = isDefault ? undefined : stringValue;
+ // Passing true for last param to make sure params are decoded
+ const url = setUrlParams(params, window.location.href, false, false, true);
+
+ updateHistory({ url, replace: true });
+}
diff --git a/ee/spec/frontend/security_dashboard/components/shared/group_risk_score_panel_spec.js b/ee/spec/frontend/security_dashboard/components/shared/group_risk_score_panel_spec.js
index 618d23fb89a4929970beacb5b1f5b987e3485add..8d4a4461a47d74c0587ec7d6a55cb809e1552038 100644
--- a/ee/spec/frontend/security_dashboard/components/shared/group_risk_score_panel_spec.js
+++ b/ee/spec/frontend/security_dashboard/components/shared/group_risk_score_panel_spec.js
@@ -5,6 +5,8 @@ import { GlDashboardPanel, GlBadge } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import * as panelStateUrlSync from 'ee/security_dashboard/utils/panel_state_url_sync';
import GroupRiskScorePanel from 'ee/security_dashboard/components/shared/group_risk_score_panel.vue';
import TotalRiskScore from 'ee/security_dashboard/components/shared/charts/total_risk_score.vue';
import RiskScoreByProject from 'ee/security_dashboard/components/shared/charts/risk_score_by_project.vue';
@@ -97,6 +99,10 @@ describe('GroupRiskScorePanel', () => {
createComponent();
});
+ afterEach(() => {
+ setWindowLocation('');
+ });
+
describe('component rendering', () => {
it('sets the correct title for the dashboard panel', () => {
expect(findDashboardPanel().props('title')).toBe('Risk score');
@@ -159,6 +165,27 @@ describe('GroupRiskScorePanel', () => {
expect(riskScoreGroupBy.props('value')).toBe('default');
});
+
+ it('initializes with project grouping if URL parameter is set', () => {
+ setWindowLocation('?riskScore.groupBy=project');
+ createComponent();
+
+ expect(findRiskScoreGroupBy().props('value')).toBe('project');
+ });
+
+ it('calls writeToUrl when grouping is set to project', async () => {
+ jest.spyOn(panelStateUrlSync, 'writeToUrl');
+
+ findRiskScoreGroupBy().vm.$emit('input', 'project');
+ await nextTick();
+
+ expect(panelStateUrlSync.writeToUrl).toHaveBeenCalledWith({
+ panelId: 'riskScore',
+ paramName: 'groupBy',
+ value: 'project',
+ defaultValue: 'default',
+ });
+ });
});
describe('Apollo query', () => {
diff --git a/ee/spec/frontend/security_dashboard/components/shared/security_dashboard_filtered_search/filtered_search_spec.js b/ee/spec/frontend/security_dashboard/components/shared/security_dashboard_filtered_search/filtered_search_spec.js
index 7d68e55a6ae4d51a1c9a9178606953491feb0238..46cd0d76b14dde98c8ca197b7caf2d4de9a64029 100644
--- a/ee/spec/frontend/security_dashboard/components/shared/security_dashboard_filtered_search/filtered_search_spec.js
+++ b/ee/spec/frontend/security_dashboard/components/shared/security_dashboard_filtered_search/filtered_search_spec.js
@@ -1,6 +1,7 @@
import { nextTick } from 'vue';
import { GlFilteredSearch } from '@gitlab/ui';
import { markRaw } from '~/lib/utils/vue3compat/mark_raw';
+import * as urlUtils from '~/lib/utils/url_utility';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
import FilteredSearch from 'ee/security_dashboard/components/shared/security_dashboard_filtered_search/filtered_search.vue';
@@ -54,8 +55,8 @@ describe('Security Dashboard Filtered Search', () => {
await nextTick();
};
- const expectUrlToBe = (expectedUrl) => {
- expect(window.history.pushState).toHaveBeenCalledWith({}, '', expectedUrl);
+ const expectUrlToBe = (url) => {
+ expect(urlUtils.updateHistory).toHaveBeenCalledWith({ url, replace: true });
};
const getLastEmittedFilters = () => {
@@ -63,7 +64,7 @@ describe('Security Dashboard Filtered Search', () => {
};
beforeEach(() => {
- jest.spyOn(window.history, 'pushState');
+ jest.spyOn(urlUtils, 'updateHistory');
});
afterEach(() => {
@@ -194,7 +195,7 @@ describe('Security Dashboard Filtered Search', () => {
setWindowLocation('?tokenA=5,10');
createWrapper();
- expect(window.history.pushState).not.toHaveBeenCalled();
+ expect(urlUtils.updateHistory).not.toHaveBeenCalled();
});
});
@@ -207,14 +208,14 @@ describe('Security Dashboard Filtered Search', () => {
it('preserves existing URL parameters when adding filter', async () => {
await updateToken('tokenA', ['5', '10']);
- expectUrlToBe('/?tab=test&tokenA=5%2C10');
+ expectUrlToBe('http://test.host/?tab=test&tokenA=5,10');
});
it('handles multiple tokens in URL', async () => {
await updateToken('tokenA', ['5']);
await updateToken('tokenB', ['15', '20']);
- expectUrlToBe('/?tab=test&tokenA=5&tokenB=15%2C20');
+ expectUrlToBe('http://test.host/?tab=test&tokenA=5&tokenB=15,20');
});
it('removes token parameter on destroy while preserving others', async () => {
@@ -222,7 +223,7 @@ describe('Security Dashboard Filtered Search', () => {
await updateToken('tokenB', ['15']);
await destroyToken('tokenA');
- expectUrlToBe('/?tab=test&tokenB=15');
+ expectUrlToBe('http://test.host/?tab=test&tokenB=15');
});
it('removes all filter parameters on clear', async () => {
@@ -230,7 +231,7 @@ describe('Security Dashboard Filtered Search', () => {
await updateToken('tokenB', ['15']);
await clearFilters();
- expectUrlToBe('/?tab=test');
+ expectUrlToBe('http://test.host/?tab=test');
});
});
});
diff --git a/ee/spec/frontend/security_dashboard/components/shared/vulnerabilities_over_time_panel_spec.js b/ee/spec/frontend/security_dashboard/components/shared/vulnerabilities_over_time_panel_spec.js
index eb682a661d63add390ab28bacd95dc8027bdb8d1..31ab732faefc5961aa41eb5d8ed60742b8af7400 100644
--- a/ee/spec/frontend/security_dashboard/components/shared/vulnerabilities_over_time_panel_spec.js
+++ b/ee/spec/frontend/security_dashboard/components/shared/vulnerabilities_over_time_panel_spec.js
@@ -1,7 +1,9 @@
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import setWindowLocation from 'helpers/set_window_location_helper';
import ExtendedDashboardPanel from '~/vue_shared/components/customizable_dashboard/extended_dashboard_panel.vue';
+import * as panelStateUrlSync from 'ee/security_dashboard/utils/panel_state_url_sync';
import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue';
import VulnerabilitiesOverTimePanel from 'ee/security_dashboard/components/shared/vulnerabilities_over_time_panel.vue';
import OverTimeGroupBy from 'ee/security_dashboard/components/shared/over_time_group_by.vue';
@@ -252,6 +254,26 @@ describe('VulnerabilitiesOverTimePanel', () => {
}),
);
});
+
+ it('initializes time period if URL parameter is set', () => {
+ setWindowLocation('?vulnerabilitiesOverTime.timePeriod=60');
+ createComponent();
+
+ expect(findOverTimePeriodSelector().props('value')).toEqual(60);
+ });
+
+ it('calls writeToUrl when time period is set', async () => {
+ jest.spyOn(panelStateUrlSync, 'writeToUrl');
+ createComponent();
+
+ await findOverTimePeriodSelector().vm.$emit('input', 90);
+ expect(panelStateUrlSync.writeToUrl).toHaveBeenCalledWith({
+ panelId: 'vulnerabilitiesOverTime',
+ paramName: 'timePeriod',
+ value: 90,
+ defaultValue: 30,
+ });
+ });
});
describe('filters', () => {
@@ -272,6 +294,76 @@ describe('VulnerabilitiesOverTimePanel', () => {
}),
);
});
+
+ it('initializes severity if URL parameter is set', () => {
+ setWindowLocation('?vulnerabilitiesOverTime.severity=HIGH%2CLOW');
+ createComponent();
+
+ expect(findSeverityFilter().props('value')).toMatchObject(['HIGH', 'LOW']);
+ });
+
+ it('calls writeToUrl when severity is set', async () => {
+ jest.spyOn(panelStateUrlSync, 'writeToUrl');
+ createComponent();
+
+ await findSeverityFilter().vm.$emit('input', ['CRITICAL', 'MEDIUM']);
+ expect(panelStateUrlSync.writeToUrl).toHaveBeenCalledWith({
+ panelId: 'vulnerabilitiesOverTime',
+ paramName: 'severity',
+ value: ['CRITICAL', 'MEDIUM'],
+ defaultValue: [],
+ });
+ });
+ });
+
+ describe('group by functionality', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('switches to report type grouping when report type button is clicked', async () => {
+ await waitForPromises();
+ const overTimeGroupBy = findOverTimeGroupBy();
+
+ await overTimeGroupBy.vm.$emit('input', 'reportType');
+ await nextTick();
+
+ expect(overTimeGroupBy.props('value')).toBe('reportType');
+ });
+
+ it('switches back to severity grouping when severity button is clicked', async () => {
+ await waitForPromises();
+ const overTimeGroupBy = findOverTimeGroupBy();
+
+ await overTimeGroupBy.vm.$emit('input', 'reportType');
+ await nextTick();
+
+ await overTimeGroupBy.vm.$emit('input', 'severity');
+ await nextTick();
+
+ expect(overTimeGroupBy.props('value')).toBe('severity');
+ });
+
+ it('initializes with report type grouping if URL parameter is set', () => {
+ setWindowLocation('?vulnerabilitiesOverTime.groupBy=reportType');
+ createComponent();
+
+ expect(findOverTimeGroupBy().props('value')).toBe('reportType');
+ });
+
+ it('calls writeToUrl when grouping is set to report type', async () => {
+ jest.spyOn(panelStateUrlSync, 'writeToUrl');
+
+ findOverTimeGroupBy().vm.$emit('input', 'reportType');
+ await nextTick();
+
+ expect(panelStateUrlSync.writeToUrl).toHaveBeenCalledWith({
+ panelId: 'vulnerabilitiesOverTime',
+ paramName: 'groupBy',
+ value: 'reportType',
+ defaultValue: 'severity',
+ });
+ });
});
describe('loading state', () => {
diff --git a/ee/spec/frontend/security_dashboard/utils/panel_state_url_sync_spec.js b/ee/spec/frontend/security_dashboard/utils/panel_state_url_sync_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..4c36a6101990745cd15a4afa45a925cc3d7d2cda
--- /dev/null
+++ b/ee/spec/frontend/security_dashboard/utils/panel_state_url_sync_spec.js
@@ -0,0 +1,170 @@
+import setWindowLocation from 'helpers/set_window_location_helper';
+import * as urlUtils from '~/lib/utils/url_utility';
+import {
+ getPanelParamName,
+ readFromUrl,
+ writeToUrl,
+} from 'ee/security_dashboard/utils/panel_state_url_sync';
+
+describe('Security Dashboard - Panel State Url Sync', () => {
+ const panelId = 'panelId';
+ const paramName = 'paramName';
+
+ afterEach(() => {
+ setWindowLocation('');
+ });
+
+ describe('getPanelParamName', () => {
+ it('returns prefixed parameter name', () => {
+ const result = getPanelParamName({
+ panelId: 'vulnerabilitiesChart',
+ paramName: 'groupBy',
+ });
+
+ expect(result).toBe('vulnerabilitiesChart.groupBy');
+ });
+ });
+
+ describe('readFromUrl', () => {
+ it('returns default value when parameter does not exist', () => {
+ setWindowLocation('');
+ expect(
+ readFromUrl({
+ panelId,
+ paramName,
+ defaultValue: 'all',
+ }),
+ ).toBe('all');
+ });
+
+ it('returns default value when parameter value is empty', () => {
+ setWindowLocation('?panelId.paramName=');
+ expect(
+ readFromUrl({
+ panelId,
+ paramName,
+ defaultValue: 'all',
+ }),
+ ).toBe('all');
+ });
+
+ it('returns array of values when default value is an array', () => {
+ setWindowLocation('?panelId.paramName=a,b');
+ expect(
+ readFromUrl({
+ panelId,
+ paramName,
+ defaultValue: [],
+ }),
+ ).toMatchObject(['a', 'b']);
+ });
+
+ it('returns number when default value is a number', () => {
+ setWindowLocation('?panelId.paramName=15');
+ expect(
+ readFromUrl({
+ panelId,
+ paramName,
+ defaultValue: 10,
+ }),
+ ).toBe(15);
+ });
+
+ it('returns string value if default value is not array or number', () => {
+ setWindowLocation('?panelId.paramName=test');
+ expect(
+ readFromUrl({
+ panelId,
+ paramName,
+ defaultValue: 'all',
+ }),
+ ).toBe('test');
+ });
+ });
+
+ describe('writeToUrl', () => {
+ const expectUrlToBe = (url) => {
+ expect(urlUtils.updateHistory).toHaveBeenCalledWith({ url, replace: true });
+ };
+
+ beforeEach(() => {
+ jest.spyOn(urlUtils, 'updateHistory');
+ });
+
+ describe('when value is equal to default value', () => {
+ it('deletes parameter for string', () => {
+ setWindowLocation('?tab=test&panelId.paramName=a');
+ writeToUrl({
+ panelId,
+ paramName,
+ value: 'all',
+ defaultValue: 'all',
+ });
+
+ expectUrlToBe('http://test.host/?tab=test');
+ });
+
+ it('deletes parameter for array', () => {
+ setWindowLocation('?tab=test&panelId.paramName=a,b');
+ writeToUrl({
+ panelId,
+ paramName,
+ value: [],
+ defaultValue: [],
+ });
+
+ expectUrlToBe('http://test.host/?tab=test');
+ });
+
+ it('deletes parameter for number', () => {
+ setWindowLocation('?tab=test&panelId.paramName=5');
+ writeToUrl({
+ panelId,
+ paramName,
+ value: 10,
+ defaultValue: 10,
+ });
+
+ expectUrlToBe('http://test.host/?tab=test');
+ });
+ });
+
+ describe('when value is not equal to default value', () => {
+ it('adds new URL parameter if it does not exist yet', () => {
+ setWindowLocation('?tab=test');
+ writeToUrl({
+ panelId,
+ paramName,
+ value: 'a',
+ defaultValue: 'all',
+ });
+
+ expectUrlToBe('http://test.host/?tab=test&panelId.paramName=a');
+ });
+
+ it('updates URL parameter if it exists already', () => {
+ setWindowLocation('?tab=test&panelId.paramName=b');
+ writeToUrl({
+ panelId,
+ paramName,
+ value: 'a',
+ defaultValue: 'all',
+ });
+
+ expectUrlToBe('http://test.host/?tab=test&panelId.paramName=a');
+ });
+
+ it('adds URL parameter with comma-separated value for array', () => {
+ setWindowLocation('');
+ writeToUrl({
+ panelId,
+ paramName,
+ value: ['a', 'b'],
+ defaultValue: [],
+ });
+
+ expectUrlToBe('http://test.host/?panelId.paramName=a,b');
+ });
+ });
+ });
+});