Browse Source

resolve conflict

tumao 4 years ago
parent
commit
9f05222984

+ 4 - 0
client/src/assets/icons/copy.svg

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13.3333 6H7.33333C6.59695 6 6 6.59695 6 7.33333V13.3333C6 14.0697 6.59695 14.6667 7.33333 14.6667H13.3333C14.0697 14.6667 14.6667 14.0697 14.6667 13.3333V7.33333C14.6667 6.59695 14.0697 6 13.3333 6Z" stroke="#06AFF2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.33301 9.99992H2.66634C2.31272 9.99992 1.97358 9.85944 1.72353 9.60939C1.47348 9.35935 1.33301 9.02021 1.33301 8.66659V2.66659C1.33301 2.31296 1.47348 1.97382 1.72353 1.72378C1.97358 1.47373 2.31272 1.33325 2.66634 1.33325H8.66634C9.01996 1.33325 9.3591 1.47373 9.60915 1.72378C9.8592 1.97382 9.99967 2.31296 9.99967 2.66659V3.33325" stroke="#06AFF2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 191 - 0
client/src/components/advancedSearch/Condition.tsx

@@ -0,0 +1,191 @@
+import React, { useState, useEffect, FC } from 'react';
+import {
+  makeStyles,
+  Theme,
+  createStyles,
+  IconButton,
+  TextField,
+} from '@material-ui/core';
+import CloseIcon from '@material-ui/icons/Close';
+import { ConditionProps, Field } from './Types';
+import CustomSelector from '../customSelector/CustomSelector';
+
+// Todo: Move to corrsponding Constant file.
+// Static logical operators.
+const LogicalOperators = [
+  {
+    value: '<',
+    label: '<',
+  },
+  {
+    value: '<=',
+    label: '<=',
+  },
+  {
+    value: '>',
+    label: '>',
+  },
+  {
+    value: '>=',
+    label: '>=',
+  },
+  {
+    value: '==',
+    label: '==',
+  },
+  {
+    value: '!=',
+    label: '!=',
+  },
+  {
+    value: 'in',
+    label: 'in',
+  },
+];
+
+const Condition: FC<ConditionProps> = props => {
+  const {
+    onDelete,
+    triggerChange,
+    fields = [],
+    id = '',
+    initData,
+    className = '',
+    ...others
+  } = props;
+  const [operator, setOperator] = useState(
+    initData?.op || LogicalOperators[0].value
+  );
+  const [conditionField, setConditionField] = useState<Field | any>(
+    initData?.field || fields[0] || {}
+  );
+  const [conditionValue, setConditionValue] = useState(initData?.value || '');
+  const [isValuelegal, setIsValueLegal] = useState(
+    initData?.isCorrect || false
+  );
+
+  /**
+   * Check condition's value by field's and operator's type.
+   * Trigger condition change event.
+   */
+  useEffect(() => {
+    const regInt = /^\d+$/;
+    const regFloat = /^\d+\.\d+$/;
+    const regIntInterval = /^\[\d+,\d+\]$/;
+    const regFloatInterval = /^\[\d+\.\d+,\d+\.\d+]$/;
+
+    const type = conditionField?.type;
+    const isIn = operator === 'in';
+    let isLegal = false;
+
+    switch (type) {
+      case 'int':
+        isLegal = isIn
+          ? regIntInterval.test(conditionValue)
+          : regInt.test(conditionValue);
+        break;
+      case 'float':
+        isLegal = isIn
+          ? regFloatInterval.test(conditionValue)
+          : regFloat.test(conditionValue);
+        break;
+      default:
+        isLegal = false;
+        break;
+    }
+    setIsValueLegal(isLegal);
+    triggerChange(id, {
+      field: conditionField,
+      op: operator,
+      value: conditionValue,
+      isCorrect: isLegal,
+      id,
+    });
+    // No need for 'id' and 'triggerChange'.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [conditionField, operator, conditionValue]);
+
+  const classes = useStyles();
+
+  // Logic operator input change.
+  const handleOpChange = (event: React.ChangeEvent<{ value: unknown }>) => {
+    setOperator(event.target.value);
+  };
+  // Field Name input change.
+  const handleFieldNameChange = (
+    event: React.ChangeEvent<{ value: unknown }>
+  ) => {
+    const value = event.target.value;
+    const target = fields.find(field => field.name === value);
+    target && setConditionField(target);
+  };
+  // Value input change.
+  const handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    const value = event.target.value;
+    setConditionValue(value);
+  };
+
+  return (
+    <div className={`${classes.wrapper} ${className}`} {...others}>
+      <CustomSelector
+        label="Field Name"
+        value={conditionField?.name}
+        onChange={handleFieldNameChange}
+        options={fields.map(i => ({ value: i.name, label: i.name }))}
+        variant="filled"
+        wrapperClass={classes.fieldName}
+      />
+      <CustomSelector
+        label="Logic"
+        value={operator}
+        onChange={handleOpChange}
+        options={LogicalOperators}
+        variant="filled"
+        wrapperClass={classes.logic}
+      />
+      <TextField
+        className={classes.value}
+        label="Value"
+        variant="filled"
+        // size="small"
+        onChange={handleValueChange}
+        value={conditionValue}
+        error={!isValuelegal}
+      />
+      <IconButton
+        aria-label="close"
+        className={classes.closeButton}
+        onClick={onDelete}
+        size="small"
+      >
+        <CloseIcon />
+      </IconButton>
+    </div>
+  );
+};
+
+Condition.displayName = 'Condition';
+
+const useStyles = makeStyles((theme: Theme) =>
+  createStyles({
+    root: {},
+    wrapper: {
+      minWidth: '466px',
+      minHeight: '62px',
+      background: '#FFFFFF',
+      padding: '12px 16px',
+      display: 'flex',
+      flexDirection: 'row',
+      alignItems: 'center',
+    },
+    closeButton: {},
+    fieldName: {
+      minHeight: '38px',
+      minWidth: '130px',
+    },
+    logic: { minHeight: '38px', minWidth: '70px', margin: '0 24px' },
+    value: { minHeight: '38px', minWidth: '130px' },
+  })
+);
+
+export default Condition;

+ 234 - 0
client/src/components/advancedSearch/ConditionGroup.tsx

@@ -0,0 +1,234 @@
+import React, { useState, FC } from 'react';
+import { makeStyles, Theme, createStyles, Button } from '@material-ui/core';
+import { ToggleButton, ToggleButtonGroup } from '@material-ui/lab';
+import ConditionItem from './Condition';
+import AddIcon from '@material-ui/icons/Add';
+import {
+  ConditionGroupProps,
+  BinaryLogicalOpProps,
+  AddConditionProps,
+} from './Types';
+
+// "And & or" operator component.
+const BinaryLogicalOp: FC<BinaryLogicalOpProps> = props => {
+  const { onChange, className, initValue = 'and' } = props;
+  const [operator, setOperator] = useState(initValue);
+  const handleChange = (
+    event: React.MouseEvent<HTMLElement>,
+    newOp: string
+  ) => {
+    if (newOp !== null) {
+      setOperator(newOp);
+      onChange(newOp);
+    }
+  };
+  return (
+    <>
+      <div className={`${className} op-${operator}`}>
+        <ToggleButtonGroup
+          value={operator}
+          exclusive
+          onChange={handleChange}
+          aria-label="Binary Logical Operator"
+        >
+          <ToggleButton value="and" aria-label="And">
+            AND
+          </ToggleButton>
+          <ToggleButton value="or" aria-label="Or">
+            OR
+          </ToggleButton>
+        </ToggleButtonGroup>
+        <div className="op-split" />
+      </div>
+    </>
+  );
+};
+
+// "+ Add condition" component.
+const AddCondition: FC<AddConditionProps> = props => {
+  const { className, onClick } = props;
+  return (
+    <Button onClick={onClick} color="primary" className={className}>
+      <AddIcon />
+      Add Condition
+    </Button>
+  );
+};
+
+// Condition group component which contains of BinaryLogicalOp, AddCondition and ConditionItem.
+const ConditionGroup = (props: ConditionGroupProps) => {
+  const {
+    fields = [],
+    handleConditions = {},
+    conditions: flatConditions = [],
+  } = props;
+  const {
+    addCondition,
+    removeCondition,
+    changeBinaryLogicalOp,
+    updateConditionData,
+  } = handleConditions;
+
+  const classes = useStyles();
+
+  const generateClassName = (conditions: any, currentIndex: number) => {
+    let className = '';
+    if (currentIndex === 0 || conditions[currentIndex - 1].type === 'break') {
+      className += 'radius-top';
+    }
+    if (
+      currentIndex === conditions.length - 1 ||
+      conditions[currentIndex + 1].type === 'break'
+    ) {
+      className ? (className = 'radius-all') : (className = 'radius-bottom');
+    }
+    return className;
+  };
+
+  // Generate condition items with operators and add condition btn.
+  const generateConditionItems = (conditions: any[]) => {
+    const conditionsLength = conditions.length;
+    const results = conditions.reduce((prev: any, condition, currentIndex) => {
+      if (currentIndex === conditionsLength - 1) {
+        prev.push(
+          <ConditionItem
+            key={condition.id}
+            id={condition.id}
+            onDelete={() => {
+              removeCondition(condition.id);
+            }}
+            fields={fields}
+            triggerChange={updateConditionData}
+            initData={condition?.data}
+            className={generateClassName(conditions, currentIndex)}
+          />
+        );
+        prev.push(
+          <AddCondition
+            key={`${condition.id}-add`}
+            className={classes.addBtn}
+            onClick={() => {
+              addCondition(condition.id);
+            }}
+          />
+        );
+      } else if (condition.type === 'break') {
+        prev.pop();
+        prev.push(
+          <AddCondition
+            key={`${condition.id}-add`}
+            className={classes.addBtn}
+            onClick={() => {
+              addCondition(condition.id, true);
+            }}
+          />
+        );
+        prev.push(
+          <BinaryLogicalOp
+            key={`${condition.id}-op`}
+            onChange={newOp => {
+              changeBinaryLogicalOp(newOp, condition.id);
+            }}
+            className={classes.binaryLogicOp}
+            initValue="or"
+          />
+        );
+      } else if (condition.type === 'condition') {
+        prev.push(
+          <ConditionItem
+            key={condition.id}
+            id={condition.id}
+            onDelete={() => {
+              removeCondition(condition.id);
+            }}
+            fields={fields}
+            triggerChange={updateConditionData}
+            initData={condition?.data}
+            className={generateClassName(conditions, currentIndex)}
+          />
+        );
+        prev.push(
+          <BinaryLogicalOp
+            key={`${condition.id}-op`}
+            onChange={newOp => {
+              changeBinaryLogicalOp(newOp, condition.id);
+            }}
+            className={classes.binaryLogicOp}
+          />
+        );
+      }
+      return prev;
+    }, []);
+    return results;
+  };
+
+  return (
+    <div className={classes.wrapper}>
+      {generateConditionItems(flatConditions)}
+      {flatConditions?.length === 0 && (
+        <AddCondition
+          className={classes.addBtn}
+          onClick={() => {
+            addCondition();
+          }}
+        />
+      )}
+    </div>
+  );
+};
+
+ConditionGroup.displayName = 'ConditionGroup';
+
+const useStyles = makeStyles((theme: Theme) =>
+  createStyles({
+    root: {},
+    wrapper: {
+      display: 'flex',
+      flexDirection: 'column',
+      alignItems: 'center',
+
+      '& .op-or': {
+        backgroundColor: 'unset',
+        margin: '16px 0',
+      },
+
+      '& .radius-top': {
+        borderRadius: '8px 8px 0 0',
+      },
+      '& .radius-bottom': {
+        borderRadius: '0 0 8px 8px',
+      },
+      '& .radius-all': {
+        borderRadius: '8px',
+      },
+    },
+    addBtn: {},
+    binaryLogicOp: {
+      width: '100%',
+      backgroundColor: '#FFFFFF',
+      display: 'flex',
+      flexDirection: 'row',
+      alignItems: 'center',
+      '& .op-split': {
+        height: '1px',
+        backgroundColor: '#E9E9ED',
+        width: '100%',
+      },
+      '& button': {
+        width: '42px',
+        height: '32px',
+        color: '#010E29',
+      },
+      '& button.Mui-selected': {
+        backgroundColor: '#06AFF2',
+        color: '#FFFFFF',
+      },
+      '& button.Mui-selected:hover': {
+        backgroundColor: '#06AFF2',
+        color: '#FFFFFF',
+      },
+    },
+  })
+);
+
+export default ConditionGroup;

+ 56 - 0
client/src/components/advancedSearch/CopyButton.tsx

@@ -0,0 +1,56 @@
+import React, { useState, FC } from 'react';
+import { makeStyles, Theme, createStyles } from '@material-ui/core';
+import { CopyButtonProps } from './Types';
+import icons from '../icons/Icons';
+import CustomIconButton from '../customButton/CustomIconButton';
+
+const CopyIcon = icons.copyExpression;
+
+const CopyButton: FC<CopyButtonProps> = props => {
+  const {
+    label = 'copy button',
+    icon,
+    className,
+    value = '',
+    ...others
+  } = props;
+  const classes = useStyles();
+  const [tooltipTitle, setTooltipTitle] = useState('Copy');
+
+  const handleClick = (v: string) => {
+    setTooltipTitle('Copied!');
+    navigator.clipboard.writeText(v);
+    setTimeout(() => {
+      setTooltipTitle('Copy');
+    }, 1000);
+  };
+
+  return (
+    <CustomIconButton
+      tooltip={tooltipTitle}
+      aria-label={label}
+      className={`${classes.button} ${className}`}
+      onClick={() => handleClick(value || '')}
+      {...others}
+    >
+      {icon || <CopyIcon style={{ color: 'transparent' }} />}
+    </CustomIconButton>
+  );
+};
+
+CopyButton.displayName = 'CopyButton';
+
+const useStyles = makeStyles((theme: Theme) =>
+  createStyles({
+    root: {},
+    button: {
+      '& svg': {
+        width: '16px',
+        height: '16px',
+      },
+    },
+    tooltip: {},
+  })
+);
+
+export default CopyButton;

+ 219 - 0
client/src/components/advancedSearch/Dialog.tsx

@@ -0,0 +1,219 @@
+import React, { useEffect } from 'react';
+import {
+  makeStyles,
+  Theme,
+  createStyles,
+  Typography,
+  Button,
+  IconButton,
+  Dialog,
+  DialogActions,
+  DialogContent,
+  DialogTitle,
+} from '@material-ui/core';
+import CloseIcon from '@material-ui/icons/Close';
+import CachedIcon from '@material-ui/icons/Cached';
+import ConditionGroup from './ConditionGroup';
+import CopyBtn from './CopyButton';
+// import DialogTemplate from '../customDialog/DialogTemplate';
+import { DialogProps } from './Types';
+
+const AdvancedDialog = (props: DialogProps) => {
+  const {
+    open = false,
+    onClose,
+    onSubmit,
+    onReset,
+    onCancel,
+    handleConditions = {},
+    conditions: flatConditions = [],
+    isLegal = false,
+    expression: filterExpression = '',
+    title = 'Advanced Filter',
+    fields = [],
+    ...others
+  } = props;
+  const { addCondition } = handleConditions;
+  const classes = useStyles();
+
+  useEffect(() => {
+    flatConditions.length === 0 && addCondition();
+    // Only need add one condition after dialog's first mount.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  return (
+    <>
+      <Dialog
+        onClose={onClose}
+        aria-labelledby="customized-dialog-title"
+        open={open}
+        maxWidth="xl"
+        className={classes.wrapper}
+        {...others}
+      >
+        <DialogTitle className={classes.dialogTitle} disableTypography>
+          <Typography variant="h5" component="h2">
+            {title}
+          </Typography>
+          <IconButton
+            aria-label="close"
+            className={classes.closeButton}
+            onClick={onCancel}
+            size="small"
+          >
+            <CloseIcon />
+          </IconButton>
+        </DialogTitle>
+        <DialogContent>
+          <div
+            className={`${classes.expResult} ${
+              !isLegal && 'disable-exp'
+            } testcopy`}
+          >
+            {`${isLegal ? filterExpression : 'Filter Expression'}`}
+            {isLegal && (
+              <CopyBtn label="copy expression" value={filterExpression} />
+            )}
+          </div>
+          <div className={classes.expWrapper}>
+            <ConditionGroup
+              fields={fields}
+              handleConditions={handleConditions}
+              conditions={flatConditions}
+            />
+          </div>
+        </DialogContent>
+        <DialogActions className={classes.dialogActions}>
+          <Button
+            onClick={onReset}
+            color="primary"
+            className={classes.resetBtn}
+            size="small"
+          >
+            <CachedIcon />
+            Reset
+          </Button>
+          <div>
+            <Button
+              autoFocus
+              onClick={onCancel}
+              color="primary"
+              className={classes.cancelBtn}
+            >
+              Cancel
+            </Button>
+            <Button
+              onClick={onSubmit}
+              variant="contained"
+              color="primary"
+              className={classes.applyBtn}
+              disabled={!isLegal}
+            >
+              Apply Filters
+            </Button>
+          </div>
+        </DialogActions>
+      </Dialog>
+      {/* <DialogTemplate
+        title={title}
+        handleClose={onClose}
+        showCloseIcon
+        handleConfirm={onSubmit}
+        confirmLabel="Apply Filters"
+        confirmDisabled={!isLegal}
+        handleCancel={onCancel}
+        cancelLabel="Cancel"
+        leftActions={
+          <Button
+            onClick={onReset}
+            color="primary"
+            className={classes.resetBtn}
+            size="small"
+          >
+            <CachedIcon />
+            Reset
+          </Button>
+        }
+      >
+        <div
+          className={`${classes.expResult} ${
+            !isLegal && 'disable-exp'
+          } testcopy`}
+        >
+          {`${isLegal ? filterExpression : 'Filter Expression'}`}
+          {isLegal && (
+            <CopyBtn label="copy expression" value={filterExpression} />
+          )}
+        </div>
+        <div className={classes.expWrapper}>
+          <ConditionGroup
+            fields={fields}
+            handleConditions={handleConditions}
+            conditions={flatConditions}
+          />
+        </div>
+      </DialogTemplate> */}
+    </>
+  );
+};
+
+AdvancedDialog.displayName = 'AdvancedDialog';
+
+const useStyles = makeStyles((theme: Theme) =>
+  createStyles({
+    root: {},
+    wrapper: {
+      '& .disable-exp': {
+        userSelect: 'none',
+        color: '#AEAEBB',
+      },
+    },
+    closeButton: {
+      color: 'black',
+    },
+    dialogTitle: {
+      display: 'flex',
+      alignItems: 'center',
+      justifyContent: 'space-between',
+    },
+    dialogActions: {
+      justifyContent: 'space-between',
+    },
+    resetBtn: {},
+    cancelBtn: {
+      marginRight: '32px',
+    },
+    applyBtn: {
+      backgroundColor: '#06AFF2',
+      color: 'white',
+    },
+    copyButton: {
+      borderRadius: '0',
+    },
+    expResult: {
+      background: '#F9F9F9',
+      borderRadius: '8px',
+      display: 'flex',
+      justifyContent: 'space-between',
+      alignItems: 'center',
+      minHeight: '40px',
+      marginBottom: '16px',
+      padding: '0 16px',
+      fontFamily: 'Source Code Pro',
+      fontStyle: 'normal',
+      fontWeight: 'normal',
+      fontSize: '16px',
+      lineHeight: '24px',
+    },
+    expWrapper: {
+      background: '#F9F9F9',
+      borderRadius: '8px',
+      minWidth: '480px',
+      minHeight: '104px',
+      padding: '12px',
+    },
+  })
+);
+
+export default AdvancedDialog;

+ 309 - 0
client/src/components/advancedSearch/Filter.tsx

@@ -0,0 +1,309 @@
+import React, { useState, useEffect } from 'react';
+import {
+  makeStyles,
+  Theme,
+  createStyles,
+  Button,
+  Chip,
+  Tooltip,
+} from '@material-ui/core';
+import FilterListIcon from '@material-ui/icons/FilterList';
+import AdvancedDialog from './Dialog';
+import { FilterProps, ConditionData } from './Types';
+import { generateIdByHash } from '../../utils/Common';
+
+const Filter = function Filter(props: FilterProps) {
+  const {
+    title = 'title',
+    showTitle = true,
+    className = '',
+    tooltipPlacement = 'top',
+    onSubmit,
+    fields = [],
+    ...others
+  } = props;
+  const classes = useStyles();
+
+  const [open, setOpen] = useState(false);
+  const [conditionSum, setConditionSum] = useState(0);
+  const [flatConditions, setFlatConditions] = useState<any[]>([]);
+  const [initConditions, setInitConditions] = useState<any[]>([]);
+  const [isConditionsLegal, setIsConditionsLegal] = useState(false);
+  const [filterExpression, setFilterExpression] = useState('');
+
+  // Check all conditions are all correct.
+  useEffect(() => {
+    // Calc the sum of conditions.
+    setConditionSum(flatConditions.filter(i => i.type === 'condition').length);
+    for (let i = 0; i < flatConditions.length; i++) {
+      const { data, type } = flatConditions[i];
+      if (type !== 'condition') continue;
+      if (!data) {
+        setIsConditionsLegal(false);
+        return;
+      }
+      if (!data.isCorrect) {
+        setIsConditionsLegal(data.isCorrect);
+        return;
+      }
+    }
+    setIsConditionsLegal(true);
+    generateExpression(flatConditions, setFilterExpression);
+  }, [flatConditions]);
+
+  const setFilteredFlatConditions = (conditions: any[]) => {
+    const newConditions = conditions.reduce((prev, item, currentIndex) => {
+      if (prev.length === 0 && item.type !== 'condition') return prev;
+      if (
+        prev.length &&
+        item.type !== 'condition' &&
+        prev[prev.length - 1].type !== 'condition'
+      )
+        return prev;
+      return [...prev, item];
+    }, []);
+    setFlatConditions(newConditions);
+  };
+
+  const generateExpression = (conditions: any[], func: any) => {
+    const expression = conditions.reduce((prev, item) => {
+      const { type, data } = item;
+      if (type === 'break') return `${prev} || `;
+      const {
+        field: { name },
+        op,
+        value,
+      } = data;
+      return `${prev}${
+        prev && !prev.endsWith('|| ') ? ' && ' : ''
+      }${name} ${op} ${value}`;
+    }, '');
+    func(expression);
+  };
+
+  /**
+   * Insert "OR" operator into specified position.
+   * @param targetId The break operator will be inserted after the target one.
+   */
+  const addOrOp = (targetId?: string) => {
+    if (!targetId) {
+      setFilteredFlatConditions([
+        ...flatConditions,
+        { id: generateIdByHash('break'), type: 'break' },
+        {
+          id: generateIdByHash('condition'),
+          type: 'condition',
+          field: '',
+          operator: '<',
+          value: '',
+        },
+      ]);
+      return;
+    }
+    const formerConditons = [...flatConditions];
+    const newConditions = formerConditons.reduce((prev, item) => {
+      if (item.id === targetId) {
+        return [
+          ...prev,
+          item,
+          { id: generateIdByHash('break'), type: 'break' },
+        ];
+      }
+      return [...prev, item];
+    }, []);
+    setFilteredFlatConditions(newConditions);
+  };
+  /**
+   * Remove "OR" operator in specified position.
+   * @param targetId Remove the break operator after the target one.
+   */
+  const removeOrOp = (targetId: string) => {
+    setFilteredFlatConditions(flatConditions.filter(i => i.id !== targetId));
+  };
+  /**
+   * Add new condition to specified position.
+   * @param targetId Position where new condition item will be inserted.
+   * Will be pushed to tail if empty.
+   * @param beforeTarget Will be inserted before the target item.
+   */
+  const addCondition = (targetId?: string, beforeTarget?: boolean) => {
+    const formerConditons = [...flatConditions];
+    const newItem = {
+      id: generateIdByHash('condition'),
+      type: 'condition',
+      field: '',
+      operator: '<',
+      value: '',
+    };
+    if (!targetId) {
+      formerConditons.push(newItem);
+      setFilteredFlatConditions(formerConditons);
+      return;
+    }
+    const newConditions = formerConditons.reduce((prev, item) => {
+      if (item.id === targetId) {
+        const newItems = [
+          item,
+          {
+            id: generateIdByHash('condition'),
+            type: 'condition',
+            field: '',
+            operator: '<',
+            value: '',
+          },
+        ];
+        beforeTarget && newItems.reverse();
+        return [...prev, ...newItems];
+      }
+      return [...prev, item];
+    }, []);
+    setIsConditionsLegal(false);
+    setFilteredFlatConditions(newConditions);
+  };
+  /**
+   * Remove condition from specified position.
+   * @param targetId Position where new condition item will be removed.
+   */
+  const removeCondition = (targetId: string) => {
+    setFilteredFlatConditions(flatConditions.filter(i => i.id !== targetId));
+  };
+  const changeBinaryLogicalOp = (value: string, targetId: string) => {
+    if (value === 'or') {
+      addOrOp(targetId);
+    } else if (value === 'and') {
+      removeOrOp(targetId);
+    }
+  };
+  /**
+   * Update specified condition's data.
+   * @param id Specify one item that will be updated.
+   * @param data Data that will be updated to condition.
+   */
+  const updateConditionData = (id: string, data: ConditionData) => {
+    const formerFlatConditions = flatConditions.map(i => {
+      if (i.id === id) return { ...i, data };
+      return i;
+    });
+    setFilteredFlatConditions(formerFlatConditions);
+  };
+  // Reset conditions.
+  const resetConditions = () => {
+    setIsConditionsLegal(false);
+    setFilteredFlatConditions([
+      {
+        id: generateIdByHash(),
+        type: 'condition',
+        field: '',
+        operator: '<',
+        value: '',
+      },
+    ]);
+  };
+  const getConditionsSum = () => {
+    const conds = flatConditions.filter(i => i.type === 'condition');
+    return conds.length;
+  };
+
+  const handleConditions = {
+    addOrOp,
+    removeOrOp,
+    addCondition,
+    removeCondition,
+    changeBinaryLogicalOp,
+    updateConditionData,
+    resetConditions,
+    getConditionsSum,
+    setFilteredFlatConditions,
+    setIsConditionsLegal,
+    setFilterExpression,
+  };
+
+  const handleClickOpen = () => {
+    setOpen(true);
+  };
+  const handleClose = () => {
+    setOpen(false);
+  };
+  const handleDeleteAll = () => {
+    setFlatConditions([]);
+    setInitConditions([]);
+    onSubmit('');
+  };
+  const handleCancel = () => {
+    setOpen(false);
+    handleFallback();
+  };
+  const handleSubmit = () => {
+    onSubmit(filterExpression);
+    setInitConditions(flatConditions);
+    setOpen(false);
+  };
+  const handleReset = () => {
+    setFilteredFlatConditions([
+      {
+        id: generateIdByHash('condition'),
+        type: 'condition',
+        field: '',
+        operator: '<',
+        value: '',
+      },
+    ]);
+  };
+  const handleFallback = () => {
+    setFilteredFlatConditions(initConditions);
+  };
+
+  return (
+    <>
+      <div className={`${classes.wrapper} ${className}`} {...others}>
+        <Button className={`${classes.afBtn} af-btn`} onClick={handleClickOpen}>
+          <FilterListIcon />
+          {showTitle ? title : ''}
+        </Button>
+        {conditionSum > 0 && (
+          <Tooltip
+            arrow
+            interactive
+            title={filterExpression}
+            placement={tooltipPlacement}
+          >
+            <Chip
+              label={conditionSum}
+              onDelete={handleDeleteAll}
+              variant="outlined"
+              size="small"
+            />
+          </Tooltip>
+        )}
+        {open && (
+          <AdvancedDialog
+            open={open}
+            onClose={handleClose}
+            onCancel={handleCancel}
+            onSubmit={handleSubmit}
+            onReset={handleReset}
+            title="Advanced Filter"
+            fields={fields}
+            handleConditions={handleConditions}
+            conditions={flatConditions}
+            isLegal={isConditionsLegal}
+            expression={filterExpression}
+          />
+        )}
+      </div>
+    </>
+  );
+};
+
+Filter.displayName = 'AdvancedFilter';
+
+const useStyles = makeStyles((theme: Theme) =>
+  createStyles({
+    wrapper: {},
+    afBtn: {
+      color: '#06AFF2',
+    },
+  })
+);
+
+export default Filter;

+ 82 - 0
client/src/components/advancedSearch/Types.ts

@@ -0,0 +1,82 @@
+// import { ReactElement } from 'react';
+
+export interface ConditionProps {
+  others?: object;
+  onDelete: () => void;
+  triggerChange: (id: string, data: TriggerChangeData) => void;
+  fields: Field[];
+  id: string;
+  initData: any;
+  className?: string;
+}
+
+export interface Field {
+  name: string;
+  type: 'int' | 'float';
+}
+
+export interface TriggerChangeData {
+  field: Field;
+  op: string;
+  value: string;
+  isCorrect: boolean;
+  id: string;
+}
+
+export interface ConditionGroupProps {
+  others?: object;
+  fields: Field[];
+  handleConditions: any;
+  conditions: any[];
+}
+
+export interface BinaryLogicalOpProps {
+  onChange: (newOp: string) => void;
+  className?: string;
+  initValue?: string;
+}
+
+export interface AddConditionProps {
+  className?: string;
+  onClick?: () => void;
+}
+
+export interface CopyButtonProps {
+  className?: string;
+  icon?: any;
+  label: string;
+  value: string;
+  others?: any;
+}
+
+export interface DialogProps {
+  others?: object;
+  open: boolean;
+  onClose: () => void;
+  onSubmit: (data: any) => void;
+  onReset: () => void;
+  onCancel: () => void;
+  title: string;
+  fields: Field[];
+  handleConditions: any;
+  conditions: any[];
+  isLegal: boolean;
+  expression: string;
+}
+
+export interface FilterProps {
+  className?: string;
+  title: string;
+  showTitle?: boolean;
+  others?: object;
+  onSubmit: (data: any) => void;
+  tooltipPlacement?: 'left' | 'right' | 'bottom' | 'top';
+  fields: Field[];
+}
+
+export interface ConditionData {
+  field: Field;
+  op: string;
+  value: string;
+  isCorrect: boolean;
+}

+ 3 - 0
client/src/components/advancedSearch/index.tsx

@@ -0,0 +1,3 @@
+import Filter from './Filter';
+
+export default Filter;

+ 18 - 13
client/src/components/customDialog/DialogTemplate.tsx

@@ -13,6 +13,7 @@ import CustomButton from '../customButton/CustomButton';
 const useStyles = makeStyles((theme: Theme) => ({
 const useStyles = makeStyles((theme: Theme) => ({
   actions: {
   actions: {
     paddingTop: theme.spacing(2),
     paddingTop: theme.spacing(2),
+    justifyContent: 'space-between',
   },
   },
 }));
 }));
 
 
@@ -28,6 +29,7 @@ const DialogTemplate: FC<DialogContainerProps> = ({
   showActions = true,
   showActions = true,
   showCancel = true,
   showCancel = true,
   showCloseIcon = true,
   showCloseIcon = true,
+  leftActions,
 }) => {
 }) => {
   const { t } = useTranslation('btn');
   const { t } = useTranslation('btn');
   const cancel = cancelLabel || t('cancel');
   const cancel = cancelLabel || t('cancel');
@@ -43,20 +45,23 @@ const DialogTemplate: FC<DialogContainerProps> = ({
       <DialogContent>{children}</DialogContent>
       <DialogContent>{children}</DialogContent>
       {showActions && (
       {showActions && (
         <DialogActions className={classes.actions}>
         <DialogActions className={classes.actions}>
-          {showCancel && (
-            <CustomButton onClick={onCancel} color="default" name="cancel">
-              {cancel}
+          <div>{leftActions}</div>
+          <div>
+            {showCancel && (
+              <CustomButton onClick={onCancel} color="default" name="cancel">
+                {cancel}
+              </CustomButton>
+            )}
+            <CustomButton
+              variant="contained"
+              onClick={handleConfirm}
+              color="primary"
+              disabled={confirmDisabled}
+              name="confirm"
+            >
+              {confirm}
             </CustomButton>
             </CustomButton>
-          )}
-          <CustomButton
-            variant="contained"
-            onClick={handleConfirm}
-            color="primary"
-            disabled={confirmDisabled}
-            name="confirm"
-          >
-            {confirm}
-          </CustomButton>
+          </div>
         </DialogActions>
         </DialogActions>
       )}
       )}
     </>
     </>

+ 1 - 0
client/src/components/customDialog/Types.ts

@@ -31,4 +31,5 @@ export type DialogContainerProps = {
   confirmDisabled?: boolean;
   confirmDisabled?: boolean;
   showActions?: boolean;
   showActions?: boolean;
   showCancel?: boolean;
   showCancel?: boolean;
+  leftActions?: ReactElement;
 };
 };

+ 4 - 0
client/src/components/icons/Icons.tsx

@@ -33,6 +33,7 @@ import { ReactComponent as KeyIcon } from '../../assets/icons/key.svg';
 import { ReactComponent as UploadIcon } from '../../assets/icons/upload.svg';
 import { ReactComponent as UploadIcon } from '../../assets/icons/upload.svg';
 import { ReactComponent as VectorSearchIcon } from '../../assets/icons/nav-search.svg';
 import { ReactComponent as VectorSearchIcon } from '../../assets/icons/nav-search.svg';
 import { ReactComponent as SearchEmptyIcon } from '../../assets/icons/search.svg';
 import { ReactComponent as SearchEmptyIcon } from '../../assets/icons/search.svg';
+import { ReactComponent as CopyIcon } from '../../assets/icons/copy.svg';
 
 
 const icons: { [x in IconsType]: (props?: any) => React.ReactElement } = {
 const icons: { [x in IconsType]: (props?: any) => React.ReactElement } = {
   search: (props = {}) => <SearchIcon {...props} />,
   search: (props = {}) => <SearchIcon {...props} />,
@@ -90,6 +91,9 @@ const icons: { [x in IconsType]: (props?: any) => React.ReactElement } = {
   vectorSearch: (props = {}) => (
   vectorSearch: (props = {}) => (
     <SvgIcon viewBox="0 0 48 48" component={SearchEmptyIcon} {...props} />
     <SvgIcon viewBox="0 0 48 48" component={SearchEmptyIcon} {...props} />
   ),
   ),
+  copyExpression: (props = {}) => (
+    <SvgIcon viewBox="0 0 16 16" component={CopyIcon} {...props} />
+  ),
 };
 };
 
 
 export default icons;
 export default icons;

+ 2 - 1
client/src/components/icons/Types.ts

@@ -30,4 +30,5 @@ export type IconsType =
   | 'dropdown'
   | 'dropdown'
   | 'vectorSearch'
   | 'vectorSearch'
   | 'refresh'
   | 'refresh'
-  | 'filter';
+  | 'filter'
+  | 'copyExpression';

+ 17 - 0
client/src/utils/Common.ts

@@ -44,3 +44,20 @@ export const findKeyValue = (
   obj: { key: string; value: string }[],
   obj: { key: string; value: string }[],
   key: string
   key: string
 ) => obj.find(v => v.key === key)?.value;
 ) => obj.find(v => v.key === key)?.value;
+
+export const generateHashCode = (source: string) => {
+  let hash = 0,
+    i,
+    chr;
+  if (source.length === 0) return hash;
+  for (i = 0; i < source.length; i++) {
+    chr = source.charCodeAt(i);
+    hash = (hash << 5) - hash + chr;
+    hash |= 0; // Convert to 32bit integer
+  }
+  return hash.toString();
+};
+
+export const generateIdByHash = (salt?: string) => {
+  return generateHashCode(`${new Date().getTime().toString()}-${salt}`);
+};