Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-06-12 12:09:47 +00:00
parent d7a8615c99
commit 0134e6bc27
69 changed files with 1214 additions and 158 deletions

View File

@ -70,6 +70,11 @@ export default {
required: false,
default: false,
},
isSnippet: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return { richContentLoaded: false };
@ -123,6 +128,7 @@ export default {
:project-path="projectPath"
:blob-path="blob.path || ''"
:rich-viewer="richViewer"
:is-snippet="isSnippet"
:is-raw-content="isRawContent"
:show-blame="showBlame"
:file-name="blob.name"

View File

@ -119,6 +119,15 @@ export default {
listTitle() {
return this.list?.label?.description || this.list?.assignee?.name || this.list.title || '';
},
listStatus() {
return this.list?.status || {};
},
listStatusColor() {
return this.listStatus?.color;
},
listStatusIconName() {
return this.listStatus?.iconName;
},
isIterationList() {
return this.listType === ListType.iteration;
},
@ -377,6 +386,18 @@ export default {
class="gl-mr-3"
/>
</a>
<gl-icon
v-if="listType === 'status'"
data-testid="status-icon"
:name="listStatusIconName"
:size="12"
:class="{
'gl-mt-2': list.collapsed,
'gl-mx-2': !list.collapsed,
'gl-shrink-0': true,
}"
:style="{ color: listStatusColor }"
/>
<!-- EE end -->
<div
class="board-title-text"
@ -387,6 +408,7 @@ export default {
}"
>
<!-- EE start -->
<span
v-if="listType !== 'label'"
v-gl-tooltip.hover

View File

@ -242,6 +242,10 @@ export default {
this.emitEvents();
}
},
selectAll() {
const allInputs = this.searchFilteredInputs.map((input) => input.value);
this.selectInputs(allInputs);
},
deselectAll() {
this.inputs = this.inputs.map((input) => ({
...input,
@ -277,6 +281,7 @@ export default {
:toggle-text="s__('Pipelines|Select inputs')"
:header-text="s__('Pipelines|Inputs')"
:search-placeholder="s__('Pipelines|Search input name')"
:show-select-all-button-label="__('Select all')"
:reset-button-label="__('Clear')"
:disabled="!hasInputs"
searchable
@ -286,6 +291,7 @@ export default {
size="small"
@reset="deselectAll"
@select="selectInputs"
@select-all="selectAll"
@search="handleSearch"
/>
</template>

View File

@ -52,12 +52,12 @@ export default {
</script>
<template>
<div class="build-job gl-relative" :class="classes">
<div class="build-job gl-relative gl-px-2" :class="classes">
<gl-link
v-gl-tooltip.left.viewport
:href="job.status.details_path"
:title="tooltipText"
class="gl-flex gl-items-center gl-py-3 gl-pl-7"
class="gl-mb-1 gl-flex gl-items-center gl-py-2 gl-pl-7"
:data-testid="dataTestId"
>
<gl-icon
@ -67,7 +67,7 @@ export default {
class="icon-arrow-right gl-absolute gl-block"
/>
<ci-icon :status="job.status" :show-tooltip="false" class="gl-mr-3" />
<ci-icon tabindex="-1" :status="job.status" :show-tooltip="false" class="gl-mr-3" />
<span class="gl-w-full gl-truncate">{{ jobName }}</span>

View File

@ -1,13 +1,14 @@
<script>
import { GlTooltip, GlDisclosureDropdown } from '@gitlab/ui';
import { GlTooltip, GlDisclosureDropdown, GlBadge } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
export default {
components: {
GlDisclosureDropdown,
GlTooltip,
GlBadge,
},
inject: ['tiptapEditor', 'contentEditor'],
data() {
@ -45,6 +46,17 @@ export default {
text: __('Horizontal rule'),
action: () => this.execute('setHorizontalRule', 'horizontalRule'),
},
{
text: __('GitLab Query Language (GLQL) view'),
action: () => this.execute('insertGLQLView', 'glqlView'),
badge: {
text: __('Beta'),
variant: 'info',
size: 'small',
target: '_blank',
href: helpPagePath('user/glql/_index'),
},
},
{
text: __('Mermaid diagram'),
action: () => this.insert('diagram', { language: 'mermaid' }),
@ -109,7 +121,16 @@ export default {
:toggle-text="__('More options')"
text-sr-only
right
/>
>
<template #list-item="{ item }">
<span class="gl-flex gl-items-center gl-justify-between">
{{ item.text }}
<gl-badge v-if="item.badge" v-bind="item.badge" class="gl-ml-4" @click.stop>
{{ item.badge.text }}
</gl-badge>
</span>
</template>
</gl-disclosure-dropdown>
<gl-tooltip :target="toggleId" placement="top">{{ __('More options') }}</gl-tooltip>
</div>
</template>

View File

@ -3,9 +3,12 @@ import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import { Fragment } from '@tiptap/pm/model';
import { mergeAttributes, textblockTypeInputRule } from '@tiptap/core';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import { __ } from '~/locale';
import languageLoader from '../services/code_block_language_loader';
import CodeBlockWrapper from '../components/wrappers/code_block.vue';
const DEFAULT_GLQL_VIEW_CONTENT = `query: assignee = currentUser()\nfields: title, createdAt, milestone, assignee\ntitle: ${__('Issues assigned to current user')}`;
const extractLanguage = (element) => element.dataset.canonicalLang ?? element.getAttribute('lang');
export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
@ -56,6 +59,27 @@ export default CodeBlockLowlight.extend({
}),
];
},
addCommands() {
return {
...this.parent?.(),
insertGLQLView:
() =>
({ commands }) => {
return commands.insertContent({
type: this.type.name,
attrs: {
language: 'glql',
},
content: [
{
type: 'text',
text: DEFAULT_GLQL_VIEW_CONTENT,
},
],
});
},
};
},
parseHTML() {
return [
{

View File

@ -60,6 +60,11 @@ export default {
required: false,
default: true,
},
hideOnNarrowScreen: {
type: Boolean,
required: false,
default: true,
},
},
computed: {
expandButtonInfo() {
@ -101,14 +106,15 @@ export default {
</script>
<template>
<div class="gl-hidden gl-items-center md:gl-flex">
<div class="gl-items-center" :class="hideOnNarrowScreen ? 'gl-hidden md:gl-flex' : 'gl-flex'">
<template v-if="hasChanges">
<diff-stats
v-if="diffsCount !== ''"
class="inline-parallel-buttons ml-auto gl-hidden md:gl-flex"
class="inline-parallel-buttons ml-auto"
:diffs-count="diffsCount"
:added-lines="addedLines"
:removed-lines="removedLines"
:hide-on-narrow-screen="hideOnNarrowScreen"
/>
<gl-button-group class="gl-mr-3">
<gl-button

View File

@ -25,6 +25,11 @@ export default {
required: false,
default: null,
},
hideOnNarrowScreen: {
type: Boolean,
required: false,
default: true,
},
},
computed: {
diffFilesLength() {
@ -65,8 +70,10 @@ export default {
<div
class="diff-stats"
:class="{
'is-compare-versions-header gl-hidden lg:gl-inline-flex': isCompareVersionsHeader,
'gl-hidden sm:!gl-inline-flex': !isCompareVersionsHeader,
'is-compare-versions-header gl-hidden lg:gl-inline-flex':
isCompareVersionsHeader && hideOnNarrowScreen,
'gl-hidden sm:!gl-inline-flex': !isCompareVersionsHeader && hideOnNarrowScreen,
'gl-inline-flex': !hideOnNarrowScreen,
}"
>
<div v-if="notDiffable" :class="fileStats.classes">

View File

@ -52,7 +52,7 @@ export default {
<gl-button
v-gl-tooltip.html="toggleFileBrowserTooltip"
variant="default"
class="js-toggle-tree-list btn-icon gl-mr-3"
class="btn-icon gl-mr-3 max-lg:gl-hidden"
data-testid="file-tree-button"
:aria-label="toggleFileBrowserTitle"
:aria-keyshortcuts="toggleFileBrowserShortcutKey"

View File

@ -6,12 +6,19 @@ export const useFileBrowser = defineStore('fileBrowser', {
state() {
return {
fileBrowserVisible: true,
fileBrowserDrawerVisible: false,
};
},
actions: {
setFileBrowserVisibility(visible) {
this.fileBrowserVisible = visible;
},
setFileBrowserDrawerVisibility(visible) {
this.fileBrowserDrawerVisible = visible;
},
toggleFileBrowserDrawerVisibility() {
this.fileBrowserDrawerVisible = !this.fileBrowserDrawerVisible;
},
toggleFileBrowserVisibility() {
this.fileBrowserVisible = !this.fileBrowserVisible;
setCookie(FILE_BROWSER_VISIBLE, this.fileBrowserVisible);

View File

@ -103,7 +103,7 @@ export function removeListenerSystemColorSchemeChange(onEvent) {
.removeEventListener('change', (event) => handleColorSchemeChange(onEvent, event));
}
function isNarrowScreenMediaQuery() {
export function isNarrowScreenMediaQuery() {
const computedStyles = getComputedStyle(document.body);
const largeBreakpointSize = parseInt(computedStyles.getPropertyValue('--breakpoint-lg'), 10);
return window.matchMedia(`(max-width: ${largeBreakpointSize - 1}px)`);

View File

@ -337,6 +337,7 @@ export default class MergeRequestTabs {
}
this.expandSidebar?.forEach((el) => el.classList.toggle('!gl-hidden', action !== 'show'));
this.rapidDiffsApp?.hide?.();
if (action === 'commits') {
if (!this.commitsLoaded) {
@ -537,6 +538,8 @@ export default class MergeRequestTabs {
this.rapidDiffsApp = this.createRapidDiffsApp();
this.rapidDiffsApp.reloadDiffs(true);
this.rapidDiffsApp.init();
} else {
this.rapidDiffsApp.show();
}
} else {
this.loadDiff(options);

View File

@ -141,6 +141,8 @@ export default {
containerRepositoriesCount: 0,
itemToDelete: {},
deleteAlertType: null,
deleteAlertMessage: null,
deleteImageErrorMessages: [],
sorting: null,
name: null,
mutationLoading: false,
@ -197,11 +199,6 @@ export default {
showConnectionError() {
return this.config.connectionError || this.config.invalidPathError;
},
deleteImageAlertMessage() {
return this.deleteAlertType === 'success'
? DELETE_IMAGE_SUCCESS_MESSAGE
: DELETE_IMAGE_ERROR_MESSAGE;
},
},
methods: {
deleteImage(item) {
@ -210,7 +207,8 @@ export default {
this.$refs.deleteModal.show();
},
dismissDeleteAlert() {
this.deleteAlertType = null;
this.setDeleteAlert(null, null);
this.itemToDelete = {};
},
fetchNextPage() {
@ -239,6 +237,20 @@ export default {
}, 200);
}
},
setDeleteAlert(alertType, alertMessage) {
this.deleteAlertType = alertType;
this.deleteAlertMessage = alertMessage;
},
setDeleteErrorMessages(deleteErrorMessages = []) {
this.deleteImageErrorMessages = deleteErrorMessages ?? [];
},
handleDeleteImageSuccess() {
this.setDeleteAlert('success', DELETE_IMAGE_SUCCESS_MESSAGE);
},
handleDeleteImageError(errors = []) {
this.setDeleteAlert('danger', DELETE_IMAGE_ERROR_MESSAGE);
this.setDeleteErrorMessages(errors?.map(({ message }) => message));
},
},
containerRegistryHelpUrl: helpPagePath('user/packages/container_registry/_index'),
dockerConnectionErrorHelpUrl: helpPagePath(
@ -260,11 +272,19 @@ export default {
dismissible
@dismiss="dismissDeleteAlert"
>
<gl-sprintf :message="deleteImageAlertMessage">
<gl-sprintf :message="deleteAlertMessage">
<template #title>
{{ itemToDelete.path }}
</template>
</gl-sprintf>
<div v-if="deleteImageErrorMessages.length">
<ul>
<li v-for="(deleteImageErrorMessage, index) in deleteImageErrorMessages" :key="index">
{{ deleteImageErrorMessage }}
</li>
</ul>
</div>
</gl-alert>
<gl-empty-state
@ -374,8 +394,8 @@ export default {
<delete-image
:id="itemToDelete.id"
@start="startDelete"
@error="deleteAlertType = 'danger'"
@success="deleteAlertType = 'success'"
@error="handleDeleteImageError"
@success="handleDeleteImageSuccess"
@end="mutationLoading = false"
>
<template #default="{ doDelete }">

View File

@ -0,0 +1,30 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { isNarrowScreenMediaQuery } from '~/lib/utils/css_utils';
export const useViewport = defineStore('viewportStore', () => {
const isNarrowScreen = ref(false);
const updateIsNarrow = (matches) => {
isNarrowScreen.value = matches;
};
const reset = () => {
isNarrowScreen.value = false;
};
const query = isNarrowScreenMediaQuery();
updateIsNarrow(query.matches);
query.addEventListener('change', (event) => {
updateIsNarrow(event.matches);
});
return {
// used only for testing
updateIsNarrow,
// used only for testing
reset,
isNarrowScreen: computed(() => isNarrowScreen.value),
};
});

View File

@ -0,0 +1,83 @@
<script>
import { mapState } from 'pinia';
import { MountingPortal } from 'portal-vue';
import { GlDrawer } from '@gitlab/ui';
import DiffsFileTree from '~/diffs/components/diffs_file_tree.vue';
import { useDiffsList } from '~/rapid_diffs/stores/diffs_list';
import { useFileBrowser } from '~/diffs/stores/file_browser';
import { useDiffsView } from '~/rapid_diffs/stores/diffs_view';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
export default {
name: 'FileBrowserDrawer',
DRAWER_Z_INDEX,
components: {
MountingPortal,
DiffsFileTree,
GlDrawer,
},
props: {
groupBlobsListItems: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
openedOnce: false,
};
},
computed: {
...mapState(useDiffsView, ['totalFilesCount']),
...mapState(useDiffsList, ['loadedFiles']),
...mapState(useFileBrowser, ['fileBrowserDrawerVisible']),
},
watch: {
fileBrowserDrawerVisible() {
this.openedOnce = true;
},
},
beforeDestroy() {
useFileBrowser().setFileBrowserDrawerVisibility(false);
},
methods: {
clickFile(file) {
this.$emit('clickFile', file);
this.close();
},
toggleFolder(path) {
useLegacyDiffs().toggleTreeOpen(path);
},
close() {
useFileBrowser().setFileBrowserDrawerVisibility(false);
},
},
};
</script>
<template>
<mounting-portal append mount-to="#js-drawer-container">
<gl-drawer
v-show="fileBrowserDrawerVisible"
:open="openedOnce"
:z-index="$options.DRAWER_Z_INDEX"
@close="close"
>
<template #title>
<h2 class="gl-my-0 gl-text-size-h2 gl-leading-24">{{ s__('RapidDiffs|File browser') }}</h2>
</template>
<template #default>
<diffs-file-tree
class="diffs-tree-drawer"
:loaded-files="loadedFiles"
:total-files-count="totalFilesCount"
:group-blobs-list-items="groupBlobsListItems"
@clickFile="clickFile"
@toggleFolder="toggleFolder"
/>
</template>
</gl-drawer>
</mounting-portal>
</template>

View File

@ -0,0 +1,36 @@
<script>
import { GlAnimatedSidebarIcon, GlButton } from '@gitlab/ui';
import { mapActions, mapState } from 'pinia';
import { __ } from '~/locale';
import { useFileBrowser } from '~/diffs/stores/file_browser';
export default {
name: 'FileBrowserDrawerToggle',
components: {
GlButton,
GlAnimatedSidebarIcon,
},
computed: {
...mapState(useFileBrowser, ['fileBrowserDrawerVisible']),
toggleFileBrowserTitle() {
return this.fileBrowserDrawerVisible ? __('Hide file browser') : __('Show file browser');
},
},
methods: {
...mapActions(useFileBrowser, ['toggleFileBrowserDrawerVisibility']),
},
};
</script>
<template>
<gl-button
variant="default"
category="tertiary"
class="btn-icon -gl-mr-3 -gl-scale-x-100 lg:gl-hidden"
:aria-label="toggleFileBrowserTitle"
data-testid="file-tree-drawer-button"
@click="toggleFileBrowserDrawerVisibility"
>
<gl-animated-sidebar-icon :is-on="fileBrowserDrawerVisible" class="gl-button-icon" />
</gl-button>
</template>

View File

@ -14,6 +14,7 @@ import { DIFF_FILE_MOUNTED } from '~/rapid_diffs/dom_events';
import { VIEWER_ADAPTERS } from '~/rapid_diffs/adapters';
import { camelizeKeys } from '~/lib/utils/object_utils';
import { disableBrokenContentVisibility } from '~/rapid_diffs/app/content_visibility_fix';
import { useApp } from '~/rapid_diffs/stores/app';
// This facade interface joins together all the bits and pieces of Rapid Diffs: DiffFile, Settings, File browser, etc.
// It's a unified entrypoint for Rapid Diffs and all external communications should happen through this interface.
@ -51,6 +52,16 @@ export class RapidDiffsFacade {
this.intersectionObserver.unobserve(instance);
}
// eslint-disable-next-line class-methods-use-this
show() {
useApp().appVisible = true;
}
// eslint-disable-next-line class-methods-use-this
hide() {
useApp().appVisible = false;
}
#delegateEvents() {
this.root.addEventListener('click', (event) => {
const diffFile = event.target.closest('diff-file');

View File

@ -7,7 +7,11 @@ import { generateTreeList } from '~/diffs/utils/tree_worker_utils';
import { SET_TREE_DATA } from '~/diffs/store/mutation_types';
import { linkTreeNodes, sortTree } from '~/ide/stores/utils';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
import { useViewport } from '~/pinia/global_stores/viewport';
import { useApp } from '~/rapid_diffs/stores/app';
import FileBrowser from './file_browser.vue';
import FileBrowserDrawer from './file_browser_drawer.vue';
import FileBrowserDrawerToggle from './file_browser_drawer_toggle.vue';
const loadFileBrowserData = async (diffFilesEndpoint, shouldSort) => {
const { data } = await axios.get(diffFilesEndpoint);
@ -19,11 +23,33 @@ const loadFileBrowserData = async (diffFilesEndpoint, shouldSort) => {
};
const initToggle = (el) => {
// eslint-disable-next-line no-new
new Vue({
el: document.querySelector('#js-page-breadcrumbs-extra'),
pinia,
computed: {
visible() {
return useViewport().isNarrowScreen && useApp().appVisible;
},
},
render(h) {
if (!this.visible) return null;
return h(FileBrowserDrawerToggle);
},
});
// eslint-disable-next-line no-new
new Vue({
el,
pinia,
computed: {
visible() {
return !useViewport().isNarrowScreen;
},
},
render(h) {
if (!this.visible) return null;
return h(FileBrowserToggle);
},
});
@ -35,7 +61,7 @@ const initBrowserComponent = async (el, shouldSort) => {
el,
pinia,
render(h) {
return h(FileBrowser, {
return h(useViewport().isNarrowScreen ? FileBrowserDrawer : FileBrowser, {
props: {
groupBlobsListItems: shouldSort,
},

View File

@ -44,6 +44,7 @@ const initSettingsApp = (el, pinia) => {
addedLines: this.diffsStats?.addedLines,
removedLines: this.diffsStats?.removedLines,
diffsCount: this.diffsStats?.diffsCount,
hideOnNarrowScreen: false,
},
on: {
updateDiffViewType: this.updateViewType,

View File

@ -0,0 +1,9 @@
import { defineStore } from 'pinia';
export const useApp = defineStore('rapidDiffsApp', {
state() {
return {
appVisible: true,
};
},
});

View File

@ -105,6 +105,7 @@ export default {
:content="blobContent"
:active-viewer="viewer"
:blob="blob"
:is-snippet="true"
@[$options.BLOB_RENDER_EVENT_LOAD]="forceQuery"
@[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="switchViewer"
/>

View File

@ -20,6 +20,13 @@ export default {
SafeHtml,
},
mixins: [ViewerMixin],
props: {
isSnippet: {
type: Boolean,
default: false,
required: false,
},
},
data() {
return {
isLoading: true,
@ -34,6 +41,13 @@ export default {
isMarkup() {
return this.type === MARKUP_FILE_TYPE;
},
forbiddenAttrs() {
const attrs = [...defaultConfig.FORBID_ATTR, 'data-lines-path'];
if (this.isSnippet) {
attrs.push('style');
}
return attrs;
},
},
created() {
this.optimizeMarkupRendering();
@ -55,7 +69,10 @@ export default {
if (!this.isMarkup) return;
const tmpWrapper = document.createElement('div');
tmpWrapper.innerHTML = sanitize(this.rawContent, this.$options.safeHtmlConfig);
tmpWrapper.innerHTML = sanitize(this.rawContent, {
...defaultConfig,
FORBID_ATTR: this.forbiddenAttrs,
});
const fileContent = tmpWrapper.querySelector(MARKUP_CONTENT_SELECTOR);
if (!fileContent) return;
@ -99,7 +116,7 @@ export default {
},
safeHtmlConfig: {
...defaultConfig,
FORBID_ATTR: [...defaultConfig.FORBID_ATTR, 'style', 'data-lines-path'],
FORBID_ATTR: [...defaultConfig.FORBID_ATTR, 'data-lines-path'],
},
};
</script>

View File

@ -26,6 +26,13 @@ import CommentTemplatesModal from './comment_templates_modal.vue';
import HeaderDivider from './header_divider.vue';
export default {
findAndReplace: {
highlightColor: '#fdf1dd',
highlightColorActive: '#e6e4f2',
highlightClass: 'js-highlight',
highlightClassActive: 'js-highlight-active',
},
components: {
ToolbarButton,
ToolbarTableButton,
@ -174,6 +181,26 @@ export default {
showSuggestPopover() {
this.updateSuggestPopoverVisibility();
},
'findAndReplace.highlightedMatchIndex': {
handler(newValue) {
const options = this.$options.findAndReplace;
const previousActive = this.cloneDiv.querySelector(`.${options.highlightClassActive}`);
if (previousActive) {
previousActive.classList.remove(options.highlightClassActive);
previousActive.style.backgroundColor = options.highlightColor;
}
const newActive = this.cloneDiv
.querySelectorAll(`.${options.highlightClass}`)
.item(newValue - 1);
if (newActive) {
newActive.classList.add(options.highlightClassActive);
newActive.style.backgroundColor = options.highlightColorActive;
}
},
},
},
mounted() {
$(document).on('markdown-preview:show.vue', this.showMarkdownPreview);
@ -306,7 +333,11 @@ export default {
}
},
findAndReplace_handleKeyUp(e) {
this.findAndReplace_highlightMatchingText(e.target.value);
if (e.key === 'Enter') {
this.findAndReplace_handleNext();
} else {
this.findAndReplace_highlightMatchingText(e.target.value);
}
},
findAndReplace_syncScroll() {
const textArea = this.getCurrentTextArea();
@ -322,20 +353,30 @@ export default {
const regex = new RegExp(`(${textToFind})`, 'g');
const segments = textArea.value.split(regex);
const options = this.$options.findAndReplace;
// Clear previous contents
this.cloneDiv.innerHTML = '';
let counter = 0;
segments.forEach((segment) => {
// If the segment matches the text we're highlighting
if (segment === textToFind) {
const span = document.createElement('span');
span.classList.add('js-highlight');
span.style.backgroundColor = 'orange';
span.classList.add(options.highlightClass);
span.style.backgroundColor = options.highlightColor;
span.style.display = 'inline-block';
span.textContent = segment; // Use textContent for safe text insertion
// Highlight first match
if (counter === 0) {
span.classList.add(options.highlightClassActive);
span.style.backgroundColor = options.highlightColorActive;
}
this.cloneDiv.appendChild(span);
this.findAndReplace.totalMatchCount += 1;
counter += 1;
} else {
// Otherwise, just append the plain text
const textNode = document.createTextNode(segment);
@ -409,6 +450,20 @@ export default {
// Required to align the clone div
this.cloneDiv.scrollTop = textArea.scrollTop;
},
findAndReplace_handlePrev() {
this.findAndReplace.highlightedMatchIndex -= 1;
if (this.findAndReplace.highlightedMatchIndex <= 0) {
this.findAndReplace.highlightedMatchIndex = this.findAndReplace.totalMatchCount;
}
},
findAndReplace_handleNext() {
this.findAndReplace.highlightedMatchIndex += 1;
if (this.findAndReplace.highlightedMatchIndex > this.findAndReplace.totalMatchCount) {
this.findAndReplace.highlightedMatchIndex = 1;
}
},
},
shortcuts: {
bold: keysFor(BOLD_TEXT),
@ -739,6 +794,24 @@ export default {
<div class="gl-ml-4 gl-min-w-12 gl-whitespace-nowrap">
{{ findAndReplace_MatchCountText }}
</div>
<div class="gl-ml-2 gl-flex gl-items-center">
<gl-button
category="tertiary"
icon="arrow-up"
size="small"
data-testid="find-prev"
:aria-label="s__('MarkdownEditor|Find previous')"
@click="findAndReplace_handlePrev"
/>
<gl-button
category="tertiary"
icon="arrow-down"
size="small"
data-testid="find-next"
:aria-label="s__('MarkdownEditor|Find next')"
@click="findAndReplace_handleNext"
/>
</div>
</div>
</div>
</template>

View File

@ -84,8 +84,15 @@
// override .diff-tree-list, remove when fully switched to Rapid Diffs
width: auto !important;
position: static !important;
padding: 0 0 $gl-spacing-scale-4 !important;
min-height: auto !important;
padding: $gl-spacing-scale-4 !important;
min-height: 0 !important;
box-sizing: border-box;
}
}
.rd-app .rd-app-sidebar {
@include media-breakpoint-down(constants.$app-vertical-breakpoint) {
display: none;
}
}

View File

@ -454,6 +454,10 @@ span.idiff {
}
}
.diffs-tree-drawer .mr-tree-list {
max-height: none;
}
.mr-tree-list:not(.tree-list-blobs) .tree-list-parent::before, .repository-tree-list .tree-list-parent::before {
content: '';
position: absolute;

View File

@ -108,8 +108,7 @@
overflow: auto;
.icon-arrow-right {
left: 8px;
top: 12px;
@apply gl-top-3 gl-left-3;
}
.build-job {
@ -120,6 +119,10 @@
&:hover {
@apply gl-bg-strong;
}
a:focus {
@apply gl-relative gl-z-2;
}
}
}
}

View File

@ -18,7 +18,7 @@ module Types
experiment: { milestone: '15.7' }
value 'EPIC', value: 'epic',
description: 'Epic issue type. ' \
'Available only when feature epics is available and the feature flag `work_item_epics` is enabled.',
'Available only when feature epics is available.',
experiment: { milestone: '16.7' }
end
end

View File

@ -85,7 +85,7 @@ module NavHelper
end
def work_item_epic_page?
current_controller?('epics') && @group.work_item_epics_enabled?
current_controller?('epics')
end
def new_issue_look?

View File

@ -1125,7 +1125,7 @@ class Group < Namespace
end
def create_group_level_work_items_feature_flag_enabled?
::Feature.enabled?(:create_group_level_work_items, self, type: :wip)
::Feature.enabled?(:create_group_level_work_items, self, type: :wip) && supports_group_work_items?
end
def supports_lock_on_merge?

View File

@ -0,0 +1,50 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "vulnerability_export#report_data",
"description": "Schema for providing additional report data for vulnerability report exports",
"type": "object",
"additionalProperties": false,
"properties": {
"project_vulnerabilities_history": {
"type": [
"object",
"null"
],
"description": "This object should include an SVG asset to be rendered in PDF reports"
},
"group_vulnerabilities_history": {
"type": [
"object",
"null"
],
"description": "This object should include an SVG asset to be rendered in PDF reports"
},
"project_security_status": {
"type": [
"array",
"null"
],
"description": "Data for rendering the project grades summary in PDF reports"
},
"dashboard_type": {
"type": [
"string",
"null"
],
"enum": [
"product",
"group",
"dashboard",
null
],
"description": "The type of vulnerability grouping this report is being created for"
},
"full_path": {
"type": [
"string",
"null"
],
"description": "The full path of the vulnerable"
}
}
}

View File

@ -8,6 +8,8 @@
%body{ class: body_classes, data: body_data }
-# all tooltips from GitLab UI will mount here by default
#js-tooltips-container
-# mount drawers here for better page performance
#js-drawer-container
= render "layouts/init_auto_complete" if @gfm_form
= render "layouts/init_client_detection_flags"
= render 'peek/bar'

View File

@ -13,5 +13,6 @@
#js-vue-page-breadcrumbs-wrapper{ data: { testid: 'breadcrumb-links' } }
#js-vue-page-breadcrumbs{ data: { breadcrumbs_json: breadcrumbs_as_json } }
#js-injected-page-breadcrumbs
#js-page-breadcrumbs-extra
= yield :header_content

View File

@ -1,5 +1,6 @@
- add_page_specific_style 'page_bundles/branches'
- page_title _('Branches')
- title = _('Branches')
- page_title title
- search = params[:search]
-# Possible values for variables passed down from the projects/branches_controller.rb
@ -7,6 +8,8 @@
-# @mode - overview|active|stale|all (default:overview)
-# @sort - name_asc|updated_asc|updated_desc
%h1.gl-sr-only= title
.top-area
= gl_tabs_nav({ class: 'gl-grow gl-border-b-0' }) do
= gl_tab_link_to s_('Branches|Overview'), project_branches_path(@project), { item_active: @mode == 'overview', title: s_('Branches|Show overview of the branches') }

View File

@ -4,6 +4,8 @@
- add_page_specific_style 'page_bundles/projects'
- page_title _("Commits"), @ref
%h1.gl-sr-only= page_title
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, project_commits_path(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits")

View File

@ -1,6 +1,8 @@
- page_title s_("Snippets|Snippets")
- new_project_snippet_link = new_project_snippet_path(@project) if can?(current_user, :create_snippet, @project)
%h1.gl-sr-only= page_title
- if @snippets.exists?
- if current_user
.top-area

View File

@ -8,14 +8,6 @@ description: The SHA referencing changes to a single design or multiple design f
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/10552
milestone: '11.10'
gitlab_schema: gitlab_main_cell
desired_sharding_key:
namespace_id:
references: namespaces
backfill_via:
parent:
foreign_key: design_id
table: design_management_designs
sharding_key: namespace_id
belongs_to: design
sharding_key:
namespace_id: namespaces
table_size: small
desired_sharding_key_migration_job_name: BackfillDesignManagementDesignsVersionsNamespaceId

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
class AddDesignManagementDesignsVersionsNamespaceIdNotNull < Gitlab::Database::Migration[2.3]
milestone '18.1'
disable_ddl_transaction!
def up
add_not_null_constraint :design_management_designs_versions, :namespace_id
end
def down
remove_not_null_constraint :design_management_designs_versions, :namespace_id
end
end

View File

@ -0,0 +1 @@
9eb96106e37385188350578a21fec79cf8a5ddd69e76aa5dfe807d8a3a8b8145

View File

@ -13703,7 +13703,8 @@ CREATE TABLE design_management_designs_versions (
version_id bigint NOT NULL,
event smallint DEFAULT 0 NOT NULL,
image_v432x230 character varying(255),
namespace_id bigint
namespace_id bigint,
CONSTRAINT check_ae7359f44b CHECK ((namespace_id IS NOT NULL))
);
CREATE SEQUENCE design_management_designs_versions_id_seq

View File

@ -107,7 +107,7 @@ For more examples of a question you can ask, see
| Feature | Available on GitLab Duo Self-Hosted | GitLab version | Status |
| -------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ---------------------- | --- |
| [GitLab Duo for the CLI](../../editor_extensions/gitlab_cli/_index.md#gitlab-duo-for-the-cli) | {{< icon name="check-circle-filled" >}} Yes | GitLab 18.1 and later | Beta |
| [GitLab Duo Workflow](../../user/duo_workflow/_index.md) | {{< icon name="dash-circle" >}} No | GitLab 17.4 and later | Beta |
| [GitLab Duo Workflow](../../user/duo_workflow/_index.md) | {{< icon name="dash-circle" >}} No | GitLab 17.4 and later | Not applicable |
| [Vulnerability Resolution](../../user/application_security/vulnerabilities/_index.md#vulnerability-resolution) | {{< icon name="check-circle-filled" >}} Yes | GitLab 18.1 and later | Beta |
| [AI Impact Dashboard](../../user/analytics/ai_impact_analytics.md) | {{< icon name="check-circle-filled" >}} Yes | GitLab 17.9 and later | Beta |

View File

@ -224,7 +224,7 @@ When restricting visibility levels, consider how these restrictions interact
with permissions for subgroups and projects that inherit their visibility from
the item you're changing.
This setting does not apply to groups and projects created under a personal namespace.
This setting does not apply to projects created under a personal namespace.
There is a [feature request](https://gitlab.com/gitlab-org/gitlab/-/issues/382749) to extend this
functionality to [enterprise users](../../user/enterprise_user/_index.md).

View File

@ -7868,6 +7868,35 @@ Input type: `LdapAdminRoleLinkDestroyInput`
| <a id="mutationldapadminrolelinkdestroyerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during the mutation. |
| <a id="mutationldapadminrolelinkdestroyldapadminrolelink"></a>`ldapAdminRoleLink` | [`LdapAdminRoleLink`](#ldapadminrolelink) | Deleted instance-level LDAP link. |
### `Mutation.lifecycleUpdate`
{{< details >}}
**Introduced** in GitLab 18.1.
**Status**: Experiment.
{{< /details >}}
Input type: `LifecycleUpdateInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationlifecycleupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationlifecycleupdatedefaultclosedstatusindex"></a>`defaultClosedStatusIndex` | [`Int`](#int) | Index of the default closed status in the statuses array. |
| <a id="mutationlifecycleupdatedefaultduplicatestatusindex"></a>`defaultDuplicateStatusIndex` | [`Int`](#int) | Index of the default duplicated status in the statuses array. |
| <a id="mutationlifecycleupdatedefaultopenstatusindex"></a>`defaultOpenStatusIndex` | [`Int`](#int) | Index of the default open status in the statuses array. |
| <a id="mutationlifecycleupdateid"></a>`id` | [`WorkItemsStatusesLifecycleID!`](#workitemsstatuseslifecycleid) | Global ID of the lifecycle to be updated. |
| <a id="mutationlifecycleupdatenamespacepath"></a>`namespacePath` | [`ID!`](#id) | Namespace path where the lifecycle exists. |
| <a id="mutationlifecycleupdatestatuses"></a>`statuses` | [`[WorkItemStatusInput!]`](#workitemstatusinput) | Statuses of the lifecycle. Can be existing (with id) or new (without id). |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationlifecycleupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationlifecycleupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during the mutation. |
| <a id="mutationlifecycleupdatelifecycle"></a>`lifecycle` | [`WorkItemLifecycle`](#workitemlifecycle) | Lifecycle updated. |
### `Mutation.markAsSpamSnippet`
Input type: `MarkAsSpamSnippetInput`
@ -45348,7 +45377,7 @@ Issue type.
| Value | Description |
| ----- | ----------- |
| <a id="issuetypeepic"></a>`EPIC` {{< icon name="warning-solid" >}} | **Introduced** in GitLab 16.7. **Status**: Experiment. Epic issue type. Available only when feature epics is available and the feature flag `work_item_epics` is enabled. |
| <a id="issuetypeepic"></a>`EPIC` {{< icon name="warning-solid" >}} | **Introduced** in GitLab 16.7. **Status**: Experiment. Epic issue type. Available only when feature epics is available. |
| <a id="issuetypeincident"></a>`INCIDENT` | Incident issue type. |
| <a id="issuetypeissue"></a>`ISSUE` | Issue issue type. |
| <a id="issuetypekey_result"></a>`KEY_RESULT` {{< icon name="warning-solid" >}} | **Introduced** in GitLab 15.7. **Status**: Experiment. Key Result issue type. Available only when feature flag `okrs_mvc` is enabled. |
@ -48779,6 +48808,12 @@ A `WorkItemsRelatedWorkItemLinkID` is a global ID. It is encoded as a string.
An example `WorkItemsRelatedWorkItemLinkID` is: `"gid://gitlab/WorkItems::RelatedWorkItemLink/1"`.
### `WorkItemsStatusesLifecycleID`
A `WorkItemsStatusesLifecycleID` is a global ID. It is encoded as a string.
An example `WorkItemsStatusesLifecycleID` is: `"gid://gitlab/WorkItems::Statuses::Lifecycle/1"`.
### `WorkItemsStatusesStatusID`
A `WorkItemsStatusesStatusID` is a global ID. It is encoded as a string.
@ -51418,6 +51453,18 @@ Attributes for value stream setting.
| <a id="workitemresolvediscussionsinputdiscussionid"></a>`discussionId` | [`String`](#string) | ID of a discussion to resolve. |
| <a id="workitemresolvediscussionsinputnoteableid"></a>`noteableId` | [`NoteableID!`](#noteableid) | Global ID of the noteable where discussions will be resolved when the work item is created. Only `MergeRequestID` is supported at the moment. |
### `WorkItemStatusInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="workitemstatusinputcategory"></a>`category` | [`WorkItemStatusCategoryEnum`](#workitemstatuscategoryenum) | Category of the status. |
| <a id="workitemstatusinputcolor"></a>`color` | [`String`](#string) | Color of the status. |
| <a id="workitemstatusinputdescription"></a>`description` | [`String`](#string) | Description of the status. |
| <a id="workitemstatusinputid"></a>`id` | [`GlobalID`](#globalid) | ID of the status. If not provided, a new status will be created. |
| <a id="workitemstatusinputname"></a>`name` | [`String`](#string) | Name of the status. |
### `WorkItemWidgetAssigneesInput`
#### Arguments

View File

@ -14,6 +14,12 @@ title: Vulnerability export API
Every API call to vulnerability exports must be [authenticated](rest/authentication.md).
{{< history >}}
- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/524058) PDF exports in GitLab 18.1 [with a flag](../administration/feature_flags.md) named `vulnerabilities_pdf_export`. Disabled by default.
{{< /history >}}
## Create a project-level vulnerability export
Creates a new vulnerability export for a project.
@ -31,10 +37,19 @@ Vulnerability exports can be only accessed by the export's author.
POST /security/projects/:id/vulnerability_exports
```
| Attribute | Type | Required | Description |
|--------------|-------------------|----------|--------------------------------------------------------------------------------------------------------------------------|
| `id` | integer or string | yes | The ID or [URL-encoded path](rest/_index.md#namespaced-paths) of the project that the authenticated user is a member of |
| `send_email` | boolean | no | When set to `true`, sends an email notification to the user who requested the export when the export completes. |
| Attribute | Type | Required | Description |
|-----------------|-------------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `id` | integer or string | yes | The ID or [URL-encoded path](rest/_index.md#namespaced-paths) of the project that the authenticated user is a member of |
| `send_email` | boolean | no | When set to `true`, sends an email notification to the user who requested the export when the export completes. |
| `export_format` | string | no | Values: `csv`,`pdf`. Default is `csv`. A `pdf` report requires the `vulnerabilities_pdf_export` feature flag. |
| `report_data` | object | no | A hash of report components mapped to frontend data assets to use in the export. For example: `{ project_vulnerabilities_history: '<svg>some report asset</svg>' }` |
{{< alert type="flag" >}}
The availability of PDF exports is controlled by a feature flag. For more information, see
the history.
{{< /alert >}}
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/security/projects/1/vulnerability_exports"
@ -81,9 +96,18 @@ Vulnerability exports can be only accessed by the export's author.
POST /security/groups/:id/vulnerability_exports
```
| Attribute | Type | Required | Description |
| ------------------- | ----------------- | ---------- | -----------------------------------------------------------------------------------------------------------------------------|
| `id` | integer or string | yes | The ID or [URL-encoded path](rest/_index.md#namespaced-paths) of the group which the authenticated user is a member of |
| Attribute | Type | Required | Description |
|-----------------|-------------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `id` | integer or string | yes | The ID or [URL-encoded path](rest/_index.md#namespaced-paths) of the group which the authenticated user is a member of |
| `export_format` | string | no | Values: `csv`,`pdf`. Default is `csv`. A PDF report requires the `vulnerabilities_pdf_export` feature flag. |
| `report_data` | object | no | A hash of report components mapped to frontend data assets to use in the export. For example: `{ project_vulnerabilities_history: '<svg>some report asset</svg>' }` |
{{< alert type="flag" >}}
The availability of PDF exports is controlled by a feature flag. For more information, see
the history.
{{< /alert >}}
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/security/groups/1/vulnerability_exports"

View File

@ -31,11 +31,6 @@ module API
additional_properties = params.fetch(:additional_properties, {}).symbolize_keys
send_snowplow_event = !!params[:send_to_snowplow]
if Gitlab::Tracking::AiTracking::EVENTS_MIGRATED_TO_INSTRUMENTATION_LAYER.exclude?(event_name)
Gitlab::Tracking::AiTracking.track_event(event_name,
**additional_properties.merge(user: current_user, project_id: project_id, namespace_id: namespace_id))
end
track_event(
event_name,
send_snowplow_event: send_snowplow_event,

View File

@ -3,9 +3,6 @@
module Gitlab
module Tracking
class AiTracking
# events getting taken care of by instrumentation layer
EVENTS_MIGRATED_TO_INSTRUMENTATION_LAYER = %w[request_duo_chat_response code_suggestion_shown_in_ide].freeze
def self.track_event(*args, **kwargs)
new.track_event(*args, **kwargs)
end

View File

@ -28316,6 +28316,9 @@ msgstr ""
msgid "GitLab Premium"
msgstr ""
msgid "GitLab Query Language (GLQL) view"
msgstr ""
msgid "GitLab Security"
msgstr ""
@ -33941,6 +33944,9 @@ msgstr ""
msgid "Issues are being rebalanced at the moment, so manual reordering is disabled."
msgstr ""
msgid "Issues assigned to current user"
msgstr ""
msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
msgstr ""
@ -37212,6 +37218,12 @@ msgstr ""
msgid "MarkdownEditor|Find and replace"
msgstr ""
msgid "MarkdownEditor|Find next"
msgstr ""
msgid "MarkdownEditor|Find previous"
msgstr ""
msgid "MarkdownEditor|Indent line (%{modifierKey}])"
msgstr ""
@ -50508,6 +50520,9 @@ msgstr ""
msgid "RapidDiffs|Failed to load changes, please try again."
msgstr ""
msgid "RapidDiffs|File browser"
msgstr ""
msgid "RapidDiffs|File moved from %{old} to %{new}"
msgstr ""

View File

@ -68,7 +68,7 @@
"@gitlab/ui": "113.7.0",
"@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-20250528064209",
"@gitlab/web-ide": "^0.0.1-dev-20250611141528",
"@gleam-lang/highlight.js-gleam": "^1.5.0",
"@mattiasbuelens/web-streams-adapter": "^0.1.0",
"@rails/actioncable": "7.1.501",

View File

@ -50,6 +50,11 @@ describe('Blob Content component', () => {
expect(wrapper.findComponent(SimpleViewer).exists()).toBe(false);
});
it('passes isSnippet prop to the viewer', () => {
createComponent({ isSnippet: true }, RichViewerMock);
expect(wrapper.findComponent(RichViewer).props('isSnippet')).toBe(true);
});
it.each`
type | mock | viewer
${'simple'} | ${SimpleViewerMock} | ${SimpleViewer}

View File

@ -125,6 +125,7 @@ describe('Board List Header Component', () => {
ListType.milestone,
ListType.iteration,
ListType.assignee,
ListType.status,
];
it.each(hasNoAddButton)('does not render dropdown when List Type is `%s`', (listType) => {

View File

@ -600,6 +600,32 @@ export const mockLabelList = {
__typename: 'BoardList',
};
export const mockStatusList = {
id: 'gid://gitlab/List/5',
title: 'In Progress',
position: 0,
listType: 'status',
collapsed: false,
label: null,
assignee: null,
milestone: null,
iteration: null,
loading: false,
issuesCount: 0,
maxIssueCount: 0,
maxIssueWeight: 0,
status: {
id: 'gid://gitlab/WorkItems::Statuses::SystemDefined::Status/2',
name: 'In progress',
iconName: 'status-running',
color: '#1f75cb',
position: 0,
__typename: 'WorkItemStatus',
},
limitMetric: 'issue_count',
__typename: 'BoardList',
};
export const mockMilestoneList = {
id: 'gid://gitlab/List/3',
title: 'To Do',
@ -613,6 +639,7 @@ export const mockMilestoneList = {
title: 'Backlog',
},
loading: false,
status: null,
issuesCount: 0,
};

View File

@ -267,6 +267,33 @@ describe('PipelineInputsForm', () => {
expect(findInputsTable().exists()).toBe(false);
expect(findEmptySelectionState().exists()).toBe(true);
});
it('selects all inputs on select all button click', async () => {
findInputsSelector().vm.$emit('select-all');
await nextTick();
const updatedSelection = [
{ ...expectedInputs[0], isSelected: true },
{ ...expectedInputs[1], isSelected: true },
{ ...expectedInputs[2], isSelected: true },
];
expect(findInputsTable().props('inputs')).toEqual(updatedSelection);
});
it('selects only filtered inputs when search is active', async () => {
findInputsSelector().vm.$emit('search', 'api');
await nextTick();
findInputsSelector().vm.$emit('select-all');
await nextTick();
const updatedSelection = findInputsTable().props('inputs');
const apiTokenInput = updatedSelection.find((i) => i.name === 'api_token');
const otherInputs = updatedSelection.filter((i) => i.name !== 'api_token');
expect(apiTokenInput.isSelected).toBe(true);
expect(otherInputs.every((i) => !i.isSelected)).toBe(true);
});
});
});

View File

@ -1,4 +1,4 @@
import { GlDisclosureDropdown } from '@gitlab/ui';
import { GlDisclosureDropdown, GlBadge } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarMoreDropdown from '~/content_editor/components/toolbar_more_dropdown.vue';
import Diagram from '~/content_editor/extensions/diagram';
@ -38,18 +38,19 @@ describe('content_editor/components/toolbar_more_dropdown', () => {
});
describe.each`
name | contentType | command | params
${'Alert'} | ${'alert'} | ${'insertAlert'} | ${[]}
${'Code block'} | ${'codeBlock'} | ${'setNode'} | ${['codeBlock']}
${'Details block'} | ${'details'} | ${'toggleList'} | ${['details', 'detailsContent']}
${'Bullet list'} | ${'bulletList'} | ${'toggleList'} | ${['bulletList', 'listItem']}
${'Ordered list'} | ${'orderedList'} | ${'toggleList'} | ${['orderedList', 'listItem']}
${'Task list'} | ${'taskList'} | ${'toggleList'} | ${['taskList', 'taskItem']}
${'Mermaid diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'mermaid' }]}
${'PlantUML diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'plantuml' }]}
${'Table of contents'} | ${'tableOfContents'} | ${'insertTableOfContents'} | ${[]}
${'Horizontal rule'} | ${'horizontalRule'} | ${'setHorizontalRule'} | ${[]}
${'Create or edit diagram'} | ${'drawioDiagram'} | ${'createOrEditDiagram'} | ${[]}
name | contentType | command | params
${'Alert'} | ${'alert'} | ${'insertAlert'} | ${[]}
${'Code block'} | ${'codeBlock'} | ${'setNode'} | ${['codeBlock']}
${'Details block'} | ${'details'} | ${'toggleList'} | ${['details', 'detailsContent']}
${'Bullet list'} | ${'bulletList'} | ${'toggleList'} | ${['bulletList', 'listItem']}
${'Ordered list'} | ${'orderedList'} | ${'toggleList'} | ${['orderedList', 'listItem']}
${'Task list'} | ${'taskList'} | ${'toggleList'} | ${['taskList', 'taskItem']}
${'Mermaid diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'mermaid' }]}
${'PlantUML diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'plantuml' }]}
${'Table of contents'} | ${'tableOfContents'} | ${'insertTableOfContents'} | ${[]}
${'Horizontal rule'} | ${'horizontalRule'} | ${'setHorizontalRule'} | ${[]}
${'Create or edit diagram'} | ${'drawioDiagram'} | ${'createOrEditDiagram'} | ${[]}
${'GitLab Query Language (GLQL) view Beta'} | ${'glqlView'} | ${'insertGLQLView'} | ${[]}
`('when option $name is clicked', ({ name, command, contentType, params }) => {
let commands;
let btn;
@ -90,4 +91,19 @@ describe('content_editor/components/toolbar_more_dropdown', () => {
});
});
});
it('shows a beta badge for the GLQL view option', () => {
buildWrapper();
const btn = wrapper.findByRole('button', { name: 'GitLab Query Language (GLQL) view Beta' });
const badge = wrapper.findComponent(GlBadge);
expect(btn.exists()).toBe(true);
expect(badge.props()).toMatchObject({
variant: 'info',
target: '_blank',
href: '/help/user/glql/_index',
});
expect(badge.text()).toBe('Beta');
});
});

View File

@ -97,4 +97,21 @@ describe('content_editor/extensions/code_block_highlight', () => {
);
});
});
describe('when inserting a GLQL view', () => {
beforeEach(() => {
tiptapEditor.commands.insertGLQLView();
});
it('inserts a GLQL view', () => {
expect(tiptapEditor.getJSON()).toEqual(
doc(
codeBlock(
{ language: 'glql' },
'query: assignee = currentUser()\nfields: title, createdAt, milestone, assignee\ntitle: Issues assigned to current user',
),
).toJSON(),
);
});
});
});

View File

@ -48,6 +48,7 @@ describe('DiffAppControls', () => {
diffsCount: DEFAULT_PROPS.diffsCount,
addedLines: DEFAULT_PROPS.addedLines,
removedLines: DEFAULT_PROPS.removedLines,
hideOnNarrowScreen: true,
});
});

View File

@ -34,4 +34,20 @@ describe('FileBrowser store', () => {
expect(useFileBrowser().fileBrowserVisible).toBe(false);
});
});
describe('browser drawer visibility', () => {
it('is hidden by default', () => {
expect(useFileBrowser().fileBrowserDrawerVisible).toBe(false);
});
it('#setFileBrowserDrawerVisibility', () => {
useFileBrowser().setFileBrowserDrawerVisibility(true);
expect(useFileBrowser().fileBrowserDrawerVisible).toBe(true);
});
it('#toggleFileBrowserDrawerVisibility', () => {
useFileBrowser().toggleFileBrowserDrawerVisibility();
expect(useFileBrowser().fileBrowserDrawerVisible).toBe(true);
});
});
});

View File

@ -459,14 +459,20 @@ describe('MergeRequestTabs', () => {
describe('Rapid Diffs', () => {
let createRapidDiffsApp;
let init;
let hide;
let show;
let reloadDiffs;
beforeEach(() => {
setWindowLocation('https://example.com?rapid_diffs=true');
reloadDiffs = jest.fn();
init = jest.fn();
hide = jest.fn();
show = jest.fn();
createRapidDiffsApp = jest.fn(() => ({
init,
hide,
show,
reloadDiffs,
}));
});
@ -494,6 +500,27 @@ describe('MergeRequestTabs', () => {
expect(init).toHaveBeenCalledTimes(1);
expect(reloadDiffs).toHaveBeenCalledTimes(1);
});
it('hides Rapid Diffs', () => {
testContext.class = new MergeRequestTabs({
stubLocation,
createRapidDiffsApp,
});
testContext.class.tabShown('diffs', 'not-a-vue-page');
testContext.class.tabShown('new', 'not-a-vue-page');
expect(hide).toHaveBeenCalledTimes(1);
});
it('shows Rapid Diffs', () => {
testContext.class = new MergeRequestTabs({
stubLocation,
createRapidDiffsApp,
});
testContext.class.tabShown('diffs', 'not-a-vue-page');
testContext.class.tabShown('new', 'not-a-vue-page');
testContext.class.tabShown('diffs', 'not-a-vue-page');
expect(show).toHaveBeenCalledTimes(1);
});
});
});
});

View File

@ -478,6 +478,22 @@ describe('List Page', () => {
DELETE_IMAGE_ERROR_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path),
);
});
it('shows an alert with multiple errors', async () => {
mountComponent();
await selectImageForDeletion();
const errors = [{ message: 'Error 1' }, { message: 'Error 2' }, { message: 'Error 3' }];
findDeleteImage().vm.$emit('error', errors);
await nextTick();
expect(findDeleteAlert().exists()).toBe(true);
expect(findDeleteAlert().text()).toMatch(/^Something went wrong while scheduling/);
expect(findDeleteAlert().text()).toContain('Error 1');
expect(findDeleteAlert().text()).toContain('Error 2');
expect(findDeleteAlert().text()).toContain('Error 3');
});
});
});
});

View File

@ -0,0 +1,50 @@
import { createTestingPinia } from '@pinia/testing';
import { useViewport } from '~/pinia/global_stores/viewport';
import { isNarrowScreenMediaQuery } from '~/lib/utils/css_utils';
import waitForPromises from 'helpers/wait_for_promises';
jest.mock('~/lib/utils/css_utils');
describe('Viewport store', () => {
beforeEach(() => {
createTestingPinia({ stubActions: false });
});
describe('isNarrowScreen', () => {
let handler;
const setNarrowScreen = (isNarrow) => {
isNarrowScreenMediaQuery.mockReturnValue({
matches: isNarrow,
addEventListener: jest.fn((_, fn) => {
handler = fn;
}),
});
};
const triggerChange = (isNarrow) => {
handler({ matches: isNarrow });
};
beforeEach(() => {
isNarrowScreenMediaQuery.mockReset();
});
it('returns true if screen is narrow', () => {
setNarrowScreen(true);
expect(useViewport().isNarrowScreen).toBe(true);
});
it('returns false if screen is not narrow', () => {
setNarrowScreen(false);
expect(useViewport().isNarrowScreen).toBe(false);
});
it('updates value if screen changes', async () => {
setNarrowScreen(true);
useViewport();
triggerChange(false);
await waitForPromises();
expect(useViewport().isNarrowScreen).toBe(false);
});
});
});

View File

@ -13,6 +13,7 @@ import { fixWebComponentsStreamingOnSafari } from '~/rapid_diffs/app/safari_fix'
import { DIFF_FILE_MOUNTED } from '~/rapid_diffs/dom_events';
import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
import { disableBrokenContentVisibility } from '~/rapid_diffs/app/content_visibility_fix';
import { useApp } from '~/rapid_diffs/stores/app';
jest.mock('~/lib/graphql');
jest.mock('~/awards_handler');
@ -149,6 +150,19 @@ describe('Rapid Diffs App', () => {
expect(app.appData.shouldSortMetadataFiles).toBe(false);
});
it('hides the app', () => {
createApp();
app.hide();
expect(useApp().appVisible).toBe(false);
});
it('shows the app', () => {
createApp();
app.hide();
app.show();
expect(useApp().appVisible).toBe(true);
});
it('delegates clicks', () => {
const onClick = jest.fn();
createApp();

View File

@ -0,0 +1,75 @@
import { shallowMount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import Vue, { nextTick } from 'vue';
import { PiniaVuePlugin } from 'pinia';
import { GlDrawer } from '@gitlab/ui';
import FileBrowserDrawer from '~/rapid_diffs/app/file_browser_drawer.vue';
import DiffsFileTree from '~/diffs/components/diffs_file_tree.vue';
import { useDiffsList } from '~/rapid_diffs/stores/diffs_list';
import { useFileBrowser } from '~/diffs/stores/file_browser';
import { useDiffsView } from '~/rapid_diffs/stores/diffs_view';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
Vue.use(PiniaVuePlugin);
describe('FileBrowserDrawer', () => {
let wrapper;
let pinia;
const createComponent = () => {
wrapper = shallowMount(FileBrowserDrawer, {
pinia,
});
};
beforeEach(() => {
pinia = createTestingPinia();
useDiffsList();
useDiffsView();
useFileBrowser();
});
it('passes down props', () => {
const loadedFiles = { foo: 1 };
const totalFilesCount = 20;
useDiffsList().loadedFiles = loadedFiles;
useDiffsView().diffsStats = { diffsCount: totalFilesCount };
createComponent();
const tree = wrapper.findComponent(DiffsFileTree);
expect(tree.props('loadedFiles')).toStrictEqual(loadedFiles);
expect(tree.props('totalFilesCount')).toStrictEqual(totalFilesCount);
});
it('is hidden by default', () => {
createComponent();
expect(wrapper.findComponent(GlDrawer).props('open')).toBe(false);
});
it('shows file browser', async () => {
createComponent();
useFileBrowser().fileBrowserDrawerVisible = true;
await nextTick();
expect(wrapper.findComponent(GlDrawer).props('open')).toBe(true);
});
it('handles click', async () => {
const file = { fileHash: 'foo' };
createComponent();
await wrapper.findComponent(DiffsFileTree).vm.$emit('clickFile', file);
expect(wrapper.emitted('clickFile')).toStrictEqual([[file]]);
expect(useFileBrowser().setFileBrowserDrawerVisibility).toHaveBeenCalledWith(false);
});
it('updates state on destroy', () => {
createComponent();
wrapper.destroy();
expect(useFileBrowser().setFileBrowserDrawerVisibility).toHaveBeenCalledWith(false);
});
it('handles toggleFolder', async () => {
const path = 'foo';
createComponent();
await wrapper.findComponent(DiffsFileTree).vm.$emit('toggleFolder', path);
expect(useLegacyDiffs().toggleTreeOpen).toHaveBeenCalledWith(path);
});
});

View File

@ -0,0 +1,31 @@
import { shallowMount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import Vue from 'vue';
import { PiniaVuePlugin } from 'pinia';
import { GlButton } from '@gitlab/ui';
import FileBrowserDrawerToggle from '~/rapid_diffs/app/file_browser_drawer_toggle.vue';
import { useFileBrowser } from '~/diffs/stores/file_browser';
Vue.use(PiniaVuePlugin);
describe('FileBrowserDrawerToggle', () => {
let wrapper;
let pinia;
const createComponent = () => {
wrapper = shallowMount(FileBrowserDrawerToggle, {
pinia,
});
};
beforeEach(() => {
pinia = createTestingPinia();
useFileBrowser();
});
it('toggles file browser drawer', () => {
createComponent();
wrapper.findComponent(GlButton).vm.$emit('click');
expect(useFileBrowser().toggleFileBrowserDrawerVisibility).toHaveBeenCalled();
});
});

View File

@ -1,13 +1,14 @@
import MockAdapter from 'axios-mock-adapter';
import { setActivePinia } from 'pinia';
import { nextTick } from 'vue';
import axios from '~/lib/utils/axios_utils';
import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures';
import { initFileBrowser } from '~/rapid_diffs/app/init_file_browser';
import createEventHub from '~/helpers/event_hub_factory';
import waitForPromises from 'helpers/wait_for_promises';
import { DiffFile } from '~/rapid_diffs/diff_file';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import store from '~/mr_notes/stores';
import { SET_TREE_DATA } from '~/diffs/store/mutation_types';
import { useViewport } from '~/pinia/global_stores/viewport';
import { pinia } from '~/pinia/instance';
import { useApp } from '~/rapid_diffs/stores/app';
jest.mock('~/rapid_diffs/app/file_browser.vue', () => ({
props: jest.requireActual('~/rapid_diffs/app/file_browser.vue').default.props,
@ -26,6 +27,23 @@ jest.mock('~/rapid_diffs/app/file_browser.vue', () => ({
},
}));
jest.mock('~/rapid_diffs/app/file_browser_drawer.vue', () => ({
props: jest.requireActual('~/rapid_diffs/app/file_browser_drawer.vue').default.props,
render(h) {
return h('div', {
attrs: {
'data-file-browser-drawer-component': true,
'data-group-blobs-list-items': JSON.stringify(this.groupBlobsListItems),
},
on: {
click: () => {
this.$emit('clickFile', { fileHash: 'first' });
},
},
});
},
}));
jest.mock('~/diffs/components/file_browser_toggle.vue', () => ({
render(h) {
return h('div', {
@ -36,14 +54,28 @@ jest.mock('~/diffs/components/file_browser_toggle.vue', () => ({
},
}));
jest.mock('~/rapid_diffs/app/file_browser_drawer_toggle.vue', () => ({
render(h) {
return h('div', {
attrs: {
'data-file-browser-drawer-toggle-component': true,
},
});
},
}));
describe('Init file browser', () => {
let mockAxios;
let commit;
let appData;
const getFileBrowserTarget = () => document.querySelector('[data-file-browser]');
const getFileBrowserToggleTarget = () => document.querySelector('[data-file-browser-toggle]');
const getFileBrowser = () => document.querySelector('[data-file-browser-component]');
const getFileBrowserDrawer = () => document.querySelector('[data-file-browser-drawer-component]');
const getFileBrowserToggle = () => document.querySelector('[data-file-browser-toggle-component]');
const getFileBrowserDrawerToggle = () =>
document.querySelector('[data-file-browser-drawer-toggle-component]');
const createDiffFiles = () => [
{
conflict_type: null,
@ -90,20 +122,23 @@ describe('Init file browser', () => {
};
beforeEach(() => {
setActivePinia(pinia);
initAppData();
window.mrTabs = { eventHub: createEventHub() };
useViewport().reset();
useApp().$reset();
mockAxios = new MockAdapter(axios);
mockAxios
.onGet(appData.diffFilesEndpoint)
.reply(HTTP_STATUS_OK, { diff_files: createDiffFiles() });
commit = jest.spyOn(store, 'commit');
setHTMLFixture(
`
<div data-file-browser-toggle></div>
<div data-file-browser data-metadata-endpoint="/metadata"></div>
<diff-file data-file-data="{}" id="first"><div></div></diff-file>
`,
);
setHTMLFixture(`
<div id="js-page-breadcrumbs-extra"></div>
<div data-file-browser-toggle></div>
<div data-file-browser data-metadata-endpoint="/metadata"></div>
<diff-file data-file-data="{}" id="first"><div></div></diff-file>
`);
DiffFile.getAll().forEach((file) =>
file.mount({ adapterConfig: {}, appData: {}, unobserve: jest.fn() }),
);
@ -117,42 +152,62 @@ describe('Init file browser', () => {
resetHTMLFixture();
});
it('mounts the component', async () => {
await init();
expect(getFileBrowser()).not.toBe(null);
});
describe.each`
isNarrowScreen | getBrowserElement | getBrowserToggleElement
${false} | ${getFileBrowser} | ${getFileBrowserToggle}
${true} | ${getFileBrowserDrawer} | ${getFileBrowserDrawerToggle}
`(
'when narrow screen is $isNarrowScreen',
({ isNarrowScreen, getBrowserElement, getBrowserToggleElement }) => {
beforeEach(() => {
useViewport().updateIsNarrow(isNarrowScreen);
});
it('mounts the components', async () => {
await init();
expect(getBrowserElement()).not.toBe(null);
expect(getBrowserToggleElement()).not.toBe(null);
});
it('handles file clicks', async () => {
const selectFile = jest.fn();
const spy = jest.spyOn(DiffFile, 'findByFileHash').mockReturnValue({ selectFile });
await init();
const fileBrowser = getBrowserElement();
fileBrowser.click();
expect(spy).toHaveBeenCalledWith('first');
expect(selectFile).toHaveBeenCalled();
});
it('passes sorting configuration to components', async () => {
await init();
expect(document.querySelector('[data-group-blobs-list-items="true"]')).not.toBe(null);
});
it('disables sorting when configured', async () => {
initAppData({ shouldSortMetadataFiles: false });
await init();
expect(document.querySelector('[data-group-blobs-list-items="false"]')).not.toBe(null);
});
},
);
it('loads diff files data', async () => {
await init();
expect(commit).toHaveBeenCalledWith(
`diffs/${SET_TREE_DATA}`,
expect.objectContaining({
tree: expect.any(Array),
treeEntries: expect.any(Object),
}),
);
expect(mockAxios.history.get).toHaveLength(1);
expect(mockAxios.history.get[0].url).toBe('/diff-files-metadata');
});
it('handles file clicks', async () => {
const selectFile = jest.fn();
const spy = jest.spyOn(DiffFile, 'findByFileHash').mockReturnValue({ selectFile });
init();
await waitForPromises();
getFileBrowser().click();
expect(spy).toHaveBeenCalledWith('first');
expect(selectFile).toHaveBeenCalled();
});
it('shows file browser toggle', async () => {
init();
await waitForPromises();
expect(document.querySelector('[data-file-browser-toggle-component]')).not.toBe(null);
});
it('disables sorting', async () => {
initAppData({ shouldSortMetadataFiles: false });
init();
await waitForPromises();
expect(document.querySelector('[data-group-blobs-list-items="false"]')).not.toBe(null);
it('hides drawer toggle when app is hidden', async () => {
useViewport().updateIsNarrow(true);
await init();
useApp().appVisible = false;
await nextTick();
expect(getFileBrowserDrawerToggle()).toBe(null);
});
});

View File

@ -22,6 +22,7 @@ jest.mock('~/diffs/components/diff_app_controls.vue', () => ({
'data-removed-lines': JSON.stringify(this.removedLines),
'data-diffs-count': JSON.stringify(this.diffsCount),
'data-file-by-file-supported': JSON.stringify(this.fileByFileSupported),
'data-hide-on-narrow-screen': JSON.stringify(this.hideOnNarrowScreen),
},
});
},
@ -95,6 +96,7 @@ describe('View settings', () => {
expect(getProp('removedLines')).toBe(2);
expect(getProp('diffsCount')).toBe(3);
expect(getProp('fileByFileSupported')).toBe(false);
expect(getProp('hideOnNarrowScreen')).toBe(false);
});
it('triggers collapse all files', () => {

View File

@ -0,0 +1,12 @@
import { createTestingPinia } from '@pinia/testing';
import { useApp } from '~/rapid_diffs/stores/app';
describe('rapidDiffsApp store', () => {
beforeEach(() => {
createTestingPinia({ stubActions: false });
});
it('is visible by default', () => {
expect(useApp().appVisible).toBe(true);
});
});

View File

@ -126,6 +126,7 @@ describe('Blob Embeddable', () => {
});
await waitForPromises();
expect(findRichViewer().exists()).toBe(true);
expect(findRichViewer().props('isSnippet')).toBe(true);
});
it('correctly switches viewer type', async () => {

View File

@ -17,12 +17,19 @@ describe('Blob Rich Viewer component', () => {
const dummyContent = '<h1 id="markdown">Foo Bar</h1>';
const defaultType = 'markdown';
function createComponent(type = defaultType, richViewer, content = dummyContent) {
// eslint-disable-next-line max-params
function createComponent(
type = defaultType,
richViewer,
content = dummyContent,
isSnippet = false,
) {
wrapper = shallowMount(RichViewer, {
propsData: {
richViewer,
content,
type,
isSnippet,
},
});
}
@ -66,6 +73,7 @@ describe('Blob Rich Viewer component', () => {
});
it('sanitizes the content', () => {
createComponent(MARKUP_FILE_TYPE, null, content, true);
jest.runAllTimers();
expect(wrapper.html()).toContain('<img src="x">');
@ -83,6 +91,7 @@ describe('Blob Rich Viewer component', () => {
});
it('sanitizes the content', () => {
createComponent(MARKUP_FILE_TYPE, null, content, true);
expect(wrapper.html()).toContain('<img src="x">');
});
});

View File

@ -364,7 +364,7 @@ describe('Markdown field header component', () => {
const field = document.createElement('div');
const root = document.createElement('div');
const textarea = document.createElement('textarea');
textarea.value = 'lorem ipsum dolor sit amet <img src="prompt">';
textarea.value = 'lorem ipsum dolor sit amet lorem <img src="prompt">';
field.classList = 'js-vue-markdown-field';
form.classList = 'md-area';
form.appendChild(textarea);
@ -378,6 +378,8 @@ describe('Markdown field header component', () => {
const findFindInput = () => wrapper.findByTestId('find-btn');
const findCloneDiv = () => formWrapper.findByTestId('find-and-replace-clone');
const findFindAndReplaceBar = () => wrapper.findByTestId('find-and-replace');
const findNextButton = () => wrapper.findByTestId('find-next');
const findPrevButton = () => wrapper.findByTestId('find-prev');
const showFindAndReplace = async () => {
$(document).triggerHandler('markdown-editor:find-and-replace:show', [$('form')]);
@ -464,7 +466,7 @@ describe('Markdown field header component', () => {
await nextTick();
expect(findCloneDiv().element.innerHTML).toBe(
'lorem ipsum dolor sit amet &lt;img src="<span class="js-highlight" style="background-color: orange; display: inline-block;">prompt</span>"&gt;',
'lorem ipsum dolor sit amet lorem &lt;img src="<span class="js-highlight js-highlight-active" style="background-color: rgb(230, 228, 242); display: inline-block;">prompt</span>"&gt;',
);
});
@ -481,7 +483,57 @@ describe('Markdown field header component', () => {
await findFindInput().vm.$emit('keyup', { target: { value: 'lorem' } });
await nextTick();
expect(findFindAndReplaceBar().text()).toBe('1 of 1');
expect(findFindAndReplaceBar().text()).toBe('1 of 2');
});
it('highlights first item when there is a match', async () => {
await showFindAndReplace();
// Text that matches
await findFindInput().vm.$emit('keyup', { target: { value: 'lorem' } });
await nextTick();
expect(findCloneDiv().element.querySelectorAll('.js-highlight-active').length).toBe(1);
});
it('allows navigating between matches through next and prev buttons', async () => {
await showFindAndReplace();
// Text that matches
await findFindInput().vm.$emit('keyup', { target: { value: 'lorem' } });
await nextTick();
const matches = findCloneDiv().element.querySelectorAll('.js-highlight');
expect(matches.length).toBe(2);
expect(Array.from(matches[0].classList)).toEqual(['js-highlight', 'js-highlight-active']);
expect(Array.from(matches[1].classList)).toEqual(['js-highlight']);
findNextButton().vm.$emit('click');
await nextTick();
expect(Array.from(matches[0].classList)).toEqual(['js-highlight']);
expect(Array.from(matches[1].classList)).toEqual(['js-highlight', 'js-highlight-active']);
findPrevButton().vm.$emit('click');
await nextTick();
expect(Array.from(matches[0].classList)).toEqual(['js-highlight', 'js-highlight-active']);
expect(Array.from(matches[1].classList)).toEqual(['js-highlight']);
// Click again to navigate to last item
findPrevButton().vm.$emit('click');
await nextTick();
expect(Array.from(matches[0].classList)).toEqual(['js-highlight']);
expect(Array.from(matches[1].classList)).toEqual(['js-highlight', 'js-highlight-active']);
// Now that we're at last match, clicking next will bring us back to index 0
findNextButton().vm.$emit('click');
await nextTick();
expect(Array.from(matches[0].classList)).toEqual(['js-highlight', 'js-highlight-active']);
expect(Array.from(matches[1].classList)).toEqual(['js-highlight']);
});
});
});

View File

@ -219,29 +219,46 @@ RSpec.describe 'new tables missing sharding_key', feature_category: :organizatio
end
end
it 'ensures all organization_id columns are not nullable, have no default, and have a foreign key',
quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/527615' do
it 'ensures all organization_id columns are not nullable, have no default, and have a foreign key' do
loose_foreign_keys = Gitlab::Database::LooseForeignKeys.definitions.group_by(&:from_table)
sql = <<~SQL
SELECT c.table_name,
CASE WHEN c.column_default IS NOT NULL THEN 'has default' ELSE NULL END,
CASE WHEN c.is_nullable::boolean THEN 'nullable / not null constraint missing' ELSE NULL END,
CASE WHEN fk.name IS NULL THEN 'no foreign key' ELSE
CASE WHEN fk.is_valid THEN NULL ELSE 'foreign key exist but it is not validated' END
END
FROM information_schema.columns c
LEFT JOIN postgres_foreign_keys fk
ON fk.constrained_table_name = c.table_name AND fk.constrained_columns = '{organization_id}' and fk.referenced_columns = '{id}'
WHERE c.column_name = 'organization_id'
AND (fk.referenced_table_name = 'organizations' OR fk.referenced_table_name IS NULL)
AND (c.column_default IS NOT NULL OR c.is_nullable::boolean OR fk.name IS NULL OR NOT fk.is_valid)
AND (c.table_schema = 'public')
ORDER BY c.table_name;
# Step 1: Get all tables with organization_id columns
tables_sql = <<~SQL
SELECT table_name
FROM information_schema.columns
WHERE column_name = 'organization_id'
AND table_schema = 'public'
ORDER BY table_name;
SQL
# To add a table to this list, create an issue under https://gitlab.com/groups/gitlab-org/-/epics/11670.
# Use https://gitlab.com/gitlab-org/gitlab/-/issues/476206 as an example.
table_names = ApplicationRecord.connection.select_values(tables_sql)
# Step 2: Check each table individually to avoid complex joins
organization_id_columns = []
# Process in batches of 50 to avoid statement timeout issues with large queries
table_names.each_slice(50) do |table_batch|
batch_conditions = table_batch.map do |table|
table_name = ApplicationRecord.connection.quote(table)
"c.table_name = #{table_name}"
end.join(' OR ')
batch_sql = <<~SQL
SELECT c.table_name,
CASE WHEN c.column_default IS NOT NULL THEN 'has default' ELSE NULL END,
CASE WHEN c.is_nullable::boolean THEN 'nullable / not null constraint missing' ELSE NULL END
FROM information_schema.columns c
WHERE c.column_name = 'organization_id'
AND c.table_schema = 'public'
AND (#{batch_conditions})
ORDER BY c.table_name;
SQL
batch_results = ApplicationRecord.connection.select_rows(batch_sql)
organization_id_columns.concat(batch_results)
end
# Step 3: Check foreign keys using Rails schema introspection
work_in_progress = {
"snippet_user_mentions" => "https://gitlab.com/gitlab-org/gitlab/-/issues/517825",
"bulk_import_failures" => "https://gitlab.com/gitlab-org/gitlab/-/issues/517824",
@ -297,22 +314,38 @@ RSpec.describe 'new tables missing sharding_key', feature_category: :organizatio
"ci_runners" => "https://gitlab.com/gitlab-org/gitlab/-/issues/525293",
"group_type_ci_runners" => "https://gitlab.com/gitlab-org/gitlab/-/issues/525293",
"instance_type_ci_runner_machines" => "https://gitlab.com/gitlab-org/gitlab/-/issues/525293",
"project_type_ci_runners" => "https://gitlab.com/gitlab-org/gitlab/-/issues/525293"
"project_type_ci_runners" => "https://gitlab.com/gitlab-org/gitlab/-/issues/525293",
"ci_runner_taggings_group_type" => "https://gitlab.com/gitlab-org/gitlab/-/issues/549027",
"ci_runner_taggings_project_type" => "https://gitlab.com/gitlab-org/gitlab/-/issues/549028",
"customer_relations_contacts" => "https://gitlab.com/gitlab-org/gitlab/-/issues/549029",
"issue_tracker_data" => "https://gitlab.com/gitlab-org/gitlab/-/issues/549030",
"jira_tracker_data" => "https://gitlab.com/gitlab-org/gitlab/-/issues/549032",
"zentao_tracker_data" => "https://gitlab.com/gitlab-org/gitlab/-/issues/549043"
}
has_lfk = ->(lfks) { lfks.any? { |k| k.options[:column] == 'organization_id' && k.to_table == 'organizations' } }
organization_id_columns = ApplicationRecord.connection.select_rows(sql)
checks = organization_id_columns.reject { |column| work_in_progress[column[0]] }
messages = checks.filter_map do |check|
table_name, *violations = check
columns_to_check = organization_id_columns.reject { |column| work_in_progress[column[0]] }
messages = columns_to_check.filter_map do |column|
table_name = column[0]
violations = column[1..].compact
# Check foreign keys using Rails
begin
foreign_keys = ApplicationRecord.connection.foreign_keys(table_name)
org_fk = foreign_keys.find { |fk| fk.column == 'organization_id' && fk.to_table == 'organizations' }
violations << 'no foreign key' unless org_fk || has_lfk.call(loose_foreign_keys.fetch(table_name, {}))
rescue ActiveRecord::StatementInvalid
# Table might not exist or be accessible
violations << 'no foreign key'
end
violations.delete_if do |v|
(v == 'nullable / not null constraint missing' && has_null_check_constraint?(table_name, 'organization_id')) ||
(v == 'no foreign key' && has_lfk.call(loose_foreign_keys.fetch(table_name, {})))
end
" #{table_name} - #{violations.compact.join(', ')}" if violations.any?
" #{table_name} - #{violations.join(', ')}" if violations.any?
end
expect(messages).to be_empty, "Expected all organization_id columns to be not nullable, have no default, " \

View File

@ -1477,10 +1477,10 @@
dependencies:
"@vue/devtools-api" "^6.0.0-beta.11"
"@gitlab/web-ide@^0.0.1-dev-20250528064209":
version "0.0.1-dev-20250528064209"
resolved "https://registry.yarnpkg.com/@gitlab/web-ide/-/web-ide-0.0.1-dev-20250528064209.tgz#1c7ba2d896df24ccddaba5a5e4788b3965b13ee5"
integrity sha512-2mpTvcjPWvVBJmIW2RTciVhgcB6H3Rvw3ldgX0QcvyMKqcftjYzi2HXPK0UeonqwZyD4mr5U1U8917Hxjhvhug==
"@gitlab/web-ide@^0.0.1-dev-20250611141528":
version "0.0.1-dev-20250611141528"
resolved "https://registry.yarnpkg.com/@gitlab/web-ide/-/web-ide-0.0.1-dev-20250611141528.tgz#2238a3e67ca1c3fed0c7b18a4c21ec236be75571"
integrity sha512-B1CqznGSZrTltsGBuBCufoHqDf0AUOPfE/lv7ElYOBJJ0MPHMMfbk3rsF/5f2vKpJ5EvHMm6C1aegi5400Mkxw==
"@gleam-lang/highlight.js-gleam@^1.5.0":
version "1.5.0"