1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-13 05:39:59 +00:00

add context menu to kanban (#7176)

* add context menu to kanban

* optimize code

* fix: download error, rename error, ...

* feat: optimize code

* feat: support download folder

* feat: optimize delete file callback

* feat: op permission

* feat: rename op

* fix: url //

---------

Co-authored-by: zhouwenxuan <aries@Mac.local>
Co-authored-by: 杨国璇 <ygx@Hello-word.local>
This commit is contained in:
Aries
2024-12-16 17:25:23 +08:00
committed by GitHub
parent 757a334112
commit 34cf5beb0d
16 changed files with 679 additions and 276 deletions

View File

@@ -0,0 +1,114 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import './index.css';
const ContextMenu = ({ options, boundaryCoordinates, onOptionClick, ignoredTriggerElements }) => {
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 getMenuPosition = useCallback((x = 0, y = 0) => {
let menuStyles = {
top: y,
left: x
};
if (!menuRef.current) return menuStyles;
const rect = menuRef.current.getBoundingClientRect();
const { top: boundaryTop, right: boundaryRight, bottom: boundaryBottom, left: boundaryLeft } = boundaryCoordinates || {};
menuStyles.top = menuStyles.top - boundaryTop;
menuStyles.left = menuStyles.left - boundaryLeft;
if (y + rect.height > boundaryBottom - 10) {
menuStyles.top -= rect.height;
}
if (x + rect.width > boundaryRight) {
menuStyles.left -= rect.width;
}
if (menuStyles.top < 0) {
menuStyles.top = rect.bottom > boundaryBottom ? (boundaryBottom - 10 - rect.height) / 2 : 0;
}
if (menuStyles.left < 0) {
menuStyles.left = rect.width < boundaryRight ? (boundaryRight - rect.width) / 2 : 0;
}
return menuStyles;
}, [boundaryCoordinates]);
const handleOptionClick = useCallback((event, option) => {
event.stopPropagation();
onOptionClick(option);
setVisible(false);
}, [onOptionClick]);
useEffect(() => {
const handleShow = (event) => {
event.preventDefault();
if (menuRef.current && menuRef.current.contains(event.target)) return;
if (ignoredTriggerElements && !ignoredTriggerElements.some(target => event.target.closest(target))) {
return;
}
setVisible(true);
const position = getMenuPosition(event.clientX, event.clientY);
setPosition(position);
};
document.addEventListener('contextmenu', handleShow);
return () => {
document.removeEventListener('contextmenu', handleShow);
};
}, [getMenuPosition, ignoredTriggerElements]);
useEffect(() => {
if (visible) {
document.addEventListener('mousedown', handleHide);
} else {
document.removeEventListener('mousedown', handleHide);
}
return () => {
document.removeEventListener('mousedown', handleHide);
};
}, [visible, handleHide]);
if (!visible) return null;
if (options.length === 0) return null;
return (
<div
ref={menuRef}
className="dropdown-menu sf-metadata-contextmenu"
style={position}
>
{options.map((option, index) => (
<button
key={index}
className="dropdown-item sf-metadata-contextmenu-item"
onClick={(event) => handleOptionClick(event, option)}
>
{option.label}
</button>
))}
</div>
);
};
ContextMenu.propTypes = {
options: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
})).isRequired,
boundaryCoordinates: PropTypes.object,
ignoredTriggerElements: PropTypes.array,
onOptionClick: PropTypes.func.isRequired,
};
export default ContextMenu;

View File

@@ -0,0 +1,85 @@
import React, { useState, useRef, useEffect } from 'react';
import { Button, Modal, ModalHeader, Input, ModalBody, ModalFooter, Alert } from 'reactstrap';
import PropTypes from 'prop-types';
import { gettext } from '../../../../utils/constants';
import { validateName } from '../../../../utils/utils';
import { isEnter } from '../../../utils/hotkey';
const RenameDialog = ({ isDir, oldName, onSubmit, onCancel }) => {
const [newName, setNewName] = useState('');
const [errMessage, setErrMessage] = useState('');
const [isSubmitBtnActive, setIsSubmitBtnActive] = useState(false);
const newInput = useRef(null);
const handleChange = (e) => {
const value = e.target.value.trim();
setNewName(value);
setIsSubmitBtnActive(!!value);
};
const handleSubmit = () => {
const { isValid, errMessage } = validateName(newName);
if (!isValid) {
setErrMessage(errMessage);
return;
}
onSubmit(newName);
};
const handleKeyDown = (e) => {
if (isEnter(e)) {
handleSubmit();
}
};
const onAfterModelOpened = () => {
if (!newInput.current) return;
newInput.current.focus();
if (!isDir) {
const endIndex = oldName.lastIndexOf('.');
if (endIndex !== -1) {
newInput.current.setSelectionRange(0, endIndex, 'forward');
} else {
newInput.current.setSelectionRange(0, oldName.length, 'forward');
}
} else {
newInput.current.setSelectionRange(0, -1);
}
};
useEffect(() => {
setNewName(oldName);
}, [oldName]);
return (
<Modal isOpen={true} toggle={onCancel} onOpened={onAfterModelOpened}>
<ModalHeader toggle={onCancel}>
{isDir ? gettext('Rename Folder') : gettext('Rename File')}
</ModalHeader>
<ModalBody>
<p>{isDir ? gettext('New folder name') : gettext('New file name')}</p>
<Input
onKeyDown={handleKeyDown}
innerRef={newInput}
value={newName}
onChange={handleChange}
/>
{errMessage && <Alert color="danger" className="mt-2">{errMessage}</Alert>}
</ModalBody>
<ModalFooter>
<Button color="secondary" onClick={onCancel}>{gettext('Cancel')}</Button>
<Button color="primary" onClick={handleSubmit} disabled={!isSubmitBtnActive}>{gettext('Submit')}</Button>
</ModalFooter>
</Modal>
);
};
RenameDialog.propTypes = {
isDir: PropTypes.bool.isRequired,
oldName: PropTypes.string.isRequired,
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
};
export default RenameDialog;

