Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-09-15 12:13:55 +00:00
parent b83c1bf235
commit 3107fe7203
40 changed files with 1106 additions and 172 deletions

View File

@ -6,26 +6,6 @@ Layout/FirstArrayElementIndentation:
- 'ee/spec/graphql/mutations/incident_management/escalation_policy/create_spec.rb'
- 'ee/spec/lib/gitlab/graphql/loaders/bulk_epic_aggregate_loader_spec.rb'
- 'ee/spec/models/snippet_repository_spec.rb'
- 'ee/spec/policies/project_policy_spec.rb'
- 'ee/spec/requests/admin/user_permission_exports_controller_spec.rb'
- 'ee/spec/requests/api/analytics/project_deployment_frequency_spec.rb'
- 'ee/spec/requests/api/experiments_spec.rb'
- 'ee/spec/requests/api/graphql/analytics/devops_adoption/enabled_namespaces_spec.rb'
- 'ee/spec/requests/api/graphql/group/epics_spec.rb'
- 'ee/spec/requests/api/graphql/mutations/quality_management/test_cases/create_spec.rb'
- 'ee/spec/requests/api/graphql/mutations/releases/create_spec.rb'
- 'ee/spec/requests/api/graphql/mutations/releases/update_spec.rb'
- 'ee/spec/requests/api/graphql/project/alert_management/payload_fields_spec.rb'
- 'ee/spec/requests/api/graphql/project/incident_management/escalation_policy/rules_spec.rb'
- 'ee/spec/requests/api/graphql/project/merge_requests_spec.rb'
- 'ee/spec/requests/api/internal/kubernetes_spec.rb'
- 'ee/spec/requests/api/ldap_group_links_spec.rb'
- 'ee/spec/requests/api/members_spec.rb'
- 'ee/spec/services/analytics/cycle_analytics/value_streams/update_service_spec.rb'
- 'ee/spec/services/audit_events/export_csv_service_spec.rb'
- 'ee/spec/services/gitlab_subscriptions/fetch_purchase_eligible_namespaces_service_spec.rb'
- 'ee/spec/services/groups/seat_usage_export_service_spec.rb'
- 'ee/spec/services/iterations/cadences/create_iterations_in_advance_service_spec.rb'
- 'ee/spec/services/protected_environments/base_service_spec.rb'
- 'ee/spec/services/search_service_spec.rb'
- 'ee/spec/services/security/ingestion/tasks/hooks_execution_spec.rb'

View File

