diff --git a/frontend/src/metadata/metadata-view/components/context-menu/context-menu.css b/frontend/src/metadata/metadata-view/components/context-menu/context-menu.css
new file mode 100644
index 0000000000..2c33bf5a80
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/context-menu/context-menu.css
@@ -0,0 +1,5 @@
+.sf-metadata-contextmenu {
+ display: block;
+ opacity: 1;
+ box-shadow: 0 0 5px #ccc;
+}
diff --git a/frontend/src/metadata/metadata-view/components/context-menu/context-menu.jsx b/frontend/src/metadata/metadata-view/components/context-menu/context-menu.jsx
new file mode 100644
index 0000000000..dd6e763be0
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/context-menu/context-menu.jsx
@@ -0,0 +1,118 @@
+import React, { useState, useRef, useEffect, useCallback } from 'react';
+import PropTypes from 'prop-types';
+import './context-menu.css';
+
+const ContextMenu = ({ options, onOptionClick }) => {
+ const menuRef = useRef(null);
+ const [visible, setVisible] = useState(false);
+ const [position, setPosition] = useState({ top: 0, left: 0 });
+
+ const handleHide = useCallback((event) => {
+ if (menuRef.current && !menuRef.current.contains(event.target)) {
+ setVisible(false);
+ }
+ }, [menuRef]);
+
+ const handleOptionClick = (event, option) => {
+ onOptionClick(event, option);
+ setVisible(false);
+ };
+
+ const getMenuPosition = (x = 0, y = 0) => {
+ let menuStyles = {
+ top: y,
+ left: x
+ };
+ if (!menuRef.current) return menuStyles;
+
+ const { innerWidth, innerHeight } = window;
+ const rect = menuRef.current.getBoundingClientRect();
+
+ // Calculate the offset of the parent components
+ const parentRect = menuRef.current.parentElement.getBoundingClientRect();
+ const offsetX = parentRect.left;
+ const offsetY = parentRect.top;
+
+ // Adjust the position based on the offset
+ menuStyles.top = y - offsetY;
+ menuStyles.left = x - offsetX;
+
+ const metadataResultFooterHeight = 32;
+ const contentHeight = innerHeight - metadataResultFooterHeight;
+ if (y + rect.height > contentHeight) {
+ menuStyles.top -= rect.height;
+ }
+ if (x + rect.width > innerWidth) {
+ menuStyles.left -= rect.width;
+ }
+ if (menuStyles.top < 0) {
+ menuStyles.top = rect.height < contentHeight ? (contentHeight - rect.height) / 2 : 0;
+ }
+ if (menuStyles.left < 0) {
+ menuStyles.left = rect.width < innerWidth ? (innerWidth - rect.width) / 2 : 0;
+ }
+ return menuStyles;
+ };
+
+ useEffect(() => {
+ const handleShow = (event) => {
+ event.preventDefault();
+ if (menuRef.current && menuRef.current.contains(event.target)) return;
+
+ setVisible(true);
+
+ const position = getMenuPosition(event.clientX, event.clientY);
+ setPosition(position);
+ };
+
+ document.addEventListener('contextmenu', handleShow);
+
+ return () => {
+ document.removeEventListener('contextmenu', handleShow);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (visible) {
+ document.addEventListener('mousedown', handleHide);
+ } else {
+ document.removeEventListener('mousedown', handleHide);
+ }
+
+ return () => {
+ document.removeEventListener('mousedown', handleHide);
+ };
+ }, [visible, handleHide]);
+
+ if (!visible) return null;
+
+ return (
+
+ {options.map((option, index) => (
+
+ ))}
+
+ );
+};
+
+ContextMenu.propTypes = {
+ options: PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ })
+ ).isRequired,
+ onOptionClick: PropTypes.func.isRequired,
+};
+
+export default ContextMenu;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/body.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/body.js
index 42da0b93d9..df3f96c9c9 100644
--- a/frontend/src/metadata/metadata-view/components/table/table-main/records/body.js
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/body.js
@@ -28,7 +28,6 @@ class RecordsBody extends Component {
this.state = {
startRenderIndex: 0,
endRenderIndex: this.getInitEndIndex(props),
- isContextMenuShow: false,
activeRecords: [],
menuPosition: null,
selectedPosition: null,
@@ -47,7 +46,6 @@ class RecordsBody extends Component {
componentDidMount() {
this.props.onRef(this);
window.sfMetadataBody = this;
- document.addEventListener('contextmenu', this.handleContextMenu);
}
UNSAFE_componentWillReceiveProps(nextProps) {
@@ -58,8 +56,6 @@ class RecordsBody extends Component {
}
componentWillUnmount() {
- document.removeEventListener('contextmenu', this.handleContextMenu);
-
this.clearHorizontalScroll();
this.clearScrollbarTimer();
this.setState = (state, callback) => {
@@ -335,6 +331,10 @@ class RecordsBody extends Component {
this.props.onCellRangeSelectionUpdated(selectedRange);
};
+ onCellContextMenu = (event, cell) => {
+ this.props.onCellContextMenu(event, cell);
+ };
+
/**
* When updating the selection by moving the mouse, you need to automatically scroll to expand the visible area
* @param {object} selectedRange
@@ -414,6 +414,7 @@ class RecordsBody extends Component {
onCellMouseMove: this.onCellMouseMove,
onDragEnter: this.handleDragEnter,
modifyRecord: this.props.modifyRecord,
+ onCellContextMenu: this.onCellContextMenu
};
return this.cellMetaData;
};
@@ -504,7 +505,6 @@ class RecordsBody extends Component {
};
render() {
- // const { isContextMenuShow, menuPosition, activeRecords } = this.state;
return (
@@ -605,6 +605,7 @@ RecordsBody.propTypes = {
getCopiedRecordsAndColumnsFromRange: PropTypes.func,
openDownloadFilesDialog: PropTypes.func,
cacheDownloadFilesProps: PropTypes.func,
+ onCellContextMenu: PropTypes.func,
};
export default RecordsBody;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-body/index.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-body/index.js
index 5448053cf1..2386f7587c 100644
--- a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-body/index.js
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-body/index.js
@@ -305,6 +305,7 @@ class GroupBody extends Component {
onCellMouseMove: this.onCellMouseMove,
onDragEnter: this.handleDragEnter,
modifyRecord: this.props.modifyRecord,
+ onCellContextMenu: this.onCellContextMenu,
};
return this.cellMetaData;
};
@@ -445,6 +446,10 @@ class GroupBody extends Component {
this.selectUpdate(cellPosition, false, this.updateViewableArea);
};
+ onCellContextMenu = (event, cell) => {
+ this.props.onCellContextMenu(event, cell);
+ };
+
onWindowMouseUp = (event) => {
window.removeEventListener('mouseup', this.onWindowMouseUp);
if (isShiftKeyDown(event)) return;
@@ -966,6 +971,7 @@ GroupBody.propTypes = {
getCopiedRecordsAndColumnsFromRange: PropTypes.func,
openDownloadFilesDialog: PropTypes.func,
cacheDownloadFilesProps: PropTypes.func,
+ onCellContextMenu: PropTypes.func,
};
export default GroupBody;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/index.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/index.js
index a1edf1b074..6d66a1bfd2 100644
--- a/frontend/src/metadata/metadata-view/components/table/table-main/records/index.js
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/index.js
@@ -15,6 +15,10 @@ import RecordMetrics from '../../../../utils/record-metrics';
import { isShiftKeyDown } from '../../../../utils/keyboard-utils';
import { getVisibleBoundaries } from '../../../../utils/viewport';
import { getColOverScanEndIdx, getColOverScanStartIdx } from '../../../../utils/grid';
+import TextTranslation from '../../../../../../utils/text-translation';
+import { Utils } from '../../../../../../utils/utils';
+import { siteRoot } from '../../../../../../utils/constants';
+import ContextMenu from '../../../context-menu/context-menu';
class Records extends Component {
@@ -38,10 +42,16 @@ class Records extends Component {
topLeft: this.initPosition,
bottomRight: this.initPosition,
},
+ selectedPosition: this.initPosition,
...initHorizontalScrollState,
};
this.isWindows = isWindowsBrowser();
this.isWebkit = isWebkitBrowser();
+ this.baseURI = '';
+ this.contextMenuOptions = [
+ { label: TextTranslation.OPEN_FILE_IN_NEW_TAB.value, value: 'openFileInNewTab' },
+ { label: TextTranslation.OPEN_PARENT_FOLDER.value, value: 'openParentFolder' },
+ ];
}
componentDidMount() {
@@ -587,6 +597,56 @@ class Records extends Component {
this.setState(scrollState);
};
+ onOpenFileInNewTab = () => {
+ const { isGroupView, recordGetterByIndex } = this.props;
+ const { groupRecordIndex, rowIdx } = this.state.selectedPosition;
+ const record = recordGetterByIndex({ isGroupView, groupRecordIndex, recordIndex: rowIdx });
+ const repoID = window.sfMetadataStore.repoId;
+ let url;
+ if (record._is_dir) {
+ url = `${this.baseURI}${record._parent_dir === '/' ? '' : record._parent_dir}/${record._name}`;
+ } else {
+ url = `${siteRoot}lib/${repoID}/file${Utils.encodePath(record._parent_dir + '/' + record._name)}`;
+ }
+
+ window.open(url, '_blank');
+ };
+
+ onOpenParentFolder = (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const { isGroupView, recordGetterByIndex } = this.props;
+ const { groupRecordIndex, rowIdx } = this.state.selectedPosition;
+ const record = recordGetterByIndex({ isGroupView, groupRecordIndex, recordIndex: rowIdx });
+ const url = `${this.baseURI}${record._parent_dir}`;
+
+ window.open(url, '_blank');
+ };
+
+ onOptionClick = (event, option) => {
+
+ const handlers = {
+ openFileInNewTab: this.onOpenFileInNewTab.bind(this),
+ openParentFolder: this.onOpenParentFolder.bind(this),
+ };
+
+ const handler = handlers[option.value];
+ if (handler) {
+ handler(event);
+ }
+ };
+
+ onCellContextMenu = (event, cell) => {
+ const url = new URL(event.target.baseURI);
+ url.search = '';
+ this.baseURI = url.toString();
+
+ this.setState({
+ selectedPosition: cell,
+ });
+ };
+
renderRecordsBody = ({ containerWidth }) => {
const { recordMetrics, columnMetrics, colOverScanStartIdx, colOverScanEndIdx } = this.state;
const {
@@ -606,6 +666,7 @@ class Records extends Component {
setRecordsScrollLeft: this.setScrollLeft,
hasSelectedCell: this.hasSelectedCell,
cacheScrollTop: this.storeScrollTop,
+ onCellContextMenu: this.onCellContextMenu,
};
if (this.props.isGroupView) {
return (
@@ -667,6 +728,10 @@ class Records extends Component {
/>
{this.renderRecordsBody({ containerWidth })}
+
{this.isWindows && this.isWebkit && (
{
+ event.preventDefault();
+
+ const cell = { idx: column.idx, groupRecordIndex, rowIdx: recordIndex };
+ if (!isFunction(cellMetaData.onCellContextMenu)) return;
+ cellMetaData.onCellContextMenu(event, cell,);
+ cellMetaData.onCellClick(cell, event);
+ }, [cellMetaData, column, groupRecordIndex, recordIndex]);
+
const getEvents = useCallback(() => {
return {
onClick: onCellClick,
@@ -110,9 +119,10 @@ const Cell = React.memo(({
onMouseEnter: onCellMouseEnter,
onMouseMove: onCellMouseMove,
onMouseLeave: onCellMouseLeave,
- onDragOver: onDragOver
+ onDragOver: onDragOver,
+ onContextMenu: onCellContextMenu,
};
- }, [onCellClick, onCellDoubleClick, onCellMouseDown, onCellMouseEnter, onCellMouseMove, onCellMouseLeave, onDragOver]);
+ }, [onCellClick, onCellDoubleClick, onCellMouseDown, onCellMouseEnter, onCellMouseMove, onCellMouseLeave, onDragOver, onCellContextMenu]);
const getOldRowData = useCallback((originalOldCellValue) => {
const { key: columnKey, name: columnName } = column;
diff --git a/frontend/src/utils/text-translation.js b/frontend/src/utils/text-translation.js
index 5241f610db..62c01f9471 100644
--- a/frontend/src/utils/text-translation.js
+++ b/frontend/src/utils/text-translation.js
@@ -19,6 +19,8 @@ const TextTranslation = {
'PERMISSION': { key: 'Permission', value: gettext('Permission') },
'DETAILS': { key: 'Details', value: gettext('Details') },
'OPEN_VIA_CLIENT': { key: 'Open via Client', value: gettext('Open via Client') },
+ 'OPEN_FILE_IN_NEW_TAB': { key: 'Open file in new tab', value: gettext('Open file in new tab') },
+ 'OPEN_PARENT_FOLDER': { key: 'Open parent folder', value: gettext('Open parent folder') },
'LOCK': { key: 'Lock', value: gettext('Lock') },
'UNLOCK': { key: 'Unlock', value: gettext('Unlock') },
'FREEZE_DOCUMENT': { key: 'Freeze Document', value: gettext('Freeze Document') },