From 6353a8318b628426db6760539338f332b3469d84 Mon Sep 17 00:00:00 2001 From: Daniil Senkiv Date: Thu, 5 Mar 2020 17:02:47 +0300 Subject: [PATCH] Web.Common: copy FilterInput component from web.components --- .../src/components/FilterInput/FilterInput.js | 517 ++++++++++++++++++ .../FilterInput/FilterInput.stories.js | 110 ++++ .../FilterInput/FilterInput.test.js | 56 ++ .../src/components/FilterInput/README.md | 46 ++ .../FilterInput/StyledFilterInput.js | 229 ++++++++ .../src/components/FilterInput/index.js | 1 + .../FilterInput/sub-components/CloseButton.js | 28 + .../FilterInput/sub-components/FilterBlock.js | 236 ++++++++ .../sub-components/FilterButton.js | 31 ++ .../FilterInput/sub-components/HideFilter.js | 91 +++ .../sub-components/SortComboBox.js | 145 +++++ web/ASC.Web.Common/src/components/index.js | 3 +- 12 files changed, 1492 insertions(+), 1 deletion(-) create mode 100644 web/ASC.Web.Common/src/components/FilterInput/FilterInput.js create mode 100644 web/ASC.Web.Common/src/components/FilterInput/FilterInput.stories.js create mode 100644 web/ASC.Web.Common/src/components/FilterInput/FilterInput.test.js create mode 100644 web/ASC.Web.Common/src/components/FilterInput/README.md create mode 100644 web/ASC.Web.Common/src/components/FilterInput/StyledFilterInput.js create mode 100644 web/ASC.Web.Common/src/components/FilterInput/index.js create mode 100644 web/ASC.Web.Common/src/components/FilterInput/sub-components/CloseButton.js create mode 100644 web/ASC.Web.Common/src/components/FilterInput/sub-components/FilterBlock.js create mode 100644 web/ASC.Web.Common/src/components/FilterInput/sub-components/FilterButton.js create mode 100644 web/ASC.Web.Common/src/components/FilterInput/sub-components/HideFilter.js create mode 100644 web/ASC.Web.Common/src/components/FilterInput/sub-components/SortComboBox.js diff --git a/web/ASC.Web.Common/src/components/FilterInput/FilterInput.js b/web/ASC.Web.Common/src/components/FilterInput/FilterInput.js new file mode 100644 index 0000000000..a29070af16 --- /dev/null +++ b/web/ASC.Web.Common/src/components/FilterInput/FilterInput.js @@ -0,0 +1,517 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { SearchInput } from 'asc-web-components'; +import isEqual from 'lodash/isEqual'; +import throttle from 'lodash/throttle'; +import FilterBlock from './sub-components/FilterBlock'; +import SortComboBox from './sub-components/SortComboBox'; +import map from 'lodash/map'; +import clone from 'lodash/clone'; +import StyledFilterInput from './StyledFilterInput'; + +const cloneObjectsArray = function (props) { + return map(props, clone); +} +const convertToInternalData = function (fullDataArray, inputDataArray) { + const filterItems = []; + for (let i = 0; i < inputDataArray.length; i++) { + let filterValue = fullDataArray.find(x => ((x.key === inputDataArray[i].key.replace(inputDataArray[i].group + "_", '')) && x.group === inputDataArray[i].group && !x.inSubgroup)); + if (filterValue) { + inputDataArray[i].key = inputDataArray[i].group + "_" + inputDataArray[i].key; + inputDataArray[i].label = filterValue.label; + inputDataArray[i].groupLabel = !fullDataArray.inSubgroup ? fullDataArray.find(x => (x.group === inputDataArray[i].group)).label : inputDataArray[i].groupLabel; + filterItems.push(inputDataArray[i]); + } else { + filterValue = fullDataArray.find(x => ((x.key === inputDataArray[i].key.replace(inputDataArray[i].group + "_", '')) && x.group === inputDataArray[i].group && x.inSubgroup)); + if (filterValue) { + inputDataArray[i].key = inputDataArray[i].group + "_" + inputDataArray[i].key; + inputDataArray[i].label = filterValue.label; + inputDataArray[i].groupLabel = fullDataArray.find(x => (x.subgroup === inputDataArray[i].group)).label; + filterItems.push(inputDataArray[i]); + } else { + filterValue = fullDataArray.find(x => ((x.subgroup === inputDataArray[i].group))); + if (filterValue) { + const subgroupItems = fullDataArray.filter(t => t.group === filterValue.subgroup); + if (subgroupItems.length > 1) { + inputDataArray[i].key = inputDataArray[i].group + "_-1"; + inputDataArray[i].label = filterValue.defaultSelectLabel; + inputDataArray[i].groupLabel = fullDataArray.find(x => (x.subgroup === inputDataArray[i].group)).label; + filterItems.push(inputDataArray[i]); + } else if (subgroupItems.length === 1) { + + const selectFilterItem = { + key: subgroupItems[0].group + "_" + subgroupItems[0].key, + group: subgroupItems[0].group, + label: subgroupItems[0].label, + groupLabel: fullDataArray.find(x => x.subgroup === subgroupItems[0].group).label, + inSubgroup: true + }; + filterItems.push(selectFilterItem); + } + } + } + } + } + return filterItems; +} + +class FilterInput extends React.Component { + constructor(props) { + super(props); + + this.isResizeUpdate = false; + this.minWidth = 190; + + function getDefaultFilterData() { + const filterData = props.getFilterData(); + const filterItems = []; + const selectedFilterData = cloneObjectsArray(props.selectedFilterData.filterValues); + selectedFilterData.forEach(defaultFilterValue => { + const filterValue = filterData.find(x => ((x.key === defaultFilterValue.key.replace(defaultFilterValue.group + "_", '')) && x.group === defaultFilterValue.group)); + let groupLabel = ''; + + const groupFilterItem = filterData.find(x => (x.key === defaultFilterValue.group)); + if (groupFilterItem != undefined) { + groupLabel = groupFilterItem.label; + } else { + const subgroupFilterItem = filterData.find(x => (x.subgroup === defaultFilterValue.group)) + if (subgroupFilterItem != undefined) { + groupLabel = subgroupFilterItem.label; + } + } + + if (filterValue != undefined) { + defaultFilterValue.key = defaultFilterValue.group + "_" + defaultFilterValue.key; + defaultFilterValue.label = filterValue.label; + defaultFilterValue.groupLabel = groupLabel; + filterItems.push(defaultFilterValue); + } + }); + return filterItems; + } + + this.state = { + sortDirection: props.selectedFilterData.sortDirection === "desc" ? true : false, + sortId: props.getSortData().findIndex(x => x.key === props.selectedFilterData.sortId) != -1 ? props.selectedFilterData.sortId : props.getSortData().length > 0 ? props.getSortData()[0].key : "", + searchText: props.selectedFilterData.inputValue || props.value, + + filterValues: props.selectedFilterData ? getDefaultFilterData() : [], + openFilterItems: [], + hideFilterItems: [] + }; + + this.searchWrapper = React.createRef(); + this.filterWrapper = React.createRef(); + + this.onClickSortItem = this.onClickSortItem.bind(this); + this.onSortDirectionClick = this.onSortDirectionClick.bind(this); + this.onChangeSortDirection = this.onChangeSortDirection.bind(this); + this.onSearch = this.onSearch.bind(this); + this.onChangeFilter = this.onChangeFilter.bind(this); + + this.onSearchChanged = this.onSearchChanged.bind(this); + + this.getDefaultSelectedIndex = this.getDefaultSelectedIndex.bind(this); + + this.updateFilter = this.updateFilter.bind(this); + this.onClickFilterItem = this.onClickFilterItem.bind(this); + this.getFilterData = this.getFilterData.bind(this); + this.onFilterRender = this.onFilterRender.bind(this); + this.onDeleteFilterItem = this.onDeleteFilterItem.bind(this); + this.clearFilter = this.clearFilter.bind(this); + + this.throttledResize = throttle(this.resize, 300); + + } + + componentDidMount() { + window.addEventListener('resize', this.throttledResize); + if (this.state.filterValues.length > 0) this.updateFilter(); + } + componentWillUnmount() { + window.removeEventListener('resize', this.throttledResize); + } + componentDidUpdate(prevProps){ + if(this.props.needForUpdate && this.props.needForUpdate(prevProps, this.props)){ + let internalFilterData = convertToInternalData(this.props.getFilterData(), cloneObjectsArray(this.props.selectedFilterData.filterValues)); + this.updateFilter(internalFilterData); + } + } + shouldComponentUpdate(nextProps, nextState) { + + const { selectedFilterData, getFilterData, getSortData, value, id, + isDisabled, size, placeholder } = this.props; + if (!isEqual(selectedFilterData, nextProps.selectedFilterData)) { + let internalFilterData = cloneObjectsArray(this.state.filterValues); + if (nextProps.selectedFilterData.filterValues) { + internalFilterData = convertToInternalData(getFilterData(), cloneObjectsArray(nextProps.selectedFilterData.filterValues)); + this.updateFilter(internalFilterData); + } + this.setState( + { + sortDirection: nextProps.selectedFilterData.sortDirection === "desc" ? true : false, + sortId: getSortData().findIndex(x => x.key === nextProps.selectedFilterData.sortId) != -1 ? nextProps.selectedFilterData.sortId : "", + filterValues: internalFilterData, + searchText: nextProps.selectedFilterData.inputValue || value + } + ); + return true; + } + if (id != nextProps.id || + isDisabled != nextProps.isDisabled || + size != nextProps.size || + placeholder != nextProps.placeholder || + value != nextProps.value) + + return true; + if (this.isResizeUpdate) { + return true; + } + return !isEqual(this.state, nextState); + } + + resize = () => { + this.isResizeUpdate = true; + this.setState({ + filterValues: this.state.filterValues, + openFilterItems: this.state.filterValues, + hideFilterItems: [] + }) + } + onChangeSortDirection(key) { + this.onFilter(this.state.filterValues, this.state.sortId, key ? "desc" : "asc"); + this.setState({ sortDirection: !!key }); + } + getDefaultSelectedIndex() { + const sortData = this.props.getSortData(); + if (sortData.length > 0) { + const defaultIndex = sortData.findIndex(x => x.key === this.state.sortId); + return defaultIndex != -1 ? defaultIndex : 0; + } + return 0; + } + onClickSortItem(key) { + this.setState({ sortId: key }); + this.onFilter(this.state.filterValues, key, this.state.sortDirection ? "desc" : "asc"); + } + onSortDirectionClick() { + + this.onFilter(this.state.filterValues, this.state.sortId, !this.state.sortDirection ? "desc" : "asc"); + this.setState({ sortDirection: !this.state.sortDirection }); + } + onSearchChanged(value) { + this.setState({ searchText: value }); + this.onFilter(this.state.filterValues, this.state.sortId, this.state.sortDirection ? "desc" : "asc", value); + } + onSearch(result) { + this.onFilter(result.filterValues, this.state.sortId, this.state.sortDirection ? "desc" : "asc"); + } + getFilterData() { + const _this = this; + const d = this.props.getFilterData(); + const result = []; + d.forEach(element => { + if (!element.inSubgroup) { + element.onClick = !element.isSeparator && !element.isHeader && !element.disabled ? ((e) => _this.props.onClickFilterItem(e, element)) : undefined; + element.key = element.group != element.key ? element.group + "_" + element.key : element.key; + if (element.subgroup != undefined) { + if (d.findIndex(x => x.group === element.subgroup) === -1) element.disabled = true; + } + result.push(element); + } + }); + return result; + } + clearFilter() { + this.setState({ + searchText: '', + filterValues: [], + openFilterItems: [], + hideFilterItems: [] + }); + this.onFilter([], this.state.sortId, this.state.sortDirection ? "desc" : "asc", ''); + } + updateFilter(inputFilterItems) { + const currentFilterItems = inputFilterItems || cloneObjectsArray(this.state.filterValues); + const fullWidth = this.searchWrapper.current.getBoundingClientRect().width; + const filterWidth = this.filterWrapper.current.getBoundingClientRect().width; + const filterArr = Array.from(Array.from(this.filterWrapper.current.children).find(x => x.id === 'filter-items-container').children); + const searchFilterButton = Array.from(this.filterWrapper.current.children).find(x => x.id != 'filter-items-container'); + + const filterButton = searchFilterButton ? Array.from(searchFilterButton.children)[0] : null; + + if (fullWidth <= this.minWidth && fullWidth > 0) { + this.setState({ + openFilterItems: [], + hideFilterItems: cloneObjectsArray(currentFilterItems) + }); + } else if (filterWidth > fullWidth / 2) { + let newOpenFilterItems = cloneObjectsArray(currentFilterItems); + let newHideFilterItems = []; + + let elementsWidth = 0; + Array.from(filterArr).forEach(element => { + elementsWidth = elementsWidth + element.getBoundingClientRect().width; + }); + + if ( filterButton !== null && (elementsWidth >= (fullWidth / 3) - filterButton.getBoundingClientRect().width)) { + for (let i = 0; i < filterArr.length; i++) { + if (elementsWidth > (fullWidth / 3) - filterButton.getBoundingClientRect().width) { + elementsWidth = elementsWidth - filterArr[i].getBoundingClientRect().width; + const hiddenItem = currentFilterItems.find(x => x.key === filterArr[i].getAttribute('id')); + if (hiddenItem) newHideFilterItems.push(hiddenItem); + newOpenFilterItems.splice(newOpenFilterItems.findIndex(x => x.key === filterArr[i].getAttribute('id')), 1); + } + } + } + this.setState({ + openFilterItems: newOpenFilterItems, + hideFilterItems: newHideFilterItems + }); + + } else { + this.setState({ + openFilterItems: currentFilterItems.slice(), + hideFilterItems: [] + }); + } + } + onDeleteFilterItem(key) { + const currentFilterItems = this.state.filterValues.slice(); + const indexFilterItem = currentFilterItems.findIndex(x => x.key === key); + if (indexFilterItem != -1) { + currentFilterItems.splice(indexFilterItem, 1); + } + this.setState({ + filterValues: currentFilterItems, + openFilterItems: currentFilterItems, + hideFilterItems: [] + }); + let filterValues = cloneObjectsArray(currentFilterItems); + filterValues = filterValues.map(function (item) { + item.key = item.key.replace(item.group + "_", ''); + return item; + }) + this.onFilter(filterValues.filter(item => item.key != '-1'), this.state.sortId, this.state.sortDirection ? "desc" : "asc"); + } + onFilter(filterValues, sortId, sortDirection, searchText) { + let cloneFilterValues = cloneObjectsArray(filterValues); + cloneFilterValues = cloneFilterValues.map(function (item) { + item.key = item.key.replace(item.group + "_", ''); + return item; + }) + this.props.onFilter({ + inputValue: searchText != undefined ? searchText : this.state.searchText, + filterValues: cloneFilterValues.filter(item => item.key != '-1'), + sortId: sortId, + sortDirection: sortDirection + }); + } + onChangeFilter(result) { + this.setState({ + searchText: result.inputValue, + filterValues: result.filterValues, + }); + this.onFilter(result.filterValues, this.state.sortId, this.state.sortDirection ? "desc" : "asc", result.inputValue); + } + onFilterRender() { + if (this.isResizeUpdate) { + this.isResizeUpdate = false; + } + + if (this.searchWrapper.current && this.filterWrapper.current) { + const fullWidth = this.searchWrapper.current.getBoundingClientRect().width; + const filterWidth = this.filterWrapper.current.getBoundingClientRect().width; + if (fullWidth <= this.minWidth || filterWidth > fullWidth / 2) this.updateFilter(); + } + } + onClickFilterItem(event, filterItem) { + const currentFilterItems = cloneObjectsArray(this.state.filterValues); + + if (filterItem.subgroup) { + const indexFilterItem = currentFilterItems.findIndex(x => x.group === filterItem.subgroup); + if (indexFilterItem != -1) { + currentFilterItems.splice(indexFilterItem, 1); + } + const subgroupItems = this.props.getFilterData().filter(t => t.group === filterItem.subgroup); + if (subgroupItems.length > 1) { + const selectFilterItem = { + key: filterItem.subgroup + "_-1", + group: filterItem.subgroup, + label: filterItem.defaultSelectLabel, + groupLabel: filterItem.label, + inSubgroup: true + }; + if (indexFilterItem != -1) + currentFilterItems.splice(indexFilterItem, 0, selectFilterItem); + else + currentFilterItems.push(selectFilterItem); + this.setState({ + filterValues: currentFilterItems, + openFilterItems: currentFilterItems, + hideFilterItems: [] + }); + } else if (subgroupItems.length === 1) { + + const selectFilterItem = { + key: subgroupItems[0].group + "_" + subgroupItems[0].key, + group: subgroupItems[0].group, + label: subgroupItems[0].label, + groupLabel: this.props.getFilterData().find(x => x.subgroup === subgroupItems[0].group).label, + inSubgroup: true + }; + if (indexFilterItem != -1) + currentFilterItems.splice(indexFilterItem, 0, selectFilterItem); + else + currentFilterItems.push(selectFilterItem); + + const clone = cloneObjectsArray(currentFilterItems.filter(item => item.key != '-1')); + clone.map(function (item) { + item.key = item.key.replace(item.group + "_", ''); + return item; + }) + this.onFilter(clone.filter(item => item.key != '-1'), this.state.sortId, this.state.sortDirection ? "desc" : "asc"); + this.setState({ + filterValues: currentFilterItems, + openFilterItems: currentFilterItems, + hideFilterItems: [] + }); + } + } else { + const filterItems = this.getFilterData(); + + const indexFilterItem = currentFilterItems.findIndex(x => x.group === filterItem.group); + if (indexFilterItem != -1) { + currentFilterItems.splice(indexFilterItem, 1); + } + + const selectFilterItem = { + key: filterItem.key, + group: filterItem.group, + label: filterItem.label, + groupLabel: filterItem.inSubgroup ? filterItems.find(x => x.subgroup === filterItem.group).label : filterItems.find(x => x.key === filterItem.group).label + }; + if (indexFilterItem != -1) + currentFilterItems.splice(indexFilterItem, 0, selectFilterItem); + else + currentFilterItems.push(selectFilterItem); + this.setState({ + filterValues: currentFilterItems, + openFilterItems: currentFilterItems, + hideFilterItems: [] + }); + + const clone = cloneObjectsArray(currentFilterItems.filter(item => item.key != '-1')); + clone.map(function (item) { + item.key = item.key.replace(item.group + "_", ''); + return item; + }) + this.onFilter(clone.filter(item => item.key != '-1'), this.state.sortId, this.state.sortDirection ? "desc" : "asc"); + } + + } + + render() { + /* eslint-disable react/prop-types */ + const { className, id, style, size, + isDisabled, scale, getFilterData, placeholder, + getSortData, directionAscLabel, directionDescLabel } = this.props; + /* eslint-enable react/prop-types */ + + const { searchText, filterValues, openFilterItems, + hideFilterItems, sortId, sortDirection} = this.state; + + //console.log("FilterInput render"); + let iconSize = 33; + switch (size) { + case 'base': + iconSize = 33; + break; + case 'middle': + case 'big': + case 'huge': + iconSize = 41; + break; + default: + break; + } + return ( + +
+ 0} + onClearSearch={this.clearFilter} + onChange={this.onSearchChanged} + > +
+ +
+ +
+
+ + 0 ? getSortData().find(x => x.key === sortId) : {}} + onButtonClick={this.onSortDirectionClick} + sortDirection={+sortDirection} + directionAscLabel={directionAscLabel} + directionDescLabel={directionDescLabel} + /> +
+ + ); + } +} + +FilterInput.protoTypes = { + size: PropTypes.oneOf(['base', 'middle', 'big', 'huge']), + autoRefresh: PropTypes.bool, + selectedFilterData: PropTypes.object, + directionAscLabel: PropTypes.string, + directionDescLabel: PropTypes.string, + className: PropTypes.string, + id: PropTypes.string, + style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), + needForUpdate: PropTypes.bool +}; + +FilterInput.defaultProps = { + autoRefresh: true, + selectedFilterData: { + sortDirection: false, + sortId: '', + filterValues: [], + searchText: '' + }, + size: 'base', + needForUpdate: false, + directionAscLabel: 'A-Z', + directionDescLabel: 'Z-A' +}; + +export default FilterInput; \ No newline at end of file diff --git a/web/ASC.Web.Common/src/components/FilterInput/FilterInput.stories.js b/web/ASC.Web.Common/src/components/FilterInput/FilterInput.stories.js new file mode 100644 index 0000000000..39e90c3635 --- /dev/null +++ b/web/ASC.Web.Common/src/components/FilterInput/FilterInput.stories.js @@ -0,0 +1,110 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { StringValue } from 'react-values'; +import { withKnobs, boolean, text, select } from '@storybook/addon-knobs/react'; +import withReadme from 'storybook-readme/with-readme'; +import Readme from './README.md'; +import FilterInput from '.'; +import { Button } from 'asc-web-components'; +import Section from '../../../.storybook/decorators/section'; + +const sizeOptions = ['base', 'middle', 'big', 'huge']; + +function getData() { + return [ + { key: 'filter-status', group: 'filter-status', label: 'Status', isHeader: true }, + { key: '0', group: 'filter-status', label: 'Active' }, + { key: '1', group: 'filter-status', label: 'Disabled' }, + { key: 'filter-type', group: 'filter-type', label: 'Type', isHeader: true }, + { key: '0', group: 'filter-type', label: 'Folders' }, + { key: '1', group: 'filter-type', label: 'Employee' }, + { key: 'filter-test', group: 'filter-test', label: 'Test', isHeader: true }, + { key: '0', group: 'filter-test', label: 'test1' }, + { key: '1', group: 'filter-test', label: 'test2' }, + { key: 'filter-other', group: 'filter-other', label: 'Other', isHeader: true }, + { key: '0', group: 'filter-other', subgroup: 'filter-groups', defaultSelectLabel: 'Select', label: 'Groups' }, + { key: '0', inSubgroup: true, group: 'filter-groups', label: 'Administration'}, + { key: '1', inSubgroup: true, group: 'filter-groups', label: 'Public Relations'} + ]; + } +function getSortData() { + return [ + {key: 'name', label: 'Name', default: true}, + {key: 'surname', label: 'Surname', default: true} + ]; +} + +class FilterStory extends React.Component { + constructor(props) { + super(props); + this.state = { + selectedFilterData: { + inputValue: "text", + filterValues: [ + {key: "1", group: "filter-status"} + ] + } + }; + this.buttonClick = this.buttonClick.bind(this); + } + buttonClick(){ + this.setState({ + selectedFilterData: { + filterValues: [ + {key: "-1", group: "filter-groups"} + ], + sortDirection: "asc", + sortId: "surname", + inputValue: "text 123" + } + }); + } + render(){ + return( +
+ { + action('onChange')(e); + } + } + > + {({ value, set }) => ( +
+
+
+
+ {console.log(result)}} + value={value} + selectedFilterData={this.state.selectedFilterData} + onChange={e => { + set(e.target.value); + }} + /> +
+
+ )} +
+
+ ) + } +} + +storiesOf('Components|FilterInput', module) + .addDecorator(withKnobs) + .addDecorator(withReadme(Readme)) + .add('base', () => ( + + )); \ No newline at end of file diff --git a/web/ASC.Web.Common/src/components/FilterInput/FilterInput.test.js b/web/ASC.Web.Common/src/components/FilterInput/FilterInput.test.js new file mode 100644 index 0000000000..27d440568f --- /dev/null +++ b/web/ASC.Web.Common/src/components/FilterInput/FilterInput.test.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import FilterInput from '.'; + +describe('', () => { + it('renders without error', () => { + const wrapper = mount( + [{ key: 'filter-example', group: 'filter-example', label: 'example group', isHeader: true }, { key: '0', group: 'filter-example', label: 'Test' }]} + getSortData={() => [{ key: 'name', label: 'Name' }, { key: 'surname', label: 'Surname' }]} + onFilter={jest.fn()} + /> + ); + + expect(wrapper).toExist(); + }); + + it('accepts id', () => { + const wrapper = mount( + [{ key: 'filter-example', group: 'filter-example', label: 'example group', isHeader: true }, { key: '0', group: 'filter-example', label: 'Test' }]} + getSortData={() => [{ key: 'name', label: 'Name' }, { key: 'surname', label: 'Surname' }]} + onFilter={jest.fn()} + id="testId" + /> + ); + + expect(wrapper.prop('id')).toEqual('testId'); + }); + + it('accepts className', () => { + const wrapper = mount( + [{ key: 'filter-example', group: 'filter-example', label: 'example group', isHeader: true }, { key: '0', group: 'filter-example', label: 'Test' }]} + getSortData={() => [{ key: 'name', label: 'Name' }, { key: 'surname', label: 'Surname' }]} + onFilter={jest.fn()} + className="test" + /> + ); + + expect(wrapper.prop('className')).toEqual('test'); + }); + + it('accepts style', () => { + const wrapper = mount( + [{ key: 'filter-example', group: 'filter-example', label: 'example group', isHeader: true }, { key: '0', group: 'filter-example', label: 'Test' }]} + getSortData={() => [{ key: 'name', label: 'Name' }, { key: 'surname', label: 'Surname' }]} + onFilter={jest.fn()} + style={{ color: 'red' }} + /> + ); + + expect(wrapper.getDOMNode().style).toHaveProperty('color', 'red'); + }); +}); diff --git a/web/ASC.Web.Common/src/components/FilterInput/README.md b/web/ASC.Web.Common/src/components/FilterInput/README.md new file mode 100644 index 0000000000..cdcda1e8cb --- /dev/null +++ b/web/ASC.Web.Common/src/components/FilterInput/README.md @@ -0,0 +1,46 @@ +# FilterInput + +Used to filter tables + +### Usage + +```js +import { FilterInput } from "asc-web-common"; +``` + +```jsx + [ + { + key: "filter-example", + group: "filter-example", + label: "example group", + isHeader: true + }, + { key: "0", group: "filter-example", label: "Test" } + ]} + getSortData={() => [ + { key: "name", label: "Name", default: true }, + { key: "surname", label: "Surname", default: true } + ]} + onFilter={result => { + console.log(result); + }} +/> +``` + +### Properties + +| Props | Type | Required | Values | Default | Description | +| -------------------- | :------------: | :------: | :-----------------------------: | :-----: | ------------------------------------------------------------------------------------------------------ | +| `className` | `string` | - | - | - | Accepts class | +| `id` | `string` | - | - | - | Used as HTML `id` property | +| `id` | `string` | - | - | - | Accepts id | +| `isDisabled` | `bool` | - | - | `false` | Indicates that the field cannot be used (e.g not authorised, or changes not saved) | +| `onChange` | `func` | - | - | - | Called with the new value. Required when input is not read only. Parent should pass it back as `value` | +| `placeholder` | `string` | - | - | - | Placeholder text for the input | +| `scale` | `bool` | - | - | - | Indicates the input field has scale | +| `selectedFilterData` | `object` | - | - | - | Selected filter data | +| `size` | `string` | | `base`, `middle`, `big`, `huge` | `base` | Supported size of the input fields. | +| `style` | `obj`, `array` | - | - | - | Accepts css style | +| `value` | `string` | - | - | - | Value of the input | diff --git a/web/ASC.Web.Common/src/components/FilterInput/StyledFilterInput.js b/web/ASC.Web.Common/src/components/FilterInput/StyledFilterInput.js new file mode 100644 index 0000000000..99dd97599f --- /dev/null +++ b/web/ASC.Web.Common/src/components/FilterInput/StyledFilterInput.js @@ -0,0 +1,229 @@ +import styled, { css } from 'styled-components'; +import { utils } from 'asc-web-components'; + +const { mobile } = utils.device; + +const StyledFilterInput = styled.div` + width: 100%; + min-width: 255px; + &:after { + content: " "; + display: block; + height: 0; + clear: both; + visibility: hidden; + } + + .styled-search-input { + display: block; + float: left; + width: calc(100% - 140px); + @media ${mobile} { + width: calc(100% - 58px); + } + + .search-input-block { + & > input { + height: 30px; + } + } + } + + .styled-filter-block { + display: flex; + + .filter-button { + + svg { + path:not(:first-child) { + stroke: #A3A9AE; + } + } + + stroke: #A3A9AE; + div:active { + svg path:first-child { + fill: #ECEEF1; + stroke: #A3A9AE; + } + } + div:first-child:hover { + svg path:not(:first-child) { + stroke: #A3A9AE; + } + } + } + } + + .styled-close-button { + margin-left: 7px; + margin-top: -1px; + } + + .styled-filter-block { + display: flex; + align-items: center; + } + + .styled-combobox { + display: inline-block; + background: transparent; + max-width: 185px; + cursor: pointer; + vertical-align: middle; + margin-top: -2px; + > div:first-child{ + width: auto; + padding-left: 4px; + } + > div:last-child{ + max-width: 220px; + } + .combo-button-label { + color: #333; + } + } + + .styled-filter-name { + line-height: 18px; + margin-left: 5px; + } + + .styled-hide-filter { + display: inline-block; + height: 100%; + } + + .dropdown-style { + position: relative; + + .drop-down { + padding: 16px; + } + } + + .styled-sort-combobox { + display: block; + float: left; + width: 132px; + margin-left: 8px; + + @media ${mobile} { + width: 50px; + .optionalBlock ~ div:first-child{ + opacity: 0 + } + } + + .combo-button-label { + color: #333; + } + } + + +`; + +export const StyledFilterItem = styled.div` + display: ${props => props.block ? 'flex' : 'inline-block'}; + margin-bottom: ${props => props.block ? '8px' : '0'}; + position: relative; + height: 100%; + margin-right: 2px; + border: 1px solid #ECEEF1; + border-radius: 3px; + background-color: #F8F9F9; + padding-right: 22px; + + font-weight: 600; + font-size: 13px; + line-height: 15px; + box-sizing: border-box; + color: #555F65; + + &:last-child{ + margin-bottom: 0; + } +`; + +export const StyledFilterItemContent = styled.div` + display: flex; + padding: 5px 4px 2px 7px; + width: 100%; + user-select: none; + color: #333; + ${props => + props.isOpen && !props.isDisabled && + css` + background: #ECEEF1; + `} + ${props => + !props.isDisabled && + css` + &:active{ + background: #ECEEF1; + } + `} +`; + +export const StyledCloseButtonBlock = styled.div` + display: flex; + cursor: ${props => + props.isDisabled || !props.isClickable ? "default" : "pointer"}; + align-items: center; + position: absolute; + height: 100%; + width: 25px; + border-left: 1px solid #ECEEF1; + right: 0; + top: 0; + background-color: #F8F9F9; + ${props => + !props.isDisabled && + css` + &:active{ + background: #ECEEF1; + svg path:first-child { + fill: #A3A9AE; + } + } + `} +`; + +export const Caret = styled.div` + width: 7px; + position: absolute; + right: 6px; + transform: ${props => (props.isOpen ? "rotate(180deg)" : "rotate(0)")}; + top: ${props => (props.isOpen ? "2px" : "0")}; +`; + +export const StyledHideFilterButton = styled.div` + box-sizing: border-box; + display: flex; + position: relative; + align-items: center; + font-weight: 600; + font-size: 16px; + height: 100%; + border: 1px solid #eceef1; + border-radius: 3px; + background-color: #f8f9f9; + padding: 0 20px 0 9px; + margin-right: 2px; + cursor: ${props => (props.isDisabled ? "default" : "pointer")}; + font-family: Open Sans; + font-style: normal; + + :hover { + border-color: ${props => (props.isDisabled ? "#ECEEF1" : "#A3A9AE")}; + } + :active { + background-color: ${props => (props.isDisabled ? "#F8F9F9" : "#ECEEF1")}; + } +`; + +export const StyledIconButton = styled.div` + transform: ${state => !state.sortDirection ? 'scale(1, -1)' : 'scale(1)'}; +`; + +export default StyledFilterInput; \ No newline at end of file diff --git a/web/ASC.Web.Common/src/components/FilterInput/index.js b/web/ASC.Web.Common/src/components/FilterInput/index.js new file mode 100644 index 0000000000..0c14f58d08 --- /dev/null +++ b/web/ASC.Web.Common/src/components/FilterInput/index.js @@ -0,0 +1 @@ +export default from './FilterInput'; \ No newline at end of file diff --git a/web/ASC.Web.Common/src/components/FilterInput/sub-components/CloseButton.js b/web/ASC.Web.Common/src/components/FilterInput/sub-components/CloseButton.js new file mode 100644 index 0000000000..f29cef1415 --- /dev/null +++ b/web/ASC.Web.Common/src/components/FilterInput/sub-components/CloseButton.js @@ -0,0 +1,28 @@ +import React from "react"; +import { IconButton } from 'asc-web-components'; +import PropTypes from 'prop-types'; + +const CloseButton = props => { + //console.log("CloseButton render"); + const { className, isDisabled, onClick } = props; + return ( +
+ +
+ ); +}; +CloseButton.propTypes = { + isDisabled: PropTypes.bool, + onClick: PropTypes.func, + className: PropTypes.string +} +export default CloseButton \ No newline at end of file diff --git a/web/ASC.Web.Common/src/components/FilterInput/sub-components/FilterBlock.js b/web/ASC.Web.Common/src/components/FilterInput/sub-components/FilterBlock.js new file mode 100644 index 0000000000..472f3d9a62 --- /dev/null +++ b/web/ASC.Web.Common/src/components/FilterInput/sub-components/FilterBlock.js @@ -0,0 +1,236 @@ +import React from 'react'; +import FilterButton from './FilterButton'; +import HideFilter from './HideFilter'; +import throttle from 'lodash/throttle'; +import { ComboBox } from 'asc-web-components'; +import CloseButton from './CloseButton'; +import isEqual from 'lodash/isEqual'; +import PropTypes from 'prop-types'; +import { StyledFilterItem, StyledFilterItemContent, StyledCloseButtonBlock } from '../StyledFilterInput'; + +class FilterItem extends React.Component { + constructor(props) { + super(props); + + const { id } = props; + + this.state = { + id, + isOpen: false + }; + } + + onSelect = (option) => { + const { group, key, label, inSubgroup } = option; + this.props.onSelectFilterItem(null, { + key: group + "_" + key, + label, + group, + inSubgroup: !!inSubgroup + }); + } + onClick = () => { + const { isDisabled, id, onClose } = this.props; + !isDisabled && onClose(id); + } + + toggleCombobox= (e, isOpen) => this.setState({ isOpen }); + + render() { + const { id, isOpen } = this.state; + const { block, opened, isDisabled, groupLabel, + groupItems, label } = this.props; + return ( + + + {groupLabel}: + {groupItems.length > 1 ? + + : {label} + } + + + + + + + + ); + } +} +FilterItem.propTypes = { + id: PropTypes.string, + opened: PropTypes.bool, + isDisabled: PropTypes.bool, + block: PropTypes.bool, + groupItems: PropTypes.array, + label: PropTypes.string, + groupLabel: PropTypes.string, + onClose: PropTypes.func, + onSelectFilterItem: PropTypes.func +} + +class FilterBlock extends React.Component { + constructor(props) { + super(props); + + const { hideFilterItems, openFilterItems } = props; + + this.state = { + hideFilterItems: hideFilterItems || [], + openFilterItems: openFilterItems || [] + }; + + this.throttledRender = throttle(this.onRender, 100); + + } + + componentDidUpdate() { + this.throttledRender(); + } + + shouldComponentUpdate(nextProps, nextState) { + + const { hideFilterItems, openFilterItems } = nextProps; + + if (!isEqual(this.props, nextProps)) { + if (!isEqual(this.props.hideFilterItems, hideFilterItems) || !isEqual(this.props.openFilterItems, openFilterItems)) { + this.setState({ + hideFilterItems, + openFilterItems + }); + return false; + } + return true; + } + if (this.props.isResizeUpdate) { + return true; + } + return !isEqual(this.state, nextState); + } + + onDeleteFilterItem = (key) => { + this.props.onDeleteFilterItem(key); + } + getFilterItems = () => { + const { openFilterItems, hideFilterItems } = this.state; + const _this = this; + let result = []; + let openItems = []; + let hideItems = []; + if (openFilterItems.length > 0) { + openItems = openFilterItems.map(function (item) { + const { key, group, groupLabel, label } = item; + return + + }); + } + if (hideFilterItems.length > 0) { + let open = false; + let hideFilterItemsList = hideFilterItems.map(function (item) { + const { key, group, groupLabel, label } = item; + open = key.indexOf('_-1') == -1 ? false : true + return + + }) + hideItems.push( + + { + hideFilterItemsList + } + + ); + } + result = hideItems.concat(openItems); + return result; + } + getData = () => { + const _this = this; + const d = this.props.getFilterData(); + let result = []; + d.forEach(element => { + if (!element.inSubgroup) { + element.onClick = !element.isSeparator && !element.isHeader && !element.disabled ? ((e) => _this.props.onClickFilterItem(e, element)) : undefined; + element.key = element.group != element.key ? element.group + "_" + element.key : element.key; + if (element.subgroup != undefined) { + if (d.findIndex(x => x.group === element.subgroup) == -1) element.disabled = true; + } + result.push(element); + } + }); + return result; + } + + onRender = () => { + this.props.onRender(); + } + render() { + const _this = this; + const filterItems = this.getFilterItems(); + const filterData = this.props.getFilterData(); + const { iconSize, isDisabled } = this.props; + return ( + <> +
+ {filterItems} +
+ {filterData.length > 0 && } + + ); + } +} +FilterBlock.propTypes = { + getFilterData: PropTypes.func, + hideFilterItems: PropTypes.array, + iconSize: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + isDisabled: PropTypes.bool, + isResizeUpdate: PropTypes.bool, + onDeleteFilterItem: PropTypes.func, + onRender: PropTypes.func, + openFilterItems: PropTypes.array, +} + +export default FilterBlock; \ No newline at end of file diff --git a/web/ASC.Web.Common/src/components/FilterInput/sub-components/FilterButton.js b/web/ASC.Web.Common/src/components/FilterInput/sub-components/FilterButton.js new file mode 100644 index 0000000000..566a2e4a51 --- /dev/null +++ b/web/ASC.Web.Common/src/components/FilterInput/sub-components/FilterButton.js @@ -0,0 +1,31 @@ +import React from "react"; +import { ContextMenuButton } from 'asc-web-components'; +import PropTypes from 'prop-types'; + +class FilterButton extends React.PureComponent { + render() { + const { getData, id, isDisabled, iconSize } = this.props; + //console.log('render FilterButton) + return ( + + ) + } +} +FilterButton.propTypes = { + getData: PropTypes.func, + iconSize: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + id: PropTypes.string, + isDisabled: PropTypes.bool, +} +export default FilterButton \ No newline at end of file diff --git a/web/ASC.Web.Common/src/components/FilterInput/sub-components/HideFilter.js b/web/ASC.Web.Common/src/components/FilterInput/sub-components/HideFilter.js new file mode 100644 index 0000000000..d4ff77ceda --- /dev/null +++ b/web/ASC.Web.Common/src/components/FilterInput/sub-components/HideFilter.js @@ -0,0 +1,91 @@ +import React from "react"; +import { Icons, DropDown, utils } from "asc-web-components"; +import PropTypes from 'prop-types'; +import { Caret, StyledHideFilterButton } from '../StyledFilterInput'; + +const { handleAnyClick } = utils.event; + +class HideFilter extends React.Component { + constructor(props) { + super(props); + + this.ref = React.createRef(); + this.dropDownRef = React.createRef(); + this.state = { + popoverOpen: this.props.open + }; + } + + componentWillUnmount() { + handleAnyClick(false, this.handleClick); + } + + componentDidUpdate(prevState) { + const { popoverOpen } = this.state; + if (popoverOpen !== prevState.popoverOpen) { + handleAnyClick(popoverOpen, this.handleClick); + } + } + + onClick = (state, e) => { + if (!state && e && this.dropDownRef.current.contains(e.target)) { + return; + } + if (!this.props.isDisabled) { + this.setState({ + popoverOpen: state + }); + } + }; + + handleClick = e => { + this.state.popoverOpen && + !this.dropDownRef.current.firstElementChild.contains(e.target) && + this.onClick(false); + }; + + render() { + //console.log("HideFilter render"); + const { isDisabled, count, children } = this.props; + const { popoverOpen } = this.state; + return ( +
+ + {count} + + + + + +
+ + {children} + +
+
+ ); + } +} +HideFilter.propTypes = { + children: PropTypes.any, + count: PropTypes.number, + isDisabled: PropTypes.bool, + open: PropTypes.bool, +} +export default HideFilter; diff --git a/web/ASC.Web.Common/src/components/FilterInput/sub-components/SortComboBox.js b/web/ASC.Web.Common/src/components/FilterInput/sub-components/SortComboBox.js new file mode 100644 index 0000000000..8e8fd27cd8 --- /dev/null +++ b/web/ASC.Web.Common/src/components/FilterInput/sub-components/SortComboBox.js @@ -0,0 +1,145 @@ +import React from 'react'; +import isEqual from 'lodash/isEqual'; +import { ComboBox, IconButton, DropDownItem, RadioButtonGroup } from 'asc-web-components'; +import PropTypes from 'prop-types'; +import { StyledIconButton } from '../StyledFilterInput'; + +class SortComboBox extends React.Component { + constructor(props) { + super(props); + + const { sortDirection } = props; + + this.state = { + sortDirection + } + + this.combobox = React.createRef(); + } + onButtonClick = () => { + const { onChangeSortDirection } = this.props; + const { sortDirection } = this.state; + typeof onChangeSortDirection === 'function' && onChangeSortDirection(+(sortDirection === 0 ? 1 : 0)); + this.setState({ + sortDirection: sortDirection === 0 ? 1 : 0 + }); + } + + onChangeSortId = (e) => { + const { onChangeSortId } = this.props; + typeof onChangeSortId === 'function' && onChangeSortId(e.target.value); + } + onChangeSortDirection = (e) => { + const sortDirection = +e.target.value; + const { onChangeSortDirection } = this.props; + this.setState({ sortDirection }); + typeof onChangeSortDirection === 'function' && onChangeSortDirection(sortDirection); + } + shouldComponentUpdate(nextProps, nextState) { + //TODO + /*const comboboxText = this.combobox.current.ref.current.children[0].children[1]; + if(comboboxText.scrollWidth > Math.round(comboboxText.getBoundingClientRect().width)){ + comboboxText.style.opacity = "0"; + }else{ + comboboxText.style.opacity = "1"; + }*/ + const { sortDirection } = nextProps; + if (this.props.sortDirection !== sortDirection) { + this.setState({ + sortDirection + }); + return true; + } + return (!isEqual(this.props, nextProps) || !isEqual(this.state, nextState)); + } + render() { + const { options, directionAscLabel, directionDescLabel, isDisabled, + selectedOption } = this.props; + const { sortDirection } = this.state; + let sortArray = options.map(function (item) { + item.value = item.key + return item; + }); + let sortDirectionArray = [ + { value: '0', label: directionAscLabel }, + { value: '1', label: directionDescLabel } + ]; + + const isMobile = window.innerWidth > 375; //TODO: Make some better + + const advancedOptions = ( + <> + + + + + + + + + ); + return ( + + + + + + ); + } +} + +SortComboBox.propTypes = { + directionAscLabel: PropTypes.string, + directionDescLabel: PropTypes.string, + isDisabled: PropTypes.bool, + onButtonClick: PropTypes.func, + onChangeSortDirection: PropTypes.func, + onChangeSortId: PropTypes.func, + sortDirection: PropTypes.number, +} + +SortComboBox.defaultProps = { + isDisabled: false, + sortDirection: 0 +} + + +export default SortComboBox; \ No newline at end of file diff --git a/web/ASC.Web.Common/src/components/index.js b/web/ASC.Web.Common/src/components/index.js index b6e87594c4..c822c0018e 100644 --- a/web/ASC.Web.Common/src/components/index.js +++ b/web/ASC.Web.Common/src/components/index.js @@ -11,4 +11,5 @@ export { default as PageLayout } from './PageLayout'; export { default as Layout } from './Layout'; export { default as ProfileMenu } from './ProfileMenu'; export { default as ErrorContainer } from './ErrorContainer'; -export { default as ErrorBoundary } from './ErrorBoundary'; \ No newline at end of file +export { default as ErrorBoundary } from './ErrorBoundary'; +export { default as FilterInput } from './FilterInput'; \ No newline at end of file