@ -5,7 +5,7 @@ import AccessorUtilities from './lib/utils/accessor';
export default class Autosave {
constructor(field, key, fallbackKey, lockVersion) {
this.field = field;
this.type = this.field.prop('type');
this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage();
if (key.join != null) {
key = key.join('/');
@ -22,11 +22,12 @@ export default class Autosave {
restore() {
if (!this.isLocalStorageAvailable) return;
if (!this.field.length) return;
const text = window.localStorage.getItem(this.key);
const fallbackText = window.localStorage.getItem(this.fallbackKey);
if (text) {
if (this.type === 'checkbox') {
this.field.prop('checked', text || fallbackText);
} else if (text) {
this.field.val(text);
} else if (fallbackText) {
this.field.val(fallbackText);
@ -49,17 +50,16 @@ export default class Autosave {
save() {
if (!this.field.length) return;
const value = this.type === 'checkbox' ? this.field.is(':checked') : this.field.val();
const text = this.field.val();
if (this.isLocalStorageAvailable && text) {
if (this.isLocalStorageAvailable && value) {
if (this.fallbackKey) {
window.localStorage.setItem(this.fallbackKey, text);
window.localStorage.setItem(this.fallbackKey, value);
}
if (this.lockVersion !== undefined) {
window.localStorage.setItem(this.lockVersionKey, this.lockVersion);
}
return window.localStorage.setItem(this.key, text);
return window.localStorage.setItem(this.key, value);
}
return this.reset();

View File

@ -64,6 +64,11 @@ export default {
type: Function,
required: true,
},
codeQualityExpanded: {
type: Boolean,
required: false,
default: false,
},
},
classNameMap: memoize(
(props) => {
@ -272,6 +277,7 @@ export default {
<component
:is="$options.CodeQualityGutterIcon"
v-if="$options.showCodequalityLeft(props)"
:code-quality-expanded="props.codeQualityExpanded"
:codequality="props.line.left.codequality"
:file-path="props.filePath"
@showCodeQualityFindings="

View File

@ -230,6 +230,7 @@ export default {
:is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine"
:inline="inline"
:index="index"
:code-quality-expanded="codeQualityExpandedLines.includes(getCodeQualityLine(line))"
:is-highlighted="isHighlighted(line)"
:file-line-coverage="fileLineCoverage"
:coverage-loaded="coverageLoaded"

View File

@ -142,6 +142,13 @@ export default class IssuableForm {
this.searchTerm,
this.fallbackKey,
);
IssuableForm.addAutosave(
autosaveMap,
'confidential',
this.form.find('input:checkbox[name*="[confidential]"]'),
this.searchTerm,
this.fallbackKey,
);
IssuableForm.addAutosave(
autosaveMap,
'due_date',

View File

@ -215,6 +215,19 @@ export const newDateAsLocaleTime = (date) => {
return new Date(`${date}${suffix}`);
};
/**
* Takes a Date object (where timezone could be GMT or EST) and
* returns a Date object with the same date but in UTC.
*
* @param {Date} date A Date object
* @returns {Date|null} A Date object with the same date but in UTC
*/
export const getDateWithUTC = (date) => {
return date instanceof Date
? new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
: null;
};
export const beginOfDayTime = 'T00:00:00Z';
export const endOfDayTime = 'T23:59:59Z';

View File

@ -16,12 +16,14 @@ import {
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_LABELS,
WIDGET_TYPE_DESCRIPTION,
WIDGET_TYPE_START_AND_DUE_DATE,
WIDGET_TYPE_WEIGHT,
WIDGET_TYPE_HIERARCHY,
WORK_ITEM_VIEWED_STORAGE_KEY,
} from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql';
import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql';
@ -30,6 +32,7 @@ import WorkItemActions from './work_item_actions.vue';
import WorkItemState from './work_item_state.vue';
import WorkItemTitle from './work_item_title.vue';
import WorkItemDescription from './work_item_description.vue';
import WorkItemDueDate from './work_item_due_date.vue';
import WorkItemAssignees from './work_item_assignees.vue';
import WorkItemLabels from './work_item_labels.vue';
import WorkItemInformation from './work_item_information.vue';
@ -49,6 +52,7 @@ export default {
WorkItemAssignees,
WorkItemActions,
WorkItemDescription,
WorkItemDueDate,
WorkItemLabels,
WorkItemTitle,
WorkItemState,
@ -106,14 +110,27 @@ export default {
document.title = `${this.workItem.title} · ${this.workItem?.workItemType?.name}${path}`;
}
},
subscribeToMore: {
document: workItemTitleSubscription,
variables() {
return {
issuableId: this.workItemId,
};
subscribeToMore: [
{
document: workItemTitleSubscription,
variables() {
return {
issuableId: this.workItemId,
};
},
},
},
{
document: workItemDatesSubscription,
variables() {
return {
issuableId: this.workItemId,
};
},
skip() {
return !this.workItemDueDate;
},
},
],
},
},
computed: {
@ -144,6 +161,11 @@ export default {
workItemLabels() {
return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS);
},
workItemDueDate() {
return this.workItem?.widgets?.find(
(widget) => widget.type === WIDGET_TYPE_START_AND_DUE_DATE,
);
},
workItemWeight() {
return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT);
},
@ -348,6 +370,15 @@ export default {
:full-path="fullPath"
@error="error = $event"
/>
<work-item-due-date
v-if="workItemDueDate"
:can-update="canUpdate"
:due-date="workItemDueDate.dueDate"
:start-date="workItemDueDate.startDate"
:work-item-id="workItem.id"
:work-item-type="workItemType"
@error="error = $event"
/>
</template>
<work-item-weight
v-if="workItemWeight"

View File

@ -0,0 +1,257 @@
<script>
import { GlButton, GlDatepicker, GlFormGroup } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { getDateWithUTC, newDateAsLocaleTime } from '~/lib/utils/datetime/date_calculation_utility';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
import {
I18N_WORK_ITEM_ERROR_UPDATING,
sprintfWorkItem,
TRACKING_CATEGORY_SHOW,
} from '~/work_items/constants';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
const nullObjectDate = new Date(0);
export default {
i18n: {
addDueDate: s__('WorkItem|Add due date'),
addStartDate: s__('WorkItem|Add start date'),
dates: s__('WorkItem|Dates'),
dueDate: s__('WorkItem|Due date'),
none: s__('WorkItem|None'),
startDate: s__('WorkItem|Start date'),
},
dueDateInputId: 'due-date-input',
startDateInputId: 'start-date-input',
components: {
GlButton,
GlDatepicker,
GlFormGroup,
},
mixins: [Tracking.mixin()],
props: {
canUpdate: {
type: Boolean,
required: false,
default: false,
},
dueDate: {
type: String,
required: false,
default: null,
},
startDate: {
type: String,
required: false,
default: null,
},
workItemId: {
type: String,
required: true,
},
workItemType: {
type: String,
required: true,
},
},
data() {
return {
dirtyDueDate: null,
dirtyStartDate: null,
isUpdating: false,
showDueDateInput: false,
showStartDateInput: false,
};
},
computed: {
datesUnchanged() {
const dirtyDueDate = this.dirtyDueDate || nullObjectDate;
const dirtyStartDate = this.dirtyStartDate || nullObjectDate;
const dueDate = this.dueDate ? newDateAsLocaleTime(this.dueDate) : nullObjectDate;
const startDate = this.startDate ? newDateAsLocaleTime(this.startDate) : nullObjectDate;
return (
dirtyDueDate.getTime() === dueDate.getTime() &&
dirtyStartDate.getTime() === startDate.getTime()
);
},
isDatepickerDisabled() {
return !this.canUpdate || this.isUpdating;
},
isReadonlyWithOnlyDueDate() {
return !this.canUpdate && this.dueDate && !this.startDate;
},
isReadonlyWithOnlyStartDate() {
return !this.canUpdate && !this.dueDate && this.startDate;
},
isReadonlyWithNoDates() {
return !this.canUpdate && !this.dueDate && !this.startDate;
},
labelClass() {
return this.isReadonlyWithNoDates ? 'gl-align-self-center gl-pb-0!' : 'gl-mt-3 gl-pb-0!';
},
showDueDateButton() {
return this.canUpdate && !this.showDueDateInput;
},
showStartDateButton() {
return this.canUpdate && !this.showStartDateInput;
},
tracking() {
return {
category: TRACKING_CATEGORY_SHOW,
label: 'item_dates',
property: `type_${this.workItemType}`,
};
},
},
watch: {
dueDate: {
handler(newDueDate) {
this.dirtyDueDate = newDateAsLocaleTime(newDueDate);
this.showDueDateInput = Boolean(newDueDate);
},
immediate: true,
},
startDate: {
handler(newStartDate) {
this.dirtyStartDate = newDateAsLocaleTime(newStartDate);
this.showStartDateInput = Boolean(newStartDate);
},
immediate: true,
},
},
methods: {
clearDueDatePicker() {
this.dirtyDueDate = null;
this.showDueDateInput = false;
this.updateDates();
},
clearStartDatePicker() {
this.dirtyStartDate = null;
this.showStartDateInput = false;
this.updateDates();
},
async clickShowDueDate() {
this.showDueDateInput = true;
await this.$nextTick();
this.$refs.dueDatePicker.calendar.show();
},
async clickShowStartDate() {
this.showStartDateInput = true;
await this.$nextTick();
this.$refs.startDatePicker.calendar.show();
},
handleStartDateInput() {
if (this.dirtyDueDate && this.dirtyStartDate > this.dirtyDueDate) {
this.dirtyDueDate = this.dirtyStartDate;
this.clickShowDueDate();
return;
}
this.updateDates();
},
updateDates() {
if (!this.canUpdate || this.datesUnchanged) {
return;
}
this.track('updated_dates');
this.isUpdating = true;
this.$apollo
.mutate({
mutation: updateWorkItemMutation,
variables: {
input: {
id: this.workItemId,
startAndDueDateWidget: {
dueDate: getDateWithUTC(this.dirtyDueDate),
startDate: getDateWithUTC(this.dirtyStartDate),
},
},
},
})
.then(({ data }) => {
if (data.workItemUpdate.errors.length) {
throw new Error(data.workItemUpdate.errors.join('; '));
}
})
.catch((error) => {
const message = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType);
this.$emit('error', message);
Sentry.captureException(error);
})
.finally(() => {
this.isUpdating = false;
});
},
},
};
</script>
<template>
<gl-form-group
class="work-item-due-date"
:label="$options.i18n.dates"
:label-class="labelClass"
label-cols="3"
label-cols-lg="2"
>
<span v-if="isReadonlyWithNoDates" class="gl-text-gray-400 gl-ml-4">
{{ $options.i18n.none }}
</span>
<div v-else class="gl-display-flex gl-flex-wrap gl-gap-5">
<gl-form-group
class="gl-display-flex gl-align-items-center gl-m-0"
:class="{ 'gl-ml-n3': isReadonlyWithOnlyDueDate }"
:label="$options.i18n.startDate"
:label-for="$options.startDateInputId"
:label-sr-only="!showStartDateInput"
label-class="gl-flex-shrink-0 gl-text-secondary gl-font-weight-normal! gl-pb-0! gl-ml-4 gl-mr-3"
>
<gl-datepicker
v-if="showStartDateInput"
ref="startDatePicker"
v-model="dirtyStartDate"
container="body"
:disabled="isDatepickerDisabled"
:input-id="$options.startDateInputId"
show-clear-button
:target="null"
@clear="clearStartDatePicker"
@close="handleStartDateInput"
/>
<gl-button v-if="showStartDateButton" category="tertiary" @click="clickShowStartDate">
{{ $options.i18n.addStartDate }}
</gl-button>
</gl-form-group>
<gl-form-group
v-if="!isReadonlyWithOnlyStartDate"
class="gl-display-flex gl-align-items-center gl-m-0"
:class="{ 'gl-ml-n3': isReadonlyWithOnlyDueDate }"
:label="$options.i18n.dueDate"
:label-for="$options.dueDateInputId"
:label-sr-only="!showDueDateInput"
label-class="gl-flex-shrink-0 gl-text-secondary gl-font-weight-normal! gl-pb-0! gl-ml-4 gl-mr-3"
>
<gl-datepicker
v-if="showDueDateInput"
ref="dueDatePicker"
v-model="dirtyDueDate"
container="body"
:disabled="isDatepickerDisabled"
:input-id="$options.dueDateInputId"
:min-date="dirtyStartDate"
show-clear-button
:target="null"
@clear="clearDueDatePicker"
@close="updateDates"
/>
<gl-button v-if="showDueDateButton" category="tertiary" @click="clickShowDueDate">
{{ $options.i18n.addDueDate }}
</gl-button>
</gl-form-group>
</div>
</gl-form-group>
</template>

View File

@ -14,6 +14,7 @@ export const TASK_TYPE_NAME = 'Task';
export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES';
export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION';
export const WIDGET_TYPE_LABELS = 'LABELS';
export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE';
export const WIDGET_TYPE_WEIGHT = 'WEIGHT';
export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY';
export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner';

View File

@ -0,0 +1,13 @@
subscription issuableDatesUpdated($issuableId: IssuableID!) {
issuableDatesUpdated(issuableId: $issuableId) {
... on WorkItem {
id
widgets {
... on WorkItemWidgetStartAndDueDate {
dueDate
startDate
}
}
}
}
}

View File

@ -14,6 +14,11 @@ fragment WorkItemWidgets on WorkItemWidget {
}
}
}
... on WorkItemWidgetStartAndDueDate {
type
dueDate
startDate
}
... on WorkItemWidgetHierarchy {
type
parent {

View File

@ -793,11 +793,13 @@ table.code {
}
.diff-comments-more-count,
.diff-notes-collapse {
.diff-notes-collapse,
.diff-codequality-collapse {
@include avatar-counter(50%);
}
.diff-notes-collapse {
.diff-notes-collapse,
.diff-codequality-collapse {
border: 0;
border-radius: 50%;
padding: 0;
@ -982,7 +984,8 @@ table.code {
position: relative;
}
.diff-notes-collapse {
.diff-notes-collapse,
.diff-codequality-collapse {
position: absolute;
left: -12px;
}
@ -1112,6 +1115,7 @@ table.code {
}
.diff-notes-collapse,
.diff-codequality-collapse,
.note,
.discussion-reply-holder {
display: none;

View File

@ -7,7 +7,7 @@
#weight-widget-input:not(:hover, :focus),
#weight-widget-input[readonly] {
box-shadow: inset 0 0 0 $gl-border-size-1 var(--white, $white);
box-shadow: none;
}
#weight-widget-input[readonly] {
@ -26,6 +26,34 @@
}
}
.work-item-due-date {
.gl-datepicker-input.gl-form-input.form-control {
width: 10rem;
&:not(:focus, :hover) {
box-shadow: none;
~ .gl-datepicker-actions {
display: none;
}
}
&[disabled] {
background-color: var(--white, $white);
box-shadow: none;
~ .gl-datepicker-actions {
display: none;
}
}
}
.gl-datepicker-actions:focus,
.gl-datepicker-actions:hover {
display: flex !important;
}
}
.work-item-labels {
.gl-token {
padding-left: $gl-spacing-scale-1;

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Types
module Ci
# rubocop: disable Graphql/AuthorizeTypes
class GroupVariableConnectionType < GraphQL::Types::Relay::BaseConnection
field :limit, GraphQL::Types::Int,
null: false,
description: 'Maximum amount of group CI/CD variables.'
def limit
::Plan.default.actual_limits.group_ci_variables
end
end
# rubocop: enable Graphql/AuthorizeTypes
end
end

View File

@ -7,6 +7,7 @@ module Types
graphql_name 'CiGroupVariable'
description 'CI/CD variables for a group.'
connection_type_class(Types::Ci::GroupVariableConnectionType)
implements(VariableInterface)
field :environment_scope, GraphQL::Types::String,

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Types
module Ci
# rubocop: disable Graphql/AuthorizeTypes
class ProjectVariableConnectionType < GraphQL::Types::Relay::BaseConnection
field :limit, GraphQL::Types::Int,
null: false,
description: 'Maximum amount of project CI/CD variables.'
def limit
::Plan.default.actual_limits.project_ci_variables
end
end
# rubocop: enable Graphql/AuthorizeTypes
end
end

View File

@ -7,6 +7,7 @@ module Types
graphql_name 'CiProjectVariable'
description 'CI/CD variables for a project.'
connection_type_class(Types::Ci::ProjectVariableConnectionType)
implements(VariableInterface)
field :environment_scope, GraphQL::Types::String,

View File

@ -107,16 +107,14 @@ module CounterAttribute
end
def delayed_increment_counter(attribute, increment)
raise ArgumentError, "#{attribute} is not a counter attribute" unless counter_attribute_enabled?(attribute)
return if increment == 0
run_after_commit_or_now do
if counter_attribute_enabled?(attribute)
increment_counter(attribute, increment)
increment_counter(attribute, increment)
FlushCounterIncrementsWorker.perform_in(WORKER_DELAY, self.class.name, self.id, attribute)
else
legacy_increment!(attribute, increment)
end
FlushCounterIncrementsWorker.perform_in(WORKER_DELAY, self.class.name, self.id, attribute)
end
true
@ -172,10 +170,6 @@ module CounterAttribute
end
end
def legacy_increment!(attribute, increment)
increment!(attribute, increment)
end
def unsafe_update_counters(id, increments)
self.class.update_counters(id, increments)
end

View File

@ -10,6 +10,6 @@ module RequestAwareEntity
end
def request
options.fetch(:request)
options.fetch(:request, nil)
end
end

View File

@ -1,8 +0,0 @@
---
name: ci_cost_factors_narrow_os_contribution_by_plan
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/96273
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/372263
milestone: '15.4'
type: development
group: group::pipeline execution
default_enabled: false

View File

@ -6323,6 +6323,7 @@ The connection type for [`CiGroupVariable`](#cigroupvariable).
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="cigroupvariableconnectionedges"></a>`edges` | [`[CiGroupVariableEdge]`](#cigroupvariableedge) | A list of edges. |
| <a id="cigroupvariableconnectionlimit"></a>`limit` | [`Int!`](#int) | Maximum amount of group CI/CD variables. |
| <a id="cigroupvariableconnectionnodes"></a>`nodes` | [`[CiGroupVariable]`](#cigroupvariable) | A list of nodes. |
| <a id="cigroupvariableconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
@ -6498,6 +6499,7 @@ The connection type for [`CiProjectVariable`](#ciprojectvariable).
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="ciprojectvariableconnectionedges"></a>`edges` | [`[CiProjectVariableEdge]`](#ciprojectvariableedge) | A list of edges. |
| <a id="ciprojectvariableconnectionlimit"></a>`limit` | [`Int!`](#int) | Maximum amount of project CI/CD variables. |
| <a id="ciprojectvariableconnectionnodes"></a>`nodes` | [`[CiProjectVariable]`](#ciprojectvariable) | A list of nodes. |
| <a id="ciprojectvariableconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |

View File

@ -1114,6 +1114,7 @@ curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab
> - Immediately deleting subgroups was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/360008) in GitLab 15.3 [with a flag](../administration/feature_flags.md) named `immediate_delete_subgroup_api`. Disabled by default.
> - Immediately deleting subgroups was [enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/368276) in GitLab 15.4.
> - Immediately deleting subgroups was [enabled](https://gitlab.com/gitlab-org/gitlab/-/issues/368276) by default in GitLab 15.4.
Only available to group owners and administrators.

View File

@ -203,7 +203,7 @@ The cost factors for jobs running on shared runners on GitLab.com are:
- `1` for internal and private projects.
- `0.5` for public projects in the [GitLab for Open Source program](../../subscriptions/index.md#gitlab-for-open-source).
- `0.008` for public forks of public projects. For every 125 minutes of job execution time,
- `0.008` for public forks of public projects in the [GitLab for Open Source program](../../subscriptions/index.md#gitlab-for-open-source). For every 125 minutes of job execution time,
you use 1 CI/CD minute.
- `0.04` for other public projects, after September 1, 2022 (previously `0.008`).
For every 25 minutes of job execution time, you use 1 CI/CD minute.

View File

@ -114,6 +114,35 @@ To change the assignee on a task:
1. From the dropdown list, select the user(s) to add as an assignee.
1. Select any area outside the dropdown list.
## Set a start and due date
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/365399) in GitLab 15.4.
You can set a [start and due date](project/issues/due_dates.md) on a task.
Prerequisites:
- You must have at least the Reporter role for the project.
You can set start and due dates on a task to show when work should begin and end.
To set a due date:
1. In the issue description, in the **Tasks** section, select the title of the task you want to edit.
The task window opens.
1. If the task already has a due date next to **Due date**, select it. Otherwise, select **Add due date**.
1. In the date picker, select the desired due date.
To set a start date:
1. In the issue description, in the **Tasks** section, select the title of the task you want to edit.
The task window opens.
1. If the task already has a start date next to **Start date**, select it. Otherwise, select **Add start date**.
1. In the date picker, select the desired due date.
The due date must be the same or later than the start date.
If you select a start date to be later than the due date, the due date is then changed to the same day.
## Set task weight **(PREMIUM)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/362550) in GitLab 15.3.

View File

@ -3,9 +3,13 @@
module API
module Entities
class UserSafe < Grape::Entity
include RequestAwareEntity
expose :id, :username
expose :name do |user|
user.redacted_name(options[:current_user])
current_user = request.respond_to?(:current_user) ? request.current_user : options.fetch(:current_user, nil)
user.redacted_name(current_user)
end
end
end

View File

@ -36,19 +36,12 @@ sast:
bandit-sast:
extends: .sast-analyzer
image:
name: "$SAST_ANALYZER_IMAGE"
variables:
SAST_ANALYZER_IMAGE_TAG: 2
SAST_ANALYZER_IMAGE: "$SECURE_ANALYZERS_PREFIX/bandit:$SAST_ANALYZER_IMAGE_TAG"
script:
- echo "This job was deprecated in GitLab 14.8 and removed in GitLab 15.4"
- echo "For more information see https://gitlab.com/gitlab-org/gitlab/-/issues/352554"
- exit 1
rules:
- if: $SAST_DISABLED
when: never
- if: $SAST_EXCLUDED_ANALYZERS =~ /bandit/
when: never
- if: $CI_COMMIT_BRANCH
exists:
- '**/*.py'
- when: never
brakeman-sast:
extends: .sast-analyzer
@ -69,23 +62,12 @@ brakeman-sast:
eslint-sast:
extends: .sast-analyzer
image:
name: "$SAST_ANALYZER_IMAGE"
variables:
SAST_ANALYZER_IMAGE_TAG: 2
SAST_ANALYZER_IMAGE: "$SECURE_ANALYZERS_PREFIX/eslint:$SAST_ANALYZER_IMAGE_TAG"
script:
- echo "This job was deprecated in GitLab 14.8 and removed in GitLab 15.4"
- echo "For more information see https://gitlab.com/gitlab-org/gitlab/-/issues/352554"
- exit 1
rules:
- if: $SAST_DISABLED
when: never
- if: $SAST_EXCLUDED_ANALYZERS =~ /eslint/
when: never
- if: $CI_COMMIT_BRANCH
exists:
- '**/*.html'
- '**/*.js'
- '**/*.jsx'
- '**/*.ts'
- '**/*.tsx'
- when: never
flawfinder-sast:
extends: .sast-analyzer
@ -125,19 +107,12 @@ kubesec-sast:
gosec-sast:
extends: .sast-analyzer
image:
name: "$SAST_ANALYZER_IMAGE"
variables:
SAST_ANALYZER_IMAGE_TAG: 3
SAST_ANALYZER_IMAGE: "$SECURE_ANALYZERS_PREFIX/gosec:$SAST_ANALYZER_IMAGE_TAG"
script:
- echo "This job was deprecated in GitLab 14.8 and removed in GitLab 15.4"
- echo "For more information see https://gitlab.com/gitlab-org/gitlab/-/issues/352554"
- exit 1
rules:
- if: $SAST_DISABLED
when: never
- if: $SAST_EXCLUDED_ANALYZERS =~ /gosec/
when: never
- if: $CI_COMMIT_BRANCH
exists:
- '**/*.go'
- when: never
.mobsf-sast:
extends: .sast-analyzer
@ -262,6 +237,7 @@ semgrep-sast:
- '**/*.go'
- '**/*.java'
- '**/*.cs'
- '**/*.html'
sobelow-sast:
extends: .sast-analyzer
@ -298,6 +274,5 @@ spotbugs-sast:
- if: $CI_COMMIT_BRANCH
exists:
- '**/*.groovy'
- '**/*.java'
- '**/*.scala'
- '**/*.kt'

View File

@ -45008,6 +45008,12 @@ msgstr ""
msgid "WorkItem|Add assignees"
msgstr ""
msgid "WorkItem|Add due date"
msgstr ""
msgid "WorkItem|Add start date"
msgstr ""
msgid "WorkItem|Add task"
msgstr ""
@ -45040,9 +45046,15 @@ msgstr ""
msgid "WorkItem|Create work item"
msgstr ""
msgid "WorkItem|Dates"
msgstr ""
msgid "WorkItem|Delete %{workItemType}"
msgstr ""
msgid "WorkItem|Due date"
msgstr ""
msgid "WorkItem|Expand tasks"
msgstr ""
@ -45109,6 +45121,9 @@ msgstr ""
msgid "WorkItem|Something went wrong while updating the work item. Please try again."
msgstr ""
msgid "WorkItem|Start date"
msgstr ""
msgid "WorkItem|Task"
msgstr ""

View File

@ -8,6 +8,7 @@ describe('Autosave', () => {
let autosave;
const field = $('<textarea></textarea>');
const checkbox = $('<input type="checkbox">');
const key = 'key';
const fallbackKey = 'fallbackKey';
const lockVersionKey = 'lockVersionKey';
@ -90,6 +91,24 @@ describe('Autosave', () => {
expect(eventHandler).toHaveBeenCalledTimes(1);
fieldElement.removeEventListener('change', eventHandler);
});
describe('if field type is checkbox', () => {
beforeEach(() => {
autosave = {
field: checkbox,
key,
isLocalStorageAvailable: true,
type: 'checkbox',
};
});
it('should restore', () => {
window.localStorage.setItem(key, true);
expect(checkbox.is(':checked')).toBe(false);
Autosave.prototype.restore.call(autosave);
expect(checkbox.is(':checked')).toBe(true);
});
});
});
describe('if field gets deleted from DOM', () => {
@ -169,6 +188,31 @@ describe('Autosave', () => {
expect(window.localStorage.setItem).toHaveBeenCalled();
});
});
describe('if field type is checkbox', () => {
beforeEach(() => {
autosave = {
field: checkbox,
key,
isLocalStorageAvailable: true,
type: 'checkbox',
};
});
it('should save true when checkbox on', () => {
checkbox.prop('checked', true);
Autosave.prototype.save.call(autosave);
expect(window.localStorage.setItem).toHaveBeenCalledWith(key, true);
});
it('should call reset when checkbox off', () => {
autosave.reset = jest.fn();
checkbox.prop('checked', false);
Autosave.prototype.save.call(autosave);
expect(autosave.reset).toHaveBeenCalled();
expect(window.localStorage.setItem).not.toHaveBeenCalled();
});
});
});
describe('save with lockVersion', () => {

View File

@ -85,18 +85,25 @@ describe('IssuableForm', () => {
);
});
it('creates due_date autosave when due_date input exist', () => {
$form.append(`<input type="text" name="issue[due_date]"/>`);
const $dueDate = $form.find(`input[name*="[due_date]"]`);
it.each([
{
id: 'confidential',
input: '<input type="checkbox" name="issue[confidential]"/>',
selector: 'input[name*="[confidential]"]',
},
{
id: 'due_date',
input: '<input type="text" name="issue[due_date]"/>',
selector: 'input[name*="[due_date]"]',
},
])('creates $id autosave when $id input exist', ({ id, input, selector }) => {
$form.append(input);
const $input = $form.find(selector);
const totalAutosaveFormFields = $form.children().length;
createIssuable($form);
expect(Autosave).toHaveBeenCalledTimes(totalAutosaveFormFields);
expect(Autosave).toHaveBeenLastCalledWith(
$dueDate,
['/', '', 'due_date'],
`autosave///=due_date`,
);
expect(Autosave).toHaveBeenLastCalledWith($input, ['/', '', id], `autosave///=${id}`);
});
});
@ -129,12 +136,15 @@ describe('IssuableForm', () => {
expect(resetAutosave).toHaveBeenCalledTimes(1);
});
it('creates due_date autosave when due_date input exist', () => {
$form.append(`<input type="text" name="issue[due_date]"/>`);
it.each([
{ id: 'confidential', input: '<input type="checkbox" name="issue[confidential]"/>' },
{ id: 'due_date', input: '<input type="text" name="issue[due_date]"/>' },
])('calls reset on autosave $id when $id input exist', ({ id, input }) => {
$form.append(input);
instance = createIssuable($form);
instance.resetAutosave();
expect(instance.autosaves.get('due_date').reset).toHaveBeenCalledTimes(1);
expect(instance.autosaves.get(id).reset).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -1,4 +1,4 @@
import { newDateAsLocaleTime } from '~/lib/utils/datetime/date_calculation_utility';
import { getDateWithUTC, newDateAsLocaleTime } from '~/lib/utils/datetime/date_calculation_utility';
describe('newDateAsLocaleTime', () => {
it.each`
@ -15,3 +15,19 @@ describe('newDateAsLocaleTime', () => {
expect(newDateAsLocaleTime(string)).toEqual(expected);
});
});
describe('getDateWithUTC', () => {
it.each`
date | expected
${new Date('2022-03-22T01:23:45.678Z')} | ${new Date('2022-03-22T00:00:00.000Z')}
${new Date('1999-12-31T23:59:59.999Z')} | ${new Date('1999-12-31T00:00:00.000Z')}
${2022} | ${null}
${[]} | ${null}
${{}} | ${null}
${true} | ${null}
${null} | ${null}
${undefined} | ${null}
`('returns $expected given $string', ({ date, expected }) => {
expect(getDateWithUTC(date)).toEqual(expected);
});
});

View File

@ -9,6 +9,7 @@ import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
import WorkItemActions from '~/work_items/components/work_item_actions.vue';
import WorkItemDescription from '~/work_items/components/work_item_description.vue';
import WorkItemDueDate from '~/work_items/components/work_item_due_date.vue';
import WorkItemState from '~/work_items/components/work_item_state.vue';
import WorkItemTitle from '~/work_items/components/work_item_title.vue';
import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue';
@ -16,15 +17,17 @@ import WorkItemLabels from '~/work_items/components/work_item_labels.vue';
import WorkItemInformation from '~/work_items/components/work_item_information.vue';
import { i18n } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemDatesSubscription from '~/work_items/graphql/work_item_dates.subscription.graphql';
import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql';
import { temporaryConfig } from '~/graphql_shared/issuable_client';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import {
workItemTitleSubscriptionResponse,
workItemResponseFactory,
mockParent,
workItemDatesSubscriptionResponse,
workItemResponseFactory,
workItemTitleSubscriptionResponse,
workItemWeightSubscriptionResponse,
} from '../mock_data';
@ -41,7 +44,8 @@ describe('WorkItemDetail component', () => {
canDelete: true,
});
const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
const initialSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse);
const datesSubscriptionHandler = jest.fn().mockResolvedValue(workItemDatesSubscriptionResponse);
const titleSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse);
const weightSubscriptionHandler = jest.fn().mockResolvedValue(workItemWeightSubscriptionResponse);
const findAlert = () => wrapper.findComponent(GlAlert);
@ -51,6 +55,7 @@ describe('WorkItemDetail component', () => {
const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle);
const findWorkItemState = () => wrapper.findComponent(WorkItemState);
const findWorkItemDescription = () => wrapper.findComponent(WorkItemDescription);
const findWorkItemDueDate = () => wrapper.findComponent(WorkItemDueDate);
const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees);
const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels);
const findParent = () => wrapper.find('[data-testid="work-item-parent"]');
@ -65,7 +70,7 @@ describe('WorkItemDetail component', () => {
updateInProgress = false,
workItemId = workItemQueryResponse.data.workItem.id,
handler = successHandler,
subscriptionHandler = initialSubscriptionHandler,
subscriptionHandler = titleSubscriptionHandler,
confidentialityMock = [updateWorkItemMutation, jest.fn()],
workItemsMvc2Enabled = false,
includeWidgets = false,
@ -74,6 +79,7 @@ describe('WorkItemDetail component', () => {
const handlers = [
[workItemQuery, handler],
[workItemTitleSubscription, subscriptionHandler],
[workItemDatesSubscription, datesSubscriptionHandler],
confidentialityMock,
];
@ -399,11 +405,37 @@ describe('WorkItemDetail component', () => {
expect(findAlert().text()).toBe(updateError);
});
it('calls the subscription', () => {
createComponent();
describe('subscriptions', () => {
it('calls the title subscription', () => {
createComponent();
expect(initialSubscriptionHandler).toHaveBeenCalledWith({
issuableId: workItemQueryResponse.data.workItem.id,
expect(titleSubscriptionHandler).toHaveBeenCalledWith({
issuableId: workItemQueryResponse.data.workItem.id,
});
});
describe('dates subscription', () => {
describe('when the due date widget exists', () => {
it('calls the dates subscription', async () => {
createComponent();
await waitForPromises();
expect(datesSubscriptionHandler).toHaveBeenCalledWith({
issuableId: workItemQueryResponse.data.workItem.id,
});
});
});
describe('when the due date widget does not exist', () => {
it('does not call the dates subscription', async () => {
const response = workItemResponseFactory({ datesWidgetPresent: false });
const handler = jest.fn().mockResolvedValue(response);
createComponent({ handler, workItemsMvc2Enabled: true });
await waitForPromises();
expect(datesSubscriptionHandler).not.toHaveBeenCalled();
});
});
});
});
@ -443,6 +475,34 @@ describe('WorkItemDetail component', () => {
});
});
describe('dates widget', () => {
describe.each`
description | datesWidgetPresent | exists
${'when widget is returned from API'} | ${true} | ${true}
${'when widget is not returned from API'} | ${false} | ${false}
`('$description', ({ datesWidgetPresent, exists }) => {
it(`${datesWidgetPresent ? 'renders' : 'does not render'} due date component`, async () => {
const response = workItemResponseFactory({ datesWidgetPresent });
const handler = jest.fn().mockResolvedValue(response);
createComponent({ handler, workItemsMvc2Enabled: true });
await waitForPromises();
expect(findWorkItemDueDate().exists()).toBe(exists);
});
});
it('shows an error message when it emits an `error` event', async () => {
createComponent({ workItemsMvc2Enabled: true });
await waitForPromises();
const updateError = 'Failed to update';
findWorkItemDueDate().vm.$emit('error', updateError);
await waitForPromises();
expect(findAlert().text()).toBe(updateError);
});
});
describe('work item information', () => {
beforeEach(() => {
createComponent();

View File

@ -0,0 +1,346 @@
import { GlFormGroup, GlDatepicker } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockTracking } from 'helpers/tracking_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import WorkItemDueDate from '~/work_items/components/work_item_due_date.vue';
import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import { updateWorkItemMutationResponse, updateWorkItemMutationErrorResponse } from '../mock_data';
describe('WorkItemDueDate component', () => {
let wrapper;
Vue.use(VueApollo);
const workItemId = 'gid://gitlab/WorkItem/1';
const updateWorkItemMutationHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
const findStartDateButton = () =>
wrapper.findByRole('button', { name: WorkItemDueDate.i18n.addStartDate });
const findStartDateInput = () => wrapper.findByLabelText(WorkItemDueDate.i18n.startDate);
const findStartDatePicker = () => wrapper.findComponent(GlDatepicker);
const findDueDateButton = () =>
wrapper.findByRole('button', { name: WorkItemDueDate.i18n.addDueDate });
const findDueDateInput = () => wrapper.findByLabelText(WorkItemDueDate.i18n.dueDate);
const findDueDatePicker = () => wrapper.findAllComponents(GlDatepicker).at(1);
const findGlFormGroup = () => wrapper.findComponent(GlFormGroup);
const createComponent = ({
canUpdate = false,
dueDate = null,
startDate = null,
mutationHandler = updateWorkItemMutationHandler,
} = {}) => {
wrapper = mountExtended(WorkItemDueDate, {
apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]),
propsData: {
canUpdate,
dueDate,
startDate,
workItemId,
workItemType: 'Task',
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when can update', () => {
describe('start date', () => {
describe('`Add start date` button', () => {
describe.each`
description | startDate | exists
${'when there is no start date'} | ${null} | ${true}
${'when there is a start date'} | ${'2022-01-01'} | ${false}
`('$description', ({ startDate, exists }) => {
beforeEach(() => {
createComponent({ canUpdate: true, startDate });
});
it(exists ? 'renders' : 'does not render', () => {
expect(findStartDateButton().exists()).toBe(exists);
});
});
describe('when it emits `click` event', () => {
beforeEach(() => {
createComponent({ canUpdate: true, startDate: null });
findStartDateButton().vm.$emit('click');
});
it('renders start date picker', () => {
expect(findStartDateInput().exists()).toBe(true);
});
it('hides itself', () => {
expect(findStartDateButton().exists()).toBe(false);
});
});
});
describe('date picker', () => {
describe('when it emits a `clear` event', () => {
beforeEach(() => {
createComponent({ canUpdate: true, dueDate: '2022-01-01', startDate: '2022-01-01' });
findStartDatePicker().vm.$emit('clear');
});
it('hides the date picker', () => {
expect(findStartDateInput().exists()).toBe(false);
});
it('shows the `Add start date` button', () => {
expect(findStartDateButton().exists()).toBe(true);
});
it('calls a mutation to update the dates', () => {
expect(updateWorkItemMutationHandler).toHaveBeenCalledWith({
input: {
id: workItemId,
startAndDueDateWidget: {
dueDate: new Date('2022-01-01T00:00:00.000Z'),
startDate: null,
},
},
});
});
});
describe('when it emits a `close` event', () => {
describe('when the start date is earlier than the due date', () => {
const startDate = new Date('2022-01-01T00:00:00.000Z');
beforeEach(() => {
createComponent({ canUpdate: true, dueDate: '2022-12-31', startDate: '2022-12-31' });
findStartDatePicker().vm.$emit('input', startDate);
findStartDatePicker().vm.$emit('close');
});
it('calls a mutation to update the dates', () => {
expect(updateWorkItemMutationHandler).toHaveBeenCalledWith({
input: {
id: workItemId,
startAndDueDateWidget: {
dueDate: new Date('2022-12-31T00:00:00.000Z'),
startDate,
},
},
});
});
});
describe('when the start date is later than the due date', () => {
const startDate = new Date('2030-01-01T00:00:00.000Z');
let datePickerOpenSpy;
beforeEach(() => {
createComponent({ canUpdate: true, dueDate: '2022-12-31', startDate: '2022-12-31' });
datePickerOpenSpy = jest.spyOn(wrapper.vm.$refs.dueDatePicker.calendar, 'show');
findStartDatePicker().vm.$emit('input', startDate);
findStartDatePicker().vm.$emit('close');
});
it('does not call a mutation to update the dates', () => {
expect(updateWorkItemMutationHandler).not.toHaveBeenCalled();
});
it('updates the due date picker to the same date', () => {
expect(findDueDatePicker().props('value')).toEqual(startDate);
});
it('opens the due date picker', () => {
expect(datePickerOpenSpy).toHaveBeenCalled();
});
});
});
});
});
describe('due date', () => {
describe('`Add due date` button', () => {
describe.each`
description | dueDate | exists
${'when there is no due date'} | ${null} | ${true}
${'when there is a due date'} | ${'2022-01-01'} | ${false}
`('$description', ({ dueDate, exists }) => {
beforeEach(() => {
createComponent({ canUpdate: true, dueDate });
});
it(exists ? 'renders' : 'does not render', () => {
expect(findDueDateButton().exists()).toBe(exists);
});
});
describe('when it emits `click` event', () => {
beforeEach(() => {
createComponent({ canUpdate: true, dueDate: null });
findDueDateButton().vm.$emit('click');
});
it('renders due date picker', () => {
expect(findDueDateInput().exists()).toBe(true);
});
it('hides itself', () => {
expect(findDueDateButton().exists()).toBe(false);
});
});
});
describe('date picker', () => {
describe('when it emits a `clear` event', () => {
beforeEach(() => {
createComponent({ canUpdate: true, dueDate: '2022-01-01', startDate: '2022-01-01' });
findDueDatePicker().vm.$emit('clear');
});
it('hides the date picker', () => {
expect(findDueDateInput().exists()).toBe(false);
});
it('shows the `Add due date` button', () => {
expect(findDueDateButton().exists()).toBe(true);
});
it('calls a mutation to update the dates', () => {
expect(updateWorkItemMutationHandler).toHaveBeenCalledWith({
input: {
id: workItemId,
startAndDueDateWidget: {
dueDate: null,
startDate: new Date('2022-01-01T00:00:00.000Z'),
},
},
});
});
});
describe('when it emits a `close` event', () => {
const dueDate = new Date('2022-12-31T00:00:00.000Z');
beforeEach(() => {
createComponent({ canUpdate: true, dueDate: '2022-01-01', startDate: '2022-01-01' });
findDueDatePicker().vm.$emit('input', dueDate);
findDueDatePicker().vm.$emit('close');
});
it('calls a mutation to update the dates', () => {
expect(updateWorkItemMutationHandler).toHaveBeenCalledWith({
input: {
id: workItemId,
startAndDueDateWidget: {
dueDate,
startDate: new Date('2022-01-01T00:00:00.000Z'),
},
},
});
});
});
});
});
describe('when updating date', () => {
describe('when dates are changed', () => {
let trackingSpy;
beforeEach(() => {
createComponent({ canUpdate: true, dueDate: '2022-12-31', startDate: '2022-12-31' });
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
findStartDatePicker().vm.$emit('input', new Date('2022-01-01T00:00:00.000Z'));
findStartDatePicker().vm.$emit('close');
});
it('mutation is called to update dates', () => {
expect(updateWorkItemMutationHandler).toHaveBeenCalledWith({
input: {
id: workItemId,
startAndDueDateWidget: {
dueDate: new Date('2022-12-31T00:00:00.000Z'),
startDate: new Date('2022-01-01T00:00:00.000Z'),
},
},
});
});
it('start date input is disabled', () => {
expect(findStartDatePicker().props('disabled')).toBe(true);
});
it('due date input is disabled', () => {
expect(findDueDatePicker().props('disabled')).toBe(true);
});
it('tracks updating the dates', () => {
expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_dates', {
category: TRACKING_CATEGORY_SHOW,
label: 'item_dates',
property: 'type_Task',
});
});
});
describe('when dates are unchanged', () => {
beforeEach(() => {
createComponent({ canUpdate: true, dueDate: '2022-12-31', startDate: '2022-12-31' });
findStartDatePicker().vm.$emit('input', new Date('2022-12-31T00:00:00.000Z'));
findStartDatePicker().vm.$emit('close');
});
it('mutation is not called to update dates', () => {
expect(updateWorkItemMutationHandler).not.toHaveBeenCalled();
});
});
describe.each`
description | mutationHandler
${'when there is a GraphQL error'} | ${jest.fn().mockResolvedValue(updateWorkItemMutationErrorResponse)}
${'when there is a network error'} | ${jest.fn().mockRejectedValue(new Error())}
`('$description', ({ mutationHandler }) => {
beforeEach(() => {
createComponent({
canUpdate: true,
dueDate: '2022-12-31',
startDate: '2022-12-31',
mutationHandler,
});
findStartDatePicker().vm.$emit('input', new Date('2022-01-01T00:00:00.000Z'));
findStartDatePicker().vm.$emit('close');
return waitForPromises();
});
it('emits an error', () => {
expect(wrapper.emitted('error')).toEqual([
['Something went wrong while updating the task. Please try again.'],
]);
});
});
});
});
describe('when cannot update', () => {
it('start and due date inputs are disabled', async () => {
createComponent({ canUpdate: false, dueDate: '2022-01-01', startDate: '2022-01-01' });
await nextTick();
expect(findStartDateInput().props('disabled')).toBe(true);
expect(findDueDateInput().props('disabled')).toBe(true);
});
describe('when there is no start and due date', () => {
it('shows None', () => {
createComponent({ canUpdate: false, dueDate: null, startDate: null });
expect(findGlFormGroup().text()).toContain(WorkItemDueDate.i18n.none);
});
});
});
});

View File

@ -138,6 +138,16 @@ export const updateWorkItemMutationResponse = {
},
};
export const updateWorkItemMutationErrorResponse = {
data: {
workItemUpdate: {
__typename: 'WorkItemUpdatePayload',
errors: ['Error!'],
workItem: {},
},
},
};
export const mockParent = {
parent: {
id: 'gid://gitlab/Issue/1',
@ -152,6 +162,7 @@ export const workItemResponseFactory = ({
canDelete = false,
allowsMultipleAssignees = true,
assigneesWidgetPresent = true,
datesWidgetPresent = true,
weightWidgetPresent = true,
confidential = false,
canInviteMembers = false,
@ -201,6 +212,14 @@ export const workItemResponseFactory = ({
},
}
: { type: 'MOCK TYPE' },
datesWidgetPresent
? {
__typename: 'WorkItemWidgetStartAndDueDate',
type: 'START_AND_DUE_DATE',
dueDate: '2022-12-31',
startDate: '2022-01-01',
}
: { type: 'MOCK TYPE' },
weightWidgetPresent
? {
__typename: 'WorkItemWidgetWeight',
@ -387,6 +406,21 @@ export const deleteWorkItemFromTaskMutationErrorResponse = {
},
};
export const workItemDatesSubscriptionResponse = {
data: {
issuableDatesUpdated: {
id: 'gid://gitlab/WorkItem/1',
widgets: [
{
__typename: 'WorkItemWidgetStartAndDueDate',
dueDate: '2022-12-31',
startDate: '2022-01-01',
},
],
},
},
};
export const workItemTitleSubscriptionResponse = {
data: {
issuableTitleUpdated: {

View File

@ -4,12 +4,14 @@ import VueApollo from 'vue-apollo';
import workItemWeightSubscription from 'ee_component/work_items/graphql/work_item_weight.subscription.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import {
workItemDatesSubscriptionResponse,
workItemResponseFactory,
workItemTitleSubscriptionResponse,
workItemWeightSubscriptionResponse,
} from 'jest/work_items/mock_data';
import App from '~/work_items/components/app.vue';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemDatesSubscription from '~/work_items/graphql/work_item_dates.subscription.graphql';
import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql';
import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
import WorkItemsRoot from '~/work_items/pages/work_item_root.vue';
@ -21,6 +23,7 @@ describe('Work items router', () => {
Vue.use(VueApollo);
const workItemQueryHandler = jest.fn().mockResolvedValue(workItemResponseFactory());
const datesSubscriptionHandler = jest.fn().mockResolvedValue(workItemDatesSubscriptionResponse);
const titleSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse);
const weightSubscriptionHandler = jest.fn().mockResolvedValue(workItemWeightSubscriptionResponse);
@ -32,6 +35,7 @@ describe('Work items router', () => {
const handlers = [
[workItemQuery, workItemQueryHandler],
[workItemDatesSubscription, datesSubscriptionHandler],
[workItemTitleSubscription, titleSubscriptionHandler],
];

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['CiGroupVariableConnection'] do
it 'has the expected fields' do
expected_fields = %i[limit page_info edges nodes]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['CiProjectVariableConnection'] do
it 'has the expected fields' do
expected_fields = %i[limit page_info edges nodes]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end

View File

@ -13,6 +13,7 @@ RSpec.describe 'Query.group(fullPath).ciVariables' do
query {
group(fullPath: "#{group.full_path}") {
ciVariables {
limit
nodes {
id
key
@ -46,6 +47,7 @@ RSpec.describe 'Query.group(fullPath).ciVariables' do
post_graphql(query, current_user: user)
expect(graphql_data.dig('group', 'ciVariables', 'limit')).to be(200)
expect(graphql_data.dig('group', 'ciVariables', 'nodes')).to contain_exactly({
'id' => variable.to_global_id.to_s,
'key' => 'TEST_VAR',

View File

@ -13,6 +13,7 @@ RSpec.describe 'Query.project(fullPath).ciVariables' do
query {
project(fullPath: "#{project.full_path}") {
ciVariables {
limit
nodes {
id
key
@ -40,6 +41,7 @@ RSpec.describe 'Query.project(fullPath).ciVariables' do
post_graphql(query, current_user: user)
expect(graphql_data.dig('project', 'ciVariables', 'limit')).to be(200)
expect(graphql_data.dig('project', 'ciVariables', 'nodes')).to contain_exactly({
'id' => variable.to_global_id.to_s,
'key' => 'TEST_VAR',

View File

@ -47,6 +47,54 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
end
# rubocop:enable Metrics/ParameterLists
context 'when pipeline has a job with environment' do
let(:pipeline) { execute_service.payload }
before do
stub_ci_pipeline_yaml_file(YAML.dump(config))
end
context 'when environment name is valid' do
let(:config) do
{
review_app: {
script: 'deploy',
environment: {
name: 'review/${CI_COMMIT_REF_NAME}',
url: 'http://${CI_COMMIT_REF_SLUG}-staging.example.com'
}
}
}
end
it 'has a job with environment' do
expect(pipeline.builds.count).to eq(1)
expect(pipeline.builds.first.persisted_environment.name).to eq('review/master')
expect(pipeline.builds.first.deployment).to be_created
end
end
context 'when environment name is invalid' do
let(:config) do
{
'job:deploy-to-test-site': {
script: 'deploy',
environment: {
name: '${CI_JOB_NAME}',
url: 'https://$APP_URL'
}
}
}
end
it 'has a job without environment' do
expect(pipeline.builds.count).to eq(1)
expect(pipeline.builds.first.persisted_environment).to be_nil
expect(pipeline.builds.first.deployment).to be_nil
end
end
end
context 'performance' do
it_behaves_like 'pipelines are created without N+1 SQL queries' do
let(:config1) do
@ -463,7 +511,7 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
it 'pull it from Auto-DevOps' do
pipeline = execute_service.payload
expect(pipeline).to be_auto_devops_source
expect(pipeline.builds.map(&:name)).to match_array(%w[brakeman-sast build code_quality container_scanning eslint-sast secret_detection semgrep-sast test])
expect(pipeline.builds.map(&:name)).to match_array(%w[brakeman-sast build code_quality container_scanning secret_detection semgrep-sast test])
end
end
@ -1268,54 +1316,6 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
end
end
context 'when pipeline has a job with environment' do
let(:pipeline) { execute_service.payload }
before do
stub_ci_pipeline_yaml_file(YAML.dump(config))
end
context 'when environment name is valid' do
let(:config) do
{
review_app: {
script: 'deploy',
environment: {
name: 'review/${CI_COMMIT_REF_NAME}',
url: 'http://${CI_COMMIT_REF_SLUG}-staging.example.com'
}
}
}
end
it 'has a job with environment' do
expect(pipeline.builds.count).to eq(1)
expect(pipeline.builds.first.persisted_environment.name).to eq('review/master')
expect(pipeline.builds.first.deployment).to be_created
end
end
context 'when environment name is invalid' do
let(:config) do
{
'job:deploy-to-test-site': {
script: 'deploy',
environment: {
name: '${CI_JOB_NAME}',
url: 'https://$APP_URL'
}
}
}
end
it 'has a job without environment' do
expect(pipeline.builds.count).to eq(1)
expect(pipeline.builds.first.persisted_environment).to be_nil
expect(pipeline.builds.first.deployment).to be_nil
end
end
end
describe 'Pipeline for external pull requests' do
let(:response) do
execute_service(source: source,

View File

@ -75,9 +75,9 @@ RSpec.shared_examples_for CounterAttribute do |counter_attributes|
end
context 'when attribute is not a counter attribute' do
it 'delegates to ActiveRecord update!' do
it 'raises ArgumentError' do
expect { model.delayed_increment_counter(:unknown_attribute, 10) }
.to raise_error(ActiveModel::MissingAttributeError)
.to raise_error(ArgumentError, 'unknown_attribute is not a counter attribute')
end
end
end