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 @@
+
+
+
+
+
+
+ {{ issuableTypeName }}
+
+ {{
+ s__('CreateTimelogForm|How do I track and estimate time?')
+ }}
+
+
+
+
+
+
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