Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
929b0ad500
commit
4596c2f5a5
|
|
@ -2236,6 +2236,7 @@
|
|||
when: never
|
||||
- <<: *if-merge-request-labels-run-all-rspec
|
||||
- <<: *if-merge-request-labels-record-queries
|
||||
- <<: *if-default-branch-refs
|
||||
|
||||
.rails:rules:default-branch-schedule-nightly--code-backstage-default-rules:
|
||||
rules:
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
7ea51fec80a59e85b8ce423ed09a65eb54b568a8
|
||||
c0e688008f860bd70647b3a30c1410f75744b6d5
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdow
|
|||
import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
|
||||
import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql';
|
||||
import { __, s__, sprintf } from '~/locale';
|
||||
import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
|
||||
import SidebarTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
|
||||
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
|
||||
import { INCIDENT } from '~/boards/constants';
|
||||
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
|
||||
|
|
@ -27,7 +27,7 @@ export default {
|
|||
SidebarAssigneesWidget,
|
||||
SidebarDateWidget,
|
||||
SidebarConfidentialityWidget,
|
||||
BoardSidebarTimeTracker,
|
||||
SidebarTimeTracker,
|
||||
SidebarLabelsWidget,
|
||||
SidebarSubscriptionsWidget,
|
||||
SidebarDropdownWidget,
|
||||
|
|
@ -75,6 +75,9 @@ export default {
|
|||
isApolloBoard: {
|
||||
default: false,
|
||||
},
|
||||
timeTrackingLimitToHours: {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
inheritAttrs: false,
|
||||
apollo: {
|
||||
|
|
@ -257,7 +260,15 @@ export default {
|
|||
data-testid="iteration-edit"
|
||||
/>
|
||||
</div>
|
||||
<board-sidebar-time-tracker />
|
||||
<sidebar-time-tracker
|
||||
:can-add-time-entries="canUpdate"
|
||||
:can-set-time-estimate="canUpdate"
|
||||
:full-path="projectPathForActiveIssue"
|
||||
:issuable-id="activeBoardIssuable.id"
|
||||
:issuable-iid="activeBoardIssuable.iid"
|
||||
:limit-to-hours="timeTrackingLimitToHours"
|
||||
:show-collapsed="false"
|
||||
/>
|
||||
<sidebar-date-widget
|
||||
:iid="activeBoardIssuable.iid"
|
||||
:full-path="projectPathForActiveIssue"
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import IssuableTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
IssuableTimeTracker,
|
||||
},
|
||||
inject: ['timeTrackingLimitToHours', 'canUpdate'],
|
||||
computed: {
|
||||
...mapGetters(['activeBoardItem']),
|
||||
initialTimeTracking() {
|
||||
const {
|
||||
timeEstimate,
|
||||
totalTimeSpent,
|
||||
humanTimeEstimate,
|
||||
humanTotalTimeSpent,
|
||||
} = this.activeBoardItem;
|
||||
return {
|
||||
timeEstimate,
|
||||
totalTimeSpent,
|
||||
humanTimeEstimate,
|
||||
humanTotalTimeSpent,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<issuable-time-tracker
|
||||
:issuable-id="activeBoardItem.id.toString()"
|
||||
:issuable-iid="activeBoardItem.iid.toString()"
|
||||
:limit-to-hours="timeTrackingLimitToHours"
|
||||
:initial-time-tracking="initialTimeTracking"
|
||||
:show-collapsed="false"
|
||||
:can-add-time-entries="canUpdate"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -136,9 +136,9 @@ export default class Notes {
|
|||
// Reopen and close actions for Issue/MR combined with note form submit
|
||||
this.$wrapperEl.on(
|
||||
'click',
|
||||
// this oddly written selector needs to match the old style (input with class) as
|
||||
// this oddly written selector needs to match the old style (button with class) as
|
||||
// well as the new DOM styling from the Vue-based note form
|
||||
'input.js-comment-submit-button, .js-comment-submit-button > button:first-child',
|
||||
'button.js-comment-submit-button, .js-comment-submit-button > button:first-child',
|
||||
this.postComment,
|
||||
);
|
||||
this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment);
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export const CREATE_TIMELOG_MODAL_ID = 'create-timelog-modal';
|
||||
export const SET_TIME_ESTIMATE_MODAL_ID = 'set-time-estimate-modal';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
<script>
|
||||
import { GlFormGroup, GlFormInput, GlModal, GlAlert, GlLink } from '@gitlab/ui';
|
||||
import { helpPagePath } from '~/helpers/help_page_helper';
|
||||
import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
|
||||
import { s__, __, sprintf } from '~/locale';
|
||||
import issueSetTimeEstimateMutation from '../../queries/issue_set_time_estimate.mutation.graphql';
|
||||
import mergeRequestSetTimeEstimateMutation from '../../queries/merge_request_set_time_estimate.mutation.graphql';
|
||||
import { SET_TIME_ESTIMATE_MODAL_ID } from './constants';
|
||||
|
||||
const MUTATIONS = {
|
||||
[TYPE_ISSUE]: issueSetTimeEstimateMutation,
|
||||
[TYPE_MERGE_REQUEST]: mergeRequestSetTimeEstimateMutation,
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlFormGroup,
|
||||
GlFormInput,
|
||||
GlModal,
|
||||
GlAlert,
|
||||
GlLink,
|
||||
},
|
||||
inject: ['issuableType'],
|
||||
props: {
|
||||
fullPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
issuableIid: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
/**
|
||||
* This object must contain the following keys, used to show
|
||||
* the initial time estimate in the form:
|
||||
* - timeEstimate: the time estimate numeric value
|
||||
* - humanTimeEstimate: the time estimate in human readable format
|
||||
*/
|
||||
timeTracking: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentEstimate: this.timeTracking.timeEstimate ?? 0,
|
||||
timeEstimate: this.timeTracking.humanTimeEstimate ?? '0h',
|
||||
isSaving: false,
|
||||
isResetting: false,
|
||||
saveError: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
submitDisabled() {
|
||||
return this.isSaving || this.isResetting || this.timeEstimate === '';
|
||||
},
|
||||
resetDisabled() {
|
||||
return this.isSaving || this.isResetting || this.currentEstimate === 0;
|
||||
},
|
||||
primaryProps() {
|
||||
return {
|
||||
text: __('Save'),
|
||||
attributes: {
|
||||
variant: 'confirm',
|
||||
disabled: this.submitDisabled,
|
||||
loading: this.isSaving,
|
||||
},
|
||||
};
|
||||
},
|
||||
secondaryProps() {
|
||||
return this.currentEstimate === 0
|
||||
? null
|
||||
: {
|
||||
text: __('Remove'),
|
||||
attributes: {
|
||||
disabled: this.resetDisabled,
|
||||
loading: this.isResetting,
|
||||
},
|
||||
};
|
||||
},
|
||||
cancelProps() {
|
||||
return {
|
||||
text: __('Cancel'),
|
||||
};
|
||||
},
|
||||
timeTrackingDocsPath() {
|
||||
return helpPagePath('user/project/time_tracking.md');
|
||||
},
|
||||
modalTitle() {
|
||||
return this.currentEstimate === 0
|
||||
? s__('TimeTracking|Set time estimate')
|
||||
: s__('TimeTracking|Edit time estimate');
|
||||
},
|
||||
isIssue() {
|
||||
return this.issuableType === TYPE_ISSUE;
|
||||
},
|
||||
modalText() {
|
||||
return sprintf(s__('TimeTracking|Set estimated time to complete this %{issuableTypeName}.'), {
|
||||
issuableTypeName: this.isIssue ? __('issue') : __('merge request'),
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
timeTracking() {
|
||||
this.currentEstimate = this.timeTracking.timeEstimate ?? 0;
|
||||
this.timeEstimate = this.timeTracking.humanTimeEstimate ?? '0h';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
resetModal() {
|
||||
this.isSaving = false;
|
||||
this.isResetting = false;
|
||||
this.saveError = '';
|
||||
},
|
||||
close() {
|
||||
this.$refs.modal.close();
|
||||
},
|
||||
saveTimeEstimate(event) {
|
||||
event?.preventDefault();
|
||||
|
||||
if (this.timeEstimate === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSaving = true;
|
||||
this.updateEstimatedTime(this.timeEstimate);
|
||||
},
|
||||
resetTimeEstimate() {
|
||||
this.isResetting = true;
|
||||
this.updateEstimatedTime('0');
|
||||
},
|
||||
updateEstimatedTime(timeEstimate) {
|
||||
this.saveError = '';
|
||||
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: MUTATIONS[this.issuableType],
|
||||
variables: {
|
||||
input: {
|
||||
projectPath: this.fullPath,
|
||||
iid: this.issuableIid,
|
||||
timeEstimate,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(({ data }) => {
|
||||
if (data.issuableSetTimeEstimate?.errors.length) {
|
||||
this.saveError =
|
||||
data.issuableSetTimeEstimate.errors[0].message ||
|
||||
data.issuableSetTimeEstimate.errors[0];
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.saveError =
|
||||
error?.message || s__('TimeTracking|An error occurred while saving the time estimate.');
|
||||
})
|
||||
.finally(() => {
|
||||
this.isSaving = false;
|
||||
this.isResetting = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
SET_TIME_ESTIMATE_MODAL_ID,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<gl-modal
|
||||
ref="modal"
|
||||
:title="modalTitle"
|
||||
:modal-id="$options.SET_TIME_ESTIMATE_MODAL_ID"
|
||||
size="sm"
|
||||
data-testid="set-time-estimate-modal"
|
||||
:action-primary="primaryProps"
|
||||
:action-secondary="secondaryProps"
|
||||
:action-cancel="cancelProps"
|
||||
@hidden="resetModal"
|
||||
@primary.prevent="saveTimeEstimate"
|
||||
@secondary.prevent="resetTimeEstimate"
|
||||
@cancel="close"
|
||||
>
|
||||
<p data-testid="timetracking-docs-link">
|
||||
{{ modalText }}
|
||||
|
||||
<gl-link :href="timeTrackingDocsPath">{{
|
||||
s__('TimeTracking|How do I estimate and track time?')
|
||||
}}</gl-link>
|
||||
</p>
|
||||
<form class="js-quick-submit" @submit.prevent="saveTimeEstimate">
|
||||
<gl-form-group
|
||||
label-for="time-estimate"
|
||||
:label="s__('TimeTracking|Estimate')"
|
||||
:description="
|
||||
s__(
|
||||
`TimeTracking|Enter time as a total duration (for example, 1mo 2w 3d 5h 10m), or specify hours and minutes (for example, 75:30).`,
|
||||
)
|
||||
"
|
||||
>
|
||||
<gl-form-input
|
||||
id="time-estimate"
|
||||
v-model="timeEstimate"
|
||||
data-testid="time-estimate"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</gl-form-group>
|
||||
<gl-alert v-if="saveError" variant="danger" class="gl-mt-5" :dismissible="false">
|
||||
{{ saveError }}
|
||||
</gl-alert>
|
||||
<!-- This is needed to have the quick-submit behaviour (with Ctrl + Enter or Cmd + Enter) -->
|
||||
<input type="submit" hidden />
|
||||
</form>
|
||||
</gl-modal>
|
||||
</template>
|
||||
|
|
@ -35,6 +35,11 @@ export default {
|
|||
required: false,
|
||||
default: true,
|
||||
},
|
||||
canSetTimeEstimate: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.listenForQuickActions();
|
||||
|
|
@ -73,6 +78,7 @@ export default {
|
|||
:issuable-iid="issuableIid"
|
||||
:limit-to-hours="limitToHours"
|
||||
:can-add-time-entries="canAddTimeEntries"
|
||||
:can-set-time-estimate="canSetTimeEstimate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@ import TimeTrackingCollapsedState from './collapsed_state.vue';
|
|||
import TimeTrackingComparisonPane from './comparison_pane.vue';
|
||||
import TimeTrackingReport from './report.vue';
|
||||
import TimeTrackingSpentOnlyPane from './spent_only_pane.vue';
|
||||
import { CREATE_TIMELOG_MODAL_ID } from './constants';
|
||||
import { CREATE_TIMELOG_MODAL_ID, SET_TIME_ESTIMATE_MODAL_ID } from './constants';
|
||||
import CreateTimelogForm from './create_timelog_form.vue';
|
||||
import SetTimeEstimateForm from './set_time_estimate_form.vue';
|
||||
|
||||
export default {
|
||||
name: 'IssuableTimeTracker',
|
||||
|
|
@ -38,6 +39,7 @@ export default {
|
|||
TimeTrackingComparisonPane,
|
||||
TimeTrackingReport,
|
||||
CreateTimelogForm,
|
||||
SetTimeEstimateForm,
|
||||
},
|
||||
directives: {
|
||||
GlModal: GlModalDirective,
|
||||
|
|
@ -94,6 +96,11 @@ export default {
|
|||
required: false,
|
||||
default: true,
|
||||
},
|
||||
canSetTimeEstimate: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -181,6 +188,11 @@ export default {
|
|||
timeTrackingIconName() {
|
||||
return this.showHelpState ? 'close' : 'question-o';
|
||||
},
|
||||
timeEstimateTooltip() {
|
||||
return this.hasTimeEstimate
|
||||
? s__('TimeTracking|Edit estimate')
|
||||
: s__('TimeTracking|Set estimate');
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
/**
|
||||
|
|
@ -203,6 +215,7 @@ export default {
|
|||
this.$root.$emit(BV_SHOW_MODAL, CREATE_TIMELOG_MODAL_ID);
|
||||
},
|
||||
},
|
||||
setTimeEstimateModalId: SET_TIME_ESTIMATE_MODAL_ID,
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
@ -223,18 +236,31 @@ export default {
|
|||
>
|
||||
{{ __('Time tracking') }}
|
||||
<gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" class="gl-ml-2" inline />
|
||||
<gl-button
|
||||
v-if="canAddTimeEntries"
|
||||
v-gl-tooltip.left
|
||||
category="tertiary"
|
||||
size="small"
|
||||
class="gl-ml-auto"
|
||||
data-testid="add-time-entry-button"
|
||||
:title="__('Add time entry')"
|
||||
@click="openRegisterTimeSpentModal()"
|
||||
>
|
||||
<gl-icon name="plus" class="gl-text-gray-900!" />
|
||||
</gl-button>
|
||||
<div v-if="canSetTimeEstimate || canAddTimeEntries" class="gl-ml-auto gl-display-flex">
|
||||
<gl-button
|
||||
v-if="canSetTimeEstimate"
|
||||
v-gl-modal="$options.setTimeEstimateModalId"
|
||||
v-gl-tooltip.top
|
||||
category="tertiary"
|
||||
size="small"
|
||||
data-testid="set-time-estimate-button"
|
||||
:title="timeEstimateTooltip"
|
||||
:aria-label="timeEstimateTooltip"
|
||||
>
|
||||
<gl-icon name="timer" class="gl-text-gray-900!" />
|
||||
</gl-button>
|
||||
<gl-button
|
||||
v-if="canAddTimeEntries"
|
||||
v-gl-tooltip.top
|
||||
category="tertiary"
|
||||
size="small"
|
||||
data-testid="add-time-entry-button"
|
||||
:title="__('Add time entry')"
|
||||
@click="openRegisterTimeSpentModal()"
|
||||
>
|
||||
<gl-icon name="plus" class="gl-text-gray-900!" />
|
||||
</gl-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed">
|
||||
<div v-if="showEstimateOnlyState" data-testid="estimateOnlyPane">
|
||||
|
|
@ -255,10 +281,11 @@ export default {
|
|||
:time-estimate-human-readable="humanTimeEstimate"
|
||||
:limit-to-hours="limitToHours"
|
||||
/>
|
||||
<template v-if="isTimeReportSupported">
|
||||
<div v-if="isTimeReportSupported">
|
||||
<gl-link
|
||||
v-if="hasTotalTimeSpent"
|
||||
v-gl-modal="'time-tracking-report'"
|
||||
class="gl-text-black-normal"
|
||||
data-testid="reportLink"
|
||||
href="#"
|
||||
>
|
||||
|
|
@ -272,8 +299,13 @@ export default {
|
|||
>
|
||||
<time-tracking-report :limit-to-hours="limitToHours" :issuable-id="issuableId" />
|
||||
</gl-modal>
|
||||
</template>
|
||||
</div>
|
||||
<create-timelog-form :issuable-id="issuableId" />
|
||||
<set-time-estimate-form
|
||||
:full-path="fullPath"
|
||||
:issuable-iid="issuableIid"
|
||||
:time-tracking="timeTracking"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export default class SidebarMilestone {
|
|||
humanTotalTimeSpent: humanTimeSpent,
|
||||
},
|
||||
canAddTimeEntries: false,
|
||||
canSetTimeEstimate: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -545,6 +545,7 @@ function mountSidebarTimeTracking() {
|
|||
issuableType,
|
||||
timeTrackingLimitToHours,
|
||||
canCreateTimelogs,
|
||||
editable,
|
||||
} = getSidebarOptions();
|
||||
|
||||
if (!el) {
|
||||
|
|
@ -564,6 +565,7 @@ function mountSidebarTimeTracking() {
|
|||
issuableIid: iid.toString(),
|
||||
limitToHours: timeTrackingLimitToHours,
|
||||
canAddTimeEntries: canCreateTimelogs,
|
||||
canSetTimeEstimate: parseBoolean(editable),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
mutation issueSetTimeEstimate($input: UpdateIssueInput!) {
|
||||
issuableSetTimeEstimate: updateIssue(input: $input) {
|
||||
errors
|
||||
issuable: issue {
|
||||
id
|
||||
humanTimeEstimate
|
||||
timeEstimate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
mutation mergeRequestSetTimeEstimate($input: MergeRequestUpdateInput!) {
|
||||
issuableSetTimeEstimate: mergeRequestUpdate(input: $input) {
|
||||
errors
|
||||
issuable: mergeRequest {
|
||||
id
|
||||
humanTimeEstimate
|
||||
timeEstimate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,8 +7,10 @@ import { isScopedLabel } from '~/lib/utils/common_utils';
|
|||
import { isExternal, setUrlFragment } from '~/lib/utils/url_utility';
|
||||
import { __, n__, sprintf } from '~/locale';
|
||||
import IssuableAssignees from '~/issuable/components/issue_assignees.vue';
|
||||
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
|
||||
import timeagoMixin from '~/vue_shared/mixins/timeago';
|
||||
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
|
||||
import { STATE_CLOSED } from '~/work_items/constants';
|
||||
import { isAssigneesWidget, isLabelsWidget } from '~/work_items/utils';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
|
@ -90,26 +92,41 @@ export default {
|
|||
reference() {
|
||||
return this.issuable.reference || `${this.issuableSymbol}${this.issuable.iid}`;
|
||||
},
|
||||
type() {
|
||||
return this.issuable.type || this.issuable.workItemType?.name.toUpperCase();
|
||||
},
|
||||
labels() {
|
||||
return this.issuable.labels?.nodes || this.issuable.labels || [];
|
||||
return (
|
||||
this.issuable.labels?.nodes ||
|
||||
this.issuable.labels ||
|
||||
this.issuable.widgets?.find(isLabelsWidget)?.labels.nodes ||
|
||||
[]
|
||||
);
|
||||
},
|
||||
labelIdsString() {
|
||||
return JSON.stringify(this.labels.map((label) => getIdFromGraphQLId(label.id)));
|
||||
},
|
||||
assignees() {
|
||||
return this.issuable.assignees?.nodes || this.issuable.assignees || [];
|
||||
return (
|
||||
this.issuable.assignees?.nodes ||
|
||||
this.issuable.assignees ||
|
||||
this.issuable.widgets?.find(isAssigneesWidget)?.assignees.nodes ||
|
||||
[]
|
||||
);
|
||||
},
|
||||
createdAt() {
|
||||
return this.timeFormatted(this.issuable.createdAt);
|
||||
},
|
||||
isClosed() {
|
||||
return this.issuable.state === STATUS_CLOSED || this.issuable.state === STATE_CLOSED;
|
||||
},
|
||||
timestamp() {
|
||||
if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) {
|
||||
return this.issuable.closedAt;
|
||||
}
|
||||
return this.issuable.updatedAt;
|
||||
return this.isClosed && this.issuable.closedAt
|
||||
? this.issuable.closedAt
|
||||
: this.issuable.updatedAt;
|
||||
},
|
||||
formattedTimestamp() {
|
||||
if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) {
|
||||
if (this.isClosed && this.issuable.closedAt) {
|
||||
return sprintf(__('closed %{timeago}'), {
|
||||
timeago: this.timeFormatted(this.issuable.closedAt),
|
||||
});
|
||||
|
|
@ -167,7 +184,10 @@ export default {
|
|||
return Boolean(this.$slots[slotName]);
|
||||
},
|
||||
scopedLabel(label) {
|
||||
return this.hasScopedLabelsFeature && isScopedLabel(label);
|
||||
const allowsScopedLabels =
|
||||
this.hasScopedLabelsFeature ||
|
||||
this.issuable.widgets?.find(isLabelsWidget)?.allowsScopedLabels;
|
||||
return allowsScopedLabels && isScopedLabel(label);
|
||||
},
|
||||
labelTitle(label) {
|
||||
return label.title || label.name;
|
||||
|
|
@ -213,7 +233,7 @@ export default {
|
|||
:checked="checked"
|
||||
:data-id="issuableId"
|
||||
:data-iid="issuableIid"
|
||||
:data-type="issuable.type"
|
||||
:data-type="type"
|
||||
@input="$emit('checked-input', $event)"
|
||||
>
|
||||
<span class="gl-sr-only">{{ issuable.title }}</span>
|
||||
|
|
@ -222,7 +242,7 @@ export default {
|
|||
<div data-testid="issuable-title" class="issue-title title">
|
||||
<work-item-type-icon
|
||||
v-if="showWorkItemTypeIcon"
|
||||
:work-item-type="issuable.type"
|
||||
:work-item-type="type"
|
||||
show-tooltip-on-hover
|
||||
/>
|
||||
<gl-icon
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
|
|||
import * as Sentry from '@sentry/browser';
|
||||
import toast from '~/vue_shared/plugins/global_toast';
|
||||
import { __ } from '~/locale';
|
||||
import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_ASSIGNEES } from '~/work_items/constants';
|
||||
import { i18n, TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
|
||||
import Tracking from '~/tracking';
|
||||
import { updateDraft, clearDraft } from '~/lib/utils/autosave';
|
||||
import { renderMarkdown } from '~/notes/utils';
|
||||
|
|
@ -17,6 +17,7 @@ import NoteActions from '~/work_items/components/notes/work_item_note_actions.vu
|
|||
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
|
||||
import updateWorkItemNoteMutation from '../../graphql/notes/update_work_item_note.mutation.graphql';
|
||||
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
|
||||
import { isAssigneesWidget } from '../../utils';
|
||||
import WorkItemCommentForm from './work_item_comment_form.vue';
|
||||
import WorkItemNoteAwardsList from './work_item_note_awards_list.vue';
|
||||
|
||||
|
|
@ -228,8 +229,6 @@ export default {
|
|||
newAssignees = [...this.assignees, this.author];
|
||||
}
|
||||
|
||||
const isAssigneesWidget = (widget) => widget.type === WIDGET_TYPE_ASSIGNEES;
|
||||
|
||||
const assigneesWidgetIndex = this.workItem.widgets.findIndex(isAssigneesWidget);
|
||||
|
||||
const editedWorkItemWidgets = [...this.workItem.widgets];
|
||||
|
|
|
|||
|
|
@ -9,13 +9,8 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
|
|||
import { isScopedLabel } from '~/lib/utils/common_utils';
|
||||
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
|
||||
import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
|
||||
|
||||
import {
|
||||
i18n,
|
||||
I18N_WORK_ITEM_ERROR_FETCHING_LABELS,
|
||||
TRACKING_CATEGORY_SHOW,
|
||||
WIDGET_TYPE_LABELS,
|
||||
} from '../constants';
|
||||
import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS, TRACKING_CATEGORY_SHOW } from '../constants';
|
||||
import { isLabelsWidget } from '../utils';
|
||||
|
||||
function isTokenSelectorElement(el) {
|
||||
return (
|
||||
|
|
@ -127,7 +122,7 @@ export default {
|
|||
return this.$apollo.queries.searchLabels.loading;
|
||||
},
|
||||
labelsWidget() {
|
||||
return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS);
|
||||
return this.workItem?.widgets?.find(isLabelsWidget);
|
||||
},
|
||||
labels() {
|
||||
return this.labelsWidget?.labels?.nodes || [];
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
<script>
|
||||
import * as Sentry from '@sentry/browser';
|
||||
import { STATUS_OPEN } from '~/issues/constants';
|
||||
import { __ } from '~/locale';
|
||||
import { __, s__ } from '~/locale';
|
||||
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
|
||||
import { issuableListTabs } from '~/vue_shared/issuable/list/constants';
|
||||
import { STATE_CLOSED } from '../../constants';
|
||||
import getWorkItemsQuery from '../queries/get_work_items.query.graphql';
|
||||
|
||||
export default {
|
||||
i18n: {
|
||||
|
|
@ -12,26 +15,59 @@ export default {
|
|||
components: {
|
||||
IssuableList,
|
||||
},
|
||||
inject: ['fullPath'],
|
||||
data() {
|
||||
return {
|
||||
issues: [],
|
||||
error: undefined,
|
||||
searchTokens: [],
|
||||
sortOptions: [],
|
||||
state: STATUS_OPEN,
|
||||
workItems: [],
|
||||
};
|
||||
},
|
||||
apollo: {
|
||||
workItems: {
|
||||
query: getWorkItemsQuery,
|
||||
variables() {
|
||||
return {
|
||||
fullPath: this.fullPath,
|
||||
};
|
||||
},
|
||||
update(data) {
|
||||
return data.group.workItems.nodes ?? [];
|
||||
},
|
||||
error(error) {
|
||||
this.error = s__(
|
||||
'WorkItem|Something went wrong when fetching work items. Please try again.',
|
||||
);
|
||||
Sentry.captureException(error);
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getStatus(issue) {
|
||||
return issue.state === STATE_CLOSED ? __('Closed') : undefined;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<issuable-list
|
||||
:current-tab="state"
|
||||
:issuables="issues"
|
||||
:error="error"
|
||||
:issuables="workItems"
|
||||
namespace="work-items"
|
||||
recent-searches-storage-key="issues"
|
||||
:search-input-placeholder="$options.i18n.searchPlaceholder"
|
||||
:search-tokens="searchTokens"
|
||||
show-work-item-type-icon
|
||||
:sort-options="sortOptions"
|
||||
:tabs="$options.issuableListTabs"
|
||||
/>
|
||||
@dismiss-alert="error = undefined"
|
||||
>
|
||||
<template #status="{ issuable }">
|
||||
{{ getStatus(issuable) }}
|
||||
</template>
|
||||
</issuable-list>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import Vue from 'vue';
|
||||
import WorkItemsListApp from '~/work_items/list/components/work_items_list_app.vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import createDefaultClient from '~/lib/graphql';
|
||||
import WorkItemsListApp from './components/work_items_list_app.vue';
|
||||
|
||||
export const mountWorkItemsListApp = () => {
|
||||
const el = document.querySelector('.js-work-items-list-root');
|
||||
|
|
@ -8,9 +10,17 @@ export const mountWorkItemsListApp = () => {
|
|||
return null;
|
||||
}
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
return new Vue({
|
||||
el,
|
||||
name: 'WorkItemsListRoot',
|
||||
apolloProvider: new VueApollo({
|
||||
defaultClient: createDefaultClient(),
|
||||
}),
|
||||
provide: {
|
||||
fullPath: el.dataset.fullPath,
|
||||
},
|
||||
render: (createComponent) => createComponent(WorkItemsListApp),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
query getWorkItems($fullPath: ID!) {
|
||||
group(fullPath: $fullPath) {
|
||||
id
|
||||
workItems {
|
||||
nodes {
|
||||
id
|
||||
author {
|
||||
id
|
||||
avatarUrl
|
||||
name
|
||||
username
|
||||
webUrl
|
||||
}
|
||||
closedAt
|
||||
confidential
|
||||
createdAt
|
||||
iid
|
||||
reference(full: true)
|
||||
state
|
||||
title
|
||||
updatedAt
|
||||
webUrl
|
||||
widgets {
|
||||
... on WorkItemWidgetAssignees {
|
||||
assignees {
|
||||
nodes {
|
||||
id
|
||||
avatarUrl
|
||||
name
|
||||
username
|
||||
webUrl
|
||||
}
|
||||
}
|
||||
type
|
||||
}
|
||||
... on WorkItemWidgetLabels {
|
||||
allowsScopedLabels
|
||||
labels {
|
||||
nodes {
|
||||
id
|
||||
color
|
||||
description
|
||||
title
|
||||
}
|
||||
}
|
||||
type
|
||||
}
|
||||
}
|
||||
workItemType {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants';
|
||||
import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_HIERARCHY, WIDGET_TYPE_LABELS } from './constants';
|
||||
|
||||
export const isAssigneesWidget = (widget) => widget.type === WIDGET_TYPE_ASSIGNEES;
|
||||
|
||||
export const isLabelsWidget = (widget) => widget.type === WIDGET_TYPE_LABELS;
|
||||
|
||||
export const findHierarchyWidgets = (widgets) =>
|
||||
widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY);
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ module EachBatch
|
|||
new_count, last_value =
|
||||
unscoped
|
||||
.from(inner_query)
|
||||
.unscope(where: :type)
|
||||
.order(count: :desc)
|
||||
.limit(1)
|
||||
.pick(:count, column)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ class Namespace::Detail < ApplicationRecord
|
|||
include IgnorableColumns
|
||||
|
||||
ignore_column :free_user_cap_over_limt_notified_at, remove_with: '15.7', remove_after: '2022-11-22'
|
||||
ignore_column :dashboard_notification_at, remove_with: '16.5', remove_after: '2023-08-22'
|
||||
ignore_column :dashboard_enforcement_at, remove_with: '16.5', remove_after: '2023-08-22'
|
||||
|
||||
belongs_to :namespace, inverse_of: :namespace_details
|
||||
validates :namespace, presence: true
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
- page_title s_('WorkItem|Work items')
|
||||
- add_page_specific_style 'page_bundles/issuable_list'
|
||||
|
||||
.js-work-items-list-root
|
||||
.js-work-items-list-root{ data: { full_path: @group.full_path } }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
- noteable_name = @note.noteable.human_class_name
|
||||
|
||||
.js-comment-type-dropdown.float-left.gl-sm-mr-3{ data: { noteable_name: noteable_name } }
|
||||
%input.btn.gl-button.btn-confirm.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment'), data: { qa_selector: 'comment_button' } }
|
||||
= render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { class: 'js-comment-button js-comment-submit-button', value: _('Comment'), data: { qa_selector: 'comment_button' }}) do
|
||||
= _('Comment')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/128570
|
||||
class AddLabelLockOnMergeRedux < Gitlab::Database::Migration[2.1]
|
||||
def change
|
||||
add_column :labels, :lock_on_merge, :boolean, default: false, null: false
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1 @@
|
|||
479182c1a96d0c827e9de927f529ebaa447fcd3811619d53da4cd52b3462ea21
|
||||
|
|
@ -17859,7 +17859,8 @@ CREATE TABLE labels (
|
|||
description_html text,
|
||||
type character varying,
|
||||
group_id integer,
|
||||
cached_markdown_version integer
|
||||
cached_markdown_version integer,
|
||||
lock_on_merge boolean DEFAULT false NOT NULL
|
||||
);
|
||||
|
||||
CREATE SEQUENCE labels_id_seq
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ MR is merged.
|
|||
|
||||
## How test coverage visualization works
|
||||
|
||||
Collecting the coverage information is done via GitLab CI/CD's
|
||||
Collecting the coverage information is done by using the GitLab CI/CD
|
||||
[artifacts reports feature](../yaml/index.md#artifactsreports).
|
||||
You can specify one or more coverage reports to collect, including wildcard paths.
|
||||
GitLab then takes the coverage information in all the files and combines it
|
||||
|
|
@ -41,8 +41,7 @@ Other coverage analysis frameworks support the format out of the box, for exampl
|
|||
- [Coverage.py](https://coverage.readthedocs.io/en/coverage-5.0.4/cmd.html#xml-reporting) (Python)
|
||||
- [PHPUnit](https://github.com/sebastianbergmann/phpunit-documentation-english/blob/master/src/textui.rst#command-line-options) (PHP)
|
||||
|
||||
Once configured, if you create a merge request that triggers a pipeline which collects
|
||||
coverage reports, the coverage is shown in the diff view. This includes reports
|
||||
After configuration, if your merge request triggers a pipeline that collects coverage reports, the coverage information is displayed in the diff view. This includes reports
|
||||
from any job in any stage in the pipeline. The coverage displays for each line:
|
||||
|
||||
- `covered` (green): lines which have been checked at least once by tests
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ The best results from Code Suggestions are expected [for languages the Google Ve
|
|||
- Swift
|
||||
- TypeScript
|
||||
|
||||
## Supported code infrastructure interfaces
|
||||
### Supported code infrastructure interfaces
|
||||
|
||||
Code Suggestions includes [Google Vertex AI Codey APIs](https://cloud.google.com/vertex-ai/docs/generative-ai/code/code-models-overview#supported_code_infrastructure_interfaces) support for the following infrastructure as code interfaces:
|
||||
|
||||
|
|
@ -68,12 +68,36 @@ Code Suggestions includes [Google Vertex AI Codey APIs](https://cloud.google.com
|
|||
|
||||
Suggestion quality for other languages and using natural language code comments to request completions may not yet result in high-quality suggestions.
|
||||
|
||||
### Supported languages in IDEs
|
||||
|
||||
Editor support for languages is documented in the following table.
|
||||
|
||||
| Language | VS Code | JetBrains IDEs | Visual Studio | Neovim |
|
||||
|---------------------------------|----------------------------------------------------------------------------------------------------------------|------------------------------|---------------|--------|
|
||||
| C++ | ✓ | | ✓ | |
|
||||
| C# | ✓ | ✓ | ✓ | |
|
||||
| Go | ✓ | ✓ (IDEA Ultimate / GoLand) | | |
|
||||
| Google SQL | | | | |
|
||||
| Java | ✓ | ✓ | | |
|
||||
| JavaScript | ✓ | ✓ | | |
|
||||
| Kotlin | ✓ | ✓ | | |
|
||||
| PHP | ✓ | ✓ (IDEA Ultimate) | | |
|
||||
| Python | ✓ | ✓ | | ✓ |
|
||||
| Ruby | ✓ | ✓ (IDEA Ultimate / RubyMine) | | ✓ |
|
||||
| Rust | ✓ | ✓ | | |
|
||||
| Scala | ✓ | ✓ | | |
|
||||
| Swift | ✓ | ✓ | | |
|
||||
| TypeScript | ✓ | ✓ | | |
|
||||
| Google Cloud CLI | | | | |
|
||||
| Kubernetes Resource Model (KRM) | | | | |
|
||||
| Terraform | [`gitlab-org/gitlab-vscode-extension#826`](https://gitlab.com/gitlab-org/gitlab-vscode-extension/-/issues/826) | | | |
|
||||
|
||||
## Supported editor extensions
|
||||
|
||||
Code Suggestions supports a variety of popular editors including:
|
||||
Code Suggestions supports a variety of popular editors including:
|
||||
|
||||
- VS Code, using [the VS Code GitLab Workflow extension](https://marketplace.visualstudio.com/items?itemName=GitLab.gitlab-workflow).
|
||||
- [GitLab WebIDE (VS Code in the Cloud)](../../project/web_ide/index.md), with no additional configuration.
|
||||
- [GitLab WebIDE (VS Code in the Cloud)](../../project/web_ide/index.md), with no additional configuration.
|
||||
- Microsoft Visual Studio, using the [Visual Studio GitLab extension](https://marketplace.visualstudio.com/items?itemName=GitLab.GitLabExtensionForVisualStudio).
|
||||
- JetBrains IDEs, using the [GitLab plugin](https://plugins.jetbrains.com/plugin/22325-gitlab).
|
||||
- Neovim, using the [`gitlab.vim` plugin](https://gitlab.com/gitlab-org/editor-extensions/gitlab.vim).
|
||||
|
|
@ -83,7 +107,7 @@ is also in process.
|
|||
This improvement should result in:
|
||||
|
||||
- Faster iteration and standardization of the IDE extensions.
|
||||
- The ability to use Code Suggestions even when an official editor extension isn't available.
|
||||
- The ability to use Code Suggestions even when an official editor extension isn't available.
|
||||
|
||||
## Enable Code Suggestions on GitLab SaaS **(FREE SAAS)**
|
||||
|
||||
|
|
@ -167,7 +191,7 @@ on self-managed instances. To request access:
|
|||
and tag your customer success manager.
|
||||
|
||||
After GitLab has provisioned access to Code Suggestions for your instance,
|
||||
the users in your instance can now enable Code Suggestions.
|
||||
the users in your instance can now enable Code Suggestions.
|
||||
|
||||
## Use Code Suggestions
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
variables:
|
||||
DAST_AUTO_DEPLOY_IMAGE_VERSION: 'v2.52.0'
|
||||
DAST_AUTO_DEPLOY_IMAGE_VERSION: 'v2.53.0'
|
||||
|
||||
.dast-auto-deploy:
|
||||
image: "${CI_TEMPLATE_REGISTRY_HOST}/gitlab-org/cluster-integration/auto-deploy-image:${DAST_AUTO_DEPLOY_IMAGE_VERSION}"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
variables:
|
||||
AUTO_DEPLOY_IMAGE_VERSION: 'v2.52.0'
|
||||
AUTO_DEPLOY_IMAGE_VERSION: 'v2.53.0'
|
||||
|
||||
.auto-deploy:
|
||||
image: "${CI_TEMPLATE_REGISTRY_HOST}/gitlab-org/cluster-integration/auto-deploy-image:${AUTO_DEPLOY_IMAGE_VERSION}"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
variables:
|
||||
AUTO_DEPLOY_IMAGE_VERSION: 'v2.52.0'
|
||||
AUTO_DEPLOY_IMAGE_VERSION: 'v2.53.0'
|
||||
|
||||
.auto-deploy:
|
||||
image: "${CI_TEMPLATE_REGISTRY_HOST}/gitlab-org/cluster-integration/auto-deploy-image:${AUTO_DEPLOY_IMAGE_VERSION}"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ module Gitlab
|
|||
end
|
||||
|
||||
def force_disconnect_if_old!
|
||||
return if Rails.env.test? && transaction_open?
|
||||
|
||||
if force_disconnect_timer.expired?
|
||||
disconnect!
|
||||
reset_force_disconnect_timer!
|
||||
|
|
|
|||
|
|
@ -48487,15 +48487,42 @@ msgstr ""
|
|||
msgid "TimeTracking|An error occurred while removing the timelog."
|
||||
msgstr ""
|
||||
|
||||
msgid "TimeTracking|An error occurred while saving the time estimate."
|
||||
msgstr ""
|
||||
|
||||
msgid "TimeTracking|Delete time spent"
|
||||
msgstr ""
|
||||
|
||||
msgid "TimeTracking|Edit estimate"
|
||||
msgstr ""
|
||||
|
||||
msgid "TimeTracking|Edit time estimate"
|
||||
msgstr ""
|
||||
|
||||
msgid "TimeTracking|Enter time as a total duration (for example, 1mo 2w 3d 5h 10m), or specify hours and minutes (for example, 75:30)."
|
||||
msgstr ""
|
||||
|
||||
msgid "TimeTracking|Estimate"
|
||||
msgstr ""
|
||||
|
||||
msgid "TimeTracking|Estimated:"
|
||||
msgstr ""
|
||||
|
||||
msgid "TimeTracking|How do I estimate and track time?"
|
||||
msgstr ""
|
||||
|
||||
msgid "TimeTracking|Over by %{timeRemainingHumanReadable}"
|
||||
msgstr ""
|
||||
|
||||
msgid "TimeTracking|Set estimate"
|
||||
msgstr ""
|
||||
|
||||
msgid "TimeTracking|Set estimated time to complete this %{issuableTypeName}."
|
||||
msgstr ""
|
||||
|
||||
msgid "TimeTracking|Set time estimate"
|
||||
msgstr ""
|
||||
|
||||
msgid "TimeTracking|Spent"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -53209,6 +53236,9 @@ msgstr ""
|
|||
msgid "WorkItem|Something went wrong when fetching work item types. Please try again"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|Something went wrong when fetching work items. Please try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|Something went wrong when trying to add a child. Please try again."
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
/*
|
||||
To avoid duplicating tests in time_tracker.spec,
|
||||
this spec only contains a simple test to check rendering.
|
||||
|
||||
A detailed feature spec is used to test time tracking feature
|
||||
in swimlanes sidebar.
|
||||
*/
|
||||
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
|
||||
import { createStore } from '~/boards/stores';
|
||||
import IssuableTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
|
||||
|
||||
describe('BoardSidebarTimeTracker', () => {
|
||||
let wrapper;
|
||||
let store;
|
||||
|
||||
const createComponent = (options) => {
|
||||
wrapper = shallowMount(BoardSidebarTimeTracker, {
|
||||
store,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore();
|
||||
store.state.boardItems = {
|
||||
1: {
|
||||
id: 1,
|
||||
iid: 1,
|
||||
timeEstimate: 3600,
|
||||
totalTimeSpent: 1800,
|
||||
humanTimeEstimate: '1h',
|
||||
humanTotalTimeSpent: '30min',
|
||||
},
|
||||
};
|
||||
store.state.activeId = '1';
|
||||
});
|
||||
|
||||
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',
|
||||
fullPath: '',
|
||||
initialTimeTracking: {
|
||||
timeEstimate: 3600,
|
||||
totalTimeSpent: 1800,
|
||||
humanTimeEstimate: '1h',
|
||||
humanTotalTimeSpent: '30min',
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'Time estimates (GraphQL fixtures)', feature_category: :team_planning do
|
||||
describe GraphQL::Query, type: :request do
|
||||
include ApiHelpers
|
||||
include GraphqlHelpers
|
||||
include JavaScriptFixturesHelpers
|
||||
|
||||
let_it_be(:developer) { create(:user) }
|
||||
|
||||
context 'for issues time estimate' do
|
||||
let_it_be(:project) { create(:project_empty_repo, :public) }
|
||||
let_it_be(:issue) { create(:issue, project: project) }
|
||||
|
||||
let(:query_path) { 'sidebar/queries/issue_set_time_estimate.mutation.graphql' }
|
||||
let(:query) { get_graphql_query_as_string(query_path) }
|
||||
|
||||
before_all do
|
||||
project.add_developer(developer)
|
||||
end
|
||||
|
||||
context 'when there are no errors while changing the time estimate' do
|
||||
it "graphql/issue_set_time_estimate_without_errors.json" do
|
||||
post_graphql(
|
||||
query,
|
||||
current_user: developer,
|
||||
variables: {
|
||||
input: {
|
||||
projectPath: project.full_path,
|
||||
iid: issue.iid.to_s,
|
||||
timeEstimate: '1d 2h'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect_graphql_errors_to_be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are errors while changing the time estimate' do
|
||||
it "graphql/issue_set_time_estimate_with_errors.json" do
|
||||
post_graphql(
|
||||
query,
|
||||
current_user: developer,
|
||||
variables: {
|
||||
input: {
|
||||
projectPath: project.full_path,
|
||||
iid: issue.iid.to_s,
|
||||
timeEstimate: '1egh'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect_graphql_errors_to_include("timeEstimate must be formatted correctly, for example `1h 30m`")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -6,6 +6,7 @@ jest.mock('~/behaviors/markdown/render_gfm');
|
|||
jest.mock('~/milestones/milestone');
|
||||
jest.mock('~/right_sidebar');
|
||||
jest.mock('~/sidebar/mount_milestone_sidebar');
|
||||
jest.mock('~/lib/graphql');
|
||||
|
||||
describe('#initShow', () => {
|
||||
beforeEach(() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,416 @@
|
|||
import Vue, { nextTick } from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import { GlModal, GlAlert } from '@gitlab/ui';
|
||||
import setIssueTimeEstimateWithErrors from 'test_fixtures/graphql/issue_set_time_estimate_with_errors.json';
|
||||
import setIssueTimeEstimateWithoutErrors from 'test_fixtures/graphql/issue_set_time_estimate_without_errors.json';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import SetTimeEstimateForm from '~/sidebar/components/time_tracking/set_time_estimate_form.vue';
|
||||
import issueSetTimeEstimateMutation from '~/sidebar/queries/issue_set_time_estimate.mutation.graphql';
|
||||
|
||||
const mockProjectFullPath = 'group/project';
|
||||
const mockMutationErrorMessage = setIssueTimeEstimateWithErrors.errors[0].message;
|
||||
const mockIssuableIid = '1';
|
||||
const mockMutationTimeEstimateInHumanReadableFormat = '1d 2h';
|
||||
const mockTimeTrackingData = {
|
||||
timeEstimate: 3600,
|
||||
humanTimeEstimate: '1h',
|
||||
};
|
||||
|
||||
const resolvedMutationWithoutErrorsMock = jest
|
||||
.fn()
|
||||
.mockResolvedValue(setIssueTimeEstimateWithoutErrors);
|
||||
const resolvedMutationWithErrorsMock = jest.fn().mockResolvedValue(setIssueTimeEstimateWithErrors);
|
||||
|
||||
const rejectedMutationMock = jest.fn().mockRejectedValue();
|
||||
const modalCloseMock = jest.fn();
|
||||
|
||||
describe('Set Time Estimate Form', () => {
|
||||
Vue.use(VueApollo);
|
||||
|
||||
let wrapper;
|
||||
|
||||
const findModal = () => wrapper.findComponent(GlModal);
|
||||
const findModalTitle = () => findModal().props('title');
|
||||
const findAlert = () => wrapper.findComponent(GlAlert);
|
||||
const findDocsLink = () => wrapper.findByTestId('timetracking-docs-link');
|
||||
const findSaveButton = () => findModal().props('actionPrimary');
|
||||
const findSaveButtonLoadingState = () => findSaveButton().attributes.loading;
|
||||
const findSaveButtonDisabledState = () => findSaveButton().attributes.disabled;
|
||||
const findResetButton = () => findModal().props('actionSecondary');
|
||||
const findResetButtonLoadingState = () => findResetButton().attributes.loading;
|
||||
const findResetButtonDisabledState = () => findResetButton().attributes.disabled;
|
||||
const findTimeEstiamteInput = () => wrapper.findByTestId('time-estimate');
|
||||
|
||||
const triggerSave = () => {
|
||||
const mockEvent = { preventDefault: jest.fn() };
|
||||
findModal().vm.$emit('primary', mockEvent);
|
||||
};
|
||||
|
||||
const triggerReset = () => {
|
||||
const mockEvent = { preventDefault: jest.fn() };
|
||||
findModal().vm.$emit('secondary', mockEvent);
|
||||
};
|
||||
|
||||
const mountComponent = async ({
|
||||
timeTracking = mockTimeTrackingData,
|
||||
data,
|
||||
providedProps,
|
||||
mutationResolverMock = resolvedMutationWithoutErrorsMock,
|
||||
} = {}) => {
|
||||
wrapper = shallowMountExtended(SetTimeEstimateForm, {
|
||||
data() {
|
||||
return {
|
||||
...data,
|
||||
};
|
||||
},
|
||||
provide: {
|
||||
issuableType: 'issue',
|
||||
...providedProps,
|
||||
},
|
||||
propsData: {
|
||||
issuableIid: mockIssuableIid,
|
||||
fullPath: mockProjectFullPath,
|
||||
timeTracking,
|
||||
},
|
||||
apolloProvider: createMockApollo([[issueSetTimeEstimateMutation, mutationResolverMock]]),
|
||||
});
|
||||
|
||||
wrapper.vm.$refs.modal.close = modalCloseMock;
|
||||
|
||||
findModal().vm.$emit('show');
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
describe('modal title', () => {
|
||||
it('is `Set time estimate` when the current estimate is 0', async () => {
|
||||
await mountComponent({
|
||||
timeTracking: { timeEstimate: 0, humanTimeEstimate: '0h' },
|
||||
mutationResolverMock: resolvedMutationWithoutErrorsMock,
|
||||
});
|
||||
|
||||
expect(findModalTitle()).toBe('Set time estimate');
|
||||
});
|
||||
|
||||
it('is `Edit time estimate` when the current estimate is not 0', async () => {
|
||||
await mountComponent();
|
||||
|
||||
expect(findModalTitle()).toBe('Edit time estimate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('modal', () => {
|
||||
it('shows the provided human time estimate from the timeTracking prop', async () => {
|
||||
await mountComponent();
|
||||
|
||||
expect(findTimeEstiamteInput().attributes('value')).toBe(
|
||||
mockTimeTrackingData.humanTimeEstimate,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save button', () => {
|
||||
it('is not loading by default', async () => {
|
||||
await mountComponent();
|
||||
|
||||
expect(findSaveButtonLoadingState()).toBe(false);
|
||||
});
|
||||
|
||||
it('is disabled and not loading when time estimate is empty', async () => {
|
||||
await mountComponent({ data: { timeEstimate: '' } });
|
||||
|
||||
expect(findSaveButtonLoadingState()).toBe(false);
|
||||
expect(findSaveButtonDisabledState()).toBe(true);
|
||||
});
|
||||
|
||||
it('is enabled and not loading when time estimate is not empty', async () => {
|
||||
await mountComponent({
|
||||
data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat },
|
||||
});
|
||||
|
||||
expect(findSaveButtonLoadingState()).toBe(false);
|
||||
expect(findSaveButtonDisabledState()).toBe(false);
|
||||
});
|
||||
|
||||
it('is disabled and loading when the the save button is clicked', async () => {
|
||||
await mountComponent({
|
||||
data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat },
|
||||
});
|
||||
|
||||
triggerSave();
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(findSaveButtonLoadingState()).toBe(true);
|
||||
expect(findSaveButtonDisabledState()).toBe(true);
|
||||
});
|
||||
|
||||
it('is disabled and loading when the the reset button is clicked', async () => {
|
||||
await mountComponent({
|
||||
data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat },
|
||||
});
|
||||
|
||||
triggerReset();
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(findSaveButtonLoadingState()).toBe(false);
|
||||
expect(findSaveButtonDisabledState()).toBe(true);
|
||||
});
|
||||
|
||||
it('is enabled and not loading the when the save button is clicked and the mutation had errors', async () => {
|
||||
await mountComponent({
|
||||
data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat },
|
||||
mutationResolverMock: rejectedMutationMock,
|
||||
});
|
||||
|
||||
triggerSave();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(rejectedMutationMock).toHaveBeenCalledWith({
|
||||
input: {
|
||||
projectPath: mockProjectFullPath,
|
||||
iid: mockIssuableIid,
|
||||
timeEstimate: mockMutationTimeEstimateInHumanReadableFormat,
|
||||
},
|
||||
});
|
||||
expect(findSaveButtonLoadingState()).toBe(false);
|
||||
expect(findSaveButtonDisabledState()).toBe(false);
|
||||
});
|
||||
|
||||
it('is enabled and not loading the when save button is clicked and the mutation returns errors', async () => {
|
||||
await mountComponent({
|
||||
data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat },
|
||||
mutationResolverMock: resolvedMutationWithErrorsMock,
|
||||
});
|
||||
|
||||
triggerSave();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(resolvedMutationWithErrorsMock).toHaveBeenCalledWith({
|
||||
input: {
|
||||
projectPath: mockProjectFullPath,
|
||||
iid: mockIssuableIid,
|
||||
timeEstimate: mockMutationTimeEstimateInHumanReadableFormat,
|
||||
},
|
||||
});
|
||||
expect(findSaveButtonLoadingState()).toBe(false);
|
||||
expect(findSaveButtonDisabledState()).toBe(false);
|
||||
});
|
||||
|
||||
it('closes the modal after submission and the mutation did not return any error', async () => {
|
||||
await mountComponent({
|
||||
data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat },
|
||||
mutationResolverMock: resolvedMutationWithoutErrorsMock,
|
||||
});
|
||||
|
||||
triggerSave();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(resolvedMutationWithoutErrorsMock).toHaveBeenCalledWith({
|
||||
input: {
|
||||
projectPath: mockProjectFullPath,
|
||||
iid: mockIssuableIid,
|
||||
timeEstimate: mockMutationTimeEstimateInHumanReadableFormat,
|
||||
},
|
||||
});
|
||||
expect(modalCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset button', () => {
|
||||
it('is not visible when the current estimate is 0', async () => {
|
||||
await mountComponent({
|
||||
timeTracking: { timeEstimate: 0, humanTimeEstimate: '0h' },
|
||||
mutationResolverMock: resolvedMutationWithoutErrorsMock,
|
||||
});
|
||||
|
||||
expect(findResetButton()).toBe(null);
|
||||
});
|
||||
|
||||
it('is enabled and not loading even if time estimate is empty', async () => {
|
||||
await mountComponent({ data: { timeEstimate: '' } });
|
||||
|
||||
expect(findResetButtonLoadingState()).toBe(false);
|
||||
expect(findResetButtonDisabledState()).toBe(false);
|
||||
});
|
||||
|
||||
it('is enabled and not loading when time estimate is not empty', async () => {
|
||||
await mountComponent({
|
||||
data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat },
|
||||
});
|
||||
|
||||
expect(findResetButtonLoadingState()).toBe(false);
|
||||
expect(findResetButtonDisabledState()).toBe(false);
|
||||
});
|
||||
|
||||
it('is disabled and loading when the the reset button is clicked', async () => {
|
||||
await mountComponent({
|
||||
data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat },
|
||||
});
|
||||
|
||||
triggerReset();
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(findResetButtonLoadingState()).toBe(true);
|
||||
expect(findResetButtonDisabledState()).toBe(true);
|
||||
});
|
||||
|
||||
it('is disabled and loading when the the save button is clicked', async () => {
|
||||
await mountComponent({
|
||||
data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat },
|
||||
});
|
||||
|
||||
triggerSave();
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(findResetButtonLoadingState()).toBe(false);
|
||||
expect(findResetButtonDisabledState()).toBe(true);
|
||||
});
|
||||
|
||||
it('is enabled and not loading the when the reset button is clicked and the mutation had errors', async () => {
|
||||
await mountComponent({
|
||||
data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat },
|
||||
mutationResolverMock: rejectedMutationMock,
|
||||
});
|
||||
|
||||
triggerReset();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(rejectedMutationMock).toHaveBeenCalledWith({
|
||||
input: {
|
||||
projectPath: mockProjectFullPath,
|
||||
iid: mockIssuableIid,
|
||||
timeEstimate: '0',
|
||||
},
|
||||
});
|
||||
expect(findSaveButtonLoadingState()).toBe(false);
|
||||
expect(findSaveButtonDisabledState()).toBe(false);
|
||||
});
|
||||
|
||||
it('is enabled and not loading the when reset button is clicked and the mutation returns errors', async () => {
|
||||
await mountComponent({
|
||||
data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat },
|
||||
mutationResolverMock: resolvedMutationWithErrorsMock,
|
||||
});
|
||||
|
||||
triggerReset();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(resolvedMutationWithErrorsMock).toHaveBeenCalledWith({
|
||||
input: {
|
||||
projectPath: mockProjectFullPath,
|
||||
iid: mockIssuableIid,
|
||||
timeEstimate: '0',
|
||||
},
|
||||
});
|
||||
expect(findSaveButtonLoadingState()).toBe(false);
|
||||
expect(findSaveButtonDisabledState()).toBe(false);
|
||||
});
|
||||
|
||||
it('closes the modal after submission and the mutation did not return any error', async () => {
|
||||
await mountComponent({
|
||||
data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat },
|
||||
mutationResolverMock: resolvedMutationWithoutErrorsMock,
|
||||
});
|
||||
|
||||
triggerReset();
|
||||
|
||||
await waitForPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(resolvedMutationWithoutErrorsMock).toHaveBeenCalledWith({
|
||||
input: {
|
||||
projectPath: mockProjectFullPath,
|
||||
iid: mockIssuableIid,
|
||||
timeEstimate: '0',
|
||||
},
|
||||
});
|
||||
expect(modalCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('alert', () => {
|
||||
it('is hidden by default', async () => {
|
||||
await mountComponent();
|
||||
|
||||
expect(findAlert().exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe('when saving a change', () => {
|
||||
it('shows an error if the submission fails with a handled error', async () => {
|
||||
await mountComponent({
|
||||
data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat },
|
||||
mutationResolverMock: resolvedMutationWithErrorsMock,
|
||||
});
|
||||
|
||||
triggerSave();
|
||||
|
||||
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 () => {
|
||||
await mountComponent({
|
||||
data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat },
|
||||
mutationResolverMock: rejectedMutationMock,
|
||||
});
|
||||
|
||||
triggerSave();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findAlert().exists()).toBe(true);
|
||||
expect(findAlert().text()).toBe('An error occurred while saving the time estimate.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when resetting the time estimate', () => {
|
||||
it('shows an error if the submission fails with a handled error', async () => {
|
||||
await mountComponent({
|
||||
data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat },
|
||||
mutationResolverMock: resolvedMutationWithErrorsMock,
|
||||
});
|
||||
|
||||
triggerReset();
|
||||
|
||||
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 () => {
|
||||
await mountComponent({
|
||||
data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat },
|
||||
mutationResolverMock: rejectedMutationMock,
|
||||
});
|
||||
|
||||
triggerReset();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findAlert().exists()).toBe(true);
|
||||
expect(findAlert().text()).toBe('An error occurred while saving the time estimate.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('docs link message', () => {
|
||||
it('is present', async () => {
|
||||
await mountComponent();
|
||||
|
||||
expect(findDocsLink().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -291,6 +291,68 @@ describe('Issuable Time Tracker', () => {
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Set time estimate button', () => {
|
||||
const findSetTimeEstimateButton = () => findByTestId('set-time-estimate-button');
|
||||
|
||||
it.each`
|
||||
visibility | canSetTimeEstimate
|
||||
${'not visible'} | ${false}
|
||||
${'visible'} | ${true}
|
||||
`(
|
||||
'is $visibility when canSetTimeEstimate is $canSetTimeEstimate',
|
||||
async ({ canSetTimeEstimate }) => {
|
||||
wrapper = mountComponent({
|
||||
props: {
|
||||
initialTimeTracking: {
|
||||
timeEstimate: 0,
|
||||
totalTimeSpent: 0,
|
||||
humanTimeEstimate: '',
|
||||
humanTotalTimeSpent: '',
|
||||
},
|
||||
canSetTimeEstimate,
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(findSetTimeEstimateButton().exists()).toBe(canSetTimeEstimate);
|
||||
},
|
||||
);
|
||||
|
||||
it('shows a tooltip with `Set estimate` when the current estimate is 0', async () => {
|
||||
wrapper = mountComponent({
|
||||
props: {
|
||||
initialTimeTracking: {
|
||||
timeEstimate: 0,
|
||||
totalTimeSpent: 0,
|
||||
humanTimeEstimate: '',
|
||||
humanTotalTimeSpent: '',
|
||||
},
|
||||
canSetTimeEstimate: true,
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(findSetTimeEstimateButton().attributes('title')).toBe('Set estimate');
|
||||
});
|
||||
|
||||
it('shows a tooltip with `Edit estimate` when the current estimate is not 0', async () => {
|
||||
wrapper = mountComponent({
|
||||
props: {
|
||||
initialTimeTracking: {
|
||||
timeEstimate: 60,
|
||||
totalTimeSpent: 0,
|
||||
humanTimeEstimate: '1m',
|
||||
humanTotalTimeSpent: '',
|
||||
},
|
||||
canSetTimeEstimate: true,
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(findSetTimeEstimateButton().attributes('title')).toBe('Edit estimate');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event listeners', () => {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,33 @@
|
|||
import * as Sentry from '@sentry/browser';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import Vue, { nextTick } from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import { STATUS_OPEN } from '~/issues/constants';
|
||||
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
|
||||
import WorkItemsListApp from '~/work_items/list/components/work_items_list_app.vue';
|
||||
import getWorkItemsQuery from '~/work_items/list/queries/get_work_items.query.graphql';
|
||||
import { groupWorkItemsQueryResponse } from '../../mock_data';
|
||||
|
||||
jest.mock('@sentry/browser');
|
||||
|
||||
describe('WorkItemsListApp component', () => {
|
||||
let wrapper;
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
const defaultQueryHandler = jest.fn().mockResolvedValue(groupWorkItemsQueryResponse);
|
||||
|
||||
const findIssuableList = () => wrapper.findComponent(IssuableList);
|
||||
|
||||
const mountComponent = () => {
|
||||
wrapper = shallowMount(WorkItemsListApp);
|
||||
const mountComponent = ({ queryHandler = defaultQueryHandler } = {}) => {
|
||||
wrapper = shallowMount(WorkItemsListApp, {
|
||||
apolloProvider: createMockApollo([[getWorkItemsQuery, queryHandler]]),
|
||||
provide: {
|
||||
fullPath: 'full/path',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it('renders IssuableList component', () => {
|
||||
|
|
@ -17,13 +35,51 @@ describe('WorkItemsListApp component', () => {
|
|||
|
||||
expect(findIssuableList().props()).toMatchObject({
|
||||
currentTab: STATUS_OPEN,
|
||||
error: '',
|
||||
issuables: [],
|
||||
namespace: 'work-items',
|
||||
recentSearchesStorageKey: 'issues',
|
||||
searchInputPlaceholder: 'Search or filter results...',
|
||||
searchTokens: [],
|
||||
showWorkItemTypeIcon: true,
|
||||
sortOptions: [],
|
||||
tabs: WorkItemsListApp.issuableListTabs,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders work items', async () => {
|
||||
mountComponent();
|
||||
await waitForPromises();
|
||||
|
||||
expect(findIssuableList().props('issuables')).toEqual(
|
||||
groupWorkItemsQueryResponse.data.group.workItems.nodes,
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches work items', () => {
|
||||
mountComponent();
|
||||
|
||||
expect(defaultQueryHandler).toHaveBeenCalledWith({ fullPath: 'full/path' });
|
||||
});
|
||||
|
||||
describe('when there is an error fetching work items', () => {
|
||||
beforeEach(async () => {
|
||||
mountComponent({ queryHandler: jest.fn().mockRejectedValue(new Error('ERROR')) });
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('renders an error message', () => {
|
||||
const message = 'Something went wrong when fetching work items. Please try again.';
|
||||
|
||||
expect(findIssuableList().props('error')).toBe(message);
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(new Error('ERROR'));
|
||||
});
|
||||
|
||||
it('clears error message when "dismiss-alert" event is emitted from IssuableList', async () => {
|
||||
findIssuableList().vm.$emit('dismiss-alert');
|
||||
await nextTick();
|
||||
|
||||
expect(findIssuableList().props('error')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3304,3 +3304,63 @@ export const getTodosMutationResponse = (state) => {
|
|||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const groupWorkItemsQueryResponse = {
|
||||
data: {
|
||||
group: {
|
||||
id: 'gid://gitlab/Group/3',
|
||||
workItems: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'gid://gitlab/WorkItem/58',
|
||||
iid: '23',
|
||||
author: {
|
||||
id: 'gid://gitlab/User/9',
|
||||
avatarUrl: 'author/avatar/url',
|
||||
name: 'Arthur',
|
||||
username: 'arthur',
|
||||
webUrl: 'author/web/url',
|
||||
},
|
||||
closedAt: '',
|
||||
confidential: true,
|
||||
createdAt: '2020-01-23T12:34:56Z',
|
||||
reference: 'javascriptjs/js#23',
|
||||
state: 'OPEN',
|
||||
title: 'a group level work item',
|
||||
updatedAt: '',
|
||||
webUrl: 'web/url',
|
||||
widgets: [
|
||||
{
|
||||
__typename: 'WorkItemWidgetAssignees',
|
||||
assignees: {
|
||||
nodes: mockAssignees,
|
||||
},
|
||||
type: 'ASSIGNEES',
|
||||
},
|
||||
{
|
||||
__typename: 'WorkItemWidgetLabels',
|
||||
allowsScopedLabels: false,
|
||||
labels: {
|
||||
nodes: [
|
||||
{
|
||||
__typename: 'Label',
|
||||
id: 'gid://gitlab/Label/7',
|
||||
color: '#f00',
|
||||
description: '',
|
||||
title: 'Label 7',
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'LABELS',
|
||||
},
|
||||
],
|
||||
workItemType: {
|
||||
id: 'gid://gitlab/WorkItems::Type/5',
|
||||
name: 'Issue',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Database::PostgresqlAdapter::ForceDisconnectableMixin, :reestablished_active_record_base do
|
||||
RSpec.describe Gitlab::Database::PostgresqlAdapter::ForceDisconnectableMixin, :delete, :reestablished_active_record_base do
|
||||
describe 'checking in a connection to the pool' do
|
||||
let(:model) do
|
||||
Class.new(ActiveRecord::Base) do
|
||||
|
|
@ -32,14 +32,29 @@ RSpec.describe Gitlab::Database::PostgresqlAdapter::ForceDisconnectableMixin, :r
|
|||
let(:timer) { connection.force_disconnect_timer }
|
||||
|
||||
context 'when the timer is expired' do
|
||||
it 'disconnects from the database' do
|
||||
before do
|
||||
allow(timer).to receive(:expired?).and_return(true)
|
||||
end
|
||||
|
||||
it 'disconnects from the database' do
|
||||
expect(connection).to receive(:disconnect!).and_call_original
|
||||
expect(timer).to receive(:reset!).and_call_original
|
||||
|
||||
connection.force_disconnect_if_old!
|
||||
end
|
||||
|
||||
context 'when the connection has an open transaction' do
|
||||
it 'does not disconnect from the database' do
|
||||
connection.begin_transaction
|
||||
|
||||
expect(connection).not_to receive(:disconnect!)
|
||||
expect(timer).not_to receive(:reset!)
|
||||
|
||||
connection.force_disconnect_if_old!
|
||||
|
||||
connection.rollback_transaction
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the timer is not expired' do
|
||||
|
|
|
|||
|
|
@ -115,11 +115,16 @@ module AutoExplain
|
|||
private
|
||||
|
||||
def record_auto_explain?(connection)
|
||||
ENV['CI'] \
|
||||
&& ENV['CI_MERGE_REQUEST_LABELS']&.include?('pipeline:record-queries') \
|
||||
&& ENV['CI_JOB_NAME_SLUG'] != 'db-migrate-non-superuser' \
|
||||
&& connection.database_version.to_s[0..1].to_i >= 14 \
|
||||
&& connection.select_one('SHOW is_superuser')['is_superuser'] == 'on'
|
||||
return false unless ENV['CI']
|
||||
return false if ENV['CI_JOB_NAME_SLUG'] == 'db-migrate-non-superuser'
|
||||
return false if connection.database_version.to_s[0..1].to_i < 14
|
||||
return false if connection.select_one('SHOW is_superuser')['is_superuser'] != 'on'
|
||||
|
||||
# This condition matches the pipeline rules for if-merge-request-labels-record-queries
|
||||
return true if ENV['CI_MERGE_REQUEST_LABELS']&.include?('pipeline:record-queries')
|
||||
|
||||
# This condition matches the pipeline rules for if-default-branch-refs
|
||||
ENV['CI_COMMIT_REF_NAME'] == ENV['CI_DEFAULT_BRANCH'] && !ENV['CI_MERGE_REQUEST_IID']
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -90,6 +90,15 @@ RSpec.shared_examples 'issuable time tracker' do |issuable_type|
|
|||
end
|
||||
end
|
||||
|
||||
it 'shows the set time estimate form when add button is clicked' do
|
||||
click_button _('Set estimate')
|
||||
|
||||
page.within '[data-testid="set-time-estimate-modal"]' do
|
||||
expect(page).to have_content 'Set time estimate'
|
||||
expect(page).to have_content 'Estimate'
|
||||
end
|
||||
end
|
||||
|
||||
it 'shows the time tracking report when link is clicked' do
|
||||
submit_time('/estimate 1w')
|
||||
submit_time('/spend 1d')
|
||||
|
|
|
|||
Loading…
Reference in New Issue