diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d112992af..57067135c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Shift Swap Requests Web UI ([#2593](https://github.com/grafana/oncall/issues/2593)) +- Final schedule shifts should lay in one line [1665](https://github.com/grafana/oncall/issues/1665) ### Changed diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index 6fa5836fec..486c988fd3 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -7,7 +7,7 @@ import hash from 'object-hash'; import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.types'; import ScheduleSlot from 'containers/ScheduleSlot/ScheduleSlot'; -import { Schedule, Event, RotationFormLiveParams, ShiftSwap } from 'models/schedule/schedule.types'; +import { Schedule, Event, RotationFormLiveParams, Shift, ShiftSwap } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import RotationTutorial from './RotationTutorial'; @@ -33,6 +33,7 @@ interface RotationProps { tutorialParams?: RotationFormLiveParams; simplified?: boolean; filters?: ScheduleFiltersType; + getColor?: (shiftId: Shift['id']) => string; onSlotClick?: (event: Event) => void; } @@ -42,7 +43,7 @@ const Rotation: FC = (props) => { scheduleId, startMoment, currentTimezone, - color, + color: propsColor, days = 7, transparent = false, tutorialParams, @@ -52,6 +53,7 @@ const Rotation: FC = (props) => { onShiftSwapClick, simplified, filters, + getColor, onSlotClick, } = props; @@ -113,7 +115,7 @@ const Rotation: FC = (props) => { }, [events]); return ( -
+
{tutorialParams && } {events ? ( @@ -130,7 +132,7 @@ const Rotation: FC = (props) => { event={event} startMoment={startMoment} currentTimezone={currentTimezone} - color={color} + color={propsColor || getColor(event.shift?.pk)} handleAddOverride={getAddOverrideClickHandler(event)} handleAddShiftSwap={getAddShiftSwapClickHandler(event)} onShiftSwapClick={onShiftSwapClick} diff --git a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx index f7b90a2c72..59685916d2 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx @@ -10,7 +10,12 @@ import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters. import Text from 'components/Text/Text'; import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; import Rotation from 'containers/Rotation/Rotation'; -import { getLayersFromStore, getOverridesFromStore, getShiftsFromStore } from 'models/schedule/schedule.helpers'; +import { + flattenFinalShifs, + getLayersFromStore, + getOverridesFromStore, + getShiftsFromStore, +} from 'models/schedule/schedule.helpers'; import { Schedule, Shift, ShiftSwap, Event } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import { WithStoreProps } from 'state/types'; @@ -55,7 +60,7 @@ class ScheduleFinal extends Component 1; + const getColor = (shiftId: Shift['id']) => findColor(shiftId, layers, overrides); + return ( <>
@@ -82,7 +89,7 @@ class ScheduleFinal extends Component {shifts && shifts.length ? ( - shifts.map(({ shiftId, events }, index) => { + shifts.map(({ events }, index) => { return ( @@ -120,18 +126,6 @@ class ScheduleFinal extends Component { - const { onClick, disabled } = this.props; - - return () => { - if (disabled) { - return; - } - - onClick(shiftId); - }; - }; - onSearchTermChangeCallback = () => {}; handleShowOverrideForm = (shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => { diff --git a/grafana-plugin/src/models/schedule/schedule.helpers.ts b/grafana-plugin/src/models/schedule/schedule.helpers.ts index 21d4e39667..4112538801 100644 --- a/grafana-plugin/src/models/schedule/schedule.helpers.ts +++ b/grafana-plugin/src/models/schedule/schedule.helpers.ts @@ -8,6 +8,23 @@ export const getFromString = (moment: dayjs.Dayjs) => { return moment.format('YYYY-MM-DD'); }; +const createGap = (start, end) => { + return { + start, + end, + is_gap: true, + users: [], + all_day: false, + shift: null, + missing_users: [], + is_empty: true, + calendar_type: ScheduleType.API, + priority_level: null, + source: 'web', + is_override: false, + }; +}; + export const fillGaps = (events: Event[]) => { const newEvents = []; @@ -18,19 +35,7 @@ export const fillGaps = (events: Event[]) => { if (nextEvent) { if (nextEvent.start !== event.end) { - newEvents.push({ - start: event.end, - end: nextEvent.start, - is_gap: true, - users: [], - all_day: false, - shift: null, - missing_users: [], - is_empty: true, - calendar_type: ScheduleType.API, - priority_level: null, - source: 'web', - }); + newEvents.push(createGap(event.end, nextEvent.start)); } } } @@ -69,6 +74,119 @@ export const getShiftsFromStore = ( : (store.scheduleStore.events[scheduleId]?.['final']?.[getFromString(startMoment)] as any); }; +export const flattenFinalShifs = (shifts: ShiftEvents[]) => { + if (!shifts) { + return undefined; + } + + function splitToPairs(shifts: ShiftEvents[]) { + const pairs = []; + for (let i = 0; i < shifts.length - 1; i++) { + for (let j = i + 1; j < shifts.length; j++) { + pairs.push([ + { ...shifts[i], events: [...shifts[i].events] }, + { ...shifts[j], events: [...shifts[j].events] }, + ]); + } + } + + return pairs; + } + + let pairs = splitToPairs(shifts); + + while (pairs.length > 0) { + const currentPair = pairs.shift(); + + const merged = mergePair(currentPair); + + if (merged !== currentPair) { + // means pair was fully merged + + shifts = shifts.filter((shift) => !currentPair.some((pairShift) => pairShift.shiftId === shift.shiftId)); + shifts.unshift(merged[0]); + pairs = splitToPairs(shifts); + } + } + + function mergePair(pair: ShiftEvents[]): ShiftEvents[] { + const recipient = { ...pair[0], events: [...pair[0].events] }; + const donor = pair[1]; + + const donorEvents = donor.events.filter((event) => !event.is_gap); + + for (let i = 0; i < donorEvents.length; i++) { + const donorEvent = donorEvents[i]; + + const eventStartMoment = dayjs(donorEvent.start); + const eventEndMoment = dayjs(donorEvent.end); + + const suitablerRecepientGapIndex = recipient.events.findIndex((event) => { + if (!event.is_gap) { + return false; + } + + const gap = event; + + const gapStartMoment = dayjs(gap.start); + const gapEndMoment = dayjs(gap.end); + + return gapStartMoment.isSameOrBefore(eventStartMoment) && gapEndMoment.isSameOrAfter(eventEndMoment); + }); + + if (suitablerRecepientGapIndex > -1) { + const suitablerRecepientGap = recipient.events[suitablerRecepientGapIndex]; + + const itemsToAdd = []; + const leftGap = createGap(suitablerRecepientGap.start, donorEvent.start); + if (leftGap.start !== leftGap.end) { + itemsToAdd.push(leftGap); + } + itemsToAdd.push(donorEvent); + + const rightGap = createGap(donorEvent.end, suitablerRecepientGap.end); + if (rightGap.start !== rightGap.end) { + itemsToAdd.push(rightGap); + } + + recipient.events = [ + ...recipient.events.slice(0, suitablerRecepientGapIndex), + ...itemsToAdd, + ...recipient.events.slice(suitablerRecepientGapIndex + 1), + ]; + } else { + const firstRecepientEvent = recipient.events[0]; + const firstRecepientEventStartMoment = dayjs(firstRecepientEvent.start); + + const lastRecepientEvent = recipient.events[recipient.events.length - 1]; + const lastRecepientEventEndMoment = dayjs(lastRecepientEvent.end); + + if (eventEndMoment.isSameOrBefore(firstRecepientEventStartMoment)) { + const itemsToAdd = [donorEvent]; + if (donorEvent.end !== firstRecepientEvent.start) { + itemsToAdd.push(createGap(donorEvent.end, firstRecepientEvent.start)); + } + recipient.events = [...itemsToAdd, ...recipient.events]; + } else if (eventStartMoment.isSameOrAfter(lastRecepientEventEndMoment)) { + const itemsToAdd = [donorEvent]; + if (lastRecepientEvent.end !== donorEvent.start) { + itemsToAdd.unshift(createGap(lastRecepientEvent.end, donorEvent.start)); + } + recipient.events = [...recipient.events, ...itemsToAdd]; + } else { + // the pair can't be fully merged + + return pair; + } + } + } + + return [recipient]; + } + + return shifts; +}; + export const getLayersFromStore = (store: RootStore, scheduleId: Schedule['id'], startMoment: dayjs.Dayjs): Layer[] => { return store.scheduleStore.rotationPreview ? store.scheduleStore.rotationPreview[getFromString(startMoment)] @@ -79,7 +197,7 @@ export const getOverridesFromStore = ( store: RootStore, scheduleId: Schedule['id'], startMoment: dayjs.Dayjs -): Layer[] | ShiftEvents[] => { +): ShiftEvents[] => { return store.scheduleStore.overridePreview ? store.scheduleStore.overridePreview[getFromString(startMoment)] : (store.scheduleStore.events[scheduleId]?.['override']?.[getFromString(startMoment)] as Layer[]); diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index 9107e28b20..0e57de6419 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -120,6 +120,7 @@ export interface Layer { export interface ShiftEvents { shiftId: string; events: Event[]; + priority: number; isPreview?: boolean; } diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 9b3e297d4b..b8c56e83cc 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -283,12 +283,17 @@ class SchedulePage extends React.Component scheduleId={scheduleId} currentTimezone={currentTimezone} startMoment={startMoment} - onClick={this.handleShowForm} disabled={disabledRotationForm} onShowOverrideForm={this.handleShowOverridesForm} filters={filters} onShowShiftSwapForm={this.handleShowShiftSwapForm} - onSlotClick={shiftSwapIdToShowForm ? this.onSlotClick : undefined} + onSlotClick={ + shiftSwapIdToShowForm + ? this.adjustShiftSwapForm + : (event: Event) => { + this.handleShowForm(event.shift.pk); + } + } /> disabled={disabledRotationForm} filters={filters} onShowShiftSwapForm={this.handleShowShiftSwapForm} - onSlotClick={shiftSwapIdToShowForm ? this.onSlotClick : undefined} + onSlotClick={shiftSwapIdToShowForm ? this.adjustShiftSwapForm : undefined} /> this.setState({ shiftSwapIdToShowForm: undefined, shiftSwapParamsToShowForm: undefined }); }; - onSlotClick = (event: Event) => { + adjustShiftSwapForm = (event: Event) => { this.setState({ shiftSwapParamsToShowForm: { ...this.state.shiftSwapParamsToShowForm,