1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-01 15:09:14 +00:00

feat(tag): support self link (#7225)

This commit is contained in:
Jerry Ren
2024-12-20 17:59:47 +08:00
committed by GitHub
parent bde5ec063e
commit cb73865b21
24 changed files with 1151 additions and 71 deletions

View File

@@ -105,6 +105,24 @@ class TagsManagerAPI {
return this.req.put(url, params);
};
addTagLinks = (repoID, link_column_key, row_id_map) => {
const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/tags-links/';
const params = {
link_column_key,
row_id_map,
};
return this.req.post(url, params);
};
deleteTagLinks = (repoID, link_column_key, row_id_map) => {
const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/tags-links/';
const params = {
link_column_key,
row_id_map,
};
return this.req.delete(url, { data: params });
};
}
const tagsAPI = new TagsManagerAPI();

View File

@@ -0,0 +1,113 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { SearchInput } from '@seafile/sf-metadata-ui-component';
import EmptyTip from '../../../../components/empty-tip';
import Tags from './tags';
import { KeyCodes } from '../../../../constants';
import { gettext } from '../../../../utils/constants';
import { getTagsByNameOrColor } from '../../../utils';
const getSelectableTags = (allTags, idTagSelectedMap) => {
if (!idTagSelectedMap) {
return allTags;
}
return allTags.filter((tag) => !idTagSelectedMap[tag._id]);
};
const initIdTagSelectedMap = (linkedTags) => {
let idTagSelectedMap = {};
linkedTags.forEach((linkedTag) => {
idTagSelectedMap[linkedTag._id] = true;
});
return idTagSelectedMap;
};
const AddLinkedTags = ({ allTags, linkedTags, switchToLinkedTagsPage, addLinkedTag, deleteLinedTag }) => {
const initialIdTagSelectedMap = initIdTagSelectedMap(linkedTags);
const [idTagSelectedMap, setIdSelectedMap] = useState(initialIdTagSelectedMap);
const [selectableTags, setSelectableTags] = useState([]);
const [searchValue, setSearchValue] = useState('');
const initialSelectableTagsRef = useRef(getSelectableTags(allTags, initialIdTagSelectedMap));
const selectTag = useCallback((tag) => {
let updatedIdTagSelectedMap = { ...idTagSelectedMap };
if (updatedIdTagSelectedMap[tag._id]) {
delete updatedIdTagSelectedMap[tag._id];
setIdSelectedMap(updatedIdTagSelectedMap);
deleteLinedTag(tag._id);
} else {
updatedIdTagSelectedMap[tag._id] = true;
setIdSelectedMap(updatedIdTagSelectedMap);
addLinkedTag(tag);
}
}, [idTagSelectedMap, addLinkedTag, deleteLinedTag]);
const onKeyDown = useCallback((event) => {
if (
event.keyCode === KeyCodes.ChineseInputMethod ||
event.keyCode === KeyCodes.Enter ||
event.keyCode === KeyCodes.LeftArrow ||
event.keyCode === KeyCodes.RightArrow
) {
event.stopPropagation();
}
}, []);
const onChangeSearch = useCallback((newSearchValue) => {
if (searchValue === newSearchValue) return;
setSearchValue(newSearchValue);
}, [searchValue]);
useEffect(() => {
let searchedTags = [];
if (!searchValue) {
searchedTags = [...initialSelectableTagsRef.current];
} else {
searchedTags = getTagsByNameOrColor(initialSelectableTagsRef.current, searchValue);
}
setSelectableTags(searchedTags);
}, [searchValue, allTags]);
return (
<div className="sf-metadata-set-linked-tags-popover-selector">
<div className="sf-metadata-set-linked-tags-popover-header">
<div className="sf-metadata-set-linked-tags-popover-title">
<span className="sf-metadata-set-linked-tags-popover-header-operation sf-metadata-set-linked-tags-popover-back" onClick={switchToLinkedTagsPage}><i className="sf3-font sf3-font-arrow sf-metadata-set-linked-tags-popover-back-icon"></i></span>
<span>{gettext('Link existing tags')}</span>
</div>
</div>
<div className="sf-metadata-set-linked-tags-popover-body">
<div className="sf-metadata-set-linked-tags-popover-search-container">
<SearchInput
autoFocus
className="sf-metadata-set-linked-tags-popover-search-tags"
placeholder={gettext('Search tag')}
onKeyDown={onKeyDown}
onChange={onChangeSearch}
/>
</div>
{selectableTags.length === 0 && (
<EmptyTip text={gettext('No tags available')} />
)}
{selectableTags.length > 0 && (
<div className="sf-metadata-set-linked-tags-popover-selectable-tags-wrapper">
<Tags selectable tags={selectableTags} idTagSelectedMap={idTagSelectedMap} selectTag={selectTag} />
</div>
)}
</div>
</div>
);
};
AddLinkedTags.propTypes = {
isParentTags: PropTypes.bool,
allTags: PropTypes.array,
linkedTags: PropTypes.array,
switchToLinkedTagsPage: PropTypes.func,
addLinkedTag: PropTypes.func,
deleteLinedTag: PropTypes.func,
};
export default AddLinkedTags;

