-
+
{{ content }}
@@ -179,11 +172,11 @@ export default {
-
{{ $options.i18n.containerRegistryTitle }}
+ {{ $options.i18n.CONTAINER_REGISTRY_TITLE }}
-
+
{{ content }}
@@ -207,73 +200,40 @@ export default {
-
-
-
-
-
- {{ listItem.path }}
-
-
-
-
-
-
-
-
-
+
+
+
+
{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}
+
+
+
-
-
+
+
+
+
+ {{ $options.i18n.EMPTY_RESULT_MESSAGE }}
+
+
+
@@ -287,9 +247,9 @@ export default {
@ok="handleDeleteImage"
@cancel="track('cancel_delete')"
>
- {{ $options.i18n.removeRepositoryLabel }}
+ {{ $options.i18n.REMOVE_REPOSITORY_LABEL }}
-
+
{{ itemToDelete.path }}
diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js
index 6e3cf3f0c80..7f80bc21d6e 100644
--- a/app/assets/javascripts/registry/explorer/stores/actions.js
+++ b/app/assets/javascripts/registry/explorer/stores/actions.js
@@ -23,12 +23,15 @@ export const receiveTagsListSuccess = ({ commit }, { data, headers }) => {
commit(types.SET_TAGS_PAGINATION, headers);
};
-export const requestImagesList = ({ commit, dispatch, state }, pagination = {}) => {
+export const requestImagesList = (
+ { commit, dispatch, state },
+ { pagination = {}, name = null } = {},
+) => {
commit(types.SET_MAIN_LOADING, true);
const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination;
return axios
- .get(state.config.endpoint, { params: { page, per_page: perPage } })
+ .get(state.config.endpoint, { params: { page, per_page: perPage, name } })
.then(({ data, headers }) => {
dispatch('receiveImagesListSuccess', { data, headers });
})
diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss
index ce1039832d3..e4466b44358 100644
--- a/app/assets/stylesheets/components/related_items_list.scss
+++ b/app/assets/stylesheets/components/related_items_list.scss
@@ -69,11 +69,6 @@ $item-weight-max-width: 48px;
font-weight: $gl-font-weight-bold;
}
- .issue-token-state-icon-open,
- .issue-token-state-icon-closed {
- display: none;
- }
-
.sortable-link {
color: $gray-900;
font-weight: normal;
@@ -92,7 +87,8 @@ $item-weight-max-width: 48px;
@include media-breakpoint-down(lg) {
.issue-count-badge {
- padding-left: 0;
+ padding: 0;
+ padding-right: 8px;
}
}
}
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index e24384156c9..3270c7c131f 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1317,6 +1317,14 @@ class MergeRequest < ApplicationRecord
actual_head_pipeline&.has_reports?(Ci::JobArtifact.terraform_reports)
end
+ def compare_accessibility_reports
+ unless has_accessibility_reports?
+ return { status: :error, status_reason: _('This merge request does not have accessibility reports') }
+ end
+
+ compare_reports(Ci::CompareAccessibilityReportsService)
+ end
+
# TODO: this method and compare_test_reports use the same
# result type, which is handled by the controller's #reports_response.
# we should minimize mistakes by isolating the common parts.
diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb
index 6b1d82e7557..5e669ff2e50 100644
--- a/app/presenters/clusterable_presenter.rb
+++ b/app/presenters/clusterable_presenter.rb
@@ -21,8 +21,8 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated
can?(current_user, :create_cluster, clusterable)
end
- def index_path
- polymorphic_path([clusterable, :clusters])
+ def index_path(options = {})
+ polymorphic_path([clusterable, :clusters], options)
end
def new_path(options = {})
diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb
index 0c267fd5735..41071bc7bc7 100644
--- a/app/presenters/instance_clusterable_presenter.rb
+++ b/app/presenters/instance_clusterable_presenter.rb
@@ -13,8 +13,8 @@ class InstanceClusterablePresenter < ClusterablePresenter
end
override :index_path
- def index_path
- admin_clusters_path
+ def index_path(options = {})
+ admin_clusters_path(options)
end
override :new_path
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 5f3dfdacc14..50431e50110 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -22,7 +22,9 @@ class IssuableBaseService < BaseService
params.delete(:milestone_id)
params.delete(:labels)
params.delete(:add_label_ids)
+ params.delete(:add_labels)
params.delete(:remove_label_ids)
+ params.delete(:remove_labels)
params.delete(:label_ids)
params.delete(:assignee_ids)
params.delete(:assignee_id)
diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml
index 28002dbff92..86194842664 100644
--- a/app/views/clusters/clusters/index.html.haml
+++ b/app/views/clusters/clusters/index.html.haml
@@ -19,7 +19,7 @@
= link_to _('More information'), help_page_path('user/group/clusters/index', anchor: 'cluster-precedence')
- if Feature.enabled?(:clusters_list_redesign)
- #js-clusters-list-app{ data: { endpoint: 'todo/add/endpoint' } }
+ #js-clusters-list-app{ data: { endpoint: clusterable.index_path(format: :json) } }
- else
.clusters-table.js-clusters-list
.gl-responsive-table-row.table-row-header{ role: "row" }
diff --git a/changelogs/unreleased/215563-migration-to-import-common-metrics.yml b/changelogs/unreleased/215563-migration-to-import-common-metrics.yml
new file mode 100644
index 00000000000..eef492ea1ac
--- /dev/null
+++ b/changelogs/unreleased/215563-migration-to-import-common-metrics.yml
@@ -0,0 +1,6 @@
+---
+title: Add migration to import changes to the system dashboard Prometheus queries
+ into DB
+merge_request: 31618
+author:
+type: changed
diff --git a/changelogs/unreleased/216122-use-search-to-quickly-find-and-discover-images-hosted-in-the-gitla.yml b/changelogs/unreleased/216122-use-search-to-quickly-find-and-discover-images-hosted-in-the-gitla.yml
new file mode 100644
index 00000000000..cca9be40473
--- /dev/null
+++ b/changelogs/unreleased/216122-use-search-to-quickly-find-and-discover-images-hosted-in-the-gitla.yml
@@ -0,0 +1,5 @@
+---
+title: Add search bar to container registry image list
+merge_request: 31322
+author:
+type: added
diff --git a/changelogs/unreleased/chore-mitt-migration-issuables-list.yml b/changelogs/unreleased/chore-mitt-migration-issuables-list.yml
new file mode 100644
index 00000000000..b60f9f7557c
--- /dev/null
+++ b/changelogs/unreleased/chore-mitt-migration-issuables-list.yml
@@ -0,0 +1,5 @@
+---
+title: Migrate from Vue event hub to Mitt in issuables list
+merge_request: 31652
+author: Arun Kumar Mohan
+type: changed
diff --git a/changelogs/unreleased/improve_add_remove_labels_api.yml b/changelogs/unreleased/improve_add_remove_labels_api.yml
new file mode 100644
index 00000000000..13b502edef7
--- /dev/null
+++ b/changelogs/unreleased/improve_add_remove_labels_api.yml
@@ -0,0 +1,5 @@
+---
+title: Add ability to add or remove MR labels via API
+merge_request: 31522
+author: Lee Tickett
+type: changed
diff --git a/changelogs/unreleased/move-browser-performance-testing-to-rules-syntax.yml b/changelogs/unreleased/move-browser-performance-testing-to-rules-syntax.yml
new file mode 100644
index 00000000000..4809d9f0de7
--- /dev/null
+++ b/changelogs/unreleased/move-browser-performance-testing-to-rules-syntax.yml
@@ -0,0 +1,5 @@
+---
+title: Move Browser-Perfomance-Testing.gitlab-ci.yml to `rules` syntax
+merge_request: 31413
+author:
+type: changed
diff --git a/db/post_migrate/20200511145545_change_variable_interpolation_format_in_common_metrics.rb b/db/post_migrate/20200511145545_change_variable_interpolation_format_in_common_metrics.rb
new file mode 100644
index 00000000000..ac3c545350d
--- /dev/null
+++ b/db/post_migrate/20200511145545_change_variable_interpolation_format_in_common_metrics.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class ChangeVariableInterpolationFormatInCommonMetrics < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def up
+ ::Gitlab::DatabaseImporters::CommonMetrics::Importer.new.execute
+ end
+
+ def down
+ # no-op
+ # The import cannot be reversed since we do not know the state that the
+ # common metrics in the PrometheusMetric table were in before the import.
+ end
+end
diff --git a/db/structure.sql b/db/structure.sql
index 5f0da8d7558..ebaec8eee10 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -13765,5 +13765,6 @@ COPY "schema_migrations" (version) FROM STDIN;
20200506085748
20200506125731
20200507221434
+20200511145545
\.
diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md
index 86c558c582c..ffe73638dc1 100644
--- a/doc/api/merge_requests.md
+++ b/doc/api/merge_requests.md
@@ -1130,6 +1130,8 @@ PUT /projects/:id/merge_requests/:merge_request_iid
| `assignee_ids` | integer array | no | The ID of the user(s) to assign the MR to. Set to `0` or provide an empty value to unassign all assignees. |
| `milestone_id` | integer | no | The global ID of a milestone to assign the merge request to. Set to `0` or provide an empty value to unassign a milestone.|
| `labels` | string | no | Comma-separated label names for a merge request. Set to an empty string to unassign all labels. |
+| `add_labels` | string | no | Comma-separated label names to add to a merge request. |
+| `remove_labels` | string | no | Comma-separated label names to remove from a merge request. |
| `description` | string | no | Description of MR. Limited to 1,048,576 characters. |
| `state_event` | string | no | New state (close/reopen) |
| `remove_source_branch` | boolean | no | Flag indicating if a merge request should remove the source branch when merging |
diff --git a/doc/api/packages.md b/doc/api/packages.md
index c68c16e92a7..784343d29fd 100644
--- a/doc/api/packages.md
+++ b/doc/api/packages.md
@@ -188,7 +188,27 @@ Example response:
"name": "Administrator",
"avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon"
}
- }
+ },
+ "versions": [
+ {
+ "id":2,
+ "version":"2.0-SNAPSHOT",
+ "created_at":"2020-04-28T04:42:11.573Z",
+ "pipeline": {
+ "id": 234,
+ "status": "pending",
+ "ref": "new-pipeline",
+ "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+ "web_url": "https://example.com/foo/bar/pipelines/58",
+ "created_at": "2016-08-11T11:28:34.085Z",
+ "updated_at": "2016-08-11T11:32:35.169Z",
+ "user": {
+ "name": "Administrator",
+ "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon"
+ }
+ }
+ }
+ ]
}
```
diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb
index d45786cdd3d..0284a055e2d 100644
--- a/lib/api/merge_requests.rb
+++ b/lib/api/merge_requests.rb
@@ -26,6 +26,8 @@ module API
assignee_ids
description
labels
+ add_labels
+ remove_labels
milestone_id
remove_source_branch
state_event
@@ -180,6 +182,8 @@ module API
optional :assignee_ids, type: Array[Integer], desc: 'The array of user IDs to assign issue'
optional :milestone_id, type: Integer, desc: 'The ID of a milestone to assign the merge request'
optional :labels, type: Array[String], coerce_with: Validations::Types::LabelsList.coerce, desc: 'Comma-separated list of label names'
+ optional :add_labels, type: Array[String], coerce_with: Validations::Types::LabelsList.coerce, desc: 'Comma-separated list of label names'
+ optional :remove_labels, type: Array[String], coerce_with: Validations::Types::LabelsList.coerce, desc: 'Comma-separated list of label names'
optional :remove_source_branch, type: Boolean, desc: 'Remove source branch when merging'
optional :allow_collaboration, type: Boolean, desc: 'Allow commits from members who can merge to the target branch'
optional :allow_maintainer_to_push, type: Boolean, as: :allow_collaboration, desc: '[deprecated] See allow_collaboration'
diff --git a/lib/gitlab/ci/templates/Jobs/Browser-Performance-Testing.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Browser-Performance-Testing.gitlab-ci.yml
index d85078c0a40..adbf9731e43 100644
--- a/lib/gitlab/ci/templates/Jobs/Browser-Performance-Testing.gitlab-ci.yml
+++ b/lib/gitlab/ci/templates/Jobs/Browser-Performance-Testing.gitlab-ci.yml
@@ -30,11 +30,9 @@ performance:
paths:
- performance.json
- sitespeed-results/
- only:
- refs:
- - branches
- - tags
- kubernetes: active
- except:
- variables:
- - $PERFORMANCE_DISABLED
+ rules:
+ - if: '$CI_KUBERNETES_ACTIVE == null || $CI_KUBERNETES_ACTIVE == ""'
+ when: never
+ - if: '$PERFORMANCE_DISABLED'
+ when: never
+ - if: '$CI_COMMIT_TAG || $CI_COMMIT_BRANCH'
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 804f4e08f91..133c5a3659d 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -5692,12 +5692,18 @@ msgstr ""
msgid "ContainerRegistry|Expiration schedule:"
msgstr ""
+msgid "ContainerRegistry|Filter by name"
+msgstr ""
+
msgid "ContainerRegistry|If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd} instead of a password."
msgstr ""
msgid "ContainerRegistry|Image ID"
msgstr ""
+msgid "ContainerRegistry|Image Repositories"
+msgstr ""
+
msgid "ContainerRegistry|Keep and protect the images that matter most."
msgstr ""
@@ -5766,6 +5772,9 @@ msgstr ""
msgid "ContainerRegistry|Something went wrong while updating the expiration policy."
msgstr ""
+msgid "ContainerRegistry|Sorry, your filter produced no results."
+msgstr ""
+
msgid "ContainerRegistry|Tag"
msgstr ""
@@ -5817,6 +5826,9 @@ msgstr ""
msgid "ContainerRegistry|This image repository is scheduled for deletion"
msgstr ""
+msgid "ContainerRegistry|To widen your search, change or remove the filters above."
+msgstr ""
+
msgid "ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}"
msgstr ""
@@ -9308,9 +9320,6 @@ msgstr ""
msgid "Filter"
msgstr ""
-msgid "Filter by %{issuable_type} that are currently archived."
-msgstr ""
-
msgid "Filter by %{issuable_type} that are currently closed."
msgstr ""
@@ -9326,6 +9335,12 @@ msgstr ""
msgid "Filter by name"
msgstr ""
+msgid "Filter by requirements that are currently archived."
+msgstr ""
+
+msgid "Filter by requirements that are currently opened."
+msgstr ""
+
msgid "Filter by status"
msgstr ""
@@ -19152,15 +19167,15 @@ msgstr ""
msgid "Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{b_start}will%{b_end} lose access to your account."
msgstr ""
-msgid "Show all %{issuable_type}."
-msgstr ""
-
msgid "Show all activity"
msgstr ""
msgid "Show all members"
msgstr ""
+msgid "Show all requirements."
+msgstr ""
+
msgid "Show archived projects"
msgstr ""
@@ -19535,6 +19550,9 @@ msgstr ""
msgid "Something went wrong while fetching related merge requests."
msgstr ""
+msgid "Something went wrong while fetching requirements count."
+msgstr ""
+
msgid "Something went wrong while fetching requirements list."
msgstr ""
@@ -21629,6 +21647,9 @@ msgstr ""
msgid "This means you can not push code until you create an empty repository or import existing one."
msgstr ""
+msgid "This merge request does not have accessibility reports"
+msgstr ""
+
msgid "This merge request is locked."
msgstr ""
@@ -22263,15 +22284,9 @@ msgstr ""
msgid "Total artifacts size: %{total_size}"
msgstr ""
-msgid "Total cores (vCPUs)"
-msgstr ""
-
msgid "Total issues"
msgstr ""
-msgid "Total memory (GB)"
-msgstr ""
-
msgid "Total test time for all commits/merges"
msgstr ""
diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js
index 85c86b2c0a9..6ebaab5178c 100644
--- a/spec/frontend/clusters_list/components/clusters_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_spec.js
@@ -1,46 +1,63 @@
-import Vuex from 'vuex';
-import { createLocalVue, mount } from '@vue/test-utils';
-import { GlTable, GlLoadingIcon } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
import Clusters from '~/clusters_list/components/clusters.vue';
-import mockData from '../mock_data';
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
+import ClusterStore from '~/clusters_list/store';
+import MockAdapter from 'axios-mock-adapter';
+import { apiData } from '../mock_data';
+import { mount } from '@vue/test-utils';
+import { GlTable, GlLoadingIcon } from '@gitlab/ui';
describe('Clusters', () => {
+ let mock;
+ let store;
let wrapper;
- const findTable = () => wrapper.find(GlTable);
+ const endpoint = 'some/endpoint';
+
const findLoader = () => wrapper.find(GlLoadingIcon);
+ const findTable = () => wrapper.find(GlTable);
const findStatuses = () => findTable().findAll('.js-status');
- const mountComponent = _state => {
- const state = { clusters: mockData, endpoint: 'some/endpoint', ..._state };
- const store = new Vuex.Store({
- state,
- });
+ const mockPollingApi = (response, body, header) => {
+ mock.onGet(endpoint).reply(response, body, header);
+ };
- wrapper = mount(Clusters, { localVue, store });
+ const mountWrapper = () => {
+ store = ClusterStore({ endpoint });
+ wrapper = mount(Clusters, { store });
+ return axios.waitForAll();
};
beforeEach(() => {
- mountComponent({ loading: false });
+ mock = new MockAdapter(axios);
+ mockPollingApi(200, apiData, {});
+
+ return mountWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
});
describe('clusters table', () => {
- it('displays a loader instead of the table while loading', () => {
- mountComponent({ loading: true });
- expect(findLoader().exists()).toBe(true);
- expect(findTable().exists()).toBe(false);
+ describe('when data is loading', () => {
+ beforeEach(() => {
+ wrapper.vm.$store.state.loading = true;
+ return wrapper.vm.$nextTick();
+ });
+
+ it('displays a loader instead of the table while loading', () => {
+ expect(findLoader().exists()).toBe(true);
+ expect(findTable().exists()).toBe(false);
+ });
});
it('displays a table component', () => {
expect(findTable().exists()).toBe(true);
- expect(findTable().exists()).toBe(true);
});
it('renders the correct table headers', () => {
- const tableHeaders = wrapper.vm.$options.fields;
+ const tableHeaders = wrapper.vm.fields;
const headers = findTable().findAll('th');
expect(headers.length).toBe(tableHeaders.length);
@@ -62,7 +79,8 @@ describe('Clusters', () => {
${'unreachable'} | ${'bg-danger'} | ${1}
${'authentication_failure'} | ${'bg-warning'} | ${2}
${'deleting'} | ${null} | ${3}
- ${'connected'} | ${'bg-success'} | ${4}
+ ${'created'} | ${'bg-success'} | ${4}
+ ${'default'} | ${'bg-white'} | ${5}
`('renders a status for each cluster', ({ statusName, className, lineNumber }) => {
const statuses = findStatuses();
const status = statuses.at(lineNumber);
diff --git a/spec/frontend/clusters_list/mock_data.js b/spec/frontend/clusters_list/mock_data.js
index 5398975d81c..9a90a378f31 100644
--- a/spec/frontend/clusters_list/mock_data.js
+++ b/spec/frontend/clusters_list/mock_data.js
@@ -1,4 +1,4 @@
-export default [
+export const clusterList = [
{
name: 'My Cluster 1',
environmentScope: '*',
@@ -40,8 +40,22 @@ export default [
environmentScope: 'development',
size: '12',
clusterType: 'project_type',
- status: 'connected',
+ status: 'created',
+ cpu: '6 (100% free)',
+ memory: '20.12 (35% free)',
+ },
+ {
+ name: 'My Cluster 6',
+ environmentScope: '*',
+ size: '1',
+ clusterType: 'project_type',
+ status: 'cleanup_ongoing',
cpu: '6 (100% free)',
memory: '20.12 (35% free)',
},
];
+
+export const apiData = {
+ clusters: clusterList,
+ has_ancestor_clusters: false,
+};
diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js
index 46132f701be..2d164b7cc4a 100644
--- a/spec/frontend/clusters_list/store/actions_spec.js
+++ b/spec/frontend/clusters_list/store/actions_spec.js
@@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import flashError from '~/flash';
import testAction from 'helpers/vuex_action_helper';
import axios from '~/lib/utils/axios_utils';
+import { apiData } from '../mock_data';
import * as types from '~/clusters_list/store/mutation_types';
import * as actions from '~/clusters_list/store/actions';
@@ -10,8 +11,6 @@ jest.mock('~/flash.js');
describe('Clusters store actions', () => {
describe('fetchClusters', () => {
let mock;
- const endpoint = '/clusters';
- const clusters = [{ name: 'test' }];
beforeEach(() => {
mock = new MockAdapter(axios);
@@ -20,14 +19,14 @@ describe('Clusters store actions', () => {
afterEach(() => mock.restore());
it('should commit SET_CLUSTERS_DATA with received response', done => {
- mock.onGet().reply(200, clusters);
+ mock.onGet().reply(200, apiData);
testAction(
actions.fetchClusters,
- { endpoint },
+ { endpoint: apiData.endpoint },
{},
[
- { type: types.SET_CLUSTERS_DATA, payload: clusters },
+ { type: types.SET_CLUSTERS_DATA, payload: apiData },
{ type: types.SET_LOADING_STATE, payload: false },
],
[],
@@ -38,7 +37,7 @@ describe('Clusters store actions', () => {
it('should show flash on API error', done => {
mock.onGet().reply(400, 'Not Found');
- testAction(actions.fetchClusters, { endpoint }, {}, [], [], () => {
+ testAction(actions.fetchClusters, { endpoint: apiData.endpoint }, {}, [], [], () => {
expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error'));
done();
});
diff --git a/spec/frontend/design_management/utils/tracking_spec.js b/spec/frontend/design_management/utils/tracking_spec.js
index ab540587c01..9fa5eae55b3 100644
--- a/spec/frontend/design_management/utils/tracking_spec.js
+++ b/spec/frontend/design_management/utils/tracking_spec.js
@@ -22,8 +22,9 @@ describe('Tracking Events', () => {
label: eventName,
value: {
'internal-object-refrerer': '',
- 'version-number': 1,
- 'current-version': false,
+ 'design-collection-owner': '',
+ 'design-version-number': 1,
+ 'design-is-current-version': false,
},
}),
);
@@ -32,7 +33,7 @@ describe('Tracking Events', () => {
it('trackDesignDetailView allows to customize the value payload', () => {
const trackingSpy = getTrackingSpy(eventKey);
- trackDesignDetailView('from-a-test', 100, true);
+ trackDesignDetailView('from-a-test', 'test', 100, true);
expect(trackingSpy).toHaveBeenCalledWith(
eventKey,
@@ -41,8 +42,9 @@ describe('Tracking Events', () => {
label: eventName,
value: {
'internal-object-refrerer': 'from-a-test',
- 'version-number': 100,
- 'current-version': true,
+ 'design-collection-owner': 'test',
+ 'design-version-number': 100,
+ 'design-is-current-version': true,
},
}),
);
diff --git a/spec/frontend/registry/explorer/components/image_list_spec.js b/spec/frontend/registry/explorer/components/image_list_spec.js
new file mode 100644
index 00000000000..12f0fbe0c87
--- /dev/null
+++ b/spec/frontend/registry/explorer/components/image_list_spec.js
@@ -0,0 +1,74 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlPagination } from '@gitlab/ui';
+import Component from '~/registry/explorer/components/image_list.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { RouterLink } from '../stubs';
+import { imagesListResponse, imagePagination } from '../mock_data';
+
+describe('Image List', () => {
+ let wrapper;
+
+ const firstElement = imagesListResponse.data[0];
+
+ const findDeleteBtn = () => wrapper.find('[data-testid="deleteImageButton"]');
+ const findRowItems = () => wrapper.findAll('[data-testid="rowItem"]');
+ const findDetailsLink = () => wrapper.find('[data-testid="detailsLink"]');
+ const findClipboardButton = () => wrapper.find(ClipboardButton);
+ const findPagination = () => wrapper.find(GlPagination);
+
+ const mountComponent = () => {
+ wrapper = shallowMount(Component, {
+ stubs: {
+ RouterLink,
+ },
+ propsData: {
+ images: imagesListResponse.data,
+ pagination: imagePagination,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('contains one list element for each image', () => {
+ expect(findRowItems().length).toBe(imagesListResponse.data.length);
+ });
+
+ it('contains a link to the details page', () => {
+ const link = findDetailsLink();
+ expect(link.html()).toContain(firstElement.path);
+ expect(link.props('to').name).toBe('details');
+ });
+
+ it('contains a clipboard button', () => {
+ const button = findClipboardButton();
+ expect(button.exists()).toBe(true);
+ expect(button.props('text')).toBe(firstElement.location);
+ expect(button.props('title')).toBe(firstElement.location);
+ });
+
+ it('should be possible to delete a repo', () => {
+ const deleteBtn = findDeleteBtn();
+ expect(deleteBtn.exists()).toBe(true);
+ });
+
+ describe('pagination', () => {
+ it('exists', () => {
+ expect(findPagination().exists()).toBe(true);
+ });
+
+ it('is wired to the correct pagination props', () => {
+ const pagination = findPagination();
+ expect(pagination.props('perPage')).toBe(imagePagination.perPage);
+ expect(pagination.props('totalItems')).toBe(imagePagination.total);
+ expect(pagination.props('value')).toBe(imagePagination.page);
+ });
+
+ it('emits a pageChange event when the page change', () => {
+ wrapper.setData({ currentPage: 2 });
+ expect(wrapper.emitted('pageChange')).toEqual([[2]]);
+ });
+ });
+});
diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js
index 2d8cd4e42bc..f6beccda9b1 100644
--- a/spec/frontend/registry/explorer/mock_data.js
+++ b/spec/frontend/registry/explorer/mock_data.js
@@ -87,3 +87,11 @@ export const tagsListResponse = {
],
headers,
};
+
+export const imagePagination = {
+ perPage: 10,
+ page: 1,
+ total: 14,
+ totalPages: 2,
+ nextPage: 2,
+};
diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js
index 1d530483093..97742b9e9b3 100644
--- a/spec/frontend/registry/explorer/pages/list_spec.js
+++ b/spec/frontend/registry/explorer/pages/list_spec.js
@@ -1,11 +1,13 @@
import { shallowMount } from '@vue/test-utils';
-import { GlPagination, GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui';
+import { GlSkeletonLoader, GlSprintf, GlAlert, GlSearchBoxByClick } from '@gitlab/ui';
import Tracking from '~/tracking';
+import waitForPromises from 'helpers/wait_for_promises';
import component from '~/registry/explorer/pages/list.vue';
import QuickstartDropdown from '~/registry/explorer/components/quickstart_dropdown.vue';
import GroupEmptyState from '~/registry/explorer/components/group_empty_state.vue';
import ProjectEmptyState from '~/registry/explorer/components/project_empty_state.vue';
import ProjectPolicyAlert from '~/registry/explorer/components/project_policy_alert.vue';
+import ImageList from '~/registry/explorer/components/image_list.vue';
import { createStore } from '~/registry/explorer/stores/';
import {
SET_MAIN_LOADING,
@@ -16,9 +18,11 @@ import {
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
+ IMAGE_REPOSITORY_LIST_LABEL,
+ SEARCH_PLACEHOLDER_TEXT,
} from '~/registry/explorer/constants';
import { imagesListResponse } from '../mock_data';
-import { GlModal, GlEmptyState, RouterLink } from '../stubs';
+import { GlModal, GlEmptyState } from '../stubs';
import { $toast } from '../../shared/mocks';
describe('List Page', () => {
@@ -26,20 +30,21 @@ describe('List Page', () => {
let dispatchSpy;
let store;
- const findDeleteBtn = () => wrapper.find({ ref: 'deleteImageButton' });
const findDeleteModal = () => wrapper.find(GlModal);
const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
const findImagesList = () => wrapper.find({ ref: 'imagesList' });
- const findRowItems = () => wrapper.findAll({ ref: 'rowItem' });
+
const findEmptyState = () => wrapper.find(GlEmptyState);
- const findDetailsLink = () => wrapper.find({ ref: 'detailsLink' });
- const findClipboardButton = () => wrapper.find({ ref: 'clipboardButton' });
- const findPagination = () => wrapper.find(GlPagination);
+
const findQuickStartDropdown = () => wrapper.find(QuickstartDropdown);
const findProjectEmptyState = () => wrapper.find(ProjectEmptyState);
const findGroupEmptyState = () => wrapper.find(GroupEmptyState);
const findProjectPolicyAlert = () => wrapper.find(ProjectPolicyAlert);
const findDeleteAlert = () => wrapper.find(GlAlert);
+ const findImageList = () => wrapper.find(ImageList);
+ const findListHeader = () => wrapper.find('[data-testid="listHeader"]');
+ const findSearchBox = () => wrapper.find(GlSearchBoxByClick);
+ const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]');
const mountComponent = ({ mocks } = {}) => {
wrapper = shallowMount(component, {
@@ -48,7 +53,6 @@ describe('List Page', () => {
GlModal,
GlEmptyState,
GlSprintf,
- RouterLink,
},
mocks: {
$toast,
@@ -164,6 +168,7 @@ describe('List Page', () => {
beforeEach(() => {
store.commit(SET_IMAGES_LIST_SUCCESS, []);
mountComponent();
+ return waitForPromises();
});
it('quick start is not visible', () => {
@@ -191,54 +196,39 @@ describe('List Page', () => {
it('quick start is not visible', () => {
expect(findQuickStartDropdown().exists()).toBe(false);
});
+
+ it('list header is not visible', () => {
+ expect(findListHeader().exists()).toBe(false);
+ });
});
});
describe('list is not empty', () => {
- beforeEach(() => {
- mountComponent();
- });
-
- it('quick start is visible', () => {
- expect(findQuickStartDropdown().exists()).toBe(true);
- });
-
- describe('listElement', () => {
- let listElements;
- let firstElement;
-
+ describe('unfiltered state', () => {
beforeEach(() => {
- listElements = findRowItems();
- [firstElement] = store.state.images;
+ mountComponent();
});
- it('contains one list element for each image', () => {
- expect(listElements.length).toBe(store.state.images.length);
+ it('quick start is visible', () => {
+ expect(findQuickStartDropdown().exists()).toBe(true);
});
- it('contains a link to the details page', () => {
- const link = findDetailsLink();
- expect(link.html()).toContain(firstElement.path);
- expect(link.props('to').name).toBe('details');
+ it('list component is visible', () => {
+ expect(findImageList().exists()).toBe(true);
});
- it('contains a clipboard button', () => {
- const button = findClipboardButton();
- expect(button.exists()).toBe(true);
- expect(button.props('text')).toBe(firstElement.location);
- expect(button.props('title')).toBe(firstElement.location);
+ it('list header is visible', () => {
+ const header = findListHeader();
+ expect(header.exists()).toBe(true);
+ expect(header.text()).toBe(IMAGE_REPOSITORY_LIST_LABEL);
});
describe('delete image', () => {
- it('should be possible to delete a repo', () => {
- const deleteBtn = findDeleteBtn();
- expect(deleteBtn.exists()).toBe(true);
- });
-
+ const itemToDelete = { path: 'bar' };
it('should call deleteItem when confirming deletion', () => {
dispatchSpy.mockResolvedValue();
- findDeleteBtn().vm.$emit('click');
- expect(wrapper.vm.itemToDelete).not.toEqual({});
+ findImageList().vm.$emit('delete', itemToDelete);
+ expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
findDeleteModal().vm.$emit('ok');
expect(store.dispatch).toHaveBeenCalledWith(
'requestDeleteImage',
@@ -248,8 +238,8 @@ describe('List Page', () => {
it('should show a success alert when delete request is successful', () => {
dispatchSpy.mockResolvedValue();
- findDeleteBtn().vm.$emit('click');
- expect(wrapper.vm.itemToDelete).not.toEqual({});
+ findImageList().vm.$emit('delete', itemToDelete);
+ expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
return wrapper.vm.handleDeleteImage().then(() => {
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
@@ -261,8 +251,8 @@ describe('List Page', () => {
it('should show an error alert when delete request fails', () => {
dispatchSpy.mockRejectedValue();
- findDeleteBtn().vm.$emit('click');
- expect(wrapper.vm.itemToDelete).not.toEqual({});
+ findImageList().vm.$emit('delete', itemToDelete);
+ expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
return wrapper.vm.handleDeleteImage().then(() => {
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
@@ -272,71 +262,93 @@ describe('List Page', () => {
});
});
});
-
- describe('pagination', () => {
- it('exists', () => {
- expect(findPagination().exists()).toBe(true);
- });
-
- it('is wired to the correct pagination props', () => {
- const pagination = findPagination();
- expect(pagination.props('perPage')).toBe(store.state.pagination.perPage);
- expect(pagination.props('totalItems')).toBe(store.state.pagination.total);
- expect(pagination.props('value')).toBe(store.state.pagination.page);
- });
-
- it('fetch the data from the API when the v-model changes', () => {
- dispatchSpy.mockReturnValue();
- wrapper.setData({ currentPage: 2 });
- return wrapper.vm.$nextTick().then(() => {
- expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', { page: 2 });
- });
- });
- });
});
- describe('modal', () => {
- it('exists', () => {
- expect(findDeleteModal().exists()).toBe(true);
+ describe('search', () => {
+ it('has a search box element', () => {
+ mountComponent();
+ const searchBox = findSearchBox();
+ expect(searchBox.exists()).toBe(true);
+ expect(searchBox.attributes('placeholder')).toBe(SEARCH_PLACEHOLDER_TEXT);
});
- it('contains a description with the path of the item to delete', () => {
- wrapper.setData({ itemToDelete: { path: 'foo' } });
+ it('performs a search', () => {
+ mountComponent();
+ findSearchBox().vm.$emit('submit', 'foo');
+ expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', {
+ name: 'foo',
+ });
+ });
+
+ it('when search result is empty displays an empty search message', () => {
+ mountComponent();
+ store.commit(SET_IMAGES_LIST_SUCCESS, []);
return wrapper.vm.$nextTick().then(() => {
- expect(findDeleteModal().html()).toContain('foo');
+ expect(findEmptySearchMessage().exists()).toBe(true);
});
});
});
- describe('tracking', () => {
- const testTrackingCall = action => {
- expect(Tracking.event).toHaveBeenCalledWith(undefined, action, {
- label: 'registry_repository_delete',
+ describe('pagination', () => {
+ it('pageChange event triggers the appropriate store function', () => {
+ mountComponent();
+ findImageList().vm.$emit('pageChange', 2);
+ expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', {
+ pagination: { page: 2 },
+ name: wrapper.vm.search,
});
- };
-
- beforeEach(() => {
- jest.spyOn(Tracking, 'event');
- dispatchSpy.mockResolvedValue();
- });
-
- it('send an event when delete button is clicked', () => {
- const deleteBtn = findDeleteBtn();
- deleteBtn.vm.$emit('click');
- testTrackingCall('click_button');
- });
-
- it('send an event when cancel is pressed on modal', () => {
- const deleteModal = findDeleteModal();
- deleteModal.vm.$emit('cancel');
- testTrackingCall('cancel_delete');
- });
-
- it('send an event when confirm is clicked on modal', () => {
- const deleteModal = findDeleteModal();
- deleteModal.vm.$emit('ok');
- testTrackingCall('confirm_delete');
});
});
});
+
+ describe('modal', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('exists', () => {
+ expect(findDeleteModal().exists()).toBe(true);
+ });
+
+ it('contains a description with the path of the item to delete', () => {
+ wrapper.setData({ itemToDelete: { path: 'foo' } });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findDeleteModal().html()).toContain('foo');
+ });
+ });
+ });
+
+ describe('tracking', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ const testTrackingCall = action => {
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, action, {
+ label: 'registry_repository_delete',
+ });
+ };
+
+ beforeEach(() => {
+ jest.spyOn(Tracking, 'event');
+ dispatchSpy.mockResolvedValue();
+ });
+
+ it('send an event when delete button is clicked', () => {
+ findImageList().vm.$emit('delete', {});
+ testTrackingCall('click_button');
+ });
+
+ it('send an event when cancel is pressed on modal', () => {
+ const deleteModal = findDeleteModal();
+ deleteModal.vm.$emit('cancel');
+ testTrackingCall('cancel_delete');
+ });
+
+ it('send an event when confirm is clicked on modal', () => {
+ const deleteModal = findDeleteModal();
+ deleteModal.vm.$emit('ok');
+ testTrackingCall('confirm_delete');
+ });
+ });
});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
index 4e4f99f09e7..9ba429c3d20 100644
--- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
@@ -920,8 +920,8 @@ describe('ReadyToMerge', () => {
});
});
- describe('Commit message area', () => {
- describe('when using merge commits', () => {
+ describe('Merge request project settings', () => {
+ describe('when the merge commit merge method is enabled', () => {
beforeEach(() => {
vm = createComponent({
mr: { ffOnlyEnabled: false },
@@ -937,7 +937,7 @@ describe('ReadyToMerge', () => {
});
});
- describe('when fast-forward merge is enabled', () => {
+ describe('when the fast-forward merge method is enabled', () => {
beforeEach(() => {
vm = createComponent({
mr: { ffOnlyEnabled: true },
diff --git a/spec/lib/gitlab/ci/templates/Jobs/browser_performance_testing_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/Jobs/browser_performance_testing_gitlab_ci_yaml_spec.rb
new file mode 100644
index 00000000000..54c3500b0a0
--- /dev/null
+++ b/spec/lib/gitlab/ci/templates/Jobs/browser_performance_testing_gitlab_ci_yaml_spec.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Jobs/Browser-Performance-Testing.gitlab-ci.yml' do
+ subject(:template) do
+ <<~YAML
+ stages:
+ - test
+ - performance
+
+ include:
+ - template: 'Jobs/Browser-Performance-Testing.gitlab-ci.yml'
+
+ placeholder:
+ script:
+ - keep pipeline validator happy by having a job when stages are intentionally empty
+ YAML
+ end
+
+ describe 'the created pipeline' do
+ let(:user) { create(:admin) }
+ let(:project) do
+ create(:project, :repository, variables: [
+ build(:ci_variable, key: 'CI_KUBERNETES_ACTIVE', value: 'true')
+ ])
+ end
+
+ let(:default_branch) { 'master' }
+ let(:pipeline_ref) { default_branch }
+ let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_ref) }
+ let(:pipeline) { service.execute!(:push) }
+ let(:build_names) { pipeline.builds.pluck(:name) }
+
+ before do
+ stub_ci_pipeline_yaml_file(template)
+
+ allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true)
+ allow(project).to receive(:default_branch).and_return(default_branch)
+ end
+
+ it 'has no errors' do
+ expect(pipeline.errors).to be_empty
+ end
+
+ shared_examples_for 'performance job on tag or branch' do
+ it 'by default' do
+ expect(build_names).to include('performance')
+ end
+
+ it 'when PERFORMANCE_DISABLED' do
+ create(:ci_variable, project: project, key: 'PERFORMANCE_DISABLED', value: '1')
+
+ expect(build_names).not_to include('performance')
+ end
+ end
+
+ context 'on master' do
+ it_behaves_like 'performance job on tag or branch'
+ end
+
+ context 'on another branch' do
+ let(:pipeline_ref) { 'feature' }
+
+ it_behaves_like 'performance job on tag or branch'
+ end
+
+ context 'on tag' do
+ let(:pipeline_ref) { 'v1.0.0' }
+
+ it_behaves_like 'performance job on tag or branch'
+ end
+
+ context 'on merge request' do
+ let(:service) { MergeRequests::CreatePipelineService.new(project, user) }
+ let(:merge_request) { create(:merge_request, :simple, source_project: project) }
+ let(:pipeline) { service.execute(merge_request) }
+
+ it 'has no jobs' do
+ expect(pipeline).to be_merge_request_event
+ expect(build_names).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20200511145545_change_variable_interpolation_format_in_common_metrics_spec.rb b/spec/migrations/20200511145545_change_variable_interpolation_format_in_common_metrics_spec.rb
new file mode 100644
index 00000000000..f9e8a7ee6e9
--- /dev/null
+++ b/spec/migrations/20200511145545_change_variable_interpolation_format_in_common_metrics_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20200511145545_change_variable_interpolation_format_in_common_metrics')
+
+describe ChangeVariableInterpolationFormatInCommonMetrics, :migration do
+ let(:prometheus_metrics) { table(:prometheus_metrics) }
+
+ let!(:common_metric) do
+ prometheus_metrics.create!(
+ identifier: 'system_metrics_kubernetes_container_memory_total',
+ query: 'avg(sum(container_memory_usage_bytes{container_name!="POD",' \
+ 'pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"})' \
+ ' by (job)) without (job) /1024/1024/1024',
+ project_id: nil,
+ title: 'Memory Usage (Total)',
+ y_label: 'Total Memory Used (GB)',
+ unit: 'GB',
+ legend: 'Total (GB)',
+ group: -5,
+ common: true
+ )
+ end
+
+ it 'updates query to use {{}}' do
+ expected_query = 'avg(sum(container_memory_usage_bytes{container_name!="POD",' \
+ 'pod_name=~"^{{ci_environment_slug}}-(.*)",namespace="{{kube_namespace}}"})' \
+ ' by (job)) without (job) /1024/1024/1024'
+
+ migrate!
+
+ expect(common_metric.reload.query).to eq(expected_query)
+ end
+end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index e8025fef877..5fe0a9052cf 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -1889,6 +1889,62 @@ describe MergeRequest do
end
end
+ describe '#compare_accessibility_reports' do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:merge_request, reload: true) { create(:merge_request, :with_accessibility_reports, source_project: project) }
+ let_it_be(:pipeline) { merge_request.head_pipeline }
+
+ subject { merge_request.compare_accessibility_reports }
+
+ context 'when head pipeline has accessibility reports' do
+ let(:job) do
+ create(:ci_build, options: { artifacts: { reports: { pa11y: ['accessibility.json'] } } }, pipeline: pipeline)
+ end
+
+ let(:artifacts_metadata) { create(:ci_job_artifact, :metadata, job: job) }
+
+ context 'when reactive cache worker is parsing results asynchronously' do
+ it 'returns parsing status' do
+ expect(subject[:status]).to eq(:parsing)
+ end
+ end
+
+ context 'when reactive cache worker is inline' do
+ before do
+ synchronous_reactive_cache(merge_request)
+ end
+
+ it 'returns parsed status' do
+ expect(subject[:status]).to eq(:parsed)
+ expect(subject[:data]).to be_present
+ end
+
+ context 'when an error occurrs' do
+ before do
+ merge_request.update!(head_pipeline: nil)
+ end
+
+ it 'returns an error status' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:status_reason]).to eq("This merge request does not have accessibility reports")
+ end
+ end
+
+ context 'when cached result is not latest' do
+ before do
+ allow_next_instance_of(Ci::CompareAccessibilityReportsService) do |service|
+ allow(service).to receive(:latest?).and_return(false)
+ end
+ end
+
+ it 'raises an InvalidateReactiveCache error' do
+ expect { subject }.to raise_error(ReactiveCaching::InvalidateReactiveCache)
+ end
+ end
+ end
+ end
+ end
+
describe '#all_commit_shas' do
context 'when merge request is persisted' do
let(:all_commit_shas) do
diff --git a/spec/presenters/clusterable_presenter_spec.rb b/spec/presenters/clusterable_presenter_spec.rb
index 47ccc59ae45..2c0a7f3e9b2 100644
--- a/spec/presenters/clusterable_presenter_spec.rb
+++ b/spec/presenters/clusterable_presenter_spec.rb
@@ -87,4 +87,20 @@ describe ClusterablePresenter do
it { is_expected.to be_nil }
end
+
+ describe '#index_path' do
+ let(:clusterable) { create(:group) }
+
+ context 'without options' do
+ subject { described_class.new(clusterable).index_path }
+
+ it { is_expected.to eq(group_clusters_path(clusterable)) }
+ end
+
+ context 'with options' do
+ subject { described_class.new(clusterable).index_path(format: :json) }
+
+ it { is_expected.to eq(group_clusters_path(clusterable, format: :json)) }
+ end
+ end
end
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index af2ce7f7aef..d3999d1ef87 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -2308,6 +2308,33 @@ describe API::MergeRequests do
end
end
+ context 'with labels' do
+ include_context 'with labels'
+
+ let(:api_base) { api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user) }
+
+ it 'when adding labels, keeps existing labels and adds new' do
+ put api_base, params: { add_labels: '1, 2' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['labels']).to contain_exactly(label.title, label2.title, '1', '2')
+ end
+
+ it 'when removing labels, only removes those specified' do
+ put api_base, params: { remove_labels: "#{label.title}" }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['labels']).to eq([label2.title])
+ end
+
+ it 'when removing all labels, keeps no labels' do
+ put api_base, params: { remove_labels: "#{label.title}, #{label2.title}" }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['labels']).to be_empty
+ end
+ end
+
it 'does not update state when title is empty' do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), params: { state_event: 'close', title: nil }