Commit 89c73227 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '271409-time-tracking-reports' into 'master'

Add issuable time tracking report

See merge request gitlab-org/gitlab!60161
parents f8c28b5b b8955f6d
fragment TimelogFragment on Timelog {
timeSpent
user {
name
}
spentAt
note {
body
}
}
<script>
import { GlLoadingIcon, GlTable } from '@gitlab/ui';
import createFlash from '~/flash';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import { timelogQueries } from '~/sidebar/constants';
const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)';
export default {
components: {
GlLoadingIcon,
GlTable,
},
inject: ['issuableId', 'issuableType'],
data() {
return { report: [], isLoading: true };
},
apollo: {
report: {
query() {
return timelogQueries[this.issuableType].query;
},
variables() {
return {
id: convertToGraphQLId(this.getGraphQLEntityType(), this.issuableId),
};
},
update(data) {
this.isLoading = false;
return this.extractTimelogs(data);
},
error() {
createFlash({ message: __('Something went wrong. Please try again.') });
},
},
},
methods: {
isIssue() {
return this.issuableType === 'issue';
},
getGraphQLEntityType() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return this.isIssue() ? 'Issue' : 'MergeRequest';
},
extractTimelogs(data) {
const timelogs = data?.issuable?.timelogs?.nodes || [];
return timelogs.slice().sort((a, b) => new Date(a.spentAt) - new Date(b.spentAt));
},
formatDate(date) {
return formatDate(date, TIME_DATE_FORMAT);
},
getNote(note) {
return note?.body;
},
getTotalTimeSpent() {
const seconds = this.report.reduce((acc, item) => acc + item.timeSpent, 0);
return this.formatTimeSpent(seconds);
},
formatTimeSpent(seconds) {
const negative = seconds < 0;
return (negative ? '- ' : '') + stringifyTime(parseSeconds(seconds));
},
},
fields: [
{ key: 'spentAt', label: __('Spent At'), sortable: true },
{ key: 'user', label: __('User'), sortable: true },
{ key: 'timeSpent', label: __('Time Spent'), sortable: true },
{ key: 'note', label: __('Note'), sortable: true },
],
};
</script>
<template>
<div>
<div v-if="isLoading"><gl-loading-icon size="md" /></div>
<gl-table v-else :items="report" :fields="$options.fields" foot-clone>
<template #cell(spentAt)="{ item: { spentAt } }">
<div>{{ formatDate(spentAt) }}</div>
</template>
<template #foot(spentAt)>&nbsp;</template>
<template #cell(user)="{ item: { user } }">
<div>{{ user.name }}</div>
</template>
<template #foot(user)>&nbsp;</template>
<template #cell(timeSpent)="{ item: { timeSpent } }">
<div>{{ formatTimeSpent(timeSpent) }}</div>
</template>
<template #foot(timeSpent)>
<div>{{ getTotalTimeSpent() }}</div>
</template>
<template #cell(note)="{ item: { note } }">
<div>{{ getNote(note) }}</div>
</template>
<template #foot(note)>&nbsp;</template>
</gl-table>
</div>
</template>
<script> <script>
import { GlIcon } from '@gitlab/ui'; import { GlIcon, GlLink, GlModal, GlModalDirective } from '@gitlab/ui';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import eventHub from '../../event_hub'; import eventHub from '../../event_hub';
import TimeTrackingCollapsedState from './collapsed_state.vue'; import TimeTrackingCollapsedState from './collapsed_state.vue';
import TimeTrackingComparisonPane from './comparison_pane.vue'; import TimeTrackingComparisonPane from './comparison_pane.vue';
import TimeTrackingHelpState from './help_state.vue'; import TimeTrackingHelpState from './help_state.vue';
import TimeTrackingReport from './report.vue';
import TimeTrackingSpentOnlyPane from './spent_only_pane.vue'; import TimeTrackingSpentOnlyPane from './spent_only_pane.vue';
export default { export default {
...@@ -15,10 +16,16 @@ export default { ...@@ -15,10 +16,16 @@ export default {
}, },
components: { components: {
GlIcon, GlIcon,
GlLink,
GlModal,
TimeTrackingCollapsedState, TimeTrackingCollapsedState,
TimeTrackingSpentOnlyPane, TimeTrackingSpentOnlyPane,
TimeTrackingComparisonPane, TimeTrackingComparisonPane,
TimeTrackingHelpState, TimeTrackingHelpState,
TimeTrackingReport,
},
directives: {
GlModal: GlModalDirective,
}, },
props: { props: {
timeEstimate: { timeEstimate: {
...@@ -160,6 +167,21 @@ export default { ...@@ -160,6 +167,21 @@ export default {
:time-estimate-human-readable="humanTimeEstimate" :time-estimate-human-readable="humanTimeEstimate"
:limit-to-hours="limitToHours" :limit-to-hours="limitToHours"
/> />
<gl-link
v-if="hasTimeSpent"
v-gl-modal="'time-tracking-report'"
data-testid="reportLink"
href="#"
class="btn-link"
>{{ __('Time tracking report') }}</gl-link
>
<gl-modal
modal-id="time-tracking-report"
:title="__('Time tracking report')"
:hide-footer="true"
>
<time-tracking-report />
</gl-modal>
<transition name="help-state-toggle"> <transition name="help-state-toggle">
<time-tracking-help-state v-if="showHelpState" /> <time-tracking-help-state v-if="showHelpState" />
</transition> </transition>
......
...@@ -21,8 +21,10 @@ import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subs ...@@ -21,8 +21,10 @@ import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subs
import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql'; import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql';
import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql';
import issueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; import issueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import getIssueTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql';
import getMergeRequestAssignees from '~/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql'; import getMergeRequestAssignees from '~/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql';
import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql'; import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql';
import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql';
import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql'; import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
...@@ -122,3 +124,12 @@ export const startDateQueries = { ...@@ -122,3 +124,12 @@ export const startDateQueries = {
mutation: updateEpicStartDateMutation, mutation: updateEpicStartDateMutation,
}, },
}; };
export const timelogQueries = {
[IssuableType.Issue]: {
query: getIssueTimelogsQuery,
},
[IssuableType.MergeRequest]: {
query: getMrTimelogsQuery,
},
};
...@@ -367,16 +367,16 @@ function mountSubscriptionsComponent() { ...@@ -367,16 +367,16 @@ function mountSubscriptionsComponent() {
function mountTimeTrackingComponent() { function mountTimeTrackingComponent() {
const el = document.getElementById('issuable-time-tracker'); const el = document.getElementById('issuable-time-tracker');
const { id, issuableType } = getSidebarOptions();
if (!el) return; if (!el) return;
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el, el,
components: { apolloProvider,
SidebarTimeTracking, provide: { issuableId: id, issuableType },
}, render: (createElement) => createElement(SidebarTimeTracking, {}),
render: (createElement) => createElement('sidebar-time-tracking', {}),
}); });
} }
......
#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql"
query timeTrackingReport($id: IssueID!) {
issuable: issue(id: $id) {
__typename
id
title
timelogs {
nodes {
...TimelogFragment
}
}
}
}
#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql"
query timeTrackingReport($id: MergeRequestID!) {
issuable: mergeRequest(id: $id) {
__typename
id
title
timelogs {
nodes {
...TimelogFragment
}
}
}
}
...@@ -43,5 +43,9 @@ module Types ...@@ -43,5 +43,9 @@ module Types
def issue def issue
Gitlab::Graphql::Loaders::BatchModelLoader.new(Issue, object.issue_id).find Gitlab::Graphql::Loaders::BatchModelLoader.new(Issue, object.issue_id).find
end end
def spent_at
object.spent_at || object.created_at
end
end end
end end
---
title: Add isuable time tracking report
merge_request: 60161
author: Lee Tickett @leetickett
type: added
...@@ -20,13 +20,14 @@ Time Tracking allows you to: ...@@ -20,13 +20,14 @@ Time Tracking allows you to:
- Record the time spent working on an issue or a merge request. - Record the time spent working on an issue or a merge request.
- Add an estimate of the amount of time needed to complete an issue or a merge - Add an estimate of the amount of time needed to complete an issue or a merge
request. request.
- View a breakdown of time spent working on an issue or a merge request.
You don't have to indicate an estimate to enter the time spent, and vice versa. You don't have to indicate an estimate to enter the time spent, and vice versa.
Data about time tracking is shown on the issue/merge request sidebar, as shown Data about time tracking is shown on the issue/merge request sidebar, as shown
below. below.
![Time tracking in the sidebar](img/time_tracking_sidebar_v8_16.png) ![Time tracking in the sidebar](img/time_tracking_sidebar_v13_12.png)
## How to enter data ## How to enter data
...@@ -75,6 +76,19 @@ command fails and no time is logged. ...@@ -75,6 +76,19 @@ command fails and no time is logged.
To remove all the time spent at once, use `/remove_time_spent`. To remove all the time spent at once, use `/remove_time_spent`.
## View a time tracking report
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/271409) in GitLab 13.12.
You can view a breakdown of time spent on an issue or merge request.
To view a time tracking report, go to an issue or a merge request and select **Time tracking report**
in the right sidebar.
![Time tracking report](img/time_tracking_report_v13_12.png)
The breakdown of spent time is limited to a maximum of 100 entries.
## Configuration ## Configuration
The following time units are available: The following time units are available:
......
...@@ -30676,6 +30676,9 @@ msgstr "" ...@@ -30676,6 +30676,9 @@ msgstr ""
msgid "Speed up your pipelines with Needs relationships" msgid "Speed up your pipelines with Needs relationships"
msgstr "" msgstr ""
msgid "Spent At"
msgstr ""
msgid "Squash commit message" msgid "Squash commit message"
msgstr "" msgstr ""
...@@ -33574,6 +33577,9 @@ msgstr "" ...@@ -33574,6 +33577,9 @@ msgstr ""
msgid "Time" msgid "Time"
msgstr "" msgstr ""
msgid "Time Spent"
msgstr ""
msgid "Time based: Yes" msgid "Time based: Yes"
msgstr "" msgstr ""
...@@ -33625,6 +33631,9 @@ msgstr "" ...@@ -33625,6 +33631,9 @@ msgstr ""
msgid "Time tracking" msgid "Time tracking"
msgstr "" msgstr ""
msgid "Time tracking report"
msgstr ""
msgid "Time until first merge request" msgid "Time until first merge request"
msgstr "" msgstr ""
......
export const getIssueTimelogsQueryResponse = {
data: {
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/148',
title:
'Est perferendis dicta expedita ipsum adipisci laudantium omnis consequatur consequatur et.',
timelogs: {
nodes: [
{
__typename: 'Timelog',
timeSpent: 14400,
user: {
name: 'John Doe18',
__typename: 'UserCore',
},
spentAt: '2020-05-01T00:00:00Z',
note: {
body: 'I paired with @root on this last week.',
__typename: 'Note',
},
},
{
__typename: 'Timelog',
timeSpent: 1800,
user: {
name: 'Administrator',
__typename: 'UserCore',
},
spentAt: '2021-05-07T13:19:01Z',
note: null,
},
{
__typename: 'Timelog',
timeSpent: 14400,
user: {
name: 'Administrator',
__typename: 'UserCore',
},
spentAt: '2021-05-01T00:00:00Z',
note: {
body: 'I did some work on this last week.',
__typename: 'Note',
},
},
],
__typename: 'TimelogConnection',
},
},
},
};
export const getMrTimelogsQueryResponse = {
data: {
issuable: {
__typename: 'MergeRequest',
id: 'gid://gitlab/MergeRequest/29',
title: 'Esse amet perspiciatis voluptas et sed praesentium debitis repellat.',
timelogs: {
nodes: [
{
__typename: 'Timelog',
timeSpent: 1800,
user: {
name: 'Administrator',
__typename: 'UserCore',
},
spentAt: '2021-05-07T14:44:55Z',
note: {
body: 'Thirty minutes!',
__typename: 'Note',
},
},
{
__typename: 'Timelog',
timeSpent: 3600,
user: {
name: 'Administrator',
__typename: 'UserCore',
},
spentAt: '2021-05-07T14:44:39Z',
note: null,
},
{
__typename: 'Timelog',
timeSpent: 300,
user: {
name: 'Administrator',
__typename: 'UserCore',
},
spentAt: '2021-03-10T00:00:00Z',
note: {
body: 'A note with some time',
__typename: 'Note',
},
},
],
__typename: 'TimelogConnection',
},
},
},
};
import { GlLoadingIcon } from '@gitlab/ui';
import { getAllByRole } from '@testing-library/dom';
import { shallowMount, createLocalVue, mount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import Report from '~/sidebar/components/time_tracking/report.vue';
import getIssueTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql';
import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql';
import { getIssueTimelogsQueryResponse, getMrTimelogsQueryResponse } from './mock_data';
jest.mock('~/flash');
describe('Issuable Time Tracking Report', () => {
const localVue = createLocalVue();
localVue.use(VueApollo);
let wrapper;
let fakeApollo;
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const successIssueQueryHandler = jest.fn().mockResolvedValue(getIssueTimelogsQueryResponse);
const successMrQueryHandler = jest.fn().mockResolvedValue(getMrTimelogsQueryResponse);
const mountComponent = ({
queryHandler = successIssueQueryHandler,
issuableType = 'issue',
mountFunction = shallowMount,
} = {}) => {
fakeApollo = createMockApollo([
[getIssueTimelogsQuery, queryHandler],
[getMrTimelogsQuery, queryHandler],
]);
wrapper = mountFunction(Report, {
provide: {
issuableId: 1,
issuableType,
},
localVue,
apolloProvider: fakeApollo,
});
};
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
});
it('should render loading spinner', () => {
mountComponent();
expect(findLoadingIcon()).toExist();
});
it('should render error message on reject', async () => {
mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
await waitForPromises();
expect(createFlash).toHaveBeenCalled();
});
describe('for issue', () => {
beforeEach(() => {
mountComponent({ mountFunction: mount });
});
it('calls correct query', () => {
expect(successIssueQueryHandler).toHaveBeenCalled();
});
it('renders correct results', async () => {
await waitForPromises();
expect(getAllByRole(wrapper.element, 'row', { name: /John Doe18/i })).toHaveLength(1);
expect(getAllByRole(wrapper.element, 'row', { name: /Administrator/i })).toHaveLength(2);
});
});
describe('for merge request', () => {
beforeEach(() => {
mountComponent({
queryHandler: successMrQueryHandler,
issuableType: 'merge_request',
mountFunction: mount,
});
});
it('calls correct query', () => {
expect(successMrQueryHandler).toHaveBeenCalled();
});
it('renders correct results', async () => {
await waitForPromises();
expect(getAllByRole(wrapper.element, 'row', { name: /Administrator/i })).toHaveLength(3);
});
});
});
...@@ -10,6 +10,7 @@ describe('Issuable Time Tracker', () => { ...@@ -10,6 +10,7 @@ describe('Issuable Time Tracker', () => {
const findComparisonMeter = () => findByTestId('compareMeter').attributes('title'); const findComparisonMeter = () => findByTestId('compareMeter').attributes('title');
const findCollapsedState = () => findByTestId('collapsedState'); const findCollapsedState = () => findByTestId('collapsedState');
const findTimeRemainingProgress = () => findByTestId('timeRemainingProgress'); const findTimeRemainingProgress = () => findByTestId('timeRemainingProgress');
const findReportLink = () => findByTestId('reportLink');
const defaultProps = { const defaultProps = {
timeEstimate: 10_000, // 2h 46m timeEstimate: 10_000, // 2h 46m
...@@ -192,6 +193,33 @@ describe('Issuable Time Tracker', () => { ...@@ -192,6 +193,33 @@ describe('Issuable Time Tracker', () => {
}); });
}); });
describe('Time tracking report', () => {
describe('When no time spent', () => {
beforeEach(() => {
wrapper = mountComponent({
props: {
timeSpent: 0,
timeSpentHumanReadable: '',
},
});
});
it('link should not appear', () => {
expect(findReportLink().exists()).toBe(false);
});
});
describe('When time spent', () => {
beforeEach(() => {
wrapper = mountComponent();
});
it('link should appear', () => {
expect(findReportLink().exists()).toBe(true);
});
});
});
describe('Help pane', () => { describe('Help pane', () => {
const findHelpButton = () => findByTestId('helpButton'); const findHelpButton = () => findByTestId('helpButton');
const findCloseHelpButton = () => findByTestId('closeHelpButton'); const findCloseHelpButton = () => findByTestId('closeHelpButton');
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment