Commit eb3361ba authored by David O'Regan's avatar David O'Regan Committed by Olena Horal-Koretska

Update oncall grid UX

parent 5dd14a02
......@@ -1010,3 +1010,25 @@ export const getStartOfDay = (date, { utc = false } = {}) => {
return new Date(cloneValue);
};
/**
* Returns the start of the current week against the provide date
*
* @param {Date} date The current date instance to calculate against
* @param {Object} [options={}] Additional options for this calculation
* @param {boolean} [options.utc=false] Performs the calculation using UTC time.
* If `true`, the time returned will be midnight UTC. If `false` (the default)
* the time returned will be midnight in the user's local time.
*
* @returns {Date} A new `Date` object that represents the start of the current week
* of the provided date
*/
export const getStartOfWeek = (date, { utc = false } = {}) => {
const cloneValue = utc
? new Date(date.setUTCHours(0, 0, 0, 0))
: new Date(date.setHours(0, 0, 0, 0));
const diff = cloneValue.getDate() - cloneValue.getDay() + (cloneValue.getDay() === 0 ? -6 : 1);
return new Date(date.setDate(diff));
};
......@@ -135,7 +135,10 @@ export default {
});
},
editSchedule() {
const { projectPath } = this;
const {
projectPath,
form: { timezone },
} = this;
this.loading = true;
this.$apollo
......@@ -167,6 +170,9 @@ export default {
})
.finally(() => {
this.loading = false;
if (timezone !== this.schedule.timezone) {
window.location.reload();
}
});
},
hideErrorAlert() {
......
......@@ -194,7 +194,7 @@ export default {
</gl-button-group>
</div>
</template>
<p class="gl-text-gray-500 gl-mb-3" data-testid="scheduleBody">
<p class="gl-text-gray-500 gl-mb-5" data-testid="scheduleBody">
{{ schedule.timezone }} | {{ offset }}
</p>
<div class="gl-display-flex gl-justify-content-space-between gl-mb-3">
......
......@@ -9,7 +9,7 @@ import OncallSchedule from './oncall_schedule.vue';
export const addScheduleModalId = 'addScheduleModal';
export const i18n = {
title: s__('OnCallSchedules|On-call schedule'),
title: s__('OnCallSchedules|On-call schedules'),
emptyState: {
title: s__('OnCallSchedules|Create on-call schedules in GitLab'),
description: s__('OnCallSchedules|Route alerts directly to specific members of your team'),
......
......@@ -168,8 +168,8 @@ export default {
return this.isEditMode ? this.$options.i18n.editRotation : this.$options.i18n.addRotation;
},
isEndDateValid() {
const startsAt = this.form.startsAt.date?.getTime();
const endsAt = this.form.endsAt.date?.getTime();
const startsAt = new Date(this.form.startsAt.date).getTime();
const endsAt = new Date(this.form.endsAt.date).getTime();
if (!startsAt || !endsAt) {
// If start or end is not present, we consider the end date valid
......
......@@ -7,9 +7,9 @@ import { __, sprintf } from '~/locale';
import { selectedTimezoneFormattedOffset } from '../../schedule/utils';
export const SHIFT_WIDTHS = {
md: 140,
sm: 90,
xs: 40,
md: 100,
sm: 50,
xs: 25,
};
const ROTATION_CENTER_CLASS = 'gl-display-flex gl-justify-content-center gl-align-items-center';
......@@ -46,7 +46,7 @@ export default {
},
computed: {
assigneeName() {
if (this.shiftWidth <= SHIFT_WIDTHS.sm) {
if (this.shiftWidth <= SHIFT_WIDTHS.md) {
return truncate(this.assignee.user.username, 3);
}
......@@ -65,9 +65,12 @@ export default {
rotationAssigneeUniqueID() {
return uniqueId('rotation-assignee-');
},
rotationMobileView() {
hasRotationMobileViewAvatar() {
return this.shiftWidth <= SHIFT_WIDTHS.xs;
},
hasRotationMobileViewText() {
return this.shiftWidth <= SHIFT_WIDTHS.sm;
},
startsAt() {
return sprintf(__('Starts: %{startsAt}'), {
startsAt: `${formatDate(this.rotationAssigneeStartsAt, TIME_DATE_FORMAT)} ${
......@@ -91,10 +94,13 @@ export default {
data-testid="rotation-assignee"
>
<div class="gl-text-white" :class="$options.ROTATION_CENTER_CLASS">
<gl-avatar :src="assignee.user.avatarUrl" :size="16" />
<span v-if="!rotationMobileView" class="gl-ml-2" data-testid="rotation-assignee-name">{{
assigneeName
}}</span>
<gl-avatar v-if="!hasRotationMobileViewAvatar" :src="assignee.user.avatarUrl" :size="16" />
<span
v-if="!hasRotationMobileViewText"
class="gl-ml-2"
data-testid="rotation-assignee-name"
>{{ assigneeName }}</span
>
</div>
</div>
<gl-popover
......
......@@ -2,6 +2,7 @@ import {
PRESET_TYPES,
DAYS_IN_WEEK,
ASSIGNEE_SPACER,
ASSIGNEE_SPACER_SMALL,
HOURS_IN_DAY,
} from 'ee/oncall_schedules/constants';
import {
......@@ -175,7 +176,7 @@ export const daysUntilEndOfTimeFrame = (shiftRangeOverlap, timeframeItem, preset
* @param {String} shiftTimeUnitWidth - the current grid type i.e. Week, Day, Hour.
* @param {Date} shiftStartsAt - current shift start Date.
* @param {Date} timeframeItem - the current timeframe start Date.
* * @param {String} presetType - the current grid type i.e. Week, Day, Hour.
* @param {String} presetType - the current grid type i.e. Week, Day, Hour.
* @returns {Number}
*
* @example
......@@ -236,9 +237,9 @@ export const weekDisplayShiftWidth = (
shiftTimeUnitWidth,
) => {
if (shiftUnitIsHour) {
const SPACER = shiftRangeOverlap.hoursOverlap === 1 ? ASSIGNEE_SPACER_SMALL : ASSIGNEE_SPACER;
return (
Math.floor((shiftTimeUnitWidth / HOURS_IN_DAY) * shiftRangeOverlap.hoursOverlap) -
ASSIGNEE_SPACER
Math.floor((shiftTimeUnitWidth / HOURS_IN_DAY) * shiftRangeOverlap.hoursOverlap) - SPACER
);
}
......
......@@ -40,5 +40,7 @@ export const editRotationModalId = 'editRotationModal';
export const deleteRotationModalId = 'deleteRotationModal';
export const ASSIGNEE_SPACER = 2;
export const ASSIGNEE_SPACER_SMALL = 1;
export const TIMELINE_CELL_WIDTH = 180;
export const SHIFT_WIDTH_CALCULATION_DELAY = 250;
export const CURRENT_DAY_INDICATOR_OFFSET = 2.25;
import { isToday } from '~/lib/utils/datetime_utility';
import { DAYS_IN_WEEK, HOURS_IN_DAY, PRESET_TYPES } from '../constants';
import {
DAYS_IN_WEEK,
HOURS_IN_DAY,
PRESET_TYPES,
CURRENT_DAY_INDICATOR_OFFSET,
} from '../constants';
export default {
currentDate: null,
......@@ -34,20 +39,22 @@ export default {
},
methods: {
getIndicatorStyles(presetType = PRESET_TYPES.WEEKS) {
if (presetType === PRESET_TYPES.DAYS) {
const currentDate = new Date();
const base = 100 / HOURS_IN_DAY;
const hours = base * currentDate.getHours();
const minutes = base * (currentDate.getMinutes() / 60) - 2.25;
if (presetType === PRESET_TYPES.DAYS) {
const minutes = base * (currentDate.getMinutes() / 60) - CURRENT_DAY_INDICATOR_OFFSET;
return {
left: `${hours + minutes}%`,
};
}
const left = 100 / DAYS_IN_WEEK / 2;
const weeklyDayOffset = 100 / DAYS_IN_WEEK / 2;
const weeklyHourOffset = (weeklyDayOffset / HOURS_IN_DAY) * currentDate.getHours();
return {
left: `${left}%`,
left: `${weeklyDayOffset + weeklyHourOffset}%`,
};
},
},
......
......@@ -28,11 +28,12 @@ describe('AddScheduleModal', () => {
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0];
let updateScheduleHandler;
const createComponent = ({ schedule, isEditMode, modalId } = {}) => {
const createComponent = ({ schedule, isEditMode, modalId, data } = {}) => {
wrapper = shallowMount(AddEditScheduleModal, {
data() {
return {
form: mockSchedule,
...data,
};
},
propsData: {
......@@ -236,5 +237,52 @@ describe('AddScheduleModal', () => {
expect(alert.text()).toContain('Houston, we have a problem');
});
});
describe('when the schedule timezone is updated', () => {
const { location } = window;
beforeEach(() => {
delete window.location;
window.location = {
reload: jest.fn(),
hash: location.hash,
};
});
afterEach(() => {
window.location = location;
});
it('it should not reload the page if the timezone has not changed', async () => {
mutate.mockResolvedValueOnce({});
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
await waitForPromises();
expect(window.location.reload).not.toHaveBeenCalled();
});
it('it should reload the page if the timezone has changed', async () => {
createComponent({
data: { form: { ...mockSchedule, timezone: mockTimezones[1] } },
schedule: mockSchedule,
isEditMode: true,
modalId: editScheduleModalId,
});
mutate.mockResolvedValueOnce({});
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
expect(mutate).toHaveBeenCalledWith({
mutation: updateOncallScheduleMutation,
update: expect.anything(),
variables: {
iid: mockSchedule.iid,
projectPath,
name: mockSchedule.name,
description: mockSchedule.description,
timezone: mockTimezones[1].identifier,
},
});
await waitForPromises();
expect(window.location.reload).toHaveBeenCalled();
});
});
});
});
......@@ -16,7 +16,7 @@ jest.mock('lodash/uniqueId', () => (prefix) => `${prefix}fakeUniqueId`);
describe('RotationAssignee', () => {
let wrapper;
const shiftWidth = 100;
const shiftWidth = SHIFT_WIDTHS.md;
const assignee = mockRotations[0].shifts.nodes[0];
const findToken = () => wrapper.findByTestId('rotation-assignee');
const findAvatar = () => wrapper.findComponent(GlAvatar);
......@@ -59,20 +59,26 @@ describe('RotationAssignee', () => {
describe('rotation assignee token', () => {
it('should render an assignee name and avatar', () => {
const LARGE_SHIFT_WIDTH = 150;
createComponent({ props: { shiftWidth: LARGE_SHIFT_WIDTH } });
expect(findAvatar().props('src')).toBe(assignee.participant.user.avatarUrl);
expect(findName().text()).toBe(assignee.participant.user.username);
});
it('truncate the rotation name on small screens', () => {
createComponent({ props: { shiftWidth: SHIFT_WIDTHS.sm } });
expect(findName().text()).toBe(truncate(assignee.participant.user.username, 3));
});
it('hide the rotation name on mobile screens', () => {
createComponent({ props: { shiftWidth: SHIFT_WIDTHS.xs } });
it('hides the rotation name on mobile screens', () => {
createComponent({ props: { shiftWidth: SHIFT_WIDTHS.sm } });
expect(findName().exists()).toBe(false);
});
it('hides the avatar on the smallest screens', () => {
createComponent({ props: { shiftWidth: SHIFT_WIDTHS.xs } });
expect(findAvatar().exists()).toBe(false);
});
it('should render an assignee color based on the chevron skipping color pallette', () => {
const token = findToken();
expect(token.classes()).toContain(
......
......@@ -88,11 +88,7 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
<div
class="gl-text-white gl-display-flex gl-justify-content-center gl-align-items-center"
>
<img
alt="avatar"
class="gl-avatar gl-avatar-circle gl-avatar-s16"
src="/url"
/>
<!---->
<!---->
</div>
......@@ -132,11 +128,7 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
<div
class="gl-text-white gl-display-flex gl-justify-content-center gl-align-items-center"
>
<img
alt="avatar"
class="gl-avatar gl-avatar-circle gl-avatar-s16"
src="/url"
/>
<!---->
<!---->
</div>
......
......@@ -3,7 +3,6 @@ import RotationsAssignee from 'ee/oncall_schedules/components/rotations/componen
import DaysScheduleShift from 'ee/oncall_schedules/components/schedule/components/shifts/components/days_schedule_shift.vue';
import { PRESET_TYPES, DAYS_IN_WEEK } from 'ee/oncall_schedules/constants';
import { nDaysAfter } from '~/lib/utils/datetime_utility';
import mockTimezones from '../../../../mocks/mock_timezones.json';
const shift = {
participant: {
......@@ -32,7 +31,6 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/d
timeframe,
presetType: PRESET_TYPES.WEEKS,
shiftTimeUnitWidth: CELL_WIDTH,
selectedTimezone: mockTimezones[0],
...props,
},
});
......
......@@ -213,6 +213,7 @@ describe('~ee/oncall_schedules/components/schedule/components/shifts/components/
it.each`
shiftUnitIsHour | shiftRangeOverlapObject | shiftStartDateOutOfRange | value
${true} | ${{ daysOverlap: 1, hoursOverlap: 1 }} | ${false} | ${1}
${true} | ${{ daysOverlap: 1, hoursOverlap: 4 }} | ${false} | ${6}
${true} | ${{ daysOverlap: 1, hoursOverlap: 8 }} | ${false} | ${14}
${true} | ${{ daysOverlap: 1, hoursOverlap: 24 }} | ${false} | ${48}
......
import { shallowMount } from '@vue/test-utils';
import { DAYS_IN_WEEK } from 'ee/oncall_schedules/constants';
import { DAYS_IN_WEEK, HOURS_IN_DAY, PRESET_TYPES } from 'ee/oncall_schedules/constants';
import CommonMixin from 'ee/oncall_schedules/mixins/common_mixin';
import { useFakeDate } from 'helpers/fake_date';
import * as dateTimeUtility from '~/lib/utils/datetime_utility';
......@@ -89,5 +89,18 @@ describe('Schedule Common Mixins', () => {
}),
);
});
it('returns object containing `left` offset for a single day grid', () => {
const currentDate = new Date(2018, 0, 8);
const base = 100 / HOURS_IN_DAY;
const hours = base * currentDate.getHours();
const minutes = base * (currentDate.getMinutes() / 60) - 2.25;
expect(wrapper.vm.getIndicatorStyles(PRESET_TYPES.DAYS)).toEqual(
expect.objectContaining({
left: `${hours + minutes}%`,
}),
);
});
});
});
......@@ -21188,7 +21188,7 @@ msgstr ""
msgid "OnCallSchedules|For this rotation, on-call will be:"
msgstr ""
msgid "OnCallSchedules|On-call schedule"
msgid "OnCallSchedules|On-call schedules"
msgstr ""
msgid "OnCallSchedules|Please note, rotations with shifts that are less than four hours are currently not supported in the weekly view."
......
......@@ -1046,3 +1046,32 @@ describe('getStartOfDay', () => {
},
);
});
describe('getStartOfWeek', () => {
beforeEach(() => {
timezoneMock.register('US/Eastern');
});
afterEach(() => {
timezoneMock.unregister();
});
it.each`
inputAsString | options | expectedAsString
${'2021-01-29T18:08:23.014Z'} | ${undefined} | ${'2021-01-25T05:00:00.000Z'}
${'2021-01-29T13:08:23.014-05:00'} | ${undefined} | ${'2021-01-25T05:00:00.000Z'}
${'2021-01-30T03:08:23.014+09:00'} | ${undefined} | ${'2021-01-25T05:00:00.000Z'}
${'2021-01-28T18:08:23.014-10:00'} | ${undefined} | ${'2021-01-25T05:00:00.000Z'}
${'2021-01-28T18:08:23.014-10:00'} | ${{}} | ${'2021-01-25T05:00:00.000Z'}
${'2021-01-28T18:08:23.014-10:00'} | ${{ utc: false }} | ${'2021-01-25T05:00:00.000Z'}
${'2021-01-28T18:08:23.014-10:00'} | ${{ utc: true }} | ${'2021-01-26T00:00:00.000Z'}
`(
'when the provided date is $inputAsString and the options parameter is $options, returns $expectedAsString',
({ inputAsString, options, expectedAsString }) => {
const inputDate = new Date(inputAsString);
const actual = datetimeUtility.getStartOfWeek(inputDate, options);
expect(actual.toISOString()).toEqual(expectedAsString);
},
);
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment