1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-17 15:53:28 +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:
Michael An
2024-12-19 09:53:32 +08:00
committed by GitHub
parent 68f791bb32
commit fc139a83fd
16 changed files with 774 additions and 90 deletions

View File

@@ -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',

View 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;

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

View 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;

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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