1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-22 20:08:19 +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

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