feat: Items data table.

This commit is contained in:
Ahmed Bouhuolia
2020-03-25 18:05:15 +02:00
parent 1894ad3b18
commit edd8693450
22 changed files with 567 additions and 20 deletions

View File

@@ -4,6 +4,13 @@ export default [
{
divider: true,
},
{
icon: 'homepage',
iconSize: 20,
text: 'Items List',
disabled: false,
href: '/dashboard/items/list',
},
{
icon: 'homepage',
iconSize: 20,

View File

@@ -0,0 +1,41 @@
import {connect} from 'react-redux';
import {
fetchItems,
fetchItem,
deleteItem,
} from 'store/items/items.actions';
import {
getResourceViews,
getViewPages,
} from 'store/customViews/customViews.selectors'
import {
getCurrentPageResults
} from 'store/selectors';
import t from 'store/types';
export const mapStateToProps = (state, props) => {
const viewPages = getViewPages(state.items.views, state.items.currentViewId);
return {
views: getResourceViews(state, 'items'),
currentPageItems: getCurrentPageResults(
state.items.items, viewPages, state.items.currentPage),
bulkSelected: state.items.bulkActions,
};
};
export const mapDispatchToProps = (dispatch) => ({
fetchItems: (query) => dispatch(fetchItems({ query })),
requestDeleteItem: (id) => dispatch(deleteItem({ id })),
addBulkActionItem: (id) => dispatch({
type: t.ITEM_BULK_ACTION_ADD, itemId: id
}),
removeBulkActionItem: (id) => dispatch({
type: t.ITEM_BULK_ACTION_REMOVE, itemId: id,
}),
});
export default connect(mapStateToProps, mapDispatchToProps);

View File

@@ -116,7 +116,8 @@ function AccountsChart({
return (
<DashboardInsider loading={fetchHook.pending} name={'accounts-chart'}>
<DashboardActionsBar onFilterChange={handleFilterChange} />
<DashboardActionsBar
onFilterChange={handleFilterChange} />
<DashboardPageContent>
<Switch>
<Route

View File

@@ -0,0 +1,93 @@
import React, {useMemo} from 'react';
import {useRouteMatch} from 'react-router-dom'
import DashboardActionsBar from 'components/Dashboard/DashboardActionsBar';
import { compose } from 'utils';
import {
MenuItem,
Popover,
Menu,
PopoverInteractionKind,
Position,
Button,
Classes,
} from '@blueprintjs/core';
import classNames from 'classnames';
import Icon from 'components/Icon';
import DashboardConnect from 'connectors/Dashboard.connector';
import ResourceConnect from 'connectors/Resource.connector'
import FilterDropdown from 'components/FilterDropdown';
import ItemsConnect from 'connectors/Items.connect';
const ItemsActionsBar = ({
getResourceFields,
getResourceViews,
views,
onFilterChange,
bulkSelected,
}) => {
const {path} = useRouteMatch();
const viewsMenuItems = views.map((view) => {
return (<MenuItem href={`${path}/${view.id}/custom_view`} text={view.name} />);
});
const onClickNewItem = () => {
};
const itemsFields = getResourceFields('items');
const filterDropdown = FilterDropdown({
fields: itemsFields,
onFilterChange,
});
const hasBulkActionsSelected = useMemo(() =>
!!Object.keys(bulkSelected).length, [bulkSelected]);
return (
<DashboardActionsBar>
<Popover
content={<Menu>{viewsMenuItems}</Menu>}
minimal={true}
interactionKind={PopoverInteractionKind.HOVER}
position={Position.BOTTOM_LEFT}>
<Button
className={classNames(Classes.MINIMAL, 'button--table-views')}
icon={ <Icon icon="table" /> }
text="Table Views"
rightIcon={'caret-down'} />
</Popover>
<Popover
content={filterDropdown}
interactionKind={PopoverInteractionKind.CLICK}
position={Position.BOTTOM_LEFT}>
<Button
className={classNames(Classes.MINIMAL, 'button--filter')}
text="Filter"
icon={ <Icon icon="filter" /> } />
</Popover>
<Button
className={Classes.MINIMAL}
icon={ <Icon icon="plus" /> }
text="New Item"
onClick={onClickNewItem} />
{hasBulkActionsSelected && (
<Button
className={Classes.MINIMAL}
icon={ <Icon icon="trash" />}
text="Delete" />)}
</DashboardActionsBar>
);
};
export default compose(
DashboardConnect,
ResourceConnect,
ItemsConnect,
)(ItemsActionsBar);

View File

@@ -0,0 +1,114 @@
import React, {useEffect, useMemo} from 'react';
import {
Button,
Popover,
Menu,
MenuItem,
MenuDivider,
Position,
Checkbox,
} from '@blueprintjs/core'
import LoadingIndicator from 'components/LoadingIndicator';
import CustomViewConnect from 'connectors/View.connector';
import ItemsConnect from 'connectors/Items.connect';
import {useParams} from 'react-router-dom'
import {compose} from 'utils';
import useAsync from 'hooks/async';
import DataTable from 'components/DataTable';
import Icon from 'components/Icon';
import {handleBooleanChange} from 'utils';
const ItemsDataTable = ({
fetchItems,
filterConditions,
currentPageItems,
onEditItem,
onDeleteItem,
addBulkActionItem,
removeBulkActionItem,
}) => {
const { custom_view_id: customViewId } = useParams();
const fetchHook = useAsync(async () => {
await Promise.all([
fetchItems({
custom_view_id: customViewId,
stringified_filter_roles: JSON.stringify(filterConditions),
}),
]);
});
const handleEditItem = (item) => () => { onEditItem(item); };
const handleDeleteItem = (item) => () => { onDeleteItem(item); };
const handleClickCheckboxBulk = (item) => handleBooleanChange((value) => {
if (value) {
addBulkActionItem(item.id);
} else {
removeBulkActionItem(item.id);
}
});
const actionMenuList = (item) =>
(<Menu>
<MenuItem text="View Details" />
<MenuDivider />
<MenuItem text="Edit Item" onClick={handleEditItem(item)} />
<MenuItem text="Delete Item" onClick={handleDeleteItem(item)} />
</Menu>);
const columns = useMemo(() => [
{
id: 'bulk_select',
Cell: ({ cell }) =>
(<Checkbox onChange={handleClickCheckboxBulk(cell.row.original)} />),
},
{
Header: 'Item Name',
accessor: 'name',
className: "actions",
},
{
Header: 'Cost Account',
accessor: 'cost_account.name',
className: "cost-account",
},
{
Header: 'Sell Account',
accessor: 'sell_account.name',
className: "sell-account",
},
{
Header: 'Inventory Account',
accessor: 'inventory_account.name',
className: "inventory-account",
},
{
Header: 'Category',
accessor: 'category.name',
className: 'category',
},
{
id: 'actions',
Cell: ({ cell }) => (
<Popover
content={actionMenuList(cell.row.original)}
position={Position.RIGHT_BOTTOM}>
<Button icon={<Icon icon="ellipsis-h" />} />
</Popover>
),
},
]);
return (
<LoadingIndicator loading={fetchHook.pending} spinnerSize={30}>
<DataTable
columns={columns}
data={currentPageItems} />
</LoadingIndicator>
);
};
export default compose(
ItemsConnect,
CustomViewConnect,
)(ItemsDataTable);

View File

@@ -0,0 +1,94 @@
import React, { useEffect, useState } from 'react';
import {
Route,
Switch,
} from 'react-router-dom';
import {
Intent,
Alert,
} from '@blueprintjs/core';
import DashboardInsider from 'components/Dashboard/DashboardInsider';
import useAsync from 'hooks/async';
import ItemsActionsBar from 'containers/Dashboard/Items/ItemsActionsBar';
import { compose } from 'utils';
import ItemsDataTable from './ItemsDataTable';
import DashboardPageContent from 'components/Dashboard/DashboardPageContent';
import ResourceConnect from 'connectors/Resource.connector';
import DashboardConnect from 'connectors/Dashboard.connector';
import ItemsConnect from 'connectors/Items.connect';
import CustomViewsConnect from 'connectors/CustomView.connector'
import DashboardViewsTabs from 'components/Accounts/AccountsViewsTabs';
import AppToaster from 'components/AppToaster';
function ItemsList({
changePageTitle,
fetchResourceViews,
fetchResourceFields,
views,
requestDeleteItem,
}) {
const [filterConditions, setFilterConditions] = useState([]);
const [deleteItem, setDeleteItem] = useState(false);
useEffect(() => {
changePageTitle('Items List');
}, []);
const fetchHook = useAsync(async () => {
await Promise.all([
fetchResourceViews('items'),
fetchResourceFields('items'),
])
});
const handleDeleteItem = (item) => { setDeleteItem(item); };
const handleEditItem = () => {};
const handleCancelDeleteItem = () => { setDeleteItem(false) };
const handleConfirmDeleteItem = () => {
requestDeleteItem(deleteItem.id).then(() => {
AppToaster.show({ message: 'the_item_has_been_deleted' });
setDeleteItem(false);
});
};
const handleFilterChange = (filter) => { setFilterConditions(filter); };
return (
<DashboardInsider isLoading={fetchHook.pending} name={'items-list'}>
<ItemsActionsBar views={views} onFilterChange={handleFilterChange} />
<DashboardPageContent>
<Switch>
<Route>
<DashboardViewsTabs resourceName={'items'} />
<ItemsDataTable
filterConditions={filterConditions}
onDeleteItem={handleDeleteItem}
onEditItem={handleEditItem} />
<Alert
cancelButtonText="Cancel"
confirmButtonText="Move to Trash"
icon="trash"
intent={Intent.DANGER}
isOpen={deleteItem}
onCancel={handleCancelDeleteItem}
onConfirm={handleConfirmDeleteItem}>
<p>
Are you sure you want to move <b>filename</b> to Trash? You will be able to restore it later,
but it will become private to you.
</p>
</Alert>
</Route>
</Switch>
</DashboardPageContent>
</DashboardInsider>
)
}
export default compose(
DashboardConnect,
ResourceConnect,
ItemsConnect,
CustomViewsConnect,
)(ItemsList);

View File

@@ -66,6 +66,13 @@ export default [
text: 'Make Journal Entry',
},
// Items
{
path: `${BASE_URL}/items/list`,
component: LazyLoader({
loader: () => import('containers/Dashboard/Items/ItemsList')
}),
},
// Financial Reports.
{

View File

@@ -12,4 +12,9 @@ export const getViewMeta = (state, viewId) => {
export const getViewItem = (state, viewId) => {
return state.views.views[viewId] || {};
};
export const getViewPages = (resourceViews, viewId) => {
return (typeof resourceViews[viewId] === 'undefined') ?
{} : resourceViews[viewId].pages;
};

View File

@@ -11,10 +11,16 @@ export const editItem = ({ id, form }) => {
export const fetchItems = ({ query }) => {
return (dispatch) => new Promise((resolve, reject) => {
ApiService.get(`items`, query).then(response => {
ApiService.get(`items`).then(response => {
dispatch({
type: t.ITEMS_LIST_SET,
items: response.data.items,
type: t.ITEMS_SET,
items: response.data.items.results,
});
dispatch({
type: t.ITEMS_PAGE_SET,
items: response.data.items.results,
customViewId: response.data.customViewId,
paginationMeta: response.data.items.pagination,
});
resolve(response);
}).catch(error => { reject(error); });

View File

@@ -1,6 +1,92 @@
import t from 'store/types';
import { createReducer } from '@reduxjs/toolkit';
import {
getItemsViewPages,
} from 'store/items/items.selectors';
const initialState = {
list: [],
itemById: {},
items: {},
views: {},
itemsRelation: {},
currentPage: 1,
currentViewId: -1,
bulkActions: {},
};
export default createReducer(initialState, {
[t.ITEMS_SET]: (state, action) => {
const _items = {};
action.items.forEach(item => {
_items[item.id] = item;
});
state.items = {
...state.items,
..._items,
};
},
[t.ITEMS_PAGE_SET]: (state, action) => {
const { items, customViewId, paginationMeta } = action;
const viewId = customViewId || -1;
const view = state.views[viewId] || {};
const viewPages = getItemsViewPages(state.views, viewId);
items.forEach((item) => {
const stateItem = state.items[item.id];
const itemRelation = state.itemsRelation[stateItem.id];
if (typeof itemRelation === 'undefined') {
state.itemsRelation[item.id] = [];
}
const filteredRelation = state.itemsRelation[item.id]
.filter((relation) => (
relation.viewId === viewId &&
relation.pageNumber === paginationMeta.page
));
filteredRelation.push({
viewId,
pageNumber: paginationMeta.page,
});
state.itemsRelation[item.id] = filteredRelation;
});
state.views[viewId] = {
...view,
pages: {
...viewPages,
[paginationMeta.page]: {
ids: items.map(i => i.id),
meta: paginationMeta,
},
},
};
},
[t.ITEM_BULK_ACTION_ADD]: (state, action) => {
state.bulkActions[action.itemId] = true;
},
[t.ITEM_BULK_ACTION_REMOVE]: (state, action) => {
delete state.bulkActions[action.itemId];
},
[t.ITEM_DELETE]: (state, action) => {
const { itemId } = action;
if (state.items[itemId]) {
const item = state.items[itemId];
const itemPageNumber = item._page_number;
delete state.items[itemId];
}
},
});
export const getItemById = (state, id) => {
return state.items.items[id];
};

View File

@@ -0,0 +1,7 @@
export const getItemsViewPages = (itemsViews, viewId) => {
return itemsViews[viewId] ?
itemsViews[viewId].pages : {};
};

View File

@@ -0,0 +1,9 @@
export default {
ITEMS_SET: 'ITEMS_SET',
ITEMS_PAGE_SET: 'ITEMS_PAGE_SET',
ITEM_DELETE: 'ITEM_DELETE',
ITEM_BULK_ACTION_ADD: 'ITEM_BULK_ACTION_ADD',
ITEM_BULK_ACTION_REMOVE: 'ITEM_BULK_ACTION_REMOVE',
}

View File

@@ -0,0 +1,27 @@
const pages = (pages, action) => {
const { type, items, meta } = action;
switch(type) {
case REQUEST_PAGE:
return {
...pages,
[meta.currentPage]: {
...pages[meta.currentPage],
ids: [],
fetching: true,
},
};
case RECEIVE_PAGE:
return {
...pages,
[meta.currentPage]: {
...pages[meta.currentPage],
ids: items.map(i => i.id),
fetching: false,
},
};
}
};

View File

@@ -5,6 +5,7 @@ import dashboard from './dashboard/dashboard.reducer';
import users from './users/users.reducer';
import accounts from './accounts/accounts.reducer';
import fields from './customFields/customFields.reducer';
import items from './items/items.reducer';
import views from './customViews/customViews.reducer';
import expenses from './expenses/expenses.reducer';
import currencies from './currencies/currencies.reducer';
@@ -22,4 +23,5 @@ export default combineReducers({
currencies,
resources,
financialStatements,
items,
});

View File

@@ -4,9 +4,10 @@ export const pickItemsFromIds = (items, ids) => {
return Object.values(pick(items, ids));
}
export const getCurrentPageResults = (items, page, name) => {
const currentPage = page.pages[page.currentPages[name]]
return typeof currentPage == 'undefined' ? [] : Object.values(pick(items || [], currentPage.ids))
export const getCurrentPageResults = (items, pages, pageNumber) => {
const currentPage = pages[pageNumber]
return typeof currentPage == 'undefined' ?
[] : Object.values(pick(items || [], currentPage.ids));
}
export const getCurrentTotalResultsCount = (pagination, name) => {

View File

@@ -693,14 +693,25 @@ label{
.bigcapital-datatable{
padding: 1rem;
table {
text-align: left;
border-spacing: 0;
border: 1px solid black;
width: 100%;
thead{
th{
height: 48px;
padding: 0.5rem 1.5rem;
background: #F8FAFA;
font-size: 14px;
color: #666;
font-weight: 500;
border-bottom: 1px solid rgb(224, 224, 224);
}
}
tr {
:last-child {
td {
@@ -713,8 +724,6 @@ label{
td {
margin: 0;
padding: 0.5rem;
border-bottom: 1px solid black;
border-right: 1px solid black;
:last-child {
border-right: 0;

View File

@@ -74,6 +74,24 @@ exports.seed = (knex) => {
predefined: 1,
columnable: true,
},
// Items
{
id: 10,
resource_id: 2,
label_name: 'Name',
data_type: 'textbox',
predefined: 1,
columnable: true,
},
{
id: 11,
resource_id: 2,
label_name: 'Type',
data_type: 'textbox',
predefined: 1,
columnable: true,
},
]);
});
};

View File

@@ -336,7 +336,7 @@ export default {
if (filter.filter_roles.length > 0) {
filterConditions.buildQuery()(builder);
}
}).page(filter.page - 1, filter.page_size);
}).pagination(filter.page - 1, filter.page_size);
return res.status(200).send({
items,

View File

@@ -1,6 +1,7 @@
import { Model } from 'objection';
import {transform, snakeCase} from 'lodash';
import {mapKeysDeep} from '@/utils';
import PaginationQueryBuilder from '@/models/Pagination';
export default class ModelBase extends Model {
static get collection() {
@@ -24,4 +25,8 @@ export default class ModelBase extends Model {
return parsedJson;
}
static get QueryBuilder() {
return PaginationQueryBuilder;
}
}

View File

@@ -0,0 +1,17 @@
import { Model } from 'objection';
export default class PaginationQueryBuilder extends Model.QueryBuilder {
pagination(page, pageSize) {
return super.page(page, pageSize).runAfter(
({ results, total }) => {
return {
results,
pagination: {
total,
page: page + 1,
pageSize,
},
};
})
}
}

View File

@@ -273,7 +273,7 @@ describe('routes: /accounts/', () => {
expect(res.body.accounts[1].account_type_id).equals(2);
});
it.only('Should retrieve accounts based on view roles conditionals with relation join column.', async () => {
it('Should retrieve accounts based on view roles conditionals with relation join column.', async () => {
const resource = await create('resource', { name: 'accounts' });
const accountTypeField = await create('resource_field', {

View File

@@ -575,15 +575,13 @@ describe('routes: `/items`', () => {
const res = await request()
.get('/api/items')
.set('x-access-token', loginRes.body.token)
.query({
page: 2,
})
.send();
expect(res.body.items.results).to.be.a('array');
expect(res.body.items.results.length).equals(0);
expect(res.body.items.total).to.be.a('number');
expect(res.body.items.total).equals(0)
expect(res.body.items.pagination).to.be.a('object');
expect(res.body.items.pagination.total).to.be.a('number');
expect(res.body.items.pagination.total).equals(0)
});
it('Should retrieve filtered items based on custom view conditions.', async () => {