Browse Source

pref: refactor query page, improve preformance (#920)

Signed-off-by: ryjiang <jiangruiyi@gmail.com>
ryjiang 2 weeks ago
parent
commit
6f99536316

+ 4 - 2
client/src/pages/databases/CollectionsTab.tsx

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
 import RouteTabList from '@/components/customTabList/RouteTabList';
 import Partitions from './collections/partitions/Partitions';
 import Schema from './collections/schema/Schema';
-import Data from './collections/data/CollectionData';
+import CollectionData from './collections/data/CollectionData';
 import Segments from './collections/segments/Segments';
 import Properties from './collections/properties/Properties';
 import Search from './collections/search/Search';
@@ -65,7 +65,9 @@ export const CollectionsTabs = (props: {
     },
     {
       label: collectionTrans('dataTab'),
-      component: <Data queryState={queryState} setQueryState={setQueryState} />,
+      component: (
+        <CollectionData queryState={queryState} setQueryState={setQueryState} />
+      ),
       path: `data`,
     },
     {

+ 134 - 480
client/src/pages/databases/collections/data/CollectionData.tsx

@@ -1,43 +1,22 @@
-import { useState, useEffect, useRef, useContext } from 'react';
+import { useState, useEffect, useRef, useContext, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
-import { rootContext, dataContext } from '@/context';
-import { DataService } from '@/http';
+import { dataContext } from '@/context';
 import { useQuery } from '@/hooks';
-import { saveCsvAs, getColumnWidth } from '@/utils';
+import { getColumnWidth } from '@/utils';
 import icons from '@/components/icons/Icons';
-import CustomButton from '@/components/customButton/CustomButton';
 import AttuGrid from '@/components/grid/Grid';
-import { ToolBarConfig } from '@/components/grid/Types';
-import Filter from '@/components/advancedSearch';
-import DeleteTemplate from '@/components/customDialog/DeleteDialogTemplate';
 import CustomToolBar from '@/components/grid/ToolBar';
-import InsertDialog from '@/pages/dialogs/insert/Dialog';
-import EditJSONDialog from '@/pages/dialogs/EditJSONDialog';
 import { getLabelDisplayedRows } from '@/pages/search/Utils';
-import { Root, Toolbar } from '../../StyledComponents';
-import {
-  DYNAMIC_FIELD,
-  DataTypeStringEnum,
-  CONSISTENCY_LEVEL_OPTIONS,
-  ConsistencyLevelEnum,
-} from '@/consts';
-import {
-  Select,
-  MenuItem,
-  FormControl,
-  InputLabel,
-  Typography,
-} from '@mui/material';
-import EmptyDataDialog from '@/pages/dialogs/EmptyDataDialog';
-import ImportSampleDialog from '@/pages/dialogs/ImportSampleDialog';
+import { Root } from '../../StyledComponents';
+import { DYNAMIC_FIELD, ConsistencyLevelEnum } from '@/consts';
+import { Typography } from '@mui/material';
 import StatusIcon, { LoadingType } from '@/components/status/StatusIcon';
-import CustomInput from '@/components/customInput/CustomInput';
 import CollectionColHeader from '../CollectionColHeader';
 import DataView from '@/components/DataView/DataView';
-import DataListView from '@/components/DataListView/DataListView';
 import type { QueryState } from '../../types';
 import { CollectionFullObject } from '@server/types';
-import CustomMultiSelector from '@/components/customSelector/CustomMultiSelector';
+import CollectionToolbar from './DataActionToolbar';
+import QueryToolbar from './QueryToolbar';
 
 export interface CollectionDataProps {
   queryState: QueryState;
@@ -57,68 +36,19 @@ const CollectionData = (props: CollectionDataProps) => {
   // UI state
   const [tableLoading, setTableLoading] = useState<boolean>();
   const [selectedData, setSelectedData] = useState<any[]>([]);
-  const [exprInput, setExprInput] = useState<string>(queryState.expr);
+  const exprInputRef = useRef<string>(queryState.expr);
+  const [, forceUpdate] = useState({});
 
   // UI functions
-  const { setDialog, handleCloseDialog, openSnackBar, setDrawer } =
-    useContext(rootContext);
   const { fetchCollection } = useContext(dataContext);
-  // icons
-  const ResetIcon = icons.refresh;
   // translations
-  const { t: dialogTrans } = useTranslation('dialog');
-  const { t: successTrans } = useTranslation('success');
   const { t: searchTrans } = useTranslation('search');
-  const { t: collectionTrans } = useTranslation('collection');
-  const { t: btnTrans } = useTranslation('btn');
   const { t: commonTrans } = useTranslation();
 
   // UI ref
   const filterRef = useRef();
   const inputRef = useRef<HTMLInputElement>();
 
-  // UI event handlers
-  const handleFilterReset = async () => {
-    // reset advanced filter
-    const currentFilter: any = filterRef.current;
-    currentFilter?.getReset();
-    // update UI expression
-    setExprInput('');
-    setQueryState({
-      ...queryState,
-      expr: '',
-      outputFields: [...collection.schema.fields].map(f => f.name),
-      tick: queryState.tick + 1,
-    });
-
-    // ensure not loading
-    setTableLoading(false);
-  };
-  const handleFilterSubmit = async (expression: string) => {
-    // update UI expression
-    setQueryState({ ...queryState, expr: expression });
-    setExprInput(expression);
-  };
-  const handlePageChange = async (e: any, page: number) => {
-    // do the query
-    await query(page, queryState.consistencyLevel);
-    // update page number
-    setCurrentPage(page);
-  };
-  const onSelectChange = (value: any) => {
-    setSelectedData(value);
-  };
-  const onDelete = async () => {
-    // clear selection
-    setSelectedData([]);
-    // reset();
-    reset();
-    // update count
-    count(ConsistencyLevelEnum.Strong);
-    // update query
-    query(0, ConsistencyLevelEnum.Strong);
-  };
-
   // Query hook
   const {
     currentPage,
@@ -146,256 +76,130 @@ const CollectionData = (props: CollectionDataProps) => {
     setQueryState: setQueryState,
   });
 
-  const onInsert = async (collectionName: string) => {
-    await fetchCollection(collectionName);
-  };
+  // Memoized handlers
+  const handleExprChange = useCallback(
+    (value: string) => {
+      exprInputRef.current = value;
+      if (value === '' || value === queryState.expr) {
+        forceUpdate({});
+      }
+    },
+    [queryState.expr]
+  );
+
+  const handleExprKeyDown = useCallback(
+    (e: any) => {
+      if (e.key === 'Enter') {
+        setQueryState({
+          ...queryState,
+          expr: exprInputRef.current,
+          tick: queryState.tick + 1,
+        });
+        // reset page
+        setCurrentPage(0);
+        e.preventDefault();
+      }
+    },
+    [queryState, setQueryState, setCurrentPage]
+  );
 
-  const handleEditConfirm = async (data: Record<string, any>) => {
-    const result = (await DataService.upsert(collection.collection_name, {
-      fields_data: [data],
-    })) as any;
+  const handleFilterSubmit = useCallback(
+    async (expression: string) => {
+      // update UI expression
+      setQueryState({ ...queryState, expr: expression });
+      exprInputRef.current = expression;
+      forceUpdate({});
+    },
+    [queryState, setQueryState]
+  );
 
-    const idField = result.IDs.id_field;
-    const id = result.IDs[idField].data;
-    // deselect all
-    setSelectedData([]);
-    const newExpr = `${collection.schema.primaryField.name} == ${id}`;
-    // update local expr
-    setExprInput(newExpr);
-    // set expr with id
+  // UI event handlers
+  const handleFilterReset = useCallback(async () => {
+    // reset advanced filter
+    const currentFilter: any = filterRef.current;
+    currentFilter?.getReset();
+    // update UI expression
+    exprInputRef.current = '';
     setQueryState({
       ...queryState,
-      consistencyLevel: ConsistencyLevelEnum.Strong,
-      expr: newExpr,
+      expr: '',
+      outputFields: [...collection.schema.fields]
+        .filter(f => !f.is_function_output)
+        .map(f => f.name),
       tick: queryState.tick + 1,
     });
-  };
+    forceUpdate({});
 
-  const getEditData = (data: any, collection: CollectionFullObject) => {
-    // sort data by collection schema order
-    const schema = collection.schema;
-    let sortedData: { [key: string]: any } = {};
-    schema.fields.forEach(field => {
-      if (data[field.name] !== undefined) {
-        sortedData[field.name] = data[field.name];
-      }
-    });
+    // ensure not loading
+    setTableLoading(false);
+  }, [collection.schema.fields, queryState, setQueryState]);
 
-    // add dynamic fields if exist
-    const isDynamicSchema = collection.schema.dynamicFields.length > 0;
-    if (isDynamicSchema) {
-      sortedData = { ...sortedData, ...data[DYNAMIC_FIELD] };
-    }
+  const handlePageChange = useCallback(
+    async (e: any, page: number) => {
+      // do the query
+      await query(page, queryState.consistencyLevel);
+      // update page number
+      setCurrentPage(page);
+    },
+    [query, queryState.consistencyLevel, setCurrentPage]
+  );
 
-    return sortedData;
-  };
+  const onSelectChange = useCallback((value: any) => {
+    setSelectedData(value);
+  }, []);
 
-  // Toolbar settings
-  const toolbarConfigs: ToolBarConfig[] = [
-    {
-      icon: 'uploadFile',
-      type: 'button',
-      btnVariant: 'text',
-      btnColor: 'secondary',
-      label: btnTrans('importFile'),
-      tooltip: btnTrans('importFileTooltip'),
-      disabled: () => selectedData?.length > 0,
-      onClick: () => {
-        setDialog({
-          open: true,
-          type: 'custom',
-          params: {
-            component: (
-              <InsertDialog
-                defaultSelectedCollection={collection.collection_name}
-                // user can't select partition on collection page, so default value is ''
-                defaultSelectedPartition={''}
-                collections={[collection!]}
-                onInsert={onInsert}
-              />
-            ),
-          },
-        });
-      },
-    },
-    {
-      type: 'button',
-      btnVariant: 'text',
-      onClick: () => {
-        setDialog({
-          open: true,
-          type: 'custom',
-          params: {
-            component: (
-              <ImportSampleDialog
-                collection={collection!}
-                cb={async () => {
-                  await onInsert(collection.collection_name);
-                  await onDelete();
-                }}
-              />
-            ),
-          },
-        });
-      },
-      tooltip: btnTrans('importSampleDataTooltip'),
-      label: btnTrans('importSampleData'),
-      icon: 'add',
-      // tooltip: collectionTrans('deleteTooltip'),
-      disabled: () => selectedData?.length > 0,
-    },
-    {
-      icon: 'deleteOutline',
-      type: 'button',
-      btnVariant: 'text',
-      onClick: () => {
-        setDialog({
-          open: true,
-          type: 'custom',
-          params: {
-            component: (
-              <EmptyDataDialog
-                cb={async () => {
-                  await onDelete();
-                }}
-                collection={collection!}
-              />
-            ),
-          },
-        });
-      },
-      label: btnTrans('empty'),
-      tooltip: btnTrans('emptyTooltip'),
-      disabled: () => selectedData?.length > 0 || total == 0,
-    },
-    {
-      icon: 'eye',
-      type: 'button',
-      btnVariant: 'text',
-      onClick: () => {
-        setDrawer({
-          open: true,
-          title: 'custom',
-          content: <DataListView collection={collection} data={selectedData} />,
-          hasActionBar: true,
-          actions: [],
-        });
-      },
-      label: btnTrans('viewData'),
-      tooltip: btnTrans('viewDataTooltip'),
-      disabled: () => selectedData?.length !== 1,
-      hideOnDisable() {
-        return selectedData?.length === 0;
-      },
-    },
-    {
-      type: 'button',
-      btnVariant: 'text',
-      onClick: () => {
-        setDialog({
-          open: true,
-          type: 'custom',
-          params: {
-            component: (
-              <EditJSONDialog
-                data={getEditData(selectedData[0], collection)}
-                dialogTitle={dialogTrans('editEntityTitle')}
-                dialogTip={dialogTrans('editEntityInfo')}
-                handleConfirm={handleEditConfirm}
-                handleCloseDialog={handleCloseDialog}
-              />
-            ),
-          },
-        });
-      },
-      label: btnTrans('edit'),
-      icon: 'edit',
-      tooltip: btnTrans('editEntityTooltip'),
-      disabledTooltip: btnTrans('editEntityDisabledTooltip'),
-      disabled: () => selectedData?.length !== 1,
-      hideOnDisable() {
-        return selectedData?.length === 0;
-      },
-    },
-    {
-      type: 'button',
-      btnVariant: 'text',
-      onClick: () => {
-        saveCsvAs(selectedData, `${collection.collection_name}.query.csv`);
-      },
-      label: btnTrans('export'),
-      icon: 'download',
-      tooltip: btnTrans('exportTooltip'),
-      disabledTooltip: btnTrans('downloadDisabledTooltip'),
-      disabled: () => !selectedData?.length,
+  const onDelete = useCallback(async () => {
+    // clear selection
+    setSelectedData([]);
+    // reset();
+    reset();
+    // update count
+    count(ConsistencyLevelEnum.Strong);
+    // update query
+    query(0, ConsistencyLevelEnum.Strong);
+  }, [count, query, reset]);
+
+  const onInsert = useCallback(
+    async (collectionName: string) => {
+      await fetchCollection(collectionName);
     },
-    {
-      type: 'button',
-      btnVariant: 'text',
-      onClick: async () => {
-        let json = JSON.stringify(selectedData);
-        try {
-          await navigator.clipboard.writeText(json);
-          alert(`${selectedData.length} rows copied to clipboard`);
-        } catch (err) {
-          console.error('Failed to copy text: ', err);
+    [fetchCollection]
+  );
+
+  const getEditData = useCallback(
+    (data: any, collection: CollectionFullObject) => {
+      // sort data by collection schema order
+      const schema = collection.schema;
+      let sortedData: { [key: string]: any } = {};
+      schema.fields.forEach(field => {
+        if (data[field.name] !== undefined) {
+          sortedData[field.name] = data[field.name];
         }
-      },
-      label: btnTrans('copyJson'),
-      icon: 'copy',
-      tooltip: btnTrans('copyJsonTooltip'),
-      disabledTooltip: btnTrans('downloadDisabledTooltip'),
-      disabled: () => !selectedData?.length,
-    },
+      });
 
-    {
-      type: 'button',
-      btnVariant: 'text',
-      onClick: () => {
-        setDialog({
-          open: true,
-          type: 'custom',
-          params: {
-            component: (
-              <DeleteTemplate
-                label={btnTrans('delete')}
-                title={dialogTrans('deleteEntityTitle')}
-                text={collectionTrans('deleteDataWarning')}
-                handleDelete={async () => {
-                  // call delete api
-                  await DataService.deleteEntities(collection.collection_name, {
-                    expr: `${
-                      collection!.schema.primaryField.name
-                    } in [${selectedData
-                      .map(v =>
-                        collection!.schema.primaryField.data_type ===
-                        DataTypeStringEnum.VarChar
-                          ? `"${v[collection!.schema.primaryField.name]}"`
-                          : v[collection!.schema.primaryField.name]
-                      )
-                      .join(',')}]`,
-                  });
-                  handleCloseDialog();
-                  openSnackBar(
-                    successTrans('delete', {
-                      name: collectionTrans('entities'),
-                    })
-                  );
-                  setSelectedData([]);
-                  await onDelete();
-                }}
-              />
-            ),
-          },
-        });
-      },
-      label: btnTrans('delete'),
-      icon: 'cross2',
-      tooltip: btnTrans('deleteTooltip'),
-      disabledTooltip: collectionTrans('deleteDisabledTooltip'),
-      disabled: () => !selectedData?.length,
+      // add dynamic fields if exist
+      const isDynamicSchema = collection.schema.dynamicFields.length > 0;
+      if (isDynamicSchema) {
+        sortedData = { ...sortedData, ...data[DYNAMIC_FIELD] };
+      }
+
+      return sortedData;
     },
-  ];
+    []
+  );
+
+  // Get toolbar configs directly from CollectionToolbar component
+  const toolbarConfigs = CollectionToolbar({
+    collection,
+    selectedData,
+    total,
+    queryState,
+    setQueryState,
+    onDelete,
+    onInsert,
+    getEditData,
+    setSelectedData,
+  });
 
   useEffect(() => {
     if (inputRef.current) {
@@ -406,176 +210,26 @@ const CollectionData = (props: CollectionDataProps) => {
   useEffect(() => {
     // reset selection
     setSelectedData([]);
-    setExprInput(queryState.expr);
-  }, [collection.collection_name]);
+    exprInputRef.current = queryState.expr;
+    forceUpdate({});
+  }, [collection.collection_name, queryState.expr]);
 
   return (
     <Root>
       {collection && (
         <>
           <CustomToolBar toolbarConfigs={toolbarConfigs} hideOnDisable={true} />
-          <Toolbar>
-            <div className="left">
-              <CustomInput
-                type="text"
-                textConfig={{
-                  label: exprInput
-                    ? collectionTrans('queryExpression')
-                    : collectionTrans('exprPlaceHolder'),
-                  key: 'advFilter',
-                  className: 'textarea',
-                  onChange: (value: string) => {
-                    setExprInput(value);
-                  },
-                  value: exprInput,
-                  disabled: !collection.loaded,
-                  variant: 'filled',
-                  required: false,
-                  InputLabelProps: { shrink: true },
-                  InputProps: {
-                    endAdornment: (
-                      <Filter
-                        title={''}
-                        showTitle={false}
-                        fields={collection.schema.scalarFields}
-                        filterDisabled={!collection.loaded}
-                        onSubmit={handleFilterSubmit}
-                        showTooltip={false}
-                      />
-                    ),
-                  },
-                  onKeyDown: (e: any) => {
-                    if (e.key === 'Enter') {
-                      setQueryState({
-                        ...queryState,
-                        expr: exprInput,
-                        tick: queryState.tick + 1,
-                      });
-                      // reset page
-                      setCurrentPage(0);
-                      e.preventDefault();
-                    }
-                  },
-                }}
-                checkValid={() => true}
-              />
-
-              <FormControl
-                variant="filled"
-                className="selector"
-                disabled={!collection.loaded}
-                sx={{ minWidth: 120 }}
-              >
-                <InputLabel>{collectionTrans('consistency')}</InputLabel>
-                <Select
-                  value={queryState.consistencyLevel}
-                  label={collectionTrans('consistency')}
-                  onChange={e => {
-                    const consistency = e.target.value as string;
-                    setQueryState({
-                      ...queryState,
-                      consistencyLevel: consistency,
-                    });
-                  }}
-                >
-                  {CONSISTENCY_LEVEL_OPTIONS.map(option => (
-                    <MenuItem key={option.value} value={option.value}>
-                      {option.label}
-                    </MenuItem>
-                  ))}
-                </Select>
-              </FormControl>
-            </div>
-
-            <div className="right">
-              <CustomMultiSelector
-                options={queryState.fields.map(field => ({
-                  label:
-                    field.name === DYNAMIC_FIELD
-                      ? searchTrans('dynamicFields')
-                      : field.name,
-                  value: field.name,
-                }))}
-                values={queryState.outputFields}
-                label={searchTrans('outputFields')}
-                variant="filled"
-                wrapperClass="outputs selector"
-                onChange={(e: { target: { value: unknown } }) => {
-                  const values = e.target.value as string[];
-                  // prevent deselecting the last option
-                  if (values.length === 0) {
-                    return;
-                  }
-                  // sort output fields by schema order
-                  const newOutputFields = [...values].sort(
-                    (a, b) =>
-                      queryState.fields.findIndex(f => f.name === a) -
-                      queryState.fields.findIndex(f => f.name === b)
-                  );
-
-                  setQueryState({
-                    ...queryState,
-                    outputFields: newOutputFields,
-                  });
-                }}
-                renderValue={(selected: unknown) => {
-                  const selectedArray = selected as string[];
-                  return (
-                    <span>{`${selectedArray.length} ${commonTrans(
-                      selectedArray.length > 1 ? 'grid.fields' : 'grid.field'
-                    )}`}</span>
-                  );
-                }}
-                disabled={!collection.loaded}
-                sx={{
-                  width: '120px',
-                  marginTop: '1px',
-                  '& .MuiSelect-select': {
-                    fontSize: '14px',
-                    height: '28px',
-                    lineHeight: '28px',
-                  },
-                  '& .MuiInputLabel-root': {
-                    fontSize: '14px',
-                  },
-                  '& .MuiMenuItem-root': {
-                    padding: '2px 14px',
-                    fontSize: '14px',
-                  },
-                  '& .MuiCheckbox-root': {
-                    padding: '4px',
-                  },
-                  '& .MuiListItemText-root': {
-                    margin: '0',
-                  },
-                }}
-              />
-              <CustomButton
-                className="btn"
-                onClick={handleFilterReset}
-                disabled={!collection.loaded}
-                startIcon={<ResetIcon classes={{ root: 'icon' }} />}
-              >
-                {btnTrans('reset')}
-              </CustomButton>
-              <CustomButton
-                className="btn"
-                variant="contained"
-                onClick={() => {
-                  setCurrentPage(0);
-                  // set expr
-                  setQueryState({
-                    ...queryState,
-                    expr: exprInput,
-                    tick: queryState.tick + 1,
-                  });
-                }}
-                disabled={!collection.loaded}
-              >
-                {btnTrans('query')}
-              </CustomButton>
-            </div>
-          </Toolbar>
+          <QueryToolbar
+            collection={collection}
+            queryState={queryState}
+            setQueryState={setQueryState}
+            exprInputRef={exprInputRef}
+            handleExprChange={handleExprChange}
+            handleExprKeyDown={handleExprKeyDown}
+            handleFilterSubmit={handleFilterSubmit}
+            handleFilterReset={handleFilterReset}
+            setCurrentPage={setCurrentPage}
+          />
           <AttuGrid
             toolbarConfigs={[]}
             colDefinitions={queryState.outputFields.map(i => {

+ 313 - 0
client/src/pages/databases/collections/data/DataActionToolbar.tsx

@@ -0,0 +1,313 @@
+import { useCallback, useMemo, useContext } from 'react';
+import { useTranslation } from 'react-i18next';
+import { rootContext } from '@/context';
+import { DataService } from '@/http';
+import { ToolBarConfig } from '@/components/grid/Types';
+import DeleteTemplate from '@/components/customDialog/DeleteDialogTemplate';
+import InsertDialog from '@/pages/dialogs/insert/Dialog';
+import EditJSONDialog from '@/pages/dialogs/EditJSONDialog';
+import EmptyDataDialog from '@/pages/dialogs/EmptyDataDialog';
+import ImportSampleDialog from '@/pages/dialogs/ImportSampleDialog';
+import DataListView from '@/components/DataListView/DataListView';
+import { saveCsvAs } from '@/utils';
+import { DataTypeStringEnum, ConsistencyLevelEnum } from '@/consts';
+import type { QueryState } from '../../types';
+import { CollectionFullObject } from '@server/types';
+
+interface DataActionToolbarProps {
+  collection: CollectionFullObject;
+  selectedData: any[];
+  total: number;
+  queryState: QueryState;
+  setQueryState: (state: QueryState) => void;
+  onDelete: () => Promise<void>;
+  onInsert: (collectionName: string) => Promise<void>;
+  getEditData: (data: any, collection: CollectionFullObject) => any;
+  setSelectedData: (data: any[]) => void;
+}
+
+const DataActionToolbar = (props: DataActionToolbarProps) => {
+  const {
+    collection,
+    selectedData,
+    total,
+    queryState,
+    setQueryState,
+    onDelete,
+    onInsert,
+    getEditData,
+    setSelectedData,
+  } = props;
+
+  // UI functions
+  const { setDialog, handleCloseDialog, openSnackBar, setDrawer } =
+    useContext(rootContext);
+
+  // translations
+  const { t: dialogTrans } = useTranslation('dialog');
+  const { t: successTrans } = useTranslation('success');
+  const { t: searchTrans } = useTranslation('search');
+  const { t: collectionTrans } = useTranslation('collection');
+  const { t: btnTrans } = useTranslation('btn');
+
+  const handleEditConfirm = useCallback(
+    async (data: Record<string, any>) => {
+      const result = (await DataService.upsert(collection.collection_name, {
+        fields_data: [data],
+      })) as any;
+
+      const idField = result.IDs.id_field;
+      const id = result.IDs[idField].data;
+      // deselect all
+      setSelectedData([]);
+      const newExpr = `${collection.schema.primaryField.name} == ${id}`;
+      // set expr with id
+      setQueryState({
+        ...queryState,
+        consistencyLevel: ConsistencyLevelEnum.Strong,
+        expr: newExpr,
+        tick: queryState.tick + 1,
+      });
+    },
+    [
+      collection.collection_name,
+      collection.schema.primaryField.name,
+      queryState,
+      setQueryState,
+      setSelectedData,
+    ]
+  );
+
+  // Memoize toolbar configs
+  return useMemo<ToolBarConfig[]>(
+    () => [
+      {
+        icon: 'uploadFile',
+        type: 'button',
+        btnVariant: 'text',
+        btnColor: 'secondary',
+        label: btnTrans('importFile'),
+        tooltip: btnTrans('importFileTooltip'),
+        disabled: () => selectedData?.length > 0,
+        onClick: () => {
+          setDialog({
+            open: true,
+            type: 'custom',
+            params: {
+              component: (
+                <InsertDialog
+                  defaultSelectedCollection={collection.collection_name}
+                  defaultSelectedPartition={''}
+                  collections={[collection!]}
+                  onInsert={onInsert}
+                />
+              ),
+            },
+          });
+        },
+      },
+      {
+        type: 'button',
+        btnVariant: 'text',
+        onClick: () => {
+          setDialog({
+            open: true,
+            type: 'custom',
+            params: {
+              component: (
+                <ImportSampleDialog
+                  collection={collection!}
+                  cb={async () => {
+                    await onInsert(collection.collection_name);
+                    await onDelete();
+                  }}
+                />
+              ),
+            },
+          });
+        },
+        tooltip: btnTrans('importSampleDataTooltip'),
+        label: btnTrans('importSampleData'),
+        icon: 'add',
+        disabled: () => selectedData?.length > 0,
+      },
+      {
+        icon: 'deleteOutline',
+        type: 'button',
+        btnVariant: 'text',
+        onClick: () => {
+          setDialog({
+            open: true,
+            type: 'custom',
+            params: {
+              component: (
+                <EmptyDataDialog
+                  cb={async () => {
+                    await onDelete();
+                  }}
+                  collection={collection!}
+                />
+              ),
+            },
+          });
+        },
+        label: btnTrans('empty'),
+        tooltip: btnTrans('emptyTooltip'),
+        disabled: () => selectedData?.length > 0 || total == 0,
+      },
+      {
+        icon: 'eye',
+        type: 'button',
+        btnVariant: 'text',
+        onClick: () => {
+          setDrawer({
+            open: true,
+            title: 'custom',
+            content: (
+              <DataListView collection={collection} data={selectedData} />
+            ),
+            hasActionBar: true,
+            actions: [],
+          });
+        },
+        label: btnTrans('viewData'),
+        tooltip: btnTrans('viewDataTooltip'),
+        disabled: () => selectedData?.length !== 1,
+        hideOnDisable() {
+          return selectedData?.length === 0;
+        },
+      },
+      {
+        type: 'button',
+        btnVariant: 'text',
+        onClick: () => {
+          setDialog({
+            open: true,
+            type: 'custom',
+            params: {
+              component: (
+                <EditJSONDialog
+                  data={getEditData(selectedData[0], collection)}
+                  dialogTitle={dialogTrans('editEntityTitle')}
+                  dialogTip={dialogTrans('editEntityInfo')}
+                  handleConfirm={handleEditConfirm}
+                  handleCloseDialog={handleCloseDialog}
+                />
+              ),
+            },
+          });
+        },
+        label: btnTrans('edit'),
+        icon: 'edit',
+        tooltip: btnTrans('editEntityTooltip'),
+        disabledTooltip: btnTrans('editEntityDisabledTooltip'),
+        disabled: () => selectedData?.length !== 1,
+        hideOnDisable() {
+          return selectedData?.length === 0;
+        },
+      },
+      {
+        type: 'button',
+        btnVariant: 'text',
+        onClick: () => {
+          saveCsvAs(selectedData, `${collection.collection_name}.query.csv`);
+        },
+        label: btnTrans('export'),
+        icon: 'download',
+        tooltip: btnTrans('exportTooltip'),
+        disabledTooltip: btnTrans('downloadDisabledTooltip'),
+        disabled: () => !selectedData?.length,
+      },
+      {
+        type: 'button',
+        btnVariant: 'text',
+        onClick: async () => {
+          let json = JSON.stringify(selectedData);
+          try {
+            await navigator.clipboard.writeText(json);
+            alert(`${selectedData.length} rows copied to clipboard`);
+          } catch (err) {
+            console.error('Failed to copy text: ', err);
+          }
+        },
+        label: btnTrans('copyJson'),
+        icon: 'copy',
+        tooltip: btnTrans('copyJsonTooltip'),
+        disabledTooltip: btnTrans('downloadDisabledTooltip'),
+        disabled: () => !selectedData?.length,
+      },
+      {
+        type: 'button',
+        btnVariant: 'text',
+        onClick: () => {
+          setDialog({
+            open: true,
+            type: 'custom',
+            params: {
+              component: (
+                <DeleteTemplate
+                  label={btnTrans('delete')}
+                  title={dialogTrans('deleteEntityTitle')}
+                  text={collectionTrans('deleteDataWarning')}
+                  handleDelete={async () => {
+                    // call delete api
+                    await DataService.deleteEntities(
+                      collection.collection_name,
+                      {
+                        expr: `${
+                          collection!.schema.primaryField.name
+                        } in [${selectedData
+                          .map(v =>
+                            collection!.schema.primaryField.data_type ===
+                            DataTypeStringEnum.VarChar
+                              ? `"${v[collection!.schema.primaryField.name]}"`
+                              : v[collection!.schema.primaryField.name]
+                          )
+                          .join(',')}]`,
+                      }
+                    );
+                    handleCloseDialog();
+                    openSnackBar(
+                      successTrans('delete', {
+                        name: collectionTrans('entities'),
+                      })
+                    );
+                    setSelectedData([]);
+                    await onDelete();
+                  }}
+                />
+              ),
+            },
+          });
+        },
+        label: btnTrans('delete'),
+        icon: 'cross2',
+        tooltip: btnTrans('deleteTooltip'),
+        disabledTooltip: collectionTrans('deleteDisabledTooltip'),
+        disabled: () => !selectedData?.length,
+      },
+    ],
+    [
+      collection,
+      selectedData,
+      total,
+      queryState,
+      setQueryState,
+      setDialog,
+      setDrawer,
+      handleCloseDialog,
+      openSnackBar,
+      onInsert,
+      onDelete,
+      getEditData,
+      setSelectedData,
+      btnTrans,
+      dialogTrans,
+      successTrans,
+      searchTrans,
+      collectionTrans,
+    ]
+  );
+};
+
+export default DataActionToolbar;

+ 89 - 0
client/src/pages/databases/collections/data/OptimizedInput.tsx

@@ -0,0 +1,89 @@
+import { useCallback, useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import CustomInput from '@/components/customInput/CustomInput';
+import Filter from '@/components/advancedSearch';
+import type { FieldObject } from '@server/types';
+
+interface OptimizedInputProps {
+  value: string;
+  onChange: (value: string) => void;
+  onKeyDown: (e: any) => void;
+  disabled?: boolean;
+  fields: FieldObject[];
+  onSubmit: (expression: string) => void;
+}
+
+const OptimizedInput = ({
+  value,
+  onChange,
+  onKeyDown,
+  disabled = false,
+  fields,
+  onSubmit,
+}: OptimizedInputProps) => {
+  const { t: collectionTrans } = useTranslation('collection');
+  const [localValue, setLocalValue] = useState(value);
+
+  useEffect(() => {
+    setLocalValue(value);
+  }, [value]);
+
+  const handleChange = useCallback(
+    (newValue: string) => {
+      setLocalValue(newValue);
+      onChange(newValue);
+    },
+    [onChange]
+  );
+
+  const handleKeyDown = useCallback(
+    (e: any) => {
+      onKeyDown(e);
+    },
+    [onKeyDown]
+  );
+
+  const handleFilterSubmit = useCallback(
+    (expression: string) => {
+      setLocalValue(expression);
+      onChange(expression);
+      onSubmit(expression);
+    },
+    [onChange, onSubmit]
+  );
+
+  return (
+    <CustomInput
+      type="text"
+      textConfig={{
+        label: localValue
+          ? collectionTrans('queryExpression')
+          : collectionTrans('exprPlaceHolder'),
+        key: 'advFilter',
+        className: 'textarea',
+        onChange: handleChange,
+        value: localValue,
+        disabled,
+        variant: 'filled',
+        required: false,
+        InputLabelProps: { shrink: true },
+        InputProps: {
+          endAdornment: (
+            <Filter
+              title={''}
+              showTitle={false}
+              fields={fields}
+              filterDisabled={disabled}
+              onSubmit={handleFilterSubmit}
+              showTooltip={false}
+            />
+          ),
+        },
+        onKeyDown: handleKeyDown,
+      }}
+      checkValid={() => true}
+    />
+  );
+};
+
+export default OptimizedInput;

+ 177 - 0
client/src/pages/databases/collections/data/QueryToolbar.tsx

@@ -0,0 +1,177 @@
+import { useTranslation } from 'react-i18next';
+import icons from '@/components/icons/Icons';
+import CustomButton from '@/components/customButton/CustomButton';
+import { Toolbar } from '../../StyledComponents';
+import { DYNAMIC_FIELD, CONSISTENCY_LEVEL_OPTIONS } from '@/consts';
+import { Select, MenuItem, FormControl, InputLabel } from '@mui/material';
+import CustomMultiSelector from '@/components/customSelector/CustomMultiSelector';
+import OptimizedInput from './OptimizedInput';
+import type { QueryState } from '../../types';
+import { CollectionFullObject } from '@server/types';
+
+interface QueryToolbarProps {
+  collection: CollectionFullObject;
+  queryState: QueryState;
+  setQueryState: (state: QueryState) => void;
+  exprInputRef: React.MutableRefObject<string>;
+  handleExprChange: (value: string) => void;
+  handleExprKeyDown: (e: any) => void;
+  handleFilterSubmit: (expression: string) => Promise<void>;
+  handleFilterReset: () => Promise<void>;
+  setCurrentPage: (page: number) => void;
+}
+
+const QueryToolbar = (props: QueryToolbarProps) => {
+  const {
+    collection,
+    queryState,
+    setQueryState,
+    exprInputRef,
+    handleExprChange,
+    handleExprKeyDown,
+    handleFilterSubmit,
+    handleFilterReset,
+    setCurrentPage,
+  } = props;
+
+  // translations
+  const { t: searchTrans } = useTranslation('search');
+  const { t: collectionTrans } = useTranslation('collection');
+  const { t: btnTrans } = useTranslation('btn');
+  const { t: commonTrans } = useTranslation();
+
+  // icons
+  const ResetIcon = icons.refresh;
+
+  return (
+    <Toolbar>
+      <div className="left">
+        <OptimizedInput
+          value={exprInputRef.current}
+          onChange={handleExprChange}
+          onKeyDown={handleExprKeyDown}
+          disabled={!collection.loaded}
+          fields={collection.schema.scalarFields}
+          onSubmit={handleFilterSubmit}
+        />
+
+        <FormControl
+          variant="filled"
+          className="selector"
+          disabled={!collection.loaded}
+          sx={{ minWidth: 120 }}
+        >
+          <InputLabel>{collectionTrans('consistency')}</InputLabel>
+          <Select
+            value={queryState.consistencyLevel}
+            label={collectionTrans('consistency')}
+            onChange={e => {
+              const consistency = e.target.value as string;
+              setQueryState({
+                ...queryState,
+                consistencyLevel: consistency,
+              });
+            }}
+          >
+            {CONSISTENCY_LEVEL_OPTIONS.map(option => (
+              <MenuItem key={option.value} value={option.value}>
+                {option.label}
+              </MenuItem>
+            ))}
+          </Select>
+        </FormControl>
+      </div>
+
+      <div className="right">
+        <CustomMultiSelector
+          options={queryState.fields.map(field => ({
+            label:
+              field.name === DYNAMIC_FIELD
+                ? searchTrans('dynamicFields')
+                : field.name,
+            value: field.name,
+          }))}
+          values={queryState.outputFields}
+          label={searchTrans('outputFields')}
+          variant="filled"
+          wrapperClass="outputs selector"
+          onChange={(e: { target: { value: unknown } }) => {
+            const values = e.target.value as string[];
+            // prevent deselecting the last option
+            if (values.length === 0) {
+              return;
+            }
+            // sort output fields by schema order
+            const newOutputFields = [...values].sort(
+              (a, b) =>
+                queryState.fields.findIndex(f => f.name === a) -
+                queryState.fields.findIndex(f => f.name === b)
+            );
+
+            setQueryState({
+              ...queryState,
+              outputFields: newOutputFields,
+            });
+          }}
+          renderValue={(selected: unknown) => {
+            const selectedArray = selected as string[];
+            return (
+              <span>{`${selectedArray.length} ${commonTrans(
+                selectedArray.length > 1 ? 'grid.fields' : 'grid.field'
+              )}`}</span>
+            );
+          }}
+          disabled={!collection.loaded}
+          sx={{
+            width: '120px',
+            marginTop: '1px',
+            '& .MuiSelect-select': {
+              fontSize: '14px',
+              height: '28px',
+              lineHeight: '28px',
+            },
+            '& .MuiInputLabel-root': {
+              fontSize: '14px',
+            },
+            '& .MuiMenuItem-root': {
+              padding: '2px 14px',
+              fontSize: '14px',
+            },
+            '& .MuiCheckbox-root': {
+              padding: '4px',
+            },
+            '& .MuiListItemText-root': {
+              margin: '0',
+            },
+          }}
+        />
+        <CustomButton
+          className="btn"
+          onClick={handleFilterReset}
+          disabled={!collection.loaded}
+          startIcon={<ResetIcon classes={{ root: 'icon' }} />}
+        >
+          {btnTrans('reset')}
+        </CustomButton>
+        <CustomButton
+          className="btn"
+          variant="contained"
+          onClick={() => {
+            setCurrentPage(0);
+            // set expr
+            setQueryState({
+              ...queryState,
+              expr: exprInputRef.current,
+              tick: queryState.tick + 1,
+            });
+          }}
+          disabled={!collection.loaded}
+        >
+          {btnTrans('query')}
+        </CustomButton>
+      </div>
+    </Toolbar>
+  );
+};
+
+export default QueryToolbar;

+ 2 - 2
client/src/pages/search/Utils.tsx

@@ -7,7 +7,7 @@ export const getLabelDisplayedRows =
     const { t: commonTrans } = useTranslation();
 
     return (
-      <div
+      <span
         style={{
           display: 'flex',
           alignItems: 'center',
@@ -34,6 +34,6 @@ export const getLabelDisplayedRows =
             {info}
           </span>
         )}
-      </div>
+      </span>
     );
   };