Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
df4287ff13
commit
f52c46075c
|
|
@ -422,6 +422,20 @@ update-patch:
|
|||
- !reference [.rules:test:omnibus-base, rules]
|
||||
- !reference [.rules:test:update-patch, rules]
|
||||
|
||||
# Job tests upgrade from previous internal release to latest if available
|
||||
# Requires GITLAB_QA_DEV_ACCESS_TOKEN to pull internal release
|
||||
update-patch-from-internal-to-internal:
|
||||
extends:
|
||||
- .omnibus-e2e
|
||||
- .update-script
|
||||
variables:
|
||||
UPDATE_TYPE: internal_patch
|
||||
QA_RSPEC_TAGS: --tag health_check
|
||||
rules:
|
||||
- !reference [.rules:test:update-jobs-never, rules]
|
||||
- !reference [.rules:test:omnibus-base, rules]
|
||||
- !reference [.rules:test:update-patch, rules]
|
||||
|
||||
update-from-patch-to-stable:
|
||||
extends:
|
||||
- .omnibus-e2e
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { buildApiUrl } from './api_utils';
|
|||
const GROUP_PATH = '/api/:version/groups/:id';
|
||||
const GROUPS_PATH = '/api/:version/groups.json';
|
||||
const GROUP_MEMBERS_PATH = '/api/:version/groups/:id/members';
|
||||
const GROUP_MEMBER_PATH = '/api/:version/groups/:id/members/:user_id';
|
||||
const GROUP_ALL_MEMBERS_PATH = '/api/:version/groups/:id/members/all';
|
||||
const DESCENDANT_GROUPS_PATH = '/api/:version/groups/:id/descendant_groups';
|
||||
const GROUP_TRANSFER_LOCATIONS_PATH = 'api/:version/groups/:id/transfer_locations';
|
||||
|
|
@ -72,6 +73,12 @@ export const getGroupMembers = (groupId, inherited = false, params = {}) => {
|
|||
return axios.get(url, { params });
|
||||
};
|
||||
|
||||
export const deleteGroupMember = (groupId, userId) => {
|
||||
const url = buildApiUrl(GROUP_MEMBER_PATH).replace(':id', groupId).replace(':user_id', userId);
|
||||
|
||||
return axios.delete(url);
|
||||
};
|
||||
|
||||
export const createGroup = (params) => {
|
||||
const url = buildApiUrl(GROUPS_PATH);
|
||||
return axios.post(url, params);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ export default {
|
|||
default: null,
|
||||
required: false,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'medium',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
emits: ['toggledPaused', 'deleted'],
|
||||
computed: {
|
||||
|
|
@ -45,13 +50,21 @@ export default {
|
|||
|
||||
<template>
|
||||
<gl-button-group>
|
||||
<runner-edit-button v-if="canUpdate && editUrl" :href="editUrl" />
|
||||
<slot><!-- space for other actions --></slot>
|
||||
<runner-edit-button v-if="canUpdate && editUrl" :size="size" :href="editUrl" />
|
||||
<runner-pause-button
|
||||
v-if="canUpdate"
|
||||
:runner="runner"
|
||||
:compact="true"
|
||||
:size="size"
|
||||
@toggledPaused="onToggledPaused"
|
||||
/>
|
||||
<runner-delete-button v-if="canDelete" :runner="runner" :compact="true" @deleted="onDeleted" />
|
||||
<runner-delete-button
|
||||
v-if="canDelete"
|
||||
:runner="runner"
|
||||
:compact="true"
|
||||
:size="size"
|
||||
@deleted="onDeleted"
|
||||
/>
|
||||
</gl-button-group>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ export default {
|
|||
required: false,
|
||||
default: false,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'medium',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
emits: ['deleted'],
|
||||
computed: {
|
||||
|
|
@ -73,6 +78,7 @@ export default {
|
|||
v-gl-tooltip="loading ? '' : tooltip"
|
||||
:aria-label="ariaLabel"
|
||||
:icon="loading ? '' : icon"
|
||||
:size="size"
|
||||
:class="buttonClass"
|
||||
:loading="loading"
|
||||
variant="danger"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ export default {
|
|||
required: false,
|
||||
default: null,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'medium',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
I18N_EDIT,
|
||||
};
|
||||
|
|
@ -26,6 +31,7 @@ export default {
|
|||
v-gl-tooltip="$options.I18N_EDIT"
|
||||
:aria-label="$options.I18N_EDIT"
|
||||
:href="href"
|
||||
:size="size"
|
||||
icon="pencil"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,11 @@ export default {
|
|||
required: false,
|
||||
default: false,
|
||||
},
|
||||
fixed: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
|
|
@ -124,10 +129,10 @@ export default {
|
|||
:items="runners"
|
||||
:fields="fields"
|
||||
:tbody-tr-attr="runnerTrAttr"
|
||||
:fixed="fixed"
|
||||
data-testid="runner-list"
|
||||
stacked="md"
|
||||
primary-key="id"
|
||||
fixed
|
||||
>
|
||||
<template #head(checkbox)>
|
||||
<runner-bulk-delete-checkbox :runners="runners" />
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ export default {
|
|||
required: false,
|
||||
default: false,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'medium',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
emits: ['toggledPaused'],
|
||||
computed: {
|
||||
|
|
@ -60,6 +65,7 @@ export default {
|
|||
<gl-button
|
||||
v-gl-tooltip="loading ? '' : tooltip"
|
||||
:icon="icon"
|
||||
:size="size"
|
||||
:aria-label="ariaLabel"
|
||||
:loading="loading"
|
||||
@click="onClick"
|
||||
|
|
|
|||
|
|
@ -53,11 +53,12 @@ export default {
|
|||
text: __('Cancel'),
|
||||
};
|
||||
},
|
||||
groupLeaveConfirmationMessage() {
|
||||
groupLeaveConfirmationTitle() {
|
||||
if (!this.targetGroup) {
|
||||
return '';
|
||||
}
|
||||
return sprintf(s__('GroupsTree|Are you sure you want to leave the "%{fullName}" group?'), {
|
||||
|
||||
return sprintf(s__('GroupsTree|Are you sure you want to leave "%{fullName}"?'), {
|
||||
fullName: this.targetGroup.fullName,
|
||||
});
|
||||
},
|
||||
|
|
@ -240,13 +241,24 @@ export default {
|
|||
<gl-modal
|
||||
modal-id="leave-group-modal"
|
||||
:visible="isModalVisible"
|
||||
:title="__('Are you sure?')"
|
||||
:title="groupLeaveConfirmationTitle"
|
||||
:action-primary="primaryProps"
|
||||
:action-cancel="cancelProps"
|
||||
@primary="leaveGroup"
|
||||
@hide="hideModal"
|
||||
>
|
||||
{{ groupLeaveConfirmationMessage }}
|
||||
<p>{{ s__('GroupsTree|When you leave this group:') }}</p>
|
||||
<ul>
|
||||
<li>{{ s__('GroupsTree|You lose access to all projects within this group') }}</li>
|
||||
<li>
|
||||
{{
|
||||
s__(
|
||||
'GroupsTree|Your assigned issues and merge requests remain, but you cannot view or modify them',
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
<li>{{ s__('GroupsTree|You need an invitation to rejoin') }}</li>
|
||||
</ul>
|
||||
</gl-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const formatGroup = (group) => ({
|
|||
updatedAt: group.updated_at,
|
||||
avatarUrl: group.avatar_url,
|
||||
userPermissions: {
|
||||
canLeave: group.can_leave,
|
||||
removeGroup: group.can_remove,
|
||||
viewEditPage: group.can_edit,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -261,7 +261,6 @@ export default {
|
|||
</ul>
|
||||
<div class="gl-ml-auto gl-hidden gl-items-center lg:gl-flex">
|
||||
<discussion-counter :blocks-merge="blocksMerge" hide-options />
|
||||
<submit-review-button v-if="glFeatures.improvedReviewExperience" class="gl-mr-3" />
|
||||
<div v-if="isSignedIn" :class="{ 'gl-flex gl-gap-3': isNotificationsTodosButtons }">
|
||||
<todo-widget
|
||||
:issuable-id="issuableId"
|
||||
|
|
@ -276,6 +275,7 @@ export default {
|
|||
issuable-type="merge_request"
|
||||
/>
|
||||
</div>
|
||||
<submit-review-button v-if="glFeatures.improvedReviewExperience" class="gl-ml-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ fragment Group on Group {
|
|||
createdAt
|
||||
updatedAt
|
||||
userPermissions {
|
||||
canLeave
|
||||
removeGroup
|
||||
viewEditPage
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
|
||||
import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
|
||||
import {
|
||||
ACTION_EDIT,
|
||||
ACTION_DELETE,
|
||||
ACTION_LEAVE,
|
||||
} from '~/vue_shared/components/list_actions/constants';
|
||||
import {
|
||||
TIMESTAMP_TYPE_CREATED_AT,
|
||||
TIMESTAMP_TYPE_UPDATED_AT,
|
||||
|
|
@ -14,6 +18,10 @@ const availableGroupActions = (userPermissions) => {
|
|||
baseActions.push(ACTION_EDIT);
|
||||
}
|
||||
|
||||
if (userPermissions.canLeave) {
|
||||
baseActions.push(ACTION_LEAVE);
|
||||
}
|
||||
|
||||
if (userPermissions.removeGroup) {
|
||||
baseActions.push(ACTION_DELETE);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ export default {
|
|||
class="gl-h-full"
|
||||
:title="badgeTitle"
|
||||
:aria-label="badgeTitle"
|
||||
tag="a"
|
||||
>
|
||||
{{ openMRsCountText }}
|
||||
</gl-badge>
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ function mountSubmitReviewButton(pinia) {
|
|||
el,
|
||||
pinia,
|
||||
render(h) {
|
||||
return h(SubmitReviewButton, { attrs: { class: 'gl-mr-3' } });
|
||||
return h(SubmitReviewButton, { attrs: { class: 'gl-ml-3' } });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,8 +19,7 @@ import {
|
|||
TODO_ACTION_TYPE_UNMERGEABLE,
|
||||
TODO_ACTION_TYPE_SSH_KEY_EXPIRED,
|
||||
TODO_ACTION_TYPE_SSH_KEY_EXPIRING_SOON,
|
||||
TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED,
|
||||
TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED,
|
||||
DUO_ACCESS_GRANTED_ACTIONS,
|
||||
} from '../constants';
|
||||
|
||||
export default {
|
||||
|
|
@ -61,15 +60,11 @@ export default {
|
|||
this.todo.action !== TODO_ACTION_TYPE_UNMERGEABLE &&
|
||||
this.todo.action !== TODO_ACTION_TYPE_SSH_KEY_EXPIRED &&
|
||||
this.todo.action !== TODO_ACTION_TYPE_SSH_KEY_EXPIRING_SOON &&
|
||||
this.todo.action !== TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED &&
|
||||
this.todo.action !== TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED
|
||||
!DUO_ACCESS_GRANTED_ACTIONS.includes(this.todo.action)
|
||||
);
|
||||
},
|
||||
showAvatarOnNote() {
|
||||
return (
|
||||
this.todo.action !== TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED &&
|
||||
this.todo.action !== TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED
|
||||
);
|
||||
return !DUO_ACCESS_GRANTED_ACTIONS.includes(this.todo.action);
|
||||
},
|
||||
author() {
|
||||
if (this.isHiddenBySaml) {
|
||||
|
|
@ -162,13 +157,7 @@ export default {
|
|||
name = s__('Todos|Your SSH key is expiring soon');
|
||||
}
|
||||
|
||||
if (this.todo.action === TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED) {
|
||||
name = s__(
|
||||
'Todos|You now have access to AI-powered features. Learn how to set up Code Suggestions and Chat in your IDE',
|
||||
);
|
||||
}
|
||||
|
||||
if (this.todo.action === TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED) {
|
||||
if (DUO_ACCESS_GRANTED_ACTIONS.includes(this.todo.action)) {
|
||||
name = s__(
|
||||
'Todos|You now have access to AI-powered features. Learn how to set up Code Suggestions and Chat in your IDE',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@ import {
|
|||
TODO_TARGET_TYPE_MERGE_REQUEST,
|
||||
TODO_TARGET_TYPE_PIPELINE,
|
||||
TODO_TARGET_TYPE_SSH_KEY,
|
||||
TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED,
|
||||
TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED,
|
||||
DUO_ACCESS_GRANTED_ACTIONS,
|
||||
} from '../constants';
|
||||
|
||||
export default {
|
||||
|
|
@ -44,10 +43,7 @@ export default {
|
|||
return this.todo.action === TODO_ACTION_TYPE_MEMBER_ACCESS_REQUESTED;
|
||||
},
|
||||
isDuoActionType() {
|
||||
return (
|
||||
this.todo.action === TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED ||
|
||||
this.todo.action === TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED
|
||||
);
|
||||
return DUO_ACCESS_GRANTED_ACTIONS.includes(this.todo.action);
|
||||
},
|
||||
issuableType() {
|
||||
if (this.isMergeRequest) {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,13 @@ export const TODO_ACTION_TYPE_SSH_KEY_EXPIRED = 'ssh_key_expired';
|
|||
export const TODO_ACTION_TYPE_SSH_KEY_EXPIRING_SOON = 'ssh_key_expiring_soon';
|
||||
export const TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED = 'duo_pro_access_granted';
|
||||
export const TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED = 'duo_enterprise_access_granted';
|
||||
export const TODO_ACTION_TYPE_DUO_CORE_ACCESS_GRANTED = 'duo_core_access_granted';
|
||||
|
||||
export const DUO_ACCESS_GRANTED_ACTIONS = [
|
||||
TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED,
|
||||
TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED,
|
||||
TODO_ACTION_TYPE_DUO_CORE_ACCESS_GRANTED,
|
||||
];
|
||||
|
||||
export const TODO_EMPTY_TITLE_POOL = [
|
||||
s__("Todos|Good job! Looks like you don't have anything left on your To-Do List"),
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export default {
|
|||
<token-permissions v-if="glFeatures.allowPushRepositoryForJobToken" />
|
||||
<inbound-token-access />
|
||||
<auth-log />
|
||||
<outbound-token-access />
|
||||
<outbound-token-access v-if="!glFeatures.removeLimitCiJobTokenScope" />
|
||||
</template>
|
||||
</gl-intersection-observer>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -167,6 +167,9 @@ export const useAccessTokens = defineStore('accessTokens', {
|
|||
message: s__('AccessTokens|The token was revoked successfully.'),
|
||||
variant: 'success',
|
||||
});
|
||||
// Reset the token to avoid a situation where a token is created or rotated and then revoked,
|
||||
// but the `Your token` banner still displays the token.
|
||||
this.token = null;
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
<script>
|
||||
import { GlModal } from '@gitlab/ui';
|
||||
import { sprintf } from '@gitlab/ui/dist/utils/i18n';
|
||||
import { renderLeaveSuccessToast } from '~/vue_shared/components/groups_list/utils';
|
||||
import { createAlert } from '~/alert';
|
||||
import { s__, __ } from '~/locale';
|
||||
import { deleteGroupMember } from '~/api/groups_api';
|
||||
|
||||
export default {
|
||||
name: 'GroupListItemLeaveModal',
|
||||
components: {
|
||||
GlModal,
|
||||
},
|
||||
model: {
|
||||
prop: 'visible',
|
||||
event: 'change',
|
||||
},
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
modalId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
group: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
modal: {
|
||||
actionCancel: { text: __('Cancel') },
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
return sprintf(s__('GroupsTree|Are you sure you want to leave "%{fullName}"?'), {
|
||||
fullName: this.group.fullName,
|
||||
});
|
||||
},
|
||||
actionPrimary() {
|
||||
return {
|
||||
text: s__('GroupsTree|Leave group'),
|
||||
attributes: {
|
||||
variant: 'danger',
|
||||
loading: this.isLoading,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async handlePrimary() {
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
await deleteGroupMember(this.group.id, gon.current_user_id);
|
||||
this.$emit('success');
|
||||
renderLeaveSuccessToast(this.group);
|
||||
} catch (error) {
|
||||
createAlert({
|
||||
message: s__(
|
||||
'GroupsTree|An error occurred while leaving the group. Please refresh the page to try again.',
|
||||
),
|
||||
error,
|
||||
captureError: true,
|
||||
});
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<gl-modal
|
||||
v-bind="$options.modal"
|
||||
:modal-id="modalId"
|
||||
:visible="visible"
|
||||
:title="title"
|
||||
:action-primary="actionPrimary"
|
||||
@primary.prevent="handlePrimary"
|
||||
@change="$emit('change')"
|
||||
>
|
||||
<p>{{ s__('GroupsTree|When you leave this group:') }}</p>
|
||||
<ul>
|
||||
<li>{{ s__('GroupsTree|You lose access to all projects within this group') }}</li>
|
||||
<li>
|
||||
{{
|
||||
s__(
|
||||
'GroupsTree|Your assigned issues and merge requests remain, but you cannot view or modify them',
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
<li>{{ s__('GroupsTree|You need an invitation to rejoin') }}</li>
|
||||
</ul>
|
||||
</gl-modal>
|
||||
</template>
|
||||
|
|
@ -9,7 +9,11 @@ import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/visibility_level/
|
|||
import { ACCESS_LEVEL_LABELS, ACCESS_LEVEL_NO_ACCESS_INTEGER } from '~/access_level/constants';
|
||||
import { __ } from '~/locale';
|
||||
import { numberToMetricPrefix } from '~/lib/utils/number_utils';
|
||||
import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
|
||||
import {
|
||||
ACTION_EDIT,
|
||||
ACTION_DELETE,
|
||||
ACTION_LEAVE,
|
||||
} from '~/vue_shared/components/list_actions/constants';
|
||||
import {
|
||||
TIMESTAMP_TYPES,
|
||||
TIMESTAMP_TYPE_CREATED_AT,
|
||||
|
|
@ -17,6 +21,7 @@ import {
|
|||
import ListItem from '~/vue_shared/components/resource_lists/list_item.vue';
|
||||
import ListItemStat from '~/vue_shared/components/resource_lists/list_item_stat.vue';
|
||||
import { renderDeleteSuccessToast, deleteParams } from '~/vue_shared/components/groups_list/utils';
|
||||
import GroupListItemLeaveModal from '~/vue_shared/components/groups_list/group_list_item_leave_modal.vue';
|
||||
import GroupListItemPreventDeleteModal from './group_list_item_prevent_delete_modal.vue';
|
||||
import GroupListItemInactiveBadge from './group_list_item_inactive_badge.vue';
|
||||
|
||||
|
|
@ -34,6 +39,7 @@ export default {
|
|||
ListItemStat,
|
||||
GlIcon,
|
||||
GlBadge,
|
||||
GroupListItemLeaveModal,
|
||||
GroupListItemPreventDeleteModal,
|
||||
GroupListItemDeleteModal,
|
||||
GroupListItemInactiveBadge,
|
||||
|
|
@ -68,7 +74,8 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
isDeleteModalVisible: false,
|
||||
isDeleteLoading: false,
|
||||
isDeleteModalLoading: false,
|
||||
isLeaveModalVisible: false,
|
||||
modalId: uniqueId('groups-list-item-modal-id-'),
|
||||
};
|
||||
},
|
||||
|
|
@ -111,34 +118,49 @@ export default {
|
|||
[ACTION_DELETE]: {
|
||||
action: this.onActionDelete,
|
||||
},
|
||||
[ACTION_LEAVE]: {
|
||||
action: this.onActionLeave,
|
||||
},
|
||||
};
|
||||
},
|
||||
hasActionDelete() {
|
||||
return this.group.availableActions?.includes(ACTION_DELETE);
|
||||
},
|
||||
hasActionLeave() {
|
||||
return this.group.availableActions?.includes(ACTION_LEAVE);
|
||||
},
|
||||
hasFooterAction() {
|
||||
return this.hasActionDelete || this.hasActionLeave;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onActionDelete() {
|
||||
this.isDeleteModalVisible = true;
|
||||
},
|
||||
onModalChange(isVisible) {
|
||||
onDeleteModalChange(isVisible) {
|
||||
this.isDeleteModalVisible = isVisible;
|
||||
},
|
||||
async onDeleteModalConfirm() {
|
||||
this.isDeleteLoading = true;
|
||||
this.isDeleteModalLoading = true;
|
||||
|
||||
try {
|
||||
await axios.delete(`/${this.group.fullPath}`, {
|
||||
params: deleteParams(this.group),
|
||||
});
|
||||
this.$emit('refetch');
|
||||
this.refetch();
|
||||
renderDeleteSuccessToast(this.group);
|
||||
} catch (error) {
|
||||
createAlert({ message: this.$options.i18n.deleteErrorMessage, error, captureError: true });
|
||||
} finally {
|
||||
this.isDeleteLoading = false;
|
||||
this.isDeleteModalLoading = false;
|
||||
}
|
||||
},
|
||||
onActionLeave() {
|
||||
this.isLeaveModalVisible = true;
|
||||
},
|
||||
refetch() {
|
||||
this.$emit('refetch');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -189,23 +211,34 @@ export default {
|
|||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="hasActionDelete" #footer>
|
||||
<group-list-item-prevent-delete-modal
|
||||
v-if="group.isLinkedToSubscription"
|
||||
:visible="isDeleteModalVisible"
|
||||
:modal-id="modalId"
|
||||
@change="onModalChange"
|
||||
/>
|
||||
<group-list-item-delete-modal
|
||||
v-else
|
||||
:visible="isDeleteModalVisible"
|
||||
:modal-id="modalId"
|
||||
:phrase="group.fullName"
|
||||
:confirm-loading="isDeleteLoading"
|
||||
:group="group"
|
||||
@confirm.prevent="onDeleteModalConfirm"
|
||||
@change="onModalChange"
|
||||
/>
|
||||
<template v-if="hasFooterAction" #footer>
|
||||
<template v-if="hasActionDelete">
|
||||
<group-list-item-prevent-delete-modal
|
||||
v-if="group.isLinkedToSubscription"
|
||||
:visible="isDeleteModalVisible"
|
||||
:modal-id="modalId"
|
||||
@change="onDeleteModalChange"
|
||||
/>
|
||||
<group-list-item-delete-modal
|
||||
v-else
|
||||
:visible="isDeleteModalVisible"
|
||||
:modal-id="modalId"
|
||||
:phrase="group.fullName"
|
||||
:confirm-loading="isDeleteModalLoading"
|
||||
:group="group"
|
||||
@confirm.prevent="onDeleteModalConfirm"
|
||||
@change="onDeleteModalChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="hasActionLeave">
|
||||
<group-list-item-leave-modal
|
||||
v-model="isLeaveModalVisible"
|
||||
:modal-id="modalId"
|
||||
:group="group"
|
||||
@success="refetch"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #children>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ export const renderDeleteSuccessToast = (item) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const renderLeaveSuccessToast = (group) => {
|
||||
toast(
|
||||
sprintf(__("Left the '%{group_name}' group successfully."), {
|
||||
group_name: group.fullName,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteParams = (item) => {
|
||||
// If delayed deletion is disabled or the project/group is not yet marked for deletion
|
||||
if (!item.isAdjournedDeletionEnabled || !item.markedForDeletionOn) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { __ } from '~/locale';
|
||||
|
||||
export const ACTION_EDIT = 'edit';
|
||||
export const ACTION_LEAVE = 'leave';
|
||||
export const ACTION_RESTORE = 'restore';
|
||||
export const ACTION_DELETE = 'delete';
|
||||
|
||||
|
|
@ -13,9 +14,14 @@ export const BASE_ACTIONS = {
|
|||
text: __('Restore'),
|
||||
order: 2,
|
||||
},
|
||||
[ACTION_DELETE]: {
|
||||
text: __('Delete'),
|
||||
[ACTION_LEAVE]: {
|
||||
text: __('Leave group'),
|
||||
variant: 'danger',
|
||||
order: 3,
|
||||
},
|
||||
[ACTION_DELETE]: {
|
||||
text: __('Delete'),
|
||||
variant: 'danger',
|
||||
order: 4,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export default {
|
|||
:timestamp-type="timestampType"
|
||||
:initial-expanded="initialExpanded"
|
||||
@load-children="$emit('load-children', $event)"
|
||||
@refetch="$emit('refetch')"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -98,12 +98,15 @@ export default {
|
|||
this.$emit('load-children', this.item.id);
|
||||
}
|
||||
},
|
||||
onRefetch() {
|
||||
this.$emit('refetch');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="itemComponent" v-bind="itemProps">
|
||||
<component :is="itemComponent" v-bind="itemProps" @refetch="onRefetch">
|
||||
<template v-if="item.hasChildren" #children-toggle>
|
||||
<gl-button v-bind="expandButtonProps" @click="onNestedItemsToggleClick" />
|
||||
</template>
|
||||
|
|
@ -116,6 +119,7 @@ export default {
|
|||
:initial-expanded="initialExpanded"
|
||||
:class="nestedItemsContainerClasses"
|
||||
@load-children="$emit('load-children', $event)"
|
||||
@refetch="onRefetch"
|
||||
/>
|
||||
</template>
|
||||
</component>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ import {
|
|||
I18N_WORK_ITEM_ERROR_CREATING,
|
||||
sprintfWorkItem,
|
||||
i18n,
|
||||
NAME_TO_LOWERCASE_TEXT_MAP,
|
||||
NAME_TO_TEXT_LOWERCASE_MAP,
|
||||
NAME_TO_TEXT_MAP,
|
||||
WIDGET_TYPE_ASSIGNEES,
|
||||
WIDGET_TYPE_COLOR,
|
||||
|
|
@ -314,7 +314,7 @@ export default {
|
|||
return getDisplayReference(this.selectedProjectFullPath, this.relatedItem.reference);
|
||||
},
|
||||
relatedItemType() {
|
||||
return NAME_TO_LOWERCASE_TEXT_MAP[this.relatedItem?.type];
|
||||
return NAME_TO_TEXT_LOWERCASE_MAP[this.relatedItem?.type];
|
||||
},
|
||||
workItemAssignees() {
|
||||
return findWidget(WIDGET_TYPE_ASSIGNEES, this.workItem);
|
||||
|
|
@ -489,7 +489,7 @@ export default {
|
|||
? this.$options.i18n.resolveOneThreadText
|
||||
: this.$options.i18n.resolveAllThreadsText;
|
||||
return sprintf(warning, {
|
||||
workItemType: this.selectedWorkItemTypeName,
|
||||
workItemType: NAME_TO_TEXT_LOWERCASE_MAP[this.selectedWorkItemTypeName],
|
||||
});
|
||||
},
|
||||
isFormFilled() {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { GlButton, GlModal } from '@gitlab/ui';
|
||||
import { s__, sprintf } from '~/locale';
|
||||
import { NAME_TO_LOWERCASE_TEXT_MAP } from '../constants';
|
||||
import { NAME_TO_TEXT_LOWERCASE_MAP } from '../constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
|
@ -22,7 +22,7 @@ export default {
|
|||
cancelConfirmationText() {
|
||||
return sprintf(
|
||||
s__('WorkItem|Are you sure you want to cancel creating this %{workItemType}?'),
|
||||
{ workItemType: NAME_TO_LOWERCASE_TEXT_MAP[this.workItemType] },
|
||||
{ workItemType: NAME_TO_TEXT_LOWERCASE_MAP[this.workItemType] },
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { __, s__ } from '~/locale';
|
|||
import { isMetaClick } from '~/lib/utils/common_utils';
|
||||
import { newWorkItemPath } from '~/work_items/utils';
|
||||
import {
|
||||
NAME_TO_LOWERCASE_TEXT_MAP,
|
||||
NAME_TO_TEXT_LOWERCASE_MAP,
|
||||
sprintfWorkItem,
|
||||
ROUTES,
|
||||
RELATED_ITEM_ID_URL_QUERY_PARAM,
|
||||
|
|
@ -136,7 +136,7 @@ export default {
|
|||
});
|
||||
},
|
||||
selectedWorkItemTypeLowercase() {
|
||||
return NAME_TO_LOWERCASE_TEXT_MAP[this.selectedWorkItemTypeName];
|
||||
return NAME_TO_TEXT_LOWERCASE_MAP[this.selectedWorkItemTypeName];
|
||||
},
|
||||
newWorkItemButtonText() {
|
||||
return this.alwaysShowWorkItemTypeSelect && this.selectedWorkItemTypeName
|
||||
|
|
|
|||
|
|
@ -93,6 +93,16 @@ export default {
|
|||
required: false,
|
||||
default: false,
|
||||
},
|
||||
canAddDesign: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
canUpdateDesign: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
apollo: {
|
||||
designCollection: {
|
||||
|
|
@ -411,7 +421,7 @@ export default {
|
|||
|
||||
<template #actions>
|
||||
<gl-button
|
||||
v-if="isLatestVersion"
|
||||
v-if="isLatestVersion && canUpdateDesign"
|
||||
category="tertiary"
|
||||
size="small"
|
||||
variant="link"
|
||||
|
|
@ -423,7 +433,7 @@ export default {
|
|||
{{ selectAllButtonText }}
|
||||
</gl-button>
|
||||
<archive-design-button
|
||||
v-if="isLatestVersion"
|
||||
v-if="isLatestVersion && canUpdateDesign"
|
||||
data-testid="archive-button"
|
||||
button-class="work-item-design-hidden-xs work-item-design-show-sm"
|
||||
:has-selected-designs="hasSelectedDesigns"
|
||||
|
|
@ -433,7 +443,7 @@ export default {
|
|||
{{ $options.i18n.archiveDesignText }}
|
||||
</archive-design-button>
|
||||
<archive-design-button
|
||||
v-if="isLatestVersion"
|
||||
v-if="isLatestVersion && canUpdateDesign"
|
||||
v-gl-tooltip.bottom
|
||||
data-testid="archive-button"
|
||||
button-class="work-item-design-hidden-sm"
|
||||
|
|
@ -445,6 +455,7 @@ export default {
|
|||
@archive-selected-designs="onArchiveDesign"
|
||||
/>
|
||||
<gl-button
|
||||
v-if="canAddDesign"
|
||||
size="small"
|
||||
data-testid="add-design"
|
||||
:disabled="isSaving"
|
||||
|
|
@ -472,7 +483,8 @@ export default {
|
|||
>
|
||||
{{ error || uploadError }}
|
||||
</gl-alert>
|
||||
<design-dropzone
|
||||
<component
|
||||
:is="canAddDesign ? 'design-dropzone' : 'div'"
|
||||
show-upload-design-overlay
|
||||
validate-design-upload-on-dragover
|
||||
:accept-design-formats="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
|
||||
|
|
@ -514,7 +526,7 @@ export default {
|
|||
/>
|
||||
|
||||
<gl-form-checkbox
|
||||
v-if="isLatestVersion"
|
||||
v-if="isLatestVersion && canUpdateDesign"
|
||||
:id="`design-checkbox-${design.id}`"
|
||||
:name="design.filename"
|
||||
:checked="isDesignSelected(design.filename)"
|
||||
|
|
@ -526,7 +538,7 @@ export default {
|
|||
/>
|
||||
</li>
|
||||
</vue-draggable>
|
||||
</design-dropzone>
|
||||
</component>
|
||||
</template>
|
||||
</crud-component>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -111,11 +111,15 @@ export default {
|
|||
},
|
||||
update(data) {
|
||||
const { event, image, imageV432x230 } = data.designManagement.designAtVersion;
|
||||
const {
|
||||
userPermissions: { updateDesign },
|
||||
} = data.designManagement.designAtVersion.design.issue;
|
||||
return {
|
||||
...data.designManagement.designAtVersion.design,
|
||||
event,
|
||||
image,
|
||||
imageV432x230,
|
||||
canUpdateDesign: updateDesign,
|
||||
};
|
||||
},
|
||||
result({ data }) {
|
||||
|
|
@ -211,6 +215,9 @@ export default {
|
|||
isAnnotating() {
|
||||
return Boolean(this.annotationCoordinates);
|
||||
},
|
||||
canUpdateDesign() {
|
||||
return this.design.canUpdateDesign || false;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
resolvedDiscussions(val) {
|
||||
|
|
@ -366,6 +373,7 @@ export default {
|
|||
:is-latest-version="isLatestVersion"
|
||||
:all-designs="allDesigns"
|
||||
:current-user-design-todos="currentUserDesignTodos"
|
||||
:can-update-design="canUpdateDesign"
|
||||
@toggle-sidebar="toggleSidebar"
|
||||
@archive-design="onArchiveDesign"
|
||||
@todosUpdated="updateWorkItemDesignCurrentTodosWidgetCache"
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ export default {
|
|||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
canUpdateDesign: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
toggleCommentsButtonLabel() {
|
||||
|
|
@ -116,7 +120,7 @@ export default {
|
|||
:aria-label="$options.i18n.downloadButtonLabel"
|
||||
/>
|
||||
<archive-design-button
|
||||
v-if="isLatestVersion"
|
||||
v-if="isLatestVersion && canUpdateDesign"
|
||||
v-gl-tooltip.bottom
|
||||
button-size="medium"
|
||||
:title="$options.i18n.archiveButtonLabel"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import * as Sentry from '~/sentry/sentry_browser_wrapper';
|
|||
import { __, sprintf } from '~/locale';
|
||||
import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
|
||||
import ReplyButton from '~/notes/components/note_actions/reply_button.vue';
|
||||
import { NAME_TO_TEXT_LOWERCASE_MAP } from '../../constants';
|
||||
import { getMutation, optimisticAwardUpdate, getNewCustomEmojiPath } from '../../notes/award_utils';
|
||||
|
||||
export default {
|
||||
|
|
@ -135,7 +136,7 @@ export default {
|
|||
},
|
||||
displayAuthorBadgeText() {
|
||||
return sprintf(__('This user is the author of this %{workItemType}.'), {
|
||||
workItemType: this.workItemType.toLowerCase(),
|
||||
workItemType: NAME_TO_TEXT_LOWERCASE_MAP[this.workItemType],
|
||||
});
|
||||
},
|
||||
displayMemberBadgeText() {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { __, s__, sprintf } from '~/locale';
|
|||
import { findDesignsWidget, getParentGroupName, isMilestoneWidget } from '~/work_items/utils';
|
||||
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import {
|
||||
NAME_TO_TEXT_LOWERCASE_MAP,
|
||||
NAME_TO_TEXT_MAP,
|
||||
ALLOWED_CONVERSION_TYPES,
|
||||
sprintfWorkItem,
|
||||
|
|
@ -375,7 +376,7 @@ export default {
|
|||
),
|
||||
{
|
||||
workItemType: NAME_TO_TEXT_MAP[this.selectedWorkItemType.name],
|
||||
childItemType: this.allowedChildTypes?.[0]?.name?.toLocaleLowerCase(),
|
||||
childItemType: NAME_TO_TEXT_LOWERCASE_MAP[this.allowedChildTypes?.[0]?.name],
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ import WorkItemCreateBranchMergeRequestSplitButton from './work_item_development
|
|||
|
||||
const defaultWorkspacePermissions = {
|
||||
createDesign: false,
|
||||
updateDesign: false,
|
||||
moveDesign: false,
|
||||
};
|
||||
|
||||
|
|
@ -396,7 +397,7 @@ export default {
|
|||
return this.findWidget(WIDGET_TYPE_DESIGNS) && (this.$router || this.isBoard);
|
||||
},
|
||||
showUploadDesign() {
|
||||
return this.hasDesignWidget && this.workspacePermissions.createDesign;
|
||||
return this.hasDesignWidget && this.canAddDesign;
|
||||
},
|
||||
canReorderDesign() {
|
||||
return this.hasDesignWidget && this.workspacePermissions.moveDesign;
|
||||
|
|
@ -590,6 +591,12 @@ export default {
|
|||
const rootPath = this.workItem?.namespace?.webUrl || '';
|
||||
return this.isGroupWorkItem ? `${rootPath}/-/uploads` : `${rootPath}/uploads`;
|
||||
},
|
||||
canAddDesign() {
|
||||
return this.workspacePermissions.createDesign;
|
||||
},
|
||||
canUpdateDesign() {
|
||||
return this.workspacePermissions.updateDesign;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
addShortcutsExtension(ShortcutsWorkItems);
|
||||
|
|
@ -733,7 +740,7 @@ export default {
|
|||
this.editMode = false;
|
||||
},
|
||||
isValidDesignUpload(files) {
|
||||
if (!this.workspacePermissions.createDesign) return false;
|
||||
if (!this.canAddDesign) return false;
|
||||
|
||||
if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) {
|
||||
this.designUploadError = MAXIMUM_FILE_UPLOAD_LIMIT_REACHED;
|
||||
|
|
@ -1166,6 +1173,8 @@ export default {
|
|||
:is-saving="isSaving"
|
||||
:can-reorder-design="canReorderDesign"
|
||||
:is-board="isBoard"
|
||||
:can-add-design="canAddDesign"
|
||||
:can-update-design="canUpdateDesign"
|
||||
@upload="onUploadDesign"
|
||||
@dismissError="designUploadError = null"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
WIDGET_TYPE_MILESTONE,
|
||||
WIDGET_TYPE_ITERATION,
|
||||
WORK_ITEM_TYPE_NAME_TASK,
|
||||
NAME_TO_TEXT_LOWERCASE_MAP,
|
||||
NAME_TO_TEXT_MAP,
|
||||
} from '../../constants';
|
||||
import WorkItemProjectsListbox from './work_item_projects_listbox.vue';
|
||||
|
|
@ -263,8 +264,8 @@ export default {
|
|||
),
|
||||
{
|
||||
invalidWorkItemsList: this.invalidWorkItemsToAdd.map(({ title }) => title).join(', '),
|
||||
childWorkItemType: this.childrenTypeText,
|
||||
parentWorkItemType: this.parentWorkItemType,
|
||||
childWorkItemType: NAME_TO_TEXT_LOWERCASE_MAP[this.childrenTypeText],
|
||||
parentWorkItemType: NAME_TO_TEXT_LOWERCASE_MAP[this.parentWorkItemType],
|
||||
},
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
WIDGET_TYPE_HIERARCHY,
|
||||
INJECTION_LINK_CHILD_PREVENT_ROUTER_NAVIGATION,
|
||||
DETAIL_VIEW_QUERY_PARAM_NAME,
|
||||
NAME_TO_LOWERCASE_TEXT_MAP,
|
||||
NAME_TO_TEXT_LOWERCASE_MAP,
|
||||
NAME_TO_TEXT_MAP,
|
||||
} from '../../constants';
|
||||
import {
|
||||
|
|
@ -308,7 +308,7 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
genericActionItems(workItemType) {
|
||||
const workItemName = NAME_TO_LOWERCASE_TEXT_MAP[workItemType];
|
||||
const workItemName = NAME_TO_TEXT_LOWERCASE_MAP[workItemType];
|
||||
return [
|
||||
{
|
||||
title: sprintf(s__('WorkItem|New %{workItemName}'), { workItemName }),
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ export const NAME_TO_TEXT_MAP = {
|
|||
[WORK_ITEM_TYPE_NAME_TICKET]: s__('WorkItem|Ticket'),
|
||||
};
|
||||
|
||||
export const NAME_TO_LOWERCASE_TEXT_MAP = {
|
||||
export const NAME_TO_TEXT_LOWERCASE_MAP = {
|
||||
[WORK_ITEM_TYPE_NAME_EPIC]: s__('WorkItem|epic'),
|
||||
[WORK_ITEM_TYPE_NAME_INCIDENT]: s__('WorkItem|incident'),
|
||||
[WORK_ITEM_TYPE_NAME_ISSUE]: s__('WorkItem|issue'),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ query workspacePermissions($fullPath: ID!) {
|
|||
id
|
||||
userPermissions {
|
||||
createDesign
|
||||
updateDesign
|
||||
moveDesign
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ class Projects::RunnersController < Projects::ApplicationController
|
|||
protected
|
||||
|
||||
def runner
|
||||
@runner ||= project.runners.find(params[:id])
|
||||
@runner ||= Ci::Runner.find(safe_params[:id])
|
||||
end
|
||||
|
||||
def runner_params
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ module Projects
|
|||
before_action do
|
||||
push_frontend_feature_flag(:ci_variables_pages, current_user)
|
||||
push_frontend_feature_flag(:allow_push_repository_for_job_token, @project)
|
||||
push_frontend_feature_flag(:remove_limit_ci_job_token_scope, @project)
|
||||
push_frontend_ability(ability: :admin_project, resource: @project, user: current_user)
|
||||
push_frontend_ability(ability: :admin_protected_environments, resource: @project, user: current_user)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
module Resolvers
|
||||
class BoardListIssuesResolver < BaseResolver
|
||||
prepend ::Issues::LookAheadPreloads
|
||||
include BoardItemFilterable
|
||||
|
||||
argument :filters, Types::Boards::BoardIssueInputType,
|
||||
|
|
@ -12,14 +13,14 @@ module Resolvers
|
|||
|
||||
alias_method :list, :object
|
||||
|
||||
def resolve(**args)
|
||||
def resolve_with_lookahead(**args)
|
||||
filters = item_filters(args[:filters])
|
||||
mutually_exclusive_milestone_args!(filters)
|
||||
|
||||
filter_params = filters.merge(board_id: list.board.id, id: list.id)
|
||||
service = ::Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params)
|
||||
|
||||
service.execute
|
||||
apply_lookahead(service.execute)
|
||||
end
|
||||
|
||||
# https://gitlab.com/gitlab-org/gitlab/-/issues/235681
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ module Types
|
|||
field :direction,
|
||||
GraphQL::Types::String,
|
||||
null: true,
|
||||
description: 'Direction of access. Defaults to INBOUND.'
|
||||
description: 'Direction of access. Defaults to INBOUND.',
|
||||
deprecated: { reason: 'Outbound job token scope is being removed. This field can only be INBOUND',
|
||||
milestone: '18.0' }
|
||||
|
||||
field :default_permissions,
|
||||
GraphQL::Types::Boolean,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ module Types
|
|||
Types::ProjectType.connection_type,
|
||||
null: false,
|
||||
description: "Allow list of projects that are accessible using the current project's CI Job tokens.",
|
||||
method: :outbound_projects
|
||||
method: :outbound_projects,
|
||||
deprecated: { reason: 'Outbound job token scope is being removed. Only inbound allowlist is supported',
|
||||
milestone: '18.0' }
|
||||
|
||||
field :inbound_allowlist,
|
||||
Types::Ci::JobTokenAccessibleProjectType.connection_type,
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@ module Types
|
|||
value 'ssh_key_expiring_soon', value: 15, description: 'SSH key of the user will expire soon.'
|
||||
value 'duo_pro_access_granted', value: 16, description: 'Access to Duo Pro has been granted to the user.'
|
||||
value 'duo_enterprise_access_granted', value: 17, description: 'Access to Duo Enterprise has been granted to the user.'
|
||||
value 'duo_core_access_granted', value: 18, description: 'Access to Duo Core has been granted to the user.'
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ module Ci
|
|||
private
|
||||
|
||||
def outbound_accessible?(accessed_project)
|
||||
return true if Feature.enabled?(:remove_limit_ci_job_token_scope, current_project)
|
||||
|
||||
# if the setting is disabled any project is considered to be in scope.
|
||||
return true unless current_project.ci_outbound_job_token_scope_enabled?
|
||||
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@ class Todo < ApplicationRecord
|
|||
ADDED_APPROVER = 13 # This is an EE-only feature,
|
||||
SSH_KEY_EXPIRED = 14
|
||||
SSH_KEY_EXPIRING_SOON = 15
|
||||
DUO_PRO_ACCESS_GRANTED = 16 # This is an EE-only feature,
|
||||
DUO_ENTERPRISE_ACCESS_GRANTED = 17 # This is an EE-only feature,
|
||||
DUO_PRO_ACCESS_GRANTED = 16 # This is an EE-only feature
|
||||
DUO_ENTERPRISE_ACCESS_GRANTED = 17 # This is an EE-only feature
|
||||
DUO_CORE_ACCESS_GRANTED = 18 # This is an EE-only feature
|
||||
|
||||
ACTION_NAMES = {
|
||||
ASSIGNED => :assigned,
|
||||
|
|
@ -46,7 +47,8 @@ class Todo < ApplicationRecord
|
|||
SSH_KEY_EXPIRED => :ssh_key_expired,
|
||||
SSH_KEY_EXPIRING_SOON => :ssh_key_expiring_soon,
|
||||
DUO_PRO_ACCESS_GRANTED => :duo_pro_access_granted,
|
||||
DUO_ENTERPRISE_ACCESS_GRANTED => :duo_enterprise_access_granted
|
||||
DUO_ENTERPRISE_ACCESS_GRANTED => :duo_enterprise_access_granted,
|
||||
DUO_CORE_ACCESS_GRANTED => :duo_core_access_granted
|
||||
}.freeze
|
||||
|
||||
ACTIONS_MULTIPLE_ALLOWED = [Todo::MENTIONED, Todo::DIRECTLY_ADDRESSED, Todo::MEMBER_ACCESS_REQUESTED].freeze
|
||||
|
|
@ -54,6 +56,7 @@ class Todo < ApplicationRecord
|
|||
PARENTLESS_ACTION_TYPES = [
|
||||
DUO_PRO_ACCESS_GRANTED,
|
||||
DUO_ENTERPRISE_ACCESS_GRANTED,
|
||||
DUO_CORE_ACCESS_GRANTED,
|
||||
SSH_KEY_EXPIRED,
|
||||
SSH_KEY_EXPIRING_SOON
|
||||
].freeze
|
||||
|
|
@ -360,7 +363,7 @@ class Todo < ApplicationRecord
|
|||
end
|
||||
|
||||
def for_duo_access_granted?
|
||||
action == DUO_PRO_ACCESS_GRANTED || action == DUO_ENTERPRISE_ACCESS_GRANTED
|
||||
[DUO_PRO_ACCESS_GRANTED, DUO_ENTERPRISE_ACCESS_GRANTED, DUO_CORE_ACCESS_GRANTED].include?(action)
|
||||
end
|
||||
|
||||
def parentless_type?
|
||||
|
|
|
|||
|
|
@ -338,12 +338,13 @@ class TodoService
|
|||
).distinct_user_ids
|
||||
end
|
||||
|
||||
def bulk_insert_todos(users, attributes)
|
||||
def bulk_insert_todos(users, attributes, &attribute_merger)
|
||||
todos_ids = []
|
||||
attribute_merger ||= ->(user, attrs) { attrs.merge(user_id: user.id) }
|
||||
|
||||
users.each_slice(BATCH_SIZE) do |users_batch|
|
||||
todos_attributes = users_batch.map do |user|
|
||||
Todo.new(attributes.merge(user_id: user.id)).attributes.except('id', 'created_at', 'updated_at')
|
||||
Todo.new(attribute_merger.call(user, attributes)).attributes.except('id', 'created_at', 'updated_at')
|
||||
end
|
||||
|
||||
todos_ids += Todo.insert_all(todos_attributes, returning: :id).rows.flatten unless todos_attributes.blank?
|
||||
|
|
|
|||
|
|
@ -51,12 +51,12 @@
|
|||
= gl_badge_tag tab_count_display(@merge_request, @diffs_count), { class: 'js-changes-tab-count', data: { gid: @merge_request.to_gid.to_s } }
|
||||
.gl-flex.gl-flex-wrap.gl-items-center.justify-content-lg-end
|
||||
#js-vue-discussion-counter{ data: { blocks_merge: @project.only_allow_merge_if_all_discussions_are_resolved?.to_s } }
|
||||
#js-submit-review-button
|
||||
- if !!@issuable_sidebar.dig(:current_user, :id)
|
||||
.gl-flex.gl-gap-3
|
||||
.js-sidebar-todo-widget-root{ data: { project_path: @issuable_sidebar[:project_full_path], iid: @issuable_sidebar[:iid], id: @issuable_sidebar[:id] } }
|
||||
- if notifications_todos_buttons_enabled?
|
||||
.js-sidebar-subscriptions-widget-root{ data: { full_path: @issuable_sidebar[:project_full_path], iid: @issuable_sidebar[:iid] } }
|
||||
#js-submit-review-button
|
||||
|
||||
.gl-ml-auto.gl-items-center.gl-hidden.sm:gl-flex.lg:gl-hidden.gl-ml-3.js-expand-sidebar.gl-absolute.gl-right-5
|
||||
= render Pajamas::ButtonComponent.new(icon: 'chevron-double-lg-left',
|
||||
|
|
|
|||
|
|
@ -1076,6 +1076,16 @@
|
|||
:idempotent: false
|
||||
:tags: []
|
||||
:queue_namespace: :cronjob
|
||||
- :name: cronjob:scheduling_schedule_within
|
||||
:worker_name: Gitlab::Scheduling::ScheduleWithinWorker
|
||||
:feature_category: :scalability
|
||||
:has_external_dependencies: false
|
||||
:urgency: :low
|
||||
:resource_boundary: :unknown
|
||||
:weight: 1
|
||||
:idempotent: true
|
||||
:tags: []
|
||||
:queue_namespace: :cronjob
|
||||
- :name: cronjob:service_desk_custom_email_verification_cleanup
|
||||
:worker_name: ServiceDesk::CustomEmailVerificationCleanupWorker
|
||||
:feature_category: :service_desk
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Scheduling
|
||||
# Schedules another worker within the given time range using random splay.
|
||||
#
|
||||
# worker_class:
|
||||
# The class name of the worker to be scheduled. REQUIRED.
|
||||
# within_minutes (1..59):
|
||||
# The maximum number of minutes to wait before scheduling the worker. OPTIONAL.
|
||||
# within_hours (1..23):
|
||||
# The maximum number of hours to wait before scheduling the worker. OPTIONAL.
|
||||
#
|
||||
# Example:
|
||||
# # schedules MyWorker to run at a random time within the next 2 hours and 30 minutes
|
||||
# Gitlab::Scheduling::ScheduleWithinWorker.perform_async({
|
||||
# 'worker_class' => 'MyWorker',
|
||||
# 'within_hours' => 2,
|
||||
# 'within_minutes' => 30
|
||||
# })
|
||||
class ScheduleWithinWorker
|
||||
include ApplicationWorker
|
||||
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext -- no relevant metadata
|
||||
|
||||
feature_category :scalability
|
||||
data_consistency :sticky
|
||||
idempotent!
|
||||
|
||||
def perform(args = {})
|
||||
validate_arguments!(args)
|
||||
|
||||
worker_class = args['worker_class']&.constantize
|
||||
within_minutes = args['within_minutes'].to_i
|
||||
within_hours = args['within_hours'].to_i
|
||||
|
||||
random_minute = within_minutes > 0 ? Random.rand(within_minutes + 1) : 0
|
||||
random_hour = within_hours > 0 ? Random.rand(within_hours + 1) : 0
|
||||
scheduled_for = (random_hour.hours + random_minute.minutes).from_now
|
||||
|
||||
worker_class.perform_at(scheduled_for)
|
||||
|
||||
log_hash_metadata_on_done({
|
||||
worker_class: worker_class.to_s,
|
||||
within_minutes: within_minutes,
|
||||
within_hours: within_hours,
|
||||
selected_minute: random_minute,
|
||||
selected_hour: random_hour,
|
||||
scheduled_for: scheduled_for
|
||||
})
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_arguments!(args)
|
||||
raise ArgumentError, "worker_class is a required argument" unless args['worker_class']
|
||||
raise ArgumentError, "within_minutes must be nil or in [1..59]" unless valid_minute?(args['within_minutes'])
|
||||
raise ArgumentError, "within_hours must be nil or in [1..23]" unless valid_hour?(args['within_hours'])
|
||||
end
|
||||
|
||||
def valid_minute?(minute)
|
||||
minute.nil? || (1..59).cover?(minute.to_i)
|
||||
end
|
||||
|
||||
def valid_hour?(hour)
|
||||
hour.nil? || (1..23).cover?(hour.to_i)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
name: duo_code_review_system_note
|
||||
description:
|
||||
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/536723
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/189025
|
||||
rollout_issue_url:
|
||||
milestone: '18.0'
|
||||
group: group::code review
|
||||
type: gitlab_com_derisk
|
||||
default_enabled: false
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
name: remove_limit_ci_job_token_scope
|
||||
description:
|
||||
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/383084
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/18902
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/537186
|
||||
milestone: '18.0'
|
||||
group: group::pipeline security
|
||||
type: gitlab_com_derisk
|
||||
default_enabled: false
|
||||
|
|
@ -749,8 +749,13 @@ Settings.cron_jobs['ci_schedule_old_pipelines_removal_cron_worker'] ||= {}
|
|||
Settings.cron_jobs['ci_schedule_old_pipelines_removal_cron_worker']['cron'] ||= '*/11 * * * *'
|
||||
Settings.cron_jobs['ci_schedule_old_pipelines_removal_cron_worker']['job_class'] = 'Ci::ScheduleOldPipelinesRemovalCronWorker'
|
||||
Settings.cron_jobs['version_version_check_cron'] ||= {}
|
||||
Settings.cron_jobs['version_version_check_cron']['cron'] ||= "#{rand(60)} #{rand(24)} * * *" # rubocop: disable Scalability/RandomCronSchedule -- https://gitlab.com/gitlab-org/gitlab/-/issues/536393
|
||||
Settings.cron_jobs['version_version_check_cron']['job_class'] = 'Gitlab::Version::VersionCheckCronWorker'
|
||||
Settings.cron_jobs['version_version_check_cron']['cron'] ||= "0 0 * * * UTC"
|
||||
Settings.cron_jobs['version_version_check_cron']['job_class'] = 'Gitlab::Scheduling::ScheduleWithinWorker'
|
||||
Settings.cron_jobs['version_version_check_cron']['args'] = {
|
||||
'worker_class' => 'Gitlab::Version::VersionCheckCronWorker',
|
||||
'within_minutes' => 59,
|
||||
'within_hours' => 23
|
||||
}
|
||||
|
||||
Gitlab.ee do
|
||||
Settings.cron_jobs['analytics_devops_adoption_create_all_snapshots_worker'] ||= {}
|
||||
|
|
|
|||
|
|
@ -443,6 +443,8 @@
|
|||
- 1
|
||||
- - gitlab_subscriptions_refresh_seats
|
||||
- 1
|
||||
- - gitlab_subscriptions_self_managed_duo_core_todo_notification
|
||||
- 1
|
||||
- - gitlab_subscriptions_trials_apply_trial
|
||||
- 1
|
||||
- - google_cloud_create_cloudsql_instance
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@ feature_category: geo_replication
|
|||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/169242
|
||||
milestone: '17.7'
|
||||
queued_migration_version: 20241125133627
|
||||
finalized_by: # version of the migration that finalized this BBM
|
||||
finalized_by: '20250408231712'
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/167609
|
|||
milestone: '17.5'
|
||||
queued_migration_version: 20240930122236
|
||||
finalize_after: '2024-10-22'
|
||||
finalized_by: # version of the migration that finalized this BBM
|
||||
finalized_by: '20250320231523'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FinalizeHkBackfillPackagesRpmMetadataProjectId < Gitlab::Database::Migration[2.2]
|
||||
milestone '17.11'
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
restrict_gitlab_migration gitlab_schema: :gitlab_main_cell
|
||||
|
||||
def up
|
||||
ensure_batched_background_migration_is_finished(
|
||||
job_class_name: 'BackfillPackagesRpmMetadataProjectId',
|
||||
table_name: :packages_rpm_metadata,
|
||||
column_name: :package_id,
|
||||
job_arguments: [:project_id, :packages_packages, :project_id, :package_id],
|
||||
finalize: true
|
||||
)
|
||||
end
|
||||
|
||||
def down; end
|
||||
end
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FinalizeHkBackfillGroupWikiRepositoryStatesGroupId < Gitlab::Database::Migration[2.2]
|
||||
milestone '18.0'
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
restrict_gitlab_migration gitlab_schema: :gitlab_main_cell
|
||||
|
||||
def up
|
||||
ensure_batched_background_migration_is_finished(
|
||||
job_class_name: 'BackfillGroupWikiRepositoryStatesGroupId',
|
||||
table_name: :group_wiki_repository_states,
|
||||
column_name: :id,
|
||||
job_arguments: [:group_id, :group_wiki_repositories, :group_id, :group_wiki_repository_id],
|
||||
finalize: true
|
||||
)
|
||||
end
|
||||
|
||||
def down; end
|
||||
end
|
||||
|
|
@ -0,0 +1 @@
|
|||
3adfb6da202db516de1aa1b2b7f9c2f4ab09dc5a33dbacd97afc8bf47980bf00
|
||||
|
|
@ -0,0 +1 @@
|
|||
5253f5a26308e579a5cf97937bb449f4cfcce4d95157ee064d68585d7012b056
|
||||
|
|
@ -23004,7 +23004,7 @@ Represents an allowlist entry for the CI_JOB_TOKEN.
|
|||
| <a id="cijobtokenscopeallowlistentryautopopulated"></a>`autopopulated` | [`Boolean`](#boolean) | Indicates whether the entry is created by the autopopulation process. |
|
||||
| <a id="cijobtokenscopeallowlistentrycreatedat"></a>`createdAt` | [`Time!`](#time) | When the entry was created. |
|
||||
| <a id="cijobtokenscopeallowlistentrydefaultpermissions"></a>`defaultPermissions` | [`Boolean`](#boolean) | Indicates whether default permissions are enabled (true) or fine-grained permissions are enabled (false). |
|
||||
| <a id="cijobtokenscopeallowlistentrydirection"></a>`direction` | [`String`](#string) | Direction of access. Defaults to INBOUND. |
|
||||
| <a id="cijobtokenscopeallowlistentrydirection"></a>`direction` {{< icon name="warning-solid" >}} | [`String`](#string) | **Deprecated** in GitLab 18.0. Outbound job token scope is being removed. This field can only be INBOUND. |
|
||||
| <a id="cijobtokenscopeallowlistentryjobtokenpolicies"></a>`jobTokenPolicies` {{< icon name="warning-solid" >}} | [`[CiJobTokenScopePolicies!]`](#cijobtokenscopepolicies) | **Introduced** in GitLab 17.5. **Status**: Experiment. List of policies for the entry. |
|
||||
| <a id="cijobtokenscopeallowlistentrysourceproject"></a>`sourceProject` | [`Project!`](#project) | Project that owns the allowlist entry. |
|
||||
| <a id="cijobtokenscopeallowlistentrytarget"></a>`target` | [`CiJobTokenScopeTarget`](#cijobtokenscopetarget) | Group or project allowed by the entry. |
|
||||
|
|
@ -23021,7 +23021,7 @@ Represents an allowlist entry for the CI_JOB_TOKEN.
|
|||
| <a id="cijobtokenscopetypeinboundallowlist"></a>`inboundAllowlist` | [`CiJobTokenAccessibleProjectConnection!`](#cijobtokenaccessibleprojectconnection) | Allowlist of projects that can access the current project by authenticating with a CI/CD job token. (see [Connections](#connections)) |
|
||||
| <a id="cijobtokenscopetypeinboundallowlistautopopulatedids"></a>`inboundAllowlistAutopopulatedIds` | [`[ProjectID!]!`](#projectid) | List of IDs of projects which have been created by the autopopulation process. |
|
||||
| <a id="cijobtokenscopetypeinboundallowlistcount"></a>`inboundAllowlistCount` | [`Int!`](#int) | Count of projects that can access the current project by authenticating with a CI/CD job token. The count does not include nested projects. |
|
||||
| <a id="cijobtokenscopetypeoutboundallowlist"></a>`outboundAllowlist` | [`ProjectConnection!`](#projectconnection) | Allow list of projects that are accessible using the current project's CI Job tokens. (see [Connections](#connections)) |
|
||||
| <a id="cijobtokenscopetypeoutboundallowlist"></a>`outboundAllowlist` {{< icon name="warning-solid" >}} | [`ProjectConnection!`](#projectconnection) | **Deprecated** in GitLab 18.0. Outbound job token scope is being removed. Only inbound allowlist is supported. |
|
||||
| <a id="cijobtokenscopetypeprojects"></a>`projects` {{< icon name="warning-solid" >}} | [`ProjectConnection!`](#projectconnection) | **Deprecated** in GitLab 15.9. The `projects` attribute is being deprecated. Use `outbound_allowlist`. |
|
||||
|
||||
### `CiJobTrace`
|
||||
|
|
@ -45470,6 +45470,7 @@ Values for sorting timelogs.
|
|||
| <a id="todoactionenumassigned"></a>`assigned` | User was assigned. |
|
||||
| <a id="todoactionenumbuild_failed"></a>`build_failed` | Build triggered by the user failed. |
|
||||
| <a id="todoactionenumdirectly_addressed"></a>`directly_addressed` | User was directly addressed. |
|
||||
| <a id="todoactionenumduo_core_access_granted"></a>`duo_core_access_granted` | Access to Duo Core has been granted to the user. |
|
||||
| <a id="todoactionenumduo_enterprise_access_granted"></a>`duo_enterprise_access_granted` | Access to Duo Enterprise has been granted to the user. |
|
||||
| <a id="todoactionenumduo_pro_access_granted"></a>`duo_pro_access_granted` | Access to Duo Pro has been granted to the user. |
|
||||
| <a id="todoactionenummarked"></a>`marked` | User added a to-do item. |
|
||||
|
|
|
|||
|
|
@ -412,11 +412,18 @@ Additionally, there are multiple valid methods for passing the job token in the
|
|||
|
||||
## Limit your project's job token access (deprecated)
|
||||
|
||||
{{< alert type="note" >}}
|
||||
{{< history >}}
|
||||
|
||||
- Deprecated in GitLab 16.0.
|
||||
- Removal process [started](https://gitlab.com/gitlab-org/gitlab/-/issues/537186) in GitLab 18.0 by putting this feature behind [a flag](../../administration/feature_flags.md) named `remove_limit_ci_job_token_scope`, disabled by default.
|
||||
|
||||
{{< /history >}}
|
||||
|
||||
{{< alert type="warning" >}}
|
||||
|
||||
The [**Limit access _from_ this project**](#configure-the-job-token-scope-deprecated)
|
||||
setting is disabled by default for all new projects and is [scheduled for removal](https://gitlab.com/gitlab-org/gitlab/-/issues/383084)
|
||||
in GitLab 17.0. Project maintainers or owners should configure the [**Limit access _to_ this project**](#add-a-group-or-project-to-the-job-token-allowlist)
|
||||
in GitLab 18.0. Project maintainers or owners should configure the [**Limit access _to_ this project**](#add-a-group-or-project-to-the-job-token-allowlist)
|
||||
setting instead.
|
||||
|
||||
{{< /alert >}}
|
||||
|
|
|
|||
|
|
@ -17,25 +17,16 @@ when they contain the `Changelog` [Git trailer](https://git-scm.com/docs/git-int
|
|||
When generating the changelog, author and merge request details are added
|
||||
automatically.
|
||||
|
||||
The `Changelog` trailer accepts the following values:
|
||||
|
||||
- `added`: New feature
|
||||
- `fixed`: Bug fix
|
||||
- `changed`: Feature change
|
||||
- `deprecated`: New deprecation
|
||||
- `removed`: Feature removal
|
||||
- `security`: Security fix
|
||||
- `performance`: Performance improvement
|
||||
- `other`: Other
|
||||
For a list of trailers, see [Add a trailer to a Git commit](../user/project/changelogs.md#add-a-trailer-to-a-git-commit).
|
||||
|
||||
An example of a Git commit to include in the changelog is the following:
|
||||
|
||||
```plaintext
|
||||
Update git vendor to gitlab
|
||||
Update git vendor to GitLab
|
||||
|
||||
Now that we are using gitaly to compile git, the git version isn't known
|
||||
from the manifest, instead we are getting the gitaly version. Update our
|
||||
vendor field to be `gitlab` to avoid cve matching old versions.
|
||||
Now that we are using Gitaly to compile Git, the Git version isn't known
|
||||
from the manifest. Instead, we are getting the Gitaly version. Update our
|
||||
vendor field to be `gitlab` to avoid CVE matching old versions.
|
||||
|
||||
Changelog: changed
|
||||
```
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ info: Any user with at least the Maintainer role can merge updates to this conte
|
|||
title: Diagrams.net integration
|
||||
---
|
||||
|
||||
In [wikis](../../user/markdown.md#diagramsnet-editor) you can use the diagrams.net editor to
|
||||
In [wikis](../../user/project/wiki/markdown.md#diagramsnet-editor) you can use the diagrams.net editor to
|
||||
create diagrams. The diagrams.net editor runs as a separate web service outside the GitLab
|
||||
application and GitLab instance administrators can
|
||||
[configure the URL](../../administration/integration/diagrams_net.md) that points to this service.
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ We only use Gollum as a storage abstraction layer, to handle the mapping between
|
|||
|
||||
When rendering wiki pages, we don't use Gollum at all and instead go through a
|
||||
[custom Banzai pipeline](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/banzai/pipeline/wiki_pipeline.rb).
|
||||
This adds some [wiki-specific markup](../user/markdown.md#wiki-specific-markdown), such as the Gollum `[[link]]` syntax.
|
||||
This adds some [wiki-specific markup](../user/project/wiki/markdown.md), such as the Gollum `[[link]]` syntax.
|
||||
|
||||
Because we do not make use of most of the Gollum features, we plan to move away from it entirely at some point.
|
||||
[See this epic](https://gitlab.com/groups/gitlab-org/-/epics/2381) for reference.
|
||||
|
|
|
|||
|
|
@ -44,6 +44,24 @@ To stage and commit your changes:
|
|||
|
||||
The changes are committed to the branch.
|
||||
|
||||
### Write a good commit message
|
||||
|
||||
The guidelines published by Chris Beams in [How to Write a Git Commit Message](https://cbea.ms/git-commit/)
|
||||
help you write a good commit message:
|
||||
|
||||
- The commit subject and body must be separated by a blank line.
|
||||
- The commit subject must start with a capital letter.
|
||||
- The commit subject must not be longer than 72 characters.
|
||||
- The commit subject must not end with a period.
|
||||
- The commit body must not contain more than 72 characters per line.
|
||||
- The commit subject or body must not contain emojis.
|
||||
- Commits that change 30 or more lines across at least 3 files should
|
||||
describe these changes in the commit body.
|
||||
- Use the full URLs for issues, milestones, and merge requests instead of short references,
|
||||
as they are displayed as plain text outside of GitLab.
|
||||
- The merge request should not contain more than 10 commit messages.
|
||||
- The commit subject should contain at least 3 words.
|
||||
|
||||
## Commit all changes
|
||||
|
||||
You can stage all your changes and commit them with one command:
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ title: 'Tutorial: Update Git commit messages'
|
|||
Occasionally, after you've made a few commits to your branch, you realize you need
|
||||
to update one or more commit messages. Perhaps you found a typo, or some automation warned you
|
||||
that your commit message didn't completely align with a project's
|
||||
commit message guidelines.
|
||||
[commit message guidelines](../../topics/git/commit.md#write-a-good-commit-message).
|
||||
|
||||
Updating the message can be tricky if you don't have much practice using Git
|
||||
from the command-line interface (CLI). But don't worry, even if you have only ever worked in
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ The following features are not found in standard Markdown:
|
|||
- [Table of Contents](#table-of-contents)
|
||||
- [Tables](#tables)
|
||||
- [Task lists](#task-lists)
|
||||
- [Wiki-specific Markdown](#wiki-specific-markdown)
|
||||
- [Wiki-specific Markdown](project/wiki/markdown.md)
|
||||
|
||||
The following features are extended from standard Markdown:
|
||||
|
||||
|
|
@ -1446,7 +1446,7 @@ You can generate diagrams from text by using:
|
|||
- [PlantUML](https://plantuml.com)
|
||||
- [Kroki](https://kroki.io) to create a wide variety of diagrams.
|
||||
|
||||
In wikis, you can also add and edit diagrams created with the [diagrams.net editor](#diagramsnet-editor).
|
||||
In wikis, you can also add and edit diagrams created with the [diagrams.net editor](project/wiki/markdown.md#diagramsnet-editor).
|
||||
|
||||
### Mermaid
|
||||
|
||||
|
|
@ -2266,149 +2266,6 @@ while the equation for the theory of relativity is E = mc<sup>2</sup>.
|
|||
|
||||
GitLab Flavored Markdown doesn't support the Redcarpet superscript syntax ( `x^2` ).
|
||||
|
||||
## Wiki-specific Markdown
|
||||
|
||||
The following topics show how links inside wikis behave.
|
||||
|
||||
When linking to wiki pages, you should use the **page slug** rather than the page name.
|
||||
|
||||
### Direct page link
|
||||
|
||||
A direct page link includes the slug for a page that points to that page,
|
||||
at the base level of the wiki.
|
||||
|
||||
This example links to a `documentation` page at the root of your wiki:
|
||||
|
||||
```markdown
|
||||
[Link to Documentation](documentation)
|
||||
```
|
||||
|
||||
### Direct file link
|
||||
|
||||
A direct file link points to a file extension for a file, relative to the current page.
|
||||
|
||||
If the following example is on a page at `<your_wiki>/documentation/related`,
|
||||
it links to `<your_wiki>/documentation/file.md`:
|
||||
|
||||
```markdown
|
||||
[Link to File](file.md)
|
||||
```
|
||||
|
||||
### Hierarchical link
|
||||
|
||||
A hierarchical link can be constructed relative to the current wiki page by using relative paths like `./<page>` or
|
||||
`../<page>`.
|
||||
|
||||
If this example is on a page at `<your_wiki>/documentation/main`,
|
||||
it links to `<your_wiki>/documentation/related`:
|
||||
|
||||
```markdown
|
||||
[Link to Related Page](related)
|
||||
```
|
||||
|
||||
If this example is on a page at `<your_wiki>/documentation/related/content`,
|
||||
it links to `<your_wiki>/documentation/main`:
|
||||
|
||||
```markdown
|
||||
[Link to Related Page](../main)
|
||||
```
|
||||
|
||||
If this example is on a page at `<your_wiki>/documentation/main`,
|
||||
it links to `<your_wiki>/documentation/related.md`:
|
||||
|
||||
```markdown
|
||||
[Link to Related Page](related.md)
|
||||
```
|
||||
|
||||
If this example is on a page at `<your_wiki>/documentation/related/content`,
|
||||
it links to `<your_wiki>/documentation/main.md`:
|
||||
|
||||
```markdown
|
||||
[Link to Related Page](../main.md)
|
||||
```
|
||||
|
||||
### Root link
|
||||
|
||||
A root link starts with a `/` and is relative to the wiki root.
|
||||
|
||||
This example links to `<wiki_root>/documentation`:
|
||||
|
||||
```markdown
|
||||
[Link to Related Page](/documentation)
|
||||
```
|
||||
|
||||
This example links to `<wiki_root>/documentation.md`:
|
||||
|
||||
```markdown
|
||||
[Link to Related Page](/documentation.md)
|
||||
```
|
||||
|
||||
### diagrams.net editor
|
||||
|
||||
{{< history >}}
|
||||
|
||||
- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/322174) in GitLab 15.10.
|
||||
|
||||
{{< /history >}}
|
||||
|
||||
In wikis, you can use the [diagrams.net](https://app.diagrams.net/) editor to create diagrams. You
|
||||
can also edit diagrams created with the diagrams.net editor. The diagram editor is available in both
|
||||
the plain text editor and the rich text editor.
|
||||
|
||||
For more information, see [Diagrams.net](../administration/integration/diagrams_net.md).
|
||||
|
||||
#### Plain text editor
|
||||
|
||||
To create a diagram in the plain text editor:
|
||||
|
||||
1. On the wiki page you want to edit, select **Edit**.
|
||||
1. In the text box, make sure you're using the plain text editor
|
||||
(the button on the bottom left says **Switch to rich text editing**).
|
||||
1. In the editor's toolbar, select **Insert or edit diagram** ({{< icon name="diagram" >}}).
|
||||
1. Create the diagram in the [app.diagrams.net](https://app.diagrams.net/) editor.
|
||||
1. Select **Save & exit**.
|
||||
|
||||
A Markdown image reference to the diagram is inserted in the wiki content.
|
||||
|
||||
To edit a diagram in the plain text editor:
|
||||
|
||||
1. On the wiki page you want to edit, select **Edit**.
|
||||
1. In the text box, make sure you're using the plain text editor
|
||||
(the button on the bottom left says **Switch to rich text editing**).
|
||||
1. Position your cursor in the Markdown image reference that contains the diagram.
|
||||
1. Select **Insert or edit diagram** ({{< icon name="diagram" >}}).
|
||||
1. Edit the diagram in the [app.diagrams.net](https://app.diagrams.net/) editor.
|
||||
1. Select **Save & exit**.
|
||||
|
||||
A Markdown image reference to the diagram is inserted in the wiki content,
|
||||
replacing the previous diagram.
|
||||
|
||||
#### Rich text editor
|
||||
|
||||
To create a diagram in the rich text editor:
|
||||
|
||||
1. On the wiki page you want to edit, select **Edit**.
|
||||
1. In the text box, make sure you're using the rich text editor
|
||||
(the button on the bottom left says **Switch to plain text editing**).
|
||||
1. In the editor's toolbar, select **More options** ({{< icon name="plus" >}}).
|
||||
1. In the dropdown list, select **Create or edit diagram**.
|
||||
1. Create the diagram in the [app.diagrams.net](https://app.diagrams.net/) editor.
|
||||
1. Select **Save & exit**.
|
||||
|
||||
The diagram as visualized in the diagrams.net editor is inserted in the wiki content.
|
||||
|
||||
To edit a diagram in the rich text editor:
|
||||
|
||||
1. On the wiki page you want to edit, select **Edit**.
|
||||
1. In the text box, make sure you're using the rich text editor
|
||||
(the button on the bottom left says **Switch to plain text editing**).
|
||||
1. Select the diagram that you want to edit.
|
||||
1. In the floating toolbar, select **Edit diagram** ({{< icon name="diagram" >}}).
|
||||
1. Edit the diagram in the [app.diagrams.net](https://app.diagrams.net/) editor.
|
||||
1. Select **Save & exit**.
|
||||
|
||||
The selected diagram is replaced with an updated version.
|
||||
|
||||
## References
|
||||
|
||||
- The [GitLab Flavored Markdown development guidelines](../development/gitlab_flavored_markdown/_index.md) is a developer-facing document that describes in detail the various goals, tools, implementations, and terms related to the GLFM specification.
|
||||
|
|
|
|||
|
|
@ -60,6 +60,20 @@ string `Changelog: feature` to your commit message, like this:
|
|||
Changelog: feature
|
||||
```
|
||||
|
||||
If your merge request has multiple commits, add the `Changelog` entry to the first commit.
|
||||
This ensures the correct entry is generated when you squash commits.
|
||||
|
||||
The `Changelog` trailer accepts these values:
|
||||
|
||||
- `added`: New feature
|
||||
- `fixed`: Bug fix
|
||||
- `changed`: Feature change
|
||||
- `deprecated`: New deprecation
|
||||
- `removed`: Feature removal
|
||||
- `security`: Security fix
|
||||
- `performance`: Performance improvement
|
||||
- `other`: Other
|
||||
|
||||
## Create a changelog
|
||||
|
||||
Changelogs are generated from the command line, using either the API or the
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ Each wiki is a separate Git repository.
|
|||
You can create and edit wiki pages through the GitLab web interface or
|
||||
[locally using Git](#create-or-edit-wiki-pages-locally).
|
||||
Wiki pages written in Markdown support all [Markdown features](../../markdown.md) and provide
|
||||
[wiki-specific behavior](../../markdown.md#wiki-specific-markdown) for links.
|
||||
[wiki-specific behavior](markdown.md) for links.
|
||||
|
||||
Wiki pages display a [sidebar](#sidebar), which you can customize.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
---
|
||||
stage: Plan
|
||||
group: Knowledge
|
||||
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: Wiki-specific Markdown
|
||||
---
|
||||
|
||||
{{< details >}}
|
||||
|
||||
- Tier: Free, Premium, Ultimate
|
||||
- Offering: GitLab.com, GitLab Self-Managed, GitLab Dedicated
|
||||
|
||||
{{< /details >}}
|
||||
|
||||
The following topics show how links inside wikis behave.
|
||||
|
||||
When linking to wiki pages, you should use the **page slug** rather than the page name.
|
||||
|
||||
## Direct page link
|
||||
|
||||
A direct page link includes the slug for a page that points to that page,
|
||||
at the base level of the wiki.
|
||||
|
||||
This example links to a `documentation` page at the root of your wiki:
|
||||
|
||||
```markdown
|
||||
[Link to Documentation](documentation)
|
||||
```
|
||||
|
||||
## Direct file link
|
||||
|
||||
A direct file link points to a file extension for a file, relative to the current page.
|
||||
|
||||
If the following example is on a page at `<your_wiki>/documentation/related`,
|
||||
it links to `<your_wiki>/documentation/file.md`:
|
||||
|
||||
```markdown
|
||||
[Link to File](file.md)
|
||||
```
|
||||
|
||||
## Hierarchical link
|
||||
|
||||
A hierarchical link can be constructed relative to the current wiki page by using relative paths like `./<page>` or
|
||||
`../<page>`.
|
||||
|
||||
If this example is on a page at `<your_wiki>/documentation/main`,
|
||||
it links to `<your_wiki>/documentation/related`:
|
||||
|
||||
```markdown
|
||||
[Link to Related Page](related)
|
||||
```
|
||||
|
||||
If this example is on a page at `<your_wiki>/documentation/related/content`,
|
||||
it links to `<your_wiki>/documentation/main`:
|
||||
|
||||
```markdown
|
||||
[Link to Related Page](../main)
|
||||
```
|
||||
|
||||
If this example is on a page at `<your_wiki>/documentation/main`,
|
||||
it links to `<your_wiki>/documentation/related.md`:
|
||||
|
||||
```markdown
|
||||
[Link to Related Page](related.md)
|
||||
```
|
||||
|
||||
If this example is on a page at `<your_wiki>/documentation/related/content`,
|
||||
it links to `<your_wiki>/documentation/main.md`:
|
||||
|
||||
```markdown
|
||||
[Link to Related Page](../main.md)
|
||||
```
|
||||
|
||||
## Root link
|
||||
|
||||
A root link starts with a `/` and is relative to the wiki root.
|
||||
|
||||
This example links to `<wiki_root>/documentation`:
|
||||
|
||||
```markdown
|
||||
[Link to Related Page](/documentation)
|
||||
```
|
||||
|
||||
This example links to `<wiki_root>/documentation.md`:
|
||||
|
||||
```markdown
|
||||
[Link to Related Page](/documentation.md)
|
||||
```
|
||||
|
||||
## diagrams.net editor
|
||||
|
||||
{{< history >}}
|
||||
|
||||
- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/322174) in GitLab 15.10.
|
||||
|
||||
{{< /history >}}
|
||||
|
||||
In wikis, you can use the [diagrams.net](https://app.diagrams.net/) editor to create diagrams. You
|
||||
can also edit diagrams created with the diagrams.net editor. The diagram editor is available in both
|
||||
the plain text editor and the rich text editor.
|
||||
|
||||
For more information, see [Diagrams.net](../../../administration/integration/diagrams_net.md).
|
||||
|
||||
### Plain text editor
|
||||
|
||||
To create a diagram in the plain text editor:
|
||||
|
||||
1. On the wiki page you want to edit, select **Edit**.
|
||||
1. In the text box, make sure you're using the plain text editor
|
||||
(the button on the bottom left says **Switch to rich text editing**).
|
||||
1. In the editor's toolbar, select **Insert or edit diagram** ({{< icon name="diagram" >}}).
|
||||
1. Create the diagram in the [app.diagrams.net](https://app.diagrams.net/) editor.
|
||||
1. Select **Save & exit**.
|
||||
|
||||
A Markdown image reference to the diagram is inserted in the wiki content.
|
||||
|
||||
To edit a diagram in the plain text editor:
|
||||
|
||||
1. On the wiki page you want to edit, select **Edit**.
|
||||
1. In the text box, make sure you're using the plain text editor
|
||||
(the button on the bottom left says **Switch to rich text editing**).
|
||||
1. Position your cursor in the Markdown image reference that contains the diagram.
|
||||
1. Select **Insert or edit diagram** ({{< icon name="diagram" >}}).
|
||||
1. Edit the diagram in the [app.diagrams.net](https://app.diagrams.net/) editor.
|
||||
1. Select **Save & exit**.
|
||||
|
||||
A Markdown image reference to the diagram is inserted in the wiki content,
|
||||
replacing the previous diagram.
|
||||
|
||||
### Rich text editor
|
||||
|
||||
To create a diagram in the rich text editor:
|
||||
|
||||
1. On the wiki page you want to edit, select **Edit**.
|
||||
1. In the text box, make sure you're using the rich text editor
|
||||
(the button on the bottom left says **Switch to plain text editing**).
|
||||
1. In the editor's toolbar, select **More options** ({{< icon name="plus" >}}).
|
||||
1. In the dropdown list, select **Create or edit diagram**.
|
||||
1. Create the diagram in the [app.diagrams.net](https://app.diagrams.net/) editor.
|
||||
1. Select **Save & exit**.
|
||||
|
||||
The diagram as visualized in the diagrams.net editor is inserted in the wiki content.
|
||||
|
||||
To edit a diagram in the rich text editor:
|
||||
|
||||
1. On the wiki page you want to edit, select **Edit**.
|
||||
1. In the text box, make sure you're using the rich text editor
|
||||
(the button on the bottom left says **Switch to plain text editing**).
|
||||
1. Select the diagram that you want to edit.
|
||||
1. In the floating toolbar, select **Edit diagram** ({{< icon name="diagram" >}}).
|
||||
1. Edit the diagram in the [app.diagrams.net](https://app.diagrams.net/) editor.
|
||||
1. Select **Save & exit**.
|
||||
|
||||
The selected diagram is replaced with an updated version.
|
||||
|
|
@ -15,8 +15,8 @@ module Gitlab
|
|||
@ctx = ctx
|
||||
@errors = []
|
||||
|
||||
if objects.count <= 1 # rubocop:disable Style/IfUnlessModifier
|
||||
@errors.push('invalid interpolation access pattern')
|
||||
if objects.count <= 1
|
||||
@errors.push('invalid pattern used for interpolation. valid pattern is $[[ inputs.input ]]')
|
||||
end
|
||||
|
||||
if access.bytesize > MAX_ACCESS_BYTESIZE # rubocop:disable Style/IfUnlessModifier
|
||||
|
|
@ -48,7 +48,7 @@ module Gitlab
|
|||
@value ||= objects.inject(@ctx) do |memo, value|
|
||||
key = value.to_sym
|
||||
|
||||
break @errors.push("unknown interpolation key: `#{key}`") unless memo.key?(key)
|
||||
break @errors.push("unknown input name provided: `#{key}`") unless memo.key?(key)
|
||||
|
||||
memo.fetch(key)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5855,6 +5855,12 @@ msgstr ""
|
|||
msgid "AiPowered|Tanuki AI icon"
|
||||
msgstr ""
|
||||
|
||||
msgid "AiPowered|This settings applies Namespace/Instance-wide, Subgroup and project access controls are coming soon.%{br}By turning this on, you accept the %{linkStart}GitLab AI functionality terms%{linkEnd}."
|
||||
msgstr ""
|
||||
|
||||
msgid "AiPowered|Turn on IDE features"
|
||||
msgstr ""
|
||||
|
||||
msgid "AiPowered|Turn on experiment and beta GitLab Duo features"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -22993,9 +22999,6 @@ msgstr ""
|
|||
msgid "DuoCodeReview|GitLab Duo Code Review"
|
||||
msgstr ""
|
||||
|
||||
msgid "DuoCodeReview|Hey :wave: I'm reviewing your merge request now. I will let you know when I'm finished."
|
||||
msgstr ""
|
||||
|
||||
msgid "DuoCodeReview|I encountered some problems while responding to your query. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -23008,6 +23011,9 @@ msgstr ""
|
|||
msgid "DuoCodeReview|I've received your Duo Code Review request, and will review your code shortly."
|
||||
msgstr ""
|
||||
|
||||
msgid "DuoCodeReview|is reviewing your merge request and will let you know when it's finished"
|
||||
msgstr ""
|
||||
|
||||
msgid "DuoEnterpriseDiscover|AI Impact Dashboard measures the ROI of AI"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -28232,6 +28238,9 @@ msgstr ""
|
|||
msgid "GithubIntegration|This requires mirroring your GitHub repository to this project. %{docs_link}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gitlab Duo Core"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gitpod"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -30162,7 +30171,10 @@ msgstr ""
|
|||
msgid "GroupsNew|e.g. h8d3f016698e…"
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTree|Are you sure you want to leave the \"%{fullName}\" group?"
|
||||
msgid "GroupsTree|An error occurred while leaving the group. Please refresh the page to try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTree|Are you sure you want to leave \"%{fullName}\"?"
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTree|Delete"
|
||||
|
|
@ -30183,6 +30195,18 @@ msgstr ""
|
|||
msgid "GroupsTree|Options"
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTree|When you leave this group:"
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTree|You lose access to all projects within this group"
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTree|You need an invitation to rejoin"
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTree|Your assigned issues and merge requests remain, but you cannot view or modify them"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups|An error occurred updating this group. Please try again."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -35626,6 +35650,9 @@ msgstr ""
|
|||
msgid "Leave project"
|
||||
msgstr ""
|
||||
|
||||
msgid "Left the '%{group_name}' group successfully."
|
||||
msgstr ""
|
||||
|
||||
msgid "Legacy Prometheus integrations cannot currently be removed"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@
|
|||
"@gitlab/ui": "113.2.1",
|
||||
"@gitlab/vue-router-vue3": "npm:vue-router@4.5.1",
|
||||
"@gitlab/vuex-vue3": "npm:vuex@4.1.0",
|
||||
"@gitlab/web-ide": "^0.0.1-dev-20250414030534",
|
||||
"@gitlab/web-ide": "^0.0.1-dev-20250430143302",
|
||||
"@gleam-lang/highlight.js-gleam": "^1.5.0",
|
||||
"@mattiasbuelens/web-streams-adapter": "^0.1.0",
|
||||
"@rails/actioncable": "7.1.501",
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ prometheus:
|
|||
tracer:
|
||||
jaeger:
|
||||
enabled: false
|
||||
# https://gitlab.com/gitlab-org/gitlab/-/issues/471172
|
||||
# https://gitlab.com/gitlab-org/quality/engineering-productivity/master-broken-incidents/-/issues/12503#note_2479345136
|
||||
gitlab_http_router:
|
||||
enabled: true
|
||||
enabled: false
|
||||
mise:
|
||||
enabled: true
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ module RuboCop
|
|||
# See https://gitlab.com/gitlab-org/gitlab/-/issues/536393
|
||||
class RandomCronSchedule < RuboCop::Cop::Base
|
||||
MSG = "Avoid randomized cron expressions. This can lead to missed executions. " \
|
||||
"Use Gitlab::Scheduling::ScheduleWithinWorker if you want to add random jitter. " \
|
||||
"See https://gitlab.com/gitlab-org/gitlab/-/issues/536393"
|
||||
|
||||
RESTRICT_ON_SEND = %i[rand].freeze
|
||||
|
|
|
|||
|
|
@ -19,6 +19,52 @@ RSpec.describe Projects::RunnersController, feature_category: :fleet_visibility
|
|||
sign_in(user)
|
||||
end
|
||||
|
||||
describe '#show' do
|
||||
context 'when user is maintainer' do
|
||||
before_all do
|
||||
project.add_maintainer(user)
|
||||
end
|
||||
|
||||
it 'renders show with 200 status code' do
|
||||
get :show, params: params
|
||||
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
end
|
||||
|
||||
context 'with an instance runner' do
|
||||
let(:runner) { create(:ci_runner, :instance) }
|
||||
|
||||
it 'renders show with 200 status code' do
|
||||
get :show, params: params
|
||||
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not maintainer' do
|
||||
before_all do
|
||||
project.add_developer(user)
|
||||
end
|
||||
|
||||
it 'renders a 404' do
|
||||
get :show, params: params
|
||||
|
||||
expect(response).to have_gitlab_http_status(:not_found)
|
||||
end
|
||||
|
||||
context 'with an instance runner' do
|
||||
let(:runner) { create(:ci_runner, :instance) }
|
||||
|
||||
it 'renders a 404' do
|
||||
get :show, params: params
|
||||
|
||||
expect(response).to have_gitlab_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#new' do
|
||||
let(:params) do
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@ import {
|
|||
getGroupMembers,
|
||||
createGroup,
|
||||
getSharedGroups,
|
||||
deleteGroupMember,
|
||||
} from '~/api/groups_api';
|
||||
|
||||
const mockApiVersion = 'v4';
|
||||
const mockUrlRoot = '/gitlab';
|
||||
const mockGroupId = '99';
|
||||
const mockUserId = '47';
|
||||
|
||||
describe('GroupsApi', () => {
|
||||
let mock;
|
||||
|
|
@ -115,6 +117,22 @@ describe('GroupsApi', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('deleteGroupMember', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(axios, 'delete');
|
||||
});
|
||||
|
||||
it('deletes to the correct URL', () => {
|
||||
const expectedUrl = `${mockUrlRoot}/api/${mockApiVersion}/groups/${mockGroupId}/members/${mockUserId}`;
|
||||
|
||||
mock.onDelete(expectedUrl).replyOnce(HTTP_STATUS_OK);
|
||||
|
||||
return deleteGroupMember(mockGroupId, mockUserId).then(() => {
|
||||
expect(axios.delete).toHaveBeenCalledWith(expectedUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGroup', () => {
|
||||
it('posts to the correct URL and returns the data', async () => {
|
||||
const body = { name: 'Foo bar', path: 'foo-bar' };
|
||||
|
|
|
|||
|
|
@ -278,6 +278,7 @@ describe('AdminRunnersApp', () => {
|
|||
expect(runnerActions.props()).toEqual({
|
||||
runner,
|
||||
editUrl: runner.editAdminUrl,
|
||||
size: 'medium',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ describe('RunnerActionsCell', () => {
|
|||
const findRunnerPauseBtn = () => wrapper.findComponent(RunnerPauseButton);
|
||||
const findDeleteBtn = () => wrapper.findComponent(RunnerDeleteButton);
|
||||
|
||||
const createComponent = ({ runner = {}, ...props } = {}) => {
|
||||
const createComponent = ({ runner = {}, ...props } = {}, options) => {
|
||||
wrapper = shallowMountExtended(RunnerActionsCell, {
|
||||
propsData: {
|
||||
editUrl: mockRunner.editAdminUrl,
|
||||
|
|
@ -28,6 +28,7 @@ describe('RunnerActionsCell', () => {
|
|||
},
|
||||
...props,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -35,9 +36,18 @@ describe('RunnerActionsCell', () => {
|
|||
it('Displays the runner edit link with the correct href', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findEditBtn().props('size')).toBe('medium');
|
||||
expect(findEditBtn().attributes('href')).toBe(mockRunner.editAdminUrl);
|
||||
});
|
||||
|
||||
it('Displays button with size prop', () => {
|
||||
createComponent({
|
||||
size: 'small',
|
||||
});
|
||||
|
||||
expect(findEditBtn().props('size')).toBe('small');
|
||||
});
|
||||
|
||||
it('Does not render the runner edit link when user cannot update', () => {
|
||||
createComponent({
|
||||
runner: {
|
||||
|
|
@ -64,9 +74,18 @@ describe('RunnerActionsCell', () => {
|
|||
it('Renders a compact pause button', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findRunnerPauseBtn().props('size')).toBe('medium');
|
||||
expect(findRunnerPauseBtn().props('compact')).toBe(true);
|
||||
});
|
||||
|
||||
it('Displays button with size prop', () => {
|
||||
createComponent({
|
||||
size: 'small',
|
||||
});
|
||||
|
||||
expect(findRunnerPauseBtn().props('size')).toBe('small');
|
||||
});
|
||||
|
||||
it('Does not render the runner pause button when user cannot update', () => {
|
||||
createComponent({
|
||||
runner: {
|
||||
|
|
@ -85,9 +104,18 @@ describe('RunnerActionsCell', () => {
|
|||
it('Renders a compact delete button', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findDeleteBtn().props('size')).toBe('medium');
|
||||
expect(findDeleteBtn().props('compact')).toBe(true);
|
||||
});
|
||||
|
||||
it('Displays button with size prop', () => {
|
||||
createComponent({
|
||||
size: 'small',
|
||||
});
|
||||
|
||||
expect(findDeleteBtn().props('size')).toBe('small');
|
||||
});
|
||||
|
||||
it('Passes runner data to delete button', () => {
|
||||
createComponent({
|
||||
runner: mockRunner,
|
||||
|
|
@ -131,4 +159,19 @@ describe('RunnerActionsCell', () => {
|
|||
expect(findDeleteBtn().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Slot', () => {
|
||||
it('shows additional content', () => {
|
||||
createComponent(
|
||||
{},
|
||||
{
|
||||
slots: {
|
||||
default: '<div>actions</div>',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(wrapper.text()).toContain('actions');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -75,8 +75,9 @@ describe('RunnerDeleteButton', () => {
|
|||
});
|
||||
|
||||
it('Passes other attributes to the button', () => {
|
||||
createComponent({ props: { category: 'secondary' } });
|
||||
createComponent({ props: { category: 'secondary', size: 'small' } });
|
||||
|
||||
expect(findBtn().props('size')).toBe('small');
|
||||
expect(findBtn().props('category')).toBe('secondary');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@ describe('RunnerEditButton', () => {
|
|||
expect(findButton().attributes('href')).toBe('/edit');
|
||||
});
|
||||
|
||||
it('Passes other attributes to the button', () => {
|
||||
createComponent({ props: { size: 'small' } });
|
||||
|
||||
expect(findButton().props('size')).toBe('small');
|
||||
expect(findButton().props('icon')).toBe('pencil');
|
||||
});
|
||||
|
||||
describe('When no href is provided', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({ props: { href: null } });
|
||||
|
|
|
|||
|
|
@ -119,6 +119,29 @@ describe('RunnerList', () => {
|
|||
expect(findCell({ fieldKey: 'actions' }).exists()).toBe(true);
|
||||
});
|
||||
|
||||
describe('fixed table appearance', () => {
|
||||
it('is enabled by default', () => {
|
||||
createComponent({
|
||||
stubs: {
|
||||
GlTableLite: stubComponent(GlTableLite),
|
||||
},
|
||||
});
|
||||
|
||||
expect(findTable().attributes('fixed')).toBe('true');
|
||||
});
|
||||
|
||||
it('can be disabled', () => {
|
||||
createComponent({
|
||||
props: { fixed: true },
|
||||
stubs: {
|
||||
GlTableLite: stubComponent(GlTableLite),
|
||||
},
|
||||
});
|
||||
|
||||
expect(findTable().attributes('fixed')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('When the list is checkable', () => {
|
||||
beforeEach(() => {
|
||||
createComponent(
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ describe('RunnerPauseButton', () => {
|
|||
});
|
||||
|
||||
it(`Displays a ${expectedIcon} button`, () => {
|
||||
expect(findBtn().props('size')).toBe('medium');
|
||||
expect(findBtn().props('loading')).toBe(false);
|
||||
expect(findBtn().props('icon')).toBe(expectedIcon);
|
||||
});
|
||||
|
|
@ -123,6 +124,16 @@ describe('RunnerPauseButton', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('Displays button with size prop', () => {
|
||||
createComponent({
|
||||
props: {
|
||||
size: 'small',
|
||||
},
|
||||
});
|
||||
|
||||
expect(findBtn().props('size')).toBe('small');
|
||||
});
|
||||
|
||||
it('Shows loading state', () => {
|
||||
createComponent({ loading: true });
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ RSpec.describe 'Organizations (GraphQL fixtures)', feature_category: :cell do
|
|||
let_it_be(:organization) { organizations.first }
|
||||
let_it_be(:groups) { create_list(:group, 3, organization: organization) }
|
||||
let_it_be(:group) { groups.first }
|
||||
let_it_be(:group_owner) { create(:group_member, :owner, group: group, user: create(:user)) }
|
||||
let_it_be(:projects) do
|
||||
groups.map do |group|
|
||||
create(:project, :public, namespace: group, organization: organization)
|
||||
|
|
|
|||
|
|
@ -308,12 +308,12 @@ describe('AppComponent', () => {
|
|||
it('updates props which show modal confirmation dialog', () => {
|
||||
const group = { ...mockParentGroupItem };
|
||||
|
||||
expect(vm.groupLeaveConfirmationMessage).toBe('');
|
||||
expect(vm.groupLeaveConfirmationTitle).toBe('');
|
||||
vm.showLeaveGroupModal(group, mockParentGroupItem);
|
||||
|
||||
expect(vm.isModalVisible).toBe(true);
|
||||
expect(vm.groupLeaveConfirmationMessage).toBe(
|
||||
`Are you sure you want to leave the "${group.fullName}" group?`,
|
||||
expect(vm.groupLeaveConfirmationTitle).toBe(
|
||||
`Are you sure you want to leave "${group.fullName}"?`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -526,8 +526,14 @@ describe('AppComponent', () => {
|
|||
const findGlModal = wrapper.findComponent(GlModal);
|
||||
|
||||
expect(findGlModal.exists()).toBe(true);
|
||||
expect(findGlModal.attributes('title')).toBe('Are you sure?');
|
||||
expect(findGlModal.attributes('title')).toBe('');
|
||||
expect(findGlModal.props('actionPrimary').text).toBe('Leave group');
|
||||
expect(findGlModal.text()).toContain('When you leave this group:');
|
||||
expect(findGlModal.text()).toContain('You lose access to all projects within this group');
|
||||
expect(findGlModal.text()).toContain(
|
||||
'Your assigned issues and merge requests remain, but you cannot view or modify them',
|
||||
);
|
||||
expect(findGlModal.text()).toContain('You need an invitation to rejoin');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -73,7 +73,11 @@ describe('your work groups resolver', () => {
|
|||
visibility: 'public',
|
||||
createdAt: mockGroup.created_at,
|
||||
updatedAt: mockGroup.updated_at,
|
||||
userPermissions: { removeGroup: true, viewEditPage: true },
|
||||
userPermissions: {
|
||||
canLeave: false,
|
||||
removeGroup: true,
|
||||
viewEditPage: true,
|
||||
},
|
||||
maxAccessLevel: { integerValue: 50 },
|
||||
children: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ import organizationProjectsGraphQlResponse from 'test_fixtures/graphql/organizat
|
|||
import { formatGroups, formatProjects, timestampType } from '~/organizations/shared/utils';
|
||||
import { formatGraphQLProjects } from '~/vue_shared/components/projects_list/formatter';
|
||||
import { SORT_CREATED_AT, SORT_UPDATED_AT, SORT_NAME } from '~/organizations/shared/constants';
|
||||
import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
|
||||
import {
|
||||
ACTION_EDIT,
|
||||
ACTION_DELETE,
|
||||
ACTION_LEAVE,
|
||||
} from '~/vue_shared/components/list_actions/constants';
|
||||
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
|
||||
import {
|
||||
TIMESTAMP_TYPE_CREATED_AT,
|
||||
|
|
@ -29,7 +33,7 @@ const {
|
|||
} = organizationProjectsGraphQlResponse;
|
||||
|
||||
describe('formatGroups', () => {
|
||||
it('correctly formats the groups with edit and delete permissions', () => {
|
||||
it('correctly formats the groups with edit, delete, and leave permissions', () => {
|
||||
const [firstMockGroup] = organizationGroups;
|
||||
const formattedGroups = formatGroups(organizationGroups);
|
||||
const [firstFormattedGroup] = formattedGroups;
|
||||
|
|
@ -43,7 +47,7 @@ describe('formatGroups', () => {
|
|||
accessLevel: {
|
||||
integerValue: 50,
|
||||
},
|
||||
availableActions: [ACTION_EDIT, ACTION_DELETE],
|
||||
availableActions: [ACTION_EDIT, ACTION_LEAVE, ACTION_DELETE],
|
||||
children: [],
|
||||
childrenLoading: false,
|
||||
hasChildren: false,
|
||||
|
|
@ -51,7 +55,7 @@ describe('formatGroups', () => {
|
|||
expect(formattedGroups.length).toBe(organizationGroups.length);
|
||||
});
|
||||
|
||||
it('correctly formats the groups without edit or delete permissions', () => {
|
||||
it('correctly formats the groups without edit, delete, and leave permissions', () => {
|
||||
const nonDeletableGroup = organizationGroups[1];
|
||||
const formattedGroups = formatGroups(organizationGroups);
|
||||
const nonDeletableFormattedGroup = formattedGroups[1];
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ describe('OpenMrBadge', () => {
|
|||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.props('variant')).toBe('success');
|
||||
expect(badge.props('icon')).toBe('merge-request');
|
||||
expect(badge.props('tag')).toBe('a');
|
||||
expect(badge.attributes('title')).toBe(
|
||||
'Open merge requests created in the past 30 days that target this branch and modify this file.',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
TODO_ACTION_TYPE_SSH_KEY_EXPIRING_SOON,
|
||||
TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED,
|
||||
TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED,
|
||||
TODO_ACTION_TYPE_DUO_CORE_ACCESS_GRANTED,
|
||||
} from '~/todos/constants';
|
||||
import { SAML_HIDDEN_TODO } from '../mock_data';
|
||||
|
||||
|
|
@ -81,6 +82,7 @@ describe('TodoItemBody', () => {
|
|||
${TODO_ACTION_TYPE_SSH_KEY_EXPIRING_SOON} | ${'Your SSH key is expiring soon.'} | ${false}
|
||||
${TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED} | ${'You now have access to AI-powered features. Learn how to set up Code Suggestions and Chat in your IDE.'} | ${false}
|
||||
${TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED} | ${'You now have access to AI-powered features. Learn how to set up Code Suggestions and Chat in your IDE.'} | ${false}
|
||||
${TODO_ACTION_TYPE_DUO_CORE_ACCESS_GRANTED} | ${'You now have access to AI-powered features. Learn how to set up Code Suggestions and Chat in your IDE.'} | ${false}
|
||||
`('renders "$text" for the "$actionName" action', ({ actionName, text, showsAuthor }) => {
|
||||
createComponent({ action: actionName, memberAccessType: 'group' });
|
||||
expect(wrapper.text()).toContain(text);
|
||||
|
|
@ -105,6 +107,7 @@ describe('TodoItemBody', () => {
|
|||
it.each([
|
||||
TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED,
|
||||
TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED,
|
||||
TODO_ACTION_TYPE_DUO_CORE_ACCESS_GRANTED,
|
||||
])('when todo action is `%s`, avatar is not shown', (action) => {
|
||||
createComponent({ action });
|
||||
expect(wrapper.findComponent(GlAvatarLink).exists()).toBe(false);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
TODO_TARGET_TYPE_SSH_KEY,
|
||||
TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED,
|
||||
TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED,
|
||||
TODO_ACTION_TYPE_DUO_CORE_ACCESS_GRANTED,
|
||||
} from '~/todos/constants';
|
||||
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
|
||||
import { DESIGN_TODO, MR_BUILD_FAILED_TODO } from '../mock_data';
|
||||
|
|
@ -98,6 +99,7 @@ describe('TodoItemTitle', () => {
|
|||
action | icon | showsIcon
|
||||
${TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED} | ${'book'} | ${true}
|
||||
${TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED} | ${'book'} | ${true}
|
||||
${TODO_ACTION_TYPE_DUO_CORE_ACCESS_GRANTED} | ${'book'} | ${true}
|
||||
`('renders "$icon" for the "$action" action', ({ action, icon, showsIcon }) => {
|
||||
createComponent({ ...mockToDo, action });
|
||||
|
||||
|
|
|
|||
|
|
@ -13,9 +13,12 @@ describe('TokenAccessApp component', () => {
|
|||
const findTokenPermissions = () => wrapper.findComponent(TokenPermissions);
|
||||
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
|
||||
|
||||
const createComponent = ({ allowPushRepositoryForJobToken = true } = {}) => {
|
||||
const createComponent = ({
|
||||
allowPushRepositoryForJobToken = true,
|
||||
removeLimitCiJobTokenScope = true,
|
||||
} = {}) => {
|
||||
wrapper = shallowMount(TokenAccessApp, {
|
||||
provide: { glFeatures: { allowPushRepositoryForJobToken } },
|
||||
provide: { glFeatures: { allowPushRepositoryForJobToken, removeLimitCiJobTokenScope } },
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -38,8 +41,8 @@ describe('TokenAccessApp component', () => {
|
|||
expect(findIntersectionObserver().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders/does not render the outbound token access component', () => {
|
||||
expect(findOutboundTokenAccess().exists()).toBe(expected);
|
||||
it('does not render the outbound token access component', () => {
|
||||
expect(findOutboundTokenAccess().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders/does not render the inbound token access component', () => {
|
||||
|
|
@ -61,4 +64,15 @@ describe('TokenAccessApp component', () => {
|
|||
expect(findTokenPermissions().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when removeLimitCiJobTokenScope feature flag is disabled', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({ removeLimitCiJobTokenScope: false });
|
||||
findIntersectionObserver().vm.$emit('update', { isIntersecting: true });
|
||||
});
|
||||
|
||||
it('renders the outbound token access component', () => {
|
||||
expect(findOutboundTokenAccess().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -410,6 +410,14 @@ describe('useAccessTokens store', () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the new token component', async () => {
|
||||
store.token = 'new token';
|
||||
mockAxios.onDelete().replyOnce(HTTP_STATUS_NO_CONTENT);
|
||||
await store.revokeToken(1);
|
||||
|
||||
expect(store.token).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotateToken', () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import { GlModal } from '@gitlab/ui';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import GroupListItemLeaveModal from '~/vue_shared/components/groups_list/group_list_item_leave_modal.vue';
|
||||
import { groups } from 'jest/vue_shared/components/groups_list/mock_data';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import { renderLeaveSuccessToast } from '~/vue_shared/components/groups_list/utils';
|
||||
import { createAlert } from '~/alert';
|
||||
import { deleteGroupMember } from '~/api/groups_api';
|
||||
|
||||
jest.mock('~/vue_shared/components/groups_list/utils', () => ({
|
||||
...jest.requireActual('~/vue_shared/components/groups_list/utils'),
|
||||
renderLeaveSuccessToast: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/alert');
|
||||
jest.mock('~/api/groups_api');
|
||||
|
||||
describe('GroupListItemLeaveModal', () => {
|
||||
let wrapper;
|
||||
|
||||
const userId = 1;
|
||||
const [group] = groups;
|
||||
const defaultProps = {
|
||||
modalId: '123',
|
||||
group,
|
||||
};
|
||||
|
||||
const createComponent = ({ props = {} } = {}) => {
|
||||
window.gon.current_user_id = userId;
|
||||
wrapper = shallowMountExtended(GroupListItemLeaveModal, {
|
||||
propsData: { ...defaultProps, ...props },
|
||||
});
|
||||
};
|
||||
|
||||
const findGlModal = () => wrapper.findComponent(GlModal);
|
||||
const firePrimaryEvent = () => findGlModal().vm.$emit('primary', { preventDefault: jest.fn() });
|
||||
|
||||
beforeEach(createComponent);
|
||||
|
||||
it('renders GlModal with correct props', () => {
|
||||
expect(findGlModal().props()).toMatchObject({
|
||||
visible: false,
|
||||
modalId: defaultProps.modalId,
|
||||
title: `Are you sure you want to leave "${group.fullName}"?`,
|
||||
actionPrimary: {
|
||||
text: 'Leave group',
|
||||
attributes: {
|
||||
variant: 'danger',
|
||||
},
|
||||
},
|
||||
actionCancel: {
|
||||
text: 'Cancel',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders body', () => {
|
||||
expect(findGlModal().text()).toContain('When you leave this group:');
|
||||
expect(findGlModal().text()).toContain('You lose access to all projects within this group');
|
||||
expect(findGlModal().text()).toContain(
|
||||
'Your assigned issues and merge requests remain, but you cannot view or modify them',
|
||||
);
|
||||
expect(findGlModal().text()).toContain('You need an invitation to rejoin');
|
||||
});
|
||||
|
||||
describe('when leave is confirmed', () => {
|
||||
describe('when API call is successful', () => {
|
||||
it('calls deleteGroupMember, properly sets loading state, and emits confirm event', async () => {
|
||||
deleteGroupMember.mockResolvedValueOnce();
|
||||
|
||||
await firePrimaryEvent();
|
||||
|
||||
expect(deleteGroupMember).toHaveBeenCalledWith(group.id, userId);
|
||||
expect(findGlModal().props('actionPrimary').attributes.loading).toEqual(true);
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findGlModal().props('actionPrimary').attributes.loading).toEqual(false);
|
||||
expect(wrapper.emitted('success')).toEqual([[]]);
|
||||
expect(renderLeaveSuccessToast).toHaveBeenCalledWith(group);
|
||||
expect(createAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when API call is not successful', () => {
|
||||
const error = new Error();
|
||||
|
||||
it('calls deleteGroupMember, properly sets loading state, and shows error alert', async () => {
|
||||
deleteGroupMember.mockRejectedValue(error);
|
||||
|
||||
await firePrimaryEvent();
|
||||
|
||||
expect(deleteGroupMember).toHaveBeenCalledWith(group.id, userId);
|
||||
expect(findGlModal().props('actionPrimary').attributes.loading).toEqual(true);
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findGlModal().props('actionPrimary').attributes.loading).toEqual(false);
|
||||
expect(wrapper.emitted('success')).toBeUndefined();
|
||||
expect(renderLeaveSuccessToast).not.toHaveBeenCalled();
|
||||
expect(createAlert).toHaveBeenCalledWith({
|
||||
message:
|
||||
'An error occurred while leaving the group. Please refresh the page to try again.',
|
||||
error,
|
||||
captureError: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when change is fired', () => {
|
||||
beforeEach(() => {
|
||||
findGlModal().vm.$emit('change', false);
|
||||
});
|
||||
|
||||
it('emits change event', () => {
|
||||
expect(wrapper.emitted('change')).toMatchObject([[]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ import waitForPromises from 'helpers/wait_for_promises';
|
|||
import GroupListItemDeleteModal from '~/vue_shared/components/groups_list/group_list_item_delete_modal.vue';
|
||||
import GroupListItemInactiveBadge from '~/vue_shared/components/groups_list/group_list_item_inactive_badge.vue';
|
||||
import GroupListItemPreventDeleteModal from '~/vue_shared/components/groups_list/group_list_item_prevent_delete_modal.vue';
|
||||
import GroupListItemLeaveModal from '~/vue_shared/components/groups_list/group_list_item_leave_modal.vue';
|
||||
import {
|
||||
VISIBILITY_TYPE_ICON,
|
||||
VISIBILITY_LEVEL_INTERNAL_STRING,
|
||||
|
|
@ -17,7 +18,11 @@ import {
|
|||
import { ACCESS_LEVEL_LABELS, ACCESS_LEVEL_NO_ACCESS_INTEGER } from '~/access_level/constants';
|
||||
import ListActions from '~/vue_shared/components/list_actions/list_actions.vue';
|
||||
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
|
||||
import {
|
||||
ACTION_EDIT,
|
||||
ACTION_DELETE,
|
||||
ACTION_LEAVE,
|
||||
} from '~/vue_shared/components/list_actions/constants';
|
||||
import {
|
||||
TIMESTAMP_TYPE_CREATED_AT,
|
||||
TIMESTAMP_TYPE_UPDATED_AT,
|
||||
|
|
@ -62,14 +67,18 @@ describe('GroupsListItem', () => {
|
|||
const findGroupDescription = () => wrapper.findByTestId('description');
|
||||
const findVisibilityIcon = () => findAvatarLabeled().findComponent(GlIcon);
|
||||
const findListActions = () => wrapper.findComponent(ListActions);
|
||||
const findConfirmationModal = () => wrapper.findComponent(GroupListItemDeleteModal);
|
||||
const findDeleteConfirmationModal = () => wrapper.findComponent(GroupListItemDeleteModal);
|
||||
const findPreventDeleteModal = () => wrapper.findComponent(GroupListItemPreventDeleteModal);
|
||||
const findLeaveModal = () => wrapper.findComponent(GroupListItemLeaveModal);
|
||||
const findAccessLevelBadge = () => wrapper.findByTestId('user-access-role');
|
||||
const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip);
|
||||
|
||||
const fireLeaveAction = () => findListActions().props('actions')[ACTION_LEAVE].action();
|
||||
const fireDeleteAction = () => findListActions().props('actions')[ACTION_DELETE].action();
|
||||
const findInactiveBadge = () => wrapper.findComponent(GroupListItemInactiveBadge);
|
||||
|
||||
const deleteModalFireConfirmEvent = async () => {
|
||||
findConfirmationModal().vm.$emit('confirm', {
|
||||
findDeleteConfirmationModal().vm.$emit('confirm', {
|
||||
preventDefault: jest.fn(),
|
||||
});
|
||||
await nextTick();
|
||||
|
|
@ -278,8 +287,47 @@ describe('GroupsListItem', () => {
|
|||
[ACTION_DELETE]: {
|
||||
action: expect.any(Function),
|
||||
},
|
||||
[ACTION_LEAVE]: {
|
||||
action: expect.any(Function),
|
||||
},
|
||||
},
|
||||
availableActions: [ACTION_EDIT, ACTION_DELETE],
|
||||
availableActions: [ACTION_EDIT, ACTION_DELETE, ACTION_LEAVE],
|
||||
});
|
||||
});
|
||||
|
||||
describe('when group has leave action', () => {
|
||||
beforeEach(createComponent);
|
||||
|
||||
it('renders hidden leave modal', () => {
|
||||
expect(findLeaveModal().props('visible')).toBe(false);
|
||||
});
|
||||
|
||||
describe('when leave action is fired', () => {
|
||||
beforeEach(async () => {
|
||||
await fireLeaveAction();
|
||||
});
|
||||
|
||||
it('shows leave modal', () => {
|
||||
expect(findLeaveModal().props('visible')).toBe(true);
|
||||
});
|
||||
|
||||
describe('when leave modal emits visibility change', () => {
|
||||
it("updates the modal's visibility prop", async () => {
|
||||
findLeaveModal().vm.$emit('change', false);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(findLeaveModal().props('visible')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when leave modal emits success event', () => {
|
||||
it('emits refetch event', () => {
|
||||
findLeaveModal().vm.$emit('success');
|
||||
|
||||
expect(wrapper.emitted('refetch')).toEqual([[]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -327,7 +375,7 @@ describe('GroupsListItem', () => {
|
|||
});
|
||||
|
||||
it('displays confirmation modal with correct props', () => {
|
||||
expect(findConfirmationModal().props()).toMatchObject({
|
||||
expect(findDeleteConfirmationModal().props()).toMatchObject({
|
||||
visible: true,
|
||||
phrase: groupWithDeleteAction.fullName,
|
||||
confirmLoading: false,
|
||||
|
|
@ -340,12 +388,12 @@ describe('GroupsListItem', () => {
|
|||
axiosMock.onDelete(`/${groupWithDeleteAction.fullPath}`).reply(200);
|
||||
|
||||
await deleteModalFireConfirmEvent();
|
||||
expect(findConfirmationModal().props('confirmLoading')).toBe(true);
|
||||
expect(findDeleteConfirmationModal().props('confirmLoading')).toBe(true);
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(axiosMock.history.delete[0].params).toEqual(MOCK_DELETE_PARAMS);
|
||||
expect(findConfirmationModal().props('confirmLoading')).toBe(false);
|
||||
expect(findDeleteConfirmationModal().props('confirmLoading')).toBe(false);
|
||||
expect(wrapper.emitted('refetch')).toEqual([[]]);
|
||||
expect(renderDeleteSuccessToast).toHaveBeenCalledWith(groupWithDeleteAction);
|
||||
expect(createAlert).not.toHaveBeenCalled();
|
||||
|
|
@ -357,12 +405,12 @@ describe('GroupsListItem', () => {
|
|||
axiosMock.onDelete(`/${groupWithDeleteAction.fullPath}`).networkError();
|
||||
|
||||
await deleteModalFireConfirmEvent();
|
||||
expect(findConfirmationModal().props('confirmLoading')).toBe(true);
|
||||
expect(findDeleteConfirmationModal().props('confirmLoading')).toBe(true);
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(axiosMock.history.delete[0].params).toEqual(MOCK_DELETE_PARAMS);
|
||||
expect(findConfirmationModal().props('confirmLoading')).toBe(false);
|
||||
expect(findDeleteConfirmationModal().props('confirmLoading')).toBe(false);
|
||||
expect(wrapper.emitted('refetch')).toBeUndefined();
|
||||
expect(createAlert).toHaveBeenCalledWith({
|
||||
message:
|
||||
|
|
@ -377,11 +425,11 @@ describe('GroupsListItem', () => {
|
|||
|
||||
describe('when change is fired', () => {
|
||||
beforeEach(() => {
|
||||
findConfirmationModal().vm.$emit('change', false);
|
||||
findDeleteConfirmationModal().vm.$emit('change', false);
|
||||
});
|
||||
|
||||
it('updates visibility prop', () => {
|
||||
expect(findConfirmationModal().props('visible')).toBe(false);
|
||||
expect(findDeleteConfirmationModal().props('visible')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -405,7 +453,7 @@ describe('GroupsListItem', () => {
|
|||
});
|
||||
|
||||
it('does not display confirmation modal', () => {
|
||||
expect(findConfirmationModal().exists()).toBe(false);
|
||||
expect(findDeleteConfirmationModal().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
|
||||
import {
|
||||
ACTION_EDIT,
|
||||
ACTION_DELETE,
|
||||
ACTION_LEAVE,
|
||||
} from '~/vue_shared/components/list_actions/constants';
|
||||
|
||||
export const groups = [
|
||||
{
|
||||
|
|
@ -19,7 +23,7 @@ export const groups = [
|
|||
integerValue: 10,
|
||||
},
|
||||
editPath: 'http://127.0.0.1:3000/groups/gitlab-org/-/edit',
|
||||
availableActions: [ACTION_EDIT, ACTION_DELETE],
|
||||
availableActions: [ACTION_EDIT, ACTION_DELETE, ACTION_LEAVE],
|
||||
createdAt: '2023-09-19T14:42:38Z',
|
||||
updatedAt: '2024-04-24T03:47:38Z',
|
||||
lastActivityAt: '2024-05-24T03:47:38Z',
|
||||
|
|
@ -44,7 +48,7 @@ export const groups = [
|
|||
integerValue: 20,
|
||||
},
|
||||
editPath: 'http://127.0.0.1:3000/groups/gitlab-org/test-subgroup/-/edit',
|
||||
availableActions: [ACTION_EDIT, ACTION_DELETE],
|
||||
availableActions: [ACTION_EDIT, ACTION_DELETE, ACTION_LEAVE],
|
||||
createdAt: '2023-09-19T14:42:38Z',
|
||||
updatedAt: '2024-04-24T03:47:38Z',
|
||||
lastActivityAt: '2024-05-24T03:47:38Z',
|
||||
|
|
|
|||
|
|
@ -1,27 +1,33 @@
|
|||
import { deleteParams, renderDeleteSuccessToast } from '~/vue_shared/components/groups_list/utils';
|
||||
import {
|
||||
deleteParams,
|
||||
renderDeleteSuccessToast,
|
||||
renderLeaveSuccessToast,
|
||||
} from '~/vue_shared/components/groups_list/utils';
|
||||
import toast from '~/vue_shared/plugins/global_toast';
|
||||
|
||||
jest.mock('~/vue_shared/plugins/global_toast');
|
||||
|
||||
const MOCK_GROUP = {
|
||||
fullName: 'Group',
|
||||
fullPath: 'path/to/group',
|
||||
};
|
||||
|
||||
const MOCK_GROUP_NO_DELAY_DELETION = {
|
||||
fullName: 'No Delay Group',
|
||||
fullPath: 'path/to/group/1',
|
||||
...MOCK_GROUP,
|
||||
isAdjournedDeletionEnabled: false,
|
||||
markedForDeletionOn: null,
|
||||
permanentDeletionDate: null,
|
||||
};
|
||||
|
||||
const MOCK_GROUP_WITH_DELAY_DELETION = {
|
||||
fullName: 'With Delay Group',
|
||||
fullPath: 'path/to/group/2',
|
||||
...MOCK_GROUP,
|
||||
isAdjournedDeletionEnabled: true,
|
||||
markedForDeletionOn: null,
|
||||
permanentDeletionDate: '2024-03-31',
|
||||
};
|
||||
|
||||
const MOCK_GROUP_PENDING_DELETION = {
|
||||
fullName: 'Pending Deletion Group',
|
||||
fullPath: 'path/to/group/3',
|
||||
...MOCK_GROUP,
|
||||
isAdjournedDeletionEnabled: true,
|
||||
markedForDeletionOn: '2024-03-24',
|
||||
permanentDeletionDate: '2024-03-31',
|
||||
|
|
@ -53,6 +59,14 @@ describe('renderDeleteSuccessToast', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('renderLeaveSuccessToast', () => {
|
||||
it('calls toast correctly', () => {
|
||||
renderLeaveSuccessToast(MOCK_GROUP);
|
||||
|
||||
expect(toast).toHaveBeenCalledWith(`Left the '${MOCK_GROUP.fullName}' group successfully.`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteParams', () => {
|
||||
it('when delayed deletion is disabled, returns an empty object', () => {
|
||||
const res = deleteParams(MOCK_GROUP_NO_DELAY_DELETION);
|
||||
|
|
|
|||
|
|
@ -43,24 +43,24 @@ describe('ListActions', () => {
|
|||
text: 'Delete',
|
||||
variant: 'danger',
|
||||
action: expect.any(Function),
|
||||
order: 3,
|
||||
order: 4,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows adding custom actions', () => {
|
||||
const ACTION_LEAVE = 'leave';
|
||||
const ACTION_CUSTOM = 'custom';
|
||||
|
||||
createComponent({
|
||||
propsData: {
|
||||
actions: {
|
||||
...defaultPropsData.actions,
|
||||
[ACTION_LEAVE]: {
|
||||
text: 'Leave project',
|
||||
[ACTION_CUSTOM]: {
|
||||
text: 'Custom',
|
||||
action: () => {},
|
||||
},
|
||||
},
|
||||
availableActions: [ACTION_EDIT, ACTION_LEAVE, ACTION_DELETE],
|
||||
availableActions: [ACTION_EDIT, ACTION_CUSTOM, ACTION_DELETE],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -71,14 +71,14 @@ describe('ListActions', () => {
|
|||
order: 1,
|
||||
},
|
||||
{
|
||||
text: 'Leave project',
|
||||
text: 'Custom',
|
||||
action: expect.any(Function),
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
variant: 'danger',
|
||||
action: expect.any(Function),
|
||||
order: 3,
|
||||
order: 4,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -111,7 +111,7 @@ describe('ListActions', () => {
|
|||
text: 'Delete',
|
||||
variant: 'danger',
|
||||
action: expect.any(Function),
|
||||
order: 3,
|
||||
order: 4,
|
||||
},
|
||||
{
|
||||
text: 'Edit',
|
||||
|
|
|
|||
|
|
@ -67,6 +67,14 @@ describe('NestedGroupsProjectsListItem', () => {
|
|||
expect(wrapper.emitted('load-children')).toEqual([[1]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when NestedGroupsProjectsList emits refetch event', () => {
|
||||
it('emits refetch event', () => {
|
||||
findNestedGroupsProjectsList().vm.$emit('refetch');
|
||||
|
||||
expect(wrapper.emitted('refetch')).toEqual([[]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when item does not have children', () => {
|
||||
|
|
|
|||
|
|
@ -24,9 +24,14 @@ describe('NestedGroupsProjectsList', () => {
|
|||
});
|
||||
};
|
||||
|
||||
it('renders list with `NestedGroupsProjectsListItem` component', () => {
|
||||
createComponent();
|
||||
const findNestedGroupsProjectsListItem = () =>
|
||||
wrapper.findComponent(NestedGroupsProjectsListItem);
|
||||
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it('renders list with `NestedGroupsProjectsListItem` component', () => {
|
||||
const listItemWrappers = wrapper.findAllComponents(NestedGroupsProjectsListItem).wrappers;
|
||||
const expectedProps = listItemWrappers.map((listItemWrapper) => listItemWrapper.props());
|
||||
|
||||
|
|
@ -41,11 +46,17 @@ describe('NestedGroupsProjectsList', () => {
|
|||
|
||||
describe('when `NestedGroupsProjectsListItem emits load-children event', () => {
|
||||
it('emits load-children event', () => {
|
||||
createComponent();
|
||||
|
||||
wrapper.findComponent(NestedGroupsProjectsListItem).vm.$emit('load-children', 1);
|
||||
findNestedGroupsProjectsListItem().vm.$emit('load-children', 1);
|
||||
|
||||
expect(wrapper.emitted('load-children')).toEqual([[1]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when NestedGroupsProjectsListItem emits refetch event', () => {
|
||||
it('emits refetch event', () => {
|
||||
findNestedGroupsProjectsListItem().vm.$emit('refetch');
|
||||
|
||||
expect(wrapper.emitted('refetch')).toEqual([[]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue