Commit 55f36fa5 authored by Peter Leitzen's avatar Peter Leitzen

Merge branch '262847-add-schedule' into 'master'

Add a schedule modal

See merge request gitlab-org/gitlab!48122
parents 8df40386 21b672cd
@import 'mixins_and_variables_and_functions';
@mixin inset-border-1-red-500($important: false) {
box-shadow: inset 0 0 0 $gl-border-size-1 $red-500 if-important($important);
}
.timezone-dropdown {
.dropdown-menu {
@include gl-w-full;
}
.gl-new-dropdown-item-text-primary {
@include gl-overflow-hidden;
@include gl-text-overflow-ellipsis;
}
}
.modal-footer {
@include gl-bg-gray-10;
}
.invalid-dropdown {
.gl-dropdown-toggle {
@include inset-border-1-red-500;
&:hover {
@include inset-border-1-red-500(true);
}
}
}
# frozen_string_literal: true
module Ci
module PipelineSchedulesHelper
def timezone_data
ActiveSupport::TimeZone.all.map do |timezone|
{
name: timezone.name,
offset: timezone.now.utc_offset,
identifier: timezone.tzinfo.identifier
}
end
end
end
end
# frozen_string_literal: true
module TimeZoneHelper
def timezone_data
ActiveSupport::TimeZone.all.map do |timezone|
{
identifier: timezone.tzinfo.identifier,
name: timezone.name,
abbr: timezone.tzinfo.strftime('%Z'),
offset: timezone.now.utc_offset,
formatted_offset: timezone.now.formatted_offset
}
end
end
end
......@@ -203,6 +203,7 @@ module Gitlab
config.assets.precompile << "page_bundles/wiki.css"
config.assets.precompile << "page_bundles/xterm.css"
config.assets.precompile << "page_bundles/alert_management_settings.css"
config.assets.precompile << "page_bundles/oncall_schedules.css"
config.assets.precompile << "lazy_bundles/cropper.css"
config.assets.precompile << "lazy_bundles/select2.css"
config.assets.precompile << "performance_bar.css"
......
<script>
import { isEqual, isEmpty } from 'lodash';
import {
GlModal,
GlForm,
GlFormGroup,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
GlAlert,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import createOncallScheduleMutation from '../graphql/create_oncall_schedule.mutation.graphql';
export const i18n = {
selectTimezone: s__('OnCallSchedules|Select timezone'),
search: __('Search'),
noResults: __('No matching results'),
cancel: __('Cancel'),
addSchedule: s__('OnCallSchedules|Add schedule'),
fields: {
name: {
title: __('Name'),
validation: {
empty: __("Can't be empty"),
},
},
description: { title: __('Description (optional)') },
timezone: {
title: __('Timezone'),
description: s__(
'OnCallSchedules|Sets the default timezone for the schedule, for all participants',
),
validation: {
empty: __("Can't be empty"),
},
},
},
errorMsg: s__('OnCallSchedules|Failed to add schedule'),
};
export default {
i18n,
inject: ['projectPath', 'timezones'],
components: {
GlModal,
GlForm,
GlFormGroup,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
GlAlert,
},
props: {
modalId: {
type: String,
required: true,
},
},
data() {
return {
loading: false,
tzSearchTerm: '',
form: {
name: '',
description: '',
timezone: {},
},
error: null,
};
},
computed: {
actionsProps() {
return {
primary: {
text: i18n.addSchedule,
attributes: [
{ variant: 'info' },
{ loading: this.loading },
{ disabled: this.isFormInvalid },
],
},
cancel: {
text: i18n.cancel,
},
};
},
filteredTimezones() {
const lowerCaseTzSearchTerm = this.tzSearchTerm.toLowerCase();
return this.timezones.filter(tz =>
this.getFormattedTimezone(tz)
.toLowerCase()
.includes(lowerCaseTzSearchTerm),
);
},
noResults() {
return !this.filteredTimezones.length;
},
selectedTimezone() {
return isEmpty(this.form.timezone)
? i18n.selectTimezone
: this.getFormattedTimezone(this.form.timezone);
},
isNameInvalid() {
return !this.form.name.length;
},
isTimezoneInvalid() {
return isEmpty(this.form.timezone);
},
isFormInvalid() {
return this.isNameInvalid || this.isTimezoneInvalid;
},
},
methods: {
createSchedule() {
this.loading = true;
this.$apollo
.mutate({
mutation: createOncallScheduleMutation,
variables: {
oncallScheduleCreateInput: {
projectPath: this.projectPath,
...this.form,
timezone: this.form.timezone.identifier,
},
},
})
.then(({ data: { oncallScheduleCreate: { errors: [error] } } }) => {
if (error) {
throw error;
}
this.$refs.createScheduleModal.hide();
})
.catch(error => {
this.error = error;
})
.finally(() => {
this.loading = false;
});
},
setSelectedTimezone(tz) {
this.form.timezone = tz;
},
getFormattedTimezone(tz) {
return __(`(UTC${tz.formatted_offset}) ${tz.abbr} ${tz.name}`);
},
isTimezoneSelected(tz) {
return isEqual(tz, this.form.timezone);
},
hideErrorAlert() {
this.error = null;
},
},
};
</script>
<template>
<gl-modal
ref="createScheduleModal"
:modal-id="modalId"
size="sm"
:title="$options.i18n.addSchedule"
:action-primary="actionsProps.primary"
:action-cancel="actionsProps.cancel"
@primary.prevent="createSchedule"
>
<gl-alert v-if="error" variant="danger" class="gl-mt-n3 gl-mb-3" @dismiss="hideErrorAlert">
{{ error || $options.i18n.errorMsg }}
</gl-alert>
<gl-form>
<gl-form-group
:label="$options.i18n.fields.name.title"
:invalid-feedback="$options.i18n.fields.name.validation.empty"
label-size="sm"
label-for="schedule-name"
>
<gl-form-input id="schedule-name" v-model="form.name" :state="!isNameInvalid" />
</gl-form-group>
<gl-form-group
:label="$options.i18n.fields.description.title"
label-size="sm"
label-for="schedule-description"
>
<gl-form-input id="schedule-description" v-model="form.description" />
</gl-form-group>
<gl-form-group
:label="$options.i18n.fields.timezone.title"
label-size="sm"
label-for="schedule-timezone"
:description="$options.i18n.fields.timezone.description"
:state="!isTimezoneInvalid"
:invalid-feedback="$options.i18n.fields.timezone.validation.empty"
>
<gl-dropdown
id="schedule-timezone"
:text="selectedTimezone"
class="timezone-dropdown gl-w-full"
:header-text="$options.i18n.selectTimezone"
:class="{ 'invalid-dropdown': isTimezoneInvalid }"
>
<gl-search-box-by-type v-model.trim="tzSearchTerm" />
<gl-dropdown-item
v-for="tz in filteredTimezones"
:key="getFormattedTimezone(tz)"
:is-checked="isTimezoneSelected(tz)"
is-check-item
@click="setSelectedTimezone(tz)"
>
<span class="gl-white-space-nowrap"> {{ getFormattedTimezone(tz) }}</span>
</gl-dropdown-item>
<gl-dropdown-item v-if="noResults">
{{ $options.i18n.noResults }}
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
</gl-form>
</gl-modal>
</template>
<script>
import { GlEmptyState, GlButton } from '@gitlab/ui';
import { GlEmptyState, GlButton, GlModalDirective } from '@gitlab/ui';
import AddScheduleModal from './add_schedule_modal.vue';
import { s__ } from '~/locale';
const addScheduleModalId = 'addScheduleModal';
export const i18n = {
emptyState: {
title: s__('OnCallSchedules|Create on-call schedules in GitLab'),
......@@ -12,27 +15,33 @@ export const i18n = {
export default {
i18n,
addScheduleModalId,
inject: ['emptyOncallSchedulesSvgPath'],
components: {
GlEmptyState,
GlButton,
AddScheduleModal,
},
methods: {
createSchedule() {},
directives: {
GlModal: GlModalDirective,
},
methods: {},
};
</script>
<template>
<gl-empty-state
:title="$options.i18n.emptyState.title"
:description="$options.i18n.emptyState.description"
:svg-path="emptyOncallSchedulesSvgPath"
>
<template #actions>
<gl-button variant="info" @click="createSchedule">{{
$options.i18n.emptyState.button
}}</gl-button>
</template>
</gl-empty-state>
<div>
<gl-empty-state
:title="$options.i18n.emptyState.title"
:description="$options.i18n.emptyState.description"
:svg-path="emptyOncallSchedulesSvgPath"
>
<template #actions>
<gl-button v-gl-modal="$options.addScheduleModalId" variant="info">
{{ $options.i18n.emptyState.button }}
</gl-button>
</template>
</gl-empty-state>
<add-schedule-modal :modal-id="$options.addScheduleModalId" />
</div>
</template>
mutation oncallScheduleCreate($oncallScheduleCreateInput: OncallScheduleCreateInput!) {
oncallScheduleCreate(input: $oncallScheduleCreateInput) {
errors
oncallSchedule {
iid
name
description
timezone
}
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import OnCallSchedulesWrapper from './components/oncall_schedules_wrapper.vue';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export default () => {
const el = document.querySelector('#js-oncall_schedule');
if (!el) return null;
const { emptyOncallSchedulesSvgPath } = el.dataset;
const { projectPath, emptyOncallSchedulesSvgPath, timezones } = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
apolloProvider,
provide: {
projectPath,
emptyOncallSchedulesSvgPath,
timezones: JSON.parse(timezones),
},
render(createElement) {
return createElement(OnCallSchedulesWrapper);
......
......@@ -2,9 +2,11 @@
module IncidentManagement
module OncallScheduleHelper
def oncall_schedule_data
def oncall_schedule_data(project)
{
'empty-oncall-schedules-svg-path' => image_path('illustrations/empty-state/empty-on-call.svg')
'project-path' => project.full_path,
'empty-oncall-schedules-svg-path' => image_path('illustrations/empty-state/empty-on-call.svg'),
'timezones' => timezone_data.to_json
}
end
end
......
- page_title _('On-call schedules')
- add_page_specific_style 'page_bundles/oncall_schedules'
#js-oncall_schedule{ data: oncall_schedule_data }
#js-oncall_schedule{ data: oncall_schedule_data(@project) }
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddScheduleModal renders modal layout 1`] = `
<gl-modal-stub
actioncancel="[object Object]"
actionprimary="[object Object]"
modalclass=""
modalid="modalId"
size="sm"
title="Add schedule"
titletag="h4"
>
<!---->
<gl-form-stub>
<gl-form-group-stub
invalid-feedback="Can't be empty"
label="Name"
label-for="schedule-name"
label-size="sm"
>
<gl-form-input-stub
id="schedule-name"
value=""
/>
</gl-form-group-stub>
<gl-form-group-stub
label="Description (optional)"
label-for="schedule-description"
label-size="sm"
>
<gl-form-input-stub
id="schedule-description"
value=""
/>
</gl-form-group-stub>
<gl-form-group-stub
description="Sets the default timezone for the schedule, for all participants"
invalid-feedback="Can't be empty"
label="Timezone"
label-for="schedule-timezone"
label-size="sm"
>
<gl-dropdown-stub
category="primary"
class="timezone-dropdown gl-w-full invalid-dropdown"
headertext="Select timezone"
id="schedule-timezone"
size="medium"
text="Select timezone"
variant="default"
>
<gl-search-box-by-type-stub
clearbuttontitle="Clear"
value=""
/>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-12:00) -12 International Date Line West
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-11:00) SST American Samoa
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-11:00) SST Midway Island
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-10:00) HST Hawaii
</span>
</gl-dropdown-item-stub>
<!---->
</gl-dropdown-stub>
</gl-form-group-stub>
</gl-form-stub>
</gl-modal-stub>
`;
import { shallowMount } from '@vue/test-utils';
import { GlSearchBoxByType, GlDropdown, GlDropdownItem, GlModal, GlAlert } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import AddScheduleModal, { i18n } from 'ee/oncall_schedules/components/add_schedule_modal.vue';
import mockTimezones from './mocks/mockTimezones.json';
describe('AddScheduleModal', () => {
let wrapper;
const projectPath = 'group/project';
const mutate = jest.fn();
const mockHideModal = jest.fn();
function mountComponent() {
wrapper = shallowMount(AddScheduleModal, {
propsData: {
modalId: 'modalId',
},
provide: {
projectPath,
timezones: mockTimezones,
},
mocks: {
$apollo: {
mutate,
},
},
stubs: {
GlFormGroup: false,
},
});
wrapper.vm.$refs.createScheduleModal.hide = mockHideModal;
}
beforeEach(() => {
mountComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findModal = () => wrapper.find(GlModal);
const findAlert = () => wrapper.find(GlAlert);
const findTimezoneDropdown = () => wrapper.find(GlDropdown);
const findDropdownOptions = () => wrapper.findAll(GlDropdownItem);
const findTimezoneSearchBox = () => wrapper.find(GlSearchBoxByType);
it('renders modal layout', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('Timezone select', () => {
it('has options based on provided BE data', () => {
expect(findDropdownOptions().length).toBe(mockTimezones.length);
});
it('formats each option', () => {
findDropdownOptions().wrappers.forEach((option, index) => {
const tz = mockTimezones[index];
const expectedValue = `(UTC${tz.formatted_offset}) ${tz.abbr} ${tz.name}`;
expect(option.text()).toBe(expectedValue);
});
});
describe('timezones filtering', () => {
it('should filter options based on search term', async () => {
const searchTerm = 'Hawaii';
findTimezoneSearchBox().vm.$emit('input', searchTerm);
await wrapper.vm.$nextTick();
const options = findDropdownOptions();
expect(options.length).toBe(1);
expect(options.at(0).text()).toContain(searchTerm);
});
it('should display no results item when there are no filter matches', async () => {
const searchTerm = 'someUnexistentTZ';
findTimezoneSearchBox().vm.$emit('input', searchTerm);
await wrapper.vm.$nextTick();
const options = findDropdownOptions();
expect(options.length).toBe(1);
expect(options.at(0).text()).toContain(i18n.noResults);
});
});
it('should add a checkmark to the selected option', async () => {
const selectedTZOption = findDropdownOptions().at(0);
selectedTZOption.vm.$emit('click');
await wrapper.vm.$nextTick();
expect(selectedTZOption.attributes('ischecked')).toBe('true');
});
});
describe('Schedule create', () => {
it('makes a request with form data to create a schedule', () => {
mutate.mockResolvedValueOnce({});
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
expect(mutate).toHaveBeenCalledWith({
mutation: expect.any(Object),
variables: { oncallScheduleCreateInput: expect.objectContaining({ projectPath }) },
});
});
it('hides the modal on successful schedule creation', async () => {
mutate.mockResolvedValueOnce({ data: { oncallScheduleCreate: { errors: [] } } });
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
await waitForPromises();
expect(mockHideModal).toHaveBeenCalled();
});
it("doesn't hide a modal and shows error alert on fail", async () => {
const error = 'some error';
mutate.mockResolvedValueOnce({ data: { oncallScheduleCreate: { errors: [error] } } });
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
await waitForPromises();
const alert = findAlert();
expect(mockHideModal).not.toHaveBeenCalled();
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain(error);
});
});
describe('Form validation', () => {
describe('Timezone select', () => {
it('has red border when nothing selected', () => {
expect(findTimezoneDropdown().classes()).toContain('invalid-dropdown');
});
it("doesn't have a red border when there is selected opeion", async () => {
findDropdownOptions()
.at(1)
.vm.$emit('click');
await wrapper.vm.$nextTick();
expect(findTimezoneDropdown().classes()).not.toContain('invalid-dropdown');
});
});
});
});
[
{
"identifier": "Etc/GMT+12",
"name": "International Date Line West",
"abbr": "-12",
"formatted_offset": "-12:00"
},
{
"identifier": "Pacific/Pago_Pago",
"name": "American Samoa",
"abbr": "SST",
"formatted_offset": "-11:00"
},
{
"identifier": "Pacific/Midway",
"name": "Midway Island",
"abbr": "SST",
"formatted_offset": "-11:00"
},
{
"identifier": "Pacific/Honolulu",
"name": "Hawaii",
"abbr": "HST",
"formatted_offset": "-10:00"
}
]
......@@ -6,11 +6,13 @@ RSpec.describe IncidentManagement::OncallScheduleHelper do
let_it_be(:project) { create(:project) }
describe '#oncall_schedule_data' do
subject(:data) { helper.oncall_schedule_data }
subject(:data) { helper.oncall_schedule_data(project) }
it 'returns on-call schedule data' do
is_expected.to eq(
'empty-oncall-schedules-svg-path' => helper.image_path('illustrations/empty-state/empty-on-call.svg')
'project-path' => project.full_path,
'empty-oncall-schedules-svg-path' => helper.image_path('illustrations/empty-state/empty-on-call.svg'),
'timezones' => helper.timezone_data.to_json
)
end
end
......
......@@ -4881,6 +4881,9 @@ msgstr ""
msgid "Can't apply this suggestion."
msgstr ""
msgid "Can't be empty"
msgstr ""
msgid "Can't create snippet: %{err}"
msgstr ""
......@@ -9348,6 +9351,9 @@ msgstr ""
msgid "Description"
msgstr ""
msgid "Description (optional)"
msgstr ""
msgid "Description parsed with %{link_start}GitLab Flavored Markdown%{link_end}"
msgstr ""
......@@ -19033,12 +19039,24 @@ msgstr ""
msgid "OnCallSchedules|Add a schedule"
msgstr ""
msgid "OnCallSchedules|Add schedule"
msgstr ""
msgid "OnCallSchedules|Create on-call schedules in GitLab"
msgstr ""
msgid "OnCallSchedules|Failed to add schedule"
msgstr ""
msgid "OnCallSchedules|Route alerts directly to specific members of your team"
msgstr ""
msgid "OnCallSchedules|Select timezone"
msgstr ""
msgid "OnCallSchedules|Sets the default timezone for the schedule, for all participants"
msgstr ""
msgid "OnDemandScans|Could not fetch scanner profiles. Please refresh the page, or try again later."
msgstr ""
......@@ -28436,6 +28454,9 @@ msgstr ""
msgid "Timeout connecting to the Google API. Please try again."
msgstr ""
msgid "Timezone"
msgstr ""
msgid "Time|hr"
msgid_plural "Time|hrs"
msgstr[0] ""
......
......@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'Freeze Periods (JavaScript fixtures)' do
include JavaScriptFixturesHelpers
include Ci::PipelineSchedulesHelper
include TimeZoneHelper
let_it_be(:admin) { create(:admin) }
let_it_be(:project) { create(:project, :repository, path: 'freeze-periods-project') }
......@@ -40,10 +40,12 @@ RSpec.describe 'Freeze Periods (JavaScript fixtures)' do
end
end
describe Ci::PipelineSchedulesHelper, '(JavaScript fixtures)' do
describe TimeZoneHelper, '(JavaScript fixtures)' do
let(:response) { timezone_data.to_json }
it 'api/freeze-periods/timezone_data.json' do
# Looks empty but does things
# More info: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38525/diffs#note_391048415
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Ci::PipelineSchedulesHelper, :aggregate_failures do
describe '#timezone_data' do
subject { helper.timezone_data }
it 'matches schema' do
expect(subject).not_to be_empty
subject.each_with_index do |timzone_hash, i|
expect(timzone_hash.keys).to contain_exactly(:name, :offset, :identifier), "Failed at index #{i}"
end
end
it 'formats for display' do
first_timezone = ActiveSupport::TimeZone.all[0]
expect(subject[0][:name]).to eq(first_timezone.name)
expect(subject[0][:offset]).to eq(first_timezone.now.utc_offset)
expect(subject[0][:identifier]).to eq(first_timezone.tzinfo.identifier)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe TimeZoneHelper, :aggregate_failures do
describe '#timezone_data' do
subject(:timezone_data) { helper.timezone_data }
it 'matches schema' do
expect(timezone_data).not_to be_empty
timezone_data.each_with_index do |timezone_hash, i|
expect(timezone_hash.keys).to contain_exactly(
:identifier,
:name,
:abbr,
:offset,
:formatted_offset
), "Failed at index #{i}"
end
end
it 'formats for display' do
tz = ActiveSupport::TimeZone.all[0]
expect(timezone_data[0]).to eq(
identifier: tz.tzinfo.identifier,
name: tz.name,
abbr: tz.tzinfo.strftime('%Z'),
offset: tz.now.utc_offset,
formatted_offset: tz.now.formatted_offset
)
end
end
end
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