View File

@@ -0,0 +1,130 @@
.sf-metadata-set-linked-tags-popover .popover {
max-width: unset;
}
.sf-metadata-set-linked-tags-popover-selected,
.sf-metadata-set-linked-tags-popover-selector {
display: flex;
flex-direction: column;
height: 100%;
}
.sf-metadata-set-linked-tags-popover-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 46px;
border-bottom: 1px solid #e9ecef;
}
.sf-metadata-set-linked-tags-popover-title {
display: inline-flex;
align-items: center;
height: 40px;
line-height: 40px;
padding: 0 20px;
font-size: 16px;
font-weight: 500;
}
.sf-metadata-set-linked-tags-popover-selector .sf-metadata-set-linked-tags-popover-title {
margin-left: -3px;
}
.sf-metadata-set-linked-tags-popover-body {
flex: 1;
height: calc(100% - 46px);
}
.sf-metadata-set-linked-tags-popover-selectable-tags-wrapper {
height: calc(100% - 48px);
}
.sf-metadata-editing-tags-list {
height: 100%;
padding: 10px 20px;
overflow-y: scroll;
}
.sf-metadata-editing-tags-list.selectable:hover {
cursor: pointer;
}
.sf-metadata-editing-tag:hover {
background: #f5f5f5;
}
.sf-metadata-editing-tag-container {
display: flex;
align-items: center;
height: 30px;
width: 100%;
border-radius: 2px;
font-size: 13px;
color: #212529;
}
.sf-metadata-set-linked-tags-popover-search-container {
height: 48px;
padding: 10px 20px 0;
}
.sf-metadata-editing-tag-color-name {
display: flex;
align-items: center;
flex: 1;
}
.sf-metadata-editing-tag-color-name .sf-metadata-tag-color {
height: 14px;
width: 14px;
border-radius: 50%;
flex-shrink: 0;
}
.sf-metadata-editing-tag-color-name .sf-metadata-tag-name {
flex: 1;
margin-left: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sf-metadata-editing-tag-operation {
display: flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
cursor: pointer;
}
.sf-metadata-editing-tag-delete-icon {
font-size: 14px;
}
.sf-metadata-editing-tag-delete:hover .sf-metadata-editing-tag-delete-icon {
fill: #555;
}
.sf-metadata-set-linked-tags-popover-header-operation {
display: inline-flex;
width: 24px;
height: 24px;
margin-right: 3px;
align-items: center;
justify-content: center;
}
.sf-metadata-set-linked-tags-popover-header-operation:hover {
border-radius: 3px;
background-color: #efefef;
cursor: pointer;
}
.sf-metadata-set-linked-tags-popover-back-icon {
display: inline-block;
transform: rotate(180deg);
font-size: 14px;
color: #666;
}

View File

@@ -0,0 +1,135 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { UncontrolledPopover } from 'reactstrap';
import isHotkey from 'is-hotkey';
import LinkedTags from './linked-tags';
import AddLinkedTags from './add-linked-tags';
import { getRowsByIds } from '../../../../metadata/utils/table';
import { useTags } from '../../../hooks';
import { getEventClassName } from '../../../../metadata/utils/common';
import './index.css';
const POPOVER_WIDTH = 560;
const POPOVER_WINDOW_SAFE_SPACE = 30;
const POPOVER_MAX_HEIGHT = 520;
const POPOVER_MIN_HEIGHT = 300;
const KEY_MODE_TYPE = {
LINKED_TAGS: 'linked_tags',
ADD_LINKED_TAGS: 'add_linked_tags',
};
const SetLinkedTagsPopover = ({ isParentTags, target, placement, tagLinks, allTags, hidePopover, addTagLinks, deleteTagLinks }) => {
const { tagsData } = useTags();
const linkedRowsIds = Array.isArray(tagLinks) ? tagLinks.map((link) => link.row_id) : [];
const initialLinkedTags = getRowsByIds(tagsData, linkedRowsIds);
const [mode, setMode] = useState(KEY_MODE_TYPE.LINKED_TAGS);
const [linkedTags, setLinkedTags] = useState(initialLinkedTags);
const popoverRef = useRef(null);
const getPopoverInnerStyle = () => {
let style = { width: POPOVER_WIDTH };
const windowHeight = window.innerHeight - POPOVER_WINDOW_SAFE_SPACE;
let maxHeight = POPOVER_MAX_HEIGHT;
if (windowHeight < maxHeight) {
maxHeight = windowHeight;
}
if (maxHeight < POPOVER_MIN_HEIGHT) {
maxHeight = POPOVER_MIN_HEIGHT;
}
style.height = maxHeight;
return style;
};
const onHidePopover = useCallback((event) => {
if (popoverRef.current && !getEventClassName(event).includes('popover') && !popoverRef.current.contains(event.target)) {
hidePopover(event);
event.preventDefault();
event.stopPropagation();
return false;
}
}, [hidePopover]);
const onHotKey = useCallback((event) => {
if (isHotkey('esc', event)) {
event.preventDefault();
hidePopover();
}
}, [hidePopover]);
useEffect(() => {
document.addEventListener('click', onHidePopover, true);
document.addEventListener('keydown', onHotKey);
return () => {
document.removeEventListener('click', onHidePopover, true);
document.removeEventListener('keydown', onHotKey);
};
}, [onHidePopover, onHotKey]);
const deleteLinedTag = useCallback((tagId) => {
let updatedLinkedTags = [...linkedTags];
const deleteIndex = updatedLinkedTags.findIndex((tag) => tag._id === tagId);
if (deleteIndex < 0) return;
updatedLinkedTags.splice(deleteIndex, 1);
setLinkedTags(updatedLinkedTags);
deleteTagLinks(tagId);
}, [linkedTags, deleteTagLinks]);
const addLinkedTag = useCallback((tag) => {
let updatedLinkedTags = [...linkedTags];
updatedLinkedTags.push(tag);
setLinkedTags(updatedLinkedTags);
addTagLinks(tag);
}, [linkedTags, addTagLinks]);
return (
<UncontrolledPopover
isOpen
hideArrow
positionFixed
fade={false}
flip={false}
placement={placement}
target={target}
className="sf-metadata-set-linked-tags-popover"
boundariesElement={document.body}
>
<div ref={popoverRef} style={getPopoverInnerStyle()}>
{mode === KEY_MODE_TYPE.LINKED_TAGS ?
<LinkedTags
isParentTags={isParentTags}
linkedTags={linkedTags}
switchToAddTagsPage={() => setMode(KEY_MODE_TYPE.ADD_LINKED_TAGS)}
deleteLinedTag={deleteLinedTag}
/> :
<AddLinkedTags
allTags={allTags}
linkedTags={linkedTags}
switchToLinkedTagsPage={() => setMode(KEY_MODE_TYPE.LINKED_TAGS)}
addLinkedTag={addLinkedTag}
deleteLinedTag={deleteLinedTag}
/>
}
</div>
</UncontrolledPopover>
);
};
SetLinkedTagsPopover.propTypes = {
isParentTags: PropTypes.bool,
placement: PropTypes.string,
target: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
tagLinks: PropTypes.array,
allTags: PropTypes.array,
hidePopover: PropTypes.func,
addTagLinks: PropTypes.func,
deleteTagLinks: PropTypes.func,
};
SetLinkedTagsPopover.defaultProps = {
placement: 'bottom-end',
};
export default SetLinkedTagsPopover;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from 'reactstrap';
import EmptyTip from '../../../../components/empty-tip';
import Tags from './tags';
import { gettext } from '../../../../utils/constants';
const LinkedTags = ({ isParentTags, linkedTags, switchToAddTagsPage, deleteLinedTag }) => {
return (
<div className="sf-metadata-set-linked-tags-popover-selected">
<div className="sf-metadata-set-linked-tags-popover-header">
<div className="sf-metadata-set-linked-tags-popover-title">
{isParentTags ? gettext('Parent tags') : gettext('Sub tags')}
</div>
<Button size="sm" color="primary" className="mr-2" onClick={switchToAddTagsPage}>{gettext('Link existing tags')}</Button>
</div>
<div className="sf-metadata-set-linked-tags-popover-body">
{linkedTags.length === 0 && (
<EmptyTip text={gettext(isParentTags ? 'No parent tag' : 'No sub tag')} />
)}
{linkedTags.length > 0 && (
<Tags deletable tags={linkedTags} deleteTag={deleteLinedTag} />
)}
</div>
</div>
);
};
LinkedTags.propTypes = {
isParentTags: PropTypes.bool,
linkedTags: PropTypes.array,
switchToAddTagsPage: PropTypes.func,
deleteTag: PropTypes.func,
};
export default LinkedTags;

View File

@@ -0,0 +1,53 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { Icon } from '@seafile/sf-metadata-ui-component';
import { getTagColor, getTagId, getTagName } from '../../../utils';
import { debounce } from '../../../../metadata/utils/common';
const Tags = ({ tags, deletable, selectable, idTagSelectedMap, selectTag, deleteTag }) => {
const clickTag = debounce((tag) => {
if (!selectable) return;
selectTag && selectTag(tag);
}, 200);
const remove = useCallback((tagId) => {
if (!deletable) return;
deleteTag && deleteTag(tagId);
}, [deletable, deleteTag]);
return (
<div className={classnames('sf-metadata-editing-tags-list', { 'selectable': selectable })}>
{tags.map((tag) => {
const tagId = getTagId(tag);
const tagName = getTagName(tag);
const tagColor = getTagColor(tag);
return (
<div className="sf-metadata-editing-tag" key={`sf-metadata-editing-tag-${tagId}`} onClick={() => clickTag(tag)}>
<div className="sf-metadata-editing-tag-container pl-2">
<div className="sf-metadata-editing-tag-color-name">
<div className="sf-metadata-tag-color" style={{ backgroundColor: tagColor }}></div>
<div className="sf-metadata-tag-name">{tagName}</div>
</div>
<div className="sf-metadata-editing-tag-operations">
{deletable && <div className="sf-metadata-editing-tag-operation sf-metadata-editing-tag-delete" onClick={() => remove(tagId)}><Icon iconName="close" className="sf-metadata-editing-tag-delete-icon" /></div>}
{(selectable && idTagSelectedMap && idTagSelectedMap[tagId]) && <div className="sf-metadata-editing-tag-operation sf-metadata-editing-tag-selected"><Icon iconName="check-mark" className="sf-metadata-editing-tag-selected-icon" /></div>}
</div>
</div>
</div>
);
})}
</div>
);
};
Tags.propTypes = {
tags: PropTypes.array,
deletable: PropTypes.bool,
selectable: PropTypes.bool,
selectTag: PropTypes.func,
deleteTag: PropTypes.func,
};
export default Tags;

View File

@@ -9,6 +9,8 @@ export const PRIVATE_COLUMN_KEY = {
TAG_NAME: '_tag_name',
TAG_COLOR: '_tag_color',
TAG_FILE_LINKS: '_tag_file_links',
PARENT_LINKS: '_tag_parent_links',
SUB_LINKS: '_tag_sub_links',
};
export const PRIVATE_COLUMN_KEYS = [

View File

@@ -109,6 +109,14 @@ class Context {
return this.api.deleteTags(this.repoId, tags);
};
addTagLinks = (link_column_key, row_id_map) => {
return this.api.addTagLinks(this.repoId, link_column_key, row_id_map);
};
deleteTagLinks = (link_column_key, row_id_map) => {
return this.api.deleteTagLinks(this.repoId, link_column_key, row_id_map);
};
}
export default Context;

View File

@@ -186,6 +186,14 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, ..
modifyLocalTags(tagIds, idTagUpdates, { [tagId]: originalRowUpdates }, { [tagId]: oldRowData }, { [tagId]: originalOldRowData }, { success_callback, fail_callback });
}, [tagsData, modifyLocalTags]);
const addTagLinks = useCallback((columnKey, tagId, otherTagsIds, { success_callback, fail_callback } = {}) => {
storeRef.current.addTagLinks(columnKey, tagId, otherTagsIds, success_callback, fail_callback);
}, []);
const deleteTagLinks = useCallback((columnKey, tagId, otherTagsIds, { success_callback, fail_callback } = {}) => {
storeRef.current.deleteTagLinks(columnKey, tagId, otherTagsIds, success_callback, fail_callback);
}, []);
useEffect(() => {
if (!handelSelectTag) return;
if (isLoading) return;
@@ -247,6 +255,8 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, ..
deleteTags,
duplicateTag,
updateTag,
addTagLinks,
deleteTagLinks,
updateLocalTag,
selectTag: handelSelectTag,
}}>

