diff --git a/src/composables/url-params.js b/src/composables/url-params.js new file mode 100644 index 0000000000..1a010745b6 --- /dev/null +++ b/src/composables/url-params.js @@ -0,0 +1,254 @@ +import { computed, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { useStore } from 'vuex' +import { debounce, identity, noop, castArray, isString, isObject, isFunction, isUndefined } from 'lodash' + +/** + * Global object to store batched updates + * Used to collect updates before applying them all at once + */ +let batchedUpdates = {} + +/** + * Debounced function to apply batched updates to the query parameters + * Updates are only applied after a 50ms delay to group multiple updates together. + * + * @param {Object} router - The Vue Router instance + * @param {Object} route - The current Vue Router route instance + */ +const applyBatchedUpdates = debounce((router, route) => { + if (Object.keys(batchedUpdates).length > 0) { + const newQuery = { ...route.query, ...batchedUpdates } + router.push({ query: newQuery }) // Apply the batched updates to the query + batchedUpdates = {} // Reset the batch after applying + } +}, 50) + +/** + * Function to batch query parameter updates + * Collects changes and applies them using the debounced function + * + * @param {Object} router - The Vue Router instance + * @param {Object} route - The current Vue Router route instance + * @param {string} queryParams - The query parameters to update + * @param {*} value - The new values for each query parameter + */ +function batchQueryParamUpdate(router, route, queryParams, values) { + queryParams.forEach((param, index) => { + batchedUpdates[param] = values[index] + }) + applyBatchedUpdates(router, route) +} + +/** + * Synchronizes a single URL query parameter with Vuex store or a custom getter/setter. + * + * This function is designed for handling a single URL query parameter and keeping it + * in sync with the Vuex store or custom getter/setter. It ensures bidirectional synchronization + * between the URL and the store or reactive state. + * + * @param {string} queryParam - The query parameter to sync. + * @param {string|Object} getOrGetters - Vuex getter string or custom getter/setter object. + * @param {string|Function} setMutation - Vuex mutation string or custom setter function. + * @param {Object} [options={}] - Optional configurations, e.g., a transform function to process the value. + * @returns {ComputedRef} - A computed reference to the synchronized value. + */ +export function useUrlParamWithStore(queryParam, getOrGetters, setMutation, options = {}) { + const route = useRoute() + const router = useRouter() + const store = useStore() + + // Determine the transform function, defaulting to identity (no transformation) + const transform = isString(getOrGetters) ? options.transform || identity : getOrGetters.transform || identity + + // Determine the getter function from Vuex or a custom getter + const getValue = isString(getOrGetters) + ? () => store.getters[getOrGetters] + : // Fallback to noop if no getter is provided + getOrGetters?.get ?? noop + + // Determine the setter function from Vuex mutation or custom setter + const setValue = isString(setMutation) + ? (value) => store.commit(setMutation, value) + : // Do nothing if the setter is not set + (value) => getOrGetters?.set(value) + + // Create a computed property that synchronizes the query parameter with the Vuex store + const param = computed({ + get() { + const value = route.query[queryParam] + // If the query parameter exists in the URL, transform and return it, else return the value from the store + return value !== undefined ? transform(value) : getValue() + }, + set(value) { + setValue(value) + // Batch the update to the query parameter in the URL + batchQueryParamUpdate(router, route, [queryParam], [value]) + } + }) + + // Watch the Vuex store value directly and update the URL when it changes + watch(getValue, (newValue) => { + if (newValue !== route.query[queryParam]) { + batchQueryParamUpdate(router, route, [queryParam], [newValue]) + } + }) + + return param +} + +/** + * Synchronizes multiple URL query parameters with Vuex store or custom getter/setter. + * + * This function is designed to handle multiple URL query parameters and keep them + * in sync with the Vuex store or a custom getter/setter. It ensures bidirectional + * synchronization between the URL and the store or reactive state for multiple parameters. + * + * @param {string[]} queryParams - The query parameters to sync. + * @param {string|Object} getOrGetters - Vuex getter string or custom getter/setter object. + * @param {string|Function} setMutation - Vuex mutation string or custom setter function. + * @param {Object} [options={}] - Optional configurations, e.g., a transform function to process the values. + * @returns {ComputedRef} - A computed reference to the synchronized values. + */ +export function useUrlParamsWithStore(queryParams, getOrGetters, setMutation, options = {}) { + const route = useRoute() + const router = useRouter() + const store = useStore() + + // Determine the transform function, defaulting to identity (no transformation) + const transform = isString(getOrGetters) ? options.transform || identity : getOrGetters.transform || identity + + // Determine the getter function from Vuex or a custom getter + const getValue = isString(getOrGetters) + ? () => store.getters[getOrGetters] + : // Fallback to noop if no getter is provided + getOrGetters.get || noop + + // Determine the setter function from Vuex mutation or custom setter + const setValue = isString(setMutation) + ? (values) => store.commit(setMutation, values) + : (values) => getOrGetters?.set(...values) + + // Create a computed property that synchronizes the query parameters with the Vuex store + const param = computed({ + get() { + const values = queryParams.map((param) => route.query[param]) + // If all query parameters exist in the URL, transform and return them, else return the values from the store + return values.every(isUndefined) ? getValue() : values.map(transform) + }, + set(values) { + setValue(values) + // Batch the update to multiple query parameters in the URL + batchQueryParamUpdate(router, route, queryParams, values) + } + }) + + // Watch the Vuex store value directly and update the URL when it changes + watch(getValue, (newValues) => { + const queryUpdate = {} + queryParams.forEach((param, index) => { + if (newValues[index] !== route.query[param]) { + queryUpdate[param] = newValues[index] + } + }) + batchQueryParamUpdate(router, route, queryParams, newValues) + }) + + return param +} + +/** + * Synchronizes a single URL query parameter with a reactive value. + * + * This function is for handling a single URL query parameter and keeping it + * in sync with a reactive value. It allows for bidirectional synchronization + * between the URL and the reactive value. + * + * @param {string} queryParam - The query parameter to sync. + * @param {string|Object} [initialOrOptions={}] - Either an initial value or an options object (e.g., get/set methods). + * @returns {ComputedRef} - A computed reference to the synchronized value. + */ +export function useUrlParam(queryParam, initialOrOptions = {}) { + const route = useRoute() + const router = useRouter() + + // Handle both simple initial value or options object + const options = isObject(initialOrOptions) ? initialOrOptions : { initialValue: initialOrOptions } + const { initialValue = null, get, set, transform = identity } = options + + // Determine the getter function (from options or fallback to initialValue) + const getValue = isFunction(get) ? get : () => initialValue + + // Determine the setter function (from options or fallback to updating the query parameter in the URL) + const setValue = isFunction(set) + ? (value) => set(transform(value)) + : (value) => batchQueryParamUpdate(router, route, [queryParam], [transform(value)]) + + // Create a computed property that synchronizes the query parameter with the reactive value + const param = computed({ + get() { + const value = route.query[queryParam] + // If the query parameter exists in the URL, transform and return it, else return the reactive value + return isUndefined(value) ? getValue() : transform(value) + }, + set: setValue + }) + + // Watch the query parameter in the URL and sync it with the reactive value when it changes + watch( + () => route.query[queryParam], + (value) => { + param.value = isUndefined(value) ? getValue() : transform(value) + } + ) + + return param +} + +/** + * Synchronizes multiple URL query parameters with reactive values. + * + * This function is for handling multiple URL query parameters and keeping them + * in sync with reactive values. It allows for bidirectional synchronization + * between the URL and the reactive values for multiple parameters. + * + * @param {string[]} queryParams - The query parameters to sync. + * @param {string|Object} [initialOrOptions={}] - Either an initial value or an options object (e.g., get/set methods). + * @returns {ComputedRef} - A computed reference to the synchronized values. + */ +export function useUrlParams(queryParams, initialOrOptions = {}) { + const route = useRoute() + const router = useRouter() + + // Handle both simple initial value or options object + const options = isObject(initialOrOptions) ? initialOrOptions : { initialValue: initialOrOptions } + const { initialValue = null, get, set, transform = identity } = options + + // Determine the getter function (from options or fallback to initialValue) + const getValue = isFunction(get) ? () => castArray(get()) : () => initialValue + + // Determine the setter function (from options or fallback to updating the query parameters in the URL) + const setValue = isFunction(set) + ? (values) => set(castArray(values)) + : (values) => batchQueryParamUpdate(router, route, queryParams, values) + + // Create a computed property that synchronizes the query parameters with the reactive values + const param = computed({ + get() { + const values = queryParams.map((param) => route.query[param]) + // If all query parameters exist in the URL, transform and return them, else return the reactive values + return values.some(isUndefined) ? getValue() : values + }, + set: setValue + }) + + // Watch the query parameters in the URL and sync them with the reactive values when they change + watch( + () => queryParams.map((param) => route.query[param]), + (newValues) => { + param.value = newValues.every((v) => v !== undefined) ? newValues.map(transform) : getValue() + } + ) + + return param +} diff --git a/tests/unit/specs/composables/url-params.spec.js b/tests/unit/specs/composables/url-params.spec.js new file mode 100644 index 0000000000..5f93e04b1a --- /dev/null +++ b/tests/unit/specs/composables/url-params.spec.js @@ -0,0 +1,277 @@ +import { createApp } from 'vue' +import { createRouter, createMemoryHistory } from 'vue-router' +import { flushPromises } from '@vue/test-utils' +import Vuex from 'vuex' + +import { useUrlParam, useUrlParams, useUrlParamWithStore, useUrlParamsWithStore } from '@/composables/url-params' + +vi.mock('lodash', async (importOriginal) => { + const { default: actual } = await importOriginal() + return { + ...actual, + debounce: (cb) => cb + } +}) + +export function withSetup(composable, store, routes = []) { + let result + + // Create a Vue Router instance with memory history for test environment + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + ...routes, + { + path: '/', // Add a default root route to match '/' + component: { template: '
Default route
' } + } + ] + }) + + const app = createApp({ + setup() { + result = composable() + // Return a dummy render function + return () => {} + } + }) + + // Provide the Vuex store if it exists + if (store) { + app.use(store) + } + + // Provide the Vue Router instance + app.use(router) + + // Mount the app in a virtual DOM element + app.mount(document.createElement('div')) + + return [result, router] +} + +describe('useUrlParam', () => { + it('should sync a single query parameter with the initial value', async () => { + const [result, router] = withSetup(() => useUrlParam('q', 'default')) + + await router.push({ query: { q: 'test' } }) + await flushPromises() + + expect(result.value).toBe('test') + expect(result.value).not.toBe('default') + }) + + it('should fallback to the initial value if query parameter is missing', async () => { + const [result, router] = withSetup(() => useUrlParam('q', 'default')) + + await router.push({ query: {} }) + await flushPromises() + + expect(result.value).toBe('default') + }) + + it('should update the query parameter when the value changes', async () => { + const [result, router] = withSetup(() => useUrlParam('q', 'default')) + + await router.push({ query: { q: 'default' } }) + await flushPromises() + + result.value = 'new value' + await flushPromises() + + expect(router.currentRoute.value.query.q).toBe('new value') + }) +}) + +describe('useUrlParams', () => { + it('should sync multiple query parameters with the initial values', async () => { + const [result, router] = withSetup(() => + useUrlParams(['sort', 'order'], { initialValue: ['defaultSort', 'defaultOrder'] }) + ) + + await router.push({ query: { sort: 'date', order: 'asc' } }) + await flushPromises() + + expect(result.value).toEqual(['date', 'asc']) + }) + + it('should fallback to the initial values if query parameters are missing', async () => { + const [result, router] = withSetup(() => + useUrlParams(['sort', 'order'], { initialValue: ['defaultSort', 'defaultOrder'] }) + ) + + await router.push({ query: {} }) + await flushPromises() + + expect(result.value).toEqual(['defaultSort', 'defaultOrder']) + }) + + it('should update the query parameters when the values change', async () => { + const [result, router] = withSetup(() => + useUrlParams(['sort', 'order'], { initialValue: ['defaultSort', 'defaultOrder'] }) + ) + + await router.push({ query: {} }) + await flushPromises() + + result.value = ['name', 'desc'] + await flushPromises() + + expect(router.currentRoute.value.query.sort).toBe('name') + expect(router.currentRoute.value.query.order).toBe('desc') + }) +}) + +describe('useUrlParamWithStore', () => { + let store + + beforeEach(() => { + store = new Vuex.Store({ + state: { + app: { + sort: 'date' + } + }, + getters: { + 'app/getSort': (state) => state.app.sort + }, + mutations: { + 'app/setSort': (state, value) => { + state.app.sort = value + } + } + }) + }) + + it('should sync a single query parameter with Vuex store getter', async () => { + const [result, router] = withSetup(() => useUrlParamWithStore('sort', 'app/getSort', 'app/setSort'), store) + + await router.push({ query: { sort: 'name' } }) + await flushPromises() + + expect(result.value).toBe('name') + }) + + it('should fallback to Vuex store value if query parameter is missing', async () => { + const [result, router] = withSetup(() => useUrlParamWithStore('sort', 'app/getSort', 'app/setSort'), store) + + await router.push({ query: {} }) + await flushPromises() + + expect(result.value).toBe('date') + }) + + it('should update Vuex store when query parameter changes', async () => { + const [result, router] = withSetup(() => useUrlParamWithStore('sort', 'app/getSort', 'app/setSort'), store) + + await router.push({ query: {} }) + await flushPromises() + + result.value = 'price' + await flushPromises() + + expect(store.state.app.sort).toBe('price') + }) + + it('should update query parameter when Vuex store value changes', async () => { + const [, router] = withSetup(() => useUrlParamWithStore('sort', 'app/getSort', 'app/setSort'), store) + + store.commit('app/setSort', 'creationDate') + await flushPromises() + + expect(router.currentRoute.value.query.sort).toBe('creationDate') + }) +}) + +describe('useUrlParamsWithStore', () => { + let store + + beforeEach(() => { + store = new Vuex.Store({ + state: { + app: { + sort: 'date', + order: 'asc' + } + }, + getters: { + 'app/getSortOrder': (state) => [state.app.sort, state.app.order] + }, + mutations: { + 'app/setSortOrder': (state, { sort, order }) => { + state.app.sort = sort + state.app.order = order + } + } + }) + }) + + it('should sync multiple query parameters with Vuex store getter', async () => { + const [result, router] = withSetup( + () => + useUrlParamsWithStore(['sort', 'order'], { + get: () => store.getters['app/getSortOrder'], + set: (sort, order) => store.commit('app/setSortOrder', { sort, order }) + }), + store + ) + + await router.push({ query: { sort: 'name', order: 'desc' } }) + await flushPromises() + + expect(result.value).toEqual(['name', 'desc']) + }) + + it('should fallback to Vuex store values if query parameters are missing', async () => { + const [result, router] = withSetup( + () => + useUrlParamsWithStore(['sort', 'order'], { + get: () => store.getters['app/getSortOrder'], + set: (sort, order) => store.commit('app/setSortOrder', { sort, order }) + }), + store + ) + + await router.push({ query: {} }) + await flushPromises() + + expect(result.value).toEqual(['date', 'asc']) + }) + + it('should update Vuex store when query parameters change', async () => { + const [result, router] = withSetup( + () => + useUrlParamsWithStore(['sort', 'order'], { + get: () => store.getters['app/getSortOrder'], + set: (sort, order) => store.commit('app/setSortOrder', { sort, order }) + }), + store + ) + + await router.push({ query: {} }) + await flushPromises() + + result.value = ['name', 'desc'] + await flushPromises() + + expect(store.state.app.sort).toBe('name') + expect(store.state.app.order).toBe('desc') + }) + + it('should update query parameters when Vuex store values change', async () => { + const [, router] = withSetup( + () => + useUrlParamsWithStore(['sort', 'order'], { + get: () => store.getters['app/getSortOrder'], + set: (sort, order) => store.commit('app/setSortOrder', { sort, order }) + }), + store + ) + + store.commit('app/setSortOrder', { sort: 'creationDate', order: 'desc' }) + await flushPromises() + + expect(router.currentRoute.value.query.sort).toBe('creationDate') + expect(router.currentRoute.value.query.order).toBe('desc') + }) +})