Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
e7bfce6d9f
commit
0eeadd3aec
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 : ''"
|
||||
|
|
|
|||
|
|
@ -135,6 +135,10 @@
|
|||
"PendingProjectMember",
|
||||
"ProjectMember"
|
||||
],
|
||||
"NamespaceUnion": [
|
||||
"CiDeletedNamespace",
|
||||
"Namespace"
|
||||
],
|
||||
"NoteableInterface": [
|
||||
"AlertManagementAlert",
|
||||
"BoardEpic",
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ export class ChunkWriter {
|
|||
}
|
||||
|
||||
abort() {
|
||||
this.balancer.abort();
|
||||
this.scheduleAccumulatorFlush.cancel();
|
||||
this.buffer = null;
|
||||
this.htmlStream.abort();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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: {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
-# @project is present when viewing Project's milestone
|
||||
- project = @project || issuable.project
|
||||
- group = milestone_issuable_group(issuable)
|
||||
- container = project || milestone_issuable_group(issuable)
|
||||
- labels = issuable.labels
|
||||
- assignees = issuable.assignees
|
||||
|
|
@ -8,11 +9,16 @@
|
|||
- base_url_args = [container]
|
||||
- issuable_type = is_merge_request ? :merge_requests : (is_epic ? :epics : :issues)
|
||||
- issuable_type_args = base_url_args + [issuable_type]
|
||||
- issuable_icon = is_epic ? 'epic' : "issue-type-#{issuable.respond_to?(:work_item_type) ? issuable.work_item_type.name.downcase : 'issue'}"
|
||||
|
||||
|
||||
%li{ class: '!gl-border-b-section' }
|
||||
%span
|
||||
= sprite_icon(issuable_icon)
|
||||
- if show_project_name && project
|
||||
%strong #{project.name} ·
|
||||
- if group
|
||||
%strong #{group.name} ·
|
||||
- if issuable.is_a?(Issue)
|
||||
= confidential_icon(issuable)
|
||||
= link_to issuable.title, ::Gitlab::UrlBuilder.build(issuable), title: issuable.title, class: "gl-text-default gl-break-words"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -176,20 +176,16 @@ To keep artifacts from the latest successful pipelines:
|
|||
|
||||
To allow artifacts to expire according to their expiration settings, clear the checkbox instead.
|
||||
|
||||
#### Display external redirect warning page
|
||||
#### Display or hide the external redirect warning page
|
||||
|
||||
Display a warning page when users view job artifacts through GitLab Pages.
|
||||
Control whether to display a warning page when users view job artifacts through GitLab Pages.
|
||||
This warning alerts about potential security risks from user-generated content.
|
||||
|
||||
By default, this setting is turned on.
|
||||
The external redirect warning page is displayed by default. To hide it:
|
||||
|
||||
To display the warning page when viewing job artifacts:
|
||||
|
||||
1. Select the **Enable the external redirect page for job artifacts** checkbox.
|
||||
1. Clear the **Enable the external redirect page for job artifacts** checkbox.
|
||||
1. Select **Save changes**.
|
||||
|
||||
To allow direct access to job artifacts without warnings, clear the checkbox instead.
|
||||
|
||||
### Jobs
|
||||
|
||||
#### Archive older jobs
|
||||
|
|
@ -217,14 +213,15 @@ To set up job archiving:
|
|||
|
||||
#### Protect CI/CD variables by default
|
||||
|
||||
To set all new [CI/CD variables](../../ci/variables/_index.md) as
|
||||
[protected](../../ci/variables/_index.md#protect-a-cicd-variable) by default:
|
||||
Set all new CI/CD variables in projects and groups to be protected by default.
|
||||
Protected variables are available only to pipelines that run on protected branches or protected tags.
|
||||
|
||||
1. On the left sidebar, at the bottom, select **Admin**.
|
||||
1. Select **Settings > CI/CD**.
|
||||
1. Select **Protect CI/CD variables by default**.
|
||||
To protect all new CI/CD variables by default:
|
||||
|
||||
#### Maximum includes
|
||||
1. Select the **Protect CI/CD variables by default** checkbox.
|
||||
1. Select **Save changes**.
|
||||
|
||||
#### Set maximum includes
|
||||
|
||||
{{< history >}}
|
||||
|
||||
|
|
@ -232,15 +229,18 @@ To set all new [CI/CD variables](../../ci/variables/_index.md) as
|
|||
|
||||
{{< /history >}}
|
||||
|
||||
The maximum number of [includes](../../ci/yaml/includes.md) per pipeline can be set for the entire instance.
|
||||
The default is `150`.
|
||||
Limit how many external YAML files a pipeline can include using the [`include` keyword](../../ci/yaml/includes.md).
|
||||
This limit prevents performance issues when pipelines include too many files.
|
||||
|
||||
1. On the left sidebar, at the bottom, select **Admin**.
|
||||
1. Select **Settings > CI/CD**.
|
||||
1. Change the value of **Maximum includes**.
|
||||
1. Select **Save changes** for the changes to take effect.
|
||||
By default, a pipeline can include up to 150 files.
|
||||
When a pipeline exceeds this limit, it fails with an error.
|
||||
|
||||
#### Maximum downstream pipeline trigger rate
|
||||
To set the maximum number of included files per pipeline:
|
||||
|
||||
1. Enter a value in the **Maximum includes** field.
|
||||
1. Select **Save changes**.
|
||||
|
||||
#### Limit downstream pipeline trigger rate
|
||||
|
||||
{{< history >}}
|
||||
|
||||
|
|
@ -248,42 +248,47 @@ The default is `150`.
|
|||
|
||||
{{< /history >}}
|
||||
|
||||
The maximum number of [downstream pipelines](../../ci/pipelines/downstream_pipelines.md) that can be triggered per minute
|
||||
(for a given project, user, and commit) can be set for the entire instance.
|
||||
The default value is `0` (no restriction).
|
||||
Restrict how many [downstream pipelines](../../ci/pipelines/downstream_pipelines.md)
|
||||
can be triggered per minute from a single source.
|
||||
|
||||
1. On the left sidebar, at the bottom, select **Admin**.
|
||||
1. Select **Settings > CI/CD**.
|
||||
1. Change the value of **Maximum downstream pipeline trigger rate**.
|
||||
1. Select **Save changes** for the changes to take effect.
|
||||
The maximum downstream pipeline trigger rate limits how many downstream pipelines
|
||||
can be triggered per minute for a given combination of project, user, and commit.
|
||||
The default value is `0`, which means there is no restriction.
|
||||
|
||||
#### Default CI/CD configuration file
|
||||
To set the maximum downstream pipeline trigger rate:
|
||||
|
||||
The default CI/CD configuration file and path for new projects can be set in the **Admin** area
|
||||
of your GitLab instance (`.gitlab-ci.yml` if not set):
|
||||
1. Enter a value in the **Maximum downstream pipeline trigger rate** field.
|
||||
1. Select **Save changes**.
|
||||
|
||||
1. On the left sidebar, at the bottom, select **Admin**.
|
||||
1. Select **Settings > CI/CD**.
|
||||
1. Input the new file and path in the **Default CI/CD configuration file** field.
|
||||
1. Select **Save changes** for the changes to take effect.
|
||||
#### Specify a default CI/CD configuration file
|
||||
|
||||
It is also possible to specify a [custom CI/CD configuration file for a specific project](../../ci/pipelines/settings.md#specify-a-custom-cicd-configuration-file).
|
||||
Set a custom path and filename to use as the default for CI/CD configuration files in all new projects.
|
||||
By default, GitLab uses the `.gitlab-ci.yml` file in the project's root directory.
|
||||
|
||||
#### Disable the pipeline suggestion banner
|
||||
This setting applies only to new projects created after you change it.
|
||||
Existing projects continue to use their current CI/CD configuration file path.
|
||||
|
||||
By default, a banner displays in merge requests with no pipeline suggesting a
|
||||
walkthrough on how to add one.
|
||||
To set a custom default CI/CD configuration file path:
|
||||
|
||||

|
||||
1. Enter a value in the **Default CI/CD configuration file** field.
|
||||
1. Select **Save changes**.
|
||||
|
||||
To disable the banner:
|
||||
Individual projects can override this instance default by
|
||||
[specifying a custom CI/CD configuration file](../../ci/pipelines/settings.md#specify-a-custom-cicd-configuration-file).
|
||||
|
||||
#### Display or hide the pipeline suggestion banner
|
||||
|
||||
Control whether to display a guidance banner in merge requests that have no pipelines.
|
||||
This banner provides a walkthrough on how to add a `.gitlab-ci.yml` file.
|
||||
|
||||

|
||||
|
||||
The pipeline suggestion banner is displayed by default. To hide it:
|
||||
|
||||
1. On the left sidebar, at the bottom, select **Admin**.
|
||||
1. Select **Settings > CI/CD**.
|
||||
1. Clear the **Enable pipeline suggestion banner** checkbox.
|
||||
1. Select **Save changes**.
|
||||
|
||||
#### Disable the migrate from Jenkins banner
|
||||
#### Display or hide the Jenkins migration banner
|
||||
|
||||
{{< history >}}
|
||||
|
||||
|
|
@ -291,15 +296,15 @@ To disable the banner:
|
|||
|
||||
{{< /history >}}
|
||||
|
||||
By default, a banner shows in merge requests in projects with the [Jenkins integration enabled](../../integration/jenkins.md) to prompt migration to GitLab CI/CD.
|
||||
Control whether to display a banner encouraging migration from Jenkins to GitLab CI/CD.
|
||||
This banner appears in merge requests for projects that have the
|
||||
[Jenkins integration enabled](../../integration/jenkins.md).
|
||||
|
||||

|
||||
|
||||
To disable the banner:
|
||||
The Jenkins migration banner is displayed by default. To hide it:
|
||||
|
||||
1. On the left sidebar, at the bottom, select **Admin**.
|
||||
1. Select **Settings > CI/CD**.
|
||||
1. Clear the **Show the migrate from Jenkins banner** checkbox.
|
||||
1. Select the **Show the migrate from Jenkins banner** checkbox.
|
||||
1. Select **Save changes**.
|
||||
|
||||
### Set CI/CD limits
|
||||
|
|
@ -314,27 +319,31 @@ To disable the banner:
|
|||
|
||||
{{< /history >}}
|
||||
|
||||
You can configure some [CI/CD limits](../instance_limits.md#cicd-limits)
|
||||
from the **Admin** area:
|
||||
Set CI/CD limits to control resource usage and help prevent performance issues.
|
||||
|
||||
You can configure the following CI/CD limits:
|
||||
|
||||
<!-- 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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 >}}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -704,6 +704,9 @@ msgstr ""
|
|||
msgid "%{commit_author_link} authored %{commit_authored_timeago} and %{commit_committer_avatar} %{commit_committer_link} committed %{commit_committer_timeago}"
|
||||
msgstr ""
|
||||
|
||||
msgid "%{completedCount} completed"
|
||||
msgstr ""
|
||||
|
||||
msgid "%{completedCount} completed weight"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -824,6 +827,9 @@ msgstr ""
|
|||
msgid "%{count} tags"
|
||||
msgstr ""
|
||||
|
||||
msgid "%{count} total"
|
||||
msgstr ""
|
||||
|
||||
msgid "%{count} total weight"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -1572,6 +1578,9 @@ msgstr ""
|
|||
msgid "%{total} remaining issue weight"
|
||||
msgstr ""
|
||||
|
||||
msgid "%{total} remaining weight"
|
||||
msgstr ""
|
||||
|
||||
msgid "%{total} warnings found: showing first %{warningsDisplayed}"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -1739,6 +1748,9 @@ msgstr ""
|
|||
msgid "(Unlimited compute minutes)"
|
||||
msgstr ""
|
||||
|
||||
msgid "(closed)"
|
||||
msgstr ""
|
||||
|
||||
msgid "(code expired)"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -1760,6 +1772,9 @@ msgstr ""
|
|||
msgid "(no user)"
|
||||
msgstr ""
|
||||
|
||||
msgid "(open and unassigned)"
|
||||
msgstr ""
|
||||
|
||||
msgid "(optional)"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -18264,6 +18279,9 @@ msgstr ""
|
|||
msgid "Couldn't reorder child due to an internal error."
|
||||
msgstr ""
|
||||
|
||||
msgid "Count"
|
||||
msgstr ""
|
||||
|
||||
msgid "Counter exceeded max value"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -38291,6 +38309,9 @@ msgstr ""
|
|||
msgid "MilestoneSidebar|Until"
|
||||
msgstr ""
|
||||
|
||||
msgid "MilestoneSidebar|Work items"
|
||||
msgstr ""
|
||||
|
||||
msgid "MilestoneSidebar|complete"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -38303,6 +38324,9 @@ msgstr ""
|
|||
msgid "Milestones| You’re about to permanently delete the milestone %{milestoneTitle}. This milestone is not currently used in any issues or merge requests."
|
||||
msgstr ""
|
||||
|
||||
msgid "Milestones|Completed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Milestones|Completed Issues (closed)"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -38327,6 +38351,9 @@ msgstr ""
|
|||
msgid "Milestones|No labels found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Milestones|Ongoing"
|
||||
msgstr ""
|
||||
|
||||
msgid "Milestones|Ongoing Issues (open and assigned)"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -38351,6 +38378,9 @@ msgstr ""
|
|||
msgid "Milestones|This action cannot be reversed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Milestones|Unstarted"
|
||||
msgstr ""
|
||||
|
||||
msgid "Milestones|Unstarted Issues (open and unassigned)"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -42223,6 +42253,9 @@ msgstr ""
|
|||
msgid "Open in Web IDE"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open in Workspace"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open in file view"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -57412,6 +57445,9 @@ msgstr[1] ""
|
|||
msgid "Showing %{limit} of %{total_count} issues. "
|
||||
msgstr ""
|
||||
|
||||
msgid "Showing %{limit} of %{total_count} items. "
|
||||
msgstr ""
|
||||
|
||||
msgid "Showing %{pageSize} of %{total} %{issuableType}"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(() =>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" />',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)") }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue