Browse Source

Merge pull request #599 from zilliztech/attu-583

init edit entity dialog
ryjiang 9 months ago
parent
commit
163026fa70

+ 7 - 0
client/src/http/Data.service.ts

@@ -18,6 +18,13 @@ export class DataService extends BaseModel {
     });
   }
 
+  static upsert(collectionName: string, param: InsertDataParam) {
+    return super.create({
+      path: `/collections/${collectionName}/upsert`,
+      data: param,
+    });
+  }
+
   static deleteEntities(collectionName: string, param: DeleteEntitiesReq) {
     return super.update({
       path: `/collections/${collectionName}/entities`,

+ 4 - 1
client/src/i18n/cn/button.ts

@@ -49,10 +49,13 @@ const btnTrans = {
   deleteColTooltip: '删除所选的collection',
   duplicateTooltip: '复制collection,不包含数据',
   renameTooltip: '重命名collection',
+  editEntityTooltip: '编辑entity',
 
   // disable tooltip
-  downloadDisabledTooltip: '导出前请先选择数据',
+  downloadDisabledTooltip: '导出前请先选择数据',
   deleteDisableTooltip: '请至少选择一个要删除的项目。',
+  editEntityDisabledTooltip: '一次只能编辑一个entity。',
+  editEntityDisabledTooltipAutoId: 'auto-id数据无法编辑。',
 };
 
 export default btnTrans;

+ 3 - 1
client/src/i18n/cn/dialog.ts

@@ -10,6 +10,7 @@ const dialogTrans = {
   compact: `压缩Collection {{type}}`,
   flush: `为 {{type}} 的数据落盘`,
   loadTitle: `加载 {{type}}`,
+  editEntityTitle: `编辑 Entity`,
 
   loadContent: `您正在尝试加载带有数据的 {{type}}。只有已加载的 {{type}} 可以被搜索。`,
   releaseContent: `您正在尝试发布带有数据的 {{type}}。请注意,数据将不再可用于搜索。`,
@@ -24,7 +25,8 @@ const dialogTrans = {
     '复制Collection不会复制Collection中的数据。它只会使用现有的Schema创建一个新的Collection。',
   flushDialogInfo: `落盘是一个在数据被插入到Milvus后,封闭和索引任何剩余段的过程。这避免了在未封闭的段上进行暴力搜索。  <br /><br />最好在插入会话结束时使用落盘,以防止数据碎片化。 <br /><br /><strong>注意:对于大型数据集,此操作可能需要一些时间。</strong>`,
   emptyDataDialogInfo: `您正在尝试清空数据。此操作无法撤销,请谨慎操作。`,
-  resetPropertyInfo: '您确定要重置属性吗?'
+  resetPropertyInfo: '您确定要重置属性吗?',
+  editEntityInfo: `注意:编辑id字段将创建一个新的实体。`,
 };
 
 export default dialogTrans;

+ 3 - 0
client/src/i18n/en/button.ts

@@ -49,10 +49,13 @@ const btnTrans = {
   deleteColTooltip: 'Drop selected collection',
   duplicateTooltip: 'Duplicate selected collection without data',
   renameTooltip: 'Rename collection',
+  editEntityTooltip: 'Edit entity(JSON)',
 
   // disable tooltip
   downloadDisabledTooltip: 'Please select data before exporting',
   deleteDisableTooltip: 'Please select at least one item to delete.',
+  editEntityDisabledTooltip: 'Only one entity can be edited at a time.',
+  editEntityDisabledTooltipAutoId: 'The auto-generated ID entity cannot be edited.',
 };
 
 export default btnTrans;

+ 3 - 1
client/src/i18n/en/dialog.ts

@@ -10,6 +10,7 @@ const dialogTrans = {
   compact: `Compact collection {{type}}`,
   flush: `Flush data for {{type}}`,
   loadTitle: `Load {{type}}`,
+  editEntityTitle: `Edit Entity(JSON)`,
 
   loadContent: `You are trying to load a {{type}} with data. Only loaded {{type}} can be searched.`,
   releaseContent: `You are trying to release {{type}} with data. Please be aware that the data will no longer be available for search.`,
@@ -24,7 +25,8 @@ const dialogTrans = {
     'Duplicating a collection does not copy the data within the collection. It only creates a new collection using the existing schema.',
   flushDialogInfo: `Flush is a process that seals and indexes any remaining segments after data is upserted into Milvus. This avoids brute force searches on unsealed segments.  <br /><br />It's best to use flush at the end of an upsert session to prevent data fragmentation. <br /><br /><strong>Note: that this operation may take some time for large datasets.</strong>`,
   emptyDataDialogInfo: `You are attempting to empty the data. This action cannot be undone, please proceed with caution.`,
-  resetPropertyInfo: `Are you sure you want to reset the property?`
+  resetPropertyInfo: `Are you sure you want to reset the property?`,
+  editEntityInfo: `NOTE: Edit id field will create a new entity.`
 };
 
 export default dialogTrans;

+ 1 - 1
client/src/pages/databases/collections/Types.ts

@@ -70,7 +70,7 @@ export interface CreateFieldsProps {
 }
 
 export interface InsertDataParam {
-  partition_name: string;
+  partition_name?: string;
   // e.g. [{vector: [1,2,3], age: 10}]
   fields_data: any[];
 }

+ 37 - 1
client/src/pages/databases/collections/data/CollectionData.tsx

@@ -13,6 +13,7 @@ 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 EditEntityDialog from '@/pages/dialogs/EditEntityDialog';
 import { getLabelDisplayedRows } from '@/pages/search/Utils';
 import { getQueryStyles } from './Styles';
 import {
@@ -168,6 +169,10 @@ const CollectionData = (props: CollectionDataProps) => {
     await fetchCollection(collectionName);
   };
 
+  const onEditEntity = async () => {
+    await query(currentPage, ConsistencyLevelEnum.Strong);
+  };
+
   // Toolbar settings
   const toolbarConfigs: ToolBarConfig[] = [
     {
@@ -249,7 +254,38 @@ const CollectionData = (props: CollectionDataProps) => {
       },
       label: btnTrans('empty'),
       tooltip: btnTrans('emptyTooltip'),
-      disabled: () => selectedData?.length > 0 ||  total == 0,
+      disabled: () => selectedData?.length > 0 || total == 0,
+    },
+    {
+      type: 'button',
+      btnVariant: 'text',
+      onClick: () => {
+        setDialog({
+          open: true,
+          type: 'custom',
+          params: {
+            component: (
+              <EditEntityDialog
+                data={selectedData[0]}
+                collection={collection!}
+                cb={onEditEntity}
+              />
+            ),
+          },
+        });
+      },
+      label: btnTrans('edit'),
+      icon: 'edit',
+      tooltip: btnTrans('editEntityTooltip'),
+      disabledTooltip: btnTrans(
+        collection.autoID
+          ? 'editEntityDisabledTooltipAutoId'
+          : 'editEntityDisabledTooltip'
+      ),
+      disabled: () => collection.autoID || selectedData?.length !== 1,
+      hideOnDisable() {
+        return selectedData?.length === 0;
+      },
     },
     {
       type: 'button',

+ 8 - 7
client/src/pages/databases/collections/search/VectorInputBox.tsx

@@ -238,17 +238,18 @@ export default function VectorInputBox(props: VectorInputBoxProps) {
         ],
       });
 
-      editor.current = new EditorView({
+      const view = new EditorView({
         state: startState,
         parent: editorEl.current!,
       });
-    }
-    return () => {
-      if (editor.current) {
-        editor.current.destroy();
+
+      editor.current = view;
+
+      return () => {
+        view.destroy();
         editor.current = undefined;
-      }
-    };
+      };
+    }
   }, [JSON.stringify(field)]);
 
   return (

+ 164 - 0
client/src/pages/dialogs/EditEntityDialog.tsx

@@ -0,0 +1,164 @@
+import { FC, useContext, useEffect, useRef, useState } from 'react';
+import { makeStyles, Theme } from '@material-ui/core';
+import { EditorState } from '@codemirror/state';
+import { EditorView, keymap, ViewUpdate } from '@codemirror/view';
+import { insertTab } from '@codemirror/commands';
+import { indentUnit } from '@codemirror/language';
+import { basicSetup } from 'codemirror';
+import { json, jsonParseLinter } from '@codemirror/lang-json';
+import { linter } from '@codemirror/lint';
+import { useTranslation } from 'react-i18next';
+import { rootContext } from '@/context';
+import DialogTemplate from '@/components/customDialog/DialogTemplate';
+import { CollectionFullObject } from '@server/types';
+import { DataService } from '@/http';
+import { DYNAMIC_FIELD } from '@/consts';
+
+const useStyles = makeStyles((theme: Theme) => ({
+  code: {
+    backgroundColor: '#f5f5f5',
+    padding: 4,
+    width: '100%',
+    height: '60vh',
+    overflow: 'auto',
+  },
+  tip: {
+    fontSize: 12,
+    marginBottom: 8,
+  },
+}));
+
+type EditEntityDialogProps = {
+  data: { [key: string]: any };
+  collection: CollectionFullObject;
+  cb?: () => void;
+};
+
+// json linter for cm
+const linterExtension = linter(jsonParseLinter());
+
+const EditEntityDialog: FC<EditEntityDialogProps> = props => {
+  // props
+  const { data, collection } = props;
+  // UI states
+  const [disabled, setDisabled] = useState(true);
+  // context
+  const { handleCloseDialog } = useContext(rootContext);
+  // translations
+  const { t: btnTrans } = useTranslation('btn');
+  const { t: dialogTrans } = useTranslation('dialog');
+  // refs
+  const editorEl = useRef<HTMLDivElement>(null);
+  const editor = useRef<EditorView>();
+  // styles
+  const classes = useStyles();
+
+  // 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];
+    }
+  });
+
+  // add dynamic fields if exist
+  const isDynamicSchema = collection.schema.dynamicFields.length > 0;
+  if (isDynamicSchema) {
+    sortedData = { ...sortedData, ...data[DYNAMIC_FIELD] };
+  }
+
+  const originalData = JSON.stringify(sortedData, null, 4);
+
+  // create editor
+  useEffect(() => {
+    if (!editor.current) {
+      const startState = EditorState.create({
+        doc: originalData,
+        extensions: [
+          basicSetup,
+          json(),
+          linterExtension,
+          keymap.of([{ key: 'Tab', run: insertTab }]), // fix tab behaviour
+          indentUnit.of('    '), // fix tab indentation
+          EditorView.theme({
+            '&.cm-editor': {
+              '&.cm-focused': {
+                outline: 'none',
+              },
+            },
+            '.cm-content': {
+              color: '#484D52',
+              fontSize: '12px',
+            },
+            '.cm-tooltip-lint': {
+              width: '80%',
+            },
+          }),
+          EditorView.lineWrapping,
+          EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
+            if (viewUpdate.docChanged) {
+              const d = jsonParseLinter()(view);
+              if (d.length !== 0) {
+                setDisabled(true);
+                return;
+              }
+
+              const doc = viewUpdate.state.doc;
+              const value = doc.toString();
+
+              setDisabled(value === originalData);
+            }
+          }),
+        ],
+      });
+
+      const view = new EditorView({
+        state: startState,
+        parent: editorEl.current!,
+      });
+
+      editor.current = view;
+
+      return () => {
+        view.destroy();
+        editor.current = undefined;
+      };
+    }
+  }, [JSON.stringify(data)]);
+
+  // handle confirm
+  const handleConfirm = async () => {
+    await DataService.upsert(collection.collection_name, {
+      fields_data: [JSON.parse(editor.current?.state.doc.toString()!)],
+    });
+
+    props.cb && props.cb();
+    handleCloseDialog();
+  };
+
+  return (
+    <DialogTemplate
+      title={dialogTrans('editEntityTitle')}
+      handleClose={handleCloseDialog}
+      children={
+        <>
+          <div className={classes.tip}>{dialogTrans('editEntityInfo')}</div>
+          <div
+            className={`${classes.code} cm-editor`}
+            ref={editorEl}
+            onClick={() => {
+              if (editor.current) editor.current.focus();
+            }}
+          ></div>
+        </>
+      }
+      confirmDisabled={disabled}
+      confirmLabel={btnTrans('edit')}
+      handleConfirm={handleConfirm}
+      showCancel={true}
+    />
+  );
+};
+
+export default EditEntityDialog;

