diff --git a/frontend/src/components/sf-table/context-menu/index.js b/frontend/src/components/sf-table/context-menu/index.js index 9c375b15797..b5b0205c3c5 100644 --- a/frontend/src/components/sf-table/context-menu/index.js +++ b/frontend/src/components/sf-table/context-menu/index.js @@ -83,8 +83,8 @@ const ContextMenu = ({ const options = useMemo(() => { if (!visible || !createContextMenuOptions) return []; - return createContextMenuOptions({ ...customProps, hideMenu: setVisible }); - }, [customProps, visible, createContextMenuOptions]); + return createContextMenuOptions({ ...customProps, hideMenu: setVisible, menuPosition: position }); + }, [customProps, visible, createContextMenuOptions, position]); if (!Array.isArray(options) || options.length === 0) return null; diff --git a/frontend/src/tag/api.js b/frontend/src/tag/api.js index 4d5897016e5..f8f57b523c3 100644 --- a/frontend/src/tag/api.js +++ b/frontend/src/tag/api.js @@ -123,6 +123,15 @@ class TagsManagerAPI { return this.req.delete(url, { data: params }); }; + mergeTags = (repoID, target_tag_id, merged_tags_ids) => { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/merge-tags/'; + const params = { + target_tag_id, + merged_tags_ids, + }; + return this.req.post(url, params); + }; + } const tagsAPI = new TagsManagerAPI(); diff --git a/frontend/src/tag/components/merge-tags-selector/index.css b/frontend/src/tag/components/merge-tags-selector/index.css new file mode 100644 index 00000000000..2a2da506878 --- /dev/null +++ b/frontend/src/tag/components/merge-tags-selector/index.css @@ -0,0 +1,71 @@ +.sf-metadata-merge-tags-selector { + left: 0; + min-height: 160px; + width: 300px; + padding: 0; + opacity: 1; + overflow: hidden; + position: fixed; + background-color: #fff; + border: 1px solid #dedede; + border-radius: 4px; + box-shadow: 0 2px 10px 0 #dedede; +} + +.sf-metadata-merge-tags-selector .sf-metadata-search-tags-container { + padding: 10px 10px 0; +} + +.sf-metadata-merge-tags-selector .sf-metadata-search-tags-container .sf-metadata-search-tags { + font-size: 14px; + max-height: 30px; +} + +.sf-metadata-merge-tags-selector .sf-metadata-merge-tags-selector-container { + max-height: 200px; + min-height: 100px; + overflow: auto; + padding: 10px; +} + +.sf-metadata-merge-tags-selector .sf-metadata-merge-tags-selector-container .none-search-result { + font-size: 14px; + opacity: 0.5; + display: inline-block; +} + +.sf-metadata-merge-tags-selector .sf-metadata-tags-editor-tag-container { + align-items: center; + border-radius: 2px; + color: #212529; + display: flex; + font-size: 13px; + height: 30px; + width: 100%; +} + +.sf-metadata-merge-tags-selector .sf-metadata-tags-editor-tag-container-highlight { + background: #f5f5f5; + cursor: pointer; +} + +.sf-metadata-tag-color-and-name { + display: flex; + align-items: center; + flex: 1; +} + +.sf-metadata-tag-color-and-name .sf-metadata-tag-color { + height: 12px; + width: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.sf-metadata-tag-color-and-name .sf-metadata-tag-name { + flex: 1; + margin-left: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/frontend/src/tag/components/merge-tags-selector/index.js b/frontend/src/tag/components/merge-tags-selector/index.js new file mode 100644 index 00000000000..1f7e134323b --- /dev/null +++ b/frontend/src/tag/components/merge-tags-selector/index.js @@ -0,0 +1,202 @@ +import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { SearchInput, ClickOutside, ModalPortal } from '@seafile/sf-metadata-ui-component'; +import { KeyCodes } from '../../../constants'; +import { gettext } from '../../../utils/constants'; +import { getRowsByIds } from '../../../components/sf-table/utils/table'; +import { getTagColor, getTagId, getTagName, getTagsByNameOrColor } from '../../utils/cell'; +import { EDITOR_CONTAINER as Z_INDEX_EDITOR_CONTAINER } from '../../../components/sf-table/constants/z-index'; +import { useTags } from '../../hooks'; + +import './index.css'; + +const getInitTags = (mergeTagsIds, tagsData) => { + if (!Array.isArray(mergeTagsIds) || mergeTagsIds.length === 0 || !tagsData || !Array.isArray(tagsData.row_ids)) return []; + const sortedTagsIds = tagsData.row_ids.filter((tagId) => mergeTagsIds.includes(tagId)); + if (sortedTagsIds.length === 0) return []; + return getRowsByIds(tagsData, sortedTagsIds); +}; + +const MergeTagsSelector = ({ + mergeTagsIds, + position = { left: 0, top: 0 }, + closeSelector, + mergeTags, +}) => { + const { tagsData } = useTags(); + const [searchValue, setSearchValue] = useState(''); + const [highlightIndex, setHighlightIndex] = useState(-1); + const [maxItemNum, setMaxItemNum] = useState(0); + const itemHeight = 30; + const allTagsRef = useRef(getInitTags(mergeTagsIds, tagsData)); + const editorContainerRef = useRef(null); + const editorRef = useRef(null); + const selectItemRef = useRef(null); + + const displayTags = useMemo(() => getTagsByNameOrColor(allTagsRef.current, searchValue), [searchValue, allTagsRef]); + + const onChangeSearch = useCallback((newSearchValue) => { + if (searchValue === newSearchValue) return; + setSearchValue(newSearchValue); + }, [searchValue]); + + const onSelectTag = useCallback((targetTagId) => { + const mergedTagsIds = mergeTagsIds.filter((tagId) => tagId !== targetTagId); + mergeTags(targetTagId, mergedTagsIds); + closeSelector(); + }, [mergeTagsIds, closeSelector, mergeTags]); + + const onMenuMouseEnter = useCallback((highlightIndex) => { + setHighlightIndex(highlightIndex); + }, []); + + const onMenuMouseLeave = useCallback((index) => { + setHighlightIndex(-1); + }, []); + + const getMaxItemNum = useCallback(() => { + let selectContainerStyle = getComputedStyle(editorContainerRef.current, null); + let selectItemStyle = getComputedStyle(selectItemRef.current, null); + let maxSelectItemNum = Math.floor(parseInt(selectContainerStyle.maxHeight) / parseInt(selectItemStyle.height)); + return maxSelectItemNum - 1; + }, [editorContainerRef, selectItemRef]); + + const onEnter = useCallback((event) => { + event.preventDefault(); + let tag; + if (displayTags.length === 1) { + tag = displayTags[0]; + } else if (highlightIndex > -1) { + tag = displayTags[highlightIndex]; + } + if (tag) { + const newTagId = getTagId(tag); + onSelectTag(newTagId); + return; + } + }, [displayTags, highlightIndex, onSelectTag]); + + const onUpArrow = useCallback((event) => { + event.preventDefault(); + event.stopPropagation(); + if (highlightIndex === 0) return; + setHighlightIndex(highlightIndex - 1); + if (highlightIndex > displayTags.length - maxItemNum) { + editorContainerRef.current.scrollTop -= itemHeight; + } + }, [editorContainerRef, highlightIndex, maxItemNum, displayTags, itemHeight]); + + const onDownArrow = useCallback((event) => { + event.preventDefault(); + event.stopPropagation(); + if (highlightIndex === displayTags.length - 1) return; + setHighlightIndex(highlightIndex + 1); + if (highlightIndex >= maxItemNum) { + editorContainerRef.current.scrollTop += itemHeight; + } + }, [editorContainerRef, highlightIndex, maxItemNum, displayTags, itemHeight]); + + const onHotKey = useCallback((event) => { + if (event.keyCode === KeyCodes.Enter) { + onEnter(event); + } else if (event.keyCode === KeyCodes.UpArrow) { + onUpArrow(event); + } else if (event.keyCode === KeyCodes.DownArrow) { + onDownArrow(event); + } + }, [onEnter, onUpArrow, onDownArrow]); + + const onKeyDown = useCallback((event) => { + if ( + event.keyCode === KeyCodes.ChineseInputMethod || + event.keyCode === KeyCodes.Enter || + event.keyCode === KeyCodes.LeftArrow || + event.keyCode === KeyCodes.RightArrow + ) { + event.stopPropagation(); + } + }, []); + + useEffect(() => { + if (editorRef.current) { + const { bottom } = editorRef.current.getBoundingClientRect(); + if (bottom > window.innerHeight) { + editorRef.current.style.top = 'unset'; + editorRef.current.style.bottom = '10px'; + } + } + if (editorContainerRef.current && selectItemRef.current) { + setMaxItemNum(getMaxItemNum()); + } + document.addEventListener('keydown', onHotKey, true); + return () => { + document.removeEventListener('keydown', onHotKey, true); + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onHotKey]); + + useEffect(() => { + const highlightIndex = displayTags.length === 0 ? -1 : 0; + setHighlightIndex(highlightIndex); + }, [displayTags]); + + const renderOptions = useCallback(() => { + if (displayTags.length === 0) { + const noOptionsTip = searchValue ? gettext('No tags available') : gettext('No tag'); + return ({noOptionsTip}); + } + + return displayTags.map((tag, i) => { + const tagId = getTagId(tag); + const tagName = getTagName(tag); + const tagColor = getTagColor(tag); + return ( +
+
onSelectTag(tagId)} + onMouseEnter={() => onMenuMouseEnter(i)} + onMouseLeave={() => onMenuMouseLeave(i)} + > +
+
+
{tagName}
+
+
+
+ ); + }); + + }, [displayTags, searchValue, highlightIndex, onMenuMouseEnter, onMenuMouseLeave, onSelectTag]); + + return ( + + +
+
+ +
+
+ {renderOptions()} +
+
+
+
+ ); +}; + +MergeTagsSelector.propTypes = { + tagsTable: PropTypes.object, + tags: PropTypes.array, + position: PropTypes.object, +}; + +export default MergeTagsSelector; diff --git a/frontend/src/tag/context.js b/frontend/src/tag/context.js index ab8e5b536c9..243c33ba4db 100644 --- a/frontend/src/tag/context.js +++ b/frontend/src/tag/context.js @@ -117,6 +117,9 @@ class Context { return this.api.deleteTagLinks(this.repoId, link_column_key, row_id_map); }; + mergeTags = (target_tag_id, merged_tags_ids) => { + return this.api.mergeTags(this.repoId, target_tag_id, merged_tags_ids); + }; } export default Context; diff --git a/frontend/src/tag/hooks/tags.js b/frontend/src/tag/hooks/tags.js index f71a712702d..c39e31e40c0 100644 --- a/frontend/src/tag/hooks/tags.js +++ b/frontend/src/tag/hooks/tags.js @@ -198,6 +198,10 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, .. storeRef.current.deleteTagLinks(columnKey, tagId, otherTagsIds, success_callback, fail_callback); }, []); + const mergeTags = useCallback((target_tag_id, merged_tags_ids, { success_callback, fail_callback } = {}) => { + storeRef.current.mergeTags(target_tag_id, merged_tags_ids, success_callback, fail_callback); + }, []); + const modifyColumnWidth = useCallback((columnKey, newWidth) => { storeRef.current.modifyColumnWidth(columnKey, newWidth); }, [storeRef]); @@ -273,6 +277,7 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, .. updateTag, addTagLinks, deleteTagLinks, + mergeTags, updateLocalTag, selectTag: handelSelectTag, modifyColumnWidth, diff --git a/frontend/src/tag/store/data-processor.js b/frontend/src/tag/store/data-processor.js index e8d187fd078..e0dcc9ff68d 100644 --- a/frontend/src/tag/store/data-processor.js +++ b/frontend/src/tag/store/data-processor.js @@ -222,7 +222,8 @@ class DataProcessor { break; } case OPERATION_TYPE.ADD_TAG_LINKS: - case OPERATION_TYPE.DELETE_TAG_LINKS: { + case OPERATION_TYPE.DELETE_TAG_LINKS: + case OPERATION_TYPE.MERGE_TAGS: { this.buildTagsTree(table.rows, table); break; } diff --git a/frontend/src/tag/store/index.js b/frontend/src/tag/store/index.js index 0cf703fcbe7..0c321d86412 100644 --- a/frontend/src/tag/store/index.js +++ b/frontend/src/tag/store/index.js @@ -386,14 +386,27 @@ class Store { this.applyOperation(operation); } - modifyColumnWidth = (columnKey, newWidth) => { + mergeTags(target_tag_id, merged_tags_ids, success_callback, fail_callback) { + const type = OPERATION_TYPE.MERGE_TAGS; + const operation = this.createOperation({ + type, + repo_id: this.repoId, + target_tag_id, + merged_tags_ids, + success_callback, + fail_callback, + }); + this.applyOperation(operation); + } + + modifyColumnWidth(columnKey, newWidth) { const type = OPERATION_TYPE.MODIFY_COLUMN_WIDTH; const column = getColumnByKey(this.data.columns, columnKey); const operation = this.createOperation({ type, repo_id: this.repoId, column_key: columnKey, new_width: newWidth, old_width: column.width }); this.applyOperation(operation); - }; + } } export default Store; diff --git a/frontend/src/tag/store/operations/apply.js b/frontend/src/tag/store/operations/apply.js index 4da72ce5fd1..0fa3713897b 100644 --- a/frontend/src/tag/store/operations/apply.js +++ b/frontend/src/tag/store/operations/apply.js @@ -6,6 +6,8 @@ import { PRIVATE_COLUMN_KEY } from '../../constants'; import { username } from '../../../utils/constants'; import { addRowLinks, removeRowLinks } from '../../utils/link'; import { getRecordIdFromRecord } from '../../../metadata/utils/cell'; +import { getRowById, getRowsByIds } from '../../../metadata/utils/table'; +import { getChildLinks, getParentLinks, getTagFileLinks } from '../../utils/cell'; dayjs.extend(utc); @@ -154,15 +156,13 @@ export default function apply(data, operation) { if (currentRowId === row_id) { // add parent tags to current tag updatedRow = addRowLinks(updatedRow, PRIVATE_COLUMN_KEY.PARENT_LINKS, other_rows_ids); - data.rows[index] = updatedRow; - data.id_row_map[currentRowId] = updatedRow; } if (other_rows_ids.includes(currentRowId)) { // add current tag as child tag to related tags updatedRow = addRowLinks(updatedRow, PRIVATE_COLUMN_KEY.SUB_LINKS, [row_id]); - data.rows[index] = updatedRow; - data.id_row_map[currentRowId] = updatedRow; } + data.rows[index] = updatedRow; + data.id_row_map[currentRowId] = updatedRow; }); } else if (column_key === PRIVATE_COLUMN_KEY.SUB_LINKS) { data.rows.forEach((row, index) => { @@ -171,15 +171,14 @@ export default function apply(data, operation) { if (currentRowId === row_id) { // add child tags to current tag updatedRow = addRowLinks(updatedRow, PRIVATE_COLUMN_KEY.SUB_LINKS, other_rows_ids); - data.rows[index] = updatedRow; - data.id_row_map[currentRowId] = updatedRow; + } if (other_rows_ids.includes(currentRowId)) { // add current tag as parent tag to related tags updatedRow = addRowLinks(updatedRow, PRIVATE_COLUMN_KEY.PARENT_LINKS, [row_id]); - data.rows[index] = updatedRow; - data.id_row_map[currentRowId] = updatedRow; } + data.rows[index] = updatedRow; + data.id_row_map[currentRowId] = updatedRow; }); } return data; @@ -194,15 +193,13 @@ export default function apply(data, operation) { if (currentRowId === row_id) { // remove parent tags from current tag updatedRow = removeRowLinks(updatedRow, PRIVATE_COLUMN_KEY.PARENT_LINKS, other_rows_ids); - data.rows[index] = updatedRow; - data.id_row_map[currentRowId] = updatedRow; } if (other_rows_ids.includes(currentRowId)) { // remove current tag as child tag from related tags updatedRow = removeRowLinks(updatedRow, PRIVATE_COLUMN_KEY.SUB_LINKS, [row_id]); - data.rows[index] = updatedRow; - data.id_row_map[currentRowId] = updatedRow; } + data.rows[index] = updatedRow; + data.id_row_map[currentRowId] = updatedRow; }); } else if (column_key === PRIVATE_COLUMN_KEY.SUB_LINKS) { data.rows.forEach((row, index) => { @@ -211,17 +208,114 @@ export default function apply(data, operation) { if (currentRowId === row_id) { // remove child tags from current tag updatedRow = removeRowLinks(updatedRow, PRIVATE_COLUMN_KEY.SUB_LINKS, other_rows_ids); - data.rows[index] = updatedRow; - data.id_row_map[currentRowId] = updatedRow; } if (other_rows_ids.includes(currentRowId)) { // remove current tag as parent tag from related tags updatedRow = removeRowLinks(updatedRow, PRIVATE_COLUMN_KEY.PARENT_LINKS, [row_id]); - data.rows[index] = updatedRow; - data.id_row_map[currentRowId] = updatedRow; + } + data.rows[index] = updatedRow; + data.id_row_map[currentRowId] = updatedRow; + }); + } + return data; + } + case OPERATION_TYPE.MERGE_TAGS: { + const { target_tag_id, merged_tags_ids } = operation; + const targetTag = getRowById(data, target_tag_id); + const mergedTags = getRowsByIds(data, merged_tags_ids); + if (!targetTag || mergedTags.length === 0) { + return data; + } + const opTagsIds = [target_tag_id, ...merged_tags_ids]; + const parentLinks = getParentLinks(targetTag); + const childLinks = getChildLinks(targetTag); + const fileLinks = getTagFileLinks(targetTag); + const idParentLinkExistMap = parentLinks.reduce((currIdParentLinkExist, link) => ({ ...currIdParentLinkExist, [link.row_id]: true }), {}); + const idChildLinkExistMap = childLinks.reduce((currIdChildLinkExist, link) => ({ ...currIdChildLinkExist, [link.row_id]: true }), {}); + const idFileLinkExistMap = fileLinks.reduce((currIdFileLinkExistMap, link) => ({ ...currIdFileLinkExistMap, [link.row_id]: true }), {}); + + // 1. get unique parent/child/file links from merged tags which not exist in target tag + let newParentTagsIds = []; + let newChildTagsIds = []; + let newFilesIds = []; + mergedTags.forEach((mergedTag) => { + const currParentLinks = getParentLinks(mergedTag); + const currChildLinks = getChildLinks(mergedTag); + const currFileLinks = getTagFileLinks(mergedTag); + currParentLinks.forEach((parentLink) => { + const parentLinkedTagId = parentLink.row_id; + if (!opTagsIds.includes(parentLinkedTagId) && !idParentLinkExistMap[parentLinkedTagId]) { + newParentTagsIds.push(parentLinkedTagId); + idParentLinkExistMap[parentLinkedTagId] = true; + } + }); + currChildLinks.forEach((childLink) => { + const childLinkedTagId = childLink.row_id; + if (!opTagsIds.includes(childLinkedTagId) && !idChildLinkExistMap[childLinkedTagId]) { + newChildTagsIds.push(childLinkedTagId); + idChildLinkExistMap[childLinkedTagId] = true; + } + }); + currFileLinks.forEach((fileLink) => { + const linkedFileId = fileLink.row_id; + if (!idFileLinkExistMap[linkedFileId]) { + newFilesIds.push(linkedFileId); + idFileLinkExistMap[linkedFileId] = true; } }); + }); + + // 2. delete merged tags + const idTagMergedMap = mergedTags.reduce((currIdTagMergedMap, tag) => ({ ...currIdTagMergedMap, [tag._id]: true }), {}); + let updatedRows = []; + data.rows.forEach((tag) => { + const currentTagId = tag._id; + if (idTagMergedMap[currentTagId]) { + delete data.id_row_map[currentTagId]; + } else { + updatedRows.push(tag); + } + }); + + // 3. merge parent links into target tag + // 4. merge child links into target tag + // 5. merge file links into target tag + const hasNewParentLinks = newParentTagsIds.length > 0; + const hasNewChildLinks = newChildTagsIds.length > 0; + const hasNewFileLinks = newFilesIds.length > 0; + if (hasNewParentLinks || hasNewChildLinks || hasNewFileLinks) { + updatedRows.forEach((row, index) => { + const currentRowId = row._id; + let updatedRow = { ...row }; + if (currentRowId === target_tag_id) { + if (hasNewParentLinks) { + // add parent links + updatedRow = addRowLinks(updatedRow, PRIVATE_COLUMN_KEY.PARENT_LINKS, newParentTagsIds); + } + if (hasNewChildLinks) { + // add child links + updatedRow = addRowLinks(updatedRow, PRIVATE_COLUMN_KEY.SUB_LINKS, newChildTagsIds); + } + if (hasNewFileLinks) { + // add file links + updatedRow = addRowLinks(updatedRow, PRIVATE_COLUMN_KEY.TAG_FILE_LINKS, newFilesIds); + } + } + + if (newParentTagsIds.includes(currentRowId)) { + // add target tag as child tag to related tags + updatedRow = addRowLinks(updatedRow, PRIVATE_COLUMN_KEY.SUB_LINKS, [target_tag_id]); + } + if (newChildTagsIds.includes(currentRowId)) { + // add target tag as parent tag to related tags + updatedRow = addRowLinks(updatedRow, PRIVATE_COLUMN_KEY.PARENT_LINKS, [target_tag_id]); + } + updatedRows[index] = updatedRow; + data.id_row_map[currentRowId] = updatedRow; + }); } + + data.rows = updatedRows; return data; } case OPERATION_TYPE.MODIFY_COLUMN_WIDTH: { diff --git a/frontend/src/tag/store/operations/constants.js b/frontend/src/tag/store/operations/constants.js index 672afd9e78d..23e942b165c 100644 --- a/frontend/src/tag/store/operations/constants.js +++ b/frontend/src/tag/store/operations/constants.js @@ -7,6 +7,7 @@ export const OPERATION_TYPE = { RELOAD_RECORDS: 'reload_records', ADD_TAG_LINKS: 'add_tag_links', DELETE_TAG_LINKS: 'delete_tag_links', + MERGE_TAGS: 'merge_tags', MODIFY_LOCAL_RECORDS: 'modify_local_records', @@ -22,6 +23,7 @@ export const OPERATION_ATTRIBUTES = { [OPERATION_TYPE.RELOAD_RECORDS]: ['repo_id', 'row_ids'], [OPERATION_TYPE.ADD_TAG_LINKS]: ['repo_id', 'column_key', 'row_id', 'other_rows_ids'], [OPERATION_TYPE.DELETE_TAG_LINKS]: ['repo_id', 'column_key', 'row_id', 'other_rows_ids'], + [OPERATION_TYPE.MERGE_TAGS]: ['repo_id', 'target_tag_id', 'merged_tags_ids'], [OPERATION_TYPE.MODIFY_LOCAL_RECORDS]: ['repo_id', 'row_ids', 'id_row_updates', 'id_original_row_updates', 'id_old_row_data', 'id_original_old_row_data', 'is_copy_paste', 'is_rename', 'id_obj_id'], [OPERATION_TYPE.MODIFY_COLUMN_WIDTH]: ['column_key', 'new_width', 'old_width'], }; diff --git a/frontend/src/tag/store/server-operator.js b/frontend/src/tag/store/server-operator.js index 879a013eb01..b6b075bf10a 100644 --- a/frontend/src/tag/store/server-operator.js +++ b/frontend/src/tag/store/server-operator.js @@ -97,6 +97,15 @@ class ServerOperator { }); break; } + case OPERATION_TYPE.MERGE_TAGS: { + const { target_tag_id, merged_tags_ids } = operation; + this.context.mergeTags(target_tag_id, merged_tags_ids).then((res) => { + callback({ operation }); + }).catch((error) => { + callback({ error: gettext('Failed to merge tags') }); + }); + break; + } case OPERATION_TYPE.RESTORE_RECORDS: { const { repo_id, rows_data } = operation; if (!Array.isArray(rows_data) || rows_data.length === 0) { diff --git a/frontend/src/tag/utils/cell.js b/frontend/src/tag/utils/cell.js index a6232767ad7..123097bee00 100644 --- a/frontend/src/tag/utils/cell.js +++ b/frontend/src/tag/utils/cell.js @@ -32,10 +32,13 @@ export const getChildLinks = (tag) => { return (tag && tag[PRIVATE_COLUMN_KEY.SUB_LINKS]) || []; }; +export const getTagFileLinks = (tag) => { + return (tag && tag[PRIVATE_COLUMN_KEY.TAG_FILE_LINKS]) || []; +}; + export const getTagFilesCount = (tag) => { - const links = tag ? tag[PRIVATE_COLUMN_KEY.TAG_FILE_LINKS] : []; - if (Array.isArray(links)) return links.length; - return 0; + const links = getTagFileLinks(tag); + return Array.isArray(links) ? links.length : 0; }; export const getTagsByNameOrColor = (tags, nameOrColor) => { if (!Array.isArray(tags) || tags.length === 0) return []; diff --git a/frontend/src/tag/views/all-tags/index.js b/frontend/src/tag/views/all-tags/index.js index 9acd3b8bfd6..14dc7b5a32a 100644 --- a/frontend/src/tag/views/all-tags/index.js +++ b/frontend/src/tag/views/all-tags/index.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; import { CenteredLoading } from '@seafile/sf-metadata-ui-component'; import toaster from '../../../components/toast'; import TagsTable from './tags-table'; @@ -17,6 +17,8 @@ const AllTags = ({ updateCurrentPath, ...params }) => { const [displayTag, setDisplayTag] = useState(''); const [isLoadingMore, setLoadingMore] = useState(false); + const tagsTableWrapperRef = useRef(null); + const { isLoading, isReloading, tagsData, store, context, currentPath } = useTags(); useEffect(() => { @@ -72,6 +74,11 @@ const AllTags = ({ updateCurrentPath, ...params }) => { } }, [isLoading, isReloading, onChangeDisplayTag]); + const getTagsTableWrapperOffsets = useCallback(() => { + if (!tagsTableWrapperRef.current) return {}; + return tagsTableWrapperRef.current.getBoundingClientRect(); + }, []); + if (isReloading) return (); if (displayTag) { @@ -85,7 +92,7 @@ const AllTags = ({ updateCurrentPath, ...params }) => { } return ( -
+
{ setDisplayTag={onChangeDisplayTag} isLoadingMoreRecords={isLoadingMore} loadMore={loadMore} + getTagsTableWrapperOffsets={getTagsTableWrapperOffsets} />
); diff --git a/frontend/src/tag/views/all-tags/tags-table/context-menu-options.js b/frontend/src/tag/views/all-tags/tags-table/context-menu-options.js index 981058d735a..57593063bc4 100644 --- a/frontend/src/tag/views/all-tags/tags-table/context-menu-options.js +++ b/frontend/src/tag/views/all-tags/tags-table/context-menu-options.js @@ -13,6 +13,7 @@ const OPERATION = { DELETE_TAG: 'delete_tag', DELETE_TAGS: 'delete_tags', NEW_SUB_TAG: 'new_sub_tag', + MERGE_TAGS: 'merge_tags', }; export const createContextMenuOptions = ({ @@ -25,11 +26,13 @@ export const createContextMenuOptions = ({ showRecordAsTree, treeMetrics, treeNodeKeyRecordIdMap, + menuPosition, hideMenu, recordGetterByIndex, recordGetterById, onDeleteTags, onNewSubTag, + onMergeTags, }) => { const canDeleteTag = context.checkCanDeleteTag(); const canAddTag = context.canAddTag(); @@ -55,6 +58,10 @@ export const createContextMenuOptions = ({ onNewSubTag(option.parentTagId); break; } + case OPERATION.MERGE_TAGS: { + onMergeTags(option.tagsIds, menuPosition); + break; + } default: { break; } @@ -115,6 +122,13 @@ export const createContextMenuOptions = ({ tagsIds, }); } + if (tagsIds.length > 1) { + options.push({ + label: gettext('Merge tags'), + value: OPERATION.MERGE_TAGS, + tagsIds, + }); + } return options; } diff --git a/frontend/src/tag/views/all-tags/tags-table/formatter/child-tags.js b/frontend/src/tag/views/all-tags/tags-table/formatter/child-tags.js index d27cbd527ca..7b2ec960e2b 100644 --- a/frontend/src/tag/views/all-tags/tags-table/formatter/child-tags.js +++ b/frontend/src/tag/views/all-tags/tags-table/formatter/child-tags.js @@ -1,12 +1,20 @@ import React, { useMemo } from 'react'; import { NumberFormatter } from '@seafile/sf-metadata-ui-component'; +import { useTags } from '../../../../hooks'; +import { getRowsByIds } from '../../../../../components/sf-table/utils/table'; const ChildTagsFormatter = ({ record, column }) => { + const { tagsData } = useTags(); const childTagsLinksCount = useMemo(() => { - const subTagLinks = record[column.key]; - return Array.isArray(subTagLinks) ? subTagLinks.length : 0; - }, [record, column]); + const childTagLinks = record[column.key]; + if (!Array.isArray(childTagLinks) || childTagLinks.length === 0) { + return 0; + } + const childTagsIds = childTagLinks.map((link) => link.row_id); + const subTags = getRowsByIds(tagsData, childTagsIds); + return subTags.length; + }, [record, column, tagsData]); return (
diff --git a/frontend/src/tag/views/all-tags/tags-table/index.js b/frontend/src/tag/views/all-tags/tags-table/index.js index f022de20ab9..0ff5116a0c6 100644 --- a/frontend/src/tag/views/all-tags/tags-table/index.js +++ b/frontend/src/tag/views/all-tags/tags-table/index.js @@ -2,6 +2,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import PropTypes from 'prop-types'; import SFTable from '../../../../components/sf-table'; import EditTagDialog from '../../../components/dialog/edit-tag-dialog'; +import MergeTagsSelector from '../../../components/merge-tags-selector'; import { createTableColumns } from './columns-factory'; import { createContextMenuOptions } from './context-menu-options'; import { gettext } from '../../../../utils/constants'; @@ -33,33 +34,15 @@ const TagsTable = ({ modifyColumnWidth: modifyColumnWidthAPI, setDisplayTag, loadMore, + getTagsTableWrapperOffsets, }) => { - const { tagsData, updateTag, deleteTags, addTagLinks, deleteTagLinks, addChildTag } = useTags(); + const { tagsData, updateTag, deleteTags, addTagLinks, deleteTagLinks, addChildTag, mergeTags } = useTags(); const [isShowNewSubTagDialog, setIsShowNewSubTagDialog] = useState(false); + const [isShowMergeTagsSelector, setIsShowMergeTagsSelector] = useState(false); const parentTagIdRef = useRef(null); - - const onDeleteTags = useCallback((tagsIds) => { - deleteTags(tagsIds); - - const eventBus = EventBus.getInstance(); - eventBus.dispatch(EVENT_BUS_TYPE.SELECT_NONE); - }, [deleteTags]); - - const onNewSubTag = useCallback((parentTagId) => { - parentTagIdRef.current = parentTagId; - setIsShowNewSubTagDialog(true); - }, []); - - const closeNewSubTagDialog = useCallback(() => { - parentTagIdRef.current = null; - setIsShowNewSubTagDialog(false); - }, []); - - const handelAddChildTag = useCallback((tagData, callback) => { - addChildTag(tagData, parentTagIdRef.current, callback); - }, [addChildTag]); + const mergeTagsSelectorProps = useRef({}); const table = useMemo(() => { if (!tagsData) { @@ -121,12 +104,44 @@ const TagsTable = ({ return scroll || {}; }, []); - const storeGridScroll = useCallback((gridScroll) => { - window.sfTagsDataContext.localStorage.setItem(KEY_STORE_SCROLL, JSON.stringify(gridScroll)); + const onDeleteTags = useCallback((tagsIds) => { + deleteTags(tagsIds); + + const eventBus = EventBus.getInstance(); + eventBus.dispatch(EVENT_BUS_TYPE.SELECT_NONE); + }, [deleteTags]); + + const onNewSubTag = useCallback((parentTagId) => { + parentTagIdRef.current = parentTagId; + setIsShowNewSubTagDialog(true); }, []); - const foldedGroups = useMemo(() => { - return {}; + const closeNewSubTagDialog = useCallback(() => { + parentTagIdRef.current = null; + setIsShowNewSubTagDialog(false); + }, []); + + const onMergeTags = useCallback((tagsIds, menuPosition) => { + const { left, top } = getTagsTableWrapperOffsets(); + mergeTagsSelectorProps.current.mergeTagsIds = tagsIds; + mergeTagsSelectorProps.current.position = { + left: (menuPosition.left || 0) + (left || 0), + top: (menuPosition.top || 0) + (top || 0), + }; + setIsShowMergeTagsSelector(true); + }, [getTagsTableWrapperOffsets]); + + const closeMergeTagsSelector = useCallback(() => { + mergeTagsSelectorProps.current = {}; + setIsShowMergeTagsSelector(false); + }, []); + + const handelAddChildTag = useCallback((tagData, callback) => { + addChildTag(tagData, parentTagIdRef.current, callback); + }, [addChildTag]); + + const storeGridScroll = useCallback((gridScroll) => { + window.sfTagsDataContext.localStorage.setItem(KEY_STORE_SCROLL, JSON.stringify(gridScroll)); }, []); const storeFoldedGroups = useCallback(() => {}, []); @@ -145,8 +160,9 @@ const TagsTable = ({ context, onDeleteTags, onNewSubTag, + onMergeTags, }); - }, [context, onDeleteTags, onNewSubTag]); + }, [context, onDeleteTags, onNewSubTag, onMergeTags]); const checkCanModifyTag = useCallback((tag) => { return context.canModifyTag(tag); @@ -174,7 +190,6 @@ const TagsTable = ({ recordsTree={recordsTree} keyTreeNodeFoldedMap={keyTreeNodeFoldedMap} canModifyTags={canModifyTags} - foldedGroups={foldedGroups} gridScroll={gridScroll} visibleColumns={visibleColumns} noRecordsTipsText={gettext('No tags')} @@ -193,6 +208,9 @@ const TagsTable = ({ {isShowNewSubTagDialog && ( )} + {isShowMergeTagsSelector && ( + + )} ); }; diff --git a/seahub/repo_metadata/apis.py b/seahub/repo_metadata/apis.py index 651769f249c..925ea916158 100644 --- a/seahub/repo_metadata/apis.py +++ b/seahub/repo_metadata/apis.py @@ -1996,7 +1996,7 @@ def delete(self, request, repo_id): tags_table_id = tags_table['id'] try: - resp = metadata_server_api.delete_rows(tags_table_id, tag_ids) + metadata_server_api.delete_rows(tags_table_id, tag_ids) except Exception as e: logger.error(e) error_msg = 'Internal Server Error' @@ -2066,7 +2066,7 @@ def post(self, request, repo_id): if link_column_key == TAGS_TABLE.columns.parent_links.key or link_column_key == TAGS_TABLE.columns.sub_links.key: try: init_tag_self_link_columns(metadata_server_api, tags_table_id) - link_id = TAGS_TABLE.self_link_id; + link_id = TAGS_TABLE.self_link_id is_linked_back = link_column_key == TAGS_TABLE.columns.sub_links.key if True else False except Exception as e: logger.error(e) @@ -2322,3 +2322,142 @@ def get(self, request, repo_id, tag_id): return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) return Response(tag_files_query) + + +class MetadataMergeTags(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def post(self, request, repo_id): + target_tag_id = request.data.get('target_tag_id') + merged_tags_ids = request.data.get('merged_tags_ids') + + if not target_tag_id: + return api_error(status.HTTP_400_BAD_REQUEST, 'target_tag_id invalid') + + if not merged_tags_ids: + return api_error(status.HTTP_400_BAD_REQUEST, 'merged_tags_ids invalid') + + metadata = RepoMetadata.objects.filter(repo_id=repo_id).first() + if not metadata or not metadata.enabled: + error_msg = f'The metadata module is disabled for repo {repo_id}.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + repo = seafile_api.get_repo(repo_id) + if not repo: + error_msg = 'Library %s not found.' % repo_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + if not can_read_metadata(request, repo_id): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + metadata_server_api = MetadataServerAPI(repo_id, request.user.username) + + try: + metadata = metadata_server_api.get_metadata() + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + from seafevents.repo_metadata.constants import TAGS_TABLE + tables = metadata.get('tables', []) + tags_table_id = [table['id'] for table in tables if table['name'] == TAGS_TABLE.name] + tags_table_id = tags_table_id[0] if tags_table_id else None + if not tags_table_id: + return api_error(status.HTTP_404_NOT_FOUND, 'tags not be used') + + try: + columns_data = metadata_server_api.list_columns(tags_table_id) + columns = columns_data.get('columns', []) + + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + op_tags_ids = [target_tag_id] + merged_tags_ids + op_tags_ids_str = ', '.join([f'"{id}"' for id in op_tags_ids]) + sql = f'SELECT * FROM {TAGS_TABLE.name} WHERE `{TAGS_TABLE.columns.id.name}` in ({op_tags_ids_str})' + try: + query_new_rows = metadata_server_api.query_rows(sql) + op_tags = query_new_rows.get('results', []) + except Exception as e: + logger.error(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') + + if not op_tags: + return api_error(status.HTTP_400_BAD_REQUEST, 'tags not found') + + target_tag = next((tag for tag in op_tags if tag.get(TAGS_TABLE.columns.id.name) == target_tag_id), None) + if not target_tag: + return api_error(status.HTTP_400_BAD_REQUEST, 'target_tag_id invalid') + + merged_tags = [tag for tag in op_tags if tag[TAGS_TABLE.columns.id.name] in merged_tags_ids] + if not merged_tags: + return api_error(status.HTTP_400_BAD_REQUEST, 'merged_tags_ids invalid') + + # get unique parent/child/file links from merged tags which not exist in target tag + exist_parent_tags_ids = [link['row_id'] for link in target_tag.get(TAGS_TABLE.columns.parent_links.key, [])] + exist_child_tags_ids = [link['row_id'] for link in target_tag.get(TAGS_TABLE.columns.sub_links.key, [])] + exist_files_ids = [link['row_id'] for link in target_tag.get(TAGS_TABLE.columns.file_links.key, [])] + new_parent_tags_ids = [] + new_child_tags_ids = [] + new_files_ids = [] + for merged_tag in merged_tags: + merged_parent_tags_ids = [link['row_id'] for link in merged_tag.get(TAGS_TABLE.columns.parent_links.key, [])] + merged_child_tags_ids = [link['row_id'] for link in merged_tag.get(TAGS_TABLE.columns.sub_links.key, [])] + merged_files_ids = [link['row_id'] for link in merged_tag.get(TAGS_TABLE.columns.file_links.key, [])] + for merged_parent_tag_id in merged_parent_tags_ids: + if merged_parent_tag_id not in op_tags_ids and merged_parent_tag_id not in exist_parent_tags_ids: + new_parent_tags_ids.append(merged_parent_tag_id) + exist_parent_tags_ids.append(merged_parent_tag_id) + + for merged_child_tag_id in merged_child_tags_ids: + if merged_child_tag_id not in op_tags_ids and merged_child_tag_id not in exist_child_tags_ids: + new_child_tags_ids.append(merged_child_tag_id) + exist_child_tags_ids.append(merged_child_tag_id) + + for merged_file_id in merged_files_ids: + if merged_file_id not in exist_files_ids: + new_files_ids.append(merged_file_id) + exist_files_ids.append(merged_file_id) + + parent_link_column = [column for column in columns if column['key'] == TAGS_TABLE.columns.parent_links.key and column['type'] == 'link'] + parent_link_column = parent_link_column[0] if parent_link_column else None + + # add new parent tags + if new_parent_tags_ids: + try: + metadata_server_api.insert_link(TAGS_TABLE.self_link_id, tags_table_id, { target_tag_id: new_parent_tags_ids }) + except Exception as e: + logger.error(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') + + # add new child tags + if new_child_tags_ids: + try: + metadata_server_api.insert_link(TAGS_TABLE.self_link_id, tags_table_id, { target_tag_id: new_child_tags_ids }, True) + except Exception as e: + logger.error(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') + + # add new tag files + if new_files_ids: + try: + metadata_server_api.insert_link(TAGS_TABLE.file_link_id, tags_table_id, { target_tag_id: new_files_ids }) + except Exception as e: + logger.error(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') + + + # remove merge tags + try: + metadata_server_api.delete_rows(tags_table_id, merged_tags_ids) + except Exception as e: + logger.error(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') + + return Response({'success': True}) diff --git a/seahub/repo_metadata/urls.py b/seahub/repo_metadata/urls.py index 25ce0fb2a2e..47cff477fe0 100644 --- a/seahub/repo_metadata/urls.py +++ b/seahub/repo_metadata/urls.py @@ -2,7 +2,7 @@ from .apis import MetadataRecords, MetadataManage, MetadataColumns, MetadataRecord, \ MetadataFolders, MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView, FacesRecords, \ FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails, PeoplePhotos, MetadataTagsStatusManage, MetadataTags, \ - MetadataTagsLinks, MetadataFileTags, MetadataTagFiles, MetadataDetailsSettingsView, MetadataOCRManageView + MetadataTagsLinks, MetadataFileTags, MetadataTagFiles, MetadataMergeTags, MetadataDetailsSettingsView, MetadataOCRManageView urlpatterns = [ re_path(r'^$', MetadataManage.as_view(), name='api-v2.1-metadata'), @@ -37,4 +37,5 @@ re_path(r'^tags-links/$', MetadataTagsLinks.as_view(), name='api-v2.1-metadata-tags-links'), re_path(r'^file-tags/$', MetadataFileTags.as_view(), name='api-v2.1-metadata-file-tags'), re_path(r'^tag-files/(?P.+)/$', MetadataTagFiles.as_view(), name='api-v2.1-metadata-tag-files'), + re_path(r'^merge-tags/$', MetadataMergeTags.as_view(), name='api-v2.1-metadata-merge-tags'), ]