View File

@@ -339,6 +339,33 @@ class Store {
this.applyOperation(operation);
}
addTagLinks(column_key, row_id, other_rows_ids, success_callback, fail_callback) {
const type = OPERATION_TYPE.ADD_TAG_LINKS;
const operation = this.createOperation({
type,
repo_id: this.repoId,
column_key,
row_id,
other_rows_ids,
success_callback,
fail_callback,
});
this.applyOperation(operation);
}
deleteTagLinks(column_key, row_id, other_rows_ids, success_callback, fail_callback) {
const type = OPERATION_TYPE.DELETE_TAG_LINKS;
const operation = this.createOperation({
type,
repo_id: this.repoId,
column_key,
row_id,
other_rows_ids,
success_callback,
fail_callback,
});
this.applyOperation(operation);
}
}
export default Store;

View File

@@ -4,6 +4,7 @@ import { UTC_FORMAT_DEFAULT } from '../../../metadata/constants';
import { OPERATION_TYPE } from './constants';
import { PRIVATE_COLUMN_KEY } from '../../constants';
import { username } from '../../../utils/constants';
import { addRowLinks, removeRowLinks } from '../../utils/link';
dayjs.extend(utc);
@@ -84,6 +85,68 @@ export default function apply(data, operation) {
data.rows.push(insertRows);
return data;
}
case OPERATION_TYPE.ADD_TAG_LINKS: {
const { column_key, row_id, other_rows_ids } = operation;
if (column_key === PRIVATE_COLUMN_KEY.PARENT_LINKS) {
data.rows = data.rows.map((row) => {
const currentRowId = row._id;
if (currentRowId === row_id) {
// add parent tags to current tag
row = addRowLinks(row, PRIVATE_COLUMN_KEY.PARENT_LINKS, other_rows_ids);
}
if (other_rows_ids.includes(currentRowId)) {
// add current tag as sub tag to related tags
row = addRowLinks(row, PRIVATE_COLUMN_KEY.SUB_LINKS, [row_id]);
}
return row;
});
} else if (column_key === PRIVATE_COLUMN_KEY.SUB_LINKS) {
data.rows = data.rows.map((row) => {
const currentRowId = row._id;
if (currentRowId === row_id) {
// add sub tags to current tag
row = addRowLinks(row, PRIVATE_COLUMN_KEY.SUB_LINKS, other_rows_ids);
}
if (other_rows_ids.includes(currentRowId)) {
// add current tag as parent tag to related tags
row = addRowLinks(row, PRIVATE_COLUMN_KEY.PARENT_LINKS, [row_id]);
}
return row;
});
}
return data;
}
case OPERATION_TYPE.DELETE_TAG_LINKS: {
const { column_key, row_id, other_rows_ids } = operation;
if (column_key === PRIVATE_COLUMN_KEY.PARENT_LINKS) {
data.rows = data.rows.map((row) => {
const currentRowId = row._id;
if (currentRowId === row_id) {
// remove parent tags from current tag
row = removeRowLinks(row, PRIVATE_COLUMN_KEY.PARENT_LINKS, other_rows_ids);
}
if (other_rows_ids.includes(currentRowId)) {
// remove current tag as sub tag from related tags
row = removeRowLinks(row, PRIVATE_COLUMN_KEY.SUB_LINKS, [row_id]);
}
return row;
});
} else if (column_key === PRIVATE_COLUMN_KEY.SUB_LINKS) {
data.rows = data.rows.map((row) => {
const currentRowId = row._id;
if (currentRowId === row_id) {
// remove sub tags from current tag
row = removeRowLinks(row, PRIVATE_COLUMN_KEY.SUB_LINKS, other_rows_ids);
}
if (other_rows_ids.includes(currentRowId)) {
// remove current tag as parent tag from related tags
row = removeRowLinks(row, PRIVATE_COLUMN_KEY.PARENT_LINKS, [row_id]);
}
return row;
});
}
return data;
}
default: {
return data;
}

