Commit b8955f6d authored by Lee Tickett's avatar Lee Tickett

Add issuable time tracking report

Changelog: added
parent a309f510
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>
import { GlIcon } from '@gitlab/ui';
import { GlIcon, GlLink, GlModal, GlModalDirective } from '@gitlab/ui';
import { s__, __ } from '~/locale';
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';
export default {
......@@ -15,10 +16,16 @@ export default {
},
components: {
GlIcon,
GlLink,
GlModal,
TimeTrackingCollapsedState,
TimeTrackingSpentOnlyPane,
TimeTrackingComparisonPane,
TimeTrackingHelpState,
TimeTrackingReport,
},
directives: {
GlModal: GlModalDirective,
},
props: {
timeEstimate: {
......@@ -160,6 +167,21 @@ export default {
:time-estimate-human-readable="humanTimeEstimate"
: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">
<time-tracking-help-state v-if="showHelpState" />
</transition>
......
......@@ -21,8 +21,10 @@ import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subs
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 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 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 updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
......@@ -122,3 +124,12 @@ export const startDateQueries = {
mutation: updateEpicStartDateMutation,
},
};
export const timelogQueries = {
[IssuableType.Issue]: {
query: getIssueTimelogsQuery,
},
[IssuableType.MergeRequest]: {
query: getMrTimelogsQuery,
},
};
......@@ -367,16 +367,16 @@ function mountSubscriptionsComponent() {
function mountTimeTrackingComponent() {
const el = document.getElementById('issuable-time-tracker');
const { id, issuableType } = getSidebarOptions();
if (!el) return;
// eslint-disable-next-line no-new
new Vue({
el,
components: {
SidebarTimeTracking,
},
render: (createElement) => createElement('sidebar-time-tracking', {}),
apolloProvider,
provide: { issuableId: id, issuableType },
render: (createElement) => createElement(SidebarTimeTracking, {}),
});
}
......
#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
def issue
Gitlab::Graphql::Loaders::BatchModelLoader.new(Issue, object.issue_id).find
end
def spent_at
object.spent_at || object.created_at
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:
- 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
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.
Data about time tracking is shown on the issue/merge request sidebar, as shown
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
......@@ -75,6 +76,19 @@ command fails and no time is logged.
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
The following time units are available:
......
......@@ -30488,6 +30488,9 @@ msgstr ""
msgid "Speed up your pipelines with Needs relationships"
msgstr ""
msgid "Spent At"
msgstr ""
msgid "Squash commit message"
msgstr ""
......@@ -33362,6 +33365,9 @@ msgstr ""
msgid "Time"
msgstr ""
msgid "Time Spent"
msgstr ""
msgid "Time based: Yes"
msgstr ""
......@@ -33413,6 +33419,9 @@ msgstr ""
msgid "Time tracking"
msgstr ""
msgid "Time tracking report"
msgstr ""
msgid "Time until first merge request"
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', () => {
const findComparisonMeter = () => findByTestId('compareMeter').attributes('title');
const findCollapsedState = () => findByTestId('collapsedState');
const findTimeRemainingProgress = () => findByTestId('timeRemainingProgress');
const findReportLink = () => findByTestId('reportLink');
const defaultProps = {
timeEstimate: 10_000, // 2h 46m
......@@ -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', () => {
const findHelpButton = () => findByTestId('helpButton');
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