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:
Maxim Mordasov 2023-08-04 17:46:54 +03:00 committed by GitHub
parent ceeb3b8f5b
commit 8412282569
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 160 additions and 39 deletions

View file

@ -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

View file

@ -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}

View file

@ -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) => {

View file

@ -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[]);

View file

@ -120,6 +120,7 @@ export interface Layer {
export interface ShiftEvents {
shiftId: string;
events: Event[];
priority: number;
isPreview?: boolean;
}

View file

@ -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,