Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-03-22 18:07:25 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-03-22 18:07:25 +0300
commite01b61d83fd7c5d3aa9d87a65eac85e8c7ea9921 (patch)
tree90e1a1f3ebbeab0f2f8714f42211800a213a1002
parent4220cf46a314ac1c4d88be13608752bc07bb28fb (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js111
-rw-r--r--app/assets/javascripts/blob/balsamiq_viewer.js22
-rw-r--r--app/assets/javascripts/blob/viewer/index.js2
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue10
-rw-r--r--app/assets/javascripts/members/constants.js18
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js11
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue289
-rw-r--r--app/assets/javascripts/releases/components/app_index_apollo_client.vue275
-rw-r--r--app/assets/javascripts/releases/components/releases_pagination.vue20
-rw-r--r--app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue37
-rw-r--r--app/assets/javascripts/releases/components/releases_sort.vue71
-rw-r--r--app/assets/javascripts/releases/components/releases_sort_apollo_client.vue91
-rw-r--r--app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql92
-rw-r--r--app/assets/javascripts/releases/mount_index.js52
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/actions.js65
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/index.js10
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/mutation_types.js4
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/mutations.js44
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/state.js24
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/index.js1
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss20
-rw-r--r--app/controllers/groups/group_links_controller.rb22
-rw-r--r--app/controllers/projects/group_links_controller.rb21
-rw-r--r--app/controllers/projects/releases_controller.rb3
-rw-r--r--app/graphql/mutations/todos/mark_all_done.rb29
-rw-r--r--app/graphql/queries/releases/all_releases.query.graphql109
-rw-r--r--app/models/blob.rb1
-rw-r--r--app/models/blob_viewer/balsamiq.rb14
-rw-r--r--app/models/member.rb8
-rw-r--r--app/models/project.rb2
-rw-r--r--app/serializers/member_user_entity.rb3
-rw-r--r--app/views/projects/blob/viewers/_balsamiq.html.haml1
-rw-r--r--app/workers/container_registry/migration/enqueuer_worker.rb64
-rw-r--r--config/feature_flags/development/releases_index_apollo_client.yml8
-rw-r--r--config/feature_flags/ops/purge_stale_security_findings.yml2
-rw-r--r--config/routes/group.rb2
-rw-r--r--config/routes/project.rb2
-rw-r--r--config/sidekiq_queues.yml2
-rw-r--r--config/webpack.config.js4
-rw-r--r--config/webpack.vendor.config.js1
-rw-r--r--data/deprecations/14-9-background-upload.yml20
-rw-r--r--data/whats_new/202203210001_14_09.yml85
-rw-r--r--doc/api/graphql/reference/index.md1
-rw-r--r--doc/api/projects.md1
-rw-r--r--doc/api/remote_mirrors.md37
-rw-r--r--doc/development/application_limits.md30
-rw-r--r--doc/update/deprecations.md19
-rw-r--r--lib/api/entities/project.rb5
-rw-r--r--lib/api/projects.rb2
-rw-r--r--lib/api/remote_mirrors.rb12
-rw-r--r--locale/gitlab.pot6
-rw-r--r--package.json1
-rw-r--r--spec/controllers/groups/group_links_controller_spec.rb114
-rw-r--r--spec/controllers/projects/group_links_controller_spec.rb130
-rw-r--r--spec/features/groups/members/sort_members_spec.rb40
-rw-r--r--spec/features/projects/blobs/balsamiq_spec.rb17
-rw-r--r--spec/features/projects/members/sorting_spec.rb40
-rw-r--r--spec/features/projects/releases/user_views_releases_spec.rb154
-rw-r--r--spec/features/refactor_blob_viewer_disabled/projects/blobs/balsamiq_spec.rb18
-rw-r--r--spec/fixtures/api/schemas/entities/member_user.json15
-rw-r--r--spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js363
-rw-r--r--spec/frontend/members/components/table/members_table_spec.js19
-rw-r--r--spec/frontend/members/mock_data.js2
-rw-r--r--spec/frontend/releases/components/app_index_apollo_client_spec.js398
-rw-r--r--spec/frontend/releases/components/app_index_spec.js482
-rw-r--r--spec/frontend/releases/components/releases_pagination_apollo_client_spec.js126
-rw-r--r--spec/frontend/releases/components/releases_pagination_spec.js180
-rw-r--r--spec/frontend/releases/components/releases_sort_apollo_client_spec.js103
-rw-r--r--spec/frontend/releases/components/releases_sort_spec.js122
-rw-r--r--spec/frontend/releases/stores/modules/list/actions_spec.js197
-rw-r--r--spec/frontend/releases/stores/modules/list/helpers.js5
-rw-r--r--spec/frontend/releases/stores/modules/list/mutations_spec.js81
-rw-r--r--spec/models/member_spec.rb60
-rw-r--r--spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb31
-rw-r--r--spec/requests/api/project_attributes.yml4
-rw-r--r--spec/requests/api/projects_spec.rb27
-rw-r--r--spec/requests/api/remote_mirrors_spec.rb26
-rw-r--r--spec/serializers/member_user_entity_spec.rb10
-rw-r--r--spec/support/helpers/test_env.rb1
-rw-r--r--spec/support/shared_examples/features/project_upload_files_shared_examples.rb2
-rw-r--r--spec/workers/container_registry/migration/enqueuer_worker_spec.rb85
-rw-r--r--yarn.lock5
83 files changed, 1610 insertions, 3044 deletions
diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
deleted file mode 100644
index 313bec7e01a..00000000000
--- a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
+++ /dev/null
@@ -1,111 +0,0 @@
-import { template as _template } from 'lodash';
-import sqljs from 'sql.js';
-import axios from '~/lib/utils/axios_utils';
-import { successCodes } from '~/lib/utils/http_status';
-
-const PREVIEW_TEMPLATE = _template(`
- <div class="card">
- <div class="card-header"><%- name %></div>
- <div class="card-body">
- <img class="img-thumbnail" src="data:image/png;base64,<%- image %>"/>
- </div>
- </div>
-`);
-
-class BalsamiqViewer {
- constructor(viewer) {
- this.viewer = viewer;
- }
-
- loadFile(endpoint) {
- return axios
- .get(endpoint, {
- responseType: 'arraybuffer',
- validateStatus(status) {
- return status !== successCodes.OK;
- },
- })
- .then(({ data }) => {
- this.renderFile(data);
- })
- .catch((e) => {
- throw new Error(e);
- });
- }
-
- renderFile(fileBuffer) {
- const container = document.createElement('ul');
-
- this.initDatabase(fileBuffer);
-
- const previews = this.getPreviews();
- previews.forEach((preview) => {
- const renderedPreview = this.renderPreview(preview);
-
- container.appendChild(renderedPreview);
- });
-
- container.classList.add('list-inline');
- container.classList.add('previews');
-
- this.viewer.appendChild(container);
- }
-
- initDatabase(data) {
- const previewBinary = new Uint8Array(data);
-
- this.database = new sqljs.Database(previewBinary);
- }
-
- getPreviews() {
- const thumbnails = this.database.exec('SELECT * FROM thumbnails');
-
- return thumbnails[0].values.map(BalsamiqViewer.parsePreview);
- }
-
- getResource(resourceID) {
- const resources = this.database.exec(`SELECT * FROM resources WHERE id = '${resourceID}'`);
-
- return resources[0];
- }
-
- renderPreview(preview) {
- const previewElement = document.createElement('li');
-
- previewElement.classList.add('preview');
- previewElement.innerHTML = this.renderTemplate(preview);
-
- return previewElement;
- }
-
- renderTemplate(preview) {
- const resource = this.getResource(preview.resourceID);
- const name = BalsamiqViewer.parseTitle(resource);
- const { image } = preview;
-
- const template = PREVIEW_TEMPLATE({
- name,
- image,
- });
-
- return template;
- }
-
- static parsePreview(preview) {
- return JSON.parse(preview[1]);
- }
-
- /*
- * resource = {
- * columns: ['ID', 'BRANCHID', 'ATTRIBUTES', 'DATA'],
- * values: [['id', 'branchId', 'attributes', 'data']],
- * }
- *
- * 'attributes' being a JSON string containing the `name` property.
- */
- static parseTitle(resource) {
- return JSON.parse(resource.values[0][2]).name;
- }
-}
-
-export default BalsamiqViewer;
diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js
deleted file mode 100644
index af8e8a4cd3d..00000000000
--- a/app/assets/javascripts/blob/balsamiq_viewer.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import createFlash from '~/flash';
-import { __ } from '~/locale';
-import BalsamiqViewer from './balsamiq/balsamiq_viewer';
-
-function onError() {
- const flash = createFlash({
- message: __('Balsamiq file could not be loaded.'),
- });
-
- return flash;
-}
-
-export default function loadBalsamiqFile() {
- const viewer = document.getElementById('js-balsamiq-viewer');
-
- if (!(viewer instanceof Element)) return;
-
- const { endpoint } = viewer.dataset;
-
- const balsamiqViewer = new BalsamiqViewer(viewer);
- balsamiqViewer.loadFile(endpoint).catch(onError);
-}
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index 1bda7d4e3f0..49b6f042911 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -17,8 +17,6 @@ import eventHub from '../../notes/event_hub';
const loadRichBlobViewer = (type) => {
switch (type) {
- case 'balsamiq':
- return import(/* webpackChunkName: 'balsamiq_viewer' */ '../balsamiq_viewer');
case 'notebook':
return import(/* webpackChunkName: 'notebook_viewer' */ '../notebook_viewer');
case 'openapi':
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index b4ba9aa36e7..0b97ce7e33e 100644
--- a/app/assets/javascripts/members/components/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -5,6 +5,7 @@ import MembersTableCell from 'ee_else_ce/members/components/table/members_table_
import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import initUserPopovers from '~/user_popovers';
+import UserDate from '~/vue_shared/components/user_date.vue';
import {
FIELD_KEY_ACTIONS,
FIELDS,
@@ -40,6 +41,7 @@ export default {
RemoveGroupLinkModal,
RemoveMemberModal,
ExpirationDatepicker,
+ UserDate,
LdapOverrideConfirmationModal: () =>
import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'),
},
@@ -287,6 +289,14 @@ export default {
</members-table-cell>
</template>
+ <template #cell(userCreatedAt)="{ item: member }">
+ <user-date :date="member.user.createdAt" />
+ </template>
+
+ <template #cell(lastActivityOn)="{ item: member }">
+ <user-date :date="member.user.lastActivityOn" />
+ </template>
+
<template #cell(actions)="{ item: member }">
<members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member">
<member-action-buttons
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index 49ce00a1689..c66a19c4765 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -9,6 +9,8 @@ export const FIELD_KEY_GRANTED = 'granted';
export const FIELD_KEY_INVITED = 'invited';
export const FIELD_KEY_REQUESTED = 'requested';
export const FIELD_KEY_MAX_ROLE = 'maxRole';
+export const FIELD_KEY_USER_CREATED_AT = 'userCreatedAt';
+export const FIELD_KEY_LAST_ACTIVITY_ON = 'lastActivityOn';
export const FIELD_KEY_EXPIRATION = 'expiration';
export const FIELD_KEY_LAST_SIGN_IN = 'lastSignIn';
export const FIELD_KEY_ACTIONS = 'actions';
@@ -67,6 +69,22 @@ export const FIELDS = [
tdClass: 'col-expiration',
},
{
+ key: FIELD_KEY_USER_CREATED_AT,
+ label: __('Created on'),
+ sort: {
+ asc: 'oldest_created_user',
+ desc: 'recent_created_user',
+ },
+ },
+ {
+ key: FIELD_KEY_LAST_ACTIVITY_ON,
+ label: __('Last activity'),
+ sort: {
+ asc: 'oldest_last_activity',
+ desc: 'recent_last_activity',
+ },
+ },
+ {
key: FIELD_KEY_LAST_SIGN_IN,
label: __('Last sign-in'),
sort: {
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 280b544af3c..f3e5ed9727a 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -12,9 +12,16 @@ const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-group-members-list-app'), {
[MEMBER_TYPES.user]: {
- tableFields: SHARED_FIELDS.concat(['source', 'granted']),
+ tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']),
tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
- tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'],
+ tableSortableFields: [
+ 'account',
+ 'granted',
+ 'maxRole',
+ 'lastSignIn',
+ 'userCreatedAt',
+ 'lastActivityOn',
+ ],
requestFormatter: groupMemberRequestFormatter,
filteredSearchBar: {
show: true,
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index 2c0394dc12c..bf4fb5f3b7e 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -18,9 +18,16 @@ initInviteGroupTrigger();
const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-project-members-list-app'), {
[MEMBER_TYPES.user]: {
- tableFields: SHARED_FIELDS.concat(['source', 'granted']),
+ tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']),
tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
- tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'],
+ tableSortableFields: [
+ 'account',
+ 'granted',
+ 'maxRole',
+ 'lastSignIn',
+ 'userCreatedAt',
+ 'lastActivityOn',
+ ],
requestFormatter: projectMemberRequestFormatter,
filteredSearchBar: {
show: true,
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index e53bfea7389..5bc855d0c18 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -1,112 +1,265 @@
<script>
-import { GlEmptyState, GlLink, GlButton } from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
-import { getParameterByName } from '~/lib/utils/url_utility';
+import { GlButton } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { historyPushState } from '~/lib/utils/common_utils';
+import { scrollUp } from '~/lib/utils/scroll_utils';
+import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
+import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants';
+import { convertAllReleasesGraphQLResponse } from '~/releases/util';
+import allReleasesQuery from '../graphql/queries/all_releases.query.graphql';
import ReleaseBlock from './release_block.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
+import ReleasesEmptyState from './releases_empty_state.vue';
import ReleasesPagination from './releases_pagination.vue';
import ReleasesSort from './releases_sort.vue';
export default {
- name: 'ReleasesApp',
+ name: 'ReleasesIndexApp',
components: {
- GlEmptyState,
- GlLink,
GlButton,
ReleaseBlock,
- ReleasesPagination,
ReleaseSkeletonLoader,
+ ReleasesEmptyState,
+ ReleasesPagination,
ReleasesSort,
},
+ inject: {
+ projectPath: {
+ default: '',
+ },
+ newReleasePath: {
+ default: '',
+ },
+ },
+ apollo: {
+ /**
+ * The same query as `fullGraphqlResponse`, except that it limits its
+ * results to a single item. This causes this request to complete much more
+ * quickly than `fullGraphqlResponse`, which allows the page to show
+ * meaningful content to the user much earlier.
+ */
+ singleGraphqlResponse: {
+ query: allReleasesQuery,
+ // This trick only works when paginating _forward_.
+ // When paginating backwards, limiting the query to a single item loads
+ // the _last_ item in the page, which is not useful for our purposes.
+ skip() {
+ return !this.includeSingleQuery;
+ },
+ variables() {
+ return {
+ ...this.queryVariables,
+ first: 1,
+ };
+ },
+ update(data) {
+ return { data };
+ },
+ error() {
+ this.singleRequestError = true;
+ },
+ },
+ fullGraphqlResponse: {
+ query: allReleasesQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return { data };
+ },
+ error(error) {
+ this.fullRequestError = true;
+
+ createFlash({
+ message: this.$options.i18n.errorMessage,
+ captureError: true,
+ error,
+ });
+ },
+ },
+ },
+ data() {
+ return {
+ singleRequestError: false,
+ fullRequestError: false,
+ cursors: {
+ before: getParameterByName('before'),
+ after: getParameterByName('after'),
+ },
+ sort: DEFAULT_SORT,
+ };
+ },
computed: {
- ...mapState('index', [
- 'documentationPath',
- 'illustrationPath',
- 'newReleasePath',
- 'isLoading',
- 'releases',
- 'hasError',
- ]),
- shouldRenderEmptyState() {
- return !this.releases.length && !this.hasError && !this.isLoading;
+ queryVariables() {
+ let paginationParams = { first: PAGE_SIZE };
+ if (this.cursors.after) {
+ paginationParams = {
+ after: this.cursors.after,
+ first: PAGE_SIZE,
+ };
+ } else if (this.cursors.before) {
+ paginationParams = {
+ before: this.cursors.before,
+ last: PAGE_SIZE,
+ };
+ }
+
+ return {
+ fullPath: this.projectPath,
+ ...paginationParams,
+ sort: this.sort,
+ };
+ },
+ /**
+ * @returns {Boolean} Whether or not to request/include
+ * the results of the single-item query
+ */
+ includeSingleQuery() {
+ return Boolean(!this.cursors.before || this.cursors.after);
+ },
+ isSingleRequestLoading() {
+ return this.$apollo.queries.singleGraphqlResponse.loading;
},
- shouldRenderSuccessState() {
- return this.releases.length && !this.isLoading && !this.hasError;
+ isFullRequestLoading() {
+ return this.$apollo.queries.fullGraphqlResponse.loading;
+ },
+ /**
+ * @returns {Boolean} `true` if the `singleGraphqlResponse`
+ * query has finished loading without errors
+ */
+ isSingleRequestLoaded() {
+ return Boolean(!this.isSingleRequestLoading && this.singleGraphqlResponse?.data.project);
+ },
+ /**
+ * @returns {Boolean} `true` if the `fullGraphqlResponse`
+ * query has finished loading without errors
+ */
+ isFullRequestLoaded() {
+ return Boolean(!this.isFullRequestLoading && this.fullGraphqlResponse?.data.project);
+ },
+ releases() {
+ if (this.isFullRequestLoaded) {
+ return convertAllReleasesGraphQLResponse(this.fullGraphqlResponse).data;
+ }
+
+ if (this.isSingleRequestLoaded && this.includeSingleQuery) {
+ return convertAllReleasesGraphQLResponse(this.singleGraphqlResponse).data;
+ }
+
+ return [];
},
- emptyStateText() {
- return __(
- "Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.",
+ pageInfo() {
+ if (!this.isFullRequestLoaded) {
+ return {
+ hasPreviousPage: false,
+ hasNextPage: false,
+ };
+ }
+
+ return this.fullGraphqlResponse.data.project.releases.pageInfo;
+ },
+ shouldRenderEmptyState() {
+ return this.isFullRequestLoaded && this.releases.length === 0;
+ },
+ shouldRenderLoadingIndicator() {
+ return (
+ (this.isSingleRequestLoading && !this.singleRequestError && !this.isFullRequestLoaded) ||
+ (this.isFullRequestLoading && !this.fullRequestError)
);
},
+ shouldRenderPagination() {
+ return this.isFullRequestLoaded && !this.shouldRenderEmptyState;
+ },
},
created() {
- this.fetchReleases();
+ this.updateQueryParamsFromUrl();
- window.addEventListener('popstate', this.fetchReleases);
+ window.addEventListener('popstate', this.updateQueryParamsFromUrl);
+ },
+ destroyed() {
+ window.removeEventListener('popstate', this.updateQueryParamsFromUrl);
},
methods: {
- ...mapActions('index', {
- fetchReleasesStoreAction: 'fetchReleases',
- }),
- fetchReleases() {
- this.fetchReleasesStoreAction({
- before: getParameterByName('before'),
- after: getParameterByName('after'),
- });
+ getReleaseKey(release, index) {
+ return [release.tagName, release.name, index].join('|');
+ },
+ updateQueryParamsFromUrl() {
+ this.cursors.before = getParameterByName('before');
+ this.cursors.after = getParameterByName('after');
+ },
+ onPaginationButtonPress() {
+ this.updateQueryParamsFromUrl();
+
+ // In some cases, Apollo Client is able to pull its results from the cache instead of making
+ // a new network request. In these cases, the page's content gets swapped out immediately without
+ // changing the page's scroll, leaving the user looking at the bottom of the new page.
+ // To make the experience consistent, regardless of how the data is sourced, we manually
+ // scroll to the top of the page every time a pagination button is pressed.
+ scrollUp();
+ },
+ onSortChanged(newSort) {
+ if (this.sort === newSort) {
+ return;
+ }
+
+ // Remove the "before" and "after" query parameters from the URL,
+ // effectively placing the user back on page 1 of the results.
+ // This prevents the frontend from requesting the results sorted
+ // by one field (e.g. `released_at`) while using a pagination cursor
+ // intended for a different field (e.g.) `created_at`).
+ // For more details, see the MR that introduced this change:
+ // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63434
+ historyPushState(
+ setUrlParams({
+ before: null,
+ after: null,
+ }),
+ );
+
+ this.updateQueryParamsFromUrl();
+
+ this.sort = newSort;
},
},
+ i18n: {
+ newRelease: __('New release'),
+ errorMessage: __('An error occurred while fetching the releases. Please try again.'),
+ },
};
</script>
<template>
<div class="flex flex-column mt-2">
<div class="gl-align-self-end gl-mb-3">
- <releases-sort class="gl-mr-2" @sort:changed="fetchReleases" />
+ <releases-sort :value="sort" class="gl-mr-2" @input="onSortChanged" />
<gl-button
v-if="newReleasePath"
:href="newReleasePath"
:aria-describedby="shouldRenderEmptyState && 'releases-description'"
category="primary"
- variant="confirm"
- data-testid="new-release-button"
+ variant="success"
+ >{{ $options.i18n.newRelease }}</gl-button
>
- {{ __('New release') }}
- </gl-button>
</div>
- <release-skeleton-loader v-if="isLoading" />
-
- <gl-empty-state
- v-else-if="shouldRenderEmptyState"
- data-testid="empty-state"
- :title="__('Getting started with releases')"
- :svg-path="illustrationPath"
- >
- <template #description>
- <span id="releases-description">
- {{ emptyStateText }}
- <gl-link
- :href="documentationPath"
- :aria-label="__('Releases documentation')"
- target="_blank"
- >
- {{ __('More information') }}
- </gl-link>
- </span>
- </template>
- </gl-empty-state>
-
- <div v-else-if="shouldRenderSuccessState" data-testid="success-state">
- <release-block
- v-for="(release, index) in releases"
- :key="index"
- :release="release"
- :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
- />
- </div>
+ <releases-empty-state v-if="shouldRenderEmptyState" />
+
+ <release-block
+ v-for="(release, index) in releases"
+ :key="getReleaseKey(release, index)"
+ :release="release"
+ :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
+ />
+
+ <release-skeleton-loader v-if="shouldRenderLoadingIndicator" />
- <releases-pagination v-if="!isLoading" />
+ <releases-pagination
+ v-if="shouldRenderPagination"
+ :page-info="pageInfo"
+ @prev="onPaginationButtonPress"
+ @next="onPaginationButtonPress"
+ />
</div>
</template>
<style>
diff --git a/app/assets/javascripts/releases/components/app_index_apollo_client.vue b/app/assets/javascripts/releases/components/app_index_apollo_client.vue
deleted file mode 100644
index f49c44a399f..00000000000
--- a/app/assets/javascripts/releases/components/app_index_apollo_client.vue
+++ /dev/null
@@ -1,275 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import allReleasesQuery from 'shared_queries/releases/all_releases.query.graphql';
-import createFlash from '~/flash';
-import { historyPushState } from '~/lib/utils/common_utils';
-import { scrollUp } from '~/lib/utils/scroll_utils';
-import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
-import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants';
-import { convertAllReleasesGraphQLResponse } from '~/releases/util';
-import ReleaseBlock from './release_block.vue';
-import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
-import ReleasesEmptyState from './releases_empty_state.vue';
-import ReleasesPaginationApolloClient from './releases_pagination_apollo_client.vue';
-import ReleasesSortApolloClient from './releases_sort_apollo_client.vue';
-
-export default {
- name: 'ReleasesIndexApolloClientApp',
- components: {
- GlButton,
- ReleaseBlock,
- ReleaseSkeletonLoader,
- ReleasesEmptyState,
- ReleasesPaginationApolloClient,
- ReleasesSortApolloClient,
- },
- inject: {
- projectPath: {
- default: '',
- },
- newReleasePath: {
- default: '',
- },
- },
- apollo: {
- /**
- * The same query as `fullGraphqlResponse`, except that it limits its
- * results to a single item. This causes this request to complete much more
- * quickly than `fullGraphqlResponse`, which allows the page to show
- * meaningful content to the user much earlier.
- */
- singleGraphqlResponse: {
- query: allReleasesQuery,
- // This trick only works when paginating _forward_.
- // When paginating backwards, limiting the query to a single item loads
- // the _last_ item in the page, which is not useful for our purposes.
- skip() {
- return !this.includeSingleQuery;
- },
- variables() {
- return {
- ...this.queryVariables,
- first: 1,
- };
- },
- update(data) {
- return { data };
- },
- error() {
- this.singleRequestError = true;
- },
- },
- fullGraphqlResponse: {
- query: allReleasesQuery,
- variables() {
- return this.queryVariables;
- },
- update(data) {
- return { data };
- },
- error(error) {
- this.fullRequestError = true;
-
- createFlash({
- message: this.$options.i18n.errorMessage,
- captureError: true,
- error,
- });
- },
- },
- },
- data() {
- return {
- singleRequestError: false,
- fullRequestError: false,
- cursors: {
- before: getParameterByName('before'),
- after: getParameterByName('after'),
- },
- sort: DEFAULT_SORT,
- };
- },
- computed: {
- queryVariables() {
- let paginationParams = { first: PAGE_SIZE };
- if (this.cursors.after) {
- paginationParams = {
- after: this.cursors.after,
- first: PAGE_SIZE,
- };
- } else if (this.cursors.before) {
- paginationParams = {
- before: this.cursors.before,
- last: PAGE_SIZE,
- };
- }
-
- return {
- fullPath: this.projectPath,
- ...paginationParams,
- sort: this.sort,
- };
- },
- /**
- * @returns {Boolean} Whether or not to request/include
- * the results of the single-item query
- */
- includeSingleQuery() {
- return Boolean(!this.cursors.before || this.cursors.after);
- },
- isSingleRequestLoading() {
- return this.$apollo.queries.singleGraphqlResponse.loading;
- },
- isFullRequestLoading() {
- return this.$apollo.queries.fullGraphqlResponse.loading;
- },
- /**
- * @returns {Boolean} `true` if the `singleGraphqlResponse`
- * query has finished loading without errors
- */
- isSingleRequestLoaded() {
- return Boolean(!this.isSingleRequestLoading && this.singleGraphqlResponse?.data.project);
- },
- /**
- * @returns {Boolean} `true` if the `fullGraphqlResponse`
- * query has finished loading without errors
- */
- isFullRequestLoaded() {
- return Boolean(!this.isFullRequestLoading && this.fullGraphqlResponse?.data.project);
- },
- releases() {
- if (this.isFullRequestLoaded) {
- return convertAllReleasesGraphQLResponse(this.fullGraphqlResponse).data;
- }
-
- if (this.isSingleRequestLoaded && this.includeSingleQuery) {
- return convertAllReleasesGraphQLResponse(this.singleGraphqlResponse).data;
- }
-
- return [];
- },
- pageInfo() {
- if (!this.isFullRequestLoaded) {
- return {
- hasPreviousPage: false,
- hasNextPage: false,
- };
- }
-
- return this.fullGraphqlResponse.data.project.releases.pageInfo;
- },
- shouldRenderEmptyState() {
- return this.isFullRequestLoaded && this.releases.length === 0;
- },
- shouldRenderLoadingIndicator() {
- return (
- (this.isSingleRequestLoading && !this.singleRequestError && !this.isFullRequestLoaded) ||
- (this.isFullRequestLoading && !this.fullRequestError)
- );
- },
- shouldRenderPagination() {
- return this.isFullRequestLoaded && !this.shouldRenderEmptyState;
- },
- },
- created() {
- this.updateQueryParamsFromUrl();
-
- window.addEventListener('popstate', this.updateQueryParamsFromUrl);
- },
- destroyed() {
- window.removeEventListener('popstate', this.updateQueryParamsFromUrl);
- },
- methods: {
- getReleaseKey(release, index) {
- return [release.tagName, release.name, index].join('|');
- },
- updateQueryParamsFromUrl() {
- this.cursors.before = getParameterByName('before');
- this.cursors.after = getParameterByName('after');
- },
- onPaginationButtonPress() {
- this.updateQueryParamsFromUrl();
-
- // In some cases, Apollo Client is able to pull its results from the cache instead of making
- // a new network request. In these cases, the page's content gets swapped out immediately without
- // changing the page's scroll, leaving the user looking at the bottom of the new page.
- // To make the experience consistent, regardless of how the data is sourced, we manually
- // scroll to the top of the page every time a pagination button is pressed.
- scrollUp();
- },
- onSortChanged(newSort) {
- if (this.sort === newSort) {
- return;
- }
-
- // Remove the "before" and "after" query parameters from the URL,
- // effectively placing the user back on page 1 of the results.
- // This prevents the frontend from requesting the results sorted
- // by one field (e.g. `released_at`) while using a pagination cursor
- // intended for a different field (e.g.) `created_at`).
- // For more details, see the MR that introduced this change:
- // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63434
- historyPushState(
- setUrlParams({
- before: null,
- after: null,
- }),
- );
-
- this.updateQueryParamsFromUrl();
-
- this.sort = newSort;
- },
- },
- i18n: {
- newRelease: __('New release'),
- errorMessage: __('An error occurred while fetching the releases. Please try again.'),
- },
-};
-</script>
-<template>
- <div class="flex flex-column mt-2">
- <div class="gl-align-self-end gl-mb-3">
- <releases-sort-apollo-client :value="sort" class="gl-mr-2" @input="onSortChanged" />
-
- <gl-button
- v-if="newReleasePath"
- :href="newReleasePath"
- :aria-describedby="shouldRenderEmptyState && 'releases-description'"
- category="primary"
- variant="success"
- >{{ $options.i18n.newRelease }}</gl-button
- >
- </div>
-
- <releases-empty-state v-if="shouldRenderEmptyState" />
-
- <release-block
- v-for="(release, index) in releases"
- :key="getReleaseKey(release, index)"
- :release="release"
- :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
- />
-
- <release-skeleton-loader v-if="shouldRenderLoadingIndicator" />
-
- <releases-pagination-apollo-client
- v-if="shouldRenderPagination"
- :page-info="pageInfo"
- @prev="onPaginationButtonPress"
- @next="onPaginationButtonPress"
- />
- </div>
-</template>
-<style>
-.linked-card::after {
- width: 1px;
- content: ' ';
- border: 1px solid #e5e5e5;
- height: 17px;
- top: 100%;
- position: absolute;
- left: 32px;
-}
-</style>
diff --git a/app/assets/javascripts/releases/components/releases_pagination.vue b/app/assets/javascripts/releases/components/releases_pagination.vue
index fddf85ead1e..52ad991d61a 100644
--- a/app/assets/javascripts/releases/components/releases_pagination.vue
+++ b/app/assets/javascripts/releases/components/releases_pagination.vue
@@ -1,26 +1,24 @@
<script>
import { GlKeysetPagination } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
+import { isBoolean } from 'lodash';
import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
export default {
- name: 'ReleasesPaginationGraphql',
+ name: 'ReleasesPagination',
components: { GlKeysetPagination },
- computed: {
- ...mapState('index', ['pageInfo']),
- showPagination() {
- return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
+ props: {
+ pageInfo: {
+ type: Object,
+ required: true,
+ validator: (info) => isBoolean(info.hasPreviousPage) && isBoolean(info.hasNextPage),
},
},
methods: {
- ...mapActions('index', ['fetchReleases']),
onPrev(before) {
historyPushState(buildUrlWithCurrentLocation(`?before=${before}`));
- this.fetchReleases({ before });
},
onNext(after) {
historyPushState(buildUrlWithCurrentLocation(`?after=${after}`));
- this.fetchReleases({ after });
},
},
};
@@ -28,8 +26,10 @@ export default {
<template>
<div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination
- v-if="showPagination"
v-bind="pageInfo"
+ :prev-text="__('Prev')"
+ :next-text="__('Next')"
+ v-on="$listeners"
@prev="onPrev($event)"
@next="onNext($event)"
/>
diff --git a/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue b/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue
deleted file mode 100644
index 73339677a4b..00000000000
--- a/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue
+++ /dev/null
@@ -1,37 +0,0 @@
-<script>
-import { GlKeysetPagination } from '@gitlab/ui';
-import { isBoolean } from 'lodash';
-import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
-
-export default {
- name: 'ReleasesPaginationApolloClient',
- components: { GlKeysetPagination },
- props: {
- pageInfo: {
- type: Object,
- required: true,
- validator: (info) => isBoolean(info.hasPreviousPage) && isBoolean(info.hasNextPage),
- },
- },
- methods: {
- onPrev(before) {
- historyPushState(buildUrlWithCurrentLocation(`?before=${before}`));
- },
- onNext(after) {
- historyPushState(buildUrlWithCurrentLocation(`?after=${after}`));
- },
- },
-};
-</script>
-<template>
- <div class="gl-display-flex gl-justify-content-center">
- <gl-keyset-pagination
- v-bind="pageInfo"
- :prev-text="__('Prev')"
- :next-text="__('Next')"
- v-on="$listeners"
- @prev="onPrev($event)"
- @next="onNext($event)"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/releases/components/releases_sort.vue b/app/assets/javascripts/releases/components/releases_sort.vue
index d4210dad19c..0f14b579da0 100644
--- a/app/assets/javascripts/releases/components/releases_sort.vue
+++ b/app/assets/javascripts/releases/components/releases_sort.vue
@@ -1,7 +1,17 @@
<script>
import { GlSorting, GlSortingItem } from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
-import { ASCENDING_ORDER, DESCENDING_ORDER, SORT_OPTIONS } from '../constants';
+import {
+ ASCENDING_ORDER,
+ DESCENDING_ORDER,
+ SORT_OPTIONS,
+ RELEASED_AT,
+ CREATED_AT,
+ RELEASED_AT_ASC,
+ RELEASED_AT_DESC,
+ CREATED_ASC,
+ ALL_SORTS,
+ SORT_MAP,
+} from '../constants';
export default {
name: 'ReleasesSort',
@@ -9,35 +19,54 @@ export default {
GlSorting,
GlSortingItem,
},
+ props: {
+ value: {
+ type: String,
+ required: true,
+ validator: (sort) => ALL_SORTS.includes(sort),
+ },
+ },
computed: {
- ...mapState('index', {
- orderBy: (state) => state.sorting.orderBy,
- sort: (state) => state.sorting.sort,
- }),
+ orderBy() {
+ if (this.value === RELEASED_AT_ASC || this.value === RELEASED_AT_DESC) {
+ return RELEASED_AT;
+ }
+
+ return CREATED_AT;
+ },
+ direction() {
+ if (this.value === RELEASED_AT_ASC || this.value === CREATED_ASC) {
+ return ASCENDING_ORDER;
+ }
+
+ return DESCENDING_ORDER;
+ },
sortOptions() {
return SORT_OPTIONS;
},
sortText() {
- const option = this.sortOptions.find((s) => s.orderBy === this.orderBy);
- return option.label;
+ return this.sortOptions.find((s) => s.orderBy === this.orderBy).label;
},
- isSortAscending() {
- return this.sort === ASCENDING_ORDER;
+ isDirectionAscending() {
+ return this.direction === ASCENDING_ORDER;
},
},
methods: {
- ...mapActions('index', ['setSorting']),
onDirectionChange() {
- const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
- this.setSorting({ sort });
- this.$emit('sort:changed');
+ const direction = this.isDirectionAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
+ this.emitInputEventIfChanged(this.orderBy, direction);
},
onSortItemClick(item) {
- this.setSorting({ orderBy: item });
- this.$emit('sort:changed');
+ this.emitInputEventIfChanged(item.orderBy, this.direction);
},
isActiveSortItem(item) {
- return this.orderBy === item;
+ return this.orderBy === item.orderBy;
+ },
+ emitInputEventIfChanged(orderBy, direction) {
+ const newSort = SORT_MAP[orderBy][direction];
+ if (newSort !== this.value) {
+ this.$emit('input', SORT_MAP[orderBy][direction]);
+ }
},
},
};
@@ -46,15 +75,15 @@ export default {
<template>
<gl-sorting
:text="sortText"
- :is-ascending="isSortAscending"
+ :is-ascending="isDirectionAscending"
data-testid="releases-sort"
@sortDirectionChange="onDirectionChange"
>
<gl-sorting-item
- v-for="item in sortOptions"
+ v-for="item of sortOptions"
:key="item.orderBy"
- :active="isActiveSortItem(item.orderBy)"
- @click="onSortItemClick(item.orderBy)"
+ :active="isActiveSortItem(item)"
+ @click="onSortItemClick(item)"
>
{{ item.label }}
</gl-sorting-item>
diff --git a/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue b/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue
deleted file mode 100644
index 7257b34bbf6..00000000000
--- a/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue
+++ /dev/null
@@ -1,91 +0,0 @@
-<script>
-import { GlSorting, GlSortingItem } from '@gitlab/ui';
-import {
- ASCENDING_ORDER,
- DESCENDING_ORDER,
- SORT_OPTIONS,
- RELEASED_AT,
- CREATED_AT,
- RELEASED_AT_ASC,
- RELEASED_AT_DESC,
- CREATED_ASC,
- ALL_SORTS,
- SORT_MAP,
-} from '../constants';
-
-export default {
- name: 'ReleasesSortApolloclient',
- components: {
- GlSorting,
- GlSortingItem,
- },
- props: {
- value: {
- type: String,
- required: true,
- validator: (sort) => ALL_SORTS.includes(sort),
- },
- },
- computed: {
- orderBy() {
- if (this.value === RELEASED_AT_ASC || this.value === RELEASED_AT_DESC) {
- return RELEASED_AT;
- }
-
- return CREATED_AT;
- },
- direction() {
- if (this.value === RELEASED_AT_ASC || this.value === CREATED_ASC) {
- return ASCENDING_ORDER;
- }
-
- return DESCENDING_ORDER;
- },
- sortOptions() {
- return SORT_OPTIONS;
- },
- sortText() {
- return this.sortOptions.find((s) => s.orderBy === this.orderBy).label;
- },
- isDirectionAscending() {
- return this.direction === ASCENDING_ORDER;
- },
- },
- methods: {
- onDirectionChange() {
- const direction = this.isDirectionAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
- this.emitInputEventIfChanged(this.orderBy, direction);
- },
- onSortItemClick(item) {
- this.emitInputEventIfChanged(item.orderBy, this.direction);
- },
- isActiveSortItem(item) {
- return this.orderBy === item.orderBy;
- },
- emitInputEventIfChanged(orderBy, direction) {
- const newSort = SORT_MAP[orderBy][direction];
- if (newSort !== this.value) {
- this.$emit('input', SORT_MAP[orderBy][direction]);
- }
- },
- },
-};
-</script>
-
-<template>
- <gl-sorting
- :text="sortText"
- :is-ascending="isDirectionAscending"
- data-testid="releases-sort"
- @sortDirectionChange="onDirectionChange"
- >
- <gl-sorting-item
- v-for="item of sortOptions"
- :key="item.orderBy"
- :active="isActiveSortItem(item)"
- @click="onSortItemClick(item)"
- >
- {{ item.label }}
- </gl-sorting-item>
- </gl-sorting>
-</template>
diff --git a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
index 7f67f7d11a3..bda7ac52a47 100644
--- a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
+++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
@@ -1,12 +1,4 @@
-#import "../fragments/release.fragment.graphql"
-
-# This query is identical to
-# `app/graphql/queries/releases/all_releases.query.graphql`.
-# These two queries should be kept in sync.
-# When the `releases_index_apollo_client` feature flag is
-# removed, this query should be removed entirely.
-
-query allReleasesDeprecated(
+query allReleases(
$fullPath: ID!
$first: Int
$last: Int
@@ -20,7 +12,87 @@ query allReleasesDeprecated(
releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) {
__typename
nodes {
- ...Release
+ __typename
+ name
+ tagName
+ tagPath
+ descriptionHtml
+ releasedAt
+ createdAt
+ upcomingRelease
+ assets {
+ __typename
+ count
+ sources {
+ __typename
+ nodes {
+ __typename
+ format
+ url
+ }
+ }
+ links {
+ __typename
+ nodes {
+ __typename
+ id
+ name
+ url
+ directAssetUrl
+ linkType
+ external
+ }
+ }
+ }
+ evidences {
+ __typename
+ nodes {
+ __typename
+ id
+ filepath
+ collectedAt
+ sha
+ }
+ }
+ links {
+ __typename
+ editUrl
+ selfUrl
+ openedIssuesUrl
+ closedIssuesUrl
+ openedMergeRequestsUrl
+ mergedMergeRequestsUrl
+ closedMergeRequestsUrl
+ }
+ commit {
+ __typename
+ id
+ sha
+ webUrl
+ title
+ }
+ author {
+ __typename
+ id
+ webUrl
+ avatarUrl
+ username
+ }
+ milestones {
+ __typename
+ nodes {
+ __typename
+ id
+ title
+ description
+ webPath
+ stats {
+ __typename
+ totalIssuesCount
+ closedIssuesCount
+ }
+ }
+ }
}
pageInfo {
__typename
diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js
index 86fa72d1496..afb8ab461cd 100644
--- a/app/assets/javascripts/releases/mount_index.js
+++ b/app/assets/javascripts/releases/mount_index.js
@@ -1,50 +1,32 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import Vuex from 'vuex';
import createDefaultClient from '~/lib/graphql';
import ReleaseIndexApp from './components/app_index.vue';
-import ReleaseIndexApollopClientApp from './components/app_index_apollo_client.vue';
-import createStore from './stores';
-import createIndexModule from './stores/modules/index';
export default () => {
const el = document.getElementById('js-releases-page');
- if (window.gon?.features?.releasesIndexApolloClient) {
- Vue.use(VueApollo);
+ Vue.use(VueApollo);
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- // This page attempts to decrease the perceived loading time
- // by sending two requests: one request for the first item only (which
- // completes relatively quickly), and one for all the items (which is slower).
- // By default, Apollo Client batches these requests together, which defeats
- // the purpose of making separate requests. So we explicitly
- // disable batching on this page.
- batchMax: 1,
- },
- ),
- });
-
- return new Vue({
- el,
- apolloProvider,
- provide: { ...el.dataset },
- render: (h) => h(ReleaseIndexApollopClientApp),
- });
- }
-
- Vue.use(Vuex);
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ // This page attempts to decrease the perceived loading time
+ // by sending two requests: one request for the first item only (which
+ // completes relatively quickly), and one for all the items (which is slower).
+ // By default, Apollo Client batches these requests together, which defeats
+ // the purpose of making separate requests. So we explicitly
+ // disable batching on this page.
+ batchMax: 1,
+ },
+ ),
+ });
return new Vue({
el,
- store: createStore({
- modules: {
- index: createIndexModule(el.dataset),
- },
- }),
+ apolloProvider,
+ provide: { ...el.dataset },
render: (h) => h(ReleaseIndexApp),
});
};
diff --git a/app/assets/javascripts/releases/stores/modules/index/actions.js b/app/assets/javascripts/releases/stores/modules/index/actions.js
deleted file mode 100644
index d3bb11cab30..00000000000
--- a/app/assets/javascripts/releases/stores/modules/index/actions.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import createFlash from '~/flash';
-import { __ } from '~/locale';
-import { PAGE_SIZE } from '~/releases/constants';
-import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
-import { gqClient, convertAllReleasesGraphQLResponse } from '~/releases/util';
-import * as types from './mutation_types';
-
-/**
- * Gets a paginated list of releases from the GraphQL endpoint
- *
- * @param {Object} vuexParams
- * @param {Object} actionParams
- * @param {String} [actionParams.before] A GraphQL cursor. If provided,
- * the items returned will proceed the provided cursor.
- * @param {String} [actionParams.after] A GraphQL cursor. If provided,
- * the items returned will follow the provided cursor.
- */
-export const fetchReleases = ({ dispatch, commit, state }, { before, after }) => {
- commit(types.REQUEST_RELEASES);
-
- const { sort, orderBy } = state.sorting;
- const orderByParam = orderBy === 'created_at' ? 'created' : orderBy;
- const sortParams = `${orderByParam}_${sort}`.toUpperCase();
-
- let paginationParams;
- if (!before && !after) {
- paginationParams = { first: PAGE_SIZE };
- } else if (before && !after) {
- paginationParams = { last: PAGE_SIZE, before };
- } else if (!before && after) {
- paginationParams = { first: PAGE_SIZE, after };
- } else {
- throw new Error(
- 'Both a `before` and an `after` parameter were provided to fetchReleases. These parameters cannot be used together.',
- );
- }
-
- gqClient
- .query({
- query: allReleasesQuery,
- variables: {
- fullPath: state.projectPath,
- sort: sortParams,
- ...paginationParams,
- },
- })
- .then((response) => {
- const { data, paginationInfo: pageInfo } = convertAllReleasesGraphQLResponse(response);
-
- commit(types.RECEIVE_RELEASES_SUCCESS, {
- data,
- pageInfo,
- });
- })
- .catch(() => dispatch('receiveReleasesError'));
-};
-
-export const receiveReleasesError = ({ commit }) => {
- commit(types.RECEIVE_RELEASES_ERROR);
- createFlash({
- message: __('An error occurred while fetching the releases. Please try again.'),
- });
-};
-
-export const setSorting = ({ commit }, data) => commit(types.SET_SORTING, data);
diff --git a/app/assets/javascripts/releases/stores/modules/index/index.js b/app/assets/javascripts/releases/stores/modules/index/index.js
deleted file mode 100644
index d5ca191153a..00000000000
--- a/app/assets/javascripts/releases/stores/modules/index/index.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import * as actions from './actions';
-import mutations from './mutations';
-import createState from './state';
-
-export default (initialState) => ({
- namespaced: true,
- actions,
- mutations,
- state: createState(initialState),
-});
diff --git a/app/assets/javascripts/releases/stores/modules/index/mutation_types.js b/app/assets/javascripts/releases/stores/modules/index/mutation_types.js
deleted file mode 100644
index 669168efb88..00000000000
--- a/app/assets/javascripts/releases/stores/modules/index/mutation_types.js
+++ /dev/null
@@ -1,4 +0,0 @@
-export const REQUEST_RELEASES = 'REQUEST_RELEASES';
-export const RECEIVE_RELEASES_SUCCESS = 'RECEIVE_RELEASES_SUCCESS';
-export const RECEIVE_RELEASES_ERROR = 'RECEIVE_RELEASES_ERROR';
-export const SET_SORTING = 'SET_SORTING';
diff --git a/app/assets/javascripts/releases/stores/modules/index/mutations.js b/app/assets/javascripts/releases/stores/modules/index/mutations.js
deleted file mode 100644
index 55a8a488be8..00000000000
--- a/app/assets/javascripts/releases/stores/modules/index/mutations.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import * as types from './mutation_types';
-
-export default {
- /**
- * Sets isLoading to true while the request is being made.
- * @param {Object} state
- */
- [types.REQUEST_RELEASES](state) {
- state.isLoading = true;
- },
-
- /**
- * Sets isLoading to false.
- * Sets hasError to false.
- * Sets the received data
- * Sets the received pagination information
- * @param {Object} state
- * @param {Object} resp
- */
- [types.RECEIVE_RELEASES_SUCCESS](state, { data, pageInfo }) {
- state.hasError = false;
- state.isLoading = false;
- state.releases = data;
- state.pageInfo = pageInfo;
- },
-
- /**
- * Sets isLoading to false.
- * Sets hasError to true.
- * Resets the data
- * @param {Object} state
- * @param {Object} data
- */
- [types.RECEIVE_RELEASES_ERROR](state) {
- state.isLoading = false;
- state.releases = [];
- state.hasError = true;
- state.pageInfo = {};
- },
-
- [types.SET_SORTING](state, sorting) {
- state.sorting = { ...state.sorting, ...sorting };
- },
-};
diff --git a/app/assets/javascripts/releases/stores/modules/index/state.js b/app/assets/javascripts/releases/stores/modules/index/state.js
deleted file mode 100644
index 5e1aaab7b58..00000000000
--- a/app/assets/javascripts/releases/stores/modules/index/state.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { DESCENDING_ORDER, RELEASED_AT } from '../../../constants';
-
-export default ({
- projectId,
- projectPath,
- documentationPath,
- illustrationPath,
- newReleasePath = '',
-}) => ({
- projectId,
- projectPath,
- documentationPath,
- illustrationPath,
- newReleasePath,
-
- isLoading: false,
- hasError: false,
- releases: [],
- pageInfo: {},
- sorting: {
- sort: DESCENDING_ORDER,
- orderBy: RELEASED_AT,
- },
-});
diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js
index cbe18ea396e..81d2168e2ce 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/index.js
+++ b/app/assets/javascripts/repository/components/blob_viewers/index.js
@@ -8,6 +8,7 @@ const viewers = {
pdf: () => import('./pdf_viewer.vue'),
lfs: () => import('./lfs_viewer.vue'),
audio: () => import('./audio_viewer.vue'),
+ svg: () => import('./image_viewer.vue'),
};
export const loadViewer = (type, isUsingLfs) => {
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 7f960e3da51..ef17600b2d2 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -520,8 +520,22 @@
}
&.is-active {
- /* stylelint-disable-next-line function-url-quotes */
- background: url(asset_path('checkmark.png')) no-repeat 14px center;
+ position: relative;
+
+ &::before {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 0.5rem;
+ left: 1rem;
+ width: 1rem;
+ height: 1rem;
+ mask-image: asset_url('icons-stacked.svg#check');
+ mask-repeat: no-repeat;
+ mask-size: cover;
+ mask-position: center center;
+ background: $black-normal;
+ }
}
}
}
@@ -692,7 +706,7 @@
.dropdown-label-box {
position: relative;
- top: 3px;
+ top: 0;
margin-right: 5px;
display: inline-block;
width: 15px;
diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb
index 0655d779a4e..36ae919e510 100644
--- a/app/controllers/groups/group_links_controller.rb
+++ b/app/controllers/groups/group_links_controller.rb
@@ -6,24 +6,6 @@ class Groups::GroupLinksController < Groups::ApplicationController
feature_category :subgroups
- def create
- shared_with_group = Group.find(params[:shared_with_group_id]) if params[:shared_with_group_id].present?
-
- if shared_with_group
- result = Groups::GroupLinks::CreateService
- .new(group, shared_with_group, current_user, group_link_create_params)
- .execute
-
- return render_404 if result[:http_status] == 404
-
- flash[:alert] = result[:message] if result[:status] == :error
- else
- flash[:alert] = _('Please select a group.')
- end
-
- redirect_to group_group_members_path(group)
- end
-
def update
Groups::GroupLinks::UpdateService.new(@group_link).execute(group_link_params)
@@ -54,10 +36,6 @@ class Groups::GroupLinksController < Groups::ApplicationController
@group_link ||= group.shared_with_group_links.find(params[:id])
end
- def group_link_create_params
- params.permit(:shared_group_access, :expires_at)
- end
-
def group_link_params
params.require(:group_link).permit(:group_access, :expires_at)
end
diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index 6bc81381d92..6007e09f109 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -7,21 +7,6 @@ class Projects::GroupLinksController < Projects::ApplicationController
feature_category :subgroups
- def create
- group = Group.find(params[:link_group_id]) if params[:link_group_id].present?
-
- if group
- result = Projects::GroupLinks::CreateService.new(project, current_user, group_link_create_params).execute(group)
- return render_404 if result[:http_status] == 404
-
- flash[:alert] = result[:message] if result[:http_status] == 409
- else
- flash[:alert] = _('Please select a group.')
- end
-
- redirect_to project_project_members_path(project)
- end
-
def update
group_link = @project.project_group_links.find(params[:id])
Projects::GroupLinks::UpdateService.new(group_link).execute(group_link_params)
@@ -54,10 +39,4 @@ class Projects::GroupLinksController < Projects::ApplicationController
def group_link_params
params.require(:group_link).permit(:group_access, :expires_at)
end
-
- def group_link_create_params
- params.permit(:link_group_access, :expires_at)
- end
end
-
-Projects::GroupLinksController.prepend_mod_with('Projects::GroupLinksController')
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index 1a2baf96020..19413d97d9d 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -8,9 +8,6 @@ class Projects::ReleasesController < Projects::ApplicationController
before_action :authorize_update_release!, only: %i[edit update]
before_action :authorize_create_release!, only: :new
before_action :validate_suffix_path, :fetch_latest_tag, only: :latest_permalink
- before_action only: :index do
- push_frontend_feature_flag(:releases_index_apollo_client, project, default_enabled: :yaml)
- end
feature_category :release_orchestration
diff --git a/app/graphql/mutations/todos/mark_all_done.rb b/app/graphql/mutations/todos/mark_all_done.rb
index 7dd06cc8293..67a822c1067 100644
--- a/app/graphql/mutations/todos/mark_all_done.rb
+++ b/app/graphql/mutations/todos/mark_all_done.rb
@@ -7,14 +7,22 @@ module Mutations
authorize :update_user
+ TodoableID = Types::GlobalIDType[Todoable]
+
+ argument :target_id,
+ TodoableID,
+ required: false,
+ description: "Global ID of the to-do item's parent. Issues, merge requests, designs, and epics are supported. " \
+ "If argument is omitted, all pending to-do items of the current user are marked as done."
+
field :todos, [::Types::TodoType],
null: false,
description: 'Updated to-do items.'
- def resolve
+ def resolve(**args)
authorize!(current_user)
- updated_ids = mark_all_todos_done
+ updated_ids = mark_all_todos_done(**args)
{
todos: Todo.id_in(updated_ids),
@@ -24,10 +32,23 @@ module Mutations
private
- def mark_all_todos_done
+ def mark_all_todos_done(**args)
return [] unless current_user
- todos = TodosFinder.new(current_user).execute
+ finder_params = { state: :pending }
+
+ if args[:target_id].present?
+ target = Gitlab::Graphql::Lazy.force(
+ GitlabSchema.find_by_gid(TodoableID.coerce_isolated_input(args[:target_id]))
+ )
+
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, "Resource not available: #{args[:target_id]}" if target.nil?
+
+ finder_params[:type] = target.class.name
+ finder_params[:target_id] = target.id
+ end
+
+ todos = TodosFinder.new(current_user, finder_params).execute
TodoService.new.resolve_todos(todos, current_user, resolved_by_action: :api_all_done)
end
diff --git a/app/graphql/queries/releases/all_releases.query.graphql b/app/graphql/queries/releases/all_releases.query.graphql
deleted file mode 100644
index 150f59832f3..00000000000
--- a/app/graphql/queries/releases/all_releases.query.graphql
+++ /dev/null
@@ -1,109 +0,0 @@
-# This query is identical to
-# `app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql`.
-# These two queries should be kept in sync.
-query allReleases(
- $fullPath: ID!
- $first: Int
- $last: Int
- $before: String
- $after: String
- $sort: ReleaseSort
-) {
- project(fullPath: $fullPath) {
- __typename
- id
- releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) {
- __typename
- nodes {
- __typename
- name
- tagName
- tagPath
- descriptionHtml
- releasedAt
- createdAt
- upcomingRelease
- assets {
- __typename
- count
- sources {
- __typename
- nodes {
- __typename
- format
- url
- }
- }
- links {
- __typename
- nodes {
- __typename
- id
- name
- url
- directAssetUrl
- linkType
- external
- }
- }
- }
- evidences {
- __typename
- nodes {
- __typename
- id
- filepath
- collectedAt
- sha
- }
- }
- links {
- __typename
- editUrl
- selfUrl
- openedIssuesUrl
- closedIssuesUrl
- openedMergeRequestsUrl
- mergedMergeRequestsUrl
- closedMergeRequestsUrl
- }
- commit {
- __typename
- id
- sha
- webUrl
- title
- }
- author {
- __typename
- id
- webUrl
- avatarUrl
- username
- }
- milestones {
- __typename
- nodes {
- __typename
- id
- title
- description
- webPath
- stats {
- __typename
- totalIssuesCount
- closedIssuesCount
- }
- }
- }
- }
- pageInfo {
- __typename
- startCursor
- hasPreviousPage
- hasNextPage
- endCursor
- }
- }
- }
-}
diff --git a/app/models/blob.rb b/app/models/blob.rb
index cc7758d9674..f5ccd163ea6 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -35,7 +35,6 @@ class Blob < SimpleDelegator
BlobViewer::Image,
BlobViewer::Sketch,
- BlobViewer::Balsamiq,
BlobViewer::Video,
BlobViewer::Audio,
diff --git a/app/models/blob_viewer/balsamiq.rb b/app/models/blob_viewer/balsamiq.rb
deleted file mode 100644
index 6ab73730222..00000000000
--- a/app/models/blob_viewer/balsamiq.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# frozen_string_literal: true
-
-module BlobViewer
- class Balsamiq < Base
- include Rich
- include ClientSide
-
- self.partial_name = 'balsamiq'
- self.extensions = %w(bmpr)
- self.binary = true
- self.switcher_icon = 'doc-image'
- self.switcher_title = 'preview'
- end
-end
diff --git a/app/models/member.rb b/app/models/member.rb
index 528c6855d9c..e00f82614c6 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -181,6 +181,10 @@ class Member < ApplicationRecord
scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) }
scope :order_recent_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'DESC')) }
scope :order_oldest_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'ASC')) }
+ scope :order_recent_last_activity, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_activity_on', 'DESC')) }
+ scope :order_oldest_last_activity, -> { left_join_users.reorder(Gitlab::Database.nulls_first_order('users.last_activity_on', 'ASC')) }
+ scope :order_recent_created_user, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.created_at', 'DESC')) }
+ scope :order_oldest_created_user, -> { left_join_users.reorder(Gitlab::Database.nulls_first_order('users.created_at', 'ASC')) }
scope :on_project_and_ancestors, ->(project) { where(source: [project] + project.ancestors) }
@@ -232,6 +236,10 @@ class Member < ApplicationRecord
when 'access_level_desc' then reorder(access_level: :desc)
when 'recent_sign_in' then order_recent_sign_in
when 'oldest_sign_in' then order_oldest_sign_in
+ when 'recent_created_user' then order_recent_created_user
+ when 'oldest_created_user' then order_oldest_created_user
+ when 'recent_last_activity' then order_recent_last_activity
+ when 'oldest_last_activity' then order_oldest_last_activity
when 'last_joined' then order_created_desc
when 'oldest_joined' then order_created_asc
else
diff --git a/app/models/project.rb b/app/models/project.rb
index 8f8f6cbf81f..c0d00408c1c 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -656,7 +656,9 @@ class Project < ApplicationRecord
preload(:project_feature, :route, namespace: [:route, :owner])
}
+ scope :created_by, -> (user) { where(creator: user) }
scope :imported_from, -> (type) { where(import_type: type) }
+ scope :imported, -> { where.not(import_type: nil) }
scope :with_tracing_enabled, -> { joins(:tracing_setting) }
scope :with_enabled_error_tracking, -> { joins(:error_tracking_setting).where(project_error_tracking_settings: { enabled: true }) }
diff --git a/app/serializers/member_user_entity.rb b/app/serializers/member_user_entity.rb
index fde3282ad25..b3d8efc9143 100644
--- a/app/serializers/member_user_entity.rb
+++ b/app/serializers/member_user_entity.rb
@@ -5,6 +5,9 @@ class MemberUserEntity < UserEntity
unexpose :state
unexpose :status_tooltip_html
+ expose :created_at
+ expose :last_activity_on
+
expose :avatar_url do |user|
user.avatar_url(size: Member::AVATAR_SIZE, only_path: false)
end
diff --git a/app/views/projects/blob/viewers/_balsamiq.html.haml b/app/views/projects/blob/viewers/_balsamiq.html.haml
deleted file mode 100644
index b20106e8c3a..00000000000
--- a/app/views/projects/blob/viewers/_balsamiq.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: blob_raw_path } }
diff --git a/app/workers/container_registry/migration/enqueuer_worker.rb b/app/workers/container_registry/migration/enqueuer_worker.rb
index 0dbbd27e309..7309895c906 100644
--- a/app/workers/container_registry/migration/enqueuer_worker.rb
+++ b/app/workers/container_registry/migration/enqueuer_worker.rb
@@ -14,48 +14,52 @@ module ContainerRegistry
idempotent!
def perform
- return unless migration.enabled?
- return unless below_capacity?
- return unless waiting_time_passed?
+ return unless runnable?
re_enqueue_if_capacity if handle_aborted_migration || handle_next_migration
- rescue StandardError => e
- Gitlab::ErrorTracking.log_exception(
- e,
- next_repository_id: next_repository&.id,
- next_aborted_repository_id: next_aborted_repository&.id
- )
-
- next_repository&.abort_import
end
private
def handle_aborted_migration
- return unless next_aborted_repository&.retry_aborted_migration
+ return unless next_aborted_repository
- log_extra_metadata_on_done(:container_repository_id, next_aborted_repository.id)
log_extra_metadata_on_done(:import_type, 'retry')
+ log_repository(next_aborted_repository)
+
+ next_aborted_repository.retry_aborted_migration
+
+ true
+ rescue StandardError => e
+ Gitlab::ErrorTracking.log_exception(e, next_aborted_repository_id: next_aborted_repository&.id)
true
end
def handle_next_migration
return unless next_repository
+
+ log_extra_metadata_on_done(:import_type, 'next')
+ log_repository(next_repository)
+
# We return true because the repository was successfully processed (migration_state is changed)
return true if tag_count_too_high?
return unless next_repository.start_pre_import
- log_extra_metadata_on_done(:container_repository_id, next_repository.id)
- log_extra_metadata_on_done(:import_type, 'next')
-
true
+ rescue StandardError => e
+ Gitlab::ErrorTracking.log_exception(e, next_repository_id: next_repository&.id)
+ next_repository&.abort_import
+
+ false
end
def tag_count_too_high?
return false unless next_repository.tags_count > migration.max_tags_count
next_repository.skip_import(reason: :too_many_tags)
+ log_extra_metadata_on_done(:tags_count_too_high, true)
+ log_extra_metadata_on_done(:max_tags_count_setting, migration.max_tags_count)
true
end
@@ -72,6 +76,29 @@ module ContainerRegistry
last_step_completed_repository.last_import_step_done_at < Time.zone.now - delay
end
+ def runnable?
+ unless migration.enabled?
+ log_extra_metadata_on_done(:migration_enabled, false)
+ return false
+ end
+
+ unless below_capacity?
+ log_extra_metadata_on_done(:max_capacity_setting, maximum_capacity)
+ log_extra_metadata_on_done(:below_capacity, false)
+
+ return false
+ end
+
+ unless waiting_time_passed?
+ log_extra_metadata_on_done(:waiting_time_passed, false)
+ log_extra_metadata_on_done(:current_waiting_time_setting, migration.enqueue_waiting_time)
+
+ return false
+ end
+
+ true
+ end
+
def current_capacity
strong_memoize(:current_capacity) do
ContainerRepository.with_migration_states(
@@ -111,6 +138,11 @@ module ContainerRegistry
self.class.perform_async
end
+
+ def log_repository(repository)
+ log_extra_metadata_on_done(:container_repository_id, repository&.id)
+ log_extra_metadata_on_done(:container_repository_path, repository&.path)
+ end
end
end
end
diff --git a/config/feature_flags/development/releases_index_apollo_client.yml b/config/feature_flags/development/releases_index_apollo_client.yml
deleted file mode 100644
index 072d72af573..00000000000
--- a/config/feature_flags/development/releases_index_apollo_client.yml
+++ /dev/null
@@ -1,8 +0,0 @@
----
-name: releases_index_apollo_client
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61828
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/331006
-milestone: '14.0'
-type: development
-group: group::release
-default_enabled: true
diff --git a/config/feature_flags/ops/purge_stale_security_findings.yml b/config/feature_flags/ops/purge_stale_security_findings.yml
index 322f31b62ce..0c011a1ddae 100644
--- a/config/feature_flags/ops/purge_stale_security_findings.yml
+++ b/config/feature_flags/ops/purge_stale_security_findings.yml
@@ -1,7 +1,7 @@
---
name: purge_stale_security_findings
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81423
-rollout_issue_url:
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/356464
milestone: '14.9'
type: ops
group: group::threat insights
diff --git a/config/routes/group.rb b/config/routes/group.rb
index fecd3135cba..7252bdc251d 100644
--- a/config/routes/group.rb
+++ b/config/routes/group.rb
@@ -99,7 +99,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
delete :leave, on: :collection
end
- resources :group_links, only: [:create, :update, :destroy], constraints: { id: /\d+|:id/ }
+ resources :group_links, only: [:update, :destroy], constraints: { id: /\d+|:id/ }
resources :uploads, only: [:create] do
collection do
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 1783f3acc68..a3f6139a6ef 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -247,7 +247,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :starrers, only: [:index]
resources :forks, only: [:index, :new, :create]
- resources :group_links, only: [:create, :update, :destroy], constraints: { id: /\d+|:id/ }
+ resources :group_links, only: [:update, :destroy], constraints: { id: /\d+|:id/ }
resource :import, only: [:new, :create, :show]
resource :avatar, only: [:show, :destroy]
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index a0f1ea4fa06..5a1ca238aa7 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -415,6 +415,8 @@
- 1
- - security_findings_delete_by_job_id
- 1
+- - security_orchestration_policy_rule_schedule_namespace
+ - 1
- - security_scans
- 2
- - self_monitoring_project_create
diff --git a/config/webpack.config.js b/config/webpack.config.js
index 4cc490362ad..aff906353f6 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -326,7 +326,7 @@ module.exports = {
],
},
{
- test: /\.(worker(\.min)?\.js|pdf|bmpr)$/,
+ test: /\.(worker(\.min)?\.js|pdf)$/,
exclude: /node_modules/,
loader: 'file-loader',
options: {
@@ -738,7 +738,7 @@ module.exports = {
devtool: NO_SOURCEMAPS ? false : devtool,
node: {
- fs: 'empty', // sqljs requires fs
+ fs: 'empty', // editorconfig requires 'fs'
setImmediate: false,
},
};
diff --git a/config/webpack.vendor.config.js b/config/webpack.vendor.config.js
index 30d60c0b5e6..ff38497a05e 100644
--- a/config/webpack.vendor.config.js
+++ b/config/webpack.vendor.config.js
@@ -28,7 +28,6 @@ module.exports = {
'jquery/dist/jquery.slim.js',
'pdfjs-dist/build/pdf',
'pdfjs-dist/build/pdf.worker.min',
- 'sql.js',
'core-js',
'echarts',
'lodash',
diff --git a/data/deprecations/14-9-background-upload.yml b/data/deprecations/14-9-background-upload.yml
new file mode 100644
index 00000000000..337d5f89c77
--- /dev/null
+++ b/data/deprecations/14-9-background-upload.yml
@@ -0,0 +1,20 @@
+- name: "Background upload for object storage"
+ announcement_milestone: "14.9"
+ announcement_date: "2022-03-22"
+ removal_milestone: "15.0"
+ removal_date: "2022-05-22"
+ breaking_change: true
+ reporter: fzimmer
+ body: | # Do not modify this line, instead modify the lines below.
+ To reduce the overall complexity and maintenance burden of GitLab's [object storage feature](https://docs.gitlab.com/ee/administration/object_storage.html), support for using `background_upload` to upload files is deprecated and will be fully removed in GitLab 15.0.
+
+ This impacts a small subset of object storage providers:
+
+ - **OpenStack** Customers using OpenStack need to change their configuration to use the S3 API instead of Swift.
+ - **RackSpace** Customers using RackSpace-based object storage need to migrate data to a different provider.
+
+ GitLab will publish additional guidance to assist affected customers in migrating.
+ stage: Enablement
+ tiers: [Core, Premium, Ultimate]
+ issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/26600
+ documentation_url: https://docs.gitlab.com/ee/administration/object_storage.html
diff --git a/data/whats_new/202203210001_14_09.yml b/data/whats_new/202203210001_14_09.yml
new file mode 100644
index 00000000000..afdbfe353bd
--- /dev/null
+++ b/data/whats_new/202203210001_14_09.yml
@@ -0,0 +1,85 @@
+- title: "Link an epic to another epic"
+ body: |
+ GitLab now supports linking epics using "related", "blocking," or "blocked" relationships. This feature enables teams to better track and manage epic dependencies across GitLab groups. Effective dependency management is a key component of reducing variability and increasing predictability in value delivery.
+ stage: plan
+ self-managed: true
+ gitlab-com: true
+ packages: [Ultimate]
+ url: 'https://docs.gitlab.com/ee/user/group/epics/linked_epics.html'
+ image_url: 'https://about.gitlab.com/images/14_9/related_epics_add.png'
+ published_at: 2022-03-22
+ release: 14.9
+- title: "Rule mode for scan result policies"
+ body: |
+ With the GitLab 14.9 release, users can now use rule mode to design and edit scan result policies without needing to edit the policy's YAML directly. This new UI editor makes it easier for users who want to create and manage MR approval rules that are triggered when a given threshold of vulnerabilities are detected in the MR.
+
+ To get started with this new rule mode, navigate to **Security & Compliance > Policies** and create a new Scan Result policy.
+ stage: protect
+ self-managed: true
+ gitlab-com: true
+ packages: [Ultimate]
+ url: 'https://docs.gitlab.com/ee/user/application_security/policies/#policy-editor'
+ image_url: 'https://about.gitlab.com/images/14_9/protect-scan-result-policy-rule-mode.png'
+ published_at: 2022-03-22
+ release: 14.9
+- title: "Deployment Approval on the Environments page"
+ body: |
+ We are excited to introduce the Deployment Approval capability in the GitLab interface. In GitLab 14.8, we introduced the ability to approve deployments via the [API](https://docs.gitlab.com/ee/ci/environments/deployment_approvals.html#using-the-api). Now, deployment approvers can view a pending deployment and approve or reject it conveniently directly in the Environments page. This update continues our work to enable teams to create workflows for approving software to go to production or other protected environments. With this update, we are now upgrading the feature to beta.
+ stage: "Release"
+ self-managed: true
+ gitlab-com: true
+ packages: [Premium, Ultimate]
+ url: 'https://docs.gitlab.com/ee/ci/environments/deployment_approvals.html#approve-or-reject-a-deployment'
+ image_url: 'https://about.gitlab.com/images/unreleased/release-deployment-approval.mp4'
+ published_at: 2022-03-22
+ release: 14.9
+- title: "New design for the Environments Page"
+ body: |
+ Previously, the Environments page enabled you to operate and understand deployments but the design hid some important information and was difficult to read. In GitLab 14.9, we made a comprehensive update to the page so that you can answer key questions about your environments and deployments. Now, you can easily see the status of the latest deployment, the status for various environments, and which commits have been deployed.
+ stage: "Release"
+ self-managed: true
+ gitlab-com: true
+ packages: [Free, Premium, Ultimate]
+ url: 'https://docs.gitlab.com/ee/ci/environments/#view-environments-and-deployments'
+ image_url: 'https://about.gitlab.com/images/14_9/release-enviroments-page-redesign.png'
+ published_at: 2022-03-22
+ release: 14.9
+- title: "Project Level Time to restore service API"
+ body: |
+ In this release, we added API support for Time to Restore Service. This is the 3rd of the 4 [DORA Metrics](https://docs.gitlab.com/ee/user/analytics/ci_cd_analytics.html#devops-research-and-assessment-dora-key-metrics). This data helps teams continuously improve in their stability metrics.
+ stage: manage
+ self-managed: true
+ gitlab-com: true
+ packages: [Ultimate]
+ url: 'https://docs.gitlab.com/ee/api/dora/metrics.html'
+ image_url: 'https://about.gitlab.com/images/14_9/ttr_api.png'
+ published_at: 2022-03-22
+ release: 14.9
+- title: "Integrated security training"
+ body: |
+ GitLab provides a comprehensive set of [security scanning tools](https://docs.gitlab.com/ee/user/application_security/#security-scanning-tools)
+ that can identify all manner of security issues. Scanner findings are presented
+ in merge requests, pipelines, and in a dedicated Vulnerability Report. When
+ available, a recommended solution is given. However, this is not possible for
+ all findings. Presenting security findings without guidance on how to fix identified
+ problems or explaining the problem’s potential impact can be challenging for
+ anyone not familiar with the specific security issue identified. This increases
+ the time and friction involved in assessing and ultimately fixing security issues — especially
+ in developer workflows.
+
+ We’re pleased to announce the launch of our new
+ integrated security training functionality. Two new partners are providing the
+ training content. GitLab is already where many developers are working, so we
+ designed a solution to provide context-aware security training options from
+ inside the GitLab experience.
+
+ Simply enable security training for your projects, select your preferred content sources, and view the results from a security scan. In the vulnerability finding, you'll find a direct link to the security training that most closely matches the particular security issue, and the specific language or framework in which it was detected. Now developers can spend a few quick minutes reviewing targeted, context-relevant training to address security issues as part of their
+ normal development workflow.
+ stage: secure
+ self-managed: true
+ gitlab-com: true
+ packages: [Ultimate]
+ url: 'https://docs.gitlab.com/ee/user/application_security/vulnerabilities/#enable-security-training-for-vulnerabilities'
+ image_url: 'https://about.gitlab.com/images/14_9/secure-enable-security-training.png'
+ published_at: 2022-03-22
+ release: 14.9
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 619f4e34de9..0a7f0efdb51 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -4631,6 +4631,7 @@ Input type: `TodosMarkAllDoneInput`
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationtodosmarkalldoneclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationtodosmarkalldonetargetid"></a>`targetId` | [`TodoableID`](#todoableid) | Global ID of the to-do item's parent. Issues, merge requests, designs, and epics are supported. If argument is omitted, all pending to-do items of the current user are marked as done. |
#### Fields
diff --git a/doc/api/projects.md b/doc/api/projects.md
index 3ca4125a2fa..33fbf01d327 100644
--- a/doc/api/projects.md
+++ b/doc/api/projects.md
@@ -49,6 +49,7 @@ GET /projects
| `archived` | boolean | **{dotted-circle}** No | Limit by archived status. |
| `id_after` | integer | **{dotted-circle}** No | Limit results to projects with IDs greater than the specified ID. |
| `id_before` | integer | **{dotted-circle}** No | Limit results to projects with IDs less than the specified ID. |
+| `imported` | boolean | **{dotted-circle}** No | Limit results to projects which were imported from external systems by current user. |
| `last_activity_after` | datetime | **{dotted-circle}** No | Limit results to projects with last_activity after specified time. Format: ISO 8601 (`YYYY-MM-DDTHH:MM:SSZ`) |
| `last_activity_before` | datetime | **{dotted-circle}** No | Limit results to projects with last_activity before specified time. Format: ISO 8601 (`YYYY-MM-DDTHH:MM:SSZ`) |
| `membership` | boolean | **{dotted-circle}** No | Limit by projects that the current user is a member of. |
diff --git a/doc/api/remote_mirrors.md b/doc/api/remote_mirrors.md
index dbe4970b5a9..351706e8514 100644
--- a/doc/api/remote_mirrors.md
+++ b/doc/api/remote_mirrors.md
@@ -51,6 +51,43 @@ NOTE:
For security reasons, the `url` attribute is always scrubbed of username
and password information.
+## Get a single project's remote mirror
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82770) in GitLab 14.10.
+
+Returns a remote mirror and its statuses:
+
+```plaintext
+GET /projects/:id/remote_mirrors/:mirror_id
+```
+
+Example request:
+
+```shell
+curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/42/remote_mirrors/101486"
+```
+
+Example response:
+
+```json
+{
+ "enabled": true,
+ "id": 101486,
+ "last_error": null,
+ "last_successful_update_at": "2020-01-06T17:32:02.823Z",
+ "last_update_at": "2020-01-06T17:32:02.823Z",
+ "last_update_started_at": "2020-01-06T17:31:55.864Z",
+ "only_protected_branches": true,
+ "keep_divergent_refs": true,
+ "update_status": "finished",
+ "url": "https://*****:*****@gitlab.com/gitlab-org/security/gitlab.git"
+}
+```
+
+NOTE:
+For security reasons, the `url` attribute is always scrubbed of username
+and password information.
+
## Create a pull mirror
Learn how to [configure a pull mirror](projects.md#configure-pull-mirroring-for-a-project) using the Projects API.
diff --git a/doc/development/application_limits.md b/doc/development/application_limits.md
index 15d21883bb8..c4146b5af3e 100644
--- a/doc/development/application_limits.md
+++ b/doc/development/application_limits.md
@@ -19,7 +19,7 @@ and communicate those limits.
There is a guide about [introducing application
limits](https://about.gitlab.com/handbook/product/product-processes/#introducing-application-limits).
-## Development
+## Implement plan limits
### Insert database plan limits
@@ -161,3 +161,31 @@ GitLab.com:
- `opensource`: Namespaces and projects that are member of GitLab Open Source program.
The `test` environment doesn't have any plans.
+
+## Implement rate limits using `Rack::Attack`
+
+We use the [`Rack::Attack`](https://github.com/rack/rack-attack) middleware to throttle Rack requests.
+This applies to Rails controllers, Grape endpoints, and any other Rack requests.
+
+The process for adding a new throttle is loosely:
+
+1. Add new columns to the `ApplicationSetting` model (`*_enabled`, `*_requests_per_period`, `*_period_in_seconds`).
+1. Extend `Gitlab::RackAttack` and `Gitlab::RackAttack::Request` to configure the new rate limit,
+ and apply it to the desired requests.
+1. Add the new settings to the Admin Area form in `app/views/admin/application_settings/_ip_limits.html.haml`.
+1. Document the new settings in [User and IP rate limits](../user/admin_area/settings/user_and_ip_rate_limits.md) and [Application settings API](../api/settings.md).
+1. Configure the rate limit for GitLab.com and document it in [GitLab.com-specific rate limits](../user/gitlab_com/index.md#gitlabcom-specific-rate-limits).
+
+Refer to these past issues for implementation details:
+
+- [Create a separate rate limit for the Files API](https://gitlab.com/gitlab-org/gitlab/-/issues/335075).
+- [Create a separate rate limit for unauthenticated API traffic](https://gitlab.com/gitlab-org/gitlab/-/issues/335300).
+
+## Implement rate limits using `Gitlab::ApplicationRateLimiter`
+
+This module implements a custom rate limiter that can be used to throttle
+certain actions. Unlike `Rack::Attack` and `Rack::Throttle`, which operate at
+the middleware level, this can be used at the controller or API level.
+
+See the `CheckRateLimit` concern for use in controllers. In other parts of the code
+the `Gitlab::ApplicationRateLimiter` module can be called directly.
diff --git a/doc/update/deprecations.md b/doc/update/deprecations.md
index 650d7f01cd2..6f63bd38123 100644
--- a/doc/update/deprecations.md
+++ b/doc/update/deprecations.md
@@ -38,6 +38,25 @@ For deprecation reviewers (Technical Writers only):
## 14.9
+### Background upload for object storage
+
+WARNING:
+This feature will be changed or removed in 15.0
+as a [breaking change](https://docs.gitlab.com/ee/development/contributing/#breaking-changes).
+Before updating GitLab, review the details carefully to determine if you need to make any
+changes to your code, settings, or workflow.
+
+To reduce the overall complexity and maintenance burden of GitLab's [object storage feature](https://docs.gitlab.com/ee/administration/object_storage.html), support for using `background_upload` to upload files is deprecated and will be fully removed in GitLab 15.0.
+
+This impacts a small subset of object storage providers:
+
+- **OpenStack** Customers using OpenStack need to change their configuration to use the S3 API instead of Swift.
+- **RackSpace** Customers using RackSpace-based object storage need to migrate data to a different provider.
+
+GitLab will publish additional guidance to assist affected customers in migrating.
+
+**Planned removal milestone: 15.0 (2022-05-22)**
+
### Deprecate support for Debian 9
Long term service and support (LTSS) for [Debian 9 Stretch ends in July 2022](https://wiki.debian.org/LTS). Therefore, we will longer support the Debian 9 distribution for the GitLab package. Users can upgrade to Debian 10 or Debian 11.
diff --git a/lib/api/entities/project.rb b/lib/api/entities/project.rb
index 8f9a8add938..60cc5167c41 100644
--- a/lib/api/entities/project.rb
+++ b/lib/api/entities/project.rb
@@ -85,8 +85,11 @@ module API
end
expose :mr_default_target_self, if: -> (project) { project.forked? }
+ expose :import_url, if: -> (project, options) { Ability.allowed?(options[:current_user], :admin_project, project) } do |project|
+ project[:import_url]
+ end
+ expose :import_type, if: -> (project, options) { Ability.allowed?(options[:current_user], :admin_project, project) }
expose :import_status
-
expose :import_error, if: lambda { |_project, options| options[:user_can_admin_project] } do |project|
project.import_state&.last_error
end
diff --git a/lib/api/projects.rb b/lib/api/projects.rb
index d772079372c..b5948cb1827 100644
--- a/lib/api/projects.rb
+++ b/lib/api/projects.rb
@@ -20,6 +20,7 @@ module API
projects = projects.with_merge_requests_enabled if params[:with_merge_requests_enabled]
projects = projects.with_statistics if params[:statistics]
projects = projects.joins(:statistics) if params[:order_by].include?('project_statistics') # rubocop: disable CodeReuse/ActiveRecord
+ projects = projects.created_by(current_user).imported.with_import_state if params[:imported]
lang = params[:with_programming_language]
projects = projects.with_programming_language(lang) if lang
@@ -125,6 +126,7 @@ module API
optional :search_namespaces, type: Boolean, desc: "Include ancestor namespaces when matching search criteria"
optional :owned, type: Boolean, default: false, desc: 'Limit by owned by authenticated user'
optional :starred, type: Boolean, default: false, desc: 'Limit by starred status'
+ optional :imported, type: Boolean, default: false, desc: 'Limit by imported by authenticated user'
optional :membership, type: Boolean, default: false, desc: 'Limit by projects that the current user is a member of'
optional :with_issues_enabled, type: Boolean, default: false, desc: 'Limit by enabled issues feature'
optional :with_merge_requests_enabled, type: Boolean, default: false, desc: 'Limit by enabled merge requests feature'
diff --git a/lib/api/remote_mirrors.rb b/lib/api/remote_mirrors.rb
index cc9d1997d92..8de155312fb 100644
--- a/lib/api/remote_mirrors.rb
+++ b/lib/api/remote_mirrors.rb
@@ -25,6 +25,18 @@ module API
with: Entities::RemoteMirror
end
+ desc 'Get a single remote mirror' do
+ success Entities::RemoteMirror
+ end
+ params do
+ requires :mirror_id, type: String, desc: 'The ID of a remote mirror'
+ end
+ get ':id/remote_mirrors/:mirror_id' do
+ mirror = user_project.remote_mirrors.find(params[:mirror_id])
+
+ present mirror, with: Entities::RemoteMirror
+ end
+
desc 'Create remote mirror for a project' do
success Entities::RemoteMirror
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 2d0cc47c5a6..967fc8be4c6 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -5550,9 +5550,6 @@ msgstr ""
msgid "Badges|Your badges"
msgstr ""
-msgid "Balsamiq file could not be loaded."
-msgstr ""
-
msgid "BambooService|Atlassian Bamboo"
msgstr ""
@@ -27877,9 +27874,6 @@ msgstr ""
msgid "Please select a group"
msgstr ""
-msgid "Please select a group."
-msgstr ""
-
msgid "Please select a valid target branch"
msgstr ""
diff --git a/package.json b/package.json
index 604df6436c6..c2718ebc336 100644
--- a/package.json
+++ b/package.json
@@ -168,7 +168,6 @@
"select2": "3.5.2-browserify",
"smooshpack": "^0.0.62",
"sortablejs": "^1.10.2",
- "sql.js": "^0.4.0",
"string-hash": "1.1.3",
"style-loader": "^2.0.0",
"swagger-ui-dist": "^3.52.3",
diff --git a/spec/controllers/groups/group_links_controller_spec.rb b/spec/controllers/groups/group_links_controller_spec.rb
index fafe9715946..f5843dbf4fe 100644
--- a/spec/controllers/groups/group_links_controller_spec.rb
+++ b/spec/controllers/groups/group_links_controller_spec.rb
@@ -35,120 +35,6 @@ RSpec.describe Groups::GroupLinksController do
end
end
- describe '#create' do
- let(:shared_with_group_id) { shared_with_group.id }
- let(:shared_group_access) { GroupGroupLink.default_access }
-
- subject do
- post(:create,
- params: { group_id: shared_group,
- shared_with_group_id: shared_with_group_id,
- shared_group_access: shared_group_access })
- end
-
- shared_examples 'creates group group link' do
- it 'links group with selected group' do
- expect { subject }.to change { shared_with_group.shared_groups.include?(shared_group) }.from(false).to(true)
- end
-
- it 'redirects to group links page' do
- subject
-
- expect(response).to(redirect_to(group_group_members_path(shared_group)))
- end
-
- it 'allows access for group member' do
- expect { subject }.to(
- change { group_member.can?(:read_group, shared_group) }.from(false).to(true))
- end
- end
-
- context 'when user has correct access to both groups' do
- before do
- shared_with_group.add_developer(user)
- shared_group.add_owner(user)
- end
-
- context 'when default access level is requested' do
- include_examples 'creates group group link'
- end
-
- context 'when owner access is requested' do
- let(:shared_group_access) { Gitlab::Access::OWNER }
-
- before do
- shared_with_group.add_owner(group_member)
- end
-
- include_examples 'creates group group link'
-
- it 'allows admin access for group member' do
- expect { subject }.to(
- change { group_member.can?(:admin_group, shared_group) }.from(false).to(true))
- end
- end
-
- it 'updates project permissions', :sidekiq_inline do
- expect { subject }.to change { group_member.can?(:read_project, project) }.from(false).to(true)
- end
-
- context 'when shared with group id is not present' do
- let(:shared_with_group_id) { nil }
-
- it 'redirects to group links page' do
- subject
-
- expect(response).to(redirect_to(group_group_members_path(shared_group)))
- expect(flash[:alert]).to eq('Please select a group.')
- end
- end
-
- context 'when link is not persisted in the database' do
- before do
- allow(::Groups::GroupLinks::CreateService).to(
- receive_message_chain(:new, :execute)
- .and_return({ status: :error,
- http_status: 409,
- message: 'error' }))
- end
-
- it 'redirects to group links page' do
- subject
-
- expect(response).to(redirect_to(group_group_members_path(shared_group)))
- expect(flash[:alert]).to eq('error')
- end
- end
- end
-
- context 'when user does not have access to the group' do
- before do
- shared_group.add_owner(user)
- end
-
- it 'renders 404' do
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- context 'when user does not have admin access to the shared group' do
- before do
- shared_with_group.add_developer(user)
- shared_group.add_developer(user)
- end
-
- it 'renders 404' do
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- include_examples 'placeholder is passed as `id` parameter', :create
- end
-
describe '#update' do
let!(:link) do
create(:group_group_link, { shared_group: shared_group,
diff --git a/spec/controllers/projects/group_links_controller_spec.rb b/spec/controllers/projects/group_links_controller_spec.rb
index ea15d483c90..96705d82ac5 100644
--- a/spec/controllers/projects/group_links_controller_spec.rb
+++ b/spec/controllers/projects/group_links_controller_spec.rb
@@ -18,136 +18,6 @@ RSpec.describe Projects::GroupLinksController do
travel_back
end
- describe '#create' do
- shared_context 'link project to group' do
- before do
- post(:create, params: {
- namespace_id: project.namespace,
- project_id: project,
- link_group_id: group.id,
- link_group_access: ProjectGroupLink.default_access
- })
- end
- end
-
- context 'when project is not allowed to be shared with a group' do
- before do
- group.update!(share_with_group_lock: false)
- end
-
- include_context 'link project to group'
-
- it 'responds with status 404' do
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- context 'when user has access to group they want to link project to' do
- before do
- group.add_developer(user)
- end
-
- include_context 'link project to group'
-
- it 'links project with selected group' do
- expect(group.shared_projects).to include project
- end
-
- it 'redirects to project group links page' do
- expect(response).to redirect_to(
- project_project_members_path(project)
- )
- end
- end
-
- context 'when user doers not have access to group they want to link to' do
- include_context 'link project to group'
-
- it 'renders 404' do
- expect(response).to have_gitlab_http_status(:not_found)
- end
-
- it 'does not share project with that group' do
- expect(group.shared_projects).not_to include project
- end
- end
-
- context 'when user does not have access to the public group' do
- let(:group) { create(:group, :public) }
-
- include_context 'link project to group'
-
- it 'renders 404' do
- expect(response).to have_gitlab_http_status(:not_found)
- end
-
- it 'does not share project with that group' do
- expect(group.shared_projects).not_to include project
- end
- end
-
- context 'when project group id equal link group id' do
- before do
- group2.add_developer(user)
-
- post(:create, params: {
- namespace_id: project.namespace,
- project_id: project,
- link_group_id: group2.id,
- link_group_access: ProjectGroupLink.default_access
- })
- end
-
- it 'does not share project with selected group' do
- expect(group2.shared_projects).not_to include project
- end
-
- it 'redirects to project group links page' do
- expect(response).to redirect_to(
- project_project_members_path(project)
- )
- end
- end
-
- context 'when link group id is not present' do
- before do
- post(:create, params: {
- namespace_id: project.namespace,
- project_id: project,
- link_group_access: ProjectGroupLink.default_access
- })
- end
-
- it 'redirects to project group links page' do
- expect(response).to redirect_to(
- project_project_members_path(project)
- )
- expect(flash[:alert]).to eq('Please select a group.')
- end
- end
-
- context 'when link is not persisted in the database' do
- before do
- allow(::Projects::GroupLinks::CreateService).to receive_message_chain(:new, :execute)
- .and_return({ status: :error, http_status: 409, message: 'error' })
-
- post(:create, params: {
- namespace_id: project.namespace,
- project_id: project,
- link_group_id: group.id,
- link_group_access: ProjectGroupLink.default_access
- })
- end
-
- it 'redirects to project group links page' do
- expect(response).to redirect_to(
- project_project_members_path(project)
- )
- expect(flash[:alert]).to eq('error')
- end
- end
- end
-
describe '#update' do
let_it_be(:link) do
create(
diff --git a/spec/features/groups/members/sort_members_spec.rb b/spec/features/groups/members/sort_members_spec.rb
index 03758e0d401..bf8e64fa1e2 100644
--- a/spec/features/groups/members/sort_members_spec.rb
+++ b/spec/features/groups/members/sort_members_spec.rb
@@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe 'Groups > Members > Sort members', :js do
include Spec::Support::Helpers::Features::MembersHelpers
- let(:owner) { create(:user, name: 'John Doe') }
- let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) }
+ let(:owner) { create(:user, name: 'John Doe', created_at: 5.days.ago, last_activity_on: Date.today) }
+ let(:developer) { create(:user, name: 'Mary Jane', created_at: 1.day.ago, last_sign_in_at: 5.days.ago, last_activity_on: Date.today - 5) }
let(:group) { create(:group) }
before do
@@ -50,6 +50,42 @@ RSpec.describe 'Groups > Members > Sort members', :js do
expect_sort_by('Max role', :desc)
end
+ it 'sorts by user created on ascending' do
+ visit_members_list(sort: :oldest_created_user)
+
+ expect(first_row.text).to include(owner.name)
+ expect(second_row.text).to include(developer.name)
+
+ expect_sort_by('Created on', :asc)
+ end
+
+ it 'sorts by user created on descending' do
+ visit_members_list(sort: :recent_created_user)
+
+ expect(first_row.text).to include(developer.name)
+ expect(second_row.text).to include(owner.name)
+
+ expect_sort_by('Created on', :desc)
+ end
+
+ it 'sorts by last activity ascending' do
+ visit_members_list(sort: :oldest_last_activity)
+
+ expect(first_row.text).to include(developer.name)
+ expect(second_row.text).to include(owner.name)
+
+ expect_sort_by('Last activity', :asc)
+ end
+
+ it 'sorts by last activity descending' do
+ visit_members_list(sort: :recent_last_activity)
+
+ expect(first_row.text).to include(owner.name)
+ expect(second_row.text).to include(developer.name)
+
+ expect_sort_by('Last activity', :desc)
+ end
+
it 'sorts by access granted ascending' do
visit_members_list(sort: :last_joined)
diff --git a/spec/features/projects/blobs/balsamiq_spec.rb b/spec/features/projects/blobs/balsamiq_spec.rb
deleted file mode 100644
index bce60856544..00000000000
--- a/spec/features/projects/blobs/balsamiq_spec.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Balsamiq file blob', :js do
- let(:project) { create(:project, :public, :repository) }
-
- before do
- visit project_blob_path(project, 'add-balsamiq-file/files/images/balsamiq.bmpr')
-
- wait_for_requests
- end
-
- it 'displays Balsamiq file content' do
- expect(page).to have_content("Mobile examples")
- end
-end
diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb
index 653564d1566..8aadd6302d0 100644
--- a/spec/features/projects/members/sorting_spec.rb
+++ b/spec/features/projects/members/sorting_spec.rb
@@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe 'Projects > Members > Sorting', :js do
include Spec::Support::Helpers::Features::MembersHelpers
- let(:maintainer) { create(:user, name: 'John Doe') }
- let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) }
+ let(:maintainer) { create(:user, name: 'John Doe', created_at: 5.days.ago, last_activity_on: Date.today) }
+ let(:developer) { create(:user, name: 'Mary Jane', created_at: 1.day.ago, last_sign_in_at: 5.days.ago, last_activity_on: Date.today - 5) }
let(:project) { create(:project, namespace: maintainer.namespace, creator: maintainer) }
before do
@@ -42,6 +42,42 @@ RSpec.describe 'Projects > Members > Sorting', :js do
expect_sort_by('Max role', :desc)
end
+ it 'sorts by user created on ascending' do
+ visit_members_list(sort: :oldest_created_user)
+
+ expect(first_row.text).to have_content(maintainer.name)
+ expect(second_row.text).to have_content(developer.name)
+
+ expect_sort_by('Created on', :asc)
+ end
+
+ it 'sorts by user created on descending' do
+ visit_members_list(sort: :recent_created_user)
+
+ expect(first_row.text).to have_content(developer.name)
+ expect(second_row.text).to have_content(maintainer.name)
+
+ expect_sort_by('Created on', :desc)
+ end
+
+ it 'sorts by last activity ascending' do
+ visit_members_list(sort: :oldest_last_activity)
+
+ expect(first_row.text).to have_content(developer.name)
+ expect(second_row.text).to have_content(maintainer.name)
+
+ expect_sort_by('Last activity', :asc)
+ end
+
+ it 'sorts by last activity descending' do
+ visit_members_list(sort: :recent_last_activity)
+
+ expect(first_row.text).to have_content(maintainer.name)
+ expect(second_row.text).to have_content(developer.name)
+
+ expect_sort_by('Last activity', :desc)
+ end
+
it 'sorts by access granted ascending' do
visit_members_list(sort: :last_joined)
diff --git a/spec/features/projects/releases/user_views_releases_spec.rb b/spec/features/projects/releases/user_views_releases_spec.rb
index 98935fdf872..a7348b62fc0 100644
--- a/spec/features/projects/releases/user_views_releases_spec.rb
+++ b/spec/features/projects/releases/user_views_releases_spec.rb
@@ -24,129 +24,111 @@ RSpec.describe 'User views releases', :js do
stub_default_url_options(host: 'localhost')
end
- shared_examples 'releases index page' do
- context('when the user is a maintainer') do
- before do
- sign_in(maintainer)
+ context('when the user is a maintainer') do
+ before do
+ sign_in(maintainer)
- visit project_releases_path(project)
+ visit project_releases_path(project)
- wait_for_requests
- end
+ wait_for_requests
+ end
- it 'sees the release' do
- page.within("##{release_v1.tag}") do
- expect(page).to have_content(release_v1.name)
- expect(page).to have_content(release_v1.tag)
- expect(page).not_to have_content('Upcoming Release')
- end
+ it 'sees the release' do
+ page.within("##{release_v1.tag}") do
+ expect(page).to have_content(release_v1.name)
+ expect(page).to have_content(release_v1.tag)
+ expect(page).not_to have_content('Upcoming Release')
end
+ end
- it 'renders the correct links', :aggregate_failures do
- page.within("##{release_v1.tag} .js-assets-list") do
- external_link_indicator_selector = '[data-testid="external-link-indicator"]'
+ it 'renders the correct links', :aggregate_failures do
+ page.within("##{release_v1.tag} .js-assets-list") do
+ external_link_indicator_selector = '[data-testid="external-link-indicator"]'
- expect(page).to have_link internal_link.name, href: internal_link.url
- expect(find_link(internal_link.name)).not_to have_css(external_link_indicator_selector)
+ expect(page).to have_link internal_link.name, href: internal_link.url
+ expect(find_link(internal_link.name)).not_to have_css(external_link_indicator_selector)
- expect(page).to have_link internal_link_with_redirect.name, href: Gitlab::Routing.url_helpers.project_release_url(project, release_v1) << "/downloads#{internal_link_with_redirect.filepath}"
- expect(find_link(internal_link_with_redirect.name)).not_to have_css(external_link_indicator_selector)
+ expect(page).to have_link internal_link_with_redirect.name, href: Gitlab::Routing.url_helpers.project_release_url(project, release_v1) << "/downloads#{internal_link_with_redirect.filepath}"
+ expect(find_link(internal_link_with_redirect.name)).not_to have_css(external_link_indicator_selector)
- expect(page).to have_link external_link.name, href: external_link.url
- expect(find_link(external_link.name)).to have_css(external_link_indicator_selector)
- end
+ expect(page).to have_link external_link.name, href: external_link.url
+ expect(find_link(external_link.name)).to have_css(external_link_indicator_selector)
end
+ end
- context 'with an upcoming release' do
- it 'sees the upcoming tag' do
- page.within("##{release_v3.tag}") do
- expect(page).to have_content('Upcoming Release')
- end
+ context 'with an upcoming release' do
+ it 'sees the upcoming tag' do
+ page.within("##{release_v3.tag}") do
+ expect(page).to have_content('Upcoming Release')
end
end
+ end
- context 'with a tag containing a slash' do
- it 'sees the release' do
- page.within("##{release_v2.tag.parameterize}") do
- expect(page).to have_content(release_v2.name)
- expect(page).to have_content(release_v2.tag)
- end
+ context 'with a tag containing a slash' do
+ it 'sees the release' do
+ page.within("##{release_v2.tag.parameterize}") do
+ expect(page).to have_content(release_v2.name)
+ expect(page).to have_content(release_v2.tag)
end
end
+ end
- context 'sorting' do
- def sort_page(by:, direction:)
- within '[data-testid="releases-sort"]' do
- find('.dropdown-toggle').click
-
- click_button(by, class: 'dropdown-item')
-
- find('.sorting-direction-button').click if direction == :ascending
- end
- end
-
- shared_examples 'releases sort order' do
- it "sorts the releases #{description}" do
- card_titles = page.all('.release-block .card-title', minimum: expected_releases.count)
-
- card_titles.each_with_index do |title, index|
- expect(title).to have_content(expected_releases[index].name)
- end
- end
- end
+ context 'sorting' do
+ def sort_page(by:, direction:)
+ within '[data-testid="releases-sort"]' do
+ find('.dropdown-toggle').click
- context "when the page is sorted by the default sort order" do
- let(:expected_releases) { [release_v3, release_v2, release_v1] }
+ click_button(by, class: 'dropdown-item')
- it_behaves_like 'releases sort order'
+ find('.sorting-direction-button').click if direction == :ascending
end
+ end
- context "when the page is sorted by created_at ascending " do
- let(:expected_releases) { [release_v2, release_v1, release_v3] }
+ shared_examples 'releases sort order' do
+ it "sorts the releases #{description}" do
+ card_titles = page.all('.release-block .card-title', minimum: expected_releases.count)
- before do
- sort_page by: 'Created date', direction: :ascending
+ card_titles.each_with_index do |title, index|
+ expect(title).to have_content(expected_releases[index].name)
end
-
- it_behaves_like 'releases sort order'
end
end
- end
- context('when the user is a guest') do
- before do
- sign_in(guest)
- end
+ context "when the page is sorted by the default sort order" do
+ let(:expected_releases) { [release_v3, release_v2, release_v1] }
- it 'renders release info except for Git-related data' do
- visit project_releases_path(project)
+ it_behaves_like 'releases sort order'
+ end
- within('.release-block', match: :first) do
- expect(page).to have_content(release_v3.description)
- expect(page).to have_content(release_v3.tag)
- expect(page).to have_content(release_v3.name)
+ context "when the page is sorted by created_at ascending " do
+ let(:expected_releases) { [release_v2, release_v1, release_v3] }
- # The following properties (sometimes) include Git info,
- # so they are not rendered for Guest users
- expect(page).not_to have_content(release_v3.commit.short_id)
+ before do
+ sort_page by: 'Created date', direction: :ascending
end
+
+ it_behaves_like 'releases sort order'
end
end
end
- context 'when the releases_index_apollo_client feature flag is enabled' do
+ context('when the user is a guest') do
before do
- stub_feature_flags(releases_index_apollo_client: true)
+ sign_in(guest)
end
- it_behaves_like 'releases index page'
- end
+ it 'renders release info except for Git-related data' do
+ visit project_releases_path(project)
- context 'when the releases_index_apollo_client feature flag is disabled' do
- before do
- stub_feature_flags(releases_index_apollo_client: false)
- end
+ within('.release-block', match: :first) do
+ expect(page).to have_content(release_v3.description)
+ expect(page).to have_content(release_v3.tag)
+ expect(page).to have_content(release_v3.name)
- it_behaves_like 'releases index page'
+ # The following properties (sometimes) include Git info,
+ # so they are not rendered for Guest users
+ expect(page).not_to have_content(release_v3.commit.short_id)
+ end
+ end
end
end
diff --git a/spec/features/refactor_blob_viewer_disabled/projects/blobs/balsamiq_spec.rb b/spec/features/refactor_blob_viewer_disabled/projects/blobs/balsamiq_spec.rb
deleted file mode 100644
index 3638e98a08a..00000000000
--- a/spec/features/refactor_blob_viewer_disabled/projects/blobs/balsamiq_spec.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Balsamiq file blob', :js do
- let(:project) { create(:project, :public, :repository) }
-
- before do
- stub_feature_flags(refactor_blob_viewer: false)
- visit project_blob_path(project, 'add-balsamiq-file/files/images/balsamiq.bmpr')
-
- wait_for_requests
- end
-
- it 'displays Balsamiq file content' do
- expect(page).to have_content("Mobile examples")
- end
-end
diff --git a/spec/fixtures/api/schemas/entities/member_user.json b/spec/fixtures/api/schemas/entities/member_user.json
index d42c686bb65..0750e81e115 100644
--- a/spec/fixtures/api/schemas/entities/member_user.json
+++ b/spec/fixtures/api/schemas/entities/member_user.json
@@ -1,15 +1,28 @@
{
"type": "object",
- "required": ["id", "name", "username", "avatar_url", "web_url", "blocked", "two_factor_enabled", "show_status"],
+ "required": [
+ "id",
+ "name",
+ "username",
+ "created_at",
+ "last_activity_on",
+ "avatar_url",
+ "web_url",
+ "blocked",
+ "two_factor_enabled",
+ "show_status"
+ ],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"username": { "type": "string" },
+ "created_at": { "type": ["string"] },
"avatar_url": { "type": ["string", "null"] },
"web_url": { "type": "string" },
"blocked": { "type": "boolean" },
"two_factor_enabled": { "type": "boolean" },
"availability": { "type": ["string", "null"] },
+ "last_activity_on": { "type": ["string", "null"] },
"status": {
"type": "object",
"required": ["emoji"],
diff --git a/spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js b/spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js
deleted file mode 100644
index d7531d15b9a..00000000000
--- a/spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js
+++ /dev/null
@@ -1,363 +0,0 @@
-import sqljs from 'sql.js';
-import ClassSpecHelper from 'helpers/class_spec_helper';
-import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer';
-import axios from '~/lib/utils/axios_utils';
-
-jest.mock('sql.js');
-
-describe('BalsamiqViewer', () => {
- const mockArrayBuffer = new ArrayBuffer(10);
- let balsamiqViewer;
- let viewer;
-
- describe('class constructor', () => {
- beforeEach(() => {
- viewer = {};
-
- balsamiqViewer = new BalsamiqViewer(viewer);
- });
-
- it('should set .viewer', () => {
- expect(balsamiqViewer.viewer).toBe(viewer);
- });
- });
-
- describe('loadFile', () => {
- let bv;
- const endpoint = 'endpoint';
- const requestSuccess = Promise.resolve({
- data: mockArrayBuffer,
- status: 200,
- });
-
- beforeEach(() => {
- viewer = {};
- bv = new BalsamiqViewer(viewer);
- });
-
- it('should call `axios.get` on `endpoint` param with responseType set to `arraybuffer', () => {
- jest.spyOn(axios, 'get').mockReturnValue(requestSuccess);
- jest.spyOn(bv, 'renderFile').mockReturnValue();
-
- bv.loadFile(endpoint);
-
- expect(axios.get).toHaveBeenCalledWith(
- endpoint,
- expect.objectContaining({
- responseType: 'arraybuffer',
- }),
- );
- });
-
- it('should call `renderFile` on request success', (done) => {
- jest.spyOn(axios, 'get').mockReturnValue(requestSuccess);
- jest.spyOn(bv, 'renderFile').mockImplementation(() => {});
-
- bv.loadFile(endpoint)
- .then(() => {
- expect(bv.renderFile).toHaveBeenCalledWith(mockArrayBuffer);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('should not call `renderFile` on request failure', (done) => {
- jest.spyOn(axios, 'get').mockReturnValue(Promise.reject());
- jest.spyOn(bv, 'renderFile').mockImplementation(() => {});
-
- bv.loadFile(endpoint)
- .then(() => {
- done.fail('Expected loadFile to throw error!');
- })
- .catch(() => {
- expect(bv.renderFile).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
- });
-
- describe('renderFile', () => {
- let container;
- let previews;
-
- beforeEach(() => {
- viewer = {
- appendChild: jest.fn(),
- };
- previews = [document.createElement('ul'), document.createElement('ul')];
-
- balsamiqViewer = {
- initDatabase: jest.fn(),
- getPreviews: jest.fn(),
- renderPreview: jest.fn(),
- };
- balsamiqViewer.viewer = viewer;
-
- balsamiqViewer.getPreviews.mockReturnValue(previews);
- balsamiqViewer.renderPreview.mockImplementation((preview) => preview);
- viewer.appendChild.mockImplementation((containerElement) => {
- container = containerElement;
- });
-
- BalsamiqViewer.prototype.renderFile.call(balsamiqViewer, mockArrayBuffer);
- });
-
- it('should call .initDatabase', () => {
- expect(balsamiqViewer.initDatabase).toHaveBeenCalledWith(mockArrayBuffer);
- });
-
- it('should call .getPreviews', () => {
- expect(balsamiqViewer.getPreviews).toHaveBeenCalled();
- });
-
- it('should call .renderPreview for each preview', () => {
- const allArgs = balsamiqViewer.renderPreview.mock.calls;
-
- expect(allArgs.length).toBe(2);
-
- previews.forEach((preview, i) => {
- expect(allArgs[i][0]).toBe(preview);
- });
- });
-
- it('should set the container HTML', () => {
- expect(container.innerHTML).toBe('<ul></ul><ul></ul>');
- });
-
- it('should add inline preview classes', () => {
- expect(container.classList[0]).toBe('list-inline');
- expect(container.classList[1]).toBe('previews');
- });
-
- it('should call viewer.appendChild', () => {
- expect(viewer.appendChild).toHaveBeenCalledWith(container);
- });
- });
-
- describe('initDatabase', () => {
- let uint8Array;
- let data;
-
- beforeEach(() => {
- uint8Array = {};
- data = 'data';
- balsamiqViewer = {};
- window.Uint8Array = jest.fn();
- window.Uint8Array.mockReturnValue(uint8Array);
-
- BalsamiqViewer.prototype.initDatabase.call(balsamiqViewer, data);
- });
-
- it('should instantiate Uint8Array', () => {
- expect(window.Uint8Array).toHaveBeenCalledWith(data);
- });
-
- it('should call sqljs.Database', () => {
- expect(sqljs.Database).toHaveBeenCalledWith(uint8Array);
- });
-
- it('should set .database', () => {
- expect(balsamiqViewer.database).not.toBe(null);
- });
- });
-
- describe('getPreviews', () => {
- let database;
- let thumbnails;
- let getPreviews;
-
- beforeEach(() => {
- database = {
- exec: jest.fn(),
- };
- thumbnails = [{ values: [0, 1, 2] }];
-
- balsamiqViewer = {
- database,
- };
-
- jest
- .spyOn(BalsamiqViewer, 'parsePreview')
- .mockImplementation((preview) => preview.toString());
- database.exec.mockReturnValue(thumbnails);
-
- getPreviews = BalsamiqViewer.prototype.getPreviews.call(balsamiqViewer);
- });
-
- it('should call database.exec', () => {
- expect(database.exec).toHaveBeenCalledWith('SELECT * FROM thumbnails');
- });
-
- it('should call .parsePreview for each value', () => {
- const allArgs = BalsamiqViewer.parsePreview.mock.calls;
-
- expect(allArgs.length).toBe(3);
-
- thumbnails[0].values.forEach((value, i) => {
- expect(allArgs[i][0]).toBe(value);
- });
- });
-
- it('should return an array of parsed values', () => {
- expect(getPreviews).toEqual(['0', '1', '2']);
- });
- });
-
- describe('getResource', () => {
- let database;
- let resourceID;
- let resource;
- let getResource;
-
- beforeEach(() => {
- database = {
- exec: jest.fn(),
- };
- resourceID = 4;
- resource = ['resource'];
-
- balsamiqViewer = {
- database,
- };
-
- database.exec.mockReturnValue(resource);
-
- getResource = BalsamiqViewer.prototype.getResource.call(balsamiqViewer, resourceID);
- });
-
- it('should call database.exec', () => {
- expect(database.exec).toHaveBeenCalledWith(
- `SELECT * FROM resources WHERE id = '${resourceID}'`,
- );
- });
-
- it('should return the selected resource', () => {
- expect(getResource).toBe(resource[0]);
- });
- });
-
- describe('renderPreview', () => {
- let previewElement;
- let innerHTML;
- let preview;
- let renderPreview;
-
- beforeEach(() => {
- innerHTML = '<a>innerHTML</a>';
- previewElement = {
- outerHTML: '<p>outerHTML</p>',
- classList: {
- add: jest.fn(),
- },
- };
- preview = {};
-
- balsamiqViewer = {
- renderTemplate: jest.fn(),
- };
-
- jest.spyOn(document, 'createElement').mockReturnValue(previewElement);
- balsamiqViewer.renderTemplate.mockReturnValue(innerHTML);
-
- renderPreview = BalsamiqViewer.prototype.renderPreview.call(balsamiqViewer, preview);
- });
-
- it('should call classList.add', () => {
- expect(previewElement.classList.add).toHaveBeenCalledWith('preview');
- });
-
- it('should call .renderTemplate', () => {
- expect(balsamiqViewer.renderTemplate).toHaveBeenCalledWith(preview);
- });
-
- it('should set .innerHTML', () => {
- expect(previewElement.innerHTML).toBe(innerHTML);
- });
-
- it('should return element', () => {
- expect(renderPreview).toBe(previewElement);
- });
- });
-
- describe('renderTemplate', () => {
- let preview;
- let name;
- let resource;
- let template;
- let renderTemplate;
-
- beforeEach(() => {
- preview = { resourceID: 1, image: 'image' };
- name = 'name';
- resource = 'resource';
- template = `
- <div class="card">
- <div class="card-header">name</div>
- <div class="card-body">
- <img class="img-thumbnail" src="data:image/png;base64,image"/>
- </div>
- </div>
- `;
-
- balsamiqViewer = {
- getResource: jest.fn(),
- };
-
- jest.spyOn(BalsamiqViewer, 'parseTitle').mockReturnValue(name);
- balsamiqViewer.getResource.mockReturnValue(resource);
-
- renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview);
- });
-
- it('should call .getResource', () => {
- expect(balsamiqViewer.getResource).toHaveBeenCalledWith(preview.resourceID);
- });
-
- it('should call .parseTitle', () => {
- expect(BalsamiqViewer.parseTitle).toHaveBeenCalledWith(resource);
- });
-
- it('should return the template string', () => {
- expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, ''));
- });
- });
-
- describe('parsePreview', () => {
- let preview;
- let parsePreview;
-
- beforeEach(() => {
- preview = ['{}', '{ "id": 1 }'];
-
- jest.spyOn(JSON, 'parse');
-
- parsePreview = BalsamiqViewer.parsePreview(preview);
- });
-
- ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview');
-
- it('should return the parsed JSON', () => {
- expect(parsePreview).toEqual(JSON.parse('{ "id": 1 }'));
- });
- });
-
- describe('parseTitle', () => {
- let title;
- let parseTitle;
-
- beforeEach(() => {
- title = { values: [['{}', '{}', '{"name":"name"}']] };
-
- jest.spyOn(JSON, 'parse');
-
- parseTitle = BalsamiqViewer.parseTitle(title);
- });
-
- ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview');
-
- it('should return the name value', () => {
- expect(parseTitle).toBe('name');
- });
- });
-});
diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js
index b2756e506eb..298a01e4f4d 100644
--- a/spec/frontend/members/components/table/members_table_spec.js
+++ b/spec/frontend/members/components/table/members_table_spec.js
@@ -10,6 +10,7 @@ import MemberAvatar from '~/members/components/table/member_avatar.vue';
import MemberSource from '~/members/components/table/member_source.vue';
import MembersTable from '~/members/components/table/members_table.vue';
import RoleDropdown from '~/members/components/table/role_dropdown.vue';
+import UserDate from '~/vue_shared/components/user_date.vue';
import {
MEMBER_TYPES,
MEMBER_STATE_CREATED,
@@ -106,14 +107,16 @@ describe('MembersTable', () => {
};
it.each`
- field | label | member | expectedComponent
- ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar}
- ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource}
- ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt}
- ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt}
- ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt}
- ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown}
- ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker}
+ field | label | member | expectedComponent
+ ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar}
+ ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource}
+ ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt}
+ ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt}
+ ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt}
+ ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown}
+ ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker}
+ ${'userCreatedAt'} | ${'Created on'} | ${memberMock} | ${UserDate}
+ ${'lastActivityOn'} | ${'Last activity'} | ${memberMock} | ${UserDate}
`('renders the $label field', ({ field, label, member, expectedComponent }) => {
createComponent({
members: [member],
diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js
index 83856a00a15..f787d37101d 100644
--- a/spec/frontend/members/mock_data.js
+++ b/spec/frontend/members/mock_data.js
@@ -17,6 +17,7 @@ export const member = {
state: MEMBER_STATE_CREATED,
user: {
id: 123,
+ createdAt: '2022-03-10T18:03:04.812Z',
name: 'Administrator',
username: 'root',
webUrl: 'https://gitlab.com/root',
@@ -26,6 +27,7 @@ export const member = {
oncallSchedules: [{ name: 'schedule 1' }],
escalationPolicies: [{ name: 'policy 1' }],
availability: null,
+ lastActivityOn: '2022-03-15',
showStatus: true,
},
id: 238,
diff --git a/spec/frontend/releases/components/app_index_apollo_client_spec.js b/spec/frontend/releases/components/app_index_apollo_client_spec.js
deleted file mode 100644
index 9881ef9bc9f..00000000000
--- a/spec/frontend/releases/components/app_index_apollo_client_spec.js
+++ /dev/null
@@ -1,398 +0,0 @@
-import { cloneDeep } from 'lodash';
-import Vue, { nextTick } from 'vue';
-import VueApollo from 'vue-apollo';
-import originalAllReleasesQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import allReleasesQuery from 'shared_queries/releases/all_releases.query.graphql';
-import createFlash from '~/flash';
-import { historyPushState } from '~/lib/utils/common_utils';
-import ReleasesIndexApolloClientApp from '~/releases/components/app_index_apollo_client.vue';
-import ReleaseBlock from '~/releases/components/release_block.vue';
-import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue';
-import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue';
-import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue';
-import ReleasesSortApolloClient from '~/releases/components/releases_sort_apollo_client.vue';
-import { PAGE_SIZE, CREATED_ASC, DEFAULT_SORT } from '~/releases/constants';
-
-Vue.use(VueApollo);
-
-jest.mock('~/flash');
-
-let mockQueryParams;
-jest.mock('~/lib/utils/common_utils', () => ({
- ...jest.requireActual('~/lib/utils/common_utils'),
- historyPushState: jest.fn(),
-}));
-
-jest.mock('~/lib/utils/url_utility', () => ({
- ...jest.requireActual('~/lib/utils/url_utility'),
- getParameterByName: jest
- .fn()
- .mockImplementation((parameterName) => mockQueryParams[parameterName]),
-}));
-
-describe('app_index_apollo_client.vue', () => {
- const projectPath = 'project/path';
- const newReleasePath = 'path/to/new/release/page';
- const before = 'beforeCursor';
- const after = 'afterCursor';
-
- let wrapper;
- let allReleases;
- let singleRelease;
- let noReleases;
- let queryMock;
-
- const createComponent = ({
- singleResponse = Promise.resolve(singleRelease),
- fullResponse = Promise.resolve(allReleases),
- } = {}) => {
- const apolloProvider = createMockApollo([
- [
- allReleasesQuery,
- queryMock.mockImplementation((vars) => {
- return vars.first === 1 ? singleResponse : fullResponse;
- }),
- ],
- ]);
-
- wrapper = shallowMountExtended(ReleasesIndexApolloClientApp, {
- apolloProvider,
- provide: {
- newReleasePath,
- projectPath,
- },
- });
- };
-
- beforeEach(() => {
- mockQueryParams = {};
-
- allReleases = cloneDeep(originalAllReleasesQueryResponse);
-
- singleRelease = cloneDeep(originalAllReleasesQueryResponse);
- singleRelease.data.project.releases.nodes.splice(
- 1,
- singleRelease.data.project.releases.nodes.length,
- );
-
- noReleases = cloneDeep(originalAllReleasesQueryResponse);
- noReleases.data.project.releases.nodes = [];
-
- queryMock = jest.fn();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- // Finders
- const findLoadingIndicator = () => wrapper.findComponent(ReleaseSkeletonLoader);
- const findEmptyState = () => wrapper.findComponent(ReleasesEmptyState);
- const findNewReleaseButton = () =>
- wrapper.findByText(ReleasesIndexApolloClientApp.i18n.newRelease);
- const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock);
- const findPagination = () => wrapper.findComponent(ReleasesPaginationApolloClient);
- const findSort = () => wrapper.findComponent(ReleasesSortApolloClient);
-
- // Tests
- describe('component states', () => {
- // These need to be defined as functions, since `singleRelease` and
- // `allReleases` are generated in a `beforeEach`, and therefore
- // aren't available at test definition time.
- const getInProgressResponse = () => new Promise(() => {});
- const getErrorResponse = () => Promise.reject(new Error('Oops!'));
- const getSingleRequestLoadedResponse = () => Promise.resolve(singleRelease);
- const getFullRequestLoadedResponse = () => Promise.resolve(allReleases);
- const getLoadedEmptyResponse = () => Promise.resolve(noReleases);
-
- const toDescription = (bool) => (bool ? 'does' : 'does not');
-
- describe.each`
- description | singleResponseFn | fullResponseFn | loadingIndicator | emptyState | flashMessage | releaseCount | pagination
- ${'both requests loading'} | ${getInProgressResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
- ${'both requests failed'} | ${getErrorResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${0} | ${false}
- ${'both requests loaded'} | ${getSingleRequestLoadedResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
- ${'both requests loaded with no results'} | ${getLoadedEmptyResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false}
- ${'single request loading, full request loaded'} | ${getInProgressResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
- ${'single request loading, full request failed'} | ${getInProgressResponse} | ${getErrorResponse} | ${true} | ${false} | ${true} | ${0} | ${false}
- ${'single request loaded, full request loading'} | ${getSingleRequestLoadedResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${1} | ${false}
- ${'single request loaded, full request failed'} | ${getSingleRequestLoadedResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${1} | ${false}
- ${'single request failed, full request loading'} | ${getErrorResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
- ${'single request failed, full request loaded'} | ${getErrorResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
- ${'single request loaded with no results, full request loading'} | ${getLoadedEmptyResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
- ${'single request loading, full request loadied with no results'} | ${getInProgressResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false}
- `(
- '$description',
- ({
- singleResponseFn,
- fullResponseFn,
- loadingIndicator,
- emptyState,
- flashMessage,
- releaseCount,
- pagination,
- }) => {
- beforeEach(() => {
- createComponent({
- singleResponse: singleResponseFn(),
- fullResponse: fullResponseFn(),
- });
- });
-
- it(`${toDescription(loadingIndicator)} render a loading indicator`, async () => {
- await waitForPromises();
- expect(findLoadingIndicator().exists()).toBe(loadingIndicator);
- });
-
- it(`${toDescription(emptyState)} render an empty state`, () => {
- expect(findEmptyState().exists()).toBe(emptyState);
- });
-
- it(`${toDescription(flashMessage)} show a flash message`, () => {
- if (flashMessage) {
- expect(createFlash).toHaveBeenCalledWith({
- message: ReleasesIndexApolloClientApp.i18n.errorMessage,
- captureError: true,
- error: expect.any(Error),
- });
- } else {
- expect(createFlash).not.toHaveBeenCalled();
- }
- });
-
- it(`renders ${releaseCount} release(s)`, () => {
- expect(findAllReleaseBlocks()).toHaveLength(releaseCount);
- });
-
- it(`${toDescription(pagination)} render the pagination controls`, () => {
- expect(findPagination().exists()).toBe(pagination);
- });
-
- it('does render the "New release" button', () => {
- expect(findNewReleaseButton().exists()).toBe(true);
- });
-
- it('does render the sort controls', () => {
- expect(findSort().exists()).toBe(true);
- });
- },
- );
- });
-
- describe('URL parameters', () => {
- describe('when the URL contains no query parameters', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('makes a request with the correct GraphQL query parameters', () => {
- expect(queryMock).toHaveBeenCalledTimes(2);
-
- expect(queryMock).toHaveBeenCalledWith({
- first: 1,
- fullPath: projectPath,
- sort: DEFAULT_SORT,
- });
-
- expect(queryMock).toHaveBeenCalledWith({
- first: PAGE_SIZE,
- fullPath: projectPath,
- sort: DEFAULT_SORT,
- });
- });
- });
-
- describe('when the URL contains a "before" query parameter', () => {
- beforeEach(() => {
- mockQueryParams = { before };
- createComponent();
- });
-
- it('makes a request with the correct GraphQL query parameters', () => {
- expect(queryMock).toHaveBeenCalledTimes(1);
-
- expect(queryMock).toHaveBeenCalledWith({
- before,
- last: PAGE_SIZE,
- fullPath: projectPath,
- sort: DEFAULT_SORT,
- });
- });
- });
-
- describe('when the URL contains an "after" query parameter', () => {
- beforeEach(() => {
- mockQueryParams = { after };
- createComponent();
- });
-
- it('makes a request with the correct GraphQL query parameters', () => {
- expect(queryMock).toHaveBeenCalledTimes(2);
-
- expect(queryMock).toHaveBeenCalledWith({
- after,
- first: 1,
- fullPath: projectPath,
- sort: DEFAULT_SORT,
- });
-
- expect(queryMock).toHaveBeenCalledWith({
- after,
- first: PAGE_SIZE,
- fullPath: projectPath,
- sort: DEFAULT_SORT,
- });
- });
- });
-
- describe('when the URL contains both "before" and "after" query parameters', () => {
- beforeEach(() => {
- mockQueryParams = { before, after };
- createComponent();
- });
-
- it('ignores the "before" parameter and behaves as if only the "after" parameter was provided', () => {
- expect(queryMock).toHaveBeenCalledTimes(2);
-
- expect(queryMock).toHaveBeenCalledWith({
- after,
- first: 1,
- fullPath: projectPath,
- sort: DEFAULT_SORT,
- });
-
- expect(queryMock).toHaveBeenCalledWith({
- after,
- first: PAGE_SIZE,
- fullPath: projectPath,
- sort: DEFAULT_SORT,
- });
- });
- });
- });
-
- describe('New release button', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders the new release button with the correct href', () => {
- expect(findNewReleaseButton().attributes().href).toBe(newReleasePath);
- });
- });
-
- describe('pagination', () => {
- beforeEach(() => {
- mockQueryParams = { before };
- createComponent();
- });
-
- it('requeries the GraphQL endpoint when a pagination button is clicked', async () => {
- expect(queryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]);
-
- mockQueryParams = { after };
- findPagination().vm.$emit('next', after);
-
- await nextTick();
-
- expect(queryMock.mock.calls).toEqual([
- [expect.objectContaining({ before })],
- [expect.objectContaining({ after })],
- [expect.objectContaining({ after })],
- ]);
- });
- });
-
- describe('sorting', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it(`sorts by ${DEFAULT_SORT} by default`, () => {
- expect(queryMock.mock.calls).toEqual([
- [expect.objectContaining({ sort: DEFAULT_SORT })],
- [expect.objectContaining({ sort: DEFAULT_SORT })],
- ]);
- });
-
- it('requeries the GraphQL endpoint and updates the URL when the sort is changed', async () => {
- findSort().vm.$emit('input', CREATED_ASC);
-
- await nextTick();
-
- expect(queryMock.mock.calls).toEqual([
- [expect.objectContaining({ sort: DEFAULT_SORT })],
- [expect.objectContaining({ sort: DEFAULT_SORT })],
- [expect.objectContaining({ sort: CREATED_ASC })],
- [expect.objectContaining({ sort: CREATED_ASC })],
- ]);
-
- // URL manipulation is tested in more detail in the `describe` block below
- expect(historyPushState).toHaveBeenCalled();
- });
-
- it('does not requery the GraphQL endpoint or update the URL if the sort is updated to the same value', async () => {
- findSort().vm.$emit('input', DEFAULT_SORT);
-
- await nextTick();
-
- expect(queryMock.mock.calls).toEqual([
- [expect.objectContaining({ sort: DEFAULT_SORT })],
- [expect.objectContaining({ sort: DEFAULT_SORT })],
- ]);
-
- expect(historyPushState).not.toHaveBeenCalled();
- });
- });
-
- describe('sorting + pagination interaction', () => {
- const nonPaginationQueryParam = 'nonPaginationQueryParam';
-
- beforeEach(() => {
- historyPushState.mockImplementation((newUrl) => {
- mockQueryParams = Object.fromEntries(new URL(newUrl).searchParams);
- });
- });
-
- describe.each`
- queryParamsBefore | paramName | paramInitialValue
- ${{ before, nonPaginationQueryParam }} | ${'before'} | ${before}
- ${{ after, nonPaginationQueryParam }} | ${'after'} | ${after}
- `(
- 'when the URL contains a "$paramName" pagination cursor',
- ({ queryParamsBefore, paramName, paramInitialValue }) => {
- beforeEach(async () => {
- mockQueryParams = queryParamsBefore;
- createComponent();
-
- findSort().vm.$emit('input', CREATED_ASC);
-
- await nextTick();
- });
-
- it(`resets the page's "${paramName}" pagination cursor when the sort is changed`, () => {
- const firstRequestVariables = queryMock.mock.calls[0][0];
- // Might be request #2 or #3, depending on the pagination direction
- const mostRecentRequestVariables =
- queryMock.mock.calls[queryMock.mock.calls.length - 1][0];
-
- expect(firstRequestVariables[paramName]).toBe(paramInitialValue);
- expect(mostRecentRequestVariables[paramName]).toBeUndefined();
- });
-
- it(`updates the URL to not include the "${paramName}" URL query parameter`, () => {
- expect(historyPushState).toHaveBeenCalledTimes(1);
-
- const updatedUrlQueryParams = Object.fromEntries(
- new URL(historyPushState.mock.calls[0][0]).searchParams,
- );
-
- expect(updatedUrlQueryParams[paramName]).toBeUndefined();
- });
- },
- );
- });
-});
diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js
index 43e88650ae3..0d376acf1ae 100644
--- a/spec/frontend/releases/components/app_index_spec.js
+++ b/spec/frontend/releases/components/app_index_spec.js
@@ -1,50 +1,87 @@
-import { shallowMount } from '@vue/test-utils';
-import { merge } from 'lodash';
-import Vue from 'vue';
-import Vuex from 'vuex';
-import { getParameterByName } from '~/lib/utils/url_utility';
-import AppIndex from '~/releases/components/app_index.vue';
+import { cloneDeep } from 'lodash';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import originalAllReleasesQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
+import createFlash from '~/flash';
+import { historyPushState } from '~/lib/utils/common_utils';
+import ReleasesIndexApp from '~/releases/components/app_index.vue';
+import ReleaseBlock from '~/releases/components/release_block.vue';
import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue';
+import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue';
import ReleasesPagination from '~/releases/components/releases_pagination.vue';
import ReleasesSort from '~/releases/components/releases_sort.vue';
+import { PAGE_SIZE, CREATED_ASC, DEFAULT_SORT } from '~/releases/constants';
+
+Vue.use(VueApollo);
+
+jest.mock('~/flash');
+
+let mockQueryParams;
+jest.mock('~/lib/utils/common_utils', () => ({
+ ...jest.requireActual('~/lib/utils/common_utils'),
+ historyPushState: jest.fn(),
+}));
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
- getParameterByName: jest.fn(),
+ getParameterByName: jest
+ .fn()
+ .mockImplementation((parameterName) => mockQueryParams[parameterName]),
}));
-Vue.use(Vuex);
-
describe('app_index.vue', () => {
+ const projectPath = 'project/path';
+ const newReleasePath = 'path/to/new/release/page';
+ const before = 'beforeCursor';
+ const after = 'afterCursor';
+
let wrapper;
- let fetchReleasesSpy;
- let urlParams;
-
- const createComponent = (storeUpdates) => {
- wrapper = shallowMount(AppIndex, {
- store: new Vuex.Store({
- modules: {
- index: merge(
- {
- namespaced: true,
- actions: {
- fetchReleases: fetchReleasesSpy,
- },
- state: {
- isLoading: true,
- releases: [],
- },
- },
- storeUpdates,
- ),
- },
- }),
+ let allReleases;
+ let singleRelease;
+ let noReleases;
+ let queryMock;
+
+ const createComponent = ({
+ singleResponse = Promise.resolve(singleRelease),
+ fullResponse = Promise.resolve(allReleases),
+ } = {}) => {
+ const apolloProvider = createMockApollo([
+ [
+ allReleasesQuery,
+ queryMock.mockImplementation((vars) => {
+ return vars.first === 1 ? singleResponse : fullResponse;
+ }),
+ ],
+ ]);
+
+ wrapper = shallowMountExtended(ReleasesIndexApp, {
+ apolloProvider,
+ provide: {
+ newReleasePath,
+ projectPath,
+ },
});
};
beforeEach(() => {
- fetchReleasesSpy = jest.fn();
- getParameterByName.mockImplementation((paramName) => urlParams[paramName]);
+ mockQueryParams = {};
+
+ allReleases = cloneDeep(originalAllReleasesQueryResponse);
+
+ singleRelease = cloneDeep(originalAllReleasesQueryResponse);
+ singleRelease.data.project.releases.nodes.splice(
+ 1,
+ singleRelease.data.project.releases.nodes.length,
+ );
+
+ noReleases = cloneDeep(originalAllReleasesQueryResponse);
+ noReleases.data.project.releases.nodes = [];
+
+ queryMock = jest.fn();
});
afterEach(() => {
@@ -52,120 +89,220 @@ describe('app_index.vue', () => {
});
// Finders
- const findLoadingIndicator = () => wrapper.find(ReleaseSkeletonLoader);
- const findEmptyState = () => wrapper.find('[data-testid="empty-state"]');
- const findSuccessState = () => wrapper.find('[data-testid="success-state"]');
- const findPagination = () => wrapper.find(ReleasesPagination);
- const findSortControls = () => wrapper.find(ReleasesSort);
- const findNewReleaseButton = () => wrapper.find('[data-testid="new-release-button"]');
-
- // Expectations
- const expectLoadingIndicator = (shouldExist) => {
- it(`${shouldExist ? 'renders' : 'does not render'} a loading indicator`, () => {
- expect(findLoadingIndicator().exists()).toBe(shouldExist);
- });
- };
+ const findLoadingIndicator = () => wrapper.findComponent(ReleaseSkeletonLoader);
+ const findEmptyState = () => wrapper.findComponent(ReleasesEmptyState);
+ const findNewReleaseButton = () => wrapper.findByText(ReleasesIndexApp.i18n.newRelease);
+ const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock);
+ const findPagination = () => wrapper.findComponent(ReleasesPagination);
+ const findSort = () => wrapper.findComponent(ReleasesSort);
- const expectEmptyState = (shouldExist) => {
- it(`${shouldExist ? 'renders' : 'does not render'} an empty state`, () => {
- expect(findEmptyState().exists()).toBe(shouldExist);
- });
- };
+ // Tests
+ describe('component states', () => {
+ // These need to be defined as functions, since `singleRelease` and
+ // `allReleases` are generated in a `beforeEach`, and therefore
+ // aren't available at test definition time.
+ const getInProgressResponse = () => new Promise(() => {});
+ const getErrorResponse = () => Promise.reject(new Error('Oops!'));
+ const getSingleRequestLoadedResponse = () => Promise.resolve(singleRelease);
+ const getFullRequestLoadedResponse = () => Promise.resolve(allReleases);
+ const getLoadedEmptyResponse = () => Promise.resolve(noReleases);
+
+ const toDescription = (bool) => (bool ? 'does' : 'does not');
+
+ describe.each`
+ description | singleResponseFn | fullResponseFn | loadingIndicator | emptyState | flashMessage | releaseCount | pagination
+ ${'both requests loading'} | ${getInProgressResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
+ ${'both requests failed'} | ${getErrorResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${0} | ${false}
+ ${'both requests loaded'} | ${getSingleRequestLoadedResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
+ ${'both requests loaded with no results'} | ${getLoadedEmptyResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false}
+ ${'single request loading, full request loaded'} | ${getInProgressResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
+ ${'single request loading, full request failed'} | ${getInProgressResponse} | ${getErrorResponse} | ${true} | ${false} | ${true} | ${0} | ${false}
+ ${'single request loaded, full request loading'} | ${getSingleRequestLoadedResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${1} | ${false}
+ ${'single request loaded, full request failed'} | ${getSingleRequestLoadedResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${1} | ${false}
+ ${'single request failed, full request loading'} | ${getErrorResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
+ ${'single request failed, full request loaded'} | ${getErrorResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
+ ${'single request loaded with no results, full request loading'} | ${getLoadedEmptyResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
+ ${'single request loading, full request loadied with no results'} | ${getInProgressResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false}
+ `(
+ '$description',
+ ({
+ singleResponseFn,
+ fullResponseFn,
+ loadingIndicator,
+ emptyState,
+ flashMessage,
+ releaseCount,
+ pagination,
+ }) => {
+ beforeEach(() => {
+ createComponent({
+ singleResponse: singleResponseFn(),
+ fullResponse: fullResponseFn(),
+ });
+ });
+
+ it(`${toDescription(loadingIndicator)} render a loading indicator`, async () => {
+ await waitForPromises();
+ expect(findLoadingIndicator().exists()).toBe(loadingIndicator);
+ });
+
+ it(`${toDescription(emptyState)} render an empty state`, () => {
+ expect(findEmptyState().exists()).toBe(emptyState);
+ });
+
+ it(`${toDescription(flashMessage)} show a flash message`, () => {
+ if (flashMessage) {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: ReleasesIndexApp.i18n.errorMessage,
+ captureError: true,
+ error: expect.any(Error),
+ });
+ } else {
+ expect(createFlash).not.toHaveBeenCalled();
+ }
+ });
+
+ it(`renders ${releaseCount} release(s)`, () => {
+ expect(findAllReleaseBlocks()).toHaveLength(releaseCount);
+ });
+
+ it(`${toDescription(pagination)} render the pagination controls`, () => {
+ expect(findPagination().exists()).toBe(pagination);
+ });
+
+ it('does render the "New release" button', () => {
+ expect(findNewReleaseButton().exists()).toBe(true);
+ });
+
+ it('does render the sort controls', () => {
+ expect(findSort().exists()).toBe(true);
+ });
+ },
+ );
+ });
- const expectSuccessState = (shouldExist) => {
- it(`${shouldExist ? 'renders' : 'does not render'} the success state`, () => {
- expect(findSuccessState().exists()).toBe(shouldExist);
- });
- };
+ describe('URL parameters', () => {
+ describe('when the URL contains no query parameters', () => {
+ beforeEach(() => {
+ createComponent();
+ });
- const expectPagination = (shouldExist) => {
- it(`${shouldExist ? 'renders' : 'does not render'} the pagination controls`, () => {
- expect(findPagination().exists()).toBe(shouldExist);
- });
- };
+ it('makes a request with the correct GraphQL query parameters', () => {
+ expect(queryMock).toHaveBeenCalledTimes(2);
- const expectNewReleaseButton = (shouldExist) => {
- it(`${shouldExist ? 'renders' : 'does not render'} the "New release" button`, () => {
- expect(findNewReleaseButton().exists()).toBe(shouldExist);
- });
- };
+ expect(queryMock).toHaveBeenCalledWith({
+ first: 1,
+ fullPath: projectPath,
+ sort: DEFAULT_SORT,
+ });
- // Tests
- describe('on startup', () => {
- it.each`
- before | after
- ${null} | ${null}
- ${'before_param_value'} | ${null}
- ${null} | ${'after_param_value'}
- `(
- 'calls fetchRelease with the correct parameters based on the curent query parameters: before: $before, after: $after',
- ({ before, after }) => {
- urlParams = { before, after };
+ expect(queryMock).toHaveBeenCalledWith({
+ first: PAGE_SIZE,
+ fullPath: projectPath,
+ sort: DEFAULT_SORT,
+ });
+ });
+ });
+ describe('when the URL contains a "before" query parameter', () => {
+ beforeEach(() => {
+ mockQueryParams = { before };
createComponent();
+ });
- expect(fetchReleasesSpy).toHaveBeenCalledTimes(1);
- expect(fetchReleasesSpy).toHaveBeenCalledWith(expect.anything(), urlParams);
- },
- );
- });
+ it('makes a request with the correct GraphQL query parameters', () => {
+ expect(queryMock).toHaveBeenCalledTimes(1);
- describe('when the request to fetch releases has not yet completed', () => {
- beforeEach(() => {
- createComponent();
+ expect(queryMock).toHaveBeenCalledWith({
+ before,
+ last: PAGE_SIZE,
+ fullPath: projectPath,
+ sort: DEFAULT_SORT,
+ });
+ });
});
- expectLoadingIndicator(true);
- expectEmptyState(false);
- expectSuccessState(false);
- expectPagination(false);
- });
+ describe('when the URL contains an "after" query parameter', () => {
+ beforeEach(() => {
+ mockQueryParams = { after };
+ createComponent();
+ });
- describe('when the request fails', () => {
- beforeEach(() => {
- createComponent({
- state: {
- isLoading: false,
- hasError: true,
- },
+ it('makes a request with the correct GraphQL query parameters', () => {
+ expect(queryMock).toHaveBeenCalledTimes(2);
+
+ expect(queryMock).toHaveBeenCalledWith({
+ after,
+ first: 1,
+ fullPath: projectPath,
+ sort: DEFAULT_SORT,
+ });
+
+ expect(queryMock).toHaveBeenCalledWith({
+ after,
+ first: PAGE_SIZE,
+ fullPath: projectPath,
+ sort: DEFAULT_SORT,
+ });
});
});
- expectLoadingIndicator(false);
- expectEmptyState(false);
- expectSuccessState(false);
- expectPagination(true);
+ describe('when the URL contains both "before" and "after" query parameters', () => {
+ beforeEach(() => {
+ mockQueryParams = { before, after };
+ createComponent();
+ });
+
+ it('ignores the "before" parameter and behaves as if only the "after" parameter was provided', () => {
+ expect(queryMock).toHaveBeenCalledTimes(2);
+
+ expect(queryMock).toHaveBeenCalledWith({
+ after,
+ first: 1,
+ fullPath: projectPath,
+ sort: DEFAULT_SORT,
+ });
+
+ expect(queryMock).toHaveBeenCalledWith({
+ after,
+ first: PAGE_SIZE,
+ fullPath: projectPath,
+ sort: DEFAULT_SORT,
+ });
+ });
+ });
});
- describe('when the request succeeds but returns no releases', () => {
+ describe('New release button', () => {
beforeEach(() => {
- createComponent({
- state: {
- isLoading: false,
- },
- });
+ createComponent();
});
- expectLoadingIndicator(false);
- expectEmptyState(true);
- expectSuccessState(false);
- expectPagination(true);
+ it('renders the new release button with the correct href', () => {
+ expect(findNewReleaseButton().attributes().href).toBe(newReleasePath);
+ });
});
- describe('when the request succeeds and includes at least one release', () => {
+ describe('pagination', () => {
beforeEach(() => {
- createComponent({
- state: {
- isLoading: false,
- releases: [{}],
- },
- });
+ mockQueryParams = { before };
+ createComponent();
});
- expectLoadingIndicator(false);
- expectEmptyState(false);
- expectSuccessState(true);
- expectPagination(true);
+ it('requeries the GraphQL endpoint when a pagination button is clicked', async () => {
+ expect(queryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]);
+
+ mockQueryParams = { after };
+ findPagination().vm.$emit('next', after);
+
+ await nextTick();
+
+ expect(queryMock.mock.calls).toEqual([
+ [expect.objectContaining({ before })],
+ [expect.objectContaining({ after })],
+ [expect.objectContaining({ after })],
+ ]);
+ });
});
describe('sorting', () => {
@@ -173,59 +310,88 @@ describe('app_index.vue', () => {
createComponent();
});
- it('renders the sort controls', () => {
- expect(findSortControls().exists()).toBe(true);
+ it(`sorts by ${DEFAULT_SORT} by default`, () => {
+ expect(queryMock.mock.calls).toEqual([
+ [expect.objectContaining({ sort: DEFAULT_SORT })],
+ [expect.objectContaining({ sort: DEFAULT_SORT })],
+ ]);
});
- it('calls the fetchReleases store method when the sort is updated', () => {
- fetchReleasesSpy.mockClear();
+ it('requeries the GraphQL endpoint and updates the URL when the sort is changed', async () => {
+ findSort().vm.$emit('input', CREATED_ASC);
+
+ await nextTick();
- findSortControls().vm.$emit('sort:changed');
+ expect(queryMock.mock.calls).toEqual([
+ [expect.objectContaining({ sort: DEFAULT_SORT })],
+ [expect.objectContaining({ sort: DEFAULT_SORT })],
+ [expect.objectContaining({ sort: CREATED_ASC })],
+ [expect.objectContaining({ sort: CREATED_ASC })],
+ ]);
- expect(fetchReleasesSpy).toHaveBeenCalledTimes(1);
+ // URL manipulation is tested in more detail in the `describe` block below
+ expect(historyPushState).toHaveBeenCalled();
});
- });
- describe('"New release" button', () => {
- describe('when the user is allowed to create releases', () => {
- const newReleasePath = 'path/to/new/release/page';
+ it('does not requery the GraphQL endpoint or update the URL if the sort is updated to the same value', async () => {
+ findSort().vm.$emit('input', DEFAULT_SORT);
- beforeEach(() => {
- createComponent({ state: { newReleasePath } });
- });
+ await nextTick();
- expectNewReleaseButton(true);
+ expect(queryMock.mock.calls).toEqual([
+ [expect.objectContaining({ sort: DEFAULT_SORT })],
+ [expect.objectContaining({ sort: DEFAULT_SORT })],
+ ]);
- it('renders the button with the correct href', () => {
- expect(findNewReleaseButton().attributes('href')).toBe(newReleasePath);
- });
+ expect(historyPushState).not.toHaveBeenCalled();
});
+ });
- describe('when the user is not allowed to create releases', () => {
- beforeEach(() => {
- createComponent();
- });
+ describe('sorting + pagination interaction', () => {
+ const nonPaginationQueryParam = 'nonPaginationQueryParam';
- expectNewReleaseButton(false);
+ beforeEach(() => {
+ historyPushState.mockImplementation((newUrl) => {
+ mockQueryParams = Object.fromEntries(new URL(newUrl).searchParams);
+ });
});
- });
- describe("when the browser's back button is pressed", () => {
- beforeEach(() => {
- urlParams = {
- before: 'before_param_value',
- };
+ describe.each`
+ queryParamsBefore | paramName | paramInitialValue
+ ${{ before, nonPaginationQueryParam }} | ${'before'} | ${before}
+ ${{ after, nonPaginationQueryParam }} | ${'after'} | ${after}
+ `(
+ 'when the URL contains a "$paramName" pagination cursor',
+ ({ queryParamsBefore, paramName, paramInitialValue }) => {
+ beforeEach(async () => {
+ mockQueryParams = queryParamsBefore;
+ createComponent();
- createComponent();
+ findSort().vm.$emit('input', CREATED_ASC);
- fetchReleasesSpy.mockClear();
+ await nextTick();
+ });
- window.dispatchEvent(new PopStateEvent('popstate'));
- });
+ it(`resets the page's "${paramName}" pagination cursor when the sort is changed`, () => {
+ const firstRequestVariables = queryMock.mock.calls[0][0];
+ // Might be request #2 or #3, depending on the pagination direction
+ const mostRecentRequestVariables =
+ queryMock.mock.calls[queryMock.mock.calls.length - 1][0];
- it('calls the fetchRelease store method with the parameters from the URL query', () => {
- expect(fetchReleasesSpy).toHaveBeenCalledTimes(1);
- expect(fetchReleasesSpy).toHaveBeenCalledWith(expect.anything(), urlParams);
- });
+ expect(firstRequestVariables[paramName]).toBe(paramInitialValue);
+ expect(mostRecentRequestVariables[paramName]).toBeUndefined();
+ });
+
+ it(`updates the URL to not include the "${paramName}" URL query parameter`, () => {
+ expect(historyPushState).toHaveBeenCalledTimes(1);
+
+ const updatedUrlQueryParams = Object.fromEntries(
+ new URL(historyPushState.mock.calls[0][0]).searchParams,
+ );
+
+ expect(updatedUrlQueryParams[paramName]).toBeUndefined();
+ });
+ },
+ );
});
});
diff --git a/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js b/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js
deleted file mode 100644
index a538afd5d38..00000000000
--- a/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js
+++ /dev/null
@@ -1,126 +0,0 @@
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import { historyPushState } from '~/lib/utils/common_utils';
-import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue';
-
-jest.mock('~/lib/utils/common_utils', () => ({
- ...jest.requireActual('~/lib/utils/common_utils'),
- historyPushState: jest.fn(),
-}));
-
-describe('releases_pagination_apollo_client.vue', () => {
- const startCursor = 'startCursor';
- const endCursor = 'endCursor';
- let wrapper;
- let onPrev;
- let onNext;
-
- const createComponent = (pageInfo) => {
- onPrev = jest.fn();
- onNext = jest.fn();
-
- wrapper = mountExtended(ReleasesPaginationApolloClient, {
- propsData: {
- pageInfo,
- },
- listeners: {
- prev: onPrev,
- next: onNext,
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- const singlePageInfo = {
- hasPreviousPage: false,
- hasNextPage: false,
- startCursor,
- endCursor,
- };
-
- const onlyNextPageInfo = {
- hasPreviousPage: false,
- hasNextPage: true,
- startCursor,
- endCursor,
- };
-
- const onlyPrevPageInfo = {
- hasPreviousPage: true,
- hasNextPage: false,
- startCursor,
- endCursor,
- };
-
- const prevAndNextPageInfo = {
- hasPreviousPage: true,
- hasNextPage: true,
- startCursor,
- endCursor,
- };
-
- const findPrevButton = () => wrapper.findByTestId('prevButton');
- const findNextButton = () => wrapper.findByTestId('nextButton');
-
- describe.each`
- description | pageInfo | prevEnabled | nextEnabled
- ${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${false}
- ${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${false} | ${true}
- ${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${false}
- ${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true}
- `('component states', ({ description, pageInfo, prevEnabled, nextEnabled }) => {
- describe(description, () => {
- beforeEach(() => {
- createComponent(pageInfo);
- });
-
- it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => {
- expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled');
- });
-
- it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => {
- expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled');
- });
- });
- });
-
- describe('button behavior', () => {
- beforeEach(() => {
- createComponent(prevAndNextPageInfo);
- });
-
- describe('next button behavior', () => {
- beforeEach(() => {
- findNextButton().trigger('click');
- });
-
- it('emits an "next" event with the "after" cursor', () => {
- expect(onNext.mock.calls).toEqual([[endCursor]]);
- });
-
- it('calls historyPushState with the new URL', () => {
- expect(historyPushState.mock.calls).toEqual([
- [expect.stringContaining(`?after=${endCursor}`)],
- ]);
- });
- });
-
- describe('prev button behavior', () => {
- beforeEach(() => {
- findPrevButton().trigger('click');
- });
-
- it('emits an "prev" event with the "before" cursor', () => {
- expect(onPrev.mock.calls).toEqual([[startCursor]]);
- });
-
- it('calls historyPushState with the new URL', () => {
- expect(historyPushState.mock.calls).toEqual([
- [expect.stringContaining(`?before=${startCursor}`)],
- ]);
- });
- });
- });
-});
diff --git a/spec/frontend/releases/components/releases_pagination_spec.js b/spec/frontend/releases/components/releases_pagination_spec.js
index b8c69b0ea70..59be808c802 100644
--- a/spec/frontend/releases/components/releases_pagination_spec.js
+++ b/spec/frontend/releases/components/releases_pagination_spec.js
@@ -1,140 +1,94 @@
-import { GlKeysetPagination } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
-import Vue from 'vue';
-import Vuex from 'vuex';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import { historyPushState } from '~/lib/utils/common_utils';
import ReleasesPagination from '~/releases/components/releases_pagination.vue';
-import createStore from '~/releases/stores';
-import createIndexModule from '~/releases/stores/modules/index';
jest.mock('~/lib/utils/common_utils', () => ({
...jest.requireActual('~/lib/utils/common_utils'),
historyPushState: jest.fn(),
}));
-Vue.use(Vuex);
-
-describe('~/releases/components/releases_pagination.vue', () => {
+describe('releases_pagination.vue', () => {
+ const startCursor = 'startCursor';
+ const endCursor = 'endCursor';
let wrapper;
- let indexModule;
-
- const cursors = {
- startCursor: 'startCursor',
- endCursor: 'endCursor',
- };
-
- const projectPath = 'my/project';
+ let onPrev;
+ let onNext;
const createComponent = (pageInfo) => {
- indexModule = createIndexModule({ projectPath });
-
- indexModule.state.pageInfo = pageInfo;
-
- indexModule.actions.fetchReleases = jest.fn();
-
- wrapper = mount(ReleasesPagination, {
- store: createStore({
- modules: {
- index: indexModule,
- },
- featureFlags: {},
- }),
+ onPrev = jest.fn();
+ onNext = jest.fn();
+
+ wrapper = mountExtended(ReleasesPagination, {
+ propsData: {
+ pageInfo,
+ },
+ listeners: {
+ prev: onPrev,
+ next: onNext,
+ },
});
};
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
- const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination);
- const findPrevButton = () => findGlKeysetPagination().find('[data-testid="prevButton"]');
- const findNextButton = () => findGlKeysetPagination().find('[data-testid="nextButton"]');
-
- const expectDisabledPrev = () => {
- expect(findPrevButton().attributes().disabled).toBe('disabled');
+ const singlePageInfo = {
+ hasPreviousPage: false,
+ hasNextPage: false,
+ startCursor,
+ endCursor,
};
- const expectEnabledPrev = () => {
- expect(findPrevButton().attributes().disabled).toBe(undefined);
+
+ const onlyNextPageInfo = {
+ hasPreviousPage: false,
+ hasNextPage: true,
+ startCursor,
+ endCursor,
};
- const expectDisabledNext = () => {
- expect(findNextButton().attributes().disabled).toBe('disabled');
+
+ const onlyPrevPageInfo = {
+ hasPreviousPage: true,
+ hasNextPage: false,
+ startCursor,
+ endCursor,
};
- const expectEnabledNext = () => {
- expect(findNextButton().attributes().disabled).toBe(undefined);
+
+ const prevAndNextPageInfo = {
+ hasPreviousPage: true,
+ hasNextPage: true,
+ startCursor,
+ endCursor,
};
- describe('when there is only one page of results', () => {
- beforeEach(() => {
- createComponent({
- hasPreviousPage: false,
- hasNextPage: false,
+ const findPrevButton = () => wrapper.findByTestId('prevButton');
+ const findNextButton = () => wrapper.findByTestId('nextButton');
+
+ describe.each`
+ description | pageInfo | prevEnabled | nextEnabled
+ ${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${false}
+ ${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${false} | ${true}
+ ${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${false}
+ ${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true}
+ `('component states', ({ description, pageInfo, prevEnabled, nextEnabled }) => {
+ describe(description, () => {
+ beforeEach(() => {
+ createComponent(pageInfo);
});
- });
-
- it('does not render a GlKeysetPagination', () => {
- expect(findGlKeysetPagination().exists()).toBe(false);
- });
- });
- describe('when there is a next page, but not a previous page', () => {
- beforeEach(() => {
- createComponent({
- hasPreviousPage: false,
- hasNextPage: true,
+ it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => {
+ expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled');
});
- });
-
- it('renders a disabled "Prev" button', () => {
- expectDisabledPrev();
- });
- it('renders an enabled "Next" button', () => {
- expectEnabledNext();
- });
- });
-
- describe('when there is a previous page, but not a next page', () => {
- beforeEach(() => {
- createComponent({
- hasPreviousPage: true,
- hasNextPage: false,
- });
- });
-
- it('renders a enabled "Prev" button', () => {
- expectEnabledPrev();
- });
-
- it('renders an disabled "Next" button', () => {
- expectDisabledNext();
- });
- });
-
- describe('when there is both a previous page and a next page', () => {
- beforeEach(() => {
- createComponent({
- hasPreviousPage: true,
- hasNextPage: true,
+ it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => {
+ expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled');
});
});
-
- it('renders a enabled "Prev" button', () => {
- expectEnabledPrev();
- });
-
- it('renders an enabled "Next" button', () => {
- expectEnabledNext();
- });
});
describe('button behavior', () => {
beforeEach(() => {
- createComponent({
- hasPreviousPage: true,
- hasNextPage: true,
- ...cursors,
- });
+ createComponent(prevAndNextPageInfo);
});
describe('next button behavior', () => {
@@ -142,33 +96,29 @@ describe('~/releases/components/releases_pagination.vue', () => {
findNextButton().trigger('click');
});
- it('calls fetchReleases with the correct after cursor', () => {
- expect(indexModule.actions.fetchReleases.mock.calls).toEqual([
- [expect.anything(), { after: cursors.endCursor }],
- ]);
+ it('emits an "next" event with the "after" cursor', () => {
+ expect(onNext.mock.calls).toEqual([[endCursor]]);
});
it('calls historyPushState with the new URL', () => {
expect(historyPushState.mock.calls).toEqual([
- [expect.stringContaining(`?after=${cursors.endCursor}`)],
+ [expect.stringContaining(`?after=${endCursor}`)],
]);
});
});
- describe('previous button behavior', () => {
+ describe('prev button behavior', () => {
beforeEach(() => {
findPrevButton().trigger('click');
});
- it('calls fetchReleases with the correct before cursor', () => {
- expect(indexModule.actions.fetchReleases.mock.calls).toEqual([
- [expect.anything(), { before: cursors.startCursor }],
- ]);
+ it('emits an "prev" event with the "before" cursor', () => {
+ expect(onPrev.mock.calls).toEqual([[startCursor]]);
});
it('calls historyPushState with the new URL', () => {
expect(historyPushState.mock.calls).toEqual([
- [expect.stringContaining(`?before=${cursors.startCursor}`)],
+ [expect.stringContaining(`?before=${startCursor}`)],
]);
});
});
diff --git a/spec/frontend/releases/components/releases_sort_apollo_client_spec.js b/spec/frontend/releases/components/releases_sort_apollo_client_spec.js
deleted file mode 100644
index d93a932af01..00000000000
--- a/spec/frontend/releases/components/releases_sort_apollo_client_spec.js
+++ /dev/null
@@ -1,103 +0,0 @@
-import { GlSorting, GlSortingItem } from '@gitlab/ui';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import ReleasesSortApolloClient from '~/releases/components/releases_sort_apollo_client.vue';
-import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants';
-
-describe('releases_sort_apollo_client.vue', () => {
- let wrapper;
-
- const createComponent = (valueProp = RELEASED_AT_ASC) => {
- wrapper = shallowMountExtended(ReleasesSortApolloClient, {
- propsData: {
- value: valueProp,
- },
- stubs: {
- GlSortingItem,
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- const findSorting = () => wrapper.findComponent(GlSorting);
- const findSortingItems = () => wrapper.findAllComponents(GlSortingItem);
- const findReleasedDateItem = () =>
- findSortingItems().wrappers.find((item) => item.text() === 'Released date');
- const findCreatedDateItem = () =>
- findSortingItems().wrappers.find((item) => item.text() === 'Created date');
- const getSortingItemsInfo = () =>
- findSortingItems().wrappers.map((item) => ({
- label: item.text(),
- active: item.attributes().active === 'true',
- }));
-
- describe.each`
- valueProp | text | isAscending | items
- ${RELEASED_AT_ASC} | ${'Released date'} | ${true} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]}
- ${RELEASED_AT_DESC} | ${'Released date'} | ${false} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]}
- ${CREATED_ASC} | ${'Created date'} | ${true} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]}
- ${CREATED_DESC} | ${'Created date'} | ${false} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]}
- `('component states', ({ valueProp, text, isAscending, items }) => {
- beforeEach(() => {
- createComponent(valueProp);
- });
-
- it(`when the sort is ${valueProp}, provides the GlSorting with the props text="${text}" and isAscending=${isAscending}`, () => {
- expect(findSorting().props()).toEqual(
- expect.objectContaining({
- text,
- isAscending,
- }),
- );
- });
-
- it(`when the sort is ${valueProp}, renders the expected dropdown items`, () => {
- expect(getSortingItemsInfo()).toEqual(items);
- });
- });
-
- const clickReleasedDateItem = () => findReleasedDateItem().vm.$emit('click');
- const clickCreatedDateItem = () => findCreatedDateItem().vm.$emit('click');
- const clickSortDirectionButton = () => findSorting().vm.$emit('sortDirectionChange');
-
- const releasedAtDropdownItemDescription = 'released at dropdown item';
- const createdAtDropdownItemDescription = 'created at dropdown item';
- const sortDirectionButtonDescription = 'sort direction button';
-
- describe.each`
- initialValueProp | itemClickFn | itemToClickDescription | emittedEvent
- ${RELEASED_AT_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined}
- ${RELEASED_AT_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_ASC}
- ${RELEASED_AT_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_DESC}
- ${RELEASED_AT_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined}
- ${RELEASED_AT_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_DESC}
- ${RELEASED_AT_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_ASC}
- ${CREATED_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_ASC}
- ${CREATED_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined}
- ${CREATED_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_DESC}
- ${CREATED_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_DESC}
- ${CREATED_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined}
- ${CREATED_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_ASC}
- `('input event', ({ initialValueProp, itemClickFn, itemToClickDescription, emittedEvent }) => {
- beforeEach(() => {
- createComponent(initialValueProp);
- itemClickFn();
- });
-
- it(`emits ${
- emittedEvent || 'nothing'
- } when value prop is ${initialValueProp} and the ${itemToClickDescription} is clicked`, () => {
- expect(wrapper.emitted().input?.[0]?.[0]).toEqual(emittedEvent);
- });
- });
-
- describe('prop validation', () => {
- it('validates that the `value` prop is one of the expected sort strings', () => {
- expect(() => {
- createComponent('not a valid value');
- }).toThrow('Invalid prop: custom validator check failed');
- });
- });
-});
diff --git a/spec/frontend/releases/components/releases_sort_spec.js b/spec/frontend/releases/components/releases_sort_spec.js
index 7774532bc12..c6e1846d252 100644
--- a/spec/frontend/releases/components/releases_sort_spec.js
+++ b/spec/frontend/releases/components/releases_sort_spec.js
@@ -1,65 +1,103 @@
import { GlSorting, GlSortingItem } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
-import Vuex from 'vuex';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ReleasesSort from '~/releases/components/releases_sort.vue';
-import createStore from '~/releases/stores';
-import createIndexModule from '~/releases/stores/modules/index';
+import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants';
-Vue.use(Vuex);
-
-describe('~/releases/components/releases_sort.vue', () => {
+describe('releases_sort.vue', () => {
let wrapper;
- let store;
- let indexModule;
- const projectId = 8;
-
- const createComponent = () => {
- indexModule = createIndexModule({ projectId });
- store = createStore({
- modules: {
- index: indexModule,
+ const createComponent = (valueProp = RELEASED_AT_ASC) => {
+ wrapper = shallowMountExtended(ReleasesSort, {
+ propsData: {
+ value: valueProp,
},
- });
-
- store.dispatch = jest.fn();
-
- wrapper = shallowMount(ReleasesSort, {
- store,
stubs: {
GlSortingItem,
},
});
};
- const findReleasesSorting = () => wrapper.find(GlSorting);
- const findSortingItems = () => wrapper.findAll(GlSortingItem);
-
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
- beforeEach(() => {
- createComponent();
- });
+ const findSorting = () => wrapper.findComponent(GlSorting);
+ const findSortingItems = () => wrapper.findAllComponents(GlSortingItem);
+ const findReleasedDateItem = () =>
+ findSortingItems().wrappers.find((item) => item.text() === 'Released date');
+ const findCreatedDateItem = () =>
+ findSortingItems().wrappers.find((item) => item.text() === 'Created date');
+ const getSortingItemsInfo = () =>
+ findSortingItems().wrappers.map((item) => ({
+ label: item.text(),
+ active: item.attributes().active === 'true',
+ }));
+
+ describe.each`
+ valueProp | text | isAscending | items
+ ${RELEASED_AT_ASC} | ${'Released date'} | ${true} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]}
+ ${RELEASED_AT_DESC} | ${'Released date'} | ${false} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]}
+ ${CREATED_ASC} | ${'Created date'} | ${true} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]}
+ ${CREATED_DESC} | ${'Created date'} | ${false} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]}
+ `('component states', ({ valueProp, text, isAscending, items }) => {
+ beforeEach(() => {
+ createComponent(valueProp);
+ });
- it('has all the sortable items', () => {
- expect(findSortingItems()).toHaveLength(wrapper.vm.sortOptions.length);
+ it(`when the sort is ${valueProp}, provides the GlSorting with the props text="${text}" and isAscending=${isAscending}`, () => {
+ expect(findSorting().props()).toEqual(
+ expect.objectContaining({
+ text,
+ isAscending,
+ }),
+ );
+ });
+
+ it(`when the sort is ${valueProp}, renders the expected dropdown items`, () => {
+ expect(getSortingItemsInfo()).toEqual(items);
+ });
});
- it('on sort change set sorting in vuex and emit event', () => {
- findReleasesSorting().vm.$emit('sortDirectionChange');
- expect(store.dispatch).toHaveBeenCalledWith('index/setSorting', { sort: 'asc' });
- expect(wrapper.emitted('sort:changed')).toBeTruthy();
+ const clickReleasedDateItem = () => findReleasedDateItem().vm.$emit('click');
+ const clickCreatedDateItem = () => findCreatedDateItem().vm.$emit('click');
+ const clickSortDirectionButton = () => findSorting().vm.$emit('sortDirectionChange');
+
+ const releasedAtDropdownItemDescription = 'released at dropdown item';
+ const createdAtDropdownItemDescription = 'created at dropdown item';
+ const sortDirectionButtonDescription = 'sort direction button';
+
+ describe.each`
+ initialValueProp | itemClickFn | itemToClickDescription | emittedEvent
+ ${RELEASED_AT_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined}
+ ${RELEASED_AT_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_ASC}
+ ${RELEASED_AT_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_DESC}
+ ${RELEASED_AT_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined}
+ ${RELEASED_AT_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_DESC}
+ ${RELEASED_AT_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_ASC}
+ ${CREATED_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_ASC}
+ ${CREATED_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined}
+ ${CREATED_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_DESC}
+ ${CREATED_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_DESC}
+ ${CREATED_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined}
+ ${CREATED_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_ASC}
+ `('input event', ({ initialValueProp, itemClickFn, itemToClickDescription, emittedEvent }) => {
+ beforeEach(() => {
+ createComponent(initialValueProp);
+ itemClickFn();
+ });
+
+ it(`emits ${
+ emittedEvent || 'nothing'
+ } when value prop is ${initialValueProp} and the ${itemToClickDescription} is clicked`, () => {
+ expect(wrapper.emitted().input?.[0]?.[0]).toEqual(emittedEvent);
+ });
});
- it('on sort item click set sorting and emit event', () => {
- const item = findSortingItems().at(0);
- const { orderBy } = wrapper.vm.sortOptions[0];
- item.vm.$emit('click');
- expect(store.dispatch).toHaveBeenCalledWith('index/setSorting', { orderBy });
- expect(wrapper.emitted('sort:changed')).toBeTruthy();
+ describe('prop validation', () => {
+ it('validates that the `value` prop is one of the expected sort strings', () => {
+ expect(() => {
+ createComponent('not a valid value');
+ }).toThrow('Invalid prop: custom validator check failed');
+ });
});
});
diff --git a/spec/frontend/releases/stores/modules/list/actions_spec.js b/spec/frontend/releases/stores/modules/list/actions_spec.js
deleted file mode 100644
index 91406f7e2f4..00000000000
--- a/spec/frontend/releases/stores/modules/list/actions_spec.js
+++ /dev/null
@@ -1,197 +0,0 @@
-import { cloneDeep } from 'lodash';
-import originalGraphqlReleasesResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json';
-import testAction from 'helpers/vuex_action_helper';
-import { PAGE_SIZE } from '~/releases/constants';
-import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
-import {
- fetchReleases,
- receiveReleasesError,
- setSorting,
-} from '~/releases/stores/modules/index/actions';
-import * as types from '~/releases/stores/modules/index/mutation_types';
-import createState from '~/releases/stores/modules/index/state';
-import { gqClient, convertAllReleasesGraphQLResponse } from '~/releases/util';
-
-describe('Releases State actions', () => {
- let mockedState;
- let graphqlReleasesResponse;
-
- const projectPath = 'root/test-project';
- const projectId = 19;
- const before = 'testBeforeCursor';
- const after = 'testAfterCursor';
-
- beforeEach(() => {
- mockedState = {
- ...createState({
- projectId,
- projectPath,
- }),
- };
-
- graphqlReleasesResponse = cloneDeep(originalGraphqlReleasesResponse);
- });
-
- describe('fetchReleases', () => {
- describe('GraphQL query variables', () => {
- let vuexParams;
-
- beforeEach(() => {
- jest.spyOn(gqClient, 'query');
-
- vuexParams = { dispatch: jest.fn(), commit: jest.fn(), state: mockedState };
- });
-
- describe('when neither a before nor an after parameter is provided', () => {
- beforeEach(() => {
- fetchReleases(vuexParams, { before: undefined, after: undefined });
- });
-
- it('makes a GraphQl query with a first variable', () => {
- expect(gqClient.query).toHaveBeenCalledWith({
- query: allReleasesQuery,
- variables: { fullPath: projectPath, first: PAGE_SIZE, sort: 'RELEASED_AT_DESC' },
- });
- });
- });
-
- describe('when only a before parameter is provided', () => {
- beforeEach(() => {
- fetchReleases(vuexParams, { before, after: undefined });
- });
-
- it('makes a GraphQl query with last and before variables', () => {
- expect(gqClient.query).toHaveBeenCalledWith({
- query: allReleasesQuery,
- variables: { fullPath: projectPath, last: PAGE_SIZE, before, sort: 'RELEASED_AT_DESC' },
- });
- });
- });
-
- describe('when only an after parameter is provided', () => {
- beforeEach(() => {
- fetchReleases(vuexParams, { before: undefined, after });
- });
-
- it('makes a GraphQl query with first and after variables', () => {
- expect(gqClient.query).toHaveBeenCalledWith({
- query: allReleasesQuery,
- variables: { fullPath: projectPath, first: PAGE_SIZE, after, sort: 'RELEASED_AT_DESC' },
- });
- });
- });
-
- describe('when both before and after parameters are provided', () => {
- it('throws an error', () => {
- const callFetchReleases = () => {
- fetchReleases(vuexParams, { before, after });
- };
-
- expect(callFetchReleases).toThrowError(
- 'Both a `before` and an `after` parameter were provided to fetchReleases. These parameters cannot be used together.',
- );
- });
- });
-
- describe('when the sort parameters are provided', () => {
- it.each`
- sort | orderBy | ReleaseSort
- ${'asc'} | ${'released_at'} | ${'RELEASED_AT_ASC'}
- ${'desc'} | ${'released_at'} | ${'RELEASED_AT_DESC'}
- ${'asc'} | ${'created_at'} | ${'CREATED_ASC'}
- ${'desc'} | ${'created_at'} | ${'CREATED_DESC'}
- `(
- 'correctly sets $ReleaseSort based on $sort and $orderBy',
- ({ sort, orderBy, ReleaseSort }) => {
- mockedState.sorting.sort = sort;
- mockedState.sorting.orderBy = orderBy;
-
- fetchReleases(vuexParams, { before: undefined, after: undefined });
-
- expect(gqClient.query).toHaveBeenCalledWith({
- query: allReleasesQuery,
- variables: { fullPath: projectPath, first: PAGE_SIZE, sort: ReleaseSort },
- });
- },
- );
- });
- });
-
- describe('when the request is successful', () => {
- beforeEach(() => {
- jest.spyOn(gqClient, 'query').mockResolvedValue(graphqlReleasesResponse);
- });
-
- it(`commits ${types.REQUEST_RELEASES} and ${types.RECEIVE_RELEASES_SUCCESS}`, () => {
- const convertedResponse = convertAllReleasesGraphQLResponse(graphqlReleasesResponse);
-
- return testAction(
- fetchReleases,
- {},
- mockedState,
- [
- {
- type: types.REQUEST_RELEASES,
- },
- {
- type: types.RECEIVE_RELEASES_SUCCESS,
- payload: {
- data: convertedResponse.data,
- pageInfo: convertedResponse.paginationInfo,
- },
- },
- ],
- [],
- );
- });
- });
-
- describe('when the request fails', () => {
- beforeEach(() => {
- jest.spyOn(gqClient, 'query').mockRejectedValue(new Error('Something went wrong!'));
- });
-
- it(`commits ${types.REQUEST_RELEASES} and dispatch receiveReleasesError`, () => {
- return testAction(
- fetchReleases,
- {},
- mockedState,
- [
- {
- type: types.REQUEST_RELEASES,
- },
- ],
- [
- {
- type: 'receiveReleasesError',
- },
- ],
- );
- });
- });
- });
-
- describe('receiveReleasesError', () => {
- it('should commit RECEIVE_RELEASES_ERROR mutation', () => {
- return testAction(
- receiveReleasesError,
- null,
- mockedState,
- [{ type: types.RECEIVE_RELEASES_ERROR }],
- [],
- );
- });
- });
-
- describe('setSorting', () => {
- it('should commit SET_SORTING', () => {
- return testAction(
- setSorting,
- { orderBy: 'released_at', sort: 'asc' },
- null,
- [{ type: types.SET_SORTING, payload: { orderBy: 'released_at', sort: 'asc' } }],
- [],
- );
- });
- });
-});
diff --git a/spec/frontend/releases/stores/modules/list/helpers.js b/spec/frontend/releases/stores/modules/list/helpers.js
deleted file mode 100644
index 6669f44aa95..00000000000
--- a/spec/frontend/releases/stores/modules/list/helpers.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import state from '~/releases/stores/modules/index/state';
-
-export const resetStore = (store) => {
- store.replaceState(state());
-};
diff --git a/spec/frontend/releases/stores/modules/list/mutations_spec.js b/spec/frontend/releases/stores/modules/list/mutations_spec.js
deleted file mode 100644
index 49e324c28a5..00000000000
--- a/spec/frontend/releases/stores/modules/list/mutations_spec.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import originalRelease from 'test_fixtures/api/releases/release.json';
-import graphqlReleasesResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import * as types from '~/releases/stores/modules/index/mutation_types';
-import mutations from '~/releases/stores/modules/index/mutations';
-import createState from '~/releases/stores/modules/index/state';
-import { convertAllReleasesGraphQLResponse } from '~/releases/util';
-
-const originalReleases = [originalRelease];
-
-describe('Releases Store Mutations', () => {
- let stateCopy;
- let pageInfo;
- let releases;
-
- beforeEach(() => {
- stateCopy = createState({});
- pageInfo = convertAllReleasesGraphQLResponse(graphqlReleasesResponse).paginationInfo;
- releases = convertObjectPropsToCamelCase(originalReleases, { deep: true });
- });
-
- describe('REQUEST_RELEASES', () => {
- it('sets isLoading to true', () => {
- mutations[types.REQUEST_RELEASES](stateCopy);
-
- expect(stateCopy.isLoading).toEqual(true);
- });
- });
-
- describe('RECEIVE_RELEASES_SUCCESS', () => {
- beforeEach(() => {
- mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, {
- pageInfo,
- data: releases,
- });
- });
-
- it('sets is loading to false', () => {
- expect(stateCopy.isLoading).toEqual(false);
- });
-
- it('sets hasError to false', () => {
- expect(stateCopy.hasError).toEqual(false);
- });
-
- it('sets data', () => {
- expect(stateCopy.releases).toEqual(releases);
- });
-
- it('sets pageInfo', () => {
- expect(stateCopy.pageInfo).toEqual(pageInfo);
- });
- });
-
- describe('RECEIVE_RELEASES_ERROR', () => {
- it('resets data', () => {
- mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, {
- pageInfo,
- data: releases,
- });
-
- mutations[types.RECEIVE_RELEASES_ERROR](stateCopy);
-
- expect(stateCopy.isLoading).toEqual(false);
- expect(stateCopy.releases).toEqual([]);
- expect(stateCopy.pageInfo).toEqual({});
- });
- });
-
- describe('SET_SORTING', () => {
- it('should merge the sorting object with sort value', () => {
- mutations[types.SET_SORTING](stateCopy, { sort: 'asc' });
- expect(stateCopy.sorting).toEqual({ ...stateCopy.sorting, sort: 'asc' });
- });
-
- it('should merge the sorting object with order_by value', () => {
- mutations[types.SET_SORTING](stateCopy, { orderBy: 'created_at' });
- expect(stateCopy.sorting).toEqual({ ...stateCopy.sorting, orderBy: 'created_at' });
- });
- });
-});
diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb
index 79491edba94..7c39bf47a5d 100644
--- a/spec/models/member_spec.rb
+++ b/spec/models/member_spec.rb
@@ -926,4 +926,64 @@ RSpec.describe Member do
end
end
end
+
+ describe '.sort_by_attribute' do
+ let_it_be(:user1) { create(:user, created_at: Date.today, last_sign_in_at: Date.today, last_activity_on: Date.today, name: 'Alpha') }
+ let_it_be(:user2) { create(:user, created_at: Date.today - 1, last_sign_in_at: Date.today - 1, last_activity_on: Date.today - 1, name: 'Omega') }
+ let_it_be(:user3) { create(:user, created_at: Date.today - 2, name: 'Beta') }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:member1) { create(:group_member, :reporter, group: group, user: user1) }
+ let_it_be(:member2) { create(:group_member, :developer, group: group, user: user2) }
+ let_it_be(:member3) { create(:group_member, :maintainer, group: group, user: user3) }
+
+ it 'sort users in ascending order by access-level' do
+ expect(described_class.sort_by_attribute('access_level_asc')).to eq([member1, member2, member3])
+ end
+
+ it 'sort users in descending order by access-level' do
+ expect(described_class.sort_by_attribute('access_level_desc')).to eq([member3, member2, member1])
+ end
+
+ context 'when sort by recent_sign_in' do
+ subject { described_class.sort_by_attribute('recent_sign_in') }
+
+ it 'sorts users by recent sign-in time' do
+ expect(subject.first).to eq(member1)
+ expect(subject.second).to eq(member2)
+ end
+
+ it 'pushes users who never signed in to the end' do
+ expect(subject.third).to eq(member3)
+ end
+ end
+
+ context 'when sort by oldest_sign_in' do
+ subject { described_class.sort_by_attribute('oldest_sign_in') }
+
+ it 'sorts users by the oldest sign-in time' do
+ expect(subject.first).to eq(member2)
+ expect(subject.second).to eq(member1)
+ end
+
+ it 'pushes users who never signed in to the end' do
+ expect(subject.third).to eq(member3)
+ end
+ end
+
+ it 'sorts users in descending order by their creation time' do
+ expect(described_class.sort_by_attribute('recent_created_user')).to eq([member1, member2, member3])
+ end
+
+ it 'sorts users in ascending order by their creation time' do
+ expect(described_class.sort_by_attribute('oldest_created_user')).to eq([member3, member2, member1])
+ end
+
+ it 'sort users by recent last activity' do
+ expect(described_class.sort_by_attribute('recent_last_activity')).to eq([member1, member2, member3])
+ end
+
+ it 'sort users by oldest last activity' do
+ expect(described_class.sort_by_attribute('oldest_last_activity')).to eq([member3, member2, member1])
+ end
+ end
end
diff --git a/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb b/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb
index 9ac98db91e2..c5c34e16717 100644
--- a/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb
+++ b/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb
@@ -50,6 +50,37 @@ RSpec.describe 'Marking all todos done' do
expect(updated_todo_ids).to contain_exactly(global_id_of(todo1), global_id_of(todo3))
end
+ context 'when target_id is given', :aggregate_failures do
+ let_it_be(:target) { create(:issue, project: project) }
+ let_it_be(:target_todo1) { create(:todo, user: current_user, author: author, state: :pending, target: target) }
+ let_it_be(:target_todo2) { create(:todo, user: current_user, author: author, state: :pending, target: target) }
+
+ let(:input) { { 'targetId' => target.to_global_id.to_s } }
+
+ it 'marks all pending todos for the target as done' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(target_todo1.reload.state).to eq('done')
+ expect(target_todo2.reload.state).to eq('done')
+
+ expect(todo1.reload.state).to eq('pending')
+ expect(todo3.reload.state).to eq('pending')
+
+ updated_todo_ids = mutation_response['todos'].map { |todo| todo['id'] }
+ expect(updated_todo_ids).to contain_exactly(global_id_of(target_todo1), global_id_of(target_todo2))
+ end
+
+ context 'when target does not exist' do
+ let(:input) { { 'targetId' => "gid://gitlab/Issue/#{non_existing_record_id}" } }
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(graphql_errors).to include(a_hash_including('message' => include('Resource not available')))
+ end
+ end
+ end
+
it 'behaves as expected if there are no todos for the requesting user' do
post_graphql_mutation(mutation, current_user: other_user2)
diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml
index 02d377efd95..e55f5820b0f 100644
--- a/spec/requests/api/project_attributes.yml
+++ b/spec/requests/api/project_attributes.yml
@@ -11,8 +11,6 @@ itself: # project
- has_external_wiki
- hidden
- import_source
- - import_type
- - import_url
- jobs_cache_index
- last_repository_check_at
- last_repository_check_failed
@@ -63,6 +61,8 @@ itself: # project
- empty_repo
- forks_count
- http_url_to_repo
+ - import_status
+ - import_url
- name_with_namespace
- open_issues_count
- owner
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index 877c68f8791..a45862976b5 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -683,6 +683,33 @@ RSpec.describe API::Projects do
end
end
+ context 'and imported=true' do
+ before do
+ other_user = create(:user)
+ # imported project by other user
+ create(:project, creator: other_user, import_type: 'github', import_url: 'http://foo.com')
+ # project created by current user directly instead of importing
+ create(:project)
+ project.update_attribute(:import_url, 'http://user:password@host/path')
+ project.update_attribute(:import_type, 'github')
+ end
+
+ it 'returns only imported projects owned by current user' do
+ get api('/projects?imported=true', user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.map { |p| p['id'] }).to eq [project.id]
+ end
+
+ it 'does not expose import credentials' do
+ get api('/projects?imported=true', user)
+
+ expect(json_response.first['import_url']).to eq 'http://host/path'
+ end
+ end
+
context 'when authenticated as a different user' do
it_behaves_like 'projects response' do
let(:filter) { {} }
diff --git a/spec/requests/api/remote_mirrors_spec.rb b/spec/requests/api/remote_mirrors_spec.rb
index 0df4c797630..338647224e0 100644
--- a/spec/requests/api/remote_mirrors_spec.rb
+++ b/spec/requests/api/remote_mirrors_spec.rb
@@ -26,6 +26,26 @@ RSpec.describe API::RemoteMirrors do
end
end
+ describe 'GET /projects/:id/remote_mirrors/:mirror_id' do
+ let(:route) { "/projects/#{project.id}/remote_mirrors/#{mirror.id}" }
+ let(:mirror) { project.remote_mirrors.first }
+
+ it 'requires `admin_remote_mirror` permission' do
+ get api(route, developer)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ it 'returns at remote mirror' do
+ project.add_maintainer(user)
+
+ get api(route, user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(response).to match_response_schema('remote_mirror')
+ end
+ end
+
describe 'POST /projects/:id/remote_mirrors' do
let(:route) { "/projects/#{project.id}/remote_mirrors" }
@@ -75,11 +95,11 @@ RSpec.describe API::RemoteMirrors do
end
describe 'PUT /projects/:id/remote_mirrors/:mirror_id' do
- let(:route) { ->(id) { "/projects/#{project.id}/remote_mirrors/#{id}" } }
+ let(:route) { "/projects/#{project.id}/remote_mirrors/#{mirror.id}" }
let(:mirror) { project.remote_mirrors.first }
it 'requires `admin_remote_mirror` permission' do
- put api(route[mirror.id], developer)
+ put api(route, developer)
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -87,7 +107,7 @@ RSpec.describe API::RemoteMirrors do
it 'updates a remote mirror' do
project.add_maintainer(user)
- put api(route[mirror.id], user), params: {
+ put api(route, user), params: {
enabled: '0',
only_protected_branches: 'true',
keep_divergent_refs: 'true'
diff --git a/spec/serializers/member_user_entity_spec.rb b/spec/serializers/member_user_entity_spec.rb
index b505571cbf2..0e6d4bcc3fb 100644
--- a/spec/serializers/member_user_entity_spec.rb
+++ b/spec/serializers/member_user_entity_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe MemberUserEntity do
- let_it_be(:user) { create(:user) }
+ let_it_be(:user) { create(:user, last_activity_on: Date.today) }
let_it_be(:emoji) { 'slight_smile' }
let_it_be(:user_status) { create(:user_status, user: user, emoji: emoji) }
@@ -36,4 +36,12 @@ RSpec.describe MemberUserEntity do
it 'correctly exposes `status.emoji`' do
expect(entity_hash[:status][:emoji]).to match(emoji)
end
+
+ it 'correctly exposes `created_at`' do
+ expect(entity_hash[:created_at]).to be(user.created_at)
+ end
+
+ it 'correctly exposes `last_activity_on`' do
+ expect(entity_hash[:last_activity_on]).to be(user.last_activity_on)
+ end
end
diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb
index 587d4e22828..edc47c78fe2 100644
--- a/spec/support/helpers/test_env.rb
+++ b/spec/support/helpers/test_env.rb
@@ -41,7 +41,6 @@ module TestEnv
'pages-deploy-target' => '7975be0',
'audio' => 'c3c21fd',
'video' => '8879059',
- 'add-balsamiq-file' => 'b89b56d',
'crlf-diff' => '5938907',
'conflict-start' => '824be60',
'conflict-resolvable' => '1450cd6',
diff --git a/spec/support/shared_examples/features/project_upload_files_shared_examples.rb b/spec/support/shared_examples/features/project_upload_files_shared_examples.rb
index 066c3e17a09..0a5ad5a59c0 100644
--- a/spec/support/shared_examples/features/project_upload_files_shared_examples.rb
+++ b/spec/support/shared_examples/features/project_upload_files_shared_examples.rb
@@ -62,7 +62,7 @@ RSpec.shared_examples 'it uploads and commits a new image file' do |drop: false|
visit(project_blob_path(project, 'upload_image/logo_sample.svg'))
- expect(page).to have_css('.file-content img')
+ expect(page).to have_css('.file-holder img')
end
end
diff --git a/spec/workers/container_registry/migration/enqueuer_worker_spec.rb b/spec/workers/container_registry/migration/enqueuer_worker_spec.rb
index 12c14c35365..de775170a18 100644
--- a/spec/workers/container_registry/migration/enqueuer_worker_spec.rb
+++ b/spec/workers/container_registry/migration/enqueuer_worker_spec.rb
@@ -31,7 +31,7 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
end
it 're-enqueues the worker' do
- expect(ContainerRegistry::Migration::EnqueuerWorker).to receive(:perform_async)
+ expect(described_class).to receive(:perform_async)
subject
end
@@ -43,7 +43,7 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
end
it 'does not re-enqueue the worker' do
- expect(ContainerRegistry::Migration::EnqueuerWorker).not_to receive(:perform_async)
+ expect(described_class).not_to receive(:perform_async)
subject
end
@@ -59,10 +59,11 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
next_qualified_repository
end
- expect(worker).to receive(:log_extra_metadata_on_done)
- .with(:container_repository_id, container_repository.id)
- expect(worker).to receive(:log_extra_metadata_on_done)
- .with(:import_type, 'next')
+ expect_log_extra_metadata(
+ import_type: 'next',
+ container_repository_id: container_repository.id,
+ container_repository_path: container_repository.path
+ )
subject
@@ -77,7 +78,11 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
allow(ContainerRegistry::Migration).to receive(:enabled?).and_return(false)
end
- it_behaves_like 'no action'
+ it_behaves_like 'no action' do
+ before do
+ expect_log_extra_metadata(migration_enabled: false)
+ end
+ end
end
context 'above capacity' do
@@ -87,7 +92,11 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
allow(ContainerRegistry::Migration).to receive(:capacity).and_return(1)
end
- it_behaves_like 'no action'
+ it_behaves_like 'no action' do
+ before do
+ expect_log_extra_metadata(below_capacity: false, max_capacity_setting: 1)
+ end
+ end
it 'does not re-enqueue the worker' do
expect(ContainerRegistry::Migration::EnqueuerWorker).not_to receive(:perform_async)
@@ -102,7 +111,11 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
allow(ContainerRegistry::Migration).to receive(:enqueue_waiting_time).and_return(1.hour)
end
- it_behaves_like 'no action'
+ it_behaves_like 'no action' do
+ before do
+ expect_log_extra_metadata(waiting_time_passed: false, current_waiting_time_setting: 1.hour)
+ end
+ end
end
context 'when an aborted import is available' do
@@ -117,10 +130,11 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
next_aborted_repository
end
- expect(worker).to receive(:log_extra_metadata_on_done)
- .with(:container_repository_id, aborted_repository.id)
- expect(worker).to receive(:log_extra_metadata_on_done)
- .with(:import_type, 'retry')
+ expect_log_extra_metadata(
+ import_type: 'retry',
+ container_repository_id: aborted_repository.id,
+ container_repository_path: aborted_repository.path
+ )
subject
@@ -129,6 +143,28 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
end
it_behaves_like 're-enqueuing based on capacity'
+
+ context 'when an error occurs' do
+ it 'does not abort that migration' do
+ method = worker.method(:next_aborted_repository)
+ allow(worker).to receive(:next_aborted_repository) do
+ next_aborted_repository = method.call
+ allow(next_aborted_repository).to receive(:retry_aborted_migration).and_raise(StandardError)
+ next_aborted_repository
+ end
+
+ expect_log_extra_metadata(
+ import_type: 'retry',
+ container_repository_id: aborted_repository.id,
+ container_repository_path: aborted_repository.path
+ )
+
+ subject
+
+ expect(aborted_repository.reload).to be_import_aborted
+ expect(container_repository.reload).to be_default
+ end
+ end
end
context 'when no repository qualifies' do
@@ -147,6 +183,14 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
end
it 'skips the repository' do
+ expect_log_extra_metadata(
+ import_type: 'next',
+ container_repository_id: container_repository.id,
+ container_repository_path: container_repository.path,
+ tags_count_too_high: true,
+ max_tags_count_setting: 2
+ )
+
subject
expect(container_repository.reload).to be_import_skipped
@@ -163,10 +207,15 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
end
it 'aborts the import' do
+ expect_log_extra_metadata(
+ import_type: 'next',
+ container_repository_id: container_repository.id,
+ container_repository_path: container_repository.path
+ )
+
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(
instance_of(StandardError),
- next_repository_id: container_repository.id,
- next_aborted_repository_id: nil
+ next_repository_id: container_repository.id
)
subject
@@ -174,5 +223,11 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
expect(container_repository.reload).to be_import_aborted
end
end
+
+ def expect_log_extra_metadata(metadata)
+ metadata.each do |key, value|
+ expect(worker).to receive(:log_extra_metadata_on_done).with(key, value)
+ end
+ end
end
end
diff --git a/yarn.lock b/yarn.lock
index 41d7ad61b50..8a8ad7c24a8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10864,11 +10864,6 @@ sprintf-js@~1.0.2:
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
-sql.js@^0.4.0:
- version "0.4.0"
- resolved "https://registry.yarnpkg.com/sql.js/-/sql.js-0.4.0.tgz#23be9635520eb0ff43a741e7e830397266e88445"
- integrity sha1-I76WNVIOsP9Dp0Hn6DA5cmbohEU=
-
sshpk@^1.7.0:
version "1.16.1"
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"