diff --git a/packages/components/src/custom/TypingFilter/TypingFIlterInput.tsx b/packages/components/src/custom/TypingFilter/TypingFIlterInput.tsx new file mode 100644 index 00000000..7c02c6b7 --- /dev/null +++ b/packages/components/src/custom/TypingFilter/TypingFIlterInput.tsx @@ -0,0 +1,16 @@ +import { TextFieldProps } from '@mui/material/TextField'; +import React from 'react'; +import { TextField } from '../../base/TextField'; + +export type TypingFilterInputProps = { + variant?: string; +} & TextFieldProps; + +export const TypingFilterInput = React.forwardRef(function TypingFilterInput( + props: TypingFilterInputProps, + ref: React.ForwardedRef +): JSX.Element { + return ; +}); + +export default TypingFilterInput; diff --git a/packages/components/src/custom/TypingFilter/TypingFIlters.tsx b/packages/components/src/custom/TypingFilter/TypingFIlters.tsx new file mode 100644 index 00000000..b19834b3 --- /dev/null +++ b/packages/components/src/custom/TypingFilter/TypingFIlters.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Divider } from '../../base/Divider'; +import { List } from '../../base/List'; +import { ListItem } from '../../base/ListItem'; +import { Typography } from '../../base/Typography'; +import { FilterSchema, FilterStateType, FilteringEvents } from '../../utils/typing.state'; +import { getCurrentFilterAndValue } from '../../utils/typing.utils'; + +interface TypingFiltersType { + filterStateMachine: FilterStateType; + dispatchFilterMachine: React.Dispatch<{ + type: FilteringEvents; + payload: { value: string }; + }>; + filterSchema: FilterSchema; +} + +export function TypingFilters({ + filterStateMachine, + dispatchFilterMachine, + filterSchema +}: TypingFiltersType) { + const selectFilter = (filter: string) => { + dispatchFilterMachine({ + type: FilteringEvents.SELECT_FILTER, + payload: { + value: filter + } + }); + }; + const { filter: currentFilter } = getCurrentFilterAndValue(filterStateMachine); + + const matchingFilters = currentFilter + ? Object.values(filterSchema).filter((filter) => filter.value.startsWith(currentFilter)) + : Object.values(filterSchema); + return ( + + {matchingFilters.length == 0 && ( + + Sorry we dont currently support this filter + + )} + {matchingFilters.map((filter) => ( + + selectFilter(filter.values)}> + {filter.values}: + {filter.description} + + + + ))} + + ); +} diff --git a/packages/components/src/custom/TypingFilter/TypingFilterSuggestions.tsx b/packages/components/src/custom/TypingFilter/TypingFilterSuggestions.tsx new file mode 100644 index 00000000..2cfe0386 --- /dev/null +++ b/packages/components/src/custom/TypingFilter/TypingFilterSuggestions.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Divider } from '../../base/Divider'; +import { List } from '../../base/List'; +import { ListItem } from '../../base/ListItem'; +import { Typography } from '../../base/Typography'; +import { FilterSchema, FilterStateType, FilteringEvents } from '../../utils/typing.state'; +import { getCurrentFilterAndValue } from '../../utils/typing.utils'; + +interface TypingFilterValueSuggestionsType { + filterStateMachine: FilterStateType; + dispatchFilterMachine: React.Dispatch<{ + type: FilteringEvents; + payload: { value: string }; + }>; + filterSchema: FilterSchema; +} + +export function TypingFilterValueSuggestions({ + filterStateMachine, + dispatchFilterMachine, + filterSchema +}: TypingFilterValueSuggestionsType) { + const selectValue = (value: string) => { + dispatchFilterMachine({ + type: FilteringEvents.SELECT_FILTER, + payload: { + value + } + }); + }; + + const { filter, value } = getCurrentFilterAndValue(filterStateMachine); + const currentFilter = Object.values(filterSchema).find((f) => f.values == filter); + const suggestions = currentFilter?.values?.filter((v) => v.startsWith(value)) ?? []; + + return ( + + {suggestions.length === 0 && ( + + No results available + + )} + {suggestions.map((suggestion) => ( + + selectValue(suggestion)} disableGutters> + + {suggestion} + + + + + ))} + + ); +} diff --git a/packages/components/src/custom/TypingFilter/index.tsx b/packages/components/src/custom/TypingFilter/index.tsx new file mode 100644 index 00000000..262877a9 --- /dev/null +++ b/packages/components/src/custom/TypingFilter/index.tsx @@ -0,0 +1,185 @@ +import { Fade, Popper } from '@mui/material'; +import { ContentFilterIcon, CrossCircleIcon } from 'packages/svg/dist'; +import React from 'react'; +import { ClickAwayListener } from '../../base/ClickAwayListener'; +import { IconButton } from '../../base/IconButton'; +import { InputAdornment } from '../../base/Input'; +import { + FilterSchema, + FilteringEvents, + FilteringState, + filterReducer +} from '../../utils/typing.state'; +import TypingFilterInput from './TypingFIlterInput'; +import { TypingFilters } from './TypingFIlters'; +import { TypingFilterValueSuggestions } from './TypingFilterSuggestions'; +import { getFilters } from '../../utils/typing.utils'; + +interface TypingFilterType { + filterSchema: FilterSchema; + handleFilter: (filters: object) => void; + autoFilter: boolean; +} + +export function TypingFilter({ filterSchema, handleFilter, autoFilter = false }: TypingFilterType) { + const inputFieldRef = React.useRef(null); + const [anchorEl, setAnchorEl] = React.useState(null); + const isPopperOpen = Boolean(anchorEl); + + const [filterState, dispatch] = React.useReducer(filterReducer, { + state: FilteringState.IDLE, + context: { + value: '', + prevValue: [''] + } + }); + + const handleFilterChange = (e: React.ChangeEvent) => { + if (!anchorEl) { + setAnchorEl(e.currentTarget); + } + + if (e.target.value === '') { + return dispatch({ + type: FilteringEvents.CLEAR + }); + } + + dispatch({ + type: FilteringEvents.INPUT_CHANGE, + payload: { + value: e.target.value + } + }); + }; + + const handleClear = () => { + dispatch({ + type: FilteringEvents.EXIT + }); + + handleFilter({}); + }; + + const handleFocus = (e: React.FocusEvent) => { + setAnchorEl(e.currentTarget); + dispatch({ type: FilteringEvents.START }); + }; + + const handleClickAway = (e: MouseEvent | TouchEvent) => { + if (inputFieldRef.current && inputFieldRef.current.contains(e.target as Node)) { + return; + } + + setAnchorEl(null); + }; + + React.useEffect(() => { + if (!inputFieldRef.current) { + return; + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + // Perform nullish check before accessing inputFieldRef.current.value + const inputValue = inputFieldRef.current?.value ?? ''; + handleFilter(getFilters(inputValue, filterSchema)); + setAnchorEl(null); + } + }; + + inputFieldRef.current?.addEventListener('keydown', handleKeyDown); + + return () => { + inputFieldRef.current?.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + React.useEffect(() => { + if (autoFilter && filterState.state === FilteringState.SELECTING_FILTER) { + // Perform nullish check before accessing filterState.context + const filterValue = filterState.context?.value ?? ''; + handleFilter(getFilters(filterValue, filterSchema)); + } + }, [filterState.state]); + + return ( + + + {' '} + { + theme.palette.iconMain; + }} + */ + />{' '} + + ), + endAdornment: ( + + + {filterState.state !== FilteringState.IDLE && ( + { + theme.palette.iconMain; + }} + */ + /> + )} + + + ) + }} + /> + + {({ TransitionProps }) => { + return ( + + +
+ {filterState.state == FilteringState.SELECTING_FILTER && ( + + )} + {filterState.state == FilteringState.SELECTING_VALUE && ( + + )} +
+
+
+ ); + }} +
+
+ ); +} diff --git a/packages/components/src/utils/typing.state.tsx b/packages/components/src/utils/typing.state.tsx new file mode 100644 index 00000000..140529bf --- /dev/null +++ b/packages/components/src/utils/typing.state.tsx @@ -0,0 +1,223 @@ +export const Delimiter = { + FILTER: ' ', + FILTER_VALUE: ':' +}; + +export enum FilteringState { + IDLE = 'idle', + SELECTING_FILTER = 'selecting_filter', + SELECTING_VALUE = 'selecting_value' +} + +export enum FilteringEvents { + START = 'start', + // SELECT = "select_filter", + SELECT_FILTER = 'select_filter', + INPUT_CHANGE = 'input_change', + SELECT_FILTER_VALUE = 'select_filter_value', + CLEAR = 'clear', + EXIT = 'exit' +} + +export interface FilterStateType { + state: FilteringState; + context?: { + value?: string; + prevValue?: string[]; + }; +} + +export interface FilterActionType { + type: FilteringEvents; + payload?: { + value?: string; + }; +} + +/** + * Filter Schema Object + * + * The `filterSchema` object defines available filter options for the TypingFilter component. + * It provides information about different filter categories, their descriptions, and possible values. + */ +export type FilterSchema = Record; +/** + * @example + * // Example filter schema with multiple filter categories + * const filterSchema = { + * SEVERITY: { + * value: "severity", + * description: "Filter by severity", + * values: ["Low", "Medium", "High"], + * }, + * STATUS: { + * value: "status", + * description: "Filter by status", + * type: "string", + * values: ["Open", "Closed", "In Progress"], + * }, + * CUSTOM_FILTER: { + * value: "custom", + * description: "Custom filter description", + * type: "number", + * }, + * // Add more filter categories as needed + * }; + */ + +export function commonReducer( + stateMachine: FilterStateType, + action: FilterActionType +): FilterStateType { + const { context } = stateMachine; + + switch (action.type) { + case FilteringEvents.CLEAR: + return { + state: FilteringState.SELECTING_FILTER, + context: { + ...context, + value: '', + prevValue: [''] + } + }; + case FilteringEvents.EXIT: + return { + state: FilteringState.IDLE, + context: { + ...context, + value: '', + prevValue: [''] + } + }; + default: + return stateMachine; + } +} + +export function filterSelectionReducer( + stateMachine: FilterStateType, + action: FilterActionType, + nextState: FilteringState, + nextValue: (prevValue: string, selectedValue: string) => string +): FilterStateType { + const { state, context } = stateMachine; + + const nextDelimiter = + nextState === FilteringState.SELECTING_FILTER ? Delimiter.FILTER : Delimiter.FILTER_VALUE; + + const prevDelimiter = + nextDelimiter == Delimiter.FILTER_VALUE ? Delimiter.FILTER : Delimiter.FILTER_VALUE; + + // Same state because the prevState is the same as the nextState (as we have only two states) + const prevState = nextState; + + switch (action.type) { + // Select a filter and move to start entering its value + case FilteringEvents.SELECT_FILTER: { + // ":" is used to separate the filter and its value + const newValue = nextValue(context?.prevValue?.at(-1) ?? '', action.payload?.value ?? ''); + return { + state: nextState, + context: { + ...context, + value: newValue + nextDelimiter, + prevValue: [...(context?.prevValue ?? []), newValue] + } + }; + } + // " " is used to separate multiple filters + case FilteringEvents.INPUT_CHANGE: + // Prevent transition when the filter/value is empty + if ( + action.payload?.value?.endsWith(nextDelimiter) && + context?.value?.endsWith(prevDelimiter) + ) { + return stateMachine; + } + // Prevent adding delimiters together + if ( + action.payload?.value?.endsWith(prevDelimiter) && + context?.value?.endsWith(prevDelimiter) + ) { + return stateMachine; + } + + if (action.payload?.value === context?.prevValue?.at(1)) { + return { + state: prevState, + context: { + ...context, + prevValue: context?.prevValue?.slice(0, -1) ?? [], + value: action.payload?.value ?? '' + } + }; + } + + if (action.payload?.value?.endsWith(nextDelimiter)) { + const newValue = action.payload.value; + return { + state: nextState, + context: { + ...context, + value: action.payload.value, + prevValue: [...(context?.prevValue ?? []), newValue.slice(0, -1)] + } + }; + } + + return { + // Stay in the same state + state, + context: { + ...context, + value: action.payload?.value + } + }; + default: + return commonReducer(stateMachine, action); + } +} + +export function filterReducer(stateMachine: FilterStateType, action: FilterActionType) { + const { state } = stateMachine; + + switch (state) { + case FilteringState.IDLE: + switch (action.type) { + case FilteringEvents.START: + return { + ...stateMachine, + state: FilteringState.SELECTING_FILTER + }; + default: + return stateMachine; + } + + case FilteringState.SELECTING_FILTER: + return filterSelectionReducer( + stateMachine, + action, + FilteringState.SELECTING_VALUE, + (prevValue, value) => prevValue + Delimiter.FILTER + value + ); + + case FilteringState.SELECTING_VALUE: + return filterSelectionReducer( + stateMachine, + action, + FilteringState.SELECTING_FILTER, + (prevValue, value) => prevValue + Delimiter.FILTER_VALUE + value + ); + + // Run for all states + default: + return stateMachine; + } +} diff --git a/packages/components/src/utils/typing.utils.ts b/packages/components/src/utils/typing.utils.ts new file mode 100644 index 00000000..faee7947 --- /dev/null +++ b/packages/components/src/utils/typing.utils.ts @@ -0,0 +1,97 @@ +import { FilterSchema, Delimiter, FilterStateType } from "./typing.state"; + +export type Filters = Record; + + /** + * Returns the filter object from the filterSchema + * + * @param value + * @param filterSchema + * @returns + */ + export const getFilterByValue = ( + value: string, + filterSchema: FilterSchema, + ): { value: string; multiple: boolean } | undefined => { + const matchingFilterKey = Object.keys(filterSchema).find( + (key) => filterSchema[key].value === value, + ); + + if (matchingFilterKey) { + const matchingFilter = filterSchema[matchingFilterKey]; + return { + value: matchingFilter.value ?? "", + multiple: !!matchingFilter.multiple, + }; + } + + return undefined; + }; + + /** + * Parses a filter string and returns a filter object + * + * @param filterString - The input filter string of the form "type:value type2:value2 type:value2" + * @param filterSchema - The filter object with types as keys and arrays of values as values + * @returns + */ + export const getFilters = ( + filterString: string, + filterSchema: FilterSchema, + ): Filters => { + const filters: Filters = {}; + + const filterValuePairs = filterString.split(Delimiter.FILTER); + + filterValuePairs.forEach((filterValuePair) => { + const [filter, value] = filterValuePair.split(Delimiter.FILTER_VALUE); + + const schemaEntry = filterSchema[filter]; + + if (schemaEntry && schemaEntry.multiple) { + filters[filter] = filters[filter] ?? []; + filters[filter]!.push(value); // Using non-null assertion + } else { + filters[filter] = [value]; // Treat as an array + } + }); + + return filters; + }; + + /** + * Returns a filter string of form "type:value type2:value2 type:value2" from + * a filter object of { type: { values }, type2: { values } } + * + * @param filters + * @returns + */ + export const getFilterString = (filters: FilterSchema) => { + return Object.entries(filters).reduce((filterString, [filter, values]) => { + const valuesArray = values?.values ?? []; + const filterValuesString = valuesArray + .map((value) => `${filter}${Delimiter.FILTER_VALUE}${value}`) + .join(" "); + + return filterString + filterValuesString; + }, ""); + }; + + /** + * + * @param filteringState + * @returns + */ + export const getCurrentFilterAndValue = (filteringState: FilterStateType) => { + const { context } = filteringState; + const currentFilterValue = context?.value?.split(Delimiter.FILTER).at(-1); + const currentFilter = + currentFilterValue?.split(Delimiter.FILTER_VALUE)?.[0] ?? ""; + const currentValue = + currentFilterValue?.split(Delimiter.FILTER_VALUE)?.[1] ?? ""; + return { + filter: currentFilter, + value: currentValue, + }; + }; + \ No newline at end of file