Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions app/assets/javascript/close-clinic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Close clinic page - page-specific enhancements on top of
// fragment-actions.js, which already handles the single outcome links
// (marked data-fragment-action in the row macro). This file adds the parts
// with wider effects: bulk actions, revealing the refresh hint when counts
// go stale, and refreshing a row after its details modal saves.

import { refreshFragment } from './fragment-actions.js'

document.addEventListener('DOMContentLoaded', () => {
const container = document.getElementById('js-close-clinic-content')
if (!container) return

const clinicId = container.dataset.clinicId
const fetchOptions = { headers: { 'X-Requested-With': 'XMLHttpRequest' } }

// Counts in the card headings and inset text aren't updated in place -
// this link invites a refresh instead
const showRefreshLink = () => {
const link = container.querySelector('.js-refresh-link')
if (link) link.hidden = false
}

// Any swapped row means the page counts may be stale
container.addEventListener('fragment:swapped', showRefreshLink)

const rowFor = (appointmentId) =>
container.querySelector(`tr[data-fragment-id="${appointmentId}"]`)

// Re-fetch one row and swap it in place
const refreshRow = (row) => {
const showActions = row.closest('table')?.dataset.showActions || 'false'
const url = `/clinics/${clinicId}/close/appointment-row/${row.dataset.fragmentId}?showActions=${showActions}`
return refreshFragment(row, url)
}

// Bulk outcome change - refresh each affected row, then swap the button
// and its undo message over
const handleBulkClick = (link) => {
const bulkContainer = link.closest('.js-bulk-action-container')
const isUndo = Boolean(link.closest('.js-bulk-undo-message'))

fetch(link.href, fetchOptions)
.then((response) => {
if (!response.ok) throw new Error('Request failed')
return response.json()
})
.then((result) => {
const rows = result.appointmentIds.map(rowFor).filter(Boolean)
return Promise.all(rows.map(refreshRow)).then(() => result)
})
.then((result) => {
bulkContainer.querySelector('.nhsuk-button').hidden = !isUndo
const undoMessage = bulkContainer.querySelector('.js-bulk-undo-message')
undoMessage.hidden = isUndo
if (!isUndo) {
undoMessage.querySelector('.js-bulk-count').textContent =
result.count === 1 ? '1 participant' : `${result.count} participants`
}
showRefreshLink()
})
.catch(() => {
window.location.href = link.href
})
}

container.addEventListener('click', (event) => {
const bulkLink = event.target.closest('.js-bulk-action')
if (bulkLink) {
event.preventDefault()
handleBulkClick(bulkLink)
return
}

// Details links open in a modal (attributes added by the openInModal
// filter). Take over from the global handler in modal.js so the row can
// be refreshed in place when the modal form saves.
const modalLink = event.target.closest('[data-load-modal-url]')
if (modalLink) {
event.preventDefault()
event.stopPropagation()
const appointmentId = modalLink.closest('tr')?.dataset.fragmentId
window.openModal(modalLink.dataset.modalId || 'app-form-modal', {
loadUrl: modalLink.dataset.loadModalUrl,
onSuccess: () => {
const row = rowFor(appointmentId)
if (!row) return window.location.reload()
refreshRow(row).catch(() => window.location.reload())
}
})
}
})
})
85 changes: 85 additions & 0 deletions app/assets/javascript/fragment-actions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// app/assets/javascript/fragment-actions.js
//
// Progressive enhancement for in-place status updates. Mark a link or form
// with data-fragment-action and wrap the markup it changes in an element
// with a unique data-fragment-id. The action is sent with fetch, and the
// server responds with a re-rendered copy of that element (built from the
// same Nunjucks macro the page used), which is swapped in place - so tags,
// labels and action links can't drift from the server's rendering. Routes
// detect these requests with req.xhr and render the fragment view instead
// of redirecting. Without JS, or on any failure, the link or form falls
// back to a normal navigation.
//
// A bubbling fragment:swapped event fires on each replacement element, for
// pages that need to react (eg revealing a 'refresh to update counts' hint).

const fetchOptions = { headers: { 'X-Requested-With': 'XMLHttpRequest' } }