View File

@@ -2,6 +2,7 @@ import { getFileNameFromRecord, getParentDirFromRecord } from './cell';
import { checkIsDir } from './row';
import { Utils } from '../../utils/utils';
import { siteRoot } from '../../utils/constants';
import URLDecorator from '../../utils/url-decorator';
const FILE_TYPE = {
FOLDER: 'folder',
@@ -99,3 +100,34 @@ export const openFile = (repoID, record, _openImage = () => {}) => {
}
};
export const openInNewTab = (repoID, record) => {
if (!record) return;
const isDir = checkIsDir(record);
const parentDir = getParentDirFromRecord(record);
const fileName = getFileNameFromRecord(record);
const url = isDir
? window.location.origin + window.location.pathname + Utils.encodePath(Utils.joinPath(parentDir, fileName))
: `${siteRoot}lib/${repoID}/file${Utils.encodePath(Utils.joinPath(parentDir, fileName))}`;
window.open(url, '_blank');
};
export const openParentFolder = (record) => {
if (!record) return;
let parentDir = getParentDirFromRecord(record);
if (window.location.pathname.endsWith('/')) {
parentDir = parentDir.slice(1);
}
const url = window.location.origin + window.location.pathname + Utils.encodePath(parentDir);
window.open(url, '_blank');
};
export const downloadFile = (repoID, record) => {
if (!repoID || !record) return;
if (checkIsDir(record)) return;
const parentDir = _getParentDir(record);
const name = getFileNameFromRecord(record);
const direntPath = Utils.joinPath(parentDir, name);
const url = URLDecorator.getUrl({ type: 'download_file_url', repoID: repoID, filePath: direntPath });
location.href = url;
};

View File

