diff --git a/assets/scss/application.scss b/assets/scss/application.scss index d6792b64..a094872c 100755 --- a/assets/scss/application.scss +++ b/assets/scss/application.scss @@ -132,3 +132,23 @@ li.ui-timepicker-selected .ui-timepicker-duration, .ui-timepicker-list li.ui-timepicker-selected.ui-timepicker-disabled { background: #f2f2f2; } + +.toggle-menu { + list-style-type: none; + margin: 0; + padding: 0; +} +.toggle-menu__list-item { + display: inline-block; + &:not(:first-child):before { + content: '|'; + padding: 0 2px; + } +} +.govuk-tag--orange { + color: #ffffff; + background-color: #f47738; +} +.govuk-colour--red { + color: govuk-colour('red'); +} diff --git a/assets/scss/components/_summary-card.scss b/assets/scss/components/_summary-card.scss index ed645f84..e1a05fab 100644 --- a/assets/scss/components/_summary-card.scss +++ b/assets/scss/components/_summary-card.scss @@ -4,7 +4,7 @@ /* Card header */ .app-summary-card__header { align-items: center; - background-color: govuk-colour("light-grey"); + background-color: govuk-colour('light-grey'); padding: govuk-spacing(3); @include govuk-media-query($from: desktop) { @@ -88,31 +88,35 @@ float: right; } - .app-compliance-panel { padding: govuk-spacing(3); - color: govuk-colour("white"); - background: govuk-colour("dark-grey"); - - p, ul, li, h2, h3, a { + color: govuk-colour('white'); + background: govuk-colour('dark-grey'); + + p, + ul, + li, + h2, + h3, + a { color: inherit; } } .app-compliance-panel--red { - background: govuk-colour("red"); + background: govuk-colour('red'); } .app-compliance-panel--green { - background: govuk-colour("green"); + background: govuk-colour('green'); } .app-compliance-panel--blue { - background: govuk-colour("blue"); + background: govuk-colour('blue'); } .app-compliance-panel--orange { - background: govuk-colour("orange"); + background: govuk-colour('orange'); } .app-summary-card--compliance { @@ -120,26 +124,22 @@ } .app-tag--dark-red { - background: govuk-colour("red"); + background: govuk-colour('red'); } - .govuk-tag { display: inline-block; outline: 2px solid transparent; outline-offset: -2px; - letter-spacing: 1px; max-width: none; text-decoration: none; - text-transform: uppercase; - font-family: "GDS Transport", arial, sans-serif; + font-family: 'GDS Transport', arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - font-weight: 700; + font-weight: 400; font-size: 1rem; padding-top: 5px; padding-right: 8px; padding-bottom: 4px; padding-left: 8px; } - diff --git a/integration_tests/e2e/activityLog.cy.ts b/integration_tests/e2e/activityLog.cy.ts index 822710e2..20fc591b 100644 --- a/integration_tests/e2e/activityLog.cy.ts +++ b/integration_tests/e2e/activityLog.cy.ts @@ -1,7 +1,281 @@ import Page from '../pages/page' import ActivityLogPage from '../pages/activityLog' +import errorMessages from '../../server/properties/errorMessages' context('Activity log', () => { + const today = new Date() + const day = today.getDate() + const month = today.getMonth() + 1 + const year = today.getFullYear() + const date = `${day}/${month}/${year}` + + it('should render the filter menu', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + cy.get('.toggle-menu .toggle-menu__list-item:nth-of-type(1)').should('contain.text', 'Default view') + cy.get('.toggle-menu .toggle-menu__list-item:nth-of-type(2) a').should('contain.text', 'Compact view') + cy.get('[data-qa="filter-form"]').within(() => cy.get('h2').should('contain.text', 'Filter activity log')) + page.getApplyFiltersButton().should('contain.text', 'Apply filters') + cy.get('[data-qa="keywords"]').within(() => cy.get('label').should('contain.text', 'Keywords')) + page.getKeywordsInput().should('exist').should('have.value', '') + cy.get('[data-qa="date-from"]').within(() => cy.get('label').should('contain.text', 'Date from')) + cy.get('[data-qa="date-from"]').within(() => cy.get('input').should('exist').should('have.value', '')) + cy.get('[data-qa="date-to"]').within(() => cy.get('label').should('contain.text', 'Date to')) + cy.get('[data-qa="date-to"]').within(() => cy.get('input').should('exist').should('have.value', '')) + cy.get('[data-qa="compliance"]').within(() => + cy.get('legend').should('exist').should('contain.text', 'Compliance filters'), + ) + const filters = ['Without an outcome', 'Complied', 'Not complied'] + cy.get('[data-qa="compliance"] .govuk-checkboxes__item').each(($el, i) => { + cy.wrap($el).find('input').should('not.be.checked') + cy.wrap($el).find('label').should('contain.text', filters[i]) + }) + }) + it('should show the correct validation if date to is selected, but no date from is selected', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getDateToToggle().click() + page.getDateToDialog().should('be.visible').find(`button[data-testid="${date}"]`).click() + page.getDateToInput().should('have.value', date) + page.getDateToDialog().should('not.be.visible') + page.getApplyFiltersButton().click() + page.getSelectedFilterTag(1).should('not.exist') + page.getErrorSummaryBox().should('be.visible') + page.getAllErrorSummaryLinks().should('have.length', 1) + page.getErrorSummaryLink(1).should('contain.text', errorMessages['activity-log']['date-from'].errors.isEmpty) + page.getErrorSummaryLink(1).click() + page.getDateFromInput().should('be.focused') + }) + it('should show the correct validation if date from is selected, but no date to is selected', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getDateFromToggle().click() + page.getDateFromDialog().should('be.visible').find(`button[data-testid="${date}"]`).click() + page.getDateFromInput().should('have.value', date) + page.getDateFromDialog().should('not.be.visible') + page.getApplyFiltersButton().click() + page.getSelectedFilterTag(1).should('not.exist') + page.getErrorSummaryBox().should('be.visible') + page.getAllErrorSummaryLinks().should('have.length', 1) + page.getErrorSummaryLink(1).should('contain.text', errorMessages['activity-log']['date-to'].errors.isEmpty) + page.getErrorSummaryLink(1).click() + page.getDateToInput().should('be.focused') + }) + it('should show the correct validation if an invalid date from is entered', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getDateFromInput().type('01/04/2025') + page.getApplyFiltersButton().click() + page.getSelectedFilterTag(1).should('not.exist') + page.getErrorSummaryBox().should('be.visible') + page.getAllErrorSummaryLinks().should('have.length', 1) + page.getErrorSummaryLink(1).should('contain.text', errorMessages['activity-log']['date-from'].errors.isInvalid) + }) + it('should show the correct validation if an invalid date to is entered', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getDateToInput().type('01/04/') + page.getApplyFiltersButton().click() + page.getSelectedFilterTag(1).should('not.exist') + page.getErrorSummaryBox().should('be.visible') + page.getAllErrorSummaryLinks().should('have.length', 1) + page.getErrorSummaryLink(1).should('contain.text', errorMessages['activity-log']['date-to'].errors.isInvalid) + }) + it('should show the correct validation if date from doesnt exist', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getDateFromInput().type('30/2/2025') + page.getApplyFiltersButton().click() + page.getSelectedFilterTag(1).should('not.exist') + page.getErrorSummaryBox().should('be.visible') + page.getAllErrorSummaryLinks().should('have.length', 1) + page.getErrorSummaryLink(1).should('contain.text', errorMessages['activity-log']['date-from'].errors.isNotReal) + }) + it('should show the correct validation if date to doesnt exist', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getDateToInput().type('30/2/2025') + page.getApplyFiltersButton().click() + page.getSelectedFilterTag(1).should('not.exist') + page.getErrorSummaryBox().should('be.visible') + page.getAllErrorSummaryLinks().should('have.length', 1) + page.getErrorSummaryLink(1).should('contain.text', errorMessages['activity-log']['date-to'].errors.isNotReal) + }) + it('should show the correct validation if date from is after date to', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getDateFromInput().type('12/1/2025') + page.getDateToInput().type('11/1/2025') + page.getApplyFiltersButton().click() + page.getSelectedFilterTag(1).should('not.exist') + page.getErrorSummaryBox().should('be.visible') + page.getAllErrorSummaryLinks().should('have.length', 1) + page.getErrorSummaryLink(1).should('contain.text', errorMessages['activity-log']['date-from'].errors.isAfterTo) + }) + + it('should show the correct validation if date from is in the future', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getDateFromInput().type(`${day}/${month}/${year + 1}`) + page.getApplyFiltersButton().click() + page.getErrorSummaryBox().should('be.visible') + page.getAllErrorSummaryLinks().should('have.length', 1) + page.getErrorSummaryLink(1).should('contain.text', errorMessages['activity-log']['date-from'].errors.isInFuture) + }) + it('should show the correct validation if date to is in the future', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getDateToInput().type(`${day}/${month}/${year + 1}`) + page.getApplyFiltersButton().click() + page.getErrorSummaryBox().should('be.visible') + page.getAllErrorSummaryLinks().should('have.length', 1) + page.getErrorSummaryLink(1).should('contain.text', errorMessages['activity-log']['date-to'].errors.isInFuture) + }) + it('should display the filter tag and filter the list if a keyword value is submitted', () => { + cy.visit('/case/X000001/activity-log') + const value = 'Phone call' + const page = Page.verifyOnPage(ActivityLogPage) + page.getKeywordsInput().type(value) + page.getApplyFiltersButton().click() + page.getSelectedFilterTags().should('have.length', 1) + page.getSelectedFilterTag(1).should('contain.text', value) + page.getCardHeader('timeline1').should('contain.text', 'Phone call from Eula Schmeler') + page.getKeywordsInput().should('have.value', value) + }) + it('should remove the tag, clear the keyword field and reset the list if the keyword tag is clicked', () => { + cy.visit('/case/X000001/activity-log') + const value = 'Phone call' + const page = Page.verifyOnPage(ActivityLogPage) + page.getKeywordsInput().type(value) + page.getApplyFiltersButton().click() + page.getSelectedFilterTag(1).click() + page.getSelectedFilterTag(1).should('not.exist') + page.getKeywordsInput().should('have.value', '') + page.getCardHeader('timeline1').should('contain.text', 'Video call') + }) + it('should display the filter tag and filter the list if a date from and date to are submitted', () => { + cy.visit('/case/X000001/activity-log') + const fromDate = '20/1/2025' + const toDate = '27/1/2025' + const page = Page.verifyOnPage(ActivityLogPage) + page.getDateFromInput().type(fromDate) + page.getDateToInput().type(toDate) + page.getApplyFiltersButton().click() + page.getDateFromInput().should('have.value', fromDate) + page.getDateToInput().should('have.value', toDate) + page.getSelectedFilterTags().should('have.length', 1) + page.getSelectedFilterTag(1).should('contain.text', `${fromDate} - ${toDate}`) + page.getCardHeader('timeline1').should('contain.text', 'Phone call from Eula Schmeler') + cy.get('[data-qa="results-count-start"]').should('contain.text', '1') + cy.get('[data-qa="results-count-end"]').should('contain.text', '1') + cy.get('[data-qa="results-count-total"]').should('contain.text', '1') + }) + it('should remove the tag, clear both date fields and reset the list if the date range tag is clicked', () => { + cy.visit('/case/X000001/activity-log') + const fromDate = '20/1/2025' + const toDate = '27/1/2025' + const page = Page.verifyOnPage(ActivityLogPage) + page.getDateFromInput().type(fromDate) + page.getDateToInput().type(toDate) + page.getApplyFiltersButton().click() + page.getSelectedFilterTag(1).click() + page.getSelectedFilterTag(1).should('not.exist') + page.getDateFromInput().should('have.value', '') + page.getDateToInput().should('have.value', '') + page.getCardHeader('timeline1').should('contain.text', 'Video call') + cy.get('[data-qa="results-count-start"]').should('contain.text', '1') + cy.get('[data-qa="results-count-end"]').should('contain.text', '10') + cy.get('[data-qa="results-count-total"]').should('contain.text', '54') + }) + it('should display the 3 filter tags and filter the list if all compliance filters as clicked and submitted', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getComplianceFilter(1).click() + page.getComplianceFilter(2).click() + page.getComplianceFilter(3).click() + page.getApplyFiltersButton().click() + page.getComplianceFilter(1).should('be.checked') + page.getComplianceFilter(2).should('be.checked') + page.getComplianceFilter(3).should('be.checked') + page.getSelectedFilterTags().should('have.length', 3) + page.getSelectedFilterTag(1).should('contain.text', 'Without an outcome') + page.getSelectedFilterTag(2).should('contain.text', 'Complied') + page.getSelectedFilterTag(3).should('contain.text', 'Not complied') + page.getCardHeader('timeline1').should('contain.text', 'AP PA - Attitudes, thinking & behaviours at 9:15am') + page.getCardHeader('timeline2').should('contain.text', 'Pre-Intervention Session 1 at 9:15am') + page.getCardHeader('timeline3').should('contain.text', 'Initial Appointment - Home Visit (NS) at 9:15am') + cy.get('[data-qa="results-count-start"]').should('contain.text', '1') + cy.get('[data-qa="results-count-end"]').should('contain.text', '3') + cy.get('[data-qa="results-count-total"]').should('contain.text', '3') + }) + it(`should display 2 filter tags, uncheck the filter option and filter the list when 'Not complied' tag is clicked`, () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getComplianceFilter(1).click() + page.getComplianceFilter(2).click() + page.getComplianceFilter(3).click() + page.getApplyFiltersButton().click() + page.getSelectedFilterTag(3).click() + page.getSelectedFilterTags().should('have.length', 2) + page.getSelectedFilterTag(1).should('contain.text', 'Without an outcome') + page.getSelectedFilterTag(2).should('contain.text', 'Complied') + page.getSelectedFilterTag(3).should('not.exist') + page.getComplianceFilter(1).should('be.checked') + page.getComplianceFilter(2).should('be.checked') + page.getComplianceFilter(3).should('not.be.checked') + page.getCardHeader('timeline1').should('contain.text', 'AP PA - Attitudes, thinking & behaviours at 9:15am') + page.getCardHeader('timeline2').should('contain.text', 'Pre-Intervention Session 1 at 9:15am') + cy.get('[data-qa="results-count-start"]').should('contain.text', '1') + cy.get('[data-qa="results-count-end"]').should('contain.text', '2') + cy.get('[data-qa="results-count-total"]').should('contain.text', '2') + }) + it(`should display 1 filter tag, uncheck the deselected filter options and filter the list when 'complied' and 'Not complied' tags are clicked`, () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getComplianceFilter(1).click() + page.getComplianceFilter(2).click() + page.getComplianceFilter(3).click() + page.getApplyFiltersButton().click() + page.getSelectedFilterTag(3).click() + page.getSelectedFilterTag(2).click() + page.getSelectedFilterTags().should('have.length', 1) + page.getSelectedFilterTag(1).should('contain.text', 'Without an outcome') + page.getSelectedFilterTag(2).should('not.exist') + page.getSelectedFilterTag(3).should('not.exist') + page.getComplianceFilter(1).should('be.checked') + page.getComplianceFilter(2).should('not.be.checked') + page.getComplianceFilter(3).should('not.be.checked') + }) + it(`should clear all selected filters when 'Clear filters' link is clicked`, () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getKeywordsInput().type('Phone call') + page.getDateFromInput().type('20/1/2025') + page.getDateToInput().type('27/1/2025') + page.getComplianceFilter(1).click() + page.getComplianceFilter(2).click() + page.getComplianceFilter(3).click() + page.getApplyFiltersButton().click() + page.getSelectedFilterTags().should('have.length', 5) + page.getCardHeader('timeline1').should('contain.text', 'Phone call from Eula Schmeler') + cy.get('[data-qa="results-count-start"]').should('contain.text', '1') + cy.get('[data-qa="results-count-end"]').should('contain.text', '1') + cy.get('[data-qa="results-count-total"]').should('contain.text', '1') + cy.get('.govuk-pagination').should('not.exist') + cy.get('.moj-filter__heading-action a').click() + page.getSelectedFilterTags().should('not.exist') + page.getKeywordsInput().should('have.value', '') + page.getDateFromInput().should('have.value', '') + page.getDateToInput().should('have.value', '') + page.getComplianceFilter(1).should('not.be.checked') + page.getComplianceFilter(2).should('not.be.checked') + page.getComplianceFilter(3).should('not.be.checked') + page.getCardHeader('timeline1').should('contain.text', 'Video call') + cy.get('[data-qa="results-count-start"]').should('contain.text', '1') + cy.get('[data-qa="results-count-end"]').should('contain.text', '10') + cy.get('[data-qa="results-count-total"]').should('contain.text', '54') + cy.get('.govuk-pagination').should('exist') + }) it('Activity log page is rendered in default view', () => { cy.visit('/case/X000001/activity-log') const page = Page.verifyOnPage(ActivityLogPage) @@ -12,11 +286,129 @@ context('Activity log', () => { page.getCardHeader('timeline5').should('contain.text', 'Office appointment at 10:15am') page.getCardHeader('timeline6').should('contain.text', 'Phone call at 8:15am') page.getCardHeader('timeline7').should('contain.text', 'Office appointment at 10:15am') - page.getCardHeader('timeline8').should('contain.text', 'Video call at 10:15am') + page.getCardHeader('timeline8').should('contain.text', 'Initial appointment at 10:15am') + page.getCardHeader('timeline9').should('contain.text', 'Initial appointment at 10:15am') + page.getCardHeader('timeline10').should('contain.text', 'Video call at 10:15am') + }) + it('should display the pagination navigation', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + cy.get('.govuk-pagination').should('exist') + cy.get('.govuk-pagination__link[rel="prev"]').should('not.exist') + cy.get('.govuk-pagination__link[rel="next"]').should('exist') + page.getPaginationLink(1).should('have.attr', 'aria-current') + page.getPaginationLink(1).should('contain.text', '1') + page.getPaginationLink(2).should('contain.text', '2') + page.getPaginationLink(3).should('contain.text', '3') + page.getPaginationItem(4).should('contain.text', '⋯') + page.getPaginationLink(5).should('contain.text', '6') + cy.get('.govuk-pagination__item').should('have.length', 5) + }) + it('should link to the next page when link 2 is clicked', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getPaginationLink(2).click() + page.getPaginationLink(2).should('have.attr', 'aria-current') + page.getPaginationLink(1).should('not.have.attr', 'aria-current') + cy.get('.govuk-pagination__link[rel="prev"]').should('exist') + cy.get('.govuk-pagination__link[rel="next"]').should('exist') + cy.get('.govuk-pagination__item').should('have.length', 5) + page.getPaginationLink(1).should('contain.text', '1') + page.getPaginationLink(2).should('contain.text', '2') + page.getPaginationLink(3).should('contain.text', '3') + page.getPaginationItem(4).should('contain.text', '⋯') + page.getPaginationLink(5).should('contain.text', '6') + }) + it('should link to the next page when link 3 is clicked', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getPaginationLink(3).click() + page.getPaginationLink(3).should('not.have.attr', 'aria-current') + page.getPaginationLink(4).should('have.attr', 'aria-current') + cy.get('.govuk-pagination__link[rel="prev"]').should('exist') + cy.get('.govuk-pagination__link[rel="next"]').should('exist') + cy.get('.govuk-pagination__item').should('have.length', 7) + page.getPaginationLink(1).should('contain.text', '1') + page.getPaginationItem(2).should('contain.text', '⋯') + page.getPaginationLink(3).should('contain.text', '2') + page.getPaginationItem(4).should('contain.text', '3') + page.getPaginationLink(5).should('contain.text', '4') + page.getPaginationItem(6).should('contain.text', '⋯') + page.getPaginationLink(7).should('contain.text', '6') + }) + it('should link to page 2 when the previous link is clicked', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getPaginationLink(3).click() + cy.get('.govuk-pagination__link[rel="prev"]').click() + page.getPaginationLink(2).should('have.attr', 'aria-current') + page.getPaginationLink(1).should('not.have.attr', 'aria-current') + cy.get('.govuk-pagination__link[rel="prev"]').should('exist') + cy.get('.govuk-pagination__link[rel="next"]').should('exist') + cy.get('.govuk-pagination__item').should('have.length', 5) + page.getPaginationLink(1).should('contain.text', '1') + page.getPaginationLink(2).should('contain.text', '2') + page.getPaginationLink(3).should('contain.text', '3') + page.getPaginationItem(4).should('contain.text', '⋯') + page.getPaginationLink(5).should('contain.text', '6') + }) + it('should link to page 4 when the next link is clicked', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getPaginationLink(3).click() + cy.get('.govuk-pagination__link[rel="next"]').click() + page.getPaginationLink(3).should('not.have.attr', 'aria-current') + page.getPaginationLink(4).should('have.attr', 'aria-current') + cy.get('.govuk-pagination__link[rel="prev"]').should('exist') + cy.get('.govuk-pagination__link[rel="next"]').should('exist') + cy.get('.govuk-pagination__item').should('have.length', 7) + page.getPaginationLink(1).should('contain.text', '1') + page.getPaginationItem(2).should('contain.text', '⋯') + page.getPaginationLink(3).should('contain.text', '3') + page.getPaginationLink(4).should('contain.text', '4') + page.getPaginationLink(5).should('contain.text', '5') + page.getPaginationItem(6).should('contain.text', '⋯') + page.getPaginationLink(7).should('contain.text', '6') + }) + it('should link to the next page when the link 5 is clicked', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getPaginationLink(3).click() + page.getPaginationLink(5).click() + page.getPaginationLink(5).click() + page.getPaginationLink(3).should('not.have.attr', 'aria-current') + page.getPaginationLink(4).should('have.attr', 'aria-current') + cy.get('.govuk-pagination__link[rel="prev"]').should('exist') + cy.get('.govuk-pagination__link[rel="next"]').should('exist') + cy.get('.govuk-pagination__item').should('have.length', 5) + page.getPaginationLink(1).should('contain.text', '1') + page.getPaginationItem(2).should('contain.text', '⋯') + page.getPaginationLink(3).should('contain.text', '4') + page.getPaginationLink(4).should('contain.text', '5') + page.getPaginationLink(5).should('contain.text', '6') + }) + it('should link to the next page when the link 6 is clicked', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getPaginationLink(3).click() + page.getPaginationLink(5).click() + page.getPaginationLink(5).click() + page.getPaginationLink(5).click() + page.getPaginationLink(4).should('not.have.attr', 'aria-current') + page.getPaginationLink(5).should('have.attr', 'aria-current') + cy.get('.govuk-pagination__link[rel="prev"]').should('exist') + cy.get('.govuk-pagination__link[rel="next"]').should('not.exist') + cy.get('.govuk-pagination__item').should('have.length', 5) + page.getPaginationLink(1).should('contain.text', '1') + page.getPaginationItem(2).should('contain.text', '⋯') + page.getPaginationLink(3).should('contain.text', '4') + page.getPaginationLink(4).should('contain.text', '5') + page.getPaginationLink(5).should('contain.text', '6') }) it('Activity log page is rendered in compact view', () => { - cy.visit('/case/X000001/activity-log?view=compact') + cy.visit('/case/X000001/activity-log') const page = Page.verifyOnPage(ActivityLogPage) + cy.get('.toggle-menu__list-item:nth-of-type(2) a').click() page.getActivity('1').should('contain.text', 'Video call') page.getActivity('2').should('contain.text', 'Phone call from Eula Schmeler') page.getActivity('3').should('contain.text', 'Planned appointment') @@ -24,6 +416,25 @@ context('Activity log', () => { page.getActivity('5').should('contain.text', 'Office appointment') page.getActivity('6').should('contain.text', 'Phone call') page.getActivity('7').should('contain.text', 'Office appointment') - page.getActivity('8').should('contain.text', 'Video call') + page.getActivity('8').should('contain.text', 'Initial appointment') + page.getActivity('9').should('contain.text', 'Initial appointment') + page.getActivity('10').should('contain.text', 'Video call') + }) + it('should display the no results message when no results are returned', () => { + cy.visit('/case/X000001/activity-log') + const page = Page.verifyOnPage(ActivityLogPage) + page.getKeywordsInput().type('No results') + page.getApplyFiltersButton().click() + cy.get('.toggle-menu').should('not.exist') + cy.get('[data-qa="results-count"]').should('not.exist') + cy.get('.govuk-pagination').should('not.exist') + page.getNoResults().find('h3').should('contain.text', '0 search results found') + page.getNoResults().find('p').should('contain.text', 'Improve your search by:') + page.getNoResults().find('li:nth-of-type(1)').should('contain.text', 'removing filters') + page.getNoResults().find('li:nth-of-type(2)').should('contain.text', 'double-checking the spelling') + page + .getNoResults() + .find('li:nth-of-type(3)') + .should('contain.text', 'removing special characters like characters and accent letters') }) }) diff --git a/integration_tests/e2e/licence-condition-note.cy.ts b/integration_tests/e2e/licence-condition-note.cy.ts index caba0255..81a35d63 100644 --- a/integration_tests/e2e/licence-condition-note.cy.ts +++ b/integration_tests/e2e/licence-condition-note.cy.ts @@ -8,6 +8,7 @@ context('Sentence', () => { const page = Page.verifyOnPage(SentencePage) page.headerCrn().should('contain.text', 'X000001') page.headerName().should('contain.text', 'Caroline Wolff') + cy.get('[data-qa=pageHeading]').eq(0).should('contain.text', 'Sentence') cy.get(`[class=predictor-timeline-item__level]`) diff --git a/integration_tests/pages/activityLog.ts b/integration_tests/pages/activityLog.ts index ac8624fc..64776704 100644 --- a/integration_tests/pages/activityLog.ts +++ b/integration_tests/pages/activityLog.ts @@ -5,5 +5,36 @@ export default class ActivityLogPage extends Page { super('Activity log') } + getApplyFiltersButton = (): PageElement => cy.get('[data-qa="submit-button"]') + + getKeywordsInput = (): PageElement => cy.get('[data-qa="keywords"] input') + + getDateFromInput = (): PageElement => cy.get('[data-qa="date-from"] input') + + getDateFromToggle = (): PageElement => cy.get('[data-qa="date-from"] .moj-datepicker__toggle') + + getDateFromDialog = (): PageElement => cy.get('[data-qa="date-from"] .moj-datepicker__dialog') + + getDateToInput = (): PageElement => cy.get('[data-qa="date-to"] input') + + getDateToToggle = (): PageElement => cy.get('[data-qa="date-to"] .moj-datepicker__toggle') + + getDateToDialog = (): PageElement => cy.get('[data-qa="date-to"] .moj-datepicker__dialog') + + getSelectedFilterTags = (): PageElement => cy.get('.moj-filter__tag') + + getSelectedFilterTag = (index: number) => cy.get(`.moj-filter-tags li:nth-of-type(${index}) a`) + getActivity = (index: string): PageElement => cy.get(`[data-qa=timeline${index}Card]`) + + getComplianceFilter = (index: number): PageElement => + cy.get(`[data-qa="compliance"] .govuk-checkboxes__item:nth-of-type(${index}) input`) + + getPaginationLink = (index: number): PageElement => cy.get(`.govuk-pagination li:nth-of-type(${index}) a`) + + getPaginationItem = (index: number): PageElement => cy.get(`.govuk-pagination li:nth-of-type(${index})`) + + getTimelineCard = (index: number): PageElement => cy.get(`.app-summary-card:nth-type-of(${index})`) + + getNoResults = (): PageElement => cy.get('[data-qa="no-results"]') } diff --git a/server/@types/ActivityLog.type.ts b/server/@types/ActivityLog.type.ts new file mode 100644 index 00000000..59a5e850 --- /dev/null +++ b/server/@types/ActivityLog.type.ts @@ -0,0 +1,38 @@ +import { PersonActivity } from '../data/model/activityLog' +import { TierCalculation } from '../data/tierApiClient' +import type { Errors, Option } from './index' + +export interface ActivityLogFilters { + keywords: string + dateFrom: string + dateTo: string + compliance: string[] +} + +export interface ActivityLogRequestBody { + keywords: string + dateFrom: string + dateTo: string + filters: string[] +} + +export interface SelectedFilterItem { + text: string + href: string +} + +export interface ActivityLogFiltersResponse extends ActivityLogFilters { + errors: Errors + selectedFilterItems: SelectedFilterItem[] + complianceOptions: Option[] + baseUrl: string + queryStr: string + queryStrPrefix: string + queryStrSuffix: string + maxDate: string +} + +export interface ActivityLogCache extends ActivityLogFilters { + personActivity: PersonActivity + tierCalculation: TierCalculation +} diff --git a/server/@types/Appointment.type.ts b/server/@types/Appointment.type.ts index 107d44f3..f6eb525e 100644 --- a/server/@types/Appointment.type.ts +++ b/server/@types/Appointment.type.ts @@ -4,9 +4,9 @@ export interface Appointment { date: string 'start-time': string 'end-time': string - repeating: 'Yes' | 'No' - 'repeating-frequency': string - 'repeating-count': string + repeating?: 'Yes' | 'No' + 'repeating-frequency'?: string + 'repeating-count'?: string id?: string } @@ -16,6 +16,11 @@ export type AppointmentType = | 'PlannedOfficeVisitNS' | 'InitialAppointmentHomeVisitNS' +export interface AppointmentTypeOption { + text: string + value: AppointmentType +} + export type AppointmentInterval = 'DAY' | 'WEEK' | 'FORTNIGHT' | 'FOUR_WEEKS' export interface AppointmentRequestBody { diff --git a/server/@types/Data.type.ts b/server/@types/Data.type.ts index 199ee888..39297225 100644 --- a/server/@types/Data.type.ts +++ b/server/@types/Data.type.ts @@ -1,15 +1,8 @@ +/* eslint-disable import/no-cycle */ import { Location } from '../data/model/caseload' import { PersonalDetails } from '../data/model/personalDetails' import { Sentence } from '../data/model/sentenceDetails' -import { Errors } from './Errors.type' - -interface Appointment { - type?: string - location?: string - date?: string - 'start-time'?: string - 'end-time'?: string -} +import { Errors, Appointment } from './index' export interface Data { appointments?: { diff --git a/server/@types/Option.type.ts b/server/@types/Option.type.ts new file mode 100644 index 00000000..b11f0f19 --- /dev/null +++ b/server/@types/Option.type.ts @@ -0,0 +1,5 @@ +export interface Option { + text: string + value?: string + checked?: boolean +} diff --git a/server/@types/Route.type.ts b/server/@types/Route.type.ts index 6a9d0f39..787be0bb 100644 --- a/server/@types/Route.type.ts +++ b/server/@types/Route.type.ts @@ -1,3 +1,39 @@ +/* eslint-disable import/no-cycle */ import { Request, Response, NextFunction } from 'express' +import { ActivityLogFiltersResponse, Appointment, AppointmentTypeOption, Errors, Option } from './index' +import { PersonalDetails } from '../data/model/personalDetails' +import { FeatureFlags } from '../data/model/featureFlags' +import { Sentence } from '../data/model/sentenceDetails' +import { Location } from '../data/model/caseload' +import { SentryConfig } from '../config' -export type Route = (req: Request, res: Response, next?: NextFunction) => T +interface Locals { + filters?: ActivityLogFiltersResponse + user: { token: string; authSource: string; username?: string } + compactView?: boolean + defaultView?: boolean + requirement?: string + appointment?: Appointment + case?: PersonalDetails + message?: string + status?: number + stack?: boolean | number | string + flags?: FeatureFlags + sentences?: Sentence[] + timeOptions?: Option[] + userLocations?: Location[] + sentry?: SentryConfig + csrfToken?: string + cspNonce?: string + errors?: Errors + change?: string + appointmentTypes?: AppointmentTypeOption[] + lastAppointmentDate?: string + version: string +} + +export interface AppResponse extends Response { + locals: Locals +} + +export type Route = (req: Request, res: AppResponse, next?: NextFunction) => T diff --git a/server/@types/express/index.d.ts b/server/@types/express/index.d.ts index 14095b38..3e0d8df7 100644 --- a/server/@types/express/index.d.ts +++ b/server/@types/express/index.d.ts @@ -1,7 +1,7 @@ import type { UserDetails } from '../../services/userService' import { Errors } from '../Errors.type' import { UserLocations } from '../../data/model/caseload' -import { Data } from '../index' +import { ActivityLogCache, Data } from '../index' export default {} @@ -16,6 +16,10 @@ declare module 'express-session' { sortBy: string caseFilter: CaseFilter data?: Data + errors?: Errors + cache?: { + activityLog: ActivityLogCache[] + } } interface CaseFilter { diff --git a/server/@types/index.ts b/server/@types/index.ts index 823bcba3..f6881738 100644 --- a/server/@types/index.ts +++ b/server/@types/index.ts @@ -1,4 +1,7 @@ +/* eslint-disable import/no-cycle */ export * from './Route.type' export * from './Errors.type' export * from './Data.type' export * from './Appointment.type' +export * from './ActivityLog.type' +export * from './Option.type' diff --git a/server/config.ts b/server/config.ts index dc5239ce..d4e25bcf 100755 --- a/server/config.ts +++ b/server/config.ts @@ -34,6 +34,14 @@ export interface ApiConfig { agent: AgentConfig } +export interface SentryConfig { + dsn: string + loaderScriptId: string + tracesSampleRate: number + replaySampleRate: number + replayOnErrorSampleRate: number +} + export default { buildNumber: get('BUILD_NUMBER', '1_0_0', requiredInProduction), productId: get('PRODUCT_ID', 'UNASSIGNED', requiredInProduction), diff --git a/server/data/masApiClient.ts b/server/data/masApiClient.ts index f4af8840..9dd9389e 100644 --- a/server/data/masApiClient.ts +++ b/server/data/masApiClient.ts @@ -20,7 +20,7 @@ import { TeamCaseload, UserCaseload, UserTeam, UserLocations } from './model/cas import { ProfessionalContact } from './model/professionalContact' import { CaseAccess, UserAccess } from './model/caseAccess' import { LicenceConditionNoteDetails } from './model/licenceConditionNoteDetails' -import { AppointmentRequestBody } from '../@types' +import { AppointmentRequestBody, ActivityLogRequestBody } from '../@types' import { RequirementNoteDetails } from './model/requirementNoteDetails' export default class MasApiClient extends RestClient { @@ -124,6 +124,20 @@ export default class MasApiClient extends RestClient { return this.get({ path: `/activity/${crn}`, handle404: false }) } + postPersonActivityLog = async ( + crn: string, + body: ActivityLogRequestBody, + page: string, + ): Promise => { + const pageQuery = `?${new URLSearchParams({ size: '10', page }).toString()}` + return this.post({ + data: body, + path: `/activity/${crn}${pageQuery}`, + handle404: true, + handle500: true, + }) + } + async getPersonRiskFlags(crn: string): Promise { return this.get({ path: `/risk-flags/${crn}`, handle404: false }) } diff --git a/server/data/model/activityLog.ts b/server/data/model/activityLog.ts index 29134a02..c19285cf 100644 --- a/server/data/model/activityLog.ts +++ b/server/data/model/activityLog.ts @@ -2,6 +2,10 @@ import { PersonSummary } from './common' import { Activity } from './schedule' export interface PersonActivity { + size: number + page: number + totalResults: number + totalPages: number personSummary: PersonSummary activities: Activity[] } diff --git a/server/errorHandler.ts b/server/errorHandler.ts index 2fa4fbdc..69e8b7cd 100644 --- a/server/errorHandler.ts +++ b/server/errorHandler.ts @@ -1,9 +1,10 @@ -import type { Request, Response, NextFunction } from 'express' +import type { Request, NextFunction } from 'express' import type { HTTPError } from 'superagent' import logger from '../logger' +import type { AppResponse } from './@types' export default function createErrorHandler(production: boolean) { - return (error: HTTPError, req: Request, res: Response, next: NextFunction): void => { + return (error: HTTPError, req: Request, res: AppResponse, next: NextFunction): void => { logger.error(`Error handling request for '${req.originalUrl}', user '${res.locals.user?.username}'`, error) if (error.status === 401 || error.status === 403) { diff --git a/server/middleware/asyncMiddleware.ts b/server/middleware/asyncMiddleware.ts index cb99dcba..a4356a6b 100644 --- a/server/middleware/asyncMiddleware.ts +++ b/server/middleware/asyncMiddleware.ts @@ -1,7 +1,8 @@ -import type { Request, Response, NextFunction, RequestHandler } from 'express' +import type { Request, NextFunction, RequestHandler } from 'express' +import { AppResponse } from '../@types' export default function asyncMiddleware(fn: RequestHandler) { - return (req: Request, res: Response, next: NextFunction): void => { + return (req: Request, res: AppResponse, next: NextFunction): void => { Promise.resolve(fn(req, res, next)).catch(next) } } diff --git a/server/middleware/authorisationMiddleware.ts b/server/middleware/authorisationMiddleware.ts index 81145dd4..d6032488 100644 --- a/server/middleware/authorisationMiddleware.ts +++ b/server/middleware/authorisationMiddleware.ts @@ -1,11 +1,12 @@ import { jwtDecode } from 'jwt-decode' -import type { RequestHandler } from 'express' +import type { RequestHandler, Request, NextFunction } from 'express' import logger from '../../logger' import asyncMiddleware from './asyncMiddleware' +import { AppResponse } from '../@types' export default function authorisationMiddleware(allowedRoles: string[] = []): RequestHandler { - return asyncMiddleware((req, res, next) => { + return asyncMiddleware((req: Request, res: AppResponse, next: NextFunction) => { const allowedAuthorities = allowedRoles.map(role => (role.startsWith('ROLE_') ? role : `ROLE_${role}`)) if (res.locals?.user?.token) { const { authorities = [], auth_source: authSource } = jwtDecode(res.locals.user.token) as { diff --git a/server/middleware/evaluateFeatureFlags.ts b/server/middleware/evaluateFeatureFlags.ts index 0737b2a8..f58be1a1 100644 --- a/server/middleware/evaluateFeatureFlags.ts +++ b/server/middleware/evaluateFeatureFlags.ts @@ -1,9 +1,10 @@ import { RequestHandler } from 'express' import logger from '../../logger' import FlagService from '../services/flagService' +import { AppResponse } from '../@types' export default function evaluateFeatureFlags(flagService: FlagService): RequestHandler { - return async (_req, res, next) => { + return async (_req, res: AppResponse, next) => { try { const flags = await flagService.getFlags() if (flags) { diff --git a/server/middleware/filterActivityLog.ts b/server/middleware/filterActivityLog.ts new file mode 100644 index 00000000..f932a309 --- /dev/null +++ b/server/middleware/filterActivityLog.ts @@ -0,0 +1,134 @@ +/* eslint-disable no-param-reassign */ + +import { DateTime } from 'luxon' +import { Route, ActivityLogFilters, ActivityLogFiltersResponse, SelectedFilterItem, Option } from '../@types' + +export const filterActivityLog: Route = (req, res, next) => { + if (req?.query?.submit) { + let url = req.url.split('&page=')[0] + url = url.replace('&submit=true', '') + return res.redirect(url) + } + const { crn } = req.params + const { keywords = '', dateFrom = '', dateTo = '', clearFilterKey, clearFilterValue } = req.query + const errors = req?.session?.errors + const { compliance: complianceQuery = [] } = req.query + let compliance = complianceQuery as string[] | string + const baseUrl = `/case/${crn}/activity-log` + if (!Array.isArray(compliance)) { + compliance = [compliance] + } + if (compliance?.length && clearFilterKey === 'compliance') { + compliance = compliance.filter(value => value !== clearFilterValue) + } + const complianceFilterOptions: Option[] = [ + { text: 'Without an outcome', value: 'no outcome' }, + { text: 'Complied', value: 'complied' }, + { text: 'Not complied', value: 'not complied' }, + ] + const filters: ActivityLogFilters = { + keywords: keywords && clearFilterKey !== 'keywords' ? (keywords as string) : '', + dateFrom: + dateFrom && dateTo && !errors?.errorMessages?.dateFrom && clearFilterKey !== 'dateRange' + ? (dateFrom as string) + : '', + dateTo: + dateTo && dateFrom && !errors?.errorMessages?.dateTo && clearFilterKey !== 'dateRange' ? (dateTo as string) : '', + compliance, + } + + const getQueryString = (values: ActivityLogFilters | Record): string => { + const keys = [...Object.keys(filters)] + const queryStr: string = Object.entries(values) + .filter(([key, _value]) => keys.includes(key)) + .reduce((acc, [key, value]: [string, string | string[]], i) => { + if (value) { + if (Array.isArray(value)) { + for (const val of value) { + acc = `${acc}${acc ? '&' : ''}${key}=${encodeURI(val)}` + } + } else { + acc = `${acc}${i > 0 ? '&' : ''}${key}=${encodeURI(value)}` + } + } + return acc + }, '') + return queryStr + } + + const queryStr = getQueryString(req.query as Record) + const queryStrPrefix = queryStr ? '?' : '' + const queryStrSuffix = queryStr ? '&' : '?' + const redirectQueryStr = getQueryString(filters) + + if (clearFilterKey) { + let redirectUrl = baseUrl + if (redirectQueryStr) redirectUrl = `${redirectUrl}?${redirectQueryStr}` + return res.redirect(redirectUrl) + } + + const filterHref = (key: string, value: string): string => + queryStr + ? `${baseUrl}?${queryStr}&clearFilterKey=${key}&clearFilterValue=${encodeURI(value)}` + : `${baseUrl}?clearFilterKey=${key}&clearFilterValue=${encodeURI(value)}` + + const selectedFilterItems: SelectedFilterItem[] = Object.entries(filters) + .filter(([_key, value]) => value) + .reduce((acc, [key, value]) => { + if (Array.isArray(value)) { + for (const text of value) { + acc = [ + ...acc, + { + text: complianceFilterOptions.find(option => option.value === text).text, + href: filterHref(key, text), + }, + ] + } + } else if (key !== 'dateTo') { + let text = value + let cfKey = key + if (key === 'dateFrom') { + text = value && filters.dateTo ? `${value} - ${filters.dateTo}` : '' + cfKey = 'dateRange' + } + if (text) { + acc = [ + ...acc, + { + text, + href: filterHref(cfKey, value), + }, + ] + } + } + return acc + }, []) + + const complianceOptions: Option[] = complianceFilterOptions.map(({ text, value }) => ({ + text, + value, + checked: filters.compliance.includes(value), + })) + + const today = new Date() + const maxDate = DateTime.fromJSDate(today).toFormat('dd/MM/yyyy') + + const filtersResponse: ActivityLogFiltersResponse = { + errors, + selectedFilterItems, + complianceOptions, + baseUrl, + queryStr, + queryStrPrefix, + queryStrSuffix, + keywords: filters.keywords, + compliance: filters.compliance, + dateFrom: filters.dateFrom, + dateTo: filters.dateTo, + maxDate, + } + res.locals.filters = filtersResponse + + return next() +} diff --git a/server/middleware/getAppointment.ts b/server/middleware/getAppointment.ts index 41e7f1c6..93262e7f 100644 --- a/server/middleware/getAppointment.ts +++ b/server/middleware/getAppointment.ts @@ -1,6 +1,6 @@ import { Route } from '../@types' -export const getAppointment: Route = (req, res, next): void => { +export const getAppointment: Route = (req, res, next) => { const { crn, id } = req.params if (req.session?.data?.appointments?.[crn]?.[id]) { res.locals.appointment = req.session.data.appointments[crn][id] diff --git a/server/middleware/getPersonActivity.ts b/server/middleware/getPersonActivity.ts new file mode 100644 index 00000000..8c492422 --- /dev/null +++ b/server/middleware/getPersonActivity.ts @@ -0,0 +1,68 @@ +import { Request } from 'express' +import { HmppsAuthClient } from '../data' +import MasApiClient from '../data/masApiClient' +import { ActivityLogCache, ActivityLogRequestBody, AppResponse } from '../@types' +import { PersonActivity } from '../data/model/activityLog' +import { toISODate, toCamelCase } from '../utils/utils' +import TierApiClient, { TierCalculation } from '../data/tierApiClient' + +export const getPersonActivity = async ( + req: Request, + res: AppResponse, + hmppsAuthClient: HmppsAuthClient, +): Promise<[TierCalculation, PersonActivity]> => { + const { filters } = res.locals + const { params, query } = req + const { keywords, dateFrom, dateTo, compliance } = filters + const { crn } = params + const { page = '0' } = query + const token = await hmppsAuthClient.getSystemClientToken(res.locals.user.username) + const masClient = new MasApiClient(token) + const tierClient = new TierApiClient(token) + + let personActivity: PersonActivity | null = null + let tierCalculation: TierCalculation | null = null + if (req?.session?.cache?.activityLog) { + const cache: ActivityLogCache | undefined = req.session.cache.activityLog.find( + cacheItem => + keywords === cacheItem.keywords && + dateFrom === cacheItem.dateFrom && + dateTo === cacheItem.dateTo && + compliance.every(option => cacheItem.compliance.includes(option)) && + cacheItem.compliance.length === compliance.length && + parseInt(page as string, 10) === cacheItem.personActivity.page, + ) + if (cache) { + personActivity = cache.personActivity + tierCalculation = cache.tierCalculation + } + } + if (!personActivity) { + const body: ActivityLogRequestBody = { + keywords, + dateFrom: dateFrom ? toISODate(dateFrom) : '', + dateTo: dateTo ? toISODate(dateTo) : '', + filters: compliance ? compliance.map(option => toCamelCase(option)) : [], + } + ;[personActivity, tierCalculation] = await Promise.all([ + masClient.postPersonActivityLog(crn, body, page as string), + tierClient.getCalculationDetails(crn), + ]) + const newCache: ActivityLogCache[] = [ + ...(req?.session?.cache?.activityLog || []), + { + keywords, + dateFrom, + dateTo, + compliance, + personActivity, + tierCalculation, + }, + ] + req.session.cache = { + ...(req?.session?.cache || {}), + activityLog: newCache, + } + } + return [tierCalculation, personActivity] +} diff --git a/server/middleware/getPersonalDetails.ts b/server/middleware/getPersonalDetails.ts index 4a5da427..68a78313 100644 --- a/server/middleware/getPersonalDetails.ts +++ b/server/middleware/getPersonalDetails.ts @@ -1,9 +1,9 @@ -import { Request, Response, NextFunction } from 'express' import { HmppsAuthClient } from '../data' import MasApiClient from '../data/masApiClient' +import { Route } from '../@types' -export const getPersonalDetails = (hmppsAuthClient: HmppsAuthClient) => { - return async (req: Request, res: Response, next: NextFunction) => { +export const getPersonalDetails = (hmppsAuthClient: HmppsAuthClient): Route> => { + return async (req, res, next) => { const { crn } = req.params if (!req?.session?.data?.personalDetails?.[crn]) { const token = await hmppsAuthClient.getSystemClientToken(res.locals.user.username) diff --git a/server/middleware/getSentences.ts b/server/middleware/getSentences.ts index 8c4ed720..be7ec7f3 100644 --- a/server/middleware/getSentences.ts +++ b/server/middleware/getSentences.ts @@ -1,12 +1,11 @@ -import { Request, Response, NextFunction } from 'express' import { HmppsAuthClient } from '../data' import MasApiClient from '../data/masApiClient' +import { Route } from '../@types' -export const getSentences = (hmppsAuthClient: HmppsAuthClient) => { - return async (req: Request, res: Response, next: NextFunction) => { +export const getSentences = (hmppsAuthClient: HmppsAuthClient): Route> => { + return async (req, res, next) => { const number = (req?.query?.number as string) || '' const crn = req.params.crn as string - const token = await hmppsAuthClient.getSystemClientToken(res.locals.user.username) const masClient = new MasApiClient(token) const allSentences = await masClient.getSentences(crn, number) diff --git a/server/middleware/getTimeOptions.ts b/server/middleware/getTimeOptions.ts index 4e8c3a30..db0df87c 100644 --- a/server/middleware/getTimeOptions.ts +++ b/server/middleware/getTimeOptions.ts @@ -1,14 +1,9 @@ -import { Route } from '../@types' - -interface Item { - text: string - value?: string -} +import { Route, Option } from '../@types' export const getTimeOptions: Route = (_req, res, next) => { const startHour = 9 const endHour = 16 - const timeOptions: Item[] = [{ text: 'Choose time', value: '' }] + const timeOptions: Option[] = [{ text: 'Choose time', value: '' }] for (let i = startHour; i <= endHour; i += 1) { const hour = i > 12 ? i - 12 : i const suffix = i >= 12 ? 'pm' : 'am' diff --git a/server/middleware/getUserLocations.ts b/server/middleware/getUserLocations.ts index 43b757b6..8d1368b4 100644 --- a/server/middleware/getUserLocations.ts +++ b/server/middleware/getUserLocations.ts @@ -1,9 +1,9 @@ -import { Request, Response, NextFunction } from 'express' import { HmppsAuthClient } from '../data' import MasApiClient from '../data/masApiClient' +import { Route } from '../@types' -export const getUserLocations = (hmppsAuthClient: HmppsAuthClient) => { - return async (req: Request, res: Response, next: NextFunction) => { +export const getUserLocations = (hmppsAuthClient: HmppsAuthClient): Route> => { + return async (req, res, next) => { const { username } = res.locals.user const token = await hmppsAuthClient.getSystemClientToken(username) if (!req?.session?.data?.locations?.[username]) { diff --git a/server/middleware/index.ts b/server/middleware/index.ts index 8c77c2e6..5475ae20 100644 --- a/server/middleware/index.ts +++ b/server/middleware/index.ts @@ -4,3 +4,4 @@ export * from './getPersonalDetails' export * from './getSentences' export * from './getAppointment' export * from './redirectWizard' +export * from './filterActivityLog' diff --git a/server/middleware/limitedAccessMiddleware.ts b/server/middleware/limitedAccessMiddleware.ts index 35de2362..b3d595ae 100644 --- a/server/middleware/limitedAccessMiddleware.ts +++ b/server/middleware/limitedAccessMiddleware.ts @@ -1,10 +1,11 @@ -import type { NextFunction, Request, Response } from 'express' +import type { NextFunction, Request } from 'express' import MasApiClient from '../data/masApiClient' import { Services } from '../services' import asyncMiddleware from './asyncMiddleware' +import { AppResponse } from '../@types' export default function limitedAccess(services: Services) { - return asyncMiddleware(async (req: Request, res: Response, next: NextFunction): Promise => { + return asyncMiddleware(async (req: Request, res: AppResponse, next: NextFunction): Promise => { const token = await services.hmppsAuthClient.getSystemClientToken() const access = await new MasApiClient(token).getUserAccess(res.locals.user.username, req.params.crn) diff --git a/server/middleware/populateCurrentUser.ts b/server/middleware/populateCurrentUser.ts index 508772bf..5b67b7fe 100644 --- a/server/middleware/populateCurrentUser.ts +++ b/server/middleware/populateCurrentUser.ts @@ -1,9 +1,9 @@ -import { RequestHandler } from 'express' import logger from '../../logger' import UserService from '../services/userService' +import { Route } from '../@types' -export default function populateCurrentUser(userService: UserService): RequestHandler { - return async (req, res, next) => { +export default function populateCurrentUser(userService: UserService): Route> { + return async (_req, res, next) => { try { if (res.locals.user) { const user = res.locals.user && (await userService.getUser(res.locals.user.token)) diff --git a/server/middleware/postAppointments.ts b/server/middleware/postAppointments.ts index 13b7ff4a..4598efab 100644 --- a/server/middleware/postAppointments.ts +++ b/server/middleware/postAppointments.ts @@ -1,11 +1,10 @@ -import { NextFunction, Request, Response } from 'express' import { HmppsAuthClient } from '../data' import MasApiClient from '../data/masApiClient' import { getDataValue } from '../utils/utils' import properties from '../properties' import { Sentence } from '../data/model/sentenceDetails' import { UserLocation } from '../data/model/caseload' -import { AppointmentRequestBody } from '../@types' +import { AppointmentRequestBody, Route } from '../@types' const dateTime = (date: string, time: string): Date => { const isPm = time.includes('pm') @@ -21,8 +20,8 @@ const dateTime = (date: string, time: string): Date => { return new Date(year, month, day, newHour, minute, 0) } -export const postAppointments = (hmppsAuthClient: HmppsAuthClient) => { - return async (req: Request, res: Response, next: NextFunction) => { +export const postAppointments = (hmppsAuthClient: HmppsAuthClient): Route> => { + return async (req, res, next) => { const { crn, id: uuid } = req.params const { username } = res.locals.user const token = await hmppsAuthClient.getSystemClientToken(res.locals.user.username) diff --git a/server/middleware/sentryMiddleware.ts b/server/middleware/sentryMiddleware.ts index de391ee3..e11298e8 100644 --- a/server/middleware/sentryMiddleware.ts +++ b/server/middleware/sentryMiddleware.ts @@ -1,7 +1,7 @@ -import type { RequestHandler } from 'express' import config from '../config' +import type { Route } from '../@types' -export default function sentryMiddleware(): RequestHandler { +export default function sentryMiddleware(): Route { // Pass-through Sentry config into locals, for use in the Sentry loader script (see layout.njk) return (req, res, next) => { res.locals.sentry = config.sentry diff --git a/server/middleware/setUpAuthentication.ts b/server/middleware/setUpAuthentication.ts index acb19a7f..7732dab1 100644 --- a/server/middleware/setUpAuthentication.ts +++ b/server/middleware/setUpAuthentication.ts @@ -5,6 +5,7 @@ import flash from 'connect-flash' import * as Sentry from '@sentry/node' import config from '../config' import auth from '../authentication/auth' +import { AppResponse } from '../@types' const router = express.Router() @@ -46,7 +47,7 @@ export default function setUpAuth(): Router { res.redirect(`${authUrl}/account-details?${authParameters}`) }) - router.use((req, res, next) => { + router.use((req, res: AppResponse, next) => { if (req.isAuthenticated()) Sentry.setUser({ username: req.user.username }) res.locals.user = req.user next() diff --git a/server/middleware/setUpCsrf.ts b/server/middleware/setUpCsrf.ts index 6f9a1a54..6c2a8bee 100644 --- a/server/middleware/setUpCsrf.ts +++ b/server/middleware/setUpCsrf.ts @@ -1,5 +1,6 @@ import { Router } from 'express' import csurf from 'csurf' +import { AppResponse } from '../@types' const testMode = process.env.NODE_ENV === 'test' @@ -11,7 +12,7 @@ export default function setUpCsrf(): Router { router.use(csurf()) } - router.use((req, res, next) => { + router.use((req, res: AppResponse, next) => { if (typeof req.csrfToken === 'function') { res.locals.csrfToken = req.csrfToken() } diff --git a/server/middleware/setUpWebSecurity.ts b/server/middleware/setUpWebSecurity.ts index df1d8ce8..68484802 100644 --- a/server/middleware/setUpWebSecurity.ts +++ b/server/middleware/setUpWebSecurity.ts @@ -2,6 +2,7 @@ import crypto from 'crypto' import express, { Router, Request, Response, NextFunction } from 'express' import helmet from 'helmet' import config from '../config' +import { AppResponse } from '../@types' export default function setUpWebSecurity(): Router { const router = express.Router() @@ -9,7 +10,7 @@ export default function setUpWebSecurity(): Router { // Secure code best practice - see: // 1. https://expressjs.com/en/advanced/best-practice-security.html, // 2. https://www.npmjs.com/package/helmet - router.use((_req: Request, res: Response, next: NextFunction) => { + router.use((_req: Request, res: AppResponse, next: NextFunction) => { res.locals.cspNonce = crypto.randomBytes(16).toString('hex') next() }) diff --git a/server/middleware/validation/activityLog.ts b/server/middleware/validation/activityLog.ts new file mode 100644 index 00000000..d96f9868 --- /dev/null +++ b/server/middleware/validation/activityLog.ts @@ -0,0 +1,109 @@ +import { DateTime } from 'luxon' +import logger from '../../../logger' +import { Errors, Route } from '../../@types' +import properties from '../../properties' +import utils from '../../utils' +import { toCamelCase } from '../../utils/utils' + +const activityLog: Route = (req, res, next) => { + const { dateFrom: dateFromQuery, dateTo: dateToQuery } = req.query + const dateFrom = dateFromQuery as string + const dateTo = dateToQuery as string + const { url, query } = req + const { submit } = query + + const isValid: { [key: string]: boolean } = { + dateFrom: true, + dateTo: true, + } + + const getIsoDate = (date: string): DateTime => { + const [day, month, year] = date.split('/') + return DateTime.fromISO(DateTime.local(parseInt(year, 10), parseInt(month, 10), parseInt(day, 10)).toISODate()) + } + + const isValidDateFormat = (nameProp: string, dateVal: string): void => { + const regex = /^(?:[1-9])?\d\/(?:[1-9])?\d\/\d{4}$/ + if (dateVal && !regex.test(dateVal)) { + const text = properties.errorMessages['activity-log'][nameProp].errors.isInvalid + const name = toCamelCase(nameProp) + errors = utils.addError(errors, { text, anchor: name }) + isValid[name] = false + } + } + + const isRealDate = (nameProp: string, dateVal: string): void => { + const name = toCamelCase(nameProp) + if (isValid[name] && req?.query?.[name]) { + const dateToIso = getIsoDate(dateVal) + if (!dateToIso.isValid) { + const text = properties.errorMessages['activity-log'][nameProp].errors.isNotReal + errors = utils.addError(errors, { text, anchor: name }) + isValid[name] = false + } + } + } + + const isDateInFuture = (nameProp: string, dateVal: string): void => { + const name = toCamelCase(nameProp) + if (isValid[name] && req?.query?.[name]) { + const dateFromIso = getIsoDate(dateVal) + const today = DateTime.now() + if (dateFromIso > today) { + const text = properties.errorMessages['activity-log'][nameProp].errors.isInFuture + errors = utils.addError(errors, { text, anchor: name }) + isValid[name] = false + } + } + } + + const dateIsValid = (dateName: string) => req?.query?.[dateName] && isValid[dateName] + + const validateDateRanges = (): void => { + isValidDateFormat('date-from', dateFrom) + isRealDate('date-from', dateFrom) + isDateInFuture('date-from', dateFrom) + isValidDateFormat('date-to', dateTo) + isRealDate('date-to', dateTo) + if (dateIsValid('dateTo')) { + isDateInFuture('date-to', dateTo) + } + if (!dateFrom && dateIsValid('dateTo')) { + logger.info(properties.errorMessages['activity-log']['date-from'].log) + const text = properties.errorMessages['activity-log']['date-from'].errors.isEmpty + errors = utils.addError(errors, { text, anchor: 'dateFrom' }) + isValid.dateFrom = false + } + if (!dateTo && dateIsValid('dateFrom')) { + logger.info(properties.errorMessages['activity-log']['date-to'].log) + const text = properties.errorMessages['activity-log']['date-to'].errors.isEmpty + errors = utils.addError(errors, { text, anchor: 'dateTo' }) + isValid.dateTo = false + } + if (dateIsValid('dateFrom') && dateIsValid('dateTo')) { + const dateFromIso = getIsoDate(dateFrom) + const dateToIso = getIsoDate(dateTo) + if (dateFromIso > dateToIso) { + const text = properties.errorMessages['activity-log']['date-from'].errors.isAfterTo + errors = utils.addError(errors, { text, anchor: 'dateFrom' }) + isValid.dateFrom = false + } + } + } + + let errors: Errors = null + if (submit) { + if (req?.session?.errors) { + delete req.session.errors + } + + validateDateRanges() + if (errors) { + req.session.errors = errors + return res.redirect(url.replace('&submit=true', '')) + } + } + return next() +} + +export default activityLog diff --git a/server/middleware/validation/index.ts b/server/middleware/validation/index.ts index 463056dd..3580a579 100644 --- a/server/middleware/validation/index.ts +++ b/server/middleware/validation/index.ts @@ -1,5 +1,7 @@ import appointments from './appointments' +import activityLog from './activityLog' export default { appointments, + activityLog, } diff --git a/server/properties/appointmentTypes.ts b/server/properties/appointmentTypes.ts index f4c37c13..5e68df89 100644 --- a/server/properties/appointmentTypes.ts +++ b/server/properties/appointmentTypes.ts @@ -1,11 +1,6 @@ -import { AppointmentType } from '../@types' +import { AppointmentTypeOption } from '../@types' -interface Type { - text: string - value: AppointmentType -} - -const appointmentTypes: Type[] = [ +const appointmentTypes: AppointmentTypeOption[] = [ { text: 'Home visit', value: 'HomeVisitToCaseNS', diff --git a/server/properties/errorMessages.ts b/server/properties/errorMessages.ts index 9277a44b..2e03e6b8 100644 --- a/server/properties/errorMessages.ts +++ b/server/properties/errorMessages.ts @@ -1,4 +1,13 @@ -const ruleKeys = ['isInvalid', 'isEmpty', 'isMoreThanAYear'] as const +const ruleKeys = [ + 'isInvalid', + 'isEmpty', + 'isMoreThanAYear', + 'isNotReal', + 'isIncomplete', + 'isInFuture', + 'isAfterTo', + 'isBeforeFrom', +] as const const appointmentsKeys = [ 'type', 'sentence', @@ -15,6 +24,9 @@ const appointmentsKeys = [ type Rule = (typeof ruleKeys)[number] type AppointmentInput = (typeof appointmentsKeys)[number] +const activityLogKeys = ['date-from', 'date-to'] +type ActivityLogInput = (typeof activityLogKeys)[number] + interface ErrorMessages { appointments: { [key in AppointmentInput]: { @@ -24,6 +36,14 @@ interface ErrorMessages { } } } + 'activity-log': { + [key in ActivityLogInput]: { + log: string + errors: { + [rule in Rule]?: string + } + } + } } const errorMessages: ErrorMessages = { @@ -98,6 +118,29 @@ const errorMessages: ErrorMessages = { }, }, }, + 'activity-log': { + 'date-from': { + log: 'Activity log date from no entered', + errors: { + isEmpty: 'Enter or select a from date', + isInvalid: 'Enter a date in the correct format, for example 17/5/2024', + isNotReal: 'Enter a real date', + isIncomplete: 'Enter a full date, for example 17/5/2024', + isInFuture: 'The from date must be today or in the past', + isAfterTo: 'The from date must be on or before the to date', + }, + }, + 'date-to': { + log: 'Activity log date from no entered', + errors: { + isEmpty: 'Enter or select a to date', + isInvalid: 'Enter a date in the correct format, for example 17/5/2024', + isNotReal: 'Enter a real date', + isIncomplete: 'Enter a full date, for example 17/5/2024', + isInFuture: 'The to date must be today or in the past', + }, + }, + }, } export default errorMessages diff --git a/server/routes/activityLog.ts b/server/routes/activityLog.ts index 85d1e524..3b3ad43e 100644 --- a/server/routes/activityLog.ts +++ b/server/routes/activityLog.ts @@ -1,66 +1,84 @@ -import { type RequestHandler, Router } from 'express' +/* eslint-disable import/no-extraneous-dependencies */ + +import { type Router } from 'express' import { auditService } from '@ministryofjustice/hmpps-audit-client' import { v4 } from 'uuid' -// eslint-disable-next-line import/no-extraneous-dependencies import { Query } from 'express-serve-static-core' import asyncMiddleware from '../middleware/asyncMiddleware' -import type { Services } from '../services' +import { type Services } from '../services' import MasApiClient from '../data/masApiClient' import TierApiClient from '../data/tierApiClient' -import { TimelineItem } from '../data/model/risk' +import validate from '../middleware/validation/index' +import { filterActivityLog } from '../middleware' +import type { AppResponse, Route } from '../@types' +import { getPersonActivity } from '../middleware/getPersonActivity' import { toPredictors, toRoshWidget, toTimeline } from '../utils/utils' import ArnsApiClient from '../data/arnsApiClient' export default function activityLogRoutes(router: Router, { hmppsAuthClient }: Services) { - const get = (path: string | string[], handler: RequestHandler) => router.get(path, asyncMiddleware(handler)) - - get('/case/:crn/activity-log', async (req, res, _next) => { - const { crn } = req.params - const token = await hmppsAuthClient.getSystemClientToken(res.locals.user.username) - const arnsClient = new ArnsApiClient(token) - const masClient = new MasApiClient(token) - const tierClient = new TierApiClient(token) - - if (req.query.view === 'compact') { - res.locals.compactView = true - } else { - res.locals.defaultView = true - } - - if (req.query.requirement) { - res.locals.requirement = req.query.requirement - } - - const [personActivity, tierCalculation, risks, predictors] = await Promise.all([ - masClient.getPersonActivityLog(crn), - tierClient.getCalculationDetails(crn), - arnsClient.getRisks(crn), - arnsClient.getPredictorsAll(crn), - ]) - - const queryParams = getQueryString(req.query) - - await auditService.sendAuditMessage({ - action: 'VIEW_MAS_ACTIVITY_LOG', - who: res.locals.user.username, - subjectId: crn, - subjectType: 'CRN', - correlationId: v4(), - service: 'hmpps-manage-people-on-probation-ui', - }) - - const risksWidget = toRoshWidget(risks) - - const predictorScores = toPredictors(predictors) - res.render('pages/activity-log', { - personActivity, - crn, - queryParams, - tierCalculation, - risksWidget, - predictorScores, - }) - }) + const get = (path: string | string[], handler: Route) => router.get(path, asyncMiddleware(handler)) + + router.get( + '/case/:crn/activity-log', + validate.activityLog, + filterActivityLog, + async (req, res: AppResponse, _next) => { + const { query, params } = req + const { crn } = params + const { page = '0', view = '' } = query + + if (req.query.view === 'compact') { + res.locals.compactView = true + } else { + res.locals.defaultView = true + } + if (req.query.requirement) { + res.locals.requirement = req.query.requirement as string + } + + const [tierCalculation, personActivity] = await getPersonActivity(req, res, hmppsAuthClient) + + const queryParams = getQueryString(req.query) + const token = await hmppsAuthClient.getSystemClientToken(res.locals.user.username) + const arnsClient = new ArnsApiClient(token) + const currentPage = parseInt(page as string, 10) + const resultsStart = currentPage > 0 ? 10 * currentPage + 1 : 1 + let resultsEnd = currentPage > 0 ? (currentPage + 1) * 10 : 10 + if (personActivity.totalResults >= resultsStart && personActivity.totalResults <= resultsEnd) { + resultsEnd = personActivity.totalResults + } + + const [risks, predictors] = await Promise.all([arnsClient.getRisks(crn), arnsClient.getPredictorsAll(crn)]) + + await auditService.sendAuditMessage({ + action: 'VIEW_MAS_ACTIVITY_LOG', + who: res.locals.user.username, + subjectId: crn, + subjectType: 'CRN', + correlationId: v4(), + service: 'hmpps-manage-people-on-probation-ui', + }) + + const risksWidget = toRoshWidget(risks) + + const predictorScores = toPredictors(predictors) + + res.render('pages/activity-log', { + personActivity, + crn, + queryParams, + page, + view, + tierCalculation, + risksWidget, + predictorScores, + url: req.url, + query, + resultsStart, + resultsEnd, + }) + }, + ) get('/case/:crn/activity-log/:category', async (req, res, _next) => { const { crn, category } = req.params @@ -93,7 +111,7 @@ export default function activityLogRoutes(router: Router, { hmppsAuthClient }: S } if (req.query.requirement) { - res.locals.requirement = req.query.requirement + res.locals.requirement = req.query.requirement as string } const queryParams = getQueryString(req.query) @@ -107,6 +125,7 @@ export default function activityLogRoutes(router: Router, { hmppsAuthClient }: S queryParams, crn, tierCalculation, + errors: req?.session?.errors, risksWidget, predictorScores, }) diff --git a/server/routes/appointments.ts b/server/routes/appointments.ts index 3d86d2a6..83073dc8 100644 --- a/server/routes/appointments.ts +++ b/server/routes/appointments.ts @@ -7,11 +7,12 @@ import MasApiClient from '../data/masApiClient' import logger from '../../logger' import { ErrorMessages } from '../data/model/caseload' import TierApiClient from '../data/tierApiClient' +import type { Route } from '../@types' import { toPredictors, toRoshWidget } from '../utils/utils' import ArnsApiClient from '../data/arnsApiClient' export default function scheduleRoutes(router: Router, { hmppsAuthClient }: Services) { - const get = (path: string | string[], handler: RequestHandler) => router.get(path, asyncMiddleware(handler)) + const get = (path: string | string[], handler: Route) => router.get(path, asyncMiddleware(handler)) const post = (path: string, handler: RequestHandler) => router.post(path, asyncMiddleware(handler)) get('/case/:crn/appointments', async (req, res, _next) => { diff --git a/server/routes/arrangeAppointment.ts b/server/routes/arrangeAppointment.ts index f3715cc3..e63b39f6 100644 --- a/server/routes/arrangeAppointment.ts +++ b/server/routes/arrangeAppointment.ts @@ -1,4 +1,4 @@ -import { type RequestHandler, Router } from 'express' +import { type Router } from 'express' import { DateTime } from 'luxon' import { v4 as uuidv4 } from 'uuid' import asyncMiddleware from '../middleware/asyncMiddleware' @@ -17,12 +17,13 @@ import { ArrangedSession } from '../models/ArrangedSession' import { postAppointments } from '../middleware/postAppointments' import properties from '../properties' import { getTimeOptions } from '../middleware/getTimeOptions' +import type { AppResponse, Route } from '../@types' const arrangeAppointmentRoutes = async (router: Router, { hmppsAuthClient }: Services) => { - const get = (path: string | string[], handler: RequestHandler) => router.get(path, asyncMiddleware(handler)) + const get = (path: string | string[], handler: Route) => router.get(path, asyncMiddleware(handler)) - router.all('/case/:crn/arrange-appointment/:id/*', (req, res, next) => { - res.locals.change = req.query.change + router.all('/case/:crn/arrange-appointment/:id/*', (req, res: AppResponse, next) => { + res.locals.change = req.query.change as string return next() }) get('/case/:crn/arrange-appointment/type', async (req, res, _next) => { @@ -30,7 +31,7 @@ const arrangeAppointmentRoutes = async (router: Router, { hmppsAuthClient }: Ser const { crn } = req.params return res.redirect(`/case/${crn}/arrange-appointment/${id}/type`) }) - router.all('/case/:crn/arrange-appointment/:id/type', (_req, res, next) => { + router.all('/case/:crn/arrange-appointment/:id/type', (_req, res: AppResponse, next) => { res.locals.appointmentTypes = properties.appointmentTypes return next() }) @@ -144,7 +145,7 @@ const arrangeAppointmentRoutes = async (router: Router, { hmppsAuthClient }: Ser router.get( '/case/:crn/arrange-appointment/:id/repeating', redirectWizard(['type', 'sentence', 'location', 'date']), - async (req, res, _next) => { + async (req, res: AppResponse, _next) => { const { data } = req.session const { crn, id } = req.params const { 'repeating-frequency': repeatingFrequency, 'repeating-count': repeatingCount } = req.query @@ -205,7 +206,7 @@ const arrangeAppointmentRoutes = async (router: Router, { hmppsAuthClient }: Ser '/case/:crn/arrange-appointment/:id/check-your-answers', redirectWizard(['type', 'sentence', 'location', 'date', 'repeating']), getUserLocations(hmppsAuthClient), - async (req, res, _next) => { + async (req, res: AppResponse, _next) => { const { params, url } = req const { crn, id } = params const { data } = req.session @@ -236,7 +237,7 @@ const arrangeAppointmentRoutes = async (router: Router, { hmppsAuthClient }: Ser return res.render(`pages/arrange-appointment/confirmation`) }, ) - router.post('/case/:crn/arrange-appointment/:id/confirmation', async (req, res, _next) => { + router.post('/case/:crn/arrange-appointment/:id/confirmation', async (_req, res, _next) => { return res.redirect('/') }) } diff --git a/server/routes/case.ts b/server/routes/case.ts index 1b4dd0af..db1fbee0 100644 --- a/server/routes/case.ts +++ b/server/routes/case.ts @@ -1,4 +1,4 @@ -import { type RequestHandler, Router } from 'express' +import { type Router } from 'express' import { auditService } from '@ministryofjustice/hmpps-audit-client' import { v4 } from 'uuid' import asyncMiddleware from '../middleware/asyncMiddleware' @@ -6,11 +6,11 @@ import type { Services } from '../services' import MasApiClient from '../data/masApiClient' import ArnsApiClient from '../data/arnsApiClient' import TierApiClient from '../data/tierApiClient' +import type { Route } from '../@types' import { toPredictors, toRoshWidget, toTimeline } from '../utils/utils' -import { TimelineItem } from '../data/model/risk' export default function caseRoutes(router: Router, { hmppsAuthClient }: Services) { - const get = (path: string | string[], handler: RequestHandler) => router.get(path, asyncMiddleware(handler)) + const get = (path: string | string[], handler: Route) => router.get(path, asyncMiddleware(handler)) get('/case/:crn', async (req, res, _next) => { const { crn } = req.params diff --git a/server/routes/caseload.ts b/server/routes/caseload.ts index 0dcdaef2..c5a348e5 100644 --- a/server/routes/caseload.ts +++ b/server/routes/caseload.ts @@ -1,4 +1,4 @@ -import { type RequestHandler, Router, Request, Response } from 'express' +import { type Router, Request } from 'express' import { auditService } from '@ministryofjustice/hmpps-audit-client' import { v4 } from 'uuid' import getPaginationLinks, { Pagination } from '@ministryofjustice/probation-search-frontend/utils/pagination' @@ -11,10 +11,11 @@ import { CaseSearchFilter, ErrorMessages, UserCaseload } from '../data/model/cas import config from '../config' import { RecentlyViewedCase } from '../data/model/caseAccess' import { checkRecentlyViewedAccess } from '../utils/utils' +import type { AppResponse, Route } from '../@types' export default function caseloadRoutes(router: Router, { hmppsAuthClient }: Services) { - const get = (path: string | string[], handler: RequestHandler) => router.get(path, asyncMiddleware(handler)) - const post = (path: string, handler: RequestHandler) => router.post(path, asyncMiddleware(handler)) + const get = (path: string | string[], handler: Route) => router.get(path, asyncMiddleware(handler)) + const post = (path: string, handler: Route) => router.post(path, asyncMiddleware(handler)) get('/', async (req, res, _next) => { const token = await hmppsAuthClient.getSystemClientToken(res.locals.user.username) @@ -97,7 +98,7 @@ export default function caseloadRoutes(router: Router, { hmppsAuthClient }: Serv await showCaseload(req, res, caseload, req.session.caseFilter) }) - const showCaseload = async (req: Request, res: Response, caseload: UserCaseload, filter: CaseSearchFilter) => { + const showCaseload = async (req: Request, res: AppResponse, caseload: UserCaseload, filter: CaseSearchFilter) => { const currentNavSection = 'yourCases' await auditService.sendAuditMessage({ action: 'VIEW_MAS_CASELOAD', diff --git a/server/routes/compliance.ts b/server/routes/compliance.ts index e23a5608..b028c889 100644 --- a/server/routes/compliance.ts +++ b/server/routes/compliance.ts @@ -1,15 +1,16 @@ -import { type RequestHandler, Router } from 'express' +import { type Router } from 'express' import { auditService } from '@ministryofjustice/hmpps-audit-client' import { v4 } from 'uuid' import asyncMiddleware from '../middleware/asyncMiddleware' import type { Services } from '../services' import MasApiClient from '../data/masApiClient' import TierApiClient from '../data/tierApiClient' +import type { Route } from '../@types' import ArnsApiClient from '../data/arnsApiClient' import { toPredictors, toRoshWidget } from '../utils/utils' export default function complianceRoutes(router: Router, { hmppsAuthClient }: Services) { - const get = (path: string | string[], handler: RequestHandler) => router.get(path, asyncMiddleware(handler)) + const get = (path: string | string[], handler: Route) => router.get(path, asyncMiddleware(handler)) get('/case/:crn/compliance', async (req, res, _next) => { const { crn } = req.params diff --git a/server/routes/interventions.ts b/server/routes/interventions.ts index 65824d54..44dff183 100644 --- a/server/routes/interventions.ts +++ b/server/routes/interventions.ts @@ -1,4 +1,4 @@ -import { type RequestHandler, Router } from 'express' +import { type Router } from 'express' import { auditService } from '@ministryofjustice/hmpps-audit-client' import { v4 } from 'uuid' import asyncMiddleware from '../middleware/asyncMiddleware' @@ -6,11 +6,12 @@ import type { Services } from '../services' import MasApiClient from '../data/masApiClient' import TierApiClient from '../data/tierApiClient' import InterventionsApiClient from '../data/interventionsApiClient' +import type { Route } from '../@types' import { toPredictors, toRoshWidget } from '../utils/utils' import ArnsApiClient from '../data/arnsApiClient' export default function interventionsRoutes(router: Router, { hmppsAuthClient }: Services) { - const get = (path: string | string[], handler: RequestHandler) => router.get(path, asyncMiddleware(handler)) + const get = (path: string | string[], handler: Route) => router.get(path, asyncMiddleware(handler)) get('/case/:crn/interventions', async (req, res, _next) => { const { crn } = req.params diff --git a/server/routes/personalDetails.ts b/server/routes/personalDetails.ts index 7f7516bb..2916e165 100644 --- a/server/routes/personalDetails.ts +++ b/server/routes/personalDetails.ts @@ -1,4 +1,4 @@ -import { type RequestHandler, Router } from 'express' +import type { Router } from 'express' import { auditService } from '@ministryofjustice/hmpps-audit-client' import { v4 } from 'uuid' import asyncMiddleware from '../middleware/asyncMiddleware' @@ -6,11 +6,11 @@ import type { Services } from '../services' import MasApiClient from '../data/masApiClient' import ArnsApiClient from '../data/arnsApiClient' import TierApiClient from '../data/tierApiClient' -import { toPredictors, toRoshWidget, toTimeline } from '../utils/utils' -import { TimelineItem } from '../data/model/risk' +import type { Route } from '../@types' +import { toPredictors, toRoshWidget } from '../utils/utils' export default function personalDetailRoutes(router: Router, { hmppsAuthClient }: Services) { - const get = (path: string | string[], handler: RequestHandler) => router.get(path, asyncMiddleware(handler)) + const get = (path: string | string[], handler: Route) => router.get(path, asyncMiddleware(handler)) get('/case/:crn/personal-details', async (req, res, _next) => { const { crn } = req.params diff --git a/server/routes/risks.ts b/server/routes/risks.ts index 6f50ffb4..260e8274 100644 --- a/server/routes/risks.ts +++ b/server/routes/risks.ts @@ -1,4 +1,4 @@ -import { type RequestHandler, Router } from 'express' +import { type Router } from 'express' import { auditService } from '@ministryofjustice/hmpps-audit-client' import { v4 } from 'uuid' import asyncMiddleware from '../middleware/asyncMiddleware' @@ -8,9 +8,10 @@ import ArnsApiClient from '../data/arnsApiClient' import TierApiClient from '../data/tierApiClient' import { TimelineItem } from '../data/model/risk' import { toRoshWidget, toTimeline } from '../utils/utils' +import type { Route } from '../@types' export default function risksRoutes(router: Router, { hmppsAuthClient }: Services) { - const get = (path: string | string[], handler: RequestHandler) => router.get(path, asyncMiddleware(handler)) + const get = (path: string | string[], handler: Route) => router.get(path, asyncMiddleware(handler)) get('/case/:crn/risk', async (req, res, _next) => { const { crn } = req.params diff --git a/server/routes/sentence.ts b/server/routes/sentence.ts index 57e20105..2b96d900 100644 --- a/server/routes/sentence.ts +++ b/server/routes/sentence.ts @@ -1,10 +1,11 @@ -import { type RequestHandler, Router } from 'express' +import { type Router } from 'express' import { auditService } from '@ministryofjustice/hmpps-audit-client' import { v4 } from 'uuid' import asyncMiddleware from '../middleware/asyncMiddleware' import type { Services } from '../services' import MasApiClient from '../data/masApiClient' import TierApiClient from '../data/tierApiClient' +import type { Route } from '../@types' import ArnsApiClient from '../data/arnsApiClient' import { toPredictors, toRoshWidget } from '../utils/utils' @@ -13,7 +14,7 @@ interface QueryParams { number?: string } export default function sentenceRoutes(router: Router, { hmppsAuthClient }: Services) { - const get = (path: string | string[], handler: RequestHandler) => router.get(path, asyncMiddleware(handler)) + const get = (path: string | string[], handler: Route) => router.get(path, asyncMiddleware(handler)) get('/case/:crn/sentence', async (req, res, _next) => { const { crn } = req.params diff --git a/server/utils/nunjucksSetup.ts b/server/utils/nunjucksSetup.ts index 2a5758fc..6bf027d6 100644 --- a/server/utils/nunjucksSetup.ts +++ b/server/utils/nunjucksSetup.ts @@ -1,7 +1,7 @@ /* eslint-disable no-param-reassign */ import path from 'path' import nunjucks from 'nunjucks' -import express, { Request, Response, NextFunction } from 'express' +import express, { Request, NextFunction } from 'express' import { activityLog, activityLogDate, @@ -57,6 +57,7 @@ import { } from './utils' import { ApplicationInfo } from '../applicationInfo' import config from '../config' +import { AppResponse } from '../@types' const production = process.env.NODE_ENV === 'production' @@ -74,7 +75,7 @@ export default function nunjucksSetup(app: express.Express, applicationInfo: App app.locals.version = applicationInfo.gitShortHash } else { // Version changes every request - app.use((_req, res, next) => { + app.use((_req, res: AppResponse, next) => { res.locals.version = Date.now().toString() return next() }) @@ -117,7 +118,7 @@ export default function nunjucksSetup(app: express.Express, applicationInfo: App njkEnv.addFilter('dateForSort', dateForSort) njkEnv.addFilter('timeForSort', timeForSort) - app.use((req: Request, res: Response, next: NextFunction): void => { + app.use((req: Request, res: AppResponse, next: NextFunction) => { njkEnv.addFilter('decorateFormAttributes', decorateFormAttributes(req, res)) return next() }) diff --git a/server/utils/utils.ts b/server/utils/utils.ts index bbb74b23..4fbced8c 100644 --- a/server/utils/utils.ts +++ b/server/utils/utils.ts @@ -4,7 +4,7 @@ import { DateTime } from 'luxon' import slugify from 'slugify' import getKeypath from 'lodash/get' import setKeypath from 'lodash/set' -import { Request, Response } from 'express' +import { Request } from 'express' import { Need, RiskScore, RiskSummary, RiskToSelf } from '../data/arnsApiClient' import { ErrorSummary, Name } from '../data/model/common' import { Address } from '../data/model/personalDetails' @@ -13,6 +13,7 @@ import { Activity } from '../data/model/schedule' import { CaseSearchFilter, SelectElement } from '../data/model/caseload' import { RecentlyViewedCase, UserAccess } from '../data/model/caseAccess' import { RiskScoresDto, RoshRiskWidgetDto, TimelineItem } from '../data/model/risk' +import type { AppResponse } from '../@types' interface Item { checked?: string @@ -100,6 +101,10 @@ export const toDate = (datetimeString: string): DateTime => { return DateTime.fromISO(datetimeString) } +export const toISODate = (datetimeString: any) => { + return DateTime.fromFormat(datetimeString, 'd/M/yyyy').toFormat('yyyy-MM-dd') +} + export const dateWithYearShortMonthAndTime = (datetimeString: string): string => { if (!datetimeString || isBlank(datetimeString)) return null const date = DateTime.fromISO(datetimeString).toFormat('d MMM yyyy') @@ -519,7 +524,7 @@ export const setDataValue = (data: any, sections: any, value: any) => { return setKeypath(data, path.map((s: any) => `["${s}"]`).join(''), value) } -export const decorateFormAttributes = (req: Request, res: Response) => (obj: any, sections?: string[]) => { +export const decorateFormAttributes = (req: Request, res: AppResponse) => (obj: any, sections?: string[]) => { const newObj = obj const { data } = req.session as any let storedValue = getDataValue(data, sections) @@ -671,6 +676,16 @@ export const toRoshWidget = (roshSummary: RiskSummary): RoshRiskWidgetDto => { } } +export const toCamelCase = (str: string): string => { + return str + .replace('-', ' ') + .split(' ') + .map((word, i) => { + return i > 0 ? `${word.slice(0, 1).toUpperCase()}${word.slice(1).toLowerCase()}` : word.toLowerCase() + }) + .join('') +} + export const toPredictors = (predictors: RiskScoresDto[] | ErrorSummary | null): TimelineItem => { let timeline: TimelineItem[] = [] let predictorScores diff --git a/server/views/_components/compliance-tag/template.njk b/server/views/_components/compliance-tag/template.njk index ac3551cb..fb0bb712 100644 --- a/server/views/_components/compliance-tag/template.njk +++ b/server/views/_components/compliance-tag/template.njk @@ -28,6 +28,14 @@ {% set text = 'Failed to comply' %} {% set shorthand = '✘' %} {% set colour = 'red' %} +{% elseif entry.isSensitive === true %} + {% set text = 'Sensitive' %} + {% set shorthand = '✘' %} + {% set colour = 'orange' %} +{% elseif entry.complied === true %} + {% set text = 'Complied' %} + {% set shorthand = '✘' %} + {% set colour = 'grey' %} {% endif %} {% set tagClasses = 'app-compliance-tag govuk-tag--' + colour + ' ' + params.classes + (' app-compliance-tag--compact' if isCompact) %} diff --git a/server/views/_components/pagination/macro.njk b/server/views/_components/pagination/macro.njk new file mode 100644 index 00000000..5bc8a379 --- /dev/null +++ b/server/views/_components/pagination/macro.njk @@ -0,0 +1,3 @@ +{% macro appPagination(params) %} + {%- include "./template.njk" -%} +{% endmacro %} \ No newline at end of file diff --git a/server/views/_components/pagination/template.njk b/server/views/_components/pagination/template.njk new file mode 100644 index 00000000..62181d8e --- /dev/null +++ b/server/views/_components/pagination/template.njk @@ -0,0 +1,57 @@ +{% from "govuk/components/pagination/macro.njk" import govukPagination %} + +{% set paginationItems = [] %} +{% set currentPage = params.currentPage or 0 %} +{% set totalPages = params.totalPages %} +{% set url = params.url or '' %} +{% set start = 0 %} +{% set end = start + 3 %} +{% if currentPage > 0 %} +{% set start = currentPage -1 %} +{% set end = start + 3 %} +{% endif %} +{% if currentPage + 1 == totalPages %} +{% set start = currentPage - 2 %} +{% set end = totalPages %} +{% endif %} + +{% if (totalPages > 3) and (currentPage != 0) and (start != 0) %} + {% set paginationItems = (paginationItems.push( + { + number: 1, + href: url + 'page=0' + }, + { + ellipsis: true + }), paginationItems) %} + {% endif %} + +{% for i in range(start , end) -%} + {% set paginationItems = (paginationItems.push({ + number: i + 1, + href: url + 'page=' + i, + current: true if i == currentPage else false + }), paginationItems) %} +{% endfor %} + + {% if (totalPages > 3) and ((currentPage + 1) != totalPages) and (end !== totalPages) %} + {% set paginationItems = (paginationItems.push( + { + ellipsis: true + }, + { + number: totalPages, + href: url + 'page=' + (totalPages - 1) + } + ), paginationItems) %} + {% endif %} + +{{ govukPagination({ + previous: { + href: url + 'page=' + (currentPage - 1) + } if currentPage > 0 and totalPages > 1, + next: { + href: url + 'page=' + (currentPage + 1) + } if currentPage + 1 < totalPages and totalPages > 1, + items: paginationItems +}) }} \ No newline at end of file diff --git a/server/views/pages/activity-log.njk b/server/views/pages/activity-log.njk index 96655d79..d77c20e7 100644 --- a/server/views/pages/activity-log.njk +++ b/server/views/pages/activity-log.njk @@ -1,5 +1,6 @@ {% extends "../partials/case.njk" %} {% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %} +{% from "govuk/components/pagination/macro.njk" import govukPagination %} {% set pageTitle = applicationName + " - Activity log" %} {% set currentNavSection = 'timeline' %} {% set currentSectionName = 'Activity log' %} @@ -28,6 +29,10 @@ } ] }) }} + {% if filters.errors %} +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} + {{ govukErrorSummary({ titleText: "There is a problem", errorList: filters.errors.errorList }) }} +{% endif %} {% endblock %} {% switch category %} @@ -71,7 +76,11 @@ {{ title }} {% endif %} - {% include "./activity-log/_switch-views.njk" %} + {% if personActivity.activities.length %} +
+ {% include "./activity-log/_switch-views.njk" %} +

Showing {{ resultsStart }} to {{ resultsEnd }} of {{ personActivity.totalResults }} results

+
{% set entries = activityLog(entries, category or 'all-previous-activity') %} {% if entries.length > 0 %} {% if compactView %} @@ -82,6 +91,27 @@ {% else %}

{{ emptyText }}

{% endif %} + +{% if personActivity.totalPages > 1 %} +
+ {{ appPagination({ + currentPage: query.page | int, + totalPages: personActivity.totalPages, + url: filters.baseUrl + filters.queryStrPrefix + filters.queryStr + filters.queryStrSuffix + 'view=' + view + '&' + }) }} +
+{% endif %} +{% else %} +
+

0 search results found

+

Improve your search by:

+
    +
  • removing filters
  • +
  • double-checking the spelling
  • +
  • removing special characters like characters and accent letters
  • +
+
+{% endif %} {% endblock %} diff --git a/server/views/pages/activity-log/_appointment-timeline-entry.njk b/server/views/pages/activity-log/_appointment-timeline-entry.njk index 4488c876..a0e3b543 100644 --- a/server/views/pages/activity-log/_appointment-timeline-entry.njk +++ b/server/views/pages/activity-log/_appointment-timeline-entry.njk @@ -6,10 +6,6 @@ {{ entry.type }} at {{ entry.startDateTime | govukTime }} on {{ thisDate }} - - - {% include '../appointments/_appointment-prefix.njk' %} - {% endset %} {% set html %} @@ -18,7 +14,7 @@ {% set actionsHtml %} {% if shouldPromptToRecordAnOutcome %} - Record an outcome + Log an outcome {% else %} {{ appComplianceTag({ entry: entry, classes: 'govuk-!-margin-left-2' }) }} {% endif %} diff --git a/server/views/pages/activity-log/_default-view.njk b/server/views/pages/activity-log/_default-view.njk index ed76e6d7..55384d9b 100644 --- a/server/views/pages/activity-log/_default-view.njk +++ b/server/views/pages/activity-log/_default-view.njk @@ -13,9 +13,7 @@ {% elseif entry.isCommunication %} {% include "./_communication-timeline-entry.njk" %} {% elseif entry.isSystemContact %} - + {% include "./_system-contact-timeline-entry.njk" %} {% else %} {% include "./_other-timeline-entry.njk" %} {% endif %} diff --git a/server/views/pages/activity-log/_filters.njk b/server/views/pages/activity-log/_filters.njk index efaf51f8..c2386192 100644 --- a/server/views/pages/activity-log/_filters.njk +++ b/server/views/pages/activity-log/_filters.njk @@ -1,41 +1,117 @@ +{%- from "moj/components/filter/macro.njk" import mojFilter -%} +{%- from "govuk/components/checkboxes/macro.njk" import govukCheckboxes -%} + {% set queryString = '?' + queryParams.join('&') if queryParams.length > 0 else '' %} -
-
- {{ govukButton({ - text: 'Add to log', - href: '/case/' + crn + '/handoff/delius' - }) }} - {% if category or requirement %} -

- Remove all filters -

- {% endif %} +{%- set filterOptionsHtml %} -

- Compliance filters -

-

Filter the activity log by National Standard (NS) activities:

+ {{ govukInput({ + id: 'keywords', + name: 'keywords', + label: { + text: 'Keywords', + classes: "govuk-label--s govuk-!-font-weight-bold" + }, + formGroup: { + classes: "govuk-!-margin-bottom-3", + attributes: { + 'data-qa': 'keywords' + } + }, + value: query.keywords + }) }} +{{ mojDatePicker({ + id: "dateFrom", + name: "dateFrom", + label: { + text: "Date from", + classes: "govuk-label--s govuk-!-font-weight-bold" + }, + formGroup: { + classes: "govuk-!-margin-bottom-3", + attributes: { + 'data-qa': 'date-from' + } + }, + value: query.dateFrom, + maxDate: filters.maxDate, + errorMessage: { + text: filters.errors.errorMessages.dateFrom.text + } if filters.errors.errorMessages.dateFrom.text +}) }} +{{ mojDatePicker({ + id: "dateTo", + name: "dateTo", + label: { + text: "Date to", + classes: "govuk-label--s govuk-!-font-weight-bold" + }, + formGroup: { + classes: "govuk-!-margin-bottom-3", + attributes: { + 'data-qa': 'date-to' + } + }, + value: query.dateTo, + maxDate: filters.maxDate, + errorMessage: { + text: filters.errors.errorMessages.dateTo.text + } if filters.errors.errorMessages.dateTo.text +}) }} + {{ govukCheckboxes({ + idPrefix: 'compliance', + name: 'compliance', + classes: "govuk-checkboxes--small", + fieldset: { + legend: { + text: 'Compliance filters', + classes: 'govuk-fieldset__legend--s' + } + }, + formGroup: { + attributes: { + 'data-qa': 'compliance' + } + }, + items: filters.complianceOptions + }) }} - + +{% endset -%} + +
+
+{{ mojFilter({ + heading: { + html: 'Filter activity log', + classes: 'govuk-heading-s' + }, + submit: { + value: "submit", + attributes: { + "data-qa": "submit-button" + } + }, + selectedFilters: { + heading: { + html: 'Selected filters', + classes: 'govuk-heading-s' + }, + clearLink: { + text: 'Clear filters', + href: '/case/' + crn + '/activity-log' + }, + categories: [ + { + items: filters.selectedFilterItems + } + ] + } if filters.selectedFilterItems.length, + optionsHtml: filterOptionsHtml +}) }} + + + +
-

- Requirement filters -

-

Filter the activity log by Requirement

-
    - {% for req in requirements %} -
  • {{ req }}
  • - {% endfor %} -
-
diff --git a/server/views/pages/activity-log/_other-timeline-entry.njk b/server/views/pages/activity-log/_other-timeline-entry.njk index b8ce2a9c..5a30674e 100644 --- a/server/views/pages/activity-log/_other-timeline-entry.njk +++ b/server/views/pages/activity-log/_other-timeline-entry.njk @@ -13,7 +13,7 @@ {% endif %} {% if shouldPromptToRecordAnOutcome %} - Record an outcome + Log an outcome {% else %} {{ appComplianceTag({ entry: entry, classes: 'govuk-!-margin-left-2' }) }} {% endif %} diff --git a/server/views/pages/activity-log/_switch-views.njk b/server/views/pages/activity-log/_switch-views.njk index 95ee11d8..37872fd5 100644 --- a/server/views/pages/activity-log/_switch-views.njk +++ b/server/views/pages/activity-log/_switch-views.njk @@ -1,30 +1,16 @@ -{% set html %} -

Change how the activity log looks:

-

+

    +
  • {% if compactView %} - Default view + Default view {% else %} - Default view + Default view {% endif %} -
    - Displays notes and details for each activity -

    - -

    - {% if defaultView %} - Compact view +

  • +
  • + {% if defaultView %} + Compact view {% else %} - Compact view + Compact view {% endif %} -
    - Displays a condensed list of activity -

    -{% endset %} - -{{ govukDetails({ - summaryText: "Switch view", - html: html, - classes: "govuk-!-margin-bottom-0" -}) }} - -
    +
  • +
\ No newline at end of file diff --git a/server/views/pages/activity-log/_system-contact-timeline-entry.njk b/server/views/pages/activity-log/_system-contact-timeline-entry.njk new file mode 100644 index 00000000..7d58bbd6 --- /dev/null +++ b/server/views/pages/activity-log/_system-contact-timeline-entry.njk @@ -0,0 +1,17 @@ +{% set titleHtml %} + + {{ entry.type }} on {{ thisDate }} + +{% endset %} + +{% set html %} + {% include './_timeline-notes.njk' %} +{% endset %} + +{{ appSummaryCard({ + attributes: {'data-qa': 'timeline' + loop.index + 'Card'}, + titleHtml: titleHtml, + classes: 'govuk-!-margin-bottom-2 app-summary-card--white-header', + html: html, + actionsHtml: actionsHtml +}) }} diff --git a/server/views/pages/activity-log/_timeline-notes.njk b/server/views/pages/activity-log/_timeline-notes.njk index af26f302..798f7425 100644 --- a/server/views/pages/activity-log/_timeline-notes.njk +++ b/server/views/pages/activity-log/_timeline-notes.njk @@ -68,10 +68,11 @@
{% endif %} -{% set showNotesDetails = entry.notes and (entry.notes.length > 250 or entry.sensitive) %} +{% set showNotesDetails = entry.notes %} {% set notes %} {% if entry.notes %}

{{ entry.notes | nl2br | safe }}

+

Comment added by {{ entry.lastUpdatedBy.forename }} {{ entry.lastUpdatedBy.surname }} on {{ entry.lastUpdated | dateWithYear }}

{% endif %} {% endset %} @@ -84,9 +85,9 @@ }) }} {% elseif not entry.notes %} {% if entry.isSensitive %} -

