Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
39d7b47a14
commit
72d86e022e
|
|
@ -282,10 +282,6 @@ export default {
|
|||
'app/assets/javascripts/super_sidebar/components/pinned_section.vue',
|
||||
'app/assets/javascripts/super_sidebar/components/super_sidebar.vue',
|
||||
'app/assets/javascripts/tags/components/delete_tag_modal.vue',
|
||||
'app/assets/javascripts/todos/components/snooze_todo_modal.vue',
|
||||
'app/assets/javascripts/todos/components/todo_item_actions.vue',
|
||||
'app/assets/javascripts/todos/components/todo_item_timestamp.vue',
|
||||
'app/assets/javascripts/todos/components/todos_app.vue',
|
||||
'app/assets/javascripts/token_access/components/outbound_token_access.vue',
|
||||
'app/assets/javascripts/token_access/components/token_permissions.vue',
|
||||
'app/assets/javascripts/tooltips/components/tooltips.vue',
|
||||
|
|
|
|||
|
|
@ -241,7 +241,6 @@ Layout/EmptyLineAfterMagicComment:
|
|||
- 'ee/spec/lib/gitlab/auth/group_saml/membership_enforcer_spec.rb'
|
||||
- 'ee/spec/lib/gitlab/auth/group_saml/token_actor_spec.rb'
|
||||
- 'ee/spec/lib/gitlab/auth/group_saml/user_spec.rb'
|
||||
- 'ee/spec/lib/gitlab/code_owners/entry_spec.rb'
|
||||
- 'ee/spec/lib/gitlab/geo/replication/blob_retriever_spec.rb'
|
||||
- 'ee/spec/lib/gitlab/license_scanning/branch_components_spec.rb'
|
||||
- 'ee/spec/lib/gitlab/license_scanning/pipeline_components_spec.rb'
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
e5c697b18d5c1b2de9ffd206e3f70ecfe5194cb5
|
||||
2c98bb84086c2655516fae799b6a66dcac2ad668
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
578d1c234fd36b1518d8c3537b2a7ceda2f8bbc7
|
||||
af8af95cc82e62811652eef557168be838149169
|
||||
|
|
|
|||
|
|
@ -546,6 +546,13 @@ export const SIDEBAR_CLOSE_WIDGET = {
|
|||
defaultKeys: ['esc'],
|
||||
};
|
||||
|
||||
export const WORK_ITEM_TOGGLE_SIDEBAR = {
|
||||
id: 'workitems.toggleSidebar',
|
||||
description: __('Show or hide sidebar'),
|
||||
defaultKeys: ['mod+/'], // eslint-disable-line @gitlab/require-i18n-strings
|
||||
customizable: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy Web IDE uses the same shortcuts as MR_GO_TO_FILE, from this shared component:
|
||||
* https://gitlab.com/gitlab-org/gitlab/-/blob/f3e807cdff5cf25765894163b4e92f8b2bcf8a68/app/assets/javascripts/vue_shared/components/file_finder/index.vue#L6
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
ISSUABLE_EDIT_DESCRIPTION,
|
||||
ISSUABLE_COPY_REF,
|
||||
ISSUABLE_COMMENT_OR_REPLY,
|
||||
WORK_ITEM_TOGGLE_SIDEBAR,
|
||||
} from './keybindings';
|
||||
|
||||
export default class ShortcutsWorkItem {
|
||||
|
|
@ -30,6 +31,7 @@ export default class ShortcutsWorkItem {
|
|||
[ISSUE_MR_CHANGE_MILESTONE, () => ShortcutsWorkItem.openSidebarDropdown('js-milestone')],
|
||||
[ISSUABLE_CHANGE_LABEL, () => ShortcutsWorkItem.openSidebarDropdown('js-labels')],
|
||||
[ISSUABLE_EDIT_DESCRIPTION, ShortcutsWorkItem.editDescription],
|
||||
[WORK_ITEM_TOGGLE_SIDEBAR, ShortcutsWorkItem.toggleSidebar],
|
||||
[ISSUABLE_COPY_REF, () => this.copyReference()],
|
||||
[ISSUABLE_COMMENT_OR_REPLY, ShortcutsWorkItem.replyWithSelectedText],
|
||||
]);
|
||||
|
|
@ -81,6 +83,15 @@ export default class ShortcutsWorkItem {
|
|||
return false;
|
||||
}
|
||||
|
||||
static toggleSidebar() {
|
||||
// Need to click the button within the actions dropdown item
|
||||
const sidebarBtn = document.querySelector('.js-sidebar-toggle-action button');
|
||||
|
||||
sidebarBtn?.click();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async copyReference() {
|
||||
const refSelector = '.shortcut-copy-reference';
|
||||
const refButton =
|
||||
|
|
|
|||
|
|
@ -11,17 +11,11 @@ import { numberToMetricPrefix } from '~/lib/utils/number_utils';
|
|||
import { createAlert } from '~/alert';
|
||||
import FilteredSearchAndSort from '~/groups_projects/components/filtered_search_and_sort.vue';
|
||||
import { calculateGraphQLPaginationQueryParams } from '~/graphql_shared/utils';
|
||||
import { RECENT_SEARCHES_STORAGE_KEY_PROJECTS } from '~/filtered_search/recent_searches_storage_keys';
|
||||
import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
|
||||
import { ACCESS_LEVEL_OWNER_INTEGER } from '~/access_level/constants';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
SORT_DIRECTION_ASC,
|
||||
SORT_DIRECTION_DESC,
|
||||
SORT_OPTION_UPDATED,
|
||||
SORT_OPTION_CREATED,
|
||||
FILTERED_SEARCH_TERM_KEY,
|
||||
FILTERED_SEARCH_NAMESPACE,
|
||||
} from '~/projects/filtered_search_and_sort/constants';
|
||||
import { CUSTOM_DASHBOARD_ROUTE_NAMES } from '~/projects/your_work/constants';
|
||||
import projectCountsQuery from '~/projects/your_work/graphql/queries/project_counts.query.graphql';
|
||||
|
|
@ -29,6 +23,8 @@ import * as Sentry from '~/sentry/sentry_browser_wrapper';
|
|||
import {
|
||||
FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
SORT_DIRECTION_ASC,
|
||||
SORT_DIRECTION_DESC,
|
||||
} from '../constants';
|
||||
import userPreferencesUpdateMutation from '../graphql/mutations/user_preferences_update.mutation.graphql';
|
||||
import TabView from './tab_view.vue';
|
||||
|
|
@ -39,12 +35,6 @@ export default {
|
|||
i18n: {
|
||||
projectCountError: __('An error occurred loading the project counts.'),
|
||||
},
|
||||
filteredSearchAndSort: {
|
||||
sortOptions: SORT_OPTIONS,
|
||||
namespace: FILTERED_SEARCH_NAMESPACE,
|
||||
recentSearchesStorageKey: RECENT_SEARCHES_STORAGE_KEY_PROJECTS,
|
||||
searchTermKey: FILTERED_SEARCH_TERM_KEY,
|
||||
},
|
||||
components: {
|
||||
GlTabs,
|
||||
GlTab,
|
||||
|
|
@ -58,6 +48,33 @@ export default {
|
|||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
filteredSearchSupportedTokens: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
filteredSearchTermKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filteredSearchNamespace: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filteredSearchRecentSearchesStorageKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
sortOptions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
defaultSortOption: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -127,13 +144,15 @@ export default {
|
|||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
].filter((filteredSearchToken) =>
|
||||
this.filteredSearchSupportedTokens.includes(filteredSearchToken.type),
|
||||
);
|
||||
},
|
||||
sortQuery() {
|
||||
return this.$route.query.sort;
|
||||
},
|
||||
sort() {
|
||||
const sortOptionValues = SORT_OPTIONS.flatMap(({ value }) => [
|
||||
const sortOptionValues = this.sortOptions.flatMap(({ value }) => [
|
||||
`${value}_${SORT_DIRECTION_ASC}`,
|
||||
`${value}_${SORT_DIRECTION_DESC}`,
|
||||
]);
|
||||
|
|
@ -146,10 +165,10 @@ export default {
|
|||
return this.initialSort;
|
||||
}
|
||||
|
||||
return `${SORT_OPTION_UPDATED.value}_${SORT_DIRECTION_ASC}`;
|
||||
return `${this.defaultSortOption.value}_${SORT_DIRECTION_ASC}`;
|
||||
},
|
||||
activeSortOption() {
|
||||
return SORT_OPTIONS.find((sortItem) => this.sort.includes(sortItem.value));
|
||||
return this.sortOptions.find((sortItem) => this.sort.includes(sortItem.value));
|
||||
},
|
||||
isAscending() {
|
||||
return this.sort.endsWith(SORT_DIRECTION_ASC);
|
||||
|
|
@ -173,7 +192,7 @@ export default {
|
|||
const filters = pick(this.routeQueryWithoutPagination, [
|
||||
FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
FILTERED_SEARCH_TERM_KEY,
|
||||
this.filteredSearchTermKey,
|
||||
]);
|
||||
|
||||
// Normalize the property to Number since Vue Router 4 will
|
||||
|
|
@ -302,15 +321,13 @@ export default {
|
|||
<li class="gl-w-full">
|
||||
<filtered-search-and-sort
|
||||
class="gl-border-b-0"
|
||||
:filtered-search-namespace="$options.filteredSearchAndSort.namespace"
|
||||
:filtered-search-namespace="filteredSearchNamespace"
|
||||
:filtered-search-tokens="filteredSearchTokens"
|
||||
:filtered-search-term-key="$options.filteredSearchAndSort.searchTermKey"
|
||||
:filtered-search-recent-searches-storage-key="
|
||||
$options.filteredSearchAndSort.recentSearchesStorageKey
|
||||
"
|
||||
:filtered-search-term-key="filteredSearchTermKey"
|
||||
:filtered-search-recent-searches-storage-key="filteredSearchRecentSearchesStorageKey"
|
||||
:filtered-search-query="$route.query"
|
||||
:is-ascending="isAscending"
|
||||
:sort-options="$options.filteredSearchAndSort.sortOptions"
|
||||
:sort-options="sortOptions"
|
||||
:active-sort-option="activeSortOption"
|
||||
@filter="onFilter"
|
||||
@sort-direction-change="onSortDirectionChange"
|
||||
|
|
|
|||
|
|
@ -7,3 +7,6 @@ export const SORT_LABEL_STARS = __('Stars');
|
|||
|
||||
export const FILTERED_SEARCH_TOKEN_LANGUAGE = 'language';
|
||||
export const FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL = 'min_access_level';
|
||||
|
||||
export const SORT_DIRECTION_ASC = 'asc';
|
||||
export const SORT_DIRECTION_DESC = 'desc';
|
||||
|
|
|
|||
|
|
@ -1,14 +1,5 @@
|
|||
import ScrollParent from 'scrollparent';
|
||||
import { contentTop } from './common_utils';
|
||||
|
||||
export function createResizeObserver() {
|
||||
return new ResizeObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
entry.target.dispatchEvent(new CustomEvent(`ResizeUpdate`, { detail: { entry } }));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches for change in size of a container element (e.g. for lazy-loaded images)
|
||||
* and scrolls the target note to the top of the content area.
|
||||
|
|
@ -17,7 +8,6 @@ export function createResizeObserver() {
|
|||
* @param {Object} options
|
||||
* @param {string} options.targetId - id of element to scroll to
|
||||
* @param {string} options.container - Selector of element containing target
|
||||
* @param {Element} options.component - Element containing target
|
||||
*
|
||||
* @return {Function} - Cleanup function to stop watching
|
||||
*/
|
||||
|
|
@ -27,67 +17,55 @@ export function scrollToTargetOnResize({
|
|||
} = {}) {
|
||||
if (!targetId) return null;
|
||||
|
||||
let scrollContainer;
|
||||
let scrollContainerIsDocument;
|
||||
|
||||
let targetElement = null;
|
||||
let targetTop = 0;
|
||||
let currentScrollPosition = 0;
|
||||
let userScrollOffset = 0;
|
||||
|
||||
// start listening to scroll after the first keepTargetAtTop call
|
||||
let scrollListenerEnabled = false;
|
||||
// can't tell difference between user and el.scrollTo, so use a flag
|
||||
let skipProgrammaticScrollEvent = false;
|
||||
|
||||
let intersectionObserver = null;
|
||||
let targetElement = null;
|
||||
let contentTopValue = contentTop();
|
||||
|
||||
const containerEl = document.querySelector(container);
|
||||
const ro = createResizeObserver();
|
||||
|
||||
let { scrollHeight } = document.scrollingElement;
|
||||
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
entries.forEach(() => {
|
||||
scrollHeight = document.scrollingElement.scrollHeight;
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
keepTargetAtTop();
|
||||
});
|
||||
});
|
||||
|
||||
function handleScroll() {
|
||||
if (skipProgrammaticScrollEvent) {
|
||||
contentTopValue = contentTop();
|
||||
skipProgrammaticScrollEvent = false;
|
||||
const diff = document.scrollingElement.scrollHeight - scrollHeight;
|
||||
if (Math.abs(diff) > 100) {
|
||||
return;
|
||||
}
|
||||
currentScrollPosition = scrollContainerIsDocument ? window.scrollY : scrollContainer.scrollTop;
|
||||
userScrollOffset = currentScrollPosition - targetTop - contentTopValue;
|
||||
|
||||
targetTop = targetElement.getBoundingClientRect().top;
|
||||
userScrollOffset = targetTop - contentTop();
|
||||
}
|
||||
|
||||
function addScrollListener() {
|
||||
if (scrollContainerIsDocument) {
|
||||
// For document scrolling, we need to listen to window
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
} else {
|
||||
scrollContainer.addEventListener('scroll', handleScroll, { passive: true });
|
||||
}
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
}
|
||||
|
||||
function removeScrollListener() {
|
||||
if (scrollContainerIsDocument) {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
} else {
|
||||
scrollContainer?.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
|
||||
function setupIntersectionObserver() {
|
||||
intersectionObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries;
|
||||
intersectionObserver = new IntersectionObserver((entries) => {
|
||||
const [entry] = entries;
|
||||
|
||||
// if element gets scrolled off screen then remove listeners
|
||||
if (!entry.isIntersecting) {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
cleanup();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollContainerIsDocument ? null : scrollContainer,
|
||||
},
|
||||
);
|
||||
// if element gets scrolled off screen then remove listeners
|
||||
if (!entry.isIntersecting) {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
intersectionObserver.observe(targetElement);
|
||||
}
|
||||
|
|
@ -95,32 +73,19 @@ export function scrollToTargetOnResize({
|
|||
function keepTargetAtTop() {
|
||||
if (document.activeElement !== document.body) return;
|
||||
|
||||
const anchorEl = document.getElementById(targetId);
|
||||
if (!anchorEl) {
|
||||
return;
|
||||
}
|
||||
targetElement = document.getElementById(targetId);
|
||||
|
||||
scrollContainer = ScrollParent(document.getElementById(targetId)) || document.documentElement;
|
||||
scrollContainerIsDocument = scrollContainer === document.documentElement;
|
||||
if (!targetElement) return;
|
||||
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
skipProgrammaticScrollEvent = true;
|
||||
|
||||
const anchorTop = anchorEl.getBoundingClientRect().top;
|
||||
currentScrollPosition = scrollContainerIsDocument ? window.scrollY : scrollContainer.scrollTop;
|
||||
const anchorTop = targetElement.getBoundingClientRect().top;
|
||||
currentScrollPosition = document.scrollingElement.scrollTop;
|
||||
|
||||
// Add scrollPosition as getBoundingClientRect is relative to viewport
|
||||
// Add the accumulated scroll offset to maintain relative position
|
||||
// subtract contentTop so it goes below sticky headers, rather than top of viewport
|
||||
targetTop = anchorTop - contentTopValue + currentScrollPosition + userScrollOffset;
|
||||
targetTop = anchorTop + currentScrollPosition - userScrollOffset - contentTop();
|
||||
|
||||
scrollContainer.scrollTo({
|
||||
top: targetTop,
|
||||
behavior: 'instant',
|
||||
});
|
||||
document.scrollingElement.scrollTo({ top: targetTop, behavior: 'instant' });
|
||||
|
||||
if (!scrollListenerEnabled) {
|
||||
addScrollListener();
|
||||
|
|
@ -128,23 +93,22 @@ export function scrollToTargetOnResize({
|
|||
}
|
||||
|
||||
if (!intersectionObserver) {
|
||||
targetElement = anchorEl;
|
||||
setupIntersectionObserver();
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
ro.unobserve(containerEl);
|
||||
containerEl.removeEventListener('ResizeUpdate', keepTargetAtTop);
|
||||
removeScrollListener();
|
||||
setTimeout(() => {
|
||||
ro.unobserve(containerEl);
|
||||
removeScrollListener();
|
||||
|
||||
if (intersectionObserver) {
|
||||
intersectionObserver.unobserve(targetElement);
|
||||
intersectionObserver.disconnect();
|
||||
}
|
||||
if (intersectionObserver) {
|
||||
intersectionObserver.unobserve(targetElement);
|
||||
intersectionObserver.disconnect();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
containerEl.addEventListener('ResizeUpdate', keepTargetAtTop);
|
||||
ro.observe(containerEl);
|
||||
|
||||
return cleanup;
|
||||
|
|
|
|||
|
|
@ -7,10 +7,9 @@ import { queryToObject, objectToQuery, visitUrl } from '~/lib/utils/url_utility'
|
|||
import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
|
||||
import { ACCESS_LEVEL_OWNER_INTEGER } from '~/access_level/constants';
|
||||
import { InternalEvents } from '~/tracking';
|
||||
import { SORT_DIRECTION_ASC, SORT_DIRECTION_DESC } from '~/groups_projects/constants';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
SORT_DIRECTION_ASC,
|
||||
SORT_DIRECTION_DESC,
|
||||
SORT_OPTION_UPDATED,
|
||||
FILTERED_SEARCH_TERM_KEY,
|
||||
FILTERED_SEARCH_NAMESPACE,
|
||||
|
|
|
|||
|
|
@ -25,9 +25,6 @@ export const SORT_OPTION_STARS = {
|
|||
text: SORT_LABEL_STARS,
|
||||
};
|
||||
|
||||
export const SORT_DIRECTION_ASC = 'asc';
|
||||
export const SORT_DIRECTION_DESC = 'desc';
|
||||
|
||||
export const FILTERED_SEARCH_TERM_KEY = 'name';
|
||||
export const FILTERED_SEARCH_NAMESPACE = 'explore';
|
||||
export const SORT_OPTIONS = [
|
||||
|
|
|
|||
|
|
@ -1,9 +1,29 @@
|
|||
<script>
|
||||
import TabsWithList from '~/groups_projects/components/tabs_with_list.vue';
|
||||
import {
|
||||
FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
} from '~/groups_projects/constants';
|
||||
import { RECENT_SEARCHES_STORAGE_KEY_PROJECTS } from '~/filtered_search/recent_searches_storage_keys';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
SORT_OPTION_UPDATED,
|
||||
FILTERED_SEARCH_TERM_KEY,
|
||||
FILTERED_SEARCH_NAMESPACE,
|
||||
} from '~/projects/filtered_search_and_sort/constants';
|
||||
import { PROJECT_DASHBOARD_TABS } from '../constants';
|
||||
|
||||
export default {
|
||||
PROJECT_DASHBOARD_TABS,
|
||||
SORT_OPTIONS,
|
||||
SORT_OPTION_UPDATED,
|
||||
RECENT_SEARCHES_STORAGE_KEY_PROJECTS,
|
||||
FILTERED_SEARCH_TERM_KEY,
|
||||
FILTERED_SEARCH_NAMESPACE,
|
||||
filteredSearchSupportedTokens: [
|
||||
FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
],
|
||||
name: 'YourWorkProjectsApp',
|
||||
components: {
|
||||
TabsWithList,
|
||||
|
|
@ -12,5 +32,13 @@ export default {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<tabs-with-list :tabs="$options.PROJECT_DASHBOARD_TABS" />
|
||||
<tabs-with-list
|
||||
:tabs="$options.PROJECT_DASHBOARD_TABS"
|
||||
:filtered-search-supported-tokens="$options.filteredSearchSupportedTokens"
|
||||
:filtered-search-term-key="$options.FILTERED_SEARCH_TERM_KEY"
|
||||
:filtered-search-namespace="$options.FILTERED_SEARCH_NAMESPACE"
|
||||
:filtered-search-recent-searches-storage-key="$options.RECENT_SEARCHES_STORAGE_KEY_PROJECTS"
|
||||
:sort-options="$options.SORT_OPTIONS"
|
||||
:default-sort-option="$options.SORT_OPTION_UPDATED"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
// eslint-disable-next-line vue/no-unused-properties -- show() is part of the component's public API.
|
||||
show() {
|
||||
this.isModalVisible = true;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,22 +1,15 @@
|
|||
<script>
|
||||
import { GlLink, GlIcon, GlFormCheckbox } from '@gitlab/ui';
|
||||
import { s__, sprintf } from '~/locale';
|
||||
import dateFormat from '~/lib/dateformat';
|
||||
import { formatDate, getDayDifference, fallsBefore } from '~/lib/utils/datetime_utility';
|
||||
import { localeDateFormat } from '~/lib/utils/datetime/locale_dateformat';
|
||||
import timeagoMixin from '~/vue_shared/mixins/timeago';
|
||||
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import { fallsBefore } from '~/lib/utils/datetime_utility';
|
||||
import { INSTRUMENT_TODO_ITEM_FOLLOW, TODO_STATE_DONE } from '../constants';
|
||||
import TodoItemTitle from './todo_item_title.vue';
|
||||
import TodoItemBody from './todo_item_body.vue';
|
||||
import TodoItemTimestamp from './todo_item_timestamp.vue';
|
||||
import TodoSnoozedTimestamp from './todo_snoozed_timestamp.vue';
|
||||
import TodoItemActions from './todo_item_actions.vue';
|
||||
import TodoItemTitleHiddenBySaml from './todo_item_title_hidden_by_saml.vue';
|
||||
|
||||
const ONE_WEEK = 6;
|
||||
const TODAY = 0;
|
||||
const TOMORROW = 1;
|
||||
|
||||
export default {
|
||||
TRACK_ACTION: INSTRUMENT_TODO_ITEM_FOLLOW,
|
||||
components: {
|
||||
|
|
@ -26,10 +19,11 @@ export default {
|
|||
TodoItemTitle,
|
||||
TodoItemBody,
|
||||
TodoItemTimestamp,
|
||||
TodoSnoozedTimestamp,
|
||||
TodoItemActions,
|
||||
TodoItemTitleHiddenBySaml,
|
||||
},
|
||||
mixins: [timeagoMixin, glFeatureFlagMixin()],
|
||||
mixins: [glFeatureFlagMixin()],
|
||||
inject: ['currentTab'],
|
||||
props: {
|
||||
currentUserId: {
|
||||
|
|
@ -57,56 +51,19 @@ export default {
|
|||
return this.todo.state === TODO_STATE_DONE;
|
||||
},
|
||||
isSnoozed() {
|
||||
if (this.todo.snoozedUntil == null) {
|
||||
if (this.todo.snoozedUntil === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const snoozedUntil = new Date(this.todo.snoozedUntil);
|
||||
return !fallsBefore(snoozedUntil, new Date());
|
||||
},
|
||||
hasReachedSnoozeTimestamp() {
|
||||
return this.todo.snoozedUntil != null && !this.isSnoozed;
|
||||
},
|
||||
targetUrl() {
|
||||
return this.todo.targetUrl;
|
||||
},
|
||||
trackingLabel() {
|
||||
return this.todo.targetType ?? 'UNKNOWN';
|
||||
},
|
||||
formattedCreatedAt() {
|
||||
return sprintf(s__('Todos|First sent %{timeago}'), {
|
||||
timeago: this.timeFormatted(this.todo.createdAt),
|
||||
});
|
||||
},
|
||||
formattedSnoozedUntil() {
|
||||
if (!this.todo.snoozedUntil) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const snoozedUntil = new Date(this.todo.snoozedUntil);
|
||||
const difference = getDayDifference(new Date(), snoozedUntil);
|
||||
|
||||
if (difference > ONE_WEEK) {
|
||||
return sprintf(s__('Todos|Snoozed until %{date}'), {
|
||||
date: formatDate(this.todo.snoozedUntil, 'mmm dd, yyyy'),
|
||||
});
|
||||
}
|
||||
|
||||
const time = localeDateFormat.asTime.format(snoozedUntil);
|
||||
|
||||
if (difference === TODAY) {
|
||||
return sprintf(s__('Todos|Snoozed until %{time}'), { time });
|
||||
}
|
||||
|
||||
if (difference === TOMORROW) {
|
||||
return sprintf(s__('Todos|Snoozed until tomorrow, %{time}'), { time });
|
||||
}
|
||||
|
||||
return sprintf(s__('Todos|Snoozed until %{day}, %{time}'), {
|
||||
day: dateFormat(snoozedUntil, 'DDDD'),
|
||||
time,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -146,21 +103,14 @@ export default {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="isSnoozed"
|
||||
class="gl-w-full gl-text-nowrap gl-px-2 gl-text-sm gl-text-subtle sm:gl-w-auto"
|
||||
>
|
||||
{{ formattedSnoozedUntil }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="hasReachedSnoozeTimestamp"
|
||||
class="gl-w-full gl-text-nowrap gl-px-2 gl-text-sm gl-text-subtle sm:gl-w-auto"
|
||||
>
|
||||
<gl-icon name="clock" class="gl-mr-2" />
|
||||
{{ formattedCreatedAt }}
|
||||
</span>
|
||||
<todo-snoozed-timestamp
|
||||
v-if="todo.snoozedUntil"
|
||||
class="gl-mr-2"
|
||||
:snoozed-until="todo.snoozedUntil"
|
||||
:has-reached-snooze-timestamp="!isSnoozed"
|
||||
/>
|
||||
|
||||
<todo-item-timestamp
|
||||
v-else
|
||||
:todo="todo"
|
||||
class="gl-w-full gl-whitespace-nowrap gl-px-2 sm:gl-w-auto"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -29,9 +29,6 @@ export default {
|
|||
},
|
||||
},
|
||||
computed: {
|
||||
showToggleSnoozed() {
|
||||
return (!this.isSnoozed && this.isPending) || this.isSnoozed;
|
||||
},
|
||||
isDone() {
|
||||
return this.todo.state === TODO_STATE_DONE;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,11 +12,6 @@ export default {
|
|||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
dueDate() {
|
||||
if (!this.todo?.targetEntity?.dueDate) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
<script>
|
||||
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
|
||||
import { s__, sprintf } from '~/locale';
|
||||
import dateFormat from '~/lib/dateformat';
|
||||
import timeagoMixin from '~/vue_shared/mixins/timeago';
|
||||
import { getDayDifference } from '~/lib/utils/datetime_utility';
|
||||
import { localeDateFormat } from '~/lib/utils/datetime/locale_dateformat';
|
||||
|
||||
const ONE_WEEK = 6;
|
||||
const TODAY = 0;
|
||||
const TOMORROW = 1;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlIcon,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
mixins: [timeagoMixin],
|
||||
props: {
|
||||
snoozedUntil: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
hasReachedSnoozeTimestamp: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
formattedSnoozedUntil() {
|
||||
const snoozedUntil = new Date(this.snoozedUntil);
|
||||
const difference = getDayDifference(new Date(), snoozedUntil);
|
||||
|
||||
if (difference > ONE_WEEK) {
|
||||
return sprintf(s__('Todos|Snoozed until %{date}'), {
|
||||
date: localeDateFormat.asDate.format(snoozedUntil),
|
||||
});
|
||||
}
|
||||
|
||||
const time = localeDateFormat.asTime.format(snoozedUntil);
|
||||
|
||||
if (difference === TODAY) {
|
||||
return sprintf(s__('Todos|Snoozed until %{time}'), { time });
|
||||
}
|
||||
|
||||
if (difference === TOMORROW) {
|
||||
return sprintf(s__('Todos|Snoozed until tomorrow, %{time}'), { time });
|
||||
}
|
||||
|
||||
return sprintf(s__('Todos|Snoozed until %{day}, %{time}'), {
|
||||
day: dateFormat(snoozedUntil, 'DDDD'),
|
||||
time,
|
||||
});
|
||||
},
|
||||
tooltipText() {
|
||||
if (this.hasReachedSnoozeTimestamp) {
|
||||
return s__('Todos|Previously snoozed');
|
||||
}
|
||||
return this.formattedSnoozedUntil;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<gl-icon v-gl-tooltip name="clock" :title="tooltipText" :aria-label="tooltipText" />
|
||||
</template>
|
||||
|
|
@ -73,7 +73,6 @@ export default {
|
|||
pageInfo: {},
|
||||
todos: [],
|
||||
currentTab: TABS_INDICES.pending,
|
||||
refreshPendingCount: null,
|
||||
queryFilterValues: {
|
||||
groupId: [],
|
||||
projectId: [],
|
||||
|
|
@ -117,6 +116,7 @@ export default {
|
|||
this.needsRefresh = false;
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @gitlab/vue-no-undef-apollo-properties -- The query is not tied to an internal state property
|
||||
refreshPendingCount: {
|
||||
query: getPendingTodosCount,
|
||||
variables() {
|
||||
|
|
@ -148,9 +148,6 @@ export default {
|
|||
const { sort: _, ...filters } = this.queryFilterValues;
|
||||
return Object.values(filters).some((value) => value.length > 0);
|
||||
},
|
||||
isOnDoneTab() {
|
||||
return this.currentTab === TABS_INDICES.done;
|
||||
},
|
||||
isOnSnoozedTab() {
|
||||
return this.currentTab === TABS_INDICES.snoozed;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { GlAvatar, GlCollapsibleListbox } from '@gitlab/ui';
|
|||
import defaultAvatarUrl from 'images/no_avatar.png';
|
||||
import { TYPENAME_DESIGN_VERSION } from '~/graphql_shared/constants';
|
||||
import { convertToGraphQLId } from '~/graphql_shared/utils';
|
||||
import { queryToObject } from '~/lib/utils/url_utility';
|
||||
import { __, sprintf } from '~/locale';
|
||||
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import { findVersionId } from './utils';
|
||||
|
|
@ -82,9 +83,13 @@ export default {
|
|||
methods: {
|
||||
findVersionId,
|
||||
routeToVersion(versionId) {
|
||||
const { show } = queryToObject(window.location.search);
|
||||
this.$router.push({
|
||||
path: this.$route.path,
|
||||
query: { version: this.findVersionId(versionId) },
|
||||
query: {
|
||||
version: this.findVersionId(versionId),
|
||||
...(show && { show }),
|
||||
},
|
||||
});
|
||||
},
|
||||
versionText(item) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ import {
|
|||
import * as Sentry from '~/sentry/sentry_browser_wrapper';
|
||||
|
||||
import { __, s__ } from '~/locale';
|
||||
import { getModifierKey } from '~/constants';
|
||||
import Tracking from '~/tracking';
|
||||
import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle';
|
||||
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import toast from '~/vue_shared/plugins/global_toast';
|
||||
import { isLoggedIn } from '~/lib/utils/common_utils';
|
||||
|
|
@ -375,6 +377,10 @@ export default {
|
|||
toggleSidebarLabel() {
|
||||
return this.showSidebar ? s__('WorkItem|Hide sidebar') : s__('WorkItem|Show sidebar');
|
||||
},
|
||||
toggleSidebarKeys() {
|
||||
const modifierKey = getModifierKey();
|
||||
return shouldDisableShortcuts() ? null : `${modifierKey}/`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
copyToClipboard(text, message) {
|
||||
|
|
@ -688,10 +694,15 @@ export default {
|
|||
</gl-disclosure-dropdown-item>
|
||||
<gl-disclosure-dropdown-item
|
||||
data-testid="sidebar-toggle-action"
|
||||
class="work-item-container-xs-hidden gl-hidden md:gl-block"
|
||||
class="work-item-container-xs-hidden js-sidebar-toggle-action gl-hidden md:gl-block"
|
||||
@action="$emit('toggleSidebar')"
|
||||
>
|
||||
<template #list-item>{{ toggleSidebarLabel }}</template>
|
||||
<template #list-item>
|
||||
<div class="gl-flex gl-items-center gl-justify-between">
|
||||
<span>{{ toggleSidebarLabel }}</span>
|
||||
<kbd v-if="toggleSidebarKeys" class="flat">{{ toggleSidebarKeys }}</kbd>
|
||||
</div>
|
||||
</template>
|
||||
</gl-disclosure-dropdown-item>
|
||||
</gl-disclosure-dropdown-group>
|
||||
</gl-disclosure-dropdown>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import noAccessSvg from '@gitlab/svgs/dist/illustrations/empty-state/empty-searc
|
|||
import DesignDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
|
||||
import * as Sentry from '~/sentry/sentry_browser_wrapper';
|
||||
import { s__, __ } from '~/locale';
|
||||
import { InternalEvents } from '~/tracking';
|
||||
import { getParameterByName, updateHistory, removeParams } from '~/lib/utils/url_utility';
|
||||
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
|
||||
|
|
@ -95,6 +96,8 @@ const defaultWorkspacePermissions = {
|
|||
moveDesign: false,
|
||||
};
|
||||
|
||||
const trackingMixin = InternalEvents.mixin();
|
||||
|
||||
export default {
|
||||
name: 'WorkItemDetail',
|
||||
i18n,
|
||||
|
|
@ -134,7 +137,7 @@ export default {
|
|||
WorkItemDevelopment,
|
||||
WorkItemCreateBranchMergeRequestSplitButton,
|
||||
},
|
||||
mixins: [glFeatureFlagMixin()],
|
||||
mixins: [glFeatureFlagMixin(), trackingMixin],
|
||||
inject: [
|
||||
'fullPath',
|
||||
'reportAbusePath',
|
||||
|
|
@ -840,9 +843,15 @@ export default {
|
|||
},
|
||||
handleToggleSidebar() {
|
||||
this.showSidebar = !this.showSidebar;
|
||||
this.trackEvent('change_work_item_sidebar_visibility', {
|
||||
label: this.showSidebar.toString(), // New sidebar visibility
|
||||
});
|
||||
},
|
||||
handleTruncationEnabled() {
|
||||
this.truncationEnabled = !this.truncationEnabled;
|
||||
this.trackEvent('change_work_item_description_truncation', {
|
||||
label: this.truncationEnabled.toString(), // New user truncation setting
|
||||
});
|
||||
},
|
||||
},
|
||||
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
|||
import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
|
||||
import {
|
||||
DETAIL_VIEW_QUERY_PARAM_NAME,
|
||||
DETAIL_VIEW_DESIGN_VERSION_PARAM_NAME,
|
||||
INJECTION_LINK_CHILD_PREVENT_ROUTER_NAVIGATION,
|
||||
} from '~/work_items/constants';
|
||||
import * as Sentry from '~/sentry/sentry_browser_wrapper';
|
||||
|
|
@ -170,7 +171,9 @@ export default {
|
|||
});
|
||||
},
|
||||
handleClose(isClickedOutside) {
|
||||
updateHistory({ url: removeParams([DETAIL_VIEW_QUERY_PARAM_NAME]) });
|
||||
updateHistory({
|
||||
url: removeParams([DETAIL_VIEW_QUERY_PARAM_NAME, DETAIL_VIEW_DESIGN_VERSION_PARAM_NAME]),
|
||||
});
|
||||
|
||||
if (!isClickedOutside) {
|
||||
document
|
||||
|
|
|
|||
|
|
@ -268,6 +268,7 @@ export const NEW_EPIC_FEEDBACK_PROMPT_EXPIRY = '2024-12-31';
|
|||
export const FEATURE_NAME = 'work_item_epic_feedback';
|
||||
|
||||
export const DETAIL_VIEW_QUERY_PARAM_NAME = 'show';
|
||||
export const DETAIL_VIEW_DESIGN_VERSION_PARAM_NAME = 'version';
|
||||
export const ROUTES = {
|
||||
index: 'workItemList',
|
||||
workItem: 'workItem',
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class PersonalAccessToken < ApplicationRecord
|
|||
# this scope must use a string condition, otherwise Postgres will not use the correct indices
|
||||
scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND seven_days_notification_sent_at IS NULL AND expires_at >= CURRENT_DATE AND expires_at <= ?", date]) }
|
||||
scope :expired_today_and_not_notified, -> { where(["revoked = false AND expires_at = CURRENT_DATE AND after_expiry_notification_delivered = false"]) }
|
||||
scope :expired_before, ->(date) { expired.where(arel_table[:expires_at].lt(date)) }
|
||||
scope :expired_after, ->(date) { expired.where(arel_table[:expires_at].gteq(date)) }
|
||||
scope :expires_before, ->(date) { where(arel_table[:expires_at].lt(date)) }
|
||||
scope :expires_after, ->(date) { where(arel_table[:expires_at].gteq(date)) }
|
||||
scope :inactive, -> { where("revoked = true OR expires_at < CURRENT_DATE") }
|
||||
|
|
@ -58,7 +58,7 @@ class PersonalAccessToken < ApplicationRecord
|
|||
scope :with_impersonation, -> { where(impersonation: true) }
|
||||
scope :without_impersonation, -> { where(impersonation: false) }
|
||||
scope :revoked, -> { where(revoked: true) }
|
||||
scope :revoked_before, ->(date) { revoked.where(arel_table[:updated_at].lt(date)) }
|
||||
scope :revoked_after, ->(date) { revoked.where(arel_table[:updated_at].gteq(date)) }
|
||||
scope :not_revoked, -> { where(revoked: [false, nil]) }
|
||||
scope :for_user, ->(user) { where(user: user) }
|
||||
scope :for_users, ->(users) { where(user: users) }
|
||||
|
|
|
|||
|
|
@ -29,8 +29,7 @@ module ResourceAccessTokens
|
|||
.and(
|
||||
PersonalAccessToken.active
|
||||
.or(
|
||||
PersonalAccessToken.expired_before(cut_off).or(PersonalAccessToken.revoked_before(cut_off))
|
||||
.invert_where
|
||||
PersonalAccessToken.expired_after(cut_off).or(PersonalAccessToken.revoked_after(cut_off))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
---
|
||||
api_type:
|
||||
attr: security_policy_scheduled_scans_max_concurrency
|
||||
clusterwide: false
|
||||
column: security_policy_scheduled_scans_max_concurrency
|
||||
db_type: integer
|
||||
default: '10000'
|
||||
description:
|
||||
encrypted: false
|
||||
gitlab_com_different_than_default: false
|
||||
jihu: false
|
||||
not_null: true
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
description: Description truncation enabled or disabled
|
||||
internal_events: true
|
||||
action: change_work_item_description_truncation
|
||||
identifiers:
|
||||
- user
|
||||
additional_properties:
|
||||
label:
|
||||
description: New user truncation setting
|
||||
product_group: project_management
|
||||
product_categories:
|
||||
- team_planning
|
||||
milestone: '17.11'
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/184858
|
||||
tiers:
|
||||
- free
|
||||
- premium
|
||||
- ultimate
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
description: Work item sidebar shown or hidden
|
||||
internal_events: true
|
||||
action: change_work_item_sidebar_visibility
|
||||
identifiers:
|
||||
- user
|
||||
additional_properties:
|
||||
label:
|
||||
description: New sidebar visibility
|
||||
product_group: project_management
|
||||
product_categories:
|
||||
- team_planning
|
||||
milestone: '17.11'
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/184858
|
||||
tiers:
|
||||
- free
|
||||
- premium
|
||||
- ultimate
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
key_path: redis_hll_counters.count_distinct_user_id_from_change_work_item_description_truncation
|
||||
description: Count of unique users that changed the description truncation setting for work items
|
||||
product_group: project_management
|
||||
product_categories:
|
||||
- team_planning
|
||||
performance_indicator_type: []
|
||||
value_type: number
|
||||
status: active
|
||||
milestone: '17.11'
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/184858
|
||||
time_frame:
|
||||
- 28d
|
||||
- 7d
|
||||
data_source: internal_events
|
||||
data_category: optional
|
||||
tiers:
|
||||
- free
|
||||
- premium
|
||||
- ultimate
|
||||
events:
|
||||
- name: change_work_item_description_truncation
|
||||
unique: user.id
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
key_path: redis_hll_counters.count_distinct_user_id_from_change_work_item_sidebar_visibility
|
||||
description: Count of unique users that change work item sidebar visibility
|
||||
product_group: project_management
|
||||
product_categories:
|
||||
- team_planning
|
||||
performance_indicator_type: []
|
||||
value_type: number
|
||||
status: active
|
||||
milestone: '17.11'
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/184858
|
||||
time_frame:
|
||||
- 28d
|
||||
- 7d
|
||||
data_source: internal_events
|
||||
data_category: optional
|
||||
tiers:
|
||||
- free
|
||||
- premium
|
||||
- ultimate
|
||||
events:
|
||||
- name: change_work_item_sidebar_visibility
|
||||
unique: user.id
|
||||
|
|
@ -247,6 +247,8 @@
|
|||
- 1
|
||||
- - compliance_management_chain_of_custody_report
|
||||
- 1
|
||||
- - compliance_management_compliance_framework_project_requirement_statuses_export_mailer
|
||||
- 1
|
||||
- - compliance_management_framework_export_mailer
|
||||
- 1
|
||||
- - compliance_management_merge_requests_compliance_violations
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class RemoveSecurityPolicyScheduledScansMaxConcurrencyFromApplicationSettings < Gitlab::Database::Migration[2.2]
|
||||
milestone '17.11'
|
||||
|
||||
def up
|
||||
remove_column :application_settings, :security_policy_scheduled_scans_max_concurrency
|
||||
end
|
||||
|
||||
def down
|
||||
add_column :application_settings,
|
||||
:security_policy_scheduled_scans_max_concurrency,
|
||||
:integer,
|
||||
default: 10000,
|
||||
null: false,
|
||||
if_not_exists: true
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1 @@
|
|||
66fde3826dad9bba274337e4469270d4f5723c246cb8460c4ffdb7ade72cf028
|
||||
|
|
@ -8687,7 +8687,6 @@ CREATE TABLE application_settings (
|
|||
package_registry jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
rate_limits_unauthenticated_git_http jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
importers jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
security_policy_scheduled_scans_max_concurrency integer DEFAULT 10000 NOT NULL,
|
||||
code_creation jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
code_suggestions_api_rate_limit integer DEFAULT 60 NOT NULL,
|
||||
ai_action_api_rate_limit integer DEFAULT 160 NOT NULL,
|
||||
|
|
|
|||
|
|
@ -635,17 +635,17 @@ This secret can be generated with `openssl rand -base64 24` to generate a random
|
|||
For example, to change the Gitaly listening interface to `0.0.0.0:8075`:
|
||||
|
||||
```ruby
|
||||
# in /etc/gitlab/gitlab.rb
|
||||
gitlab_rails['gitaly_token'] = 'enter-secret-token-here'
|
||||
|
||||
gitlab_rails['repositories_storages'] = {
|
||||
'default' => { 'gitaly_address' => 'tcp://gitlab.example.com:8075' },
|
||||
}
|
||||
# /etc/gitlab/gitlab.rb
|
||||
# Add a shared token for Gitaly authentication
|
||||
gitlab_shell['secret_token'] = 'your_secure_token_here'
|
||||
gitlab_rails['gitaly_token'] = 'your_secure_token_here'
|
||||
|
||||
# Gitaly configuration
|
||||
gitaly['gitlab_secret'] = 'your_secure_token_here'
|
||||
gitaly['configuration'] = {
|
||||
listen_addr: '0.0.0.0:8075',
|
||||
auth: {
|
||||
token: 'enter-secret-token-here',
|
||||
token: 'your_secure_token_here',
|
||||
},
|
||||
storage: [
|
||||
{
|
||||
|
|
@ -654,6 +654,14 @@ gitaly['configuration'] = {
|
|||
},
|
||||
]
|
||||
}
|
||||
|
||||
# Tell Rails where to find Gitaly
|
||||
gitlab_rails['repositories_storages'] = {
|
||||
'default' => { 'gitaly_address' => 'tcp://ip_address_here:8075' },
|
||||
}
|
||||
|
||||
# Internal API URL (important for multi-server setups)
|
||||
gitlab_rails['internal_api_url'] = 'http://ip_address_here'
|
||||
```
|
||||
|
||||
## Control groups
|
||||
|
|
|
|||
|
|
@ -26879,7 +26879,6 @@ GPG signature for a signed commit.
|
|||
| <a id="grouppendingmembers"></a>`pendingMembers` {{< icon name="warning-solid" >}} | [`PendingMemberInterfaceConnection`](#pendingmemberinterfaceconnection) | **Introduced** in GitLab 16.6. **Status**: Experiment. A pending membership of a user within this group. |
|
||||
| <a id="grouppermanentdeletiondate"></a>`permanentDeletionDate` {{< icon name="warning-solid" >}} | [`String`](#string) | **Introduced** in GitLab 16.11. **Status**: Experiment. Date when group will be deleted if delayed group deletion is enabled. |
|
||||
| <a id="groupproductanalyticsstoredeventslimit"></a>`productAnalyticsStoredEventsLimit` {{< icon name="warning-solid" >}} | [`Int`](#int) | **Introduced** in GitLab 16.9. **Status**: Experiment. Number of product analytics events namespace is permitted to store per cycle. |
|
||||
| <a id="groupprojectcompliancerequirementsstatus"></a>`projectComplianceRequirementsStatus` {{< icon name="warning-solid" >}} | [`ProjectComplianceRequirementStatusConnection`](#projectcompliancerequirementstatusconnection) | **Introduced** in GitLab 17.10. **Status**: Experiment. Compliance standards adherence for the projects in a group and its subgroups. |
|
||||
| <a id="groupprojectcreationlevel"></a>`projectCreationLevel` | [`String`](#string) | Permission level required to create projects in the group. |
|
||||
| <a id="groupprojectscount"></a>`projectsCount` | [`Int!`](#int) | Count of direct projects in the group. |
|
||||
| <a id="grouprecentissueboards"></a>`recentIssueBoards` | [`BoardConnection`](#boardconnection) | List of recently visited boards of the group. Maximum size is 4. (see [Connections](#connections)) |
|
||||
|
|
@ -27949,6 +27948,27 @@ four standard [pagination arguments](#pagination-arguments):
|
|||
| <a id="grouppipelineexecutionpoliciesincludeunscoped"></a>`includeUnscoped` | [`Boolean`](#boolean) | Filter policies that are scoped to the project. |
|
||||
| <a id="grouppipelineexecutionpoliciesrelationship"></a>`relationship` | [`SecurityPolicyRelationType`](#securitypolicyrelationtype) | Filter policies by the given policy relationship. |
|
||||
|
||||
##### `Group.projectComplianceRequirementsStatus`
|
||||
|
||||
Compliance standards adherence for the projects in a group and its subgroups.
|
||||
|
||||
{{< details >}}
|
||||
**Introduced** in GitLab 17.10.
|
||||
**Status**: Experiment.
|
||||
{{< /details >}}
|
||||
|
||||
Returns [`ProjectComplianceRequirementStatusConnection`](#projectcompliancerequirementstatusconnection).
|
||||
|
||||
This field returns a [connection](#connections). It accepts the
|
||||
four standard [pagination arguments](#pagination-arguments):
|
||||
`before: String`, `after: String`, `first: Int`, and `last: Int`.
|
||||
|
||||
###### Arguments
|
||||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| <a id="groupprojectcompliancerequirementsstatusfilters"></a>`filters` | [`GroupProjectRequirementComplianceStatusInput`](#groupprojectrequirementcompliancestatusinput) | Filters applied when retrieving compliance requirement statuses. |
|
||||
|
||||
##### `Group.projectComplianceStandardsAdherence`
|
||||
|
||||
Compliance standards adherence for the projects in a group and its subgroups.
|
||||
|
|
@ -47826,6 +47846,16 @@ Labels for the Node Pool of a GKE cluster.
|
|||
| <a id="googlecloudnodepoollabelkey"></a>`key` | [`String!`](#string) | Key of the label. |
|
||||
| <a id="googlecloudnodepoollabelvalue"></a>`value` | [`String!`](#string) | Value of the label. |
|
||||
|
||||
### `GroupProjectRequirementComplianceStatusInput`
|
||||
|
||||
#### Arguments
|
||||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| <a id="groupprojectrequirementcompliancestatusinputframeworkid"></a>`frameworkId` | [`ComplianceManagementFrameworkID`](#compliancemanagementframeworkid) | Filter compliance requirement statuses by compliance framework. |
|
||||
| <a id="groupprojectrequirementcompliancestatusinputprojectid"></a>`projectId` | [`ProjectID`](#projectid) | Filter compliance requirement statuses by project. |
|
||||
| <a id="groupprojectrequirementcompliancestatusinputrequirementid"></a>`requirementId` | [`ComplianceManagementComplianceFrameworkComplianceRequirementID`](#compliancemanagementcomplianceframeworkcompliancerequirementid) | Filter compliance requirement statuses by compliance requirement. |
|
||||
|
||||
### `JiraUsersMappingInputType`
|
||||
|
||||
#### Arguments
|
||||
|
|
|
|||
|
|
@ -400,7 +400,6 @@ title: Application Settings analysis
|
|||
| `security_approval_policies_limit` | `false` | `integer` | `integer` | `true` | `5` | `false` | `false`| `true` |
|
||||
| `security_policies` | `false` | `jsonb` | `` | `true` | `'{}'::jsonb` | `true` | `false`| `false` |
|
||||
| `security_policy_global_group_approvers_enabled` | `false` | `boolean` | `boolean` | `true` | `true` | `true` | `false`| `true` |
|
||||
| `security_policy_scheduled_scans_max_concurrency` | `false` | `integer` | `` | `true` | `10000` | `false` | `false`| `false` |
|
||||
| `security_txt_content` | `false` | `text` | `string` | `false` | `null` | `true` | `true`| `true` |
|
||||
| `sentry_clientside_dsn` | `false` | `text` | `` | `false` | `null` | `true` | `false`| `false` |
|
||||
| `sentry_clientside_traces_sample_rate` | `false` | `double` | `` | `true` | `0.0` | `true` | `false`| `false` |
|
||||
|
|
|
|||
|
|
@ -16,4 +16,5 @@ This page is a work in progress. If you have access to the GitLab Slack workspac
|
|||
- [Metric definition guide](metric_definition_guide.md)
|
||||
- [Local setup and debugging](local_setup_and_debugging.md)
|
||||
- [Internal Events CLI contribution guide](../cli_contribution_guidelines.md)
|
||||
- [Internal Events Payload Samples](internal_events_payload.md)
|
||||
- [Standard context fields description](standard_context_fields.md)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,452 @@
|
|||
---
|
||||
stage: Monitor
|
||||
group: Analytics Instrumentation
|
||||
info: Any user with at least the Maintainer role can merge updates to this content. For details, see https://docs.gitlab.com/ee/development/development_processes.html#development-guidelines-review.
|
||||
title: Internal Events Payload Samples
|
||||
---
|
||||
|
||||
## Internal Events Payload
|
||||
|
||||
This guide provides payload samples for internal events tracked across frontend and backend services. Each event type includes a detailed breakdown of its fields and descriptions. Internal events use Snowplow to track events. For more information, see [Snowplow event parameters guide](https://docs.snowplow.io/docs/sources/trackers/snowplow-tracker-protocol/going-deeper/event-parameters/).
|
||||
|
||||
From GitLab 18.0, Self-Managed and Dedicated instances will be sending structured events, self-describing events, page views, and page pings.
|
||||
|
||||
## Event Types
|
||||
|
||||
At its core, our Internal Events tracking system is designed for granular tracking of events. Each event is denoted by an `e=...` parameter.
|
||||
|
||||
There are three categories of events:
|
||||
|
||||
- Standard events, such as page views and page pings
|
||||
- Custom structured events
|
||||
- Self-describing events based on a schema
|
||||
|
||||
| **Type of tracking** | **Event type (value of e)** |
|
||||
| ----------------------------------- | --------------------------- |
|
||||
| Self-describing event | ue |
|
||||
| Pageview tracking | pv |
|
||||
| Page pings | pp |
|
||||
| Custom structured event | se |
|
||||
|
||||
## Common Parameters
|
||||
|
||||
### Event Parameters
|
||||
|
||||
| **Parameter** | **Table Column** | **Type** | **Description** | **Example values** |
|
||||
| ------------- | ---------------- | -------- | --------------- | ------------------ |
|
||||
| e | event | text | Event type | pv, pp, ue, se |
|
||||
| eid | `event_id` | text | Event UUID | 606adff6-9ccc-41f4-8807-db8fdb600df8 |
|
||||
|
||||
### Application Parameters
|
||||
|
||||
| **Parameter** | **Table Column** | **Type** | **Description** | **Example values** |
|
||||
| ------------- | ---------------- | -------- | --------------- | ------------------ |
|
||||
| tna | namespace_tracker | text | The tracker namespace | `gl` |
|
||||
| aid | `app_id` | text | Unique identifier for the application | `gitlab-sm`|
|
||||
| p | platform | text | The platform the app runs on | web, srv, app |
|
||||
| tv | v_tracker | text | Identifier for tracker version | js-3.24.2 |
|
||||
|
||||
### Timestamp Parameters
|
||||
|
||||
| **Parameter** | **Table Column** | **Type** | **Description** | **Example values** |
|
||||
| ------------- | --------------------- | -------- | --------------- | ------------------ |
|
||||
| dtm | dvce_created_tstamp | int | Timestamp when event occurred, as recorded by client device | 1361553733313 |
|
||||
| stm | dvce_sent_tstamp | int | Timestamp when event was sent by client device to collector | 1361553733371 |
|
||||
| ttm | true_tstamp | int | User-set exact timestamp | 1361553733371 |
|
||||
| tz | os_timezone | text | Time zone of client devices OS | Europe%2FLondon |
|
||||
|
||||
> **Note:** The Internal Events Collector will also capture `collector_tstamp` which is the time the event arrived at the collector.
|
||||
|
||||
### User-Related Parameters
|
||||
|
||||
| **Parameter** | **Table Column** | **Type** | **Description** | **Example values** |
|
||||
| ------------- | ------------------ | -------- | --------------- | ------------------ |
|
||||
| duid | `domain_userid` | text | Unique rotating identifier for a user, based on a first-party cookie. | aeb1691c5a0ee5a6 |
|
||||
| uid | `user_id` | text | `user_id`, which gets pseudonymized in the snowplow [pipeline](https://metrics.gitlab.com/identifiers/) | 1234567890 |
|
||||
| vid | `domain_sessionidx` | int | Index of number of visits that this user has made to the application | 1 |
|
||||
| sid | `domain_sessionid` | text | Unique identifier (UUID) generated to track a user's activity during a single visit or session. This identifier resets between sessions. The identifier is not linked to personal information. | 9c65e7f3-8e8e-470d-b243-910b5b300da0 |
|
||||
| `ip` | `user_ipaddress`, we collect Geo information but do not store the IP address in the snowplow pipeline | text | IP address override | 37.157.33.178 |
|
||||
|
||||
### Platform Parameters
|
||||
|
||||
| **Parameter** | **Table Column** | **Type** | **Description** | **Example values** |
|
||||
| ------------- | ---------------- | -------- | --------------- | ------------------ |
|
||||
| `url` | `page_url` | text | Page URL. We pseudonymize sensitive data from the URL ([see examples](https://metrics.gitlab.com/identifiers/)). | `https://gitlab.com/dashboard/projects` |
|
||||
| `ua` | `useragent` | text | Useragent | `Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:105.0) Gecko/20100101 Firefox/105.0` |
|
||||
| `page` | page_title | text | This value will always be hardcoded to `GitLab` | GitLab |
|
||||
| refr | page_referrer | text | Referrer URL, similar to `page_url`. We pseudonymize referrer URL. | `https://gitlab.com/group:123/project:356` |
|
||||
| cookie | br_cookies | boolean | Does the browser permit cookies? | 1 |
|
||||
| lang | br_lang | text | Browser language | en-US |
|
||||
| cd | br_colordepth | integer | Browser color depth | 24 |
|
||||
| cs | doc_charset | text | Web page's character encoding | UTF-8 |
|
||||
| ds | doc_width and doc_height | text | Web page width and height | 1090x1152 |
|
||||
| vp | br_viewwidth and br_viewheight | text | Browser viewport width and height | 1105x390 |
|
||||
| res | dvce_screenwidth and dvce_screenheight | text | Screen/monitor resolution | 1280x1024 |
|
||||
|
||||
## Self-describing Events
|
||||
|
||||
Self-describing events are the recommended way to track custom events with Internal Events tracking. They allow tracking of events according to a predefined schema.
|
||||
|
||||
When tracking a self-describing event:
|
||||
|
||||
- The event type is set to `e=ue`.
|
||||
- The event data is base64 encoded and included in the payload.
|
||||
|
||||
## Specific Event Types
|
||||
|
||||
### Page Views
|
||||
|
||||
Pageview tracking is used to record views of web pages.
|
||||
|
||||
Recording a pageview involves recording an event where `e=pv`. All the fields associated with web events can be tracked.
|
||||
|
||||
### Page Pings
|
||||
|
||||
Page ping events track user engagement by periodically firing while a user remains active on a page. They measure actual time spent on page.
|
||||
|
||||
Page pings are identified by `e=pp` and include these additional fields:
|
||||
|
||||
| **Parameter** | **Table Column** | **Type** | **Description** |
|
||||
| ------------- | ---------------- | -------- | --------------- |
|
||||
| pp_mix | pp_xoffset_min | integer | Minimum page x offset seen in the last ping period |
|
||||
| pp_max | pp_xoffset_max | integer | Maximum page x offset seen in the last ping period |
|
||||
| pp_miy | pp_yoffset_min | integer | Minimum page y offset seen in the last ping period |
|
||||
| pp_may | pp_yoffset_max | integer | Maximum page y offset seen in the last ping period |
|
||||
|
||||
### Structured Event Tracking
|
||||
|
||||
As well as setting `e=se`, there are five custom event specific parameters that can be set:
|
||||
|
||||
| **Parameter** | **Table Column** | **Type** | **Description** | **Example values** |
|
||||
| ------------- | ---------------- | -------- | --------------- | ------------------ |
|
||||
| se_ca | se_category | text | The event category. By default, where the event happened. For frontend events, it is the page name, for backend events it is the controller name. | projects:merge_requests:show |
|
||||
| se_ac | se_action | text | The action or event name | code_suggestion_accepted |
|
||||
| se_la | se_label | text | A label often used to refer to the 'object' the action is performed on | `${editor_name}` |
|
||||
| se_pr | se_property | text | A property associated with either the action or the object | `${suggestion_type}` |
|
||||
| se_va | se_value | decimal | A value associated with the user action | `${suggestion_shown_duration}` |
|
||||
| cx | contexts | JSON | It passes base64 encoded context to the event | JSON |
|
||||
|
||||
Contexts has some of the predefined fields which will be sent with each event. All the predefined schemas are stored in the [`gitlab-org/iglu`](https://gitlab.com/gitlab-org/iglu) repository.
|
||||
|
||||
Most of the self-describing events have `gitlab_standard` context, which is a set of fields that are common to all events. For more information about the `gitlab_standard` context, see [Standard context fields](standard_context_fields.md).
|
||||
|
||||
## Internal Events Payload Examples
|
||||
|
||||
### Page View
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "iglu:com.snowplowanalytics.snowplow/payload_data/jsonschema/1-0-4",
|
||||
"data": [
|
||||
{
|
||||
"e": "pv",
|
||||
"url": "https://gitlab.com/",
|
||||
"page": "GitLab",
|
||||
"refr": "https://gitlab.com/",
|
||||
"eid": "564f9834-3f98-4d78-a738-b7977d621371",
|
||||
"tv": "js-3.24.2",
|
||||
"tna": "gl",
|
||||
"aid": "gitlab",
|
||||
"p": "web",
|
||||
"cookie": "1",
|
||||
"cs": "UTF-8",
|
||||
"lang": "en-GB",
|
||||
"res": "1728x1117",
|
||||
"cd": "30",
|
||||
"tz": "Asia/Calcutta",
|
||||
"dtm": "1742205227525",
|
||||
"vp": "1920x331",
|
||||
"ds": "1920x388",
|
||||
"vid": "720",
|
||||
"sid": "1574509e-5d6d-43d1-9e76-e42801ae2e55",
|
||||
"duid": "9e5500ac-3437-4457-a007-351911d54983",
|
||||
"cx": "eyJzY2hlbWEiOiJpZ2x1OmNvbS5zbm93cGxvd2FuYWx5dGljcy5z...",
|
||||
"stm": "1742205227528"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
cx field is base64 encoded and contains the following JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0",
|
||||
"data": [
|
||||
{
|
||||
"schema": "iglu:com.gitlab/gitlab_standard/jsonschema/1-1-1",
|
||||
"data": {
|
||||
"environment": "production",
|
||||
"source": "gitlab-javascript",
|
||||
"correlation_id": "01JPHRC3K30KDDV165EWTCFJ02",
|
||||
"plan": null,
|
||||
"extra": {},
|
||||
"user_id": 11979729,
|
||||
"global_user_id": "XsZfAb677xjp9zut/lL6X0ZKX5b7pli65uk2wnfu0SY=",
|
||||
"is_gitlab_team_member": true,
|
||||
"namespace_id": null,
|
||||
"project_id": null,
|
||||
"feature_enabled_by_namespace_ids": null,
|
||||
"realm": "saas",
|
||||
"instance_id": "ea8bf810-1d6f-4a6a-b4fd-93e8cbd8b57f",
|
||||
"host_name": "gitlab-webservice-web-58446c98b5-zprvd",
|
||||
"instance_version": "17.10.0",
|
||||
"context_generated_at": "2025-03-17T09:53:46.709Z",
|
||||
"google_analytics_id": "GA1.1.424273043.1737451027"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "iglu:com.snowplowanalytics.snowplow/web_page/jsonschema/1-0-0",
|
||||
"data": {
|
||||
"id": "90ea98bd-3bdb-48d2-935c-59a4d03a4710"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "iglu:com.google.analytics/cookies/jsonschema/1-0-0",
|
||||
"data": {
|
||||
"_ga": "GA1.1.424273043.1737451027"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "iglu:com.google.ga4/cookies/jsonschema/1-0-0",
|
||||
"data": {
|
||||
"_ga": "GA1.1.424273043.1737451027",
|
||||
"session_cookies": [
|
||||
{
|
||||
"measurement_id": "G-ENFH3X7M5Y",
|
||||
"session_cookie": "GS1.1.1742200876.45.1.1742202521.0.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "iglu:org.w3/PerformanceTiming/jsonschema/1-0-0",
|
||||
"data": {
|
||||
"navigationStart": 1742205226288,
|
||||
"redirectStart": 0,
|
||||
"redirectEnd": 0,
|
||||
"fetchStart": 1742205226289,
|
||||
"domainLookupStart": 1742205226289,
|
||||
"domainLookupEnd": 1742205226289,
|
||||
"connectStart": 1742205226289,
|
||||
"secureConnectionStart": 0,
|
||||
"connectEnd": 1742205226289,
|
||||
"requestStart": 1742205226323,
|
||||
"responseStart": 1742205226969,
|
||||
"responseEnd": 1742205226972,
|
||||
"unloadEventStart": 1742205226975,
|
||||
"unloadEventEnd": 1742205226975,
|
||||
"domLoading": 1742205226980,
|
||||
"domInteractive": 1742205227044,
|
||||
"domContentLoadedEventStart": 1742205227437,
|
||||
"domContentLoadedEventEnd": 1742205227437,
|
||||
"domComplete": 0,
|
||||
"loadEventStart": 0,
|
||||
"loadEventEnd": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "iglu:org.ietf/http_client_hints/jsonschema/1-0-0",
|
||||
"data": {
|
||||
"isMobile": false,
|
||||
"brands": [
|
||||
{
|
||||
"brand": "Chromium",
|
||||
"version": "134"
|
||||
},
|
||||
{
|
||||
"brand": "Not:A-Brand",
|
||||
"version": "24"
|
||||
},
|
||||
{
|
||||
"brand": "Google Chrome",
|
||||
"version": "134"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Page Ping
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "iglu:com.snowplowanalytics.snowplow/payload_data/jsonschema/1-0-4",
|
||||
"data": [
|
||||
{
|
||||
"e": "pp",
|
||||
"url": "https://gitlab.com/",
|
||||
"page": "GitLab",
|
||||
"refr": "https://gitlab.com/",
|
||||
"eid": "ac958a76-5360-44e1-a9f3-8172d6df0f80",
|
||||
"tv": "js-3.24.2",
|
||||
"tna": "gl",
|
||||
"aid": "gitlab",
|
||||
"p": "web",
|
||||
"cookie": "1",
|
||||
"cs": "UTF-8",
|
||||
"lang": "en-GB",
|
||||
"res": "1728x1117",
|
||||
"cd": "30",
|
||||
"tz": "Asia/Calcutta",
|
||||
"dtm": "1742205324496",
|
||||
"vp": "1920x331",
|
||||
"ds": "1920x1694",
|
||||
"vid": "720",
|
||||
"sid": "1574509e-5d6d-43d1-9e76-e42801ae2e55",
|
||||
"duid": "9e5500ac-3437-4457-a007-351911d54983",
|
||||
"cx": "eyJzY2hlbWEiOiJpZ2x1OmNvbS5zbm93cGxvd2FuYWx5dGljcy...",
|
||||
"stm": "1742205324501"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Self-describing Events
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "iglu:com.snowplowanalytics.snowplow/payload_data/jsonschema/1-0-4",
|
||||
"data": [
|
||||
{
|
||||
"e": "ue",
|
||||
"eid": "67ae8ec1-3ec0-46b7-89e0-fd944d90acc6",
|
||||
"tv": "js-3.24.2",
|
||||
"tna": "gl",
|
||||
"aid": "gitlab",
|
||||
"p": "web",
|
||||
"cookie": "1",
|
||||
"cs": "UTF-8",
|
||||
"lang": "en-GB",
|
||||
"res": "1728x1117",
|
||||
"cd": "30",
|
||||
"tz": "Asia/Calcutta",
|
||||
"dtm": "1742205393772",
|
||||
"vp": "1920x331",
|
||||
"ds": "1920x1694",
|
||||
"vid": "720",
|
||||
"sid": "1574509e-5d6d-43d1-9e76-e42801ae2e55",
|
||||
"duid": "9e5500ac-3437-4457-a007-351911d54983",
|
||||
"refr": "https://gitlab.com/",
|
||||
"url": "https://gitlab.com/",
|
||||
"ue_px": "eyJzY2hlbWEiOiJpZ2x1OmNvbS5zbm93cGxvd2FuYWx5dGljcy...",
|
||||
"cx": "eyJzY2hlbWEiOiJpZ2x1OmNvbS5zbm93cGxvd2FuYWx5dGljcy...",
|
||||
"stm": "1742205393774"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This is part of link click tracking. The `ue_px` field is base64 encoded and contains the following JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0",
|
||||
"data": {
|
||||
"schema": "iglu:com.snowplowanalytics.snowplow/link_click/jsonschema/1-0-1",
|
||||
"data": {
|
||||
"targetUrl": "https://gitlab.com/",
|
||||
"elementId": "",
|
||||
"elementClasses": [
|
||||
"brand-logo"
|
||||
],
|
||||
"elementTarget": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Structured Events
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "iglu:com.snowplowanalytics.snowplow/payload_data/jsonschema/1-0-4",
|
||||
"data": [
|
||||
{
|
||||
"e": "se",
|
||||
"se_ca": "root:index",
|
||||
"se_ac": "render_duo_chat_callout",
|
||||
"eid": "12c18f54-ef65-489e-99f8-00922f9c3249",
|
||||
"tv": "js-3.24.2",
|
||||
"tna": "gl",
|
||||
"aid": "gitlab",
|
||||
"p": "web",
|
||||
"cookie": "1",
|
||||
"cs": "UTF-8",
|
||||
"lang": "en-GB",
|
||||
"res": "1728x1117",
|
||||
"cd": "30",
|
||||
"tz": "Asia/Calcutta",
|
||||
"dtm": "1742205394848",
|
||||
"vp": "1920x331",
|
||||
"ds": "1920x388",
|
||||
"vid": "720",
|
||||
"sid": "1574509e-5d6d-43d1-9e76-e42801ae2e55",
|
||||
"duid": "9e5500ac-3437-4457-a007-351911d54983",
|
||||
"refr": "https://gitlab.com/",
|
||||
"url": "https://gitlab.com/",
|
||||
"cx": "eyJzY2hlbWEiOiJpZ2x1OmNvbS5zbm93cGxvd2FuYWx5dGljcy...",
|
||||
"stm": "1742205395080"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Backend Events
|
||||
|
||||
```json
|
||||
{
|
||||
"e": "se",
|
||||
"eid": "2e78c447-c18e-4087-a3a8-35723ecfb602",
|
||||
"aid": "asdfsadf",
|
||||
"cx": "eyJzY2hlbWEiOiJpZ2x1OmNvbS5zbm93cGxvd2FuYWx5dGljcy...",
|
||||
"tna": "gl",
|
||||
"stm": "1742268163018",
|
||||
"tv": "rb-0.8.0",
|
||||
"se_ac": "perform_action",
|
||||
"se_la": "redis_hll_counters.manage.unique_active_users_monthly",
|
||||
"se_ca": "Users::ActivityService",
|
||||
"p": "srv",
|
||||
"dtm": "1742268163016"
|
||||
}
|
||||
```
|
||||
|
||||
cx field is base64 encoded and contains the following JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-1",
|
||||
"data": [
|
||||
{
|
||||
"schema": "iglu:com.gitlab/gitlab_standard/jsonschema/1-1-1",
|
||||
"data": {
|
||||
"environment": "development",
|
||||
"source": "gitlab-rails",
|
||||
"correlation_id": "01JPKMCRCBSMB07DPGVSJJ708F",
|
||||
"plan": null,
|
||||
"extra": {},
|
||||
"user_id": 1,
|
||||
"global_user_id": "KaAjqePKpCsnc6P40up8ZOi4+BUwEUIyab6W5jWIg5M=",
|
||||
"is_gitlab_team_member": null,
|
||||
"namespace_id": null,
|
||||
"project_id": null,
|
||||
"feature_enabled_by_namespace_ids": null,
|
||||
"realm": "self-managed",
|
||||
"instance_id": "e1baa3de-7e45-4fbc-b17e-95995935cf09",
|
||||
"host_name": "nbelokolodov--20220811-Y26WJ",
|
||||
"instance_version": "17.10.0",
|
||||
"context_generated_at": "2025-03-18 03:22:43 UTC"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "iglu:com.gitlab/gitlab_service_ping/jsonschema/1-0-1",
|
||||
"data": {
|
||||
"data_source": "redis_hll",
|
||||
"event_name": "unique_active_user"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
|
@ -166,6 +166,22 @@ To set the number of concurrent indexing tasks:
|
|||
|
||||
1. Select **Save changes**.
|
||||
|
||||
## Run Zoekt on a separate server
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- You must have administrator access to the instance.
|
||||
|
||||
To run Zoekt on a different server than GitLab:
|
||||
|
||||
1. [Change the Gitaly listening interface](../../administration/gitaly/configure_gitaly.md#change-the-gitaly-listening-interface).
|
||||
1. [Install Zoekt](#install-zoekt).
|
||||
|
||||
Zoekt does not support any authentication, so ensure:
|
||||
|
||||
- The zoekt instance is not publicly accessible.
|
||||
- Only the GitLab server has access to the Zoekt server through firewall policies or IP rules.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
When working with Zoekt, you might encounter the following issues.
|
||||
|
|
@ -196,3 +212,59 @@ To index a namespace manually, run this command:
|
|||
namespace = Namespace.find_by_full_path('<top-level-group-to-index>')
|
||||
Search::Zoekt::EnabledNamespace.find_or_create_by(namespace: namespace)
|
||||
```
|
||||
|
||||
### Error: `SilentModeBlockedError`
|
||||
|
||||
You might get a `SilentModeBlockedError` when you try to run exact code search.
|
||||
This issue occurs when [Silent Mode](../../administration/silent_mode) is enabled on the GitLab instance.
|
||||
|
||||
To resolve this issue, ensure Silent Mode is disabled.
|
||||
|
||||
### Error: `connections to all backends failing`
|
||||
|
||||
In `application_json.log`, you might get the following error:
|
||||
|
||||
```plaintext
|
||||
connections to all backends failing; last error: UNKNOWN: ipv4:1.2.3.4:5678: Trying to connect an http1.x server
|
||||
```
|
||||
|
||||
To resolve this issue, check if you're using any proxies.
|
||||
If you are, set the IP address of the GitLab server to `no_proxy`:
|
||||
|
||||
```ruby
|
||||
gitlab_rails['env'] = {
|
||||
"http_proxy" => "http://proxy.domain.com:1234",
|
||||
"https_proxy" => "http://proxy.domain.com:1234",
|
||||
"no_proxy" => ".domain.com,IP_OF_GITLAB_INSTANCE,127.0.0.1,localhost"
|
||||
}
|
||||
```
|
||||
|
||||
`proxy.domain.com:1234` is the domain of the proxy instance and the port.
|
||||
`IP_OF_GITLAB_INSTANCE` points to the public IP address of the GitLab instance.
|
||||
|
||||
You can get this information by running `ip a` and checking one of the following:
|
||||
|
||||
- The IP address of the appropriate network interface
|
||||
- The public IP address of any load balancer you're using
|
||||
|
||||
### Verify Zoekt node connections
|
||||
|
||||
To verify that your Zoekt nodes are properly configured and connected,
|
||||
in a [Rails console session](../../administration/operations/rails_console.md#starting-a-rails-console-session):
|
||||
|
||||
- Check the total number of configured Zoekt nodes:
|
||||
|
||||
```ruby
|
||||
Search::Zoekt::Node.count
|
||||
```
|
||||
|
||||
- Check how many nodes are online:
|
||||
|
||||
```ruby
|
||||
Search::Zoekt::Node.online.count
|
||||
```
|
||||
|
||||
Alternatively, you can use the `gitlab:zoekt:info` Rake task.
|
||||
|
||||
If the number of online nodes is lower than the number of configured nodes or is zero when nodes are configured,
|
||||
you might have connectivity issues between GitLab and your Zoekt nodes.
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ This rule enforces the defined actions whenever the pipeline runs for a selected
|
|||
- [Enabled](https://gitlab.com/gitlab-org/gitlab/-/issues/451890) the `scan_execution_pipeline_worker` feature flag on GitLab.com in GitLab 17.5.
|
||||
- [Feature flag](https://gitlab.com/gitlab-org/gitlab/-/issues/451890) `scan_execution_pipeline_worker` removed in GitLab 17.6.
|
||||
- [Feature flag](https://gitlab.com/gitlab-org/gitlab/-/issues/463802) `scan_execution_pipeline_concurrency_control` removed in GitLab 17.9.
|
||||
- [Removed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/178892) a new application setting `security_policy_scheduled_scans_max_concurrency` in GitLab 17.11
|
||||
|
||||
{{< /history >}}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,30 +44,30 @@ module Gitlab
|
|||
TRANSLATION_LEVELS = {
|
||||
'bg' => 0,
|
||||
'cs_CZ' => 0,
|
||||
'da_DK' => 19,
|
||||
'da_DK' => 18,
|
||||
'de' => 95,
|
||||
'en' => 100,
|
||||
'eo' => 0,
|
||||
'es' => 41,
|
||||
'es' => 42,
|
||||
'fil_PH' => 0,
|
||||
'fr' => 98,
|
||||
'fr' => 97,
|
||||
'gl_ES' => 0,
|
||||
'id_ID' => 0,
|
||||
'it' => 85,
|
||||
'ja' => 94,
|
||||
'ko' => 63,
|
||||
'ja' => 93,
|
||||
'ko' => 83,
|
||||
'nb_NO' => 15,
|
||||
'nl_NL' => 0,
|
||||
'pl_PL' => 1,
|
||||
'pt_BR' => 92,
|
||||
'ro_RO' => 47,
|
||||
'ru' => 61,
|
||||
'ru' => 64,
|
||||
'si_LK' => 9,
|
||||
'tr_TR' => 5,
|
||||
'uk' => 36,
|
||||
'zh_CN' => 95,
|
||||
'zh_CN' => 94,
|
||||
'zh_HK' => 0,
|
||||
'zh_TW' => 79
|
||||
'zh_TW' => 78
|
||||
}.freeze
|
||||
private_constant :TRANSLATION_LEVELS
|
||||
|
||||
|
|
|
|||
|
|
@ -138,6 +138,8 @@ module Gitlab
|
|||
transaction = Gitlab::Metrics::BackgroundTransaction.new
|
||||
transaction.run { yield }
|
||||
@job_succeeded = true
|
||||
rescue Gitlab::SidekiqMiddleware::RetryError => e
|
||||
raise
|
||||
ensure
|
||||
monotonic_time_end = Gitlab::Metrics::System.monotonic_time
|
||||
job_thread_cputime_end = get_thread_cputime
|
||||
|
|
@ -170,7 +172,7 @@ module Gitlab
|
|||
end
|
||||
|
||||
@sli_labels = labels.slice(*SIDEKIQ_SLI_LABELS)
|
||||
record_execution_sli
|
||||
record_execution_sli unless e.is_a?(Gitlab::SidekiqMiddleware::RetryError)
|
||||
record_queueing_sli
|
||||
record_db_txn_sli if Feature.enabled?(:emit_db_transaction_sli_metrics, type: :ops)
|
||||
end
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1086
locale/es/gitlab.po
1086
locale/es/gitlab.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -55743,6 +55743,9 @@ msgstr ""
|
|||
msgid "Show open epics"
|
||||
msgstr ""
|
||||
|
||||
msgid "Show or hide sidebar"
|
||||
msgstr ""
|
||||
|
||||
msgid "Show password"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -61335,9 +61338,6 @@ msgstr ""
|
|||
msgid "Todos|Filter to-do items"
|
||||
msgstr ""
|
||||
|
||||
msgid "Todos|First sent %{timeago}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Todos|For one hour"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -61418,6 +61418,9 @@ msgstr ""
|
|||
msgid "Todos|OKR checkin requested"
|
||||
msgstr ""
|
||||
|
||||
msgid "Todos|Previously snoozed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Todos|Raw text search is not currently supported"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1026
locale/it/gitlab.po
1026
locale/it/gitlab.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
9597
locale/ko/gitlab.po
9597
locale/ko/gitlab.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1904
locale/ru/gitlab.po
1904
locale/ru/gitlab.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -22,18 +22,17 @@ import {
|
|||
import {
|
||||
FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
SORT_DIRECTION_DESC,
|
||||
SORT_DIRECTION_ASC,
|
||||
} from '~/groups_projects/constants';
|
||||
import { RECENT_SEARCHES_STORAGE_KEY_PROJECTS } from '~/filtered_search/recent_searches_storage_keys';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
SORT_OPTION_CREATED,
|
||||
SORT_OPTION_UPDATED,
|
||||
SORT_DIRECTION_DESC,
|
||||
SORT_DIRECTION_ASC,
|
||||
FILTERED_SEARCH_TERM_KEY,
|
||||
FILTERED_SEARCH_NAMESPACE,
|
||||
} from '~/projects/filtered_search_and_sort/constants';
|
||||
import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
|
||||
import FilteredSearchAndSort from '~/groups_projects/components/filtered_search_and_sort.vue';
|
||||
import projectCountsQuery from '~/projects/your_work/graphql/queries/project_counts.query.graphql';
|
||||
import userPreferencesUpdateMutation from '~/groups_projects/graphql/mutations/user_preferences_update.mutation.graphql';
|
||||
|
|
@ -66,6 +65,15 @@ const defaultProvide = {
|
|||
|
||||
const defaultPropsData = {
|
||||
tabs: PROJECT_DASHBOARD_TABS,
|
||||
filteredSearchSupportedTokens: [
|
||||
FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
],
|
||||
filteredSearchTermKey: FILTERED_SEARCH_TERM_KEY,
|
||||
filteredSearchNamespace: FILTERED_SEARCH_NAMESPACE,
|
||||
filteredSearchRecentSearchesStorageKey: RECENT_SEARCHES_STORAGE_KEY_PROJECTS,
|
||||
sortOptions: SORT_OPTIONS,
|
||||
defaultSortOption: SORT_OPTION_UPDATED,
|
||||
};
|
||||
|
||||
const searchTerm = 'foo bar';
|
||||
|
|
@ -176,7 +184,11 @@ describe('TabsWithList', () => {
|
|||
});
|
||||
|
||||
it('renders filtered search bar with correct props', async () => {
|
||||
await createComponent();
|
||||
await createComponent({
|
||||
propsData: {
|
||||
filteredSearchSupportedTokens: [FILTERED_SEARCH_TOKEN_LANGUAGE],
|
||||
},
|
||||
});
|
||||
|
||||
expect(findFilteredSearchAndSort().props()).toMatchObject({
|
||||
filteredSearchTokens: [
|
||||
|
|
@ -192,26 +204,13 @@ describe('TabsWithList', () => {
|
|||
{ value: '8', title: 'CoffeeScript' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
icon: 'user',
|
||||
title: 'Role',
|
||||
token: GlFilteredSearchToken,
|
||||
unique: true,
|
||||
operators: OPERATORS_IS,
|
||||
options: [
|
||||
{
|
||||
value: '50',
|
||||
title: 'Owner',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
filteredSearchQuery: {},
|
||||
filteredSearchTermKey: FILTERED_SEARCH_TERM_KEY,
|
||||
filteredSearchNamespace: FILTERED_SEARCH_NAMESPACE,
|
||||
filteredSearchRecentSearchesStorageKey: RECENT_SEARCHES_STORAGE_KEY_PROJECTS,
|
||||
sortOptions: SORT_OPTIONS,
|
||||
filteredSearchTermKey: defaultPropsData.filteredSearchTermKey,
|
||||
filteredSearchNamespace: defaultPropsData.filteredSearchNamespace,
|
||||
filteredSearchRecentSearchesStorageKey:
|
||||
defaultPropsData.filteredSearchRecentSearchesStorageKey,
|
||||
sortOptions: defaultPropsData.sortOptions,
|
||||
activeSortOption: SORT_OPTION_CREATED,
|
||||
isAscending: false,
|
||||
});
|
||||
|
|
@ -222,13 +221,15 @@ describe('TabsWithList', () => {
|
|||
await createComponent();
|
||||
|
||||
findFilteredSearchAndSort().vm.$emit('filter', {
|
||||
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
|
||||
[defaultPropsData.filteredSearchTermKey]: searchTerm,
|
||||
});
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('updates query string', () => {
|
||||
expect(router.currentRoute.query).toEqual({ [FILTERED_SEARCH_TERM_KEY]: searchTerm });
|
||||
expect(router.currentRoute.query).toEqual({
|
||||
[defaultPropsData.filteredSearchTermKey]: searchTerm,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -238,7 +239,7 @@ describe('TabsWithList', () => {
|
|||
route: {
|
||||
...defaultRoute,
|
||||
query: {
|
||||
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
|
||||
[defaultPropsData.filteredSearchTermKey]: searchTerm,
|
||||
[QUERY_PARAM_END_CURSOR]: mockEndCursor,
|
||||
},
|
||||
},
|
||||
|
|
@ -250,7 +251,7 @@ describe('TabsWithList', () => {
|
|||
|
||||
it('updates query string', () => {
|
||||
expect(router.currentRoute.query).toEqual({
|
||||
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
|
||||
[defaultPropsData.filteredSearchTermKey]: searchTerm,
|
||||
sort: `${SORT_OPTION_UPDATED.value}_${SORT_DIRECTION_DESC}`,
|
||||
});
|
||||
});
|
||||
|
|
@ -272,7 +273,7 @@ describe('TabsWithList', () => {
|
|||
route: {
|
||||
...defaultRoute,
|
||||
query: {
|
||||
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
|
||||
[defaultPropsData.filteredSearchTermKey]: searchTerm,
|
||||
[QUERY_PARAM_END_CURSOR]: mockEndCursor,
|
||||
},
|
||||
},
|
||||
|
|
@ -284,7 +285,7 @@ describe('TabsWithList', () => {
|
|||
|
||||
it('updates query string', () => {
|
||||
expect(router.currentRoute.query).toEqual({
|
||||
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
|
||||
[defaultPropsData.filteredSearchTermKey]: searchTerm,
|
||||
sort: `${SORT_OPTION_CREATED.value}_${SORT_DIRECTION_ASC}`,
|
||||
});
|
||||
});
|
||||
|
|
@ -329,7 +330,7 @@ describe('TabsWithList', () => {
|
|||
`('onMount when route name is $name', ({ name, expectedTab }) => {
|
||||
const query = {
|
||||
sort: 'name_desc',
|
||||
[FILTERED_SEARCH_TERM_KEY]: 'foo',
|
||||
[defaultPropsData.filteredSearchTermKey]: 'foo',
|
||||
[FILTERED_SEARCH_TOKEN_LANGUAGE]: '8',
|
||||
[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL]: ACCESS_LEVEL_OWNER_INTEGER,
|
||||
[QUERY_PARAM_END_CURSOR]: mockEndCursor,
|
||||
|
|
@ -354,7 +355,7 @@ describe('TabsWithList', () => {
|
|||
expect(findTabView().props()).toMatchObject({
|
||||
sort: query.sort,
|
||||
filters: {
|
||||
[FILTERED_SEARCH_TERM_KEY]: query[FILTERED_SEARCH_TERM_KEY],
|
||||
[defaultPropsData.filteredSearchTermKey]: query[defaultPropsData.filteredSearchTermKey],
|
||||
[FILTERED_SEARCH_TOKEN_LANGUAGE]: query[FILTERED_SEARCH_TOKEN_LANGUAGE],
|
||||
[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL]: query[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL],
|
||||
},
|
||||
|
|
@ -396,9 +397,9 @@ describe('TabsWithList', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('falls back to updated in ascending order', () => {
|
||||
it('falls back to defaultSortOption prop ascending order', () => {
|
||||
expect(findTabView().props()).toMatchObject({
|
||||
sort: `${SORT_OPTION_UPDATED.value}_${SORT_DIRECTION_ASC}`,
|
||||
sort: `${defaultPropsData.defaultSortOption.value}_${SORT_DIRECTION_ASC}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,142 +8,276 @@ function mockStickyHeaderSize(val) {
|
|||
contentTop.mockReturnValue(val);
|
||||
}
|
||||
|
||||
describe('ResizeObserver Utility', () => {
|
||||
describe('scrollToTargetOnResize', () => {
|
||||
let cleanup;
|
||||
const mockHeaderSize = 90;
|
||||
const triggerResize = () => {
|
||||
const entry = document.querySelector('#content-body');
|
||||
entry.dispatchEvent(new CustomEvent(`ResizeUpdate`, { detail: { entry } }));
|
||||
};
|
||||
const mockHeaderSize = 50;
|
||||
let resizeObserverCallback;
|
||||
let mockObserve;
|
||||
let mockUnobserve;
|
||||
|
||||
beforeEach(() => {
|
||||
mockObserve = jest.fn();
|
||||
mockUnobserve = jest.fn();
|
||||
|
||||
global.ResizeObserver = jest.fn((callback) => {
|
||||
resizeObserverCallback = callback;
|
||||
return {
|
||||
observe: mockObserve,
|
||||
unobserve: mockUnobserve,
|
||||
};
|
||||
});
|
||||
|
||||
mockStickyHeaderSize(mockHeaderSize);
|
||||
|
||||
jest.spyOn(document.documentElement, 'scrollTo');
|
||||
Object.defineProperty(document, 'scrollingElement', {
|
||||
value: {
|
||||
scrollTo: jest.fn(),
|
||||
scrollTop: 0,
|
||||
scrollHeight: 1000,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
setHTMLFixture(
|
||||
`<div id="content-body"><div id="note_1234">note to scroll to</div><textarea id="reply-field"></textarea></div>`,
|
||||
`<div id="content-body">
|
||||
<div id="target-element">Target content</div>
|
||||
<div id="other-content">Other content</div>
|
||||
</div>`,
|
||||
);
|
||||
|
||||
const target = document.querySelector('#note_1234');
|
||||
|
||||
jest.spyOn(target, 'getBoundingClientRect').mockReturnValue({ top: 200 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
}
|
||||
contentTop.mockReset();
|
||||
resetHTMLFixture();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns null for empty target', () => {
|
||||
cleanup = scrollToTargetOnResize({
|
||||
targetId: '',
|
||||
container: '#content-body',
|
||||
describe('initialization and basic functionality', () => {
|
||||
it('returns null if no targetId is provided and no hash exists', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hash: '' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const result = scrollToTargetOnResize();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockObserve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(cleanup).toBe(null);
|
||||
});
|
||||
|
||||
it('does not scroll if target does not exist', () => {
|
||||
scrollToTargetOnResize({
|
||||
targetId: 'some_imaginary_id',
|
||||
container: '#content-body',
|
||||
});
|
||||
|
||||
triggerResize();
|
||||
|
||||
expect(document.documentElement.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('with existing target', () => {
|
||||
const topHeight = 110;
|
||||
const scrollAmount = 160;
|
||||
|
||||
beforeEach(() => {
|
||||
it('observes the container element on initialization', () => {
|
||||
cleanup = scrollToTargetOnResize({
|
||||
targetId: 'note_1234',
|
||||
targetId: 'target-element',
|
||||
container: '#content-body',
|
||||
});
|
||||
|
||||
expect(mockObserve).toHaveBeenCalledWith(document.querySelector('#content-body'));
|
||||
});
|
||||
|
||||
it('returns cleanup function', () => {
|
||||
it('uses window.location.hash if no targetId is provided', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hash: '#target-element' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const getBoundingClientRectSpy = jest
|
||||
.spyOn(document.getElementById('target-element'), 'getBoundingClientRect')
|
||||
.mockReturnValue({ top: 200 });
|
||||
|
||||
cleanup = scrollToTargetOnResize({ container: '#content-body' });
|
||||
|
||||
resizeObserverCallback([{ target: document.querySelector('#content-body') }]);
|
||||
|
||||
expect(getBoundingClientRectSpy).toHaveBeenCalled();
|
||||
expect(document.scrollingElement.scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a cleanup function that stops observing', () => {
|
||||
cleanup = scrollToTargetOnResize({
|
||||
targetId: 'target-element',
|
||||
container: '#content-body',
|
||||
});
|
||||
|
||||
expect(typeof cleanup).toBe('function');
|
||||
|
||||
cleanup();
|
||||
|
||||
triggerResize();
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(document.documentElement.scrollTo).not.toHaveBeenCalled();
|
||||
expect(mockUnobserve).toHaveBeenCalledWith(document.querySelector('#content-body'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrolling behavior', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(document.getElementById('target-element'), 'getBoundingClientRect')
|
||||
.mockReturnValue({ top: 200 });
|
||||
});
|
||||
|
||||
it('scrolls body so anchor is just below sticky header (contentTop)', () => {
|
||||
triggerResize();
|
||||
it('scrolls to keep target at top minus header size on resize', () => {
|
||||
cleanup = scrollToTargetOnResize({
|
||||
targetId: 'target-element',
|
||||
container: '#content-body',
|
||||
});
|
||||
|
||||
expect(document.documentElement.scrollTo).toHaveBeenCalledWith({
|
||||
resizeObserverCallback([{ target: document.querySelector('#content-body') }]);
|
||||
|
||||
expect(document.scrollingElement.scrollTo).toHaveBeenCalledWith({
|
||||
top: 200 - 0 - mockHeaderSize,
|
||||
behavior: 'instant',
|
||||
top: topHeight,
|
||||
});
|
||||
});
|
||||
|
||||
it('maintains scroll position relative to anchor after user scroll', () => {
|
||||
// Initial scroll to anchor
|
||||
triggerResize();
|
||||
it('does not scroll when an element other than body is focused', () => {
|
||||
const otherElement = document.getElementById('other-content');
|
||||
jest.spyOn(document, 'activeElement', 'get').mockReturnValue(otherElement);
|
||||
|
||||
cleanup = scrollToTargetOnResize({
|
||||
targetId: 'target-element',
|
||||
container: '#content-body',
|
||||
});
|
||||
|
||||
resizeObserverCallback([{ target: document.querySelector('#content-body') }]);
|
||||
|
||||
expect(document.scrollingElement.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing if target element does not exist', () => {
|
||||
cleanup = scrollToTargetOnResize({
|
||||
targetId: 'non-existent-id',
|
||||
container: '#content-body',
|
||||
});
|
||||
|
||||
resizeObserverCallback([{ target: document.querySelector('#content-body') }]);
|
||||
|
||||
expect(document.scrollingElement.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maintains scroll position relative to target after user scroll', () => {
|
||||
cleanup = scrollToTargetOnResize({
|
||||
targetId: 'target-element',
|
||||
container: '#content-body',
|
||||
});
|
||||
|
||||
resizeObserverCallback([{ target: document.querySelector('#content-body') }]);
|
||||
|
||||
expect(document.scrollingElement.scrollTo).toHaveBeenCalledWith({
|
||||
top: 150,
|
||||
behavior: 'instant',
|
||||
});
|
||||
|
||||
document.scrollingElement.scrollTop = 100;
|
||||
|
||||
jest
|
||||
.spyOn(document.getElementById('target-element'), 'getBoundingClientRect')
|
||||
.mockReturnValue({ top: 100 });
|
||||
|
||||
// Simulate user scrolling down
|
||||
window.scrollY = scrollAmount;
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
|
||||
// Trigger resize again
|
||||
triggerResize();
|
||||
jest
|
||||
.spyOn(document.getElementById('target-element'), 'getBoundingClientRect')
|
||||
.mockReturnValue({ top: 200 });
|
||||
|
||||
// Should maintain the 50px offset from original position
|
||||
expect(document.documentElement.scrollTo).toHaveBeenCalledWith({
|
||||
top: topHeight + scrollAmount,
|
||||
resizeObserverCallback([{ target: document.querySelector('#content-body') }]);
|
||||
|
||||
expect(document.scrollingElement.scrollTo).toHaveBeenLastCalledWith({
|
||||
top: 200,
|
||||
behavior: 'instant',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('does not scroll if another element is focused', () => {
|
||||
const anchorEl = document.getElementById('reply-field');
|
||||
anchorEl.focus();
|
||||
describe('intersection observer', () => {
|
||||
let intersectionCallback;
|
||||
let observeSpy;
|
||||
let unobserveSpy;
|
||||
let disconnectSpy;
|
||||
|
||||
triggerResize();
|
||||
beforeEach(() => {
|
||||
observeSpy = jest.fn();
|
||||
unobserveSpy = jest.fn();
|
||||
disconnectSpy = jest.fn();
|
||||
|
||||
expect(document.documentElement.scrollTo).not.toHaveBeenCalled();
|
||||
global.IntersectionObserver = jest.fn((callback) => {
|
||||
intersectionCallback = callback;
|
||||
return {
|
||||
observe: observeSpy,
|
||||
unobserve: unobserveSpy,
|
||||
disconnect: disconnectSpy,
|
||||
};
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(document.getElementById('target-element'), 'getBoundingClientRect')
|
||||
.mockReturnValue({ top: 200 });
|
||||
});
|
||||
|
||||
describe('intersection observer', () => {
|
||||
let intersectionCallback;
|
||||
let mockIntersectionObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
mockIntersectionObserver = jest.fn((callback) => {
|
||||
intersectionCallback = callback;
|
||||
return {
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
global.IntersectionObserver = mockIntersectionObserver;
|
||||
it('creates intersection observer after first resize', () => {
|
||||
cleanup = scrollToTargetOnResize({
|
||||
targetId: 'target-element',
|
||||
container: '#content-body',
|
||||
});
|
||||
|
||||
it('sets up intersection observer after first scroll', () => {
|
||||
triggerResize();
|
||||
resizeObserverCallback([{ target: document.querySelector('#content-body') }]);
|
||||
|
||||
expect(mockIntersectionObserver).toHaveBeenCalled();
|
||||
expect(mockIntersectionObserver.mock.calls[0][1]).toEqual({
|
||||
root: null,
|
||||
});
|
||||
expect(global.IntersectionObserver).toHaveBeenCalled();
|
||||
expect(observeSpy).toHaveBeenCalledWith(document.getElementById('target-element'));
|
||||
});
|
||||
|
||||
it('cleans up when target is scrolled out of view', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
cleanup = scrollToTargetOnResize({
|
||||
targetId: 'target-element',
|
||||
container: '#content-body',
|
||||
});
|
||||
|
||||
it('cleans up when target is no longer visible', () => {
|
||||
triggerResize();
|
||||
resizeObserverCallback([{ target: document.querySelector('#content-body') }]);
|
||||
|
||||
intersectionCallback([{ isIntersecting: false }]);
|
||||
intersectionCallback([{ isIntersecting: false }]);
|
||||
|
||||
triggerResize();
|
||||
expect(document.documentElement.scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(unobserveSpy).toHaveBeenCalledWith(document.getElementById('target-element'));
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
expect(mockUnobserve).toHaveBeenCalledWith(document.querySelector('#content-body'));
|
||||
|
||||
document.getElementById('target-element').remove();
|
||||
|
||||
document.scrollingElement.scrollTo.mockClear();
|
||||
resizeObserverCallback([{ target: document.querySelector('#content-body') }]);
|
||||
expect(document.scrollingElement.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores large scrollHeight changes', () => {
|
||||
jest
|
||||
.spyOn(document.getElementById('target-element'), 'getBoundingClientRect')
|
||||
.mockReturnValue({ top: 200 });
|
||||
|
||||
cleanup = scrollToTargetOnResize({
|
||||
targetId: 'target-element',
|
||||
container: '#content-body',
|
||||
});
|
||||
|
||||
resizeObserverCallback([{ target: document.querySelector('#content-body') }]);
|
||||
expect(document.scrollingElement.scrollTo).toHaveBeenCalled();
|
||||
|
||||
document.scrollingElement.scrollTo.mockClear();
|
||||
|
||||
document.scrollingElement.scrollHeight = 1200;
|
||||
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
|
||||
resizeObserverCallback([{ target: document.querySelector('#content-body') }]);
|
||||
|
||||
expect(document.scrollingElement.scrollTo).toHaveBeenCalledWith({
|
||||
top: 150,
|
||||
behavior: 'instant',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ import {
|
|||
FILTERED_SEARCH_TERM_KEY,
|
||||
FILTERED_SEARCH_NAMESPACE,
|
||||
SORT_OPTIONS,
|
||||
SORT_DIRECTION_ASC,
|
||||
SORT_DIRECTION_DESC,
|
||||
} from '~/projects/filtered_search_and_sort/constants';
|
||||
import { SORT_DIRECTION_ASC, SORT_DIRECTION_DESC } from '~/groups_projects/constants';
|
||||
import { RECENT_SEARCHES_STORAGE_KEY_PROJECTS } from '~/filtered_search/recent_searches_storage_keys';
|
||||
import FilteredSearchAndSort from '~/groups_projects/components/filtered_search_and_sort.vue';
|
||||
import ProjectsExploreFilteredSearchAndSort from '~/projects/filtered_search_and_sort/components/filtered_search_and_sort.vue';
|
||||
|
|
|
|||
|
|
@ -2,6 +2,17 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
|||
import YourWorkProjectsApp from '~/projects/your_work/components/app.vue';
|
||||
import { PROJECT_DASHBOARD_TABS } from '~/projects/your_work/constants';
|
||||
import TabsWithList from '~/groups_projects/components/tabs_with_list.vue';
|
||||
import {
|
||||
FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
} from '~/groups_projects/constants';
|
||||
import { RECENT_SEARCHES_STORAGE_KEY_PROJECTS } from '~/filtered_search/recent_searches_storage_keys';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
SORT_OPTION_UPDATED,
|
||||
FILTERED_SEARCH_TERM_KEY,
|
||||
FILTERED_SEARCH_NAMESPACE,
|
||||
} from '~/projects/filtered_search_and_sort/constants';
|
||||
|
||||
describe('YourWorkProjectsApp', () => {
|
||||
let wrapper;
|
||||
|
|
@ -17,6 +28,15 @@ describe('YourWorkProjectsApp', () => {
|
|||
it('renders TabsWithList component and passes correct props', () => {
|
||||
expect(wrapper.findComponent(TabsWithList).props()).toEqual({
|
||||
tabs: PROJECT_DASHBOARD_TABS,
|
||||
filteredSearchSupportedTokens: [
|
||||
FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
],
|
||||
filteredSearchTermKey: FILTERED_SEARCH_TERM_KEY,
|
||||
filteredSearchNamespace: FILTERED_SEARCH_NAMESPACE,
|
||||
filteredSearchRecentSearchesStorageKey: RECENT_SEARCHES_STORAGE_KEY_PROJECTS,
|
||||
sortOptions: SORT_OPTIONS,
|
||||
defaultSortOption: SORT_OPTION_UPDATED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import { GlIcon, GlFormCheckbox } from '@gitlab/ui';
|
||||
import { GlFormCheckbox } from '@gitlab/ui';
|
||||
import TodoItem from '~/todos/components/todo_item.vue';
|
||||
import TodoItemTitle from '~/todos/components/todo_item_title.vue';
|
||||
import TodoItemTitleHiddenBySaml from '~/todos/components/todo_item_title_hidden_by_saml.vue';
|
||||
import TodoItemBody from '~/todos/components/todo_item_body.vue';
|
||||
import TodoItemTimestamp from '~/todos/components/todo_item_timestamp.vue';
|
||||
import TodoSnoozedTimestamp from '~/todos/components/todo_snoozed_timestamp.vue';
|
||||
import TodoItemActions from '~/todos/components/todo_item_actions.vue';
|
||||
import { TODO_STATE_DONE, TODO_STATE_PENDING } from '~/todos/constants';
|
||||
import { useFakeDate } from 'helpers/fake_date';
|
||||
|
|
@ -14,15 +15,14 @@ describe('TodoItem', () => {
|
|||
let wrapper;
|
||||
|
||||
const mockCurrentTime = new Date('2024-12-18T13:24:00');
|
||||
const mockForAnHour = new Date('2024-12-18T14:24:00');
|
||||
const mockUntilLaterToday = new Date('2024-12-18T17:24:00');
|
||||
const mockUntilTomorrow = new Date('2024-12-19T08:00:00');
|
||||
const mockUntilNextWeek = new Date('2024-12-25T08:00:00');
|
||||
const mockYesterday = new Date('2024-12-17T08:00:00');
|
||||
const mockForAnHour = '2024-12-18T14:24:00';
|
||||
const mockUntilTomorrow = '2024-12-19T08:00:00';
|
||||
const mockYesterday = '2024-12-17T08:00:00';
|
||||
|
||||
useFakeDate(mockCurrentTime);
|
||||
|
||||
const findTodoItemTimestamp = () => wrapper.findComponent(TodoItemTimestamp);
|
||||
const findTodoSnoozedTimestamp = () => wrapper.findComponent(TodoSnoozedTimestamp);
|
||||
|
||||
const createComponent = (props = {}, todosBulkActions = true) => {
|
||||
wrapper = shallowMount(TodoItem, {
|
||||
|
|
@ -117,28 +117,27 @@ describe('TodoItem', () => {
|
|||
});
|
||||
|
||||
describe('snoozed to-do items', () => {
|
||||
it.each`
|
||||
snoozedUntil | expectedLabel
|
||||
${mockForAnHour} | ${'Snoozed until 2:24 PM'}
|
||||
${mockUntilLaterToday} | ${'Snoozed until 5:24 PM'}
|
||||
${mockUntilTomorrow} | ${'Snoozed until tomorrow, 8:00 AM'}
|
||||
${mockUntilNextWeek} | ${'Snoozed until Dec 25, 2024'}
|
||||
`(
|
||||
'renders "$expectedLabel" when the item is snoozed until a future date ($snoozedUntil)',
|
||||
({ snoozedUntil, expectedLabel }) => {
|
||||
createComponent({
|
||||
todo: {
|
||||
...MR_REVIEW_REQUEST_TODO,
|
||||
snoozedUntil,
|
||||
},
|
||||
});
|
||||
it('does not render the TodoSnoozedTimestamp component when the item is not snoozed', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findTodoItemTimestamp().exists()).toBe(false);
|
||||
expect(wrapper.text()).toBe(expectedLabel);
|
||||
},
|
||||
);
|
||||
expect(findTodoSnoozedTimestamp().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders the creation date when the item has reached its snooze time', () => {
|
||||
it('renders the TodoSnoozedTimestamp component when the item is snoozed until a future date', () => {
|
||||
createComponent({
|
||||
todo: {
|
||||
...MR_REVIEW_REQUEST_TODO,
|
||||
snoozedUntil: mockForAnHour,
|
||||
},
|
||||
});
|
||||
|
||||
const component = findTodoSnoozedTimestamp();
|
||||
expect(component.exists()).toBe(true);
|
||||
expect(component.props('snoozedUntil')).toBe(mockForAnHour);
|
||||
expect(component.props('hasReachedSnoozeTimestamp')).toBe(false);
|
||||
});
|
||||
|
||||
it('renders the TodoSnoozedTimestamp component when the item has reached its snooze time', () => {
|
||||
createComponent({
|
||||
todo: {
|
||||
...MR_REVIEW_REQUEST_TODO,
|
||||
|
|
@ -146,12 +145,10 @@ describe('TodoItem', () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(findTodoItemTimestamp().exists()).toBe(false);
|
||||
expect(wrapper.text()).toBe('First sent 4 months ago');
|
||||
|
||||
const icon = wrapper.findComponent(GlIcon);
|
||||
expect(icon.exists()).toBe(true);
|
||||
expect(icon.props('name')).toBe('clock');
|
||||
const component = findTodoSnoozedTimestamp();
|
||||
expect(component.exists()).toBe(true);
|
||||
expect(component.props('snoozedUntil')).toBe(mockYesterday);
|
||||
expect(component.props('hasReachedSnoozeTimestamp')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import { GlIcon } from '@gitlab/ui';
|
||||
import TodoSnoozedTimestamp from '~/todos/components/todo_snoozed_timestamp.vue';
|
||||
import { useFakeDate } from 'helpers/fake_date';
|
||||
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
|
||||
|
||||
describe('TodoSnoozedTimestamp', () => {
|
||||
let wrapper;
|
||||
|
||||
const mockCurrentTime = '2024-12-18T13:24:00';
|
||||
const mockForAnHour = '2024-12-18T14:24:00';
|
||||
const mockUntilLaterToday = '2024-12-18T17:24:00';
|
||||
const mockUntilTomorrow = '2024-12-19T08:00:00';
|
||||
const mockUntilNextWeek = '2024-12-25T08:00:00';
|
||||
const mockUntilLaterSameWeek = '2024-12-21T08:00:00';
|
||||
const mockYesterday = '2024-12-17T08:00:00';
|
||||
|
||||
useFakeDate(mockCurrentTime);
|
||||
|
||||
const findGlIcon = () => wrapper.findComponent(GlIcon);
|
||||
|
||||
const createComponent = (props = {}) => {
|
||||
wrapper = shallowMount(TodoSnoozedTimestamp, {
|
||||
propsData: {
|
||||
...props,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: createMockDirective('gl-tooltip'),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const expectWithLabel = (label) => {
|
||||
const icon = findGlIcon();
|
||||
const tooltip = getBinding(icon.element, 'gl-tooltip');
|
||||
|
||||
expect(tooltip).toBeDefined();
|
||||
expect(icon.props('name')).toBe('clock');
|
||||
expect(icon.attributes('title')).toBe(label);
|
||||
expect(icon.props('ariaLabel')).toBe(label);
|
||||
};
|
||||
|
||||
it.each`
|
||||
snoozedUntil | expectedLabel
|
||||
${mockForAnHour} | ${'Snoozed until 2:24 PM'}
|
||||
${mockUntilLaterToday} | ${'Snoozed until 5:24 PM'}
|
||||
${mockUntilTomorrow} | ${'Snoozed until tomorrow, 8:00 AM'}
|
||||
${mockUntilLaterSameWeek} | ${'Snoozed until Saturday, 8:00 AM'}
|
||||
${mockUntilNextWeek} | ${'Snoozed until Dec 25, 2024'}
|
||||
`(
|
||||
'renders "$expectedLabel" when the item is snoozed until a future date ($snoozedUntil)',
|
||||
({ snoozedUntil, expectedLabel }) => {
|
||||
createComponent({
|
||||
snoozedUntil,
|
||||
hasReachedSnoozeTimestamp: false,
|
||||
});
|
||||
|
||||
expectWithLabel(expectedLabel);
|
||||
},
|
||||
);
|
||||
|
||||
it('renders the timeago-formatted snoozed date when the item has reached its snooze time', () => {
|
||||
createComponent({
|
||||
snoozedUntil: mockYesterday,
|
||||
hasReachedSnoozeTimestamp: true,
|
||||
});
|
||||
|
||||
expectWithLabel('Previously snoozed');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { GlAvatar, GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import setWindowLocation from 'helpers/set_window_location_helper';
|
||||
import DesignVersionDropdown from '~/work_items/components/design_management/design_version_dropdown.vue';
|
||||
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import { mockAllVersions } from './mock_data';
|
||||
|
|
@ -21,6 +22,7 @@ const MOCK_ROUTE = {
|
|||
};
|
||||
|
||||
describe('Design management design version dropdown component', () => {
|
||||
const $router = { push: jest.fn() };
|
||||
let wrapper;
|
||||
|
||||
function createComponent({ maxVersions = -1, $route = MOCK_ROUTE } = {}) {
|
||||
|
|
@ -33,6 +35,7 @@ describe('Design management design version dropdown component', () => {
|
|||
},
|
||||
mocks: {
|
||||
$route,
|
||||
$router,
|
||||
},
|
||||
stubs: { GlAvatar: true, GlCollapsibleListbox },
|
||||
});
|
||||
|
|
@ -124,5 +127,21 @@ describe('Design management design version dropdown component', () => {
|
|||
|
||||
expect(wrapper.findAllComponents(TimeAgo)).toHaveLength(mockAllVersions.length);
|
||||
});
|
||||
|
||||
it('should update the route when a version is selected', async () => {
|
||||
const originalLocation = window.location.href;
|
||||
setWindowLocation(`${originalLocation}?show=foo`);
|
||||
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
findListbox().vm.$emit('select', mockAllVersions[1].id);
|
||||
|
||||
expect($router.push).toHaveBeenCalledWith({
|
||||
path: MOCK_ROUTE.path,
|
||||
query: { version: '2', show: 'foo' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { GlAlert, GlEmptyState, GlIntersectionObserver } from '@gitlab/ui';
|
|||
import Vue, { nextTick } from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import { useMockInternalEventsTracking } from 'helpers/tracking_internal_events_helper';
|
||||
import { isLoggedIn } from '~/lib/utils/common_utils';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
|
|
@ -1255,4 +1256,87 @@ describe('WorkItemDetail component', () => {
|
|||
expect(findRightSidebar().isVisible()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tracking', () => {
|
||||
const { bindInternalEventDocument } = useMockInternalEventsTracking();
|
||||
|
||||
beforeEach(async () => {
|
||||
createComponent();
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
describe('sidebar visibility tracking', () => {
|
||||
it('tracks when sidebar is toggled', async () => {
|
||||
const { trackEventSpy } = bindInternalEventDocument(wrapper.element);
|
||||
|
||||
findWorkItemActions().vm.$emit('toggleSidebar');
|
||||
await nextTick();
|
||||
|
||||
expect(trackEventSpy).toHaveBeenCalledWith(
|
||||
'change_work_item_sidebar_visibility',
|
||||
{
|
||||
label: 'false',
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
findWorkItemActions().vm.$emit('toggleSidebar');
|
||||
await nextTick();
|
||||
|
||||
expect(trackEventSpy).toHaveBeenCalledWith(
|
||||
'change_work_item_sidebar_visibility',
|
||||
{
|
||||
label: 'true',
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('tracks when show sidebar button is clicked', async () => {
|
||||
const { trackEventSpy } = bindInternalEventDocument(wrapper.element);
|
||||
|
||||
createComponent({ showSidebar: false });
|
||||
await waitForPromises();
|
||||
|
||||
findShowSidebarButton().vm.$emit('click');
|
||||
await nextTick();
|
||||
|
||||
expect(trackEventSpy).toHaveBeenCalledWith(
|
||||
'change_work_item_sidebar_visibility',
|
||||
{
|
||||
label: 'true',
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('description truncation tracking', () => {
|
||||
it('tracks when truncation setting is toggled', async () => {
|
||||
const { trackEventSpy } = bindInternalEventDocument(wrapper.element);
|
||||
|
||||
findWorkItemActions().vm.$emit('toggleTruncationEnabled');
|
||||
await nextTick();
|
||||
|
||||
expect(trackEventSpy).toHaveBeenCalledWith(
|
||||
'change_work_item_description_truncation',
|
||||
{
|
||||
label: 'false',
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
findWorkItemActions().vm.$emit('toggleTruncationEnabled');
|
||||
await nextTick();
|
||||
|
||||
expect(trackEventSpy).toHaveBeenCalledWith(
|
||||
'change_work_item_description_truncation',
|
||||
{
|
||||
label: 'true',
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import { stubComponent } from 'helpers/stub_component';
|
|||
import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures';
|
||||
|
||||
import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
|
||||
import { DETAIL_VIEW_QUERY_PARAM_NAME } from '~/work_items/constants';
|
||||
import {
|
||||
DETAIL_VIEW_QUERY_PARAM_NAME,
|
||||
DETAIL_VIEW_DESIGN_VERSION_PARAM_NAME,
|
||||
} from '~/work_items/constants';
|
||||
import WorkItemDrawer from '~/work_items/components/work_item_drawer.vue';
|
||||
import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
|
||||
import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql';
|
||||
|
|
@ -162,7 +165,10 @@ describe('WorkItemDrawer', () => {
|
|||
|
||||
findGlDrawer().vm.$emit('close');
|
||||
|
||||
expect(removeParams).toHaveBeenCalledWith([DETAIL_VIEW_QUERY_PARAM_NAME]);
|
||||
expect(removeParams).toHaveBeenCalledWith([
|
||||
DETAIL_VIEW_QUERY_PARAM_NAME,
|
||||
DETAIL_VIEW_DESIGN_VERSION_PARAM_NAME,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('`clickOutsideExcludeSelector` prop', () => {
|
||||
|
|
|
|||
|
|
@ -29,12 +29,21 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillPartitionedWebHookLogsDaily,
|
|||
suffix = from.strftime('%Y%m')
|
||||
partition_name = "gitlab_partitions_dynamic.web_hook_logs_#{suffix}"
|
||||
|
||||
current_month_start = Time.current.beginning_of_month
|
||||
current_month_end = current_month_start.end_of_month
|
||||
current_month_suffix = current_month_start.strftime('%Y%m')
|
||||
current_month_partition_name = "gitlab_partitions_dynamic.web_hook_logs_#{current_month_suffix}"
|
||||
|
||||
connection.execute <<~SQL
|
||||
ALTER TABLE web_hook_logs DISABLE TRIGGER ALL; -- Don't sync records to partitioned table
|
||||
|
||||
CREATE TABLE IF NOT EXISTS #{partition_name}
|
||||
PARTITION OF public.web_hook_logs
|
||||
FOR VALUES FROM (#{connection.quote(from)}) TO (#{connection.quote(to)});
|
||||
|
||||
CREATE TABLE IF NOT EXISTS #{current_month_partition_name}
|
||||
PARTITION OF public.web_hook_logs
|
||||
FOR VALUES FROM (#{connection.quote(current_month_start)}) TO (#{connection.quote(current_month_end)});
|
||||
SQL
|
||||
|
||||
create_web_hook_logs(created_at: from)
|
||||
|
|
|
|||
|
|
@ -433,6 +433,41 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics, feature_category: :shar
|
|||
end
|
||||
end
|
||||
|
||||
context 'handling RetryError' do
|
||||
let(:middleware) { described_class.new }
|
||||
let(:worker_class) do
|
||||
Class.new do
|
||||
def self.name
|
||||
"TestWorker"
|
||||
end
|
||||
include ApplicationWorker
|
||||
end
|
||||
end
|
||||
|
||||
let(:worker) { worker_class.new }
|
||||
let(:job) { {} }
|
||||
let(:queue) { :test }
|
||||
|
||||
it 'does not record execution SLI metrics when RetryError is raised' do
|
||||
running_jobs_metric = double('running jobs metric')
|
||||
transaction = double('background transaction')
|
||||
|
||||
allow(middleware).to receive(:metrics).and_return(
|
||||
{ sidekiq_running_jobs: running_jobs_metric }
|
||||
)
|
||||
allow(running_jobs_metric).to receive(:increment)
|
||||
allow(Gitlab::Metrics::BackgroundTransaction).to receive(:new).and_return(transaction)
|
||||
allow(transaction).to receive(:run).and_raise(Gitlab::SidekiqMiddleware::RetryError, "Retry")
|
||||
|
||||
expect(Gitlab::Metrics::SidekiqSlis).not_to receive(:record_execution_apdex)
|
||||
expect(Gitlab::Metrics::SidekiqSlis).not_to receive(:record_execution_error)
|
||||
|
||||
expect do
|
||||
middleware.call(worker, job, queue) { nil }
|
||||
end.to raise_error(Gitlab::SidekiqMiddleware::RetryError, "Retry")
|
||||
end
|
||||
end
|
||||
|
||||
context 'feature attribution' do
|
||||
let(:test_worker) do
|
||||
category = worker_category
|
||||
|
|
|
|||
|
|
@ -592,7 +592,7 @@ RSpec.describe PersonalAccessToken, feature_category: :system_access do
|
|||
end
|
||||
end
|
||||
|
||||
describe '.expired_before' do
|
||||
describe '.expired_after' do
|
||||
let_it_be(:cut_off) { 3.days.ago }
|
||||
|
||||
let_it_be(:active_token) { create(:personal_access_token) }
|
||||
|
|
@ -601,8 +601,8 @@ RSpec.describe PersonalAccessToken, feature_category: :system_access do
|
|||
let_it_be(:token_expired_at_cut_off) { create(:personal_access_token, expires_at: cut_off) }
|
||||
let_it_be(:token_expired_after_cut_off) { create(:personal_access_token, expires_at: cut_off + 1.day) }
|
||||
|
||||
it 'returns tokens that are expired before date passed in' do
|
||||
expect(described_class.expired_before(cut_off)).to contain_exactly(token_expired_before_cut_off)
|
||||
it 'returns tokens that are expired after date passed in' do
|
||||
expect(described_class.expired_after(cut_off)).to contain_exactly(token_expired_at_cut_off, token_expired_after_cut_off)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -616,7 +616,7 @@ RSpec.describe PersonalAccessToken, feature_category: :system_access do
|
|||
end
|
||||
|
||||
describe 'revoke scopes' do
|
||||
let_it_be(:revoked_token) { create(:personal_access_token, :revoked) }
|
||||
let_it_be(:revoked_token) { create(:personal_access_token, :revoked, updated_at: 4.days.ago) }
|
||||
let_it_be(:non_revoked_token) { create(:personal_access_token, revoked: false) }
|
||||
let_it_be(:non_revoked_token2) { create(:personal_access_token, revoked: nil) }
|
||||
|
||||
|
|
@ -628,7 +628,7 @@ RSpec.describe PersonalAccessToken, feature_category: :system_access do
|
|||
it { expect(described_class.not_revoked).to contain_exactly(non_revoked_token, non_revoked_token2) }
|
||||
end
|
||||
|
||||
describe '.revoked_before' do
|
||||
describe '.revoked_after' do
|
||||
let_it_be(:cut_off) { 3.days.ago }
|
||||
|
||||
let_it_be(:token_revoked_before_cut_off) { create(:personal_access_token, :revoked, updated_at: cut_off - 1.second) }
|
||||
|
|
@ -639,8 +639,8 @@ RSpec.describe PersonalAccessToken, feature_category: :system_access do
|
|||
let_it_be(:non_revoked_token_updated_at_cut_off) { create(:personal_access_token, updated_at: cut_off) }
|
||||
let_it_be(:non_revoked_token_updated_after_cut_off) { create(:personal_access_token, updated_at: cut_off + 1.second) }
|
||||
|
||||
it 'returns tokens that are revoked before date passed in' do
|
||||
expect(described_class.revoked_before(cut_off)).to contain_exactly(token_revoked_before_cut_off)
|
||||
it 'returns tokens that are revoked after date passed in' do
|
||||
expect(described_class.revoked_after(cut_off)).to contain_exactly(token_revoked_at_cut_off, token_revoked_after_cut_off)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in New Issue