From 090c744f57ae30e7767fe0ec8b5279fbeb733aa9 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 19 Apr 2020 17:00:28 +0200 Subject: [PATCH] feat: Sorting manual journals and items. --- server/src/database/seeds/seed_resources.js | 1 + .../database/seeds/seed_resources_fields.js | 2 + server/src/http/controllers/Accounting.js | 58 +++++-- server/src/http/controllers/ItemCategories.js | 161 ++++++++++++------ server/src/http/controllers/Items.js | 77 ++++++--- .../DynamicFilter/DynamicFilterFilterRoles.js | 2 +- server/src/lib/ViewRolesBuilder/index.js | 2 +- server/src/models/ItemCategory.js | 4 +- server/tests/routes/accounting.test.js | 16 +- server/tests/routes/items.test.js | 28 +-- server/tests/routes/itemsCategories.test.js | 18 +- 11 files changed, 237 insertions(+), 132 deletions(-) diff --git a/server/src/database/seeds/seed_resources.js b/server/src/database/seeds/seed_resources.js index 0c99696be..710ffe686 100644 --- a/server/src/database/seeds/seed_resources.js +++ b/server/src/database/seeds/seed_resources.js @@ -9,6 +9,7 @@ exports.seed = (knex) => { { id: 2, name: 'items' }, { id: 3, name: 'expenses' }, { id: 4, name: 'manual_journals' }, + { id: 5, name: 'items_categories' }, ]); }); }; diff --git a/server/src/database/seeds/seed_resources_fields.js b/server/src/database/seeds/seed_resources_fields.js index 3c520b79d..d6d25d72b 100644 --- a/server/src/database/seeds/seed_resources_fields.js +++ b/server/src/database/seeds/seed_resources_fields.js @@ -80,6 +80,7 @@ exports.seed = (knex) => { id: 10, resource_id: 2, label_name: 'Name', + key: 'name', data_type: 'textbox', predefined: 1, columnable: true, @@ -88,6 +89,7 @@ exports.seed = (knex) => { id: 11, resource_id: 2, label_name: 'Type', + key: 'type', data_type: 'textbox', predefined: 1, columnable: true, diff --git a/server/src/http/controllers/Accounting.js b/server/src/http/controllers/Accounting.js index 78d59150e..7f7706fdd 100644 --- a/server/src/http/controllers/Accounting.js +++ b/server/src/http/controllers/Accounting.js @@ -13,9 +13,15 @@ import Resource from '@/models/Resource'; import View from '@/models/View'; import { mapViewRolesToConditionals, - validateViewRoles, + mapFilterRolesToDynamicFilter, } from '@/lib/ViewRolesBuilder'; -import FilterRoles from '@/lib/FilterRoles'; +import { + DynamicFilter, + DynamicFilterSortBy, + DynamicFilterViews, + DynamicFilterFilterRoles, +} from '@/lib/DynamicFilter'; + export default { /** @@ -78,6 +84,8 @@ export default { } const filter = { filter_roles: [], + page: 1, + page_size: 10, ...req.query, }; if (filter.stringified_filter_roles) { @@ -85,7 +93,6 @@ export default { } const errorReasons = []; - const viewConditionals = []; const manualJournalsResource = await Resource.query() .where('name', 'manual_journals') .withGraphFetched('fields') @@ -109,27 +116,52 @@ export default { builder.first(); }); + const resourceFieldsKeys = manualJournalsResource.fields.map((c) => c.key); + const dynamicFilter = new DynamicFilter(ManualJournal.tableName); + + // Dynamic filter with view roles. if (view && view.roles.length > 0) { - viewConditionals.push( - ...mapViewRolesToConditionals(view.roles), + const viewFilter = new DynamicFilterViews( + mapViewRolesToConditionals(view.roles), + view.rolesLogicExpression, ); - if (!validateViewRoles(viewConditionals, view.rolesLogicExpression)) { + if (!viewFilter.validateFilterRoles()) { errorReasons.push({ type: 'VIEW.LOGIC.EXPRESSION.INVALID', code: 400 }); } + dynamicFilter.setFilter(viewFilter); } - // Validate the accounts resource fields. - const filterRoles = new FilterRoles(Resource.tableName, - filter.filter_roles.map((role) => ({ ...role, columnKey: role.fieldKey })), - manualJournalsResource.fields); - if (filterRoles.validateFilterRoles().length > 0) { - errorReasons.push({ type: 'ACCOUNTS.RESOURCE.HAS.NO.GIVEN.FIELDS', code: 500 }); + // Dynamic filter with filter roles. + if (filter.filter_roles.length > 0) { + // Validate the accounts resource fields. + const filterRoles = new DynamicFilterFilterRoles( + mapFilterRolesToDynamicFilter(filter.filter_roles), + manualJournalsResource.fields, + ); + dynamicFilter.setFilter(filterRoles); + + if (filterRoles.validateFilterRoles().length > 0) { + errorReasons.push({ type: 'MANUAL.JOURNAL.HAS.NO.FIELDS', code: 500 }); + } + } + // Dynamic filter with column sort order. + if (filter.column_sort_order) { + if (resourceFieldsKeys.indexOf(filter.column_sort_order) === -1) { + errorReasons.push({ type: 'COLUMN.SORT.ORDER.NOT.FOUND', code: 300 }); + } + const sortByFilter = new DynamicFilterSortBy( + filter.column_sort_order, + filter.sort_order, + ); + dynamicFilter.setFilter(sortByFilter); } if (errorReasons.length > 0) { return res.status(400).send({ errors: errorReasons }); } // Manual journals. - const manualJournals = await ManualJournal.query(); + const manualJournals = await ManualJournal.query().onBuild((builder) => { + dynamicFilter.buildQuery()(builder); + }).pagination(filter.page - 1, filter.page_size); return res.status(200).send({ manualJournals, diff --git a/server/src/http/controllers/ItemCategories.js b/server/src/http/controllers/ItemCategories.js index 4ea416f5d..03070e804 100644 --- a/server/src/http/controllers/ItemCategories.js +++ b/server/src/http/controllers/ItemCategories.js @@ -1,9 +1,23 @@ import express from 'express'; -import { check, param, validationResult } from 'express-validator'; +import { + check, + param, + validationResult, + query, +} from 'express-validator'; import asyncMiddleware from '../middleware/asyncMiddleware'; import ItemCategory from '@/models/ItemCategory'; import Authorization from '@/http/middleware/authorization'; import JWTAuth from '@/http/middleware/jwtAuth'; +import Resource from '@/models/Resource'; +import { + DynamicFilter, + DynamicFilterSortBy, + DynamicFilterFilterRoles, +} from '@/lib/DynamicFilter'; +import { + mapFilterRolesToDynamicFilter, +} from '@/lib/ViewRolesBuilder'; export default { /** @@ -15,40 +29,25 @@ export default { router.use(JWTAuth); - router.post( - '/:id', - // permit('create', 'edit'), + router.post('/:id', this.editCategory.validation, - asyncMiddleware(this.editCategory.handler) - ); + asyncMiddleware(this.editCategory.handler)); - router.post( - '/', - // permit('create'), + router.post('/', this.newCategory.validation, - asyncMiddleware(this.newCategory.handler) - ); + asyncMiddleware(this.newCategory.handler)); - router.delete( - '/:id', - // permit('create', 'edit', 'delete'), + router.delete('/:id', this.deleteItem.validation, - asyncMiddleware(this.deleteItem.handler) - ); + asyncMiddleware(this.deleteItem.handler)); - router.get( - '/:id', - // permit('view'), + router.get('/:id', this.getCategory.validation, - asyncMiddleware(this.getCategory.handler) - ); + asyncMiddleware(this.getCategory.handler)); - router.get( - '/', - // permit('view'), + router.get('/', this.getList.validation, - asyncMiddleware(this.getList.handler) - ); + asyncMiddleware(this.getList.handler)); return router; }, @@ -58,29 +57,21 @@ export default { */ newCategory: { validation: [ - check('name') - .exists() - .trim() - .escape(), + check('name').exists().trim().escape(), check('parent_category_id') .optional({ nullable: true, checkFalsy: true }) .isNumeric() .toInt(), - check('description') - .optional() - .trim() - .escape() + check('description').optional().trim().escape() ], async handler(req, res) { const validationErrors = validationResult(req); if (!validationErrors.isEmpty()) { return res.boom.badData(null, { - code: 'validation_error', - ...validationErrors + code: 'validation_error', ...validationErrors, }); } - const { user } = req; const form = { ...req.body }; @@ -97,7 +88,7 @@ export default { } const category = await ItemCategory.query().insert({ ...form, - user_id: user.id + user_id: user.id, }); return res.status(200).send({ category }); } @@ -109,18 +100,12 @@ export default { editCategory: { validation: [ param('id').toInt(), - check('name') - .exists() - .trim() - .escape(), + check('name').exists().trim().escape(), check('parent_category_id') .optional({ nullable: true, checkFalsy: true }) .isNumeric() .toInt(), - check('description') - .optional() - .trim() - .escape() + check('description').optional().trim().escape(), ], async handler(req, res) { const { id } = req.params; @@ -129,7 +114,7 @@ export default { if (!validationErrors.isEmpty()) { return res.boom.badData(null, { code: 'validation_error', - ...validationErrors + ...validationErrors, }); } @@ -162,7 +147,7 @@ export default { .update({ ...form }); return res.status(200).send({ id: updateItemCategory }); - } + }, }, /** @@ -170,9 +155,7 @@ export default { */ deleteItem: { validation: [ - param('id') - .exists() - .toInt() + param('id').exists().toInt() ], async handler(req, res) { const { id } = req.params; @@ -196,9 +179,81 @@ export default { * Retrieve the list of items. */ getList: { - validation: [], + validation: [ + query('column_sort_order').optional().trim().escape(), + query('sort_order').optional().isInt(['desc', 'asc']), + query('stringified_filter_roles').optional().isJSON(), + ], async handler(req, res) { - const categories = await ItemCategory.query(); + const validationErrors = validationResult(req); + + if (!validationErrors.isEmpty()) { + return res.boom.badData(null, { + code: 'validation_error', ...validationErrors, + }); + } + + const categoriesResource = await Resource.query() + .where('name', 'items_categories') + .withGraphFetched('fields') + .first(); + + if (!categoriesResource) { + return res.status(400).send({ errors: [ + { type: 'ITEMS.CATEGORIES.RESOURCE.NOT.FOUND', code: 200, } + ]}); + } + + const filter = { + column_sort_order: '', + sort_order: '', + filter_roles: [], + ...req.query, + }; + if (filter.stringified_filter_roles) { + filter.filter_roles = JSON.parse(filter.stringified_filter_roles); + } + const errorReasons = []; + const resourceFieldsKeys = categoriesResource.fields.map((c) => c.key); + const dynamicFilter = new DynamicFilter(ItemCategory.tableName); + + // Dynamic filter with filter roles. + if (filter.filter_roles.length > 0) { + // Validate the accounts resource fields. + const filterRoles = new DynamicFilterFilterRoles( + mapFilterRolesToDynamicFilter(filter.filter_roles), + categoriesResource.fields, + ); + categoriesResource.setFilter(filterRoles); + + if (filterRoles.validateFilterRoles().length > 0) { + errorReasons.push({ type: 'ITEMS.RESOURCE.HAS.NO.FIELDS', code: 500 }); + } + } + + // Dynamic filter with column sort order. + if (filter.column_sort_order) { + if (resourceFieldsKeys.indexOf(filter.column_sort_order) === -1) { + errorReasons.push({ type: 'COLUMN.SORT.ORDER.NOT.FOUND', code: 300 }); + } + const sortByFilter = new DynamicFilterSortBy( + filter.column_sort_order, + filter.sort_order, + ); + dynamicFilter.setFilter(sortByFilter); + } + if (errorReasons.length > 0) { + return res.status(400).send({ errors: errorReasons }); + } + + const categories = await ItemCategory.query().onBuild((builder) => { + dynamicFilter.buildQuery()(builder); + + builder.select([ + '*', + ItemCategory.relatedQuery('items').count().as('count'), + ]); + }); return res.status(200).send({ categories }); } diff --git a/server/src/http/controllers/Items.js b/server/src/http/controllers/Items.js index 92b9a423c..4e6c23c0e 100644 --- a/server/src/http/controllers/Items.js +++ b/server/src/http/controllers/Items.js @@ -1,6 +1,5 @@ import express from 'express'; -import { check, query, oneOf, validationResult } from 'express-validator'; -import moment from 'moment'; +import { check, query, validationResult } from 'express-validator'; import { difference } from 'lodash'; import asyncMiddleware from '@/http/middleware/asyncMiddleware'; import jwtAuth from '@/http/middleware/jwtAuth'; @@ -13,9 +12,15 @@ import Authorization from '@/http/middleware/authorization'; import View from '@/models/View'; import { mapViewRolesToConditionals, - validateViewRoles, + mapFilterRolesToDynamicFilter, } from '@/lib/ViewRolesBuilder'; -import FilterRoles from '@/lib/FilterRoles'; +import { + DynamicFilter, + DynamicFilterSortBy, + DynamicFilterViews, + DynamicFilterFilterRoles, +} from '@/lib/DynamicFilter'; + export default { @@ -64,7 +69,9 @@ export default { check('sell_account_id').exists().isInt().toInt(), check('inventory_account_id') .if(check('type').equals('inventory')) - .exists().isInt().toInt(), + .exists() + .isInt() + .toInt(), check('category_id').optional().isInt().toInt(), check('custom_fields').optional().isArray({ min: 1 }), @@ -159,7 +166,11 @@ export default { editItem: { validation: [ check('name').exists(), - check('type').exists().trim().escape().isIn(['product', 'service']), + check('type') + .exists() + .trim() + .escape() + .isIn(['product', 'service']), check('cost_price').exists().isNumeric(), check('sell_price').exists().isNumeric(), check('cost_account_id').exists().isInt(), @@ -281,7 +292,7 @@ export default { ]}); } const filter = { - column_sort_order: 'created_at', + column_sort_order: '', sort_order: '', page: 1, page_size: 10, @@ -304,20 +315,45 @@ export default { builder.withGraphFetched('columns'); builder.first(); }); + const resourceFieldsKeys = itemsResource.fields.map((c) => c.key); + const dynamicFilter = new DynamicFilter(Item.tableName); + + // Dynamic filter with view roles. if (view && view.roles.length > 0) { - viewConditions.push( - ...mapViewRolesToConditionals(view.roles), + const viewFilter = new DynamicFilterViews( + mapViewRolesToConditionals(view.roles), + view.rolesLogicExpression, ); - if (!validateViewRoles(viewConditions, view.rolesLogicExpression)) { + if (!viewFilter.validateFilterRoles()) { errorReasons.push({ type: 'VIEW.LOGIC.EXPRESSION.INVALID', code: 400 }); } + dynamicFilter.setFilter(viewFilter); } - const filterConditions = new FilterRoles(Item.tableName, - filter.filter_roles.map((role) => ({ ...role, columnKey: role.fieldKey })), - itemsResource.fields, - ); - if (filterConditions.validateFilterRoles().length > 0) { - errorReasons.push({ type: 'ITEMS.RESOURCE.HAS.NO.FIELDS', code: 500 }); + + // Dynamic filter with filter roles. + if (filter.filter_roles.length > 0) { + // Validate the accounts resource fields. + const filterRoles = new DynamicFilterFilterRoles( + mapFilterRolesToDynamicFilter(filter.filter_roles), + itemsResource.fields, + ); + dynamicFilter.setFilter(filterRoles); + + if (filterRoles.validateFilterRoles().length > 0) { + errorReasons.push({ type: 'ITEMS.RESOURCE.HAS.NO.FIELDS', code: 500 }); + } + } + + // Dynamic filter with column sort order. + if (filter.column_sort_order) { + if (resourceFieldsKeys.indexOf(filter.column_sort_order) === -1) { + errorReasons.push({ type: 'COLUMN.SORT.ORDER.NOT.FOUND', code: 300 }); + } + const sortByFilter = new DynamicFilterSortBy( + filter.column_sort_order, + filter.sort_order, + ); + dynamicFilter.setFilter(sortByFilter); } if (errorReasons.length > 0) { return res.status(400).send({ errors: errorReasons }); @@ -328,14 +364,7 @@ export default { builder.withGraphFetched('inventoryAccount'); builder.withGraphFetched('category'); - builder.modify('sortBy', filter.column_sort_order, filter.sort_order); - - if (viewConditions.length > 0) { - builder.modify('viewRolesBuilder', viewConditions, view.rolesLogicExpression); - } - if (filter.filter_roles.length > 0) { - filterConditions.buildQuery()(builder); - } + dynamicFilter.buildQuery()(builder); }).pagination(filter.page - 1, filter.page_size); return res.status(200).send({ diff --git a/server/src/lib/DynamicFilter/DynamicFilterFilterRoles.js b/server/src/lib/DynamicFilter/DynamicFilterFilterRoles.js index 229a83e91..015c7f009 100644 --- a/server/src/lib/DynamicFilter/DynamicFilterFilterRoles.js +++ b/server/src/lib/DynamicFilter/DynamicFilterFilterRoles.js @@ -17,7 +17,7 @@ export default class FilterRoles extends DynamicFilterRoleAbstructor { ...role, index: index + 1, columnKey: role.field_key, - comparator: role.comparator === 'AND' ? '&&' : '||', + condition: role.comparator === 'AND' ? '&&' : '||', })); this.resourceFields = resourceFields; } diff --git a/server/src/lib/ViewRolesBuilder/index.js b/server/src/lib/ViewRolesBuilder/index.js index f8b5fc67a..0e94533c5 100644 --- a/server/src/lib/ViewRolesBuilder/index.js +++ b/server/src/lib/ViewRolesBuilder/index.js @@ -86,7 +86,7 @@ export function buildSortColumnJoin(tableName, sortColumnKey) { const joinTable = getTableFromRelationColumn(fieldColumn.relation); builder.join(joinTable, `${tableName}.${fieldColumn.column}`, '=', fieldColumn.relation); } - } + }; } /** diff --git a/server/src/models/ItemCategory.js b/server/src/models/ItemCategory.js index f3e4f538c..89af2b72a 100644 --- a/server/src/models/ItemCategory.js +++ b/server/src/models/ItemCategory.js @@ -24,8 +24,8 @@ export default class ItemCategory extends BaseModel { relation: Model.HasManyRelation, modelClass: Item.default, join: { - from: 'items_categories.itemId', - to: 'items.id', + from: 'items_categories.id', + to: 'items.categoryId', }, }, }; diff --git a/server/tests/routes/accounting.test.js b/server/tests/routes/accounting.test.js index 7dc6f94c5..2b4070f31 100644 --- a/server/tests/routes/accounting.test.js +++ b/server/tests/routes/accounting.test.js @@ -581,19 +581,7 @@ describe('routes: `/accounting`', () => { describe('route: `accounting/manual-journals`', async () => { - it('Should retrieve manual journals resource not found.', async () => { - const res = await request() - .get('/api/accounting/manual-journals') - .set('x-access-token', loginRes.body.token) - .send(); - - expect(res.status).equal(400); - expect(res.body.errors[0].type).equals('MANUAL_JOURNALS.RESOURCE.NOT.FOUND'); - expect(res.body.errors[0].code).equals(200); - }); - it('Should retrieve all manual journals with pagination meta.', async () => { - const resource = await create('resource', { name: 'manual_journals' }); const manualJournal1 = await create('manual_journal'); const manualJournal2 = await create('manual_journal'); @@ -603,8 +591,8 @@ describe('routes: `/accounting`', () => { .send(); expect(res.status).equals(200); - expect(res.body.manualJournals).to.be.a('array'); - expect(res.body.manualJournals.length).equals(2); + expect(res.body.manualJournals.results).to.be.a('array'); + expect(res.body.manualJournals.results.length).equals(2); }); }); diff --git a/server/tests/routes/items.test.js b/server/tests/routes/items.test.js index f95a23ee5..1f5aa2936 100644 --- a/server/tests/routes/items.test.js +++ b/server/tests/routes/items.test.js @@ -86,7 +86,7 @@ describe('routes: `/items`', () => { expect(res.status).equals(422); expect(res.body.code).equals('validation_error'); - expect(res.body.errors).include.something.deep.equals({ + expect(res.body.errors).include.something.deep.equals({ value: 'not_numeric', msg: 'Invalid value', param: 'cost_price', @@ -551,7 +551,6 @@ describe('routes: `/items`', () => { }); it('Should retrieve ordered items based on the given `column_sort_order` and `sort_order` query.', async () => { - await create('resource', { name: 'items' }); await create('item', { name: 'ahmed' }); await create('item', { name: 'mohamed' }); @@ -585,12 +584,6 @@ describe('routes: `/items`', () => { }); it('Should retrieve filtered items based on custom view conditions.', async () => { - const resource = await create('resource', { name: 'items' }); - const resourceField = await create('resource_field', { - label_name: 'Type', - key: 'type', - resource_id: resource.id, - }); const item1 = await create('item', { type: 'service' }); const item2 = await create('item', { type: 'service' }); const item3 = await create('item', { type: 'inventory' }); @@ -598,13 +591,13 @@ describe('routes: `/items`', () => { const view = await create('view', { name: 'Items Inventory', - resource_id: resource.id, + resource_id: 2, roles_logic_expression: '1', }); const viewCondition = await create('view_role', { view_id: view.id, index: 1, - field_id: resourceField, + field_id: 11, value: 'inventory', comparator: 'equals', }); @@ -625,17 +618,6 @@ describe('routes: `/items`', () => { }); it('Should retrieve filtered items based on filtering conditions.', async () => { - const resource = await create('resource', { name: 'items' }); - const resourceField = await create('resource_field', { - label_name: 'Type', - key: 'type', - resource_id: resource.id, - }); - const resourceNameField = await create('resource_field', { - label_name: 'item name', - key: 'name', - resource_id: resource.id, - }); const item1 = await create('item', { type: 'service' }); const item2 = await create('item', { type: 'service', name: 'target' }); const item3 = await create('item', { type: 'inventory' }); @@ -647,13 +629,13 @@ describe('routes: `/items`', () => { .query({ stringified_filter_roles: JSON.stringify([ { - condition: '&&', + condition: 'AND', field_key: 'type', comparator: 'equals', value: 'inventory', }, { - condition: '||', + condition: 'OR', field_key: 'name', comparator: 'equals', value: 'target', diff --git a/server/tests/routes/itemsCategories.test.js b/server/tests/routes/itemsCategories.test.js index 2f0ae0ad1..8066eb1ca 100644 --- a/server/tests/routes/itemsCategories.test.js +++ b/server/tests/routes/itemsCategories.test.js @@ -209,7 +209,7 @@ describe('routes: /item_categories/', () => { }); }); - describe('GET: `/item_categories`', () => { + describe.only('GET: `/item_categories`', () => { it('Should retrieve list of item categories.', async () => { const category1 = await create('item_category'); @@ -230,6 +230,22 @@ describe('routes: /item_categories/', () => { expect(res.body.categories[1].parent_category_id).to.be.a('number'); }); + + + it('Should retrieve of related items.', async () => { + const category1 = await create('item_category'); + const category2 = await create('item_category', { parent_category_id: category1.id }); + + await create('item', { category_id: category1.id }); + + const res = await request() + .get('/api/item_categories') + .set('x-access-token', loginRes.body.token) + .send(); + + expect(res.body.categories[0].count).to.be.a('number'); + expect(res.body.categories[0].count).equals(1); + }); }); describe('GET `/items_category/{id}', () => {