feat: WIP adavanced filter.

This commit is contained in:
a.bouhuolia
2021-08-09 07:18:21 +02:00
parent 1b90610cec
commit aefb89e1c0
15 changed files with 730 additions and 59 deletions

View File

@@ -0,0 +1,13 @@
import * as Yup from 'yup';
import { DATATYPES_LENGTH } from 'common/dataTypes';
export const getFilterDropdownSchema = () => Yup.object().shape({
conditions: Yup.array().of(
Yup.object().shape({
fieldKey: Yup.string().max(DATATYPES_LENGTH.TEXT),
value: Yup.string().nullable().max(DATATYPES_LENGTH.TEXT),
condition: Yup.string().nullable().max(DATATYPES_LENGTH.TEXT),
comparator: Yup.string().nullable().max(DATATYPES_LENGTH.TEXT),
}),
),
});

View File

@@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import { HTMLSelect, Classes } from '@blueprintjs/core';
import intl from 'react-intl-universal';
import { getConditionTypeCompatators } from './DynamicFilterCompatators';
import { getConditionTypeCompatators } from './utils';
export default function DynamicFilterCompatatorField({
dataType,
@@ -9,7 +9,7 @@ export default function DynamicFilterCompatatorField({
}) {
const options = useMemo(
() => getConditionTypeCompatators(dataType).map(comp => ({
value: comp.value, label: intl.get(comp.label_id),
value: comp.value, label: intl.get(comp.label),
})),
[dataType]
);

View File

@@ -0,0 +1,306 @@
import React from 'react';
import { Formik, Field, FieldArray, useFormikContext } from 'formik';
import {
Button,
Intent,
FormGroup,
Classes,
HTMLSelect,
} from '@blueprintjs/core';
import { get, defaultTo, isEqual } from 'lodash';
import { Icon, FormattedMessage as T } from 'components';
import {
AdvancedFilterDropdownProvider,
FilterConditionProvider,
useFilterCondition,
useAdvancedFilterContext,
} from './AdvancedFilterDropdownContext';
import AdvancedFilterCompatatorField from './AdvancedFilterCompatatorField';
import AdvancedFilterValueField from './AdvancedFilterValueField2';
import {
filterConditionRoles,
getConditionalsOptions,
transformFieldsToOptions,
} from './utils';
import { getFilterDropdownSchema } from './AdvancedFilter.schema';
import {
IAdvancedFilterDropdown,
IAdvancedFilterDropdownFooter,
IFilterDropdownFormikValues,
IAdvancedFilterDropdownConditionsProps,
IAdvancedFilterDropdownCondition,
IFilterRole,
} from './interfaces';
import { useAdvancedFilterAutoSubmit } from './components';
/**
* Filter condition field.
*/
function FilterConditionField() {
const conditionalsOptions = getConditionalsOptions();
const { conditionIndex } = useFilterCondition();
return (
<Field name={`conditions[${conditionIndex}].condition`}>
{({ field }) => (
<FormGroup className={'form-group--condition'}>
<HTMLSelect
options={conditionalsOptions}
className={Classes.FILL}
{...field}
/>
</FormGroup>
)}
</Field>
);
}
/**
* Compatator field.
*/
function FilterCompatatorFilter() {
const { conditionIndex } = useFilterCondition();
return (
<Field name={`conditions[${conditionIndex}].comparator`}>
{({ field }) => (
<FormGroup className={'form-group--comparator'}>
<AdvancedFilterCompatatorField
className={Classes.FILL}
dataType={'text'}
{...field}
/>
</FormGroup>
)}
</Field>
);
}
/**
* Resource fields field.
*/
function FilterFieldsField() {
const { conditionIndex } = useFilterCondition();
const { fields } = useAdvancedFilterContext();
return (
<Field name={`conditions[${conditionIndex}].fieldKey`}>
{({ field }) => (
<FormGroup className={'form-group--fieldKey'}>
<HTMLSelect
options={transformFieldsToOptions(fields)}
value={1}
className={Classes.FILL}
{...field}
/>
</FormGroup>
)}
</Field>
);
}
/**
* Advanced filter value field.
*/
function FilterValueField(): JSX.Element | null {
const { values } = useFormikContext();
const { conditionIndex } = useFilterCondition();
const { fieldsByKey } = useAdvancedFilterContext();
// Current condition field key.
const conditionFieldKey = get(
values.conditions,
`[${conditionIndex}].fieldKey`,
);
// Field meta.
const fieldMeta = fieldsByKey[conditionFieldKey];
// Can't continue if the given field key is not selected yet.
if (!conditionFieldKey || !fieldMeta) {
return null;
}
// Field meta type, name and options.
const fieldType = get(fieldMeta, 'fieldType');
const fieldName = get(fieldMeta, 'name');
const options = get(fieldMeta, 'options');
const valueFieldPath = `conditions[${conditionIndex}].value`;
return (
<Field name={valueFieldPath}>
{({ form: { setFieldValue } }) => (
<FormGroup className={'form-group--value'}>
<AdvancedFilterValueField
key={'name'}
label={fieldName}
fieldType={fieldType}
options={options}
onChange={(value) => {
setFieldValue(valueFieldPath, value);
}}
/>
</FormGroup>
)}
</Field>
);
}
/**
* Advanced filter condition line.
*/
function AdvancedFilterDropdownCondition({
conditionIndex,
onRemoveClick,
}: IAdvancedFilterDropdownCondition) {
// Handle click remove condition.
const handleClickRemoveCondition = () => {
onRemoveClick && onRemoveClick(conditionIndex);
};
return (
<div className="filter-dropdown__condition">
<FilterConditionProvider conditionIndex={conditionIndex}>
<FilterConditionField />
<FilterCompatatorFilter />
<FilterFieldsField />
<FilterValueField />
<Button
icon={<Icon icon="times" iconSize={14} />}
minimal={true}
onClick={handleClickRemoveCondition}
/>
</FilterConditionProvider>
</div>
);
}
/**
* Advanced filter dropdown condition.
*/
function AdvancedFilterDropdownConditions({
push,
remove,
form,
}: IAdvancedFilterDropdownConditionsProps) {
const { initialCondition } = useAdvancedFilterContext();
// Handle remove condition.
const handleClickRemoveCondition = (conditionIndex: number) => {
remove(conditionIndex);
};
// Handle new condition button click.
const handleNewConditionBtnClick = (index: number) => {
push({ ...initialCondition });
};
return (
<div className="filter-dropdonw__conditions-wrap">
<div className={'filter-dropdown__conditions'}>
{form.values.conditions.map((condition: IFilterRole, index: number) => (
<AdvancedFilterDropdownCondition
conditionIndex={index}
onRemoveClick={handleClickRemoveCondition}
/>
))}
</div>
<AdvancedFilterDropdownFooter onClick={handleNewConditionBtnClick} />
</div>
);
}
/**
* Advanced filter dropdown form.
*/
function AdvancedFilterDropdownForm() {
//
useAdvancedFilterAutoSubmit();
return (
<div className="filter-dropdown__form">
<FieldArray
name={'conditions'}
render={({ ...fieldArrayProps }) => (
<AdvancedFilterDropdownConditions {...fieldArrayProps} />
)}
/>
</div>
);
}
/**
* Advanced filter dropdown footer.
*/
function AdvancedFilterDropdownFooter({
onClick,
}: IAdvancedFilterDropdownFooter) {
// Handle new filter condition button click.
const onClickNewFilter = (event) => {
onClick && onClick(event);
};
return (
<div className="filter-dropdown__footer">
<Button minimal={true} intent={Intent.PRIMARY} onClick={onClickNewFilter}>
<T id={'new_conditional'} />
</Button>
</div>
);
}
/**
* Advanced filter dropdown.
*/
export default function AdvancedFilterDropdown({
fields,
defaultFieldKey,
defaultComparator,
defaultValue,
onFilterChange,
}: IAdvancedFilterDropdown) {
const [prevConditions, setPrevConditions] = React.useState({});
// Handle the filter dropdown form submit.
const handleFitlerDropdownSubmit = (values: IFilterDropdownFormikValues) => {
const conditions = filterConditionRoles(values.conditions);
// Campare the current conditions with previous conditions, if they were equal
// there is no need to execute `onFilterChange` function.
if (!isEqual(prevConditions, conditions) && conditions.length > 0) {
onFilterChange && onFilterChange(conditions);
setPrevConditions(conditions);
}
};
// Filter dropdown validation schema.
const validationSchema = getFilterDropdownSchema();
// Initial condition.
const initialCondition = {
fieldKey: defaultFieldKey,
comparator: defaultTo(defaultComparator, 'equals'),
condition: '',
value: defaultTo(defaultValue, ''),
};
// Initial values.
const initialValues: IFilterDropdownFormikValues = {
conditions: [initialCondition],
};
return (
<div className="filter-dropdown">
<AdvancedFilterDropdownProvider
initialCondition={initialCondition}
fields={fields}
>
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
component={AdvancedFilterDropdownForm}
onSubmit={handleFitlerDropdownSubmit}
/>
</AdvancedFilterDropdownProvider>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import React, { createContext, useContext } from 'react';
import { keyBy } from 'lodash';
import {
IAdvancedFilterContextProps,
IFilterConditionContextProps,
IAdvancedFilterProviderProps,
IFilterConditionProviderProps,
} from './interfaces';
const AdvancedFilterContext = createContext<IAdvancedFilterContextProps>({});
const FilterConditionContext = createContext<IFilterConditionContextProps>({});
/**
* Advanced filter dropdown context provider.
*/
function AdvancedFilterDropdownProvider({
initialCondition,
fields,
...props
}: IAdvancedFilterProviderProps) {
const fieldsByKey = keyBy(fields, 'key');
// Provider payload.
const provider = { initialCondition, fields, fieldsByKey };
return <AdvancedFilterContext.Provider value={provider} {...props} />;
}
/**
* Filter condition row context provider.
*/
function FilterConditionProvider({
conditionIndex,
...props
}: IFilterConditionProviderProps) {
// Provider payload.
const provider = { conditionIndex };
return <FilterConditionContext.Provider value={provider} {...props} />;
}
const useFilterCondition = () => useContext(FilterConditionContext);
const useAdvancedFilterContext = () => useContext(AdvancedFilterContext);
export {
AdvancedFilterDropdownProvider,
FilterConditionProvider,
useAdvancedFilterContext,
useFilterCondition,
};

View File

@@ -0,0 +1,104 @@
import React from 'react';
import { Position, Checkbox, InputGroup, FormGroup } from '@blueprintjs/core';
import { DateInput } from '@blueprintjs/datetime';
import moment from 'moment';
import intl from 'react-intl-universal';
import { Choose, ListSelect } from 'components';
import { momentFormatter, tansformDateValue } from 'utils';
import { IFieldType, IAdvancedFilterValueField } from './interfaces';
function AdvancedFilterEnumerationField({ options, value, ...rest }) {
return (
<ListSelect
items={options}
selectedItem={value}
popoverProps={{
inline: true,
minimal: true,
captureDismiss: true,
}}
defaultText={`Select an option`}
textProp={'label'}
selectedItemProp={'key'}
{...rest}
/>
);
}
/**
* Advanced filter value field detarminer.
*/
export default function AdvancedFilterValueField2({
fieldType,
options,
onChange,
}: IAdvancedFilterValueField) {
const [localValue, setLocalValue] = React.useState<string>('');
const triggerOnChange = (value: string) => onChange && onChange(value);
// Handle input change.
const handleInputChange = (e) => {
if (e.currentTarget.type === 'checkbox') {
setLocalValue(e.currentTarget.checked);
triggerOnChange(e.currentTarget.checked);
} else {
setLocalValue(e.currentTarget.value);
triggerOnChange(e.currentTarget.value);
}
};
// Handle enumeration field type change.
const handleEnumerationChange = (option) => {
setLocalValue(option.key);
triggerOnChange(option.key);
};
// Handle date field change.
const handleDateChange = (date: Date) => {
const formattedDate: string = moment(date).format('YYYY/MM/DD');
setLocalValue(formattedDate);
triggerOnChange(formattedDate);
};
return (
<FormGroup className={'form-group--value'}>
<Choose>
<Choose.When condition={fieldType === IFieldType.ENUMERATION}>
<AdvancedFilterEnumerationField
options={options}
value={localValue}
onItemSelect={handleEnumerationChange}
/>
</Choose.When>
<Choose.When condition={fieldType === IFieldType.DATE}>
<DateInput
{...momentFormatter('YYYY/MM/DD')}
value={tansformDateValue(localValue)}
onChange={handleDateChange}
popoverProps={{
minimal: true,
position: Position.BOTTOM,
}}
shortcuts={true}
placeholder={'Select date'}
/>
</Choose.When>
<Choose.When condition={fieldType === IFieldType.BOOLEAN}>
<Checkbox value={localValue} onChange={handleInputChange} />
</Choose.When>
<Choose.Otherwise>
<InputGroup
placeholder={intl.get('value')}
onChange={handleInputChange}
value={localValue}
/>
</Choose.Otherwise>
</Choose>
</FormGroup>
);
}

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { useFormikContext } from 'formik';
import { debounce } from 'lodash';
const DEBOUNCE_MS = 100;
/**
* Advanced filter auto-save.
*/
export function useAdvancedFilterAutoSubmit() {
const { submitForm, values } = useFormikContext();
const [isSubmit, setIsSubmit] = React.useState<boolean>(false);
const debouncedSubmit = React.useCallback(
debounce(() => {
return submitForm().then(() => setIsSubmit(true));
}, DEBOUNCE_MS),
[submitForm],
);
React.useEffect(() => debouncedSubmit, [debouncedSubmit, values]);
}

View File

@@ -0,0 +1,84 @@
import { ArrayHelpers } from 'formik';
export type IResourceFieldType = 'text' | 'number' | 'enumeration' | 'boolean';
export interface IResourceField {
name: string;
key: string;
fieldType: IResourceFieldType;
}
export interface IAdvancedFilterDropdown {
fields: IResourceField[];
defaultFieldKey: string;
defaultComparator?: string;
defaultValue?: string;
}
export interface IAdvancedFilterDropdownFooter {
onClick?: Function;
}
export interface IFilterFieldsField {
fields: IResourceField[];
}
export interface IFilterRole {
fieldKey: string;
comparator: string;
condition: string;
value: string;
}
export interface IAdvancedFilterContextProps {
initialCondition: IFilterRole;
fields: IResourceField[];
fieldsByKey: { [fieldKey: string]: IResourceField };
}
export interface IFilterConditionContextProps {
conditionIndex: number;
}
export interface IAdvancedFilterProviderProps {
initialCondition: IFilterRole;
fields: IResourceField[];
children: JSX.Element | JSX.Element[];
}
export interface IFilterConditionProviderProps {
conditionIndex: number;
children: JSX.Element | JSX.Element[];
}
export interface IFilterDropdownFormikValues {
conditions: IFilterRole[];
}
export type IAdvancedFilterDropdownConditionsProps = ArrayHelpers;
export interface IAdvancedFilterDropdownCondition {
conditionIndex: number;
onRemoveClick: Function;
}
export interface IFilterOption {
key: string;
label: string;
}
export interface IAdvancedFilterValueField {
fieldType: string;
key: string;
label: string;
options?: IFilterOption[];
onChange: Function;
}
export enum IFieldType {
TEXT = 'text',
NUMBER = 'number',
DATE = 'date',
ENUMERATION = 'enumeration',
BOOLEAN = 'boolean',
}

View File

@@ -0,0 +1,98 @@
import intl from 'react-intl-universal';
import { IResourceField, IFilterRole } from './interfaces';
import { uniqueMultiProps, checkRequiredProperties } from 'utils';
interface IConditionOption {
label: string;
value: string;
}
// Conditions options.
export const getConditionalsOptions = (): IConditionOption[] => [
{ value: 'and', label: intl.get('and') },
{ value: 'or', label: intl.get('or') },
];
interface IConditionTypeOption {
value: string;
label: string;
}
export const getBooleanCompatators = (): IConditionTypeOption[] => [
{ value: 'is', label: 'is' },
{ value: 'is_not', label: 'is_not' },
];
export const getTextCompatators = (): IConditionTypeOption[] => [
{ value: 'contain', label: 'contain' },
{ value: 'not_contain', label: 'not_contain' },
{ value: 'equals', label: 'equals' },
{ value: 'not_equal', label: 'not_equals' },
];
export const getDateCompatators = (): IConditionTypeOption[] => [
{ value: 'in', label: 'in' },
{ value: 'after', label: 'after' },
{ value: 'before', label: 'before' },
];
export const getOptionsCompatators = (): IConditionTypeOption[] => [
{ value: 'is', label: 'is' },
{ value: 'is_not', label: 'is_not' },
];
export const getNumberCampatators = (): IConditionTypeOption[] => [
{ value: 'equals', label: 'equals' },
{ value: 'not_equal', label: 'not_equal' },
{ value: 'bigger_than', label: 'bigger_than' },
{ value: 'bigger_or_equals', label: 'bigger_or_equals' },
{ value: 'smaller_than', label: 'smaller_than' },
{ value: 'smaller_or_equals', label: 'smaller_or_equals' },
];
export const getConditionTypeCompatators = (
dataType: string,
): IConditionTypeOption[] => {
return [
...(dataType === 'options'
? [...getOptionsCompatators()]
: dataType === 'date'
? [...getDateCompatators()]
: dataType === 'boolean'
? [...getBooleanCompatators()]
: dataType === 'number'
? [...getNumberCampatators()]
: [...getTextCompatators()]),
];
};
export const getConditionDefaultCompatator = (
dataType: string,
): IConditionTypeOption => {
const compatators = getConditionTypeCompatators(dataType);
return compatators[0];
};
export const transformFieldsToOptions = (fields: IResourceField[]) =>
fields.map((field) => ({
value: field.key,
label: field.name,
}));
/**
* Filtered conditions that don't contain atleast on required fields or
* fileds keys that not exists.
* @param {IFilterRole[]} conditions
* @returns
*/
export const filterConditionRoles = (
conditions: IFilterRole[],
): IFilterRole[] => {
const requiredProps = ['fieldKey', 'condition', 'comparator', 'value'];
const filteredConditions = conditions.filter(
(condition: IFilterRole) =>
!checkRequiredProperties(condition, requiredProps),
);
return uniqueMultiProps(filteredConditions, requiredProps);
};

View File

@@ -1,50 +0,0 @@
export const BooleanCompatators = [
{ value: 'is', label_id: 'is' },
{ value: 'is_not', label_id: 'is_not' },
];
export const TextCompatators = [
{ value: 'contain', label_id: 'contain' },
{ value: 'not_contain', label_id: 'not_contain' },
{ value: 'equals', label_id: 'equals' },
{ value: 'not_equal', label_id: 'not_equals' },
];
export const DateCompatators = [
{ value: 'in', label_id: 'in' },
{ value: 'after', label_id: 'after' },
{ value: 'before', label_id: 'before' },
];
export const OptionsCompatators = [
{ value: 'is', label_id: 'is' },
{ value: 'is_not', label_id: 'is_not' },
];
export const NumberCampatators = [
{ value: 'equals', label_id: 'equals' },
{ value: 'not_equal', label_id: 'not_equal' },
{ value: 'bigger_than', label_id: 'bigger_than' },
{ value: 'bigger_or_equals', label_id: 'bigger_or_equals' },
{ value: 'smaller_than', label_id: 'smaller_than' },
{ value: 'smaller_or_equals', label_id: 'smaller_or_equals' },
]
export const getConditionTypeCompatators = (dataType) => {
return [
...(dataType === 'options'
? [...OptionsCompatators]
: dataType === 'date'
? [...DateCompatators]
: dataType === 'boolean'
? [...BooleanCompatators]
: dataType === 'number'
? [...NumberCampatators]
: [...TextCompatators]),
];
};
export const getConditionDefaultCompatator = (dataType) => {
const compatators = getConditionTypeCompatators(dataType);
return compatators[0];
};

View File

@@ -6,8 +6,8 @@ import For from './Utils/For';
import { FormattedMessage, FormattedHTMLMessage } from './FormattedMessage';
import ListSelect from './ListSelect';
import FinancialStatement from './FinancialStatement';
import DynamicFilterValueField from './DynamicFilter/DynamicFilterValueField';
import DynamicFilterCompatatorField from './DynamicFilter/DynamicFilterCompatatorField';
// import DynamicFilterValueField from './DynamicFilter/DynamicFilterValueField';
// import DynamicFilterCompatatorField from './DynamicFilter/DynamicFilterCompatatorField';
import ErrorMessage from './ErrorMessage';
import MODIFIER from './modifiers';
import FieldHint from './FieldHint';
@@ -78,8 +78,8 @@ export {
Money,
ListSelect,
FinancialStatement,
DynamicFilterValueField,
DynamicFilterCompatatorField,
// DynamicFilterValueField,
// DynamicFilterCompatatorField,
MODIFIER,
ErrorMessage,
FieldHint,