+ 19 - 0
server/src/collections/collections.controller.ts

@@ -91,6 +91,11 @@ export class CollectionController {
       dtoValidationMiddleware(InsertDataDto),
       this.insert.bind(this)
     );
+    this.router.post(
+      '/:name/upsert',
+      dtoValidationMiddleware(InsertDataDto),
+      this.upsert.bind(this)
+    );
 
     // insert sample data
     this.router.post(
@@ -309,6 +314,20 @@ export class CollectionController {
     }
   }
 
+  async upsert(req: Request, res: Response, next: NextFunction) {
+    const name = req.params?.name;
+    const data = req.body;
+    try {
+      const result = await this.collectionsService.upsert(req.clientId, {
+        collection_name: name,
+        ...data,
+      });
+      res.send(result);
+    } catch (error) {
+      next(error);
+    }
+  }
+
   async importSample(req: Request, res: Response, next: NextFunction) {
     const data = req.body;
     try {

+ 8 - 0
server/src/collections/collections.service.ts

@@ -244,6 +244,14 @@ export class CollectionsService {
     return res;
   }
 
+  async upsert(clientId: string, data: InsertReq) {
+    const { milvusClient } = clientCache.get(clientId);
+    const res = await milvusClient.upsert(data);
+    throwErrorFromSDK(res.status);
+    return res;
+  }
+
+
   async deleteEntities(clientId: string, data: DeleteEntitiesReq) {
     const { milvusClient } = clientCache.get(clientId);
     const res = await milvusClient.deleteEntities(data);