From 1894ad3b187d4d49e8b65c4dbb6523de0f15c081 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 24 Mar 2020 16:47:35 +0200 Subject: [PATCH] feat: items list restful api. --- client/src/static/json/icons.js | 8 + client/src/style/App.scss | 38 ++++ server/src/data/ResourceFieldsKeys.js | 9 + server/src/database/factories/index.js | 9 +- .../20190822214303_create_items_table.js | 1 + server/src/http/controllers/Accounts.js | 1 - server/src/http/controllers/Items.js | 131 ++++++++++---- server/src/models/Item.js | 48 ++++- server/tests/routes/accounts.test.js | 2 +- server/tests/routes/items.test.js | 167 ++++++++++++++++++ 10 files changed, 371 insertions(+), 43 deletions(-) diff --git a/client/src/static/json/icons.js b/client/src/static/json/icons.js index 357a4c13b..8f43dbf1b 100644 --- a/client/src/static/json/icons.js +++ b/client/src/static/json/icons.js @@ -55,4 +55,12 @@ export default { path: ['M8 256c0 137 111 248 248 248s248-111 248-248S393 8 256 8 8 119 8 256zM256 40c118.7 0 216 96.1 216 216 0 118.7-96.1 216-216 216-118.7 0-216-96.1-216-216 0-118.7 96.1-216 216-216zm12.5 92.5l115.1 115c4.7 4.7 4.7 12.3 0 17l-115.1 115c-4.7 4.7-12.3 4.7-17 0l-6.9-6.9c-4.7-4.7-4.7-12.5.2-17.1l85.6-82.5H140c-6.6 0-12-5.4-12-12v-10c0-6.6 5.4-12 12-12h190.3l-85.6-82.5c-4.8-4.7-4.9-12.4-.2-17.1l6.9-6.9c4.8-4.7 12.4-4.7 17.1 0z'], viewBox: '0 0 512 512', }, + "times": { + path: ['M193.94 256L296.5 153.44l21.15-21.15c3.12-3.12 3.12-8.19 0-11.31l-22.63-22.63c-3.12-3.12-8.19-3.12-11.31 0L160 222.06 36.29 98.34c-3.12-3.12-8.19-3.12-11.31 0L2.34 120.97c-3.12 3.12-3.12 8.19 0 11.31L126.06 256 2.34 379.71c-3.12 3.12-3.12 8.19 0 11.31l22.63 22.63c3.12 3.12 8.19 3.12 11.31 0L160 289.94 262.56 392.5l21.15 21.15c3.12 3.12 8.19 3.12 11.31 0l22.63-22.63c3.12-3.12 3.12-8.19 0-11.31L193.94 256z'], + viewBox: '0 0 320 512', + }, + "filter": { + path: ['M91,32.1H8c-2.5,0-4.6-2.1-4.6-4.6s2.1-4.6,4.6-4.6H91c2.5,0,4.6,2.1,4.6,4.6S93.5,32.1,91,32.1z M21.9,45.9 h55.3c2.5,0,4.6,2.1,4.6,4.6c0,2.5-2.1,4.6-4.6,4.6H21.9c-2.5,0-4.6-2.1-4.6-4.6C17.2,48,19.3,45.9,21.9,45.9z M35.7,68.9h27.6 c2.5,0,4.6,2.1,4.6,4.6s-2.1,4.6-4.6,4.6H35.7c-2.5,0-4.6-2.1-4.6-4.6S33.1,68.9,35.7,68.9z'], + viewBox: '0 0 91 91', + } } \ No newline at end of file diff --git a/client/src/style/App.scss b/client/src/style/App.scss index 50c0f2858..e5a74efc3 100644 --- a/client/src/style/App.scss +++ b/client/src/style/App.scss @@ -809,4 +809,42 @@ label{ &__accounting-basis{ } +} + +.filter-dropdown{ + width: 500px; + + &__body{ + padding: 12px; + } + + &__condition{ + display: flex; + + &:not(:first-of-type) { + padding-top: 8px; + border-top: 1px solid #e6e6e6; + margin-top: 8px; + } + } + + .bp3-form-group{ + padding-right: 16px; + margin-bottom: 0; + + &:not(:last-of-type) { + padding-right: 15px; + } + } + + &__footer{ + border-top: 1px solid #e8e8e8; + padding: 5px 10px; + } + .form-group{ + &--condition{ width: 25%; } + &--field{ width: 45%; } + &--compatator{ width: 30%; } + &--value{ width: 25%; } + } } \ No newline at end of file diff --git a/server/src/data/ResourceFieldsKeys.js b/server/src/data/ResourceFieldsKeys.js index 2646a064b..e4f3c7d6e 100644 --- a/server/src/data/ResourceFieldsKeys.js +++ b/server/src/data/ResourceFieldsKeys.js @@ -33,4 +33,13 @@ export default { column: 'code', }, }, + + 'items': { + 'type': { + column: 'type', + }, + 'name': { + column: 'name', + }, + } }; diff --git a/server/src/database/factories/index.js b/server/src/database/factories/index.js index 49ad60f7b..39200e9d2 100644 --- a/server/src/database/factories/index.js +++ b/server/src/database/factories/index.js @@ -91,14 +91,17 @@ factory.define('item_metadata', 'items_metadata', async () => { factory.define('item', 'items', async () => { const category = await factory.create('item_category'); - const account = await factory.create('account'); + const costAccount = await factory.create('account'); + const sellAccount = await factory.create('account'); + const inventoryAccount = await factory.create('account'); return { name: faker.lorem.word(), note: faker.lorem.paragraph(), cost_price: faker.random.number(), sell_price: faker.random.number(), - cost_account_id: account.id, - sell_account_id: account.id, + cost_account_id: costAccount.id, + sell_account_id: sellAccount.id, + inventory_account_id: inventoryAccount.id, category_id: category.id, }; }); diff --git a/server/src/database/migrations/20190822214303_create_items_table.js b/server/src/database/migrations/20190822214303_create_items_table.js index 36491b4d4..c01186c82 100644 --- a/server/src/database/migrations/20190822214303_create_items_table.js +++ b/server/src/database/migrations/20190822214303_create_items_table.js @@ -10,6 +10,7 @@ exports.up = function (knex) { table.string('picture_uri'); table.integer('cost_account_id').unsigned(); table.integer('sell_account_id').unsigned(); + table.integer('inventory_account_id').unsigned(); table.text('note').nullable(); table.integer('category_id').unsigned(); table.integer('user_id').unsigned(); diff --git a/server/src/http/controllers/Accounts.js b/server/src/http/controllers/Accounts.js index 2116093a2..6e4aa54a8 100644 --- a/server/src/http/controllers/Accounts.js +++ b/server/src/http/controllers/Accounts.js @@ -287,7 +287,6 @@ export default { if (viewConditionals.length > 0) { builder.modify('viewRolesBuilder', viewConditionals, view.rolesLogicExpression); } - // Build filter query. if (filter.filter_roles.length > 0) { filterRoles.buildQuery()(builder); diff --git a/server/src/http/controllers/Items.js b/server/src/http/controllers/Items.js index 351b89c61..74052c582 100644 --- a/server/src/http/controllers/Items.js +++ b/server/src/http/controllers/Items.js @@ -1,5 +1,5 @@ import express from 'express'; -import { check, oneOf, validationResult } from 'express-validator'; +import { check, query, oneOf, validationResult } from 'express-validator'; import moment from 'moment'; import { difference } from 'lodash'; import asyncMiddleware from '@/http/middleware/asyncMiddleware'; @@ -10,8 +10,12 @@ import ItemCategory from '@/models/ItemCategory'; import Resource from '@/models/Resource'; import ResourceField from '@/models/ResourceField'; import Authorization from '@/http/middleware/authorization'; - - +import View from '@/models/View'; +import { + mapViewRolesToConditionals, + validateViewRoles, +} from '@/lib/ViewRolesBuilder'; +import FilterRoles from '@/lib/FilterRoles'; export default { @@ -38,9 +42,9 @@ export default { // this.getCategory.validation, // asyncMiddleware(this.getCategory.handler)); - // router.get('/', - // this.categoriesList.validation, - // asyncMiddleware(this.categoriesList.validation)); + router.get('/', + this.listItems.validation, + asyncMiddleware(this.listItems.handler)); return router; }, @@ -248,47 +252,100 @@ export default { * Retrive the list items with pagination meta. */ listItems: { - validation: [], + validation: [ + query('column_sort_order').optional().isIn(['created_at', 'name', 'amount', 'sku']), + query('sort_order').optional().isIn(['desc', 'asc']), + query('page').optional().isNumeric().toInt(), + query('page_size').optional().isNumeric().toInt(), + query('custom_view_id').optional().isNumeric().toInt(), + query('stringified_filter_roles').optional().isJSON(), + ], async handler(req, res) { + const validationErrors = validationResult(req); + + if (!validationErrors.isEmpty()) { + return res.boom.badData(null, { + code: 'validation_error', ...validationErrors, + }); + } + const errorReasons = []; + const viewConditions = []; + const itemsResource = await Resource.query() + .where('name', 'items') + .withGraphFetched('fields') + .first(); + + if (!itemsResource) { + return res.status(400).send({ errors: [ + {type: 'ITEMS_RESOURCE_NOT_FOUND', code: 200}, + ]}); + } const filter = { - name: '', - description: '', - SKU: '', - account_id: null, - page_size: 10, + column_sort_order: 'created_at', + sort_order: '', page: 1, - start_date: null, - end_date: null, + page_size: 10, + custom_view_id: null, + filter_roles: [], ...req.query, }; + if (filter.stringified_filter_roles) { + filter.filter_roles = JSON.parse(filter.stringified_filter_roles); + } - const items = await Item.query((query) => { - if (filter.description) { - query.where('description', 'like', `%${filter.description}%`); + const view = await View.query().onBuild((builder) => { + if (filter.custom_view_id) { + builder.where('id', filter.custom_view_id); + } else { + builder.where('favourite', true); } - if (filter.description) { - query.where('SKU', filter.SKY); - } - if (filter.name) { - query.where('name', filter.name); - } - if (filter.start_date) { - const startDateFormatted = moment(filter.start_date).format('YYYY-MM-DD HH:mm:SS'); - query.where('created_at', '>=', startDateFormatted); - } - if (filter.end_date) { - const endDateFormatted = moment(filter.end_date).format('YYYY-MM-DD HH:mm:SS'); - query.where('created_at', '<=', endDateFormatted); - } - }).fetchPage({ - page_size: filter.page_size, - page: filter.page, + builder.where('resource_id', itemsResource.id); + builder.withGraphFetched('roles.field'); + builder.withGraphFetched('columns'); + builder.first(); }); + if (view && view.roles.length > 0) { + viewConditions.push( + ...mapViewRolesToConditionals(view.roles), + ); + if (!validateViewRoles(viewConditions, view.rolesLogicExpression)) { + errorReasons.push({ type: 'VIEW.LOGIC.EXPRESSION.INVALID', code: 400 }); + } + } + 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 }); + } + if (errorReasons.length > 0) { + return res.status(400).send({ errors: errorReasons }); + } + const items = await Item.query().onBuild((builder) => { + builder.withGraphFetched('costAccount'); + builder.withGraphFetched('sellAccount'); + 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); + } + }).page(filter.page - 1, filter.page_size); return res.status(200).send({ - items: items.toJSON(), - pagination: items.pagination, + items, + ...(view) && { + customViewId: view.id, + viewColumns: view.columns, + viewConditions, + }, }); }, }, -}; +}; \ No newline at end of file diff --git a/server/src/models/Item.js b/server/src/models/Item.js index f962a664a..6173560da 100644 --- a/server/src/models/Item.js +++ b/server/src/models/Item.js @@ -1,6 +1,9 @@ import { Model } from 'objection'; import path from 'path'; import BaseModel from '@/models/Model'; +import { + buildFilterQuery, +} from '@/lib/ViewRolesBuilder'; export default class Item extends BaseModel { /** @@ -10,10 +13,26 @@ export default class Item extends BaseModel { return 'items'; } + static get modifiers() { + const TABLE_NAME = Item.tableName; + + return { + sortBy(query, columnSort, sortDirection) { + query.orderBy(columnSort, sortDirection); + }, + viewRolesBuilder(query, conditions, logicExpression) { + buildFilterQuery(Item.tableName, conditions, logicExpression)(query); + }, + }; + } + /** * Relationship mapping. */ static get relationMappings() { + const Account = require('@/models/Account'); + const ItemCategory = require('@/models/ItemCategory'); + return { /** * Item may has many meta data. @@ -32,12 +51,39 @@ export default class Item extends BaseModel { */ category: { relation: Model.BelongsToOneRelation, - modelBase: path.join(__dirname, 'ItemCategory'), + modelClass: ItemCategory.default, join: { from: 'items.categoryId', to: 'items_categories.id', }, }, + + costAccount: { + relation: Model.BelongsToOneRelation, + modelClass: Account.default, + join: { + from: 'items.costAccountId', + to: 'accounts.id', + }, + }, + + sellAccount: { + relation: Model.BelongsToOneRelation, + modelClass: Account.default, + join: { + from: 'items.sellAccountId', + to: 'accounts.id', + }, + }, + + inventoryAccount: { + relation: Model.BelongsToOneRelation, + modelClass: Account.default, + join: { + from: 'items.inventoryAccountId', + to: 'accounts.id', + }, + }, }; } } diff --git a/server/tests/routes/accounts.test.js b/server/tests/routes/accounts.test.js index f3389cc0f..c9833b9fa 100644 --- a/server/tests/routes/accounts.test.js +++ b/server/tests/routes/accounts.test.js @@ -3,7 +3,7 @@ import Account from '@/models/Account'; let loginRes; -describe.only('routes: /accounts/', () => { +describe('routes: /accounts/', () => { beforeEach(async () => { loginRes = await login(); }); diff --git a/server/tests/routes/items.test.js b/server/tests/routes/items.test.js index 429656c92..dc380d07a 100644 --- a/server/tests/routes/items.test.js +++ b/server/tests/routes/items.test.js @@ -503,4 +503,171 @@ describe('routes: `/items`', () => { expect(storedItems).to.have.lengthOf(0); }); }); + + describe('GET: `items`', () => { + it('Should response unauthorized access in case the user not authenticated.', async () => { + const res = await request() + .get('/api/items') + .send(); + + expect(res.status).equals(401); + expect(res.body.message).equals('unauthorized'); + }); + + it('Should response items resource not found.', async () => { + await create('item'); + + const res = await request() + .get('/api/items') + .set('x-access-token', loginRes.body.token) + .send(); + + expect(res.status).equals(400); + expect(res.body.errors).include.something.that.deep.equal({ + type: 'ITEMS_RESOURCE_NOT_FOUND', + code: 200, + }); + }); + + it('Should retrieve items list with associated accounts.', async () => { + await create('resource', { name: 'items' }); + await create('item'); + + const res = await request() + .get('/api/items') + .set('x-access-token', loginRes.body.token) + .send(); + + expect(res.status).equals(200); + + expect(res.body.items).to.be.a('object'); + expect(res.body.items.results).to.be.a('array'); + expect(res.body.items.results.length).equals(1); + + expect(res.body.items.results[0].cost_account).to.be.an('object'); + expect(res.body.items.results[0].sell_account).to.be.an('object'); + expect(res.body.items.results[0].inventory_account).to.be.an('object'); + expect(res.body.items.results[0].category).to.be.an('object'); + }); + + 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' }); + + const res = await request() + .get('/api/items') + .set('x-access-token', loginRes.body.token) + .query({ + column_sort_order: 'name', + sort_order: 'desc', + }) + .send(); + + expect(res.body.items.results.length).equals(2); + expect(res.body.items.results[0].name).equals('mohamed'); + expect(res.body.items.results[1].name).equals('ahmed'); + }); + + it('Should retrieve pagination meta of items list.', async () => { + await create('resource', { name: '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) + }); + + 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' }); + const item4 = await create('item', { type: 'inventory' }); + + const view = await create('view', { + name: 'Items Inventory', + resource_id: resource.id, + roles_logic_expression: '1', + }); + const viewCondition = await create('view_role', { + view_id: view.id, + index: 1, + field_id: resourceField, + value: 'inventory', + comparator: 'equals', + }); + const res = await request() + .get('/api/items') + .set('x-access-token', loginRes.body.token) + .query({ + custom_view_id: view.id, + }) + .send(); + + expect(res.body.customViewId).equals(view.id); + expect(res.body.viewColumns).to.be.a('array'); + expect(res.body.viewConditions).to.be.a('array'); + expect(res.body.items.results.length).equals(2); + expect(res.body.items.results[0].type).equals('inventory'); + expect(res.body.items.results[1].type).equals('inventory'); + }); + + 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' }); + const item4 = await create('item', { type: 'inventory' }); + + const res = await request() + .get('/api/items') + .set('x-access-token', loginRes.body.token) + .query({ + stringified_filter_roles: JSON.stringify([ + { + condition: '&&', + field_key: 'type', + comparator: 'equals', + value: 'inventory', + }, + { + condition: '||', + field_key: 'name', + comparator: 'equals', + value: 'target', + }, + ]), + }) + .send(); + + expect(res.body.items.results.length).equals(3); + expect(res.body.items.results[0].name).equals('target'); + expect(res.body.items.results[1].type).equals('inventory'); + expect(res.body.items.results[2].type).equals('inventory'); + }); + }); });