Unverified Commit 3ea4dd23 authored by Farhan Munshi's avatar Farhan Munshi Committed by GitHub
Browse files

[MM-25264] [MM-25265] Create and apply filter component to user grids (#5882)

* Filter component styling

* Add filter component to team members, filters not hooked up

* Fix types and set proper icon for filter

* Fix tests

* Update redux

* Use existing translations when possible

* Create admin_console_base / css_variables

* Fix typo

* Use selector for filtered count

* Update redux

* Fix typo

* Handle filters with no search better

* Translation for Role

* Update snaps

* Use the right name for filter checkbox

* Remove forceupdate on updatevalues

* Update redux

* Remove filters enabled on datagrid

* Review changes

* Use classnames and fix options destructuring

* Add newlines

* MM-26965 Hide guest option in filters when guest accounts disabled

* Update redux
parent c15b4a30
......@@ -13,6 +13,26 @@ export function setModalSearchTerm(term) {
};
}
export function setUserGridSearch(term) {
return async (dispatch) => {
dispatch({
type: SearchTypes.SET_USER_GRID_SEARCH,
data: term,
});
return {data: true};
};
}
export function setUserGridFilters(filters = {}) {
return async (dispatch) => {
dispatch({
type: SearchTypes.SET_USER_GRID_FILTERS,
data: filters,
});
return {data: true};
};
}
export function setSystemUsersSearch(term, team = '', filter = '') {
return async (dispatch) => {
dispatch({
......
......@@ -28,7 +28,7 @@ type Actions = {
function mapStateToProps(state: GlobalState, props: Props) {
const {id: teamId} = props.team;
let filterOptions: {} = {skipInactive: true};
let filterOptions: {} = {active: true};
if (props.filterExcludeGuests) {
filterOptions = {role: 'system_user', ...filterOptions};
}
......
......@@ -88,7 +88,7 @@
.DataGrid_row {
border-left: 2px solid transparent;
&:nth-child(odd) {
background-color: $bg--gray;
}
......
......@@ -7,6 +7,7 @@ import {FormattedMessage} from 'react-intl';
import NextIcon from 'components/widgets/icons/fa_next_icon';
import PreviousIcon from 'components/widgets/icons/fa_previous_icon';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
import {FilterOptions} from 'components/admin_console/filter/filter';
import DataGridHeader from './data_grid_header';
import DataGridRow from './data_grid_row';
......@@ -54,6 +55,12 @@ type Props = {
search: (term: string) => void;
term: string;
searchPlaceholder?: string;
filterProps?: {
options: FilterOptions;
keys: string[];
onFilter: (options: FilterOptions) => void;
};
};
type State = {
......@@ -182,6 +189,7 @@ class DataGrid extends React.PureComponent<Props, State> {
onSearch={this.search}
placeholder={this.props.searchPlaceholder || ''}
term={this.props.term}
filterProps={this.props.filterProps}
/>
);
}
......
......@@ -7,29 +7,62 @@ import FaSearchIcon from 'components/widgets/icons/fa_search_icon';
import * as Utils from 'utils/utils.jsx';
import Filter, {FilterOptions} from 'components/admin_console/filter/filter';
import './data_grid.scss';
type Props = {
onSearch: (term: string) => void;
placeholder: string;
term: string;
filterProps?: {
options: FilterOptions;
keys: string[];
onFilter: (options: FilterOptions) => void;
};
}
type State = {
term: string;
}
class DataGridSearch extends React.PureComponent<Props> {
class DataGridSearch extends React.PureComponent<Props, State> {
public constructor(props: Props) {
super(props);
this.state = {
term: '',
};
}
handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const term = e.target.value;
this.setState({term});
this.props.onSearch(term);
}
resetSearch = () => {
this.props.onSearch('');
}
};
onFilter = (filters: FilterOptions) => {
this.props.filterProps?.onFilter(filters);
};
render() {
const {filterProps} = this.props;
let {placeholder} = this.props;
if (!placeholder) {
placeholder = Utils.localizeMessage('search_bar.search', 'Search');
}
let filter;
if (filterProps) {
filter = <Filter {...filterProps}/>;
}
return (
<div className='DataGrid_search'>
<div className='DataGrid_searchBar'>
......@@ -52,6 +85,8 @@ class DataGridSearch extends React.PureComponent<Props> {
data-testid='clear-search'
/>
</div>
{filter}
</div>
);
}
......
.Filter {
padding-left: 8px;
position: relative;
display: inline-block;
.Filter_button {
width: auto;
height: 32px;
background: var(--sys-center-channel-bg);
border-radius: 4px;
border: 0px;
color: var(--sys-button-bg);
font-size: 12px;
padding: 0 10px;
&.Filter__active, &:hover, &:focus {
background: linear-gradient(0deg, rgba(var(--sys-button-bg-rgb), 0.12), rgba(var(--sys-button-bg-rgb), 0.12)), var(--sys-center-channel-bg);
}
}
.Icon {
margin-right: 2px;
font-size: 14px;
}
.Filter_content {
display: none;
position: absolute;
padding: 16px;
padding-top: 8px;
min-width: 256px;
margin-bottom: 24px;
background: var(--sys-center-channel-bg);
border: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08);
box-sizing: border-box;
box-shadow: 0px 8px 24px rgba(0, 0, 0, 0.12);
border-radius: 4px;
z-index: 1;
&.Filter__show {
display: block;
}
hr {
margin: 8px -16px;
padding: 0;
}
}
.Filter_header {
display: flex;
flex-direction: row;
justify-content: space-between;
.Filter_title {
font-weight: bold;
font-size: 14px;
line-height: 14px;
padding: 8px;
color: var(--sys-center-channel-color);
}
.Filter_reset {
font-size: 12px;
line-height: 16px;
padding: 8px
}
}
.Filter_lists {
white-space: nowrap;
}
.Filter_apply {
float: right;
margin: 0 8px 8px 0px;
border-radius: 4px;
font-size: 12px;
&:disabled {
background: rgba(var(--sys-center-channel-color-rgb), 0.1);
color: var(--sys-center-channel-color);
}
}
.FilterList {
display: inline-block;
vertical-align: top;
width: 140px;
padding: 16px 16px 16px 8px;
font-size: 14px;
.FilterList__full {
width: 280px;
display: block;
}
.FilterList_name {
font-size: 12px;
font-weight: normal;
padding-bottom: 8px;
}
.FilterList_checkbox {
color: var(--sys-center-channel-color);
input {
vertical-align: bottom;
position: relative;
top: -4px;
margin-right: 8px
}
label {
font-weight: normal;
}
}
}
}
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import classNames from 'classnames';
import FilterList from './filter_list';
import './filter.scss';
export type Filters = {
[filterKey: string]: string[];
};
export type FilterValue = {
name: string | JSX.Element;
value: boolean | string | string[];
};
export type FilterValues = {
[key: string]: FilterValue;
};
export type FilterOption = {
// Display name of the filter option eg. 'Channels', 'Roles' or <FormattedMessage .../>
name: string | JSX.Element;
// List of keys that match the filter values, used to define the order in which the filters appear
keys: string[];
// Key value map of filter values with keys matching the keys above
values: FilterValues;
// Filter Component type, optional parameter defaults to FilterCheckbox
type?: React.ElementType;
}
export type FilterOptions = {
[key: string]: FilterOption;
}
type Props = {
onFilter: (filters: FilterOptions) => void;
options: FilterOptions;
keys: string[];
}
type State = {
show: boolean;
options: FilterOptions;
keys: string[];
optionsModified: boolean;
filterCount: number;
}
class Filter extends React.PureComponent<Props, State> {
private buttonRef: React.RefObject<HTMLButtonElement>;
private filterRef: React.RefObject<HTMLDivElement>;
public constructor(props: Props) {
super(props);
let options = {...props.options};
let keys = [...props.keys];
let valid = true;
keys.forEach((key) => {
const option = options[key];
if (option && valid) {
option.keys.forEach((optionKey) => {
if (!option.values[optionKey]) {
valid = false;
}
});
} else {
valid = false;
}
});
if (!valid) {
options = {};
keys = [];
}
this.state = {
show: false,
options,
keys,
optionsModified: false,
filterCount: 0,
};
this.filterRef = React.createRef();
this.buttonRef = React.createRef();
}
componentDidMount = () => {
document.addEventListener('mousedown', this.handleClickOutside);
}
componentWillUnmount = () => {
document.removeEventListener('mousedown', this.handleClickOutside);
}
handleClickOutside = (event: MouseEvent) => {
if (this.filterRef?.current?.contains(event.target as Node)) {
return;
}
this.hidePopover();
}
hidePopover = () => {
this.setState({show: false});
this.buttonRef?.current?.blur();
}
togglePopover = () => {
if (this.state.show) {
this.hidePopover();
return;
}
this.setState({show: true});
}
updateValues = async (values: FilterValues, optionKey: string) => {
const options = {
...this.state.options,
[optionKey]: {
...this.state.options[optionKey],
values: {
...values
},
},
};
this.setState({options, optionsModified: true});
}
onFilter = () => {
this.props.onFilter(this.state.options);
this.setState({optionsModified: false, show: false, filterCount: this.calculateFilterCount()});
}
calculateFilterCount = () => {
const options = this.state.options;
let filterCount = 0;
this.props.keys.forEach((key) => {
const {values, keys} = options[key];
keys.forEach((filterKey: string) => {
if (values[filterKey].value instanceof Array) {
filterCount += (values[filterKey].value as string[]).length;
} else if (values[filterKey].value) {
filterCount += 1;
}
});
});
return filterCount;
}
resetFilters = () => {
this.setState({options: {...this.props.options}}, this.onFilter);
}
renderFilterOptions = () => {
const {keys, options} = this.state;
return keys.map((key: string) => {
const filter = options[key];
const FilterListComponent = filter.type || FilterList;
return (
<FilterListComponent
option={filter}
optionKey={key}
updateValues={this.updateValues}
key={key}
/>
);
});
}
render() {
const filters = this.renderFilterOptions();
const {filterCount} = this.state;
return (
<div
className='Filter'
ref={this.filterRef}
>
<button
className={classNames('Filter_button', {Filter__active: this.state.show})}
onClick={this.togglePopover}
ref={this.buttonRef}
>
<i className='Icon icon-filter-variant'/>
<FormattedMessage
id='filter.filters'
defaultMessage='Filters'
/>
{filterCount > 0 && ` (${filterCount})`}
</button>
<div
className={classNames('Filter_content', {Filter__show: this.state.show})}
>
<div className='Filter_header'>
<div className='Filter_title'>
<FormattedMessage
id='filter.title'
defaultMessage='Filter by'
/>
</div>
<a
className='Filter_reset'
onClick={this.resetFilters}
>
<FormattedMessage
id='filter.reset'
defaultMessage='Reset filters'
/>
</a>
</div>
<hr/>
<div className='Filter_lists'>
{filters}
</div>
<button
className='Filter_apply style--none btn btn-primary'
disabled={!this.state.optionsModified}
onClick={this.onFilter}
>
<FormattedMessage
id='filter.apply'
defaultMessage='Apply'
/>
</button>
</div>
</div>
);
}
}
export default Filter;
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
type Props = {
name: string;
checked: boolean;
label: string | JSX.Element;
updateOption: (checked: boolean, name: string) => void;
};
class FilterCheckbox extends React.PureComponent<Props> {
toggleOption = () => {
const {checked, name, updateOption} = this.props;
updateOption(!checked, name);
}
render() {
const {name, checked, label} = this.props;
return (
<div className='FilterList_checkbox'>
<label>
<input
type='checkbox'
id={name}
name={name}
checked={checked}
onChange={this.toggleOption}
/>
{label}
</label>
</div>
);
}
}
export default FilterCheckbox;
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FilterOption, FilterValues} from './filter';
import FilterCheckbox from './filter_checkbox';
import './filter.scss';
type Props = {
option: FilterOption;
optionKey: string;
updateValues: (values: FilterValues, optionKey: string) => void;
}
class FilterList extends React.PureComponent<Props> {
updateOption = async (value: boolean, key: string) => {
const values = {...this.props.option.values};
values[key].value = value;
await this.props.updateValues(values, this.props.optionKey);
}
render() {
const {option} = this.props;
const valuesToRender = option.keys.map((optionKey: string, index: number) => {
const currentValue = option.values[optionKey];
const {value, name} = currentValue;
const FilterItem = option.type || FilterCheckbox;
return (
<div
key={index}
className='FilterList_item'
>
<FilterItem
key={index}
name={optionKey}
checked={value}
label={name}