// Swap target for the fragment contained in html, verifying the ids match
// so an unexpected response (eg a redirect to a full page) never gets
// injected into the table
export const swapFragment = (target, html) => {
const template = document.createElement('template')
template.innerHTML = html.trim()
const replacement = template.content.querySelector('[data-fragment-id]')
if (!replacement || replacement.dataset.fragmentId !== target.dataset.fragmentId) {
throw new Error('Response was not the expected fragment')
}
target.replaceWith(replacement)
replacement.dispatchEvent(
new CustomEvent('fragment:swapped', {
bubbles: true,
detail: { fragment: replacement }
})
)
return replacement
}

// Fetch a fragment URL and swap the response into target
export const refreshFragment = (target, url) =>
fetch(url, fetchOptions)
.then((response) => {
if (!response.ok) throw new Error('Failed to fetch fragment')
return response.text()
})
.then((html) => swapFragment(target, html))

// GET actions: <a data-fragment-action href="...">
document.addEventListener('click', (event) => {
const link = event.target.closest('a[data-fragment-action]')
if (!link) return
const target = link.closest('[data-fragment-id]')
if (!target) return

event.preventDefault()
fetch(link.href, fetchOptions)
.then((response) => {
if (!response.ok) throw new Error('Request failed')
return response.text()
})
.then((html) => swapFragment(target, html))
.catch(() => {
window.location.href = link.href
})
})

// POST actions: <form data-fragment-action>
document.addEventListener('submit', (event) => {
const form = event.target.closest('form[data-fragment-action]')
if (!form) return
const target = form.closest('[data-fragment-id]')
if (!target) return

event.preventDefault()
fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
body: new URLSearchParams(new FormData(form)),
headers: fetchOptions.headers
})
.then((response) => {
if (!response.ok) throw new Error('Request failed')
return response.text()
})
.then((html) => swapFragment(target, html))
.catch(() => form.submit())
})
92 changes: 24 additions & 68 deletions app/assets/javascript/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,92 +2,48 @@

// ES6 or Vanilla JavaScript

import { swapFragment } from './fragment-actions.js'

