Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-08-17 06:12:03 +00:00
parent 493d6f7512
commit a17e8b1687
19 changed files with 534 additions and 295 deletions

View File

@ -1,4 +1,4 @@
import { s__ } from '~/locale'; import { __, s__ } from '~/locale';
export const timelineTabI18n = Object.freeze({ export const timelineTabI18n = Object.freeze({
title: s__('Incident|Timeline'), title: s__('Incident|Timeline'),
@ -12,6 +12,9 @@ export const timelineFormI18n = Object.freeze({
'Incident|Something went wrong while creating the incident timeline event.', 'Incident|Something went wrong while creating the incident timeline event.',
), ),
areaPlaceholder: s__('Incident|Timeline text...'), areaPlaceholder: s__('Incident|Timeline text...'),
save: __('Save'),
cancel: __('Cancel'),
description: __('Description'),
saveAndAdd: s__('Incident|Save and add another event'), saveAndAdd: s__('Incident|Save and add another event'),
areaLabel: s__('Incident|Timeline text'), areaLabel: s__('Incident|Timeline text'),
}); });

View File

@ -0,0 +1,117 @@
<script>
import { produce } from 'immer';
import { sortBy } from 'lodash';
import { sprintf } from '~/locale';
import { createAlert } from '~/flash';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_ISSUE } from '~/graphql_shared/constants';
import { timelineFormI18n } from './constants';
import TimelineEventsForm from './timeline_events_form.vue';
import CreateTimelineEvent from './graphql/queries/create_timeline_event.mutation.graphql';
import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql';
export default {
name: 'CreateTimelineEvent',
i18n: timelineFormI18n,
components: {
TimelineEventsForm,
},
inject: ['fullPath', 'issuableId'],
props: {
hasTimelineEvents: {
type: Boolean,
required: true,
},
},
data() {
return { createTimelineEventActive: false };
},
methods: {
clearForm() {
this.$refs.eventForm.clear();
},
focusDate() {
this.$refs.eventForm.focusDate();
},
updateCache(store, { data }) {
const { timelineEvent: event, errors } = data?.timelineEventCreate || {};
if (errors.length) {
return;
}
const variables = {
incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
fullPath: this.fullPath,
};
const sourceData = store.readQuery({
query: getTimelineEvents,
variables,
});
const newData = produce(sourceData, (draftData) => {
const { nodes: draftEventList } = draftData.project.incidentManagementTimelineEvents;
draftEventList.push(event);
// ISOStrings sort correctly in lexical order
const sortedEvents = sortBy(draftEventList, 'occurredAt');
draftData.project.incidentManagementTimelineEvents.nodes = sortedEvents;
});
store.writeQuery({
query: getTimelineEvents,
variables,
data: newData,
});
},
createIncidentTimelineEvent(eventDetails, addAnotherEvent = false) {
this.createTimelineEventActive = true;
return this.$apollo
.mutate({
mutation: CreateTimelineEvent,
variables: {
input: {
incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
note: eventDetails.note,
occurredAt: eventDetails.occurredAt,
},
},
update: this.updateCache,
})
.then(({ data = {} }) => {
this.createTimelineEventActive = false;
const errors = data.timelineEventCreate?.errors;
if (errors.length) {
createAlert({
message: sprintf(this.$options.i18n.createError, { error: errors.join('. ') }, false),
});
return;
}
if (addAnotherEvent) {
this.$refs.eventForm.clear();
} else {
this.$emit('hide-new-timeline-events-form');
}
})
.catch((error) => {
createAlert({
message: this.$options.i18n.createErrorGeneric,
captureError: true,
error,
});
});
},
},
};
</script>
<template>
<timeline-events-form
ref="eventForm"
:is-event-processed="createTimelineEventActive"
:has-timeline-events="hasTimelineEvents"
@save-event="createIncidentTimelineEvent"
@cancel="$emit('hide-new-timeline-events-form')"
/>
</template>

View File

