<script> import { GlButton, GlButtonGroup, GlCard, GlCollapse, GlIcon, GlModalDirective, GlTooltipDirective, } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { capitalize } from 'lodash'; import { getStartOfWeek, formatDate, nWeeksBefore, nWeeksAfter, nDaysBefore, nDaysAfter, } from '~/lib/utils/datetime_utility'; import { s__ } from '~/locale'; import { addRotationModalId, deleteRotationModalId, editRotationModalId, PRESET_TYPES, } from '../constants'; import getShiftsForRotationsQuery from '../graphql/queries/get_oncall_schedules_with_rotations_shifts.query.graphql'; import EditScheduleModal from './add_edit_schedule_modal.vue'; import DeleteScheduleModal from './delete_schedule_modal.vue'; import AddEditRotationModal from './rotations/components/add_edit_rotation_modal.vue'; import DeleteRotationModal from './rotations/components/delete_rotation_modal.vue'; import RotationsListSection from './schedule/components/rotations_list_section.vue'; import ScheduleTimelineSection from './schedule/components/schedule_timeline_section.vue'; import { getTimeframeForWeeksView, selectedTimezoneFormattedOffset } from './schedule/utils'; export const i18n = { editScheduleLabel: s__('OnCallSchedules|Edit schedule'), deleteScheduleLabel: s__('OnCallSchedules|Delete schedule'), rotationTitle: s__('OnCallSchedules|Rotations'), addARotation: s__('OnCallSchedules|Add a rotation'), viewPreviousTimeframe: s__('OnCallSchedules|View previous timeframe'), viewNextTimeframe: s__('OnCallSchedules|View next timeframe'), presetTypeLabels: { DAYS: s__('OnCallSchedules|1 day'), WEEKS: s__('OnCallSchedules|2 weeks'), }, scheduleOpen: s__('OnCallSchedules|Expand schedule'), scheduleClose: s__('OnCallSchedules|Collapse schedule'), }; export const editScheduleModalId = 'editScheduleModal'; export const deleteScheduleModalId = 'deleteScheduleModal'; export default { i18n, addRotationModalId, editRotationModalId, editScheduleModalId, deleteRotationModalId, deleteScheduleModalId, PRESET_TYPES, components: { GlButton, GlButtonGroup, GlCard, GlCollapse, GlIcon, AddEditRotationModal, DeleteRotationModal, DeleteScheduleModal, EditScheduleModal, RotationsListSection, ScheduleTimelineSection, }, directives: { GlModal: GlModalDirective, GlTooltip: GlTooltipDirective, }, inject: ['projectPath', 'timezones'], props: { schedule: { type: Object, required: true, }, scheduleIndex: { type: Number, required: true, }, }, apollo: { rotations: { query: getShiftsForRotationsQuery, skip() { return !this.scheduleVisible; }, variables() { this.timeframeStartDate.setHours(0, 0, 0, 0); const startsAt = this.timeframeStartDate; const endsAt = this.presetType === this.$options.PRESET_TYPES.WEEKS ? nWeeksAfter(startsAt, 2) : nDaysAfter(startsAt, 1); return { projectPath: this.projectPath, startsAt, endsAt, iids: [this.schedule.iid], }; }, update(data) { const nodes = data.project?.incidentManagementOncallSchedules?.nodes ?? []; const [schedule] = nodes; return schedule?.rotations.nodes ?? []; }, error(error) { Sentry.captureException(error); }, }, }, data() { return { presetType: this.$options.PRESET_TYPES.WEEKS, timeframeStartDate: getStartOfWeek(new Date()), rotations: this.schedule.rotations.nodes, rotationToUpdate: {}, scheduleVisible: this.scheduleIndex === 0, bodyClass: this.scheduleIndex === 0 ? 'gl-p-5' : 'gl-p-0', }; }, computed: { addRotationModalId() { return `${this.$options.addRotationModalId}-${this.schedule.iid}`; }, deleteScheduleModalId() { return `${this.$options.deleteScheduleModalId}-${this.schedule.iid}`; }, deleteRotationModalId() { return `${this.$options.deleteRotationModalId}-${this.schedule.iid}`; }, editScheduleModalId() { return `${this.$options.editScheduleModalId}-${this.schedule.iid}`; }, editRotationModalId() { return `${this.$options.editRotationModalId}-${this.schedule.iid}`; }, loading() { return this.$apollo.queries.rotations.loading; }, offset() { return selectedTimezoneFormattedOffset(this.selectedTimezone.formatted_offset); }, scheduleRange() { switch (this.presetType) { case PRESET_TYPES.DAYS: return formatDate(this.timeframe[0], 'mmmm d, yyyy'); case PRESET_TYPES.WEEKS: { const firstDayOfTheLastWeek = this.timeframe[this.timeframe.length - 1]; const firstDayOfTheNextTimeframe = nWeeksAfter(firstDayOfTheLastWeek, 1); const lastDayOfTimeframe = nDaysBefore(firstDayOfTheNextTimeframe, 1); return `${formatDate(this.timeframe[0], 'mmmm d')} - ${formatDate( lastDayOfTimeframe, 'mmmm d, yyyy', )}`; } default: return ''; } }, scheduleInfo() { if (this.schedule.description) { return `${this.schedule.description} | ${this.offset} ${this.schedule.timezone}`; } return `${this.schedule.timezone} | ${this.offset}`; }, scheduleVisibleAriaLabel() { return this.scheduleVisible ? this.$options.i18n.scheduleClose : this.$options.i18n.scheduleOpen; }, scheduleVisibleAngleIcon() { return this.scheduleVisible ? 'angle-down' : 'angle-right'; }, selectedTimezone() { return this.timezones.find((tz) => tz.identifier === this.schedule.timezone); }, timeframe() { return getTimeframeForWeeksView(this.timeframeStartDate); }, }, methods: { switchPresetType(type) { this.presetType = type; this.timeframeStartDate = type === PRESET_TYPES.WEEKS ? getStartOfWeek(new Date()) : new Date(); }, formatPresetType(type) { return capitalize(type); }, updateToViewPreviousTimeframe() { switch (this.presetType) { case PRESET_TYPES.DAYS: this.timeframeStartDate = nDaysBefore(this.timeframeStartDate, 1); break; case PRESET_TYPES.WEEKS: this.timeframeStartDate = nWeeksBefore(this.timeframeStartDate, 2); break; default: break; } }, updateToViewNextTimeframe() { switch (this.presetType) { case PRESET_TYPES.DAYS: this.timeframeStartDate = nDaysAfter(this.timeframeStartDate, 1); break; case PRESET_TYPES.WEEKS: this.timeframeStartDate = nWeeksAfter(this.timeframeStartDate, 2); break; default: break; } }, onRotationUpdate(message) { this.$apollo.queries.rotations.refetch(); this.$emit('rotation-updated', message); }, onRotationDelete() { this.$apollo.queries.rotations.refetch(); }, setRotationToUpdate(rotation) { this.rotationToUpdate = rotation; }, }, }; </script> <template> <div> <gl-card class="gl-mt-5" :class="{ 'gl-border-bottom-0': !scheduleVisible }" :body-class="bodyClass" :header-class="{ 'gl-py-3': true, 'gl-rounded-small': !scheduleVisible }" > <template #header> <div class="gl-display-flex gl-align-items-center" data-testid="scheduleHeader"> <gl-button v-gl-tooltip class="gl-mr-2 gl-p-0!" :title="scheduleVisibleAriaLabel" :aria-label="scheduleVisibleAriaLabel" category="tertiary" @click="scheduleVisible = !scheduleVisible" > <gl-icon :size="12" :name="scheduleVisibleAngleIcon" /> </gl-button> <h3 class="gl-font-weight-bold gl-font-lg gl-m-0">{{ schedule.name }}</h3> <gl-button-group class="gl-ml-auto"> <gl-button v-gl-modal="editScheduleModalId" v-gl-tooltip :title="$options.i18n.editScheduleLabel" icon="pencil" :aria-label="$options.i18n.editScheduleLabel" /> <gl-button v-gl-modal="deleteScheduleModalId" v-gl-tooltip :title="$options.i18n.deleteScheduleLabel" icon="remove" :aria-label="$options.i18n.deleteScheduleLabel" /> </gl-button-group> </div> </template> <gl-collapse :visible="scheduleVisible" @hidden="bodyClass = 'gl-p-0'" @show="bodyClass = 'gl-p-5'" > <p class="gl-text-gray-500 gl-mb-5" data-testid="scheduleBody"> {{ scheduleInfo }} </p> <div class="gl-display-flex gl-justify-content-space-between gl-mb-3"> <div class="gl-display-flex gl-align-items-center"> <gl-button-group> <gl-button data-testid="previous-timeframe-btn" icon="chevron-left" :disabled="loading" :aria-label="$options.i18n.viewPreviousTimeframe" @click="updateToViewPreviousTimeframe" /> <gl-button data-testid="next-timeframe-btn" icon="chevron-right" :disabled="loading" :aria-label="$options.i18n.viewNextTimeframe" @click="updateToViewNextTimeframe" /> </gl-button-group> <div class="gl-ml-3">{{ scheduleRange }}</div> </div> <gl-button-group data-testid="shift-preset-change"> <gl-button v-for="type in $options.PRESET_TYPES" :key="type" :selected="type === presetType" :title="formatPresetType(type)" @click="switchPresetType(type)" > {{ $options.i18n.presetTypeLabels[type] }} </gl-button> </gl-button-group> </div> <gl-card header-class="gl-bg-transparent"> <template #header> <div class="gl-display-flex gl-justify-content-space-between" data-testid="rotationsHeader" > <h6 class="gl-m-0">{{ $options.i18n.rotationTitle }}</h6> <gl-button v-gl-modal="addRotationModalId" variant="link" >{{ $options.i18n.addARotation }} </gl-button> </div> </template> <div class="schedule-shell" data-testid="rotationsBody"> <schedule-timeline-section :preset-type="presetType" :timeframe="timeframe" /> <rotations-list-section :preset-type="presetType" :rotations="rotations" :timeframe="timeframe" :schedule-iid="schedule.iid" :loading="loading" @set-rotation-to-update="setRotationToUpdate" /> </div> </gl-card> </gl-collapse> </gl-card> <delete-schedule-modal :schedule="schedule" :modal-id="deleteScheduleModalId" /> <edit-schedule-modal :schedule="schedule" :modal-id="editScheduleModalId" is-edit-mode /> <add-edit-rotation-modal :schedule="schedule" :modal-id="addRotationModalId" @rotation-updated="onRotationUpdate" /> <add-edit-rotation-modal :schedule="schedule" :modal-id="editRotationModalId" :rotation="rotationToUpdate" is-edit-mode @rotation-updated="onRotationUpdate" /> <delete-rotation-modal :rotation="rotationToUpdate" :schedule="schedule" :modal-id="deleteRotationModalId" @rotation-deleted="onRotationDelete" /> </div> </template>