document.addEventListener('DOMContentLoaded', () => {
// Inline check in without requiring page reload
// Inline check-in without a page reload. The server responds with the
// re-rendered appointment row (see the check-in route), which replaces the
// old one. Handled here rather than by fragment-actions.js directly because
// the trigger can be a button inside the confirm-identity modal, which
// lives inside the row being replaced.
const checkInLinks = document.querySelectorAll('.js-check-in-link')

checkInLinks.forEach((link) => {
link.addEventListener('click', async (e) => {
e.preventDefault()
const link = e.currentTarget
const clinicId = link.dataset.clinicId
const appointmentId = link.dataset.appointmentId
const statusTagId = link.dataset.statusTagId
const { clinicId, appointmentId } = e.currentTarget.dataset
const url = `/clinics/${clinicId}/check-in/${appointmentId}`

try {
const response = await fetch(
`/clinics/${clinicId}/check-in/${appointmentId}`,
{
method: 'GET',
headers: {
Accept: 'application/json'
}
}
)

const response = await fetch(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
if (!response.ok) {
throw new Error('Failed to check in participant')
}
const html = await response.text()

// Update the status tag if we have an ID
if (statusTagId) {
const statusTag = document.getElementById(statusTagId)
if (statusTag) {
// Update the existing tag's text and classes
statusTag.textContent = 'Checked in'
statusTag.className = 'nhsuk-tag app-nowrap'
}
}

// Show the start appointment link by removing the hidden class
const appointmentRow = document.getElementById(`appointment-row-${appointmentId}`)
if (appointmentRow) {
const startAppointmentLink = appointmentRow.querySelector(
'.js-start-appointment-link'
)
if (startAppointmentLink) {
startAppointmentLink.classList.remove('app-display-none')
}

// Set focus on the row for accessibility
appointmentRow.setAttribute('tabindex', '-1')
appointmentRow.focus()
}

// Remove the check-in link
// Check if this is a modal button or a direct link
const isModalButton = link.closest('.app-modal')

if (isModalButton) {
// For modal buttons, find the original check-in link on the main page
// Look for a link that opens the modal for this specific appointment
const modalId = `check-in-modal-${appointmentId}`
const originalCheckInLink = document.querySelector(
`a[onclick*="openModal('${modalId}')"]`
)

if (originalCheckInLink) {
const checkInParagraph = originalCheckInLink.closest('p')
if (checkInParagraph) {
checkInParagraph.remove()
}
}
} else {
// For direct links, remove the paragraph containing the link
const checkInParagraph = link.closest('p')
if (checkInParagraph) {
checkInParagraph.remove()
}
}

// Close any open modal (for modal-based check-ins)
// Close the confirm-identity modal before the swap - its markup
// lives inside the row that's about to be replaced
const openModal = document.querySelector('.app-modal:not([hidden])')
if (openModal && window.closeModal) {
window.closeModal(openModal.id)
}

const row = document.querySelector(`tr[data-fragment-id="${appointmentId}"]`)
if (!row) throw new Error('Appointment row not found')
const newRow = swapFragment(row, html)

// Set focus on the row for accessibility
newRow.setAttribute('tabindex', '-1')
newRow.focus()
} catch (error) {
console.error('Error checking in participant:', error)
window.location.href = link.href
window.location.href = url
}
})
})
Expand Down
8 changes: 5 additions & 3 deletions app/assets/javascript/modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -717,10 +717,12 @@ class AppModal {
this.close()
window.location.href = finalUrl
} else {
// Flow complete — close and refresh
// Flow complete — close and refresh. Capture the callback before
// close(), which resets it.
const onSuccess = this._onSuccessCallback
this.close()
if (this._onSuccessCallback) {
this._onSuccessCallback()
if (onSuccess) {
onSuccess()
} else {
window.location.reload()
}
Expand Down
1 change: 1 addition & 0 deletions app/assets/sass/_app-styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
@forward "components/secondary-navigation-overrides";
@forward "components/count";
@forward "components/card";
@forward "components/clinic-appointments-table";
@forward "components/forward-link";
@forward "components/status";
@forward "components/status-bar";
Expand Down
10 changes: 10 additions & 0 deletions app/assets/sass/components/_card.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,13 @@
background-color: nhsuk-colour("grey-1");
color: nhsuk-colour("white");
}

.nhsuk-card--feature.app-card--feature-orange .nhsuk-card__heading {
background-color: nhsuk-colour("orange");
color: nhsuk-colour("white");
}

.nhsuk-card--feature.app-card--feature-green .nhsuk-card__heading {
background-color: nhsuk-colour("green");
color: nhsuk-colour("white");
}
25 changes: 25 additions & 0 deletions app/assets/sass/components/_clinic-appointments-table.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// app/assets/sass/components/_clinic-appointments-table.scss
// Appointment tables on the clinic pages (clinic view and close clinic)

.app-clinic-appointments-table {
table-layout: fixed;
width: 100%;

.app-clinic-appointments-table__time-column {
width: 22%;
}

.app-clinic-appointments-table__status-column {
width: 25%;
}

.app-clinic-appointments-table__actions-column {
width: 30%;
}
}

// Flush variant - sits at the bottom of a card, which provides the spacing,
// so drop the final row border
.app-clinic-appointments-table--flush .nhsuk-table__body tr:last-child .nhsuk-table__cell {
border-bottom: 0;
}
7 changes: 1 addition & 6 deletions app/assets/sass/components/_compact.scss
Original file line number Diff line number Diff line change
Expand Up @@ -273,12 +273,6 @@
}

.app-clinic-appointments-table {
table-layout: fixed;
width: 100%;

.app-clinic-appointments-table__time-column {
width: 22%;
}

.app-clinic-appointments-table__time-column .nhsuk-tag {
display: inline-block;
Expand Down Expand Up @@ -428,3 +422,4 @@
@include nhsuk-font($size: 22, $weight: bold);
}
}

8 changes: 8 additions & 0 deletions app/lib/generators/appointment-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const weighted = require('weighted')
const dayjs = require('dayjs')
const config = require('../../config')
const { STATUS_GROUPS, isCompleted } = require('../utils/status')
const { getStoppedReasons } = require('../utils/appointment-data')
const { generateMammogramImages } = require('./mammogram-generator')
const {
generateMedicalInformation
Expand Down Expand Up @@ -394,6 +395,13 @@ const generateAppointment = ({
startedBy: randomUser.id,
endedAt: actualEndTime.toISOString()
}

// Stopping an appointment in the appointment flow requires reasons, so
// seeded attended-not-screened appointments always have them
appointment.appointmentStopped = {
stoppedReason: [faker.helpers.arrayElement(getStoppedReasons()).value],
needsReschedule: faker.helpers.arrayElement(['no-invite', 'no-invite', 'yes'])
}
}

// Select image set for appointments with mammogram data
Expand Down
Loading