@ -1,21 +1,12 @@
<script> <script>
import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlIcon } from '@gitlab/ui'; import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlIcon } from '@gitlab/ui';
import { produce } from 'immer';
import { sortBy } from 'lodash';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_ISSUE } from '~/graphql_shared/constants';
import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { createAlert } from '~/flash';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import { sprintf } from '~/locale';
import { getUtcShiftedDateNow } from './utils';
import { timelineFormI18n } from './constants'; import { timelineFormI18n } from './constants';
import { getUtcShiftedDateNow } from './utils';
import CreateTimelineEvent from './graphql/queries/create_timeline_event.mutation.graphql';
import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql';
export default { export default {
name: 'IncidentTimelineEventForm', name: 'TimelineEventsForm',
restrictedToolBarItems: [ restrictedToolBarItems: [
'quote', 'quote',
'strikethrough', 'strikethrough',
@ -38,112 +29,55 @@ export default {
directives: { directives: {
autofocusonshow, autofocusonshow,
}, },
inject: ['fullPath', 'issuableId'],
props: { props: {
hasTimelineEvents: { hasTimelineEvents: {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
isEventProcessed: {
type: Boolean,
required: true,
},
}, },
data() { data() {
// Create shifted date to force the datepicker to format in UTC // if occurredAt is undefined, returns "now" in UTC
const utcShiftedDate = getUtcShiftedDateNow(); const placeholderDate = getUtcShiftedDateNow();
return { return {
currentDate: utcShiftedDate,
currentHour: utcShiftedDate.getHours(),
currentMinute: utcShiftedDate.getMinutes(),
timelineText: '', timelineText: '',
createTimelineEventActive: false, placeholderDate,
hourPickerInput: placeholderDate.getHours(),
minutePickerInput: placeholderDate.getMinutes(),
datepickerTextInput: null, datepickerTextInput: null,
}; };
}, },
computed: {
occurredAt() {
const [years, months, days] = this.datepickerTextInput.split('-');
const utcDate = new Date(
Date.UTC(years, months - 1, days, this.hourPickerInput, this.minutePickerInput),
);
return utcDate.toISOString();
},
},
methods: { methods: {
clear() { clear() {
const utcShiftedDate = getUtcShiftedDateNow(); const utcShiftedDateNow = getUtcShiftedDateNow();
this.currentDate = utcShiftedDate; this.placeholderDate = utcShiftedDateNow;
this.currentHour = utcShiftedDate.getHours(); this.hourPickerInput = utcShiftedDateNow.getHours();
this.currentMinute = utcShiftedDate.getMinutes(); this.minutePickerInput = utcShiftedDateNow.getMinutes();
}, this.timelineText = '';
hideIncidentTimelineEventForm() {
this.$emit('hide-incident-timeline-event-form');
}, },
focusDate() { focusDate() {
this.$refs.datepicker.$el.focus(); this.$refs.datepicker.$el.focus();
}, },
updateCache(store, { data }) { handleSave(addAnotherEvent) {
const { timelineEvent: event, errors } = data?.timelineEventCreate || {}; const eventDetails = {
note: this.timelineText,
if (errors.length) { occurredAt: this.occurredAt,
return;
}
const variables = {
incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
fullPath: this.fullPath,
}; };
this.$emit('save-event', eventDetails, addAnotherEvent);
const sourceData = store.readQuery({
query: getTimelineEvents,
variables,
});
const newData = produce(sourceData, (draftData) => {
const { nodes: draftEventList } = draftData.project.incidentManagementTimelineEvents;
draftEventList.push(event);
// ISOStrings sort correctly in lexical order
const sortedEvents = sortBy(draftEventList, 'occurredAt');
draftData.project.incidentManagementTimelineEvents.nodes = sortedEvents;
});
store.writeQuery({
query: getTimelineEvents,
variables,
data: newData,
});
},
createIncidentTimelineEvent(addOneEvent) {
this.createTimelineEventActive = true;
return this.$apollo
.mutate({
mutation: CreateTimelineEvent,
variables: {
input: {
incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
note: this.timelineText,
occurredAt: this.createDateString(),
},
},
update: this.updateCache,
})
.then(({ data = {} }) => {
const errors = data.timelineEventCreate?.errors;
if (errors.length) {
createAlert({
message: sprintf(this.$options.i18n.createError, { error: errors.join('. ') }, false),
});
}
})
.catch((error) => {
createAlert({
message: this.$options.i18n.createErrorGeneric,
captureError: true,
error,
});
})
.finally(() => {
this.createTimelineEventActive = false;
this.timelineText = '';
if (addOneEvent) {
this.hideIncidentTimelineEventForm();
}
});
},
createDateString() {
const [years, months, days] = this.datepickerTextInput.split('-');
const utcDate = new Date(
Date.UTC(years, months - 1, days, this.currentHour, this.currentMinute),
);
return utcDate.toISOString();
}, },
}, },
}; };
@ -165,7 +99,7 @@ export default {
class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row datetime-picker" class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row datetime-picker"
> >
<gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5"> <gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5">
<gl-datepicker id="incident-date" #default="{ formattedDate }" v-model="currentDate"> <gl-datepicker id="incident-date" #default="{ formattedDate }" v-model="placeholderDate">
<gl-form-input <gl-form-input
id="incident-date" id="incident-date"
ref="datepicker" ref="datepicker"
@ -184,7 +118,7 @@ export default {
<label label-for="timeline-input-hours" class="sr-only"></label> <label label-for="timeline-input-hours" class="sr-only"></label>
<gl-form-input <gl-form-input
id="timeline-input-hours" id="timeline-input-hours"
v-model="currentHour" v-model="hourPickerInput"
data-testid="input-hours" data-testid="input-hours"
size="xs" size="xs"
type="number" type="number"
@ -194,7 +128,7 @@ export default {
<label label-for="timeline-input-minutes" class="sr-only"></label> <label label-for="timeline-input-minutes" class="sr-only"></label>
<gl-form-input <gl-form-input
id="timeline-input-minutes" id="timeline-input-minutes"
v-model="currentMinute" v-model="minutePickerInput"
class="gl-ml-3" class="gl-ml-3"
data-testid="input-minutes" data-testid="input-minutes"
size="xs" size="xs"
@ -223,9 +157,10 @@ export default {
<textarea <textarea
v-model="timelineText" v-model="timelineText"
class="note-textarea js-gfm-input js-autosize markdown-area" class="note-textarea js-gfm-input js-autosize markdown-area"
data-testid="input-note"
dir="auto" dir="auto"
data-supports-quick-actions="false" data-supports-quick-actions="false"
:aria-label="__('Description')" :aria-label="$options.i18n.description"
:placeholder="$options.i18n.areaPlaceholder" :placeholder="$options.i18n.areaPlaceholder"
> >
</textarea> </textarea>
@ -238,26 +173,22 @@ export default {
variant="confirm" variant="confirm"
category="primary" category="primary"
class="gl-mr-3" class="gl-mr-3"
:loading="createTimelineEventActive" :loading="isEventProcessed"
@click="createIncidentTimelineEvent(true)" @click="handleSave(false)"
> >
{{ __('Save') }} {{ $options.i18n.save }}
</gl-button> </gl-button>
<gl-button <gl-button
variant="confirm" variant="confirm"
category="secondary" category="secondary"
class="gl-mr-3 gl-ml-n2" class="gl-mr-3 gl-ml-n2"
:loading="createTimelineEventActive" :loading="isEventProcessed"
@click="createIncidentTimelineEvent(false)" @click="handleSave(true)"
> >
{{ $options.i18n.saveAndAdd }} {{ $options.i18n.saveAndAdd }}
</gl-button> </gl-button>
<gl-button <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')">
class="gl-ml-n2" {{ $options.i18n.cancel }}
:disabled="createTimelineEventActive"
@click="hideIncidentTimelineEventForm"
>
{{ __('Cancel') }}
</gl-button> </gl-button>
<div class="gl-border-b gl-pt-5"></div> <div class="gl-border-b gl-pt-5"></div>
</gl-form-group> </gl-form-group>

View File

@ -7,7 +7,7 @@ import getTimelineEvents from './graphql/queries/get_timeline_events.query.graph
import { displayAndLogError } from './utils'; import { displayAndLogError } from './utils';
import { timelineTabI18n } from './constants'; import { timelineTabI18n } from './constants';
import IncidentTimelineEventForm from './timeline_events_form.vue'; import CreateTimelineEvent from './create_timeline_event.vue';
import IncidentTimelineEventsList from './timeline_events_list.vue'; import IncidentTimelineEventsList from './timeline_events_list.vue';
export default { export default {
@ -16,7 +16,7 @@ export default {
GlEmptyState, GlEmptyState,
GlLoadingIcon, GlLoadingIcon,
GlTab, GlTab,
IncidentTimelineEventForm, CreateTimelineEvent,
IncidentTimelineEventsList, IncidentTimelineEventsList,
}, },
i18n: timelineTabI18n, i18n: timelineTabI18n,
@ -61,10 +61,10 @@ export default {
this.isEventFormVisible = false; this.isEventFormVisible = false;
}, },
async showEventForm() { async showEventForm() {
this.$refs.eventForm.clear(); this.$refs.createEventForm.clearForm();
this.isEventFormVisible = true; this.isEventFormVisible = true;
await this.$nextTick(); await this.$nextTick();
this.$refs.eventForm.focusDate(); this.$refs.createEventForm.focusDate();
}, },
}, },
}; };
@ -82,14 +82,15 @@ export default {
v-if="hasTimelineEvents" v-if="hasTimelineEvents"
:timeline-event-loading="timelineEventLoading" :timeline-event-loading="timelineEventLoading"
:timeline-events="timelineEvents" :timeline-events="timelineEvents"
@hide-new-timeline-events-form="hideEventForm"
/> />
<incident-timeline-event-form <create-timeline-event
v-show="isEventFormVisible" v-show="isEventFormVisible"
ref="eventForm" ref="createEventForm"
:has-timeline-events="hasTimelineEvents" :has-timeline-events="hasTimelineEvents"
class="timeline-event-note timeline-event-note-form" class="timeline-event-note timeline-event-note-form"
:class="{ 'gl-pl-0': !hasTimelineEvents }" :class="{ 'gl-pl-0': !hasTimelineEvents }"
@hide-incident-timeline-event-form="hideEventForm" @hide-new-timeline-events-form="hideEventForm"
/> />
<gl-button v-if="canUpdate" variant="default" class="gl-mb-3 gl-mt-7" @click="showEventForm"> <gl-button v-if="canUpdate" variant="default" class="gl-mb-3 gl-mt-7" @click="showEventForm">
{{ $options.i18n.addEventButton }} {{ $options.i18n.addEventButton }}

View File

@ -217,6 +217,7 @@ export default {
size="small" size="small"
:icon="toggleIcon" :icon="toggleIcon"
:aria-label="toggleLabel" :aria-label="toggleLabel"
:disabled="!hasRelatedIssues"
data-testid="toggle-links" data-testid="toggle-links"
@click="handleToggle" @click="handleToggle"
/> />

View File

@ -80,7 +80,7 @@ module Database
end end
def max_id def max_id
@max_id ||= source_model.minimum(source_sort_column) @max_id ||= source_model.maximum(source_sort_column)
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord

View File

@ -205,6 +205,7 @@ successfully, you must replicate their data using some other means.
|[External merge request diffs](../../merge_request_diffs.md) | **Yes** (13.5) | **Yes** (14.6) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Replication is behind the feature flag `geo_merge_request_diff_replication`, enabled by default. Verification was behind the feature flag `geo_merge_request_diff_verification`, removed in 14.7.| |[External merge request diffs](../../merge_request_diffs.md) | **Yes** (13.5) | **Yes** (14.6) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Replication is behind the feature flag `geo_merge_request_diff_replication`, enabled by default. Verification was behind the feature flag `geo_merge_request_diff_verification`, removed in 14.7.|
|[Versioned snippets](../../../user/snippets.md#versioned-snippets) | [**Yes** (13.7)](https://gitlab.com/groups/gitlab-org/-/epics/2809) | [**Yes** (14.2)](https://gitlab.com/groups/gitlab-org/-/epics/2810) | N/A | N/A | Verification was implemented behind the feature flag `geo_snippet_repository_verification` in 13.11, and the feature flag was removed in 14.2. | |[Versioned snippets](../../../user/snippets.md#versioned-snippets) | [**Yes** (13.7)](https://gitlab.com/groups/gitlab-org/-/epics/2809) | [**Yes** (14.2)](https://gitlab.com/groups/gitlab-org/-/epics/2810) | N/A | N/A | Verification was implemented behind the feature flag `geo_snippet_repository_verification` in 13.11, and the feature flag was removed in 14.2. |
|[GitLab Pages](../../pages/index.md) | [**Yes** (14.3)](https://gitlab.com/groups/gitlab-org/-/epics/589) | **Yes** (14.6) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Behind feature flag `geo_pages_deployment_replication`, enabled by default. Verification was behind the feature flag `geo_pages_deployment_verification`, removed in 14.7. | |[GitLab Pages](../../pages/index.md) | [**Yes** (14.3)](https://gitlab.com/groups/gitlab-org/-/epics/589) | **Yes** (14.6) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Behind feature flag `geo_pages_deployment_replication`, enabled by default. Verification was behind the feature flag `geo_pages_deployment_verification`, removed in 14.7. |
|[Project-level Secure files](../../../ci/secure_files/index.md) | **Yes** (15.3) | **Yes** (15.3) | **Yes** (15.3) | [No](object_storage.md#verification-of-files-in-object-storage) | |
|[Incident Metric Images](../../../operations/incident_management/incidents.md#metrics) | [Planned](https://gitlab.com/gitlab-org/gitlab/-/issues/362561) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/362561) | No | No | | |[Incident Metric Images](../../../operations/incident_management/incidents.md#metrics) | [Planned](https://gitlab.com/gitlab-org/gitlab/-/issues/362561) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/362561) | No | No | |
|[Alert Metric Images](../../../operations/incident_management/alerts.md#metrics-tab) | [Planned](https://gitlab.com/gitlab-org/gitlab/-/issues/362564) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/362564) | No | No | | |[Alert Metric Images](../../../operations/incident_management/alerts.md#metrics-tab) | [Planned](https://gitlab.com/gitlab-org/gitlab/-/issues/362564) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/362564) | No | No | |
|[Server-side Git hooks](../../server_hooks.md) | [Not planned](https://gitlab.com/groups/gitlab-org/-/epics/1867) | No | N/A | N/A | Not planned because of current implementation complexity, low customer interest, and availability of alternatives to hooks. | |[Server-side Git hooks](../../server_hooks.md) | [Not planned](https://gitlab.com/groups/gitlab-org/-/epics/1867) | No | N/A | N/A | Not planned because of current implementation complexity, low customer interest, and availability of alternatives to hooks. |

View File

@ -656,10 +656,12 @@ On each node perform the following:
# Set the network addresses that the exporters used for monitoring will listen on # Set the network addresses that the exporters used for monitoring will listen on
node_exporter['listen_address'] = '0.0.0.0:9100' node_exporter['listen_address'] = '0.0.0.0:9100'
gitlab_workhorse['prometheus_listen_addr'] = '0.0.0.0:9229' gitlab_workhorse['prometheus_listen_addr'] = '0.0.0.0:9229'
sidekiq['listen_address'] = "0.0.0.0"
# Set number of Sidekiq threads per queue process to the recommend number of 10
sidekiq['max_concurrency'] = 10
puma['listen'] = '0.0.0.0' puma['listen'] = '0.0.0.0'
sidekiq['listen_address'] = "0.0.0.0"
# Configure Sidekiq with 2 workers and 10 max concurrency
sidekiq['max_concurrency'] = 10
sidekiq['queue_groups'] = ['*'] * 2
# Add the monitoring node's IP address to the monitoring whitelist and allow it to # Add the monitoring node's IP address to the monitoring whitelist and allow it to
# scrape the NGINX metrics. Replace placeholder `monitoring.gitlab.example.com` with # scrape the NGINX metrics. Replace placeholder `monitoring.gitlab.example.com` with
@ -1009,8 +1011,8 @@ future with further specific cloud provider details.
| Service | Nodes | Configuration | GCP | AWS | Min Allocatable CPUs and Memory | | Service | Nodes | Configuration | GCP | AWS | Min Allocatable CPUs and Memory |
|-----------------------------------------------|-------|------------------------|-----------------|--------------|---------------------------------| |-----------------------------------------------|-------|------------------------|-----------------|--------------|---------------------------------|
| Webservice | 3 | 8 vCPU, 7.2 GB memory | `n1-highcpu-8` | `c5.2xlarge` | 23.7 vCPU, 16.9 GB memory | | Webservice | 3 | 8 vCPU, 7.2 GB memory | `n1-highcpu-8` | `c5.2xlarge` | 23.7 vCPU, 16.9 GB memory |
| Sidekiq | 2 | 2 vCPU, 7.5 GB memory | `n1-standard-2` | `m5.large` | 3.9 vCPU, 11.8 GB memory | | Sidekiq | 1 | 4 vCPU, 15 GB memory | `n1-standard-4` | `m5.xlarge` | 3.9 vCPU, 11.8 GB memory |
| Supporting services such as NGINX, Prometheus | 2 | 1 vCPU, 3.75 GB memory | `n1-standard-1` | `m5.large` | 1.9 vCPU, 5.5 GB memory | | Supporting services such as NGINX, Prometheus | 2 | 2 vCPU, 7.5 GB memory | `n1-standard-2` | `m5.large` | 1.9 vCPU, 5.5 GB memory |
- For this setup, we **recommend** and regularly [test](index.md#validation-and-test-results) - For this setup, we **recommend** and regularly [test](index.md#validation-and-test-results)
[Google Kubernetes Engine (GKE)](https://cloud.google.com/kubernetes-engine) and [Amazon Elastic Kubernetes Service (EKS)](https://aws.amazon.com/eks/). Other Kubernetes services may also work, but your mileage may vary. [Google Kubernetes Engine (GKE)](https://cloud.google.com/kubernetes-engine) and [Amazon Elastic Kubernetes Service (EKS)](https://aws.amazon.com/eks/). Other Kubernetes services may also work, but your mileage may vary.
@ -1046,8 +1048,8 @@ card "Kubernetes via Helm Charts" as kubernetes {
together { together {
collections "**Webservice** x3" as gitlab #32CD32 collections "**Webservice** x3" as gitlab #32CD32
collections "**Sidekiq** x2" as sidekiq #ff8dd1 card "**Sidekiq**" as sidekiq #ff8dd1
card "**Supporting Services**" as support collections "**Supporting Services** x2" as support
} }
} }
@ -1080,13 +1082,13 @@ documents how to apply the calculated configuration to the Helm Chart.
#### Webservice #### Webservice
Webservice pods typically need about 1 vCPU and 1.25 GB of memory _per worker_. Webservice pods typically need about 1 vCPU and 1.25 GB of memory _per worker_.
Each Webservice pod consumes roughly 2 vCPUs and 2.5 GB of memory using Each Webservice pod consumes roughly 4 vCPUs and 5 GB of memory using
the [recommended topology](#cluster-topology) because two worker processes the [recommended topology](#cluster-topology) because two worker processes
are created by default and each pod has other small processes running. are created by default and each pod has other small processes running.
For 2,000 users we recommend a total Puma worker count of around 12. For 2,000 users we recommend a total Puma worker count of around 12.
With the [provided recommendations](#cluster-topology) this allows the deployment of up to 6 With the [provided recommendations](#cluster-topology) this allows the deployment of up to 3
Webservice pods with 2 workers per pod and 2 pods per node. Expand available resources using Webservice pods with 4 workers per pod and 1 pod per node. Expand available resources using
the ratio of 1 vCPU to 1.25 GB of memory _per each worker process_ for each additional the ratio of 1 vCPU to 1.25 GB of memory _per each worker process_ for each additional
Webservice pod. Webservice pod.
@ -1097,7 +1099,7 @@ For further information on resource usage, see the [Webservice resources](https:
Sidekiq pods should generally have 1 vCPU and 2 GB of memory. Sidekiq pods should generally have 1 vCPU and 2 GB of memory.
[The provided starting point](#cluster-topology) allows the deployment of up to [The provided starting point](#cluster-topology) allows the deployment of up to
2 Sidekiq pods. Expand available resources using the 1 vCPU to 2GB memory 4 Sidekiq pods. Expand available resources using the 1 vCPU to 2 GB memory
ratio for each additional pod. ratio for each additional pod.
For further information on resource usage, see the [Sidekiq resources](https://docs.gitlab.com/charts/charts/gitlab/sidekiq/#resources). For further information on resource usage, see the [Sidekiq resources](https://docs.gitlab.com/charts/charts/gitlab/sidekiq/#resources).

View File

@ -11795,7 +11795,7 @@ Represents epic board list metadata.
| Name | Type | Description | | Name | Type | Description |
| ---- | ---- | ----------- | | ---- | ---- | ----------- |
| <a id="epiclistmetadataepicscount"></a>`epicsCount` | [`Int`](#int) | Count of epics in the list. | | <a id="epiclistmetadataepicscount"></a>`epicsCount` | [`Int`](#int) | Count of epics in the list. |
| <a id="epiclistmetadatatotalweight"></a>`totalWeight` | [`Int`](#int) | Total weight of all issues in the list. Available only when feature flag `epic_board_total_weight` is enabled. This flag is disabled by default, because the feature is experimental and is subject to change without notice. | | <a id="epiclistmetadatatotalweight"></a>`totalWeight` **{warning-solid}** | [`Int`](#int) | **Introduced** in 14.7. This feature is in Alpha. It can be changed or removed at any time. Total weight of all issues in the list. |
### `EpicPermissions` ### `EpicPermissions`

View File

@ -25,8 +25,8 @@ multiple projects.
If you are using a self-managed instance of GitLab: If you are using a self-managed instance of GitLab:
- Your administrator can install and register shared runners by - Your administrator can install and register shared runners by
going to your project's **Settings > CI/CD**, expanding the **Runners** section, going to your project's **Settings > CI/CD**, expanding **Runners**,
and clicking **Show runner installation instructions**. and selecting **Show runner installation instructions**.
These instructions are also available [in the documentation](https://docs.gitlab.com/runner/install/index.html). These instructions are also available [in the documentation](https://docs.gitlab.com/runner/install/index.html).
- The administrator can also configure a maximum number of shared runner - The administrator can also configure a maximum number of shared runner
[CI/CD minutes for each group](../pipelines/cicd_minutes.md#set-the-quota-of-cicd-minutes-for-a-specific-namespace). [CI/CD minutes for each group](../pipelines/cicd_minutes.md#set-the-quota-of-cicd-minutes-for-a-specific-namespace).
@ -51,15 +51,19 @@ For existing projects, an administrator must
To enable shared runners for a project: To enable shared runners for a project:
1. Go to the project's **Settings > CI/CD** and expand the **Runners** section. 1. On the top bar, select **Menu > Projects** and find your project.
1. Select **Enable shared runners for this project**. 1. On the left sidebar, select **Settings > CI/CD**.
1. Expand **Runners**.
1. Turn on the **Enable shared runners for this project** toggle.
### Enable shared runners for a group ### Enable shared runners for a group
To enable shared runners for a group: To enable shared runners for a group:
1. Go to the group's **Settings > CI/CD** and expand the **Runners** section. 1. On the top bar, select **Menu > Groups** and find your group.
1. Select **Enable shared runners for this group**. 1. On the left sidebar, select **Settings > CI/CD**.
1. Expand **Runners**.
1. Turn on the **Enable shared runners for this group** toggle.
### Disable shared runners for a project ### Disable shared runners for a project
@ -69,8 +73,10 @@ or group.
To disable shared runners for a project: To disable shared runners for a project:
1. Go to the project's **Settings > CI/CD** and expand the **Runners** section. 1. On the top bar, select **Menu > Projects** and find your project.
1. In the **Shared runners** area, select **Enable shared runners for this project** so the toggle is grayed-out. 1. On the left sidebar, select **Settings > CI/CD**.
1. Expand **Runners**.
1. In the **Shared runners** area, turn off the **Enable shared runners for this project** toggle.
Shared runners are automatically disabled for a project: Shared runners are automatically disabled for a project:
@ -81,9 +87,11 @@ Shared runners are automatically disabled for a project:
To disable shared runners for a group: To disable shared runners for a group:
1. Go to the group's **Settings > CI/CD** and expand the **Runners** section. 1. On the top bar, select **Menu > Groups** and find your group.
1. In the **Shared runners** area, turn off the **Enable shared runners for this group** toggle. 1. On the left sidebar, select **Settings > CI/CD**.
1. Optionally, to allow shared runners to be enabled for individual projects or subgroups, 1. Expand **Runners**.
1. Turn off the **Enable shared runners for this group** toggle.
1. Optional. To allow shared runners to be enabled for individual projects or subgroups,
select **Allow projects and subgroups to override the group setting**. select **Allow projects and subgroups to override the group setting**.
NOTE: NOTE:
@ -147,7 +155,7 @@ The fair usage algorithm assigns jobs in this order:
## Group runners ## Group runners
Use *Group runners* when you want all projects in a group Use _group runners_ when you want all projects in a group
to have access to a set of runners. to have access to a set of runners.
Group runners process jobs by using a first in, first out ([FIFO](https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics))) queue. Group runners process jobs by using a first in, first out ([FIFO](https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics))) queue.
@ -162,7 +170,7 @@ You must have the Owner role for the group.
To create a group runner: To create a group runner:
1. [Install GitLab Runner](https://docs.gitlab.com/runner/install/). 1. [Install GitLab Runner](https://docs.gitlab.com/runner/install/).
1. Go to the group you want to make the runner work for. 1. On the top bar, select **Menu > Groups** and find your group.
1. On the left sidebar, select **CI/CD > Runners**. 1. On the left sidebar, select **CI/CD > Runners**.
1. Note the URL and token. 1. Note the URL and token.
1. [Register the runner](https://docs.gitlab.com/runner/register/). 1. [Register the runner](https://docs.gitlab.com/runner/register/).
@ -175,21 +183,8 @@ You can view and manage all runners for a group, its subgroups, and projects.
You can do this for your self-managed GitLab instance or for GitLab.com. You can do this for your self-managed GitLab instance or for GitLab.com.
You must have the Owner role for the group. You must have the Owner role for the group.
1. Go to the group where you want to view the runners. 1. On the top bar, select **Menu > Groups** and find your group.
1. On the left sidebar, select **CI/CD > Runners**. 1. On the left sidebar, select **CI/CD > Runners**.
1. The following fields are displayed.
| Attribute | Description |
| ------------ | ----------- |
| Type | Displays the runner type: `group` or `specific`, and the optional state `paused` |
| Runner token | Token used to identify the runner, and that the runner uses to communicate with the GitLab instance |
| Description | Description given to the runner when it was created |
| Version | GitLab Runner version |
| IP address | IP address of the host on which the runner is registered |
| Projects | The count of projects to which the runner is assigned |
| Jobs | Total of jobs run by the runner |
| Tags | Tags associated with the runner |
| Last contact | Timestamp indicating when the GitLab instance last contacted the runner |
From this page, you can edit, pause, and remove runners from the group, its subgroups, and projects. From this page, you can edit, pause, and remove runners from the group, its subgroups, and projects.
@ -198,7 +193,7 @@ From this page, you can edit, pause, and remove runners from the group, its subg
You can pause or remove a group runner for your self-managed GitLab instance or for GitLab.com. You can pause or remove a group runner for your self-managed GitLab instance or for GitLab.com.
You must have the Owner role for the group. You must have the Owner role for the group.
1. Go to the group you want to remove or pause the runner for. 1. On the top bar, select **Menu > Groups** and find your group.
1. On the left sidebar, select **CI/CD > Runners**. 1. On the left sidebar, select **CI/CD > Runners**.
1. Select **Pause** or **Remove runner**. 1. Select **Pause** or **Remove runner**.
- If you pause a group runner that is used by multiple projects, the runner pauses for all projects. - If you pause a group runner that is used by multiple projects, the runner pauses for all projects.
@ -208,7 +203,7 @@ You must have the Owner role for the group.
## Specific runners ## Specific runners
Use *Specific runners* when you want to use runners for specific projects. For example, Use _specific runners_ when you want to use runners for specific projects. For example,
when you have: when you have:
- Jobs with specific requirements, like a deploy job that requires credentials. - Jobs with specific requirements, like a deploy job that requires credentials.
@ -257,9 +252,8 @@ To enable a specific runner for a project:
1. On the top bar, select **Menu > Projects** and find the project where you want to enable the runner. 1. On the top bar, select **Menu > Projects** and find the project where you want to enable the runner.
1. On the left sidebar, select **Settings > CI/CD**. 1. On the left sidebar, select **Settings > CI/CD**.
1. Expand **General pipelines**.
1. Expand **Runners**. 1. Expand **Runners**.
1. By the runner you want, select **Enable for this project**. 1. In the **Specific runners** area, by the runner you want, select **Enable for this project**.
You can edit a specific runner from any of the projects it's enabled for. You can edit a specific runner from any of the projects it's enabled for.
The modifications, which include unlocking and editing tags and the description, The modifications, which include unlocking and editing tags and the description,
@ -275,9 +269,10 @@ but can also be changed later.
To lock or unlock a specific runner: To lock or unlock a specific runner:
1. Go to the project's **Settings > CI/CD**. 1. On the top bar, select **Menu > Projects** and find the project where you want to enable the runner.
1. Expand the **Runners** section. 1. On the left sidebar, select **Settings > CI/CD**.
1. Expand **Runners**.
1. Find the specific runner you want to lock or unlock. Make sure it's enabled. You cannot lock shared or group runners. 1. Find the specific runner you want to lock or unlock. Make sure it's enabled. You cannot lock shared or group runners.
1. Select **Edit** (**{pencil}**). 1. Select **Edit** (**{pencil}**).
1. Check the **Lock to current projects** option. 1. Select the **Lock to current projects** checkbox.
1. Select **Save changes**. 1. Select **Save changes**.

View File

@ -239,6 +239,13 @@ Use **CI/CD minutes** instead of **CI minutes**, **pipeline minutes**, **pipelin
Do not use **click**. Instead, use **select** with buttons, links, menu items, and lists. Do not use **click**. Instead, use **select** with buttons, links, menu items, and lists.
**Select** applies to more devices, while **click** is more specific to a mouse. **Select** applies to more devices, while **click** is more specific to a mouse.
## cloud native
When you're talking about using a Kubernetes cluster to host GitLab, you're talking about a **cloud-native version of GitLab**.
This version is different than the larger, more monolithic **Omnibus package** that is used to deploy GitLab.
You can also use **cloud-native GitLab** for short. It should be hyphenated and lowercase.
## collapse ## collapse
Use **collapse** instead of **close** when you are talking about expanding or collapsing a section in the UI. Use **collapse** instead of **close** when you are talking about expanding or collapsing a section in the UI.
@ -434,6 +441,17 @@ Do not make **GitLab** possessive (GitLab's). This guidance follows [GitLab Trad
**GitLab.com** refers to the GitLab instance managed by GitLab itself. **GitLab.com** refers to the GitLab instance managed by GitLab itself.
## GitLab Helm chart, GitLab chart
To deploy a cloud-native version of GitLab, use:
- The GitLab Helm chart (long version)
- The GitLab chart (short version)
Do not use **the `gitlab` chart**, **the GitLab Chart**, or **the cloud-native chart**.
You use the **GitLab Helm chart** to deploy **cloud-native GitLab** in a Kubernetes cluster.
## GitLab Flavored Markdown ## GitLab Flavored Markdown
When possible, spell out [**GitLab Flavored Markdown**](../../../user/markdown.md). When possible, spell out [**GitLab Flavored Markdown**](../../../user/markdown.md).

View File

@ -20,8 +20,7 @@ describe('RelatedIssuesBlock', () => {
const findToggleButton = () => wrapper.findByTestId('toggle-links'); const findToggleButton = () => wrapper.findByTestId('toggle-links');
const findRelatedIssuesBody = () => wrapper.findByTestId('related-issues-body'); const findRelatedIssuesBody = () => wrapper.findByTestId('related-issues-body');
const findIssueCountBadgeAddButton = () => const findIssueCountBadgeAddButton = () => wrapper.findByTestId('related-issues-plus-button');
wrapper.find('[data-testid="related-issues-plus-button"]');
afterEach(() => { afterEach(() => {
if (wrapper) { if (wrapper) {
@ -240,6 +239,7 @@ describe('RelatedIssuesBlock', () => {
wrapper = shallowMountExtended(RelatedIssuesBlock, { wrapper = shallowMountExtended(RelatedIssuesBlock, {
propsData: { propsData: {
pathIdSeparator: PathIdSeparator.Issue, pathIdSeparator: PathIdSeparator.Issue,
relatedIssues: [issuable1, issuable2, issuable3],
issuableType: issuableTypesMap.ISSUE, issuableType: issuableTypesMap.ISSUE,
}, },
}); });
@ -247,6 +247,7 @@ describe('RelatedIssuesBlock', () => {
it('is expanded by default', () => { it('is expanded by default', () => {
expect(findToggleButton().props('icon')).toBe('chevron-lg-up'); expect(findToggleButton().props('icon')).toBe('chevron-lg-up');
expect(findToggleButton().props('disabled')).toBe(false);
expect(findRelatedIssuesBody().exists()).toBe(true); expect(findRelatedIssuesBody().exists()).toBe(true);
}); });
@ -258,4 +259,16 @@ describe('RelatedIssuesBlock', () => {
expect(findRelatedIssuesBody().exists()).toBe(false); expect(findRelatedIssuesBody().exists()).toBe(false);
}); });
}); });
it('toggle button is disabled when issue has no related items', () => {
wrapper = shallowMountExtended(RelatedIssuesBlock, {
propsData: {
pathIdSeparator: PathIdSeparator.Issue,
relatedIssues: [],
issuableType: 'issue',
},
});
expect(findToggleButton().props('disabled')).toBe(true);
});
}); });

View File

@ -0,0 +1,189 @@
import VueApollo from 'vue-apollo';
import Vue from 'vue';
import { GlDatepicker } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import CreateTimelineEvent from '~/issues/show/components/incidents/create_timeline_event.vue';
import TimelineEventsForm from '~/issues/show/components/incidents/timeline_events_form.vue';
import createTimelineEventMutation from '~/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql';
import getTimelineEvents from '~/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createAlert } from '~/flash';
import { useFakeDate } from 'helpers/fake_date';
import {
timelineEventsCreateEventResponse,
timelineEventsCreateEventError,
mockGetTimelineData,
} from './mock_data';
Vue.use(VueApollo);
jest.mock('~/flash');
const fakeDate = '2020-07-08T00:00:00.000Z';
const mockInputData = {
incidentId: 'gid://gitlab/Issue/1',
note: 'test',
occurredAt: '2020-07-08T00:00:00.000Z',
};
describe('Create Timeline events', () => {
useFakeDate(fakeDate);
let wrapper;
let responseSpy;
let apolloProvider;
const findSubmitButton = () => wrapper.findByText(__('Save'));
const findSubmitAndAddButton = () =>
wrapper.findByText(s__('Incident|Save and add another event'));
const findCancelButton = () => wrapper.findByText(__('Cancel'));
const findDatePicker = () => wrapper.findComponent(GlDatepicker);
const findNoteInput = () => wrapper.findByTestId('input-note');
const setNoteInput = () => {
const textarea = findNoteInput().element;
textarea.value = mockInputData.note;
textarea.dispatchEvent(new Event('input'));
};
const findHourInput = () => wrapper.findByTestId('input-hours');
const findMinuteInput = () => wrapper.findByTestId('input-minutes');
const setDatetime = () => {
const inputDate = new Date(mockInputData.occurredAt);
findDatePicker().vm.$emit('input', inputDate);
findHourInput().vm.$emit('input', inputDate.getHours());
findMinuteInput().vm.$emit('input', inputDate.getMinutes());
};
const fillForm = () => {
setDatetime();
setNoteInput();
};
function createMockApolloProvider() {
const requestHandlers = [[createTimelineEventMutation, responseSpy]];
const mockApollo = createMockApollo(requestHandlers);
mockApollo.clients.defaultClient.cache.writeQuery({
query: getTimelineEvents,
data: mockGetTimelineData,
variables: {
fullPath: 'group/project',
incidentId: 'gid://gitlab/Issue/1',
},
});
return mockApollo;
}
const mountComponent = () => {
wrapper = mountExtended(CreateTimelineEvent, {
propsData: {
hasTimelineEvents: true,
},
provide: {
fullPath: 'group/project',
issuableId: '1',
},
apolloProvider,
});
};
beforeEach(() => {
responseSpy = jest.fn().mockResolvedValue(timelineEventsCreateEventResponse);
apolloProvider = createMockApolloProvider();
});
afterEach(() => {
createAlert.mockReset();
wrapper.destroy();
});
describe('createIncidentTimelineEvent', () => {
const closeFormEvent = { 'hide-new-timeline-events-form': [[]] };
const expectedData = {
input: mockInputData,
};
beforeEach(() => {
mountComponent();
fillForm();
});
describe('on submit', () => {
beforeEach(async () => {
findSubmitButton().trigger('click');
await waitForPromises();
});
it('should call the mutation with the right variables', () => {
expect(responseSpy).toHaveBeenCalledWith(expectedData);
});
it('should close the form on successful addition', () => {
expect(wrapper.emitted()).toEqual(closeFormEvent);
});
});
describe('on submit and add', () => {
beforeEach(async () => {
findSubmitAndAddButton().trigger('click');
await waitForPromises();
});
it('should keep the form open for save and add another', () => {
expect(wrapper.emitted()).toEqual({});
});
});
describe('on cancel', () => {
beforeEach(async () => {
findCancelButton().trigger('click');
await waitForPromises();
});
it('should close the form', () => {
expect(wrapper.emitted()).toEqual(closeFormEvent);
});
});
});
describe('error handling', () => {
it('should show an error when submission returns an error', async () => {
const expectedAlertArgs = {
message: `Error creating incident timeline event: ${timelineEventsCreateEventError.data.timelineEventCreate.errors[0]}`,
};
responseSpy.mockResolvedValueOnce(timelineEventsCreateEventError);
mountComponent();
findSubmitButton().trigger('click');
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith(expectedAlertArgs);
});
it('should show an error when submission fails', async () => {
const expectedAlertArgs = {
captureError: true,
error: new Error(),
message: 'Something went wrong while creating the incident timeline event.',
};
responseSpy.mockRejectedValueOnce();
mountComponent();
findSubmitButton().trigger('click');
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith(expectedAlertArgs);
});
it('should keep the form open on failed addition', async () => {
responseSpy.mockResolvedValueOnce(timelineEventsCreateEventError);
mountComponent();
await wrapper.findComponent(TimelineEventsForm).vm.$emit('save-event', mockInputData);
await waitForPromises;
expect(wrapper.emitted()).toEqual({});
});
});
});

View File

@ -72,10 +72,14 @@ export const timelineEventsQueryEmptyResponse = {
}; };
export const timelineEventsCreateEventResponse = { export const timelineEventsCreateEventResponse = {
timelineEvent: { data: {
...mockEvents[0], timelineEventCreate: {
timelineEvent: {
...mockEvents[0],
},
errors: [],
},
}, },
errors: [],
}; };
export const timelineEventsCreateEventError = { export const timelineEventsCreateEventError = {
@ -103,3 +107,21 @@ const timelineEventDeleteData = (errors = []) => {
export const timelineEventsDeleteEventResponse = timelineEventDeleteData(); export const timelineEventsDeleteEventResponse = timelineEventDeleteData();
export const timelineEventsDeleteEventError = timelineEventDeleteData(['Item does not exist']); export const timelineEventsDeleteEventError = timelineEventDeleteData(['Item does not exist']);
export const mockGetTimelineData = {
project: {
id: 'gid://gitlab/Project/19',
incidentManagementTimelineEvents: {
nodes: [
{
id: 'gid://gitlab/IncidentManagement::TimelineEvent/8',
note: 'another one2',
noteHtml: '<p>another one2</p>',
action: 'comment',
occurredAt: '2022-07-01T12:47:00Z',
createdAt: '2022-07-20T12:47:40Z',
},
],
},
},
};

View File

@ -3,49 +3,33 @@ import Vue, { nextTick } from 'vue';
import { GlDatepicker } from '@gitlab/ui'; import { GlDatepicker } from '@gitlab/ui';
import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import IncidentTimelineEventForm from '~/issues/show/components/incidents/timeline_events_form.vue'; import TimelineEventsForm from '~/issues/show/components/incidents/timeline_events_form.vue';
import createTimelineEventMutation from '~/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createAlert } from '~/flash'; import { createAlert } from '~/flash';
import { useFakeDate } from 'helpers/fake_date'; import { useFakeDate } from 'helpers/fake_date';
import { timelineEventsCreateEventResponse, timelineEventsCreateEventError } from './mock_data';
Vue.use(VueApollo); Vue.use(VueApollo);
jest.mock('~/flash'); jest.mock('~/flash');
const addEventResponse = jest.fn().mockResolvedValue(timelineEventsCreateEventResponse); const fakeDate = '2020-07-08T00:00:00.000Z';
function createMockApolloProvider(response = addEventResponse) {
const requestHandlers = [[createTimelineEventMutation, response]];
return createMockApollo(requestHandlers);
}
describe('Timeline events form', () => { describe('Timeline events form', () => {
// July 8 2020 // July 8 2020
useFakeDate(2020, 6, 8); useFakeDate(fakeDate);
let wrapper; let wrapper;
const mountComponent = ({ mockApollo, mountMethod = shallowMountExtended, stubs }) => { const mountComponent = ({ mountMethod = shallowMountExtended }) => {
wrapper = mountMethod(IncidentTimelineEventForm, { wrapper = mountMethod(TimelineEventsForm, {
propsData: { propsData: {
hasTimelineEvents: true, hasTimelineEvents: true,
isEventProcessed: false,
}, },
provide: {
fullPath: 'group/project',
issuableId: '1',
},
apolloProvider: mockApollo,
stubs,
}); });
}; };
afterEach(() => { afterEach(() => {
addEventResponse.mockReset();
createAlert.mockReset(); createAlert.mockReset();
if (wrapper) { wrapper.destroy();
wrapper.destroy();
}
}); });
const findSubmitButton = () => wrapper.findByText('Save'); const findSubmitButton = () => wrapper.findByText('Save');
@ -75,24 +59,28 @@ describe('Timeline events form', () => {
}; };
describe('form button behaviour', () => { describe('form button behaviour', () => {
const closeFormEvent = { 'hide-incident-timeline-event-form': [[]] };
beforeEach(() => { beforeEach(() => {
mountComponent({ mockApollo: createMockApolloProvider(), mountMethod: mountExtended }); mountComponent({ mountMethod: mountExtended });
}); });
it('should close the form on submit', async () => { it('should save event on submit', async () => {
await submitForm(); await submitForm();
expect(wrapper.emitted()).toEqual(closeFormEvent);
expect(wrapper.emitted()).toEqual({
'save-event': [[{ note: '', occurredAt: fakeDate }, false]],
});
}); });
it('should not close the form on "submit and add another"', async () => { it('should save event on "submit and add another"', async () => {
await submitFormAndAddAnother(); await submitFormAndAddAnother();
expect(wrapper.emitted()).toEqual({}); expect(wrapper.emitted()).toEqual({
'save-event': [[{ note: '', occurredAt: fakeDate }, true]],
});
}); });
it('should close the form on cancel', async () => { it('should emit cancel on cancel', async () => {
await cancelForm(); await cancelForm();
expect(wrapper.emitted()).toEqual(closeFormEvent); expect(wrapper.emitted()).toEqual({ cancel: [[]] });
}); });
it('should clear the form', async () => { it('should clear the form', async () => {
@ -111,71 +99,4 @@ describe('Timeline events form', () => {
expect(findMinuteInput().element.value).toBe('0'); expect(findMinuteInput().element.value).toBe('0');
}); });
}); });
describe('addTimelineEventQuery', () => {
const expectedData = {
input: {
incidentId: 'gid://gitlab/Issue/1',
note: '',
occurredAt: '2020-07-08T00:00:00.000Z',
},
};
let mockApollo;
beforeEach(() => {
mockApollo = createMockApolloProvider();
mountComponent({ mockApollo, mountMethod: mountExtended });
});
it('should call the mutation with the right variables', async () => {
await submitForm();
expect(addEventResponse).toHaveBeenCalledWith(expectedData);
});
it('should call the mutation with user selected variables', async () => {
const expectedUserSelectedData = {
input: {
...expectedData.input,
occurredAt: '2021-08-12T05:45:00.000Z',
},
};
setDatetime();
await nextTick();
await submitForm();
expect(addEventResponse).toHaveBeenCalledWith(expectedUserSelectedData);
});
});
describe('error handling', () => {
it('should show an error when submission returns an error', async () => {
const expectedAlertArgs = {
message: 'Error creating incident timeline event: Create error',
};
addEventResponse.mockResolvedValueOnce(timelineEventsCreateEventError);
mountComponent({ mockApollo: createMockApolloProvider(), mountMethod: mountExtended });
await submitForm();
expect(createAlert).toHaveBeenCalledWith(expectedAlertArgs);
});
it('should show an error when submission fails', async () => {
const expectedAlertArgs = {
captureError: true,
error: new Error(),
message: 'Something went wrong while creating the incident timeline event.',
};
addEventResponse.mockRejectedValueOnce();
mountComponent({ mockApollo: createMockApolloProvider(), mountMethod: mountExtended });
await submitForm();
expect(createAlert).toHaveBeenCalledWith(expectedAlertArgs);
});
});
}); });

View File

@ -5,7 +5,7 @@ import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_help
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import TimelineEventsTab from '~/issues/show/components/incidents/timeline_events_tab.vue'; import TimelineEventsTab from '~/issues/show/components/incidents/timeline_events_tab.vue';
import IncidentTimelineEventsList from '~/issues/show/components/incidents/timeline_events_list.vue'; import IncidentTimelineEventsList from '~/issues/show/components/incidents/timeline_events_list.vue';
import IncidentTimelineEventForm from '~/issues/show/components/incidents/timeline_events_form.vue'; import CreateTimelineEvent from '~/issues/show/components/incidents/create_timeline_event.vue';
import timelineEventsQuery from '~/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql'; import timelineEventsQuery from '~/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import { createAlert } from '~/flash'; import { createAlert } from '~/flash';
@ -53,7 +53,7 @@ describe('TimelineEventsTab', () => {
const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon);
const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findTimelineEventsList = () => wrapper.findComponent(IncidentTimelineEventsList); const findTimelineEventsList = () => wrapper.findComponent(IncidentTimelineEventsList);
const findTimelineEventForm = () => wrapper.findComponent(IncidentTimelineEventForm); const findCreateTimelineEvent = () => wrapper.findComponent(CreateTimelineEvent);
const findAddEventButton = () => wrapper.findByText(timelineTabI18n.addEventButton); const findAddEventButton = () => wrapper.findByText(timelineTabI18n.addEventButton);
describe('Timeline events tab', () => { describe('Timeline events tab', () => {
@ -143,18 +143,18 @@ describe('TimelineEventsTab', () => {
}); });
it('should not show a form by default', () => { it('should not show a form by default', () => {
expect(findTimelineEventForm().isVisible()).toBe(false); expect(findCreateTimelineEvent().isVisible()).toBe(false);
}); });
it('should show a form when button is clicked', async () => { it('should show a form when button is clicked', async () => {
await findAddEventButton().trigger('click'); await findAddEventButton().trigger('click');
expect(findTimelineEventForm().isVisible()).toBe(true); expect(findCreateTimelineEvent().isVisible()).toBe(true);
}); });
it('should clear the form when button is clicked', async () => { it('should clear the form when button is clicked', async () => {
const mockClear = jest.fn(); const mockClear = jest.fn();
wrapper.vm.$refs.eventForm.clear = mockClear; wrapper.vm.$refs.createEventForm.clearForm = mockClear;
await findAddEventButton().trigger('click'); await findAddEventButton().trigger('click');
@ -165,9 +165,9 @@ describe('TimelineEventsTab', () => {
// open the form // open the form
await findAddEventButton().trigger('click'); await findAddEventButton().trigger('click');
await findTimelineEventForm().vm.$emit('hide-incident-timeline-event-form'); await findCreateTimelineEvent().vm.$emit('hide-new-timeline-events-form');
expect(findTimelineEventForm().isVisible()).toBe(false); expect(findCreateTimelineEvent().isVisible()).toBe(false);
}); });
}); });
}); });

View File

@ -24,9 +24,27 @@ RSpec.describe Database::ConsistencyCheckService do
) )
end end
describe '#random_start_id' do describe '#min_id' do
let(:batch_size) { 5 } before do
create_list(:namespace, 3)
end
it 'returns the id of the first record in the database' do
expect(subject.send(:min_id)).to eq(Namespace.first.id)
end
end
describe '#max_id' do
before do
create_list(:namespace, 3)
end
it 'returns the id of the first record in the database' do
expect(subject.send(:max_id)).to eq(Namespace.last.id)
end
end
describe '#random_start_id' do
before do before do
create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects
end end
@ -58,12 +76,11 @@ RSpec.describe Database::ConsistencyCheckService do
end end
context 'no cursor has been saved before' do context 'no cursor has been saved before' do
let(:selected_start_id) { Namespace.order(:id).limit(5).pluck(:id).last } let(:min_id) { Namespace.first.id }
let(:expected_next_start_id) { selected_start_id + batch_size * max_batches } let(:max_id) { Namespace.last.id }
before do before do
create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects
expect(consistency_check_service).to receive(:random_start_id).and_return(selected_start_id)
end end
it 'picks a random start_id' do it 'picks a random start_id' do
@ -72,17 +89,21 @@ RSpec.describe Database::ConsistencyCheckService do
matches: 10, matches: 10,
mismatches: 0, mismatches: 0,
mismatches_details: [], mismatches_details: [],
start_id: selected_start_id, start_id: be_between(min_id, max_id),
next_start_id: expected_next_start_id next_start_id: be_between(min_id, max_id)
} }
expect(consistency_check_service.execute).to eq(expected_result) expect(consistency_check_service).to receive(:rand).with(min_id..max_id).and_call_original
result = consistency_check_service.execute
expect(result).to match(expected_result)
end end
it 'calls the ConsistencyCheckService with the expected parameters' do it 'calls the ConsistencyCheckService with the expected parameters' do
expect(consistency_check_service).to receive(:random_start_id).and_return(min_id)
allow_next_instance_of(Gitlab::Database::ConsistencyChecker) do |instance| allow_next_instance_of(Gitlab::Database::ConsistencyChecker) do |instance|
expect(instance).to receive(:execute).with(start_id: selected_start_id).and_return({ expect(instance).to receive(:execute).with(start_id: min_id).and_return({
batches: 2, batches: 2,
next_start_id: expected_next_start_id, next_start_id: min_id + batch_size,
matches: 10, matches: 10,
mismatches: 0, mismatches: 0,
mismatches_details: [] mismatches_details: []
@ -98,17 +119,19 @@ RSpec.describe Database::ConsistencyCheckService do
expected_result = { expected_result = {
batches: 2, batches: 2,
start_id: selected_start_id,
next_start_id: expected_next_start_id,
matches: 10, matches: 10,
mismatches: 0, mismatches: 0,
mismatches_details: [] mismatches_details: [],
start_id: be_between(min_id, max_id),
next_start_id: be_between(min_id, max_id)
} }
expect(consistency_check_service.execute).to eq(expected_result) result = consistency_check_service.execute
expect(result).to match(expected_result)
end end
it 'saves the next_start_id in Redis for he next iteration' do it 'saves the next_start_id in Redis for he next iteration' do
expect(consistency_check_service).to receive(:save_next_start_id).with(expected_next_start_id).and_call_original expect(consistency_check_service).to receive(:save_next_start_id)
.with(be_between(min_id, max_id)).and_call_original
consistency_check_service.execute consistency_check_service.execute
end end
end end

View File

@ -6,7 +6,7 @@ RSpec.describe Git::BranchPushService, services: true do
include RepoHelpers include RepoHelpers
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:project, reload: true) { create(:project, :repository) } let_it_be_with_refind(:project) { create(:project, :repository) }
let(:blankrev) { Gitlab::Git::BLANK_SHA } let(:blankrev) { Gitlab::Git::BLANK_SHA }
let(:oldrev) { sample_commit.parent_id } let(:oldrev) { sample_commit.parent_id }

View File

@ -16,7 +16,9 @@ RSpec.shared_examples 'date sidebar widget' do
page.within('[data-testid="sidebar-due-date"]') do page.within('[data-testid="sidebar-due-date"]') do
today = Date.today.day today = Date.today.day
click_button 'Edit' button = find_button('Edit')
scroll_to(button)
button.click
click_button today.to_s click_button today.to_s