From b28965c78115f1d3adaeb18cb15a210596b98cf3 Mon Sep 17 00:00:00 2001 From: Gabriela Cortes Date: Fri, 18 Dec 2020 00:36:38 -0600 Subject: [PATCH 1/9] feat: created inventory folder --- src/inventory/Inventory.tsx | 47 ++++++++++++++++ src/inventory/InventoryFilter.ts | 8 +++ src/inventory/ViewInventory.tsx | 0 src/inventory/ViewItem.tsx | 0 src/inventory/add/AddItem.tsx | 0 src/shared/components/Sidebar.tsx | 53 +++++++++++++++++++ src/shared/db/InventoryRepository.ts | 30 +++++++++++ src/shared/locales/enUs/translations/index.ts | 2 + .../enUs/translations/inventory/index.ts | 43 +++++++++++++++ src/shared/model/InventoryItem.ts | 43 +++++++++++++++ src/shared/model/Permissions.ts | 3 ++ 11 files changed, 229 insertions(+) create mode 100644 src/inventory/Inventory.tsx create mode 100644 src/inventory/InventoryFilter.ts create mode 100644 src/inventory/ViewInventory.tsx create mode 100644 src/inventory/ViewItem.tsx create mode 100644 src/inventory/add/AddItem.tsx create mode 100644 src/shared/db/InventoryRepository.ts create mode 100644 src/shared/locales/enUs/translations/inventory/index.ts create mode 100644 src/shared/model/InventoryItem.ts diff --git a/src/inventory/Inventory.tsx b/src/inventory/Inventory.tsx new file mode 100644 index 0000000000..52507b3a7f --- /dev/null +++ b/src/inventory/Inventory.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import { Switch } from 'react-router-dom' + +import useAddBreadcrumbs from '../page-header/breadcrumbs/useAddBreadcrumbs' +import PrivateRoute from '../shared/components/PrivateRoute' +import Permissions from '../shared/model/Permissions' +import { RootState } from '../shared/store' +import AddItem from './add/AddItem' +import ViewInventory from './ViewInventory' +import ViewItem from './ViewItem' + +const Inventory = () => { + const { permissions } = useSelector((state: RootState) => state.user) + const breadcrumbs = [ + { + i18nKey: 'inventory.label', + location: `/inventory`, + }, + ] + useAddBreadcrumbs(breadcrumbs, true) + + return ( + + + + + + ) +} + +export default Inventory diff --git a/src/inventory/InventoryFilter.ts b/src/inventory/InventoryFilter.ts new file mode 100644 index 0000000000..6909f6b4b7 --- /dev/null +++ b/src/inventory/InventoryFilter.ts @@ -0,0 +1,8 @@ +enum InventoryFilter { + clothing = 'clothing', + equipment = 'equipment', + medication = 'medication', + all = 'all', +} + +export default InventoryFilter diff --git a/src/inventory/ViewInventory.tsx b/src/inventory/ViewInventory.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/inventory/ViewItem.tsx b/src/inventory/ViewItem.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/inventory/add/AddItem.tsx b/src/inventory/add/AddItem.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx index 0ed2ca4aba..698301cd75 100644 --- a/src/shared/components/Sidebar.tsx +++ b/src/shared/components/Sidebar.tsx @@ -48,6 +48,8 @@ const Sidebar = () => { ? 'incidents' : splittedPath[1].includes('imagings') ? 'imagings' + : splittedPath[1].includes('inventory') + ? 'inventory' : 'none', ) @@ -412,6 +414,56 @@ const Sidebar = () => { ) + const getInventoryLinks = () => ( + <> + { + navigateTo('/inventory') + setExpansion('inventory') + }} + className="nav-item" + style={listItemStyle} + > + + {!sidebarCollapsed && t('inventory.label')} + + {splittedPath[1].includes('inventory') && expandedItem === 'inventory' && ( + + {permissions.includes(Permissions.AddInventory) && ( + navigateTo('/inventory/new')} + active={splittedPath[1].includes('inventory') && splittedPath.length > 2} + > + + {!sidebarCollapsed && t('inventory.add.new')} + + )} + {permissions.includes(Permissions.ViewInventory) && ( + navigateTo('/inventory')} + active={splittedPath[1].includes('inventory') && splittedPath.length < 3} + > + + {!sidebarCollapsed && t('inventory.add.label')} + + )} + + )} + + ) + return ( diff --git a/src/shared/db/InventoryRepository.ts b/src/shared/db/InventoryRepository.ts new file mode 100644 index 0000000000..64b40aa089 --- /dev/null +++ b/src/shared/db/InventoryRepository.ts @@ -0,0 +1,30 @@ +import InventoryFilter from '../../inventory/InventoryFilter' +import { relationalDb } from '../config/pouchdb' +import InventoryItem from '../model/InventoryItem' +import Repository from './Repository' + +interface SearchOptions { + status: InventoryFilter +} +class InventoryRepository extends Repository { + constructor() { + super('inventory', relationalDb) + } + + async search(options: SearchOptions): Promise { + return super.search(InventoryRepository.getSearchCriteria(options)) + } + + private static getSearchCriteria(options: SearchOptions): any { + const statusFilter = + options.status !== InventoryFilter.all ? [{ 'data.status': options.status }] : [] + const selector = { + $and: statusFilter, + } + return { + selector, + } + } +} + +export default new InventoryRepository() diff --git a/src/shared/locales/enUs/translations/index.ts b/src/shared/locales/enUs/translations/index.ts index 5995498562..1d24bad509 100644 --- a/src/shared/locales/enUs/translations/index.ts +++ b/src/shared/locales/enUs/translations/index.ts @@ -3,6 +3,7 @@ import bloodType from './blood-type' import dashboard from './dashboard' import imagings from './imagings' import incidents from './incidents' +import inventory from './inventory' import labs from './labs' import medications from './medications' import networkStatus from './network-status' @@ -30,4 +31,5 @@ export default { ...user, ...bloodType, ...imagings, + ...inventory, } diff --git a/src/shared/locales/enUs/translations/inventory/index.ts b/src/shared/locales/enUs/translations/inventory/index.ts new file mode 100644 index 0000000000..1f8a452583 --- /dev/null +++ b/src/shared/locales/enUs/translations/inventory/index.ts @@ -0,0 +1,43 @@ +export default { + inventory: { + filterTitle: ' Filter by status', + label: 'Inventory', + actions: { + add: 'Add', + }, + status: { + reported: 'reported', + resolved: 'resolved', + all: 'all', + }, + reports: { + label: 'Reported Incidents', + new: 'Report Incident', + view: 'View Incident', + resolve: 'Resolve Incident', + dateOfIncident: 'Date of Incident', + department: 'Department', + download: 'Download', + category: 'Category', + categoryItem: 'Category Item', + description: 'Description of Incident', + code: 'Code', + reportedBy: 'Reported By', + reportedOn: 'Reported On', + resolvedOn: 'Resolved On', + status: 'Status', + error: { + dateRequired: 'Date is required.', + dateMustBeInThePast: 'Date must be in the past.', + departmentRequired: 'Department is required.', + categoryRequired: 'Category is required', + categoryItemRequired: 'Category Item is required', + descriptionRequired: 'Description is required', + }, + }, + visualize: { + label: 'Visualize', + view: 'Visualize Incidents', + }, + }, +} diff --git a/src/shared/model/InventoryItem.ts b/src/shared/model/InventoryItem.ts new file mode 100644 index 0000000000..55695c9e8f --- /dev/null +++ b/src/shared/model/InventoryItem.ts @@ -0,0 +1,43 @@ +import AbstractDBModel from './AbstractDBModel' + +export default interface InventoryItem extends AbstractDBModel { + id: string + name: string + rank: string + type: 'clothing' | 'equipment' | 'medication' + crossReference: string + reorderPoint: string + distributionUnit: + | 'ampoule' + | 'bag' + | 'bottle' + | 'box' + | 'bundle' + | 'capsule' + | 'case' + | 'container' + | 'cream' + | 'each' + | 'gel' + | 'nebule' + | 'ointment' + | 'pack' + | 'pair' + | 'pallet' + | 'patch' + | 'pcs' + | 'pill' + | 'plastic' + | 'polyamp' + | 'rollset' + | 'spray' + | 'suppository' + | 'suspension' + | 'syrup' + | 'tablet' + | 'tray' + | 'tube' + | 'vial' + pricePerUnit: number + note: string +} diff --git a/src/shared/model/Permissions.ts b/src/shared/model/Permissions.ts index d9532b6b36..2372626cd8 100644 --- a/src/shared/model/Permissions.ts +++ b/src/shared/model/Permissions.ts @@ -29,6 +29,9 @@ enum Permissions { RequestImaging = 'write:imaging', ViewImagings = 'read:imagings', ViewIncidentWidgets = 'read:incident_widgets', + ViewInventory = 'read:inventory', + AddItem = 'write:item', + ViewItem = 'read:item', } export default Permissions From 1bc96fb005aace6660e560c3cab27dc3c5fd26df Mon Sep 17 00:00:00 2001 From: Gabriela Cortes Date: Sat, 19 Dec 2020 00:18:20 -0600 Subject: [PATCH 2/9] feat: completed sidebar and navbar --- src/shared/components/Sidebar.tsx | 9 +- src/shared/components/navbar/Navbar.tsx | 2 + src/shared/components/navbar/pageMap.tsx | 14 ++++ .../enUs/translations/inventory/index.ts | 84 ++++++++++++------- 4 files changed, 74 insertions(+), 35 deletions(-) diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx index 698301cd75..b5a7a94d07 100644 --- a/src/shared/components/Sidebar.tsx +++ b/src/shared/components/Sidebar.tsx @@ -437,7 +437,8 @@ const Sidebar = () => { {splittedPath[1].includes('inventory') && expandedItem === 'inventory' && ( - {permissions.includes(Permissions.AddInventory) && ( + {/* Need to figure out how to give permissions */} + {permissions.includes(Permissions.ViewImagings) && ( { active={splittedPath[1].includes('inventory') && splittedPath.length > 2} > - {!sidebarCollapsed && t('inventory.add.new')} + {!sidebarCollapsed && t('inventory.actions.add')} )} - {permissions.includes(Permissions.ViewInventory) && ( + {permissions.includes(Permissions.ViewImagings) && ( { active={splittedPath[1].includes('inventory') && splittedPath.length < 3} > - {!sidebarCollapsed && t('inventory.add.label')} + {!sidebarCollapsed && t('inventory.items.label')} )} diff --git a/src/shared/components/navbar/Navbar.tsx b/src/shared/components/navbar/Navbar.tsx index 6096259f2a..1dca909b21 100644 --- a/src/shared/components/navbar/Navbar.tsx +++ b/src/shared/components/navbar/Navbar.tsx @@ -25,6 +25,7 @@ const Navbar = () => { 'incidents.reports.new', 'imagings.requests.new', 'settings.label', + 'inventory.actions.add', ] function getDropdownListOfPages(pages: Page[]) { @@ -52,6 +53,7 @@ const Navbar = () => { pageMap.newLab, pageMap.newImaging, pageMap.newIncident, + pageMap.newItem, ] return ( diff --git a/src/shared/components/navbar/pageMap.tsx b/src/shared/components/navbar/pageMap.tsx index 6a9cec6dc9..dc8dbd12b2 100644 --- a/src/shared/components/navbar/pageMap.tsx +++ b/src/shared/components/navbar/pageMap.tsx @@ -89,6 +89,20 @@ const pageMap: { path: '/incidents/visualize', icon: 'incident', }, + newItem: { + // Need to add permissions + permission: Permissions.ViewIncidents, + label: 'inventory.actions.add', + path: '/inventory/new', + icon: 'add', + }, + ViewInventory: { + // Need to add permissions + permission: Permissions.ViewIncidents, + label: 'inventory.items.label', + path: '/inventory', + icon: 'lab', + }, newVisit: { permission: Permissions.AddVisit, label: 'visits.visit.new', diff --git a/src/shared/locales/enUs/translations/inventory/index.ts b/src/shared/locales/enUs/translations/inventory/index.ts index 1f8a452583..ae504b0050 100644 --- a/src/shared/locales/enUs/translations/inventory/index.ts +++ b/src/shared/locales/enUs/translations/inventory/index.ts @@ -3,41 +3,63 @@ export default { filterTitle: ' Filter by status', label: 'Inventory', actions: { - add: 'Add', + add: 'Add Inventory Item', }, - status: { - reported: 'reported', - resolved: 'resolved', - all: 'all', + type: { + clothing: 'Clothing', + equipment: 'Equipment', + medication: 'Medication', }, - reports: { - label: 'Reported Incidents', - new: 'Report Incident', - view: 'View Incident', - resolve: 'Resolve Incident', - dateOfIncident: 'Date of Incident', - department: 'Department', - download: 'Download', - category: 'Category', - categoryItem: 'Category Item', - description: 'Description of Incident', - code: 'Code', - reportedBy: 'Reported By', - reportedOn: 'Reported On', - resolvedOn: 'Resolved On', - status: 'Status', + items: { + label: 'Inventory Items', + new: 'Add Item', + view: 'View Item', + name: 'Name', + rank: 'Rank', + crossReference: 'Cross Reference', + reorderPoint: 'Reorder Point', + pricePerUnit: 'Price per Unit', + note: 'Note', error: { - dateRequired: 'Date is required.', - dateMustBeInThePast: 'Date must be in the past.', - departmentRequired: 'Department is required.', - categoryRequired: 'Category is required', - categoryItemRequired: 'Category Item is required', - descriptionRequired: 'Description is required', + nameRequired: 'Name is required.', + rankRequired: 'Rank is required.', + typeRequired: 'Type is required', + reorderPoint: 'Reorder point is required', + distributionUnitRequired: 'Distribution unit is required', + pricePerUnitRequired: 'Prive per unit is required', + }, + distributionUnit: { + ampoule: 'Ampoule', + bag: 'Bag', + bottle: 'Bottle', + box: 'Box', + bundle: 'Bundle', + capsule: 'Capsule', + case: 'Case', + container: 'Container', + cream: 'Cream', + each: 'Each', + gel: 'Gel', + nebule: 'Nebule', + ointment: 'Ointment', + pack: 'Pack', + pair: 'Pair', + pallet: 'Pallet', + patch: 'Patch', + pcs: 'Pcs', + pill: 'Pill', + plastic: 'Plastic', + polyamp: 'Polyamp', + rollset: 'Rollset', + spray: 'Spray', + suppository: 'Suppository', + suspension: 'Suspension', + syrup: 'Syrup', + tablet: 'Tablet', + tray: 'Tray', + tube: 'Tube', + vial: 'Vial', }, - }, - visualize: { - label: 'Visualize', - view: 'Visualize Incidents', }, }, } From 561de3a38b2ffd2a05edd94a7c724b85c740bf73 Mon Sep 17 00:00:00 2001 From: Gabriela Cortes Date: Thu, 24 Dec 2020 23:19:27 -0600 Subject: [PATCH 3/9] feat: created table header and began filter feature for main inventory page --- src/HospitalRun.tsx | 2 + src/inventory/Inventory.tsx | 10 +-- src/inventory/ViewInventory.tsx | 0 src/inventory/hooks/useInventory.tsx | 16 ++++ src/inventory/model/InventorySearchRequest.ts | 5 ++ src/inventory/view/ViewInventory.tsx | 68 +++++++++++++++ src/inventory/view/ViewInventoryTable.tsx | 83 +++++++++++++++++++ src/inventory/{ => view}/ViewItem.tsx | 0 src/shared/config/pouchdb.ts | 5 ++ src/shared/db/InventoryRepository.ts | 7 +- .../enUs/translations/inventory/index.ts | 5 +- 11 files changed, 191 insertions(+), 10 deletions(-) delete mode 100644 src/inventory/ViewInventory.tsx create mode 100644 src/inventory/hooks/useInventory.tsx create mode 100644 src/inventory/model/InventorySearchRequest.ts create mode 100644 src/inventory/view/ViewInventory.tsx create mode 100644 src/inventory/view/ViewInventoryTable.tsx rename src/inventory/{ => view}/ViewItem.tsx (100%) diff --git a/src/HospitalRun.tsx b/src/HospitalRun.tsx index 4bd60bdd01..8335d29852 100644 --- a/src/HospitalRun.tsx +++ b/src/HospitalRun.tsx @@ -6,6 +6,7 @@ import { Route, Switch } from 'react-router-dom' import Dashboard from './dashboard/Dashboard' import Imagings from './imagings/Imagings' import Incidents from './incidents/Incidents' +import Inventory from './inventory/Inventory' import Labs from './labs/Labs' import Medications from './medications/Medications' import Breadcrumbs from './page-header/breadcrumbs/Breadcrumbs' @@ -55,6 +56,7 @@ const HospitalRun = () => { + diff --git a/src/inventory/Inventory.tsx b/src/inventory/Inventory.tsx index 52507b3a7f..89e2824fc1 100644 --- a/src/inventory/Inventory.tsx +++ b/src/inventory/Inventory.tsx @@ -7,8 +7,8 @@ import PrivateRoute from '../shared/components/PrivateRoute' import Permissions from '../shared/model/Permissions' import { RootState } from '../shared/store' import AddItem from './add/AddItem' -import ViewInventory from './ViewInventory' -import ViewItem from './ViewItem' +import ViewInventory from './view/ViewInventory' +import ViewItem from './view/ViewItem' const Inventory = () => { const { permissions } = useSelector((state: RootState) => state.user) @@ -23,19 +23,19 @@ const Inventory = () => { return ( { + return InventoryRepository.search(searchRequest) +} + +export default function useInventory(searchRequest: InventorySearchRequest) { + return useQuery(['inventory', searchRequest], fetchInventory) +} diff --git a/src/inventory/model/InventorySearchRequest.ts b/src/inventory/model/InventorySearchRequest.ts new file mode 100644 index 0000000000..b1f87984fc --- /dev/null +++ b/src/inventory/model/InventorySearchRequest.ts @@ -0,0 +1,5 @@ +import InventoryFilter from '../InventoryFilter' + +export default interface InventorySearchRequest { + type: InventoryFilter +} diff --git a/src/inventory/view/ViewInventory.tsx b/src/inventory/view/ViewInventory.tsx new file mode 100644 index 0000000000..785df8e9ae --- /dev/null +++ b/src/inventory/view/ViewInventory.tsx @@ -0,0 +1,68 @@ +import { Button } from '@hospitalrun/components' +import React, { useEffect, useState } from 'react' +import { useHistory } from 'react-router-dom' + +import { useButtonToolbarSetter } from '../../page-header/button-toolbar/ButtonBarProvider' +import { useUpdateTitle } from '../../page-header/title/TitleContext' +import SelectWithLabelFormGroup, { + Option, +} from '../../shared/components/input/SelectWithLabelFormGroup' +import useTranslator from '../../shared/hooks/useTranslator' +import InventoryFilter from '../InventoryFilter' +import ViewInventoryTable from './ViewInventoryTable' + +const ViewInventory = () => { + const { t } = useTranslator() + const history = useHistory() + const setButtonToolBar = useButtonToolbarSetter() + const updateTitle = useUpdateTitle() + useEffect(() => { + updateTitle(t('inventory.items.label')) + }) + const [searchFilter, setSearchFilter] = useState(InventoryFilter.all) + + useEffect(() => { + setButtonToolBar([ + , + ]) + + return () => { + setButtonToolBar([]) + } + }, [setButtonToolBar, t, history]) + + const filterOptions: Option[] = Object.values(InventoryFilter).map((filter) => ({ + label: t(`inventory.type.${filter}`), + value: `${filter}`, + })) + + return ( + <> +
+
+ value === searchFilter)} + onChange={(values) => setSearchFilter(values[0] as InventoryFilter)} + isEditable + /> +
+
+
+ +
+ + ) +} + +export default ViewInventory diff --git a/src/inventory/view/ViewInventoryTable.tsx b/src/inventory/view/ViewInventoryTable.tsx new file mode 100644 index 0000000000..01de121c04 --- /dev/null +++ b/src/inventory/view/ViewInventoryTable.tsx @@ -0,0 +1,83 @@ +import { Spinner, Table } from '@hospitalrun/components' +import React from 'react' +import { useHistory } from 'react-router' + +import useTranslator from '../../shared/hooks/useTranslator' +import useInventory from '../hooks/useInventory' +import InventorySearchRequest from '../model/InventorySearchRequest' + +interface Props { + searchRequest: InventorySearchRequest +} + +export function populateExportData(dataToPopulate: any, theData: any) { + let first = true + if (theData != null) { + theData.forEach((elm: any) => { + const entry = { + name: elm.name, + type: elm.type, + reorderPoint: elm.reorderPoint, + distributionUnit: elm.distributionUnit, + pricePerUnit: elm.pricePerUnit, + } + if (first) { + dataToPopulate[0] = entry + first = false + } else { + dataToPopulate.push(entry) + } + }) + } +} + +function ViewInventoryTable(props: Props) { + const { searchRequest } = props + const { t } = useTranslator() + const history = useHistory() + const { data, isLoading } = useInventory(searchRequest) + + if (data === undefined || isLoading) { + return + } + + return ( + <> + row.id} + data={data} + columns={[ + { + label: t('inventory.items.name'), + key: 'name', + }, + { + label: t('inventory.items.type'), + key: 'type', + }, + { + label: t('inventory.items.reorderPoint'), + key: 'reorderPoint', + }, + { + label: t('inventory.items.distributionUnitLabel'), + key: 'distributionUnit', + }, + { + label: t('inventory.items.pricePerUnit'), + key: 'pricePerUnit', + }, + ]} + actionsHeaderText={t('actions.label')} + actions={[ + { + label: t('actions.view'), + action: (row) => history.push(`incidents/${row.id}`), + }, + ]} + /> + + ) +} + +export default ViewInventoryTable diff --git a/src/inventory/ViewItem.tsx b/src/inventory/view/ViewItem.tsx similarity index 100% rename from src/inventory/ViewItem.tsx rename to src/inventory/view/ViewItem.tsx diff --git a/src/shared/config/pouchdb.ts b/src/shared/config/pouchdb.ts index e7eb77c722..b322460128 100644 --- a/src/shared/config/pouchdb.ts +++ b/src/shared/config/pouchdb.ts @@ -63,6 +63,11 @@ export const schema = [ plural: 'medications', relations: { patient: { belongsTo: 'patient' } }, }, + { + singular: 'inventory', + plural: 'inventories', + // fix + }, ] export const relationalDb = localDb.setSchema(schema) export const remoteDb = serverDb as PouchDB.Database diff --git a/src/shared/db/InventoryRepository.ts b/src/shared/db/InventoryRepository.ts index 64b40aa089..7ffc96a01a 100644 --- a/src/shared/db/InventoryRepository.ts +++ b/src/shared/db/InventoryRepository.ts @@ -4,7 +4,7 @@ import InventoryItem from '../model/InventoryItem' import Repository from './Repository' interface SearchOptions { - status: InventoryFilter + type: InventoryFilter } class InventoryRepository extends Repository { constructor() { @@ -16,10 +16,9 @@ class InventoryRepository extends Repository { } private static getSearchCriteria(options: SearchOptions): any { - const statusFilter = - options.status !== InventoryFilter.all ? [{ 'data.status': options.status }] : [] + const typeFilter = options.type !== InventoryFilter.all ? [{ 'data.type': options.type }] : [] const selector = { - $and: statusFilter, + $and: typeFilter, } return { selector, diff --git a/src/shared/locales/enUs/translations/inventory/index.ts b/src/shared/locales/enUs/translations/inventory/index.ts index ae504b0050..3f007dccb0 100644 --- a/src/shared/locales/enUs/translations/inventory/index.ts +++ b/src/shared/locales/enUs/translations/inventory/index.ts @@ -1,6 +1,6 @@ export default { inventory: { - filterTitle: ' Filter by status', + filterTitle: ' Filter by type', label: 'Inventory', actions: { add: 'Add Inventory Item', @@ -9,6 +9,7 @@ export default { clothing: 'Clothing', equipment: 'Equipment', medication: 'Medication', + all: 'All Types', }, items: { label: 'Inventory Items', @@ -16,8 +17,10 @@ export default { view: 'View Item', name: 'Name', rank: 'Rank', + type: 'Type', crossReference: 'Cross Reference', reorderPoint: 'Reorder Point', + distributionUnitLabel: 'Distribution Unit', pricePerUnit: 'Price per Unit', note: 'Note', error: { From 5a958197dfb0906ca05291d5eb1d5a5fd99f7478 Mon Sep 17 00:00:00 2001 From: Gabriela Cortes Date: Mon, 28 Dec 2020 23:18:58 -0600 Subject: [PATCH 4/9] feat: added search bar function --- src/inventory/Inventory.tsx | 6 +- src/inventory/model/InventorySearchRequest.ts | 5 +- src/inventory/model/ItemType.ts | 1 + src/inventory/{ => view}/InventoryFilter.ts | 0 src/inventory/view/InventorySearch.tsx | 66 +++++++++++++++ src/inventory/view/ViewInventory.tsx | 81 ++++++++++--------- src/inventory/view/ViewInventoryTable.tsx | 29 ++----- src/shared/components/Sidebar.tsx | 6 +- src/shared/components/navbar/Navbar.tsx | 2 +- src/shared/components/navbar/pageMap.tsx | 8 +- src/shared/db/InventoryRepository.ts | 27 ++++--- .../enUs/translations/inventory/index.ts | 5 +- src/user/user-slice.ts | 3 + 13 files changed, 152 insertions(+), 87 deletions(-) create mode 100644 src/inventory/model/ItemType.ts rename src/inventory/{ => view}/InventoryFilter.ts (100%) create mode 100644 src/inventory/view/InventorySearch.tsx diff --git a/src/inventory/Inventory.tsx b/src/inventory/Inventory.tsx index 89e2824fc1..f8a399b028 100644 --- a/src/inventory/Inventory.tsx +++ b/src/inventory/Inventory.tsx @@ -23,19 +23,19 @@ const Inventory = () => { return ( void +} + +const InventorySearch = (props: Props) => { + const { searchRequest, onChange } = props + const { t } = useTranslator() + const filterOptions: Option[] = Object.values(InventoryFilter).map((filter) => ({ + label: t(`inventory.type.${filter}`), + value: `${filter}`, + })) + + const onSearchQueryChange = (event: ChangeEvent) => { + const query = event.target.value + onChange({ + ...searchRequest, + text: query, + }) + } + + const onFilterChange = (filter: ItemType) => { + onChange({ + ...searchRequest, + type: filter, + }) + } + + return ( +
+
+ value === searchRequest.type)} + onChange={(values) => onFilterChange(values[0] as ItemType)} + isEditable + /> +
+
+ +
+
+ ) +} + +export default InventorySearch diff --git a/src/inventory/view/ViewInventory.tsx b/src/inventory/view/ViewInventory.tsx index 785df8e9ae..fb6c29a90a 100644 --- a/src/inventory/view/ViewInventory.tsx +++ b/src/inventory/view/ViewInventory.tsx @@ -1,66 +1,67 @@ import { Button } from '@hospitalrun/components' -import React, { useEffect, useState } from 'react' +import React, { useEffect, useCallback, useState } from 'react' +import { useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' import { useButtonToolbarSetter } from '../../page-header/button-toolbar/ButtonBarProvider' import { useUpdateTitle } from '../../page-header/title/TitleContext' -import SelectWithLabelFormGroup, { - Option, -} from '../../shared/components/input/SelectWithLabelFormGroup' import useTranslator from '../../shared/hooks/useTranslator' -import InventoryFilter from '../InventoryFilter' +import Permissions from '../../shared/model/Permissions' +import { RootState } from '../../shared/store' +import InventorySearchRequest from '../model/InventorySearchRequest' +import InventorySearch from './InventorySearch' import ViewInventoryTable from './ViewInventoryTable' const ViewInventory = () => { const { t } = useTranslator() const history = useHistory() - const setButtonToolBar = useButtonToolbarSetter() + const setButtons = useButtonToolbarSetter() const updateTitle = useUpdateTitle() useEffect(() => { updateTitle(t('inventory.items.label')) }) - const [searchFilter, setSearchFilter] = useState(InventoryFilter.all) + const { permissions } = useSelector((state: RootState) => state.user) - useEffect(() => { - setButtonToolBar([ - , - ]) + const getButtons = useCallback(() => { + const buttons: React.ReactNode[] = [] + + if (permissions.includes(Permissions.AddItem)) { + buttons.push( + , + ) + } + + return buttons + }, [permissions, history, t]) + useEffect(() => { + setButtons(getButtons()) return () => { - setButtonToolBar([]) + setButtons([]) } - }, [setButtonToolBar, t, history]) + }, [getButtons, setButtons]) + + const [searchRequest, setSearchRequest] = useState({ + text: '', + type: 'all', + }) - const filterOptions: Option[] = Object.values(InventoryFilter).map((filter) => ({ - label: t(`inventory.type.${filter}`), - value: `${filter}`, - })) + const onSearchRequestChange = (newSearchRequest: InventorySearchRequest) => { + setSearchRequest(newSearchRequest) + } return ( <> -
-
- value === searchFilter)} - onChange={(values) => setSearchFilter(values[0] as InventoryFilter)} - isEditable - /> -
-
-
- -
+ + ) } diff --git a/src/inventory/view/ViewInventoryTable.tsx b/src/inventory/view/ViewInventoryTable.tsx index 01de121c04..e4cb28ae6b 100644 --- a/src/inventory/view/ViewInventoryTable.tsx +++ b/src/inventory/view/ViewInventoryTable.tsx @@ -10,27 +10,6 @@ interface Props { searchRequest: InventorySearchRequest } -export function populateExportData(dataToPopulate: any, theData: any) { - let first = true - if (theData != null) { - theData.forEach((elm: any) => { - const entry = { - name: elm.name, - type: elm.type, - reorderPoint: elm.reorderPoint, - distributionUnit: elm.distributionUnit, - pricePerUnit: elm.pricePerUnit, - } - if (first) { - dataToPopulate[0] = entry - first = false - } else { - dataToPopulate.push(entry) - } - }) - } -} - function ViewInventoryTable(props: Props) { const { searchRequest } = props const { t } = useTranslator() @@ -41,6 +20,14 @@ function ViewInventoryTable(props: Props) { return } + if (data.length === 0) { + return ( + <> +
No Items Found
+ + ) + } + return ( <>
{ {splittedPath[1].includes('inventory') && expandedItem === 'inventory' && ( {/* Need to figure out how to give permissions */} - {permissions.includes(Permissions.ViewImagings) && ( + {permissions.includes(Permissions.ViewInventory) && ( { active={splittedPath[1].includes('inventory') && splittedPath.length > 2} > - {!sidebarCollapsed && t('inventory.actions.add')} + {!sidebarCollapsed && t('inventory.items.new')} )} - {permissions.includes(Permissions.ViewImagings) && ( + {permissions.includes(Permissions.ViewInventory) && ( { 'incidents.reports.new', 'imagings.requests.new', 'settings.label', - 'inventory.actions.add', + 'inventory.items.new', ] function getDropdownListOfPages(pages: Page[]) { diff --git a/src/shared/components/navbar/pageMap.tsx b/src/shared/components/navbar/pageMap.tsx index dc8dbd12b2..cb4c02b859 100644 --- a/src/shared/components/navbar/pageMap.tsx +++ b/src/shared/components/navbar/pageMap.tsx @@ -90,15 +90,13 @@ const pageMap: { icon: 'incident', }, newItem: { - // Need to add permissions - permission: Permissions.ViewIncidents, - label: 'inventory.actions.add', + permission: Permissions.AddItem, + label: 'inventory.items.new', path: '/inventory/new', icon: 'add', }, ViewInventory: { - // Need to add permissions - permission: Permissions.ViewIncidents, + permission: Permissions.ViewInventory, label: 'inventory.items.label', path: '/inventory', icon: 'lab', diff --git a/src/shared/db/InventoryRepository.ts b/src/shared/db/InventoryRepository.ts index 7ffc96a01a..853fd646c7 100644 --- a/src/shared/db/InventoryRepository.ts +++ b/src/shared/db/InventoryRepository.ts @@ -1,10 +1,11 @@ -import InventoryFilter from '../../inventory/InventoryFilter' +import { ItemType } from '../../inventory/model/ItemType' import { relationalDb } from '../config/pouchdb' import InventoryItem from '../model/InventoryItem' import Repository from './Repository' interface SearchOptions { - type: InventoryFilter + text: string + type: ItemType } class InventoryRepository extends Repository { constructor() { @@ -12,17 +13,23 @@ class InventoryRepository extends Repository { } async search(options: SearchOptions): Promise { - return super.search(InventoryRepository.getSearchCriteria(options)) - } - - private static getSearchCriteria(options: SearchOptions): any { - const typeFilter = options.type !== InventoryFilter.all ? [{ 'data.type': options.type }] : [] + const searchValue = { $regex: RegExp(options.text, 'i') } + const typeFilter = options.type !== 'all' ? [{ 'data.type': options.type }] : [] const selector = { - $and: typeFilter, + $and: [ + { + 'data.name': searchValue, + }, + typeFilter, + ], } - return { + return super.search({ selector, - } + }) + } + + async save(entity: InventoryItem): Promise { + return super.save(entity) } } diff --git a/src/shared/locales/enUs/translations/inventory/index.ts b/src/shared/locales/enUs/translations/inventory/index.ts index 3f007dccb0..10ef35a9ff 100644 --- a/src/shared/locales/enUs/translations/inventory/index.ts +++ b/src/shared/locales/enUs/translations/inventory/index.ts @@ -3,7 +3,8 @@ export default { filterTitle: ' Filter by type', label: 'Inventory', actions: { - add: 'Add Inventory Item', + add: 'Add Item', + search: 'Search Inventory', }, type: { clothing: 'Clothing', @@ -13,7 +14,7 @@ export default { }, items: { label: 'Inventory Items', - new: 'Add Item', + new: 'Add Inventory Item', view: 'View Item', name: 'Name', rank: 'Rank', diff --git a/src/user/user-slice.ts b/src/user/user-slice.ts index 6d72c8f806..48d63e9e2c 100644 --- a/src/user/user-slice.ts +++ b/src/user/user-slice.ts @@ -56,6 +56,9 @@ const initialState: UserState = { Permissions.ReadVisits, Permissions.ViewImagings, Permissions.RequestImaging, + Permissions.ViewInventory, + Permissions.AddItem, + Permissions.ViewItem, ], } From c5a6c8597f1abac18f6b65a947d113543cc86f91 Mon Sep 17 00:00:00 2001 From: Gabriela Cortes Date: Tue, 29 Dec 2020 23:07:50 -0600 Subject: [PATCH 5/9] feat: created feature to add new inventory items and revised type filter to view all inventory items --- src/inventory/Inventory.tsx | 8 +- src/inventory/add/AddInventoryItem.tsx | 213 ++++++++++++++++++ src/inventory/add/AddItem.tsx | 0 src/inventory/add/validate-inventory-item.ts | 64 ++++++ src/inventory/hooks/useAddInventoryItem.tsx | 31 +++ .../{view => model}/InventoryFilter.ts | 0 src/inventory/view/InventorySearch.tsx | 2 +- src/inventory/view/ViewInventoryTable.tsx | 10 +- src/shared/db/InventoryRepository.ts | 18 +- .../enUs/translations/inventory/index.ts | 75 +++--- 10 files changed, 365 insertions(+), 56 deletions(-) create mode 100644 src/inventory/add/AddInventoryItem.tsx delete mode 100644 src/inventory/add/AddItem.tsx create mode 100644 src/inventory/add/validate-inventory-item.ts create mode 100644 src/inventory/hooks/useAddInventoryItem.tsx rename src/inventory/{view => model}/InventoryFilter.ts (100%) diff --git a/src/inventory/Inventory.tsx b/src/inventory/Inventory.tsx index f8a399b028..f7c17718b3 100644 --- a/src/inventory/Inventory.tsx +++ b/src/inventory/Inventory.tsx @@ -6,7 +6,7 @@ import useAddBreadcrumbs from '../page-header/breadcrumbs/useAddBreadcrumbs' import PrivateRoute from '../shared/components/PrivateRoute' import Permissions from '../shared/model/Permissions' import { RootState } from '../shared/store' -import AddItem from './add/AddItem' +import AddInventoryItem from './add/AddInventoryItem' import ViewInventory from './view/ViewInventory' import ViewItem from './view/ViewItem' @@ -29,10 +29,10 @@ const Inventory = () => { component={ViewInventory} /> { + const [mutate] = useAddInventoryItem() + const { t } = useTranslator() + const history = useHistory() + const updateTitle = useUpdateTitle() + useEffect(() => { + updateTitle(t('inventory.items.new')) + }) + const [error, setError] = useState(undefined) + + const [addInventoryItem, setAddInventoryItem] = useState(({ + name: '', + rank: '', + type: '', + crossReference: '', + reorderPoint: ('' as unknown) as number, + distributionUnit: '', + pricePerUnit: ('' as unknown) as number, + note: '', + } as unknown) as InventoryItem) + + const typeOptions: Option[] = [ + { label: t('inventory.type.clothing'), value: 'clothing' }, + { label: t('inventory.type.equipment'), value: 'equipment' }, + { label: t('inventory.type.medication'), value: 'medication' }, + ] + + const distributionUnitOptions: Option[] = [ + { label: t('inventory.distributionUnit.ampoule'), value: 'ampoule' }, + { label: t('inventory.distributionUnit.bag'), value: 'bag' }, + { label: t('inventory.distributionUnit.bottle'), value: 'bottle' }, + { label: t('inventory.distributionUnit.box'), value: 'box' }, + { label: t('inventory.distributionUnit.bundle'), value: 'bundle' }, + { label: t('inventory.distributionUnit.capsule'), value: 'capsule' }, + { label: t('inventory.distributionUnit.case'), value: 'case' }, + { label: t('inventory.distributionUnit.container'), value: 'container' }, + { label: t('inventory.distributionUnit.cream'), value: 'cream' }, + { label: t('inventory.distributionUnit.each'), value: 'each' }, + { label: t('inventory.distributionUnit.gel'), value: 'gel' }, + { label: t('inventory.distributionUnit.nebule'), value: 'nebule' }, + { label: t('inventory.distributionUnit.ointment'), value: 'ointment' }, + { label: t('inventory.distributionUnit.pack'), value: 'pack' }, + { label: t('inventory.distributionUnit.pair'), value: 'pair' }, + { label: t('inventory.distributionUnit.pallet'), value: 'pallet' }, + { label: t('inventory.distributionUnit.patch'), value: 'patch' }, + { label: t('inventory.distributionUnit.pcs'), value: 'pcs' }, + { label: t('inventory.distributionUnit.pill'), value: 'pill' }, + { label: t('inventory.distributionUnit.plastic'), value: 'plastic' }, + { label: t('inventory.distributionUnit.polyamp'), value: 'polyamp' }, + { label: t('inventory.distributionUnit.rollset'), value: 'rollset' }, + { label: t('inventory.distributionUnit.spray'), value: 'spray' }, + { label: t('inventory.distributionUnit.suppository'), value: 'suppository' }, + { label: t('inventory.distributionUnit.suspension'), value: 'suspension' }, + { label: t('inventory.distributionUnit.syrup'), value: 'syrup' }, + { label: t('inventory.distributionUnit.tablet'), value: 'tablet' }, + { label: t('inventory.distributionUnit.tray'), value: 'tray' }, + { label: t('inventory.distributionUnit.tube'), value: 'tube' }, + { label: t('inventory.distributionUnit.vial'), value: 'vial' }, + ] + + const breadcrumbs = [ + { + i18nKey: 'inventory.items.new', + location: `/inventory/new`, + }, + ] + useAddBreadcrumbs(breadcrumbs) + + const onFieldChange = (key: string, value: string | boolean) => { + setAddInventoryItem((previousAddInventoryItem) => ({ + ...previousAddInventoryItem, + [key]: value, + })) + } + + const onTextInputChange = (text: string, key: string) => { + setAddInventoryItem((previousAddInventoryItem) => ({ + ...previousAddInventoryItem, + [key]: text, + })) + } + + const onSave = async () => { + try { + const newInventoryItem = await mutate(addInventoryItem as InventoryItem) + history.push(`/inventory/${newInventoryItem?.id}`) + } catch (e) { + setError(e) + } + } + + const onCancel = () => { + history.push('/inventory') + } + + return ( + <> +
+ onTextInputChange(event.currentTarget.value, 'name')} + /> + onTextInputChange(event.currentTarget.value, 'rank')} + /> +
+ value === addInventoryItem.type)} + onChange={(values) => onFieldChange && onFieldChange('type', values[0])} + isEditable + /> +
+ onTextInputChange(event.currentTarget.value, 'crossReference')} + /> + onTextInputChange(event.currentTarget.value, 'reorderPoint')} + isInvalid={!!error?.reorderPoint} + feedback={t(error?.reorderPoint as number)} + /> +
+ value === addInventoryItem.distributionUnit, + )} + onChange={(values) => onFieldChange && onFieldChange('distributionUnit', values[0])} + isEditable + /> +
+ onTextInputChange(event.currentTarget.value, 'pricePerUnit')} + isInvalid={!!error?.pricePerUnit} + feedback={t(error?.pricePerUnit as number)} + /> +
+ onTextInputChange(event.currentTarget.value, 'note')} + /> +
+
+
+ + +
+
+ + + ) +} + +export default AddInventoryItem diff --git a/src/inventory/add/AddItem.tsx b/src/inventory/add/AddItem.tsx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/inventory/add/validate-inventory-item.ts b/src/inventory/add/validate-inventory-item.ts new file mode 100644 index 0000000000..ed1994535b --- /dev/null +++ b/src/inventory/add/validate-inventory-item.ts @@ -0,0 +1,64 @@ +import InventoryItem from '../../shared/model/InventoryItem' + +export class InventoryItemError extends Error { + itemName?: string + + rank?: string + + crossReference?: string + + reorderPoint?: number + + pricePerUnit?: number + + constructor( + message: string, + itemName: string, + rank: string, + crossReference: string, + reorderPoint: number, + pricePerUnit: number, + ) { + super(message) + this.itemName = itemName + this.rank = rank + this.crossReference = crossReference + this.reorderPoint = reorderPoint + this.pricePerUnit = pricePerUnit + Object.setPrototypeOf(this, InventoryItemError.prototype) + } +} + +export default function validateItem(item: InventoryItem): InventoryItemError { + const newError: any = {} + + if (!item.name) { + newError.itemName = 'inventory.items.error.nameRequired' + } + + if (!item.rank) { + newError.rank = 'inventory.items.error.rankRequired' + } + + if (!item.crossReference) { + newError.crossReference = 'inventory.items.error.crossReferenceRequired' + } + + if (!item.reorderPoint) { + newError.reorderPoint = 'inventory.items.error.reorderPointRequired' + } + + if (Number.isNaN(Number(item.reorderPoint))) { + newError.reorderPoint = 'inventory.items.error.reorderPointNaN' + } + + if (!item.pricePerUnit) { + newError.pricePerUnit = 'inventory.items.error.pricePerUnitRequired' + } + + if (Number.isNaN(Number(item.pricePerUnit))) { + newError.pricePerUnit = 'inventory.items.error.pricePerUnitNaN' + } + + return newError as InventoryItemError +} diff --git a/src/inventory/hooks/useAddInventoryItem.tsx b/src/inventory/hooks/useAddInventoryItem.tsx new file mode 100644 index 0000000000..2e7939f601 --- /dev/null +++ b/src/inventory/hooks/useAddInventoryItem.tsx @@ -0,0 +1,31 @@ +import { isEmpty } from 'lodash' +import { queryCache, useMutation } from 'react-query' +import shortid from 'shortid' + +import InventoryRepository from '../../shared/db/InventoryRepository' +import InventoryItem from '../../shared/model/InventoryItem' +import validateItem from '../add/validate-inventory-item' + +const getItemID = (): string => `I-${shortid.generate()}` + +export function addInventoryItem(item: InventoryItem): Promise { + const error = validateItem(item) + if (isEmpty(error)) { + const updatedItem: InventoryItem = { + ...item, + id: getItemID(), + } + return InventoryRepository.save(updatedItem) + } + + throw error +} + +export default function useAddedItem() { + return useMutation(addInventoryItem, { + onSuccess: async () => { + await queryCache.invalidateQueries('items') + }, + throwOnError: true, + }) +} diff --git a/src/inventory/view/InventoryFilter.ts b/src/inventory/model/InventoryFilter.ts similarity index 100% rename from src/inventory/view/InventoryFilter.ts rename to src/inventory/model/InventoryFilter.ts diff --git a/src/inventory/view/InventorySearch.tsx b/src/inventory/view/InventorySearch.tsx index 39f12531fe..09c66072e7 100644 --- a/src/inventory/view/InventorySearch.tsx +++ b/src/inventory/view/InventorySearch.tsx @@ -5,9 +5,9 @@ import SelectWithLabelFormGroup, { } from '../../shared/components/input/SelectWithLabelFormGroup' import TextInputWithLabelFormGroup from '../../shared/components/input/TextInputWithLabelFormGroup' import useTranslator from '../../shared/hooks/useTranslator' +import InventoryFilter from '../model/InventoryFilter' import InventorySearchRequest from '../model/InventorySearchRequest' import { ItemType } from '../model/ItemType' -import InventoryFilter from './InventoryFilter' interface Props { searchRequest: InventorySearchRequest diff --git a/src/inventory/view/ViewInventoryTable.tsx b/src/inventory/view/ViewInventoryTable.tsx index e4cb28ae6b..9c3a3d8899 100644 --- a/src/inventory/view/ViewInventoryTable.tsx +++ b/src/inventory/view/ViewInventoryTable.tsx @@ -20,14 +20,6 @@ function ViewInventoryTable(props: Props) { return } - if (data.length === 0) { - return ( - <> -
No Items Found
- - ) - } - return ( <>
{ constructor() { super('inventory', relationalDb) } - async search(options: SearchOptions): Promise { - const searchValue = { $regex: RegExp(options.text, 'i') } - const typeFilter = options.type !== 'all' ? [{ 'data.type': options.type }] : [] + async search(container: SearchOptions): Promise { + const searchValue = { $regex: RegExp(container.text, 'i') } + const typeFilter = container.type !== 'all' ? [{ 'data.type': container.type }] : [undefined] const selector = { $and: [ { - 'data.name': searchValue, + $or: [ + { + 'data.name': searchValue, + }, + ], }, - typeFilter, - ], + ...typeFilter, + ].filter((x) => x !== undefined), } + return super.search({ selector, }) diff --git a/src/shared/locales/enUs/translations/inventory/index.ts b/src/shared/locales/enUs/translations/inventory/index.ts index 10ef35a9ff..f5ec75f49d 100644 --- a/src/shared/locales/enUs/translations/inventory/index.ts +++ b/src/shared/locales/enUs/translations/inventory/index.ts @@ -12,6 +12,38 @@ export default { medication: 'Medication', all: 'All Types', }, + distributionUnit: { + ampoule: 'Ampoule', + bag: 'Bag', + bottle: 'Bottle', + box: 'Box', + bundle: 'Bundle', + capsule: 'Capsule', + case: 'Case', + container: 'Container', + cream: 'Cream', + each: 'Each', + gel: 'Gel', + nebule: 'Nebule', + ointment: 'Ointment', + pack: 'Pack', + pair: 'Pair', + pallet: 'Pallet', + patch: 'Patch', + pcs: 'Pcs', + pill: 'Pill', + plastic: 'Plastic', + polyamp: 'Polyamp', + rollset: 'Rollset', + spray: 'Spray', + suppository: 'Suppository', + suspension: 'Suspension', + syrup: 'Syrup', + tablet: 'Tablet', + tray: 'Tray', + tube: 'Tube', + vial: 'Vial', + }, items: { label: 'Inventory Items', new: 'Add Inventory Item', @@ -21,48 +53,19 @@ export default { type: 'Type', crossReference: 'Cross Reference', reorderPoint: 'Reorder Point', - distributionUnitLabel: 'Distribution Unit', + distributionUnit: 'Distribution Unit', pricePerUnit: 'Price per Unit', note: 'Note', error: { nameRequired: 'Name is required.', rankRequired: 'Rank is required.', typeRequired: 'Type is required', - reorderPoint: 'Reorder point is required', - distributionUnitRequired: 'Distribution unit is required', - pricePerUnitRequired: 'Prive per unit is required', - }, - distributionUnit: { - ampoule: 'Ampoule', - bag: 'Bag', - bottle: 'Bottle', - box: 'Box', - bundle: 'Bundle', - capsule: 'Capsule', - case: 'Case', - container: 'Container', - cream: 'Cream', - each: 'Each', - gel: 'Gel', - nebule: 'Nebule', - ointment: 'Ointment', - pack: 'Pack', - pair: 'Pair', - pallet: 'Pallet', - patch: 'Patch', - pcs: 'Pcs', - pill: 'Pill', - plastic: 'Plastic', - polyamp: 'Polyamp', - rollset: 'Rollset', - spray: 'Spray', - suppository: 'Suppository', - suspension: 'Suspension', - syrup: 'Syrup', - tablet: 'Tablet', - tray: 'Tray', - tube: 'Tube', - vial: 'Vial', + crossReferenceRequired: 'Cross Reference is required', + reorderPointRequired: 'Reorder Point is required', + reorderPointNaN: 'Reorder Point must be a number', + distributionUnitRequired: 'Distribution Unit is required', + pricePerUnitRequired: 'Price per Unit is required', + pricePerUnitNaN: 'Price per Unit must be a number', }, }, }, From 59ea38bce6ab6627c0fc6062cdb10079b5719e38 Mon Sep 17 00:00:00 2001 From: Gabriela Cortes Date: Sun, 3 Jan 2021 17:00:15 -0600 Subject: [PATCH 6/9] feat: can now view edit and delete individual inventory items --- src/inventory/Inventory.tsx | 13 +- src/inventory/edit/EditItem.tsx | 92 +++++++++++ src/inventory/hooks/useDeleteItem.tsx | 21 +++ src/inventory/hooks/useItem.tsx | 12 ++ src/inventory/hooks/useUpdateItem.tsx | 33 ++++ src/inventory/view/ViewInventoryTable.tsx | 2 +- src/inventory/view/ViewItem.tsx | 111 +++++++++++++ src/inventory/view/ViewItemDetails.tsx | 155 ++++++++++++++++++ .../enUs/translations/inventory/index.ts | 5 + 9 files changed, 441 insertions(+), 3 deletions(-) create mode 100644 src/inventory/edit/EditItem.tsx create mode 100644 src/inventory/hooks/useDeleteItem.tsx create mode 100644 src/inventory/hooks/useItem.tsx create mode 100644 src/inventory/hooks/useUpdateItem.tsx create mode 100644 src/inventory/view/ViewItemDetails.tsx diff --git a/src/inventory/Inventory.tsx b/src/inventory/Inventory.tsx index f7c17718b3..fadee87b45 100644 --- a/src/inventory/Inventory.tsx +++ b/src/inventory/Inventory.tsx @@ -7,6 +7,7 @@ import PrivateRoute from '../shared/components/PrivateRoute' import Permissions from '../shared/model/Permissions' import { RootState } from '../shared/store' import AddInventoryItem from './add/AddInventoryItem' +import EditItem from './edit/EditItem' import ViewInventory from './view/ViewInventory' import ViewItem from './view/ViewItem' @@ -29,15 +30,23 @@ const Inventory = () => { component={ViewInventory} /> + diff --git a/src/inventory/edit/EditItem.tsx b/src/inventory/edit/EditItem.tsx new file mode 100644 index 0000000000..874ce319cb --- /dev/null +++ b/src/inventory/edit/EditItem.tsx @@ -0,0 +1,92 @@ +import { Spinner, Button, Toast } from '@hospitalrun/components' +import _ from 'lodash' +import React, { useEffect, useState } from 'react' +import { useHistory, useParams } from 'react-router-dom' + +import useAddBreadcrumbs from '../../page-header/breadcrumbs/useAddBreadcrumbs' +import { useUpdateTitle } from '../../page-header/title/TitleContext' +import useTranslator from '../../shared/hooks/useTranslator' +import InventoryItem from '../../shared/model/InventoryItem' +import useItem from '../hooks/useItem' +import useUpdateItem from '../hooks/useUpdateItem' +import ViewItemDetails from '../view/ViewItemDetails' + +const EditItem = () => { + const { t } = useTranslator() + const { id } = useParams() + + const updateTitle = useUpdateTitle() + updateTitle(t('inventory.items.edit')) + const history = useHistory() + + const [newItem, setItem] = useState({} as InventoryItem) + const { data: currentItem, isLoading: isLoadingItem } = useItem(id) + + const { + mutate: updateMutate, + isLoading: isLoadingUpdate, + isError: isErrorUpdate, + error: updateMutateError, + } = useUpdateItem(newItem) + + useAddBreadcrumbs([ + { + i18nKey: 'inventory.items.edit', + location: `/inventory/edit/${id}`, + }, + ]) + + useEffect(() => { + if (currentItem !== undefined) { + setItem(currentItem) + } + }, [currentItem]) + + const onCancel = () => { + history.push(`/inventory/${newItem.id}`) + } + + const onSave = () => { + if (_.isEmpty(updateMutateError) && !isErrorUpdate) { + updateMutate(newItem).then(() => { + Toast('success', t('states.success'), t('inventory.items.successfullyUpdated')) + history.push(`/inventory/${newItem.id}`) + }) + } + } + + const onFieldChange = (key: string, value: string | boolean) => { + setItem({ + ...newItem, + + [key]: value, + }) + } + + if (isLoadingItem || isLoadingUpdate) { + return + } + + return ( +
+ +
+
+ + +
+
+
+ ) +} + +export default EditItem diff --git a/src/inventory/hooks/useDeleteItem.tsx b/src/inventory/hooks/useDeleteItem.tsx new file mode 100644 index 0000000000..20b64aa820 --- /dev/null +++ b/src/inventory/hooks/useDeleteItem.tsx @@ -0,0 +1,21 @@ +import { queryCache, useMutation } from 'react-query' + +import InventoryRepository from '../../shared/db/InventoryRepository' +import InventoryItem from '../../shared/model/InventoryItem' + +interface deleteItemRequest { + itemId: string +} + +async function deleteItem(request: deleteItemRequest): Promise { + const item = await InventoryRepository.find(request.itemId) + return InventoryRepository.delete(item) +} + +export default function useDeleteItem() { + return useMutation(deleteItem, { + onSuccess: async () => { + await queryCache.invalidateQueries('item') + }, + }) +} diff --git a/src/inventory/hooks/useItem.tsx b/src/inventory/hooks/useItem.tsx new file mode 100644 index 0000000000..ddd8bc821d --- /dev/null +++ b/src/inventory/hooks/useItem.tsx @@ -0,0 +1,12 @@ +import { useQuery } from 'react-query' + +import InventoryRepository from '../../shared/db/InventoryRepository' +import InventoryItem from '../../shared/model/InventoryItem' + +function fetchInventoryItemById(_: any, itemId: string): Promise { + return InventoryRepository.find(itemId) +} + +export default function useItem(itemId: string) { + return useQuery(['item', itemId], fetchInventoryItemById) +} diff --git a/src/inventory/hooks/useUpdateItem.tsx b/src/inventory/hooks/useUpdateItem.tsx new file mode 100644 index 0000000000..2f05ed1f62 --- /dev/null +++ b/src/inventory/hooks/useUpdateItem.tsx @@ -0,0 +1,33 @@ +import { MutateFunction, queryCache, useMutation } from 'react-query' + +import InventoryRepository from '../../shared/db/InventoryRepository' +import InventoryItem from '../../shared/model/InventoryItem' +import validateItem, { InventoryItemError } from '../add/validate-inventory-item' + +interface updateItemResult { + mutate: MutateFunction + isLoading: boolean + isError: boolean + error: InventoryItemError +} + +async function updateItem(item: InventoryItem): Promise { + return InventoryRepository.saveOrUpdate(item) +} + +export default function useUpdateItem(item: InventoryItem): updateItemResult { + const updateItemError = validateItem(item) + const [mutate, { isLoading, isError }] = useMutation(updateItem, { + onSuccess: async () => { + await queryCache.invalidateQueries('item') + }, + throwOnError: true, + }) + const result: updateItemResult = { + mutate, + isLoading, + isError, + error: updateItemError, + } + return result +} diff --git a/src/inventory/view/ViewInventoryTable.tsx b/src/inventory/view/ViewInventoryTable.tsx index 9c3a3d8899..be14ffba2d 100644 --- a/src/inventory/view/ViewInventoryTable.tsx +++ b/src/inventory/view/ViewInventoryTable.tsx @@ -51,7 +51,7 @@ function ViewInventoryTable(props: Props) { actions={[ { label: t('actions.view'), - action: (row) => history.push(`incidents/${row.id}`), + action: (row) => history.push(`inventory/${row.id}`), }, ]} /> diff --git a/src/inventory/view/ViewItem.tsx b/src/inventory/view/ViewItem.tsx index e69de29bb2..00b2e2fa2b 100644 --- a/src/inventory/view/ViewItem.tsx +++ b/src/inventory/view/ViewItem.tsx @@ -0,0 +1,111 @@ +import { Spinner, Button, Modal, Toast } from '@hospitalrun/components' +import React, { useCallback, useEffect, useState } from 'react' +import { useSelector } from 'react-redux' +import { useHistory, useParams } from 'react-router-dom' + +import useAddBreadcrumbs from '../../page-header/breadcrumbs/useAddBreadcrumbs' +import { useButtonToolbarSetter } from '../../page-header/button-toolbar/ButtonBarProvider' +import { useUpdateTitle } from '../../page-header/title/TitleContext' +import useTranslator from '../../shared/hooks/useTranslator' +import Permissions from '../../shared/model/Permissions' +import { RootState } from '../../shared/store' +import useDeleteItem from '../hooks/useDeleteItem' +import useItem from '../hooks/useItem' +import ViewItemDetails from './ViewItemDetails' + +const ViewItem = () => { + const { t } = useTranslator() + const updateTitle = useUpdateTitle() + updateTitle(t('inventory.items.view')) + const { id } = useParams() + const history = useHistory() + const [deleteMutate] = useDeleteItem() + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false) + const setButtonToolBar = useButtonToolbarSetter() + const { permissions } = useSelector((state: RootState) => state.user) + + const { data: item } = useItem(id) + useAddBreadcrumbs([ + { + i18nKey: 'inventory.items.view', + location: `/inventory/${id}`, + }, + ]) + + const onDeleteRequest = (event: React.MouseEvent) => { + event.preventDefault() + setShowDeleteConfirmation(true) + } + + const onDeleteConfirmation = () => { + if (!item) { + return + } + + deleteMutate({ itemId: item.id }).then(() => { + history.push('/inventory') + Toast('success', t('states.success'), t('inventory.items.successfullyDeleted')) + }) + setShowDeleteConfirmation(false) + } + + const getButtons = useCallback(() => { + const buttons: React.ReactNode[] = [] + if (item && permissions.includes(Permissions.AddItem)) { + buttons.push( + <> + + + , + ) + } + + return buttons + }, [item, history, permissions, t]) + + useEffect(() => { + setButtonToolBar(getButtons()) + + return () => { + setButtonToolBar([]) + } + }, [getButtons, setButtonToolBar]) + + return ( + <> + {item ? ( +
+ + setShowDeleteConfirmation(false)} + /> +
+ ) : ( + + )} + + ) +} + +export default ViewItem diff --git a/src/inventory/view/ViewItemDetails.tsx b/src/inventory/view/ViewItemDetails.tsx new file mode 100644 index 0000000000..f4743c0c34 --- /dev/null +++ b/src/inventory/view/ViewItemDetails.tsx @@ -0,0 +1,155 @@ +import { Alert } from '@hospitalrun/components' +import React from 'react' + +import SelectWithLabelFormGroup, { + Option, +} from '../../shared/components/input/SelectWithLabelFormGroup' +import TextFieldWithLabelFormGroup from '../../shared/components/input/TextFieldWithLabelFormGroup' +import TextInputWithLabelFormGroup from '../../shared/components/input/TextInputWithLabelFormGroup' +import useTranslator from '../../shared/hooks/useTranslator' +import InventoryItem from '../../shared/model/InventoryItem' + +interface Props { + item: InventoryItem + isEditable: boolean + error?: any + onFieldChange?: (key: string, value: string | boolean) => void +} + +const ViewItemDetails = (props: Props) => { + const { onFieldChange, item, isEditable, error } = props + const { t } = useTranslator() + + const onInputElementChange = (event: React.ChangeEvent, fieldName: string) => + onFieldChange && onFieldChange(fieldName, event.target.value) + + const typeOptions: Option[] = [ + { label: t('inventory.type.clothing'), value: 'clothing' }, + { label: t('inventory.type.equipment'), value: 'equipment' }, + { label: t('inventory.type.medication'), value: 'medication' }, + ] + + const distributionUnitOptions: Option[] = [ + { label: t('inventory.distributionUnit.ampoule'), value: 'ampoule' }, + { label: t('inventory.distributionUnit.bag'), value: 'bag' }, + { label: t('inventory.distributionUnit.bottle'), value: 'bottle' }, + { label: t('inventory.distributionUnit.box'), value: 'box' }, + { label: t('inventory.distributionUnit.bundle'), value: 'bundle' }, + { label: t('inventory.distributionUnit.capsule'), value: 'capsule' }, + { label: t('inventory.distributionUnit.case'), value: 'case' }, + { label: t('inventory.distributionUnit.container'), value: 'container' }, + { label: t('inventory.distributionUnit.cream'), value: 'cream' }, + { label: t('inventory.distributionUnit.each'), value: 'each' }, + { label: t('inventory.distributionUnit.gel'), value: 'gel' }, + { label: t('inventory.distributionUnit.nebule'), value: 'nebule' }, + { label: t('inventory.distributionUnit.ointment'), value: 'ointment' }, + { label: t('inventory.distributionUnit.pack'), value: 'pack' }, + { label: t('inventory.distributionUnit.pair'), value: 'pair' }, + { label: t('inventory.distributionUnit.pallet'), value: 'pallet' }, + { label: t('inventory.distributionUnit.patch'), value: 'patch' }, + { label: t('inventory.distributionUnit.pcs'), value: 'pcs' }, + { label: t('inventory.distributionUnit.pill'), value: 'pill' }, + { label: t('inventory.distributionUnit.plastic'), value: 'plastic' }, + { label: t('inventory.distributionUnit.polyamp'), value: 'polyamp' }, + { label: t('inventory.distributionUnit.rollset'), value: 'rollset' }, + { label: t('inventory.distributionUnit.spray'), value: 'spray' }, + { label: t('inventory.distributionUnit.suppository'), value: 'suppository' }, + { label: t('inventory.distributionUnit.suspension'), value: 'suspension' }, + { label: t('inventory.distributionUnit.syrup'), value: 'syrup' }, + { label: t('inventory.distributionUnit.tablet'), value: 'tablet' }, + { label: t('inventory.distributionUnit.tray'), value: 'tray' }, + { label: t('inventory.distributionUnit.tube'), value: 'tube' }, + { label: t('inventory.distributionUnit.vial'), value: 'vial' }, + ] + + return ( + <> + {error?.message && } + onInputElementChange(event, 'name')} + /> + onInputElementChange(event, 'rank')} + /> +
+ value === item.type)} + onChange={(values) => onFieldChange && onFieldChange('type', values[0])} + isEditable={isEditable} + /> +
+ onInputElementChange(event, 'crossReference')} + /> + onInputElementChange(event, 'reorderPoint')} + isInvalid={!!error?.reorderPoint} + feedback={t(error?.reorderPoint as number)} + /> +
+ value === item.distributionUnit, + )} + onChange={(values) => onFieldChange && onFieldChange('distributionUnit', values[0])} + isEditable={isEditable} + /> +
+ onInputElementChange(event, 'pricePerUnit')} + isInvalid={!!error?.pricePerUnit} + feedback={t(error?.pricePerUnit as number)} + /> +
+ onFieldChange && onFieldChange('note', event.currentTarget.value)} + /> +
+ + ) +} + +export default ViewItemDetails diff --git a/src/shared/locales/enUs/translations/inventory/index.ts b/src/shared/locales/enUs/translations/inventory/index.ts index f5ec75f49d..5aa143147b 100644 --- a/src/shared/locales/enUs/translations/inventory/index.ts +++ b/src/shared/locales/enUs/translations/inventory/index.ts @@ -47,6 +47,11 @@ export default { items: { label: 'Inventory Items', new: 'Add Inventory Item', + delete: 'Delete Item', + successfullyDeleted: 'Successfully Deleted', + deleteConfirmationMessage: 'Are you sure you would like to delete this item?', + edit: 'Edit Item', + successfullyUpdated: 'Successfully Updated', view: 'View Item', name: 'Name', rank: 'Rank', From 785c4a9b4b0d1fb710c01574b3ca24a4cc039130 Mon Sep 17 00:00:00 2001 From: Gabriela Cortes Date: Wed, 6 Jan 2021 20:56:50 -0600 Subject: [PATCH 7/9] feat(inventory): addressed issues in code review --- src/inventory/add/AddInventoryItem.tsx | 20 +++---- src/inventory/add/validate-inventory-item.ts | 52 +++++++++++-------- src/inventory/view/ViewItemDetails.tsx | 20 +++---- src/shared/components/Sidebar.tsx | 3 +- src/shared/components/navbar/pageMap.tsx | 2 +- src/shared/config/pouchdb.ts | 1 - src/shared/db/InventoryRepository.ts | 6 +-- .../enUs/translations/inventory/index.ts | 1 + 8 files changed, 54 insertions(+), 51 deletions(-) diff --git a/src/inventory/add/AddInventoryItem.tsx b/src/inventory/add/AddInventoryItem.tsx index dab286bb91..dbeccf59ef 100644 --- a/src/inventory/add/AddInventoryItem.tsx +++ b/src/inventory/add/AddInventoryItem.tsx @@ -117,8 +117,8 @@ const AddInventoryItem = () => { label={t('inventory.items.name')} isRequired isEditable - isInvalid={!!error?.itemName} - feedback={t(error?.itemName as string)} + isInvalid={!!error?.itemNameError} + feedback={t(error?.itemNameError as string)} value={addInventoryItem.name} onChange={(event) => onTextInputChange(event.currentTarget.value, 'name')} /> @@ -127,8 +127,8 @@ const AddInventoryItem = () => { label={t('inventory.items.rank')} isRequired isEditable - isInvalid={!!error?.rank} - feedback={t(error?.rank as string)} + isInvalid={!!error?.rankError} + feedback={t(error?.rankError as string)} value={addInventoryItem.rank} onChange={(event) => onTextInputChange(event.currentTarget.value, 'rank')} /> @@ -148,8 +148,8 @@ const AddInventoryItem = () => { label={t('inventory.items.crossReference')} isRequired isEditable - isInvalid={!!error?.crossReference} - feedback={t(error?.crossReference as string)} + isInvalid={!!error?.crossReferenceError} + feedback={t(error?.crossReferenceError as string)} value={addInventoryItem.crossReference} onChange={(event) => onTextInputChange(event.currentTarget.value, 'crossReference')} /> @@ -160,8 +160,8 @@ const AddInventoryItem = () => { isEditable value={(addInventoryItem.reorderPoint as unknown) as string} onChange={(event) => onTextInputChange(event.currentTarget.value, 'reorderPoint')} - isInvalid={!!error?.reorderPoint} - feedback={t(error?.reorderPoint as number)} + isInvalid={!!error?.reorderPointError} + feedback={t(error?.reorderPointError as string)} />
{ isRequired value={(addInventoryItem.pricePerUnit as unknown) as string} onChange={(event) => onTextInputChange(event.currentTarget.value, 'pricePerUnit')} - isInvalid={!!error?.pricePerUnit} - feedback={t(error?.pricePerUnit as number)} + isInvalid={!!error?.pricePerUnitError} + feedback={t(error?.pricePerUnitError as string)} />
{ label={t('inventory.items.name')} isRequired isEditable={isEditable} - isInvalid={!!error?.itemName} - feedback={t(error?.itemName as string)} + isInvalid={!!error?.itemNameError} + feedback={t(error?.itemNameError as string)} value={item.name} onChange={(event) => onInputElementChange(event, 'name')} /> @@ -80,8 +80,8 @@ const ViewItemDetails = (props: Props) => { label={t('inventory.items.rank')} isRequired isEditable={isEditable} - isInvalid={!!error?.rank} - feedback={t(error?.rank as string)} + isInvalid={!!error?.rankError} + feedback={t(error?.rankError as string)} value={item.rank} onChange={(event) => onInputElementChange(event, 'rank')} /> @@ -101,8 +101,8 @@ const ViewItemDetails = (props: Props) => { label={t('inventory.items.crossReference')} isRequired isEditable={isEditable} - isInvalid={!!error?.crossReference} - feedback={t(error?.crossReference as string)} + isInvalid={!!error?.crossReferenceError} + feedback={t(error?.crossReferenceError as string)} value={item.crossReference} onChange={(event) => onInputElementChange(event, 'crossReference')} /> @@ -113,8 +113,8 @@ const ViewItemDetails = (props: Props) => { isEditable={isEditable} value={(item.reorderPoint as unknown) as string} onChange={(event) => onInputElementChange(event, 'reorderPoint')} - isInvalid={!!error?.reorderPoint} - feedback={t(error?.reorderPoint as number)} + isInvalid={!!error?.reorderPointError} + feedback={t(error?.reorderPointError as string)} />
{ isRequired value={(item.pricePerUnit as unknown) as string} onChange={(event) => onInputElementChange(event, 'pricePerUnit')} - isInvalid={!!error?.pricePerUnit} - feedback={t(error?.pricePerUnit as number)} + isInvalid={!!error?.pricePerUnitError} + feedback={t(error?.pricePerUnitError as string)} />
{ {splittedPath[1].includes('inventory') && expandedItem === 'inventory' && ( - {/* Need to figure out how to give permissions */} - {permissions.includes(Permissions.ViewInventory) && ( + {permissions.includes(Permissions.AddItem) && ( { const selector = { $and: [ { - $or: [ - { - 'data.name': searchValue, - }, - ], + 'data.name': searchValue, }, ...typeFilter, ].filter((x) => x !== undefined), diff --git a/src/shared/locales/enUs/translations/inventory/index.ts b/src/shared/locales/enUs/translations/inventory/index.ts index 5aa143147b..ebe7eb6fd7 100644 --- a/src/shared/locales/enUs/translations/inventory/index.ts +++ b/src/shared/locales/enUs/translations/inventory/index.ts @@ -71,6 +71,7 @@ export default { distributionUnitRequired: 'Distribution Unit is required', pricePerUnitRequired: 'Price per Unit is required', pricePerUnitNaN: 'Price per Unit must be a number', + negative: 'Input must be nonnegative', }, }, }, From 47254528dd6bab4b9f96ce07c8b6de978ef2687b Mon Sep 17 00:00:00 2001 From: Gabriela Cortes Date: Thu, 7 Jan 2021 17:35:56 -0600 Subject: [PATCH 8/9] feat(inventory): fixed existing test issues --- .../shared/components/Sidebar.test.tsx | 161 +++++++++++++++--- 1 file changed, 140 insertions(+), 21 deletions(-) diff --git a/src/__tests__/shared/components/Sidebar.test.tsx b/src/__tests__/shared/components/Sidebar.test.tsx index 7602971847..df3fda9eaa 100644 --- a/src/__tests__/shared/components/Sidebar.test.tsx +++ b/src/__tests__/shared/components/Sidebar.test.tsx @@ -42,6 +42,9 @@ describe('Sidebar', () => { Permissions.AddVisit, Permissions.RequestImaging, Permissions.ViewImagings, + Permissions.AddItem, + Permissions.ViewItem, + Permissions.ViewInventory, ] const store = mockStore({ components: { sidebarCollapsed: false }, @@ -461,27 +464,6 @@ describe('Sidebar', () => { expect(incidentsIndex).not.toBe(-1) }) - it('should be the last one in the sidebar', () => { - const wrapper = setup('/incidents') - - const listItems = wrapper.find(ListItem) - const reportsLabel = listItems.length - 2 - - expect(listItems.at(reportsLabel).text().trim()).toBe('incidents.reports.label') - expect( - listItems - .at(reportsLabel - 1) - .text() - .trim(), - ).toBe('incidents.reports.new') - expect( - listItems - .at(reportsLabel - 2) - .text() - .trim(), - ).toBe('incidents.label') - }) - it('should render the new incident report link', () => { const wrapper = setup('/incidents') @@ -849,4 +831,141 @@ describe('Sidebar', () => { expect(history.location.pathname).toEqual('/medications') }) }) + + describe('inventory links', () => { + it('should be the last one in the sidebar', () => { + const wrapper = setup('/inventory') + + const listItems = wrapper.find(ListItem) + const inventoryLabel = listItems.length - 1 + + expect(listItems.at(inventoryLabel).text().trim()).toBe('inventory.items.label') + expect( + listItems + .at(inventoryLabel - 1) + .text() + .trim(), + ).toBe('inventory.items.new') + expect( + listItems + .at(inventoryLabel - 2) + .text() + .trim(), + ).toBe('inventory.label') + }) + + it('should render the main inventory link', () => { + const wrapper = setup('/inventory') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.label') + + expect(inventoryIndex).not.toBe(-1) + }) + + it('should render the add inventory item link', () => { + const wrapper = setup('/inventory') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.items.new') + + expect(inventoryIndex).not.toBe(-1) + }) + + it('should not render the add inventory item link when user does not have add item privileges', () => { + const wrapper = setupNoPermissions('/inventory') + + const listItems = wrapper.find(ListItem) + const labsIndex = getIndex(listItems, 'inventory.items.new') + + expect(labsIndex).toBe(-1) + }) + + it('should render the inventory list link', () => { + const wrapper = setup('/inventory') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.items.label') + + expect(inventoryIndex).not.toBe(-1) + }) + + it('should not render the inventory list link when user does not have view inventory privileges', () => { + const wrapper = setupNoPermissions('/inventory') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.items.label') + + expect(inventoryIndex).toBe(-1) + }) + + it('main inventory link should be active when the current path is /inventory', () => { + const wrapper = setup('/inventory') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.label') + + expect(listItems.at(inventoryIndex).prop('active')).toBeTruthy() + }) + + it('should navigate to /inventory when the main lab link is clicked', () => { + const wrapper = setup('/') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.label') + + act(() => { + const onClick = listItems.at(inventoryIndex).prop('onClick') as any + onClick() + }) + + expect(history.location.pathname).toEqual('/inventory') + }) + + it('add inventory item link should be active when the current path is /inventory/new', () => { + const wrapper = setup('/inventory/new') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.items.new') + + expect(listItems.at(inventoryIndex).prop('active')).toBeTruthy() + }) + + it('should navigate to /inventory/new when the add inventory item link is clicked', () => { + const wrapper = setup('/inventory') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.items.new') + + act(() => { + const onClick = listItems.at(inventoryIndex).prop('onClick') as any + onClick() + }) + + expect(history.location.pathname).toEqual('/inventory/new') + }) + + it('inventory list link should be active when the current path is /inventory', () => { + const wrapper = setup('/inventory') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.items.label') + + expect(listItems.at(inventoryIndex).prop('active')).toBeTruthy() + }) + + it('should navigate to /inventory when the inventory list link is clicked', () => { + const wrapper = setup('/inventory/new') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.items.label') + + act(() => { + const onClick = listItems.at(inventoryIndex).prop('onClick') as any + onClick() + }) + + expect(history.location.pathname).toEqual('/inventory') + }) + }) }) From 6da4f63df6a0c678c76242489e2a268e3c08e154 Mon Sep 17 00:00:00 2001 From: Gabriela Cortes Date: Fri, 12 Feb 2021 00:09:08 -0600 Subject: [PATCH 9/9] feat(inventory): created test files --- .../inventory/AddInventoryItem.test.tsx | 9 ++ src/__tests__/inventory/EditItem.test.tsx | 11 ++ src/__tests__/inventory/Inventory.test.tsx | 108 ++++++++++++++++++ .../hooks/useAddInventoryItem.test.tsx | 57 +++++++++ .../inventory/hooks/useDeleteItem.test.tsx | 0 .../inventory/hooks/useInventory.test.tsx | 34 ++++++ .../inventory/hooks/useItem.test.tsx | 28 +++++ .../inventory/hooks/useUpdateItem.test.tsx | 0 .../inventory/view/InventorySearch.test.tsx | 5 + .../inventory/view/ViewInventory.test.tsx | 5 + .../view/ViewInventoryTable.test.tsx | 11 ++ .../inventory/view/ViewItem.test.tsx | 29 +++++ .../inventory/view/ViewItemDetails.test.tsx | 17 +++ .../shared/components/navbar/Navbar.test.tsx | 8 +- 14 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/inventory/AddInventoryItem.test.tsx create mode 100644 src/__tests__/inventory/EditItem.test.tsx create mode 100644 src/__tests__/inventory/Inventory.test.tsx create mode 100644 src/__tests__/inventory/hooks/useAddInventoryItem.test.tsx create mode 100644 src/__tests__/inventory/hooks/useDeleteItem.test.tsx create mode 100644 src/__tests__/inventory/hooks/useInventory.test.tsx create mode 100644 src/__tests__/inventory/hooks/useItem.test.tsx create mode 100644 src/__tests__/inventory/hooks/useUpdateItem.test.tsx create mode 100644 src/__tests__/inventory/view/InventorySearch.test.tsx create mode 100644 src/__tests__/inventory/view/ViewInventory.test.tsx create mode 100644 src/__tests__/inventory/view/ViewInventoryTable.test.tsx create mode 100644 src/__tests__/inventory/view/ViewItem.test.tsx create mode 100644 src/__tests__/inventory/view/ViewItemDetails.test.tsx diff --git a/src/__tests__/inventory/AddInventoryItem.test.tsx b/src/__tests__/inventory/AddInventoryItem.test.tsx new file mode 100644 index 0000000000..b7d6f60275 --- /dev/null +++ b/src/__tests__/inventory/AddInventoryItem.test.tsx @@ -0,0 +1,9 @@ +describe('AddInventoryItem', () => { + it('add item and cancel buttons render', () => {}) + + it('cancel returns you to inventory page', () => {}) + + it('save takes you to view item', () => {}) + + it('text fields are editable', () => {}) +}) diff --git a/src/__tests__/inventory/EditItem.test.tsx b/src/__tests__/inventory/EditItem.test.tsx new file mode 100644 index 0000000000..c63c30f6be --- /dev/null +++ b/src/__tests__/inventory/EditItem.test.tsx @@ -0,0 +1,11 @@ +describe('EditItem', () => { + it('text fields are editable', () => {}) + + it('save and cancel buttons render', () => {}) + + it('if data is loading the spinner should render', () => {}) + + it('check that useUpdateItem is called when save is clicked', () => {}) + + it('cancel should return to View Item', () => {}) +}) diff --git a/src/__tests__/inventory/Inventory.test.tsx b/src/__tests__/inventory/Inventory.test.tsx new file mode 100644 index 0000000000..b9c0ec03e6 --- /dev/null +++ b/src/__tests__/inventory/Inventory.test.tsx @@ -0,0 +1,108 @@ +// Code taken from Incidents.test.tsx + +import { mount, ReactWrapper } from 'enzyme' +import React from 'react' +import { act } from 'react-dom/test-utils' +import { Provider } from 'react-redux' +import { MemoryRouter } from 'react-router-dom' +import createMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import Incidents from '../../incidents/Incidents' +import ReportIncident from '../../incidents/report/ReportIncident' +import ViewIncident from '../../incidents/view/ViewIncident' +import VisualizeIncidents from '../../incidents/visualize/VisualizeIncidents' +import * as titleUtil from '../../page-header/title/TitleContext' +import IncidentRepository from '../../shared/db/IncidentRepository' +import Incident from '../../shared/model/Incident' +import Permissions from '../../shared/model/Permissions' +import { RootState } from '../../shared/store' + +const mockStore = createMockStore([thunk]) + +describe('Incidents', () => { + const setup = async (permissions: Permissions[], path: string) => { + const expectedIncident = { + id: '1234', + code: '1234', + date: new Date().toISOString(), + reportedOn: new Date().toISOString(), + } as Incident + jest.spyOn(titleUtil, 'useUpdateTitle').mockImplementation(() => jest.fn()) + jest.spyOn(IncidentRepository, 'search').mockResolvedValue([]) + jest.spyOn(IncidentRepository, 'find').mockResolvedValue(expectedIncident) + const store = mockStore({ + user: { permissions }, + breadcrumbs: { breadcrumbs: [] }, + components: { sidebarCollapsed: false }, + } as any) + + let wrapper: any + await act(async () => { + wrapper = await mount( + + + + + + + , + ) + }) + wrapper.find(Incidents).props().updateTitle = jest.fn() + wrapper.update() + + return { wrapper: wrapper as ReactWrapper } + } + + describe('title', () => { + it('should have called the useUpdateTitle hook', async () => { + await setup([Permissions.ViewIncidents], '/incidents') + expect(titleUtil.useUpdateTitle).toHaveBeenCalledTimes(1) + }) + }) + + describe('routing', () => { + describe('/incidents/new', () => { + it('should render the new incident screen when /incidents/new is accessed', async () => { + const { wrapper } = await setup([Permissions.ReportIncident], '/incidents/new') + + expect(wrapper.find(ReportIncident)).toHaveLength(1) + }) + + it('should not navigate to /incidents/new if the user does not have ReportIncident permissions', async () => { + const { wrapper } = await setup([], '/incidents/new') + + expect(wrapper.find(ReportIncident)).toHaveLength(0) + }) + }) + + describe('/incidents/visualize', () => { + it('should render the incident visualize screen when /incidents/visualize is accessed', async () => { + const { wrapper } = await setup([Permissions.ViewIncidentWidgets], '/incidents/visualize') + + expect(wrapper.find(VisualizeIncidents)).toHaveLength(1) + }) + + it('should not navigate to /incidents/visualize if the user does not have ViewIncidentWidgets permissions', async () => { + const { wrapper } = await setup([], '/incidents/visualize') + + expect(wrapper.find(VisualizeIncidents)).toHaveLength(0) + }) + }) + + describe('/incidents/:id', () => { + it('should render the view incident screen when /incidents/:id is accessed', async () => { + const { wrapper } = await setup([Permissions.ViewIncident], '/incidents/1234') + + expect(wrapper.find(ViewIncident)).toHaveLength(1) + }) + + it('should not navigate to /incidents/:id if the user does not have ViewIncident permissions', async () => { + const { wrapper } = await setup([], '/incidents/1234') + + expect(wrapper.find(ViewIncident)).toHaveLength(0) + }) + }) + }) +}) diff --git a/src/__tests__/inventory/hooks/useAddInventoryItem.test.tsx b/src/__tests__/inventory/hooks/useAddInventoryItem.test.tsx new file mode 100644 index 0000000000..49b2c0056d --- /dev/null +++ b/src/__tests__/inventory/hooks/useAddInventoryItem.test.tsx @@ -0,0 +1,57 @@ +/* eslint-disable no-console */ + +import shortid from 'shortid' + +import * as itemValidator from '../../../inventory/add/validate-inventory-item' +import { InventoryItemError } from '../../../inventory/add/validate-inventory-item' +import useAddInventoryItem from '../../../inventory/hooks/useAddInventoryItem' +import InventoryRepository from '../../../shared/db/InventoryRepository' +import InventoryItem from '../../../shared/model/InventoryItem' +import executeMutation from '../../test-utils/use-mutation.util' + +// This is code taken and slightly edited from Incidents. Not sure if you want +// to make use of it or start from scracth + +describe('useAddInventoryItem', () => { + // beforeEach(() => { + // jest.restoreAllMocks() + // console.error = jest.fn() + // }) + // it('should add an item with the correct data', async () => { + // const expectedId = '123456' + // const givenItemInformation = { + // name: 'some name', + // rank: 'some rank', + // type: 'clothing', + // crossReference: 'some cross reference', + // reorderPoint: 'some reorder point', + // distributionUnit: 'ampoule', + // pricePerUnit: 0, + // note: 'some note', + // } as InventoryItem + // const expectedItem = { + // ...givenItemInformation, + // id: `I-${expectedId}`, + // } as InventoryItem + // jest.spyOn(shortid, 'generate').mockReturnValue(expectedId) + // jest.spyOn(InventoryRepository, 'save').mockResolvedValue(expectedItem) + // const actualData = await executeMutation(() => useAddInventoryItem(), givenItemInformation) + // expect(InventoryRepository.save).toHaveBeenCalledTimes(1) + // expect(InventoryRepository.save).toBeCalledWith(expectedItem) + // expect(actualData).toEqual(expectedItem) + // }) + // it('should throw an error if validation fails', async () => { + // // review InventoryItemError + // const expectedInventoryError = { + // description: 'some description error', + // } as InventoryItemError + // jest.spyOn(itemValidator, 'default').mockReturnValue(expectedInventoryError) + // jest.spyOn(InventoryRepository, 'save').mockResolvedValue({} as InventoryItem) + // try { + // await executeMutation(() => useAddInventoryItem(), {}) + // } catch (e) { + // expect(e).toEqual(expectedInventoryError) + // expect(InventoryRepository.save).not.toHaveBeenCalled() + // } + // }) +}) diff --git a/src/__tests__/inventory/hooks/useDeleteItem.test.tsx b/src/__tests__/inventory/hooks/useDeleteItem.test.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/__tests__/inventory/hooks/useInventory.test.tsx b/src/__tests__/inventory/hooks/useInventory.test.tsx new file mode 100644 index 0000000000..5022a2929e --- /dev/null +++ b/src/__tests__/inventory/hooks/useInventory.test.tsx @@ -0,0 +1,34 @@ +import { act, renderHook } from '@testing-library/react-hooks' + +import useInventory from '../../../inventory/hooks/useInventory' +import InventorySearchRequest from '../../../inventory/model/InventorySearchRequest' +import InventoryRepository from '../../../shared/db/InventoryRepository' +import InventoryItem from '../../../shared/model/InventoryItem' +import waitUntilQueryIsSuccessful from '../../test-utils/wait-for-query.util' + +describe('useInventory', () => { + it('should search inventory', async () => { + const expectedSearchRequest: InventorySearchRequest = { + type: 'all', + text: '', + } + const expectedItems = [ + { + id: 'some id', + }, + ] as InventoryItem[] + jest.spyOn(InventoryRepository, 'search').mockResolvedValue(expectedItems) + + let actualData: any + await act(async () => { + const renderHookResult = renderHook(() => useInventory(expectedSearchRequest)) + const { result } = renderHookResult + await waitUntilQueryIsSuccessful(renderHookResult) + actualData = result.current.data + }) + + expect(InventoryRepository.search).toHaveBeenCalledTimes(1) + expect(InventoryRepository.search).toBeCalledWith(expectedSearchRequest) + expect(actualData).toEqual(expectedItems) + }) +}) diff --git a/src/__tests__/inventory/hooks/useItem.test.tsx b/src/__tests__/inventory/hooks/useItem.test.tsx new file mode 100644 index 0000000000..193ade28d6 --- /dev/null +++ b/src/__tests__/inventory/hooks/useItem.test.tsx @@ -0,0 +1,28 @@ +import { renderHook, act } from '@testing-library/react-hooks' + +import useItem from '../../../inventory/hooks/useItem' +import InventoryRepository from '../../../shared/db/InventoryRepository' +import InventoryItem from '../../../shared/model/InventoryItem' +import waitUntilQueryIsSuccessful from '../../test-utils/wait-for-query.util' + +describe('useItem', () => { + it('should get an item by id', async () => { + const expectedItemId = 'some id' + const expectedItem = { + id: expectedItemId, + } as InventoryItem + jest.spyOn(InventoryRepository, 'find').mockResolvedValue(expectedItem) + + let actualData: any + await act(async () => { + const renderHookResult = renderHook(() => useItem(expectedItemId)) + const { result } = renderHookResult + await waitUntilQueryIsSuccessful(renderHookResult) + actualData = result.current.data + }) + + expect(InventoryRepository.find).toHaveBeenCalledTimes(1) + expect(InventoryRepository.find).toBeCalledWith(expectedItemId) + expect(actualData).toEqual(expectedItem) + }) +}) diff --git a/src/__tests__/inventory/hooks/useUpdateItem.test.tsx b/src/__tests__/inventory/hooks/useUpdateItem.test.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/__tests__/inventory/view/InventorySearch.test.tsx b/src/__tests__/inventory/view/InventorySearch.test.tsx new file mode 100644 index 0000000000..3933655d5c --- /dev/null +++ b/src/__tests__/inventory/view/InventorySearch.test.tsx @@ -0,0 +1,5 @@ +describe('InventorySearch', () => { + it('typing in search bar calls onChange', () => {}) + + it('filtering calls onChange', () => {}) +}) diff --git a/src/__tests__/inventory/view/ViewInventory.test.tsx b/src/__tests__/inventory/view/ViewInventory.test.tsx new file mode 100644 index 0000000000..658d7c2922 --- /dev/null +++ b/src/__tests__/inventory/view/ViewInventory.test.tsx @@ -0,0 +1,5 @@ +describe('ViewInventory', () => { + it('test if props of table component match a given list', () => {}) + + it('add inventory button renders', () => {}) +}) diff --git a/src/__tests__/inventory/view/ViewInventoryTable.test.tsx b/src/__tests__/inventory/view/ViewInventoryTable.test.tsx new file mode 100644 index 0000000000..d8b229a9cd --- /dev/null +++ b/src/__tests__/inventory/view/ViewInventoryTable.test.tsx @@ -0,0 +1,11 @@ +describe('ViewInventoryTable', () => { + it('spinner renders when loading', () => {}) + + it('renders table', () => {}) + + it('clicking view takes you to item details', () => {}) + + it('if there are no items, alert should pop up', () => {}) + + it('data columns should match column headers', () => {}) +}) diff --git a/src/__tests__/inventory/view/ViewItem.test.tsx b/src/__tests__/inventory/view/ViewItem.test.tsx new file mode 100644 index 0000000000..8ef4495803 --- /dev/null +++ b/src/__tests__/inventory/view/ViewItem.test.tsx @@ -0,0 +1,29 @@ +import { mount, ReactWrapper } from 'enzyme' +import { createMemoryHistory } from 'history' +import React from 'react' +import { act } from 'react-dom/test-utils' +import { Provider } from 'react-redux' +import { Route, Router } from 'react-router-dom' +import createMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import ViewIncident from '../../../incidents/view/ViewIncident' +import ViewIncidentDetails from '../../../incidents/view/ViewIncidentDetails' +import * as breadcrumbUtil from '../../../page-header/breadcrumbs/useAddBreadcrumbs' +import * as ButtonBarProvider from '../../../page-header/button-toolbar/ButtonBarProvider' +import * as titleUtil from '../../../page-header/title/TitleContext' +import IncidentRepository from '../../../shared/db/IncidentRepository' +import Incident from '../../../shared/model/Incident' +import Permissions from '../../../shared/model/Permissions' +import { RootState } from '../../../shared/store' + +const { TitleProvider } = titleUtil +const mockStore = createMockStore([thunk]) + +describe('ViewItem', () => { + it('clicking edit takes you to EditItem', () => {}) + + it('text fields should not be editable', () => {}) + + it('check if delete button exists and leads you to a confirmation', () => {}) +}) diff --git a/src/__tests__/inventory/view/ViewItemDetails.test.tsx b/src/__tests__/inventory/view/ViewItemDetails.test.tsx new file mode 100644 index 0000000000..b29150cd4e --- /dev/null +++ b/src/__tests__/inventory/view/ViewItemDetails.test.tsx @@ -0,0 +1,17 @@ +import { Button } from '@hospitalrun/components' +import { mount, ReactWrapper } from 'enzyme' +import { createMemoryHistory } from 'history' +import React from 'react' +import { act } from 'react-dom/test-utils' +import { Router } from 'react-router' + +import ViewIncidentDetails from '../../../incidents/view/ViewIncidentDetails' +import * as breadcrumbUtil from '../../../page-header/breadcrumbs/useAddBreadcrumbs' +import * as ButtonBarProvider from '../../../page-header/button-toolbar/ButtonBarProvider' +import IncidentRepository from '../../../shared/db/IncidentRepository' +import Incident from '../../../shared/model/Incident' +import Permissions from '../../../shared/model/Permissions' + +describe('ViewItemDetails', () => { + it('renders correct data in each field', () => {}) +}) diff --git a/src/__tests__/shared/components/navbar/Navbar.test.tsx b/src/__tests__/shared/components/navbar/Navbar.test.tsx index f91d76b4fe..fa036168d5 100644 --- a/src/__tests__/shared/components/navbar/Navbar.test.tsx +++ b/src/__tests__/shared/components/navbar/Navbar.test.tsx @@ -65,6 +65,9 @@ describe('Navbar', () => { Permissions.ReadVisits, Permissions.RequestImaging, Permissions.ViewImagings, + Permissions.AddItem, + Permissions.ViewItem, + Permissions.ViewInventory, ] describe('hamberger', () => { @@ -87,7 +90,7 @@ describe('Navbar', () => { }) it('should not show an item if user does not have a permission', () => { - // exclude labs, incidents, and imagings permissions + // exclude labs, incidents, inventory, and imagings permissions const wrapper = setup(cloneDeep(allPermissions).slice(0, 6)) const hospitalRunNavbar = wrapper.find(HospitalRunNavbar) const hamberger = hospitalRunNavbar.find('.nav-hamberger') @@ -98,6 +101,8 @@ describe('Navbar', () => { 'labs.requests.label', 'incidents.reports.new', 'incidents.reports.label', + 'inventory.items.new', + 'inventory.items.label', 'medications.requests.new', 'medications.requests.label', 'imagings.requests.new', @@ -165,6 +170,7 @@ describe('Navbar', () => { expect(option.props.children).not.toEqual('labs.requests.new') expect(option.props.children).not.toEqual('incidents.requests.new') expect(option.props.children).not.toEqual('imagings.requests.new') + expect(option.props.children).not.toEqual('inventory.items.new') }) }) })