diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue index 662edda13f18e817c542436c09a2b6010b011d21..2225dfc3fc42ba93a8f2c18ca09391319255a2b0 100644 --- a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue @@ -11,6 +11,7 @@ import { WIDGET_TYPE_PARTICIPANTS, WIDGET_TYPE_PROGRESS, WIDGET_TYPE_START_AND_DUE_DATE, + WIDGET_TYPE_TIME_TRACKING, WIDGET_TYPE_WEIGHT, WIDGET_TYPE_COLOR, WORK_ITEM_TYPE_VALUE_KEY_RESULT, @@ -26,6 +27,7 @@ import WorkItemMilestoneInline from './work_item_milestone_inline.vue'; import WorkItemMilestoneWithEdit from './work_item_milestone_with_edit.vue'; import WorkItemParentInline from './work_item_parent_inline.vue'; import WorkItemParent from './work_item_parent_with_edit.vue'; +import WorkItemTimeTracking from './work_item_time_tracking.vue'; export default { components: { @@ -39,6 +41,7 @@ export default { WorkItemDueDateWithEdit, WorkItemParent, WorkItemParentInline, + WorkItemTimeTracking, WorkItemWeightInline: () => import('ee_component/work_items/components/work_item_weight_inline.vue'), WorkItemWeight: () => @@ -116,6 +119,9 @@ export default { workItemParent() { return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY)?.parent; }, + workItemTimeTracking() { + return this.isWidgetPresent(WIDGET_TYPE_TIME_TRACKING); + }, workItemColor() { return this.isWidgetPresent(WIDGET_TYPE_COLOR); }, @@ -309,6 +315,12 @@ export default { :can-update="canUpdate" @error="$emit('error', $event)" /> + <work-item-time-tracking + v-if="workItemTimeTracking && glFeatures.workItemsMvc2" + class="gl-mb-5" + :time-estimate="workItemTimeTracking.timeEstimate" + :total-time-spent="workItemTimeTracking.totalTimeSpent" + /> <participants v-if="workItemParticipants && glFeatures.workItemsMvc" class="gl-mb-5 gl-pt-5 gl-border-t gl-border-gray-50" diff --git a/app/assets/javascripts/work_items/components/work_item_time_tracking.vue b/app/assets/javascripts/work_items/components/work_item_time_tracking.vue new file mode 100644 index 0000000000000000000000000000000000000000..d28747fdaaef9c1200beab2bc2bd602a048a0e2b --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_time_tracking.vue @@ -0,0 +1,80 @@ +<script> +import { GlProgressBar, GlTooltipDirective } from '@gitlab/ui'; +import { outputChronicDuration } from '~/chronic_duration'; +import { isPositiveInteger } from '~/lib/utils/number_utils'; +import { s__, sprintf } from '~/locale'; + +const options = { format: 'short' }; + +export default { + components: { + GlProgressBar, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + timeEstimate: { + type: Number, + required: false, + default: 0, + }, + totalTimeSpent: { + type: Number, + required: false, + default: 0, + }, + }, + computed: { + humanTimeEstimate() { + return outputChronicDuration(this.timeEstimate, options); + }, + humanTotalTimeSpent() { + return outputChronicDuration(this.totalTimeSpent, options) ?? '0h'; + }, + progressBarTooltipText() { + const timeDifference = this.totalTimeSpent - this.timeEstimate; + const time = outputChronicDuration(Math.abs(timeDifference), options); + return isPositiveInteger(timeDifference) + ? sprintf(s__('TimeTracking|%{time} over'), { time }) + : sprintf(s__('TimeTracking|%{time} remaining'), { time }); + }, + progressBarVariant() { + return this.timeRemainingPercent > 100 ? 'danger' : 'primary'; + }, + timeRemainingPercent() { + return Math.floor((this.totalTimeSpent / this.timeEstimate) * 100); + }, + }, +}; +</script> + +<template> + <div> + <h3 class="gl-heading-5 gl-mb-2!"> + {{ __('Time tracking') }} + </h3> + <div + class="gl-display-flex gl-align-items-center gl-gap-2 gl-font-sm" + data-testid="time-tracking-body" + > + <template v-if="totalTimeSpent || timeEstimate"> + <span class="gl-text-secondary">{{ s__('TimeTracking|Spent') }}</span> + {{ humanTotalTimeSpent }} + <template v-if="timeEstimate"> + <gl-progress-bar + v-gl-tooltip="progressBarTooltipText" + class="gl-flex-grow-1 gl-mx-2" + :value="timeRemainingPercent" + :variant="progressBarVariant" + /> + <span class="gl-text-secondary">{{ s__('TimeTracking|Estimate') }}</span> + {{ humanTimeEstimate }} + </template> + </template> + <span v-else class="gl-text-secondary"> + {{ s__('TimeTracking|Use /spend or /estimate to manage time.') }} + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 39d7fe338fcdec9f721cdabdf9bea466a2d1f67e..511b896b94ab332ecbb48715f0199d4785df9357 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -16,6 +16,7 @@ export const WIDGET_TYPE_NOTIFICATIONS = 'NOTIFICATIONS'; export const WIDGET_TYPE_CURRENT_USER_TODOS = 'CURRENT_USER_TODOS'; export const WIDGET_TYPE_LABELS = 'LABELS'; export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE'; +export const WIDGET_TYPE_TIME_TRACKING = 'TIME_TRACKING'; export const WIDGET_TYPE_WEIGHT = 'WEIGHT'; export const WIDGET_TYPE_PARTICIPANTS = 'PARTICIPANTS'; export const WIDGET_TYPE_PROGRESS = 'PROGRESS'; diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index 90bd9be1007541e2582937cc4669253c1d00c20c..42752f2ce30294fca0e0b685418b280f8dab1288 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -41,6 +41,10 @@ fragment WorkItemWidgets on WorkItemWidget { dueDate startDate } + ... on WorkItemWidgetTimeTracking { + timeEstimate + totalTimeSpent + } ... on WorkItemWidgetHierarchy { hasChildren parent { diff --git a/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index 40499836a210f6604118c105f2204034e38f5fe2..08c09ccd856ea63d29bdc326308ea757a90833c5 100644 --- a/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -42,6 +42,10 @@ fragment WorkItemWidgets on WorkItemWidget { dueDate startDate } + ... on WorkItemWidgetTimeTracking { + timeEstimate + totalTimeSpent + } ... on WorkItemWidgetWeight { weight } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index eb1f9c3c53dec7dc5528729c66de426f7790f4df..1d219757157a0e54ead39ce4cc1de2c2cf277b75 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -51426,6 +51426,12 @@ msgstr "" msgid "TimeTracking|%{spentStart}Spent: %{spentEnd}" msgstr "" +msgid "TimeTracking|%{time} over" +msgstr "" + +msgid "TimeTracking|%{time} remaining" +msgstr "" + msgid "TimeTracking|An error occurred while removing the timelog." msgstr "" @@ -51471,6 +51477,9 @@ msgstr "" msgid "TimeTracking|Time remaining: %{timeRemainingHumanReadable}" msgstr "" +msgid "TimeTracking|Use /spend or /estimate to manage time." +msgstr "" + msgid "Timeago|%s days ago" msgstr "" diff --git a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js index 5dfe27c41817b73a81af3ad85a8be16189e0114f..51e398da078985c8c1a03b38249862da9329777c 100644 --- a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js +++ b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js @@ -9,6 +9,7 @@ import WorkItemMilestoneInline from '~/work_items/components/work_item_milestone import WorkItemMilestoneWithEdit from '~/work_items/components/work_item_milestone_with_edit.vue'; import WorkItemParentInline from '~/work_items/components/work_item_parent_inline.vue'; import WorkItemParent from '~/work_items/components/work_item_parent_with_edit.vue'; +import WorkItemTimeTracking from '~/work_items/components/work_item_time_tracking.vue'; import waitForPromises from 'helpers/wait_for_promises'; import WorkItemAttributesWrapper from '~/work_items/components/work_item_attributes_wrapper.vue'; import { @@ -32,7 +33,8 @@ describe('WorkItemAttributesWrapper component', () => { const findWorkItemMilestoneInline = () => wrapper.findComponent(WorkItemMilestoneInline); const findWorkItemParentInline = () => wrapper.findComponent(WorkItemParentInline); const findWorkItemParent = () => wrapper.findComponent(WorkItemParent); - const findWorkItemParticipents = () => wrapper.findComponent(Participants); + const findWorkItemTimeTracking = () => wrapper.findComponent(WorkItemTimeTracking); + const findWorkItemParticipants = () => wrapper.findComponent(Participants); const createComponent = ({ workItem = workItemQueryResponse.data.workItem, @@ -209,6 +211,19 @@ describe('WorkItemAttributesWrapper component', () => { }); }); + describe('time tracking widget', () => { + it.each` + description | timeTrackingWidgetPresent | exists + ${'renders when widget is returned from API'} | ${true} | ${true} + ${'does not render when widget is not returned from API'} | ${false} | ${false} + `('$description', ({ timeTrackingWidgetPresent, exists }) => { + const response = workItemResponseFactory({ timeTrackingWidgetPresent }); + createComponent({ workItem: response.data.workItem }); + + expect(findWorkItemTimeTracking().exists()).toBe(exists); + }); + }); + describe('participants widget', () => { it.each` description | participantsWidgetPresent | exists @@ -218,7 +233,7 @@ describe('WorkItemAttributesWrapper component', () => { const response = workItemResponseFactory({ participantsWidgetPresent }); createComponent({ workItem: response.data.workItem }); - expect(findWorkItemParticipents().exists()).toBe(exists); + expect(findWorkItemParticipants().exists()).toBe(exists); }); }); }); diff --git a/spec/frontend/work_items/components/work_item_time_tracking_spec.js b/spec/frontend/work_items/components/work_item_time_tracking_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..3c40bee6325c23fe6846873f14faf563c1af536a --- /dev/null +++ b/spec/frontend/work_items/components/work_item_time_tracking_spec.js @@ -0,0 +1,106 @@ +import { GlProgressBar } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import WorkItemTimeTracking from '~/work_items/components/work_item_time_tracking.vue'; + +describe('WorkItemTimeTracking component', () => { + let wrapper; + + const findProgressBar = () => wrapper.findComponent(GlProgressBar); + const findTimeTrackingBody = () => wrapper.findByTestId('time-tracking-body'); + const getTooltip = () => getBinding(findProgressBar().element, 'gl-tooltip'); + + const createComponent = ({ timeEstimate = 0, totalTimeSpent = 0 } = {}) => { + wrapper = shallowMountExtended(WorkItemTimeTracking, { + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + propsData: { + timeEstimate, + totalTimeSpent, + }, + }); + }; + + it('renders heading text', () => { + createComponent(); + + expect(wrapper.find('h3').text()).toBe('Time tracking'); + }); + + describe('with no time spent and no time estimate', () => { + it('shows help text', () => { + createComponent({ timeEstimate: 0, totalTimeSpent: 0 }); + + expect(findTimeTrackingBody().text()).toMatchInterpolatedText( + 'Use /spend or /estimate to manage time.', + ); + expect(findProgressBar().exists()).toBe(false); + }); + }); + + describe('with time spent and no time estimate', () => { + it('shows only time spent', () => { + createComponent({ timeEstimate: 0, totalTimeSpent: 10800 }); + + expect(findTimeTrackingBody().text()).toMatchInterpolatedText('Spent 3h'); + expect(findProgressBar().exists()).toBe(false); + }); + }); + + describe('with no time spent and time estimate', () => { + beforeEach(() => { + createComponent({ timeEstimate: 10800, totalTimeSpent: 0 }); + }); + + it('shows 0h time spent and time estimate', () => { + expect(findTimeTrackingBody().text()).toMatchInterpolatedText('Spent 0h Estimate 3h'); + }); + + it('shows progress bar with tooltip', () => { + expect(findProgressBar().attributes()).toMatchObject({ + value: '0', + variant: 'primary', + }); + expect(getTooltip().value).toContain('3h remaining'); + }); + }); + + describe('with time spent and time estimate', () => { + describe('when time spent is less than the time estimate', () => { + beforeEach(() => { + createComponent({ timeEstimate: 18000, totalTimeSpent: 10800 }); + }); + + it('shows time spent and time estimate', () => { + expect(findTimeTrackingBody().text()).toMatchInterpolatedText('Spent 3h Estimate 5h'); + }); + + it('shows progress bar with tooltip', () => { + expect(findProgressBar().attributes()).toMatchObject({ + value: '60', + variant: 'primary', + }); + expect(getTooltip().value).toContain('2h remaining'); + }); + }); + + describe('when time spent is greater than the time estimate', () => { + beforeEach(() => { + createComponent({ timeEstimate: 10800, totalTimeSpent: 18000 }); + }); + + it('shows time spent and time estimate', () => { + expect(findTimeTrackingBody().text()).toMatchInterpolatedText('Spent 5h Estimate 3h'); + }); + + it('shows progress bar with tooltip', () => { + expect(findProgressBar().attributes()).toMatchObject({ + value: '166', + variant: 'danger', + }); + expect(getTooltip().value).toContain('2h over'); + }); + }); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 0962bed9a4cab149cd3c0748ec1038f4756d81c3..d75daaefb22a33b9f01bf1b678236cc41c6f3916 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -628,6 +628,7 @@ export const workItemResponseFactory = ({ assigneesWidgetPresent = true, datesWidgetPresent = true, weightWidgetPresent = true, + timeTrackingWidgetPresent = true, participantsWidgetPresent = true, progressWidgetPresent = true, milestoneWidgetPresent = true, @@ -757,6 +758,14 @@ export const workItemResponseFactory = ({ }, } : { type: 'MOCK TYPE' }, + timeTrackingWidgetPresent + ? { + __typename: 'WorkItemWidgetTimeTracking', + type: 'TIME_TRACKING', + timeEstimate: '5h', + totalTimeSpent: '3h', + } + : { type: 'MOCK TYPE' }, participantsWidgetPresent ? { __typename: 'WorkItemWidgetParticipants',