Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-04-28 21:07:24 +00:00
parent e7bfce6d9f
commit 0eeadd3aec
96 changed files with 3606 additions and 383 deletions

View File

@ -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: {

View File

@ -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)

View File

@ -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', '')"
>
<div v-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }">
<div
v-if="isParallel(group) || isMatrix(group)"
:class="{ 'gl-opacity-3': isFadedOut(group.name) }"
>
<job-group-dropdown
:group="group"
:stage-name="showStageName ? group.stageName : ''"
@ -263,7 +269,10 @@ export default {
@mouseenter="$emit('jobHover', group.name)"
@mouseleave="$emit('jobHover', '')"
>
<div v-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }">
<div
v-if="isParallel(group) || isMatrix(group)"
:class="{ 'gl-opacity-3': isFadedOut(group.name) }"
>
<job-group-dropdown
:group="group"
:stage-name="showStageName ? group.stageName : ''"

View File

@ -135,6 +135,10 @@
"PendingProjectMember",
"ProjectMember"
],
"NamespaceUnion": [
"CiDeletedNamespace",
"Namespace"
],
"NoteableInterface": [
"AlertManagementAlert",
"BoardEpic",

View File

@ -159,6 +159,7 @@ export class ChunkWriter {
}
abort() {
this.balancer.abort();
this.scheduleAccumulatorFlush.cancel();
this.buffer = null;
this.htmlStream.abort();

View File

@ -1,5 +1,6 @@
export class RenderBalancer {
previousTimestamp = undefined;
#aborted = false;
constructor({ increase, decrease, highFrameTime, lowFrameTime }) {
this.increase = increase;
@ -12,7 +13,7 @@ export class RenderBalancer {
return new Promise((resolve) => {
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;
}
}

View File

@ -0,0 +1,43 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { mapActions, mapState } from 'pinia';
import InputCopyToggleVisibility from '~/vue_shared/components/input_copy_toggle_visibility/input_copy_toggle_visibility.vue';
import { useAccessTokens } from '../stores/access_tokens';
export default {
components: { GlAlert, InputCopyToggleVisibility },
computed: {
...mapState(useAccessTokens, ['token']),
formInputGroupProps() {
return {
'data-testid': this.$options.inputId,
id: this.$options.inputId,
name: this.$options.inputId,
};
},
},
methods: {
...mapActions(useAccessTokens, ['setToken']),
},
inputId: 'access-token-field',
};
</script>
<template>
<gl-alert variant="success" class="gl-mb-5" @dismiss="setToken(null)">
<input-copy-toggle-visibility
:copy-button-title="s__('AccessTokens|Copy token')"
:label="s__('AccessTokens|Your token')"
:label-for="$options.inputId"
:value="token"
:form-input-group-props="formInputGroupProps"
readonly
size="lg"
class="gl-mb-0"
>
<template #description>
{{ s__("AccessTokens|Make sure you save it - you won't be able to access it again.") }}
</template>
</input-copy-toggle-visibility>
</gl-alert>
</template>

View File

@ -0,0 +1,233 @@
<script>
import {
GlButton,
GlDatepicker,
GlForm,
GlFormCheckboxGroup,
GlFormCheckbox,
GlFormFields,
GlFormTextarea,
GlLink,
GlSprintf,
} from '@gitlab/ui';
import { formValidators } from '@gitlab/ui/dist/utils';
import { mapActions, mapState } from 'pinia';
import { helpPagePath } from '~/helpers/help_page_helper';
import { toISODateFormat } from '~/lib/utils/datetime_utility';
import { __, s__ } from '~/locale';
import { useAccessTokens } from '../stores/access_tokens';
import { defaultDate } from '../utils';
export default {
components: {
GlButton,
GlDatepicker,
GlForm,
GlFormCheckboxGroup,
GlFormCheckbox,
GlFormFields,
GlFormTextarea,
GlLink,
GlSprintf,
MaxExpirationDateMessage: () =>
import('ee_component/vue_shared/components/access_tokens/max_expiration_date_message.vue'),
},
inject: ['accessTokenMaxDate', 'accessTokenMinDate'],
data() {
const maxDate = this.accessTokenMaxDate ? new Date(this.accessTokenMaxDate) : null;
if (maxDate) {
this.$options.fields.expiresAt.validators.push(
formValidators.required(s__('AccessTokens|Expiration date is required.')),
);
}
const minDate = new Date(this.accessTokenMinDate);
const expiresAt = defaultDate(maxDate);
return { maxDate, minDate, values: { expiresAt } };
},
computed: {
...mapState(useAccessTokens, ['busy']),
},
methods: {
...mapActions(useAccessTokens, ['createToken', 'setShowCreateForm']),
clearDatepicker() {
this.values.expiresAt = null;
},
reset() {
this.setShowCreateForm(false);
},
submit() {
const expiresAt = this.values.expiresAt ? toISODateFormat(this.values.expiresAt) : null;
this.createToken({ ...this.values, expiresAt });
},
},
helpScopes: helpPagePath('user/profile/personal_access_tokens', {
anchor: 'personal-access-token-scopes',
}),
scopes: [
{
value: 'read_service_ping',
text: s__(
'AccessTokens|Grant access to download Service Ping payload via API when authenticated as an admin user.',
),
},
{
value: 'read_user',
text: s__(
'AccessTokens|Grants read-only access to your profile through the /user API endpoint, which includes username, public email, and full name. Also grants access to read-only API endpoints under /users.',
),
},
{
value: 'read_repository',
text: s__(
'AccessTokens|Grants read-only access to repositories on private projects using Git-over-HTTP or the Repository Files API.',
),
},
{
value: 'read_api',
text: s__(
'AccessTokens|Grants read access to the API, including all groups and projects, the container registry, and the package registry.',
),
},
{
value: 'self_rotate',
text: s__('AccessTokens|Grants permission for token to rotate itself.'),
},
{
value: 'write_repository',
text: s__(
'AccessTokens|Grants read-write access to repositories on private projects using Git-over-HTTP (not using the API).',
),
},
{
value: 'api',
text: s__(
'AccessTokens|Grants complete read/write access to the API, including all groups and projects, the container registry, the dependency proxy, and the package registry.',
),
},
{
value: 'ai_features',
text: s__('AccessTokens|Grants access to GitLab Duo related API endpoints.'),
},
{ value: 'create_runner', text: s__('AccessTokens|Grants create access to the runners.') },
{ value: 'manage_runner', text: s__('AccessTokens|Grants access to manage the runners.') },
{
value: 'k8s_proxy',
text: s__(
'AccessTokens|Grants permission to perform Kubernetes API calls using the agent for Kubernetes.',
),
},
{
value: 'sudo',
text: s__(
'AccessTokens|Grants permission to perform API actions as any user in the system, when authenticated as an admin user.',
),
},
{
value: 'admin_mode',
text: s__(
'AccessTokens|Grants permission to perform API actions as an administrator, when Admin Mode is enabled.',
),
},
],
fields: {
name: {
label: s__('AccessTokens|Token name'),
validators: [formValidators.required(s__('AccessTokens|Token name is required.'))],
groupAttrs: {
class: 'gl-form-input-xl',
},
},
description: {
label: s__('AccessTokens|Description'),
groupAttrs: {
optional: true,
'optional-text': __('(optional)'),
},
},
expiresAt: {
label: s__('AccessTokens|Expiration date'),
validators: [],
},
scopes: {
label: s__('AccessTokens|Select scopes'),
validators: [formValidators.required(s__('AccessTokens|At least one scope is required.'))],
},
},
};
</script>
<template>
<gl-form
id="token-create-form"
class="gl-rounded-base gl-bg-subtle gl-p-5"
@submit.prevent
@reset="reset"
>
<gl-form-fields
v-model="values"
form-id="token-create-form"
:fields="$options.fields"
@submit="submit"
>
<template #input(description)="{ id, input, value }">
<gl-form-textarea :id="id" :value="value" @input="input" />
</template>
<template #group(expiresAt)-description>
<max-expiration-date-message :max-date="maxDate" />
</template>
<template #input(expiresAt)="{ id, input, validation, value }">
<gl-datepicker
show-clear-button
:max-date="maxDate"
:min-date="minDate"
:input-id="id"
:state="validation.state"
:value="value"
:target="null"
@input="input"
@clear="clearDatepicker"
/>
</template>
<template #group(scopes)-label-description>
<gl-sprintf
:message="
s__(
'AccessTokens|Scopes set the permission levels granted to the token. %{linkStart}Learn more%{linkEnd}.',
)
"
>
<template #link="{ content }">
<gl-link :href="$options.helpScopes" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</template>
<template #input(scopes)="{ id, input, validation, value }">
<gl-form-checkbox-group :id="id" :state="validation.state" :checked="value" @input="input">
<gl-form-checkbox
v-for="scope in $options.scopes"
:key="scope.value"
:value="scope.value"
:state="validation.state"
>
{{ scope.value }}
<template #help>{{ scope.text }}</template>
</gl-form-checkbox>
</gl-form-checkbox-group>
</template>
</gl-form-fields>
<div class="gl-flex gl-gap-3">
<gl-button variant="confirm" type="submit" class="js-no-auto-disable" :loading="busy">
{{ s__('AccessTokens|Create token') }}
</gl-button>
<gl-button variant="default" type="reset">
{{ __('Cancel') }}
</gl-button>
</div>
</gl-form>
</template>

View File

@ -0,0 +1,41 @@
<script>
import { GlButton, GlCard } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { mapActions, mapState } from 'pinia';
import { useAccessTokens } from '../stores/access_tokens';
export default {
components: {
GlButton,
GlCard,
GlSingleStat,
},
computed: {
...mapState(useAccessTokens, ['statistics']),
},
methods: {
...mapActions(useAccessTokens, ['fetchTokens', 'setFilters', 'setPage']),
handleFilter(filters) {
this.setFilters(filters);
this.setPage(1);
this.fetchTokens();
},
},
};
</script>
<template>
<div class="gl-my-5 gl-grid gl-gap-4 sm:gl-grid-cols-2 lg:gl-grid-cols-4">
<gl-card v-for="statistic in statistics" :key="statistic.title">
<gl-single-stat class="!gl-p-0" :title="statistic.title" :value="statistic.value" />
<gl-button
class="mt-2"
:title="statistic.tooltipTitle"
variant="link"
@click="handleFilter(statistic.filters)"
>
{{ s__('AccessTokens|Filter list') }}
</gl-button>
</gl-card>
</div>
</template>

View File

@ -0,0 +1,306 @@
<script>
import {
GlBadge,
GlDisclosureDropdown,
GlIcon,
GlLink,
GlModal,
GlSprintf,
GlTable,
GlTooltipDirective,
} from '@gitlab/ui';
import { mapActions } from 'pinia';
import { helpPagePath } from '~/helpers/help_page_helper';
import { fallsBefore, nWeeksAfter } from '~/lib/utils/datetime_utility';
import { __, s__, sprintf } from '~/locale';
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserDate from '~/vue_shared/components/user_date.vue';
import { useAccessTokens } from '../stores/access_tokens';
const REVOKE = 'revoke';
const ROTATE = 'rotate';
export default {
components: {
GlBadge,
GlDisclosureDropdown,
GlIcon,
GlLink,
GlModal,
GlSprintf,
GlTable,
HelpIcon,
TimeAgoTooltip,
UserDate,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
busy: {
type: Boolean,
required: true,
},
tokens: {
type: Array,
required: true,
},
},
data() {
return {
action: REVOKE,
selectedToken: null,
showModal: false,
};
},
computed: {
actionPrimary() {
return {
text: this.$options.i18n.modal.button[this.action],
attributes: {
variant: 'danger',
},
};
},
modalTitle() {
return sprintf(
this.$options.i18n.modal.title[this.action],
{
tokenName: this.selectedToken?.name,
},
false,
);
},
},
methods: {
...mapActions(useAccessTokens, ['revokeToken', 'rotateToken']),
actionToken() {
if (this.action === REVOKE) {
this.revokeToken(this.selectedToken.id);
} else if (this.action === ROTATE) {
this.rotateToken(this.selectedToken.id, this.selectedToken.expiresAt);
}
},
isExpiring(expiresAt) {
if (expiresAt) {
return fallsBefore(new Date(expiresAt), nWeeksAfter(new Date(), 2));
}
return false;
},
options(item) {
return [
{
action: () => {
this.toggleModal({ action: ROTATE, item });
},
text: s__('AccessTokens|Rotate'),
},
{
action: () => {
this.toggleModal({ action: REVOKE, item });
},
text: s__('AccessTokens|Revoke'),
variant: 'danger',
},
];
},
toggleModal({ action, item }) {
this.action = action;
this.showModal = true;
this.selectedToken = item;
},
},
usage: helpPagePath('/user/profile/personal_access_tokens.md', {
anchor: 'view-token-usage-information',
}),
fields: [
{
key: 'name',
label: s__('AccessTokens|Name'),
},
{
key: 'status',
label: s__('AccessTokens|Status'),
},
{
formatter: (property) => (property?.length ? property.join(', ') : '-'),
key: 'scopes',
label: s__('AccessTokens|Scopes'),
tdAttr: { 'data-testid': 'cell-scopes' },
},
{
key: 'usage',
label: s__('AccessTokens|Usage'),
thAttr: { 'data-testid': 'header-usage' },
},
{
key: 'lifetime',
label: s__('AccessTokens|Lifetime'),
},
{
key: 'options',
label: '',
tdClass: 'gl-text-end',
},
],
i18n: {
modal: {
actionCancel: {
text: __('Cancel'),
},
button: {
revoke: s__('AccessTokens|Revoke'),
rotate: s__('AccessTokens|Rotate'),
},
message: {
revoke: s__(
'AccessTokens|Are you sure you want to revoke the token %{tokenName}? This action cannot be undone. Any tools that rely on this access token will stop working.',
),
rotate: s__(
'AccessTokens|Are you sure you want to rotate the token %{tokenName}? This action cannot be undone. Any tools that rely on this access token will stop working.',
),
},
title: {
revoke: s__("AccessTokens|Revoke the token '%{tokenName}'?"),
rotate: s__("AccessTokens|Rotate the token '%{tokenName}'?"),
},
},
},
};
</script>
<template>
<div>
<gl-table
:items="tokens"
:fields="$options.fields"
:empty-text="s__('AccessTokens|No access tokens')"
show-empty
stacked="md"
:busy="busy"
>
<template #head(usage)="{ label }">
<span>{{ label }}</span>
<gl-link :href="$options.usage"
><help-icon class="gl-ml-2" /><span class="gl-sr-only">{{
s__('AccessTokens|View token usage information')
}}</span></gl-link
>
</template>
<template #cell(name)="{ item: { name, description } }">
<div data-testid="field-name" class="gl-font-bold">{{ name }}</div>
<div v-if="description" data-testid="field-description" class="gl-mt-3">
{{ description }}
</div>
</template>
<template #cell(status)="{ item: { active, revoked, expiresAt } }">
<template v-if="active">
<template v-if="isExpiring(expiresAt)">
<gl-badge
v-gl-tooltip
:title="s__('AccessTokens|Token expires in less than two weeks.')"
variant="warning"
icon="expire"
icon-optically-aligned
>{{ s__('AccessTokens|Expiring') }}</gl-badge
>
</template>
<template v-else>
<gl-badge variant="success" icon="check-circle" icon-optically-aligned>{{
s__('AccessTokens|Active')
}}</gl-badge>
</template>
</template>
<template v-else-if="revoked">
<gl-badge variant="neutral" icon="remove" icon-optically-aligned>{{
s__('AccessTokens|Revoked')
}}</gl-badge>
</template>
<template v-else>
<gl-badge variant="neutral" icon="time-out" icon-optically-aligned>{{
s__('AccessTokens|Expired')
}}</gl-badge>
</template>
</template>
<template #cell(usage)="{ item: { lastUsedAt, lastUsedIps } }">
<div data-testid="field-last-used">
<span>{{ s__('AccessTokens|Last used:') }}</span>
<time-ago-tooltip v-if="lastUsedAt" :time="lastUsedAt" />
<template v-else>{{ __('Never') }}</template>
</div>
<div
v-if="lastUsedIps && lastUsedIps.length"
class="gl-mt-3"
data-testid="field-last-used-ips"
>
<gl-sprintf
:message="
n__('AccessTokens|IP: %{ips}', 'AccessTokens|IPs: %{ips}', lastUsedIps.length)
"
>
<template #ips>{{ lastUsedIps.join(', ') }}</template>
</gl-sprintf>
</div>
</template>
<template #cell(lifetime)="{ item: { createdAt, expiresAt } }">
<div class="gl-flex gl-flex-col gl-gap-3 gl-justify-self-end md:gl-justify-self-start">
<div class="gl-flex gl-gap-2 gl-whitespace-nowrap" data-testid="field-expires">
<gl-icon
v-gl-tooltip
:aria-label="s__('AccessTokens|Expires')"
:title="s__('AccessTokens|Expires')"
name="time-out"
/>
<time-ago-tooltip v-if="expiresAt" :time="expiresAt" />
<span v-else>{{ s__('AccessTokens|Never until revoked') }}</span>
</div>
<div class="gl-flex gl-gap-2 gl-whitespace-nowrap" data-testid="field-created">
<gl-icon
v-gl-tooltip
:aria-label="s__('AccessTokens|Created')"
:title="s__('AccessTokens|Created')"
name="clock"
/>
<user-date :date="createdAt" />
</div>
</div>
</template>
<template #cell(options)="{ item }">
<gl-disclosure-dropdown
v-if="item.active"
icon="ellipsis_v"
:no-caret="true"
:disabled="busy"
category="tertiary"
:fluid-width="true"
:items="options(item)"
/>
</template>
</gl-table>
<gl-modal
v-model="showModal"
:title="modalTitle"
:action-cancel="$options.i18n.modal.actionCancel"
:action-primary="actionPrimary"
modal-id="token-action-modal"
@primary="actionToken"
>
<gl-sprintf :message="$options.i18n.modal.message[action]">
<template #tokenName
><code>{{ selectedToken && selectedToken.name }}</code></template
>
</gl-sprintf>
</gl-modal>
</div>
</template>

View File

@ -0,0 +1,152 @@
<script>
import { GlButton, GlFilteredSearch, GlPagination, GlSorting } from '@gitlab/ui';
import { mapActions, mapState } from 'pinia';
import PageHeading from '~/vue_shared/components/page_heading.vue';
import { FILTER_OPTIONS, SORT_OPTIONS } from '~/access_tokens/constants';
import { useAccessTokens } from '../stores/access_tokens';
import AccessToken from './access_token.vue';
import AccessTokenForm from './access_token_form.vue';
import AccessTokenTable from './access_token_table.vue';
import AccessTokenStatistics from './access_token_statistics.vue';
export default {
components: {
GlButton,
GlFilteredSearch,
GlPagination,
GlSorting,
PageHeading,
AccessToken,
AccessTokenForm,
AccessTokenTable,
AccessTokenStatistics,
},
inject: ['accessTokenCreate', 'accessTokenRevoke', 'accessTokenRotate', 'accessTokenShow'],
props: {
id: {
type: Number,
required: true,
},
},
computed: {
...mapState(useAccessTokens, [
'busy',
'filters',
'page',
'perPage',
'showCreateForm',
'sorting',
'token',
'tokens',
'total',
]),
},
created() {
this.setup({
filters: [
{
type: 'state',
value: {
data: 'active',
operator: '=',
},
},
],
id: this.id,
urlCreate: this.accessTokenCreate,
urlRevoke: this.accessTokenRevoke,
urlRotate: this.accessTokenRotate,
urlShow: this.accessTokenShow,
});
this.fetchTokens();
},
methods: {
...mapActions(useAccessTokens, [
'fetchTokens',
'setFilters',
'setPage',
'setShowCreateForm',
'setSorting',
'setToken',
'setup',
]),
addAccessToken() {
this.setToken(null);
this.setShowCreateForm(true);
},
search(filters) {
this.setFilters(filters);
this.setPage(1);
this.fetchTokens();
},
async pageChanged(page) {
this.setPage(page);
await this.fetchTokens();
window.scrollTo({ top: 0 });
},
handleSortChange(value) {
this.setSorting({ value, isAsc: this.sorting.isAsc });
this.fetchTokens();
},
handleSortDirectionChange(isAsc) {
this.setSorting({ value: this.sorting.value, isAsc });
this.fetchTokens();
},
},
FILTER_OPTIONS,
SORT_OPTIONS,
};
</script>
<template>
<div>
<page-heading :heading="s__('AccessTokens|Personal access tokens')">
<template #description>
{{
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.',
)
}}
</template>
<template #actions>
<gl-button variant="confirm" data-testid="add-new-token-button" @click="addAccessToken">
{{ s__('AccessTokens|Add new token') }}
</gl-button>
</template>
</page-heading>
<access-token v-if="token" />
<access-token-form v-if="showCreateForm" />
<access-token-statistics />
<div class="gl-my-5 gl-flex gl-flex-col gl-gap-3 md:gl-flex-row">
<gl-filtered-search
class="gl-min-w-0 gl-grow"
:value="filters"
:placeholder="s__('AccessTokens|Search or filter access tokens…')"
:available-tokens="$options.FILTER_OPTIONS"
filtered-search-term-key="search"
terms-as-tokens
@submit="search"
/>
<gl-sorting
block
dropdown-class="gl-w-full !gl-flex"
:is-ascending="sorting.isAsc"
:sort-by="sorting.value"
:sort-options="$options.SORT_OPTIONS"
@sortByChange="handleSortChange"
@sortDirectionChange="handleSortDirectionChange"
/>
</div>
<access-token-table :busy="busy" :tokens="tokens" />
<gl-pagination
:value="page"
:per-page="perPage"
:total-items="total"
:disabled="busy"
align="center"
class="gl-mt-5"
@input="pageChanged"
/>
</div>
</template>

View File

@ -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<string|Filter>} Filters
*/
/**
* Fetch access tokens
*
* @param {Object} options
* @param {string} options.url
* @param {string|number} options.id
* @param {Object<string, string|number>} 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;
},
},
});

View File

@ -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<string, number|string>} */
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;
}

View File

@ -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(

View File

@ -1,10 +1,26 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { createAlert } from '~/alert';
import { restoreProject } from '~/rest_api';
import ListActions from '~/vue_shared/components/list_actions/list_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 { renderRestoreSuccessToast } from './utils';
export default {
name: 'ProjectListItemActionsCE',
name: 'ProjectListItemActions',
i18n: {
project: __('Project'),
restoreErrorMessage: s__(
'Projects|An error occurred restoring the project. Please refresh the page to try again.',
),
},
components: {
GlLoadingIcon,
ListActions,
},
props: {
@ -13,12 +29,20 @@ export default {
required: true,
},
},
data() {
return {
actionsLoading: false,
};
},
computed: {
actions() {
return {
[ACTION_EDIT]: {
href: this.project.editPath,
},
[ACTION_RESTORE]: {
action: this.onActionRestore,
},
[ACTION_DELETE]: {
action: this.onActionDelete,
},
@ -26,6 +50,19 @@ export default {
},
},
methods: {
async onActionRestore() {
this.actionsLoading = true;
try {
await restoreProject(this.project.id);
this.$emit('refetch');
renderRestoreSuccessToast(this.project, this.$options.i18n.project);
} catch (error) {
createAlert({ message: this.$options.i18n.restoreErrorMessage, error, captureError: true });
} finally {
this.actionsLoading = false;
}
},
onActionDelete() {
this.$emit('delete');
},
@ -34,5 +71,6 @@ export default {
</script>
<template>
<list-actions :actions="actions" :available-actions="project.availableActions" />
<gl-loading-icon v-if="actionsLoading" size="sm" class="gl-px-3" />
<list-actions v-else :actions="actions" :available-actions="project.availableActions" />
</template>

View File

@ -0,0 +1,47 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
export default {
name: 'ProjectListItemDelayedDeletionModalFooter',
components: {
GlSprintf,
GlLink,
},
props: {
project: {
type: Object,
required: true,
},
},
i18n: {
projectRestoreMessage: __(
'This project can be restored until %{date}. %{linkStart}Learn more%{linkEnd}.',
),
},
computed: {
showRestoreMessage() {
return this.project.isAdjournedDeletionEnabled && !this.project.markedForDeletionOn;
},
},
RESTORE_HELP_PATH: helpPagePath('user/project/working_with_projects', {
anchor: 'restore-a-project',
}),
};
</script>
<template>
<p
v-if="showRestoreMessage"
class="gl-mb-0 gl-mt-3 gl-text-subtle"
data-testid="delayed-delete-modal-footer"
>
<gl-sprintf :message="$options.i18n.projectRestoreMessage">
<template #date>{{ project.permanentDeletionDate }}</template>
<template #link="{ content }">
<gl-link :href="$options.RESTORE_HELP_PATH">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
</template>

View File

@ -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: {
/**

View File

@ -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 {};
};

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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} &middot;
- if group
%strong #{group.name} &middot;
- 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"

View File

@ -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

View File

@ -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)

View File

@ -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,

View File

@ -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')

View File

@ -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

View File

@ -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:
![A banner displays guidance on how to get started with GitLab Pipelines.](img/suggest_pipeline_banner_v14_5.png)
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.
![A banner displays guidance on how to get started with GitLab pipelines.](img/suggest_pipeline_banner_v14_5.png)
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).
![A banner prompting migration from Jenkins to GitLab CI](img/suggest_migrate_from_jenkins_v17_7.png)
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:
<!-- vale gitlab_base.CurrentStatus = NO -->
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
<!-- vale gitlab_base.CurrentStatus = YES -->
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.

View File

@ -22587,7 +22587,17 @@ Compute usage data for hosted runners on GitLab Dedicated.
| <a id="cidedicatedhostedrunnerusagebillingmonthiso8601"></a>`billingMonthIso8601` | [`ISO8601Date!`](#iso8601date) | Timestamp of the billing month in ISO 8601 format. |
| <a id="cidedicatedhostedrunnerusagecomputeminutes"></a>`computeMinutes` | [`Int!`](#int) | Total compute minutes used across all namespaces. |
| <a id="cidedicatedhostedrunnerusagedurationseconds"></a>`durationSeconds` | [`Int!`](#int) | Total duration in seconds of runner usage. |
| <a id="cidedicatedhostedrunnerusagerootnamespace"></a>`rootNamespace` | [`Namespace`](#namespace) | Namespace associated with the usage data. Null for instance aggregate data. |
| <a id="cidedicatedhostedrunnerusagerootnamespace"></a>`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 |
| ---- | ---- | ----------- |
| <a id="cideletednamespaceid"></a>`id` | [`NamespaceID`](#namespaceid) | ID of the deleted namespace. |
### `CiDeletedRunner`
@ -27721,6 +27731,27 @@ four standard [pagination arguments](#pagination-arguments):
| <a id="groupcomplianceframeworksids"></a>`ids` | [`[ComplianceManagementFrameworkID!]`](#compliancemanagementframeworkid) | List of Global IDs of compliance frameworks to return. |
| <a id="groupcomplianceframeworkssearch"></a>`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 |
| ---- | ---- | ----------- |
| <a id="groupcomponentversionscomponentname"></a>`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 |
| ---- | ---- | ----------- |
| <a id="projectcomponentversionscomponentid"></a>`componentId` | [`SbomComponentID!`](#sbomcomponentid) | Global ID of the SBoM component. |
| <a id="projectcomponentversionscomponentname"></a>`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.

View File

@ -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. |

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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).
- <i class="fa fa-youtube-play youtube" aria-hidden="true"></i> [GitLab Duo with Amazon Q - From idea to merge request](https://youtu.be/jxxzNst3jpo?si=QHO8JnPgMoFIllbL) <!-- Video published on 2025-04-17 -->
- For a click-through demo, see [the GitLab Duo with Amazon Q Product Tour](https://gitlab.navattic.com/duo-with-q).
<!-- Demo published on 2025-04-23 -->
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).

View File

@ -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<br/>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`<br>Premium tier: `100`<br>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

View File

@ -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.

View File

@ -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.

View File

@ -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`.

View File

@ -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

View File

@ -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)

View File

@ -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 >}}

View File

@ -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 >}}
<!-- markdownlint-disable -->
- 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.
<!-- This redirect file can be deleted after <2025-07-25>. -->
<!-- Redirects that point to other docs in the same project expire in three months. -->
<!-- Redirects that point to docs in a different project or site (for example, link is not relative and starts with `https:`) expire in one year. -->
<!-- Before deletion, see: https://docs.gitlab.com/development/documentation/redirects -->

View File

@ -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`.

View File

@ -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/<format>`. 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:

View File

@ -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)

View File

@ -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| Youre 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 ""

View File

@ -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

View File

@ -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

View File

@ -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))

View File

@ -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

View File

@ -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');

View File

@ -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);
});
});
});

View File

@ -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', () => {

View File

@ -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');

View File

@ -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);
});

View File

@ -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', () => {

View File

@ -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);

View File

@ -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,

View File

@ -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();
});

View File

@ -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();
});
});

View File

@ -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'],
}),
);
});
});
});
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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 <super token>',
};
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 <super token>');
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 <super token>'?",
actionPrimary: {
text: 'Revoke',
attributes: { variant: 'danger' },
},
actionCancel: {
text: 'Cancel',
},
});
expect(modal.text()).toBe(
'Are you sure you want to revoke the token My name <super token>? 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 <super token>'?",
actionPrimary: {
text: 'Rotate',
attributes: { variant: 'danger' },
},
actionCancel: {
text: 'Cancel',
},
});
expect(modal.text()).toBe(
'Are you sure you want to rotate the token My name <super token>? 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');
});
});
});

View File

@ -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);
});
});

View File

@ -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');
});
});
});
});

View File

@ -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);
});
});

View File

@ -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', () => {

View File

@ -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);

View File

@ -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]: `<span>${slotContent}</span>`,
},

View File

@ -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', () => {

View File

@ -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),
}));

View File

@ -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();
});
});
});

View File

@ -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);
});
},
);
});

View File

@ -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);
});
});
});

View File

@ -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,
});
});
});
});

View File

@ -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(() =>

View File

@ -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));
});
});

View File

@ -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();

View File

@ -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': '<div data-testid="left-action" />',

View File

@ -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');

View File

@ -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);

View File

@ -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('<b>Sample</b> title');

View File

@ -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)") }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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: '',

View File

@ -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
}