+
diff --git a/app/assets/javascripts/diffs/components/file_browser_toggle.vue b/app/assets/javascripts/diffs/components/file_browser_toggle.vue
index 6984672fa26..b823f2a7bc5 100644
--- a/app/assets/javascripts/diffs/components/file_browser_toggle.vue
+++ b/app/assets/javascripts/diffs/components/file_browser_toggle.vue
@@ -52,7 +52,7 @@ export default {
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)`);
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 30d3c3e2bfd..b4faf4d0511 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -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);
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
index 75a2ff42108..f7cf90b61e9 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
@@ -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"
>
-
+
{{ itemToDelete.path }}
+
+
+
+ -
+ {{ deleteImageErrorMessage }}
+
+
+
diff --git a/app/assets/javascripts/pinia/global_stores/viewport.js b/app/assets/javascripts/pinia/global_stores/viewport.js
new file mode 100644
index 00000000000..a70942b9606
--- /dev/null
+++ b/app/assets/javascripts/pinia/global_stores/viewport.js
@@ -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),
+ };
+});
diff --git a/app/assets/javascripts/rapid_diffs/app/file_browser_drawer.vue b/app/assets/javascripts/rapid_diffs/app/file_browser_drawer.vue
new file mode 100644
index 00000000000..e202e1844ea
--- /dev/null
+++ b/app/assets/javascripts/rapid_diffs/app/file_browser_drawer.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+ {{ s__('RapidDiffs|File browser') }}
+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/rapid_diffs/app/file_browser_drawer_toggle.vue b/app/assets/javascripts/rapid_diffs/app/file_browser_drawer_toggle.vue
new file mode 100644
index 00000000000..716c32b6ddd
--- /dev/null
+++ b/app/assets/javascripts/rapid_diffs/app/file_browser_drawer_toggle.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/rapid_diffs/app/index.js b/app/assets/javascripts/rapid_diffs/app/index.js
index 646f8c34bfe..cb70051e86a 100644
--- a/app/assets/javascripts/rapid_diffs/app/index.js
+++ b/app/assets/javascripts/rapid_diffs/app/index.js
@@ -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');
diff --git a/app/assets/javascripts/rapid_diffs/app/init_file_browser.js b/app/assets/javascripts/rapid_diffs/app/init_file_browser.js
index bda3f82d985..850de569aab 100644
--- a/app/assets/javascripts/rapid_diffs/app/init_file_browser.js
+++ b/app/assets/javascripts/rapid_diffs/app/init_file_browser.js
@@ -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,
},
diff --git a/app/assets/javascripts/rapid_diffs/app/view_settings.js b/app/assets/javascripts/rapid_diffs/app/view_settings.js
index b2e694c65a2..745decf78b8 100644
--- a/app/assets/javascripts/rapid_diffs/app/view_settings.js
+++ b/app/assets/javascripts/rapid_diffs/app/view_settings.js
@@ -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,
diff --git a/app/assets/javascripts/rapid_diffs/stores/app.js b/app/assets/javascripts/rapid_diffs/stores/app.js
new file mode 100644
index 00000000000..dbe0ae1d54f
--- /dev/null
+++ b/app/assets/javascripts/rapid_diffs/stores/app.js
@@ -0,0 +1,9 @@
+import { defineStore } from 'pinia';
+
+export const useApp = defineStore('rapidDiffsApp', {
+ state() {
+ return {
+ appVisible: true,
+ };
+ },
+});
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
index c4924088c6e..59b7bbd928c 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
@@ -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"
/>
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
index c51dcb27854..27c5d1d6cc3 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
@@ -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'],
},
};
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 966f259ac6a..0666bc1bd03 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -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 {
{{ findAndReplace_MatchCountText }}
+
+
+
+
diff --git a/app/assets/stylesheets/components/rapid_diffs/app.scss b/app/assets/stylesheets/components/rapid_diffs/app.scss
index 1e066e7907f..031328868a7 100644
--- a/app/assets/stylesheets/components/rapid_diffs/app.scss
+++ b/app/assets/stylesheets/components/rapid_diffs/app.scss
@@ -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;
}
}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 616c85dfeb2..69fe643c3de 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -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;
diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss
index 2b54df68777..c65bc4b4d9c 100644
--- a/app/assets/stylesheets/page_bundles/build.scss
+++ b/app/assets/stylesheets/page_bundles/build.scss
@@ -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;
+ }
}
}
}
diff --git a/app/graphql/types/issue_type_enum.rb b/app/graphql/types/issue_type_enum.rb
index a6a57804742..82050545fa8 100644
--- a/app/graphql/types/issue_type_enum.rb
+++ b/app/graphql/types/issue_type_enum.rb
@@ -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
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 67cb46f3424..4aa6f56c8c1 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -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?
diff --git a/app/models/group.rb b/app/models/group.rb
index 98344ea4216..acc1e896b45 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -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?
diff --git a/app/validators/json_schemas/vulnerabilities_export.json b/app/validators/json_schemas/vulnerabilities_export.json
new file mode 100644
index 00000000000..0aba38f0250
--- /dev/null
+++ b/app/validators/json_schemas/vulnerabilities_export.json
@@ -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"
+ }
+ }
+}
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index f0008ef6177..f630ec888eb 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -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'
diff --git a/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml b/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml
index 59702d3b30a..346245b2dc9 100644
--- a/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml
+++ b/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml
@@ -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
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index d7b799ce9f2..619d2228f1f 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -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') }
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index eb411de5fa2..a481e4ad3f5 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -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")
diff --git a/app/views/projects/snippets/index.html.haml b/app/views/projects/snippets/index.html.haml
index 55d1c7a2759..cf4b71cfaf6 100644
--- a/app/views/projects/snippets/index.html.haml
+++ b/app/views/projects/snippets/index.html.haml
@@ -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
diff --git a/db/docs/design_management_designs_versions.yml b/db/docs/design_management_designs_versions.yml
index d1509a7cc08..2fd67b593e5 100644
--- a/db/docs/design_management_designs_versions.yml
+++ b/db/docs/design_management_designs_versions.yml
@@ -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
diff --git a/db/post_migrate/20250606164754_add_design_management_designs_versions_namespace_id_not_null.rb b/db/post_migrate/20250606164754_add_design_management_designs_versions_namespace_id_not_null.rb
new file mode 100644
index 00000000000..4fa7eb87b55
--- /dev/null
+++ b/db/post_migrate/20250606164754_add_design_management_designs_versions_namespace_id_not_null.rb
@@ -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
diff --git a/db/schema_migrations/20250606164754 b/db/schema_migrations/20250606164754
new file mode 100644
index 00000000000..08a99a94f63
--- /dev/null
+++ b/db/schema_migrations/20250606164754
@@ -0,0 +1 @@
+9eb96106e37385188350578a21fec79cf8a5ddd69e76aa5dfe807d8a3a8b8145
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 04c0dd0ecef..6dd2f990887 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -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
diff --git a/doc/administration/gitlab_duo_self_hosted/_index.md b/doc/administration/gitlab_duo_self_hosted/_index.md
index bf77123485b..c4d60c5a029 100644
--- a/doc/administration/gitlab_duo_self_hosted/_index.md
+++ b/doc/administration/gitlab_duo_self_hosted/_index.md
@@ -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 |
diff --git a/doc/administration/settings/visibility_and_access_controls.md b/doc/administration/settings/visibility_and_access_controls.md
index f7a0f11d80d..4160c5c90e0 100644
--- a/doc/administration/settings/visibility_and_access_controls.md
+++ b/doc/administration/settings/visibility_and_access_controls.md
@@ -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).
diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md
index f18e583abe1..ed450db73a7 100644
--- a/doc/api/graphql/reference/_index.md
+++ b/doc/api/graphql/reference/_index.md
@@ -7868,6 +7868,35 @@ Input type: `LdapAdminRoleLinkDestroyInput`
|
`errors` | [`[String!]!`](#string) | Errors encountered during the mutation. |
|
`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 |
+| ---- | ---- | ----------- |
+|
`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+|
`defaultClosedStatusIndex` | [`Int`](#int) | Index of the default closed status in the statuses array. |
+|
`defaultDuplicateStatusIndex` | [`Int`](#int) | Index of the default duplicated status in the statuses array. |
+|
`defaultOpenStatusIndex` | [`Int`](#int) | Index of the default open status in the statuses array. |
+|
`id` | [`WorkItemsStatusesLifecycleID!`](#workitemsstatuseslifecycleid) | Global ID of the lifecycle to be updated. |
+|
`namespacePath` | [`ID!`](#id) | Namespace path where the lifecycle exists. |
+|
`statuses` | [`[WorkItemStatusInput!]`](#workitemstatusinput) | Statuses of the lifecycle. Can be existing (with id) or new (without id). |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+|
`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+|
`errors` | [`[String!]!`](#string) | Errors encountered during the mutation. |
+|
`lifecycle` | [`WorkItemLifecycle`](#workitemlifecycle) | Lifecycle updated. |
+
### `Mutation.markAsSpamSnippet`
Input type: `MarkAsSpamSnippetInput`
@@ -45348,7 +45377,7 @@ Issue type.
| Value | Description |
| ----- | ----------- |
-|
`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. |
+|
`EPIC` {{< icon name="warning-solid" >}} | **Introduced** in GitLab 16.7. **Status**: Experiment. Epic issue type. Available only when feature epics is available. |
|
`INCIDENT` | Incident issue type. |
|
`ISSUE` | Issue issue type. |
|
`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.
|
`discussionId` | [`String`](#string) | ID of a discussion to resolve. |
|
`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 |
+| ---- | ---- | ----------- |
+|
`category` | [`WorkItemStatusCategoryEnum`](#workitemstatuscategoryenum) | Category of the status. |
+|
`color` | [`String`](#string) | Color of the status. |
+|
`description` | [`String`](#string) | Description of the status. |
+|
`id` | [`GlobalID`](#globalid) | ID of the status. If not provided, a new status will be created. |
+|
`name` | [`String`](#string) | Name of the status. |
+
### `WorkItemWidgetAssigneesInput`
#### Arguments
diff --git a/doc/api/vulnerability_exports.md b/doc/api/vulnerability_exports.md
index 23ba9a8eacc..b1fc624323e 100644
--- a/doc/api/vulnerability_exports.md
+++ b/doc/api/vulnerability_exports.md
@@ -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: '
' }` |
+
+{{< 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:
" "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: '' }` |
+
+{{< 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: " "https://gitlab.example.com/api/v4/security/groups/1/vulnerability_exports"
diff --git a/lib/api/usage_data.rb b/lib/api/usage_data.rb
index eed9b576701..1d35ff8a417 100644
--- a/lib/api/usage_data.rb
+++ b/lib/api/usage_data.rb
@@ -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,
diff --git a/lib/gitlab/tracking/ai_tracking.rb b/lib/gitlab/tracking/ai_tracking.rb
index 00834a2b194..648347f5e4d 100644
--- a/lib/gitlab/tracking/ai_tracking.rb
+++ b/lib/gitlab/tracking/ai_tracking.rb
@@ -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
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index e5b97629319..0588069abc9 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -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 ""
diff --git a/package.json b/package.json
index 7d63038c03d..68d6965e05b 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/spec/frontend/blob/components/blob_content_spec.js b/spec/frontend/blob/components/blob_content_spec.js
index 768423371d9..5a3bada4030 100644
--- a/spec/frontend/blob/components/blob_content_spec.js
+++ b/spec/frontend/blob/components/blob_content_spec.js
@@ -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}
diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js
index 8799710d021..35565d371a8 100644
--- a/spec/frontend/boards/components/board_list_header_spec.js
+++ b/spec/frontend/boards/components/board_list_header_spec.js
@@ -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) => {
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index 782c1aaac4a..cda6d259e2d 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -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,
};
diff --git a/spec/frontend/ci/common/pipeline_inputs/pipeline_inputs_form_spec.js b/spec/frontend/ci/common/pipeline_inputs/pipeline_inputs_form_spec.js
index 2f79628ef9a..8f21490ca98 100644
--- a/spec/frontend/ci/common/pipeline_inputs/pipeline_inputs_form_spec.js
+++ b/spec/frontend/ci/common/pipeline_inputs/pipeline_inputs_form_spec.js
@@ -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);
+ });
});
});
diff --git a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js
index 946117b0b1d..dfb4f5184b0 100644
--- a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js
@@ -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');
+ });
});
diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
index 3beee0918d6..aa33190440d 100644
--- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
+++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
@@ -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(),
+ );
+ });
+ });
});
diff --git a/spec/frontend/diffs/components/diff_app_controls_spec.js b/spec/frontend/diffs/components/diff_app_controls_spec.js
index 859f637d606..a1f0f2d9787 100644
--- a/spec/frontend/diffs/components/diff_app_controls_spec.js
+++ b/spec/frontend/diffs/components/diff_app_controls_spec.js
@@ -48,6 +48,7 @@ describe('DiffAppControls', () => {
diffsCount: DEFAULT_PROPS.diffsCount,
addedLines: DEFAULT_PROPS.addedLines,
removedLines: DEFAULT_PROPS.removedLines,
+ hideOnNarrowScreen: true,
});
});
diff --git a/spec/frontend/diffs/stores/file_browser_spec.js b/spec/frontend/diffs/stores/file_browser_spec.js
index df225063cff..685399989b9 100644
--- a/spec/frontend/diffs/stores/file_browser_spec.js
+++ b/spec/frontend/diffs/stores/file_browser_spec.js
@@ -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);
+ });
+ });
});
diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js
index 05bc1ab5eb8..3fd1736270f 100644
--- a/spec/frontend/merge_request_tabs_spec.js
+++ b/spec/frontend/merge_request_tabs_spec.js
@@ -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);
+ });
});
});
});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js
index b86706ce9ba..ab8181e7a34 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js
@@ -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');
+ });
});
});
});
diff --git a/spec/frontend/pinia/global_stores/viewport_spec.js b/spec/frontend/pinia/global_stores/viewport_spec.js
new file mode 100644
index 00000000000..bb210a56b0c
--- /dev/null
+++ b/spec/frontend/pinia/global_stores/viewport_spec.js
@@ -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);
+ });
+ });
+});
diff --git a/spec/frontend/rapid_diffs/app/app_spec.js b/spec/frontend/rapid_diffs/app/app_spec.js
index 7b1c233a35f..a83e68397c5 100644
--- a/spec/frontend/rapid_diffs/app/app_spec.js
+++ b/spec/frontend/rapid_diffs/app/app_spec.js
@@ -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();
diff --git a/spec/frontend/rapid_diffs/app/file_browser_drawer_spec.js b/spec/frontend/rapid_diffs/app/file_browser_drawer_spec.js
new file mode 100644
index 00000000000..c68119fa4ee
--- /dev/null
+++ b/spec/frontend/rapid_diffs/app/file_browser_drawer_spec.js
@@ -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);
+ });
+});
diff --git a/spec/frontend/rapid_diffs/app/file_browser_drawer_toggle_spec.js b/spec/frontend/rapid_diffs/app/file_browser_drawer_toggle_spec.js
new file mode 100644
index 00000000000..77a4f44868a
--- /dev/null
+++ b/spec/frontend/rapid_diffs/app/file_browser_drawer_toggle_spec.js
@@ -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();
+ });
+});
diff --git a/spec/frontend/rapid_diffs/app/init_file_browser_spec.js b/spec/frontend/rapid_diffs/app/init_file_browser_spec.js
index 7baaa1ec8fd..c7d73fad70c 100644
--- a/spec/frontend/rapid_diffs/app/init_file_browser_spec.js
+++ b/spec/frontend/rapid_diffs/app/init_file_browser_spec.js
@@ -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(
- `
-
-
-
- `,
- );
+
+ setHTMLFixture(`
+
+
+
+
+ `);
+
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);
});
});
diff --git a/spec/frontend/rapid_diffs/app/view_settings_spec.js b/spec/frontend/rapid_diffs/app/view_settings_spec.js
index 27702ea0385..e2fdc820c03 100644
--- a/spec/frontend/rapid_diffs/app/view_settings_spec.js
+++ b/spec/frontend/rapid_diffs/app/view_settings_spec.js
@@ -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', () => {
diff --git a/spec/frontend/rapid_diffs/stores/app_spec.js b/spec/frontend/rapid_diffs/stores/app_spec.js
new file mode 100644
index 00000000000..2f65e1050e5
--- /dev/null
+++ b/spec/frontend/rapid_diffs/stores/app_spec.js
@@ -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);
+ });
+});
diff --git a/spec/frontend/snippets/components/snippet_blob_view_spec.js b/spec/frontend/snippets/components/snippet_blob_view_spec.js
index 38ccebb7b63..63d4658d2cf 100644
--- a/spec/frontend/snippets/components/snippet_blob_view_spec.js
+++ b/spec/frontend/snippets/components/snippet_blob_view_spec.js
@@ -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 () => {
diff --git a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js
index 62facdb67ef..017751bcdd9 100644
--- a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js
@@ -17,12 +17,19 @@ describe('Blob Rich Viewer component', () => {
const dummyContent = 'Foo Bar
';
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('
');
@@ -83,6 +91,7 @@ describe('Blob Rich Viewer component', () => {
});
it('sanitizes the content', () => {
+ createComponent(MARKUP_FILE_TYPE, null, content, true);
expect(wrapper.html()).toContain('
');
});
});
diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js
index 9ff82d90016..b55ceaa4ece 100644
--- a/spec/frontend/vue_shared/components/markdown/header_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/header_spec.js
@@ -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
';
+ textarea.value = 'lorem ipsum dolor sit amet lorem
';
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 <img src="prompt">',
+ 'lorem ipsum dolor sit amet lorem <img src="prompt">',
);
});
@@ -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']);
});
});
});
diff --git a/spec/lib/gitlab/database/sharding_key_spec.rb b/spec/lib/gitlab/database/sharding_key_spec.rb
index 3f9522f888e..7c078db409c 100644
--- a/spec/lib/gitlab/database/sharding_key_spec.rb
+++ b/spec/lib/gitlab/database/sharding_key_spec.rb
@@ -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, " \
diff --git a/yarn.lock b/yarn.lock
index 93cd11cfec7..804f354e38a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"