diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss new file mode 100644 index 0000000000000000000000000000000000000000..4a96d4fa612cd855323499cd3dbc77726ab16e3a --- /dev/null +++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss @@ -0,0 +1,30 @@ +@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); + } + } +} diff --git a/app/helpers/ci/pipeline_schedules_helper.rb b/app/helpers/ci/pipeline_schedules_helper.rb deleted file mode 100644 index 20e5c90a60e9cf82a6f8e62bcff57551783a9e61..0000000000000000000000000000000000000000 --- a/app/helpers/ci/pipeline_schedules_helper.rb +++ /dev/null @@ -1,15 +0,0 @@ -# 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 diff --git a/app/helpers/time_zone_helper.rb b/app/helpers/time_zone_helper.rb new file mode 100644 index 0000000000000000000000000000000000000000..daf99ad9b5e92e8ccfb9b0fa61f46eef6de6a265 --- /dev/null +++ b/app/helpers/time_zone_helper.rb @@ -0,0 +1,15 @@ +# 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 diff --git a/config/application.rb b/config/application.rb index e8aebec086b981f20cb9fa2bf0b47dc89955455c..023b1a059ac2fb562017267dd9da3a46f128d156 100644 --- a/config/application.rb +++ b/config/application.rb @@ -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" diff --git a/ee/app/assets/javascripts/oncall_schedules/components/add_schedule_modal.vue b/ee/app/assets/javascripts/oncall_schedules/components/add_schedule_modal.vue new file mode 100644 index 0000000000000000000000000000000000000000..ca530941c5855f2670f40fa4323df463cceef15c --- /dev/null +++ b/ee/app/assets/javascripts/oncall_schedules/components/add_schedule_modal.vue @@ -0,0 +1,223 @@ +<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> diff --git a/ee/app/assets/javascripts/oncall_schedules/components/oncall_schedules_wrapper.vue b/ee/app/assets/javascripts/oncall_schedules/components/oncall_schedules_wrapper.vue index 1037dcacf8431ed16e50c8fe967dc18f8af9e90b..504d9bade708609d8a7b3e870c19acc8ec5e399a 100644 --- a/ee/app/assets/javascripts/oncall_schedules/components/oncall_schedules_wrapper.vue +++ b/ee/app/assets/javascripts/oncall_schedules/components/oncall_schedules_wrapper.vue @@ -1,7 +1,10 @@ <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> diff --git a/ee/app/assets/javascripts/oncall_schedules/graphql/create_oncall_schedule.mutation.graphql b/ee/app/assets/javascripts/oncall_schedules/graphql/create_oncall_schedule.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..27631fd9c752c46e9856cc42ce6b6f29f414f8d6 --- /dev/null +++ b/ee/app/assets/javascripts/oncall_schedules/graphql/create_oncall_schedule.mutation.graphql @@ -0,0 +1,11 @@ +mutation oncallScheduleCreate($oncallScheduleCreateInput: OncallScheduleCreateInput!) { + oncallScheduleCreate(input: $oncallScheduleCreateInput) { + errors + oncallSchedule { + iid + name + description + timezone + } + } +} diff --git a/ee/app/assets/javascripts/oncall_schedules/index.js b/ee/app/assets/javascripts/oncall_schedules/index.js index 71f0db171f2420e5e05ff6236050b632fb423827..a3a1e3e1ee15ee2121578503f7060673ff059d19 100644 --- a/ee/app/assets/javascripts/oncall_schedules/index.js +++ b/ee/app/assets/javascripts/oncall_schedules/index.js @@ -1,17 +1,28 @@ 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); diff --git a/ee/app/helpers/incident_management/oncall_schedule_helper.rb b/ee/app/helpers/incident_management/oncall_schedule_helper.rb index c9d41a83a11b8cdb25b463f2d6cb34feb9b51724..ec4ab46f7c8ddb7d9c0ac6b0357c653c470314a0 100644 --- a/ee/app/helpers/incident_management/oncall_schedule_helper.rb +++ b/ee/app/helpers/incident_management/oncall_schedule_helper.rb @@ -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 diff --git a/ee/app/views/projects/incident_management/oncall_schedules/index.html.haml b/ee/app/views/projects/incident_management/oncall_schedules/index.html.haml index 9f2945fc3896ad124fde8d7c7d6e721c74dd9927..8cc16924f0add4d7a54c678048e19ab20fd37871 100644 --- a/ee/app/views/projects/incident_management/oncall_schedules/index.html.haml +++ b/ee/app/views/projects/incident_management/oncall_schedules/index.html.haml @@ -1,3 +1,4 @@ - 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) } diff --git a/ee/spec/frontend/oncall_schedule/__snapshots__/add_schedule_modal_spec.js.snap b/ee/spec/frontend/oncall_schedule/__snapshots__/add_schedule_modal_spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..671eca39a1fc443197fe50398071e9e8723dad14 --- /dev/null +++ b/ee/spec/frontend/oncall_schedule/__snapshots__/add_schedule_modal_spec.js.snap @@ -0,0 +1,126 @@ +// 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> +`; diff --git a/ee/spec/frontend/oncall_schedule/add_schedule_modal_spec.js b/ee/spec/frontend/oncall_schedule/add_schedule_modal_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b6947e1afccd171bf03bd6f032bcf7264197a8a7 --- /dev/null +++ b/ee/spec/frontend/oncall_schedule/add_schedule_modal_spec.js @@ -0,0 +1,140 @@ +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'); + }); + }); + }); +}); diff --git a/ee/spec/frontend/oncall_schedule/mocks/mockTimezones.json b/ee/spec/frontend/oncall_schedule/mocks/mockTimezones.json new file mode 100644 index 0000000000000000000000000000000000000000..9faec475c2c79d248334ac0624f07e8e10b26e2e --- /dev/null +++ b/ee/spec/frontend/oncall_schedule/mocks/mockTimezones.json @@ -0,0 +1,26 @@ +[ + { + "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" + } +] diff --git a/ee/spec/helpers/incident_management/oncall_schedule_helper_spec.rb b/ee/spec/helpers/incident_management/oncall_schedule_helper_spec.rb index bea4bf5fb9d1daf255be173bad6cd4872c350665..8382bfeaa4fdf3a478cb282aabba600cb32dbc2c 100644 --- a/ee/spec/helpers/incident_management/oncall_schedule_helper_spec.rb +++ b/ee/spec/helpers/incident_management/oncall_schedule_helper_spec.rb @@ -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 diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3558eb11ae284c8b669270df0ddf0f06ad4fd5ce..056ca2a411cf18ed7932381afa7b8be8fb5568d4 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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] "" diff --git a/spec/frontend/fixtures/freeze_period.rb b/spec/frontend/fixtures/freeze_period.rb index 193bd0c3ef25c051a8c3be867d856e8c36298d83..b05045663f172671e53a1e65eab2749faac1d86b 100644 --- a/spec/frontend/fixtures/freeze_period.rb +++ b/spec/frontend/fixtures/freeze_period.rb @@ -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 diff --git a/spec/helpers/ci/pipeline_schedules_helper_spec.rb b/spec/helpers/ci/pipeline_schedules_helper_spec.rb deleted file mode 100644 index 2a81c2a44a0863df9e50806adc0ccbaa7017781b..0000000000000000000000000000000000000000 --- a/spec/helpers/ci/pipeline_schedules_helper_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -# 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 diff --git a/spec/helpers/time_zone_helper_spec.rb b/spec/helpers/time_zone_helper_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7e7eb3684747419284ff845b545c01a624b6d914 --- /dev/null +++ b/spec/helpers/time_zone_helper_spec.rb @@ -0,0 +1,35 @@ +# 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