Sensitive (No notes)

+

Sensitive (No notes)

{% else %} -

No notes

+

No notes

{% endif %} {% else %} {{ notes | safe }} diff --git a/server/views/partials/layout.njk b/server/views/partials/layout.njk index 32e2480d..5b112069 100644 --- a/server/views/partials/layout.njk +++ b/server/views/partials/layout.njk @@ -11,12 +11,14 @@ {% from "govuk/components/button/macro.njk" import govukButton %} {% from "govuk/components/details/macro.njk" import govukDetails %} {% from "_components/compliance-tag/macro.njk" import appComplianceTag %} +{% from "_components/pagination/macro.njk" import appPagination %} {% from "govuk/components/radios/macro.njk" import govukRadios %} {% from "moj/components/pagination/macro.njk" import mojPagination %} {% from "govuk/components/pagination/macro.njk" import govukPagination %} {% from "govuk/components/input/macro.njk" import govukInput %} {% from "govuk/components/select/macro.njk" import govukSelect %} {% from "govuk/components/panel/macro.njk" import govukPanel %} +{%- from "moj/components/date-picker/macro.njk" import mojDatePicker -%} {% block head %} diff --git a/wiremock/mappings/X000001-activity-log.json b/wiremock/mappings/X000001-activity-log.json index d23dee82..f22e7892 100644 --- a/wiremock/mappings/X000001-activity-log.json +++ b/wiremock/mappings/X000001-activity-log.json @@ -2,12 +2,24 @@ "mappings": [ { "request": { - "urlPattern": "/mas/activity/X000001", - "method": "GET" + "urlPathPattern": "/mas/activity/X000001", + "method": "POST", + "queryParameters": { + "page": { + "matches": ".*" + }, + "size": { + "equalTo": "10" + } + } }, "response": { "status": 200, "jsonBody": { + "size": 10, + "page": 0, + "totalResults": 54, + "totalPages": 6, "personSummary": { "name": { "forename": "Eula", @@ -225,6 +237,554 @@ "forename": "Paul", "surname": "Smith" } + }, + { + "id": 19, + "type": "Initial appointment", + "isInitial": true, + "startDateTime": "2024-02-12T10:15:00.382936Z[Europe/London]", + "endDateTime": "2024-02-12T10:30:00.382936Z[Europe/London]", + "rarToolKit": "Choices and Changes", + "isSensitive": false, + "hasOutcome": true, + "wasAbsent": false, + "isAppointment": true, + "isNationalStandard": true, + "rescheduled": true, + "rescheduledBy": { + "forename": "Terry", + "surname": "Jones" + }, + "rearrangeOrCancelReason": "Rearranged due to needing to go to work", + "absentWaitingEvidence": false, + "documents": [ + { + "id": "83fdbf8a-a2f2-43b4-93ef-67e71c04fc58", + "name": "Eula-Schmeler-X000001-UPW.pdf", + "lastUpdated": "2023-04-06T11:06:25.672587+01:00" + }, + { + "id": "c2650260-9568-476e-a293-0b168027a5f1", + "name": "Eula-Schmeler-X000001-UPW.pdf", + "lastUpdated": "2023-04-06T11:09:45.860739+01:00" + }, + { + "id": "b82e444b-c77c-4d44-bf99-4ce4dc426ff4", + "name": "Eula-Schmeler-X000001-UPW.pdf", + "lastUpdated": "2023-04-06T11:21:17.06356+01:00" + } + ], + "notes": "Some notes", + "lastUpdated": "2023-03-20", + "officerName": { + "forename": "Terry", + "surname": "Jones" + }, + "lastUpdatedBy": { + "forename": "Paul", + "surname": "Smith" + } + }, + { + "id": 20, + "type": "Initial appointment", + "isInitial": true, + "startDateTime": "2024-02-11T10:15:00.382936Z[Europe/London]", + "endDateTime": "2024-02-11T10:30:00.382936Z[Europe/London]", + "rarToolKit": "Choices and Changes", + "isSensitive": false, + "hasOutcome": true, + "wasAbsent": false, + "isAppointment": true, + "isNationalStandard": true, + "rescheduled": true, + "rescheduledBy": { + "forename": "Terry", + "surname": "Jones" + }, + "rearrangeOrCancelReason": "Rearranged due to needing to go to work", + "absentWaitingEvidence": false, + "documents": [ + { + "id": "83fdbf8a-a2f2-43b4-93ef-67e71c04fc58", + "name": "Eula-Schmeler-X000001-UPW.pdf", + "lastUpdated": "2023-04-06T11:06:25.672587+01:00" + }, + { + "id": "c2650260-9568-476e-a293-0b168027a5f1", + "name": "Eula-Schmeler-X000001-UPW.pdf", + "lastUpdated": "2023-04-06T11:09:45.860739+01:00" + }, + { + "id": "b82e444b-c77c-4d44-bf99-4ce4dc426ff4", + "name": "Eula-Schmeler-X000001-UPW.pdf", + "lastUpdated": "2023-04-06T11:21:17.06356+01:00" + } + ], + "notes": "Some notes", + "lastUpdated": "2023-03-20", + "officerName": { + "forename": "Terry", + "surname": "Jones" + }, + "lastUpdatedBy": { + "forename": "Paul", + "surname": "Smith" + } + } + ] + }, + "headers": { + "Content-Type": "application/json" + } + } + }, + { + "request": { + "method": "POST", + "urlPathPattern": "/mas/activity/X000001", + "bodyPatterns": [ + { + "equalToJson": "{\"keywords\": \"No results\", \"dateFrom\": \"\", \"dateTo\": \"\", \"filters\": []}" + } + ] + }, + "response": { + "status": 200, + "jsonBody": { + "size": 10, + "page": 0, + "totalResults": 0, + "totalPages": 0, + "personSummary": { + "name": { + "forename": "Eula", + "surname": "Schmeler" + }, + "crn": "X000001", + "dateOfBirth": "1979-08-18" + }, + "activities": [] + }, + "headers": { + "Content-Type": "application/json" + } + } + }, + { + "request": { + "method": "POST", + "urlPathPattern": "/mas/activity/X000001", + "bodyPatterns": [ + { + "equalToJson": "{\"keywords\": \"Phone call\", \"dateFrom\": \"\", \"dateTo\": \"\", \"filters\": []}" + } + ] + }, + "response": { + "status": 200, + "jsonBody": { + "size": 10, + "page": 0, + "totalResults": 1, + "totalPages": 1, + "personSummary": { + "name": { + "forename": "Eula", + "surname": "Schmeler" + }, + "crn": "X000001", + "dateOfBirth": "1979-08-18" + }, + "activities": [ + { + "id": 11, + "type": "Phone call", + "startDateTime": "2044-12-22T09:15:00.382936Z[Europe/London]", + "endDateTime": "2044-12-22T09:30:00.382936Z[Europe/London]", + "rarToolKit": "Choices and Changes", + "rarCategory": "Stepping Stones", + "isSensitive": false, + "hasOutcome": false, + "wasAbsent": true, + "notes": "Phone call from Stuart to say that he is sorry for not attending. He sounded under the influence of alcohol. He has recently heard that his ex partner is getting remarried and stated that he “went on a bit of a bender” He states that he has not attempted to contact the victim but I will make enquiries with the police to confirm this as given that he is audibly drunk i don't think i can take his word for it. I asked him to come in and see me at 9am in the morning and reiterated that if he turns up drunk I will have to mark that as another failure to comply due to behavioural issues. This will result in him being in breach of his order. I reiterated that up until now he has been doing so well with his probation and it’s really important he remember and make use of all the tools and techniques we had discussed during his RAR sessions.\n\nHe agreed to see me at 9am as his next shift at Timpsons isn’t until the afternoon. He apologized many times for “letting me down” and has confirmed that he has put a reminder on his phone about the meeting.", + "isAppointment": false, + "isCommunication": true, + "isPhoneCallFromPop": true, + "officerName": { + "forename": "Terry", + "surname": "Jones" + }, + "lastUpdated": "2023-03-20", + "lastUpdatedBy": { + "forename": "Paul", + "surname": "Smith" + } + } + ] + }, + "headers": { + "Content-Type": "application/json" + } + } + }, + { + "request": { + "method": "POST", + "urlPathPattern": "/mas/activity/X000001", + "bodyPatterns": [ + { + "equalToJson": "{\"keywords\": \"Phone call\", \"dateFrom\": \"2025-01-20\", \"dateTo\": \"2025-01-27\", \"filters\": [\"noOutcome\", \"complied\", \"notComplied\"]}" + } + ] + }, + "response": { + "status": 200, + "jsonBody": { + "size": 10, + "page": 0, + "totalResults": 1, + "totalPages": 1, + "personSummary": { + "name": { + "forename": "Eula", + "surname": "Schmeler" + }, + "crn": "X000001", + "dateOfBirth": "1979-08-18" + }, + "activities": [ + { + "id": 11, + "type": "Phone call", + "startDateTime": "2044-12-22T09:15:00.382936Z[Europe/London]", + "endDateTime": "2044-12-22T09:30:00.382936Z[Europe/London]", + "rarToolKit": "Choices and Changes", + "rarCategory": "Stepping Stones", + "isSensitive": false, + "hasOutcome": false, + "wasAbsent": true, + "notes": "Phone call from Stuart to say that he is sorry for not attending. He sounded under the influence of alcohol. He has recently heard that his ex partner is getting remarried and stated that he “went on a bit of a bender” He states that he has not attempted to contact the victim but I will make enquiries with the police to confirm this as given that he is audibly drunk i don't think i can take his word for it. I asked him to come in and see me at 9am in the morning and reiterated that if he turns up drunk I will have to mark that as another failure to comply due to behavioural issues. This will result in him being in breach of his order. I reiterated that up until now he has been doing so well with his probation and it’s really important he remember and make use of all the tools and techniques we had discussed during his RAR sessions.\n\nHe agreed to see me at 9am as his next shift at Timpsons isn’t until the afternoon. He apologized many times for “letting me down” and has confirmed that he has put a reminder on his phone about the meeting.", + "isAppointment": false, + "isCommunication": true, + "isPhoneCallFromPop": true, + "officerName": { + "forename": "Terry", + "surname": "Jones" + }, + "lastUpdated": "2023-03-20", + "lastUpdatedBy": { + "forename": "Paul", + "surname": "Smith" + } + } + ] + }, + "headers": { + "Content-Type": "application/json" + } + } + }, + { + "request": { + "method": "POST", + "urlPathPattern": "/mas/activity/X000001", + "bodyPatterns": [ + { + "equalToJson": "{\"keywords\": \"\", \"dateFrom\": \"2025-01-20\", \"dateTo\": \"2025-01-27\", \"filters\": []}" + } + ] + }, + "response": { + "status": 200, + "jsonBody": { + "size": 10, + "page": 0, + "totalResults": 1, + "totalPages": 1, + "personSummary": { + "name": { + "forename": "Eula", + "surname": "Schmeler" + }, + "crn": "X000001", + "dateOfBirth": "1979-08-18" + }, + "activities": [ + { + "id": 11, + "type": "Office appointment", + "startDateTime": "2025-01-25T09:15:00.382936Z[Europe/London]", + "endDateTime": "2025-01-25T09:30:00.382936Z[Europe/London]", + "rarToolKit": "Choices and Changes", + "rarCategory": "Stepping Stones", + "isSensitive": false, + "hasOutcome": false, + "wasAbsent": true, + "notes": "Phone call from Stuart to say that he is sorry for not attending. He sounded under the influence of alcohol. He has recently heard that his ex partner is getting remarried and stated that he “went on a bit of a bender” He states that he has not attempted to contact the victim but I will make enquiries with the police to confirm this as given that he is audibly drunk i don't think i can take his word for it. I asked him to come in and see me at 9am in the morning and reiterated that if he turns up drunk I will have to mark that as another failure to comply due to behavioural issues. This will result in him being in breach of his order. I reiterated that up until now he has been doing so well with his probation and it’s really important he remember and make use of all the tools and techniques we had discussed during his RAR sessions.\n\nHe agreed to see me at 9am as his next shift at Timpsons isn’t until the afternoon. He apologized many times for “letting me down” and has confirmed that he has put a reminder on his phone about the meeting.", + "isAppointment": false, + "isCommunication": true, + "isPhoneCallFromPop": true, + "officerName": { + "forename": "Terry", + "surname": "Jones" + }, + "lastUpdated": "2023-03-20", + "lastUpdatedBy": { + "forename": "Paul", + "surname": "Smith" + } + } + ] + }, + "headers": { + "Content-Type": "application/json" + } + } + }, + { + "request": { + "method": "POST", + "urlPathPattern": "/mas/activity/X000001", + "bodyPatterns": [ + { + "equalToJson": "{\"keywords\": \"\", \"dateFrom\": \"\", \"dateTo\": \"\", \"filters\": [\"noOutcome\", \"complied\", \"notComplied\"]}" + } + ] + }, + "response": { + "status": 200, + "jsonBody": { + "size": 10, + "page": 0, + "totalResults": 3, + "totalPages": 1, + "personSummary": { + "name": { + "forename": "Eula", + "surname": "Schmeler" + }, + "crn": "X000001", + "dateOfBirth": "1979-08-18" + }, + "activities": [ + { + "id": 11, + "type": "AP PA - Attitudes, thinking & behaviours", + "startDateTime": "2025-01-25T09:15:00.382936Z[Europe/London]", + "endDateTime": "2025-01-25T09:30:00.382936Z[Europe/London]", + "rarToolKit": "Choices and Changes", + "rarCategory": "Stepping Stones", + "isSensitive": false, + "hasOutcome": false, + "wasAbsent": false, + "notes": "Phone call from Stuart to say that he is sorry for not attending. He sounded under the influence of alcohol. He has recently heard that his ex partner is getting remarried and stated that he “went on a bit of a bender” He states that he has not attempted to contact the victim but I will make enquiries with the police to confirm this as given that he is audibly drunk i don't think i can take his word for it. I asked him to come in and see me at 9am in the morning and reiterated that if he turns up drunk I will have to mark that as another failure to comply due to behavioural issues. This will result in him being in breach of his order. I reiterated that up until now he has been doing so well with his probation and it’s really important he remember and make use of all the tools and techniques we had discussed during his RAR sessions.\n\nHe agreed to see me at 9am as his next shift at Timpsons isn’t until the afternoon. He apologized many times for “letting me down” and has confirmed that he has put a reminder on his phone about the meeting.", + "isAppointment": true, + "isCommunication": false, + "isPhoneCallFromPop": false, + "officerName": { + "forename": "Terry", + "surname": "Jones" + }, + "lastUpdated": "2023-03-20", + "lastUpdatedBy": { + "forename": "Paul", + "surname": "Smith" + } + }, + { + "id": 12, + "type": "Pre-Intervention Session 1", + "startDateTime": "2025-01-25T09:15:00.382936Z[Europe/London]", + "endDateTime": "2025-01-25T09:30:00.382936Z[Europe/London]", + "rarToolKit": "Choices and Changes", + "rarCategory": "Stepping Stones", + "isSensitive": false, + "hasOutcome": false, + "didTheyComply": true, + "wasAbsent": false, + "complied": true, + "notes": "Phone call from Stuart to say that he is sorry for not attending. He sounded under the influence of alcohol. He has recently heard that his ex partner is getting remarried and stated that he “went on a bit of a bender” He states that he has not attempted to contact the victim but I will make enquiries with the police to confirm this as given that he is audibly drunk i don't think i can take his word for it. I asked him to come in and see me at 9am in the morning and reiterated that if he turns up drunk I will have to mark that as another failure to comply due to behavioural issues. This will result in him being in breach of his order. I reiterated that up until now he has been doing so well with his probation and it’s really important he remember and make use of all the tools and techniques we had discussed during his RAR sessions.\n\nHe agreed to see me at 9am as his next shift at Timpsons isn’t until the afternoon. He apologized many times for “letting me down” and has confirmed that he has put a reminder on his phone about the meeting.", + "isAppointment": true, + "isCommunication": false, + "isPhoneCallFromPop": false, + "officerName": { + "forename": "Terry", + "surname": "Jones" + }, + "lastUpdated": "2023-03-20", + "lastUpdatedBy": { + "forename": "Paul", + "surname": "Smith" + } + }, + { + "id": 13, + "type": "Initial Appointment - Home Visit (NS)", + "startDateTime": "2025-01-25T09:15:00.382936Z[Europe/London]", + "endDateTime": "2025-01-25T09:30:00.382936Z[Europe/London]", + "rarToolKit": "Choices and Changes", + "rarCategory": "Stepping Stones", + "isSensitive": false, + "hasOutcome": false, + "complied": false, + "didTheyComply": false, + "wasAbsent": true, + "acceptableAbsence": false, + "nonComplianceReason": "Unacceptable absence", + "notes": "Phone call from Stuart to say that he is sorry for not attending. He sounded under the influence of alcohol. He has recently heard that his ex partner is getting remarried and stated that he “went on a bit of a bender” He states that he has not attempted to contact the victim but I will make enquiries with the police to confirm this as given that he is audibly drunk i don't think i can take his word for it. I asked him to come in and see me at 9am in the morning and reiterated that if he turns up drunk I will have to mark that as another failure to comply due to behavioural issues. This will result in him being in breach of his order. I reiterated that up until now he has been doing so well with his probation and it’s really important he remember and make use of all the tools and techniques we had discussed during his RAR sessions.\n\nHe agreed to see me at 9am as his next shift at Timpsons isn’t until the afternoon. He apologized many times for “letting me down” and has confirmed that he has put a reminder on his phone about the meeting.", + "isAppointment": true, + "isCommunication": false, + "isPhoneCallFromPop": false, + "officerName": { + "forename": "Terry", + "surname": "Jones" + }, + "lastUpdated": "2023-03-20", + "lastUpdatedBy": { + "forename": "Paul", + "surname": "Smith" + } + } + ] + }, + "headers": { + "Content-Type": "application/json" + } + } + }, + { + "request": { + "method": "POST", + "urlPathPattern": "/mas/activity/X000001", + "bodyPatterns": [ + { + "equalToJson": "{\"keywords\": \"\", \"dateFrom\": \"\", \"dateTo\": \"\", \"filters\": [\"noOutcome\", \"complied\"]}" + } + ] + }, + "response": { + "status": 200, + "jsonBody": { + "size": 10, + "page": 0, + "totalResults": 2, + "totalPages": 1, + "personSummary": { + "name": { + "forename": "Eula", + "surname": "Schmeler" + }, + "crn": "X000001", + "dateOfBirth": "1979-08-18" + }, + "activities": [ + { + "id": 11, + "type": "AP PA - Attitudes, thinking & behaviours", + "startDateTime": "2025-01-25T09:15:00.382936Z[Europe/London]", + "endDateTime": "2025-01-25T09:30:00.382936Z[Europe/London]", + "rarToolKit": "Choices and Changes", + "rarCategory": "Stepping Stones", + "isSensitive": false, + "hasOutcome": false, + "wasAbsent": false, + "notes": "Phone call from Stuart to say that he is sorry for not attending. He sounded under the influence of alcohol. He has recently heard that his ex partner is getting remarried and stated that he “went on a bit of a bender” He states that he has not attempted to contact the victim but I will make enquiries with the police to confirm this as given that he is audibly drunk i don't think i can take his word for it. I asked him to come in and see me at 9am in the morning and reiterated that if he turns up drunk I will have to mark that as another failure to comply due to behavioural issues. This will result in him being in breach of his order. I reiterated that up until now he has been doing so well with his probation and it’s really important he remember and make use of all the tools and techniques we had discussed during his RAR sessions.\n\nHe agreed to see me at 9am as his next shift at Timpsons isn’t until the afternoon. He apologized many times for “letting me down” and has confirmed that he has put a reminder on his phone about the meeting.", + "isAppointment": true, + "isCommunication": false, + "isPhoneCallFromPop": false, + "officerName": { + "forename": "Terry", + "surname": "Jones" + }, + "lastUpdated": "2023-03-20", + "lastUpdatedBy": { + "forename": "Paul", + "surname": "Smith" + } + }, + { + "id": 12, + "type": "Pre-Intervention Session 1", + "startDateTime": "2025-01-25T09:15:00.382936Z[Europe/London]", + "endDateTime": "2025-01-25T09:30:00.382936Z[Europe/London]", + "rarToolKit": "Choices and Changes", + "rarCategory": "Stepping Stones", + "isSensitive": false, + "hasOutcome": false, + "didTheyComply": true, + "wasAbsent": false, + "complied": true, + "notes": "Phone call from Stuart to say that he is sorry for not attending. He sounded under the influence of alcohol. He has recently heard that his ex partner is getting remarried and stated that he “went on a bit of a bender” He states that he has not attempted to contact the victim but I will make enquiries with the police to confirm this as given that he is audibly drunk i don't think i can take his word for it. I asked him to come in and see me at 9am in the morning and reiterated that if he turns up drunk I will have to mark that as another failure to comply due to behavioural issues. This will result in him being in breach of his order. I reiterated that up until now he has been doing so well with his probation and it’s really important he remember and make use of all the tools and techniques we had discussed during his RAR sessions.\n\nHe agreed to see me at 9am as his next shift at Timpsons isn’t until the afternoon. He apologized many times for “letting me down” and has confirmed that he has put a reminder on his phone about the meeting.", + "isAppointment": true, + "isCommunication": false, + "isPhoneCallFromPop": false, + "officerName": { + "forename": "Terry", + "surname": "Jones" + }, + "lastUpdated": "2023-03-20", + "lastUpdatedBy": { + "forename": "Paul", + "surname": "Smith" + } + } + ] + }, + "headers": { + "Content-Type": "application/json" + } + } + }, + { + "request": { + "method": "POST", + "urlPathPattern": "/mas/activity/X000001", + "bodyPatterns": [ + { + "equalToJson": "{\"keywords\": \"\", \"dateFrom\": \"\", \"dateTo\": \"\", \"filters\": [\"noOutcome\"]}" + } + ] + }, + "response": { + "status": 200, + "jsonBody": { + "size": 10, + "page": 0, + "totalResults": 1, + "totalPages": 1, + "personSummary": { + "name": { + "forename": "Eula", + "surname": "Schmeler" + }, + "crn": "X000001", + "dateOfBirth": "1979-08-18" + }, + "activities": [ + { + "id": 11, + "type": "AP PA - Attitudes, thinking & behaviours", + "startDateTime": "2025-01-25T09:15:00.382936Z[Europe/London]", + "endDateTime": "2025-01-25T09:30:00.382936Z[Europe/London]", + "rarToolKit": "Choices and Changes", + "rarCategory": "Stepping Stones", + "isSensitive": false, + "hasOutcome": false, + "wasAbsent": false, + "notes": "Phone call from Stuart to say that he is sorry for not attending. He sounded under the influence of alcohol. He has recently heard that his ex partner is getting remarried and stated that he “went on a bit of a bender” He states that he has not attempted to contact the victim but I will make enquiries with the police to confirm this as given that he is audibly drunk i don't think i can take his word for it. I asked him to come in and see me at 9am in the morning and reiterated that if he turns up drunk I will have to mark that as another failure to comply due to behavioural issues. This will result in him being in breach of his order. I reiterated that up until now he has been doing so well with his probation and it’s really important he remember and make use of all the tools and techniques we had discussed during his RAR sessions.\n\nHe agreed to see me at 9am as his next shift at Timpsons isn’t until the afternoon. He apologized many times for “letting me down” and has confirmed that he has put a reminder on his phone about the meeting.", + "isAppointment": true, + "isCommunication": false, + "isPhoneCallFromPop": false, + "officerName": { + "forename": "Terry", + "surname": "Jones" + }, + "lastUpdated": "2023-03-20", + "lastUpdatedBy": { + "forename": "Paul", + "surname": "Smith" + } } ] },