merge final schedule in as less lines as possible (#2649)
# What this PR does Merge final schedule in less rows ## Which issue(s) this PR fixes [Final schedule shifts should lay in one line](https://github.com/grafana/oncall/issues/1665) ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required)
This commit is contained in:
parent
ceeb3b8f5b
commit
8412282569
6 changed files with 160 additions and 39 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<RotationProps> = (props) => {
|
|||
scheduleId,
|
||||
startMoment,
|
||||
currentTimezone,
|
||||
color,
|
||||
color: propsColor,
|
||||
days = 7,
|
||||
transparent = false,
|
||||
tutorialParams,
|
||||
|
|
@ -52,6 +53,7 @@ const Rotation: FC<RotationProps> = (props) => {
|
|||
onShiftSwapClick,
|
||||
simplified,
|
||||
filters,
|
||||
getColor,
|
||||
onSlotClick,
|
||||
} = props;
|
||||
|
||||
|
|
@ -113,7 +115,7 @@ const Rotation: FC<RotationProps> = (props) => {
|
|||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className={cx('root')} onClick={handleRotationClick}>
|
||||
<div className={cx('root')} onClick={onClick && handleRotationClick}>
|
||||
<div className={cx('timeline')}>
|
||||
{tutorialParams && <RotationTutorial startMoment={startMoment} {...tutorialParams} />}
|
||||
{events ? (
|
||||
|
|
@ -130,7 +132,7 @@ const Rotation: FC<RotationProps> = (props) => {
|
|||
event={event}
|
||||
startMoment={startMoment}
|
||||
currentTimezone={currentTimezone}
|
||||
color={color}
|
||||
color={propsColor || getColor(event.shift?.pk)}
|
||||
handleAddOverride={getAddOverrideClickHandler(event)}
|
||||
handleAddShiftSwap={getAddShiftSwapClickHandler(event)}
|
||||
onShiftSwapClick={onShiftSwapClick}
|
||||
|
|
|
|||
|
|
@ -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<ScheduleFinalProps, ScheduleOverridesState
|
|||
|
||||
const currentTimeX = diff / base;
|
||||
|
||||
const shifts = getShiftsFromStore(store, scheduleId, startMoment);
|
||||
const shifts = flattenFinalShifs(getShiftsFromStore(store, scheduleId, startMoment));
|
||||
|
||||
const layers = getLayersFromStore(store, scheduleId, startMoment);
|
||||
|
||||
|
|
@ -63,6 +68,8 @@ class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState
|
|||
|
||||
const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1;
|
||||
|
||||
const getColor = (shiftId: Shift['id']) => findColor(shiftId, layers, overrides);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
|
|
@ -82,7 +89,7 @@ class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState
|
|||
<TimelineMarks startMoment={startMoment} timezone={currentTimezone} />
|
||||
<TransitionGroup className={cx('rotations')}>
|
||||
{shifts && shifts.length ? (
|
||||
shifts.map(({ shiftId, events }, index) => {
|
||||
shifts.map(({ events }, index) => {
|
||||
return (
|
||||
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
|
||||
<Rotation
|
||||
|
|
@ -91,13 +98,12 @@ class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState
|
|||
events={events}
|
||||
startMoment={startMoment}
|
||||
currentTimezone={currentTimezone}
|
||||
color={findColor(shiftId, layers, overrides)}
|
||||
onClick={this.getRotationClickHandler(shiftId)}
|
||||
handleAddOverride={this.handleShowOverrideForm}
|
||||
handleAddShiftSwap={onShowShiftSwapForm}
|
||||
onShiftSwapClick={onShowShiftSwapForm}
|
||||
simplified={simplified}
|
||||
filters={filters}
|
||||
getColor={getColor}
|
||||
onSlotClick={onSlotClick}
|
||||
/>
|
||||
</CSSTransition>
|
||||
|
|
@ -120,18 +126,6 @@ class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState
|
|||
);
|
||||
}
|
||||
|
||||
getRotationClickHandler = (shiftId: Shift['id']) => {
|
||||
const { onClick, disabled } = this.props;
|
||||
|
||||
return () => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClick(shiftId);
|
||||
};
|
||||
};
|
||||
|
||||
onSearchTermChangeCallback = () => {};
|
||||
|
||||
handleShowOverrideForm = (shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => {
|
||||
|
|
|
|||
|
|
@ -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[]);
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ export interface Layer {
|
|||
export interface ShiftEvents {
|
||||
shiftId: string;
|
||||
events: Event[];
|
||||
priority: number;
|
||||
isPreview?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -283,12 +283,17 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
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);
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Rotations
|
||||
scheduleId={scheduleId}
|
||||
|
|
@ -303,7 +308,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
disabled={disabledRotationForm}
|
||||
filters={filters}
|
||||
onShowShiftSwapForm={this.handleShowShiftSwapForm}
|
||||
onSlotClick={shiftSwapIdToShowForm ? this.onSlotClick : undefined}
|
||||
onSlotClick={shiftSwapIdToShowForm ? this.adjustShiftSwapForm : undefined}
|
||||
/>
|
||||
<ScheduleOverrides
|
||||
scheduleId={scheduleId}
|
||||
|
|
@ -595,7 +600,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
this.setState({ shiftSwapIdToShowForm: undefined, shiftSwapParamsToShowForm: undefined });
|
||||
};
|
||||
|
||||
onSlotClick = (event: Event) => {
|
||||
adjustShiftSwapForm = (event: Event) => {
|
||||
this.setState({
|
||||
shiftSwapParamsToShowForm: {
|
||||
...this.state.shiftSwapParamsToShowForm,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue