Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tag): support merge tags #7402

Merged
merged 1 commit into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading