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