View File

@@ -4,6 +4,8 @@ export const OPERATION_TYPE = {
DELETE_RECORDS: 'delete_records',
RESTORE_RECORDS: 'restore_records',
RELOAD_RECORDS: 'reload_records',
ADD_TAG_LINKS: 'add_tag_links',
DELETE_TAG_LINKS: 'delete_tag_links',
MODIFY_LOCAL_RECORDS: 'modify_local_records',
};
@@ -14,6 +16,8 @@ export const OPERATION_ATTRIBUTES = {
[OPERATION_TYPE.DELETE_RECORDS]: ['repo_id', 'tag_ids', 'deleted_tags'],
[OPERATION_TYPE.RESTORE_RECORDS]: ['repo_id', 'rows_data', 'original_rows', 'link_infos', 'upper_row_ids'],
[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.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'],
};

View File

@@ -51,6 +51,30 @@ class ServerOperator {
});
break;
}
case OPERATION_TYPE.ADD_TAG_LINKS: {
const { column_key, row_id, other_rows_ids } = operation;
const id_linked_rows_ids_map = {
[row_id]: other_rows_ids,
};
this.context.addTagLinks(column_key, id_linked_rows_ids_map).then(res => {
callback({ operation });
}).catch(error => {
callback({ error: gettext('Failed to add linked tags') });
});
break;
}
case OPERATION_TYPE.DELETE_TAG_LINKS: {
const { column_key, row_id, other_rows_ids } = operation;
const id_linked_rows_ids_map = {
[row_id]: other_rows_ids,
};
this.context.deleteTagLinks(column_key, id_linked_rows_ids_map).then(res => {
callback({ operation });
}).catch(error => {
callback({ error: gettext('Failed to delete linked tags') });
});
break;
}
case OPERATION_TYPE.RESTORE_RECORDS: {
const { repo_id, rows_data } = operation;
if (!Array.isArray(rows_data) || rows_data.length === 0) {

View File

@@ -24,6 +24,19 @@ export const getTagId = (tag) => {
return tag ? tag[PRIVATE_COLUMN_KEY.ID] : '';
};
export const getParentLinks = (tag) => {
return (tag && tag[PRIVATE_COLUMN_KEY.PARENT_LINKS]) || [];
};
export const getSubLinks = (tag) => {
return (tag && tag[PRIVATE_COLUMN_KEY.SUB_LINKS]) || [];
};
export const getSubTagsCount = (tag) => {
const subLinks = getSubLinks(tag);
return subLinks.length;
};
export const getTagFilesCount = (tag) => {
const links = tag ? tag[PRIVATE_COLUMN_KEY.TAG_FILE_LINKS] : [];
if (Array.isArray(links)) return links.length;

View File

@@ -0,0 +1,27 @@
export const addRowLinks = (row, key, other_rows_ids) => {
let updatedRow = row;
let updatedLinks = Array.isArray(updatedRow[key]) ? [...updatedRow[key]] : [];
other_rows_ids.forEach((otherRowId) => {
if (updatedLinks.findIndex((linked) => linked.row_id === otherRowId) < 0) {
updatedLinks.push({ row_id: otherRowId, display_value: otherRowId });
}
});
updatedRow[key] = updatedLinks;
return updatedRow;
};
export const removeRowLinks = (row, key, other_rows_ids) => {
if (!Array.isArray(row[key]) || row[key].length === 0) {
return row;
}
let updatedRow = row;
let updatedLinks = [...updatedRow[key]];
other_rows_ids.forEach((otherRowId) => {
const deleteIndex = updatedLinks.findIndex((linked) => linked.row_id === otherRowId);
if (deleteIndex > -1) {
updatedLinks.splice(deleteIndex, 1);
}
});
updatedRow[key] = updatedLinks;
return updatedRow;
};

View File

@@ -30,30 +30,36 @@
background-color: #f8f8f8;
}
.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell:first-child {
width: calc((100% - 64px) * 0.7);
height: 100%;
padding-left: 10px;
}
.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell:nth-child(2) {
width: calc((100% - 64px) * 0.3);
height: 100%;
}
.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell:last-child {
width: 64px;
height: 100%;
}
.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell {
font-size: 14px;
position: relative;
height: 100%;
padding: 0 8px;
font-size: 14px;
line-height: 40px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 8px;
}
.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell-tag {
width: calc((100% - 96px) * 0.3);
padding-left: 10px;
}
.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell-parent-tags,
.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell-sub-tags-count,
.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell-tag-files-count {
width: calc((100% - 96px) * 0.2);
}
.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell-operations-wrapper {
width: 96px;
height: 100%;
}
.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell-parent-tags {
display: flex;
align-items: center;
}
.sf-metadata-tags-table .sf-metadata-tags-table-header .sf-metadata-tags-table-cell {
@@ -62,3 +68,11 @@
line-height: 16px;
padding: 10px 8px;
}
.sf-metadata-tags-table .sf-metadata-tags-table-cell .sf-metadata-tags-operation-pop-handler {
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
}

View File

@@ -45,9 +45,11 @@ const Main = React.memo(({ context, tags, onChangeDisplayTag, onLoadMore }) => {
return (
<div className="sf-metadata-tags-table" ref={tableRef} onScroll={handelScroll}>
<div className="sf-metadata-tags-table-header sf-metadata-tags-table-row">
<div className="sf-metadata-tags-table-cell">{gettext('Tag')}</div>
<div className="sf-metadata-tags-table-cell">{gettext('File count')}</div>
<div className="sf-metadata-tags-table-cell"></div>
<div className="sf-metadata-tags-table-cell sf-metadata-tags-table-cell-tag">{gettext('Tag')}</div>
<div className="sf-metadata-tags-table-cell sf-metadata-tags-table-cell-parent-tags">{gettext('Parent tags')}</div>
<div className="sf-metadata-tags-table-cell sf-metadata-tags-table-cell-sub-tags-count">{gettext('Sub tags count')}</div>
<div className="sf-metadata-tags-table-cell sf-metadata-tags-table-cell-tag-files-count">{gettext('File count')}</div>
<div className="sf-metadata-tags-table-cell sf-metadata-tags-table-cell-operations-wrapper"></div>
</div>
{tags.map(tag => {
const id = getTagId(tag);

View File

@@ -4,23 +4,24 @@
width: 100%;
}
.sf-metadata-tags-table-row.freezed .sf-metadata-tags-table-cell .sf-metadata-tags-table-cell-actions,
.sf-metadata-tags-table-row:hover .sf-metadata-tags-table-cell .sf-metadata-tags-table-cell-actions {
display: flex;
align-items: center;
justify-content: center;
}
.sf-metadata-tags-table-cell-tag {
.sf-metadata-tags-table-row .sf-metadata-tags-table-cell-tag {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sf-metadata-tags-table-cell-tag span:hover {
.sf-metadata-tags-table-row .sf-metadata-tags-table-cell-tag span:hover {
cursor: pointer;
}
.sf-metadata-tags-table-cell-tag .sf-metadata-tag-color {
.sf-metadata-tags-table-row .sf-metadata-tags-table-cell-tag .sf-metadata-tag-color {
display: inline-block;
height: 10px;
width: 10px;
@@ -28,7 +29,7 @@
margin-right: 8px;
}
.sf-metadata-tags-table-row:hover .sf-metadata-tags-table-cell .sf-metadata-tags-table-cell-action {
.sf-metadata-tags-table-row .sf-metadata-tags-table-cell .sf-metadata-tags-table-cell-action {
height: 20px;
width: 20px;
display: flex;
@@ -36,6 +37,11 @@
justify-content: center;
}
.sf-metadata-tags-table-row:hover .sf-metadata-tags-table-cell .sf-metadata-tags-table-cell-action .op-icon {
.sf-metadata-tags-table-row .sf-metadata-tags-table-cell .sf-metadata-tags-table-cell-action .sf-dropdown-toggle,
.sf-metadata-tags-table-row .sf-metadata-tags-table-cell .sf-metadata-tags-table-cell-action .op-icon {
margin-left: 0;
}
.sf-metadata-tags-table-cell-parent-tags .sf-metadata-ui.tags-formatter .sf-metadata-ui-tags-container .sf-metadata-ui-tag {
cursor: default;
}

View File

@@ -1,22 +1,33 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { getTagName, getTagColor, getTagFilesCount, getTagId } from '../../../../utils/cell/core';
import { gettext } from '../../../../../utils/constants';
import classnames from 'classnames';
import { FileTagsFormatter } from '@seafile/sf-metadata-ui-component';
import EditTagDialog from '../../../../components/dialog/edit-tag-dialog';
import DeleteConfirmDialog from '../../../../../metadata/components/dialog/delete-confirm-dialog';
import TagMoreOperation from './tag-more-operation';
import SetLinkedTagsPopover from '../../../../components/popover/set-linked-tags-popover';
import { getTagName, getTagColor, getTagFilesCount, getTagId, getParentLinks, getSubTagsCount, getSubLinks } from '../../../../utils/cell/core';
import { gettext } from '../../../../../utils/constants';
import { useTags } from '../../../../hooks';
import { PRIVATE_COLUMN_KEY } from '../../../../constants';
import './index.css';
const Tag = ({ tags, tag, context, onChangeDisplayTag }) => {
const { tagsData, updateTag, deleteTags, addTagLinks, deleteTagLinks } = useTags();
const tagId = getTagId(tag);
const tagName = getTagName(tag);
const tagColor = getTagColor(tag);
const parentLinks = getParentLinks(tag);
const subLinks = getSubLinks(tag);
const subTagsCount = getSubTagsCount(tag);
const fileCount = getTagFilesCount(tag);
const [isShowEditTagDialog, setShowEditTagDialog] = useState(false);
const [isShowDeleteDialog, setShowDeleteDialog] = useState(false);
const [freeze, setFreeze] = useState(false);
const [editingColumnKey, setEditingColumnKey] = useState(null);
const { updateTag, deleteTags } = useTags();
const operationsPopHandlerRef = useRef(null);
const openEditTagDialog = useCallback(() => {
setShowEditTagDialog(true);
@@ -48,23 +59,69 @@ const Tag = ({ tags, tag, context, onChangeDisplayTag }) => {
}
}, [tagId, onChangeDisplayTag]);
const freezeItem = useCallback(() => {
setFreeze(true);
}, []);
const unfreezeItem = useCallback(() => {
setFreeze(false);
}, []);
const hideSetLinkedTagsPopover = useCallback(() => {
setEditingColumnKey(null);
}, []);
const showParentTagsSetter = useCallback(() => {
setEditingColumnKey(PRIVATE_COLUMN_KEY.PARENT_LINKS);
}, []);
const showSubTagsSetter = useCallback(() => {
setEditingColumnKey(PRIVATE_COLUMN_KEY.SUB_LINKS);
}, []);
const getEditingTagLinks = useCallback(() => {
return editingColumnKey === PRIVATE_COLUMN_KEY.PARENT_LINKS ? parentLinks : subLinks;
}, [editingColumnKey, parentLinks, subLinks]);
const handleAddTagLinks = useCallback((linkedTag) => {
const { _id: otherTagId } = linkedTag;
addTagLinks(editingColumnKey, tagId, [otherTagId]);
}, [editingColumnKey, tagId, addTagLinks]);
const handleDeleteTagLinks = useCallback((otherTagId) => {
deleteTagLinks(editingColumnKey, tagId, [otherTagId]);
}, [editingColumnKey, tagId, deleteTagLinks]);
return (
<>
<div className="sf-metadata-tags-table-row">
<div className={classnames('sf-metadata-tags-table-row', { 'freezed': freeze })}>
<div className="sf-metadata-tags-table-cell sf-metadata-tags-table-cell-tag" onClick={handleDisplayTag}>
<span className="sf-metadata-tag-color" style={{ backgroundColor: tagColor }}></span>
<span className="sf-metadata-tag-name">{tagName}</span>
<span className="sf-metadata-tag-name" title={tagName}>{tagName}</span>
</div>
<div className="sf-metadata-tags-table-cell">{fileCount}</div>
<div className="sf-metadata-tags-table-cell">
<div className="sf-metadata-tags-table-cell sf-metadata-tags-table-cell-parent-tags">
<FileTagsFormatter tagsData={tagsData} value={parentLinks} />
</div>
<div className="sf-metadata-tags-table-cell sf-metadata-tags-table-cell-sub-tags-count" title={subTagsCount}>{subTagsCount}</div>
<div className="sf-metadata-tags-table-cell sf-metadata-tags-table-cell-tag-files-count" title={fileCount}>{fileCount}</div>
<div className="sf-metadata-tags-table-cell sf-metadata-tags-table-cell-operations-wrapper">
<div className="sf-metadata-tags-operation-pop-handler" ref={operationsPopHandlerRef}></div>
<div className="sf-metadata-tags-table-cell-actions">
{context.canModifyTag() && (
<div className="sf-metadata-tags-table-cell-action" title={gettext('Edit')} onClick={openEditTagDialog}>
<i className="op-icon sf3-font-rename sf3-font"></i>
</div>
<>
<TagMoreOperation
freezeItem={freezeItem}
unfreezeItem={unfreezeItem}
showParentTagsSetter={showParentTagsSetter}
showSubTagsSetter={showSubTagsSetter}
/>
<div className="sf-metadata-tags-table-cell-action mr-2" title={gettext('Edit')} onClick={openEditTagDialog}>
<i className="op-icon sf3-font-rename sf3-font"></i>
</div>
</>
)}
{context.checkCanDeleteTag() && (
<div className="sf-metadata-tags-table-cell-action ml-2" title={gettext('Delete')} onClick={openDeleteConfirmDialog}>
<div className="sf-metadata-tags-table-cell-action" title={gettext('Delete')} onClick={openDeleteConfirmDialog}>
<i className="op-icon sf3-font-delete1 sf3-font"></i>
</div>
)}
@@ -77,6 +134,17 @@ const Tag = ({ tags, tag, context, onChangeDisplayTag }) => {
{isShowDeleteDialog && (
<DeleteConfirmDialog title={gettext('Delete tag')} content={tagName} onToggle={closeDeleteConfirmDialog} onSubmit={handelDelete} />
)}
{editingColumnKey && (
<SetLinkedTagsPopover
target={operationsPopHandlerRef.current}
isParentTags={editingColumnKey === PRIVATE_COLUMN_KEY.PARENT_LINKS}
tagLinks={getEditingTagLinks()}
allTags={tags}
hidePopover={hideSetLinkedTagsPopover}
addTagLinks={handleAddTagLinks}
deleteTagLinks={handleDeleteTagLinks}
/>
)}
</>
);
};

View File

@@ -0,0 +1,62 @@
import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import ItemDropdownMenu from '../../../../../components/dropdown-menu/item-dropdown-menu';
import { gettext } from '../../../../../utils/constants';
import { isMobile } from '../../../../../utils/utils';
const KEY_MORE_OPERATION = {
SET_PARENT_TAGS: 'set_parent_tags',
SET_SUB_TAGS: 'set_sub_tags',
};
const TagMoreOperation = ({ freezeItem, unfreezeItem, showParentTagsSetter, showSubTagsSetter }) => {
const operationMenus = useMemo(() => {
let menus = [];
menus.push(
{ key: KEY_MORE_OPERATION.SET_PARENT_TAGS, value: gettext('Set parent tags') },
{ key: KEY_MORE_OPERATION.SET_SUB_TAGS, value: gettext('Set sub tags') },
);
return menus;
}, []);
const clickMenu = useCallback((key) => {
switch (key) {
case KEY_MORE_OPERATION.SET_PARENT_TAGS: {
showParentTagsSetter();
return;
}
case KEY_MORE_OPERATION.SET_SUB_TAGS: {
showSubTagsSetter();
return;
}
default: {
return;
}
}
}, [showParentTagsSetter, showSubTagsSetter]);
return (
<div className="sf-metadata-tags-table-cell-action mr-2" title={gettext('More')}>
<ItemDropdownMenu
item={{ name: 'metadata-tag' }}
menuClassname="metadata-tag-dropdown-menu"
toggleClass="sf3-font sf3-font-more"
freezeItem={freezeItem}
unfreezeItem={unfreezeItem}
getMenuList={() => operationMenus}
onMenuItemClick={clickMenu}
menuStyle={isMobile ? { zIndex: 1050 } : {}}
/>
</div>
);
};
TagMoreOperation.propTypes = {
freezeItem: PropTypes.func,
unfreezeItem: PropTypes.func,
showParentTagsSetter: PropTypes.func,
showSubTagsSetter: PropTypes.func,
};
export default TagMoreOperation;