Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-05-23 21:17:49 +00:00
parent 51cb20bfda
commit 9cc6ca7c94
103 changed files with 1054 additions and 667 deletions

View File

@ -1,5 +1,5 @@
<script>
import { GlForm, GlModal, GlAlert } from '@gitlab/ui';
import { GlForm, GlModal, GlAlert, GlButton } from '@gitlab/ui';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import { formType } from '../constants';
@ -32,6 +32,7 @@ export default {
[formType.edit]: { title: s__('Board|Configure board'), btnText: __('Save changes') },
scopeModalTitle: s__('Board|Board configuration'),
cancelButtonText: __('Cancel'),
deleteButtonText: s__('Board|Delete board'),
deleteErrorMessage: s__('Board|Failed to delete board. Please try again.'),
saveErrorMessage: __('Unable to save your changes. Please try again.'),
deleteConfirmationMessage: s__('Board|Are you sure you want to delete this board?'),
@ -41,6 +42,7 @@ export default {
components: {
BoardScope: () => import('ee_component/boards/components/board_scope.vue'),
GlModal,
GlButton,
BoardConfigurationOptions,
GlAlert,
GlForm,
@ -82,6 +84,11 @@ export default {
type: String,
required: true,
},
showDelete: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
@ -170,12 +177,16 @@ export default {
mutationVariables() {
return this.baseMutationVariables;
},
canDelete() {
return this.canAdminBoard && this.showDelete && this.isEditForm;
},
},
mounted() {
this.resetFormState();
if (this.$refs.name) {
this.$refs.name.focus();
}
this.$emit('shown');
},
methods: {
setError,
@ -197,6 +208,9 @@ export default {
return response.data.updateBoard.board;
},
openDeleteModal() {
this.$emit('showBoardModal', this.$options.formType.delete);
},
async deleteBoard() {
await this.$apollo.mutate({
mutation: this.deleteMutation,
@ -265,6 +279,7 @@ export default {
this.$set(this.board, 'weight', weight);
},
},
formType,
};
</script>
@ -331,5 +346,23 @@ export default {
@set-weight="setWeight"
/>
</gl-form>
<template v-if="canDelete" #modal-footer>
<div class="gl-display-flex gl-justify-content-space-between gl-w-full gl-m-0">
<gl-button
category="secondary"
variant="danger"
data-testid="delete-board-button"
@click="openDeleteModal"
>
{{ $options.i18n.deleteButtonText }}</gl-button
>
<div>
<gl-button @click="cancel">{{ cancelProps.text }}</gl-button>
<gl-button v-bind="primaryProps.attributes" @click="submit">{{
primaryProps.text
}}</gl-button>
</div>
</div>
</template>
</gl-modal>
</template>

View File

@ -303,18 +303,6 @@ export default {
>
{{ s__('IssueBoards|Create new board') }}
</gl-button>
<gl-button
v-if="showDelete"
v-gl-modal-directive="'board-config-modal'"
block
category="tertiary"
variant="danger"
class="gl-mt-0! gl-justify-content-start!"
@click="$emit('showBoardModal', $options.formType.delete)"
>
{{ s__('IssueBoards|Delete board') }}
</gl-button>
</div>
</template>
</gl-collapsible-listbox>
@ -326,8 +314,11 @@ export default {
:weights="weights"
:current-board="board"
:current-page="boardModalForm"
:show-delete="showDelete"
@addBoard="addBoard"
@updateBoard="$emit('updateBoard', $event)"
@showBoardModal="$emit('showBoardModal', $event)"
@shown="loadBoards"
@cancel="cancel"
/>
</span>

View File

@ -1,5 +1,5 @@
<script>
import CommentTemplatesDropdown from '~/vue_shared/components/markdown/comment_templates_dropdown.vue';
import CommentTemplatesModal from '~/vue_shared/components/markdown/comment_templates_modal.vue';
import { __, sprintf } from '~/locale';
import { getModifierKey } from '~/constants';
import trackUIControl from '../services/track_ui_control';
@ -17,7 +17,7 @@ export default {
ToolbarTableButton,
ToolbarAttachmentButton,
ToolbarMoreDropdown,
CommentTemplatesDropdown,
CommentTemplatesModal,
HeaderDivider,
},
inject: {
@ -201,7 +201,7 @@ export default {
/>
<header-divider v-if="newCommentTemplatePaths.length" />
</div>
<comment-templates-dropdown
<comment-templates-modal
v-if="newCommentTemplatePaths.length"
:new-comment-template-paths="newCommentTemplatePaths"
@select="insertSavedReply"

View File

@ -0,0 +1,17 @@
<script>
export default {
props: {
podName: {
type: String,
required: true,
},
namespace: {
type: String,
required: true,
},
},
};
</script>
<template>
<div>{{ podName }} {{ namespace }}</div>
</template>

View File

@ -0,0 +1,45 @@
<script>
import { GlBreadcrumb } from '@gitlab/ui';
export default {
components: {
GlBreadcrumb,
},
computed: {
rootRoute() {
const rootName = this.$route.meta.environmentName;
return {
text: rootName,
to: `/`,
};
},
logsRoute() {
const { podName } = this.$route.params;
return {
text: `${podName}`,
to: this.$route.path,
};
},
isRootRoute() {
return this.$route.name === 'environment_details';
},
isLoaded() {
return Boolean(this.$route.meta.environmentName);
},
breadcrumbs() {
if (!this.isLoaded) {
return [];
}
const breadCrumbs = [this.rootRoute];
if (!this.isRootRoute) {
breadCrumbs.push(this.logsRoute);
}
return breadCrumbs;
},
},
};
</script>
<template>
<gl-breadcrumb v-if="isLoaded" :items="breadcrumbs" />
</template>

View File

@ -3,6 +3,8 @@ import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { removeLastSlashInUrlPath } from '~/lib/utils/url_utility';
import { injectVueAppBreadcrumbs } from '~/lib/utils/breadcrumbs';
import EnvironmentBreadcrumbs from './environment_details/environment_breadcrumbs.vue';
import EnvironmentsDetailHeader from './components/environments_detail_header.vue';
import { apolloProvider as createApolloProvider } from './graphql/client';
import environmentsMixin from './mixins/environments_mixin';
@ -60,8 +62,6 @@ export const initHeader = () => {
};
export const initPage = async () => {
const EnvironmentsDetailPageModule = await import('./environment_details/index.vue');
const EnvironmentsDetailPage = EnvironmentsDetailPageModule.default;
const dataElement = document.getElementById('environments-detail-view');
const dataSet = convertObjectPropsToCamelCase(JSON.parse(dataElement.dataset.details));
@ -70,12 +70,27 @@ export const initPage = async () => {
const router = new VueRouter({
mode: 'history',
base: window.location.pathname,
base: dataSet.basePath,
routes: [
{
path: '/k8s/namespace/:namespace/pods/:podName/logs',
name: 'logs',
meta: {
environmentName: dataSet.name,
},
component: () => import('./environment_details/components/kubernetes/kubernetes_logs.vue'),
props: (route) => ({
podName: route.params.podName,
namespace: route.params.namespace,
}),
},
{
path: '/',
name: 'environment_details',
component: EnvironmentsDetailPage,
meta: {
environmentName: dataSet.name,
},
component: () => import('./environment_details/index.vue'),
props: (route) => ({
after: route.query.after,
before: route.query.before,
@ -92,6 +107,8 @@ export const initPage = async () => {
},
});
injectVueAppBreadcrumbs(router, EnvironmentBreadcrumbs);
return new Vue({
el,
apolloProvider,

View File

@ -0,0 +1 @@
import '../show';

View File

@ -2,18 +2,10 @@
import { GlSearchBoxByType, GlOutsideDirective as Outside, GlModal } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions, mapGetters } from 'vuex';
import { debounce, clamp } from 'lodash';
import { debounce } from 'lodash';
import { visitUrl } from '~/lib/utils/url_utility';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { sprintf } from '~/locale';
import {
ARROW_DOWN_KEY,
ARROW_UP_KEY,
END_KEY,
HOME_KEY,
ESC_KEY,
NUMPAD_ENTER_KEY,
} from '~/lib/utils/keys';
import {
COMMAND_PALETTE,
MIN_SEARCH_TERM,
@ -23,6 +15,7 @@ import {
SEARCH_RESULTS_LOADING,
COMMAND_PALETTE_TIP,
} from '~/vue_shared/global_search/constants';
import modalKeyboardNavigationMixin from '~/vue_shared/mixins/modal_keyboard_navigation_mixin';
import { darkModeEnabled } from '~/lib/utils/color_utils';
import ScrollScrim from '~/super_sidebar/components/scroll_scrim.vue';
import {
@ -30,8 +23,6 @@ import {
SEARCH_RESULTS_DESCRIPTION,
SEARCH_SHORTCUTS_MIN_CHARACTERS,
SEARCH_MODAL_ID,
SEARCH_INPUT_SELECTOR,
SEARCH_RESULTS_ITEM_SELECTOR,
KEY_K,
} from '../constants';
import CommandPaletteItems from '../command_palette/command_palette_items.vue';
@ -71,6 +62,7 @@ export default {
ScrollScrim,
CommandsOverviewDropdown,
},
mixins: [modalKeyboardNavigationMixin],
data() {
return {
nextFocusedItemIndex: null,
@ -152,51 +144,6 @@ export default {
this.fetchAutocompleteOptions();
}
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
getFocusableOptions() {
return Array.from(
this.$refs.resultsList?.querySelectorAll(SEARCH_RESULTS_ITEM_SELECTOR) || [],
);
},
onKeydown(event) {
const { code, target } = event;
let stop = true;
const elements = this.getFocusableOptions();
if (elements.length < 1) return;
const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR);
if (code === HOME_KEY) {
if (isSearchInput) return;
this.focusItem(0, elements);
} else if (code === END_KEY) {
if (isSearchInput) return;
this.focusItem(elements.length - 1, elements);
} else if (code === ARROW_UP_KEY) {
if (isSearchInput) return;
if (elements.indexOf(target) === 0) {
this.focusSearchInput();
} else {
this.focusNextItem(event, elements, -1);
}
} else if (code === ARROW_DOWN_KEY) {
this.focusNextItem(event, elements, 1);
} else if (code === ESC_KEY) {
this.$refs.searchModal.close();
} else if (code === NUMPAD_ENTER_KEY) {
event.target?.firstChild.click();
} else {
stop = false;
}
if (stop) {
event.preventDefault();
}
},
onKeyComboDown(event) {
const { code, metaKey } = event;
@ -209,21 +156,6 @@ export default {
this.commandPaletteDropdownOpen = !this.commandPaletteDropdownOpen;
}
},
focusSearchInput() {
this.$refs.searchInput.$el.querySelector('input')?.focus();
},
focusNextItem(event, elements, offset) {
const { target } = event;
const currentIndex = elements.indexOf(target);
const nextIndex = clamp(currentIndex + offset, 0, elements.length - 1);
this.focusItem(nextIndex, elements);
},
focusItem(index, elements) {
this.nextFocusedItemIndex = index;
elements[index]?.focus();
},
submitSearch() {
if (this.isCommandMode) {
this.runFirstCommand();
@ -283,7 +215,7 @@ export default {
<template>
<gl-modal
ref="searchModal"
ref="modal"
:modal-id="$options.SEARCH_MODAL_ID"
hide-header
hide-header-close

View File

@ -271,6 +271,8 @@ export const Tracker = {
if (window.glClient) {
window.glClient?.setReferrerUrl(pageLinks.referrer);
}
} else {
window.snowplow('setReferrerUrl', window.gl?.maskedDefaultReferrerUrl);
}
}

View File

@ -1,186 +0,0 @@
<script>
import { GlCollapsibleListbox, GlTooltip, GlButton } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { getDerivedMergeRequestInformation } from '~/diffs/utils/merge_request';
import { InternalEvents } from '~/tracking';
import savedRepliesQuery from 'ee_else_ce/vue_shared/components/markdown/saved_replies.query.graphql';
import {
TRACKING_SAVED_REPLIES_USE,
TRACKING_SAVED_REPLIES_USE_IN_MR,
TRACKING_SAVED_REPLIES_USE_IN_OTHER,
COMMENT_TEMPLATES_KEYS,
COMMENT_TEMPLATES_TITLES,
} from 'ee_else_ce/vue_shared/components/markdown/constants';
export default {
apollo: {
savedReplies: {
query: savedRepliesQuery,
manual: true,
result({ data, loading }) {
if (!loading) {
this.savedReplies = data;
}
},
variables() {
const groupPath = document.body.dataset.groupFullPath;
const projectPath = document.body.dataset.projectFullPath;
return {
groupPath,
hideGroup: !groupPath,
projectPath,
hideProject: !projectPath,
};
},
skip() {
return !this.shouldFetchCommentTemplates;
},
},
},
components: {
GlCollapsibleListbox,
GlButton,
GlTooltip,
},
mixins: [InternalEvents.mixin()],
props: {
newCommentTemplatePaths: {
type: Array,
required: true,
},
},
data() {
return {
shouldFetchCommentTemplates: false,
savedReplies: {},
commentTemplateSearch: '',
loadingSavedReplies: false,
};
},
computed: {
allSavedReplies() {
return COMMENT_TEMPLATES_KEYS.map((key) => ({
text: COMMENT_TEMPLATES_TITLES[key],
options: (this.savedReplies[key]?.savedReplies?.nodes || []).map((r) => ({
value: r.id,
text: r.name,
content: r.content,
})),
}));
},
filteredSavedReplies() {
let savedReplies = this.allSavedReplies;
if (this.commentTemplateSearch) {
savedReplies = savedReplies
.map((group) => ({
...group,
options: fuzzaldrinPlus.filter(group.options, this.commentTemplateSearch, {
key: ['text'],
}),
}))
.filter(({ options }) => options.length);
}
return savedReplies.filter(({ options }) => options.length);
},
},
mounted() {
this.tooltipTarget = this.$el.querySelector('.js-comment-template-toggle');
},
methods: {
fetchCommentTemplates() {
this.shouldFetchCommentTemplates = true;
},
setCommentTemplateSearch(search) {
this.commentTemplateSearch = search;
},
onSelect(id) {
let savedReply;
const isInMr = Boolean(getDerivedMergeRequestInformation({ endpoint: window.location }).id);
for (let i = 0, len = this.allSavedReplies.length; i < len; i += 1) {
const { options } = this.allSavedReplies[i];
savedReply = options.find(({ value }) => value === id);
if (savedReply) break;
}
if (savedReply) {
this.$emit('select', savedReply.content);
this.trackEvent(TRACKING_SAVED_REPLIES_USE);
this.trackEvent(
isInMr ? TRACKING_SAVED_REPLIES_USE_IN_MR : TRACKING_SAVED_REPLIES_USE_IN_OTHER,
);
}
},
},
};
</script>
<template>
<span>
<gl-collapsible-listbox
:header-text="__('Insert comment template')"
:items="filteredSavedReplies"
:toggle-text="__('Insert comment template')"
text-sr-only
no-caret
toggle-class="js-comment-template-toggle"
icon="comment-lines"
category="tertiary"
placement="right"
searchable
size="small"
class="comment-template-dropdown gl-mr-2"
positioning-strategy="fixed"
:searching="$apollo.queries.savedReplies.loading"
@shown="fetchCommentTemplates"
@search="setCommentTemplateSearch"
@select="onSelect"
>
<template #list-item="{ item }">
<div class="gl-display-flex js-comment-template-content">
<div class="gl-text-truncate">
<strong>{{ item.text }}</strong
><span class="gl-ml-2">{{ item.content }}</span>
</div>
</div>
</template>
<template #footer>
<div
class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-200 gl-display-flex gl-justify-content-center gl-flex-direction-column gl-p-2"
>
<gl-button
v-for="(manage, index) in newCommentTemplatePaths"
:key="index"
:href="manage.path"
category="tertiary"
block
class="gl-justify-content-start! gl-mt-0! gl-mb-0! gl-px-3!"
data-testid="manage-button"
>{{ manage.text }}</gl-button
>
</div>
</template>
</gl-collapsible-listbox>
<gl-tooltip :target="() => tooltipTarget">
{{ __('Insert comment template') }}
</gl-tooltip>
</span>
</template>
<style>
.comment-template-dropdown .gl-new-dropdown-panel {
width: 350px !important;
}
.comment-template-dropdown .gl-new-dropdown-item-check-icon {
display: none;
}
.comment-template-dropdown input {
border-radius: 0;
}
</style>

View File

@ -0,0 +1,196 @@
<script>
import {
GlTooltipDirective,
GlButton,
GlModal,
GlSearchBoxByType,
GlTruncate,
GlDisclosureDropdown,
GlDisclosureDropdownGroup,
} from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { uniqueId } from 'lodash';
import modalKeyboardNavigationMixin from '~/vue_shared/mixins/modal_keyboard_navigation_mixin';
import { getDerivedMergeRequestInformation } from '~/diffs/utils/merge_request';
import { InternalEvents } from '~/tracking';
import savedRepliesQuery from 'ee_else_ce/vue_shared/components/markdown/saved_replies.query.graphql';
import {
TRACKING_SAVED_REPLIES_USE,
TRACKING_SAVED_REPLIES_USE_IN_MR,
TRACKING_SAVED_REPLIES_USE_IN_OTHER,
COMMENT_TEMPLATES_KEYS,
COMMENT_TEMPLATES_TITLES,
} from 'ee_else_ce/vue_shared/components/markdown/constants';
export default {
apollo: {
savedReplies: {
query: savedRepliesQuery,
manual: true,
result({ data, loading }) {
if (!loading) {
this.savedReplies = data;
}
},
variables() {
const groupPath = document.body.dataset.groupFullPath;
const projectPath = document.body.dataset.projectFullPath;
return {
groupPath,
hideGroup: !groupPath,
projectPath,
hideProject: !projectPath,
};
},
skip() {
return !this.shouldFetchCommentTemplates;
},
},
},
components: {
GlButton,
GlModal,
GlSearchBoxByType,
GlTruncate,
GlDisclosureDropdown,
GlDisclosureDropdownGroup,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [InternalEvents.mixin(), modalKeyboardNavigationMixin],
props: {
newCommentTemplatePaths: {
type: Array,
required: true,
},
},
data() {
return {
shouldFetchCommentTemplates: false,
savedReplies: {},
commentTemplateSearch: '',
loadingSavedReplies: false,
};
},
computed: {
allSavedReplies() {
return COMMENT_TEMPLATES_KEYS.map((key) => ({
name: COMMENT_TEMPLATES_TITLES[key],
items: (this.savedReplies[key]?.savedReplies?.nodes || []).map((r) => ({
value: r.id,
text: r.name,
content: r.content,
})),
}));
},
filteredSavedReplies() {
let savedReplies = this.allSavedReplies;
if (this.commentTemplateSearch) {
savedReplies = savedReplies
.map((group) => ({
...group,
items: fuzzaldrinPlus.filter(group.items, this.commentTemplateSearch, {
key: ['text'],
}),
}))
.filter(({ items }) => items.length);
}
return savedReplies.filter(({ items }) => items.length);
},
modalId() {
return uniqueId('insert-comment-template-modal-');
},
},
methods: {
onSelect(savedReply) {
const isInMr = Boolean(getDerivedMergeRequestInformation({ endpoint: window.location }).id);
this.$emit('select', savedReply.content);
this.trackEvent(TRACKING_SAVED_REPLIES_USE);
this.trackEvent(
isInMr ? TRACKING_SAVED_REPLIES_USE_IN_MR : TRACKING_SAVED_REPLIES_USE_IN_OTHER,
);
this.shouldFetchCommentTemplates = false;
},
toggleModal() {
this.shouldFetchCommentTemplates = !this.shouldFetchCommentTemplates;
},
},
};
</script>
<template>
<span>
<gl-modal
ref="modal"
v-model="shouldFetchCommentTemplates"
:title="__('Select a comment template')"
scrollable
:modal-id="modalId"
modal-class="comment-templates-modal"
>
<gl-search-box-by-type
ref="searchInput"
v-model="commentTemplateSearch"
:placeholder="__('Search comment templates')"
@keydown="onKeydown"
/>
<section v-if="!filteredSavedReplies.length" class="gl-mt-3">
{{ __('No comment templates found.') }}
</section>
<ul
v-else
ref="resultsList"
class="gl-m-0 gl-p-0 gl-list-none comment-templates-options"
data-testid="comment-templates-list"
@keydown="onKeydown"
>
<gl-disclosure-dropdown-group
v-for="(commentTemplateGroup, index) in filteredSavedReplies"
:key="commentTemplateGroup.name"
:class="{ 'gl-mt-0! gl-border-t-0! gl-pt-0': index === 0 }"
:group="commentTemplateGroup"
bordered
@action="onSelect"
>
<template #list-item="{ item }">
<strong class="gl-block gl-w-full">{{ item.text }}</strong>
<gl-truncate class="gl-mt-2" :text="item.content" position="end" />
</template>
</gl-disclosure-dropdown-group>
</ul>
<template #modal-footer>
<gl-disclosure-dropdown
:items="newCommentTemplatePaths"
:toggle-text="__('Manage comment templates')"
placement="bottom-end"
fluid-width
data-testid="manage-dropdown"
>
<template #header>
<div
class="gl-font-sm gl-font-weight-bold gl-p-4! gl-min-h-8 gl-border-b-1 gl-border-b-solid gl-border-b-gray-200"
>
{{ __('Manage') }}
</div>
</template>
</gl-disclosure-dropdown>
</template>
</gl-modal>
<gl-button
v-gl-tooltip
:title="__('Insert comment template')"
:aria-label="__('Insert comment template')"
category="tertiary"
size="small"
icon="comment-lines"
class="js-comment-template-toggle"
data-testid="comment-templates-dropdown-toggle"
@click="toggleModal"
/>
</span>
</template>

View File

@ -20,7 +20,7 @@ import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
import { updateText } from '~/lib/utils/text_markdown';
import ToolbarButton from './toolbar_button.vue';
import DrawioToolbarButton from './drawio_toolbar_button.vue';
import CommentTemplatesDropdown from './comment_templates_dropdown.vue';
import CommentTemplatesModal from './comment_templates_modal.vue';
import HeaderDivider from './header_divider.vue';
export default {
@ -29,7 +29,7 @@ export default {
GlPopover,
GlButton,
DrawioToolbarButton,
CommentTemplatesDropdown,
CommentTemplatesModal,
AiActionsDropdown: () => import('ee_component/ai/components/ai_actions_dropdown.vue'),
HeaderDivider,
},
@ -235,6 +235,10 @@ export default {
},
insertSavedReply(savedReply) {
this.insertIntoTextarea(savedReply);
setTimeout(() => {
this.$el.closest('.md-area')?.querySelector('textarea')?.focus();
}, 500);
},
},
shortcuts: {
@ -521,7 +525,7 @@ export default {
icon="quick-actions"
tracking-property="quickAction"
/>
<comment-templates-dropdown
<comment-templates-modal
v-if="!previewMarkdown && newCommentTemplatePaths.length"
:new-comment-template-paths="newCommentTemplatePaths"
@select="insertSavedReply"

View File

@ -0,0 +1,72 @@
import { clamp } from 'lodash';
import {
ARROW_DOWN_KEY,
ARROW_UP_KEY,
END_KEY,
HOME_KEY,
ESC_KEY,
NUMPAD_ENTER_KEY,
} from '~/lib/utils/keys';
export default {
methods: {
getFocusableOptions() {
return Array.from(this.$refs.resultsList?.querySelectorAll('.gl-new-dropdown-item') || []);
},
onKeydown(event) {
const { code, target } = event;
let stop = true;
const elements = this.getFocusableOptions();
if (elements.length < 1) return;
const isSearchInput = target.matches('input[role="searchbox"]');
if (code === HOME_KEY) {
if (isSearchInput) return;
this.focusItem(0, elements);
} else if (code === END_KEY) {
if (isSearchInput) return;
this.focusItem(elements.length - 1, elements);
} else if (code === ARROW_UP_KEY) {
if (isSearchInput) return;
if (elements.indexOf(target) === 0) {
this.focusSearchInput();
} else {
this.focusNextItem(event, elements, -1);
}
} else if (code === ARROW_DOWN_KEY) {
this.focusNextItem(event, elements, 1);
} else if (code === ESC_KEY) {
this.$refs.modal.close();
} else if (code === NUMPAD_ENTER_KEY) {
event.target?.firstChild.click();
} else {
stop = false;
}
if (stop) {
event.preventDefault();
}
},
focusSearchInput() {
this.$refs.searchInput.$el.querySelector('input')?.focus();
},
focusNextItem(event, elements, offset) {
const { target } = event;
const currentIndex = elements.indexOf(target);
const nextIndex = clamp(currentIndex + offset, 0, elements.length - 1);
this.focusItem(nextIndex, elements);
},
focusItem(index, elements) {
this.nextFocusedItemIndex = index;
elements[index]?.focus();
},
},
};

View File

@ -105,3 +105,24 @@
font-weight: $gl-font-weight-normal;
}
}
.comment-templates-modal {
padding: 3rem 0.5rem 0;
&.gl-modal .modal-dialog {
align-items: flex-start;
}
@include gl-media-breakpoint-up(sm) {
padding: 5rem 1rem 0;
}
[id*='gl-disclosure-dropdown-group'] {
padding-left: 0;
}
.comment-templates-options .gl-new-dropdown-item {
padding-left: 0;
padding-right: 0;
}
}

View File

@ -19,13 +19,13 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :authorize_stop_environment!, only: [:stop]
before_action :authorize_update_environment!, only: [:edit, :update, :cancel_auto_stop]
before_action :authorize_admin_environment!, only: [:terminal, :terminal_websocket_authorize]
before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :cancel_auto_stop]
before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :cancel_auto_stop, :k8s]
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? }
before_action :set_kas_cookie, only: [:edit, :new, :show], if: -> { current_user && request.format.html? }
before_action :set_kas_cookie, only: [:edit, :new, :show, :k8s], if: -> { current_user && request.format.html? }
after_action :expire_etag_cache, only: [:cancel_auto_stop]
track_event :index, :folder, :show, :new, :edit, :create, :update, :stop, :cancel_auto_stop, :terminal,
track_event :index, :folder, :show, :new, :edit, :create, :update, :stop, :cancel_auto_stop, :terminal, :k8s,
name: 'users_visiting_environments_pages'
feature_category :continuous_delivery
@ -81,7 +81,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController
# rubocop: enable CodeReuse/ActiveRecord
def show
@deployments = deployments
end
def new
@ -91,6 +90,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def edit
end
def k8s
render action: :show
end
def create
@environment = project.environments.create(environment_params)
@ -130,7 +133,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def cancel_auto_stop
result = Environments::ResetAutoStopService.new(project, current_user)
.execute(environment)
.execute(environment)
if result[:status] == :success
respond_to do |format|

View File

@ -0,0 +1,63 @@
# frozen_string_literal: true
module Mutations
module Projects
class BlobsRemove < BaseMutation
graphql_name 'projectBlobsRemove'
include FindsProject
EMPTY_BLOBS_OIDS_ARG = <<~ERROR
Argument 'blobOids' on InputObject 'projectBlobsRemoveInput' is required. Expected type [String!]!
ERROR
authorize :owner_access
argument :project_path, GraphQL::Types::ID,
required: true,
description: 'Full path of the project to replace.'
argument :blob_oids, [GraphQL::Types::String],
required: true,
description: 'List of blob oids.',
prepare: ->(blob_oids, _ctx) do
blob_oids.reject!(&:blank?)
break blob_oids if blob_oids.present?
raise GraphQL::ExecutionError, EMPTY_BLOBS_OIDS_ARG
end
def resolve(project_path:, blob_oids:)
project = authorized_find!(project_path)
begin
project.set_repository_read_only!
client = Gitlab::GitalyClient::CleanupService.new(project.repository)
client.rewrite_history(blobs: blob_oids)
audit_removals(project, blob_oids)
{ errors: [] }
ensure
project.set_repository_writable!
end
end
private
def audit_removals(project, blob_oids)
context = {
name: 'project_blobs_removal',
author: current_user,
scope: project,
target: project,
message: 'Project blobs removed',
additional_details: { blob_oids: blob_oids }
}
::Gitlab::Audit::Auditor.audit(context)
end
end
end
end

View File

@ -111,6 +111,7 @@ module Types
mount_mutation Mutations::Notes::Destroy
mount_mutation Mutations::Organizations::Create, alpha: { milestone: '16.6' }
mount_mutation Mutations::Organizations::Update, alpha: { milestone: '16.7' }
mount_mutation Mutations::Projects::BlobsRemove, calls_gitaly: true, alpha: { milestone: '17.1' }
mount_mutation Mutations::Projects::SyncFork, calls_gitaly: true, alpha: { milestone: '15.9' }
mount_mutation Mutations::Projects::Star, alpha: { milestone: '16.7' }
mount_mutation Mutations::BranchRules::Update, alpha: { milestone: '16.7' }

View File

@ -18,6 +18,7 @@ module EnvironmentHelper
name: environment.name,
id: environment.id,
project_full_path: project.full_path,
base_path: project_environment_path(project, environment),
external_url: environment.external_url,
can_update_environment: can?(current_user, :update_environment, environment),
can_destroy_environment: can_destroy_environment?(environment),

View File

@ -421,8 +421,8 @@ module IssuablesHelper
def new_comment_template_paths(group, project = nil)
[{
text: _('Manage your comment templates'),
path: profile_comment_templates_path
text: _('Your comment templates'),
href: profile_comment_templates_path
}]
end
end

View File

@ -2,6 +2,9 @@
module Routing
module PseudonymizationHelper
PSEUDONOMIZED_NAMESPACE = "namespace"
PSEUDONOMIZED_PROJECT = "project"
class MaskHelper
QUERY_PARAMS_TO_NOT_MASK = %w[
scope
@ -93,5 +96,49 @@ module Routing
Gitlab::ErrorTracking.track_exception(e, url: request.original_fullpath)
nil
end
def masked_referrer_url(url)
return unless url
params = referrer_params(url)
return unless params && params[:controller]
return if params[:action] == "route_not_found"
case params[:controller]
when 'groups'
params[:id] = PSEUDONOMIZED_NAMESPACE
when 'projects'
params[:id] = PSEUDONOMIZED_PROJECT
params[:namespace_id] = PSEUDONOMIZED_NAMESPACE
else
params[:project_id] = PSEUDONOMIZED_PROJECT if params[:project_id]
params[:namespace_id] = PSEUDONOMIZED_NAMESPACE if params[:namespace_id]
end
masked_query_params = masked_query_params(URI.parse(url))
Gitlab::Routing.url_helpers.url_for(params.merge(params: masked_query_params))
end
def masked_query_params(uri)
query_params = CGI.parse(uri.query.to_s)
query_params.transform_keys!(&:downcase)
return if query_params.empty?
query_params.each do |key, _|
query_params[key] = ["masked_#{key}"] unless MaskHelper::QUERY_PARAMS_TO_NOT_MASK.include?(key)
end
query_params
end
def referrer_params(url)
Rails.application.routes.recognize_path(url)
rescue StandardError => e
Gitlab::ErrorTracking.track_exception(e, url: request.original_fullpath)
nil
end
end
end

View File

@ -16,3 +16,4 @@
user_id: current_user&.id
).to_context.to_json.to_json}
gl.snowplowPseudonymizedPageUrl = #{masked_page_url(group: namespace, project: @project).to_json};
gl.maskedDefaultReferrerUrl = #{masked_referrer_url(request.referer).to_json};

View File

@ -2,7 +2,7 @@
- namespace_id = local_assigns.fetch(:namespace_id, nil)
- page_title _('Comment templates')
#js-comment-templates-root.settings-section.gl-mt-3{ data: { base_path: base_path, namespace_id: namespace_id, } }
#js-comment-templates-root.settings-section.gl-mt-3{ data: { base_path: base_path, namespace_id: namespace_id } }
.settings-sticky-header
.settings-sticky-header-inner
%h4.gl-my-0

View File

@ -0,0 +1,11 @@
---
name: project_blobs_removal
description: Triggered when removing blobs via the GraphQL API or project settings
UI
introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/450701
introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/152522
feature_category: source_code_management
milestone: '17.0'
saved_to_database: true
streamed: true
scope: [Project]

View File

@ -327,6 +327,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
post :stop
post :cancel_auto_stop
get :terminal
get '/k8s(/*vueroute)', to: 'environments#k8s', as: :k8s_subroute
# This route is also defined in gitlab-workhorse. Make sure to update accordingly.
get '/terminal.ws/authorize', to: 'environments#terminal_websocket_authorize', format: false

View File

@ -445,6 +445,7 @@ Audit event types belong to the following product categories.
| [`protected_branch_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/107530) | Event triggered on the setting for protected branches is update| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.8](https://gitlab.com/gitlab-org/gitlab/-/issues/369318) | Project |
| [`repository_git_operation`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/76719) | Triggered when authenticated users push, pull, or clone a project using SSH, HTTP(S), or the UI| **{dotted-circle}** No | **{check-circle}** Yes | GitLab [14.9](https://gitlab.com/gitlab-org/gitlab/-/issues/373950) | Project |
| [`manually_trigger_housekeeping`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/112095) | Triggered when manually triggering housekeeping via API or admin UI| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/390761) | Project |
| [`project_blobs_removal`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/152522) | Triggered when removing blobs via the GraphQL API or project settings UI| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.0](https://gitlab.com/gitlab-org/gitlab/-/issues/450701) | Project |
### Subgroup

View File

@ -394,19 +394,6 @@ Although most S3 compatible services (like [MinIO](https://min.io/)) should work
we only guarantee support for AWS S3. Because we cannot assert the correctness of third-party S3 implementations,
we can debug issues, but we cannot patch the registry unless an issue is reproducible against an AWS S3 bucket.
<!--- start_remove The following content will be removed on remove_date: '2024-05-16' -->
WARNING:
Support for the following drivers was [deprecated](https://gitlab.com/gitlab-org/container-registry/-/issues/1141)
in GitLab 16.6, and is planned for removal in 17.0. This change is a breaking change.
| Driver | Description |
|---------|-------------|
| `swift` | OpenStack Swift Object Storage |
| `oss` | Aliyun OSS |
<!--- end_remove -->
### Use file system
If you want to store your images on the file system, you can change the storage

View File

@ -284,32 +284,7 @@ Example response:
}
```
When the [unified approval setting](../ci/environments/deployment_approvals.md#unified-approval-setting-deprecated) is configured, deployments created by users on GitLab Premium or Ultimate include the `approvals` and `pending_approval_count` properties:
```json
{
"status": "created",
"pending_approval_count": 0,
"approvals": [
{
"user": {
"id": 49,
"username": "project_6_bot",
"name": "****",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/e83ac685f68ea07553ad3054c738c709?s=80&d=identicon",
"web_url": "http://localhost:3000/project_6_bot"
},
"status": "approved",
"created_at": "2022-02-24T20:22:30.097Z",
"comment": "Looks good to me"
}
],
...
}
```
When the [multiple approval rules](../ci/environments/deployment_approvals.md#add-multiple-approval-rules) is configured, deployments created by users on GitLab Premium or Ultimate include the `approval_summary` property:
When [multiple approval rules](../ci/environments/deployment_approvals.md#add-multiple-approval-rules) are configured, deployments created by users on GitLab Premium or Ultimate include the `approval_summary` property:
```json
{

View File

@ -7248,6 +7248,29 @@ Input type: `ProductAnalyticsProjectSettingsUpdateInput`
| <a id="mutationproductanalyticsprojectsettingsupdateproductanalyticsconfiguratorconnectionstring"></a>`productAnalyticsConfiguratorConnectionString` | [`String`](#string) | Connection string for the product analytics configurator. |
| <a id="mutationproductanalyticsprojectsettingsupdateproductanalyticsdatacollectorhost"></a>`productAnalyticsDataCollectorHost` | [`String`](#string) | Host for the product analytics data collector. |
### `Mutation.projectBlobsRemove`
DETAILS:
**Introduced** in GitLab 17.1.
**Status**: Experiment.
Input type: `projectBlobsRemoveInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationprojectblobsremovebloboids"></a>`blobOids` | [`[String!]!`](#string) | List of blob oids. |
| <a id="mutationprojectblobsremoveclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationprojectblobsremoveprojectpath"></a>`projectPath` | [`ID!`](#id) | Full path of the project to replace. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationprojectblobsremoveclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationprojectblobsremoveerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
### `Mutation.projectCiCdSettingsUpdate`
Input type: `ProjectCiCdSettingsUpdateInput`

View File

@ -65,8 +65,6 @@ Example response:
- key_path: redis_hll_counters.search.i_search_paid_monthly
description: Calculated unique users to perform a search with a paid license enabled
by month
product_section: enablement
product_stage: enablement
product_group: global_search
value_type: number
status: active

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -66,71 +66,6 @@ Make sure the number of required approvals is less than the number of users allo
After a deployment job is approved, you must [run the job manually](../jobs/job_control.md#run-a-manual-job).
<!--- start_remove The following content will be removed on remove_date: '2024-05-22' -->
### Unified approval setting (deprecated)
> - UI configuration [removed](https://gitlab.com/gitlab-org/gitlab/-/issues/378447) in GitLab
> 15.11.
WARNING:
This feature was [deprecated](https://gitlab.com/groups/gitlab-org/-/epics/9662) in GitLab 16.1 and is planned for removal
in 17.0. Use [multiple approval rules](https://gitlab.com/gitlab-org/gitlab/-/issues/404579) instead. This change
is a breaking change.
To configure approvals for a protected environment:
- Using the [REST API](../../api/protected_environments.md#protect-a-single-environment),
set the `required_approval_count` field to 1 or more.
After this setting is configured, all jobs deploying to this environment automatically go into a blocked state and wait for approvals before running. Ensure that the number of required approvals is less than the number of users allowed to deploy.
Example:
```shell
curl --header 'Content-Type: application/json' --request POST \
--data '{"name": "production", "deploy_access_levels": [{"group_id": 9899826}], "required_approval_count": 1}' \
--header "PRIVATE-TOKEN: <your_access_token>" \
"https://gitlab.example.com/api/v4/projects/22034114/protected_environments"
```
### Migrate to multiple approval rules
You can migrate a protected environment from unified approval rules to multiple
approval rules. Unified approval rules allow all entities that can deploy to an
environment to approve deployment jobs. To migrate to multiple approval rules,
create a new approval rule for each entity allowed to deploy to the environment.
To migrate to multiple approval rules:
1. On the left sidebar, select **Search or go to** and find your project.
1. Select **Settings > CI/CD**.
1. Expand **Protected environments**.
1. From the **Environment** list, select your environment.
1. For each entity allowed to deploy to the environment:
1. Select **Add approval rules**.
1. On the dialog, select which entity is allowed to approve the
deployment job.
1. Enter the number of required approvals.
1. Select **Save**.
Each deployment requires the specified number of approvals from each entity.
For example, the `Production` environment below requires five total approvals,
and allows deployments from only the group `Very Important Group` and the user
`Administrator`:
![unified approval rules](img/unified_approval_rules_v16_0.png)
To migrate, create rules for the `Very Important Group` and `Administrator`. To
preserve the number of required approvals, set the number of required approvals
for `Very Important Group` to four and `Administrator` to one. The new rules
require `Administrator` to approve every deployment job in `Production`.
![multiple approval rules](img/multiple_approval_rules_v16_0.png)
<!--- end_remove -->
### Allow self-approval
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/381418) in GitLab 15.8.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

View File

@ -29,8 +29,6 @@ Each event is defined in a separate YAML file consisting of the following fields
| `category` | no | Required for legacy events. Should not be used for Internal Events. |
| `action` | yes | A unique name for the event. Only lowercase, numbers, and underscores are allowed. Use the format `<operation>_<target_of_operation>_<where/when>`. <br/><br/> Ex: `publish_go_module_to_the_registry_from_pipeline` <br/>`<operation> = publish`<br/>`<target> = go_module`<br/>`<when/where> = to_the_registry_from_pipeline`. |
| `identifiers` | no | A list of identifiers sent with the event. Can be set to one or more of `project`, `user`, or `namespace`. |
| `product_section` | yes | The [section](https://gitlab.com/gitlab-com/www-gitlab-com/-/blob/master/data/sections.yml). |
| `product_stage` | no | The [stage](https://gitlab.com/gitlab-com/www-gitlab-com/blob/master/data/stages.yml) for the event. |
| `product_group` | yes | The [group](https://gitlab.com/gitlab-com/www-gitlab-com/blob/master/data/stages.yml) that owns the event. |
| `milestone` | no | The milestone when the event is introduced. |
| `introduced_by_url` | no | The URL to the merge request that introduced the event. |
@ -49,8 +47,6 @@ identifiers:
- project
- user
- namespace
product_section: dev
product_stage: monitor
product_group: group::product analytics
milestone: "16.4"
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/128029

View File

@ -33,8 +33,6 @@ Each metric is defined in a separate YAML file consisting of a number of fields:
|------------------------------|----------|------------------------|
| `key_path` | yes | JSON key path for the metric, location in Service Ping payload. |
| `description` | yes | |
| `product_section` | yes | The [section](https://gitlab.com/gitlab-com/www-gitlab-com/-/blob/master/data/sections.yml). |
| `product_stage` | yes | The [stage](https://gitlab.com/gitlab-com/www-gitlab-com/blob/master/data/stages.yml) for the metric. |
| `product_group` | yes | The [group](https://gitlab.com/gitlab-com/www-gitlab-com/blob/master/data/stages.yml) that owns the metric. |
| `value_type` | yes | `string`; one of [`string`, `number`, `boolean`, `object`](https://json-schema.org/understanding-json-schema/reference/type). |
| `status` | yes | `string`; [status](#metric-statuses) of the metric, may be set to `active`, `removed`, `broken`. |
@ -117,8 +115,6 @@ instance unique identifier.
```yaml
key_path: uuid
description: GitLab instance unique identifier
product_section: analytics
product_stage: analytics
product_group: analytics_instrumentation
value_type: string
status: active

View File

@ -53,7 +53,7 @@ In most cases, an Analytics Instrumentation review is automatically added, but i
- For a metric's YAML definition:
- Check the metric's `description`.
- Check the metric's `key_path`.
- Check the `product_section`, `product_stage`, and `product_group` fields.
- Check the `product_group` field.
They should correspond to the [stages file](https://gitlab.com/gitlab-com/www-gitlab-com/blob/master/data/stages.yml).
- Check the file location. Consider the time frame, and if the file should be under `ee`.
- Check the tiers.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -162,7 +162,7 @@ Prerequisites:
Prerequisites:
- You must have an active paid Premium or Ultimate subscription.
- You must have an active paid Premium or Ultimate subscription. GitLab Duo Pro trials are not available to free users of self-managed at this time.
- You must have GitLab 16.8 or later and your instance must be able to [synchronize your subscription data](self_managed/index.md#subscription-data-synchronization) with GitLab.
1. Go to the [GitLab Duo Pro trial page](https://about.gitlab.com/solutions/gitlab-duo-pro/self-managed-and-gitlab-dedicated-trial/).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -1,169 +1,11 @@
---
stage: Manage
group: Import and Integrate
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
redirect_to: 'gitlab_slack_application.md'
remove_date: '2024-08-23'
---
<!--- start_remove The following content will be removed on remove_date: '2024-05-22' -->
# Slack notifications (deprecated)
This document was moved to [another location](gitlab_slack_application.md).
DETAILS:
**Tier:** Free, Premium, Ultimate
**Offering:** GitLab.com, Self-managed, GitLab Dedicated
WARNING:
This feature was [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/372411) in GitLab 15.9
and is planned for removal in 18.0. Use the [GitLab for Slack app](gitlab_slack_application.md) instead.
This change is a breaking change.
The Slack notifications integration enables your GitLab project to send events
(such as issue creation) to your existing Slack team as notifications. Setting up
Slack notifications requires configuration changes for both Slack and GitLab.
You can also use [Slack slash commands](slack_slash_commands.md)
to control GitLab from Slack. Slash commands are configured separately.
## Configure Slack
1. Sign in to your Slack team and [start a new Incoming WebHooks configuration](https://my.slack.com/services/new/incoming-webhook).
1. Identify the Slack channel where notifications should be sent to by default.
Select **Add Incoming WebHooks integration** to add the configuration.
1. Copy the **Webhook URL** to use later when you configure GitLab.
## Configure GitLab
> - [Changed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106760) in GitLab 15.9 to limit Slack channels to 10 per event.
1. On the left sidebar, select **Search or go to** and find your project.
1. Select **Settings > Integrations**.
1. Select **Slack notifications**.
1. Under **Enable integration**, select the **Active** checkbox.
1. In the **Trigger** section, select the checkboxes for each type of GitLab
event to send to Slack as a notification. For a full list, see
[Triggers for Slack notifications](#triggers-for-slack-notifications).
By default, messages are sent to the channel you configured during
[Slack configuration](#configure-slack).
1. Optional. To send messages to a different channel, multiple channels, or as
a direct message:
- *To send messages to channels,* enter the Slack channel names, separated by
commas.
- *To send direct messages,* use the Member ID found in the user's Slack profile.
1. In **Webhook**, enter the webhook URL you copied in the
[Slack configuration](#configure-slack) step.
1. Optional. In **Username**, enter the username of the Slack bot that sends
the notifications.
1. Select the **Notify only broken pipelines** checkbox to notify only on failures.
1. In the **Branches for which notifications are to be sent** dropdown list, select which types of branches
to send notifications for.
1. Leave the **Labels to be notified** field blank to get all notifications, or
add labels that the issue or merge request must have to trigger a
notification.
1. Optional. Select **Test settings**.
1. Select **Save changes**.
Your Slack team now starts receiving GitLab event notifications as configured.
## Triggers for Slack notifications
The following triggers are available for Slack notifications:
| Trigger name | Trigger event |
|--------------------------------------------------------------------------|------------------------------------------------------|
| **Push** | A push to the repository. |
| **Issue** | An issue is created, closed, or reopened. |
| **Incident** | An incident is created, closed, or reopened. |
| **Confidential issue** | A confidential issue is created, closed, or reopened.|
| **Merge request** | A merge request is created, merged, closed, or reopened.|
| **Note** | A comment is added. |
| **Confidential note** | An internal note or comment on a confidential issue is added.|
| **Tag push** | A new tag is pushed to the repository or removed. |
| **Pipeline** | A pipeline status changed. |
| **Wiki page** | A wiki page is created or updated. |
| **Deployment** | A deployment starts or finishes. |
| **Alert** | A new, unique alert is recorded. |
| **[Group mention](#trigger-notifications-for-group-mentions) in public** | A group is mentioned in a public context. |
| **[Group mention](#trigger-notifications-for-group-mentions) in private** | A group is mentioned in a confidential context. |
| [**Vulnerability**](../../application_security/vulnerabilities/index.md) | A new, unique vulnerability is recorded. |
## Trigger notifications for group mentions
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/417751) in GitLab 16.4.
To trigger a [notification event](#triggers-for-slack-notifications) for a group mention, use `@<group_name>` in:
- Issue and merge request descriptions
- Comments on issues, merge requests, and commits
## Troubleshooting
If your Slack integration is not working, start troubleshooting by
searching through the [Sidekiq logs](../../../administration/logs/index.md#sidekiqlog)
for errors relating to your Slack service.
### Something went wrong on our end
You might get this generic error message in the GitLab UI.
Review [the logs](../../../administration/logs/index.md#productionlog) to find
the error message and keep troubleshooting from there.
### `certificate verify failed`
You might see an entry like the following in your Sidekiq log:
```plaintext
2019-01-10_13:22:08.42572 2019-01-10T13:22:08.425Z 6877 TID-abcdefg Integrations::ExecuteWorker JID-3bade5fb3dd47a85db6d78c5 ERROR: {:class=>"Integrations::ExecuteWorker :integration_class=>"SlackService", :message=>"SSL_connect returned=1 errno=0 state=error: certificate verify failed"}
```
This issue occurs when there is a problem with GitLab communicating with Slack,
or GitLab communicating with itself.
The former is less likely, as Slack security certificates should always be trusted.
To view which of these problems is the cause of the issue:
1. Start a Rails console:
```shell
sudo gitlab-rails console -e production
# for source installs:
bundle exec rails console -e production
```
1. Run the following commands:
```ruby
# replace <SLACK URL> with your actual Slack URL
result = Net::HTTP.get(URI('https://<SLACK URL>'));0
# replace <GITLAB URL> with your actual GitLab URL
result = Net::HTTP.get(URI('https://<GITLAB URL>'));0
```
If GitLab does not trust HTTPS connections to itself,
[add your certificate to the GitLab trusted certificates](https://docs.gitlab.com/omnibus/settings/ssl/index.html#install-custom-public-certificates).
If GitLab does not trust connections to Slack,
the GitLab OpenSSL trust store is incorrect. Typical causes are:
- Overriding the trust store with `gitlab_rails['env'] = {"SSL_CERT_FILE" => "/path/to/file.pem"}`.
- Accidentally modifying the default CA bundle `/opt/gitlab/embedded/ssl/certs/cacert.pem`.
### Bulk update to disable the Slack Notification integration
To disable notifications for all projects that have Slack integration enabled,
[start a rails console session](../../../administration/operations/rails_console.md#starting-a-rails-console-session) and use a script similar to the following:
WARNING:
Commands that change data can cause damage if not run correctly or under the right conditions. Always run commands in a test environment first and have a backup instance ready to restore.
```ruby
# Grab all projects that have the Slack notifications enabled
p = Project.find_by_sql("SELECT p.id FROM projects p LEFT JOIN integrations s ON p.id = s.project_id WHERE s.type_new = 'Integrations::Slack' AND s.active = true")
# Disable the integration on each of the projects that were found.
p.each do |project|
project.slack_integration.update!(:active, false)
end
```
<!--- end_remove -->
<!-- This redirect file can be deleted after <2024-08-23>. -->
<!-- Redirects that point to other docs in the same project expire in three months. -->
<!-- Redirects that point to docs in a different project or site (for example, link is not relative and starts with `https:`) expire in one year. -->
<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/redirects.html -->

View File

@ -93,7 +93,7 @@ Prerequisites:
To delete the currently active issue board:
1. In the upper-left corner of the issue board page, select the dropdown list with the current board name.
1. In the upper-right corner of the issue board page, select **Configure board** (**{settings}**).
1. Select **Delete board**.
1. Select **Delete** to confirm.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@ -24649,6 +24649,9 @@ msgstr ""
msgid "Group '%{group_name}' could not be updated."
msgstr ""
msgid "Group '%{group_name}' has been successfully restored."
msgstr ""
msgid "Group '%{group_name}' was successfully updated."
msgstr ""
@ -24691,6 +24694,9 @@ msgstr ""
msgid "Group by:"
msgstr ""
msgid "Group comment templates"
msgstr ""
msgid "Group description (optional)"
msgstr ""
@ -28479,9 +28485,6 @@ msgstr ""
msgid "IssueBoards|Create new board"
msgstr ""
msgid "IssueBoards|Delete board"
msgstr ""
msgid "IssueBoards|No matching boards found"
msgstr ""
@ -31084,6 +31087,9 @@ msgstr ""
msgid "Makes this %{type} confidential."
msgstr ""
msgid "Manage"
msgstr ""
msgid "Manage %{workspace} labels"
msgstr ""
@ -31102,10 +31108,10 @@ msgstr ""
msgid "Manage branch rules"
msgstr ""
msgid "Manage git repositories with fine-grained access controls that keep your code secure."
msgid "Manage comment templates"
msgstr ""
msgid "Manage group comment templates"
msgid "Manage git repositories with fine-grained access controls that keep your code secure."
msgstr ""
msgid "Manage group labels"
@ -31120,9 +31126,6 @@ msgstr ""
msgid "Manage milestones"
msgstr ""
msgid "Manage project comment templates"
msgstr ""
msgid "Manage project labels"
msgstr ""
@ -31141,9 +31144,6 @@ msgstr ""
msgid "Manage usage"
msgstr ""
msgid "Manage your comment templates"
msgstr ""
msgid "Manage your subscription"
msgstr ""
@ -34192,6 +34192,9 @@ msgstr ""
msgid "No child epics match applied filters"
msgstr ""
msgid "No comment templates found."
msgstr ""
msgid "No commenters"
msgstr ""
@ -40168,15 +40171,15 @@ msgstr ""
msgid "Project & Group can not be assigned at the same time"
msgstr ""
msgid "Project '%{project_name}' has been successfully restored."
msgstr ""
msgid "Project '%{project_name}' is being imported."
msgstr ""
msgid "Project '%{project_name}' is in the process of being deleted."
msgstr ""
msgid "Project '%{project_name}' is restored."
msgstr ""
msgid "Project '%{project_name}' queued for deletion."
msgstr ""
@ -40228,6 +40231,9 @@ msgstr ""
msgid "Project cannot be shared with the group it is in or one of its ancestors."
msgstr ""
msgid "Project comment templates"
msgstr ""
msgid "Project configuration, excluding integrations"
msgstr ""
@ -46085,6 +46091,9 @@ msgstr ""
msgid "Search by name"
msgstr ""
msgid "Search comment templates"
msgstr ""
msgid "Search files"
msgstr ""
@ -48030,6 +48039,9 @@ msgstr ""
msgid "Select a color from the color picker or from the presets below."
msgstr ""
msgid "Select a comment template"
msgstr ""
msgid "Select a file from the left sidebar to begin editing. Afterwards, you'll be able to commit your changes."
msgstr ""
@ -60496,6 +60508,9 @@ msgstr ""
msgid "Your comment could not be submitted! Please check your network connection and try again."
msgstr ""
msgid "Your comment templates"
msgstr ""
msgid "Your comment will be discarded."
msgstr ""

View File

@ -268,6 +268,26 @@ RSpec.describe Projects::EnvironmentsController, feature_category: :continuous_d
end
end
describe 'GET k8s' do
context 'with valid id' do
it 'responds with a status code 200' do
get :k8s, params: environment_params
expect(response).to be_ok
end
end
context 'with invalid id' do
it 'responds with a status code 404' do
params = environment_params
params[:id] = non_existing_record_id
get :k8s, params: params
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
describe 'GET show' do
context 'with valid id' do
it 'responds with a status code 200' do

View File

@ -17,7 +17,7 @@ require 'spec_helper'
# - https://gitlab.com/gitlab-org/gitlab/-/merge_requests/105849
# - https://gitlab.com/gitlab-org/gitlab/-/issues/383970
#
RSpec.describe 'Project issue boards', :js, feature_category: :team_planning do
RSpec.describe 'Project issue boards', :js, feature_category: :portfolio_management do
include DragTo
include MobileHelpers
include BoardHelpers

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'Issue Boards focus mode', :js, feature_category: :team_planning do
RSpec.describe 'Issue Boards focus mode', :js, feature_category: :portfolio_management do
let(:project) { create(:project, :public) }
before do

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'Issue Boards', :js, feature_category: :team_planning do
RSpec.describe 'Issue Boards', :js, feature_category: :portfolio_management do
include DragTo
let(:project) { create(:project, :public) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'Issue Boards shortcut', :js, feature_category: :team_planning do
RSpec.describe 'Issue Boards shortcut', :js, feature_category: :portfolio_management do
context 'issues are enabled' do
let(:project) { create(:project) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'Multiple Issue Boards', :js, feature_category: :team_planning do
RSpec.describe 'Multiple Issue Boards', :js, feature_category: :portfolio_management do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public) }
let_it_be(:planning) { create(:label, project: project, name: 'Planning') }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'Issue Boards new issue', :js, feature_category: :team_planning do
RSpec.describe 'Issue Boards new issue', :js, feature_category: :portfolio_management do
let_it_be(:project) { create(:project, :public) }
let_it_be(:board) { create(:board, project: project) }
let_it_be(:label) { create(:label, project: project, name: 'Label 1') }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'Ensure Boards do not show stale data on browser back', :js, feature_category: :team_planning do
RSpec.describe 'Ensure Boards do not show stale data on browser back', :js, feature_category: :portfolio_management do
let(:project) { create(:project, :public) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }

View File

@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'Project issue boards sidebar assignee', :js,
quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/332078',
feature_category: :team_planning do
feature_category: :portfolio_management do
include BoardHelpers
let_it_be(:user) { create(:user) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'Issue boards sidebar labels select', :js, feature_category: :team_planning do
RSpec.describe 'Issue boards sidebar labels select', :js, feature_category: :portfolio_management do
include BoardHelpers
include_context 'labels from nested groups and projects'

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'Project issue boards sidebar labels', :js, feature_category: :team_planning do
RSpec.describe 'Project issue boards sidebar labels', :js, feature_category: :portfolio_management do
include BoardHelpers
let_it_be(:group) { create(:group, :public) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'Project issue boards sidebar', :js, feature_category: :team_planning do
RSpec.describe 'Project issue boards sidebar', :js, feature_category: :portfolio_management do
include BoardHelpers
let_it_be(:user) { create(:user) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'User visits issue boards', :js, feature_category: :team_planning do
RSpec.describe 'User visits issue boards', :js, feature_category: :portfolio_management do
using RSpec::Parameterized::TableSyntax
let_it_be(:group) { create_default(:group, :public) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'Group Issue Boards', :js, feature_category: :team_planning do
RSpec.describe 'Group Issue Boards', :js, feature_category: :portfolio_management do
include BoardHelpers
let(:group) { create(:group) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'Group Boards', feature_category: :team_planning do
RSpec.describe 'Group Boards', feature_category: :portfolio_management do
include DragTo
include MobileHelpers
include BoardHelpers

View File

@ -49,6 +49,7 @@ describe('BoardForm', () => {
const findDeleteConfirmation = () => wrapper.findByTestId('delete-confirmation-message');
const findInput = () => wrapper.find('#board-new-name');
const findInputFormWrapper = () => wrapper.findComponent(GlForm);
const findDeleteButton = () => wrapper.findByTestId('delete-board-button');
const defaultHandlers = {
createBoardMutationHandler: jest.fn().mockResolvedValue({
@ -84,7 +85,12 @@ describe('BoardForm', () => {
]);
};
const createComponent = ({ props, provide, handlers = defaultHandlers } = {}) => {
const createComponent = ({
props,
provide,
handlers = defaultHandlers,
stubs = { GlForm },
} = {}) => {
wrapper = shallowMountExtended(BoardForm, {
apolloProvider: createMockApolloProvider(handlers),
propsData: { ...defaultProps, ...props },
@ -95,9 +101,7 @@ describe('BoardForm', () => {
...provide,
},
attachTo: document.body,
stubs: {
GlForm,
},
stubs,
});
};
@ -261,6 +265,19 @@ describe('BoardForm', () => {
it('renders form wrapper', () => {
expect(findFormWrapper().exists()).toBe(true);
});
it('emits showBoardModal with delete when clicking on delete board button', () => {
createComponent({
props: {
currentPage: formType.edit,
showDelete: true,
canAdminBoard: true,
},
stubs: { GlModal },
});
findDeleteButton().vm.$emit('click');
expect(wrapper.emitted('showBoardModal')).toEqual([[formType.delete]]);
});
});
it('calls GraphQL mutation with correct parameters when issues are not grouped', async () => {

View File

@ -342,13 +342,15 @@ describe('BoardsSelector', () => {
expect(wrapper.emitted('showBoardModal')).toEqual([[formType.new]]);
});
it('emits showBoardModal with delete when clicking on delete board button', async () => {
createComponent({ isProjectBoard: true });
it('emits showBoardModal when BoardForm emits showBoardModal', () => {
createComponent({
isProjectBoard: true,
props: {
boardModalForm: formType.edit,
},
});
findDropdown().vm.$emit('shown');
await waitForPromises();
wrapper.findAllComponents(GlButton).at(1).vm.$emit('click');
findBoardForm().vm.$emit('showBoardModal', formType.delete);
expect(wrapper.emitted('showBoardModal')).toEqual([[formType.delete]]);
});
});

View File

@ -2,7 +2,7 @@ import { GlTabs, GlTab } from '@gitlab/ui';
import { mockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import FormattingToolbar from '~/content_editor/components/formatting_toolbar.vue';
import CommentTemplatesDropdown from '~/vue_shared/components/markdown/comment_templates_dropdown.vue';
import CommentTemplatesModal from '~/vue_shared/components/markdown/comment_templates_modal.vue';
import {
TOOLBAR_CONTROL_TRACKING_ACTION,
CONTENT_EDITOR_TRACKING_LABEL,
@ -117,9 +117,7 @@ describe('content_editor/components/formatting_toolbar', () => {
});
const commands = mockChainedCommands(tiptapEditor, ['focus', 'pasteContent', 'run']);
await wrapper
.findComponent(CommentTemplatesDropdown)
.vm.$emit('select', 'Some saved comment');
await wrapper.findComponent(CommentTemplatesModal).vm.$emit('select', 'Some saved comment');
expect(commands.focus).toHaveBeenCalled();
expect(commands.pasteContent).toHaveBeenCalledWith('Some saved comment');
@ -129,7 +127,7 @@ describe('content_editor/components/formatting_toolbar', () => {
it('does not show the saved replies icon if newCommentTemplatePath is not provided', () => {
buildWrapper();
expect(wrapper.findComponent(CommentTemplatesDropdown).exists()).toBe(false);
expect(wrapper.findComponent(CommentTemplatesModal).exists()).toBe(false);
});
});

View File

@ -0,0 +1,73 @@
import { mount, RouterLinkStub } from '@vue/test-utils';
import EnvironmentBreadcrumb from '~/environments/environment_details/environment_breadcrumbs.vue';
describe('Environment Breadcrumb', () => {
let wrapper;
const environmentName = 'production';
const routes = [
{ name: 'environment_details', path: '/', meta: { environmentName } },
{
name: 'logs',
path: '/k8s/namespace/namespace/pods/podName/logs',
meta: { environmentName },
params: {
namespace: 'namespace',
podName: 'podName',
},
},
];
const mountComponent = ($route) => {
wrapper = mount(EnvironmentBreadcrumb, {
mocks: {
$route,
$router: {
options: {
routes,
},
},
},
stubs: {
RouterLink: RouterLinkStub,
},
});
};
describe('when is rootRoute', () => {
beforeEach(() => {
mountComponent(routes[0]);
});
it('contains only a single router-link to list', () => {
const links = wrapper.findAll('a');
expect(links).toHaveLength(1);
expect(links.at(0).props('to')).toEqual(routes[0].path);
});
it('the link text is environmentName', () => {
expect(wrapper.text()).toContain(environmentName);
});
});
describe('when is not rootRoute', () => {
beforeEach(() => {
mountComponent(routes[1]);
});
it('contains two router-links to list and details', () => {
const links = wrapper.findAll('a');
expect(links).toHaveLength(2);
expect(links.at(0).props('to')).toEqual(routes[0].path);
expect(links.at(1).props('to')).toBe(routes[1].path);
});
it('the last link text is podName', () => {
const lastLink = wrapper.findAll('a').at(1);
expect(lastLink.text()).toContain(routes[1].params.podName);
});
});
});

View File

@ -396,6 +396,8 @@ describe('Tracking', () => {
});
it('ignores and removes old entries from the cache', () => {
window.gl.maskedDefaultReferrerUrl =
'https://gitlab.com/namespace:#/project:#/-/merge_requests/';
const oldTimestamp = Date.now() - (REFERRER_TTL + 1);
Object.defineProperty(document, 'referrer', { value: testOriginalUrl });
setUrlsCache([
@ -408,9 +410,29 @@ describe('Tracking', () => {
Tracking.setAnonymousUrls();
expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', testUrl);
expect(snowplowSpy).toHaveBeenCalledWith(
'setReferrerUrl',
window.gl.maskedDefaultReferrerUrl,
);
expect(localStorage.getItem(URLS_CACHE_STORAGE_KEY)).not.toContain(oldTimestamp.toString());
});
it('sets the referrer URL to maskedDefaultReferrerUrl if no referrer is found in cache', () => {
window.gl.maskedDefaultReferrerUrl =
'https://gitlab.com/namespace:#/project:#/-/merge_requests/';
setUrlsCache([]);
Object.defineProperty(document, 'referrer', {
value: 'https://gitlab.com/my-namespace/my-project/-/merge_requests/',
});
Tracking.setAnonymousUrls();
expect(snowplowSpy).toHaveBeenCalledWith(
'setReferrerUrl',
window.gl.maskedDefaultReferrerUrl,
);
});
});
});

View File

@ -1,13 +1,18 @@
import { GlCollapsibleListbox } from '@gitlab/ui';
import {
GlModal,
GlDisclosureDropdown,
GlDisclosureDropdownGroup,
GlDisclosureDropdownItem,
} from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import savedRepliesResponse from 'test_fixtures/graphql/comment_templates/saved_replies.query.graphql.json';
import { mockTracking } from 'helpers/tracking_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import waitForPromises from 'helpers/wait_for_promises';
import CommentTemplatesDropdown from '~/vue_shared/components/markdown/comment_templates_dropdown.vue';
import CommentTemplatesDropdown from '~/vue_shared/components/markdown/comment_templates_modal.vue';
import savedRepliesQuery from 'ee_else_ce/vue_shared/components/markdown/saved_replies.query.graphql';
import {
TRACKING_SAVED_REPLIES_USE,
@ -19,8 +24,8 @@ let wrapper;
let savedRepliesResp;
const newCommentTemplatePaths = [
{ path: '/user/comment_templates', text: 'Manage user' },
{ path: '/group/comment_templates', text: 'Manage group' },
{ href: '/user/comment_templates', text: 'Manage user' },
{ href: '/group/comment_templates', text: 'Manage group' },
];
function createMockApolloProvider(response) {
@ -38,26 +43,31 @@ function createMockApolloProvider(response) {
function createComponent(options = {}) {
const { mockApollo } = options;
return mountExtended(CommentTemplatesDropdown, {
return shallowMountExtended(CommentTemplatesDropdown, {
propsData: {
newCommentTemplatePaths,
},
apolloProvider: mockApollo,
stubs: {
GlModal,
GlDisclosureDropdown,
GlDisclosureDropdownGroup,
GlDisclosureDropdownItem,
},
});
}
function findDropdownComponent() {
return wrapper.findComponent(GlCollapsibleListbox);
}
const findToggleButton = () => wrapper.findByTestId('comment-templates-dropdown-toggle');
const findModalComponent = () => wrapper.findComponent(GlModal);
const findActionButton = () =>
findModalComponent().findComponent(GlDisclosureDropdownItem).find('button');
async function selectSavedReply() {
const dropdown = findDropdownComponent();
dropdown.vm.$emit('shown');
findToggleButton().vm.$emit('click');
await waitForPromises();
dropdown.vm.$emit('select', savedRepliesResponse.data.object.savedReplies.nodes[0].id);
await findActionButton().trigger('click');
}
useMockLocationHelper();
@ -67,7 +77,7 @@ describe('Comment templates dropdown', () => {
const mockApollo = createMockApolloProvider(savedRepliesResponse);
wrapper = createComponent({ mockApollo });
wrapper.find('.js-comment-template-toggle').trigger('click');
findToggleButton().vm.$emit('click');
await waitForPromises();
@ -78,16 +88,13 @@ describe('Comment templates dropdown', () => {
const mockApollo = createMockApolloProvider(savedRepliesResponse);
wrapper = createComponent({ mockApollo });
wrapper.find('.js-comment-template-toggle').trigger('click');
findToggleButton().vm.$emit('click');
const links = wrapper.findAllByTestId('manage-button');
const manageDropdown = wrapper.findByTestId('manage-dropdown');
const links = manageDropdown.props('items');
expect(links).toHaveLength(newCommentTemplatePaths.length);
newCommentTemplatePaths.forEach(({ path, text }, index) => {
expect(links.at(index).attributes('href')).toBe(path);
expect(links.at(index).text()).toBe(text);
});
expect(links).toBe(newCommentTemplatePaths);
});
describe('when selecting a comment', () => {
@ -101,11 +108,7 @@ describe('Comment templates dropdown', () => {
});
it('emits a select event', async () => {
wrapper.find('.js-comment-template-toggle').trigger('click');
await waitForPromises();
wrapper.find('.gl-new-dropdown-item').trigger('click');
await selectSavedReply();
expect(wrapper.emitted().select[0]).toEqual(['Saved Reply Content']);
});

View File

@ -3,7 +3,7 @@ import { nextTick } from 'vue';
import { GlToggle, GlButton } from '@gitlab/ui';
import HeaderComponent from '~/vue_shared/components/markdown/header.vue';
import HeaderDividerComponent from '~/vue_shared/components/markdown/header_divider.vue';
import CommentTemplatesDropdown from '~/vue_shared/components/markdown/comment_templates_dropdown.vue';
import CommentTemplatesModal from '~/vue_shared/components/markdown/comment_templates_modal.vue';
import ToolbarButton from '~/vue_shared/components/markdown/toolbar_button.vue';
import DrawioToolbarButton from '~/vue_shared/components/markdown/drawio_toolbar_button.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
@ -36,7 +36,7 @@ describe('Markdown field header component', () => {
.filter((button) => button.props(prop) === value)
.at(0);
const findDrawioToolbarButton = () => wrapper.findComponent(DrawioToolbarButton);
const findCommentTemplatesDropdown = () => wrapper.findComponent(CommentTemplatesDropdown);
const findCommentTemplatesModal = () => wrapper.findComponent(CommentTemplatesModal);
beforeEach(() => {
window.gl = {
@ -269,7 +269,7 @@ describe('Markdown field header component', () => {
},
});
await findCommentTemplatesDropdown().vm.$emit('select', 'Some saved comment');
await findCommentTemplatesModal().vm.$emit('select', 'Some saved comment');
expect(updateText).toHaveBeenCalledWith({
textArea: document.querySelector('textarea'),
@ -288,7 +288,7 @@ describe('Markdown field header component', () => {
},
});
expect(findCommentTemplatesDropdown().exists()).toBe(false);
expect(findCommentTemplatesModal().exists()).toBe(false);
});
});
});

View File

@ -289,7 +289,7 @@ RSpec.describe Resolvers::MergeRequestsResolver, feature_category: :code_review_
context 'with deployment' do
it 'returns merge request with matching deployment' do
result = resolve_mr(project, deployment_id: gprd.id)
result = resolve_mr(project, deployment_id: deploy2.id)
expect(result).to contain_exactly(merge_request_2)
end

View File

@ -21,6 +21,7 @@ RSpec.describe EnvironmentHelper, feature_category: :environment_management do
name: environment.name,
id: environment.id,
project_full_path: project.full_path,
base_path: project_environment_path(project, environment),
external_url: environment.external_url,
can_update_environment: true,
can_destroy_environment: true,

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe ::Routing::PseudonymizationHelper do
RSpec.describe ::Routing::PseudonymizationHelper, feature_category: :product_analytics_data_management do
let_it_be(:group) { create(:group) }
let_it_be(:subgroup) { create(:group, parent: group) }
let_it_be(:project) { create(:project, group: group) }
@ -307,4 +307,90 @@ RSpec.describe ::Routing::PseudonymizationHelper do
expect(subject).to be_nil
end
end
describe '#masked_referrer_url' do
let(:original_url) { "http://localhost/#{project.full_path}/-/issues/123" }
let(:masked_url) { 'http://localhost/namespace/project/-/issues/123' }
it 'masks sensitive parameters in the URL' do
expect(helper.masked_referrer_url(original_url)).to eq(masked_url)
end
context 'when an error occurs' do
before do
allow(Rails.application.routes).to receive(:recognize_path)
.with(original_url)
.and_raise(ActionController::RoutingError, 'Some routing error')
allow(helper).to receive(:request).and_return(
double(
:Request,
original_url: original_url,
original_fullpath: '/dashboard/issues?assignee_username=root'
)
)
end
it 'calls error tracking and returns nil' do
expect(Gitlab::ErrorTracking).to receive(:track_exception)
.with(
ActionController::RoutingError,
url: '/dashboard/issues?assignee_username=root'
).and_call_original
expect(helper.masked_referrer_url(original_url)).to be_nil
end
end
context 'with controller for projects' do
let(:original_url) { "http://localhost/#{project.full_path}" }
let(:masked_url) { 'http://localhost/namespace/project' }
it 'masks sensitive parameters in the URL for projects controller' do
expect(helper.masked_referrer_url(original_url)).to eq(masked_url)
end
end
context 'with controller for projects/issues' do
let(:original_url) { "http://localhost/#{project.full_path}/-/issues" }
let(:masked_url) { 'http://localhost/namespace/project/-/issues' }
it 'masks sensitive parameters in the URL for projects/issues controller' do
expect(helper.masked_referrer_url(original_url)).to eq(masked_url)
end
end
end
describe 'masked_query_params' do
let(:helper) { Class.new { include Routing::PseudonymizationHelper }.new }
context 'when there are no query parameters' do
it 'returns nil' do
uri = URI.parse('https://gitlab.com')
expect(helper.masked_query_params(uri)).to be_nil
end
end
context 'when there are query parameters to mask' do
it 'masks the appropriate query parameters' do
uri = URI.parse('https://gitlab.com?user_id=123&token=abc')
result = helper.masked_query_params(uri)
expect(result).to eq({ 'user_id' => ['masked_user_id'], 'token' => ['masked_token'] })
end
end
context 'when there are query parameters that should not be masked' do
it 'does not mask the excluded query parameters' do
uri = URI.parse('https://gitlab.com?scope=all&user_id=123')
result = helper.masked_query_params(uri)
expect(result).to eq({ 'scope' => ['all'], 'user_id' => ['masked_user_id'] })
end
end
context 'when there are mixed query parameters' do
it 'masks only the non-excluded query parameters' do
uri = URI.parse('http://localhost?scope=all&state=opened&user_id=123')
result = helper.masked_query_params(uri)
expect(result).to eq({ 'scope' => ['all'], 'state' => ['opened'], 'user_id' => ['masked_user_id'] })
end
end
end
end

View File

@ -16,7 +16,7 @@ RSpec.describe WorkItemsHelper, feature_category: :team_planning do
register_path: new_user_registration_path(redirect_to_referer: 'yes'),
sign_in_path: user_session_path(redirect_to_referer: 'yes'),
new_comment_template_paths:
[{ text: "Manage your comment templates", path: profile_comment_templates_path }].to_json,
[{ text: "Your comment templates", href: profile_comment_templates_path }].to_json,
report_abuse_path: add_category_abuse_reports_path
}
)

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe API::Boards, :with_license, feature_category: :team_planning do
RSpec.describe API::Boards, :with_license, feature_category: :portfolio_management do
let_it_be(:user) { create(:user) }
let_it_be(:non_member) { create(:user) }
let_it_be(:guest) { create(:user) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'get list of boards', feature_category: :team_planning do
RSpec.describe 'get list of boards', feature_category: :portfolio_management do
include GraphqlHelpers
include_context 'group and project boards query context'

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'getting group recent issue boards', feature_category: :team_planning do
RSpec.describe 'getting group recent issue boards', feature_category: :portfolio_management do
include GraphqlHelpers
it_behaves_like 'querying a GraphQL type recent boards' do

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Mutations::Boards::Create, feature_category: :team_planning do
RSpec.describe Mutations::Boards::Create, feature_category: :portfolio_management do
let_it_be(:parent) { create(:project) }
let_it_be(:current_user, reload: true) { create(:user) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Mutations::Boards::Destroy, feature_category: :team_planning do
RSpec.describe Mutations::Boards::Destroy, feature_category: :portfolio_management do
include GraphqlHelpers
let_it_be(:current_user, reload: true) { create(:user) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Mutations::Boards::Lists::Destroy, feature_category: :team_planning do
RSpec.describe Mutations::Boards::Lists::Destroy, feature_category: :portfolio_management do
include GraphqlHelpers
let_it_be(:current_user, reload: true) { create(:user) }

View File

@ -0,0 +1,124 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe "projectBlobsRemove", feature_category: :source_code_management do
include GraphqlHelpers
let_it_be(:project) { create(:project, :repository) }
let_it_be(:current_user) { create(:user, owner_of: project) }
let_it_be(:repo) { project.repository }
let(:project_path) { project.full_path }
let(:mutation_params) { { project_path: project_path, blob_oids: blob_oids } }
let(:mutation) { graphql_mutation(:project_blobs_remove, mutation_params) }
let(:blob_oids) { ['53855584db773c3df5b5f61f72974cb298822fbb'] }
subject(:post_mutation) { post_graphql_mutation(mutation, current_user: current_user) }
describe 'Removing blobs:' do
before do
::Gitlab::GitalyClient.clear_stubs!
end
it 'submits blobs to rewriteHistory RPC' do
expect_next_instance_of(Gitaly::CleanupService::Stub) do |instance|
blobs = array_including(gitaly_request_with_params(blobs: blob_oids))
expect(instance).to receive(:rewrite_history)
.with(blobs, kind_of(Hash))
.and_return(Gitaly::RewriteHistoryResponse.new)
end
post_mutation
expect(graphql_mutation_response(:project_blobs_remove)['errors']).not_to be_present
end
it 'does not create an audit event' do
allow_next_instance_of(Gitaly::CleanupService::Stub) do |instance|
blobs = array_including(gitaly_request_with_params(blobs: blob_oids))
allow(instance).to receive(:rewrite_history)
.with(blobs, kind_of(Hash))
.and_return(Gitaly::RewriteHistoryResponse.new)
end
expect { post_mutation }.not_to change { AuditEvent.count }
end
end
describe 'Invalid requests:' do
context 'when the current_user is a maintainer' do
let(:current_user) { create(:user, maintainer_of: project) }
it_behaves_like 'a mutation on an unauthorized resource'
end
context 'when arg `projectPath` is invalid' do
let(:project_path) { 'gid://Gitlab/User/1' }
it 'returns an error' do
post_mutation
expect(graphql_errors).to include(a_hash_including('message' => <<~MESSAGE.strip))
The resource that you are attempting to access does not exist or you don't have permission to perform this action
MESSAGE
end
end
context 'when arg `blob_oids` is nil' do
let(:blob_oids) { nil }
it 'returns an error' do
post_mutation
expect(graphql_errors).to include(a_hash_including('message' => <<~MESSAGE.strip))
Variable $projectBlobsRemoveInput of type projectBlobsRemoveInput! was provided invalid value for blobOids (Expected value to not be null)
MESSAGE
end
end
context 'when arg `blob_oids` is an empty list' do
let(:blob_oids) { [] }
it 'returns an error' do
post_mutation
expect(graphql_errors).to include(a_hash_including('message' => <<~MESSAGE))
Argument 'blobOids' on InputObject 'projectBlobsRemoveInput' is required. Expected type [String!]!
MESSAGE
end
end
context 'when arg `blob_oids` does not contain any valid strings' do
let(:blob_oids) { ["", ""] }
it 'returns an error' do
post_mutation
expect(graphql_errors).to include(a_hash_including('message' => <<~MESSAGE))
Argument 'blobOids' on InputObject 'projectBlobsRemoveInput' is required. Expected type [String!]!
MESSAGE
end
end
context 'when Gitaly RPC returns an error' do
before do
::Gitlab::GitalyClient.clear_stubs!
end
let(:error_message) { 'error message' }
it 'returns a generic error message' do
expect_next_instance_of(Gitaly::CleanupService::Stub) do |instance|
blobs = array_including(gitaly_request_with_params(blobs: blob_oids))
generic_error = GRPC::BadStatus.new(GRPC::Core::StatusCodes::FAILED_PRECONDITION, error_message)
expect(instance).to receive(:rewrite_history).with(blobs, kind_of(Hash)).and_raise(generic_error)
end
post_mutation
expect(graphql_errors).to include(a_hash_including('message' => "Internal server error: 9:#{error_message}"))
end
end
end
end

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'getting project recent issue boards', feature_category: :team_planning do
RSpec.describe 'getting project recent issue boards', feature_category: :portfolio_management do
include GraphqlHelpers
it_behaves_like 'querying a GraphQL type recent boards' do

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe API::GroupBoards, :with_license, feature_category: :team_planning do
RSpec.describe API::GroupBoards, :with_license, feature_category: :portfolio_management do
let_it_be(:user) { create(:user) }
let_it_be(:non_member) { create(:user) }
let_it_be(:guest) { create(:user) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Boards::CreateService, feature_category: :team_planning do
RSpec.describe Boards::CreateService, feature_category: :portfolio_management do
describe '#execute' do
context 'when board parent is a project' do
let(:parent) { create(:project) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Boards::DestroyService, feature_category: :team_planning do
RSpec.describe Boards::DestroyService, feature_category: :portfolio_management do
context 'with project board' do
let_it_be(:parent) { create(:project) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Boards::Issues::CreateService, feature_category: :team_planning do
RSpec.describe Boards::Issues::CreateService, feature_category: :portfolio_management do
describe '#execute' do
let(:project) { create(:project) }
let(:board) { create(:board, project: project) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Boards::Issues::ListService, feature_category: :team_planning do
RSpec.describe Boards::Issues::ListService, feature_category: :portfolio_management do
describe '#execute' do
let_it_be(:user) { create(:user) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Boards::Issues::MoveService, feature_category: :team_planning do
RSpec.describe Boards::Issues::MoveService, feature_category: :portfolio_management do
describe '#execute' do
context 'when parent is a project' do
let(:user) { create(:user) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Boards::Lists::CreateService, feature_category: :team_planning do
RSpec.describe Boards::Lists::CreateService, feature_category: :portfolio_management do
context 'when board parent is a project' do
let_it_be(:parent) { create(:project) }
let_it_be(:board) { create(:board, project: parent) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Boards::Lists::DestroyService, feature_category: :team_planning do
RSpec.describe Boards::Lists::DestroyService, feature_category: :portfolio_management do
let_it_be(:user) { create(:user) }
let(:list_type) { :list }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Boards::Lists::ListService, feature_category: :team_planning do
RSpec.describe Boards::Lists::ListService, feature_category: :portfolio_management do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }

Some files were not shown because too many files have changed in this diff Show More