@@ -26,7 +26,7 @@ const updateTableRowsWithRowsData = (tables, tableId, recordsData = []) => {
});
};
export const checkIsDir = (record) => {
const checkIsDir = (record) => {
if (!record) return false;
const isDir = record[PRIVATE_COLUMN_KEY.IS_DIR];
if (typeof isDir === 'string') {
@@ -38,4 +38,5 @@ export const checkIsDir = (record) => {
export {
isTableRows,
updateTableRowsWithRowsData,
checkIsDir,
};

View File

@@ -16,7 +16,7 @@ const Content = ({
onImageSelect,
onImageClick,
onImageDoubleClick,
onImageRightClick
onContextMenu
}) => {
const containerRef = useRef(null);
const imageRef = useRef(null);
@@ -136,7 +136,7 @@ const Content = ({
size={size}
onClick={(e) => onImageClick(e, img)}
onDoubleClick={(e) => onImageDoubleClick(e, img)}
onContextMenu={(e) => onImageRightClick(e, img)}
onContextMenu={(e) => onContextMenu(e, img)}
/>
);
});
@@ -144,7 +144,7 @@ const Content = ({
</div>
</div>
);
}, [overScan, columns, size, imageHeight, mode, selectedImageIds, onImageClick, onImageDoubleClick, onImageRightClick]);
}, [overScan, columns, size, imageHeight, mode, selectedImageIds, onImageClick, onImageDoubleClick, onContextMenu]);
if (!Array.isArray(groups) || groups.length === 0) {
return <EmptyTip text={gettext('No record')}/>;
@@ -191,7 +191,7 @@ Content.propTypes = {
onImageSelect: PropTypes.func.isRequired,
onImageClick: PropTypes.func.isRequired,
onImageDoubleClick: PropTypes.func.isRequired,
onImageRightClick: PropTypes.func.isRequired,
onContextMenu: PropTypes.func.isRequired,
};
export default Content;

View File

@@ -1,139 +1,104 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import React, { useMemo, useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import { gettext } from '../../../../utils/constants';
import './index.css';
import ContextMenu from '../../../components/context-menu';
import { gettext, useGoFileserver, fileServerRoot } from '../../../../utils/constants';
import { getRowById } from '../../../utils/table';
import { useMetadataView } from '../../../hooks/metadata-view';
import { downloadFile } from '../../../utils/file';
import ZipDownloadDialog from '../../../../components/dialog/zip-download-dialog';
import metadataAPI from '../../../api';
import toaster from '../../../../components/toast';
import { Utils } from '../../../../utils/utils';
import ModalPortal from '../../../../components/modal-portal';
const OPERATION = {
const CONTEXT_MENU_KEY = {
DOWNLOAD: 'download',
DELETE: 'delete',
};
const ContextMenu = ({ getContentRect, getContainerRect, onDownload, onDelete }) => {
const menuRef = useRef(null);
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const GalleryContextMenu = ({ selectedImages, boundaryCoordinates, onDelete }) => {
const [isZipDialogOpen, setIsZipDialogOpen] = useState(false);
const { metadata } = useMetadataView();
const repoID = window.sfMetadataContext.getSetting('repoID');
const checkCanDeleteRow = window.sfMetadataContext.checkCanDeleteRow();
const options = useMemo(() => {
if (!visible) return [];
return [
{ value: OPERATION.DOWNLOAD, label: gettext('Download') },
{ value: OPERATION.DELETE, label: gettext('Delete') }
];
}, [visible]);
const handleHide = useCallback((event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
setVisible(false);
let validOptions = [{ value: CONTEXT_MENU_KEY.DOWNLOAD, label: gettext('Download') }];
if (checkCanDeleteRow) {
validOptions.push({ value: CONTEXT_MENU_KEY.DELETE, label: selectedImages.length > 1 ? gettext('Delete') : gettext('Delete file') });
}
}, [menuRef]);
return validOptions;
}, [checkCanDeleteRow, selectedImages]);
const handleOptionClick = useCallback((event, option) => {
event.stopPropagation();
const closeZipDialog = () => {
setIsZipDialogOpen(false);
};
const handleDownload = useCallback(() => {
if (!selectedImages.length) return;
if (selectedImages.length === 1) {
const image = selectedImages[0];
const record = getRowById(metadata, image.id);
downloadFile(repoID, record);
return;
}
if (!useGoFileserver) {
setIsZipDialogOpen(true);
return;
}
const dirents = selectedImages.map(image => {
const value = image.path === '/' ? image.name : `${image.path}/${image.name}`;
return value;
});
metadataAPI.zipDownload(repoID, '/', dirents).then((res) => {
const zipToken = res.data['zip_token'];
location.href = `${fileServerRoot}zip/${zipToken}`;
}).catch(error => {
const errMessage = Utils.getErrorMsg(error);
toaster.danger(errMessage);
});
}, [repoID, metadata, selectedImages]);
const handleOptionClick = useCallback(option => {
switch (option.value) {
case OPERATION.DOWNLOAD: {
onDownload && onDownload();
case 'download':
handleDownload();
break;
}
case OPERATION.DELETE: {
onDelete && onDelete();
case 'delete':
onDelete(selectedImages);
break;
}
default: {
default:
break;
}
}
setVisible(false);
}, [onDownload, onDelete]);
const getMenuPosition = useCallback((x = 0, y = 0) => {
let menuStyles = {
top: y,
left: x
};
if (!menuRef.current) return menuStyles;
const rect = menuRef.current.getBoundingClientRect();
const containerRect = getContainerRect();
const { right: innerWidth, bottom: innerHeight } = getContentRect();
menuStyles.top = menuStyles.top - containerRect.top;
menuStyles.left = menuStyles.left - containerRect.left;
if (y + rect.height > innerHeight - 10) {
menuStyles.top -= rect.height;
}
if (x + rect.width > innerWidth) {
menuStyles.left -= rect.width;
}
if (menuStyles.top < 0) {
menuStyles.top = rect.bottom > innerHeight ? (innerHeight - 10 - rect.height) / 2 : 0;
}
if (menuStyles.left < 0) {
menuStyles.left = rect.width < innerWidth ? (innerWidth - rect.width) / 2 : 0;
}
return menuStyles;
}, [getContentRect, getContainerRect]);
useEffect(() => {
const handleShow = (event) => {
event.preventDefault();
if (menuRef.current && menuRef.current.contains(event.target)) return;
if (event.target.tagName.toLowerCase() !== 'img') {
return;
}
setVisible(true);
const position = getMenuPosition(event.clientX, event.clientY);
setPosition(position);
};
document.addEventListener('contextmenu', handleShow);
return () => {
document.removeEventListener('contextmenu', handleShow);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (visible) {
document.addEventListener('mousedown', handleHide);
} else {
document.removeEventListener('mousedown', handleHide);
}
return () => {
document.removeEventListener('mousedown', handleHide);
};
}, [visible, handleHide]);
if (!visible) return null;
if (options.length === 0) return null;
}, [selectedImages, handleDownload, onDelete]);
return (
<div
ref={menuRef}
className='dropdown-menu sf-metadata-contextmenu'
style={position}
>
{options.map((option, index) => (
<button
key={index}
className='dropdown-item sf-metadata-contextmenu-item'
onClick={(event) => handleOptionClick(event, option)}
>
{option.label}
</button>
))}
</div>
<>
<ContextMenu
options={options}
boundaryCoordinates={boundaryCoordinates}
ignoredTriggerElements={['.metadata-gallery-image-item', '.metadata-gallery-grid-image']}
onOptionClick={handleOptionClick}
/>
{isZipDialogOpen && (
<ModalPortal>
<ZipDownloadDialog
repoID={repoID}
path="/"
target={selectedImages.map(image => image.path === '/' ? image.name : `${image.path}/${image.name}`)}
toggleDialog={closeZipDialog}
/>
</ModalPortal>
)}
</>
);
};
ContextMenu.propTypes = {
getContentRect: PropTypes.func.isRequired,
getContainerRect: PropTypes.func.isRequired,
onDownload: PropTypes.func,
GalleryContextMenu.propTypes = {
selectedImages: PropTypes.array,
boundaryCoordinates: PropTypes.object,
onDelete: PropTypes.func,
};
export default ContextMenu;
export default GalleryContextMenu;

View File

@@ -1,21 +1,17 @@
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import PropTypes from 'prop-types';
import { CenteredLoading } from '@seafile/sf-metadata-ui-component';
import metadataAPI from '../../api';
import URLDecorator from '../../../utils/url-decorator';
import toaster from '../../../components/toast';
import Content from './content';
import ContextMenu from './context-menu';
import ImageDialog from '../../../components/dialog/image-dialog';
import ZipDownloadDialog from '../../../components/dialog/zip-download-dialog';
import ModalPortal from '../../../components/modal-portal';
import { useMetadataView } from '../../hooks/metadata-view';
import { Utils } from '../../../utils/utils';
import { getDateDisplayString, getFileNameFromRecord, getParentDirFromRecord } from '../../utils/cell';
import { siteRoot, fileServerRoot, useGoFileserver, thumbnailSizeForGrid, thumbnailSizeForOriginal } from '../../../utils/constants';
import { siteRoot, fileServerRoot, thumbnailSizeForGrid, thumbnailSizeForOriginal } from '../../../utils/constants';
import { EVENT_BUS_TYPE, PRIVATE_COLUMN_KEY, GALLERY_DATE_MODE, DATE_TAG_HEIGHT, GALLERY_IMAGE_GAP } from '../../constants';
import { getRowById } from '../../utils/table';
import { getEventClassName } from '../../utils/common';
import GalleryContextmenu from './context-menu';
import './index.css';
@@ -28,7 +24,6 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore }) => {
const [overScan, setOverScan] = useState({ top: 0, bottom: 0 });
const [mode, setMode] = useState(GALLERY_DATE_MODE.DAY);
const [isImagePopupOpen, setIsImagePopupOpen] = useState(false);
const [isZipDialogOpen, setIsZipDialogOpen] = useState(false);
const [imageIndex, setImageIndex] = useState(0);
const [selectedImages, setSelectedImages] = useState([]);
@@ -267,7 +262,7 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore }) => {
setIsImagePopupOpen(true);
}, [imageItems]);
const handleRightClick = useCallback((event, image) => {
const handleContextMenu = useCallback((event, image) => {
event.preventDefault();
const index = imageItems.findIndex(item => item.id === image.id);
if (isNaN(index) || index === -1) return;
@@ -291,46 +286,17 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore }) => {
setSelectedImages(selectedImages);
}, []);
const closeImagePopup = () => {
const closeImagePopup = useCallback(() => {
setIsImagePopupOpen(false);
};
}, []);
const handleDownload = useCallback(() => {
if (!selectedImages.length) return;
if (selectedImages.length === 1) {
const image = selectedImages[0];
let direntPath = image.path === '/' ? image.name : Utils.joinPath(image.path, image.name);
let url = URLDecorator.getUrl({ type: 'download_file_url', repoID: repoID, filePath: direntPath });
location.href = url;
return;
}
if (!useGoFileserver) {
setIsZipDialogOpen(true);
return;
}
const dirents = selectedImages.map(image => {
const value = image.path === '/' ? image.name : `${image.path}/${image.name}`;
return value;
});
metadataAPI.zipDownload(repoID, '/', dirents).then((res) => {
const zipToken = res.data['zip_token'];
location.href = `${fileServerRoot}zip/${zipToken}`;
}).catch(error => {
const errMessage = Utils.getErrorMsg(error);
toaster.danger(errMessage);
});
}, [repoID, selectedImages]);
const handleDelete = useCallback(() => {
const handleDeleteSelectedImages = useCallback((selectedImages) => {
if (!selectedImages.length) return;
onDelete(selectedImages, () => {
updateCurrentDirent();
setSelectedImages([]);
});
}, [selectedImages, onDelete]);
const closeZipDialog = () => {
setIsZipDialogOpen(false);
};
}, [onDelete, updateCurrentDirent]);
const handleClickOutside = useCallback((event) => {
const className = getEventClassName(event);
@@ -385,7 +351,7 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore }) => {
onImageSelect={handleImageSelection}
onImageClick={handleClick}
onImageDoubleClick={handleDoubleClick}
onImageRightClick={handleRightClick}
onContextMenu={handleContextMenu}
/>
{isLoadingMore &&
<div className="sf-metadata-gallery-loading-more">
@@ -395,11 +361,10 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore }) => {
</>
)}
</div>
<ContextMenu
getContentRect={() => containerRef.current.getBoundingClientRect()}
getContainerRect={() => containerRef.current.getBoundingClientRect()}
onDownload={handleDownload}
onDelete={handleDelete}
<GalleryContextmenu
selectedImages={selectedImages}
boundaryCoordinates={containerRef?.current?.getBoundingClientRect() || {}}
onDelete={handleDeleteSelectedImages}
/>
{isImagePopupOpen && (
<ModalPortal>
@@ -413,16 +378,6 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore }) => {
/>
</ModalPortal>
)}
{isZipDialogOpen && (
<ModalPortal>
<ZipDownloadDialog
repoID={repoID}
path={'/'}
target={selectedImages.map(image => image.path === '/' ? image.name : `${image.path}/${image.name}`)}
toggleDialog={closeZipDialog}
/>
</ModalPortal>
)}
</>
);
};

