Skip to content

Instantly share code, notes, and snippets.

@mluxe
Created March 26, 2021 09:49
Show Gist options
  • Select an option

  • Save mluxe/7dec97dfbedbeb33cc5a3cb752a83ba0 to your computer and use it in GitHub Desktop.

Select an option

Save mluxe/7dec97dfbedbeb33cc5a3cb752a83ba0 to your computer and use it in GitHub Desktop.
React scroll to load pagination
import React from "react";
import './product-list.scss'
export class ProductCard extends React.Component {
constructor(props){
super(props);
this.state = {};
}
render(){
const {product} = this.props;
return <div className="product-card">
<div>{product.description}</div>
</div>;
}
}
import React from "react";
import debounce from 'lodash.debounce';
import './product-list.scss'
import { ProductService } from "../../shared/services/product.service";
import { formatNumber } from "../../shared/func/utils-func";
export class ProductItem extends React.Component {
product_service;
constructor(props){
super(props);
this.state = {
category_id: props.product.category_id || 0,
notes: props.product.notes || '',
saving: false
};
this.product_service = new ProductService();
this.handleCategoryChange = this.handleCategoryChange.bind(this);
this.handleNoteChange = this.handleNoteChange.bind(this);
this.handleExcludeProduct = this.handleExcludeProduct.bind(this);
this.handleAutoApplyMerchant = this.handleAutoApplyMerchant.bind(this);
this.onNoteChangeDebounced = debounce(this.onNoteChangeDebounced, 1000);
}
handleCategoryChange(event){
const new_category_id = parseInt(event.target.value);
this.setState({ category_id: new_category_id });
this.setState({saving: true});
this.product_service.setCategory(this.props.product.id, new_category_id).then((data) => {
if(data.success){
const category = this.props.categories.find(i => i.id === new_category_id);
this.props.product.category_id = category.id;
this.props.product.category = category;
this.props.product.is_excluded = category.auto_exclude;
}
this.setState({saving: false});
});
}
handleExcludeProduct(event){
const id = event.target.getAttribute('data-id');
const excluded = event.target.checked;
this.props.product.is_excluded = excluded;
this.setState({saving: true});
this.product_service.exclude(id, excluded).then((success) => {
this.setState({saving: false});
});
}
handleNoteChange(event){
this.setState({ notes: event.target.value });
this.onNoteChangeDebounced(event.target.value)
}
onNoteChangeDebounced = (notes) => {
this.setState({saving: true});
this.product_service.set(this.props.product.id, 'notes', notes)
.then((success) => {
if(success){
this.props.product.notes = notes;
}
this.setState({saving: false});
});
}
renderCategoryGroupBadge(){
const { categories } = this.props;
const category = categories.find(i => i.id === this.state.category_id);
if(category === undefined){
return;
}
return <span className="badge border border-secondary text-secondary">
{category.category_group}
</span>
}
handleAutoApplyMerchant(){
this.setState({saving: true});
this.product_service.autoApplyMerchant(this.props.product.id)
.then((data)=>{
if(data.merchant != null){
const trans = this.props.product;
trans.category_id = data.merchant.category_id;
trans.merchant_id = data.merchant.id;
trans.category = data.merchant.category;
trans.merchant = data.merchant;
trans.merchant_name = data.merchant.name;
trans.is_excluded = data.merchant.category.auto_exclude;
this.setState({saving: false, category_id: trans.category_id});
}else{
this.setState({saving: false});
}
});
}
renderCategoryBadge(){
const { categories, product } = this.props;
const category_type = (product.credit > 0? "income": "expense")
const category_options = categories.filter(i => i.type === category_type);
let category_group;
/*
let groups = [...new Set(category_options.map(item => item.category_group))];
const selection = <select name="category" className="hidden-selection" value={this.state.category_id} onChange={this.handleCategoryChange}>
<option value="">No Category</option>
{groups.map((group, i) => {
const options = category_options.filter(item => item.category_group === group);
return <optgroup label={group} key={`opt-group-${group}`}>
{options.map((category, i) => (
<option value={category.id} key={i}>{category.name}</option>
))}
</optgroup>
})}
</select>
*/
const selection = <select name="category" className="hidden-selection" value={this.state.category_id || ''} onChange={this.handleCategoryChange}>
<option value="">No Category</option>
{category_options.map((category, i) => {
const results = [];
if(category_group !== category.category_group){
results.push(<optgroup label={category.category_group} key={`opt-group-${category.category_group}`}>{category.category_group}</optgroup>);
category_group = category.category_group;
}
results.push(<option value={category.id} key={i}>{category.name}</option>);
return results;
})}
</select>
let category_name = "No Category";
const category = categories.find(i => i.id === this.state.category_id);
if(category !== undefined){
category_name = `${category.name}`;
}
return <span className="badge border border-secondary text-secondary">
{category_name}
{selection}
</span>
}
render(){
const { product, providers } = this.props;
const provider = providers.find(i => i.id === product.provider_id);
const amount = product.product_amount;
let notes;
notes = <span className="product-notes">
<input type="text" className="notes-input hover-visible" value={this.state.notes || ''} placeholder="Enter notes" onChange={this.handleNoteChange}/>
<span className="notes-text hover-hidden">{this.state.notes}</span>
</span>
return <tr className="product">
<td className="text-vertical">{product.product_date.substr(0, 10)}</td>
<td className="">
<div className="">
<div className="product-details">
<div className="product-description">
<div style={{'display': 'inline-block', 'paddingRight': '.5rem'}}>
{product.description}
{product.local_currency !== product.product_currency? <span className="local_amount">{product.local_currency}: {product.local_amount}, rate: {product.exchange_rate}</span>:''}
</div>
{notes}
</div>
{/* <div className="product-notes">
<input type="text" className="form-control" value={this.state.notes || ''} placeholder="Notes" onChange={this.handleNoteChange}/>
</div> */}
</div>
<div className="product-tags">
<span className="badge border border-secondary text-secondary">{product.product_currency } {product.product_currency !== product.local_currency? `/ ${product.local_currency}`:''}</span>
<span className="badge border border-secondary text-secondary">{provider?.catalog || '000000' }</span>
{/* <span className="badge border border-secondary text-secondary">{provider?.catalog || ''}</span> */}
<span className="badge border border-secondary text-secondary">{provider?.account_number}</span>
<span className="badge border border-secondary text-secondary">{provider?.name}</span>
<span className="badge border border-secondary text-secondary">{product.product_code}</span>
{this.renderCategoryGroupBadge()}
{this.renderCategoryBadge()}
<span className="badge border border-success text-success">{product.merchant_name}</span>
{/* <span className="badge border border-info text-info">
<input type="text" className="notes-control text-info" value={this.state.notes || ''} placeholder="Enter notes" onChange={this.handleNoteChange}/>
</span> */}
{this.state.saving? <span className="badge"><i className="indicator fa fa-spin fa-spinner"></i></span>:''}
<div className="pull-right">
<span className="badge text-info hover-visible">
<div className="custom-control custom-switch custom-control-sm">
<input type="checkbox" id={`tran-exclude-${product.id}`} className="custom-control-input" checked={product.is_excluded || false} data-id={product.id} onChange={this.handleExcludeProduct} />
<label className="custom-control-label" htmlFor={`tran-exclude-${product.id}`}>Exclude</label>
</div>
</span>
</div>
</div>
</div>
</td>
<td className="text-right text-vertical">
<div className={`amount ${amount < 0? 'text-danger' : 'text-success'} ${product.is_excluded? 'exclude-amount':''}`}>
{ formatNumber(amount) }
</div>
<div className="balance">
{product.balance? formatNumber(product.balance) : '-' }
</div>
</td>
<td className="text-center text-vertical">
{ this.state.saving && <i className="fa fa-sync fa-spin"></i>}
{ !this.state.saving && <button className="btn btn-link fal fa-sync btn-sync-merchant" onClick={this.handleAutoApplyMerchant}></button> }
</td>
</tr>;
}
}
// export interface ProductListProps{
// products: Product[];
// categories: Category[];
// providers: BankAccount[];
// }
export class ProductList extends React.Component{
constructor(props){
super(props);
this.state = {
sort: 'product_date',
dir: 'desc'
};
}
sort(column){
if(this.props.onSort){
let dir = this.state.dir;
dir= (dir === 'desc'? 'asc': 'desc');
this.props.onSort(column, dir);
this.setState({ sort: column, dir: dir });
}
}
render(){
const products = this.props.products;
let empty_products;
if(products.length === 0){
empty_products = <tr><td colSpan={4}>No products</td></tr>;
}
let current_month = '';//products[0].product_date?.substr(0, 7);
return <table className="table table-bordered table-sm table-hover-border product-list">
<thead>
<tr>
<th style={{'width': '6rem'}} className={`sortable ${this.state.sort==='product_date'? this.state.dir:''}`} onClick={() => this.sort('product_date')}>Date</th>
<th className={`sortable ${this.state.sort==='description'? this.state.dir:''}`} onClick={() => this.sort('description')}>Details</th>
<th style={{'width': '5rem'}} className={`text-right sortable ${this.state.sort==='product_amount'? this.state.dir:''}`} onClick={() => this.sort('product_amount')}>Amount</th>
<th className="text-right" style={{'width': '2rem'}} ></th>
</tr>
</thead>
<tbody>
{ products.map((trans, i) => {
let results = [];
const product = (<ProductItem product={trans} categories={this.props.categories} providers={this.props.providers} key={trans.id}></ProductItem>)
if(current_month !== trans.product_date.substr(0, 7)){
current_month = trans.product_date.substr(0, 7);
results.push(
<tr className="section-header" key={`month-category-${current_month}`}>
<td colSpan={6}>{current_month}</td>
</tr>
);
}
results.push(product);
return results;
})}
{empty_products}
</tbody>
</table>;
}
}
.product-list-no-cross-line{
.product{
.text-success{
&.exclude-amount {
text-decoration: none !important;
color: #62c462 !important;
}
}
.text-danger{
&.exclude-amount {
text-decoration: none !important;
color: #ee5f5b !important;
}
}
}
}
table.product-list{
> thead {
> tr > th.sortable{
cursor: pointer;
&.asc:after{
font-family: "Font Awesome 5 Pro";
content: "\f161";
padding-left: 2px;
}
&.desc:after{
font-family: "Font Awesome 5 Pro";
content: "\f160";
padding-left: 2px;
}
}
}
}
.product {
.product-details{
.product-description{
padding: 0 .5rem .3rem .5rem;
.product-notes{
color: #999;
// &:before{
// content: "( ";
// }
// &:after{
// content: " )";
// }
}
.local_amount{
font-weight: bold;
padding: 0 .5rem;
&:before{
content: "[ ";
}
&:after{
content: " ]";
}
}
}
.product-notes{
margin-bottom: .3rem;
position: relative;
> input{
border: none;
padding: .2rem .5rem;
line-height: 1rem;
height: calc(1.1em + 4px);
border-radius: 0;
color: #fff;
font-size: .8rem;
transition: background-color 20.5 ease-out;
min-width: 15rem;
&::placeholder{
color: #fff;
}
// &:hover {
// background-color: #4b7ccd;
// color: #fff;
// border: solid 1px darken(#4b7ccd, 15%);
// }
}
.notes-input{
display: none;
}
.notes-text{
display: inline;
padding: .2rem .5rem;
}
}
}
&:hover{
.notes-input{
display: inline !important;
background-color: #848b90;
color: #7a8288;
border: solid 1px darken(#4b7ccd, 15%);
}
.notes-text{
display: none;
}
}
.product-tags{
margin-bottom: .5rem;
.badge{
&:first{
margin-left: 0;
}
position: relative;
margin: 0 5px;
}
input.notes-control{
border: none;
font-size: .7rem;
padding: 0;
}
}
.exclude-amount{
text-decoration: line-through;
color: #999 !important;
}
.text-vertical{
vertical-align: middle;
}
.amount{
font-size: 1.5rem;
}
.balance{
color: #666;
font-size: 0.7rem;
}
.hover-visible{
display: none;
}
.hover-hidden{
display: block;
}
&:hover{
.hover-visible{
display: block;
}
.hover-hidden{
display: none !important;
}
}
.btn-sync-merchant{
&:hover {
text-decoration: none;
}
}
}
select.hidden-selection{
position: absolute;
top: 0px;
left: 0px;
cursor: pointer;
opacity: 0;
}
.custom-control-sm{
min-height: 0.8rem;
font-size: 0.8rem;
.custom-control-input{
height: 0.8rem;
}
.custom-control-label{
padding-top: 2px;
cursor: pointer;
&:before{
left: -2.16875rem;
top: 0rem;
height: 0.9rem;
}
&:after{
left:calc(-2.16875rem + 4px);
height: calc(1rem - 8px);
top: 3px;
}
}
}
import React from "react";
import debounce from 'lodash.debounce';
import './product-search-bar.scss';
import Moment from 'moment';
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
export class ProductSearchBar extends React.Component {
constructor(props){
super(props);
this.state = {
// filter: props.filter
keyword: (props.filter.keyword || ''),
amount: (props.filter.amount || '')
};
this.handleFilterChange = this.handleFilterChange.bind(this);
this.handleTextInputChange = this.handleTextInputChange.bind(this);
this.handleIsCategorisedChange = this.handleIsCategorisedChange.bind(this);
this.handleDateChange = this.handleDateChange.bind(this);
this.onTextInputChangeDebounce = debounce(this.onTextInputChangeDebounce, 700);
}
componentDidMount(){
}
static getDerivedStateFromProps(props, state){
state.keyword = props.filter.keyword;
state.amount = props.filter.amount;
return state;
}
handleFilterChange(e){
const target = e.target;
const name = target.name;
// @ts-ignore
this.props.filter[name] = target.value;
this.props.onFilterChange();
}
handleDateChange = (date) => {
this.props.filter.month = (date == null? '':Moment(date).format("yyyy-MM-DD"));
this.props.onFilterChange();
}
handleTextInputChange(e){
// this.setState({})
const state = this.state;
const name = e.target.name;
const val = e.target.value;
state[name] = val;
this.props.filter[name] = val;
this.setState(state);
this.onTextInputChangeDebounce(val);
}
handleIsCategorisedChange(e){
const val = e.target.checked;
this.props.filter.uncategorised_only = val?1:0;
this.props.onFilterChange();
}
onTextInputChangeDebounce(val){
this.props.onFilterChange();//this.state.filter);
}
renderInput( props, openCalendar, closeCalendar ){
function clear(){
props.onChange({target: {value: ''}});
}
return (
<div>
<input {...props} />
<button onClick={openCalendar}>open calendar</button>
<button onClick={closeCalendar}>close calendar</button>
<button onClick={clear}>clear</button>
</div>
);
}
render(){
const { providers, categories } = this.props;
let date, category_group;
if(this.props.filter.month){
date = Moment(this.props.filter.month, "yyyy-MM-dd").toDate();
}
return <div className="product-filter">
<div className="form-inline">
<div className="input-group mb-2 mr-sm-2">
<DatePicker className="form-control" showMonthYearPicker dateFormat="MMM yyyy" placeholderText="All" name="product_date" selected={date} onChange={this.handleDateChange}></DatePicker>
<div className="input-group-append">
<span className="input-group-text fs-xl">
<i className="fa fa-calendar"></i>
</span>
</div>
</div>
<div className="form-group mb-2 mr-sm-2">
<select className="form-control" name="provider_id" value={this.props.filter.provider_id} onChange={this.handleFilterChange}>
<option value="">All Bank providers</option>
{ providers.map((provider, i) => (
<option value={provider.id} key={provider.id}>{provider.provider_number?.substr(-4)} - {provider.provider_type} - {provider.card_holder_name}</option>
))}
</select>
</div>
<div className="form-group mb-2 mr-sm-2">
<select className="form-control" name="category_id" value={this.props.filter.category_id} onChange={this.handleFilterChange}>
<option value="">All Categories</option>
{categories.map((category, i) => {
const results = [];
if(category_group !== category.category_group){
results.push(<optgroup label={category.category_group} key={`opt-group-${category.category_group}`}>{category.category_group}</optgroup>);
category_group = category.category_group;
}
results.push(<option value={category.id} key={i}>{category.name}</option>);
return results;
})}
</select>
</div>
<div className="input-group mb-2 mr-sm-2">
<input type="text" className="form-control" placeholder="Keyword" name="keyword" style={{width: "25rem"}} value={this.state.keyword} onChange={this.handleTextInputChange}/>
<div className="input-group-append">
<button className="btn btn-primary fa fa-search"></button>
</div>
</div>
<div className="input-group mb-2 mr-sm-2">
<div className="input-group-prepend">
<button className="btn btn-primary fa fa-pound-sign"></button>
</div>
<input type="text" className="form-control" placeholder="Amount" name="amount" value={this.state.amount} onChange={this.handleTextInputChange}/>
</div>
<div className="form-group mb-2 mr-sm-2">
<div className="custom-control custom-switch">
<input type="checkbox" id="uncategorised_only" className="custom-control-input" name="uncategorised_only" checked={this.props.filter.uncategorised_only || false} onChange={this.handleIsCategorisedChange} />
<label className="custom-control-label" htmlFor="uncategorised_only">
Uncategoried Only
</label>
</div>
</div>
</div>
</div>;
}
}
.product-filter{
margin-bottom: 1rem;
}
import React from "react";
import { ProductSearchBar } from "./product-search-bar";
import { ProductList } from "./product-list";
import { ProductService } from "../../shared/services/product.service";
import { CategoryService } from "../../shared/services/category.service";
import { ProviderService } from "../../shared/services/bank-account.service";
import InfiniteScroll from 'react-infinite-scroller';
import { objectToQueryParams } from "../../shared/func/utils-func";
import { parse } from 'query-string';
export class ProductsModule extends React.Component{
product_service;
category_service;
provider_service;
constructor(props){
super(props);
this.state = {
products: [],
categories: [],
providers: [],
has_more: false,
page: 1,
page_size: 20,
loading: false,
sort: 'product_date',
dir: 'desc',
filter: {
uncategorised_only: false
}
};
this.product_service = new ProductService();
this.category_service = new CategoryService();
this.provider_service = new ProviderService();
this.handleFilterChange = this.handleFilterChange.bind(this);
}
componentDidMount(){
// this.refreshProducts();
let params = parse(this.props.location.search)
this.setState({ filter: params, has_more: true });
this.category_service.getAll()
.then((data) => this.setState({ categories: data }));
this.provider_service.getAll()
.then((data2) => this.setState({ providers: data2 }));
}
handleLoadProduct(){
if(this.state.loading){
return;
}
this.setState({loading: true});
const page = this.state.page;
const page_size = this.state.page_size;
const filter = this.state.filter;
filter.sort = this.state.sort;
filter.dir = this.state.dir;
this.product_service.search(filter, page, page_size)
.then((data) => {
const trans = this.state.products;
trans.push(...data);
this.setState({ products: trans, has_more: data.length >= page_size, page: page + 1, loading: false });
});
}
handleFilterChange(){// filter: ProductFilter){
this.props.history.push({ pathname: this.props.location.pathname, search: '?'+objectToQueryParams(this.state.filter) });
this.setState({ products: [], page: 1, has_more: true });
}
handleSort(column, dir){
this.setState({ products: [], page: 1, has_more: true, dir: dir, sort: column });
}
render(){
return (
<div>
<h2>Products</h2>
<div>
<ProductSearchBar providers={this.state.providers} categories={this.state.categories} filter={this.state.filter} onFilterChange={this.handleFilterChange}></ProductSearchBar>
</div>
<div>
<InfiniteScroll
pageStart={0}
loadMore={(page) => this.handleLoadProduct()}
hasMore={this.state.has_more}
loader={<div className="load-indicator text-center" key={'load-indicator'}><i className="fa fa-spin fa-spinner"></i> Loading ...</div>}>
<ProductList products={this.state.products} categories={this.state.categories} providers={this.state.providers} onSort={this.handleSort.bind(this)}></ProductList>
</InfiniteScroll>
</div>
</div>
)
}
}
import { productsModule } from "../products.module";
const base_url = '/products';
const routes = [
{
path: `${base_url}`, exact: true, component: ProductsModule,
// routes: [
// { path: `${base_url}/bank-accounts/add`, exact: true, component: null },
// { path: `${base_url}/bank-accounts/:id/edit`, exact: true, component: null },
// ]
},
];
export default routes;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment