diff --git a/app/assets/javascripts/access_tokens/components/expires_at_field.vue b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
index 65206670a3c..db9e798ca9c 100644
--- a/app/assets/javascripts/access_tokens/components/expires_at_field.vue
+++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
@@ -13,7 +13,7 @@ export default {
GlDatepicker,
GlFormGroup,
MaxExpirationDateMessage: () =>
- import('ee_component/access_tokens/components/max_expiration_date_message.vue'),
+ import('ee_component/vue_shared/components/access_tokens/max_expiration_date_message.vue'),
},
props: {
defaultDateOffset: {
diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js
index 558520faf94..aa3e2f7f59a 100644
--- a/app/assets/javascripts/api/projects_api.js
+++ b/app/assets/javascripts/api/projects_api.js
@@ -11,6 +11,7 @@ const PROJECT_REPOSITORY_SIZE_PATH = '/api/:version/projects/:id/repository_size
const PROJECT_TRANSFER_LOCATIONS_PATH = 'api/:version/projects/:id/transfer_locations';
const PROJECT_SHARE_LOCATIONS_PATH = 'api/:version/projects/:id/share_locations';
const PROJECT_UPLOADS_PATH = '/api/:version/projects/:id/uploads';
+const PROJECT_RESTORE_PATH = '/api/:version/projects/:id/restore';
export function getProjects(query, options, callback = () => {}) {
const url = buildApiUrl(PROJECTS_PATH);
@@ -57,6 +58,12 @@ export function deleteProject(projectId, params) {
return axios.delete(url, { params });
}
+export function restoreProject(projectId) {
+ const url = buildApiUrl(PROJECT_RESTORE_PATH).replace(':id', projectId);
+
+ return axios.post(url);
+}
+
export function importProjectMembers(sourceId, targetId) {
const url = buildApiUrl(PROJECT_IMPORT_MEMBERS_PATH)
.replace(':id', sourceId)
diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue
index eadcda542b0..769d238a1bc 100644
--- a/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue
+++ b/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue
@@ -136,6 +136,9 @@ export default {
return this.highlightedJobs.length > 1 && !this.highlightedJobs.includes(jobName);
},
isParallel(group) {
+ return !this.isMatrix(group) && group.size > 1;
+ },
+ isMatrix(group) {
return group.jobs[0].name !== group.name;
},
singleJobExists(group) {
@@ -223,7 +226,10 @@ export default {
@mouseenter="$emit('jobHover', group.name)"
@mouseleave="$emit('jobHover', '')"
>
-
+
-
+
{
const callback = (timestamp) => {
this.throttle(timestamp);
- if (fn()) requestAnimationFrame(callback);
+ if (!this.#aborted && fn()) requestAnimationFrame(callback);
else resolve();
};
requestAnimationFrame(callback);
@@ -33,4 +34,8 @@ export class RenderBalancer {
this.increase();
}
}
+
+ abort() {
+ this.#aborted = true;
+ }
}
diff --git a/app/assets/javascripts/vue_shared/access_tokens/components/access_token.vue b/app/assets/javascripts/vue_shared/access_tokens/components/access_token.vue
new file mode 100644
index 00000000000..06c476352a4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/access_tokens/components/access_token.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+ {{ s__("AccessTokens|Make sure you save it - you won't be able to access it again.") }}
+
+
+
+
diff --git a/app/assets/javascripts/vue_shared/access_tokens/components/access_token_form.vue b/app/assets/javascripts/vue_shared/access_tokens/components/access_token_form.vue
new file mode 100644
index 00000000000..b0af530baf7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/access_tokens/components/access_token_form.vue
@@ -0,0 +1,233 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ content }}
+
+
+
+
+
+
+
+ {{ scope.value }}
+ {{ scope.text }}
+
+
+
+
+
+
+
+ {{ s__('AccessTokens|Create token') }}
+
+
+ {{ __('Cancel') }}
+
+
+
+
diff --git a/app/assets/javascripts/vue_shared/access_tokens/components/access_token_statistics.vue b/app/assets/javascripts/vue_shared/access_tokens/components/access_token_statistics.vue
new file mode 100644
index 00000000000..e7a2446d2c6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/access_tokens/components/access_token_statistics.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+ {{ s__('AccessTokens|Filter list') }}
+
+
+
+
diff --git a/app/assets/javascripts/vue_shared/access_tokens/components/access_token_table.vue b/app/assets/javascripts/vue_shared/access_tokens/components/access_token_table.vue
new file mode 100644
index 00000000000..8aa9504ca50
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/access_tokens/components/access_token_table.vue
@@ -0,0 +1,306 @@
+
+
+
+
+
+
+ {{ label }}
+ {{
+ s__('AccessTokens|View token usage information')
+ }}
+
+
+
+ {{ name }}
+
+ {{ description }}
+
+
+
+
+
+
+ {{ s__('AccessTokens|Expiring') }}
+
+
+ {{
+ s__('AccessTokens|Active')
+ }}
+
+
+
+ {{
+ s__('AccessTokens|Revoked')
+ }}
+
+
+ {{
+ s__('AccessTokens|Expired')
+ }}
+
+
+
+
+
+ {{ s__('AccessTokens|Last used:') }}
+
+ {{ __('Never') }}
+
+
+
+
+ {{ lastUsedIps.join(', ') }}
+
+
+
+
+
+
+
+
+
+ {{ s__('AccessTokens|Never until revoked') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedToken && selectedToken.name }}
+
+
+
+
diff --git a/app/assets/javascripts/vue_shared/access_tokens/components/access_tokens.vue b/app/assets/javascripts/vue_shared/access_tokens/components/access_tokens.vue
new file mode 100644
index 00000000000..a07dcfc6c12
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/access_tokens/components/access_tokens.vue
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+ {{
+ s__(
+ 'AccessTokens|You can generate a personal access token for each application you use that needs access to the GitLab API. You can also use personal access tokens to authenticate against Git over HTTP. They are the only accepted password when you have Two-Factor Authentication (2FA) enabled.',
+ )
+ }}
+
+
+
+ {{ s__('AccessTokens|Add new token') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/vue_shared/access_tokens/stores/access_tokens.js b/app/assets/javascripts/vue_shared/access_tokens/stores/access_tokens.js
new file mode 100644
index 00000000000..ce878c154ed
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/access_tokens/stores/access_tokens.js
@@ -0,0 +1,272 @@
+import { defineStore } from 'pinia';
+import { createAlert } from '~/alert';
+import Api from '~/api';
+import { smoothScrollTop } from '~/behaviors/smooth_scroll';
+import axios from '~/lib/utils/axios_utils';
+import {
+ convertObjectPropsToCamelCase,
+ normalizeHeaders,
+ parseIntPagination,
+} from '~/lib/utils/common_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
+import { SORT_OPTIONS, DEFAULT_SORT } from '~/access_tokens/constants';
+import { serializeParams, update2WeekFromNow } from '../utils';
+
+/**
+ * @typedef {{type: string, value: {data: string, operator: string}}} Filter
+ * @typedef {Array} Filters
+ */
+
+/**
+ * Fetch access tokens
+ *
+ * @param {Object} options
+ * @param {string} options.url
+ * @param {string|number} options.id
+ * @param {Object} options.params
+ * @param {string} options.sort
+ */
+const fetchTokens = async ({ url, id, params, sort }) => {
+ const { data, headers } = await axios.get(url, {
+ params: { user_id: id, sort, ...params },
+ });
+ const { perPage, total } = parseIntPagination(normalizeHeaders(headers));
+
+ return { data, perPage, total };
+};
+
+export const useAccessTokens = defineStore('accessTokens', {
+ state() {
+ return {
+ alert: null,
+ busy: false,
+ /** @type {Filters} */
+ filters: [],
+ id: null,
+ page: 1,
+ perPage: null,
+ showCreateForm: false,
+ token: null, // New and rotated token
+ tokens: [],
+ total: 0,
+ urlCreate: '',
+ urlRevoke: '',
+ urlRotate: '',
+ urlShow: '',
+ sorting: DEFAULT_SORT,
+ /** @type{Array<{title: string, tooltipTitle: string, filters: Filters, value: number}>} */
+ statistics: [],
+ };
+ },
+ actions: {
+ /**
+ * @param {Object} options
+ * @param {string} options.name
+ * @param {string} options.description
+ * @param {string} options.expiresAt
+ * @param {string[]} options.scopes
+ */
+ async createToken({ name, description, expiresAt, scopes }) {
+ this.alert?.dismiss();
+ this.alert = null;
+ this.busy = true;
+ try {
+ const url = Api.buildUrl(this.urlCreate.replace(':id', this.id));
+ const { data } = await axios.post(url, {
+ name,
+ description,
+ expires_at: expiresAt,
+ scopes,
+ });
+ this.token = data.token;
+ this.showCreateForm = false;
+ // Reset pagination because after creation the token may appear on a different page.
+ this.page = 1;
+ await this.fetchTokens({ clearAlert: false });
+ } catch (error) {
+ const responseData = error?.response?.data;
+ const message =
+ responseData?.error ??
+ responseData?.message ??
+ s__('AccessTokens|An error occurred while creating the token.');
+ this.alert = createAlert({ message });
+ } finally {
+ smoothScrollTop();
+ this.busy = false;
+ }
+ },
+ async fetchStatistics() {
+ try {
+ const updatedFilters = update2WeekFromNow();
+ this.statistics = await Promise.all(
+ updatedFilters.map(async (stat) => {
+ const params = serializeParams(stat.filters);
+ const url = Api.buildUrl(this.urlShow.replace(':id', this.id));
+ const { total } = await fetchTokens({
+ url,
+ id: this.id,
+ params,
+ sort: this.sort,
+ });
+ return {
+ title: stat.title,
+ tooltipTitle: stat.tooltipTitle,
+ filters: stat.filters,
+ value: total,
+ };
+ }),
+ );
+ } catch {
+ if (!this.alert) {
+ this.alert = createAlert({
+ message: s__('AccessTokens|Failed to fetch statistics.'),
+ });
+ }
+ }
+ },
+ async fetchTokens({ clearAlert } = { clearAlert: true }) {
+ if (clearAlert) {
+ this.alert?.dismiss();
+ this.alert = null;
+ }
+ this.busy = true;
+ try {
+ const url = Api.buildUrl(this.urlShow.replace(':id', this.id));
+ const { data, perPage, total } = await fetchTokens({
+ url,
+ id: this.id,
+ params: this.params,
+ sort: this.sort,
+ });
+ this.tokens = convertObjectPropsToCamelCase(data, { deep: true });
+ this.perPage = perPage;
+ this.total = total;
+ await this.fetchStatistics();
+ } catch {
+ this.alert = createAlert({
+ message: s__('AccessTokens|An error occurred while fetching the tokens.'),
+ });
+ } finally {
+ this.busy = false;
+ }
+ },
+ /**
+ * @param {number} tokenId
+ */
+ async revokeToken(tokenId) {
+ this.alert?.dismiss();
+ this.alert = null;
+ this.busy = true;
+ this.showCreateForm = false;
+ try {
+ const url = Api.buildUrl(this.urlRevoke.replace(':id', this.id));
+ await axios.delete(joinPaths(url, `${tokenId}`));
+ this.alert = createAlert({
+ message: s__('AccessTokens|The token was revoked successfully.'),
+ variant: 'success',
+ });
+ // Reset pagination to avoid situations like: page 2 contains only one token and after it
+ // is revoked the page shows `No tokens access tokens` (but there are 20 tokens on page 1).
+ this.page = 1;
+ await this.fetchTokens({ clearAlert: false });
+ } catch {
+ this.alert = createAlert({
+ message: s__('AccessTokens|An error occurred while revoking the token.'),
+ });
+ } finally {
+ smoothScrollTop();
+ this.busy = false;
+ }
+ },
+ /**
+ * @param {number} tokenId
+ * @param {string} expiresAt
+ */
+ async rotateToken(tokenId, expiresAt) {
+ this.alert?.dismiss();
+ this.alert = null;
+ this.busy = true;
+ this.showCreateForm = false;
+ try {
+ const url = Api.buildUrl(this.urlRotate.replace(':id', this.id));
+ const { data } = await axios.post(joinPaths(url, `${tokenId}`, 'rotate'), {
+ expires_at: expiresAt,
+ });
+ this.token = data.token;
+ // Reset pagination because after rotation the token may appear on a different page.
+ this.page = 1;
+ await this.fetchTokens({ clearAlert: false });
+ } catch (error) {
+ const responseData = error?.response?.data;
+ const message =
+ responseData?.error ??
+ responseData?.message ??
+ s__('AccessTokens|An error occurred while rotating the token.');
+ this.alert = createAlert({ message });
+ } finally {
+ smoothScrollTop();
+ this.busy = false;
+ }
+ },
+ /**
+ * @param {Filters} filters
+ */
+ setFilters(filters) {
+ this.filters = filters;
+ },
+ /**
+ * @param {number} page
+ */
+ setPage(page) {
+ smoothScrollTop();
+ this.page = page;
+ },
+ /**
+ * @param {boolean} value
+ */
+ setShowCreateForm(value) {
+ this.showCreateForm = value;
+ },
+ /**
+ * @param {string} token
+ */
+ setToken(token) {
+ this.token = token;
+ },
+ /**
+ * @param {{isAsc: boolean, value: string}} sorting
+ */
+ setSorting(sorting) {
+ this.sorting = sorting;
+ },
+ /**
+ * @param {Object} options
+ * @param {Filters} options.filters
+ * @param {number} options.id
+ * @param {string} options.urlCreate
+ * @param {string} options.urlRevoke
+ * @param {string} options.urlRotate
+ * @param {string} options.urlShow
+ */
+ setup({ filters, id, urlCreate, urlRevoke, urlRotate, urlShow }) {
+ this.filters = filters;
+ this.id = id;
+ this.urlCreate = urlCreate;
+ this.urlRevoke = urlRevoke;
+ this.urlRotate = urlRotate;
+ this.urlShow = urlShow;
+ },
+ },
+ getters: {
+ params() {
+ return serializeParams(this.filters, this.page);
+ },
+ sort() {
+ const { value, isAsc } = this.sorting;
+ const sortOption = SORT_OPTIONS.find((option) => option.value === value);
+
+ return isAsc ? sortOption.sort.asc : sortOption.sort.desc;
+ },
+ },
+});
diff --git a/app/assets/javascripts/vue_shared/access_tokens/utils.js b/app/assets/javascripts/vue_shared/access_tokens/utils.js
new file mode 100644
index 00000000000..596004bfa29
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/access_tokens/utils.js
@@ -0,0 +1,56 @@
+import { getDateInFuture, nWeeksAfter, toISODateFormat } from '~/lib/utils/datetime_utility';
+import { STATISTICS_CONFIG } from '~/access_tokens/constants';
+
+/**
+ * Return the default expiration date.
+ * If the maximum date is sooner than the 30 days we use the maximum date, otherwise default to 30 days.
+ * The maximum date can be set by admins only in EE.
+ * @param {Date} [maxDate]
+ */
+export function defaultDate(maxDate) {
+ const OFFSET_DAYS = 30;
+ const thirtyDaysFromNow = getDateInFuture(new Date(), OFFSET_DAYS);
+ if (maxDate && maxDate < thirtyDaysFromNow) {
+ return maxDate;
+ }
+ return thirtyDaysFromNow;
+}
+
+/**
+ * Convert filter structure to an object that can be used as query params.
+ * @param {import('./stores/access_tokens').Filters} filters
+ * @param {number} [page]
+ */
+export function serializeParams(filters, page = 1) {
+ /** @type {Object} */
+ const newParams = { page };
+
+ filters?.forEach((token) => {
+ if (typeof token === 'string') {
+ newParams.search = token;
+ } else if (['created', 'expires', 'last_used'].includes(token.type)) {
+ const isBefore = token.value.operator === '<';
+ const key = `${token.type}${isBefore ? '_before' : '_after'}`;
+ newParams[key] = token.value.data;
+ } else {
+ newParams[token.type] = token.value.data;
+ }
+ });
+
+ return newParams;
+}
+
+/**
+ * Replace the 'DATE_HOLDER' string with a date 2 weeks in the future based on current time.
+ */
+export function update2WeekFromNow(stats = STATISTICS_CONFIG) {
+ const clonedStats = structuredClone(stats);
+ clonedStats.forEach((stat) => {
+ const filter = stat.filters.find((item) => item.value.data === 'DATE_HOLDER');
+ if (filter) {
+ filter.value.data = toISODateFormat(nWeeksAfter(new Date(), 2));
+ }
+ });
+
+ return clonedStats;
+}
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/formatter.js b/app/assets/javascripts/vue_shared/components/projects_list/formatter.js
index 3aafd63bafb..c4b77738810 100644
--- a/app/assets/javascripts/vue_shared/components/projects_list/formatter.js
+++ b/app/assets/javascripts/vue_shared/components/projects_list/formatter.js
@@ -1,5 +1,5 @@
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { availableGraphQLProjectActions } from 'ee_else_ce/vue_shared/components/projects_list/utils';
+import { availableGraphQLProjectActions } from '~/vue_shared/components/projects_list/utils';
export const formatGraphQLProjects = (projects, callback = () => {}) =>
projects.map(
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_actions.vue b/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_actions.vue
index 771bae0493c..bbd8d7a8f7d 100644
--- a/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_actions.vue
+++ b/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_actions.vue
@@ -1,10 +1,26 @@
-
+
+
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_delayed_deletion_modal_footer.vue b/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_delayed_deletion_modal_footer.vue
new file mode 100644
index 00000000000..03d5f11fb0d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_delayed_deletion_modal_footer.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+ {{ project.permanentDeletionDate }}
+
+ {{ content }}
+
+
+
+
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
index 0b13768a005..6f1df6548f1 100644
--- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
@@ -4,9 +4,9 @@ import { GlIcon, GlBadge, GlTooltip } from '@gitlab/ui';
import {
renderDeleteSuccessToast,
deleteParams,
-} from 'ee_else_ce/vue_shared/components/projects_list/utils';
+} from '~/vue_shared/components/projects_list/utils';
import ProjectListItemDescription from '~/vue_shared/components/projects_list/project_list_item_description.vue';
-import ProjectListItemActions from 'ee_else_ce/vue_shared/components/projects_list/project_list_item_actions.vue';
+import ProjectListItemActions from '~/vue_shared/components/projects_list/project_list_item_actions.vue';
import ProjectListItemInactiveBadge from '~/vue_shared/components/projects_list/project_list_item_inactive_badge.vue';
import { VISIBILITY_TYPE_ICON, PROJECT_VISIBILITY_TYPE } from '~/visibility_level/constants';
import { ACCESS_LEVEL_LABELS, ACCESS_LEVEL_NO_ACCESS_INTEGER } from '~/access_level/constants';
@@ -15,6 +15,7 @@ import { __, s__, n__, sprintf } from '~/locale';
import { numberToMetricPrefix } from '~/lib/utils/number_utils';
import { ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
import DeleteModal from '~/projects/components/shared/delete_modal.vue';
+import ProjectListItemDelayedDeletionModalFooter from '~/vue_shared/components/projects_list/project_list_item_delayed_deletion_modal_footer.vue';
import {
TIMESTAMP_TYPES,
TIMESTAMP_TYPE_CREATED_AT,
@@ -53,10 +54,7 @@ export default {
ProjectListItemInactiveBadge,
CiIcon,
TopicBadges,
- ProjectListItemDelayedDeletionModalFooter: () =>
- import(
- 'ee_component/vue_shared/components/projects_list/project_list_item_delayed_deletion_modal_footer.vue'
- ),
+ ProjectListItemDelayedDeletionModalFooter,
},
props: {
/**
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/utils.js b/app/assets/javascripts/vue_shared/components/projects_list/utils.js
index 24b6eb55dbc..ae40ed2372a 100644
--- a/app/assets/javascripts/vue_shared/components/projects_list/utils.js
+++ b/app/assets/javascripts/vue_shared/components/projects_list/utils.js
@@ -1,30 +1,89 @@
+import {
+ ACTION_EDIT,
+ ACTION_DELETE,
+ ACTION_RESTORE,
+ BASE_ACTIONS,
+} from '~/vue_shared/components/list_actions/constants';
import toast from '~/vue_shared/plugins/global_toast';
import { sprintf, __ } from '~/locale';
-import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
-export const availableGraphQLProjectActions = ({ userPermissions }) => {
- const baseActions = [];
+const isAdjournedDeletionEnabled = (project) => {
+ // Check if enabled at the project level or globally
+ return (
+ project.isAdjournedDeletionEnabled ||
+ gon?.licensed_features?.adjournedDeletionForProjectsAndGroups
+ );
+};
+
+export const availableGraphQLProjectActions = ({ userPermissions, markedForDeletionOn }) => {
+ const availableActions = [];
if (userPermissions.viewEditPage) {
- baseActions.push(ACTION_EDIT);
+ availableActions.push(ACTION_EDIT);
}
if (userPermissions.removeProject) {
- baseActions.push(ACTION_DELETE);
+ availableActions.push(ACTION_DELETE);
}
- return baseActions;
+ if (userPermissions.removeProject && markedForDeletionOn) {
+ availableActions.push(ACTION_RESTORE);
+ }
+
+ return availableActions.sort((a, b) => BASE_ACTIONS[a].order - BASE_ACTIONS[b].order);
};
-export const renderDeleteSuccessToast = (project) => {
+export const renderRestoreSuccessToast = (project) => {
toast(
- sprintf(__("Project '%{project_name}' is being deleted."), {
+ sprintf(__("Project '%{project_name}' has been successfully restored."), {
project_name: project.nameWithNamespace,
}),
);
};
-export const deleteParams = () => {
- // Overridden in EE
+export const renderDeleteSuccessToast = (project) => {
+ // Delete immediately if
+ // 1. Adjourned deletion is not enabled
+ // 2. The project is in a personal namespace
+ // 3. The project has already been marked for deletion
+ if (!isAdjournedDeletionEnabled(project) || project.isPersonal || project.markedForDeletionOn) {
+ toast(
+ sprintf(__("Project '%{project_name}' is being deleted."), {
+ project_name: project.nameWithNamespace,
+ }),
+ );
+
+ return;
+ }
+
+ // Adjourned deletion is available for the project
+ if (project.isAdjournedDeletionEnabled) {
+ toast(
+ sprintf(__("Project '%{project_name}' will be deleted on %{date}."), {
+ project_name: project.nameWithNamespace,
+ date: project.permanentDeletionDate,
+ }),
+ );
+
+ return;
+ }
+
+ // Adjourned deletion is available globally but not at the project level.
+ // This means we are deleting a free project. It will be deleted delayed but can only be
+ // restored by an admin.
+ toast(
+ sprintf(__("Deleting project '%{project_name}'. All data will be removed on %{date}."), {
+ project_name: project.nameWithNamespace,
+ date: project.permanentDeletionDate,
+ }),
+ );
+};
+
+export const deleteParams = (project) => {
+ // Project has been marked for delayed deletion so will now be deleted immediately.
+ if (project.markedForDeletionOn) {
+ return { permanently_remove: true, full_path: project.fullPath };
+ }
+
return {};
};
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index 9855ce49864..337f6dbddad 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -6,6 +6,10 @@ class Groups::MilestonesController < Groups::ApplicationController
before_action :milestone, only: [:edit, :show, :update, :issues, :merge_requests, :participants, :labels, :destroy]
before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update, :destroy]
+ before_action do
+ push_force_frontend_feature_flag(:work_items_alpha, !!group&.work_items_alpha_feature_flag_enabled?)
+ end
+
feature_category :team_planning
urgency :low
diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
index a5523130f81..c52deab3598 100644
--- a/app/controllers/projects/merge_requests/creations_controller.rb
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -130,7 +130,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
@source_project = @merge_request.source_project
recent_commits = @merge_request.recent_commits(
- load_from_gitaly: Feature.enabled?(:more_commits_from_gitaly, @target_project)
+ load_from_gitaly: Feature.enabled?(:commits_from_gitaly, @target_project)
).with_latest_pipeline(@merge_request.source_branch)
@commits = set_commits_for_rendering(
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index 8121502b7d3..4a990ed1f74 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -19,6 +19,10 @@ class Projects::MilestonesController < Projects::ApplicationController
# Allow to promote milestone
before_action :authorize_promote_milestone!, only: :promote
+ before_action do
+ push_force_frontend_feature_flag(:work_items_alpha, !!@project&.work_items_alpha_feature_flag_enabled?)
+ end
+
respond_to :html
feature_category :team_planning
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 476ba9297cb..1d8a44afd78 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -348,13 +348,13 @@ module Types
def commits
object.commits(
- load_from_gitaly: Feature.enabled?(:more_commits_from_gitaly, object.target_project)
+ load_from_gitaly: Feature.enabled?(:commits_from_gitaly, object.target_project)
).commits
end
def commits_without_merge_commits
object.commits(
- load_from_gitaly: Feature.enabled?(:more_commits_from_gitaly, object.target_project)
+ load_from_gitaly: Feature.enabled?(:commits_from_gitaly, object.target_project)
).without_merge_commits
end
diff --git a/app/helpers/timeboxes_helper.rb b/app/helpers/timeboxes_helper.rb
index 98590c31a06..dec43ae801b 100644
--- a/app/helpers/timeboxes_helper.rb
+++ b/app/helpers/timeboxes_helper.rb
@@ -295,8 +295,13 @@ module TimeboxesHelper
limit = Milestone::DISPLAY_ISSUES_LIMIT
link_options = { milestone_title: @milestone.title }
- message = _('Showing %{limit} of %{total_count} issues. ') % { limit: limit, total_count: total_count }
- message += link_to(_('View all issues'), milestones_issues_path(link_options))
+ if Feature.enabled?(:work_items_alpha, current_user)
+ message = _('Showing %{limit} of %{total_count} items. ') % { limit: limit, total_count: total_count }
+ message += link_to(_('View all'), milestones_issues_path(link_options))
+ else
+ message = _('Showing %{limit} of %{total_count} issues. ') % { limit: limit, total_count: total_count }
+ message += link_to(_('View all issues'), milestones_issues_path(link_options))
+ end
message.html_safe
end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index e1124a603bf..1fe675fd4b7 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -413,7 +413,7 @@ class Commit
message_body = ["(cherry picked from commit #{sha})"]
if merged_merge_request?(user)
- commits_in_merge_request = if Feature.enabled?(:more_commits_from_gitaly, project)
+ commits_in_merge_request = if Feature.enabled?(:commits_from_gitaly, project)
merged_merge_request(user).commits(load_from_gitaly: true)
else
merged_merge_request(user).commits
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index dfc34bb6099..4c7ce700516 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1655,7 +1655,7 @@ class MergeRequest < ApplicationRecord
def closes_issues(current_user = self.author)
if target_branch == project.default_branch
messages = [title, description]
- messages.concat(commits(load_from_gitaly: Feature.enabled?(:more_commits_from_gitaly, target_project)).map(&:safe_message)) if merge_request_diff.persisted?
+ messages.concat(commits(load_from_gitaly: Feature.enabled?(:commits_from_gitaly, target_project)).map(&:safe_message)) if merge_request_diff.persisted?
Gitlab::ClosingIssueExtractor.new(project, current_user)
.closed_by_message(messages.join("\n"))
@@ -1677,7 +1677,7 @@ class MergeRequest < ApplicationRecord
visible_notes = user.can?(:read_internal_note, project) ? notes : notes.not_internal
messages = [title, description, *visible_notes.pluck(:note)]
- messages += commits(load_from_gitaly: Feature.enabled?(:more_commits_from_gitaly, target_project)).map(&:safe_message) if merge_request_diff.persisted?
+ messages += commits(load_from_gitaly: Feature.enabled?(:commits_from_gitaly, target_project)).map(&:safe_message) if merge_request_diff.persisted?
ext = Gitlab::ReferenceExtractor.new(project, user)
ext.analyze(messages.join("\n"))
@@ -1762,7 +1762,7 @@ class MergeRequest < ApplicationRecord
# Returns the oldest multi-line commit
def first_multiline_commit
strong_memoize(:first_multiline_commit) do
- recent_commits(load_from_gitaly: Feature.enabled?(:more_commits_from_gitaly, target_project)).without_merge_commits.reverse_each.find(&:description?)
+ recent_commits(load_from_gitaly: Feature.enabled?(:commits_from_gitaly, target_project)).without_merge_commits.reverse_each.find(&:description?)
end
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 4df404e6dbd..e0a7f8a100a 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -358,7 +358,7 @@ class MergeRequestDiff < ApplicationRecord
end
def first_commit
- if Feature.enabled?(:more_commits_from_gitaly, project)
+ if Feature.enabled?(:commits_from_gitaly, project)
commits(load_from_gitaly: true).last
else
commits.last
@@ -366,7 +366,7 @@ class MergeRequestDiff < ApplicationRecord
end
def last_commit
- if Feature.enabled?(:more_commits_from_gitaly, project)
+ if Feature.enabled?(:commits_from_gitaly, project)
commits(load_from_gitaly: true).first
else
commits.first
diff --git a/app/views/projects/merge_requests/_code_dropdown.html.haml b/app/views/projects/merge_requests/_code_dropdown.html.haml
index df7baaf7102..e734d011c37 100644
--- a/app/views/projects/merge_requests/_code_dropdown.html.haml
+++ b/app/views/projects/merge_requests/_code_dropdown.html.haml
@@ -24,6 +24,7 @@
= link_to "#{Gitlab::CurrentSettings.gitpod_url}##{merge_request_url(@merge_request)}", target: '_blank', class: 'dropdown-item' do
.gl-dropdown-item-text-wrapper
= _('Open in Gitpod')
+ = render_if_exists 'projects/merge_requests/code_dropdown_open_in_workspace'
%li.gl-dropdown-divider
%hr.dropdown-divider
%li.gl-dropdown-section-header
diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml
index b5cbc41e405..b7c0dbee936 100644
--- a/app/views/shared/milestones/_issuable.html.haml
+++ b/app/views/shared/milestones/_issuable.html.haml
@@ -1,5 +1,6 @@
-# @project is present when viewing Project's milestone
- project = @project || issuable.project
+- group = milestone_issuable_group(issuable)
- container = project || milestone_issuable_group(issuable)
- labels = issuable.labels
- assignees = issuable.assignees
@@ -8,11 +9,16 @@
- base_url_args = [container]
- issuable_type = is_merge_request ? :merge_requests : (is_epic ? :epics : :issues)
- issuable_type_args = base_url_args + [issuable_type]
+- issuable_icon = is_epic ? 'epic' : "issue-type-#{issuable.respond_to?(:work_item_type) ? issuable.work_item_type.name.downcase : 'issue'}"
+
%li{ class: '!gl-border-b-section' }
%span
+ = sprite_icon(issuable_icon)
- if show_project_name && project
%strong #{project.name} ·
+ - if group
+ %strong #{group.name} ·
- if issuable.is_a?(Issue)
= confidential_icon(issuable)
= link_to issuable.title, ::Gitlab::UrlBuilder.build(issuable), title: issuable.title, class: "gl-text-default gl-break-words"
diff --git a/app/views/shared/milestones/_issuables.html.haml b/app/views/shared/milestones/_issuables.html.haml
index 1ec9c4b4585..a0194d721bc 100644
--- a/app/views/shared/milestones/_issuables.html.haml
+++ b/app/views/shared/milestones/_issuables.html.haml
@@ -1,9 +1,14 @@
- show_counter = local_assigns.fetch(:show_counter, false)
+- subtitle = local_assigns.fetch(:subtitle, nil)
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-0' }, header_options: { class: "gl-text-subtle gl-flex" }) do |c|
- c.with_header do
.gl-grow-2
- = title
+ .gl-flex.gl-flex-col.gl-text-default
+ = title
+ - if Feature.enabled?(:work_items_alpha, current_user)
+ .gl-text-subtle.gl-text-sm
+ = subtitle
.gl-ml-3.gl-shrink-0.gl-font-bold.gl-whitespace-nowrap.gl-text-subtle
- if show_counter
%span
diff --git a/app/views/shared/milestones/_issues_tab.html.haml b/app/views/shared/milestones/_issues_tab.html.haml
index 9790bfbac99..0b60af0bd96 100644
--- a/app/views/shared/milestones/_issues_tab.html.haml
+++ b/app/views/shared/milestones/_issues_tab.html.haml
@@ -7,10 +7,19 @@
- c.with_body do
= milestone_issues_count_message(@milestone)
-.row.gl-mt-3
- .col-md-4
- = render 'shared/milestones/issuables', args.merge(title: s_('Milestones|Unstarted Issues (open and unassigned)'), issuables: issues.opened.unassigned, id: 'unassigned', show_counter: true)
- .col-md-4
- = render 'shared/milestones/issuables', args.merge(title: s_('Milestones|Ongoing Issues (open and assigned)'), issuables: issues.opened.assigned, id: 'ongoing', show_counter: true)
- .col-md-4
- = render 'shared/milestones/issuables', args.merge(title: s_('Milestones|Completed Issues (closed)'), issuables: issues.closed, id: 'closed', show_counter: true)
+- if Feature.enabled?(:work_items_alpha, current_user)
+ .row.gl-mt-3
+ .col-md-4
+ = render 'shared/milestones/issuables', args.merge(title: s_('Milestones|Unstarted'), subtitle: _('(open and unassigned)'), issuables: issues.opened.unassigned, id: 'unassigned', show_counter: true)
+ .col-md-4
+ = render 'shared/milestones/issuables', args.merge(title: s_('Milestones|Ongoing'), subtitle: _('(open and unassigned)'), issuables: issues.opened.assigned, id: 'ongoing', show_counter: true)
+ .col-md-4
+ = render 'shared/milestones/issuables', args.merge(title: s_('Milestones|Completed'), subtitle: _('(closed)'), issuables: issues.closed, id: 'closed', show_counter: true)
+- else
+ .row.gl-mt-3
+ .col-md-4
+ = render 'shared/milestones/issuables', args.merge(title: s_('Milestones|Unstarted Issues (open and unassigned)'), issuables: issues.opened.unassigned, id: 'unassigned', show_counter: true)
+ .col-md-4
+ = render 'shared/milestones/issuables', args.merge(title: s_('Milestones|Ongoing Issues (open and assigned)'), issuables: issues.opened.assigned, id: 'ongoing', show_counter: true)
+ .col-md-4
+ = render 'shared/milestones/issuables', args.merge(title: s_('Milestones|Completed Issues (closed)'), issuables: issues.closed, id: 'closed', show_counter: true)
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index f2bf17ad7ad..7366682579b 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -78,8 +78,12 @@
= sprite_icon('issues')
%span= milestone.issues_visible_to_user(current_user).count
.title.hide-collapsed
- = s_('MilestoneSidebar|Issues')
- = gl_badge_tag milestone.issues_visible_to_user(current_user).count, variant: :muted
+ - if Feature.enabled?(:work_items_alpha, current_user)
+ = s_('MilestoneSidebar|Work items')
+ = gl_badge_tag milestone.sorted_issues(current_user).length, variant: :muted
+ - else
+ = s_('MilestoneSidebar|Issues')
+ = gl_badge_tag milestone.issues_visible_to_user(current_user).count, variant: :muted
- if show_new_issue_link?(project)
= link_to new_project_issue_path(project, issue: { milestone_id: milestone.id }), class: "gl-float-right", title: s_('MilestoneSidebar|New Issue') do
= s_('MilestoneSidebar|New issue')
@@ -87,11 +91,17 @@
%span.milestone-stat
= link_to milestones_browse_issuables_path(milestone, type: :issues) do
= s_('MilestoneSidebar|Open:')
- = milestone.issues_visible_to_user(current_user).opened.count
+ - if Feature.enabled?(:work_items_alpha, current_user)
+ = milestone.sorted_issues(current_user).opened.length
+ - else
+ = milestone.issues_visible_to_user(current_user).opened.count
%span.milestone-stat
= link_to milestones_browse_issuables_path(milestone, type: :issues, state: 'closed') do
= s_('MilestoneSidebar|Closed:')
- = milestone.issues_visible_to_user(current_user).closed.count
+ - if Feature.enabled?(:work_items_alpha, current_user)
+ = milestone.sorted_issues(current_user).closed.length
+ - else
+ = milestone.issues_visible_to_user(current_user).closed.count
.block
.js-sidebar-time-tracking-root{ data: { time_estimate: @milestone.total_time_estimate,
diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml
index 51f3979027a..90b03db300f 100644
--- a/app/views/shared/milestones/_tabs.html.haml
+++ b/app/views/shared/milestones/_tabs.html.haml
@@ -7,8 +7,12 @@
= sprite_icon('chevron-lg-right', size: 12)
= gl_tabs_nav({ class: %w[scrolling-tabs js-milestone-tabs] }) do
= gl_tab_link_to '#tab-issues', item_active: true, data: { endpoint: milestone_tab_path(milestone, 'issues', show_project_name: show_project_name) } do
- = _('Issues')
- = gl_tab_counter_badge milestone.issues_visible_to_user(current_user).size
+ - if Feature.enabled?(:work_items_alpha, current_user)
+ = _('Work items')
+ = gl_tab_counter_badge milestone.sorted_issues(current_user).length
+ - else
+ = _('Issues')
+ = gl_tab_counter_badge milestone.issues_visible_to_user(current_user).size
- if milestone.merge_requests_enabled?
= gl_tab_link_to '#tab-merge-requests', data: { endpoint: milestone_tab_path(milestone, 'merge_requests', show_project_name: show_project_name) } do
= _('Merge requests')
diff --git a/config/feature_flags/gitlab_com_derisk/more_commits_from_gitaly.yml b/config/feature_flags/gitlab_com_derisk/more_commits_from_gitaly.yml
deleted file mode 100644
index 9030072b560..00000000000
--- a/config/feature_flags/gitlab_com_derisk/more_commits_from_gitaly.yml
+++ /dev/null
@@ -1,9 +0,0 @@
----
-name: more_commits_from_gitaly
-feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/520302
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/182568
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/521169
-milestone: '17.10'
-group: group::source code
-type: gitlab_com_derisk
-default_enabled: false
diff --git a/doc/administration/settings/continuous_integration.md b/doc/administration/settings/continuous_integration.md
index 632406a53f6..208f851ea82 100644
--- a/doc/administration/settings/continuous_integration.md
+++ b/doc/administration/settings/continuous_integration.md
@@ -176,20 +176,16 @@ To keep artifacts from the latest successful pipelines:
To allow artifacts to expire according to their expiration settings, clear the checkbox instead.
-#### Display external redirect warning page
+#### Display or hide the external redirect warning page
-Display a warning page when users view job artifacts through GitLab Pages.
+Control whether to display a warning page when users view job artifacts through GitLab Pages.
This warning alerts about potential security risks from user-generated content.
-By default, this setting is turned on.
+The external redirect warning page is displayed by default. To hide it:
-To display the warning page when viewing job artifacts:
-
-1. Select the **Enable the external redirect page for job artifacts** checkbox.
+1. Clear the **Enable the external redirect page for job artifacts** checkbox.
1. Select **Save changes**.
-To allow direct access to job artifacts without warnings, clear the checkbox instead.
-
### Jobs
#### Archive older jobs
@@ -217,14 +213,15 @@ To set up job archiving:
#### Protect CI/CD variables by default
-To set all new [CI/CD variables](../../ci/variables/_index.md) as
-[protected](../../ci/variables/_index.md#protect-a-cicd-variable) by default:
+Set all new CI/CD variables in projects and groups to be protected by default.
+Protected variables are available only to pipelines that run on protected branches or protected tags.
-1. On the left sidebar, at the bottom, select **Admin**.
-1. Select **Settings > CI/CD**.
-1. Select **Protect CI/CD variables by default**.
+To protect all new CI/CD variables by default:
-#### Maximum includes
+1. Select the **Protect CI/CD variables by default** checkbox.
+1. Select **Save changes**.
+
+#### Set maximum includes
{{< history >}}
@@ -232,15 +229,18 @@ To set all new [CI/CD variables](../../ci/variables/_index.md) as
{{< /history >}}
-The maximum number of [includes](../../ci/yaml/includes.md) per pipeline can be set for the entire instance.
-The default is `150`.
+Limit how many external YAML files a pipeline can include using the [`include` keyword](../../ci/yaml/includes.md).
+This limit prevents performance issues when pipelines include too many files.
-1. On the left sidebar, at the bottom, select **Admin**.
-1. Select **Settings > CI/CD**.
-1. Change the value of **Maximum includes**.
-1. Select **Save changes** for the changes to take effect.
+By default, a pipeline can include up to 150 files.
+When a pipeline exceeds this limit, it fails with an error.
-#### Maximum downstream pipeline trigger rate
+To set the maximum number of included files per pipeline:
+
+1. Enter a value in the **Maximum includes** field.
+1. Select **Save changes**.
+
+#### Limit downstream pipeline trigger rate
{{< history >}}
@@ -248,42 +248,47 @@ The default is `150`.
{{< /history >}}
-The maximum number of [downstream pipelines](../../ci/pipelines/downstream_pipelines.md) that can be triggered per minute
-(for a given project, user, and commit) can be set for the entire instance.
-The default value is `0` (no restriction).
+Restrict how many [downstream pipelines](../../ci/pipelines/downstream_pipelines.md)
+can be triggered per minute from a single source.
-1. On the left sidebar, at the bottom, select **Admin**.
-1. Select **Settings > CI/CD**.
-1. Change the value of **Maximum downstream pipeline trigger rate**.
-1. Select **Save changes** for the changes to take effect.
+The maximum downstream pipeline trigger rate limits how many downstream pipelines
+can be triggered per minute for a given combination of project, user, and commit.
+The default value is `0`, which means there is no restriction.
-#### Default CI/CD configuration file
+To set the maximum downstream pipeline trigger rate:
-The default CI/CD configuration file and path for new projects can be set in the **Admin** area
-of your GitLab instance (`.gitlab-ci.yml` if not set):
+1. Enter a value in the **Maximum downstream pipeline trigger rate** field.
+1. Select **Save changes**.
-1. On the left sidebar, at the bottom, select **Admin**.
-1. Select **Settings > CI/CD**.
-1. Input the new file and path in the **Default CI/CD configuration file** field.
-1. Select **Save changes** for the changes to take effect.
+#### Specify a default CI/CD configuration file
-It is also possible to specify a [custom CI/CD configuration file for a specific project](../../ci/pipelines/settings.md#specify-a-custom-cicd-configuration-file).
+Set a custom path and filename to use as the default for CI/CD configuration files in all new projects.
+By default, GitLab uses the `.gitlab-ci.yml` file in the project's root directory.
-#### Disable the pipeline suggestion banner
+This setting applies only to new projects created after you change it.
+Existing projects continue to use their current CI/CD configuration file path.
-By default, a banner displays in merge requests with no pipeline suggesting a
-walkthrough on how to add one.
+To set a custom default CI/CD configuration file path:
-
+1. Enter a value in the **Default CI/CD configuration file** field.
+1. Select **Save changes**.
-To disable the banner:
+Individual projects can override this instance default by
+[specifying a custom CI/CD configuration file](../../ci/pipelines/settings.md#specify-a-custom-cicd-configuration-file).
+
+#### Display or hide the pipeline suggestion banner
+
+Control whether to display a guidance banner in merge requests that have no pipelines.
+This banner provides a walkthrough on how to add a `.gitlab-ci.yml` file.
+
+
+
+The pipeline suggestion banner is displayed by default. To hide it:
-1. On the left sidebar, at the bottom, select **Admin**.
-1. Select **Settings > CI/CD**.
1. Clear the **Enable pipeline suggestion banner** checkbox.
1. Select **Save changes**.
-#### Disable the migrate from Jenkins banner
+#### Display or hide the Jenkins migration banner
{{< history >}}
@@ -291,15 +296,15 @@ To disable the banner:
{{< /history >}}
-By default, a banner shows in merge requests in projects with the [Jenkins integration enabled](../../integration/jenkins.md) to prompt migration to GitLab CI/CD.
+Control whether to display a banner encouraging migration from Jenkins to GitLab CI/CD.
+This banner appears in merge requests for projects that have the
+[Jenkins integration enabled](../../integration/jenkins.md).

-To disable the banner:
+The Jenkins migration banner is displayed by default. To hide it:
-1. On the left sidebar, at the bottom, select **Admin**.
-1. Select **Settings > CI/CD**.
-1. Clear the **Show the migrate from Jenkins banner** checkbox.
+1. Select the **Show the migrate from Jenkins banner** checkbox.
1. Select **Save changes**.
### Set CI/CD limits
@@ -314,27 +319,31 @@ To disable the banner:
{{< /history >}}
-You can configure some [CI/CD limits](../instance_limits.md#cicd-limits)
-from the **Admin** area:
+Set CI/CD limits to control resource usage and help prevent performance issues.
+
+You can configure the following CI/CD limits:
-1. On the left sidebar, at the bottom, select **Admin**.
-1. Select **Settings > CI/CD**.
-1. Expand **Continuous Integration and Deployment**.
-1. In the **CI/CD limits** section, you can set the following limits:
- - **Maximum number of instance-level CI/CD variables**
- - **Maximum size of a dotenv artifact in bytes**
- - **Maximum number of variables in a dotenv artifact**
- - **Maximum number of jobs in a single pipeline**
- - **Total number of jobs in currently active pipelines**
- - **Maximum number of pipeline subscriptions to and from a project**
- - **Maximum number of pipeline schedules**
- - **Maximum number of needs dependencies that a job can have**
- - **Maximum number of runners created or active in a group during the past seven days**
- - **Maximum number of runners created or active in a project during the past seven days**
- - **Maximum number of downstream pipelines in a pipeline's hierarchy tree**
+- Maximum number of instance-level CI/CD variables
+- Maximum size of a dotenv artifact in bytes
+- Maximum number of variables in a dotenv artifact
+- Maximum number of jobs in a single pipeline
+- Total number of jobs in currently active pipelines
+- Maximum number of pipeline subscriptions to and from a project
+- Maximum number of pipeline schedules
+- Maximum number of needs dependencies that a job can have
+- Maximum number of runners created or active in a group during the past seven days
+- Maximum number of runners created or active in a project during the past seven days
+- Maximum number of downstream pipelines in a pipeline's hierarchy tree
+For more information on what these limits control, see [CI/CD limits](../instance_limits.md#cicd-limits).
+
+To configure CI/CD limits:
+
+1. Under **CI/CD limits**, set values for the limits you want to configure.
+1. Select **Save changes**.
+
## Package registry configuration
Configure package forwarding, package limits, and package file size limits.
diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md
index f77055dab96..e35490d2a86 100644
--- a/doc/api/graphql/reference/_index.md
+++ b/doc/api/graphql/reference/_index.md
@@ -22587,7 +22587,17 @@ Compute usage data for hosted runners on GitLab Dedicated.
| `billingMonthIso8601` | [`ISO8601Date!`](#iso8601date) | Timestamp of the billing month in ISO 8601 format. |
| `computeMinutes` | [`Int!`](#int) | Total compute minutes used across all namespaces. |
| `durationSeconds` | [`Int!`](#int) | Total duration in seconds of runner usage. |
-| `rootNamespace` | [`Namespace`](#namespace) | Namespace associated with the usage data. Null for instance aggregate data. |
+| `rootNamespace` | [`NamespaceUnion`](#namespaceunion) | Namespace associated with the usage data. Null for instance aggregate data. |
+
+### `CiDeletedNamespace`
+
+Reference to a namespace that no longer exists.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `id` | [`NamespaceID`](#namespaceid) | ID of the deleted namespace. |
### `CiDeletedRunner`
@@ -27721,6 +27731,27 @@ four standard [pagination arguments](#pagination-arguments):
| `ids` | [`[ComplianceManagementFrameworkID!]`](#compliancemanagementframeworkid) | List of Global IDs of compliance frameworks to return. |
| `search` | [`String`](#string) | Search framework with most similar names. |
+##### `Group.componentVersions`
+
+Find software dependency versions by component name.
+
+{{< details >}}
+**Introduced** in GitLab 18.0.
+**Status**: Experiment.
+{{< /details >}}
+
+Returns [`ComponentVersionConnection!`](#componentversionconnection).
+
+This field returns a [connection](#connections). It accepts the
+four standard [pagination arguments](#pagination-arguments):
+`before: String`, `after: String`, `first: Int`, and `last: Int`.
+
+###### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `componentName` | [`String!`](#string) | Name of the SBoM component. |
+
##### `Group.components`
Find software dependencies by name.
@@ -35327,7 +35358,7 @@ four standard [pagination arguments](#pagination-arguments):
##### `Project.componentVersions`
-Find software dependency versions by component.
+Find software dependency versions by component name.
{{< details >}}
**Introduced** in GitLab 17.10.
@@ -35344,7 +35375,7 @@ four standard [pagination arguments](#pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
-| `componentId` | [`SbomComponentID!`](#sbomcomponentid) | Global ID of the SBoM component. |
+| `componentName` | [`String!`](#string) | Name of the SBoM component. |
##### `Project.components`
@@ -47181,6 +47212,15 @@ One of:
- [`CiBuildNeed`](#cibuildneed)
- [`CiJob`](#cijob)
+#### `NamespaceUnion`
+
+Represents either a namespace or a reference to a deleted namespace.
+
+One of:
+
+- [`CiDeletedNamespace`](#cideletednamespace)
+- [`Namespace`](#namespace)
+
#### `NoteableType`
Represents an object that supports notes.
diff --git a/doc/api/settings.md b/doc/api/settings.md
index 523e7ac9316..122efa574d3 100644
--- a/doc/api/settings.md
+++ b/doc/api/settings.md
@@ -467,7 +467,7 @@ to configure other related settings. These requirements are
| `check_namespace_plan` | boolean | no | Enabling this makes only licensed EE features available to projects if the project namespace's plan includes the feature or if the project is public. Premium and Ultimate only. |
| `ci_job_live_trace_enabled` | boolean | no | Turns on incremental logging for job logs. When turned on, archived job logs are incrementally uploaded to object storage. Object storage must be configured. You can also configure this setting in the [**Admin** area](../administration/settings/continuous_integration.md#incremental-logging). |
| `ci_max_total_yaml_size_bytes` | integer | no | The maximum amount of memory, in bytes, that can be allocated for the pipeline configuration, with all included YAML configuration files. |
-| `ci_max_includes` | integer | no | The [maximum number of includes](../administration/settings/continuous_integration.md#maximum-includes) per pipeline. Default is `150`. |
+| `ci_max_includes` | integer | no | The [maximum number of includes](../administration/settings/continuous_integration.md#set-maximum-includes) per pipeline. Default is `150`. |
| `ci_partitions_size_limit` | integer | no | The maximum amount of disk space, in bytes, that can be used by a database partition for the CI tables before creating new partitions. Default is `100 GB`. |
| `concurrent_github_import_jobs_limit` | integer | no | Maximum number of simultaneous import jobs for the GitHub importer. Default is 1000. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/143875) in GitLab 16.11. |
| `concurrent_bitbucket_import_jobs_limit` | integer | no | Maximum number of simultaneous import jobs for the Bitbucket Cloud importer. Default is 100. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/143875) in GitLab 16.11. |
@@ -513,7 +513,7 @@ to configure other related settings. These requirements are
| `domain_denylist_enabled` | boolean | no | (**If enabled, requires:** `domain_denylist`) Allows blocking sign-ups from emails from specific domains. |
| `domain_denylist` | array of strings | no | Users with email addresses that match these domains **cannot** sign up. Wildcards allowed. Enter multiple entries on separate lines. For example: `domain.com`, `*.domain.com`. |
| `domain_allowlist` | array of strings | no | Force people to use only corporate emails for sign-up. Default is `null`, meaning there is no restriction. |
-| `downstream_pipeline_trigger_limit_per_project_user_sha` | integer | no | [Maximum downstream pipeline trigger rate](../administration/settings/continuous_integration.md#maximum-downstream-pipeline-trigger-rate). Default: `0` (no restriction). [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/144077) in GitLab 16.10. |
+| `downstream_pipeline_trigger_limit_per_project_user_sha` | integer | no | [Maximum downstream pipeline trigger rate](../administration/settings/continuous_integration.md#limit-downstream-pipeline-trigger-rate). Default: `0` (no restriction). [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/144077) in GitLab 16.10. |
| `dsa_key_restriction` | integer | no | The minimum allowed bit length of an uploaded DSA key. Default is `0` (no restriction). `-1` disables DSA keys. |
| `ecdsa_key_restriction` | integer | no | The minimum allowed curve size (in bits) of an uploaded ECDSA key. Default is `0` (no restriction). `-1` disables ECDSA keys. |
| `ecdsa_sk_key_restriction` | integer | no | The minimum allowed curve size (in bits) of an uploaded ECDSA_SK key. Default is `0` (no restriction). `-1` disables ECDSA_SK keys. |
diff --git a/doc/ci/yaml/_index.md b/doc/ci/yaml/_index.md
index f742382479b..7aa845ee172 100644
--- a/doc/ci/yaml/_index.md
+++ b/doc/ci/yaml/_index.md
@@ -202,7 +202,7 @@ And optionally:
pipeline run, the new pipeline uses the changed configuration.
- You can have up to 150 includes per pipeline by default, including [nested](includes.md#use-nested-includes). Additionally:
- In [GitLab 16.0 and later](https://gitlab.com/gitlab-org/gitlab/-/issues/207270) users on GitLab Self-Managed can
- change the [maximum includes](../../administration/settings/continuous_integration.md#maximum-includes) value.
+ change the [maximum includes](../../administration/settings/continuous_integration.md#set-maximum-includes) value.
- In [GitLab 15.10 and later](https://gitlab.com/gitlab-org/gitlab/-/issues/367150) you can have up to 150 includes.
In nested includes, the same file can be included multiple times, but duplicated includes
count towards the limit.
diff --git a/doc/ci/yaml/includes.md b/doc/ci/yaml/includes.md
index 61d94e1d2e3..2b7d7798034 100644
--- a/doc/ci/yaml/includes.md
+++ b/doc/ci/yaml/includes.md
@@ -649,7 +649,7 @@ limit is reached. You can remove one included file at a time to try to narrow do
which configuration file is the source of the loop or excessive included files.
In [GitLab 16.0 and later](https://gitlab.com/gitlab-org/gitlab/-/issues/207270) users on GitLab Self-Managed can
-change the [maximum includes](../../administration/settings/continuous_integration.md#maximum-includes) value.
+change the [maximum includes](../../administration/settings/continuous_integration.md#set-maximum-includes) value.
### `SSL_connect SYSCALL returned=5 errno=0 state=SSLv3/TLS write client hello` and other network failures
diff --git a/doc/development/ai_features/duo_chat.md b/doc/development/ai_features/duo_chat.md
index 4d380b36056..d9a96cafa9d 100644
--- a/doc/development/ai_features/duo_chat.md
+++ b/doc/development/ai_features/duo_chat.md
@@ -548,15 +548,16 @@ and the analysis is tracked as a Snowplow event.
The analysis can contain any of the attributes defined in the latest [iglu schema](https://gitlab.com/gitlab-org/iglu/-/blob/master/public/schemas/com.gitlab/ai_question_category/jsonschema).
-- All possible "category" and "detailed_category" are listed [here](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/gitlab/llm/fixtures/categories.xml).
-- The following is yet to be implemented:
- - "is_proper_sentence"
-- The following are deprecated:
- - "number_of_questions_in_history"
- - "length_of_questions_in_history"
- - "time_since_first_question"
+- The categories and detailed categories have been predefined by the product manager and the product designer, as we are not allowed to look at the actual questions from users. If there is reason to believe that there are missing or confusing categories, they can be changed. To edit the definitions, update `categories.xml` in both [AI Gateway](https://gitlab.com/gitlab-org/modelops/applied-ml/code-suggestions/ai-assist/-/blob/main/ai_gateway/prompts/definitions/categorize_question/categories.xml) and [monolith](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/gitlab/llm/fixtures/categories.xml).
+- The list of attributes captured can be found in [labesl.xml](https://gitlab.com/gitlab-org/modelops/applied-ml/code-suggestions/ai-assist/-/blob/main/ai_gateway/prompts/definitions/categorize_question/labels.xml).
+ - The following is yet to be implemented:
+ - `is_proper_sentence`
+ - The following are deprecated:
+ - `number_of_questions_in_history`
+ - `length_of_questions_in_history`
+ - `time_since_first_question`
-[Dashboards](https://handbook.gitlab.com/handbook/engineering/development/data-science/duo-chat/#-dashboards-internal-only) can be created to visualize the collected data.
+The request count and the user count for each question category and detail category can be reviewed in [this Tableau dashboard](https://10az.online.tableau.com/#/site/gitlab/views/DuoCategoriesofQuestions/DuoCategories) (GitLab team members only).
## How `access_duo_chat` policy works
diff --git a/doc/subscriptions/customers_portal.md b/doc/subscriptions/customers_portal.md
index 0206e618778..213feb50bfe 100644
--- a/doc/subscriptions/customers_portal.md
+++ b/doc/subscriptions/customers_portal.md
@@ -65,10 +65,7 @@ To change profile details, including name and email address:
1. Edit **Your personal details**.
1. Select **Save changes**.
-If you want to transfer ownership of the Customers Portal profile
-to another person, after you enter that person's personal details, you must also:
-
-- [Change the linked GitLab.com account](#change-the-linked-account), if you have one linked.
+You can also [transfer ownership of the Customers Portal profile and billing account](https://support.gitlab.com/hc/en-us/articles/17767356437148-How-to-transfer-subscription-ownership) to another person.
## Change your company details
diff --git a/doc/user/duo_amazon_q/_index.md b/doc/user/duo_amazon_q/_index.md
index 66963bc5fdd..f9e3e533fef 100644
--- a/doc/user/duo_amazon_q/_index.md
+++ b/doc/user/duo_amazon_q/_index.md
@@ -30,11 +30,12 @@ If you have a GitLab Duo Pro or Duo Enterprise add-on, this feature is not avail
At Re:Invent 2024, Amazon announced the GitLab Duo with Amazon Q integration.
With this integration, you can automate tasks and increase productivity.
-To get a subscription to GitLab Duo with Amazon Q, contact your Account Executive.
-
-For a click-through demo, see [the GitLab Duo with Amazon Q Product Tour](https://gitlab.navattic.com/duo-with-q).
+- [GitLab Duo with Amazon Q - From idea to merge request](https://youtu.be/jxxzNst3jpo?si=QHO8JnPgMoFIllbL)
+- For a click-through demo, see [the GitLab Duo with Amazon Q Product Tour](https://gitlab.navattic.com/duo-with-q).
+To get a subscription to GitLab Duo with Amazon Q, contact your Account Executive.
+
## Set up GitLab Duo with Amazon Q
To access GitLab Duo with Amazon Q, request [access to a lab environment](https://about.gitlab.com/partners/technology-partners/aws/#interest).
diff --git a/doc/user/gitlab_com/_index.md b/doc/user/gitlab_com/_index.md
index 4ea83bcfd63..105492d1659 100644
--- a/doc/user/gitlab_com/_index.md
+++ b/doc/user/gitlab_com/_index.md
@@ -87,7 +87,7 @@ the related documentation:
| Maximum test cases for each [unit test report](../../ci/testing/unit_test_reports.md) | `500000` | Unlimited. |
| Maximum registered runners | Free tier: `50` for each group and `50`for each project
All paid tiers: `1000` for each group and `1000` for each project | See [Number of registered runners for each scope](../../administration/instance_limits.md#number-of-registered-runners-for-each-scope). |
| Limit of dotenv variables | Free tier: `50`
Premium tier: `100`
Ultimate tier: `150` | See [Limit dotenv variables](../../administration/instance_limits.md#limit-dotenv-variables). |
-| Maximum downstream pipeline trigger rate (for a given project, user, and commit) | `350` each minute | See [Maximum downstream pipeline trigger rate](../../administration/settings/continuous_integration.md#maximum-downstream-pipeline-trigger-rate). |
+| Maximum downstream pipeline trigger rate (for a given project, user, and commit) | `350` each minute | See [Maximum downstream pipeline trigger rate](../../administration/settings/continuous_integration.md#limit-downstream-pipeline-trigger-rate). |
| Maximum number of downstream pipelines in a pipeline's hierarchy tree | `1000` | See [Limit pipeline hierarchy size](../../administration/instance_limits.md#limit-pipeline-hierarchy-size). |
## Container registry
diff --git a/doc/user/packages/maven_repository/_index.md b/doc/user/packages/maven_repository/_index.md
index e47fd5ca1a3..6203da58bb8 100644
--- a/doc/user/packages/maven_repository/_index.md
+++ b/doc/user/packages/maven_repository/_index.md
@@ -28,7 +28,7 @@ Supported clients:
### Authenticate to the package registry
-You need a token to publish a package. There are different tokens available depending on what you're trying to achieve. For more information, review the [guidance on tokens](../package_registry/_index.md#authenticate-with-the-registry).
+You need a token to publish a package. There are different tokens available depending on what you're trying to achieve. For more information, review the [guidance on tokens](../package_registry/supported_functionality.md#authenticate-with-the-registry).
Create a token and save it to use later in the process.
diff --git a/doc/user/packages/npm_registry/_index.md b/doc/user/packages/npm_registry/_index.md
index 028d32afcfe..e0c0c02bfba 100644
--- a/doc/user/packages/npm_registry/_index.md
+++ b/doc/user/packages/npm_registry/_index.md
@@ -37,7 +37,7 @@ To authenticate, you can use:
If your organization uses two-factor authentication (2FA), you must use a personal access token with the scope set to `api`.
If you want to publish a package with a CI/CD pipeline, you must use a CI/CD job token.
-For more information, review the [guidance on tokens](../package_registry/_index.md#authenticate-with-the-registry).
+For more information, review the [guidance on tokens](../package_registry/supported_functionality.md#authenticate-with-the-registry).
Do not use authentication methods other than the methods documented here. Undocumented authentication methods might be removed in the future.
diff --git a/doc/user/packages/nuget_repository/_index.md b/doc/user/packages/nuget_repository/_index.md
index 04d7665a4f8..49a6954ccb3 100644
--- a/doc/user/packages/nuget_repository/_index.md
+++ b/doc/user/packages/nuget_repository/_index.md
@@ -28,7 +28,7 @@ Learn how to [install NuGet](../workflows/build_packages.md#nuget).
## Authenticate to the package registry
You need an authentication token to access the GitLab package registry. Different tokens are available depending on what you're trying to
-achieve. For more information, review the [guidance on tokens](../package_registry/_index.md#authenticate-with-the-registry).
+achieve. For more information, review the [guidance on tokens](../package_registry/supported_functionality.md#authenticate-with-the-registry).
- If your organization uses two-factor authentication (2FA), you must use a
[personal access token](../../profile/personal_access_tokens.md) with the scope set to `api`.
diff --git a/doc/user/packages/package_registry/_index.md b/doc/user/packages/package_registry/_index.md
index 8c35ecd184b..08fcd04bf69 100644
--- a/doc/user/packages/package_registry/_index.md
+++ b/doc/user/packages/package_registry/_index.md
@@ -51,35 +51,6 @@ When you view packages in a group:
To learn how to create and upload a package, follow the instructions for your [package type](supported_package_managers.md).
-## Authenticate with the registry
-
-Authentication depends on the package manager being used. To learn what authentication protocols are supported for a specific package type, see [Authentication protocols](supported_functionality.md#authentication-protocols).
-
-For most package types, the following credential types are valid:
-
-- [Personal access token](../../profile/personal_access_tokens.md):
- authenticates with your user permissions. Good for personal and local use of the package registry.
-- [Project deploy token](../../project/deploy_tokens/_index.md):
- allows access to all packages in a project. Good for granting and revoking project access to many
- users.
-- [Group deploy token](../../project/deploy_tokens/_index.md):
- allows access to all packages in a group and its subgroups. Good for granting and revoking access
- to a large number of packages to sets of users.
-- [Job token](../../../ci/jobs/ci_job_token.md):
- allows access to packages in the project running the job for the users running the pipeline.
- Access to other external projects can be configured.
-- If your organization uses two-factor authentication (2FA), you must use a personal access token with the scope set to `api`.
-- If you are publishing a package by using CI/CD pipelines, you must use a CI/CD job token.
-
-{{< alert type="note" >}}
-
-When configuring authentication to the package registry:
-
-- If the **Package registry** project setting is [turned off](#turn-off-the-package-registry), you receive a `403 Forbidden` error when you interact with the package registry, even if you have the Owner role.
-- If [external authorization](../../../administration/settings/external_authorization.md) is turned on, you can't access the package registry with a deploy token.
-
-{{< /alert >}}
-
## Use GitLab CI/CD
You can use [GitLab CI/CD](../../../ci/_index.md) to build or import packages into
diff --git a/doc/user/packages/package_registry/dependency_proxy/_index.md b/doc/user/packages/package_registry/dependency_proxy/_index.md
index a8be3ea6c9d..d0f59763769 100644
--- a/doc/user/packages/package_registry/dependency_proxy/_index.md
+++ b/doc/user/packages/package_registry/dependency_proxy/_index.md
@@ -42,7 +42,7 @@ and that users who pull from the cache have the necessary authentication:
- The [`dependency_proxy` feature](../../../../administration/packages/dependency_proxy.md#turn-on-the-dependency-proxy). Enabled by default.
1. In the project settings, if the [`package` feature](../_index.md#turn-off-the-package-registry)
is disabled, enable it. It is enabled by default.
-1. [Add an authentication method](#configure-a-client). The dependency proxy supports the same [authentication methods](../_index.md#authenticate-with-the-registry) as the package registry:
+1. [Add an authentication method](#configure-a-client). The dependency proxy supports the same [authentication methods](../supported_functionality.md#authenticate-with-the-registry) as the package registry:
- [Personal access token](../../../profile/personal_access_tokens.md)
- [Project deploy token](../../../project/deploy_tokens/_index.md)
- [Group deploy token](../../../project/deploy_tokens/_index.md)
diff --git a/doc/user/packages/package_registry/supported_functionality.md b/doc/user/packages/package_registry/supported_functionality.md
index 56e602042be..ff454238e05 100644
--- a/doc/user/packages/package_registry/supported_functionality.md
+++ b/doc/user/packages/package_registry/supported_functionality.md
@@ -2,12 +2,47 @@
stage: Package
group: Package Registry
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
-title: Supported package functionality
+title: Supported package managers and functionality
---
The GitLab package registry supports different functionalities for each package type. This support includes publishing
and pulling packages, request forwarding, managing duplicates, and authentication.
+## Supported package managers
+
+{{< details >}}
+
+- Tier: Free, Premium, Ultimate
+- Offering: GitLab.com, GitLab Self-Managed, GitLab Dedicated
+
+{{< /details >}}
+
+{{< alert type="warning" >}}
+
+Not all package manager formats are ready for production use.
+
+{{< /alert >}}
+
+The package registry supports the following package manager types:
+
+| Package type | Status |
+|---------------------------------------------------|--------|
+| [Composer](../composer_repository/_index.md) | [Beta](https://gitlab.com/groups/gitlab-org/-/epics/6817) |
+| [Conan](../conan_repository/_index.md) | [Experiment](https://gitlab.com/groups/gitlab-org/-/epics/6816) |
+| [Debian](../debian_repository/_index.md) | [Experiment](https://gitlab.com/groups/gitlab-org/-/epics/6057) |
+| [Generic packages](../generic_packages/_index.md) | Generally available |
+| [Go](../go_proxy/_index.md) | [Experiment](https://gitlab.com/groups/gitlab-org/-/epics/3043) |
+| [Helm](../helm_repository/_index.md) | [Beta](https://gitlab.com/groups/gitlab-org/-/epics/6366) |
+| [Maven](../maven_repository/_index.md) | Generally available |
+| [npm](../npm_registry/_index.md) | Generally available |
+| [NuGet](../nuget_repository/_index.md) | Generally available |
+| [PyPI](../pypi_repository/_index.md) | Generally available |
+| [Ruby gems](../rubygems_registry/_index.md) | [Experiment](https://gitlab.com/groups/gitlab-org/-/epics/3200) |
+
+[View what each status means](../../../policy/development_stages_support.md).
+
+You can also use the [API](../../../api/packages.md) to administer the package registry.
+
## Publishing packages
{{< details >}}
@@ -168,7 +203,7 @@ By default, the GitLab package registry either allows or prevents duplicates bas
| [Go](../go_proxy/_index.md) | N |
| [Ruby gems](../rubygems_registry/_index.md) | Y |
-## Authentication tokens
+## Authenticate with the registry
{{< details >}}
@@ -177,9 +212,17 @@ By default, the GitLab package registry either allows or prevents duplicates bas
{{< /details >}}
-GitLab tokens are used to authenticate with the GitLab package registry.
+Authentication depends on the package manager you're using. To learn what authentication protocols are supported for a specific package type, see [Authentication protocols](#authentication-protocols).
-The following tokens are supported:
+For most package types, the following authentication tokens are valid:
+
+- [Personal access token](../../profile/personal_access_tokens.md)
+- [Project deploy token](../../project/deploy_tokens/_index.md)
+- [Group deploy token](../../project/deploy_tokens/_index.md)
+- [CI/CD job token](../../../ci/jobs/ci_job_token.md)
+
+The following table lists which authentication tokens are supported
+for a given package manager:
| Package type | Supported tokens |
|--------------------------------------------------------|------------------------------------------------------------------------|
@@ -198,7 +241,18 @@ The following tokens are supported:
| [Go](../go_proxy/_index.md) | Personal access, job tokens, project access |
| [Ruby gems](../rubygems_registry/_index.md) | Personal access, job tokens, deploy (project or group) |
-## Authentication protocols
+{{< alert type="note" >}}
+
+When you configure authentication to the package registry:
+
+- If the **Package registry** project setting is [turned off](_index.md#turn-off-the-package-registry), you receive a `403 Forbidden` error when you interact with the package registry, even if you have the Owner role.
+- If [external authorization](../../../administration/settings/external_authorization.md) is turned on, you can't access the package registry with a deploy token.
+- If your organization uses two-factor authentication (2FA), you must use a personal access token with the scope set to `api`.
+- If you are publishing a package by using CI/CD pipelines, you must use a CI/CD job token.
+
+{{< /alert >}}
+
+### Authentication protocols
{{< details >}}
diff --git a/doc/user/packages/package_registry/supported_package_managers.md b/doc/user/packages/package_registry/supported_package_managers.md
index ddd97708bf9..6736b90646b 100644
--- a/doc/user/packages/package_registry/supported_package_managers.md
+++ b/doc/user/packages/package_registry/supported_package_managers.md
@@ -1,39 +1,13 @@
---
-stage: Package
-group: Package Registry
-info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
-title: Supported package managers
+redirect_to: 'supported_functionality.md'
+remove_date: '2025-04-25'
---
-{{< details >}}
+
-- Tier: Free, Premium, Ultimate
-- Offering: GitLab.com, GitLab Self-Managed, GitLab Dedicated
+This document was moved to [another location](supported_functionality.md).
-{{< /details >}}
-
-{{< alert type="warning" >}}
-
-Not all package manager formats are ready for production use.
-
-{{< /alert >}}
-
-The package registry supports the following package manager types:
-
-| Package type | Status |
-|---------------------------------------------------|--------|
-| [Composer](../composer_repository/_index.md) | [Beta](https://gitlab.com/groups/gitlab-org/-/epics/6817) |
-| [Conan](../conan_repository/_index.md) | [Experiment](https://gitlab.com/groups/gitlab-org/-/epics/6816) |
-| [Debian](../debian_repository/_index.md) | [Experiment](https://gitlab.com/groups/gitlab-org/-/epics/6057) |
-| [Generic packages](../generic_packages/_index.md) | Generally available |
-| [Go](../go_proxy/_index.md) | [Experiment](https://gitlab.com/groups/gitlab-org/-/epics/3043) |
-| [Helm](../helm_repository/_index.md) | [Beta](https://gitlab.com/groups/gitlab-org/-/epics/6366) |
-| [Maven](../maven_repository/_index.md) | Generally available |
-| [npm](../npm_registry/_index.md) | Generally available |
-| [NuGet](../nuget_repository/_index.md) | Generally available |
-| [PyPI](../pypi_repository/_index.md) | Generally available |
-| [Ruby gems](../rubygems_registry/_index.md) | [Experiment](https://gitlab.com/groups/gitlab-org/-/epics/3200) |
-
-[View what each status means](../../../policy/development_stages_support.md).
-
-You can also use the [API](../../../api/packages.md) to administer the package registry.
+
+
+
+
diff --git a/doc/user/packages/yarn_repository/_index.md b/doc/user/packages/yarn_repository/_index.md
index 11a3e596447..658ab0c6646 100644
--- a/doc/user/packages/yarn_repository/_index.md
+++ b/doc/user/packages/yarn_repository/_index.md
@@ -13,7 +13,7 @@ script job block that is responsible for calling `yarn publish`. The Yarn versio
## Authenticating to the package registry
You need a token to interact with the package registry. Different tokens are available depending on what you're trying to
-achieve. For more information, review the [guidance on tokens](../package_registry/_index.md#authenticate-with-the-registry).
+achieve. For more information, review the [guidance on tokens](../package_registry/supported_functionality.md#authenticate-with-the-registry).
- If your organization uses two-factor authentication (2FA), you must use a
[personal access token](../../profile/personal_access_tokens.md) with the scope set to `api`.
diff --git a/doc/user/project/deploy_tokens/_index.md b/doc/user/project/deploy_tokens/_index.md
index 87003cb3936..da75bdec074 100644
--- a/doc/user/project/deploy_tokens/_index.md
+++ b/doc/user/project/deploy_tokens/_index.md
@@ -118,7 +118,7 @@ jobs.
Deploy tokens can't be used with the GitLab public API. However, you can use deploy tokens with some
endpoints, such as those from the package registry. You can tell an endpoint belongs to the package registry because the URL has the string `packages/`. For example: `https://gitlab.example.com/api/v4/projects/24/packages/generic/my_package/0.0.1/file.txt`. For more information, see
-[Authenticate with the registry](../../packages/package_registry/_index.md#authenticate-with-the-registry).
+[Authenticate with the registry](../../packages/package_registry/supported_functionality.md#authenticate-with-the-registry).
## Create a deploy token
@@ -207,7 +207,7 @@ Prerequisites:
- A deploy token with the `read_package_registry` scope.
-For the [package type of your choice](../../packages/package_registry/supported_functionality.md#authentication-tokens), follow the authentication
+For the [package type of your choice](../../packages/package_registry/supported_functionality.md#authenticate-with-the-registry), follow the authentication
instructions for deploy tokens.
Example of installing a NuGet package from a GitLab registry:
@@ -225,7 +225,7 @@ Prerequisites:
- A deploy token with the `write_package_registry` scope.
-For the [package type of your choice](../../packages/package_registry/supported_functionality.md#authentication-tokens), follow the authentication
+For the [package type of your choice](../../packages/package_registry/supported_functionality.md#authenticate-with-the-registry), follow the authentication
instructions for deploy tokens.
Example of publishing a NuGet package to a package registry:
diff --git a/lib/gitlab/merge_requests/message_generator.rb b/lib/gitlab/merge_requests/message_generator.rb
index 301b224a2bb..b46c5cf1707 100644
--- a/lib/gitlab/merge_requests/message_generator.rb
+++ b/lib/gitlab/merge_requests/message_generator.rb
@@ -86,7 +86,7 @@ module Gitlab
},
'all_commits' => ->(merge_request, _, _) do
merge_request
- .recent_commits(load_from_gitaly: Feature.enabled?(:more_commits_from_gitaly, merge_request.target_project))
+ .recent_commits(load_from_gitaly: Feature.enabled?(:commits_from_gitaly, merge_request.target_project))
.without_merge_commits
.map do |commit|
if commit.safe_message&.bytesize&.>(100.kilobytes)
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 0a9136f9c0a..24639920f0d 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -704,6 +704,9 @@ msgstr ""
msgid "%{commit_author_link} authored %{commit_authored_timeago} and %{commit_committer_avatar} %{commit_committer_link} committed %{commit_committer_timeago}"
msgstr ""
+msgid "%{completedCount} completed"
+msgstr ""
+
msgid "%{completedCount} completed weight"
msgstr ""
@@ -824,6 +827,9 @@ msgstr ""
msgid "%{count} tags"
msgstr ""
+msgid "%{count} total"
+msgstr ""
+
msgid "%{count} total weight"
msgstr ""
@@ -1572,6 +1578,9 @@ msgstr ""
msgid "%{total} remaining issue weight"
msgstr ""
+msgid "%{total} remaining weight"
+msgstr ""
+
msgid "%{total} warnings found: showing first %{warningsDisplayed}"
msgstr ""
@@ -1739,6 +1748,9 @@ msgstr ""
msgid "(Unlimited compute minutes)"
msgstr ""
+msgid "(closed)"
+msgstr ""
+
msgid "(code expired)"
msgstr ""
@@ -1760,6 +1772,9 @@ msgstr ""
msgid "(no user)"
msgstr ""
+msgid "(open and unassigned)"
+msgstr ""
+
msgid "(optional)"
msgstr ""
@@ -18264,6 +18279,9 @@ msgstr ""
msgid "Couldn't reorder child due to an internal error."
msgstr ""
+msgid "Count"
+msgstr ""
+
msgid "Counter exceeded max value"
msgstr ""
@@ -38291,6 +38309,9 @@ msgstr ""
msgid "MilestoneSidebar|Until"
msgstr ""
+msgid "MilestoneSidebar|Work items"
+msgstr ""
+
msgid "MilestoneSidebar|complete"
msgstr ""
@@ -38303,6 +38324,9 @@ msgstr ""
msgid "Milestones| You’re about to permanently delete the milestone %{milestoneTitle}. This milestone is not currently used in any issues or merge requests."
msgstr ""
+msgid "Milestones|Completed"
+msgstr ""
+
msgid "Milestones|Completed Issues (closed)"
msgstr ""
@@ -38327,6 +38351,9 @@ msgstr ""
msgid "Milestones|No labels found"
msgstr ""
+msgid "Milestones|Ongoing"
+msgstr ""
+
msgid "Milestones|Ongoing Issues (open and assigned)"
msgstr ""
@@ -38351,6 +38378,9 @@ msgstr ""
msgid "Milestones|This action cannot be reversed."
msgstr ""
+msgid "Milestones|Unstarted"
+msgstr ""
+
msgid "Milestones|Unstarted Issues (open and unassigned)"
msgstr ""
@@ -42223,6 +42253,9 @@ msgstr ""
msgid "Open in Web IDE"
msgstr ""
+msgid "Open in Workspace"
+msgstr ""
+
msgid "Open in file view"
msgstr ""
@@ -57412,6 +57445,9 @@ msgstr[1] ""
msgid "Showing %{limit} of %{total_count} issues. "
msgstr ""
+msgid "Showing %{limit} of %{total_count} items. "
+msgstr ""
+
msgid "Showing %{pageSize} of %{total} %{issuableType}"
msgstr ""
diff --git a/scripts/frontend/quarantined_vue3_specs.txt b/scripts/frontend/quarantined_vue3_specs.txt
index cfe6647cc8e..a934eb34c81 100644
--- a/scripts/frontend/quarantined_vue3_specs.txt
+++ b/scripts/frontend/quarantined_vue3_specs.txt
@@ -57,7 +57,6 @@ ee/spec/frontend/status_checks/mount_spec.js
ee/spec/frontend/usage_quotas/transfer/components/usage_by_month_spec.js
ee/spec/frontend/users/identity_verification/components/international_phone_input_spec.js
ee/spec/frontend/users/identity_verification/components/verify_phone_verification_code_spec.js
-ee/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js
spec/frontend/__helpers__/vue_test_utils_helper_spec.js
spec/frontend/access_tokens/index_spec.js
spec/frontend/admin/abuse_report/components/reported_content_spec.js
diff --git a/spec/features/milestones/user_creates_milestone_spec.rb b/spec/features/milestones/user_creates_milestone_spec.rb
index 0ad6868069d..8514d9f5c18 100644
--- a/spec/features/milestones/user_creates_milestone_spec.rb
+++ b/spec/features/milestones/user_creates_milestone_spec.rb
@@ -35,6 +35,7 @@ RSpec.describe "User creates milestone", :js, feature_category: :team_planning d
before do
sign_in(user)
+ stub_feature_flags(work_items_alpha: false)
visit(new_project_milestone_path(project))
end
diff --git a/spec/features/milestones/user_views_milestone_spec.rb b/spec/features/milestones/user_views_milestone_spec.rb
index ab8a7c8654e..cc7f84dce25 100644
--- a/spec/features/milestones/user_views_milestone_spec.rb
+++ b/spec/features/milestones/user_views_milestone_spec.rb
@@ -71,6 +71,7 @@ RSpec.describe "User views milestone", feature_category: :team_planning do
context 'when issues on milestone are over DISPLAY_ISSUES_LIMIT' do
it "limits issues to display and shows warning" do
+ stub_feature_flags(work_items_alpha: false)
stub_const('Milestoneish::DISPLAY_ISSUES_LIMIT', 3)
visit(project_milestone_path(project, milestone))
@@ -81,6 +82,19 @@ RSpec.describe "User views milestone", feature_category: :team_planning do
end
end
+ context 'when issues on milestone are over DISPLAY_ISSUES_LIMIT and work_items_alpha FF is on' do
+ it "limits issues to display and shows warning" do
+ stub_feature_flags(work_items_alpha: true)
+ stub_const('Milestoneish::DISPLAY_ISSUES_LIMIT', 3)
+
+ visit(project_milestone_path(project, milestone))
+
+ expect(page).to have_selector('#tab-issues li', count: 3)
+ expect(page).to have_selector('#milestone-issue-count-warning', text: 'Showing 3 of 6 items. View all')
+ expect(page).to have_link('View all', href: project_issues_path(project, { milestone_title: milestone.title }))
+ end
+ end
+
context 'when issues on milestone are below DISPLAY_ISSUES_LIMIT' do
it 'does not display warning' do
visit(project_milestone_path(project, milestone))
diff --git a/spec/features/projects/milestones/milestone_spec.rb b/spec/features/projects/milestones/milestone_spec.rb
index 1990308af31..8da6c9bbff6 100644
--- a/spec/features/projects/milestones/milestone_spec.rb
+++ b/spec/features/projects/milestones/milestone_spec.rb
@@ -22,6 +22,7 @@ RSpec.describe 'Project milestone', :js, feature_category: :team_planning do
before do
sign_in(user)
+ stub_feature_flags(work_items_alpha: false)
end
context 'when project has enabled issues' do
diff --git a/spec/frontend/api/projects_api_spec.js b/spec/frontend/api/projects_api_spec.js
index 76e98165f92..95a5dbc6608 100644
--- a/spec/frontend/api/projects_api_spec.js
+++ b/spec/frontend/api/projects_api_spec.js
@@ -125,6 +125,22 @@ describe('~/api/projects_api.js', () => {
});
});
+ describe('restoreProject', () => {
+ beforeEach(() => {
+ jest.spyOn(axios, 'post');
+ });
+
+ it('calls POST to the correct URL', () => {
+ const expectedUrl = `/api/v7/projects/${projectId}/restore`;
+
+ mock.onPost(expectedUrl).replyOnce(HTTP_STATUS_OK);
+
+ return projectsApi.restoreProject(projectId).then(() => {
+ expect(axios.post).toHaveBeenCalledWith(expectedUrl);
+ });
+ });
+ });
+
describe('importProjectMembers', () => {
beforeEach(() => {
jest.spyOn(axios, 'post');
diff --git a/spec/frontend/ci/pipeline_details/graph/components/stage_column_component_spec.js b/spec/frontend/ci/pipeline_details/graph/components/stage_column_component_spec.js
index 6d4adda2cc0..96f419991f7 100644
--- a/spec/frontend/ci/pipeline_details/graph/components/stage_column_component_spec.js
+++ b/spec/frontend/ci/pipeline_details/graph/components/stage_column_component_spec.js
@@ -1,6 +1,7 @@
import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import JobItem from '~/ci/pipeline_details/graph/components/job_item.vue';
+import JobGroupDropdown from '~/ci/pipeline_details/graph/components/job_group_dropdown.vue';
import StageColumnComponent from '~/ci/pipeline_details/graph/components/stage_column_component.vue';
import ActionComponent from '~/ci/common/private/job_action_component.vue';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
@@ -54,6 +55,7 @@ describe('stage column component', () => {
wrapper.findAll('[data-testid="stage-column-group-failed"]');
const findJobItem = () => wrapper.findComponent(JobItem);
const findActionComponent = () => wrapper.findComponent(ActionComponent);
+ const findJobGroupDropdown = () => wrapper.findComponent(JobGroupDropdown);
const createComponent = ({ method = shallowMount, props = {} } = {}) => {
wrapper = method(StageColumnComponent, {
@@ -334,4 +336,85 @@ describe('stage column component', () => {
expect(findActionComponent().exists()).toBe(false);
});
});
+
+ describe('with matrix', () => {
+ beforeEach(() => {
+ createComponent({
+ method: mount,
+ props: {
+ groups: [
+ {
+ id: 'gid://gitlab/Ci::Group/3719-build+job',
+ status: {
+ __typename: 'DetailedStatus',
+ label: 'passed',
+ group: 'success',
+ icon: 'status_success',
+ text: 'Passed',
+ },
+ name: 'build job',
+ size: 3,
+ jobs: [
+ {
+ id: 'gid://gitlab/Ci::Build/13149',
+ name: 'build job',
+ kind: 'BUILD',
+ needs: [],
+ previousStageJobsOrNeeds: [],
+ status: {
+ icon: 'status_success',
+ tooltip: 'passed',
+ hasDetails: true,
+ detailsPath: '/root/parallel-matrix-use-case/-/jobs/13149',
+ group: 'success',
+ label: 'passed',
+ text: 'Passed',
+ action: {
+ id: 'Ci::BuildPresenter-success-13149',
+ buttonTitle: 'Run this job again',
+ confirmationMessage: null,
+ icon: 'retry',
+ path: '/root/parallel-matrix-use-case/-/jobs/13149/retry',
+ title: 'Run again',
+ },
+ },
+ },
+ {
+ id: 'gid://gitlab/Ci::Build/13151',
+ name: 'build job [eu-region]',
+ kind: 'BUILD',
+ needs: [],
+ previousStageJobsOrNeeds: [],
+ status: {
+ icon: 'status_success',
+ tooltip: 'passed',
+ hasDetails: true,
+ detailsPath: '/root/parallel-matrix-use-case/-/jobs/13151',
+ group: 'success',
+ label: 'passed',
+ text: 'Passed',
+ action: {
+ id: 'Ci::BuildPresenter-success-13151',
+ buttonTitle: 'Run this job again',
+ confirmationMessage: null,
+ icon: 'retry',
+ path: '/root/parallel-matrix-use-case/-/jobs/13151/retry',
+ title: 'Run again',
+ },
+ },
+ },
+ ],
+ stageName: 'test',
+ },
+ ],
+ title: 'test',
+ hasTriggeredBy: false,
+ },
+ });
+ });
+
+ it('renders stage jobs', () => {
+ expect(findJobGroupDropdown().exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/issuable/related_issues/components/issue_token_spec.js b/spec/frontend/issuable/related_issues/components/issue_token_spec.js
index 946b0834d10..ad0ba73d60f 100644
--- a/spec/frontend/issuable/related_issues/components/issue_token_spec.js
+++ b/spec/frontend/issuable/related_issues/components/issue_token_spec.js
@@ -1,4 +1,4 @@
-import { shallowMount } from '@vue/test-utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import IssueToken from '~/related_issues/components/issue_token.vue';
import { PathIdSeparator } from '~/related_issues/constants';
@@ -19,15 +19,15 @@ describe('IssueToken', () => {
};
const createComponent = (props = {}) => {
- wrapper = shallowMount(IssueToken, {
+ wrapper = shallowMountExtended(IssueToken, {
propsData: { ...defaultProps, ...props },
});
};
const findLink = () => wrapper.findComponent({ ref: 'link' });
const findReference = () => wrapper.findComponent({ ref: 'reference' });
- const findReferenceIcon = () => wrapper.find('[data-testid="referenceIcon"]');
- const findRemoveBtn = () => wrapper.find('[data-testid="removeBtn"]');
+ const findReferenceIcon = () => wrapper.findByTestId('referenceIcon');
+ const findRemoveBtn = () => wrapper.findByTestId('removeBtn');
const findTitle = () => wrapper.findComponent({ ref: 'title' });
describe('with reference supplied', () => {
diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js
index 5ade851fc1c..3dc72224308 100644
--- a/spec/frontend/issues/show/components/description_spec.js
+++ b/spec/frontend/issues/show/components/description_spec.js
@@ -43,8 +43,8 @@ describe('Description component', () => {
Vue.use(VueApollo);
- const findGfmContent = () => wrapper.find('[data-testid="gfm-content"]');
- const findTextarea = () => wrapper.find('[data-testid="textarea"]');
+ const findGfmContent = () => wrapper.findByTestId('gfm-content');
+ const findTextarea = () => wrapper.findByTestId('textarea');
const findListItems = () => findGfmContent().findAll('ul > li');
const findTaskActionButtons = () => wrapper.findAll('.task-list-item-actions');
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js
index ec7b89aa927..b0427908888 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js
@@ -1,7 +1,7 @@
import { GlToggle, GlSprintf } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { GlFormGroup } from 'jest/packages_and_registries/shared/stubs';
-import component from '~/packages_and_registries/settings/project/components/expiration_toggle.vue';
+import ExpirationToggle from '~/packages_and_registries/settings/project/components/expiration_toggle.vue';
import {
ENABLED_TOGGLE_DESCRIPTION,
DISABLED_TOGGLE_DESCRIPTION,
@@ -11,27 +11,29 @@ describe('ExpirationToggle', () => {
let wrapper;
const findToggle = () => wrapper.findComponent(GlToggle);
- const findDescription = () => wrapper.find('[data-testid="description"]');
+ const findDescription = () => wrapper.findByTestId('description');
- const mountComponent = (propsData) => {
- wrapper = shallowMount(component, {
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(ExpirationToggle, {
stubs: {
GlFormGroup,
GlSprintf,
},
- propsData,
+ propsData: {
+ ...props,
+ },
});
};
describe('structure', () => {
it('has a toggle component', () => {
- mountComponent();
+ createComponent();
- expect(findToggle().props('label')).toBe(component.i18n.toggleLabel);
+ expect(findToggle().props('label')).toBe(ExpirationToggle.i18n.toggleLabel);
});
it('has a description', () => {
- mountComponent();
+ createComponent();
expect(findDescription().exists()).toBe(true);
});
@@ -39,7 +41,7 @@ describe('ExpirationToggle', () => {
describe('model', () => {
it('assigns the right props to the toggle component', () => {
- mountComponent({ value: true, disabled: true });
+ createComponent({ value: true, disabled: true });
expect(findToggle().props()).toMatchObject({
value: true,
@@ -48,7 +50,7 @@ describe('ExpirationToggle', () => {
});
it('emits input event when toggle is updated', () => {
- mountComponent();
+ createComponent();
findToggle().vm.$emit('change', false);
@@ -58,13 +60,13 @@ describe('ExpirationToggle', () => {
describe('toggle description', () => {
it('says enabled when the toggle is on', () => {
- mountComponent({ value: true });
+ createComponent({ value: true });
expect(findDescription().text()).toMatchInterpolatedText(ENABLED_TOGGLE_DESCRIPTION);
});
it('says disabled when the toggle is off', () => {
- mountComponent({ value: false });
+ createComponent({ value: false });
expect(findDescription().text()).toMatchInterpolatedText(DISABLED_TOGGLE_DESCRIPTION);
});
diff --git a/spec/frontend/packages_and_registries/shared/components/package_tags_spec.js b/spec/frontend/packages_and_registries/shared/components/package_tags_spec.js
index 77b8a669cf0..3827d0d1899 100644
--- a/spec/frontend/packages_and_registries/shared/components/package_tags_spec.js
+++ b/spec/frontend/packages_and_registries/shared/components/package_tags_spec.js
@@ -1,24 +1,22 @@
-import { mount } from '@vue/test-utils';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue';
import { mockTags } from 'jest/packages_and_registries/infrastructure_registry/components/mock_data';
describe('PackageTags', () => {
let wrapper;
- function createComponent(tags = [], props = {}) {
- const propsData = {
- tags,
- ...props,
- };
-
- wrapper = mount(PackageTags, {
- propsData,
+ const createComponent = (tags = [], props = {}) => {
+ wrapper = mountExtended(PackageTags, {
+ propsData: {
+ tags,
+ ...props,
+ },
});
- }
+ };
- const tagLabel = () => wrapper.find('[data-testid="tagLabel"]');
- const tagBadges = () => wrapper.findAll('[data-testid="tagBadge"]');
- const moreBadge = () => wrapper.find('[data-testid="moreBadge"]');
+ const tagLabel = () => wrapper.findByTestId('tagLabel');
+ const tagBadges = () => wrapper.findAllByTestId('tagBadge');
+ const moreBadge = () => wrapper.findByTestId('moreBadge');
describe('tag label', () => {
it('shows the tag label by default', () => {
diff --git a/spec/frontend/packages_and_registries/shared/components/packages_list_loader_spec.js b/spec/frontend/packages_and_registries/shared/components/packages_list_loader_spec.js
index 62a5f3e2aa7..11c910ce402 100644
--- a/spec/frontend/packages_and_registries/shared/components/packages_list_loader_spec.js
+++ b/spec/frontend/packages_and_registries/shared/components/packages_list_loader_spec.js
@@ -1,19 +1,15 @@
-import { mount } from '@vue/test-utils';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
describe('PackagesListLoader', () => {
let wrapper;
- const createComponent = (props = {}) => {
- wrapper = mount(PackagesListLoader, {
- propsData: {
- ...props,
- },
- });
+ const createComponent = () => {
+ wrapper = mountExtended(PackagesListLoader);
};
- const findDesktopShapes = () => wrapper.find('[data-testid="desktop-loader"]');
- const findMobileShapes = () => wrapper.find('[data-testid="mobile-loader"]');
+ const findDesktopShapes = () => wrapper.findByTestId('desktop-loader');
+ const findMobileShapes = () => wrapper.findByTestId('mobile-loader');
beforeEach(createComponent);
diff --git a/spec/frontend/packages_and_registries/shared/components/publish_method_spec.js b/spec/frontend/packages_and_registries/shared/components/publish_method_spec.js
index 237d442bf53..f2dfdf01aaa 100644
--- a/spec/frontend/packages_and_registries/shared/components/publish_method_spec.js
+++ b/spec/frontend/packages_and_registries/shared/components/publish_method_spec.js
@@ -1,4 +1,4 @@
-import { shallowMount } from '@vue/test-utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import PublishMethod from '~/packages_and_registries/shared/components/publish_method.vue';
import { packageList } from 'jest/packages_and_registries/infrastructure_registry/components/mock_data';
@@ -7,12 +7,12 @@ describe('publish_method', () => {
const [packageWithoutPipeline, packageWithPipeline] = packageList;
- const findPipelineRef = () => wrapper.find('[data-testid="pipeline-ref"]');
- const findPipelineSha = () => wrapper.find('[data-testid="pipeline-sha"]');
- const findManualPublish = () => wrapper.find('[data-testid="manually-published"]');
+ const findPipelineRef = () => wrapper.findByTestId('pipeline-ref');
+ const findPipelineSha = () => wrapper.findByTestId('pipeline-sha');
+ const findManualPublish = () => wrapper.findByTestId('manually-published');
const mountComponent = (packageEntity = {}, isGroup = false) => {
- wrapper = shallowMount(PublishMethod, {
+ wrapper = shallowMountExtended(PublishMethod, {
propsData: {
packageEntity,
isGroup,
diff --git a/spec/frontend/streaming/chunk_writer_spec.js b/spec/frontend/streaming/chunk_writer_spec.js
index 84b25a5fb92..a2fc0f4300d 100644
--- a/spec/frontend/streaming/chunk_writer_spec.js
+++ b/spec/frontend/streaming/chunk_writer_spec.js
@@ -12,6 +12,7 @@ describe('ChunkWriter', () => {
let abort;
let config;
let render;
+ let abortBalancer;
let cancelTimer;
let runTimer;
@@ -58,7 +59,8 @@ describe('ChunkWriter', () => {
// render until 'false'
}
});
- RenderBalancer.mockImplementation(() => ({ render }));
+ abortBalancer = jest.fn();
+ RenderBalancer.mockImplementation(() => ({ render, abort: abortBalancer }));
cancelTimer = jest.fn();
throttle.mockImplementation((fn) => {
const promise = new Promise((resolve) => {
@@ -253,6 +255,7 @@ describe('ChunkWriter', () => {
config = { signal: controller.signal };
createWriter().write(createChunk('1234567890'));
controller.abort();
+ expect(abortBalancer).toHaveBeenCalledTimes(1);
expect(abort).toHaveBeenCalledTimes(1);
expect(write).not.toHaveBeenCalled();
});
diff --git a/spec/frontend/streaming/render_balancer_spec.js b/spec/frontend/streaming/render_balancer_spec.js
index dae0c98d678..f6c3c398671 100644
--- a/spec/frontend/streaming/render_balancer_spec.js
+++ b/spec/frontend/streaming/render_balancer_spec.js
@@ -66,4 +66,17 @@ describe('renderBalancer', () => {
expect(increase).toHaveBeenCalled();
expect(decrease).not.toHaveBeenCalled();
});
+
+ it('aborts', () => {
+ let tick;
+ jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
+ tick = () => cb();
+ });
+ const callback = jest.fn();
+ const balancer = createBalancer();
+ balancer.render(callback);
+ balancer.abort();
+ tick();
+ expect(callback).not.toHaveBeenCalled();
+ });
});
diff --git a/spec/frontend/vue_shared/access_tokens/components/access_token_form_spec.js b/spec/frontend/vue_shared/access_tokens/components/access_token_form_spec.js
new file mode 100644
index 00000000000..48ff8127967
--- /dev/null
+++ b/spec/frontend/vue_shared/access_tokens/components/access_token_form_spec.js
@@ -0,0 +1,150 @@
+import {
+ GlDatepicker,
+ GlFormCheckbox,
+ GlFormFields,
+ GlFormInput,
+ GlFormTextarea,
+} from '@gitlab/ui';
+import { createTestingPinia } from '@pinia/testing';
+import Vue, { nextTick } from 'vue';
+import { PiniaVuePlugin } from 'pinia';
+import AccessTokenForm from '~/vue_shared/access_tokens/components/access_token_form.vue';
+import { useAccessTokens } from '~/vue_shared/access_tokens/stores/access_tokens';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+
+Vue.use(PiniaVuePlugin);
+
+describe('AccessTokenForm', () => {
+ let wrapper;
+
+ const pinia = createTestingPinia();
+ const store = useAccessTokens();
+
+ const accessTokenMaxDate = '2021-07-06';
+ const accessTokenMinDate = '2020-07-06';
+
+ const createComponent = (provide = {}) => {
+ wrapper = mountExtended(AccessTokenForm, {
+ pinia,
+ provide: {
+ accessTokenMaxDate,
+ accessTokenMinDate,
+ ...provide,
+ },
+ });
+ };
+
+ const findCheckboxes = () => wrapper.findAllComponents(GlFormCheckbox);
+ const findDatepicker = () => wrapper.findComponent(GlDatepicker);
+ const findForm = () => wrapper.find('form');
+ const findFormFields = () => wrapper.findComponent(GlFormFields);
+ const findInput = () => wrapper.findComponent(GlFormInput);
+ const findTextArea = () => wrapper.findComponent(GlFormTextarea);
+
+ it('contains a name field', () => {
+ createComponent();
+
+ expect(findInput().exists()).toBe(true);
+ });
+
+ it('contains a description field', () => {
+ createComponent();
+
+ expect(findTextArea().exists()).toBe(true);
+ });
+
+ describe('expiration field', () => {
+ it('contains a datepicker with correct props', () => {
+ createComponent();
+
+ const datepicker = findDatepicker();
+ expect(datepicker.exists()).toBe(true);
+ expect(datepicker.props()).toMatchObject({
+ minDate: new Date(accessTokenMinDate),
+ maxDate: new Date(accessTokenMaxDate),
+ });
+ });
+
+ it('removes the expiration date when the datepicker is cleared', async () => {
+ createComponent();
+ const datepicker = findDatepicker();
+ expect(datepicker.props('value')).toBeDefined();
+ datepicker.vm.$emit('clear');
+ await nextTick();
+
+ expect(datepicker.props('value')).toBeNull();
+ });
+ });
+
+ it('contains scope checkboxes', () => {
+ createComponent();
+
+ const checkboxes = findCheckboxes();
+ expect(checkboxes).toHaveLength(13);
+ const checkbox = checkboxes.at(0);
+ expect(checkbox.find('input').element.value).toBe('read_service_ping');
+ expect(checkbox.find('label').text()).toContain(
+ 'Grant access to download Service Ping payload via API when authenticated as an admin user.',
+ );
+ });
+
+ describe('reset button', () => {
+ it('emits a cancel event', () => {
+ createComponent();
+ expect(store.setShowCreateForm).toHaveBeenCalledTimes(0);
+ findForm().trigger('reset');
+
+ expect(store.setShowCreateForm).toHaveBeenCalledWith(false);
+ });
+ });
+
+ describe('submit button', () => {
+ describe('when mandatory fields are empty', () => {
+ it('does not create token', () => {
+ createComponent();
+ findFormFields().trigger('submit');
+
+ expect(store.createToken).toHaveBeenCalledTimes(0);
+ });
+ });
+
+ describe('when mandatory fields are filled', () => {
+ describe('when the expiration date is mandatory', () => {
+ it('creates token if mandatory fields are present', async () => {
+ createComponent();
+ findInput().setValue('my-token');
+ findCheckboxes().at(0).find('input').setChecked();
+ await nextTick();
+ findFormFields().vm.$emit('submit');
+
+ expect(store.createToken).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'my-token',
+ expiresAt: '2020-08-05',
+ scopes: ['read_service_ping'],
+ }),
+ );
+ });
+ });
+
+ describe('when the expiration date is not mandatory', () => {
+ it('creates token if mandatory fields are present', async () => {
+ createComponent();
+ findInput().setValue('my-token');
+ findCheckboxes().at(0).find('input').setChecked();
+ findDatepicker().vm.$emit('clear');
+ await nextTick();
+ findFormFields().vm.$emit('submit');
+
+ expect(store.createToken).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'my-token',
+ expiresAt: null,
+ scopes: ['read_service_ping'],
+ }),
+ );
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/access_tokens/components/access_token_spec.js b/spec/frontend/vue_shared/access_tokens/components/access_token_spec.js
new file mode 100644
index 00000000000..0d410e081d2
--- /dev/null
+++ b/spec/frontend/vue_shared/access_tokens/components/access_token_spec.js
@@ -0,0 +1,56 @@
+import { GlAlert } from '@gitlab/ui';
+import { createTestingPinia } from '@pinia/testing';
+import Vue from 'vue';
+import { PiniaVuePlugin } from 'pinia';
+import AccessToken from '~/vue_shared/access_tokens/components/access_token.vue';
+import { useAccessTokens } from '~/vue_shared/access_tokens/stores/access_tokens';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import InputCopyToggleVisibility from '~/vue_shared/components/input_copy_toggle_visibility/input_copy_toggle_visibility.vue';
+
+Vue.use(PiniaVuePlugin);
+
+describe('AccessToken', () => {
+ let wrapper;
+
+ const token = 'my-token';
+
+ const pinia = createTestingPinia();
+ const store = useAccessTokens();
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(AccessToken, {
+ pinia,
+ });
+ };
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findInputCopyToggleVisibility = () => wrapper.findComponent(InputCopyToggleVisibility);
+
+ beforeEach(() => {
+ store.token = token;
+ createComponent();
+ });
+
+ it('renders the alert', () => {
+ expect(findAlert().exists()).toBe(true);
+ expect(findInputCopyToggleVisibility().props()).toMatchObject({
+ copyButtonTitle: 'Copy token',
+ formInputGroupProps: {
+ 'data-testid': 'access-token-field',
+ id: 'access-token-field',
+ name: 'access-token-field',
+ },
+ initialVisibility: false,
+ readonly: true,
+ showCopyButton: true,
+ showToggleVisibilityButton: true,
+ size: 'lg',
+ value: token,
+ });
+ });
+
+ it('nullifies token if alert is dismissed', () => {
+ findAlert().vm.$emit('dismiss');
+ expect(store.setToken).toHaveBeenCalledWith(null);
+ });
+});
diff --git a/spec/frontend/vue_shared/access_tokens/components/access_token_statistics_spec.js b/spec/frontend/vue_shared/access_tokens/components/access_token_statistics_spec.js
new file mode 100644
index 00000000000..7ee27929ab1
--- /dev/null
+++ b/spec/frontend/vue_shared/access_tokens/components/access_token_statistics_spec.js
@@ -0,0 +1,52 @@
+import { GlButton } from '@gitlab/ui';
+import { createTestingPinia } from '@pinia/testing';
+import Vue from 'vue';
+import { PiniaVuePlugin } from 'pinia';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { useAccessTokens } from '~/vue_shared/access_tokens/stores/access_tokens';
+import AccessTokenStatistics from '~/vue_shared/access_tokens/components/access_token_statistics.vue';
+
+Vue.use(PiniaVuePlugin);
+
+describe('AccessTokenStatistics', () => {
+ let wrapper;
+
+ const pinia = createTestingPinia();
+ const store = useAccessTokens();
+
+ const createComponent = () => {
+ wrapper = mountExtended(AccessTokenStatistics, {
+ pinia,
+ });
+ };
+
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ it('fetches tokens with respective filters when `Filter list` is clicked', () => {
+ store.statistics = [
+ {
+ title: 'Active tokens',
+ tooltipTitle: 'Filter tokens for active tokens',
+ value: 1,
+ filters: [
+ {
+ type: 'state',
+ value: {
+ data: 'active',
+ operator: '=',
+ },
+ },
+ ],
+ },
+ ];
+ createComponent();
+
+ findButton().trigger('click');
+
+ expect(store.setFilters).toHaveBeenCalledWith([
+ { type: 'state', value: { data: 'active', operator: '=' } },
+ ]);
+ expect(store.setPage).toHaveBeenCalledWith(1);
+ expect(store.fetchTokens).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/spec/frontend/vue_shared/access_tokens/components/access_token_table_spec.js b/spec/frontend/vue_shared/access_tokens/components/access_token_table_spec.js
new file mode 100644
index 00000000000..ad3733959b3
--- /dev/null
+++ b/spec/frontend/vue_shared/access_tokens/components/access_token_table_spec.js
@@ -0,0 +1,364 @@
+import { GlBadge, GlDisclosureDropdown, GlIcon, GlModal, GlTable } from '@gitlab/ui';
+import { createTestingPinia } from '@pinia/testing';
+import Vue from 'vue';
+import { PiniaVuePlugin } from 'pinia';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { stubComponent } from 'helpers/stub_component';
+import AccessTokenTable from '~/vue_shared/access_tokens/components/access_token_table.vue';
+import { useAccessTokens } from '~/vue_shared/access_tokens/stores/access_tokens';
+
+Vue.use(PiniaVuePlugin);
+
+describe('AccessTokenTable', () => {
+ let wrapper;
+
+ const pinia = createTestingPinia();
+ const store = useAccessTokens();
+
+ const defaultToken = {
+ active: true,
+ id: 1,
+ name: 'My name ',
+ };
+
+ const createComponent = (props = {}) => {
+ wrapper = mountExtended(AccessTokenTable, {
+ pinia,
+ propsData: {
+ busy: false,
+ tokens: [defaultToken],
+ ...props,
+ },
+ stubs: {
+ GlModal: stubComponent(GlModal),
+ },
+ });
+ };
+
+ const findBadge = () => wrapper.findComponent(GlBadge);
+ const findDisclosure = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findDisclosureButton = (index) =>
+ findDisclosure().findAll('button.gl-new-dropdown-item-content').at(index);
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findIcon = (component) => component.findComponent(GlIcon);
+ const findTable = () => wrapper.findComponent(GlTable);
+
+ describe('busy state', () => {
+ describe('when it is `true`', () => {
+ beforeEach(() => {
+ createComponent({ busy: true });
+ });
+
+ it('has aria-busy `true` in the table', () => {
+ expect(findTable().attributes('aria-busy')).toBe('true');
+ });
+
+ it('disables the dropdown', () => {
+ expect(findDisclosure().props('disabled')).toBe(true);
+ });
+ });
+
+ describe('when it is `false`', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('has aria-busy `false` in the table', () => {
+ expect(findTable().attributes('aria-busy')).toBe('false');
+ });
+
+ it('enables the dropdown', () => {
+ expect(findDisclosure().props('disabled')).toBe(false);
+ });
+ });
+ });
+
+ describe('table headers', () => {
+ it('usage header should contain a link and an assistive message', () => {
+ createComponent();
+
+ const header = wrapper.findByTestId('header-usage');
+ const anchor = header.find('a');
+ const assistiveElement = header.find('.gl-sr-only');
+ expect(anchor.attributes('href')).toBe(
+ '/help/user/profile/personal_access_tokens.md#view-token-usage-information',
+ );
+ expect(assistiveElement.text()).toBe('View token usage information');
+ });
+ });
+
+ describe('table cells', () => {
+ describe('name cell', () => {
+ it('shows the name of the token in bold and description', () => {
+ createComponent();
+
+ const field = wrapper.findByTestId('field-name');
+ expect(field.text()).toBe('My name ');
+ expect(field.classes()).toContain('gl-font-bold');
+ });
+
+ it('shows description', () => {
+ const tokens = [{ ...defaultToken, description: 'My description' }];
+ createComponent({ tokens });
+
+ const field = wrapper.findByTestId('field-description');
+ expect(field.text()).toBe('My description');
+ });
+ });
+
+ describe('status cell', () => {
+ describe('when token is active', () => {
+ it('shows an active status badge', () => {
+ createComponent();
+
+ const badge = findBadge();
+ expect(badge.props()).toMatchObject({
+ variant: 'success',
+ icon: 'check-circle',
+ });
+ expect(badge.text()).toBe('Active');
+ });
+ });
+
+ describe('when token is expiring', () => {
+ it('shows an expiring status badge', () => {
+ const tokens = [
+ { ...defaultToken, expiresAt: new Date(Date.now() + 10 * 366000).toString() },
+ ];
+ createComponent({ tokens });
+
+ const badge = findBadge();
+ expect(badge.props()).toMatchObject({
+ variant: 'warning',
+ icon: 'expire',
+ });
+ expect(badge.text()).toBe('Expiring');
+ expect(badge.attributes('title')).toBe('Token expires in less than two weeks.');
+ });
+ });
+
+ describe('when token is revoked', () => {
+ it('shows a revoked status badge', () => {
+ const tokens = [{ ...defaultToken, active: false, revoked: true }];
+ createComponent({ tokens });
+
+ const badge = findBadge();
+ expect(badge.props()).toMatchObject({
+ variant: 'neutral',
+ icon: 'remove',
+ });
+ expect(badge.text()).toBe('Revoked');
+ });
+ });
+
+ describe('when token is expired', () => {
+ it('shows an expired status badge', () => {
+ const tokens = [{ ...defaultToken, active: false, revoked: false }];
+ createComponent({ tokens });
+
+ const badge = findBadge();
+ expect(badge.props()).toMatchObject({
+ variant: 'neutral',
+ icon: 'time-out',
+ });
+ expect(badge.text()).toBe('Expired');
+ });
+ });
+ });
+
+ describe('scopes cell', () => {
+ describe('when it is empty', () => {
+ it('shows a hyphen', () => {
+ createComponent();
+
+ expect(wrapper.findByTestId('cell-scopes').text()).toBe('-');
+ });
+ });
+
+ describe('when it is non-empty', () => {
+ it('shows a comma-limited list of scopes', () => {
+ const tokens = [{ ...defaultToken, scopes: ['api', 'sudo'] }];
+ createComponent({ tokens });
+
+ expect(wrapper.findByTestId('cell-scopes').text()).toBe('api, sudo');
+ });
+ });
+ });
+
+ describe('usage cell', () => {
+ describe('last used field', () => {
+ describe('when it is empty', () => {
+ it('shows "Never"', () => {
+ createComponent();
+
+ expect(wrapper.findByTestId('field-last-used').text()).toBe('Last used: Never');
+ });
+ });
+
+ describe('when it is non-empty', () => {
+ it('shows a relative date', () => {
+ const tokens = [{ ...defaultToken, lastUsedAt: '2020-01-01T00:00:00.000Z' }];
+ createComponent({ tokens });
+
+ expect(wrapper.findByTestId('field-last-used').text()).toBe('Last used: 6 months ago');
+ });
+ });
+ });
+
+ describe('last used IPs field', () => {
+ describe('when it is empty', () => {
+ it('hides field', () => {
+ createComponent();
+
+ expect(wrapper.findByTestId('field-last-used-ips').exists()).toBe(false);
+ });
+ });
+
+ describe('when it is non-empty', () => {
+ it('shows a single IP', () => {
+ const tokens = [{ ...defaultToken, lastUsedIps: ['192.0.2.1'] }];
+ createComponent({ tokens });
+
+ expect(wrapper.findByTestId('field-last-used-ips').text()).toBe('IP: 192.0.2.1');
+ });
+
+ it('shows a several IPs', () => {
+ const tokens = [{ ...defaultToken, lastUsedIps: ['192.0.2.1', '192.0.2.2'] }];
+ createComponent({ tokens });
+
+ expect(wrapper.findByTestId('field-last-used-ips').text()).toBe(
+ 'IPs: 192.0.2.1, 192.0.2.2',
+ );
+ });
+ });
+ });
+ });
+
+ describe('lifetime cell', () => {
+ describe('expires field', () => {
+ describe('when it is empty', () => {
+ it('shows "Never until revoked"', () => {
+ createComponent();
+
+ const field = wrapper.findByTestId('field-expires');
+ const icon = findIcon(field);
+ expect(icon.props('name')).toBe('time-out');
+ expect(icon.attributes('title')).toBe('Expires');
+ expect(field.text()).toBe('Never until revoked');
+ });
+ });
+
+ describe('when it is non-empty', () => {
+ it('shows a relative date', () => {
+ const tokens = [{ ...defaultToken, expiresAt: '2021-01-01T00:00:00.000Z' }];
+ createComponent({ tokens });
+
+ const field = wrapper.findByTestId('field-expires');
+ const icon = findIcon(field);
+ expect(icon.props('name')).toBe('time-out');
+ expect(icon.attributes('title')).toBe('Expires');
+ expect(field.text()).toBe('in 5 months');
+ });
+ });
+ });
+ describe('created field', () => {
+ describe('when it is non-empty', () => {
+ it('shows a date', () => {
+ const tokens = [{ ...defaultToken, createdAt: '2020-01-01T00:00:00.000Z' }];
+ createComponent({ tokens });
+
+ const field = wrapper.findByTestId('field-created');
+ const icon = findIcon(field);
+ expect(icon.props('name')).toBe('clock');
+ expect(icon.attributes('title')).toBe('Created');
+ expect(field.text()).toBe('Jan 01, 2020');
+ });
+ });
+ });
+ });
+
+ describe('options cell', () => {
+ describe('when token is active', () => {
+ it('shows the dropdown', () => {
+ createComponent();
+
+ expect(findDisclosure().exists()).toBe(true);
+ });
+ });
+
+ describe('when token is inactive', () => {
+ it('hides the dropdown', () => {
+ const tokens = [{ ...defaultToken, active: false }];
+ createComponent({ tokens });
+
+ expect(findDisclosure().exists()).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe('when revoking a token', () => {
+ it('makes the modal to appear with correct text', async () => {
+ createComponent();
+ const modal = findModal();
+ expect(modal.props('visible')).toBe(false);
+ await findDisclosureButton(1).trigger('click');
+
+ expect(modal.props()).toMatchObject({
+ visible: true,
+ title: "Revoke the token 'My name '?",
+ actionPrimary: {
+ text: 'Revoke',
+ attributes: { variant: 'danger' },
+ },
+ actionCancel: {
+ text: 'Cancel',
+ },
+ });
+ expect(modal.text()).toBe(
+ 'Are you sure you want to revoke the token My name ? This action cannot be undone. Any tools that rely on this access token will stop working.',
+ );
+ });
+
+ it('confirming the primary action calls the revokeToken method in the store', async () => {
+ createComponent();
+ findDisclosureButton(1).trigger('click');
+ await findModal().vm.$emit('primary');
+
+ expect(store.revokeToken).toHaveBeenCalledWith(1);
+ });
+ });
+
+ describe('when rotating a token', () => {
+ it('makes the modal to appear with correct text', async () => {
+ createComponent();
+ const modal = findModal();
+ expect(modal.props('visible')).toBe(false);
+ await findDisclosureButton(0).trigger('click');
+
+ expect(modal.props()).toMatchObject({
+ visible: true,
+ title: "Rotate the token 'My name '?",
+ actionPrimary: {
+ text: 'Rotate',
+ attributes: { variant: 'danger' },
+ },
+ actionCancel: {
+ text: 'Cancel',
+ },
+ });
+ expect(modal.text()).toBe(
+ 'Are you sure you want to rotate the token My name ? This action cannot be undone. Any tools that rely on this access token will stop working.',
+ );
+ });
+
+ it('confirming the primary action calls the rotateToken method in the store', async () => {
+ const tokens = [{ ...defaultToken, expiresAt: '2025-01-01' }];
+ createComponent({ tokens });
+ findDisclosureButton(0).trigger('click');
+ await findModal().vm.$emit('primary');
+
+ expect(store.rotateToken).toHaveBeenCalledWith(1, '2025-01-01');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/access_tokens/components/access_tokens_spec.js b/spec/frontend/vue_shared/access_tokens/components/access_tokens_spec.js
new file mode 100644
index 00000000000..d510bf69b50
--- /dev/null
+++ b/spec/frontend/vue_shared/access_tokens/components/access_tokens_spec.js
@@ -0,0 +1,117 @@
+import { GlFilteredSearch, GlPagination, GlSorting } from '@gitlab/ui';
+import { createTestingPinia } from '@pinia/testing';
+import Vue, { nextTick } from 'vue';
+import { PiniaVuePlugin } from 'pinia';
+import AccessTokens from '~/vue_shared/access_tokens/components/access_tokens.vue';
+import AccessTokenForm from '~/vue_shared/access_tokens/components/access_token_form.vue';
+import { useAccessTokens } from '~/vue_shared/access_tokens/stores/access_tokens';
+import waitForPromises from 'helpers/wait_for_promises';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
+Vue.use(PiniaVuePlugin);
+
+describe('AccessTokens', () => {
+ let wrapper;
+
+ const pinia = createTestingPinia();
+ const store = useAccessTokens();
+
+ const accessTokenCreate = '/api/v4/groups/1/service_accounts/:id/personal_access_tokens/';
+ const accessTokenRevoke = '/api/v4/groups/2/service_accounts/:id/personal_access_tokens/';
+ const accessTokenRotate = '/api/v4/groups/3/service_accounts/:id/personal_access_tokens/';
+ const accessTokenShow = '/api/v4/personal_access_tokens';
+ const id = 235;
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(AccessTokens, {
+ pinia,
+ provide: {
+ accessTokenCreate,
+ accessTokenRevoke,
+ accessTokenRotate,
+ accessTokenShow,
+ },
+ propsData: {
+ id,
+ },
+ });
+ };
+
+ const findCreateTokenButton = () => wrapper.findByTestId('add-new-token-button');
+ const findCreateTokenForm = () => wrapper.findComponent(AccessTokenForm);
+ const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch);
+ const findPagination = () => wrapper.findComponent(GlPagination);
+ const findSorting = () => wrapper.findComponent(GlSorting);
+
+ it('fetches tokens when it is rendered', () => {
+ createComponent();
+ waitForPromises();
+
+ expect(store.setup).toHaveBeenCalledWith({
+ filters: [{ type: 'state', value: { data: 'active', operator: '=' } }],
+ id: 235,
+ urlCreate: '/api/v4/groups/1/service_accounts/:id/personal_access_tokens/',
+ urlRevoke: '/api/v4/groups/2/service_accounts/:id/personal_access_tokens/',
+ urlRotate: '/api/v4/groups/3/service_accounts/:id/personal_access_tokens/',
+ urlShow: '/api/v4/personal_access_tokens',
+ });
+ expect(store.fetchTokens).toHaveBeenCalledTimes(1);
+ });
+
+ describe('when clicking on the add new token button', () => {
+ it('clears the current token', () => {
+ createComponent();
+ expect(store.setToken).toHaveBeenCalledTimes(0);
+ findCreateTokenButton().vm.$emit('click');
+
+ expect(store.setToken).toHaveBeenCalledWith(null);
+ });
+
+ it('shows the token creation form', async () => {
+ createComponent();
+ expect(findCreateTokenForm().exists()).toBe(false);
+ findCreateTokenButton().vm.$emit('click');
+
+ expect(store.setShowCreateForm).toHaveBeenCalledWith(true);
+ store.showCreateForm = true;
+ await nextTick();
+
+ expect(findCreateTokenForm().exists()).toBe(true);
+ });
+ });
+
+ it('fetches tokens when the page is changed', () => {
+ createComponent();
+ expect(store.fetchTokens).toHaveBeenCalledTimes(1);
+ findPagination().vm.$emit('input', 2);
+
+ expect(store.fetchTokens).toHaveBeenCalledTimes(2);
+ });
+
+ it('fetches tokens when filters are changed', () => {
+ createComponent();
+ expect(store.fetchTokens).toHaveBeenCalledTimes(1);
+ findFilteredSearch().vm.$emit('submit', ['my token']);
+
+ expect(store.fetchTokens).toHaveBeenCalledTimes(2);
+ });
+
+ it('sets the sorting and fetches tokens when sorting option is changed', () => {
+ createComponent();
+ expect(store.fetchTokens).toHaveBeenCalledTimes(1);
+ findSorting().vm.$emit('sortByChange', 'name');
+
+ expect(store.setSorting).toHaveBeenCalledWith(expect.objectContaining({ value: 'name' }));
+ expect(store.fetchTokens).toHaveBeenCalledTimes(2);
+ });
+
+ it('sets the sorting and fetches tokens when sorting direction is changed', () => {
+ createComponent();
+ expect(store.fetchTokens).toHaveBeenCalledTimes(1);
+ store.sorting = { value: 'name', isAsc: true };
+ findSorting().vm.$emit('sortDirectionChange', false);
+
+ expect(store.setSorting).toHaveBeenCalledWith(expect.objectContaining({ isAsc: false }));
+ expect(store.fetchTokens).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/spec/frontend/vue_shared/access_tokens/stores/access_tokens_spec.js b/spec/frontend/vue_shared/access_tokens/stores/access_tokens_spec.js
new file mode 100644
index 00000000000..cfcfff742e3
--- /dev/null
+++ b/spec/frontend/vue_shared/access_tokens/stores/access_tokens_spec.js
@@ -0,0 +1,581 @@
+import MockAdapter from 'axios-mock-adapter';
+import { setActivePinia, createPinia } from 'pinia';
+import { useAccessTokens } from '~/vue_shared/access_tokens/stores/access_tokens';
+import { update2WeekFromNow } from '~/vue_shared/access_tokens/utils';
+import { createAlert } from '~/alert';
+import { smoothScrollTop } from '~/behaviors/smooth_scroll';
+import axios from '~/lib/utils/axios_utils';
+import {
+ HTTP_STATUS_NO_CONTENT,
+ HTTP_STATUS_OK,
+ HTTP_STATUS_INTERNAL_SERVER_ERROR,
+} from '~/lib/utils/http_status';
+import { DEFAULT_SORT } from '~/access_tokens/constants';
+
+const mockAlertDismiss = jest.fn();
+jest.mock('~/alert', () => ({
+ createAlert: jest.fn().mockImplementation(() => ({
+ dismiss: mockAlertDismiss,
+ })),
+}));
+
+jest.mock('~/vue_shared/access_tokens/utils', () => ({
+ ...jest.requireActual('~/vue_shared/access_tokens/utils'),
+ update2WeekFromNow: jest.fn(),
+}));
+
+jest.mock('~/behaviors/smooth_scroll');
+
+describe('useAccessTokens store', () => {
+ let store;
+
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ store = useAccessTokens();
+ });
+
+ describe('initial state', () => {
+ it('has an empty list of access tokens', () => {
+ expect(store.alert).toBe(null);
+ expect(store.busy).toBe(false);
+ expect(store.filters).toEqual([]);
+ expect(store.id).toBe(null);
+ expect(store.page).toBe(1);
+ expect(store.perPage).toBe(null);
+ expect(store.showCreateForm).toBe(false);
+ expect(store.token).toEqual(null);
+ expect(store.tokens).toEqual([]);
+ expect(store.total).toBe(0);
+ expect(store.urlCreate).toBe('');
+ expect(store.urlRevoke).toBe('');
+ expect(store.urlRotate).toBe('');
+ expect(store.urlShow).toBe('');
+ expect(store.sorting).toEqual(DEFAULT_SORT);
+ expect(store.statistics).toEqual([]);
+ });
+ });
+
+ describe('actions', () => {
+ const mockAxios = new MockAdapter(axios);
+ const filters = ['dummy'];
+ const id = 235;
+ const urlCreate = '/api/v4/groups/1/service_accounts/:id/personal_access_tokens';
+ const urlRevoke = '/api/v4/groups/2/service_accounts/:id/personal_access_tokens';
+ const urlRotate = '/api/v4/groups/3/service_accounts/:id/personal_access_tokens';
+ const urlShow = '/api/v4/groups/4/service_accounts/:id/personal_access_token';
+
+ const headers = {
+ 'X-Page': 1,
+ 'X-Per-Page': 20,
+ 'X-Total': 1,
+ };
+
+ beforeEach(() => {
+ mockAxios.reset();
+ });
+
+ describe('createToken', () => {
+ const name = 'dummy-name';
+ const description = 'dummy-description';
+ const expiresAt = '2020-01-01';
+ const scopes = ['dummy-scope'];
+
+ beforeEach(() => {
+ store.setup({ id, filters, urlCreate, urlShow });
+ });
+
+ it('dismisses any existing alert', () => {
+ store.alert = createAlert({ message: 'dummy' });
+ store.fetchTokens();
+
+ expect(mockAlertDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it('sets busy to true when revoking', () => {
+ store.createToken({ name, description, expiresAt, scopes });
+
+ expect(store.busy).toBe(true);
+ });
+
+ it('creates the token', async () => {
+ await store.createToken({ name, description, expiresAt, scopes });
+
+ expect(mockAxios.history.post).toHaveLength(1);
+ expect(mockAxios.history.post[0]).toEqual(
+ expect.objectContaining({
+ data: '{"name":"dummy-name","description":"dummy-description","expires_at":"2020-01-01","scopes":["dummy-scope"]}',
+ url: '/api/v4/groups/1/service_accounts/235/personal_access_tokens',
+ }),
+ );
+ });
+
+ it('hides the token creation form', async () => {
+ mockAxios.onPost().replyOnce(HTTP_STATUS_OK, { token: 'new-token' });
+ store.showCreateForm = true;
+ await store.createToken({ name, description, expiresAt, scopes });
+
+ expect(store.showCreateForm).toBe(false);
+ });
+
+ it('scrolls to the top', async () => {
+ mockAxios.onPost().replyOnce(HTTP_STATUS_OK, { token: 'new-token' });
+ await store.createToken({ name, description, expiresAt, scopes });
+
+ expect(smoothScrollTop).toHaveBeenCalledTimes(1);
+ });
+
+ it('updates tokens and sets busy to false after fetching', async () => {
+ mockAxios.onPost().replyOnce(HTTP_STATUS_OK, { token: 'new-token' });
+ mockAxios.onGet().replyOnce(HTTP_STATUS_OK, [{ active: true, name: 'Token' }], headers);
+ await store.createToken({ name, description, expiresAt, scopes });
+
+ expect(store.tokens).toHaveLength(1);
+ expect(store.busy).toBe(false);
+ });
+
+ it('shows an alert if an error occurs while revoking', async () => {
+ mockAxios.onPost().replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+
+ await store.createToken({ name, description, expiresAt, scopes });
+
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'An error occurred while creating the token.',
+ });
+ expect(store.busy).toBe(false);
+ });
+
+ it('shows an alert if an error occurs while fetching', async () => {
+ mockAxios.onPost().replyOnce(HTTP_STATUS_OK, { token: 'new-token' });
+ mockAxios.onGet().replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ await store.createToken({ name, description, expiresAt, scopes });
+
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'An error occurred while fetching the tokens.',
+ });
+ expect(store.busy).toBe(false);
+ });
+
+ it('uses correct params in the fetch', async () => {
+ mockAxios.onPost().replyOnce(HTTP_STATUS_OK, { token: 'new-token' });
+ mockAxios.onGet().replyOnce(HTTP_STATUS_OK, [{ active: true, name: 'Token' }], headers);
+ store.setPage(2);
+ store.setFilters(['my token']);
+ await store.createToken({ name, description, expiresAt, scopes });
+
+ expect(mockAxios.history.get).toHaveLength(1);
+ expect(mockAxios.history.get[0]).toEqual(
+ expect.objectContaining({
+ params: {
+ page: 1,
+ sort: 'expires_asc',
+ search: 'my token',
+ user_id: 235,
+ },
+ }),
+ );
+ });
+ });
+
+ describe('fetchStatistics', () => {
+ const title = 'Active tokens';
+ const tooltipTitle = 'Filter for active tokens';
+ beforeEach(() => {
+ store.setup({ id, filters, urlShow });
+ update2WeekFromNow.mockReturnValueOnce([{ title, tooltipTitle, filters }]);
+ });
+
+ it('uses correct params in the fetch', async () => {
+ await store.fetchStatistics();
+
+ expect(mockAxios.history.get).toHaveLength(1);
+ expect(mockAxios.history.get[0]).toEqual(
+ expect.objectContaining({
+ url: '/api/v4/groups/4/service_accounts/235/personal_access_token',
+ params: {
+ page: 1,
+ sort: 'expires_asc',
+ search: 'dummy',
+ user_id: 235,
+ },
+ }),
+ );
+ });
+
+ it('fetches all statistics successfully and updates the store', async () => {
+ mockAxios.onGet().replyOnce(HTTP_STATUS_OK, [], headers);
+ await store.fetchStatistics();
+
+ expect(store.statistics).toMatchObject([{ title, tooltipTitle, filters, value: 1 }]);
+ });
+
+ it('shows an alert if an error occurs while fetching', async () => {
+ mockAxios.onGet().replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ await store.fetchStatistics();
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'Failed to fetch statistics.',
+ });
+ });
+
+ it('does not show an alert if an error is still on view', async () => {
+ store.alert = 'dummy';
+ mockAxios.onGet().replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ await store.fetchStatistics();
+
+ expect(createAlert).toHaveBeenCalledTimes(0);
+ });
+ });
+
+ describe('fetchTokens', () => {
+ beforeEach(() => {
+ store.setup({ id, filters, urlShow });
+ });
+
+ it('sets busy to true when fetching', () => {
+ store.fetchTokens();
+
+ expect(store.busy).toBe(true);
+ });
+
+ it('dismisses any existing alert by default', () => {
+ store.alert = createAlert({ message: 'dummy' });
+ store.fetchTokens();
+
+ expect(mockAlertDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not dismiss existing alert if clearAlert is false', () => {
+ store.alert = createAlert({ message: 'dummy' });
+ store.fetchTokens({ clearAlert: false });
+
+ expect(mockAlertDismiss).toHaveBeenCalledTimes(0);
+ });
+
+ it('updates tokens and sets busy to false after fetching', async () => {
+ mockAxios.onGet().replyOnce(HTTP_STATUS_OK, [{ active: true, name: 'Token' }], headers);
+ await store.fetchTokens();
+
+ expect(store.tokens).toHaveLength(1);
+ expect(store.busy).toBe(false);
+ });
+
+ it('shows an alert if an error occurs while fetching', async () => {
+ mockAxios.onGet().replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ await store.fetchTokens();
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'An error occurred while fetching the tokens.',
+ });
+ expect(store.busy).toBe(false);
+ });
+
+ it('uses correct params in the fetch', async () => {
+ store.setFilters([
+ 'my token',
+ {
+ type: 'state',
+ value: { data: 'inactive', operator: '=' },
+ },
+ ]);
+ await store.fetchTokens();
+
+ expect(mockAxios.history.get).toHaveLength(1);
+ expect(mockAxios.history.get[0]).toEqual(
+ expect.objectContaining({
+ url: '/api/v4/groups/4/service_accounts/235/personal_access_token',
+ params: {
+ page: 1,
+ sort: 'expires_asc',
+ state: 'inactive',
+ search: 'my token',
+ user_id: 235,
+ },
+ }),
+ );
+ });
+ });
+
+ describe('revokeToken', () => {
+ beforeEach(() => {
+ store.setup({ id, filters, urlRevoke, urlShow });
+ });
+
+ it('sets busy to true when revoking', () => {
+ store.revokeToken(1);
+
+ expect(store.busy).toBe(true);
+ });
+
+ it('hides the token creation form', () => {
+ store.showCreateForm = true;
+ store.revokeToken(1);
+
+ expect(store.showCreateForm).toBe(false);
+ });
+
+ it('dismisses any existing alert', () => {
+ store.alert = createAlert({ message: 'dummy' });
+ store.fetchTokens();
+
+ expect(mockAlertDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it('revokes the token', async () => {
+ await store.revokeToken(1);
+
+ expect(mockAxios.history.delete).toHaveLength(1);
+ expect(mockAxios.history.delete[0]).toEqual(
+ expect.objectContaining({
+ url: '/api/v4/groups/2/service_accounts/235/personal_access_tokens/1',
+ }),
+ );
+ });
+
+ it('scrolls to the top', async () => {
+ mockAxios.onDelete().replyOnce(HTTP_STATUS_NO_CONTENT);
+ await store.revokeToken(1);
+
+ expect(smoothScrollTop).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows an alert after successful token revocation', async () => {
+ mockAxios.onDelete().replyOnce(HTTP_STATUS_NO_CONTENT);
+ mockAxios.onGet().replyOnce(HTTP_STATUS_OK);
+ await store.revokeToken(1);
+
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'The token was revoked successfully.',
+ variant: 'success',
+ });
+ });
+
+ it('updates tokens and sets busy to false after fetching', async () => {
+ mockAxios.onDelete().replyOnce(HTTP_STATUS_NO_CONTENT);
+ mockAxios.onGet().replyOnce(HTTP_STATUS_OK, [{ active: true, name: 'Token' }], headers);
+ await store.revokeToken(1);
+
+ expect(store.tokens).toHaveLength(1);
+ expect(store.busy).toBe(false);
+ });
+
+ it('shows an alert if an error occurs while revoking', async () => {
+ mockAxios.onDelete().replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ await store.revokeToken(1);
+
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'An error occurred while revoking the token.',
+ });
+ expect(store.busy).toBe(false);
+ });
+
+ it('shows an alert if an error occurs while fetching', async () => {
+ mockAxios.onDelete().replyOnce(HTTP_STATUS_NO_CONTENT);
+ mockAxios.onGet().replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ await store.revokeToken(1);
+
+ expect(createAlert).toHaveBeenCalledTimes(2);
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'The token was revoked successfully.',
+ variant: 'success',
+ });
+ // This alert hides the one above.
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'An error occurred while fetching the tokens.',
+ });
+ expect(store.busy).toBe(false);
+ });
+
+ it('uses correct params in the fetch', async () => {
+ mockAxios.onDelete().replyOnce(HTTP_STATUS_NO_CONTENT);
+ mockAxios.onGet().replyOnce(HTTP_STATUS_OK, [{ active: true, name: 'Token' }], headers);
+ store.setPage(2);
+ store.setFilters(['my token']);
+ await store.revokeToken(1);
+
+ expect(mockAxios.history.get).toHaveLength(1);
+ expect(mockAxios.history.get[0]).toEqual(
+ expect.objectContaining({
+ params: {
+ page: 1,
+ sort: 'expires_asc',
+ search: 'my token',
+ user_id: 235,
+ },
+ }),
+ );
+ });
+ });
+
+ describe('rotateToken', () => {
+ beforeEach(() => {
+ store.setup({ id, filters, urlRotate, urlShow });
+ });
+
+ it('sets busy to true when rotating', () => {
+ store.rotateToken(1, '2025-01-01');
+
+ expect(store.busy).toBe(true);
+ });
+
+ it('hides the token creation form', () => {
+ store.showCreateForm = true;
+ store.rotateToken(1, '2025-01-01');
+
+ expect(store.showCreateForm).toBe(false);
+ });
+
+ it('dismisses any existing alert', () => {
+ store.alert = createAlert({ message: 'dummy' });
+ store.fetchTokens();
+
+ expect(mockAlertDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it('rotates the token', async () => {
+ await store.rotateToken(1, '2025-01-01');
+
+ expect(mockAxios.history.post).toHaveLength(1);
+ expect(mockAxios.history.post[0]).toEqual(
+ expect.objectContaining({
+ data: '{"expires_at":"2025-01-01"}',
+ url: '/api/v4/groups/3/service_accounts/235/personal_access_tokens/1/rotate',
+ }),
+ );
+ });
+
+ it('scrolls to the top', async () => {
+ mockAxios.onPost().replyOnce(HTTP_STATUS_OK, { token: 'new-token' });
+ await store.rotateToken(1, '2025-01-01');
+
+ expect(smoothScrollTop).toHaveBeenCalledTimes(1);
+ });
+
+ it('updates tokens and sets busy to false after fetching', async () => {
+ mockAxios.onPost().replyOnce(HTTP_STATUS_OK, { token: 'new-token' });
+ mockAxios.onGet().replyOnce(HTTP_STATUS_OK, [{ active: true, name: 'Token' }], headers);
+ await store.rotateToken(1, '2025-01-01');
+
+ expect(store.tokens).toHaveLength(1);
+ expect(store.busy).toBe(false);
+ });
+
+ it('shows an alert if an error occurs while rotating', async () => {
+ mockAxios.onPost().replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ await store.rotateToken(1, '2025-01-01');
+
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'An error occurred while rotating the token.',
+ });
+ expect(store.busy).toBe(false);
+ });
+
+ it('shows an alert if an error occurs while fetching', async () => {
+ mockAxios.onPost().replyOnce(HTTP_STATUS_OK, { token: 'new-token' });
+ mockAxios.onGet().replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ await store.rotateToken(1, '2025-01-01');
+
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'An error occurred while fetching the tokens.',
+ });
+ expect(store.busy).toBe(false);
+ });
+
+ it('uses correct params in the fetch', async () => {
+ mockAxios.onPost().replyOnce(HTTP_STATUS_OK, { token: 'new-token' });
+ mockAxios.onGet().replyOnce(HTTP_STATUS_OK, [{ active: true, name: 'Token' }], headers);
+ store.setPage(2);
+ store.setFilters(['my token']);
+ await store.rotateToken(1, '2025-01-01');
+
+ expect(mockAxios.history.get).toHaveLength(1);
+ expect(mockAxios.history.get[0]).toEqual(
+ expect.objectContaining({
+ params: {
+ page: 1,
+ sort: 'expires_asc',
+ search: 'my token',
+ user_id: 235,
+ },
+ }),
+ );
+ });
+ });
+
+ describe('setFilters', () => {
+ it('sets the filters', () => {
+ store.setFilters(['my token']);
+
+ expect(store.filters).toEqual(['my token']);
+ });
+ });
+
+ describe('setPage', () => {
+ it('sets the page', () => {
+ store.setPage(2);
+
+ expect(store.page).toBe(2);
+ });
+
+ it('scrolls to the top', () => {
+ store.setPage(2);
+
+ expect(smoothScrollTop).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('setShowCreateForm', () => {
+ it('sets the value', () => {
+ store.setShowCreateForm(true);
+
+ expect(store.showCreateForm).toBe(true);
+ });
+ });
+
+ describe('setToken', () => {
+ it('sets the token', () => {
+ store.setToken('new-token');
+
+ expect(store.token).toBe('new-token');
+ });
+ });
+
+ describe('setSorting', () => {
+ it('sets the sorting', () => {
+ store.setSorting({ isAsc: false, value: 'name' });
+
+ expect(store.sorting).toEqual({ isAsc: false, value: 'name' });
+ });
+ });
+
+ describe('setup', () => {
+ it('sets up the store', () => {
+ store.setup({ filters, id, urlCreate, urlRevoke, urlRotate, urlShow });
+
+ expect(store.filters).toEqual(filters);
+ expect(store.id).toBe(id);
+ expect(store.urlCreate).toBe(urlCreate);
+ expect(store.urlRevoke).toBe(urlRevoke);
+ expect(store.urlRotate).toBe(urlRotate);
+ expect(store.urlShow).toBe(urlShow);
+ });
+ });
+ });
+
+ describe('getters', () => {
+ describe('sort', () => {
+ it('returns correct value', () => {
+ expect(store.sort).toBe('expires_asc');
+
+ store.sorting = { value: 'name', isAsc: false };
+
+ expect(store.sort).toBe('name_desc');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/access_tokens/utils_spec.js b/spec/frontend/vue_shared/access_tokens/utils_spec.js
new file mode 100644
index 00000000000..6b4f73919a2
--- /dev/null
+++ b/spec/frontend/vue_shared/access_tokens/utils_spec.js
@@ -0,0 +1,88 @@
+import { defaultDate, serializeParams, update2WeekFromNow } from '~/vue_shared/access_tokens/utils';
+
+// Current date, `new Date()`, for these tests is 2020-07-06
+describe('defaultDate', () => {
+ describe('when max date is not present', () => {
+ it('defaults to 30 days from now', () => {
+ expect(defaultDate().getTime()).toBe(new Date('2020-08-05').getTime());
+ });
+ });
+
+ describe('when max date is present', () => {
+ it('defaults to 30 days from now if max date is later', () => {
+ const maxDate = new Date('2021-01-01');
+ expect(defaultDate(maxDate).getTime()).toBe(new Date('2020-08-05').getTime());
+ });
+
+ it('defaults max date if max date is sooner than 30 days', () => {
+ const maxDate = new Date('2020-08-01');
+ expect(defaultDate(maxDate).getTime()).toBe(new Date('2020-08-01').getTime());
+ });
+ });
+});
+
+describe('serializeParams', () => {
+ it('returns correct params for the fetch', () => {
+ expect(
+ serializeParams(
+ [
+ 'my token',
+ {
+ type: 'created',
+ value: { data: '2025-01-01', operator: '<' },
+ },
+ {
+ type: 'expires',
+ value: { data: '2025-01-02', operator: '<' },
+ },
+ {
+ type: 'last_used',
+ value: { data: '2025-01-03', operator: '≥' },
+ },
+ {
+ type: 'state',
+ value: { data: 'inactive', operator: '=' },
+ },
+ ],
+ 2,
+ ),
+ ).toMatchObject({
+ created_before: '2025-01-01',
+ expires_before: '2025-01-02',
+ last_used_after: '2025-01-03',
+ page: 2,
+ search: 'my token',
+ state: 'inactive',
+ });
+ });
+});
+
+describe('update2WeekFromNow', () => {
+ const param = [
+ {
+ title: 'dummy',
+ tooltipTitle: 'dummy',
+ filters: [{ type: 'dummy', value: { data: 'DATE_HOLDER', operator: 'dummy' } }],
+ },
+ ];
+
+ it('replace `DATE_HOLDER` with date 2 weeks from now', () => {
+ expect(update2WeekFromNow(param)).toMatchObject([
+ {
+ title: 'dummy',
+ tooltipTitle: 'dummy',
+ filters: [{ type: 'dummy', value: { data: '2020-07-20', operator: 'dummy' } }],
+ },
+ ]);
+ });
+
+ it('use default parameter', () => {
+ expect(update2WeekFromNow()).toBeDefined();
+ });
+
+ it('returns a clone of the original parameter', () => {
+ const result = update2WeekFromNow(param);
+ expect(result).not.toBe(param);
+ expect(result[0].filters).not.toBe(param[0].filters);
+ });
+});
diff --git a/spec/frontend/vue_shared/alert_details/sidebar_todo_spec.js b/spec/frontend/vue_shared/alert_details/sidebar_todo_spec.js
index 5db39e13968..5be1fcd8954 100644
--- a/spec/frontend/vue_shared/alert_details/sidebar_todo_spec.js
+++ b/spec/frontend/vue_shared/alert_details/sidebar_todo_spec.js
@@ -1,6 +1,6 @@
-import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql';
import SidebarTodo from '~/vue_shared/alert_details/components/sidebar/sidebar_todo.vue';
@@ -60,13 +60,13 @@ describe('Alert Details Sidebar To Do', () => {
},
});
- wrapper = mount(SidebarTodo, {
+ wrapper = mountExtended(SidebarTodo, {
apolloProvider: fakeApollo,
propsData,
});
}
- const findToDoButton = () => wrapper.find('[data-testid="alert-todo-button"]');
+ const findToDoButton = () => wrapper.findByTestId('alert-todo-button');
describe('updating the alert to do', () => {
describe('adding a todo', () => {
diff --git a/spec/frontend/vue_shared/components/confidentiality_badge_spec.js b/spec/frontend/vue_shared/components/confidentiality_badge_spec.js
index 8b25d4ee38e..9fc62447ea7 100644
--- a/spec/frontend/vue_shared/components/confidentiality_badge_spec.js
+++ b/spec/frontend/vue_shared/components/confidentiality_badge_spec.js
@@ -1,6 +1,5 @@
import { GlBadge, GlIcon } from '@gitlab/ui';
-
-import { shallowMount } from '@vue/test-utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { TYPE_ISSUE, TYPE_EPIC, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
@@ -10,7 +9,7 @@ const createComponent = ({
issuableType = TYPE_ISSUE,
hideTextInSmallScreens = false,
} = {}) =>
- shallowMount(ConfidentialityBadge, {
+ shallowMountExtended(ConfidentialityBadge, {
propsData: {
workspaceType,
issuableType,
@@ -25,8 +24,7 @@ describe('ConfidentialityBadge', () => {
wrapper = createComponent();
});
- const findConfidentialityBadgeText = () =>
- wrapper.find('[data-testid="confidential-badge-text"]');
+ const findConfidentialityBadgeText = () => wrapper.findByTestId('confidential-badge-text');
const findBadge = () => wrapper.findComponent(GlBadge);
const findBadgeIcon = () => wrapper.findComponent(GlIcon);
diff --git a/spec/frontend/vue_shared/components/dismissible_container_spec.js b/spec/frontend/vue_shared/components/dismissible_container_spec.js
index 6d179434d1d..e102676e192 100644
--- a/spec/frontend/vue_shared/components/dismissible_container_spec.js
+++ b/spec/frontend/vue_shared/components/dismissible_container_spec.js
@@ -1,23 +1,31 @@
-import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
-import dismissibleContainer from '~/vue_shared/components/dismissible_container.vue';
+import DismissibleContainer from '~/vue_shared/components/dismissible_container.vue';
describe('DismissibleContainer', () => {
let wrapper;
- const propsData = {
+
+ const defaultProps = {
path: 'some/path',
featureId: 'some-feature-id',
};
+ const createComponent = ({ slots = {} } = {}) => {
+ wrapper = shallowMountExtended(DismissibleContainer, {
+ propsData: { ...defaultProps },
+ slots,
+ });
+ };
+
describe('template', () => {
- const findBtn = () => wrapper.find('[data-testid="close"]');
+ const findBtn = () => wrapper.findByTestId('close');
let mockAxios;
beforeEach(() => {
mockAxios = new MockAdapter(axios);
- wrapper = shallowMount(dismissibleContainer, { propsData });
+ createComponent();
});
afterEach(() => {
@@ -25,7 +33,7 @@ describe('DismissibleContainer', () => {
});
it('successfully dismisses', () => {
- mockAxios.onPost(propsData.path).replyOnce(HTTP_STATUS_OK);
+ mockAxios.onPost(defaultProps.path).replyOnce(HTTP_STATUS_OK);
const button = findBtn();
button.trigger('click');
@@ -42,8 +50,7 @@ describe('DismissibleContainer', () => {
it.each(Object.keys(slots))('renders the %s slot', (slot) => {
const slotContent = slots[slot];
- wrapper = shallowMount(dismissibleContainer, {
- propsData,
+ createComponent({
slots: {
[slot]: `${slotContent}`,
},
diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js
index 82130500458..373b27959b0 100644
--- a/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js
+++ b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js
@@ -1,4 +1,4 @@
-import { mount } from '@vue/test-utils';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
describe('DropdownButton component', () => {
@@ -8,7 +8,10 @@ describe('DropdownButton component', () => {
const customLabel = 'Select project';
const createComponent = (props, slots = {}) => {
- wrapper = mount(DropdownButton, { propsData: props, slots });
+ wrapper = mountExtended(DropdownButton, {
+ propsData: { ...props },
+ slots,
+ });
};
describe('computed', () => {
@@ -54,7 +57,7 @@ describe('DropdownButton component', () => {
it('renders dropdown button icon', () => {
createComponent();
- expect(wrapper.find('[data-testid="chevron-down-icon"]').exists()).toBe(true);
+ expect(wrapper.findByTestId('chevron-down-icon').exists()).toBe(true);
});
it('renders slot, if default slot exists', () => {
diff --git a/spec/frontend/vue_shared/components/projects_list/formatter_spec.js b/spec/frontend/vue_shared/components/projects_list/formatter_spec.js
index 5c5bb2780b9..078435b83a2 100644
--- a/spec/frontend/vue_shared/components/projects_list/formatter_spec.js
+++ b/spec/frontend/vue_shared/components/projects_list/formatter_spec.js
@@ -5,7 +5,7 @@ import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions
const MOCK_AVAILABLE_ACTIONS = [ACTION_EDIT, ACTION_DELETE];
-jest.mock('ee_else_ce/vue_shared/components/projects_list/utils', () => ({
+jest.mock('~/vue_shared/components/projects_list/utils', () => ({
availableGraphQLProjectActions: jest.fn(() => MOCK_AVAILABLE_ACTIONS),
}));
diff --git a/spec/frontend/vue_shared/components/projects_list/project_list_item_actions_spec.js b/spec/frontend/vue_shared/components/projects_list/project_list_item_actions_spec.js
index c77d9576e8c..914f321482f 100644
--- a/spec/frontend/vue_shared/components/projects_list/project_list_item_actions_spec.js
+++ b/spec/frontend/vue_shared/components/projects_list/project_list_item_actions_spec.js
@@ -1,11 +1,28 @@
+import { nextTick } from 'vue';
+import { GlLoadingIcon } from '@gitlab/ui';
import projects from 'test_fixtures/api/users/projects/get.json';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { renderRestoreSuccessToast } from '~/vue_shared/components/projects_list/utils';
+import { restoreProject } from '~/rest_api';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import ListActions from '~/vue_shared/components/list_actions/list_actions.vue';
import ProjectListItemActions from '~/vue_shared/components/projects_list/project_list_item_actions.vue';
-import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
+import {
+ ACTION_EDIT,
+ ACTION_RESTORE,
+ ACTION_DELETE,
+} from '~/vue_shared/components/list_actions/constants';
+import { createAlert } from '~/alert';
-describe('ProjectListItemActionsCE', () => {
+jest.mock('~/vue_shared/components/projects_list/utils', () => ({
+ ...jest.requireActual('~/vue_shared/components/projects_list/utils'),
+ renderRestoreSuccessToast: jest.fn(),
+}));
+jest.mock('~/alert');
+jest.mock('~/api/projects_api');
+
+describe('ProjectListItemActions', () => {
let wrapper;
const [project] = convertObjectPropsToCamelCase(projects, { deep: true });
@@ -13,7 +30,7 @@ describe('ProjectListItemActionsCE', () => {
const editPath = '/foo/bar/edit';
const projectWithActions = {
...project,
- availableActions: [ACTION_EDIT, ACTION_DELETE],
+ availableActions: [ACTION_EDIT, ACTION_RESTORE, ACTION_DELETE],
editPath,
};
@@ -28,6 +45,11 @@ describe('ProjectListItemActionsCE', () => {
};
const findListActions = () => wrapper.findComponent(ListActions);
+ const findListActionsLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const restoreProjectAction = async () => {
+ findListActions().props('actions')[ACTION_RESTORE].action();
+ await nextTick();
+ };
beforeEach(() => {
createComponent();
@@ -40,11 +62,64 @@ describe('ProjectListItemActionsCE', () => {
[ACTION_EDIT]: {
href: editPath,
},
+ [ACTION_RESTORE]: {
+ action: expect.any(Function),
+ },
[ACTION_DELETE]: {
action: expect.any(Function),
},
},
- availableActions: [ACTION_EDIT, ACTION_DELETE],
+ availableActions: [ACTION_EDIT, ACTION_RESTORE, ACTION_DELETE],
+ });
+ });
+ });
+
+ describe('when restore action is fired', () => {
+ describe('when API call is successful', () => {
+ it('calls restoreProject, properly sets loading state, and emits refetch event', async () => {
+ restoreProject.mockResolvedValueOnce();
+
+ await restoreProjectAction();
+ expect(restoreProject).toHaveBeenCalledWith(projectWithActions.id);
+
+ expect(findListActionsLoadingIcon().exists()).toBe(true);
+ expect(findListActions().exists()).toBe(false);
+
+ await waitForPromises();
+
+ expect(findListActionsLoadingIcon().exists()).toBe(false);
+ expect(findListActions().exists()).toBe(true);
+
+ expect(wrapper.emitted('refetch')).toEqual([[]]);
+ expect(renderRestoreSuccessToast).toHaveBeenCalledWith(projectWithActions, 'Project');
+ expect(createAlert).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when API call is not successful', () => {
+ const error = new Error();
+
+ it('calls restoreProject, properly sets loading state, and shows error alert', async () => {
+ restoreProject.mockRejectedValue(error);
+
+ await restoreProjectAction();
+ expect(restoreProject).toHaveBeenCalledWith(projectWithActions.id);
+
+ expect(findListActionsLoadingIcon().exists()).toBe(true);
+ expect(findListActions().exists()).toBe(false);
+
+ await waitForPromises();
+
+ expect(findListActionsLoadingIcon().exists()).toBe(false);
+ expect(findListActions().exists()).toBe(true);
+
+ expect(wrapper.emitted('refetch')).toBeUndefined();
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'An error occurred restoring the project. Please refresh the page to try again.',
+ error,
+ captureError: true,
+ });
+ expect(renderRestoreSuccessToast).not.toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/vue_shared/components/projects_list/project_list_item_delayed_deletion_modal_footer_spec.js b/spec/frontend/vue_shared/components/projects_list/project_list_item_delayed_deletion_modal_footer_spec.js
new file mode 100644
index 00000000000..698c773e16d
--- /dev/null
+++ b/spec/frontend/vue_shared/components/projects_list/project_list_item_delayed_deletion_modal_footer_spec.js
@@ -0,0 +1,63 @@
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import projects from 'test_fixtures/api/users/projects/get.json';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import ProjectListItemDelayedDeletionModalFooter from '~/vue_shared/components/projects_list/project_list_item_delayed_deletion_modal_footer.vue';
+
+describe('ProjectListItemDelayedDeletionModalFooterEE', () => {
+ let wrapper;
+
+ const [project] = convertObjectPropsToCamelCase(projects, { deep: true });
+ const MOCK_PERM_DELETION_DATE = '2024-03-31';
+ const HELP_PATH = helpPagePath('user/project/working_with_projects', {
+ anchor: 'restore-a-project',
+ });
+
+ const defaultProps = {
+ project,
+ };
+
+ const createComponent = ({ props = {} } = {}) => {
+ wrapper = shallowMountExtended(ProjectListItemDelayedDeletionModalFooter, {
+ propsData: { ...defaultProps, ...props },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ const findDelayedDeletionModalFooter = () => wrapper.findByTestId('delayed-delete-modal-footer');
+ const findGlLink = () => wrapper.findComponent(GlLink);
+
+ describe.each`
+ isAdjournedDeletionEnabled | markedForDeletionOn | footer | link
+ ${false} | ${null} | ${false} | ${false}
+ ${false} | ${'2024-03-24'} | ${false} | ${false}
+ ${true} | ${null} | ${`This project can be restored until ${MOCK_PERM_DELETION_DATE}. Learn more.`} | ${HELP_PATH}
+ ${true} | ${'2024-03-24'} | ${false} | ${false}
+ `(
+ 'when project.isAdjournedDeletionEnabled is $isAdjournedDeletionEnabled and project.markedForDeletionOn is $markedForDeletionOn',
+ ({ isAdjournedDeletionEnabled, markedForDeletionOn, footer, link }) => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ project: {
+ ...project,
+ isAdjournedDeletionEnabled,
+ markedForDeletionOn,
+ permanentDeletionDate: MOCK_PERM_DELETION_DATE,
+ },
+ },
+ });
+ });
+
+ it(`does ${footer ? 'render' : 'not render'} the delayed deletion modal footer`, () => {
+ expect(
+ findDelayedDeletionModalFooter().exists() && findDelayedDeletionModalFooter().text(),
+ ).toBe(footer);
+ expect(findGlLink().exists() && findGlLink().attributes('href')).toBe(link);
+ });
+ },
+ );
+});
diff --git a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js
index d2f1675342a..76ee775d073 100644
--- a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js
+++ b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js
@@ -4,9 +4,10 @@ import projects from 'test_fixtures/api/users/projects/get.json';
import { stubComponent } from 'helpers/stub_component';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ProjectListItemDescription from '~/vue_shared/components/projects_list/project_list_item_description.vue';
-import ProjectListItemActions from 'ee_else_ce/vue_shared/components/projects_list/project_list_item_actions.vue';
+import ProjectListItemActions from '~/vue_shared/components/projects_list/project_list_item_actions.vue';
import ProjectListItemInactiveBadge from '~/vue_shared/components/projects_list/project_list_item_inactive_badge.vue';
import ProjectsListItem from '~/vue_shared/components/projects_list/projects_list_item.vue';
+import ProjectListItemDelayedDeletionModalFooter from '~/vue_shared/components/projects_list/project_list_item_delayed_deletion_modal_footer.vue';
import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import waitForPromises from 'helpers/wait_for_promises';
@@ -27,7 +28,7 @@ import {
import {
renderDeleteSuccessToast,
deleteParams,
-} from 'ee_else_ce/vue_shared/components/projects_list/utils';
+} from '~/vue_shared/components/projects_list/utils';
import { deleteProject } from '~/api/projects_api';
import { createAlert } from '~/alert';
import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
@@ -36,8 +37,8 @@ const MOCK_DELETE_PARAMS = {
testParam: true,
};
-jest.mock('ee_else_ce/vue_shared/components/projects_list/utils', () => ({
- ...jest.requireActual('ee_else_ce/vue_shared/components/projects_list/utils'),
+jest.mock('~/vue_shared/components/projects_list/utils', () => ({
+ ...jest.requireActual('~/vue_shared/components/projects_list/utils'),
renderDeleteSuccessToast: jest.fn(),
deleteParams: jest.fn(() => MOCK_DELETE_PARAMS),
}));
@@ -84,6 +85,8 @@ describe('ProjectsListItem', () => {
const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip);
const findTopicBadges = () => wrapper.findComponent(TopicBadges);
const findDeleteModal = () => wrapper.findComponent(DeleteModal);
+ const findDelayedDeletionModalFooter = () =>
+ wrapper.findComponent(ProjectListItemDelayedDeletionModalFooter);
const deleteModalFirePrimaryEvent = async () => {
findDeleteModal().vm.$emit('primary');
await nextTick();
@@ -612,4 +615,22 @@ describe('ProjectsListItem', () => {
expect(wrapper.element.firstChild.classList).toContain('foo');
});
+
+ describe('ProjectListItemDelayedDeletionModalFooter', () => {
+ const deleteProps = {
+ project: {
+ ...project,
+ availableActions: [ACTION_DELETE],
+ actionLoadingStates: { [ACTION_DELETE]: false },
+ },
+ };
+
+ it('renders modal footer', () => {
+ createComponent({ propsData: deleteProps });
+ findListActions().vm.$emit('delete');
+
+ expect(findDeleteModal().exists()).toBe(true);
+ expect(findDelayedDeletionModalFooter().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/projects_list/utils_spec.js b/spec/frontend/vue_shared/components/projects_list/utils_spec.js
index fef5a6fe398..a292c39dd7f 100644
--- a/spec/frontend/vue_shared/components/projects_list/utils_spec.js
+++ b/spec/frontend/vue_shared/components/projects_list/utils_spec.js
@@ -1,49 +1,169 @@
-import organizationProjectsGraphQlResponse from 'test_fixtures/graphql/organizations/projects.query.graphql.json';
import {
availableGraphQLProjectActions,
deleteParams,
renderDeleteSuccessToast,
+ renderRestoreSuccessToast,
} from '~/vue_shared/components/projects_list/utils';
-import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
-import { formatGraphQLProjects } from '~/vue_shared/components/projects_list/formatter';
+import {
+ ACTION_EDIT,
+ ACTION_RESTORE,
+ ACTION_DELETE,
+} from '~/vue_shared/components/list_actions/constants';
import toast from '~/vue_shared/plugins/global_toast';
jest.mock('~/vue_shared/plugins/global_toast');
-const {
- data: {
- organization: {
- projects: { nodes: projects },
- },
+const MOCK_PERSONAL_PROJECT = {
+ nameWithNamespace: 'No Delay Project',
+ fullPath: 'path/to/project/1',
+ isAdjournedDeletionEnabled: false,
+ markedForDeletionOn: null,
+ permanentDeletionDate: '2024-03-31',
+ group: null,
+};
+
+const MOCK_PROJECT_DELAY_DELETION_DISABLED = {
+ nameWithNamespace: 'No Delay Project',
+ fullPath: 'path/to/project/1',
+ isAdjournedDeletionEnabled: false,
+ markedForDeletionOn: null,
+ permanentDeletionDate: '2024-03-31',
+ group: {
+ id: 'gid://gitlab/Group/1',
},
-} = organizationProjectsGraphQlResponse;
+};
+
+const MOCK_PROJECT_DELAY_DELETION_ENABLED = {
+ nameWithNamespace: 'With Delay Project',
+ fullPath: 'path/to/project/2',
+ isAdjournedDeletionEnabled: true,
+ markedForDeletionOn: null,
+ permanentDeletionDate: '2024-03-31',
+ group: {
+ id: 'gid://gitlab/Group/2',
+ },
+};
+
+const MOCK_PROJECT_PENDING_DELETION = {
+ nameWithNamespace: 'Pending Deletion Project',
+ fullPath: 'path/to/project/3',
+ isAdjournedDeletionEnabled: true,
+ markedForDeletionOn: '2024-03-24',
+ permanentDeletionDate: '2024-03-31',
+ group: {
+ id: 'gid://gitlab/Group/3',
+ },
+};
describe('availableGraphQLProjectActions', () => {
describe.each`
- userPermissions | availableActions
- ${{ viewEditPage: false, removeProject: false }} | ${[]}
- ${{ viewEditPage: true, removeProject: false }} | ${[ACTION_EDIT]}
- ${{ viewEditPage: false, removeProject: true }} | ${[ACTION_DELETE]}
- ${{ viewEditPage: true, removeProject: true }} | ${[ACTION_EDIT, ACTION_DELETE]}
- `('availableGraphQLProjectActions', ({ userPermissions, availableActions }) => {
- it(`when userPermissions = ${JSON.stringify(userPermissions)} then availableActions = [${availableActions}] and is sorted correctly`, () => {
- expect(availableGraphQLProjectActions({ userPermissions })).toStrictEqual(availableActions);
- });
+ userPermissions | markedForDeletionOn | availableActions
+ ${{ viewEditPage: false, removeProject: false }} | ${null} | ${[]}
+ ${{ viewEditPage: true, removeProject: false }} | ${null} | ${[ACTION_EDIT]}
+ ${{ viewEditPage: false, removeProject: true }} | ${null} | ${[ACTION_DELETE]}
+ ${{ viewEditPage: true, removeProject: true }} | ${null} | ${[ACTION_EDIT, ACTION_DELETE]}
+ ${{ viewEditPage: true, removeProject: false }} | ${'2024-12-31'} | ${[ACTION_EDIT]}
+ ${{ viewEditPage: true, removeProject: true }} | ${'2024-12-31'} | ${[ACTION_EDIT, ACTION_RESTORE, ACTION_DELETE]}
+ `(
+ 'availableGraphQLProjectActions',
+ ({ userPermissions, markedForDeletionOn, availableActions }) => {
+ it(`when userPermissions = ${JSON.stringify(userPermissions)}, markedForDeletionOn is ${markedForDeletionOn}, then availableActions = [${availableActions}] and is sorted correctly`, () => {
+ expect(
+ availableGraphQLProjectActions({ userPermissions, markedForDeletionOn }),
+ ).toStrictEqual(availableActions);
+ });
+ },
+ );
+});
+
+describe('renderRestoreSuccessToast', () => {
+ it('calls toast correctly', () => {
+ renderRestoreSuccessToast(MOCK_PROJECT_PENDING_DELETION);
+
+ expect(toast).toHaveBeenCalledWith(
+ `Project '${MOCK_PROJECT_PENDING_DELETION.nameWithNamespace}' has been successfully restored.`,
+ );
});
});
describe('renderDeleteSuccessToast', () => {
- const [project] = formatGraphQLProjects(projects);
+ describe('when adjourned deletion is enabled', () => {
+ beforeEach(() => {
+ renderDeleteSuccessToast(MOCK_PROJECT_DELAY_DELETION_ENABLED);
+ });
- it('calls toast correctly', () => {
- renderDeleteSuccessToast(project);
+ it('renders toast explaining project will be delayed deleted', () => {
+ expect(toast).toHaveBeenCalledWith(
+ `Project '${MOCK_PROJECT_DELAY_DELETION_ENABLED.nameWithNamespace}' will be deleted on ${MOCK_PROJECT_DELAY_DELETION_ENABLED.permanentDeletionDate}.`,
+ );
+ });
+ });
- expect(toast).toHaveBeenCalledWith(`Project '${project.nameWithNamespace}' is being deleted.`);
+ describe('when adjourned deletion is not enabled', () => {
+ beforeEach(() => {
+ renderDeleteSuccessToast(MOCK_PROJECT_DELAY_DELETION_DISABLED);
+ });
+
+ it('renders toast explaining project is being deleted', () => {
+ expect(toast).toHaveBeenCalledWith(
+ `Project '${MOCK_PROJECT_DELAY_DELETION_DISABLED.nameWithNamespace}' is being deleted.`,
+ );
+ });
+ });
+
+ describe('when adjourned deletion is available at the global level but not the project level', () => {
+ beforeEach(() => {
+ window.gon = {
+ licensed_features: {
+ adjournedDeletionForProjectsAndGroups: true,
+ },
+ };
+ renderDeleteSuccessToast(MOCK_PROJECT_DELAY_DELETION_DISABLED);
+ });
+
+ it('renders toast explaining project is deleted and when data will be removed', () => {
+ expect(toast).toHaveBeenCalledWith(
+ `Deleting project '${MOCK_PROJECT_DELAY_DELETION_DISABLED.nameWithNamespace}'. All data will be removed on ${MOCK_PROJECT_DELAY_DELETION_DISABLED.permanentDeletionDate}.`,
+ );
+ });
+ });
+
+ describe('when project is a personal project', () => {
+ beforeEach(() => {
+ renderDeleteSuccessToast(MOCK_PERSONAL_PROJECT);
+ });
+
+ it('renders toast explaining project is being deleted', () => {
+ expect(toast).toHaveBeenCalledWith(
+ `Project '${MOCK_PERSONAL_PROJECT.nameWithNamespace}' is being deleted.`,
+ );
+ });
+ });
+
+ describe('when project has already been marked for deletion', () => {
+ beforeEach(() => {
+ renderDeleteSuccessToast(MOCK_PROJECT_PENDING_DELETION);
+ });
+
+ it('renders toast explaining project is being deleted', () => {
+ expect(toast).toHaveBeenCalledWith(
+ `Project '${MOCK_PROJECT_PENDING_DELETION.nameWithNamespace}' is being deleted.`,
+ );
+ });
});
});
describe('deleteParams', () => {
it('returns empty object', () => {
- expect(deleteParams()).toStrictEqual({});
+ expect(deleteParams(MOCK_PROJECT_DELAY_DELETION_ENABLED)).toStrictEqual({});
+ });
+
+ describe('when project has already been marked for deletion', () => {
+ it('sets permanently_remove param to true and passes full_path param', () => {
+ expect(deleteParams(MOCK_PROJECT_PENDING_DELETION)).toStrictEqual({
+ permanently_remove: true,
+ full_path: MOCK_PROJECT_PENDING_DELETION.fullPath,
+ });
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/registry/code_instruction_spec.js b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js
index bcc274cf7cd..263b92dd03b 100644
--- a/spec/frontend/vue_shared/components/registry/code_instruction_spec.js
+++ b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js
@@ -1,4 +1,4 @@
-import { shallowMount } from '@vue/test-utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
@@ -14,7 +14,7 @@ describe('Package code instruction', () => {
};
function createComponent(props = {}) {
- wrapper = shallowMount(CodeInstruction, {
+ wrapper = shallowMountExtended(CodeInstruction, {
propsData: {
...defaultProps,
...props,
@@ -23,8 +23,8 @@ describe('Package code instruction', () => {
}
const findCopyButton = () => wrapper.findComponent(ClipboardButton);
- const findInputElement = () => wrapper.find('[data-testid="instruction-input"]');
- const findMultilineInstruction = () => wrapper.find('[data-testid="multiline-instruction"]');
+ const findInputElement = () => wrapper.findByTestId('instruction-input');
+ const findMultilineInstruction = () => wrapper.findByTestId('multiline-instruction');
describe('single line', () => {
beforeEach(() =>
diff --git a/spec/frontend/vue_shared/components/registry/details_row_spec.js b/spec/frontend/vue_shared/components/registry/details_row_spec.js
index 08e7aef7106..702909547ba 100644
--- a/spec/frontend/vue_shared/components/registry/details_row_spec.js
+++ b/spec/frontend/vue_shared/components/registry/details_row_spec.js
@@ -1,17 +1,18 @@
import { GlIcon } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import component from '~/vue_shared/components/registry/details_row.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
describe('DetailsRow', () => {
let wrapper;
- const findIcon = () => wrapper.findComponent(GlIcon);
- const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]');
+ const defaultProps = {
+ icon: 'clock',
+ };
- const mountComponent = (props) => {
- wrapper = shallowMount(component, {
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(DetailsRow, {
propsData: {
- icon: 'clock',
+ ...defaultProps,
...props,
},
slots: {
@@ -20,19 +21,22 @@ describe('DetailsRow', () => {
});
};
+ const findIcon = () => wrapper.findComponent(GlIcon);
+ const findDefaultSlot = () => wrapper.findByTestId('default-slot');
+
it('has a default slot', () => {
- mountComponent();
+ createComponent();
expect(findDefaultSlot().exists()).toBe(true);
});
describe('icon prop', () => {
it('contains an icon', () => {
- mountComponent();
+ createComponent();
expect(findIcon().exists()).toBe(true);
});
it('icon has the correct props', () => {
- mountComponent();
+ createComponent();
expect(findIcon().props()).toMatchObject({
name: 'clock',
});
@@ -41,12 +45,12 @@ describe('DetailsRow', () => {
describe('padding prop', () => {
it('padding has a default', () => {
- mountComponent();
+ createComponent();
expect(wrapper.classes('gl-py-2')).toBe(true);
});
it('is reflected in the template', () => {
- mountComponent({ padding: 'gl-py-4' });
+ createComponent({ padding: 'gl-py-4' });
expect(wrapper.classes('gl-py-4')).toBe(true);
});
});
@@ -54,12 +58,12 @@ describe('DetailsRow', () => {
describe('dashed prop', () => {
const borderClasses = ['gl-border-b-solid', 'gl-border-default', 'gl-border-b-1'];
it('by default component has no border', () => {
- mountComponent();
+ createComponent();
expect(wrapper.classes).not.toEqual(expect.arrayContaining(borderClasses));
});
it('has a border when dashed is true', () => {
- mountComponent({ dashed: true });
+ createComponent({ dashed: true });
expect(wrapper.classes()).toEqual(expect.arrayContaining(borderClasses));
});
});
diff --git a/spec/frontend/vue_shared/components/registry/history_item_spec.js b/spec/frontend/vue_shared/components/registry/history_item_spec.js
index 17abe06dbee..09a6af83ed9 100644
--- a/spec/frontend/vue_shared/components/registry/history_item_spec.js
+++ b/spec/frontend/vue_shared/components/registry/history_item_spec.js
@@ -1,7 +1,7 @@
import { GlIcon } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
-import component from '~/vue_shared/components/registry/history_item.vue';
+import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
describe('History Item', () => {
let wrapper;
@@ -10,7 +10,7 @@ describe('History Item', () => {
};
const mountComponent = () => {
- wrapper = shallowMount(component, {
+ wrapper = shallowMountExtended(HistoryItem, {
propsData: { ...defaultProps },
stubs: {
TimelineEntryItem,
@@ -24,8 +24,8 @@ describe('History Item', () => {
const findTimelineEntry = () => wrapper.findComponent(TimelineEntryItem);
const findGlIcon = () => wrapper.findComponent(GlIcon);
- const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]');
- const findBodySlot = () => wrapper.find('[data-testid="body-slot"]');
+ const findDefaultSlot = () => wrapper.findByTestId('default-slot');
+ const findBodySlot = () => wrapper.findByTestId('body-slot');
it('renders the correct markup', () => {
mountComponent();
diff --git a/spec/frontend/vue_shared/components/registry/list_item_spec.js b/spec/frontend/vue_shared/components/registry/list_item_spec.js
index f5a72599d4c..c23a7bb10f8 100644
--- a/spec/frontend/vue_shared/components/registry/list_item_spec.js
+++ b/spec/frontend/vue_shared/components/registry/list_item_spec.js
@@ -1,24 +1,24 @@
import { GlButton } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import component from '~/vue_shared/components/registry/list_item.vue';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
describe('list item', () => {
let wrapper;
- const findLeftActionSlot = () => wrapper.find('[data-testid="left-action"]');
- const findLeftPrimarySlot = () => wrapper.find('[data-testid="left-primary"]');
- const findLeftSecondarySlot = () => wrapper.find('[data-testid="left-secondary"]');
- const findLeftAfterToggleSlot = () => wrapper.find('[data-testid="left-after-toggle"]');
- const findRightPrimarySlot = () => wrapper.find('[data-testid="right-primary"]');
- const findRightSecondarySlot = () => wrapper.find('[data-testid="right-secondary"]');
- const findRightActionSlot = () => wrapper.find('[data-testid="right-action"]');
+ const findLeftActionSlot = () => wrapper.findByTestId('left-action');
+ const findLeftPrimarySlot = () => wrapper.findByTestId('left-primary');
+ const findLeftSecondarySlot = () => wrapper.findByTestId('left-secondary');
+ const findLeftAfterToggleSlot = () => wrapper.findByTestId('left-after-toggle');
+ const findRightPrimarySlot = () => wrapper.findByTestId('right-primary');
+ const findRightSecondarySlot = () => wrapper.findByTestId('right-secondary');
+ const findRightActionSlot = () => wrapper.findByTestId('right-action');
const findDetailsSlot = (name) => wrapper.find(`[data-testid="${name}"]`);
const findToggleDetailsButton = () => wrapper.findComponent(GlButton);
const mountComponent = (propsData, slots) => {
- wrapper = shallowMount(component, {
+ wrapper = shallowMountExtended(ListItem, {
propsData,
slots: {
'left-action': '',
diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_milestone_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_milestone_spec.js
index cbc06197746..385af4dec24 100644
--- a/spec/frontend/vue_shared/issuable/list/components/issuable_milestone_spec.js
+++ b/spec/frontend/vue_shared/issuable/list/components/issuable_milestone_spec.js
@@ -1,5 +1,5 @@
import { GlIcon } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { useFakeDate } from 'helpers/fake_date';
import IssuableMilestone from '~/vue_shared/issuable/list/components/issuable_milestone.vue';
import WorkItemAttribute from '~/vue_shared/components/work_item_attribute.vue';
@@ -19,11 +19,14 @@ describe('IssuableMilestone component', () => {
const findWorkItemAttribute = () => wrapper.findComponent(WorkItemAttribute);
const mountComponent = ({ milestone = milestoneObject() } = {}) =>
- shallowMount(IssuableMilestone, { propsData: { milestone }, stubs: { WorkItemAttribute } });
+ shallowMountExtended(IssuableMilestone, {
+ propsData: { milestone },
+ stubs: { WorkItemAttribute },
+ });
it('renders milestone link', () => {
wrapper = mountComponent();
- const milestoneEl = wrapper.find('[data-testid="issuable-milestone"]');
+ const milestoneEl = wrapper.findByTestId('issuable-milestone');
expect(findWorkItemAttribute().props('title')).toBe('My milestone');
expect(milestoneEl.findComponent(GlIcon).props('name')).toBe('milestone');
diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js
index 304343d1a06..f7acd80bde4 100644
--- a/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js
@@ -1,6 +1,6 @@
import { GlFormInput } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import IssuableEditForm from '~/vue_shared/issuable/show/components/issuable_edit_form.vue';
import IssuableEventHub from '~/vue_shared/issuable/show/event_hub';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
@@ -21,7 +21,7 @@ const issuableEditFormProps = {
};
const createComponent = ({ propsData = issuableEditFormProps } = {}) =>
- shallowMount(IssuableEditForm, {
+ shallowMountExtended(IssuableEditForm, {
propsData,
stubs: {
MarkdownEditor,
@@ -44,6 +44,10 @@ describe('IssuableEditForm', () => {
expect(eventSpy).toHaveBeenNthCalledWith(2, 'close.form', expect.any(Function));
};
+ const findActions = () => wrapper.findByTestId('actions');
+ const findTitle = () => wrapper.findByTestId('title');
+ const findDescription = () => wrapper.findByTestId('description');
+
beforeEach(() => {
wrapper = createComponent();
jest.spyOn(Autosave.prototype, 'reset');
@@ -134,7 +138,7 @@ describe('IssuableEditForm', () => {
describe('template', () => {
it('renders title input field', () => {
- const titleInputEl = wrapper.find('[data-testid="title"]');
+ const titleInputEl = findTitle();
expect(titleInputEl.exists()).toBe(true);
expect(titleInputEl.findComponent(GlFormInput).attributes()).toMatchObject({
@@ -144,7 +148,7 @@ describe('IssuableEditForm', () => {
});
it('renders description textarea field', () => {
- const descriptionEl = wrapper.find('[data-testid="description"]');
+ const descriptionEl = findDescription();
expect(descriptionEl.exists()).toBe(true);
expect(descriptionEl.findComponent(MarkdownField).props()).toMatchObject({
@@ -161,13 +165,11 @@ describe('IssuableEditForm', () => {
});
it('allows switching to rich text editor', () => {
- const descriptionEl = wrapper.find('[data-testid="description"]');
-
- expect(descriptionEl.text()).toContain('Switch to rich text editing');
+ expect(findDescription().text()).toContain('Switch to rich text editing');
});
it('renders form actions', () => {
- const actionsEl = wrapper.find('[data-testid="actions"]');
+ const actionsEl = findActions();
expect(actionsEl.find('button.js-save').exists()).toBe(true);
expect(actionsEl.find('button.js-cancel').exists()).toBe(true);
@@ -194,7 +196,7 @@ describe('IssuableEditForm', () => {
});
it('component emits `keydown-description` event with event object and issuableMeta params via textarea', () => {
- const descriptionInputEl = wrapper.find('[data-testid="description"] textarea');
+ const descriptionInputEl = findDescription().find('textarea');
descriptionInputEl.trigger('keydown', eventObj, 'description');
expect(wrapper.emitted('keydown-description')).toHaveLength(1);
diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js
index 96bb4231c95..e60b2e48ac2 100644
--- a/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js
@@ -1,6 +1,6 @@
import { GlIcon, GlBadge, GlButton, GlIntersectionObserver } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import IssuableTitle from '~/vue_shared/issuable/show/components/issuable_title.vue';
@@ -14,7 +14,7 @@ const issuableTitleProps = {
};
const createComponent = (propsData = issuableTitleProps) =>
- shallowMount(IssuableTitle, {
+ shallowMountExtended(IssuableTitle, {
propsData,
slots: {
'status-badge': 'Open',
@@ -27,7 +27,7 @@ const createComponent = (propsData = issuableTitleProps) =>
describe('IssuableTitle', () => {
let wrapper;
- const findStickyHeader = () => wrapper.find('[data-testid="header"]');
+ const findStickyHeader = () => wrapper.findByTestId('header');
beforeEach(() => {
wrapper = createComponent();
@@ -66,7 +66,7 @@ describe('IssuableTitle', () => {
});
await nextTick();
- const titleEl = wrapperWithTitle.find('[data-testid="issuable-title"]');
+ const titleEl = wrapperWithTitle.findByTestId('issuable-title');
expect(titleEl.exists()).toBe(true);
expect(titleEl.element.innerHTML).toBe('Sample title');
diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb
index e526ef2aa21..542376f158f 100644
--- a/spec/models/commit_spec.rb
+++ b/spec/models/commit_spec.rb
@@ -558,9 +558,9 @@ EOS
it { expect(commit.cherry_pick_message(user)).to include("\n\n(cherry picked from commit 88790590ed1337ab189bccaa355f068481c90bec)") }
- context 'when "more_commits_from_gitaly" feature flag is disabled' do
+ context 'when "commits_from_gitaly" feature flag is disabled' do
before do
- stub_feature_flags(more_commits_from_gitaly: false)
+ stub_feature_flags(commits_from_gitaly: false)
end
it { expect(commit.cherry_pick_message(user)).to include("\n\n(cherry picked from commit 88790590ed1337ab189bccaa355f068481c90bec)") }
diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb
index 0d3215bc206..4615d2934b3 100644
--- a/spec/models/merge_request_diff_spec.rb
+++ b/spec/models/merge_request_diff_spec.rb
@@ -1278,9 +1278,9 @@ RSpec.describe MergeRequestDiff, feature_category: :code_review_workflow do
expect(diff_with_commits.first_commit.sha).to eq(diff_with_commits.merge_request_diff_commits.last.sha)
end
- context 'when "more_commits_from_gitaly" feature flag is disabled' do
+ context 'when "commits_from_gitaly" feature flag is disabled' do
before do
- stub_feature_flags(more_commits_from_gitaly: false)
+ stub_feature_flags(commits_from_gitaly: false)
end
it 'returns first commit' do
@@ -1294,9 +1294,9 @@ RSpec.describe MergeRequestDiff, feature_category: :code_review_workflow do
expect(diff_with_commits.last_commit.sha).to eq(diff_with_commits.merge_request_diff_commits.first.sha)
end
- context 'when "more_commits_from_gitaly" feature flag is disabled' do
+ context 'when "commits_from_gitaly" feature flag is disabled' do
before do
- stub_feature_flags(more_commits_from_gitaly: false)
+ stub_feature_flags(commits_from_gitaly: false)
end
it 'returns last commit' do
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 46a3b3a9bc8..cac515cd0f8 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -1403,7 +1403,7 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
create(:note, :internal, noteable: merge_request, note: issue_referenced_in_internal_mr_note.to_reference)
end
- context 'feature flag: more_commits_from_gitaly' do
+ context 'feature flag: commits_from_gitaly' do
let_it_be(:user) { create(:user, guest_of: project) }
it 'loads commits from Gitaly' do
@@ -1412,9 +1412,9 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
related_issues
end
- context 'when "more_commits_from_gitaly" is disabled' do
+ context 'when "commits_from_gitaly" is disabled' do
before do
- stub_feature_flags(more_commits_from_gitaly: false)
+ stub_feature_flags(commits_from_gitaly: false)
end
it 'loads commits from DB' do
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index e57658e02e6..f71da42e9a2 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -1819,7 +1819,6 @@ RSpec.describe API::MergeRequests, :aggregate_failures, feature_category: :sourc
context 'when commits_from_gitaly and optimized_commit_storage feature flags are disabled' do
before do
- stub_feature_flags(more_commits_from_gitaly: false)
stub_feature_flags(commits_from_gitaly: false)
stub_feature_flags(optimized_commit_storage: false)
end
diff --git a/spec/views/shared/milestones/_issuables.html.haml_spec.rb b/spec/views/shared/milestones/_issuables.html.haml_spec.rb
index cd11c028bd7..6b2bdf6eab5 100644
--- a/spec/views/shared/milestones/_issuables.html.haml_spec.rb
+++ b/spec/views/shared/milestones/_issuables.html.haml_spec.rb
@@ -8,6 +8,7 @@ RSpec.describe 'shared/milestones/_issuables.html.haml' do
before do
allow(view).to receive_messages(
title: nil,
+ subtitle: nil,
id: nil,
show_project_name: nil,
dom_class: '',
diff --git a/vendor/assets/javascripts/vue-virtual-scroller/src/components/RecycleScroller.vue b/vendor/assets/javascripts/vue-virtual-scroller/src/components/RecycleScroller.vue
index 7c8fc724062..ea3c5aa12c1 100644
--- a/vendor/assets/javascripts/vue-virtual-scroller/src/components/RecycleScroller.vue
+++ b/vendor/assets/javascripts/vue-virtual-scroller/src/components/RecycleScroller.vue
@@ -450,7 +450,7 @@ export default {
}
view = views.get(key)
- if (!itemSize && !sizes[i].size) {
+ if (!itemSize && !sizes[i]?.size) {
if (view) this.unuseView(view)
continue
}