Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-08-09 00:08:46 +00:00
parent 929b0ad500
commit 4596c2f5a5
44 changed files with 1236 additions and 178 deletions

View File

@ -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:

View File

@ -1 +1 @@
7ea51fec80a59e85b8ce423ed09a65eb54b568a8
c0e688008f860bd70647b3a30c1410f75744b6d5

View File

@ -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"

View File

@ -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>

View File

@ -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);

View File

@ -1 +1,2 @@
export const CREATE_TIMELOG_MODAL_ID = 'create-timelog-modal';
export const SET_TIME_ESTIMATE_MODAL_ID = 'set-time-estimate-modal';

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -36,6 +36,7 @@ export default class SidebarMilestone {
humanTotalTimeSpent: humanTimeSpent,
},
canAddTimeEntries: false,
canSetTimeEstimate: false,
},
}),
});

View File

@ -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),
},
}),
});

View File

@ -0,0 +1,10 @@
mutation issueSetTimeEstimate($input: UpdateIssueInput!) {
issuableSetTimeEstimate: updateIssue(input: $input) {
errors
issuable: issue {
id
humanTimeEstimate
timeEstimate
}
}
}

View File

@ -0,0 +1,10 @@
mutation mergeRequestSetTimeEstimate($input: MergeRequestUpdateInput!) {
issuableSetTimeEstimate: mergeRequestUpdate(input: $input) {
errors
issuable: mergeRequest {
id
humanTimeEstimate
timeEstimate
}
}
}

View File

@ -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

View File

@ -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];

View File

@ -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 || [];

View File

@ -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>

View File

@ -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),
});
};

View File

@ -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
}
}
}
}
}

View File

@ -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);

View File

@ -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)

View File

@ -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

View File

@ -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 } }

View File

@ -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')

View File

@ -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

View File

@ -0,0 +1 @@
479182c1a96d0c827e9de927f529ebaa447fcd3811619d53da4cd52b3462ea21

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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}"

View File

@ -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}"

View File

@ -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}"

View File

@ -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!

View File

@ -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 ""

View File

@ -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',
},
});
},
);
});

View File

@ -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

View File

@ -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(() => {

View File

@ -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);
});
});
});

View File

@ -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', () => {

View File

@ -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('');
});
});
});

View File

@ -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',
},
},
],
},
},
},
};

View File

@ -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

View File

@ -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

View File

@ -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')