instantjob-recruiter-client
Version:
a set of tools for creating an instantjob recruiter react client
483 lines (455 loc) • 16.7 kB
JSX
import React, {Component} from 'react'
import {connect} from 'react-redux'
import classNames from 'classnames/bind'
import {
MdCheckBox, MdCheckBoxOutlineBlank, MdAddCircle, MdKeyboardArrowDown, MdPerson,
} from 'react-icons/lib/md'
import {ItemsCounter, Count} from './styles.react.scss'
import SelectedItems from 'components/filterable_list/selected_items'
import NonExclusiveField from 'components/non_exclusive_field'
import CommentField from 'components/comment_field'
import FileField from 'components/file_field'
import Filter from 'components/filter'
import Button from 'components/utils/button'
import List from 'components/list'
import SearchBar from 'components/search_bar'
import HorizontalScrollableView from 'components/utils/horizontal_scrollable_view'
import NonExclusiveFieldFilter from 'components/non_exclusive_field_filter'
import SecondaryActions from 'components/utils/secondary_actions'
import {tolerant_equal} from 'selectors/base'
import {get_fields} from 'selectors/fields'
import {
action_create_value, new_values_fuse, new_comments_fuse, allow_creating_value,
} from 'common/fields'
import store from 'common/store'
import auto_bind from 'common/auto_bind'
import request from 'common/request'
import persistent_state from 'common/persistent_state'
import event_system from 'common/event_system'
import {
range, set_from_array, array_contains, make_persistent, map_hash, for_all, array_from_set,
array_sum, empty_set, set_count,
} from 'common/utilities'
import {alert_failure, alert_warning, show_popover, dismiss_popover} from 'actions/display'
import {update_field, remove_field} from 'actions/fields'
const cx = classNames.bind(require('styles/filterable_table.scss'))
const get_values_with_selection = make_persistent(
(values, selected, locked, existing_values) => {
const existing_value_ids = {}
existing_values.forEach((values) => values.forEach(({id}) => existing_value_ids[id] = true))
return values.filter(({id}) => existing_value_ids[id] || selected[id] || locked[id])
.map((value) => ({
...value,
selected: selected[value.id],
locked: locked[value.id],
}))
}
)
const compare = (a, b, null_values = [], ascending = true) => {
let is_null = (c) => array_contains([null, undefined, ...null_values], c, true)
let a_is_null = is_null(a), b_is_null = is_null(b)
if (b_is_null) {
if (a_is_null) {
return 0
} else {
return -1
}
} else {
if (a_is_null) {
return 1
} else {
if (a > b) {
return ascending ? 1 : -1
} else if (a < b) {
return ascending ? -1 : 1
} else {
return 0
}
}
}
}
let counter = 0
class FilterableTable extends Component {
constructor(props) {
super(props)
this.state = {
comment_filtered_item_ids: {},
value_filters: {},
order_by: null,
order_by_field: null,
ascending: true,
edit_field_mode: false,
...persistent_state.get(props.persistent_state_key),
}
auto_bind(this)
}
componentDidMount() {
this.mounted = true
this.props.get_table_ref(this)
this.selected_items_box = `filterable_table_selected_items_${counter++}`
}
componentWillUnmount() {
this.mounted = false
persistent_state.store(this.props.persistent_state_key, this.state)
}
componentWillReceiveProps({items, selected}) {
if (items !== this.props.items || selected !== this.props.selected) {
event_system.post(this.selected_items_box, array_from_set(selected).map((id) => items[id]))
}
}
start_edit_field(field_id) {
this.setState({edit_field_mode: field_id})
}
on_comment_query_change(field_id, query, results) {
if (this.mounted && !tolerant_equal(results, this.state.comment_filtered_item_ids[field_id])) {
this.setState({
comment_filtered_item_ids: {
...this.state.comment_filtered_item_ids,
[field_id]: results,
},
})
}
}
toggle_filtered_value(value) {
if (this.mounted) {
this.setState({value_filters: {...this.state.value_filters, [value.id]: !this.state.value_filters[value.id]}})
}
}
order_by(order_by, ascending = null) {
if (this.mounted) {
if (this.state.order_by == order_by) {
this.setState({ascending: ascending === null ? !this.state.ascending : ascending})
} else {
this.setState({order_by, ascending: ascending === null ? true : ascending})
}
}
}
order_by_comment(field_id) {
if (this.mounted) {
if (this.state.order_by == 'comment' && this.state.order_by_field == field_id) {
this.setState({ascending: !this.state.ascending})
} else {
this.setState({order_by: 'comment', order_by_field: field_id, ascending: true})
}
}
}
get_filtered_item_ids() {
let order = map_hash(this.props.items, (item) => {
if (this.state.order_by == 'comment') {
let {comments} = this.props.fields[this.state.order_by_field]
return comments[item.id]
} else {
return item[this.state.order_by]
}
})
return Object.keys(this.state.comment_filtered_item_ids).reduce((filtered_item_ids, field_id) => {
let results = this.state.comment_filtered_item_ids[field_id]
if (results) {
let results_set = set_from_array(results)
return filtered_item_ids.filter((id) => results_set[id])
} else {
return filtered_item_ids
}
}, [...array_from_set(this.state.value_filters), ...array_from_set(this.props.locked_values)].reduce((filtered_item_ids, value_id) => {
return filtered_item_ids.filter(this.props.filter_value(value_id))
}, this.props.filtered_item_ids))
.filter((id) => this.props.items[id])
.sort((id1, id2) => compare(order[id1], order[id2], ["", " "], this.state.ascending))
}
view_selected() {
const {on_card_click, render_card} = this.props
store.dispatch(show_popover(
SelectedItems,
{
on_card_click,
render_card,
box: this.selected_items_box,
},
""
))
}
render() {
let {actions, selectable, selected, create_field} = this.props
let fake_state = {fields: this.props}
let fields = get_fields(fake_state)
let filtered_item_ids = this.get_filtered_item_ids()
let all_selected = for_all(filtered_item_ids, (id) => this.props.selected[id])
let secondary_actions_active = array_sum(actions.map(({badge}) => badge || 0))
return (
<div className={cx('filterable-table', {'filterable-table_selectable': this.props.selectable})}>
{this.props.title ? (
<div className={cx('filterable-table__top')}>
<div className={cx('filterable-table__top-title')}>
{this.props.title}
</div>
<div className={cx('filterable-table__top-actions')}>
{empty_set(selected) ? null : (
<ItemsCounter onClick={this.view_selected}>
<Count>
{set_count(selected)}
</Count>
<MdPerson />
</ItemsCounter>
)}
{actions.length > 0 ? (
<SecondaryActions active={secondary_actions_active}>
{actions}
</SecondaryActions>
) : null}
{this.props.main_action ? (
<Button {...this.props.main_action} />
) : null}
</div>
</div>
) : null}
<HorizontalScrollableView>
<div className={cx('filterable-table__main')}>
<div className={cx('filterable-table__header')}>
{this.props.selectable ? (
<div className={cx('filterable-table__all-items')}>
<div className={cx('filterable-table__items-count')}>
{filtered_item_ids.length}
</div>
<div className={cx('filterable-table__select-all-checkbox', {'filterable-table__select-all-checkbox_selected': all_selected})} onClick={() => this.props.on_select_all(filtered_item_ids)}>
{all_selected ? (
<MdCheckBox />
) : (
<MdCheckBoxOutlineBlank />
)}
</div>
</div>
) : null}
<div className={cx('filterable-table__card-header')}>
{this.props.children}
</div>
<div className={cx('filterable-table__fields-header')}>
{fields.map((field) => {
let props = {
key: field.id,
id: field.id,
name: field.name,
}
switch (field.category) {
case 'non_exclusive':
return (
<NonExclusiveFieldFilter
{...props}
values={get_values_with_selection(
field.values,
this.state.value_filters,
this.props.locked_values,
filtered_item_ids.map((item_id) => this.props.get_item_field_values(item_id, field.id))
)}
toggle_filtered_value={this.toggle_filtered_value}
className={cx('filterable-table__filter_field')}
stop_recycling={() => this.props.stop_recycling(field.id)}
/>
)
case 'comment':
return (
<CommentFieldFilter
{...props}
comments={Object.keys(field.comments).map((id) => ({id, comment: field.comments[id]}))}
default_results={Object.keys(this.props.items)}
on_query_change={(query, results) => this.on_comment_query_change(field.id, query, results)}
sort_items={() => this.order_by_comment(field.id)}
/>
)
case 'file':
return (
<FileFieldFilter {...props} />
)
default:
return null
}
})}
{this.props.editable && create_field ? (
<Button icon={MdAddCircle} onClick={create_field} discreet className={cx('filterable-table__add-field-button')}>
Nouveau Champ
</Button>
) : null}
</div>
</div>
<div className={cx('filterable-table__rows')}>
<List item_height={60}>
{filtered_item_ids.map((id) => (
<Item
key={id}
item={this.props.items[id]}
fields={fields}
get_item_field_values={this.props.get_item_field_values}
add_value={(value_id) => this.props.add_value(id, value_id)}
remove_value={(value_id) => this.props.remove_value(id, value_id)}
update_comment={(field_id, comment) => this.props.update_comment(id, field_id, comment)}
editable={this.props.editable}
selectable={this.props.selectable}
selected={this.props.selected[id]}
render_card={this.props.render_card}
on_card_click={() => this.props.on_card_click(id)}
item_type={this.props.item_type}
/>
))}
</List>
</div>
</div>
</HorizontalScrollableView>
</div>
)
}
}
class Item extends Component {
shouldComponentUpdate(props) {
return props.item !== this.props.item ||
props.fields !== this.props.fields ||
props.selected !== this.props.selected
}
render() {
return (
<div className={cx('filterable-table__item')}>
<div className={cx('filterable-table__card', {'filterable-table__card_selected': this.props.selected})} onClick={this.props.on_card_click}>
{this.props.render_card(this.props.item)}
</div>
{this.props.fields.map((field) => {
let props = {
key: field.id,
...field,
editable: this.props.editable,
edit_mode: 'click',
show_label: false,
}
switch (field.category) {
case "non_exclusive":
return (
<ItemNonExclusiveField
{...props}
selected_values={this.props.get_item_field_values(this.props.item.id, field.id)}
add_value={this.props.add_value}
remove_value={this.props.remove_value}
allow_creating_values={allow_creating_value(this.props.item_type, field)}
/>
)
case "comment":
return (
<ItemCommentField
{...props}
comment={field.comments[this.props.item.id]}
update_comment={(comment) => this.props.update_comment(field.id, comment)}
/>
)
case "file":
return (
<ItemFileField
{...props}
file={field.files[this.props.item.id]}
user_id={this.props.item.id}
/>
)
default:
return null
}
})}
<div key="dummy1" className={cx('filterable-table__field')}>
</div>
<div key="dummy2" className={cx('filterable-table__field')}>
</div>
</div>
)
}
}
const ItemNonExclusiveField = (props) => (
<div className={cx('filterable-table__field')}>
<NonExclusiveField
{...props}
ellipsis={3}
value_ellipsis={65}
field_ellipsis={150}
on_select_value={props.add_value}
on_deselect_value={props.remove_value}
create_value={(value_name) => new Promise((resolve, reject) => {
let characters = value_name.split("")
if (array_contains(characters, ',') || array_contains(characters, ';')) {
store.dispatch(alert_failure(`Les caractères ',' et ';' sont interdits dans le nom d'une valeur.`))
reject()
} else {
action_create_value(props.id, value_name)
.then(resolve)
}
})}
/>
</div>
)
const ItemCommentField = (props) => (
<div className={cx('filterable-table__field')}>
<CommentField
{...props}
comment_ellipsis={300}
on_submit_comment={props.update_comment}
/>
</div>
)
const ItemFileField = (props) => (
<div className={cx('filterable-table__field')}>
<FileField {...props} />
</div>
)
class CommentFieldFilter extends Component {
constructor(props) {
super(props)
this.state = {
active: false,
}
this.on_query_change = this.on_query_change.bind(this)
}
on_query_change(query, results) {
if (this.state.active == (query == "")) {
this.setState({active: !this.state.active})
}
this.props.on_query_change(query, results)
}
render() {
return (
<Filter
className={cx('filterable-table__filter', 'filterable-table__filter_field')}
active={this.state.active}
label={this.props.name}
onClick={this.props.sort_items}
>
<SearchBar
ref={(search_bar) => this.search_bar = search_bar}
className={cx('filterable-table__filter-search-bar')}
extend={false}
make_fuse={new_comments_fuse}
items={this.props.comments}
default_results={this.props.default_results}
on_query_change={this.on_query_change}
/>
</Filter>
)
}
}
const FileFieldFilter = ({name}) => (
<Filter
className={cx('filterable-table__filter', 'filterable-table__filter_field')}
active={false}
label={name}
/>
)
FilterableTable.defaultProps = {
items: {},
fields: {},
values: {},
on_card_click: (item_id) => {},
editable: true,
selectable: false,
shareable_with_users: false,
selected: {},
locked_values: {},
on_select_all: (filtered_item_ids) => {},
get_item_field_values: (item_id, field_id) => null,
filter_value: (value_id) => (id) => true,
actions: [],
get_table_ref: (table) => {},
item_type: null,
persistent_state_key: "",
}
export default FilterableTable