View File

@@ -17,6 +17,7 @@ const Card = ({
displayColumns,
onOpenFile,
onSelectCard,
onContextMenu,
}) => {
const titleValue = getCellValueByColumn(record, titleColumn);
@@ -41,6 +42,7 @@ const Card = ({
data-id={record._id}
className={classnames('sf-metadata-kanban-card', { 'selected': isSelected })}
onClick={handleClickCard}
onContextMenu={onContextMenu}
>
{titleColumn && (
<div className="sf-metadata-kanban-card-header" onClick={handleClickFilename}>
@@ -82,6 +84,7 @@ Card.propTypes = {
displayColumns: PropTypes.array,
onOpenFile: PropTypes.func.isRequired,
onSelectCard: PropTypes.func.isRequired,
onContextMenu: PropTypes.func.isRequired,
};
export default Card;

View File

@@ -28,6 +28,7 @@ const Board = ({
onOpenFile,
onSelectCard,
updateDragging,
onContextMenu,
}) => {
const [isDraggingOver, setDraggingOver] = useState(false);
const boardName = useMemo(() => `sf_metadata_kanban_board_${board.key}`, [board]);
@@ -80,7 +81,8 @@ const Board = ({
{board.children.map((cardKey) => {
const record = getRowById(metadata, cardKey);
if (!record) return null;
const isSelected = selectedCard === getRecordIdFromRecord(record);
const recordId = getRecordIdFromRecord(record);
const isSelected = selectedCard === recordId;
const CardElement = (
<Card
key={cardKey}
@@ -92,6 +94,7 @@ const Board = ({
displayColumns={displayColumns}
onOpenFile={onOpenFile}
onSelectCard={onSelectCard}
onContextMenu={(e) => onContextMenu(e, recordId)}
/>
);
if (readonly) return CardElement;
@@ -124,6 +127,7 @@ Board.propTypes = {
onOpenFile: PropTypes.func.isRequired,
onSelectCard: PropTypes.func.isRequired,
updateDragging: PropTypes.func.isRequired,
onContextMenu: PropTypes.func,
};
export default Board;

View File

@@ -10,16 +10,17 @@ import { checkIsPredefinedOption, getCellValueByColumn, isValidCellValue, getRec
getFileNameFromRecord, getParentDirFromRecord
} from '../../../utils/cell';
import { getColumnOptions, getColumnOriginName } from '../../../utils/column';
import { openFile } from '../../../utils/file';
import { checkIsDir } from '../../../utils/row';
import AddBoard from '../add-board';
import EmptyTip from '../../../../components/empty-tip';
import Board from './board';
import ImagePreviewer from '../../../components/cell-formatter/image-previewer';
import { openFile } from '../../../utils/open-file';
import { checkIsDir } from '../../../utils/row';
import ContextMenu from '../context-menu';
import './index.css';
const Boards = ({ modifyRecord, modifyColumnData, onCloseSettings }) => {
const Boards = ({ modifyRecord, deleteRecords, modifyColumnData, onCloseSettings }) => {
const [haveFreezed, setHaveFreezed] = useState(false);
const [isImagePreviewerVisible, setImagePreviewerVisible] = useState(false);
const [selectedCard, setSelectedCard] = useState('');
@@ -31,6 +32,9 @@ const Boards = ({ modifyRecord, modifyColumnData, onCloseSettings }) => {
const { isDirentDetailShow, metadata, store, updateCurrentDirent, showDirentDetail } = useMetadataView();
const { collaborators } = useCollaborators();
const repoID = window.sfMetadataContext.getSetting('repoID');
const repoInfo = window.sfMetadataContext.getSetting('repoInfo');
const groupByColumn = useMemo(() => {
const groupByColumnKey = metadata.view.settings[KANBAN_SETTINGS_KEYS.GROUP_BY_COLUMN_KEY];
return metadata.key_column_map[groupByColumnKey];
@@ -216,6 +220,24 @@ const Boards = ({ modifyRecord, modifyColumnData, onCloseSettings }) => {
setDragging(isDragging);
}, []);
const onContextMenu = useCallback((event, recordId) => {
event.preventDefault();
setSelectedCard(recordId);
}, []);
const onDeleteRecords = useCallback((recordIds) => {
deleteRecords(recordIds, {
success_callback: () => {
setSelectedCard(null);
updateCurrentDirent();
},
});
}, [deleteRecords, updateCurrentDirent]);
const onRename = useCallback((rowId, updates, oldRowData, originalUpdates, originalOldRowData, { success_callback }) => {
modifyRecord(rowId, updates, oldRowData, originalUpdates, originalOldRowData, { success_callback });
}, [modifyRecord]);
useEffect(() => {
if (!isDirentDetailShow) {
setSelectedCard(null);
@@ -223,50 +245,57 @@ const Boards = ({ modifyRecord, modifyColumnData, onCloseSettings }) => {
}, [isDirentDetailShow]);
const isEmpty = boards.length === 0;
const repoID = window.sfMetadataContext.getSetting('repoID');
const repoInfo = window.sfMetadataContext.getSetting('repoInfo');
return (
<div
ref={containerRef}
className={classnames('sf-metadata-view-kanban-boards', {
'sf-metadata-view-kanban-boards-text-wrap': textWrap,
'readonly': readonly,
})}
onClick={handleClickOutside}
>
<div className="smooth-dnd-container horizontal">
{isEmpty && (<EmptyTip className="tips-empty-boards" text={gettext('No categories')} />)}
{!isEmpty && (
<>
{boards.map((board, index) => {
return (
<Board
key={board.key}
board={board}
index={index}
readonly={readonly}
displayEmptyValue={displayEmptyValue}
displayColumnName={displayColumnName}
haveFreezed={haveFreezed}
groupByColumn={groupByColumn}
titleColumn={titleColumn}
displayColumns={displayColumns}
selectedCard={selectedCard}
onMove={onMove}
deleteOption={deleteOption}
onFreezed={onFreezed}
onUnFreezed={onUnFreezed}
onOpenFile={onOpenFile}
onSelectCard={onSelectCard}
updateDragging={updateDragging}
/>
);
})}
</>
)}
{!readonly && (<AddBoard groupByColumn={groupByColumn}/>)}
<>
<div
ref={containerRef}
className={classnames('sf-metadata-view-kanban-boards', {
'sf-metadata-view-kanban-boards-text-wrap': textWrap,
'readonly': readonly,
})}
onClick={handleClickOutside}
>
<div className="smooth-dnd-container horizontal">
{isEmpty && (<EmptyTip className="tips-empty-boards" text={gettext('No categories')} />)}
{!isEmpty && (
<>
{boards.map((board, index) => {
return (
<Board
key={board.key}
board={board}
index={index}
readonly={readonly}
displayEmptyValue={displayEmptyValue}
displayColumnName={displayColumnName}
haveFreezed={haveFreezed}
groupByColumn={groupByColumn}
titleColumn={titleColumn}
displayColumns={displayColumns}
selectedCard={selectedCard}
onMove={onMove}
deleteOption={deleteOption}
onFreezed={onFreezed}
onUnFreezed={onUnFreezed}
onOpenFile={onOpenFile}
onSelectCard={onSelectCard}
updateDragging={updateDragging}
onContextMenu={onContextMenu}
/>
);
})}
</>
)}
{!readonly && (<AddBoard groupByColumn={groupByColumn}/>)}
</div>
</div>
<ContextMenu
boundaryCoordinates={containerRef?.current?.getBoundingClientRect()}
selectedCard={selectedCard}
onDelete={onDeleteRecords}
onRename={onRename}
/>
{isImagePreviewerVisible && (
<ImagePreviewer
repoID={repoID}
@@ -276,7 +305,7 @@ const Boards = ({ modifyRecord, modifyColumnData, onCloseSettings }) => {
closeImagePopup={closeImagePreviewer}
/>
)}
</div>
</>
);
};

View File

@@ -0,0 +1,163 @@
import React, { useCallback, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { ModalPortal } from '@seafile/sf-metadata-ui-component';
import ContextMenu from '../../../components/context-menu';
import { getRowById } from '../../../utils/table';
import { checkIsDir } from '../../../utils/row';
import { getFileNameFromRecord, getParentDirFromRecord } from '../../../utils/cell';
import { gettext, useGoFileserver, fileServerRoot } from '../../../../utils/constants';
import { openInNewTab, openParentFolder, downloadFile } from '../../../utils/file';
import { useMetadataView } from '../../../hooks/metadata-view';
import { PRIVATE_COLUMN_KEY } from '../../../constants';
import RenameDialog from '../../../components/dialog/rename-dialog';
import { Utils } from '../../../../utils/utils';
import toaster from '../../../../components/toast';
import metadataAPI from '../../../api';
import ZipDownloadDialog from '../../../../components/dialog/zip-download-dialog';
const CONTEXT_MENU_KEY = {
OPEN_IN_NEW_TAB: 'open_in_new_tab',
OPEN_PARENT_FOLDER: 'open_parent_folder',
DOWNLOAD: 'download',
DELETE: 'delete',
RENAME: 'rename',
};
const KanbanContextMenu = ({ boundaryCoordinates, selectedCard, onDelete, onRename }) => {
const [isRenameDialogShow, setIsRenameDialogShow] = useState(false);
const [isZipDialogOpen, setIsZipDialogOpen] = useState(false);
const { metadata } = useMetadataView();
const selectedRecord = useMemo(() => getRowById(metadata, selectedCard), [metadata, selectedCard]);
const isDir = useMemo(() => checkIsDir(selectedRecord), [selectedRecord]);
const oldName = useMemo(() => getFileNameFromRecord(selectedRecord), [selectedRecord]);
const parentDir = useMemo(() => getParentDirFromRecord(selectedRecord), [selectedRecord]);
const repoID = window.sfMetadataContext.getSetting('repoID');
const checkCanDeleteRow = window.sfMetadataContext.checkCanDeleteRow();
const canModifyRow = window.sfMetadataContext.canModifyRow();
const options = useMemo(() => {
let validOptions = [
{ value: CONTEXT_MENU_KEY.OPEN_IN_NEW_TAB, label: isDir ? gettext('Open folder in new tab') : gettext('Open file in new tab') },
{ value: CONTEXT_MENU_KEY.OPEN_PARENT_FOLDER, label: gettext('Open parent folder') },
{ value: CONTEXT_MENU_KEY.DOWNLOAD, label: gettext('Download') },
];
if (checkCanDeleteRow) {
validOptions.push({ value: CONTEXT_MENU_KEY.DELETE, label: isDir ? gettext('Delete folder') : gettext('Delete file') });
}
if (canModifyRow) {
validOptions.push({ value: CONTEXT_MENU_KEY.RENAME, label: isDir ? gettext('Rename folder') : gettext('Rename file') });
}
return validOptions;
}, [isDir, checkCanDeleteRow, canModifyRow]);
const closeZipDialog = useCallback(() => {
setIsZipDialogOpen(false);
}, []);
const openRenameDialog = useCallback(() => {
setIsRenameDialogShow(true);
}, []);
const handleRename = useCallback((newName) => {
if (!selectedCard) return;
const record = getRowById(metadata, selectedCard);
if (!record) return;
const oldName = getFileNameFromRecord(record);
const updates = { [PRIVATE_COLUMN_KEY.FILE_NAME]: newName };
const oldRowData = { [PRIVATE_COLUMN_KEY.FILE_NAME]: oldName };
onRename(selectedCard, updates, oldRowData, updates, oldRowData, {
success_callback: () => setIsRenameDialogShow(false),
});
}, [metadata, selectedCard, onRename]);
const handelDownload = useCallback((record) => {
if (!isDir) {
downloadFile(repoID, record);
return;
}
if (!useGoFileserver) {
setIsZipDialogOpen(true);
return;
}
const fileName = getFileNameFromRecord(record);
metadataAPI.zipDownload(repoID, parentDir, [fileName]).then((res) => {
const zipToken = res.data['zip_token'];
location.href = `${fileServerRoot}zip/${zipToken}`;
}).catch(error => {
const errMessage = Utils.getErrorMsg(error);
toaster.danger(errMessage);
});
}, [repoID, isDir, parentDir]);
const handleOptionClick = useCallback((option) => {
if (!selectedCard) return;
const record = getRowById(metadata, selectedCard);
if (!record) return;
switch (option.value) {
case CONTEXT_MENU_KEY.OPEN_IN_NEW_TAB: {
openInNewTab(repoID, record);
break;
}
case CONTEXT_MENU_KEY.OPEN_PARENT_FOLDER: {
openParentFolder(record);
break;
}
case CONTEXT_MENU_KEY.DOWNLOAD: {
handelDownload(record);
break;
}
case CONTEXT_MENU_KEY.DELETE: {
onDelete([selectedCard]);
break;
}
case CONTEXT_MENU_KEY.RENAME: {
openRenameDialog();
break;
}
default: {
break;
}
}
}, [metadata, repoID, selectedCard, onDelete, openRenameDialog, handelDownload]);
return (
<>
<ContextMenu
options={options}
boundaryCoordinates={boundaryCoordinates}
onOptionClick={handleOptionClick}
ignoredTriggerElements={['.sf-metadata-kanban-card']}
/>
{isRenameDialogShow && (
<ModalPortal>
<RenameDialog
isDir={isDir}
oldName={oldName}
onSubmit={handleRename}
onCancel={() => setIsRenameDialogShow(false)}
/>
</ModalPortal>
)}
{isZipDialogOpen && (
<ModalPortal>
<ZipDownloadDialog repoID={repoID} path={parentDir} target={[oldName]} toggleDialog={closeZipDialog}/>
</ModalPortal>
)}
</>
);
};
KanbanContextMenu.propTypes = {
boundaryCoordinates: PropTypes.object,
selectedCard: PropTypes.string,
onDelete: PropTypes.func,
onRename: PropTypes.func,
};
export default KanbanContextMenu;

View File

@@ -4,32 +4,101 @@ import { EVENT_BUS_TYPE } from '../../constants';
import toaster from '../../../components/toast';
import Boards from './boards';
import Settings from './settings';
import { getRowById } from '../../utils/table';
import { getFileNameFromRecord, getParentDirFromRecord } from '../../utils/cell';
import { Utils, validateName } from '../../../utils/utils';
import { gettext } from '../../../utils/constants';
import './index.css';
const Kanban = () => {
const [isShowSettings, setShowSettings] = useState(false);
const { metadata, store } = useMetadataView();
const { metadata, store, renameFileCallback, deleteFilesCallback } = useMetadataView();
const columns = useMemo(() => metadata.view.columns, [metadata.view.columns]);
const modifyRecord = useCallback((rowId, updates, oldRowData, originalUpdates, originalOldRowData) => {
const modifyRecord = useCallback((rowId, updates, oldRowData, originalUpdates, originalOldRowData, { success_callback }) => {
const rowIds = [rowId];
const idRowUpdates = { [rowId]: updates };
const idOriginalRowUpdates = { [rowId]: originalUpdates };
const idOldRowData = { [rowId]: oldRowData };
const idOriginalOldRowData = { [rowId]: originalOldRowData };
store.modifyRecords(rowIds, idRowUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData, false, false, {
const isRename = store.checkIsRenameFileOperator(rowIds, idOriginalRowUpdates);
let newName = null;
if (isRename) {
const rowId = rowIds[0];
const row = getRowById(metadata, rowId);
const rowUpdates = idOriginalRowUpdates[rowId];
const { _parent_dir, _name } = row;
newName = getFileNameFromRecord(rowUpdates);
const { isValid, errMessage } = validateName(newName);
if (!isValid) {
toaster.danger(errMessage);
return;
}
if (newName === _name) {
return;
}
if (store.checkDuplicatedName(newName, _parent_dir)) {
let errMessage = gettext('The name "{name}" is already taken. Please choose a different name.');
errMessage = errMessage.replace('{name}', Utils.HTMLescape(newName));
toaster.danger(errMessage);
return;
}
}
store.modifyRecords(rowIds, idRowUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData, false, isRename, {
fail_callback: (error) => {
error && toaster.danger(error);
},
success_callback: () => {
success_callback: (operation) => {
if (operation.is_rename) {
const rowId = operation.row_ids[0];
const row = getRowById(metadata, rowId);
const rowUpdates = operation.id_original_row_updates[rowId];
const oldRow = operation.id_original_old_row_data[rowId];
const parentDir = getParentDirFromRecord(row);
const oldName = getFileNameFromRecord(oldRow);
const path = Utils.joinPath(parentDir, oldName);
const newName = getFileNameFromRecord(rowUpdates);
renameFileCallback(path, newName);
success_callback && success_callback();
}
const eventBus = window.sfMetadataContext.eventBus;
eventBus.dispatch(EVENT_BUS_TYPE.LOCAL_RECORD_DETAIL_CHANGED, rowId, updates);
},
});
}, [store]);
}, [store, metadata, renameFileCallback]);
const deleteRecords = useCallback((recordsIds, { success_callback }) => {
if (!Array.isArray(recordsIds) || recordsIds.length === 0) return;
let paths = [];
let fileNames = [];
recordsIds.forEach((recordId) => {
const record = getRowById(metadata, recordId);
const { _parent_dir, _name } = record || {};
if (_parent_dir && _name) {
const path = Utils.joinPath(_parent_dir, _name);
paths.push(path);
fileNames.push(_name);
}
});
store.deleteRecords(recordsIds, {
fail_callback: (error) => {
toaster.danger(error);
},
success_callback: () => {
deleteFilesCallback(paths, fileNames);
let msg = fileNames.length > 1
? gettext('Successfully deleted {name} and {n} other items')
: gettext('Successfully deleted {name}');
msg = msg.replace('{name}', fileNames[0])
.replace('{n}', fileNames.length - 1);
toaster.success(msg);
success_callback && success_callback();
},
});
}, [metadata, store, deleteFilesCallback]);
const modifySettings = useCallback((newSettings) => {
store.modifySettings(newSettings);
@@ -54,18 +123,25 @@ const Kanban = () => {
}, [isShowSettings]);
return (
<div className="sf-metadata-view-kanban">
<Boards modifyRecord={modifyRecord} modifyColumnData={modifyColumnData} onCloseSettings={closeSettings} />
<div className="sf-metadata-view-setting-panel sf-metadata-view-kanban-setting h-100">
{isShowSettings && (
<Settings
columns={columns}
columnsMap={metadata.key_column_map}
settings={metadata.view.settings}
modifySettings={modifySettings}
onClose={closeSettings}
/>
)}
<div className="sf-metadata-container">
<div className="sf-metadata-view-kanban">
<Boards
modifyRecord={modifyRecord}
deleteRecords={deleteRecords}
modifyColumnData={modifyColumnData}
onCloseSettings={closeSettings}
/>
<div className="sf-metadata-view-setting-panel sf-metadata-view-kanban-setting h-100">
{isShowSettings && (
<Settings
columns={columns}
columnsMap={metadata.key_column_map}
settings={metadata.view.settings}
modifySettings={modifySettings}
onClose={closeSettings}
/>
)}
</div>
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import toaster from '../../../../components/toast';
import { gettext, siteRoot } from '../../../../utils/constants';
import { gettext } from '../../../../utils/constants';
import { Utils } from '../../../../utils/utils';
import { useMetadataView } from '../../../hooks/metadata-view';
import { useMetadataStatus } from '../../../../hooks';
@@ -12,6 +12,7 @@ import { getFileNameFromRecord, getParentDirFromRecord, getFileObjIdFromRecord,
getRecordIdFromRecord,
} from '../../../utils/cell';
import FileTagsDialog from '../../../components/dialog/file-tags-dialog';
import { openInNewTab, openParentFolder } from '../../../utils/file';
const OPERATION = {
CLEAR_SELECTED: 'clear-selected',
@@ -175,32 +176,6 @@ const ContextMenu = (props) => {
}
}, [menuRef, visible]);
const onOpenFileInNewTab = useCallback((record) => {
const repoID = window.sfMetadataStore.repoId;
const isFolder = checkIsDir(record);
const parentDir = getParentDirFromRecord(record);
const fileName = getFileNameFromRecord(record);
const url = isFolder ?
window.location.origin + window.location.pathname + Utils.encodePath(Utils.joinPath(parentDir, fileName)) :
`${siteRoot}lib/${repoID}/file${Utils.encodePath(Utils.joinPath(parentDir, fileName))}`;
window.open(url, '_blank');
}, []);
const onOpenParentFolder = useCallback((event, record) => {
event.preventDefault();
event.stopPropagation();
let parentDir = getParentDirFromRecord(record);
if (window.location.pathname.endsWith('/')) {
parentDir = parentDir.slice(1);
}
const url = window.location.origin + window.location.pathname + Utils.encodePath(parentDir);
window.open(url, '_blank');
}, []);
const generateDescription = useCallback((record) => {
const descriptionColumnKey = PRIVATE_COLUMN_KEY.FILE_DESCRIPTION;
let path = '';
@@ -325,17 +300,18 @@ const ContextMenu = (props) => {
const handleOptionClick = useCallback((event, option) => {
event.stopPropagation();
const repoID = window.sfMetadataStore.repoId;
switch (option.value) {
case OPERATION.OPEN_IN_NEW_TAB: {
const { record } = option;
if (!record) break;
onOpenFileInNewTab(record);
openInNewTab(repoID, record);
break;
}
case OPERATION.OPEN_PARENT_FOLDER: {
event.preventDefault();
event.stopPropagation();
const { record } = option;
if (!record) break;
onOpenParentFolder(event, record);
openParentFolder(record);
break;
}
case OPERATION.COPY_SELECTED: {
@@ -413,7 +389,7 @@ const ContextMenu = (props) => {
}
}
setVisible(false);
}, [onOpenFileInNewTab, onOpenParentFolder, onCopySelected, onClearSelected, generateDescription, imageCaption, ocr, deleteRecords, toggleDeleteFolderDialog, selectNone, updateFileDetails, toggleFileTagsRecord]);
}, [onCopySelected, onClearSelected, generateDescription, imageCaption, ocr, deleteRecords, toggleDeleteFolderDialog, selectNone, updateFileDetails, toggleFileTagsRecord]);
const getMenuPosition = useCallback((x = 0, y = 0) => {
let menuStyles = {

View File

@@ -5,7 +5,7 @@ import { IconBtn } from '@seafile/sf-metadata-ui-component';
import { gettext } from '../../../../../../../../../utils/constants';
import { EVENT_BUS_TYPE as METADATA_EVENT_BUS_TYPE, EDITOR_TYPE } from '../../../../../../../../constants';
import { checkIsDir } from '../../../../../../../../utils/row';
import { openFile } from '../../../../../../../../utils/open-file';
import { openFile } from '../../../../../../../../utils/file';
import './index.css';

View File

@@ -9,7 +9,7 @@ import { getParentDirFromRecord, getRecordIdFromRecord, getFileNameFromRecord, g
getFileMTimeFromRecord, getTagsFromRecord, getFilePathByRecord,
} from '../../../../metadata/utils/cell';
import { Utils } from '../../../../utils/utils';
import { openFile } from '../../../../metadata/utils/open-file';
import { openFile } from '../../../../metadata/utils/file';
import './index.css';