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 } = {}) => { ...@@ -1010,3 +1010,25 @@ export const getStartOfDay = (date, { utc = false } = {}) => {
return new Date(cloneValue); 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 { ...@@ -135,7 +135,10 @@ export default {
}); });
}, },
editSchedule() { editSchedule() {
const { projectPath } = this; const {
projectPath,
form: { timezone },
} = this;
this.loading = true; this.loading = true;
this.$apollo this.$apollo
...@@ -167,6 +170,9 @@ export default { ...@@ -167,6 +170,9 @@ export default {
}) })
.finally(() => { .finally(() => {
this.loading = false; this.loading = false;
if (timezone !== this.schedule.timezone) {
window.location.reload();
}
}); });
}, },
hideErrorAlert() { hideErrorAlert() {
......
...@@ -194,7 +194,7 @@ export default { ...@@ -194,7 +194,7 @@ export default {
</gl-button-group> </gl-button-group>
</div> </div>
</template> </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 }} {{ schedule.timezone }} | {{ offset }}
</p> </p>
<div class="gl-display-flex gl-justify-content-space-between gl-mb-3"> <div class="gl-display-flex gl-justify-content-space-between gl-mb-3">
......
...@@ -9,7 +9,7 @@ import OncallSchedule from './oncall_schedule.vue'; ...@@ -9,7 +9,7 @@ import OncallSchedule from './oncall_schedule.vue';
export const addScheduleModalId = 'addScheduleModal'; export const addScheduleModalId = 'addScheduleModal';
export const i18n = { export const i18n = {
title: s__('OnCallSchedules|On-call schedule'), title: s__('OnCallSchedules|On-call schedules'),
emptyState: { emptyState: {
title: s__('OnCallSchedules|Create on-call schedules in GitLab'), title: s__('OnCallSchedules|Create on-call schedules in GitLab'),
description: s__('OnCallSchedules|Route alerts directly to specific members of your team'), description: s__('OnCallSchedules|Route alerts directly to specific members of your team'),
......
...@@ -168,8 +168,8 @@ export default { ...@@ -168,8 +168,8 @@ export default {
return this.isEditMode ? this.$options.i18n.editRotation : this.$options.i18n.addRotation; return this.isEditMode ? this.$options.i18n.editRotation : this.$options.i18n.addRotation;
}, },
isEndDateValid() { isEndDateValid() {
const startsAt = this.form.startsAt.date?.getTime(); const startsAt = new Date(this.form.startsAt.date).getTime();
const endsAt = this.form.endsAt.date?.getTime(); const endsAt = new Date(this.form.endsAt.date).getTime();
if (!startsAt || !endsAt) { if (!startsAt || !endsAt) {
// If start or end is not present, we consider the end date valid // If start or end is not present, we consider the end date valid
......
...@@ -7,9 +7,9 @@ import { __, sprintf } from '~/locale'; ...@@ -7,9 +7,9 @@ import { __, sprintf } from '~/locale';
import { selectedTimezoneFormattedOffset } from '../../schedule/utils'; import { selectedTimezoneFormattedOffset } from '../../schedule/utils';
export const SHIFT_WIDTHS = { export const SHIFT_WIDTHS = {
md: 140, md: 100,
sm: 90, sm: 50,
xs: 40, xs: 25,
}; };
const ROTATION_CENTER_CLASS = 'gl-display-flex gl-justify-content-center gl-align-items-center'; const ROTATION_CENTER_CLASS = 'gl-display-flex gl-justify-content-center gl-align-items-center';
...@@ -46,7 +46,7 @@ export default { ...@@ -46,7 +46,7 @@ export default {
}, },
computed: { computed: {
assigneeName() { assigneeName() {
if (this.shiftWidth <= SHIFT_WIDTHS.sm) { if (this.shiftWidth <= SHIFT_WIDTHS.md) {
return truncate(this.assignee.user.username, 3); return truncate(this.assignee.user.username, 3);
} }
...@@ -65,9 +65,12 @@ export default { ...@@ -65,9 +65,12 @@ export default {
rotationAssigneeUniqueID() { rotationAssigneeUniqueID() {
return uniqueId('rotation-assignee-'); return uniqueId('rotation-assignee-');
}, },
rotationMobileView() { hasRotationMobileViewAvatar() {
return this.shiftWidth <= SHIFT_WIDTHS.xs; return this.shiftWidth <= SHIFT_WIDTHS.xs;
}, },
hasRotationMobileViewText() {
return this.shiftWidth <= SHIFT_WIDTHS.sm;
},
startsAt() { startsAt() {
return sprintf(__('Starts: %{startsAt}'), { return sprintf(__('Starts: %{startsAt}'), {
startsAt: `${formatDate(this.rotationAssigneeStartsAt, TIME_DATE_FORMAT)} ${ startsAt: `${formatDate(this.rotationAssigneeStartsAt, TIME_DATE_FORMAT)} ${
...@@ -91,10 +94,13 @@ export default { ...@@ -91,10 +94,13 @@ export default {
data-testid="rotation-assignee" data-testid="rotation-assignee"
> >
<div class="gl-text-white" :class="$options.ROTATION_CENTER_CLASS"> <div class="gl-text-white" :class="$options.ROTATION_CENTER_CLASS">
<gl-avatar :src="assignee.user.avatarUrl" :size="16" /> <gl-avatar v-if="!hasRotationMobileViewAvatar" :src="assignee.user.avatarUrl" :size="16" />
<span v-if="!rotationMobileView" class="gl-ml-2" data-testid="rotation-assignee-name">{{ <span
assigneeName v-if="!hasRotationMobileViewText"
}}</span> class="gl-ml-2"
data-testid="rotation-assignee-name"
>{{ assigneeName }}</span
>
</div> </div>
</div> </div>
<gl-popover <gl-popover
......
...@@ -2,6 +2,7 @@ import { ...@@ -2,6 +2,7 @@ import {
PRESET_TYPES, PRESET_TYPES,
DAYS_IN_WEEK, DAYS_IN_WEEK,
ASSIGNEE_SPACER, ASSIGNEE_SPACER,
ASSIGNEE_SPACER_SMALL,
HOURS_IN_DAY, HOURS_IN_DAY,
} from 'ee/oncall_schedules/constants'; } from 'ee/oncall_schedules/constants';
import { import {
...@@ -175,7 +176,7 @@ export const daysUntilEndOfTimeFrame = (shiftRangeOverlap, timeframeItem, preset ...@@ -175,7 +176,7 @@ export const daysUntilEndOfTimeFrame = (shiftRangeOverlap, timeframeItem, preset
* @param {String} shiftTimeUnitWidth - the current grid type i.e. Week, Day, Hour. * @param {String} shiftTimeUnitWidth - the current grid type i.e. Week, Day, Hour.
* @param {Date} shiftStartsAt - current shift start Date. * @param {Date} shiftStartsAt - current shift start Date.
* @param {Date} timeframeItem - the current timeframe 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} * @returns {Number}
* *
* @example * @example
...@@ -236,9 +237,9 @@ export const weekDisplayShiftWidth = ( ...@@ -236,9 +237,9 @@ export const weekDisplayShiftWidth = (
shiftTimeUnitWidth, shiftTimeUnitWidth,
) => { ) => {
if (shiftUnitIsHour) { if (shiftUnitIsHour) {
const SPACER = shiftRangeOverlap.hoursOverlap === 1 ? ASSIGNEE_SPACER_SMALL : ASSIGNEE_SPACER;
return ( return (
Math.floor((shiftTimeUnitWidth / HOURS_IN_DAY) * shiftRangeOverlap.hoursOverlap) - Math.floor((shiftTimeUnitWidth / HOURS_IN_DAY) * shiftRangeOverlap.hoursOverlap) - SPACER
ASSIGNEE_SPACER
); );
} }
......
...@@ -40,5 +40,7 @@ export const editRotationModalId = 'editRotationModal'; ...@@ -40,5 +40,7 @@ export const editRotationModalId = 'editRotationModal';
export const deleteRotationModalId = 'deleteRotationModal'; export const deleteRotationModalId = 'deleteRotationModal';
export const ASSIGNEE_SPACER = 2; export const ASSIGNEE_SPACER = 2;
export const ASSIGNEE_SPACER_SMALL = 1;
export const TIMELINE_CELL_WIDTH = 180; export const TIMELINE_CELL_WIDTH = 180;
export const SHIFT_WIDTH_CALCULATION_DELAY = 250; export const SHIFT_WIDTH_CALCULATION_DELAY = 250;
export const CURRENT_DAY_INDICATOR_OFFSET = 2.25;
import { isToday } from '~/lib/utils/datetime_utility'; 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 { export default {
currentDate: null, currentDate: null,
...@@ -34,20 +39,22 @@ export default { ...@@ -34,20 +39,22 @@ export default {
}, },
methods: { methods: {
getIndicatorStyles(presetType = PRESET_TYPES.WEEKS) { getIndicatorStyles(presetType = PRESET_TYPES.WEEKS) {
const currentDate = new Date();
const base = 100 / HOURS_IN_DAY;
const hours = base * currentDate.getHours();
if (presetType === PRESET_TYPES.DAYS) { if (presetType === PRESET_TYPES.DAYS) {
const currentDate = new Date(); const minutes = base * (currentDate.getMinutes() / 60) - CURRENT_DAY_INDICATOR_OFFSET;
const base = 100 / HOURS_IN_DAY;
const hours = base * currentDate.getHours();
const minutes = base * (currentDate.getMinutes() / 60) - 2.25;
return { return {
left: `${hours + minutes}%`, 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 { return {
left: `${left}%`, left: `${weeklyDayOffset + weeklyHourOffset}%`,
}; };
}, },
}, },
......
...@@ -28,11 +28,12 @@ describe('AddScheduleModal', () => { ...@@ -28,11 +28,12 @@ describe('AddScheduleModal', () => {
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0]; getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0];
let updateScheduleHandler; let updateScheduleHandler;
const createComponent = ({ schedule, isEditMode, modalId } = {}) => { const createComponent = ({ schedule, isEditMode, modalId, data } = {}) => {
wrapper = shallowMount(AddEditScheduleModal, { wrapper = shallowMount(AddEditScheduleModal, {
data() { data() {
return { return {
form: mockSchedule, form: mockSchedule,
...data,
}; };
}, },
propsData: { propsData: {
...@@ -236,5 +237,52 @@ describe('AddScheduleModal', () => { ...@@ -236,5 +237,52 @@ describe('AddScheduleModal', () => {
expect(alert.text()).toContain('Houston, we have a problem'); 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`); ...@@ -16,7 +16,7 @@ jest.mock('lodash/uniqueId', () => (prefix) => `${prefix}fakeUniqueId`);
describe('RotationAssignee', () => { describe('RotationAssignee', () => {
let wrapper; let wrapper;
const shiftWidth = 100; const shiftWidth = SHIFT_WIDTHS.md;
const assignee = mockRotations[0].shifts.nodes[0]; const assignee = mockRotations[0].shifts.nodes[0];
const findToken = () => wrapper.findByTestId('rotation-assignee'); const findToken = () => wrapper.findByTestId('rotation-assignee');
const findAvatar = () => wrapper.findComponent(GlAvatar); const findAvatar = () => wrapper.findComponent(GlAvatar);
...@@ -59,20 +59,26 @@ describe('RotationAssignee', () => { ...@@ -59,20 +59,26 @@ describe('RotationAssignee', () => {
describe('rotation assignee token', () => { describe('rotation assignee token', () => {
it('should render an assignee name and avatar', () => { 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(findAvatar().props('src')).toBe(assignee.participant.user.avatarUrl);
expect(findName().text()).toBe(assignee.participant.user.username); expect(findName().text()).toBe(assignee.participant.user.username);
}); });
it('truncate the rotation name on small screens', () => { it('truncate the rotation name on small screens', () => {
createComponent({ props: { shiftWidth: SHIFT_WIDTHS.sm } });
expect(findName().text()).toBe(truncate(assignee.participant.user.username, 3)); expect(findName().text()).toBe(truncate(assignee.participant.user.username, 3));
}); });
it('hide the rotation name on mobile screens', () => { it('hides the rotation name on mobile screens', () => {
createComponent({ props: { shiftWidth: SHIFT_WIDTHS.xs } }); createComponent({ props: { shiftWidth: SHIFT_WIDTHS.sm } });
expect(findName().exists()).toBe(false); 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', () => { it('should render an assignee color based on the chevron skipping color pallette', () => {
const token = findToken(); const token = findToken();
expect(token.classes()).toContain( expect(token.classes()).toContain(
......
...@@ -88,11 +88,7 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders ...@@ -88,11 +88,7 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
<div <div
class="gl-text-white gl-display-flex gl-justify-content-center gl-align-items-center" 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> </div>
...@@ -132,11 +128,7 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders ...@@ -132,11 +128,7 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
<div <div
class="gl-text-white gl-display-flex gl-justify-content-center gl-align-items-center" 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> </div>
......
...@@ -3,7 +3,6 @@ import RotationsAssignee from 'ee/oncall_schedules/components/rotations/componen ...@@ -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 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 { PRESET_TYPES, DAYS_IN_WEEK } from 'ee/oncall_schedules/constants';
import { nDaysAfter } from '~/lib/utils/datetime_utility'; import { nDaysAfter } from '~/lib/utils/datetime_utility';
import mockTimezones from '../../../../mocks/mock_timezones.json';
const shift = { const shift = {
participant: { participant: {
...@@ -32,7 +31,6 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/d ...@@ -32,7 +31,6 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/d
timeframe, timeframe,
presetType: PRESET_TYPES.WEEKS, presetType: PRESET_TYPES.WEEKS,
shiftTimeUnitWidth: CELL_WIDTH, shiftTimeUnitWidth: CELL_WIDTH,
selectedTimezone: mockTimezones[0],
...props, ...props,
}, },
}); });
......
...@@ -213,6 +213,7 @@ describe('~ee/oncall_schedules/components/schedule/components/shifts/components/ ...@@ -213,6 +213,7 @@ describe('~ee/oncall_schedules/components/schedule/components/shifts/components/
it.each` it.each`
shiftUnitIsHour | shiftRangeOverlapObject | shiftStartDateOutOfRange | value shiftUnitIsHour | shiftRangeOverlapObject | shiftStartDateOutOfRange | value
${true} | ${{ daysOverlap: 1, hoursOverlap: 1 }} | ${false} | ${1}
${true} | ${{ daysOverlap: 1, hoursOverlap: 4 }} | ${false} | ${6} ${true} | ${{ daysOverlap: 1, hoursOverlap: 4 }} | ${false} | ${6}
${true} | ${{ daysOverlap: 1, hoursOverlap: 8 }} | ${false} | ${14} ${true} | ${{ daysOverlap: 1, hoursOverlap: 8 }} | ${false} | ${14}
${true} | ${{ daysOverlap: 1, hoursOverlap: 24 }} | ${false} | ${48} ${true} | ${{ daysOverlap: 1, hoursOverlap: 24 }} | ${false} | ${48}
......
import { shallowMount } from '@vue/test-utils'; 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 CommonMixin from 'ee/oncall_schedules/mixins/common_mixin';
import { useFakeDate } from 'helpers/fake_date'; import { useFakeDate } from 'helpers/fake_date';
import * as dateTimeUtility from '~/lib/utils/datetime_utility'; import * as dateTimeUtility from '~/lib/utils/datetime_utility';
...@@ -89,5 +89,18 @@ describe('Schedule Common Mixins', () => { ...@@ -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 "" ...@@ -21188,7 +21188,7 @@ msgstr ""
msgid "OnCallSchedules|For this rotation, on-call will be:" msgid "OnCallSchedules|For this rotation, on-call will be:"
msgstr "" msgstr ""
msgid "OnCallSchedules|On-call schedule" msgid "OnCallSchedules|On-call schedules"
msgstr "" msgstr ""
msgid "OnCallSchedules|Please note, rotations with shifts that are less than four hours are currently not supported in the weekly view." 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', () => { ...@@ -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