diff --git a/packages/filters/package.json b/packages/filters/package.json new file mode 100644 index 0000000000..62a78b6a24 --- /dev/null +++ b/packages/filters/package.json @@ -0,0 +1,54 @@ +{ + "name": "@harnessio/filters", + "version": "1.0.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "dev": "run-p build:watch", + "build": "vite build", + "build:watch": "vite build --watch", + "prepublishOnly": "pnpm build", + "pretty": "prettier --check ./src", + "pre-commit": "lint-staged" + }, + "private": false, + "type": "module", + "module": "./dist/index.js", + "files": [ + "dist" + ], + "exports": { + ".": { + "import": "./dist/index.js" + } + }, + "peerDependencies": { + "react": ">=17.0.0 <19.0.0", + "react-dom": ">=17.0.0 <19.0.0", + "react-router-dom": ">=5.0.0 <7.0.0" + }, + "devDependencies": { + "@types/node": "^16.18.84", + "@types/react": "^17.0.3", + "@types/react-dom": "^17.0.3", + "@vitejs/plugin-react-swc": "^3.7.2", + "dts-bundle-generator": "^6.4.0", + "eslint": "^8.57.1", + "flatted": "^3.3.2", + "jest": "^29.7.0", + "lint-staged": "^15.2.9", + "npm-run-all": "^4.1.5", + "ts-jest": "^29.1.2", + "typescript": "^5.3.3", + "vite": "^6.0.3", + "vite-plugin-dts": "^4.3.0", + "vite-plugin-svgr": "^4.3.0" + }, + "license": "Apache-2.0", + "lint-staged": { + "*.{js,jsx,ts,tsx}": [ + "eslint ./src --fix", + "prettier ./src --write" + ] + } +} diff --git a/packages/filters/src/Filter.tsx b/packages/filters/src/Filter.tsx new file mode 100644 index 0000000000..22eabc4d53 --- /dev/null +++ b/packages/filters/src/Filter.tsx @@ -0,0 +1,59 @@ +import React from 'react' + +import { useFiltersContext } from './Filters' +import { defaultStringParser } from './parsers' + +type Parser = { + parse: (value: string) => T + serialize: (value: T) => string +} + +export interface FilterProps { + filterKey: K + children: (props: { + onChange: (value: T[K]) => void + value?: Parser extends undefined ? string : T[K] + removeFilter: (filterKey?: K) => void + }) => React.ReactNode + parser?: Parser + sticky?: boolean + className?: string +} + +const Filter = ({ + filterKey, + children, + parser = defaultStringParser as Parser, + className +}: FilterProps): React.ReactElement | null => { + const { updateFilter, getFilterValue, removeFilter } = useFiltersContext() + + // Handles when a new value is set + const handleChange = (value: T[K]) => { + const serializedValue = parser.serialize(value) + updateFilter(filterKey as string, serializedValue, value) + } + + // If no filter key is provided, + // filterKey provided to component will be used + const wrappedRemoveFilter = (fkey?: K) => { + removeFilter(fkey ?? filterKey) + } + + // Retrieves the raw and parsed filter value + const rawValue = getFilterValue(filterKey as string) + const parsedValue = rawValue as T + + // Render the children with the injected props + return ( +
+ {children({ + onChange: handleChange, + value: parsedValue as T[K], + removeFilter: wrappedRemoveFilter + })} +
+ ) +} + +export default Filter diff --git a/packages/filters/src/Filters.tsx b/packages/filters/src/Filters.tsx new file mode 100644 index 0000000000..ee363b1e6b --- /dev/null +++ b/packages/filters/src/Filters.tsx @@ -0,0 +1,362 @@ +import React, { + createContext, + forwardRef, + ReactNode, + useContext, + useEffect, + useImperativeHandle, + useRef, + useState +} from 'react' + +import { debug, warn } from './debug' +import Filter, { FilterProps } from './Filter' +import FiltersContent, { FiltersContentProps } from './FiltersContent' +import FiltersDropdown, { FiltersDropdownProps } from './FiltersDropdown' +import { FilterConfig, FilterRefType, FilterStatus, FilterType, InitializeFiltersConfigType } from './types' +import useRouter from './useRouter' +import { createQueryString, isNullable, mergeURLSearchParams } from './utils' + +interface FiltersContextType> { + visibleFilters: (keyof T)[] + availableFilters: (keyof T)[] + removeFilter: (filterKey: keyof T) => void + resetFilters: () => void + addFilter: (filterKey: keyof T) => void + getFilterValue: (filterKey: keyof T) => any + updateFilter: (filterKey: keyof T, parsedValue: any, value: any) => void + addInitialFilters: (filtersConfig: Record>) => void +} + +const FiltersContext = createContext> | null>(null) + +interface FiltersProps> { + children?: ReactNode + allFiltersSticky?: boolean + onChange?: (filters: T) => void +} + +const Filters = forwardRef(function Filters>( + { children, allFiltersSticky, onChange }: FiltersProps, + ref: React.Ref> +) { + type FilterKeys = keyof T + const [filtersOrder, setFiltersOrder] = useState([]) + const [filtersMap, setFiltersMap] = useState>({} as Record) + const [filtersConfig, setFiltersConfig] = useState>( + {} as Record + ) + const { searchParams, updateURL: routerUpdateURL } = useRouter() + const initialFiltersRef = useRef | undefined>(undefined) + + const updateURL = (params: URLSearchParams) => { + // merge params into search params and update the URL. + const paramsOtherthanFilters: URLSearchParams = new URLSearchParams() + searchParams.forEach((value, key) => { + if (!filtersMap[key as FilterKeys]) { + paramsOtherthanFilters.append(key, value) + } + }) + const mergedParams = mergeURLSearchParams(paramsOtherthanFilters, params) + routerUpdateURL(mergedParams) + } + + const setFiltersMapTrigger = (filtersMap: Record) => { + setFiltersMap(filtersMap) + onChange?.(getValues(filtersMap)) + } + + const addFilter = (filterKey: FilterKeys) => { + debug('Adding filter with key: %s', filterKey) + setFiltersMap(prev => ({ + ...prev, + [filterKey]: createNewFilter() + })) + + onChange?.( + getValues({ + ...filtersMap, + [filterKey]: createNewFilter() + }) + ) + setFiltersOrder(prev => [...prev, filterKey]) + } + + const removeFilter = (filterKey: FilterKeys) => { + debug('Removing filter with key: %s', filterKey) + const updatedFiltersMap = { ...filtersMap } + delete updatedFiltersMap[filterKey] + const updatedFiltersOrder = filtersOrder.filter(key => key !== filterKey) + setFiltersMapTrigger(updatedFiltersMap) + setFiltersOrder(updatedFiltersOrder) + + const query = createQueryString(updatedFiltersOrder, updatedFiltersMap) + debug('Updating URL with query: %s', query) + updateURL(new URLSearchParams(query)) + } + + const updateFilter = (filterKey: FilterKeys, parsedValue: any, value: any) => { + debug('Updating filter: %s with value: %O', filterKey, value) + const updatedFiltersMap = { ...filtersMap, [filterKey]: getUpdatedFilter(parsedValue, value) } + setFiltersMapTrigger(updatedFiltersMap) + + // when updating URL, include params other than filters params. + const query = createQueryString(filtersOrder, updatedFiltersMap) + debug('Updating URL with query: %s', query) + updateURL(new URLSearchParams(query)) + } + + const initializeFilters = (initialFiltersConfig: Record>) => { + debug('Adding initial filters: %O', filtersMap) + + const map = {} as Record + const config = {} as Record + + for (const key in initialFiltersConfig) { + const { defaultValue, parser, isSticky } = initialFiltersConfig[key] + const isStickyFilter = allFiltersSticky ? true : isSticky + + // If default values is set, check if it is a valid non-null value and apply filter_applied status + // If not, set the filter state to visible + const serializedDefaultValue = defaultValue ?? parser?.serialize(defaultValue) + let filterState = isStickyFilter ? FilterStatus.VISIBLE : FilterStatus.HIDDEN + + if (!isNullable(serializedDefaultValue)) { + filterState = FilterStatus.FILTER_APPLIED + } + + map[key] = { + value: defaultValue, + query: serializedDefaultValue, + state: filterState + } + + config[key] = { + defaultValue: serializedDefaultValue, + parser: initialFiltersConfig[key].parser, + isSticky: isStickyFilter + } + } + + // initialize filters + // add sticky filters + // update filters from search params; + // updating filters map state from search params + searchParams?.forEach((value, key) => { + if (map[key as FilterKeys]) { + const parser = config?.[key as FilterKeys]?.parser + const parsedValue = parser ? parser.parse(value) : value + + map[key as FilterKeys] = { + value: parsedValue, + query: value, + state: FilterStatus.FILTER_APPLIED + } + } + }) + + // setting updated filters map to state and ref + setFiltersMapTrigger(map) + setFiltersConfig(config) + + debug('Initial filters added: %O', map) + debug('Initial filters config added: %O', config) + + // setting the order of filters based on the filtersMap + // adding all the filters which are not hidden + const newFiltersOrder = Object.keys(map).filter( + filter => map[filter as FilterKeys].state !== FilterStatus.HIDDEN + ) as FilterKeys[] + setFiltersOrder(newFiltersOrder) + + const query = createQueryString(newFiltersOrder, map) + debug('Updating URL with query: %s', query) + updateURL(new URLSearchParams(query)) + + // remove setVisibleFilters + initialFiltersRef.current = map + } + + useEffect(() => { + if (!initialFiltersRef.current) return + + const currentQuery = createQueryString(filtersOrder, filtersMap) + const searchParamsFiltersMap = {} as Record + + // we don't need to update URL here since it's already updated + debug('Syncing search params with filters: %s', currentQuery) + + searchParams.forEach((value, key) => { + if (filtersMap[key as FilterKeys]) { + const parser = filtersConfig?.[key as FilterKeys]?.parser + const parsedValue = parser ? parser.parse(value) : value + + searchParamsFiltersMap[key as FilterKeys] = { + value: parsedValue, + query: value, + state: FilterStatus.FILTER_APPLIED + } + } + }, {}) + + // check if filtersOrder should be passed or not + const searchParamsQuery = createQueryString(filtersOrder, searchParamsFiltersMap) + + if (currentQuery === searchParamsQuery) { + return + } + + // check typecasting + setFiltersMapTrigger(searchParamsFiltersMap as Record) + setFiltersOrder(Object.keys(searchParamsFiltersMap) as FilterKeys[]) + }, [searchParams]) + + const createNewFilter = (): FilterType => ({ + value: undefined, + query: undefined, + state: FilterStatus.VISIBLE + }) + + const getUpdatedFilter = (parsedValue: any, value: any): FilterType => { + const isValueNullable = isNullable(parsedValue) + return { + value: value, + query: isValueNullable ? undefined : parsedValue, + state: isValueNullable ? FilterStatus.VISIBLE : FilterStatus.FILTER_APPLIED + } + } + + const getValues = (updatedFiltersMap: Record) => { + const newFiltersMap = updatedFiltersMap || filtersMap + const filters = Object.keys(newFiltersMap).length === 0 ? initialFiltersRef.current : newFiltersMap + + return Object.entries(filters || {}).reduce((acc: T, [filterKey, value]) => { + acc[filterKey as keyof T] = value.value + return acc + }, {} as T) + } + + const resetFilters = () => { + // add only sticky filters and remove other filters. + // remove values also from sticky filters + const updatedFiltersMap = { ...filtersMap } + Object.keys(updatedFiltersMap).forEach(key => { + const isSticky = filtersConfig[key as FilterKeys]?.isSticky + const defaultValue = filtersConfig[key as FilterKeys]?.defaultValue + + const serializedDefaultValue = defaultValue + let filterState = isSticky ? FilterStatus.VISIBLE : FilterStatus.HIDDEN + + if (!isNullable(serializedDefaultValue)) { + filterState = FilterStatus.FILTER_APPLIED + } + + updatedFiltersMap[key as FilterKeys] = { + value: defaultValue, + query: serializedDefaultValue, + state: filterState + } + }) + + const newFiltersOrder = Object.keys(updatedFiltersMap).filter( + filter => updatedFiltersMap[filter as FilterKeys].state !== FilterStatus.HIDDEN + ) as FilterKeys[] + + setFiltersMapTrigger(updatedFiltersMap) + setFiltersOrder(newFiltersOrder) + + const query = createQueryString(newFiltersOrder, updatedFiltersMap) + debug('Updating URL with query: %s', query) + updateURL(new URLSearchParams(query)) + } + + useImperativeHandle(ref, () => ({ + // @ts-ignore + getValues, + reset: resetFilters + })) + + const availableFilters = Object.keys(filtersMap).filter( + filter => filtersMap[filter as FilterKeys].state === FilterStatus.HIDDEN + ) + + const getFilterValue = (filterKey: FilterKeys) => { + return filtersMap[filterKey]?.value + } + + return ( + +
{children}
+
+ ) +}) + +export const useFiltersContext = >() => { + const context = useContext(FiltersContext as React.Context | null>) + if (!context) { + warn('FiltersContext is missing. Ensure this is used within a FiltersProvider.') // Warn if context is missing + throw new Error('FiltersDropdown, FiltersRow, and Filter must be used within a FiltersAdapter.') + } + + return context +} + +export { Filters } + +type FiltersView = 'dropdown' | 'row' +interface FiltersWrapperProps> extends FiltersProps { + view?: FiltersView +} + +const FiltersWrapper = forwardRef(function FiltersWrapper>( + { view = 'row', ...props }, + ref: React.Ref> +) { + if (view === 'row') { + return + } + + return +}) + +export default FiltersWrapper + +export const createFilters = >() => { + const Filters = forwardRef, FiltersWrapperProps>(function filtersCore(props, ref) { + return + }) + + const FiltersWithStatics = Filters as typeof Filters & { + Dropdown: (props: FiltersDropdownProps) => JSX.Element + Content: (props: FiltersContentProps) => JSX.Element + Component: (props: FilterProps) => JSX.Element + } + + FiltersWithStatics.Dropdown = function filtersDropdown(props: FiltersDropdownProps) { + // @ts-ignore + return + } + + FiltersWithStatics.Content = function filtersContent(props: FiltersContentProps) { + return + } + + FiltersWithStatics.Component = function filtersComponent(props: FilterProps) { + return + } + + return FiltersWithStatics +} diff --git a/packages/filters/src/FiltersContent.tsx b/packages/filters/src/FiltersContent.tsx new file mode 100644 index 0000000000..4003c85afb --- /dev/null +++ b/packages/filters/src/FiltersContent.tsx @@ -0,0 +1,47 @@ +import React, { ReactNode, useRef } from 'react' + +import { useFiltersContext } from './Filters' +import { FilterStatus, InitializeFiltersConfigType } from './types' + +export interface FiltersContentProps { + children: ReactNode + className?: string +} + +const FiltersContent: React.FC = ({ children, className }) => { + const { visibleFilters, addInitialFilters } = useFiltersContext() + const initializedFiltersRef = useRef(false) + + const reducerInitialState = { + components: [], + filtersConfig: {} + } + + const { components, filtersConfig } = React.Children.toArray(children).reduce<{ + components: ReactNode[] + filtersConfig: Record + }>((acc, child) => { + if (React.isValidElement(child) && child.props.filterKey !== null && typeof child.props.filterKey === 'string') { + if (visibleFilters.includes(child.props.filterKey)) { + acc.components.push(child) + } + + acc.filtersConfig[child.props.filterKey] = { + defaultValue: child.props.defaultValue, + parser: child.props.parser, + isSticky: child.props.sticky, + state: FilterStatus.HIDDEN + } + } + return acc + }, reducerInitialState) + + if (initializedFiltersRef.current === false) { + addInitialFilters(filtersConfig) + initializedFiltersRef.current = true + } + + return
{components}
+} + +export default FiltersContent diff --git a/packages/filters/src/FiltersDropdown.tsx b/packages/filters/src/FiltersDropdown.tsx new file mode 100644 index 0000000000..526bd264ad --- /dev/null +++ b/packages/filters/src/FiltersDropdown.tsx @@ -0,0 +1,23 @@ +import React, { ReactNode } from 'react' + +import { useFiltersContext } from './Filters' + +export interface FiltersDropdownProps { + children: (addFilter: (filterKey: K) => void, availableFilters: K[], resetFilters: () => void) => ReactNode +} + +const FiltersDropdown = ({ children }: FiltersDropdownProps): React.ReactElement | null => { + const { addFilter: addFilterContext, availableFilters, resetFilters } = useFiltersContext() + + const addFilter = (filterKey: K) => { + addFilterContext(filterKey) + } + + const getAvailableFilters = () => { + return availableFilters as K[] + } + + return
{children(addFilter, getAvailableFilters(), resetFilters)}
+} + +export default FiltersDropdown diff --git a/packages/filters/src/debug.ts b/packages/filters/src/debug.ts new file mode 100644 index 0000000000..62fd54ce7f --- /dev/null +++ b/packages/filters/src/debug.ts @@ -0,0 +1,59 @@ +import { stringify } from 'flatted' // Import flatted library + +const isDebugEnabled = () => { + // Check if localStorage is available + try { + if (typeof localStorage === 'undefined') { + return false + } + const test = 'debug-test' + localStorage.setItem(test, test) + const isStorageAvailable = localStorage.getItem(test) === test + localStorage.removeItem(test) + if (!isStorageAvailable) { + return false + } + } catch (error) { + console.error('[debugUtils]: Debug mode is disabled (localStorage unavailable).', error) + return false + } + const debug = localStorage.getItem('debug') ?? '' + return debug.includes('enable-debug') // Change this as needed +} + +const enabled = isDebugEnabled() + +// Utility function to log messages with optional arguments. +export function debug(message: string, ...args: any[]) { + if (!enabled) { + return + } + const msg = sprintf(message, ...args) + performance.mark(msg) + console.log(`[DEBUG]: ${msg}`, ...args) +} + +// Utility function to log warnings. +export function warn(message: string, ...args: any[]) { + if (!enabled) { + return + } + console.warn(`[WARN]: ${message}`, ...args) +} + +// Function to format messages like `sprintf`. +export function sprintf(base: string, ...args: any[]) { + return base.replace(/%[sfdO]/g, match => { + const arg = args.shift() + if (match === '%O' && arg) { + try { + return stringify(arg) // Use `flatted.stringify` instead of `JSON.stringify` + } catch (e) { + console.error('Error stringifying object', e) + return '[Circular Object]' + } + } else { + return String(arg) + } + }) +} diff --git a/packages/filters/src/index.ts b/packages/filters/src/index.ts new file mode 100644 index 0000000000..765f999ccc --- /dev/null +++ b/packages/filters/src/index.ts @@ -0,0 +1,6 @@ +import { createFilters } from './Filters' + +export * from './parsers' +export * from './types' + +export { createFilters } diff --git a/packages/filters/src/parsers.ts b/packages/filters/src/parsers.ts new file mode 100644 index 0000000000..5112f8554f --- /dev/null +++ b/packages/filters/src/parsers.ts @@ -0,0 +1,57 @@ +import { Parser } from './types' + +export const defaultStringParser: Parser = { + parse: (value: string) => value, + serialize: (value: unknown) => String(value) +} + +export const booleanParser: Parser = { + parse: (value: string) => value === 'true', + serialize: (value: boolean) => (value ? 'true' : 'false') +} + +export const stringArrayParser: Parser = { + parse: (value: string) => { + if (!value) { + return [] + } + return value.split(',').map(item => item.trim()) + }, + serialize: (value: string[]) => { + if (!Array.isArray(value)) return '' + return value.join(',') + } +} + +export const booleanArrayParser: Parser = { + parse: (value: string) => { + if (!value) return [] // Return empty array if the string is empty + return value.split(',').map(item => item.trim().toLowerCase() === 'true') // Convert each value to a boolean + }, + serialize: (value: boolean[]) => { + if (!Array.isArray(value)) return '' // Return empty string if the value is not an array + return value.map(item => item.toString()).join(',') // Convert each boolean to a string and join with commas + } +} + +export const dateTimeParser: Parser<[Date, Date]> = { + parse: (value: string) => { + if (!value) { + const start = new Date() + start.setHours(0, 0, 0, 0) + + const end = new Date() + end.setHours(23, 59, 59, 999) + + return [start, end] + } + + const [startTime, endTime] = value.split(',').map(time => new Date(Number(time))) + return [startTime, endTime] + }, + + serialize: (value: [Date, Date]) => { + const [start, end] = value + return `${start.getTime()},${end.getTime()}` + } +} diff --git a/packages/filters/src/types.ts b/packages/filters/src/types.ts new file mode 100644 index 0000000000..48aaa93597 --- /dev/null +++ b/packages/filters/src/types.ts @@ -0,0 +1,34 @@ +export interface FilterType { + value?: T + query?: string + state: FilterStatus +} + +export interface FilterConfig { + defaultValue?: T[keyof T] + parser?: Parser + isSticky?: boolean +} + +export type InitializeFiltersConfigType = { state: FilterStatus; defaultValue?: T[keyof T] } & FilterConfig +export interface FilterTypeWithComponent extends FilterType { + component: React.ReactElement +} + +export interface FilterQueryParamsType {} + +export type Parser = { + parse: (value: string) => T + serialize: (value: T) => string +} + +export enum FilterStatus { + VISIBLE = 'VISIBLE', + FILTER_APPLIED = 'FILTER_APPLIED', + HIDDEN = 'HIDDEN' +} + +export interface FilterRefType { + getValues: () => T + reset: () => void +} diff --git a/packages/filters/src/useRouter.ts b/packages/filters/src/useRouter.ts new file mode 100644 index 0000000000..96a0c126d6 --- /dev/null +++ b/packages/filters/src/useRouter.ts @@ -0,0 +1,84 @@ +import { useMemo } from 'react' +// @ts-ignore +import { + createSearchParams, + // @ts-ignore + useHistory, + useLocation, + useNavigate +} from 'react-router-dom' + +interface UseRouterReturnType { + searchParams: URLSearchParams + push: (path: string, searchParams?: Record) => void + replace: (path: string, searchParams?: Record) => void + updateURL: (params: URLSearchParams, replace?: boolean) => void +} + +const isReactRouterV6 = typeof useNavigate === 'function' + +export default function useRouter(): UseRouterReturnType { + const navigate = isReactRouterV6 ? useNavigate() : null // v6 + const location = useLocation() // Works for both v5 and v6 + const history = !isReactRouterV6 ? useHistory() : null // v5 + + const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]) + + const push = (path: string, searchParamsObject?: Record) => { + const search = searchParamsObject + ? `?${ + isReactRouterV6 + ? createSearchParams(searchParamsObject).toString() + : new URLSearchParams(searchParamsObject).toString() + }` + : '' + + if (isReactRouterV6 && navigate) { + navigate(`${path}${search}`, { replace: false }) + } else if (history) { + history.push(`${path}${search}`) + } + } + + const replace = (path: string, searchParamsObject?: Record) => { + const search = searchParamsObject + ? `?${ + isReactRouterV6 + ? createSearchParams(searchParamsObject).toString() + : new URLSearchParams(searchParamsObject).toString() + }` + : '' + + if (isReactRouterV6 && navigate) { + navigate(`${path}${search}`, { replace: true }) + } else if (history) { + history.replace(`${path}${search}`) + } + } + + const updateURL = (params: URLSearchParams, replace = false) => { + const updatedSearch = `?${params.toString()}` + const path = location.pathname + + if (replace) { + if (isReactRouterV6 && navigate) { + navigate(`${path}${updatedSearch}`, { replace: true }) + } else if (history) { + history.replace(`${path}${updatedSearch}`) + } + } else { + if (isReactRouterV6 && navigate) { + navigate(`${path}${updatedSearch}`, { replace: false }) + } else if (history) { + history.push(`${path}${updatedSearch}`) + } + } + } + + return { + searchParams, + push, + replace, + updateURL + } +} diff --git a/packages/filters/src/useSearchParams.ts b/packages/filters/src/useSearchParams.ts new file mode 100644 index 0000000000..dca3110e50 --- /dev/null +++ b/packages/filters/src/useSearchParams.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' + +export default function useSearchParams() { + const [searchParams, setSearchParams] = useState(() => { + if (typeof location === 'undefined') { + return new URLSearchParams() + } + return new URLSearchParams(location.search) + }) + + useEffect(() => { + const onPopState = () => { + setSearchParams(new URLSearchParams(location.search)) + } + + window.addEventListener('popstate', onPopState) + + return () => { + window.removeEventListener('popstate', onPopState) + } + }, []) + + return { + searchParams + } +} diff --git a/packages/filters/src/utils.ts b/packages/filters/src/utils.ts new file mode 100644 index 0000000000..a11d5b01ce --- /dev/null +++ b/packages/filters/src/utils.ts @@ -0,0 +1,74 @@ +import { FilterStatus, FilterType } from './types' + +export function renderQueryString(search: URLSearchParams) { + // @ts-ignore + if (search.size === 0) { + return '' + } + const query: string[] = [] + for (const [key, value] of search.entries()) { + const safeKey = key + .replace(/#/g, '%23') + .replace(/&/g, '%26') + .replace(/\+/g, '%2B') + .replace(/=/g, '%3D') + .replace(/\?/g, '%3F') + query.push(`${safeKey}=${encodeQueryValue(value)}`) + } + const queryString = '?' + query.join('&') + return queryString +} + +export function encodeQueryValue(input: string) { + return input + .replace(/%/g, '%25') + .replace(/\+/g, '%2B') + .replace(/ /g, '+') + .replace(/#/g, '%23') + .replace(/&/g, '%26') + .replace(/"/g, '%22') + .replace(/'/g, '%27') + .replace(/`/g, '%60') + .replace(//g, '%3E') + .replace(/[\x00-\x1F]/g, char => encodeURIComponent(char)) +} + +export const createQueryString = >( + visibleFilters: (keyof T)[], + updatedFiltersMap: Record +) => { + const query = visibleFilters.reduce((acc, key) => { + if (updatedFiltersMap[key]?.state === FilterStatus.FILTER_APPLIED) { + // Add & if there's already an existing query + const stringKey = key as string + return acc + ? // @ts-ignore + `${acc}&${stringKey}=${updatedFiltersMap[stringKey].query}` + : `${stringKey}=${updatedFiltersMap[stringKey].query}` + } + return acc + }, '') as string + + return renderQueryString(new URLSearchParams(query ? `?${query}` : '')) // Add ? only if there's a query +} + +export function mergeURLSearchParams(target: URLSearchParams, source: URLSearchParams): URLSearchParams { + const mergedParams = new URLSearchParams(target.toString()) // Create a copy of target + + // Iterate through the source URLSearchParams + for (const [key, value] of source) { + // If the value is falsy except for `false`, skip the merging + if (!value && value !== 'false') { + mergedParams.delete(key) // Remove the parameter if it's falsy + } else { + mergedParams.set(key, value) // Otherwise, add or overwrite the param + } + } + + return mergedParams +} + +export function isNullable(parsedValue: string | null | undefined) { + return parsedValue === '' || parsedValue === undefined || parsedValue === null +} diff --git a/packages/filters/tsconfig.json b/packages/filters/tsconfig.json new file mode 100644 index 0000000000..1603898e23 --- /dev/null +++ b/packages/filters/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "emitDeclarationOnly": true, + "jsx": "react-jsx", + "outDir": "./dist", + "declaration": true + }, + "include": ["./src/**/*"] +} diff --git a/packages/filters/vite.config.ts b/packages/filters/vite.config.ts new file mode 100644 index 0000000000..4608d43b21 --- /dev/null +++ b/packages/filters/vite.config.ts @@ -0,0 +1,31 @@ +import { resolve } from 'path' + +import react from '@vitejs/plugin-react-swc' +import { defineConfig } from 'vite' +import dts from 'vite-plugin-dts' + +const pkg = require('./package.json') + +const external = [...new Set([...Object.keys(pkg.devDependencies || []), ...Object.keys(pkg.peerDependencies || [])])] + +export default defineConfig({ + define: { 'process.env.NODE_ENV': '"production"' }, + plugins: [ + react(), + dts({ + outDir: 'dist', + tsconfigPath: './tsconfig.json' + }) + ], + build: { + sourcemap: true, + copyPublicDir: false, + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'filters', + fileName: 'index', + formats: ['es'] + }, + rollupOptions: { external } + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fe57d9365..ebb84ff932 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -604,6 +604,64 @@ importers: specifier: ^4.3.0 version: 4.3.0(rollup@4.32.0)(typescript@5.7.3)(vite@6.0.11(@types/node@20.17.16)(jiti@1.21.7)(sass@1.83.4)(terser@5.37.0)(yaml@2.7.0)) + packages/filters: + dependencies: + react: + specifier: 17.0.2 + version: 17.0.2 + react-dom: + specifier: 17.0.2 + version: 17.0.2(react@17.0.2) + react-router-dom: + specifier: '>=5.0.0 <7.0.0' + version: 6.28.2(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + devDependencies: + '@types/node': + specifier: ^16.18.84 + version: 16.18.125 + '@types/react': + specifier: ^17.0.3 + version: 17.0.83 + '@types/react-dom': + specifier: ^17.0.3 + version: 17.0.26(@types/react@17.0.83) + '@vitejs/plugin-react-swc': + specifier: ^3.7.2 + version: 3.7.2(vite@6.0.11(@types/node@16.18.125)(jiti@1.21.7)(sass@1.83.4)(terser@5.37.0)(yaml@2.7.0)) + dts-bundle-generator: + specifier: ^6.4.0 + version: 6.13.0 + eslint: + specifier: ^8.57.1 + version: 8.57.1 + flatted: + specifier: ^3.3.2 + version: 3.3.2 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@16.18.125) + lint-staged: + specifier: ^15.2.9 + version: 15.4.2 + npm-run-all: + specifier: ^4.1.5 + version: 4.1.5 + ts-jest: + specifier: ^29.1.2 + version: 29.2.5(@babel/core@7.26.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.7))(jest@29.7.0(@types/node@16.18.125))(typescript@5.7.3) + typescript: + specifier: ^5.3.3 + version: 5.7.3 + vite: + specifier: ^6.0.3 + version: 6.0.11(@types/node@16.18.125)(jiti@1.21.7)(sass@1.83.4)(terser@5.37.0)(yaml@2.7.0) + vite-plugin-dts: + specifier: ^4.3.0 + version: 4.5.0(@types/node@16.18.125)(rollup@4.32.0)(typescript@5.7.3)(vite@6.0.11(@types/node@16.18.125)(jiti@1.21.7)(sass@1.83.4)(terser@5.37.0)(yaml@2.7.0)) + vite-plugin-svgr: + specifier: ^4.3.0 + version: 4.3.0(rollup@4.32.0)(typescript@5.7.3)(vite@6.0.11(@types/node@16.18.125)(jiti@1.21.7)(sass@1.83.4)(terser@5.37.0)(yaml@2.7.0)) + packages/forms: dependencies: '@hookform/resolvers': @@ -18278,12 +18336,12 @@ snapshots: esbuild: 0.24.2 fs-extra: 11.3.0 gulp-sort: 2.0.0 - i18next: 24.2.1(typescript@5.6.3) + i18next: 24.2.1(typescript@5.7.3) js-yaml: 4.1.0 lilconfig: 3.1.3 rsvp: 4.8.5 sort-keys: 5.1.0 - typescript: 5.6.3 + typescript: 5.7.3 vinyl: 3.0.0 vinyl-fs: 4.0.0 transitivePeerDependencies: