mirror of
https://github.com/haiwen/seahub.git
synced 2025-09-04 16:31:13 +00:00
Change select group UI (#7201)
* 01 share repo to group UI * 02 ADD_SHARED_REPO_INTO_GROUP * 03 share folder to groups * 04 system admin share repo to group * 05 remove old NoGroupMessage * 06 change API * change API * change select icons indents
This commit is contained in:
@@ -5,7 +5,7 @@ export const EVENT_BUS_TYPE = {
|
||||
|
||||
// group
|
||||
ADD_NEW_GROUP: 'add_new_group',
|
||||
ADD_SHARED_REPO_INO_GROUP: 'add_shared_repo_into_group',
|
||||
ADD_SHARED_REPO_INTO_GROUP: 'add_shared_repo_into_group',
|
||||
UNSHARE_REPO_TO_GROUP: 'unshare_repo_to_group',
|
||||
|
||||
RESTORE_IMAGE: 'restore_image',
|
||||
|
43
frontend/src/components/common/group-select/click-outside.js
Normal file
43
frontend/src/components/common/group-select/click-outside.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class ClickOutside extends React.Component {
|
||||
|
||||
isClickedInside = false;
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('mousedown', this.handleDocumentClick);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('mousedown', this.handleDocumentClick);
|
||||
}
|
||||
|
||||
handleDocumentClick = (e) => {
|
||||
if (this.isClickedInside) {
|
||||
this.isClickedInside = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onClickOutside(e);
|
||||
};
|
||||
|
||||
handleMouseDown = () => {
|
||||
this.isClickedInside = true;
|
||||
};
|
||||
|
||||
render() {
|
||||
return React.cloneElement(
|
||||
React.Children.only(this.props.children), {
|
||||
onMouseDownCapture: this.handleMouseDown
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ClickOutside.propTypes = {
|
||||
children: PropTypes.element.isRequired,
|
||||
onClickOutside: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ClickOutside;
|
103
frontend/src/components/common/group-select/index.css
Normal file
103
frontend/src/components/common/group-select/index.css
Normal file
@@ -0,0 +1,103 @@
|
||||
.group-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.group-select.custom-select {
|
||||
display: flex;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 900px;
|
||||
user-select: none;
|
||||
text-align: left;
|
||||
border-color: hsl(0, 0%, 80%);
|
||||
height: auto;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.group-select.custom-select:hover {
|
||||
border-color: hsl(0, 0%, 70%);
|
||||
}
|
||||
|
||||
.group-select.custom-select:focus,
|
||||
.group-select.custom-select.focus {
|
||||
border-color: #1991eb !important;
|
||||
box-shadow: 0 0 0 2px rgba(70, 127, 207, 0.25);
|
||||
}
|
||||
|
||||
.group-select.custom-select.disabled:focus,
|
||||
.group-select.custom-select.focus.disabled,
|
||||
.group-select.custom-select.disabled:hover {
|
||||
border-color: rgba(0, 40, 100, 0.12) !important;
|
||||
box-shadow: unset;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.group-select.custom-select:hover {
|
||||
cursor: pointer;
|
||||
border-color: rgb(179, 179, 179);
|
||||
}
|
||||
|
||||
.group-select .sf3-font-down {
|
||||
display: inline-block;
|
||||
color: #999;
|
||||
transform: translateY(2px);
|
||||
transition: all 0.1s;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.group-select .sf3-font-down:hover {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.group-select .selected-option {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.group-select.selector-collaborator .option-group .option-group-content {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.group-select.custom-select.selector-collaborator .option-group .option-group-content {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.group-select.custom-select.selector-collaborator .option {
|
||||
padding: 5px 0 5px 10px !important;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.group-select .select-placeholder {
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group-select .selected-option-show {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.group-select .selected-option-show .selected-option-item {
|
||||
background-color: rgb(240, 240, 240);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.group-select .selected-option-show .selected-option-item .selected-option-item-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.group-select .selected-option-show .selected-option-item .sf2-icon-close {
|
||||
cursor: pointer;
|
||||
color: rgb(103, 103, 103);
|
||||
}
|
138
frontend/src/components/common/group-select/index.js
Normal file
138
frontend/src/components/common/group-select/index.js
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import ModalPortal from '../../modal-portal';
|
||||
import SelectOptionGroup from './select-option-group.js';
|
||||
|
||||
import './index.css';
|
||||
|
||||
class GroupSelect extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isShowSelectOptions: false
|
||||
};
|
||||
}
|
||||
|
||||
onSelectToggle = (event) => {
|
||||
event.preventDefault();
|
||||
if (this.state.isShowSelectOptions) event.stopPropagation();
|
||||
let eventClassName = event.target.className;
|
||||
if (eventClassName.indexOf('sf2-icon-close') > -1 || eventClassName === 'option-group-search') return;
|
||||
if (event.target.value === '') return;
|
||||
this.setState({
|
||||
isShowSelectOptions: !this.state.isShowSelectOptions
|
||||
});
|
||||
};
|
||||
|
||||
onClickOutside = (event) => {
|
||||
if (this.props.isShowSelected && event.target.className.includes('icon-fork-number')) {
|
||||
return;
|
||||
}
|
||||
if (!this.selector.contains(event.target)) {
|
||||
this.closeSelect();
|
||||
}
|
||||
};
|
||||
|
||||
closeSelect = () => {
|
||||
this.setState({ isShowSelectOptions: false });
|
||||
};
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.selectedOptions.length !== this.props.selectedOptions.length) {
|
||||
// when selectedOptions change and dom rendered, calculate top
|
||||
setTimeout(() => {
|
||||
this.forceUpdate();
|
||||
}, 1);
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedOptionTop = () => {
|
||||
if (!this.selector) return 38;
|
||||
const { height } = this.selector.getBoundingClientRect();
|
||||
return height;
|
||||
};
|
||||
|
||||
getFilterOptions = (searchValue) => {
|
||||
const { options } = this.props;
|
||||
const validSearchVal = searchValue.trim().toLowerCase();
|
||||
if (!validSearchVal) return options || [];
|
||||
return options.filter(option => option.name.toLowerCase().includes(validSearchVal));
|
||||
};
|
||||
|
||||
render() {
|
||||
let { className, selectedOptions, options, placeholder, searchPlaceholder, noOptionsPlaceholder, isInModal } = this.props;
|
||||
return (
|
||||
<div
|
||||
ref={(node) => this.selector = node}
|
||||
className={classnames('group-select custom-select',
|
||||
{ 'focus': this.state.isShowSelectOptions },
|
||||
className
|
||||
)}
|
||||
onClick={this.onSelectToggle}>
|
||||
<div className="selected-option">
|
||||
{selectedOptions.length > 0 ?
|
||||
<span className="selected-option-show">
|
||||
{selectedOptions.map(item =>
|
||||
<span key={item.id} className="selected-option-item mr-1 px-1">
|
||||
<span className='selected-option-item-name'>{item.name}</span>
|
||||
<i className="sf2-icon-close ml-1" onClick={() => {this.props.onDeleteOption(item);}}></i>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
:
|
||||
<span className="select-placeholder">{placeholder}</span>
|
||||
}
|
||||
<i className="sf3-font-down sf3-font"></i>
|
||||
</div>
|
||||
{this.state.isShowSelectOptions && !isInModal && (
|
||||
<SelectOptionGroup
|
||||
selectedOptions={selectedOptions}
|
||||
top={this.getSelectedOptionTop()}
|
||||
options={options}
|
||||
onSelectOption={this.props.onSelectOption}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
noOptionsPlaceholder={noOptionsPlaceholder}
|
||||
onClickOutside={this.onClickOutside}
|
||||
closeSelect={this.closeSelect}
|
||||
getFilterOptions={this.getFilterOptions}
|
||||
/>
|
||||
)}
|
||||
{this.state.isShowSelectOptions && isInModal && (
|
||||
<ModalPortal>
|
||||
<SelectOptionGroup
|
||||
className={className}
|
||||
selectedOptions={selectedOptions}
|
||||
position={this.selector.getBoundingClientRect()}
|
||||
isInModal={isInModal}
|
||||
top={this.getSelectedOptionTop()}
|
||||
options={options}
|
||||
onSelectOption={this.props.onSelectOption}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
noOptionsPlaceholder={noOptionsPlaceholder}
|
||||
onClickOutside={this.onClickOutside}
|
||||
closeSelect={this.closeSelect}
|
||||
getFilterOptions={this.getFilterOptions}
|
||||
/>
|
||||
</ModalPortal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
GroupSelect.propTypes = {
|
||||
className: PropTypes.string,
|
||||
selectedOptions: PropTypes.array,
|
||||
options: PropTypes.array,
|
||||
placeholder: PropTypes.string,
|
||||
onSelectOption: PropTypes.func,
|
||||
onDeleteOption: PropTypes.func,
|
||||
searchable: PropTypes.bool,
|
||||
searchPlaceholder: PropTypes.string,
|
||||
noOptionsPlaceholder: PropTypes.string,
|
||||
isInModal: PropTypes.bool, // if select component in a modal (option group need ModalPortal to show)
|
||||
};
|
||||
|
||||
export default GroupSelect;
|
46
frontend/src/components/common/group-select/option.js
Normal file
46
frontend/src/components/common/group-select/option.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class Option extends Component {
|
||||
|
||||
onSelectOption = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onSelectOption(this.props.option);
|
||||
};
|
||||
|
||||
onMouseEnter = () => {
|
||||
if (!this.props.disableHover) {
|
||||
this.props.changeIndex(this.props.index);
|
||||
}
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
if (!this.props.disableHover) {
|
||||
this.props.changeIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
className={this.props.isActive ? 'option option-active' : 'option'}
|
||||
onClick={this.onSelectOption}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Option.propTypes = {
|
||||
index: PropTypes.number,
|
||||
isActive: PropTypes.bool,
|
||||
changeIndex: PropTypes.func,
|
||||
option: PropTypes.object,
|
||||
children: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
|
||||
onSelectOption: PropTypes.func,
|
||||
disableHover: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Option;
|
@@ -0,0 +1,86 @@
|
||||
.option-group {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
min-height: 60px;
|
||||
max-height: 300px;
|
||||
min-width: 100%;
|
||||
max-width: 15rem;
|
||||
padding: 0.5rem 0;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 40, 100, 0.12);
|
||||
border-radius: 3px;
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.option-group .option-group-search {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.option-group-search .form-control {
|
||||
height: 31px;
|
||||
}
|
||||
|
||||
.option-group .none-search-result {
|
||||
height: 100px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.option-group .option-group-content {
|
||||
max-height: 252px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
line-height: 24px;
|
||||
padding: 6px 10px;
|
||||
clear: both;
|
||||
font-weight: 400;
|
||||
text-align: inherit;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.option.option-active {
|
||||
background-color: #20a0ff;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.option.option-active .sf2-icon-tick,
|
||||
.option.option-active .select-option-name {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.option .select-option-name .single-select-option {
|
||||
margin: 0 0 0 12px;
|
||||
}
|
||||
|
||||
.option .select-option-name .multiple-select-option {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.option-group-selector-single-select .select-option-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.option-group-selector-single-select .option:hover,
|
||||
.option-group-selector-single-select .option.option-active,
|
||||
.option-group-selector-multiple-select .option:hover,
|
||||
.option-group-selector-multiple-select .option.option-active {
|
||||
background-color: #f5f5f5;
|
||||
}
|
@@ -0,0 +1,215 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import { DTableSearchInput } from 'dtable-ui-component';
|
||||
import Option from './option';
|
||||
import KeyCodes from '../../../constants/keyCodes';
|
||||
import ClickOutside from './click-outside';
|
||||
|
||||
import './select-option-group.css';
|
||||
|
||||
const OPTION_HEIGHT = 32;
|
||||
|
||||
class SelectOptionGroup extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
searchVal: '',
|
||||
activeIndex: -1,
|
||||
disableHover: false,
|
||||
};
|
||||
this.filterOptions = null;
|
||||
this.timer = null;
|
||||
this.searchInputRef = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('keydown', this.onHotKey);
|
||||
setTimeout(() => {
|
||||
this.resetMenuStyle();
|
||||
}, 1);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.filterOptions = null;
|
||||
this.timer && clearTimeout(this.timer);
|
||||
window.removeEventListener('keydown', this.onHotKey);
|
||||
}
|
||||
|
||||
resetMenuStyle = () => {
|
||||
const { isInModal, position } = this.props;
|
||||
const { top, height } = this.optionGroupRef.getBoundingClientRect();
|
||||
if (isInModal) {
|
||||
if (position.y + position.height + height > window.innerHeight) {
|
||||
this.optionGroupRef.style.top = (position.y - height) + 'px';
|
||||
}
|
||||
this.optionGroupRef.style.opacity = 1;
|
||||
this.searchInputRef.current && this.searchInputRef.current.inputRef.focus();
|
||||
}
|
||||
else {
|
||||
if (height + top > window.innerHeight) {
|
||||
const borderWidth = 2;
|
||||
this.optionGroupRef.style.top = -1 * (height + borderWidth) + 'px';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onHotKey = (event) => {
|
||||
const keyCode = event.keyCode;
|
||||
if (keyCode === KeyCodes.UpArrow) {
|
||||
this.onPressUp();
|
||||
} else if (keyCode === KeyCodes.DownArrow) {
|
||||
this.onPressDown();
|
||||
} else if (keyCode === KeyCodes.Enter) {
|
||||
let option = this.filterOptions && this.filterOptions[this.state.activeIndex];
|
||||
if (option) {
|
||||
this.props.onSelectOption(option);
|
||||
}
|
||||
} else if (keyCode === KeyCodes.Tab || keyCode === KeyCodes.Escape) {
|
||||
this.props.closeSelect();
|
||||
}
|
||||
};
|
||||
|
||||
onPressUp = () => {
|
||||
if (this.state.activeIndex > 0) {
|
||||
this.setState({ activeIndex: this.state.activeIndex - 1 }, () => {
|
||||
this.scrollContent();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onPressDown = () => {
|
||||
if (this.filterOptions && this.state.activeIndex < this.filterOptions.length - 1) {
|
||||
this.setState({ activeIndex: this.state.activeIndex + 1 }, () => {
|
||||
this.scrollContent();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onMouseDown = (e) => {
|
||||
const { isInModal } = this.props;
|
||||
// prevent event propagation when click option or search input
|
||||
if (isInModal) {
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
scrollContent = () => {
|
||||
const { offsetHeight, scrollTop } = this.optionGroupContentRef;
|
||||
this.setState({ disableHover: true });
|
||||
this.timer = setTimeout(() => {
|
||||
this.setState({ disableHover: false });
|
||||
}, 500);
|
||||
if (this.state.activeIndex * OPTION_HEIGHT === 0) {
|
||||
this.optionGroupContentRef.scrollTop = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.activeIndex * OPTION_HEIGHT < scrollTop) {
|
||||
this.optionGroupContentRef.scrollTop = scrollTop - OPTION_HEIGHT;
|
||||
}
|
||||
else if (this.state.activeIndex * OPTION_HEIGHT > offsetHeight + scrollTop) {
|
||||
this.optionGroupContentRef.scrollTop = scrollTop + OPTION_HEIGHT;
|
||||
}
|
||||
};
|
||||
|
||||
changeIndex = (index) => {
|
||||
this.setState({ activeIndex: index });
|
||||
};
|
||||
|
||||
onChangeSearch = (searchVal) => {
|
||||
this.setState({ searchVal: searchVal || '', activeIndex: -1, });
|
||||
};
|
||||
|
||||
clearValue = () => {
|
||||
this.setState({ searchVal: '', activeIndex: -1, });
|
||||
};
|
||||
|
||||
renderOptGroup = (searchVal) => {
|
||||
let { noOptionsPlaceholder, onSelectOption, selectedOptions } = this.props;
|
||||
this.filterOptions = this.props.getFilterOptions(searchVal);
|
||||
if (this.filterOptions.length === 0) {
|
||||
return (
|
||||
<div className="none-search-result">{noOptionsPlaceholder}</div>
|
||||
);
|
||||
}
|
||||
return this.filterOptions.map((option, index) => {
|
||||
const isSelected = selectedOptions.some(item => item.id === option.id);
|
||||
return (
|
||||
<Option
|
||||
key={`${option.id}-${index}`}
|
||||
index={index}
|
||||
isActive={this.state.activeIndex === index}
|
||||
option={option}
|
||||
onSelectOption={onSelectOption}
|
||||
changeIndex={this.changeIndex}
|
||||
disableHover={this.state.disableHover}
|
||||
>
|
||||
<div className='option-label'>{option.label}</div>
|
||||
{isSelected && <i className="sf2-icon-tick text-gray font-weight-bold"></i>}
|
||||
</Option>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { searchPlaceholder, top, left, minWidth, isInModal, position, className } = this.props;
|
||||
let { searchVal } = this.state;
|
||||
let style = { top: top || 0, left: left || 0 };
|
||||
if (minWidth) {
|
||||
style = { top: top || 0, left: left || 0, minWidth };
|
||||
}
|
||||
if (isInModal) {
|
||||
style = {
|
||||
position: 'fixed',
|
||||
left: position.x,
|
||||
top: position.y + position.height,
|
||||
minWidth: position.width,
|
||||
opacity: 0,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<ClickOutside onClickOutside={this.props.onClickOutside}>
|
||||
<div
|
||||
className={classnames('pt-0 option-group', className ? 'option-group-' + className : '')}
|
||||
ref={(ref) => this.optionGroupRef = ref}
|
||||
style={style}
|
||||
onMouseDown={this.onMouseDown}
|
||||
>
|
||||
<div className="option-group-search position-relative">
|
||||
<DTableSearchInput
|
||||
className="option-search-control"
|
||||
placeholder={searchPlaceholder}
|
||||
onChange={this.onChangeSearch}
|
||||
ref={this.searchInputRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="option-group-content" ref={(ref) => this.optionGroupContentRef = ref}>
|
||||
{this.renderOptGroup(searchVal)}
|
||||
</div>
|
||||
</div>
|
||||
</ClickOutside>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SelectOptionGroup.propTypes = {
|
||||
top: PropTypes.number,
|
||||
left: PropTypes.number,
|
||||
minWidth: PropTypes.number,
|
||||
options: PropTypes.array,
|
||||
onSelectOption: PropTypes.func,
|
||||
searchPlaceholder: PropTypes.string,
|
||||
noOptionsPlaceholder: PropTypes.string,
|
||||
onClickOutside: PropTypes.func.isRequired,
|
||||
closeSelect: PropTypes.func.isRequired,
|
||||
getFilterOptions: PropTypes.func.isRequired,
|
||||
selectedOptions: PropTypes.array,
|
||||
isInModal: PropTypes.bool,
|
||||
position: PropTypes.object,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default SelectOptionGroup;
|
@@ -1,5 +1,4 @@
|
||||
import SeahubSelect from './seahub-select';
|
||||
import { NoGroupMessage } from './no-group-message';
|
||||
import { MenuSelectStyle, UserSelectStyle, NoOptionsStyle } from './seahub-select-style';
|
||||
|
||||
export { SeahubSelect, NoGroupMessage, MenuSelectStyle, UserSelectStyle, NoOptionsStyle };
|
||||
export { SeahubSelect, MenuSelectStyle, UserSelectStyle, NoOptionsStyle };
|
||||
|
@@ -1,16 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { gettext } from '../../../utils/constants';
|
||||
import { NoOptionsStyle } from './seahub-select-style';
|
||||
|
||||
const NoGroupMessage = (props) => {
|
||||
return (
|
||||
<div {...props.innerProps} style={NoOptionsStyle}>{gettext('Group not found')}</div>
|
||||
);
|
||||
};
|
||||
|
||||
NoGroupMessage.propTypes = {
|
||||
innerProps: PropTypes.any.isRequired,
|
||||
};
|
||||
|
||||
export { NoGroupMessage };
|
@@ -3,13 +3,27 @@ import PropTypes from 'prop-types';
|
||||
import Select, { components } from 'react-select';
|
||||
import { MenuSelectStyle } from './seahub-select-style';
|
||||
|
||||
const DropdownIndicator = props => {
|
||||
return (
|
||||
components.DropdownIndicator && (
|
||||
<components.DropdownIndicator {...props}>
|
||||
<span className="sf3-font sf3-font-down" style={{ fontSize: '12px', marginLeft: '-2px' }} aria-hidden="true"></span>
|
||||
</components.DropdownIndicator>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const ClearIndicator = ({ innerProps, ...props }) => {
|
||||
const onMouseDown = e => {
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
innerProps.onMouseDown(e);
|
||||
};
|
||||
props.innerProps = { ...innerProps, onMouseDown };
|
||||
return <components.ClearIndicator {...props} />;
|
||||
return (
|
||||
<components.ClearIndicator {...props} >
|
||||
<span className="sf3-font sf3-font-x-01" style={{ fontSize: '12px', marginLeft: '-2px' }} aria-hidden="true"></span>
|
||||
</components.ClearIndicator>
|
||||
);
|
||||
};
|
||||
|
||||
ClearIndicator.propTypes = {
|
||||
@@ -93,7 +107,7 @@ export default class SeahubSelect extends React.Component {
|
||||
className={className}
|
||||
classNamePrefix={classNamePrefix}
|
||||
styles={MenuSelectStyle}
|
||||
components={{ Option, MenuList, ClearIndicator }}
|
||||
components={{ Option, DropdownIndicator, MenuList, ClearIndicator }}
|
||||
placeholder={placeholder}
|
||||
isSearchable={isSearchable}
|
||||
isClearable={isClearable}
|
||||
|
@@ -6,7 +6,7 @@ import { seafileAPI } from '../../utils/seafile-api';
|
||||
import { Utils } from '../../utils/utils';
|
||||
import SharePermissionEditor from '../select-editor/share-permission-editor';
|
||||
import FileChooser from '../file-chooser';
|
||||
import { SeahubSelect, NoGroupMessage } from '../common/select';
|
||||
import GroupSelect from '../common/group-select';
|
||||
import toaster from '../../components/toast';
|
||||
|
||||
class GroupItem extends React.Component {
|
||||
@@ -96,7 +96,7 @@ class LibSubFolderSetGroupPermissionDialog extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
selectedOption: null,
|
||||
selectedOptions: [],
|
||||
errorMsg: [],
|
||||
permission: 'rw',
|
||||
groupPermissionItems: [],
|
||||
@@ -111,10 +111,6 @@ class LibSubFolderSetGroupPermissionDialog extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleSelectChange = (option) => {
|
||||
this.setState({ selectedOption: option });
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.loadOptions();
|
||||
this.listGroupPermissionItems();
|
||||
@@ -126,6 +122,7 @@ class LibSubFolderSetGroupPermissionDialog extends React.Component {
|
||||
return {
|
||||
id: item.id,
|
||||
label: item.name,
|
||||
name: item.name,
|
||||
value: item.name
|
||||
};
|
||||
});
|
||||
@@ -152,20 +149,41 @@ class LibSubFolderSetGroupPermissionDialog extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
onSelectOption = (option) => {
|
||||
const selectedOptions = this.state.selectedOptions.slice(0);
|
||||
const index = selectedOptions.findIndex(item => item.id === option.id);
|
||||
if (index > -1) {
|
||||
selectedOptions.splice(index, 1);
|
||||
} else {
|
||||
selectedOptions.push(option);
|
||||
}
|
||||
this.setState({ selectedOptions: selectedOptions });
|
||||
};
|
||||
|
||||
onDeleteOption = (option) => {
|
||||
const selectedOptions = this.state.selectedOptions.slice(0);
|
||||
const index = selectedOptions.findIndex(item => item.id === option.id);
|
||||
if (index > -1) {
|
||||
selectedOptions.splice(index, 1);
|
||||
}
|
||||
this.setState({ selectedOptions: selectedOptions });
|
||||
};
|
||||
|
||||
setPermission = (permission) => {
|
||||
this.setState({ permission: permission });
|
||||
};
|
||||
|
||||
addGroupFolderPerm = () => {
|
||||
const { selectedOption } = this.state;
|
||||
const { selectedOptions } = this.state;
|
||||
const folderPath = this.props.folderPath || this.state.folderPath;
|
||||
if (!selectedOption || !folderPath) {
|
||||
if (selectedOptions.length === 0 || !folderPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetGroupIds = selectedOptions.map(op => op.id);
|
||||
const request = this.props.isDepartmentRepo ?
|
||||
seafileAPI.addDepartmentRepoGroupFolderPerm(this.props.repoID, this.state.permission, folderPath, selectedOption.id) :
|
||||
seafileAPI.addGroupFolderPerm(this.props.repoID, this.state.permission, folderPath, selectedOption.id);
|
||||
seafileAPI.addDepartmentRepoGroupFolderPerm(this.props.repoID, this.state.permission, folderPath, targetGroupIds) :
|
||||
seafileAPI.addGroupFolderPerm(this.props.repoID, this.state.permission, folderPath, targetGroupIds);
|
||||
request.then(res => {
|
||||
let errorMsg = [];
|
||||
if (res.data.failed.length > 0) {
|
||||
@@ -176,7 +194,7 @@ class LibSubFolderSetGroupPermissionDialog extends React.Component {
|
||||
this.setState({
|
||||
errorMsg: errorMsg,
|
||||
groupPermissionItems: this.state.groupPermissionItems.concat(res.data.success),
|
||||
selectedOption: null,
|
||||
selectedOptions: [],
|
||||
permission: 'rw',
|
||||
folderPath: ''
|
||||
});
|
||||
@@ -296,13 +314,14 @@ class LibSubFolderSetGroupPermissionDialog extends React.Component {
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<SeahubSelect
|
||||
onChange={this.handleSelectChange}
|
||||
<GroupSelect
|
||||
selectedOptions={this.state.selectedOptions}
|
||||
options={this.options}
|
||||
placeholder={gettext('Select a group')}
|
||||
maxMenuHeight={200}
|
||||
value={this.state.selectedOption}
|
||||
noOptionsMessage={NoGroupMessage}
|
||||
onSelectOption={this.onSelectOption}
|
||||
onDeleteOption={this.onDeleteOption}
|
||||
searchPlaceholder={gettext('Search groups')}
|
||||
noOptionsPlaceholder={gettext('No results')}
|
||||
isInModal={true}
|
||||
/>
|
||||
</td>
|
||||
{showPath &&
|
||||
|
@@ -6,9 +6,9 @@ import { seafileAPI } from '../../utils/seafile-api';
|
||||
import { Utils } from '../../utils/utils';
|
||||
import toaster from '../toast';
|
||||
import SharePermissionEditor from '../select-editor/share-permission-editor';
|
||||
import { SeahubSelect, NoGroupMessage } from '../common/select';
|
||||
import EventBus from '../common/event-bus';
|
||||
import { EVENT_BUS_TYPE } from '../common/event-bus-type';
|
||||
import GroupSelect from '../common/group-select';
|
||||
|
||||
class GroupItem extends React.Component {
|
||||
|
||||
@@ -127,7 +127,7 @@ class ShareToGroup extends React.Component {
|
||||
super(props);
|
||||
this.state = {
|
||||
options: [],
|
||||
selectedOption: null,
|
||||
selectedOptions: [],
|
||||
errorMsg: [],
|
||||
permission: 'rw',
|
||||
sharedItems: [],
|
||||
@@ -151,8 +151,24 @@ class ShareToGroup extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleSelectChange = (option) => {
|
||||
this.setState({ selectedOption: option });
|
||||
onSelectOption = (option) => {
|
||||
const selectedOptions = this.state.selectedOptions.slice(0);
|
||||
const index = selectedOptions.findIndex(item => item.id === option.id);
|
||||
if (index > -1) {
|
||||
selectedOptions.splice(index, 1);
|
||||
} else {
|
||||
selectedOptions.push(option);
|
||||
}
|
||||
this.setState({ selectedOptions: selectedOptions });
|
||||
};
|
||||
|
||||
onDeleteOption = (option) => {
|
||||
const selectedOptions = this.state.selectedOptions.slice(0);
|
||||
const index = selectedOptions.findIndex(item => item.id === option.id);
|
||||
if (index > -1) {
|
||||
selectedOptions.splice(index, 1);
|
||||
}
|
||||
this.setState({ selectedOptions: selectedOptions });
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
@@ -174,6 +190,7 @@ class ShareToGroup extends React.Component {
|
||||
obj.value = res.data[i].name;
|
||||
obj.id = res.data[i].id;
|
||||
obj.label = res.data[i].name;
|
||||
obj.name = res.data[i].name;
|
||||
options.push(obj);
|
||||
}
|
||||
this.setState({ options: options });
|
||||
@@ -205,10 +222,10 @@ class ShareToGroup extends React.Component {
|
||||
shareToGroup = () => {
|
||||
const eventBus = EventBus.getInstance();
|
||||
const { isGroupOwnedRepo, itemPath: path, repoID } = this.props;
|
||||
const { permission, selectedOption } = this.state;
|
||||
const targetGroupId = selectedOption.id;
|
||||
const { permission, selectedOptions } = this.state;
|
||||
const targetGroupIds = selectedOptions.map(item => item.id);
|
||||
if (isGroupOwnedRepo) {
|
||||
seafileAPI.shareGroupOwnedRepoToGroup(repoID, permission, targetGroupId, path).then(res => {
|
||||
seafileAPI.shareGroupOwnedRepoToGroup(repoID, permission, targetGroupIds, path).then(res => {
|
||||
let errorMsg = [];
|
||||
if (res.data.failed.length > 0) {
|
||||
for (let i = 0 ; i < res.data.failed.length ; i++) {
|
||||
@@ -228,13 +245,15 @@ class ShareToGroup extends React.Component {
|
||||
|
||||
if (this.props.repo && res.data.success.length > 0) {
|
||||
const sharedRepo = { ...this.props.repo, permission: res.data.success[0].permission };
|
||||
eventBus.dispatch(EVENT_BUS_TYPE.ADD_SHARED_REPO_INO_GROUP, { repo: sharedRepo, group_id: targetGroupId });
|
||||
targetGroupIds.forEach(targetGroupId => {
|
||||
eventBus.dispatch(EVENT_BUS_TYPE.ADD_SHARED_REPO_INTO_GROUP, { repo: sharedRepo, group_id: targetGroupId });
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({
|
||||
errorMsg: errorMsg,
|
||||
sharedItems: this.state.sharedItems.concat(items),
|
||||
selectedOption: null,
|
||||
selectedOptions: [],
|
||||
permission: 'rw',
|
||||
});
|
||||
}).catch(error => {
|
||||
@@ -242,7 +261,7 @@ class ShareToGroup extends React.Component {
|
||||
toaster.danger(errMessage);
|
||||
});
|
||||
} else {
|
||||
seafileAPI.shareFolder(repoID, path, 'group', permission, [targetGroupId]).then(res => {
|
||||
seafileAPI.shareFolder(repoID, path, 'group', permission, targetGroupIds).then(res => {
|
||||
let errorMsg = [];
|
||||
if (res.data.failed.length > 0) {
|
||||
for (let i = 0 ; i < res.data.failed.length ; i++) {
|
||||
@@ -252,13 +271,15 @@ class ShareToGroup extends React.Component {
|
||||
|
||||
if (this.props.repo && res.data.success.length > 0) {
|
||||
const sharedRepo = { ...this.props.repo, permission: res.data.success[0].permission };
|
||||
eventBus.dispatch(EVENT_BUS_TYPE.ADD_SHARED_REPO_INO_GROUP, { repo: sharedRepo, group_id: targetGroupId });
|
||||
targetGroupIds.forEach(targetGroupId => {
|
||||
eventBus.dispatch(EVENT_BUS_TYPE.ADD_SHARED_REPO_INTO_GROUP, { repo: sharedRepo, group_id: targetGroupId });
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({
|
||||
errorMsg: errorMsg,
|
||||
sharedItems: this.state.sharedItems.concat(res.data.success),
|
||||
selectedOption: null,
|
||||
selectedOptions: [],
|
||||
permission: 'rw'
|
||||
});
|
||||
}).catch(error => {
|
||||
@@ -347,15 +368,14 @@ class ShareToGroup extends React.Component {
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<SeahubSelect
|
||||
onChange={this.handleSelectChange}
|
||||
<GroupSelect
|
||||
selectedOptions={this.state.selectedOptions}
|
||||
options={this.state.options}
|
||||
placeholder={gettext('Select groups')}
|
||||
maxMenuHeight={200}
|
||||
value={this.state.selectedOption}
|
||||
noOptionsMessage={NoGroupMessage}
|
||||
isSearchable={true}
|
||||
isClearable={true}
|
||||
onSelectOption={this.onSelectOption}
|
||||
onDeleteOption={this.onDeleteOption}
|
||||
searchPlaceholder={gettext('Search groups')}
|
||||
noOptionsPlaceholder={gettext('No results')}
|
||||
isInModal={true}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
|
@@ -80,7 +80,7 @@ class SysAdminShareDialog extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<Modal isOpen={true} style={{ maxWidth: '720px' }} className="share-dialog" toggle={this.props.toggleDialog}>
|
||||
<Modal isOpen={true} style={{ maxWidth: '800px' }} className="share-dialog" toggle={this.props.toggleDialog}>
|
||||
<ModalHeader toggle={this.props.toggleDialog}>{gettext('Share')} <span className="op-target" title={this.props.itemName}>{this.props.itemName}</span></ModalHeader>
|
||||
<ModalBody className="share-dialog-content">
|
||||
{this.renderDirContent()}
|
||||
|
@@ -7,7 +7,7 @@ import { systemAdminAPI } from '../../../utils/system-admin-api';
|
||||
import { Utils } from '../../../utils/utils';
|
||||
import toaster from '../../toast';
|
||||
import SharePermissionEditor from '../../select-editor/share-permission-editor';
|
||||
import { SeahubSelect, NoGroupMessage } from '../../common/select';
|
||||
import GroupSelect from '../../common/group-select';
|
||||
|
||||
class GroupItem extends React.Component {
|
||||
|
||||
@@ -116,7 +116,7 @@ class SysAdminShareToGroup extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
selectedOption: null,
|
||||
selectedOptions: [],
|
||||
errorMsg: [],
|
||||
permission: 'rw',
|
||||
sharedItems: []
|
||||
@@ -128,10 +128,6 @@ class SysAdminShareToGroup extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleSelectChange = (option) => {
|
||||
this.setState({ selectedOption: option });
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.loadOptions();
|
||||
this.listSharedGroups();
|
||||
@@ -142,6 +138,7 @@ class SysAdminShareToGroup extends React.Component {
|
||||
this.options = [];
|
||||
for (let i = 0 ; i < res.data.length; i++) {
|
||||
let obj = {};
|
||||
obj.name = res.data[i].name;
|
||||
obj.value = res.data[i].name;
|
||||
obj.id = res.data[i].id;
|
||||
obj.label = res.data[i].name;
|
||||
@@ -153,6 +150,26 @@ class SysAdminShareToGroup extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
onSelectOption = (option) => {
|
||||
const selectedOptions = this.state.selectedOptions.slice(0);
|
||||
const index = selectedOptions.findIndex(item => item.id === option.id);
|
||||
if (index > -1) {
|
||||
selectedOptions.splice(index, 1);
|
||||
} else {
|
||||
selectedOptions.push(option);
|
||||
}
|
||||
this.setState({ selectedOptions: selectedOptions });
|
||||
};
|
||||
|
||||
onDeleteOption = (option) => {
|
||||
const selectedOptions = this.state.selectedOptions.slice(0);
|
||||
const index = selectedOptions.findIndex(item => item.id === option.id);
|
||||
if (index > -1) {
|
||||
selectedOptions.splice(index, 1);
|
||||
}
|
||||
this.setState({ selectedOptions: selectedOptions });
|
||||
};
|
||||
|
||||
listSharedGroups = () => {
|
||||
let repoID = this.props.repoID;
|
||||
systemAdminAPI.sysAdminListRepoSharedItems(repoID, 'group').then((res) => {
|
||||
@@ -172,12 +189,11 @@ class SysAdminShareToGroup extends React.Component {
|
||||
};
|
||||
|
||||
shareToGroup = () => {
|
||||
let groups = [];
|
||||
let repoID = this.props.repoID;
|
||||
if (this.state.selectedOption) {
|
||||
groups[0] = this.state.selectedOption.id;
|
||||
}
|
||||
systemAdminAPI.sysAdminAddRepoSharedItem(repoID, 'group', groups, this.state.permission).then(res => {
|
||||
let { selectedOptions } = this.state;
|
||||
if (selectedOptions.length === 0) return;
|
||||
let targetGroupIDs = selectedOptions.map(item => { return item.id; });
|
||||
systemAdminAPI.sysAdminAddRepoSharedItem(repoID, 'group', targetGroupIDs, this.state.permission).then(res => {
|
||||
let errorMsg = [];
|
||||
if (res.data.failed.length > 0) {
|
||||
for (let i = 0 ; i < res.data.failed.length ; i++) {
|
||||
@@ -188,7 +204,7 @@ class SysAdminShareToGroup extends React.Component {
|
||||
this.setState({
|
||||
errorMsg: errorMsg,
|
||||
sharedItems: this.state.sharedItems.concat(items),
|
||||
selectedOption: null,
|
||||
selectedOptions: [],
|
||||
permission: 'rw',
|
||||
});
|
||||
}).catch(error => {
|
||||
@@ -247,13 +263,14 @@ class SysAdminShareToGroup extends React.Component {
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<SeahubSelect
|
||||
onChange={this.handleSelectChange}
|
||||
<GroupSelect
|
||||
selectedOptions={this.state.selectedOptions}
|
||||
options={this.options}
|
||||
placeholder={gettext('Select groups')}
|
||||
maxMenuHeight={200}
|
||||
value={this.state.selectedOption}
|
||||
noOptionsMessage={NoGroupMessage}
|
||||
onSelectOption={this.onSelectOption}
|
||||
onDeleteOption={this.onDeleteOption}
|
||||
searchPlaceholder={gettext('Search groups')}
|
||||
noOptionsPlaceholder={gettext('No results')}
|
||||
isInModal={true}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
|
@@ -52,7 +52,7 @@ class Libraries extends Component {
|
||||
|
||||
const eventBus = EventBus.getInstance();
|
||||
this.unsubscribeAddNewGroup = eventBus.subscribe(EVENT_BUS_TYPE.ADD_NEW_GROUP, this.addNewGroup);
|
||||
this.unsubscribeAddSharedRepoIntoGroup = eventBus.subscribe(EVENT_BUS_TYPE.ADD_SHARED_REPO_INO_GROUP, this.insertRepoIntoGroup);
|
||||
this.unsubscribeAddSharedRepoIntoGroup = eventBus.subscribe(EVENT_BUS_TYPE.ADD_SHARED_REPO_INTO_GROUP, this.insertRepoIntoGroup);
|
||||
this.unsubscribeUnsharedRepoToGroup = eventBus.subscribe(EVENT_BUS_TYPE.UN_SHARE_REPO_TO_GROUP, this.unshareRepoToGroup);
|
||||
}
|
||||
|
||||
|
@@ -267,18 +267,14 @@ class SeafileAPI {
|
||||
return this.req.delete(url, { data: params });
|
||||
}
|
||||
|
||||
shareGroupOwnedRepoToGroup(repoID, permission, groupID, path) {
|
||||
shareGroupOwnedRepoToGroup(repoID, permission, groupIDs, path) {
|
||||
const url = this.server + '/api/v2.1/group-owned-libraries/' + repoID + '/group-share/';
|
||||
let form = new FormData();
|
||||
form.append('permission', permission);
|
||||
form.append('path', path);
|
||||
if (Array.isArray(groupID)) {
|
||||
groupID.forEach(item => {
|
||||
form.append('group_id', item);
|
||||
});
|
||||
} else {
|
||||
form.append('group_id', groupID);
|
||||
}
|
||||
groupIDs.forEach(item => {
|
||||
form.append('group_id', item);
|
||||
});
|
||||
return this._sendPostRequest(url, form);
|
||||
}
|
||||
|
||||
@@ -1825,12 +1821,14 @@ class SeafileAPI {
|
||||
return this.req.get(url);
|
||||
}
|
||||
|
||||
addGroupFolderPerm(repoID, permission, folderPath, groupID) {
|
||||
addGroupFolderPerm(repoID, permission, folderPath, groupIDs) {
|
||||
const url = this.server + '/api2/repos/' + repoID + '/group-folder-perm/';
|
||||
let form = new FormData();
|
||||
form.append('permission', permission);
|
||||
form.append('folder_path', folderPath);
|
||||
form.append('group_id', groupID);
|
||||
groupIDs.forEach(item => {
|
||||
form.append('group_id', item);
|
||||
});
|
||||
return this._sendPostRequest(url, form);
|
||||
}
|
||||
|
||||
@@ -1903,12 +1901,14 @@ class SeafileAPI {
|
||||
return this.req.get(url);
|
||||
}
|
||||
|
||||
addDepartmentRepoGroupFolderPerm(repoID, permission, folderPath, groupID) {
|
||||
addDepartmentRepoGroupFolderPerm(repoID, permission, folderPath, groupIDs) {
|
||||
const url = this.server + '/api/v2.1/group-owned-libraries/' + repoID + '/group-folder-permission/';
|
||||
let form = new FormData();
|
||||
form.append('permission', permission);
|
||||
form.append('folder_path', folderPath);
|
||||
form.append('group_id', groupID);
|
||||
groupIDs.forEach(item => {
|
||||
form.append('group_id', item);
|
||||
});
|
||||
return this._sendPostRequest(url, form);
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user