diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue index a35b3f14be42540941432bd359efec5441dc7891..b70294c9db3755b4fa17e3c84aab720219dcf011 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue @@ -6,7 +6,7 @@ export default { components: { IssuableTimeTracker, }, - inject: ['timeTrackingLimitToHours'], + inject: ['timeTrackingLimitToHours', 'canUpdate'], computed: { ...mapGetters(['activeBoardItem']), initialTimeTracking() { @@ -34,5 +34,6 @@ export default { :limit-to-hours="timeTrackingLimitToHours" :initial-time-tracking="initialTimeTracking" :show-collapsed="false" + :can-add-time-entries="canUpdate" /> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/constants.js b/app/assets/javascripts/sidebar/components/time_tracking/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..56e986e3b270de8873f67b2dfea1de860f486ab7 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/constants.js @@ -0,0 +1 @@ +export const CREATE_TIMELOG_MODAL_ID = 'create-timelog-modal'; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue new file mode 100644 index 0000000000000000000000000000000000000000..ec8e1ee9952686be8dd2b896a36607df04180d1e --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue @@ -0,0 +1,227 @@ + + + diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue index 62b054218843dbd05f424d192b3d544011e190eb..06adc048942b584d6578317c250f80c97c1f161f 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue @@ -30,6 +30,11 @@ export default { required: false, default: false, }, + canAddTimeEntries: { + type: Boolean, + required: false, + default: true, + }, }, mounted() { this.listenForQuickActions(); @@ -67,6 +72,7 @@ export default { :issuable-id="issuableId" :issuable-iid="issuableIid" :limit-to-hours="limitToHours" + :can-add-time-entries="canAddTimeEntries" /> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 3d4939490a36e5c3d94053a0ff25abd48e118b36..b32836dc87da46ef705c80b263552620c33fe948 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -9,15 +9,17 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { IssuableType } from '~/issues/constants'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { s__, __ } from '~/locale'; import { HOW_TO_TRACK_TIME, timeTrackingQueries } from '../../constants'; import eventHub from '../../event_hub'; import TimeTrackingCollapsedState from './collapsed_state.vue'; import TimeTrackingComparisonPane from './comparison_pane.vue'; -import TimeTrackingHelpState from './help_state.vue'; import TimeTrackingReport from './report.vue'; import TimeTrackingSpentOnlyPane from './spent_only_pane.vue'; +import { CREATE_TIMELOG_MODAL_ID } from './constants'; +import CreateTimelogForm from './create_timelog_form.vue'; export default { name: 'IssuableTimeTracker', @@ -34,8 +36,8 @@ export default { TimeTrackingCollapsedState, TimeTrackingSpentOnlyPane, TimeTrackingComparisonPane, - TimeTrackingHelpState, TimeTrackingReport, + CreateTimelogForm, }, directives: { GlModal: GlModalDirective, @@ -87,6 +89,11 @@ export default { default: true, required: false, }, + canAddTimeEntries: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -192,12 +199,12 @@ export default { eventHub.$on('timeTracker:refresh', this.refresh); }, methods: { - toggleHelpState(show) { - this.showHelp = show; - }, refresh() { this.$apollo.queries.issuableTimeTracking.refetch(); }, + openRegisterTimeSpentModal() { + this.$root.$emit(BV_SHOW_MODAL, CREATE_TIMELOG_MODAL_ID); + }, }, }; @@ -215,24 +222,21 @@ export default { :time-estimate-human-readable="humanTimeEstimate" />
{{ __('Time tracking') }} - +
@@ -272,9 +276,7 @@ export default { - - - +
diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js index afce59d304f5b6bc9a0fb1d8e3dcb7ec7ab26ea0..b908cf0cd9e5411244f275e12a98238d96bea4fc 100644 --- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js @@ -39,6 +39,7 @@ export default class SidebarMilestone { humanTimeEstimate, humanTotalTimeSpent: humanTimeSpent, }, + canAddTimeEntries: false, }, }), }); diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 5127db6368d1d1d6de5e7df3ba9c4df6b4cc6cc2..a308dc8d13c36d89965e87c59a1002cacbdba33b 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -544,7 +544,15 @@ function mountSidebarSubscriptionsWidget() { function mountSidebarTimeTracking() { const el = document.querySelector('.js-sidebar-time-tracking-root'); - const { id, iid, fullPath, issuableType, timeTrackingLimitToHours } = getSidebarOptions(); + + const { + id, + iid, + fullPath, + issuableType, + timeTrackingLimitToHours, + canCreateTimelogs, + } = getSidebarOptions(); if (!el) { return null; @@ -562,6 +570,7 @@ function mountSidebarTimeTracking() { issuableId: id.toString(), issuableIid: iid.toString(), limitToHours: timeTrackingLimitToHours, + canAddTimeEntries: canCreateTimelogs, }, }), }); diff --git a/app/assets/javascripts/sidebar/queries/create_timelog.mutation.graphql b/app/assets/javascripts/sidebar/queries/create_timelog.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..a8692387a46bba8ee2bdd94ef598dac638f542f0 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/create_timelog.mutation.graphql @@ -0,0 +1,17 @@ +#import "~/graphql_shared/fragments/issue_time_tracking.fragment.graphql" +#import "~/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql" + +mutation createTimelog($input: TimelogCreateInput!) { + timelogCreate(input: $input) { + errors + timelog { + id + issue { + ...IssueTimeTrackingFragment + } + mergeRequest { + ...MergeRequestTimeTrackingFragment + } + } + } +} diff --git a/app/graphql/mutations/timelogs/create.rb b/app/graphql/mutations/timelogs/create.rb index bab7508454e3714ea868143d1019ab57625b3ebd..1be023eed8a8b2da111aca8ee8e9e52820cd4ffd 100644 --- a/app/graphql/mutations/timelogs/create.rb +++ b/app/graphql/mutations/timelogs/create.rb @@ -11,7 +11,7 @@ class Create < Base description: 'Amount of time spent.' argument :spent_at, - Types::DateType, + Types::TimeType, required: true, description: 'When the time was spent.' @@ -28,8 +28,12 @@ class Create < Base authorize :create_timelog def resolve(issuable_id:, time_spent:, spent_at:, summary:, **args) - issuable = authorized_find!(id: issuable_id) parsed_time_spent = Gitlab::TimeTrackingFormatter.parse(time_spent) + if parsed_time_spent.nil? + return { timelog: nil, errors: [_('Time spent must be formatted correctly. For example: 1h 30m.')] } + end + + issuable = authorized_find!(id: issuable_id) result = ::Timelogs::CreateService.new( issuable, parsed_time_spent, spent_at, summary, current_user diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index c43cca3a81d35f0074391b805a0f51d455766951..e0bf4e8a759501ed5c7fb8c7dcab7fe5c21abe3a 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -412,6 +412,7 @@ def issuable_sidebar_options(issuable) id: issuable[:id], severity: issuable[:severity], timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours, + canCreateTimelogs: issuable.dig(:current_user, :can_create_timelogs), createNoteEmail: issuable[:create_note_email], issuableType: issuable[:type] } diff --git a/app/serializers/issuable_sidebar_basic_entity.rb b/app/serializers/issuable_sidebar_basic_entity.rb index b66aad6cc6522b4e52b9488996c94074549a3291..9039606a8e59a0c45b9c64ac8bc3d3e524c76182 100644 --- a/app/serializers/issuable_sidebar_basic_entity.rb +++ b/app/serializers/issuable_sidebar_basic_entity.rb @@ -38,6 +38,10 @@ class IssuableSidebarBasicEntity < Grape::Entity expose :can_admin_label do |issuable| can?(current_user, :admin_label, issuable.project) end + + expose :can_create_timelogs do |issuable| + can?(current_user, :create_timelog, issuable) + end end expose :issuable_json_path do |issuable| diff --git a/app/services/timelogs/base_service.rb b/app/services/timelogs/base_service.rb index e09264864fd3cb4ccaa1fe715b982037f5ea525e..712a0a4f1280442972a0fb18fc8772cf801ac73f 100644 --- a/app/services/timelogs/base_service.rb +++ b/app/services/timelogs/base_service.rb @@ -22,9 +22,9 @@ def error(message, http_status = nil) end def error_in_save(timelog) - return error(_("Failed to save timelog")) if timelog.errors.empty? + return error(_("Failed to save timelog"), 404) if timelog.errors.empty? - error(timelog.errors.full_messages.to_sentence) + error(timelog.errors.full_messages.to_sentence, 404) end end end diff --git a/app/services/timelogs/create_service.rb b/app/services/timelogs/create_service.rb index 12181cec20a05b31246f59be275c500cbbe2a8d8..19428864fa94c086180d7e6d136eda36d9a2f7ca 100644 --- a/app/services/timelogs/create_service.rb +++ b/app/services/timelogs/create_service.rb @@ -21,6 +21,9 @@ def execute }, 404) end + return error(_("Spent at can't be a future date and time."), 404) if spent_at.future? + return error(_("Time spent can't be zero."), 404) if time_spent == 0 + issue = issuable if issuable.is_a?(Issue) merge_request = issuable if issuable.is_a?(MergeRequest) diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 7feaddf3e2655bbb388599cc60d2038e9f23eca4..a8c1b39ba7c9c6a978dbb5c9acdbc75d830ab7e3 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -5106,7 +5106,7 @@ Input type: `TimelogCreateInput` | ---- | ---- | ----------- | | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | `issuableId` | [`IssuableID!`](#issuableid) | Global ID of the issuable (Issue, WorkItem or MergeRequest). | -| `spentAt` | [`Date!`](#date) | When the time was spent. | +| `spentAt` | [`Time!`](#time) | When the time was spent. | | `summary` | [`String!`](#string) | Summary of time spent. | | `timeSpent` | [`String!`](#string) | Amount of time spent. | diff --git a/ee/spec/requests/api/graphql/mutations/timelogs/create_spec.rb b/ee/spec/requests/api/graphql/mutations/timelogs/create_spec.rb index af8855a8334b0719054546265937e98e0fe3d117..1e3491882f63f488707705ab36fd1538ea918c79 100644 --- a/ee/spec/requests/api/graphql/mutations/timelogs/create_spec.rb +++ b/ee/spec/requests/api/graphql/mutations/timelogs/create_spec.rb @@ -12,16 +12,6 @@ let(:current_user) { nil } let(:users_container) { group } - let(:mutation) do - graphql_mutation(:timelogCreate, { - 'time_spent' => time_spent, - 'spent_at' => '2022-07-08', - 'summary' => 'Test summary', - 'issuable_id' => issuable.to_global_id.to_s - }) - end - - let(:mutation_response) { graphql_mutation_response(:timelog_create) } context 'when issuable is an Epic' do let_it_be(:issuable) { create(:epic, group: group) } diff --git a/ee/spec/serializers/merge_request_sidebar_basic_entity_spec.rb b/ee/spec/serializers/merge_request_sidebar_basic_entity_spec.rb index 3d625bb67c4a2dfc51543f2f493167cd16c89029..b50399f43a6b64bfd5158f7e8f114cd8170e136d 100644 --- a/ee/spec/serializers/merge_request_sidebar_basic_entity_spec.rb +++ b/ee/spec/serializers/merge_request_sidebar_basic_entity_spec.rb @@ -17,7 +17,7 @@ stub_feature_flags(gitlab_employee_badge: false) expect(entity[:current_user].keys).to contain_exactly( - :id, :name, :username, :state, :avatar_url, :web_url, :todo, + :id, :name, :username, :state, :avatar_url, :web_url, :todo, :can_create_timelogs, :can_edit, :can_move, :can_admin_label, :can_merge, :can_update_merge_request ) end @@ -29,7 +29,7 @@ allow(Gitlab).to receive(:com?).and_return(false) expect(entity[:current_user].keys).to contain_exactly( - :id, :name, :username, :state, :avatar_url, :web_url, :todo, + :id, :name, :username, :state, :avatar_url, :web_url, :todo, :can_create_timelogs, :can_edit, :can_move, :can_admin_label, :can_merge, :can_update_merge_request ) end @@ -41,7 +41,7 @@ allow(Gitlab).to receive(:com?).and_return(true) expect(entity[:current_user].keys).to contain_exactly( - :id, :name, :username, :state, :avatar_url, :web_url, :todo, + :id, :name, :username, :state, :avatar_url, :web_url, :todo, :can_create_timelogs, :can_edit, :can_move, :can_admin_label, :can_merge, :is_gitlab_employee, :can_update_merge_request ) end diff --git a/ee/spec/services/timelogs/create_service_spec.rb b/ee/spec/services/timelogs/create_service_spec.rb index f10385c6572bdab37c4d0e56fe044d9b76cafdef..0376904a69f45a0931af1f2ef94229dad075328b 100644 --- a/ee/spec/services/timelogs/create_service_spec.rb +++ b/ee/spec/services/timelogs/create_service_spec.rb @@ -2,17 +2,13 @@ require 'spec_helper' -RSpec.describe Timelogs::CreateService do +RSpec.describe Timelogs::CreateService, feature_category: :team_planning do let_it_be(:author) { create(:user) } let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, group: group) } - let_it_be(:time_spent) { 3600 } - let_it_be(:spent_at) { "2022-07-08" } - let_it_be(:summary) { "Test summary" } let(:issuable) { nil } let(:users_container) { group } - let(:service) { described_class.new(issuable, time_spent, spent_at, summary, user) } describe '#execute' do subject { service.execute } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 26784602b0b5e0fb54744939a37330585be338a9..120fbdf385ca2b574fac2aa0f3f41c266ff597e9 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2383,6 +2383,9 @@ msgstr "" msgid "Add text to the sign-in page. Markdown enabled." msgstr "" +msgid "Add time entry" +msgstr "" + msgid "Add to board" msgstr "" @@ -11484,6 +11487,42 @@ msgstr "" msgid "CreateTag|Tag" msgstr "" +msgid "CreateTimelogForm|Add time entry" +msgstr "" + +msgid "CreateTimelogForm|An error occurred while saving the time entry." +msgstr "" + +msgid "CreateTimelogForm|Cancel" +msgstr "" + +msgid "CreateTimelogForm|Example: 1h 30m" +msgstr "" + +msgid "CreateTimelogForm|How do I track and estimate time?" +msgstr "" + +msgid "CreateTimelogForm|Save" +msgstr "" + +msgid "CreateTimelogForm|Spent at" +msgstr "" + +msgid "CreateTimelogForm|Summary" +msgstr "" + +msgid "CreateTimelogForm|Time spent" +msgstr "" + +msgid "CreateTimelogForm|Track time spent on this %{issuableTypeNameStart}%{issuableTypeNameEnd}. %{timeTrackingDocsLinkStart}%{timeTrackingDocsLinkEnd}" +msgstr "" + +msgid "CreateTimelogForm|issue" +msgstr "" + +msgid "CreateTimelogForm|merge request" +msgstr "" + msgid "CreateValueStreamForm|%{name} (default)" msgstr "" @@ -39252,6 +39291,9 @@ msgstr "" msgid "Spent at" msgstr "" +msgid "Spent at can't be a future date and time." +msgstr "" + msgid "Squash commit message" msgstr "" @@ -42532,6 +42574,12 @@ msgstr "" msgid "Time spent" msgstr "" +msgid "Time spent can't be zero." +msgstr "" + +msgid "Time spent must be formatted correctly. For example: 1h 30m." +msgstr "" + msgid "Time to Restore Service" msgstr "" diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js index 5c4356434255cdb6fb528e2318660202aff87d37..e2e4baefad00a42612faeffd3eabb261a29738f1 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js @@ -42,13 +42,20 @@ describe('BoardSidebarTimeTracker', () => { wrapper = null; }); - it.each([[true], [false]])( - 'renders IssuableTimeTracker with correct spent and estimated time (timeTrackingLimitToHours=%s)', - (timeTrackingLimitToHours) => { - createComponent({ provide: { timeTrackingLimitToHours } }); + it.each` + timeTrackingLimitToHours | canUpdate + ${true} | ${false} + ${true} | ${true} + ${false} | ${false} + ${false} | ${true} + `( + 'renders IssuableTimeTracker with correct spent and estimated time (timeTrackingLimitToHours=$timeTrackingLimitToHours, canUpdate=$canUpdate)', + ({ timeTrackingLimitToHours, canUpdate }) => { + createComponent({ provide: { timeTrackingLimitToHours, canUpdate } }); expect(wrapper.findComponent(IssuableTimeTracker).props()).toEqual({ limitToHours: timeTrackingLimitToHours, + canAddTimeEntries: canUpdate, showCollapsed: false, issuableId: '1', issuableIid: '1', diff --git a/spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js b/spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..cb3bb7a4538c32eabde6f51ce86df00de11b659c --- /dev/null +++ b/spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js @@ -0,0 +1,219 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlAlert, GlModal } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import CreateTimelogForm from '~/sidebar/components/time_tracking/create_timelog_form.vue'; +import createTimelogMutation from '~/sidebar/queries/create_timelog.mutation.graphql'; +import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; + +const mockMutationErrorMessage = 'Example error message'; + +const resolvedMutationWithoutErrorsMock = jest.fn().mockResolvedValue({ + data: { + timelogCreate: { + errors: [], + timelog: { + id: 'gid://gitlab/Timelog/1', + issue: {}, + mergeRequest: {}, + }, + }, + }, +}); + +const resolvedMutationWithErrorsMock = jest.fn().mockResolvedValue({ + data: { + timelogCreate: { + errors: [{ message: mockMutationErrorMessage }], + timelog: null, + }, + }, +}); + +const rejectedMutationMock = jest.fn().mockRejectedValue(); +const modalCloseMock = jest.fn(); + +describe('Create Timelog Form', () => { + Vue.use(VueApollo); + + let wrapper; + let fakeApollo; + + const findForm = () => wrapper.find('form'); + const findModal = () => wrapper.findComponent(GlModal); + const findAlert = () => wrapper.findComponent(GlAlert); + const findDocsLink = () => wrapper.findByTestId('timetracking-docs-link'); + const findSaveButton = () => findModal().props('actionPrimary'); + const findSaveButtonLoadingState = () => findSaveButton().attributes[0].loading; + const findSaveButtonDisabledState = () => findSaveButton().attributes[0].disabled; + + const submitForm = () => findForm().trigger('submit'); + + const mountComponent = ( + { props, data, providedProps } = {}, + mutationResolverMock = rejectedMutationMock, + ) => { + fakeApollo = createMockApollo([[createTimelogMutation, mutationResolverMock]]); + + wrapper = shallowMountExtended(CreateTimelogForm, { + data() { + return { + ...data, + }; + }, + provide: { + issuableType: 'issue', + ...providedProps, + }, + propsData: { + issuableId: '1', + ...props, + }, + apolloProvider: fakeApollo, + }); + + wrapper.vm.$refs.modal.close = modalCloseMock; + }; + + afterEach(() => { + fakeApollo = null; + }); + + describe('save button', () => { + it('is disabled and not loading by default', () => { + mountComponent(); + + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(true); + }); + + it('is enabled and not loading when time spent is not empty', () => { + mountComponent({ data: { timeSpent: '2d' } }); + + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(false); + }); + + it('is disabled and loading when the the form is submitted', async () => { + mountComponent({ data: { timeSpent: '2d' } }); + + submitForm(); + + await nextTick(); + + expect(findSaveButtonLoadingState()).toBe(true); + expect(findSaveButtonDisabledState()).toBe(true); + }); + + it('is enabled and not loading the when form is submitted but the mutation has errors', async () => { + mountComponent({ data: { timeSpent: '2d' } }); + + submitForm(); + + await waitForPromises(); + + expect(rejectedMutationMock).toHaveBeenCalled(); + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(false); + }); + + it('is enabled and not loading the when form is submitted but the mutation returns errors', async () => { + mountComponent({ data: { timeSpent: '2d' } }, resolvedMutationWithErrorsMock); + + submitForm(); + + await waitForPromises(); + + expect(resolvedMutationWithErrorsMock).toHaveBeenCalled(); + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(false); + }); + }); + + describe('form', () => { + it('does not call any mutation when the the form is incomplete', async () => { + mountComponent(); + + submitForm(); + + await waitForPromises(); + + expect(rejectedMutationMock).not.toHaveBeenCalled(); + }); + + it('closes the modal after a successful mutation', async () => { + mountComponent({ data: { timeSpent: '2d' } }, resolvedMutationWithoutErrorsMock); + + submitForm(); + + await waitForPromises(); + await nextTick(); + + expect(modalCloseMock).toHaveBeenCalled(); + }); + + it.each` + issuableType | typeConstant + ${'issue'} | ${TYPE_ISSUE} + ${'merge_request'} | ${TYPE_MERGE_REQUEST} + `( + 'calls the mutation with all the fields when the the form is submitted and issuable type is $issuableType', + async ({ issuableType, typeConstant }) => { + const timeSpent = '2d'; + const spentAt = '2022-11-20T21:53:00+0000'; + const summary = 'Example'; + + mountComponent({ data: { timeSpent, spentAt, summary }, providedProps: { issuableType } }); + + submitForm(); + + await waitForPromises(); + + expect(rejectedMutationMock).toHaveBeenCalledWith({ + input: { timeSpent, spentAt, summary, issuableId: convertToGraphQLId(typeConstant, '1') }, + }); + }, + ); + }); + + describe('alert', () => { + it('is hidden by default', () => { + mountComponent(); + + expect(findAlert().exists()).toBe(false); + }); + + it('shows an error if the submission fails with a handled error', async () => { + mountComponent({ data: { timeSpent: '2d' } }, resolvedMutationWithErrorsMock); + + submitForm(); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(mockMutationErrorMessage); + }); + + it('shows an error if the submission fails with an unhandled error', async () => { + mountComponent({ data: { timeSpent: '2d' } }); + + submitForm(); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe('An error occurred while saving the time entry.'); + }); + }); + + describe('docs link message', () => { + it('is present', () => { + mountComponent(); + + expect(findDocsLink().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js index 835e700e63cb914b275b2f0e537f48638fe9c66a..45d8b5e4647931b2dfb0ae8c284aa0081829dca2 100644 --- a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js @@ -268,47 +268,32 @@ describe('Issuable Time Tracker', () => { }); }); - describe('Help pane', () => { - const findHelpButton = () => findByTestId('helpButton'); - const findCloseHelpButton = () => findByTestId('closeHelpButton'); - - beforeEach(async () => { - wrapper = mountComponent({ - props: { - initialTimeTracking: { - timeEstimate: 0, - totalTimeSpent: 0, - humanTimeEstimate: '', - humanTotalTimeSpent: '', + describe('Add button', () => { + const findAddButton = () => findByTestId('add-time-entry-button'); + + it.each` + visibility | canAddTimeEntries + ${'not visible'} | ${false} + ${'visible'} | ${true} + `( + 'is $visibility when canAddTimeEntries is $canAddTimeEntries', + async ({ canAddTimeEntries }) => { + wrapper = mountComponent({ + props: { + initialTimeTracking: { + timeEstimate: 0, + totalTimeSpent: 0, + humanTimeEstimate: '', + humanTotalTimeSpent: '', + }, + canAddTimeEntries, }, - }, - }); - await nextTick(); - }); - - it('should not show the "Help" pane by default', () => { - expect(findByTestId('helpPane').exists()).toBe(false); - }); - - it('should show the "Help" pane when help button is clicked', async () => { - findHelpButton().trigger('click'); - - await nextTick(); - - expect(findByTestId('helpPane').exists()).toBe(true); - }); - - it('should not show the "Help" pane when help button is clicked and then closed', async () => { - findHelpButton().trigger('click'); - await nextTick(); - - expect(findByTestId('helpPane').exists()).toBe(true); - - findCloseHelpButton().trigger('click'); - await nextTick(); + }); + await nextTick(); - expect(findByTestId('helpPane').exists()).toBe(false); - }); + expect(findAddButton().exists()).toBe(canAddTimeEntries); + }, + ); }); }); diff --git a/spec/requests/api/graphql/mutations/timelogs/create_spec.rb b/spec/requests/api/graphql/mutations/timelogs/create_spec.rb index eea04b89783bcc3a366a94c06f3608277a7258c1..9b89d053901ece135149e04d42b8f96ca9fb14bf 100644 --- a/spec/requests/api/graphql/mutations/timelogs/create_spec.rb +++ b/spec/requests/api/graphql/mutations/timelogs/create_spec.rb @@ -11,14 +11,6 @@ let(:current_user) { nil } let(:users_container) { project } - let(:mutation) do - graphql_mutation(:timelogCreate, { - 'time_spent' => time_spent, - 'spent_at' => '2022-07-08', - 'summary' => 'Test summary', - 'issuable_id' => issuable.to_global_id.to_s - }) - end let(:mutation_response) { graphql_mutation_response(:timelog_create) } diff --git a/spec/services/timelogs/create_service_spec.rb b/spec/services/timelogs/create_service_spec.rb index b5ed4a005c7c6e431e618dd8abc0e2736c1c8281..73860619bcc72502375dee7afe8ef65a60f6bb1f 100644 --- a/spec/services/timelogs/create_service_spec.rb +++ b/spec/services/timelogs/create_service_spec.rb @@ -2,16 +2,12 @@ require 'spec_helper' -RSpec.describe Timelogs::CreateService do +RSpec.describe Timelogs::CreateService, feature_category: :team_planning do let_it_be(:author) { create(:user) } let_it_be(:project) { create(:project, :public) } - let_it_be(:time_spent) { 3600 } - let_it_be(:spent_at) { "2022-07-08" } - let_it_be(:summary) { "Test summary" } let(:issuable) { nil } let(:users_container) { project } - let(:service) { described_class.new(issuable, time_spent, spent_at, summary, user) } describe '#execute' do subject { service.execute } diff --git a/spec/support/shared_examples/graphql/mutations/timelogs/create_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/timelogs/create_shared_examples.rb index 9e8478d76381e49551b878453bf75bc1170bd277..c6402a89f02dc2604ecb79aca6ad34007cc1a23f 100644 --- a/spec/support/shared_examples/graphql/mutations/timelogs/create_shared_examples.rb +++ b/spec/support/shared_examples/graphql/mutations/timelogs/create_shared_examples.rb @@ -1,6 +1,17 @@ # frozen_string_literal: true RSpec.shared_examples 'issuable supports timelog creation mutation' do + let(:mutation_response) { graphql_mutation_response(:timelog_create) } + let(:mutation) do + variables = { + 'time_spent' => time_spent, + 'spent_at' => '2022-11-16T12:59:35+0100', + 'summary' => 'Test summary', + 'issuable_id' => issuable.to_global_id.to_s + } + graphql_mutation(:timelogCreate, variables) + end + context 'when the user is anonymous' do before do post_graphql_mutation(mutation, current_user: current_user) @@ -38,7 +49,8 @@ expect(mutation_response['errors']).to be_empty expect(mutation_response['timelog']).to include( 'timeSpent' => 3600, - 'spentAt' => '2022-07-08T00:00:00Z', + # This also checks that the ISO time was converted to UTC + 'spentAt' => '2022-11-16T11:59:35Z', 'summary' => 'Test summary' ) end @@ -53,7 +65,8 @@ end.to change { Timelog.count }.by(0) expect(response).to have_gitlab_http_status(:success) - expect(mutation_response['errors']).to match_array(['Time spent can\'t be blank']) + expect(mutation_response['errors']).to match_array( + ['Time spent must be formatted correctly. For example: 1h 30m.']) expect(mutation_response['timelog']).to be_nil end end @@ -61,6 +74,17 @@ end RSpec.shared_examples 'issuable does not support timelog creation mutation' do + let(:mutation_response) { graphql_mutation_response(:timelog_create) } + let(:mutation) do + variables = { + 'time_spent' => time_spent, + 'spent_at' => '2022-11-16T12:59:35+0100', + 'summary' => 'Test summary', + 'issuable_id' => issuable.to_global_id.to_s + } + graphql_mutation(:timelogCreate, variables) + end + context 'when the user is anonymous' do before do post_graphql_mutation(mutation, current_user: current_user) diff --git a/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb b/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb index 18304951e41b6ac9ceae190dc5cdcdfff3d2330d..56a1cee44c877ec7fd273b24c3e93dfc2e6eea35 100644 --- a/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb +++ b/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb @@ -22,6 +22,12 @@ def open_time_tracking_report end end + def open_create_timelog_form + page.within time_tracker_selector do + find('[data-testid="add-time-entry-button"]').click + end + end + it 'renders the sidebar component empty state' do page.within '[data-testid="noTrackingPane"]' do expect(page).to have_content 'No estimate or time spent' @@ -74,11 +80,13 @@ def open_time_tracking_report end end - it 'shows the help state when icon is clicked' do - page.within time_tracker_selector do - find('[data-testid="helpButton"]').click - expect(page).to have_content 'Track time with quick actions' - expect(page).to have_content 'Learn more' + it 'shows the create timelog form when add button is clicked' do + open_create_timelog_form + + page.within '[data-testid="create-timelog-modal"]' do + expect(page).to have_content 'Add time entry' + expect(page).to have_content 'Time spent' + expect(page).to have_content 'Spent at' end end @@ -123,24 +131,6 @@ def open_time_tracking_report expect(page).to have_content '1d' end end - - it 'hides the help state when close icon is clicked' do - page.within time_tracker_selector do - find('[data-testid="helpButton"]').click - find('[data-testid="closeHelpButton"]').click - - expect(page).not_to have_content 'Track time with quick actions' - expect(page).not_to have_content 'Learn more' - end - end - - it 'displays the correct help url' do - page.within time_tracker_selector do - find('[data-testid="helpButton"]').click - - expect(find_link('Learn more')[:href]).to have_content('/help/user/project/time_tracking.md') - end - end end def submit_time(quick_action) diff --git a/spec/support/shared_examples/services/timelogs/create_service_shared_examples.rb b/spec/support/shared_examples/services/timelogs/create_service_shared_examples.rb index 53c42ec0e0041fe817f8ce1bdc2c61adba1a3f51..00d4224f021d4172b20016b3fac464552b62cf46 100644 --- a/spec/support/shared_examples/services/timelogs/create_service_shared_examples.rb +++ b/spec/support/shared_examples/services/timelogs/create_service_shared_examples.rb @@ -1,6 +1,12 @@ # frozen_string_literal: true RSpec.shared_examples 'issuable supports timelog creation service' do + let_it_be(:time_spent) { 3600 } + let_it_be(:spent_at) { Time.now } + let_it_be(:summary) { "Test summary" } + + let(:service) { described_class.new(issuable, time_spent, spent_at, summary, user) } + shared_examples 'success_response' do it 'sucessfully saves the timelog' do is_expected.to be_success @@ -9,7 +15,7 @@ expect(timelog).to be_persisted expect(timelog.time_spent).to eq(time_spent) - expect(timelog.spent_at).to eq('Fri, 08 Jul 2022 00:00:00.000000000 UTC +00:00') + expect(timelog.spent_at).to eq(spent_at) expect(timelog.summary).to eq(summary) expect(timelog.issuable).to eq(issuable) end @@ -34,6 +40,39 @@ users_container.add_reporter(user) end + context 'when spent_at is in the future' do + let_it_be(:spent_at) { Time.now + 2.hours } + + it 'returns an error' do + is_expected.to be_error + + expect(subject.message).to eq("Spent at can't be a future date and time.") + expect(subject.http_status).to eq(404) + end + end + + context 'when time_spent is zero' do + let_it_be(:time_spent) { 0 } + + it 'returns an error' do + is_expected.to be_error + + expect(subject.message).to eq("Time spent can't be zero.") + expect(subject.http_status).to eq(404) + end + end + + context 'when time_spent is nil' do + let_it_be(:time_spent) { nil } + + it 'returns an error' do + is_expected.to be_error + + expect(subject.message).to eq("Time spent can't be blank") + expect(subject.http_status).to eq(404) + end + end + context 'when the timelog save fails' do before do allow_next_instance_of(Timelog) do |timelog| @@ -54,6 +93,12 @@ end RSpec.shared_examples 'issuable does not support timelog creation service' do + let_it_be(:time_spent) { 3600 } + let_it_be(:spent_at) { Time.now } + let_it_be(:summary) { "Test summary" } + + let(:service) { described_class.new(issuable, time_spent, spent_at, summary, user) } + shared_examples 'error_response' do it 'returns an error' do is_expected.to be_error