Skip to content

Commit

Permalink
feat(tag): support merge tags (#7402)
Browse files Browse the repository at this point in the history
  • Loading branch information
renjie-run authored Jan 21, 2025
1 parent b606f65 commit 16b361f
Show file tree
Hide file tree
Showing 18 changed files with 660 additions and 60 deletions.
4 changes: 2 additions & 2 deletions frontend/src/components/sf-table/context-menu/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/tag/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/tag/components/merge-tags-selector/index.css
Original file line number Diff line number Diff line change
@@ -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;
}
202 changes: 202 additions & 0 deletions frontend/src/tag/components/merge-tags-selector/index.js
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions frontend/src/tag/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
5 changes: 5 additions & 0 deletions frontend/src/tag/hooks/tags.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -273,6 +277,7 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, ..
updateTag,
addTagLinks,
deleteTagLinks,
mergeTags,
updateLocalTag,
selectTag: handelSelectTag,
modifyColumnWidth,
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/tag/store/data-processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
17 changes: 15 additions & 2 deletions frontend/src/tag/store/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading

0 comments on commit 16b361f

Please sign in to comment.