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

Change edit tags dialog UI (#5655)

* fix warnings

* 01 tags icon always show

* 02 tag list footer UI

* 03 change select color popover style

* 04 Add virtual tag

* 05 handle key event

* 06 add createRepoTags API

* 07 optimize codes

* 08 optimize codes

* optimize python code

* change create tags success callback

---------

Co-authored-by: wang <40563566+loveclever@users.noreply.github.com>
This commit is contained in:
Michael An
2023-10-09 21:27:44 +08:00
committed by GitHub
parent 39d490a253
commit e90a64cc90
19 changed files with 802 additions and 266 deletions

View File

@@ -1,12 +1,10 @@
import React, { Fragment } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { v4 as uuidv4 } from 'uuid';
import { gettext, siteRoot } from '../../utils/constants';
import { Utils } from '../../utils/utils';
import PropTypes from 'prop-types';
import ModalPortal from '../modal-portal';
import { Modal } from 'reactstrap';
import ListTagDialog from '../dialog/list-tag-dialog';
import CreateTagDialog from '../dialog/create-tag-dialog';
import ListTaggedFilesDialog from '../dialog/list-taggedfiles-dialog';
import SeahubPopover from '../common/seahub-popover';
import ListTagPopover from '../popover/list-tag-popover';
const propTypes = {
repoID: PropTypes.string.isRequired,
@@ -21,112 +19,93 @@ class DirTool extends React.Component {
constructor(props) {
super(props);
this.state = {
isRepoTagDialogShow: false,
currentTag: null,
isListRepoTagShow: false,
isCreateRepoTagShow: false,
isListTaggedFileShow: false,
};
this.tagsIconID = `tags-icon-${uuidv4()}`;
}
onShowListRepoTag = (e) => {
e.preventDefault();
this.setState({
isRepoTagDialogShow: true,
isListRepoTagShow: true,
isCreateRepoTagShow: false,
isListTaggedFileShow: false
});
this.setState({ isListRepoTagShow: true });
};
onCloseRepoTagDialog = () => {
this.setState({
isRepoTagDialogShow: false,
isListRepoTagShow: false,
isCreateRepoTagShow: false,
isListTaggedFileShow: false
});
hidePopover = (e) => {
if (e) {
let dom = e.target;
while (dom) {
if (typeof dom.className === 'string' && dom.className.includes('tag-color-popover')) return;
dom = dom.parentNode;
}
}
this.setState({ isListRepoTagShow: false });
};
onCreateRepoTagToggle = () => {
this.setState({
isCreateRepoTagShow: !this.state.isCreateRepoTagShow,
isListRepoTagShow: !this.state.isListRepoTagShow,
});
};
onListTaggedFileToggle = (currentTag) => {
this.setState({
currentTag: currentTag,
isListRepoTagShow: !this.state.isListRepoTagShow,
isListTaggedFileShow: !this.state.isListTaggedFileShow,
});
toggleCancel = () => {
this.setState({ isListRepoTagShow: false });
};
isMarkdownFile(filePath) {
let name = Utils.getFileName(filePath);
return name.indexOf('.md') > -1 ? true : false;
return Utils.getFileName(filePath).includes('.md');
}
render() {
let { repoID, userPerm, currentPath } = this.props;
let isFile = this.isMarkdownFile(currentPath);
let name = Utils.getFileName(currentPath);
let trashUrl = siteRoot + 'repo/' + repoID + '/trash/';
let historyUrl = siteRoot + 'repo/history/' + repoID + '/';
if (userPerm === 'rw') {
if (!isFile) {
if (name) { // name not '' is not root path
trashUrl = siteRoot + 'repo/' + repoID + '/trash/?path=' + encodeURIComponent(currentPath);
return (
if (userPerm !== 'rw') {
return '';
}
if (this.isMarkdownFile(currentPath)) {
return '';
}
let toolbarDom = null;
if (Utils.getFileName(currentPath)) { // name not '' is not root path
let trashUrl = siteRoot + 'repo/' + repoID + '/trash/?path=' + encodeURIComponent(currentPath);
toolbarDom = (
<ul className="path-toolbar">
<li className="toolbar-item"><a className="op-link sf2-icon-recycle" href={trashUrl} title={gettext('Trash')} aria-label={gettext('Trash')}></a></li>
<li className="toolbar-item">
<a className="op-link sf2-icon-tag" href="#" id={this.tagsIconID} role="button" onClick={this.onShowListRepoTag} title={gettext('Tags')} aria-label={gettext('Tags')}></a>
</li>
<li className="toolbar-item">
<a className="op-link sf2-icon-recycle" href={trashUrl} title={gettext('Trash')} aria-label={gettext('Trash')}></a>
</li>
</ul>
);
} else { // currentPath === '/' is root path
return (
<Fragment>
let trashUrl = siteRoot + 'repo/' + repoID + '/trash/';
let historyUrl = siteRoot + 'repo/history/' + repoID + '/';
toolbarDom = (
<ul className="path-toolbar">
<li className="toolbar-item"><a className="op-link sf2-icon-tag" href="#" role="button" onClick={this.onShowListRepoTag} title={gettext('Tags')} aria-label={gettext('Tags')}></a></li>
<li className="toolbar-item"><a className="op-link sf2-icon-recycle" href={trashUrl} title={gettext('Trash')} aria-label={gettext('Trash')}></a></li>
<li className="toolbar-item"><a className="op-link sf2-icon-history" href={historyUrl} title={gettext('History')} aria-label={gettext('History')}></a></li>
<li className="toolbar-item">
<a className="op-link sf2-icon-tag" href="#" id={this.tagsIconID} role="button" onClick={this.onShowListRepoTag} title={gettext('Tags')} aria-label={gettext('Tags')}></a>
</li>
<li className="toolbar-item">
<a className="op-link sf2-icon-recycle" href={trashUrl} title={gettext('Trash')} aria-label={gettext('Trash')}></a>
</li>
<li className="toolbar-item">
<a className="op-link sf2-icon-history" href={historyUrl} title={gettext('History')} aria-label={gettext('History')}></a>
</li>
</ul>
{this.state.isRepoTagDialogShow && (
<ModalPortal>
<Modal isOpen={true} autoFocus={false}>
{this.state.isListRepoTagShow && (
<ListTagDialog
repoID={repoID}
onListTagCancel={this.onCloseRepoTagDialog}
onCreateRepoTag={this.onCreateRepoTagToggle}
/>
)}
{this.state.isCreateRepoTagShow && (
<CreateTagDialog
repoID={repoID}
onClose={this.onCloseRepoTagDialog}
toggleCancel={this.onCreateRepoTagToggle}
/>
)}
{this.state.isListTaggedFileShow && (
<ListTaggedFilesDialog
repoID={this.props.repoID}
currentTag={this.state.currentTag}
onClose={this.onCloseRepoTagDialog}
toggleCancel={this.onListTaggedFileToggle}
updateUsedRepoTags={this.props.updateUsedRepoTags}
/>
)}
</Modal>
</ModalPortal>
)}
</Fragment>
);
}
return (
<>
{toolbarDom}
{this.state.isListRepoTagShow &&
<SeahubPopover
popoverClassName="list-tag-popover"
target={this.tagsIconID}
hideSeahubPopover={this.hidePopover}
hideSeahubPopoverWithEsc={this.hidePopover}
canHideSeahubPopover={true}
boundariesElement={document.body}
placement={'bottom-end'}
>
<ListTagPopover
repoID={repoID}
onListTagCancel={this.toggleCancel}
/>
</SeahubPopover>
}
}
return '';
</>
);
}
}

View File

@@ -1,158 +0,0 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { Button, ModalHeader, ModalBody, ModalFooter } from 'reactstrap';
import { gettext } from '../../utils/constants';
import { seafileAPI } from '../../utils/seafile-api';
import { Utils } from '../../utils/utils';
import toaster from '../toast';
import RepoTag from '../../models/repo-tag';
import TagColor from './tag-color';
import TagName from './tag-name';
import '../../css/repo-tag.css';
const tagListItemPropTypes = {
item: PropTypes.object.isRequired,
repoID: PropTypes.string.isRequired,
onDeleteTag : PropTypes.func.isRequired
};
class TagListItem extends React.Component {
constructor(props) {
super(props);
this.state = {
isTagHighlighted: false
};
}
onMouseOver = () => {
this.setState({
isTagHighlighted: true
});
};
onMouseOut = () => {
this.setState({
isTagHighlighted: false
});
};
deleteTag = () => {
this.props.onDeleteTag(this.props.item);
};
render() {
const { isTagHighlighted } = this.state;
const { item, repoID } = this.props;
return (
<li
className={`tag-list-item px-4 d-flex justify-content-between align-items-center ${isTagHighlighted ? 'hl' : ''}`}
onMouseOver={this.onMouseOver}
onMouseOut={this.onMouseOut}
>
<TagColor repoID={repoID} tag={item} />
<TagName repoID={repoID} tag={item} />
<button
className={`tag-delete-icon sf2-icon-delete border-0 px-0 bg-transparent cursor-pointer ${isTagHighlighted ? '' : 'invisible'}`}
onClick={this.deleteTag}
aria-label={gettext('Delete')}
title={gettext('Delete')}
></button>
</li>
);
}
}
TagListItem.propTypes = tagListItemPropTypes;
const listTagPropTypes = {
repoID: PropTypes.string.isRequired,
onListTagCancel: PropTypes.func.isRequired,
onCreateRepoTag: PropTypes.func.isRequired
};
class ListTagDialog extends React.Component {
constructor(props) {
super(props);
this.state = {
repotagList: []
};
}
componentDidMount() {
let repoID = this.props.repoID;
seafileAPI.listRepoTags(repoID).then(res => {
let repotagList = [];
res.data.repo_tags.forEach(item => {
let repo_tag = new RepoTag(item);
repotagList.push(repo_tag);
});
this.setState({
repotagList: repotagList
});
}).catch(error => {
let errMessage = Utils.getErrorMsg(error);
toaster.danger(errMessage);
});
}
toggle = () => {
this.props.onListTagCancel();
};
createNewTag = (e) => {
e.preventDefault();
this.props.onCreateRepoTag();
};
onDeleteTag = (tag) => {
const { repoID } = this.props;
const { id: targetTagID } = tag;
seafileAPI.deleteRepoTag(repoID, targetTagID).then((res) => {
this.setState({
repotagList: this.state.repotagList.filter(tag => tag.id != targetTagID)
});
}).catch((error) => {
let errMessage = Utils.getErrorMsg(error);
toaster.danger(errMessage);
});
};
render() {
return (
<Fragment>
<ModalHeader toggle={this.toggle}>{gettext('Tags')}</ModalHeader>
<ModalBody className="px-0">
<ul className="tag-list tag-list-container">
{this.state.repotagList.map((repoTag, index) => {
return (
<TagListItem
key={index}
item={repoTag}
repoID={this.props.repoID}
onDeleteTag={this.onDeleteTag}
/>
);
})}
</ul>
<a
href="#"
className="add-tag-link px-4 py-2 d-flex align-items-center"
onClick={this.createNewTag}
>
<span className="sf2-icon-plus mr-2"></span>
{gettext('Create a new tag')}
</a>
</ModalBody>
<ModalFooter>
<Button color="secondary" onClick={this.toggle}>{gettext('Close')}</Button>
</ModalFooter>
</Fragment>
);
}
}
ListTagDialog.propTypes = listTagPropTypes;
export default ListTagDialog;

View File

@@ -23,6 +23,14 @@ class TagColor extends React.Component {
};
}
UNSAFE_componentWillReceiveProps(nextProps) {
if (nextProps.tag.color !== this.props.tag.color) {
this.setState({
tagColor: nextProps.tag.color,
});
}
}
togglePopover = () => {
this.setState({
isPopoverOpen: !this.state.isPopoverOpen
@@ -59,7 +67,7 @@ class TagColor extends React.Component {
<div>
<span
id={`tag-${id}-color`}
className="tag-color cursor-pointer w-4 h-4 rounded-circle d-flex align-items-center justify-content-center"
className="tag-color cursor-pointer rounded-circle d-flex align-items-center justify-content-center"
style={{backgroundColor: tagColor}}
onClick={this.togglePopover}
>
@@ -70,7 +78,7 @@ class TagColor extends React.Component {
isOpen={isPopoverOpen}
placement="bottom"
toggle={this.togglePopover}
className="mw-100"
className="tag-color-popover mw-100"
>
<PopoverBody className="p-2">
<div className="d-flex justify-content-between">

View File

@@ -22,6 +22,14 @@ class TagName extends React.Component {
this.input = React.createRef();
}
UNSAFE_componentWillReceiveProps(nextProps) {
if (nextProps.tag.name !== this.props.tag.name) {
this.setState({
tagName: nextProps.tag.name,
});
}
}
toggleMode = () => {
this.setState({
isEditing: !this.state.isEditing
@@ -51,6 +59,10 @@ class TagName extends React.Component {
this.toggleMode();
this.updateTagName(e);
}
else if (e.key == 'Escape') {
e.nativeEvent.stopImmediatePropagation();
this.toggleMode();
}
};
onInputBlur = (e) => {

View File

@@ -48,8 +48,8 @@ const propTypes = {
onAddFolder: PropTypes.func.isRequired,
showDirentDetail: PropTypes.func.isRequired,
onItemRename: PropTypes.func.isRequired,
posX: PropTypes.number.isRequired,
posY: PropTypes.number.isRequired,
posX: PropTypes.number,
posY: PropTypes.number,
};
class DirentGridView extends React.Component {

View File

@@ -54,7 +54,7 @@ const propTypes = {
showDirentDetail: PropTypes.func.isRequired,
onItemsMove: PropTypes.func.isRequired,
onShowDirentsDraggablePreview: PropTypes.func,
loadDirentList: PropTypes.func.isRequired,
loadDirentList: PropTypes.func,
};
class DirentListItem extends React.Component {

View File

@@ -0,0 +1,31 @@
.list-tag-popover .popover {
width: 500px;
max-width: 500px;
}
.list-tag-popover .add-tag-link {
cursor: pointer;
}
.list-tag-popover .tag-list-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
padding: 1rem;
border-top: 1px solid #dedede;
}
.list-tag-popover .tag-list-footer .item-text {
color: #ff8000;
cursor: pointer;
}
.list-tag-popover .tag-list-footer a:hover {
text-decoration: none;
}
.list-tag-popover .tag-color {
width: 20px;
height: 20px;
}

View File

@@ -0,0 +1,154 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { v4 as uuidv4 } from 'uuid';
import { gettext } from '../../utils/constants';
import { seafileAPI } from '../../utils/seafile-api';
import { Utils } from '../../utils/utils';
import toaster from '../toast';
import RepoTag from '../../models/repo-tag';
import TagListItem from './tag-list-item';
import VirtualTagListItem from './virtual-tag-list-item';
import TagListFooter from './tag-list-footer';
import { TAG_COLORS } from '../../constants/';
import '../../css/repo-tag.css';
import './list-tag-popover.css';
export default class ListTagPopover extends React.Component {
static propTypes = {
repoID: PropTypes.string.isRequired,
onListTagCancel: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
repotagList: []
};
}
componentDidMount() {
this.loadTags();
}
loadTags = () => {
seafileAPI.listRepoTags(this.props.repoID).then(res => {
let repotagList = [];
res.data.repo_tags.forEach(item => {
let repo_tag = new RepoTag(item);
repotagList.push(repo_tag);
});
this.setState({ repotagList });
}).catch(error => {
let errMessage = Utils.getErrorMsg(error);
toaster.danger(errMessage);
});
};
updateTags = (repotagList) => {
this.setState({ repotagList });
};
onDeleteTag = (tag) => {
const { repoID } = this.props;
const { id: targetTagID } = tag;
seafileAPI.deleteRepoTag(repoID, targetTagID).then((res) => {
this.setState({
repotagList: this.state.repotagList.filter(tag => tag.id != targetTagID)
});
}).catch((error) => {
let errMessage = Utils.getErrorMsg(error);
toaster.danger(errMessage);
});
};
createVirtualTag = (e) => {
e.preventDefault();
let { repotagList } = this.state;
let virtual_repo_tag = {
name: '',
color: TAG_COLORS[Math.floor(Math.random() * TAG_COLORS.length)], // generate random tag color for virtual tag
id: `virtual-tag-${uuidv4()}`,
is_virtual: true,
};
repotagList.push(virtual_repo_tag);
this.setState({ repotagList });
};
deleteVirtualTag = (virtualTag) => {
let { repotagList } = this.state;
let index = repotagList.findIndex(item => item.id === virtualTag.id);
repotagList.splice(index, 1);
this.setState({ repotagList });
};
updateVirtualTag = (virtualTag, data) => {
const repoID = this.props.repoID;
const { repotagList } = this.state;
const index = repotagList.findIndex(item => item.id === virtualTag.id);
if (index < 0) return null;
// If virtual tag color is updated and virtual tag name is empty, it will be saved to local state, don't save it to the server
if (data.color) {
virtualTag.color = data.color;
repotagList[index] = virtualTag;
this.setState({ repotagList });
return;
}
// If virtual tag name is updated and name is not empty, virtual tag color use default, save it to the server
if (data.name && data.name.length > 0) {
let color = virtualTag.color;
let name = data.name;
seafileAPI.createRepoTag(repoID, name, color).then((res) => {
// After saving sag to the server, replace the virtual tag with newly created tag
repotagList[index] = new RepoTag(res.data.repo_tag);
this.setState({ repotagList });
}).catch((error) => {
let errMessage = Utils.getErrorMsg(error);
toaster.danger(errMessage);
});
}
};
render() {
return (
<Fragment>
<ul className="tag-list tag-list-container my-2">
{this.state.repotagList.map((repoTag, index) => {
if (repoTag.is_virtual) {
return (
<VirtualTagListItem
key={index}
item={repoTag}
repoID={this.props.repoID}
deleteVirtualTag={this.deleteVirtualTag}
updateVirtualTag={this.updateVirtualTag}
/>
);
} else {
return (
<TagListItem
key={index}
item={repoTag}
repoID={this.props.repoID}
onDeleteTag={this.onDeleteTag}
/>
);
}
})}
</ul>
<div className="add-tag-link px-4 py-2 d-flex align-items-center" onClick={this.createVirtualTag}>
<span className="sf2-icon-plus mr-2"></span>{gettext('Create a new tag')}
</div>
<TagListFooter
toggle={this.props.onListTagCancel}
repotagList={this.state.repotagList}
updateTags={this.updateTags}
repoID={this.props.repoID}
/>
</Fragment>
);
}
}

View File

@@ -0,0 +1,137 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Tooltip } from 'reactstrap';
import { seafileAPI } from '../../utils/seafile-api';
import { gettext } from '../../utils/constants';
import { Utils } from '../../utils/utils';
import RepoTag from '../../models/repo-tag';
import toaster from '../toast';
export default class TagListFooter extends Component {
static propTypes = {
repoID: PropTypes.string.isRequired,
toggle: PropTypes.func.isRequired,
repotagList: PropTypes.array.isRequired,
updateTags: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
showTooltip: false,
};
}
toggleTooltip = () => {
this.setState({showTooltip: !this.state.showTooltip});
};
onClickImport = () => {
this.importOptionsInput.click();
};
importTagsInputChange = () => {
if (!this.importOptionsInput.files || !this.importOptionsInput.files.length) {
toaster.warning(gettext('Please select a file'));
return;
}
const fileReader = new FileReader();
fileReader.onload = this.onImportTags.bind(this);
fileReader.onerror = this.onImportTagsError.bind(this);
fileReader.readAsText(this.importOptionsInput.files[0]);
};
getValidTags = (tags) => {
let validTags = [];
let tagNameMap = {};
this.props.repotagList.forEach(tag => tagNameMap[tag.name] = true);
for (let i = 0; i < tags.length; i++) {
if (!tags[i] || typeof tags[i] !== 'object' || !tags[i].name || !tags[i].color) {
continue;
}
if (!tagNameMap[tags[i].name]) {
validTags.push(
{
name: tags[i].name,
color: tags[i].color,
}
);
tagNameMap[tags[i].name] = true;
}
}
return validTags;
};
onImportTags = (event) => {
let tags = [];
try {
tags = JSON.parse(event.target.result); // handle JSON file format is error
} catch (error) {
toaster.danger(gettext('The imported tags are invalid'));
return;
}
if (!Array.isArray(tags) || tags.length === 0) {
toaster.danger(gettext('The imported tags are invalid'));
return;
}
let validTags = this.getValidTags(tags);
if (validTags.length === 0) {
toaster.warning(gettext('The imported tag already exists'));
return;
}
seafileAPI.createRepoTags(this.props.repoID, validTags).then((res) => {
toaster.success(gettext('Tags imported'));
let repotagList = [];
res.data.repo_tags.forEach(item => {
let repo_tag = new RepoTag(item);
repotagList.push(repo_tag);
});
this.props.updateTags(repotagList);
}).catch(error => {
let errMessage = Utils.getErrorMsg(error);
toaster.danger(errMessage);
});
this.importOptionsInput.value = null;
};
onImportTagsError = () => {
toaster.success(gettext('Failed to import tags. Please reupload.'));
};
getDownloadUrl = () => {
const tags = this.props.repotagList.map(item => {
return { name: item.name, color: item.color };
});
return `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(tags))}`;
};
render() {
return (
<div className="tag-list-footer">
<span className="fa fa-question-circle mr-2" style={{color: '#999'}} id="import-export-tags-tip"></span>
<Tooltip
toggle={this.toggleTooltip}
delay={{show: 0, hide: 0}}
target='import-export-tags-tip'
placement='bottom'
isOpen={this.state.showTooltip}
>
{gettext('Use the import/export function to transfer tags quickly to another library. (The export is in JSON format.)')}
</Tooltip>
<input
type="file"
ref={ref => this.importOptionsInput = ref}
accept='.json'
className="d-none"
onChange={this.importTagsInputChange}
/>
<span className="item-text" onClick={this.onClickImport}>{gettext('Import tags')}</span>
<span className="mx-2">|</span>
<a href={this.getDownloadUrl()} download='tags.json' onClick={this.props.toggle}>
<span className="item-text">{gettext('Export tags')}</span>
</a>
</div>
);
}
}

View File

@@ -0,0 +1,65 @@
import React from 'react';
import PropTypes from 'prop-types';
import { gettext } from '../../utils/constants';
import TagColor from '../dialog/tag-color';
import TagName from '../dialog/tag-name';
import '../../css/repo-tag.css';
import './list-tag-popover.css';
const tagListItemPropTypes = {
item: PropTypes.object.isRequired,
repoID: PropTypes.string.isRequired,
onDeleteTag : PropTypes.func.isRequired
};
class TagListItem extends React.Component {
constructor(props) {
super(props);
this.state = {
isTagHighlighted: false
};
}
onMouseOver = () => {
this.setState({
isTagHighlighted: true
});
};
onMouseOut = () => {
this.setState({
isTagHighlighted: false
});
};
deleteTag = () => {
this.props.onDeleteTag(this.props.item);
};
render() {
const { isTagHighlighted } = this.state;
const { item, repoID } = this.props;
return (
<li
className={`tag-list-item px-4 d-flex justify-content-between align-items-center ${isTagHighlighted ? 'hl' : ''}`}
onMouseOver={this.onMouseOver}
onMouseOut={this.onMouseOut}
>
<TagColor repoID={repoID} tag={item} />
<TagName repoID={repoID} tag={item} />
<button
className={`tag-delete-icon sf2-icon-delete border-0 px-0 bg-transparent cursor-pointer ${isTagHighlighted ? '' : 'invisible'}`}
onClick={this.deleteTag}
aria-label={gettext('Delete')}
title={gettext('Delete')}
></button>
</li>
);
}
}
TagListItem.propTypes = tagListItemPropTypes;
export default TagListItem;

View File

@@ -0,0 +1,96 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Popover, PopoverBody } from 'reactstrap';
import { TAG_COLORS } from '../../constants';
import '../../css/repo-tag.css';
export default class VirtualTagColor extends React.Component {
static propTypes = {
updateVirtualTag: PropTypes.func.isRequired,
tag: PropTypes.object.isRequired,
repoID: PropTypes.string.isRequired
};
constructor(props) {
super(props);
this.state = {
tagColor: this.props.tag.color,
isPopoverOpen: false
};
}
UNSAFE_componentWillReceiveProps(nextProps) {
if (nextProps.tag.color !== this.props.tag.color) {
this.setState({
tagColor: nextProps.tag.color,
});
}
}
togglePopover = () => {
this.setState({
isPopoverOpen: !this.state.isPopoverOpen
});
};
selectTagColor = (e) => {
const newColor = e.target.value;
this.props.updateVirtualTag(this.props.tag, { color: newColor });
this.setState({
tagColor: newColor,
isPopoverOpen: !this.state.isPopoverOpen,
});
};
render() {
const { isPopoverOpen, tagColor } = this.state;
const { tag } = this.props;
const { id, color } = tag;
let colorList = [...TAG_COLORS];
// for color from previous color options
if (colorList.indexOf(color) == -1) {
colorList.unshift(color);
}
return (
<div>
<span
id={`tag-${id}-color`}
className="tag-color cursor-pointer rounded-circle d-flex align-items-center justify-content-center"
style={{backgroundColor: tagColor}}
onClick={this.togglePopover}
>
<i className="fas fa-caret-down text-white"></i>
</span>
<Popover
target={`tag-${id}-color`}
isOpen={isPopoverOpen}
placement="bottom"
toggle={this.togglePopover}
className="tag-color-popover mw-100"
>
<PopoverBody className="p-2">
<div className="d-flex justify-content-between">
{colorList.map((item, index)=>{
return (
<div key={index} className="tag-color-option mx-1">
<label className="colorinput">
<input name="color" type="radio" value={item} className="colorinput-input" defaultChecked={item == tagColor} onClick={this.selectTagColor} />
<span className="colorinput-color rounded-circle d-flex align-items-center justify-content-center" style={{backgroundColor: item}}>
<i className="fas fa-check color-selected"></i>
</span>
</label>
</div>
);
})
}
</div>
</PopoverBody>
</Popover>
</div>
);
}
}

View File

@@ -0,0 +1,58 @@
import React from 'react';
import PropTypes from 'prop-types';
import { gettext } from '../../utils/constants';
import VirtualTagColor from './virtual-tag-color';
import VirtualTagName from './virtual-tag-name';
import '../../css/repo-tag.css';
import './list-tag-popover.css';
export default class VirtualTagListItem extends React.Component {
static propTypes = {
item: PropTypes.object.isRequired,
repoID: PropTypes.string.isRequired,
deleteVirtualTag: PropTypes.func.isRequired,
updateVirtualTag: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
isTagHighlighted: false
};
}
onMouseOver = () => {
this.setState({ isTagHighlighted: true });
};
onMouseOut = () => {
this.setState({ isTagHighlighted: false });
};
deleteVirtualTag = () => {
this.props.deleteVirtualTag(this.props.item);
};
render() {
const { isTagHighlighted } = this.state;
const { item, repoID } = this.props;
return (
<li
className={`tag-list-item px-4 d-flex justify-content-between align-items-center ${isTagHighlighted ? 'hl' : ''}`}
onMouseOver={this.onMouseOver}
onMouseOut={this.onMouseOut}
>
<VirtualTagColor repoID={repoID} tag={item} updateVirtualTag={this.props.updateVirtualTag} />
<VirtualTagName repoID={repoID} tag={item} updateVirtualTag={this.props.updateVirtualTag} />
<button
className={`tag-delete-icon sf2-icon-delete border-0 px-0 bg-transparent cursor-pointer ${isTagHighlighted ? '' : 'invisible'}`}
onClick={this.deleteVirtualTag}
aria-label={gettext('Delete')}
title={gettext('Delete')}
></button>
</li>
);
}
}

View File

@@ -0,0 +1,89 @@
import React from 'react';
import PropTypes from 'prop-types';
import '../../css/repo-tag.css';
export default class VirtualTagName extends React.Component {
static propTypes = {
updateVirtualTag: PropTypes.func.isRequired,
tag: PropTypes.object.isRequired,
repoID: PropTypes.string.isRequired
};
constructor(props) {
super(props);
this.state = {
tagName: this.props.tag.name,
isEditing: true,
};
this.input = React.createRef();
}
UNSAFE_componentWillReceiveProps(nextProps) {
if (nextProps.tag.name !== this.props.tag.name) {
this.setState({
tagName: nextProps.tag.name,
});
}
}
componentDidMount() {
setTimeout(() => {
this.input.current.focus();
}, 1);
}
toggleMode = () => {
this.setState({
isEditing: !this.state.isEditing
});
};
updateTagName = (e) => {
const newName = e.target.value;
this.props.updateVirtualTag(this.props.tag, { name: newName });
this.setState({
tagName: newName
});
};
onInputKeyDown = (e) => {
if (e.key == 'Enter') {
this.toggleMode();
this.updateTagName(e);
}
else if (e.key == 'Escape') {
e.nativeEvent.stopImmediatePropagation();
this.toggleMode();
}
};
onInputBlur = (e) => {
this.toggleMode();
this.updateTagName(e);
};
render() {
const { isEditing, tagName } = this.state;
return (
<div className="mx-2 flex-fill d-flex">
{isEditing ?
<input
type="text"
ref={this.input}
defaultValue={tagName}
onBlur={this.onInputBlur}
onKeyDown={this.onInputKeyDown}
className="flex-fill form-control-sm form-control"
/> :
<span
onClick={this.toggleMode}
className="cursor-pointer flex-fill"
style={{width: 100, height: 20}}
>{tagName}</span>
}
</div>
);
}
}

View File

@@ -105,7 +105,7 @@ class Alert extends React.PureComponent {
Alert.propTypes = {
onRemove: PropTypes.func.isRequired,
children: PropTypes.any.isRequired,
children: PropTypes.any,
title: PropTypes.string.isRequired,
intent: PropTypes.string.isRequired,
};

View File

@@ -19,8 +19,8 @@ const propTypes = {
currentRepoInfo: PropTypes.object,
selectedDirentList: PropTypes.array,
onItemsMove: PropTypes.func,
posX: PropTypes.number.isRequired,
posY: PropTypes.number.isRequired,
posX: PropTypes.number,
posY: PropTypes.number,
};
const PADDING_LEFT = 20;

View File

@@ -58,3 +58,22 @@
.tag-color-option .colorinput-input:checked ~ .colorinput-color .color-selected {
opacity: 1;
}
/* tag-color */
.tag-color-popover .popover {
max-width: 360px;
}
.tag-color-popover .tag-color {
width: 20px;
height: 20px;
}
.tag-color-popover .colorinput-color {
width: 20px;
height: 20px;
}
.tag-color-popover .tag-color-option .colorinput-input:checked ~ .colorinput-color .color-selected {
font-size: 12px;
}

View File

@@ -87,8 +87,8 @@ const propTypes = {
onListContainerScroll: PropTypes.func.isRequired,
onDirentClick: PropTypes.func.isRequired,
direntDetailPanelTab: PropTypes.string,
loadDirentList: PropTypes.func.isRequired,
fullDirentList: PropTypes.array.isRequired,
loadDirentList: PropTypes.func,
fullDirentList: PropTypes.array,
};
class LibContentContainer extends React.Component {

View File

@@ -86,8 +86,6 @@ class RichMarkdownEditor extends React.Component {
editorApi={this.props.editorApi}
onChange={this.props.onChange}
resetRichValue={this.props.resetRichValue}
isSupportComment={false}
onAddComment={() => {}}
/>
</div>
<div className={`markdown-help-wrapper ${isShowHelpWrapper ? 'show' : ''}`} style={helpWrapperStyle}>

View File

@@ -117,6 +117,54 @@ class RepoTagsView(APIView):
return Response({"repo_tag": repo_tag.to_dict()}, status=status.HTTP_201_CREATED)
def put(self, request, repo_id):
"""bulk add repo_tags.
"""
# argument check
tags = request.data.get('tags')
if not tags:
error_msg = 'tags invalid.'
return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
# resource check
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)
# permission check
if check_folder_permission(request, repo_id, '/') != PERMISSION_READ_WRITE:
error_msg = 'Permission denied.'
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
tag_objs = list()
try:
for tag in tags:
name = tag.get('name' ,'')
color = tag.get('color', '')
if name and color:
obj = RepoTags(repo_id=repo_id, name=name, color=color)
tag_objs.append(obj)
except Exception as e:
logger.error(e)
error_msg = 'tags invalid.'
return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
try:
repo_tag_list = RepoTags.objects.bulk_create(tag_objs)
except Exception as e:
logger.error(e)
error_msg = 'Internal Server Error'
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
repo_tags = list()
for repo_tag in repo_tag_list:
res = repo_tag.to_dict()
repo_tags.append(res)
return Response({"repo_tags": repo_tags}, status=status.HTTP_200_OK)
class RepoTagView(APIView):
authentication_classes = (TokenAuthentication, SessionAuthentication)