diff --git a/web/src/app/schedules/temp-sched/TempSchedAddShiftsStep.tsx b/web/src/app/schedules/temp-sched/TempSchedAddShiftsStep.tsx index 0a80eb29db..c4e1f6f230 100644 --- a/web/src/app/schedules/temp-sched/TempSchedAddShiftsStep.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedAddShiftsStep.tsx @@ -5,6 +5,9 @@ import { Grid, Typography, makeStyles, + FormControlLabel, + Checkbox, + FormHelperText, } from '@material-ui/core' import ArrowRightAltIcon from '@material-ui/icons/ArrowRightAlt' import { contentText, Shift, StepContainer } from './sharedUtils' @@ -14,14 +17,23 @@ import TempSchedShiftsList from './TempSchedShiftsList' import TempSchedAddShiftForm from './TempSchedAddShiftForm' import { DateTime, Interval } from 'luxon' import { FieldError } from '../../util/errutil' -import { isISOAfter } from '../../util/shifts' +import { isISOAfter, parseInterval } from '../../util/shifts' +import { Alert, AlertTitle } from '@material-ui/lab' +import { useScheduleTZ } from './hooks' +import { getCoverageGapItems } from './shiftsListUtil' const useStyles = makeStyles((theme) => ({ contentText, avatar: { backgroundColor: theme.palette.primary.main, }, + shiftsListContainer: { + height: '100%', + display: 'flex', + flexDirection: 'column', + }, listOuterContainer: { + height: '100%', position: 'relative', overflowY: 'auto', }, @@ -36,6 +48,10 @@ const useStyles = makeStyles((theme) => ({ maxHeight: '100%', paddingRight: '2rem', }, + noCoverageError: { + marginTop: '.5rem', + marginBottom: '.5rem', + }, })) type AddShiftsStepProps = { @@ -46,6 +62,10 @@ type AddShiftsStepProps = { scheduleID: string edit?: boolean + + coverageGapsAllowed?: boolean + setCoverageGapsAllowed: (isAllowed: boolean) => void + isShowingCoverageGapsWarning: boolean } type DTShift = { @@ -102,10 +122,14 @@ export default function TempSchedAddShiftsStep({ end, value, edit, + coverageGapsAllowed, + setCoverageGapsAllowed, + isShowingCoverageGapsWarning, }: AddShiftsStepProps): JSX.Element { const classes = useStyles() const [shift, setShift] = useState(null as Shift | null) const [submitted, setSubmitted] = useState(false) + const { zone, q } = useScheduleTZ(scheduleID) // set start equal to the temporary schedule's start // can't this do on mount since the step renderer puts everyone on the DOM at once @@ -160,6 +184,12 @@ export default function TempSchedAddShiftsStep({ setSubmitted(false) } + const hasCoverageGaps = (() => { + if (q.loading) return false + const schedInterval = parseInterval({ start: start, end: end }, zone) + return getCoverageGapItems(schedInterval, value, zone).length > 0 + })() + return ( {/* main container for fields | button | shifts */} @@ -211,20 +241,44 @@ export default function TempSchedAddShiftsStep({ {/* shifts list container */} - -
- { - setShift(shift) - onChange(value.filter((s) => !shiftEquals(shift, s))) - }} - edit={edit} - /> + +
+
+ { + setShift(shift) + onChange(value.filter((s) => !shiftEquals(shift, s))) + }} + edit={edit} + /> +
+ {isShowingCoverageGapsWarning && hasCoverageGaps && ( + + Gaps in coverage + + There are gaps in coverage. During these gaps, nobody on the + schedule will receive alerts. If you still want to proceed, + check the box and retry. + + setCoverageGapsAllowed(e.target.checked)} + name='allowCoverageGaps' + /> + } + /> + + )}
diff --git a/web/src/app/schedules/temp-sched/TempSchedDialog.tsx b/web/src/app/schedules/temp-sched/TempSchedDialog.tsx index 2b34900608..430170d0c5 100644 --- a/web/src/app/schedules/temp-sched/TempSchedDialog.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedDialog.tsx @@ -13,6 +13,7 @@ import { parseInterval } from '../../util/shifts' import { DateTime } from 'luxon' import { getNextWeekday } from '../../util/luxon-helpers' import { useScheduleTZ } from './hooks' +import { getCoverageGapItems } from './shiftsListUtil' // allows changing the index programatically const VirtualizeAnimatedViews = virtualize(SwipeableViews) @@ -85,6 +86,28 @@ export default function TempSchedDialog({ }, }) + const [shouldAllowNoCoverage, setShouldAllowNoCoverage] = useState(false) + const [isShowingCoverageGapsWarning, setIsShowingCoverageGapsWarning] = + useState(false) + + const hasCoverageGaps = (() => { + if (q.loading) return false + const schedInterval = parseInterval(value, zone) + return getCoverageGapItems(schedInterval, value.shifts, zone).length > 0 + })() + + const handleSubmit = (): void => { + if (hasCoverageGaps && !shouldAllowNoCoverage) { + setIsShowingCoverageGapsWarning(true) + return + } + if (isShowingCoverageGapsWarning && shouldAllowNoCoverage) { + setIsShowingCoverageGapsWarning(false) + } + + submit() + } + type SlideRenderer = { index: number key: number @@ -111,6 +134,9 @@ export default function TempSchedDialog({ start={value.start} end={value.end} edit={edit} + coverageGapsAllowed={shouldAllowNoCoverage} + setCoverageGapsAllowed={setShouldAllowNoCoverage} + isShowingCoverageGapsWarning={isShowingCoverageGapsWarning} /> ) } @@ -119,13 +145,20 @@ export default function TempSchedDialog({ return
} + const noCoverageErrs = + hasCoverageGaps && isShowingCoverageGapsWarning + ? [new Error('This temporary schedule has gaps in coverage.')] + : [] const nonFieldErrs = nonFieldErrors(error).map((e) => ({ message: e.message, })) const fieldErrs = fieldErrors(error).map((e) => ({ message: `${e.field}: ${e.message}`, })) - const errs = nonFieldErrs.concat(fieldErrs).concat(shiftErrors) + const errs = nonFieldErrs + .concat(fieldErrs) + .concat(shiftErrors) + .concat(noCoverageErrs) return ( } - onSubmit={() => submit()} + onSubmit={handleSubmit} onNext={step === 1 ? null : () => setStep(step + 1)} onBack={(edit ? step === 1 : step === 0) ? null : () => setStep(step - 1)} /> diff --git a/web/src/cypress/integration/temporarySchedule.ts b/web/src/cypress/integration/temporarySchedule.ts index 23d66528a7..2c00b6e94d 100644 --- a/web/src/cypress/integration/temporarySchedule.ts +++ b/web/src/cypress/integration/temporarySchedule.ts @@ -118,11 +118,14 @@ function testTemporarySchedule(screen: string): void { ) cy.get('[data-cy="loading-button"]').contains('Next').click() cy.get(addShiftsSelector).should('be.visible.and.contain', 'STEP 2 OF 2') + cy.get('[data-cy="no-coverage-checkbox"]').should('not.exist') cy.dialogForm({ userID: manualAddUser.name }, addShiftsSelector) cy.get('[data-cy="shifts-list"]').should('not.contain', manualAddUser.name) cy.get('button[data-cy="add-shift"]').click() cy.get('[data-cy="shifts-list"]').should('contain', manualAddUser.name) - cy.dialogFinish('Submit') + cy.get('[data-cy="loading-button"]').contains('Submit').click() + cy.get('[data-cy="no-coverage-checkbox"]').should('be.visible').click() + cy.dialogFinish('Retry') cy.visit('/schedules/' + schedule.id + '?start=' + start.toISO()) cy.get('div').contains('Temporary Schedule').click() cy.get('div[data-cy="shift-tooltip"]').should('be.visible') @@ -167,7 +170,9 @@ function testTemporarySchedule(screen: string): void { ) cy.get('button[data-cy="add-shift"]').click() cy.get('[data-cy="shifts-list"]').should('contain', manualAddUser.name) - cy.dialogFinish('Submit') + cy.get('[data-cy="loading-button"]').contains('Submit').click() + cy.get('[data-cy="no-coverage-checkbox"]').should('be.visible').click() + cy.dialogFinish('Retry') cy.reload() // ensure calendar update cy.get('div').contains(manualAddUser.name).click() cy.get('div[data-cy="shift-tooltip"]').should('be.visible')