1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-08 10:22:46 +00:00

feat(tag): support merge tags (#7402)

This commit is contained in:
Jerry Ren
2025-01-21 15:08:52 +08:00
committed by GitHub
parent b606f65f4e
commit 16b361f932
18 changed files with 660 additions and 60 deletions

View File

@@ -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;

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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 (<span className="none-search-result">{noOptionsTip}</span>);
}
return displayTags.map((tag, i) => {
const tagId = getTagId(tag);
const tagName = getTagName(tag);
const tagColor = getTagColor(tag);
return (
<div key={tagId} className="sf-metadata-tags-editor-tag-item" ref={selectItemRef}>
<div
className={classnames('sf-metadata-tags-editor-tag-container pl-2', { 'sf-metadata-tags-editor-tag-container-highlight': i === highlightIndex })}
onMouseDown={() => onSelectTag(tagId)}
onMouseEnter={() => onMenuMouseEnter(i)}
onMouseLeave={() => onMenuMouseLeave(i)}
>
<div className="sf-metadata-tag-color-and-name">
<div className="sf-metadata-tag-color" style={{ backgroundColor: tagColor }}></div>
<div className="sf-metadata-tag-name">{tagName}</div>
</div>
</div>
</div>
);
});
}, [displayTags, searchValue, highlightIndex, onMenuMouseEnter, onMenuMouseLeave, onSelectTag]);
return (
<ModalPortal>
<ClickOutside onClickOutside={closeSelector}>
<div className="sf-metadata-merge-tags-selector" style={{ ...position, position: 'fixed', width: 300, zIndex: Z_INDEX_EDITOR_CONTAINER }} ref={editorRef}>
<div className="sf-metadata-search-tags-container">
<SearchInput
autoFocus
placeholder={gettext('Merge tags to:')}
onKeyDown={onKeyDown}
onChange={onChangeSearch}
className="sf-metadata-search-tags"
/>
</div>
<div className="sf-metadata-merge-tags-selector-container" ref={editorContainerRef}>
{renderOptions()}
</div>
</div>
</ClickOutside>
</ModalPortal>
);
};
MergeTagsSelector.propTypes = {
tagsTable: PropTypes.object,
tags: PropTypes.array,
position: PropTypes.object,
};
export default MergeTagsSelector;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}
});
} 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;
}
});
}
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;
}
});
} else if (column_key === PRIVATE_COLUMN_KEY.SUB_LINKS) {
data.rows.forEach((row, index) => {
@@ -211,19 +208,116 @@ 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;
}
});
}
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: {
const { column_key, new_width } = operation;
const columnIndex = data.columns.findIndex(column => column.key === column_key);

View File

@@ -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'],
};

View File

@@ -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) {

View File

@@ -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 [];

View File

@@ -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 (<CenteredLoading />);
if (displayTag) {
@@ -85,7 +92,7 @@ const AllTags = ({ updateCurrentPath, ...params }) => {
}
return (
<div className="sf-metadata-tags-wrapper sf-metadata-all-tags-wrapper">
<div className="sf-metadata-tags-wrapper sf-metadata-all-tags-wrapper" ref={tagsTableWrapperRef}>
<TagsTable
context={context}
tagsData={tagsData}
@@ -93,6 +100,7 @@ const AllTags = ({ updateCurrentPath, ...params }) => {
setDisplayTag={onChangeDisplayTag}
isLoadingMoreRecords={isLoadingMore}
loadMore={loadMore}
getTagsTableWrapperOffsets={getTagsTableWrapperOffsets}
/>
</div>
);

View File

@@ -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;
}

View File

@@ -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 (
<div className="sf-table-child-tags-formatter sf-table-cell-formatter sf-metadata-ui cell-formatter-container">

View File

@@ -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 && (
<EditTagDialog tags={table.rows} title={gettext('New child tag')} onToggle={closeNewSubTagDialog} onSubmit={handelAddChildTag} />
)}
{isShowMergeTagsSelector && (
<MergeTagsSelector {...mergeTagsSelectorProps.current} closeSelector={closeMergeTagsSelector} mergeTags={mergeTags} />
)}
</>
);
};

View File

@@ -1996,7 +1996,7 @@ class MetadataTags(APIView):
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 @@ class MetadataTagsLinks(APIView):
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 @@ class MetadataTagFiles(APIView):
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})

View File

@@ -2,7 +2,7 @@ from django.urls import re_path
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 @@ urlpatterns = [
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<tag_id>.+)/$', 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'),
]