From 691a2db61ebf1f9138dbaed8ec4e5be5942373d4 Mon Sep 17 00:00:00 2001 From: Shelley Nason Date: Wed, 28 Feb 2024 10:24:30 -0600 Subject: [PATCH] Refactor and fix bugs in Recent Activity and Upcoming Closing Dates tables. --- packages/client/package.json | 1 + .../client/src/components/ActivityTable.vue | 151 ++++++++++++ .../src/components/ClosingDatesTable.vue | 102 ++++++++ .../client/src/components/GrantsTable.vue | 16 +- packages/client/src/helpers/constants.js | 6 + packages/client/src/helpers/dates.js | 10 + packages/client/src/store/modules/grants.js | 4 + packages/client/src/views/Dashboard.vue | 224 +----------------- packages/client/src/views/RecentActivity.vue | 151 ++---------- .../client/src/views/UpcomingClosingDates.vue | 181 ++------------ .../client/tests/unit/helpers/dates.spec.js | 42 ++++ .../client/tests/unit/views/Dashboard.spec.js | 4 - .../server/__tests__/db/seeds/fixtures.js | 9 + packages/server/src/db/index.js | 4 +- packages/server/src/routes/grants.js | 17 +- yarn.lock | 60 ++++- 16 files changed, 467 insertions(+), 515 deletions(-) create mode 100644 packages/client/src/components/ActivityTable.vue create mode 100644 packages/client/src/components/ClosingDatesTable.vue create mode 100644 packages/client/src/helpers/dates.js create mode 100644 packages/client/tests/unit/helpers/dates.spec.js diff --git a/packages/client/package.json b/packages/client/package.json index bb0f83135..b7afaba88 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -69,6 +69,7 @@ "prettier": "2.6.1", "sass": "1.52.1", "sass-loader": "13.0.0", + "sinon": "^17.0.1", "standard": "^16.0.3", "typescript": "^4.7.2", "vue-template-compiler": "^2.7.12", diff --git a/packages/client/src/components/ActivityTable.vue b/packages/client/src/components/ActivityTable.vue new file mode 100644 index 000000000..e005d93d0 --- /dev/null +++ b/packages/client/src/components/ActivityTable.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/packages/client/src/components/ClosingDatesTable.vue b/packages/client/src/components/ClosingDatesTable.vue new file mode 100644 index 000000000..d447d2625 --- /dev/null +++ b/packages/client/src/components/ClosingDatesTable.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/packages/client/src/components/GrantsTable.vue b/packages/client/src/components/GrantsTable.vue index 5a2889bde..ac1716142 100644 --- a/packages/client/src/components/GrantsTable.vue +++ b/packages/client/src/components/GrantsTable.vue @@ -101,7 +101,9 @@ import { mapActions, mapGetters } from 'vuex'; import { newTerminologyEnabled, newGrantsDetailPageEnabled } from '@/helpers/featureFlags'; import { datadogRum } from '@datadog/browser-rum'; -import { titleize } from '../helpers/form-helpers'; +import { titleize } from '@/helpers/form-helpers'; +import { daysUntil } from '@/helpers/dates'; +import { defaultCloseDateThresholds } from '@/helpers/constants'; import GrantDetailsLegacy from './Modals/GrantDetailsLegacy.vue'; import SearchPanel from './Modals/SearchPanel.vue'; import SavedSearchPanel from './Modals/SavedSearchPanel.vue'; @@ -278,10 +280,8 @@ export default { return this.grantsPagination ? this.grantsPagination.lastPage : 0; }, formattedGrants() { - const DAYS_TO_MILLISECS = 24 * 60 * 60 * 1000; - const warningThreshold = (this.agency.warning_threshold || 30) * DAYS_TO_MILLISECS; - const dangerThreshold = (this.agency.danger_threshold || 15) * DAYS_TO_MILLISECS; - const now = new Date(); + const warningThreshold = this.agency.warning_threshold || defaultCloseDateThresholds.warning; + const dangerThreshold = this.agency.danger_threshold || defaultCloseDateThresholds.danger; const generateTitle = (t) => { const txt = document.createElement('textarea'); txt.innerHTML = t; @@ -301,11 +301,11 @@ export default { open_date: new Date(grant.open_date).toLocaleDateString('en-US', { timeZone: 'UTC' }), close_date: new Date(grant.close_date).toLocaleDateString('en-US', { timeZone: 'UTC' }), _cellVariants: (() => { - const diff = new Date(grant.close_date) - now; - if (diff <= dangerThreshold) { + const daysUntilClose = daysUntil(grant.close_date); + if (daysUntilClose <= dangerThreshold) { return { close_date: 'danger' }; } - if (diff <= warningThreshold) { + if (daysUntilClose <= warningThreshold) { return { close_date: 'warning' }; } return {}; diff --git a/packages/client/src/helpers/constants.js b/packages/client/src/helpers/constants.js index bd5df19bf..269042acd 100644 --- a/packages/client/src/helpers/constants.js +++ b/packages/client/src/helpers/constants.js @@ -56,7 +56,13 @@ const avatarColors = { '#FCD663': '#000', }; +const defaultCloseDateThresholds = { + warning: 30, + danger: 15, +}; + module.exports = { billOptions, avatarColors, + defaultCloseDateThresholds, }; diff --git a/packages/client/src/helpers/dates.js b/packages/client/src/helpers/dates.js new file mode 100644 index 000000000..0ed34d6f1 --- /dev/null +++ b/packages/client/src/helpers/dates.js @@ -0,0 +1,10 @@ +import { DateTime } from 'luxon'; + +// Given a date (represented as a date-only ISO string), return the number of days until that date. +// Specifically, the number of whole days between now and the end of the given day in the local time-zone. +// Example: If today is 2021-01-01, then daysUntil('2021-01-02') returns 1. +// Returns zero if the date is in the past. +export function daysUntil(dateString) { + const daysDiff = DateTime.fromISO(dateString).endOf('day').diffNow('days').days; + return daysDiff < 0 ? 0 : Math.floor(daysDiff); +} diff --git a/packages/client/src/store/modules/grants.js b/packages/client/src/store/modules/grants.js index 795963cda..5c993c859 100644 --- a/packages/client/src/store/modules/grants.js +++ b/packages/client/src/store/modules/grants.js @@ -149,10 +149,14 @@ export default { return fetchApi.get(`/api/organizations/:organizationId/grants/next?${paginationQuery}&${orderingQuery}&${criteriaQuery}`) .then((data) => commit('SET_GRANTS', data)); }, + // Retrieves grants that the user's team (or any subteam) has interacted with (either by setting status or assigning to a user). + // Sorted in descending order by the date on which the interaction occurred (recently interacted with are first). fetchGrantsInterested({ commit }, { perPage, currentPage }) { return fetchApi.get(`/api/organizations/:organizationId/grants/grantsInterested/${perPage}/${currentPage}`) .then((data) => commit('SET_GRANTS_INTERESTED', data)); }, + // Retrieves grants that the user's team (or any subteam) is interested in and that have closing dates in the future. + // Sorted in ascending order by the closing date (grants that close soonest are first). fetchClosestGrants({ commit }, { perPage, currentPage }) { return fetchApi.get(`/api/organizations/:organizationId/grants/closestGrants/${perPage}/${currentPage}`) .then((data) => commit('SET_CLOSEST_GRANTS', data)); diff --git a/packages/client/src/views/Dashboard.vue b/packages/client/src/views/Dashboard.vue index 5e2d5e3fd..d49f51f57 100644 --- a/packages/client/src/views/Dashboard.vue +++ b/packages/client/src/views/Dashboard.vue @@ -9,36 +9,9 @@

Recent Activity

- Your {{newTerminologyEnabled ? 'team' : 'agency'}} has no recent activity. + Your {{newTerminologyEnabled ? 'team' : 'agency'}} has no recent activity.
- - - - - +
@@ -52,23 +25,10 @@

Upcoming Closing Dates

- Your {{newTerminologyEnabled ? 'team' : 'agency'}} has no upcoming close dates. + Your {{newTerminologyEnabled ? 'team' : 'agency'}} has no upcoming close dates.
- - - +
@@ -89,11 +49,6 @@ diff --git a/packages/client/tests/unit/helpers/dates.spec.js b/packages/client/tests/unit/helpers/dates.spec.js new file mode 100644 index 000000000..d0da48f98 --- /dev/null +++ b/packages/client/tests/unit/helpers/dates.spec.js @@ -0,0 +1,42 @@ +import { expect } from 'chai'; +import { useFakeTimers } from 'sinon'; + +import { daysUntil } from '@/helpers/dates'; + +describe('dates', () => { + describe('daysUntil()', () => { + let clock; + beforeEach(() => { + // Set Date.now() to 2021-01-15T22:00:00 in local timezone + clock = useFakeTimers(new Date('2021-01-15T22:00:00')); + }); + afterEach(() => { + clock.restore(); + }); + + describe('when called with date in the past', () => { + it('returns zero', () => { + const result = daysUntil('2021-01-10'); + expect(result).to.equal(0); + }); + }); + describe('when called with today as date', () => { + it('returns zero', () => { + const result = daysUntil('2021-01-15'); + expect(result).to.equal(0); + }); + }); + describe('when called with tomorrow as date', () => { + it('returns one', () => { + const result = daysUntil('2021-01-16'); + expect(result).to.equal(1); + }); + }); + describe('when called with date in the future', () => { + it('returns the correct number of days', () => { + const result = daysUntil('2022-01-16'); + expect(result).to.equal(366); + }); + }); + }); +}); diff --git a/packages/client/tests/unit/views/Dashboard.spec.js b/packages/client/tests/unit/views/Dashboard.spec.js index a065fcdec..2a127d714 100644 --- a/packages/client/tests/unit/views/Dashboard.spec.js +++ b/packages/client/tests/unit/views/Dashboard.spec.js @@ -13,7 +13,6 @@ const stubs = ['b-row', 'b-col', 'b-table', 'b-button', 'b-card', 'b-link', 'b-c const noOpGetters = { 'dashboard/totalGrants': () => [], 'dashboard/totalGrantsMatchingAgencyCriteria': () => [], - 'dashboard/totalViewedGrants': () => [], 'grants/totalInterestedGrants': () => [], 'grants/totalUpcomingGrants': () => [], 'dashboard/grantsCreatedInTimeframe': () => [], @@ -23,13 +22,10 @@ const noOpGetters = { 'dashboard/totalInterestedGrantsByAgencies': () => [], 'users/selectedAgency': () => undefined, 'grants/closestGrants': () => [], - 'grants/grants': () => [], 'grants/grantsInterested': () => [], - 'users/agency': () => undefined, 'grants/currentGrant': () => undefined, }; const noOpActions = { - 'dashboard/fetchDashboard': () => {}, 'grants/fetchGrantsInterested': () => {}, 'grants/fetchClosestGrants': () => {}, }; diff --git a/packages/server/__tests__/db/seeds/fixtures.js b/packages/server/__tests__/db/seeds/fixtures.js index 86ed183ab..9672afb24 100644 --- a/packages/server/__tests__/db/seeds/fixtures.js +++ b/packages/server/__tests__/db/seeds/fixtures.js @@ -472,6 +472,15 @@ const grantsInterested = { updated_at: '2022-04-23 12:30:39.531-07', interested_code_id: interestedCodes.filter((code) => code.status_code === 'Result')[0].id, }, + entry6: { + // Both this agency and its parent agency are interested in same grant + agency_id: agencies.subAccountancy.id, + grant_id: grants.interestedGrant.grant_id, + user_id: users.adminUser.id, + created_at: '2022-01-06 11:30:38.89828-07', + updated_at: '2022-04-23 12:30:39.531-07', + interested_code_id: interestedCodes.filter((code) => code.status_code === 'Interested')[0].id, + }, }; const assignedAgencyGrants = { diff --git a/packages/server/src/db/index.js b/packages/server/src/db/index.js index 231ea0f71..321c19c42 100755 --- a/packages/server/src/db/index.js +++ b/packages/server/src/db/index.js @@ -1061,6 +1061,7 @@ async function getClosestGrants({ .whereIn('grants_interested.agency_id', agencies.map((a) => a.id)) .andWhere('close_date', '>=', timestamp) .andWhere('interested_codes.status_code', '!=', 'Rejected') + .groupBy('grants.title', 'grants.close_date', 'grants.grant_id') .orderBy('close_date', 'asc') .paginate({ currentPage, perPage, isLengthAware: true }); } @@ -1191,7 +1192,8 @@ async function getGrantsInterested({ agencyId, perPage, currentPage }) { .from('assigned_grants_agency') .innerJoin('agencies', 'agencies.id', 'assigned_grants_agency.agency_id') .innerJoin('grants', 'grants.grant_id', 'assigned_grants_agency.grant_id') - .whereIn('agencies.id', agencies.map((subAgency) => subAgency.id)); + .whereIn('agencies.id', agencies.map((subAgency) => subAgency.id)) + .andWhereNot('assigned_by', null); }) .orderBy('created_at', 'DESC') .paginate({ currentPage, perPage, isLengthAware: true }); diff --git a/packages/server/src/routes/grants.js b/packages/server/src/routes/grants.js index 49edc79d9..eda8321ee 100755 --- a/packages/server/src/routes/grants.js +++ b/packages/server/src/routes/grants.js @@ -1,6 +1,7 @@ const express = require('express'); // eslint-disable-next-line import/no-unresolved const { stringify: csvStringify } = require('csv-stringify/sync'); +const groupBy = require('lodash/groupBy'); const db = require('../db'); const email = require('../lib/email'); const { requireUser, isUserAuthorized } = require('../lib/access-helpers'); @@ -102,8 +103,20 @@ router.get('/:grantId/grantDetails', requireUser, async (req, res) => { router.get('/closestGrants/:perPage/:currentPage', requireUser, async (req, res) => { const { perPage, currentPage } = req.params; - const rows = await db.getClosestGrants({ agency: req.session.selectedAgency, perPage, currentPage }); - res.json(rows); + const { data, pagination } = await db.getClosestGrants({ agency: req.session.selectedAgency, perPage, currentPage }); + + // Get interested agencies for each grant + const grantIds = data.map((grant) => grant.grant_id); + const agencies = await db.getInterestedAgencies({ grantIds, tenantId: req.session.user.tenant_id }); + const agenciesByGrantId = groupBy(agencies, 'grant_id'); + const enhancedData = data.map((grant) => ( + { + ...grant, + interested_agencies: (agenciesByGrantId[grant.grant_id] || []).map((agency) => agency.agency_abbreviation || agency.agency_name), + } + )); + + res.json({ data: enhancedData, pagination }); }); // For API tests, reduce the limit to 100 -- this is so we can test the logic around the limit diff --git a/yarn.lock b/yarn.lock index ad0399d1b..7ca6df067 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2989,6 +2989,20 @@ dependencies: type-detect "4.0.8" +"@sinonjs/commons@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^11.2.2": + version "11.2.2" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz#50063cc3574f4a27bd8453180a04171c85cc9699" + integrity sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw== + dependencies: + "@sinonjs/commons" "^3.0.0" + "@sinonjs/fake-timers@^7.0.4": version "7.1.2" resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz#2524eae70c4910edccf99b2f4e6efc5894aff7b5" @@ -3012,7 +3026,16 @@ lodash.get "^4.4.2" type-detect "^4.0.8" -"@sinonjs/text-encoding@^0.7.1": +"@sinonjs/samsam@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-8.0.0.tgz#0d488c91efb3fa1442e26abea81759dfc8b5ac60" + integrity sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew== + dependencies: + "@sinonjs/commons" "^2.0.0" + lodash.get "^4.4.2" + type-detect "^4.0.8" + +"@sinonjs/text-encoding@^0.7.1", "@sinonjs/text-encoding@^0.7.2": version "0.7.2" resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918" integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ== @@ -7108,6 +7131,11 @@ diff@^5.0.0: resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw== +diff@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" + integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -10782,6 +10810,11 @@ just-extend@^4.0.2: resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744" integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg== +just-extend@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-6.2.0.tgz#b816abfb3d67ee860482e7401564672558163947" + integrity sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw== + keyv@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.0.0.tgz#44923ba39e68b12a7cec7df6c3268c031f2ef373" @@ -11898,6 +11931,17 @@ nise@^5.1.2: just-extend "^4.0.2" path-to-regexp "^1.7.0" +nise@^5.1.5: + version "5.1.9" + resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.9.tgz#0cb73b5e4499d738231a473cd89bd8afbb618139" + integrity sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww== + dependencies: + "@sinonjs/commons" "^3.0.0" + "@sinonjs/fake-timers" "^11.2.2" + "@sinonjs/text-encoding" "^0.7.2" + just-extend "^6.2.0" + path-to-regexp "^6.2.1" + no-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" @@ -12737,7 +12781,7 @@ path-to-regexp@^1.7.0: dependencies: isarray "0.0.1" -path-to-regexp@^6.2.0: +path-to-regexp@^6.2.0, path-to-regexp@^6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.1.tgz#d54934d6798eb9e5ef14e7af7962c945906918e5" integrity sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw== @@ -14465,6 +14509,18 @@ sinon@^14.0.0, sinon@^14.0.1: nise "^5.1.2" supports-color "^7.2.0" +sinon@^17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-17.0.1.tgz#26b8ef719261bf8df43f925924cccc96748e407a" + integrity sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g== + dependencies: + "@sinonjs/commons" "^3.0.0" + "@sinonjs/fake-timers" "^11.2.2" + "@sinonjs/samsam" "^8.0.0" + diff "^5.1.0" + nise "^5.1.5" + supports-color "^7.2.0" + sirv@^1.0.7: version "1.0.19" resolved "https://registry.yarnpkg.com/sirv/-/sirv-1.0.19.tgz#1d73979b38c7fe91fcba49c85280daa9c2363b49"