diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a81dca4..1f9c1cc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Use shift data from event object + ### Fixed - Update ical schedule creation/update to trigger final schedule refresh ([#3156](https://github.com/grafana/oncall/pull/3156)) +- Polish "Build 'When I am on-call' for web UI" [#2915](https://github.com/grafana/oncall/issues/2915) +- Fix iCal schedule incorrect view [#2001](https://github.com/grafana/oncall-private/issues/2001) +- Fix rotation name rendering issue [#2324](https://github.com/grafana/oncall/issues/2324) ### Changed diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index 97156cc1..29c798a3 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -8,7 +8,7 @@ import hash from 'object-hash'; import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.types'; import Text from 'components/Text/Text'; import ScheduleSlot from 'containers/ScheduleSlot/ScheduleSlot'; -import { Event, RotationFormLiveParams, Shift, ShiftSwap } from 'models/schedule/schedule.types'; +import { Event, RotationFormLiveParams, ShiftSwap } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import RotationTutorial from './RotationTutorial'; @@ -34,7 +34,7 @@ interface RotationProps { tutorialParams?: RotationFormLiveParams; simplified?: boolean; filters?: ScheduleFiltersType; - getColor?: (shiftId: Shift['id']) => string; + getColor?: (event: Event) => string; onSlotClick?: (event: Event) => void; emptyText?: string; showScheduleNameAsSlotTitle?: boolean; @@ -156,7 +156,7 @@ const Rotation: FC = (props) => { event={event} startMoment={startMoment} currentTimezone={currentTimezone} - color={propsColor || getColor(event.shift?.pk)} + color={propsColor || getColor(event)} handleAddOverride={getAddOverrideClickHandler(event)} handleAddShiftSwap={getAddShiftSwapClickHandler(event)} handleOpenSchedule={getOpenScheduleClickHandler(event)} diff --git a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx index d75e6cab..89166869 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx @@ -16,7 +16,7 @@ import { getOverridesFromStore, getShiftsFromStore, } from 'models/schedule/schedule.helpers'; -import { Schedule, Shift, ShiftSwap, Event } from 'models/schedule/schedule.types'; +import { Schedule, ShiftSwap, Event } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; @@ -59,7 +59,7 @@ class ScheduleFinal extends Component { const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1; - const getColor = (shiftId: Shift['id']) => findColor(shiftId, layers, overrides); + const getColor = (event: Event) => findColor(event.shift?.pk, layers, overrides); return ( <> diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx index 0c22747d..accbdebe 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -46,6 +46,7 @@ interface ScheduleOverridesProps extends WithStoreProps { onUpdate: () => void; onDelete: () => void; disabled: boolean; + disableShiftSwaps: boolean; filters: ScheduleFiltersType; } @@ -72,6 +73,7 @@ class ScheduleOverrides extends Component + + + + + + diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx index 821ed787..32ad21b0 100644 --- a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx @@ -57,7 +57,7 @@ const ScheduleSlot: FC = observer((props) => { const base = 60 * 60 * 24 * 7; - const width = duration / base; + const width = Math.max(duration / base, 0); const currentMoment = useMemo(() => dayjs(), []); @@ -172,6 +172,7 @@ const ShiftSwapEvent = (props: ShiftSwapEventProps) => { content={ { {users.map(({ display_name, pk: userPk, swap_request }) => { const storeUser = store.userStore.items[userPk]; + const { schedule, shift } = event; + const isCurrentUserSlot = userPk === store.userStore.currentUserPk; const inactive = filters && filters.users.length && !filters.users.includes(userPk); - const userTitle = storeUser ? getTitle(storeUser) : display_name; + const userTitle = showScheduleNameAsSlotTitle ? schedule?.name : storeUser ? getTitle(storeUser) : display_name; const isShiftSwap = Boolean(swap_request); + const title = isShiftSwap ? 'Shift swap' : showScheduleNameAsSlotTitle ? schedule?.name : getShiftName(shift); + let backgroundColor = color; if (isShiftSwap) { backgroundColor = SHIFT_SWAP_COLOR; @@ -282,7 +287,7 @@ const RegularEvent = (props: RegularEventProps) => { key={userPk} content={ { @@ -344,7 +349,7 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => { beneficiaryName, benefactorName, currentMoment, - showScheduleNameAsSlotTitle, + title, } = props; const { scheduleStore } = useStore(); @@ -368,8 +373,6 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => { } }, [shift]); - const title = isShiftSwap ? 'Shift swap' : showScheduleNameAsSlotTitle ? schedule?.name : getShiftName(shift); - // const onCallNow = schedule?.on_call_now; // const isOncall = Boolean(storeUser && onCallNow && onCallNow.some((onCallUser) => storeUser.pk === onCallUser.pk)); diff --git a/grafana-plugin/src/models/schedule/schedule.helpers.ts b/grafana-plugin/src/models/schedule/schedule.helpers.ts index 6464db0b..81e4ca71 100644 --- a/grafana-plugin/src/models/schedule/schedule.helpers.ts +++ b/grafana-plugin/src/models/schedule/schedule.helpers.ts @@ -63,7 +63,7 @@ export const fillGaps = (events: Event[]) => { return newEvents; }; -export const splitToShiftsAndFillGaps = (events: Event[]) => { +export const splitToShifts = (events: Event[]) => { const shifts: Array<{ shiftId: Shift['id']; priority: Shift['priority_level']; events: Event[] }> = []; for (const [_i, event] of events.entries()) { @@ -77,13 +77,20 @@ export const splitToShiftsAndFillGaps = (events: Event[]) => { } } - shifts.forEach((shift) => { - shift.events = fillGaps(shift.events); - }); - return shifts; }; +export const fillGapsInShifts = (shifts: ShiftEvents[]) => { + return shifts.map((shift) => ({ + ...shift, + events: fillGaps(shift.events), + })); +}; + +export const enrichEventsWithScheduleData = (events: Event[], schedule: Partial) => { + return events.map((event) => ({ ...event, schedule })); +}; + export const getPersonalShiftsFromStore = ( store: RootStore, userPk: User['pk'], @@ -102,6 +109,44 @@ export const getShiftsFromStore = ( : (store.scheduleStore.events[scheduleId]?.['final']?.[getFromString(startMoment)] as any); }; +export const unFlattenShiftEvents = (shifts: ShiftEvents[]) => { + for (let i = 0; i < shifts.length; i++) { + const shift = shifts[i]; + + for (let j = 0; j < shift.events.length - 1; j++) { + for (let k = j + 1; k < shift.events.length; k++) { + const event1 = shift.events[j]; + const event2 = shift.events[k]; + + const event1Start = dayjs(event1.start); + const event1End = dayjs(event1.end); + + const event2Start = dayjs(event2.start); + const event2End = dayjs(event2.end); + + if ( + (event1Start.isBefore(event2Start) && event1End.isAfter(event2Start)) || + (event1End.isAfter(event2End) && event1Start.isBefore(event2End)) + ) { + const firstEvent = event1Start.isBefore(event2Start) ? event1 : event2; + const secondEvent = firstEvent === event1 ? event2 : event1; + + const oldShift = { ...shift, events: shift.events.filter((event) => event !== secondEvent) }; + + const newShift = { ...shift, events: [secondEvent] }; + + shifts[i] = oldShift; + shifts.push(newShift); + + return unFlattenShiftEvents(shifts); + } + } + } + } + + return shifts; +}; + export const flattenShiftEvents = (shifts: ShiftEvents[]) => { if (!shifts) { return undefined; @@ -241,9 +286,7 @@ export const getOverridesFromStore = ( : (store.scheduleStore.events[scheduleId]?.['override']?.[getFromString(startMoment)] as ShiftEvents[]); }; -export const splitToLayers = ( - shifts: Array<{ shiftId: Shift['id']; priority: Shift['priority_level']; events: Event[] }> -) => { +export const splitToLayers = (shifts: ShiftEvents[]) => { return shifts .reduce((memo, shift) => { let layer = memo.find((level) => level.priority === shift.priority); @@ -395,7 +438,7 @@ export const getOverrideColor = (rotationIndex: number) => { return OVERRIDE_COLORS[normalizedRotationIndex]; }; -export const getShiftName = (shift: Shift) => { +export const getShiftName = (shift: Partial) => { if (!shift) { return ''; } @@ -408,5 +451,5 @@ export const getShiftName = (shift: Shift) => { return 'Override'; } - return `[L${shift.priority_level}] Rotation`; + return 'Rotation'; }; diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index 08749df3..4ea418a1 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -11,12 +11,15 @@ import { SelectOption } from 'state/types'; import { createShiftSwapEventFromShiftSwap, + enrichEventsWithScheduleData, enrichLayers, enrichOverrides, + fillGapsInShifts, flattenShiftEvents, getFromString, splitToLayers, - splitToShiftsAndFillGaps, + splitToShifts, + unFlattenShiftEvents, } from './schedule.helpers'; import { Rotation, @@ -34,7 +37,7 @@ import { export class ScheduleStore extends BaseStore { @observable - searchResult: { count?: number; results?: Array } = {}; + searchResult: { page_size?: number; count?: number; results?: Array } = {}; @observable.shallow items: { [id: string]: Schedule } = {}; @@ -137,7 +140,7 @@ export class ScheduleStore extends BaseStore { shouldUpdateFn: () => boolean = undefined ) { const filters = typeof f === 'string' ? { search: f } : f; - const { count, results } = await makeRequest(this.path, { + const { page_size, count, results } = await makeRequest(this.path, { method: 'GET', params: { ...filters, page }, }); @@ -157,6 +160,7 @@ export class ScheduleStore extends BaseStore { ), }; this.searchResult = { + page_size, count, results: results.map((item: Schedule) => item.id), }; @@ -193,6 +197,7 @@ export class ScheduleStore extends BaseStore { return undefined; } return { + page_size: this.searchResult.page_size, count: this.searchResult.count, results: this.searchResult.results?.map((scheduleId: Schedule['id']) => this.items[scheduleId]), }; @@ -287,7 +292,7 @@ export class ScheduleStore extends BaseStore { this.rotationPreview = { ...this.rotationPreview, [fromString]: layers }; } - this.finalPreview = { ...this.finalPreview, [fromString]: splitToShiftsAndFillGaps(response.final) }; + this.finalPreview = { ...this.finalPreview, [fromString]: fillGapsInShifts(splitToShifts(response.final)) }; } @action @@ -450,7 +455,9 @@ export class ScheduleStore extends BaseStore { }); const fromString = getFromString(startMoment); - const shifts = splitToShiftsAndFillGaps(response.events); + const shiftsRaw = splitToShifts(response.events); + const shiftsUnflattened = unFlattenShiftEvents(shiftsRaw); + const shifts = fillGapsInShifts(shiftsUnflattened); const layers = type === 'rotation' ? splitToLayers(shifts) : undefined; this.events = { @@ -535,7 +542,7 @@ export class ScheduleStore extends BaseStore { }; } - async updatePersonalEvents(userPk: User['pk'], startMoment: dayjs.Dayjs, days = 9) { + async updatePersonalEvents(userPk: User['pk'], startMoment: dayjs.Dayjs, days = 9, isUpdateOnCallNow = false) { const fromString = getFromString(startMoment); const dayBefore = startMoment.subtract(1, 'day'); @@ -548,8 +555,8 @@ export class ScheduleStore extends BaseStore { }, }); - const shiftEventsList = schedules.reduce((acc, schedule) => { - return [...acc, ...splitToShiftsAndFillGaps(schedule.events)]; + const shiftEventsList = schedules.reduce((acc, { events, id, name }) => { + return [...acc, ...fillGapsInShifts(splitToShifts(enrichEventsWithScheduleData(events, { id, name })))]; }, []); const shiftEventsListFlattened = flattenShiftEvents(shiftEventsList); @@ -562,9 +569,12 @@ export class ScheduleStore extends BaseStore { }, }; - this.onCallNow = { - ...this.onCallNow, - [userPk]: is_oncall, - }; + if (isUpdateOnCallNow) { + // since current endpoint works incorrectly we are waiting for https://github.com/grafana/oncall/issues/3164 + this.onCallNow = { + ...this.onCallNow, + [userPk]: is_oncall, + }; + } } } diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index 2ce3272e..2acd94be 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -94,7 +94,7 @@ export interface Event { is_gap: boolean; missing_users: Array<{ display_name: User['username']; pk: User['pk'] }>; priority_level: number; - shift: { pk: Shift['id'] | null }; + shift: Pick & { pk: string }; source: string; start: string; users: Array<{ @@ -104,6 +104,7 @@ export interface Event { }>; is_override: boolean; + schedule?: Partial; // populated by frontend for personal schedule to display schedule name instead of user name shiftSwapId?: ShiftSwap['id']; // if event is acually shift swap request (filled out by frontend) } diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 3b95bcd8..a386332d 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -156,6 +156,12 @@ class SchedulePage extends React.Component shiftIdToShowRotationForm || shiftSwapIdToShowForm; + const disabledShiftSwaps = + !isUserActionAllowed(UserActions.SchedulesWrite) || + !!shiftIdToShowOverridesForm || + shiftIdToShowRotationForm || + shiftSwapIdToShowForm; + return ( {() => ( @@ -314,6 +320,7 @@ class SchedulePage extends React.Component shiftIdToShowRotationForm={shiftIdToShowOverridesForm} onShowRotationForm={this.handleShowOverridesForm} disabled={disabledOverrideForm} + disableShiftSwaps={disabledShiftSwaps} shiftStartToShowOverrideForm={shiftStartToShowOverrideForm} shiftEndToShowOverrideForm={shiftEndToShowOverrideForm} onShowShiftSwapForm={!shiftSwapIdToShowForm ? this.handleShowShiftSwapForm : undefined} diff --git a/grafana-plugin/src/pages/schedules/Schedules.tsx b/grafana-plugin/src/pages/schedules/Schedules.tsx index 041cda71..c366c877 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules/Schedules.tsx @@ -25,7 +25,7 @@ import SchedulePersonal from 'containers/Rotations/SchedulePersonal'; import ScheduleForm from 'containers/ScheduleForm/ScheduleForm'; import TeamName from 'containers/TeamName/TeamName'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; -import { Schedule, ScheduleType } from 'models/schedule/schedule.types'; +import { Schedule } from 'models/schedule/schedule.types'; import { getSlackChannelName } from 'models/slack_channel/slack_channel.helpers'; import { Timezone } from 'models/timezone/timezone.types'; import { getStartOfWeek } from 'pages/schedule/Schedule.helpers'; @@ -39,7 +39,7 @@ import styles from './Schedules.module.css'; const cx = cn.bind(styles); const FILTERS_DEBOUNCE_MS = 500; -const ITEMS_PER_PAGE = 10; +const PAGE_SIZE_DEFAULT = 15; interface SchedulesPageProps extends WithStoreProps, RouteComponentProps, PageProps {} @@ -83,7 +83,7 @@ class SchedulesPage extends React.Component { - console.log(rest); - }} />
@@ -179,7 +176,11 @@ class SchedulesPage extends React.Component { const { history, query } = this.props; - if (data.type === ScheduleType.API) { - history.push(`${PLUGIN_ROOT}/schedules/${data.id}?${qs.stringify(query)}`); - } + history.push(`${PLUGIN_ROOT}/schedules/${data.id}?${qs.stringify(query)}`); }; handleExpandRow = (expanded: boolean, data: Schedule) => { @@ -455,7 +454,7 @@ class SchedulesPage extends React.Component