Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-01-09 12:07:31 +00:00
parent a352bc8e72
commit 1935f3e81b
98 changed files with 1637 additions and 539 deletions

View File

@ -65,7 +65,7 @@ export function getUserProjects(userId, query, options, callback) {
export function updateUserStatus({ emoji, message, availability, clearStatusAfter }) {
const url = buildApiUrl(USER_POST_STATUS_PATH);
return axios.put(url, {
return axios.patch(url, {
emoji,
message,
availability,

View File

@ -83,7 +83,7 @@ export function initIssueApp(issueData, store) {
return undefined;
}
const { fullPath } = el.dataset;
const { fullPath, registerPath, signInPath } = el.dataset;
scrollToTargetOnResize();
@ -99,6 +99,8 @@ export function initIssueApp(issueData, store) {
provide: {
canCreateIncident,
fullPath,
registerPath,
signInPath,
hasIssueWeightsFeature,
},
computed: {

View File

@ -0,0 +1,3 @@
import ObservabilityApp from '~/observability';
ObservabilityApp();

View File

@ -8,7 +8,6 @@ import {
GlFormInputGroup,
GlDropdown,
GlDropdownItem,
GlSprintf,
GlFormGroup,
} from '@gitlab/ui';
import $ from 'jquery';
@ -16,7 +15,8 @@ import SafeHtml from '~/vue_shared/directives/safe_html';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import * as Emoji from '~/emoji';
import { s__ } from '~/locale';
import { TIME_RANGES_WITH_NEVER, AVAILABILITY_STATUS } from './constants';
import { formatDate, newDate, nSecondsAfter, isToday } from '~/lib/utils/datetime_utility';
import { TIME_RANGES_WITH_NEVER, AVAILABILITY_STATUS, NEVER_TIME_RANGE } from './constants';
export default {
components: {
@ -27,7 +27,6 @@ export default {
GlFormInputGroup,
GlDropdown,
GlDropdownItem,
GlSprintf,
GlFormGroup,
EmojiPicker: () => import('~/emoji/components/picker.vue'),
},
@ -56,7 +55,7 @@ export default {
clearStatusAfter: {
type: Object,
required: false,
default: () => ({}),
default: null,
},
currentClearStatusAfter: {
type: String,
@ -80,6 +79,21 @@ export default {
noEmoji() {
return this.emojiTag === '';
},
clearStatusAfterDropdownText() {
if (this.clearStatusAfter === null && this.currentClearStatusAfter.length) {
return this.formatClearStatusAfterDate(new Date(this.currentClearStatusAfter));
}
if (this.clearStatusAfter?.duration?.seconds) {
const clearStatusAfterDate = nSecondsAfter(
newDate(),
this.clearStatusAfter.duration.seconds,
);
return this.formatClearStatusAfterDate(clearStatusAfterDate);
}
return NEVER_TIME_RANGE.label;
},
},
mounted() {
this.setupEmojiListAndAutocomplete();
@ -124,6 +138,13 @@ export default {
this.$emit('message-input', '');
this.clearEmoji();
},
formatClearStatusAfterDate(date) {
if (isToday(date)) {
return formatDate(date, 'h:MMtt');
}
return formatDate(date, 'mmm d, yyyy h:MMtt');
},
},
TIME_RANGES_WITH_NEVER,
AVAILABILITY_STATUS,
@ -202,7 +223,7 @@ export default {
<gl-form-group :label="$options.i18n.clearStatusAfterDropdownLabel" class="gl-mb-0">
<gl-dropdown
block
:text="clearStatusAfter.label"
:text="clearStatusAfterDropdownText"
data-testid="clear-status-at-dropdown"
toggle-class="gl-mb-0 gl-form-input-md"
>
@ -214,14 +235,6 @@ export default {
>{{ after.label }}</gl-dropdown-item
>
</gl-dropdown>
<template v-if="currentClearStatusAfter.length" #description>
<span data-testid="clear-status-at-message">
<gl-sprintf :message="$options.i18n.clearStatusAfterMessage">
<template #date>{{ currentClearStatusAfter }}</template>
</gl-sprintf>
</span>
</template>
</gl-form-group>
</div>
</template>

View File

@ -6,8 +6,8 @@ import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { s__ } from '~/locale';
import { updateUserStatus } from '~/rest_api';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { isUserBusy } from './utils';
import { NEVER_TIME_RANGE, AVAILABILITY_STATUS } from './constants';
import { isUserBusy, computedClearStatusAfterValue } from './utils';
import { AVAILABILITY_STATUS } from './constants';
import SetStatusForm from './set_status_form.vue';
Vue.use(GlToast);
@ -53,9 +53,17 @@ export default {
message: this.currentMessage,
modalId: 'set-user-status-modal',
availability: isUserBusy(this.currentAvailability),
clearStatusAfter: NEVER_TIME_RANGE,
clearStatusAfter: null,
};
},
computed: {
shouldIncludeClearStatusAfterInApiRequest() {
return this.clearStatusAfter !== null;
},
clearStatusAfterApiRequestValue() {
return computedClearStatusAfterValue(this.clearStatusAfter);
},
},
mounted() {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
@ -70,14 +78,21 @@ export default {
this.setStatus();
},
setStatus() {
const { emoji, message, availability, clearStatusAfter } = this;
const {
emoji,
message,
availability,
shouldIncludeClearStatusAfterInApiRequest,
clearStatusAfterApiRequestValue,
} = this;
updateUserStatus({
emoji,
message,
availability: availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET,
clearStatusAfter:
clearStatusAfter.label === NEVER_TIME_RANGE.label ? null : clearStatusAfter.shortcut,
...(shouldIncludeClearStatusAfterInApiRequest
? { clearStatusAfter: clearStatusAfterApiRequestValue }
: {}),
})
.then(this.onUpdateSuccess)
.catch(this.onUpdateFail);

View File

@ -1,9 +1,7 @@
<script>
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import dateFormat from '~/lib/dateformat';
import SetStatusForm from './set_status_form.vue';
import { isUserBusy } from './utils';
import { NEVER_TIME_RANGE, AVAILABILITY_STATUS } from './constants';
import { isUserBusy, computedClearStatusAfterValue } from './utils';
import { AVAILABILITY_STATUS } from './constants';
export default {
components: { SetStatusForm },
@ -13,15 +11,16 @@ export default {
emoji: this.fields.emoji.value,
message: this.fields.message.value,
availability: isUserBusy(this.fields.availability.value),
clearStatusAfter: NEVER_TIME_RANGE,
clearStatusAfter: null,
currentClearStatusAfter: this.fields.clearStatusAfter.value,
};
},
computed: {
clearStatusAfterInputValue() {
return this.clearStatusAfter.label === NEVER_TIME_RANGE.label
? null
: this.clearStatusAfter.shortcut;
showClearStatusAfterHiddenInput() {
return this.clearStatusAfter !== null;
},
clearStatusAfterHiddenInputValue() {
return computedClearStatusAfterValue(this.clearStatusAfter);
},
availabilityInputValue() {
return this.availability
@ -29,18 +28,6 @@ export default {
: this.$options.AVAILABILITY_STATUS.NOT_SET;
},
},
mounted() {
this.$options.formEl = document.querySelector('form.js-edit-user');
if (!this.$options.formEl) return;
this.$options.formEl.addEventListener('ajax:success', this.handleFormSuccess);
},
beforeDestroy() {
if (!this.$options.formEl) return;
this.$options.formEl.removeEventListener('ajax:success', this.handleFormSuccess);
},
methods: {
handleMessageInput(value) {
this.message = value;
@ -54,24 +41,6 @@ export default {
handleAvailabilityInput(value) {
this.availability = value;
},
handleFormSuccess() {
if (!this.clearStatusAfter?.duration?.seconds) {
this.currentClearStatusAfter = '';
return;
}
const now = new Date();
const currentClearStatusAfterDate = new Date(
now.getTime() + secondsToMilliseconds(this.clearStatusAfter.duration.seconds),
);
this.currentClearStatusAfter = dateFormat(
currentClearStatusAfterDate,
"UTC:yyyy-mm-dd HH:MM:ss 'UTC'",
);
this.clearStatusAfter = NEVER_TIME_RANGE;
},
},
AVAILABILITY_STATUS,
formEl: null,
@ -83,7 +52,12 @@ export default {
<input :value="emoji" type="hidden" :name="fields.emoji.name" />
<input :value="message" type="hidden" :name="fields.message.name" />
<input :value="availabilityInputValue" type="hidden" :name="fields.availability.name" />
<input :value="clearStatusAfterInputValue" type="hidden" :name="fields.clearStatusAfter.name" />
<input
v-if="showClearStatusAfterHiddenInput"
:value="clearStatusAfterHiddenInputValue"
type="hidden"
:name="fields.clearStatusAfter.name"
/>
<set-status-form
default-emoji="speech_balloon"
:emoji="emoji"

View File

@ -1,4 +1,12 @@
import { AVAILABILITY_STATUS } from './constants';
import { AVAILABILITY_STATUS, NEVER_TIME_RANGE } from './constants';
export const isUserBusy = (status = '') =>
Boolean(status.length && status.toLowerCase().trim() === AVAILABILITY_STATUS.BUSY);
export const computedClearStatusAfterValue = (value) => {
if (value === null || value.name === NEVER_TIME_RANGE.name) {
return null;
}
return value.shortcut;
};

View File

@ -0,0 +1,223 @@
<script>
import { GlAvatar, GlButton } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { helpPagePath } from '~/helpers/help_page_helper';
import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import { getWorkItemQuery } from '../utils';
import createNoteMutation from '../graphql/create_work_item_note.mutation.graphql';
import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
import WorkItemNoteSignedOut from './work_item_note_signed_out.vue';
import WorkItemCommentLocked from './work_item_comment_locked.vue';
export default {
constantOptions: {
markdownDocsPath: helpPagePath('user/markdown'),
avatarUrl: window.gon.current_user_avatar_url,
},
components: {
GlAvatar,
GlButton,
MarkdownEditor,
WorkItemNoteSignedOut,
WorkItemCommentLocked,
},
mixins: [glFeatureFlagMixin(), Tracking.mixin()],
props: {
workItemId: {
type: String,
required: true,
},
fullPath: {
type: String,
required: true,
},
fetchByIid: {
type: Boolean,
required: false,
default: false,
},
queryVariables: {
type: Object,
required: true,
},
},
data() {
return {
workItem: {},
isEditing: false,
isSubmitting: false,
isSubmittingWithKeydown: false,
commentText: '',
};
},
apollo: {
workItem: {
query() {
return getWorkItemQuery(this.fetchByIid);
},
variables() {
return this.queryVariables;
},
update(data) {
return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
},
skip() {
return !this.queryVariables.id && !this.queryVariables.iid;
},
error() {
this.$emit('error', i18n.fetchError);
},
},
},
computed: {
signedIn() {
return Boolean(window.gon.current_user_id);
},
autosaveKey() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `${this.workItemId}-comment`;
},
canEdit() {
// maybe this should use `NotePermissions.updateNote`, but if
// we don't have any notes yet, that permission isn't on WorkItem
return Boolean(this.workItem?.userPermissions?.updateWorkItem);
},
tracking() {
return {
category: TRACKING_CATEGORY_SHOW,
label: 'item_comment',
property: `type_${this.workItemType}`,
};
},
workItemType() {
return this.workItem?.workItemType?.name;
},
markdownPreviewPath() {
return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${
this.workItemType
}`;
},
isProjectArchived() {
return this.workItem?.project?.archived;
},
},
methods: {
startEditing() {
this.isEditing = true;
this.commentText = getDraft(this.autosaveKey) || '';
},
async cancelEditing() {
if (this.commentText) {
const msg = s__('WorkItem|Are you sure you want to cancel editing?');
const confirmed = await confirmAction(msg, {
primaryBtnText: __('Discard changes'),
cancelBtnText: __('Continue editing'),
});
if (!confirmed) {
return;
}
}
this.isEditing = false;
clearDraft(this.autosaveKey);
},
async updateWorkItem(event = {}) {
const { key } = event;
if (key) {
this.isSubmittingWithKeydown = true;
}
this.isSubmitting = true;
try {
this.track('add_work_item_comment');
const {
data: { createNote },
} = await this.$apollo.mutate({
mutation: createNoteMutation,
variables: {
input: {
noteableId: this.workItem.id,
body: this.commentText,
},
},
});
if (createNote.errors?.length) {
throw new Error(createNote.errors[0]);
}
this.isEditing = false;
clearDraft(this.autosaveKey);
} catch (error) {
this.$emit('error', error.message);
Sentry.captureException(error);
}
this.isSubmitting = false;
},
setCommentText(newText) {
this.commentText = newText;
updateDraft(this.autosaveKey, this.commentText);
},
},
};
</script>
<template>
<li class="timeline-entry">
<work-item-note-signed-out v-if="!signedIn" />
<work-item-comment-locked
v-else-if="!canEdit"
:work-item-type="workItemType"
:is-project-archived="isProjectArchived"
/>
<div v-else class="gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap">
<gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" />
<form v-if="isEditing" class="common-note-form gfm-form js-main-target-form gl-flex-grow-1">
<markdown-editor
class="gl-mb-3"
:value="commentText"
:render-markdown-path="markdownPreviewPath"
:markdown-docs-path="$options.constantOptions.markdownDocsPath"
:form-field-aria-label="__('Add a comment')"
:form-field-placeholder="__('Write a comment or drag your files here…')"
form-field-id="work-item-add-comment"
form-field-name="work-item-add-comment"
enable-autocomplete
autofocus
use-bottom-toolbar
@input="setCommentText"
@keydown.meta.enter="updateWorkItem"
@keydown.ctrl.enter="updateWorkItem"
@keydown.esc="cancelEditing"
/>
<gl-button
category="primary"
variant="confirm"
:loading="isSubmitting"
@click="updateWorkItem"
>{{ __('Comment') }}
</gl-button>
<gl-button category="tertiary" class="gl-ml-3" @click="cancelEditing"
>{{ __('Cancel') }}
</gl-button>
</form>
<gl-button
v-else
class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!"
@click="startEditing"
>{{ __('Add a comment') }}</gl-button
>
</div>
</li>
</template>

View File

@ -0,0 +1,66 @@
<script>
import { GlLink, GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import { TASK_TYPE_NAME } from '~/work_items/constants';
export default {
components: {
GlIcon,
GlLink,
},
props: {
workItemType: {
required: false,
type: String,
default: TASK_TYPE_NAME,
},
isProjectArchived: {
required: false,
type: Boolean,
default: false,
},
},
constantOptions: {
archivedProjectDocsPath: helpPagePath('user/project/settings/index.md', {
anchor: 'archive-a-project',
}),
lockedIssueDocsPath: helpPagePath('user/discussions/index.md', {
anchor: 'prevent-comments-by-locking-the-discussion',
}),
projectArchivedWarning: __('This project is archived and cannot be commented on.'),
},
computed: {
issuableDisplayName() {
return this.workItemType.replace(/_/g, ' ');
},
lockedIssueWarning() {
return sprintf(
__('This %{issuableDisplayName} is locked. Only project members can comment.'),
{ issuableDisplayName: this.issuableDisplayName },
);
},
},
};
</script>
<template>
<div class="disabled-comment text-center">
<span class="issuable-note-warning gl-display-inline-block">
<gl-icon name="lock" class="gl-mr-2" />
<template v-if="isProjectArchived">
{{ $options.constantOptions.projectArchivedWarning }}
<gl-link :href="$options.constantOptions.archivedProjectDocsPath" class="learn-more">
{{ __('Learn more') }}
</gl-link>
</template>
<template v-else>
{{ lockedIssueWarning }}
<gl-link :href="$options.constantOptions.lockedIssueDocsPath" class="learn-more">
{{ __('Learn more') }}
</gl-link>
</template>
</span>
</div>
</template>

View File

@ -633,7 +633,7 @@ export default {
@addWorkItemChild="addChild"
@removeChild="removeChild"
/>
<template v-if="workItemsMvc2Enabled">
<template v-if="workItemsMvcEnabled">
<work-item-notes
v-if="workItemNotes"
:work-item-id="workItem.id"

View File

@ -18,6 +18,8 @@ export default function initWorkItemLinks() {
iid,
wiHasIterationsFeature,
wiHasIssuableHealthStatusFeature,
registerPath,
signInPath,
} = workItemLinksRoot.dataset;
// eslint-disable-next-line no-new
@ -35,6 +37,8 @@ export default function initWorkItemLinks() {
hasIssueWeightsFeature: wiHasIssueWeightsFeature,
hasIterationsFeature: wiHasIterationsFeature,
hasIssuableHealthStatusFeature: wiHasIssuableHealthStatusFeature,
registerPath,
signInPath,
},
render: (createElement) =>
createElement('work-item-links', {

View File

@ -0,0 +1,31 @@
<script>
import SafeHtml from '~/vue_shared/directives/safe_html';
import { __, sprintf } from '~/locale';
export default {
directives: {
SafeHtml,
},
inject: ['registerPath', 'signInPath'],
computed: {
signedOutText() {
return sprintf(
__(
'Please %{startTagRegister}register%{endRegisterTag} or %{startTagSignIn}sign in%{endSignInTag} to reply',
),
{
startTagRegister: `<a href="${this.registerPath}">`,
startTagSignIn: `<a href="${this.signInPath}">`,
endRegisterTag: '</a>',
endSignInTag: '</a>',
},
false,
);
},
},
};
</script>
<template>
<div v-safe-html="signedOutText" class="disabled-comment gl-text-center"></div>
</template>

View File

@ -6,6 +6,7 @@ import ActivityFilter from '~/work_items/components/notes/activity_filter.vue';
import { i18n, DEFAULT_PAGE_SIZE_NOTES } from '~/work_items/constants';
import { ASC, DESC } from '~/notes/constants';
import { getWorkItemNotesQuery } from '~/work_items/utils';
import WorkItemCommentForm from './work_item_comment_form.vue';
export default {
i18n: {
@ -17,29 +18,34 @@ export default {
height: 40,
},
components: {
SystemNote,
GlSkeletonLoader,
ActivityFilter,
GlIntersectionObserver,
SystemNote,
WorkItemCommentForm,
},
props: {
workItemId: {
type: String,
required: true,
},
queryVariables: {
type: Object,
required: true,
},
fullPath: {
type: String,
required: true,
},
workItemType: {
type: String,
required: true,
},
fetchByIid: {
type: Boolean,
required: false,
default: false,
},
queryVariables: {
type: Object,
required: true,
},
workItemType: {
type: String,
required: true,
},
},
data() {
return {
@ -57,6 +63,9 @@ export default {
pageInfo() {
return this.workItemNotes?.pageInfo;
},
avatarUrl() {
return window.gon.current_user_avatar_url;
},
hasNextPage() {
return this.pageInfo?.hasNextPage;
},
@ -196,6 +205,12 @@ export default {
:note="note.notes.nodes[0]"
:data-testid="note.notes.nodes[0].id"
/>
<work-item-comment-form
:query-variables="queryVariables"
:full-path="fullPath"
:work-item-id="workItemId"
@error="$emit('error', $event)"
/>
</ul>
</template>

View File

@ -0,0 +1,5 @@
mutation createWorkItemNote($input: CreateNoteInput!) {
createNote(input: $input) {
errors
}
}

View File

@ -12,6 +12,7 @@ fragment WorkItem on WorkItem {
project {
id
fullPath
archived
}
workItemType {
id

View File

@ -10,6 +10,8 @@ export const initWorkItemsRoot = () => {
fullPath,
hasIssueWeightsFeature,
issuesListPath,
registerPath,
signInPath,
hasIterationsFeature,
hasOkrsFeature,
hasIssuableHealthStatusFeature,
@ -26,6 +28,8 @@ export const initWorkItemsRoot = () => {
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasOkrsFeature: parseBoolean(hasOkrsFeature),
issuesListPath,
registerPath,
signInPath,
hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
},

View File

@ -392,7 +392,7 @@ $gl-font-size-large: 16px;
$gl-font-weight-normal: 400;
$gl-font-weight-bold: 600;
$gl-text-color: $gray-900;
$gl-text-color-secondary: $gray-500;
$gl-text-color-secondary: $gray-500 !default;
$gl-text-color-tertiary: $gray-400;
$gl-text-color-quaternary: #d6d6d6;
$gl-text-color-inverted: $white;

View File

@ -79,11 +79,11 @@
@include gl-py-0;
@include gl-px-1;
@include gl-m-0;
@include gl-bg-gray-50;
@include gl-border-0;
@include gl-rounded-base;
@include gl-display-inline-flex;
@include gl-text-body;
background: var(--gray-50, $gray-50);
color: var(--gl-text-color, $gl-text-color);
}
.gl-label-scoped {

View File

@ -495,7 +495,7 @@
.protected-branches-list,
.protected-tags-list {
margin-bottom: 30px;
margin-bottom: 32px;
.settings-message {
margin: 0;

View File

@ -591,7 +591,7 @@ svg {
}
.toggle-sidebar-button .collapse-text,
.toggle-sidebar-button .icon-chevron-double-lg-left {
color: #89888d;
color: #bfbfc3;
}
html {
overflow-y: scroll;
@ -1424,7 +1424,7 @@ kbd {
padding: 0 16px;
background-color: #24232a;
border: 0;
color: #89888d;
color: #bfbfc3;
display: flex;
align-items: center;
background-color: #1f1e24;

View File

@ -115,6 +115,8 @@ $data-viz-blue-950: #e9ebff;
$border-white-normal: $border-color;
$gl-text-color-secondary: $gray-700;
$body-bg: $gray-10;
$input-bg: $white;
$input-focus-bg: $white;
@ -130,7 +132,7 @@ $popover-color: $gray-950;
$popover-box-shadow: 0 2px 3px 1px $gray-700;
$popover-arrow-outer-color: $gray-800;
$secondary: $gray-600;
$secondary: $gray-700;
$yiq-text-dark: $gray-50;
$yiq-text-light: $gray-950;

View File

@ -2,6 +2,8 @@
class AbuseReportsController < ApplicationController
before_action :set_user, :set_ref_url, only: [:new, :add_category]
before_action :save_origin_url, only: [:new, :add_category]
before_action :set_origin_url, only: [:create]
feature_category :insider_threat
@ -55,4 +57,13 @@ class AbuseReportsController < ApplicationController
def set_ref_url
@ref_url = params.fetch(:ref_url, '')
end
def save_origin_url
@origin_url = params.fetch(:ref_url, request.referer)
session[:abuse_report_origin_url] = @origin_url
end
def set_origin_url
@origin_url = session[:abuse_report_origin_url]
end
end

View File

@ -19,6 +19,10 @@ module Groups
render_observability
end
def datasources
render_observability
end
private
def render_observability

View File

@ -11,6 +11,6 @@ module Types
value 'directly_addressed', value: 7, description: 'User was directly addressed.'
value 'merge_train_removed', value: 8, description: 'Merge request authored by the user was removed from the merge train.'
value 'review_requested', value: 9, description: 'Review was requested from the user.'
value 'member_access_requested', value: 10, description: 'Group access requested from the user.'
value 'member_access_requested', value: 10, description: 'Group or project access requested from the user.'
end
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module FormHelper
def form_errors(model, type: 'form', truncate: [])
def form_errors(model, type: 'form', truncate: [], custom_message: [])
errors = model.errors
return unless errors.any?
@ -16,9 +16,15 @@ module FormHelper
messages = errors.map do |error|
attribute = error.attribute
message = error.message
message = html_escape_once(errors.full_message(attribute, message)).html_safe
message = errors.full_message(attribute, error.message)
# When error message is custom and does not follow the default pattern
# "<attribute name> <error message>" (e.g. "You have already reported this
# user"), use the message as-is
message = error.message if custom_message.include?(attribute)
message = html_escape_once(message).html_safe
message = tag.span(message, class: 'str-truncated-100') if truncate.include?(attribute)
message = append_help_page_link(message, error.options) if error.options[:help_page_url].present?

View File

@ -5,15 +5,19 @@ module Groups
ACTION_TO_PATH = {
'dashboards' => {
path: '/',
title: -> { _('Dashboards') }
title: -> { s_('Observability|Dashboards') }
},
'manage' => {
path: '/dashboards',
title: -> { _('Manage Dashboards') }
title: -> { s_('Observability|Manage dashboards') }
},
'explore' => {
path: '/explore',
title: -> { _('Explore') }
title: -> { s_('Observability|Explore') }
},
'datasources' => {
path: '/datasources',
title: -> { s_('Observability|Data sources') }
}
}.freeze

View File

@ -28,7 +28,7 @@ module TodosHelper
when Todo::UNMERGEABLE then s_('Todos|Could not merge')
when Todo::MERGE_TRAIN_REMOVED then s_("Todos|Removed from Merge Train")
when Todo::MEMBER_ACCESS_REQUESTED then format(
s_("Todos|has requested access to group %{which}"), which: _(todo.target.name)
s_("Todos|has requested access to %{what} %{which}"), what: _(todo.member_access_type), which: _(todo.target.name)
)
end
end
@ -99,7 +99,7 @@ module TodosHelper
path_options[:only_path] = true
Gitlab::UrlBuilder.build(todo.target, **path_options)
elsif todo.member_access_requested?
todo.access_request_url
todo.access_request_url(only_path: true)
else
path = [todo.resource_parent, todo.target]

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
module UrlHelper
def escaped_url(url)
Addressable::URI.escape(url)
end
end

View File

@ -4,7 +4,9 @@ module WorkItemsHelper
def work_items_index_data(project)
{
full_path: project.full_path,
issues_list_path: project_issues_path(project)
issues_list_path: project_issues_path(project),
register_path: new_user_registration_path(redirect_to_referer: 'yes'),
sign_in_path: new_session_path(:user, redirect_to_referer: 'yes')
}
end
end

View File

@ -13,7 +13,13 @@ class AbuseReport < ApplicationRecord
validates :user, presence: true
validates :message, presence: true
validates :category, presence: true
validates :user_id, uniqueness: { message: 'has already been reported' }
validates :user_id,
uniqueness: {
scope: :reporter_id,
message: ->(object, data) do
_('You have already reported this user')
end
}
scope :by_user, ->(user) { where(user_id: user) }
scope :with_users, -> { includes(:reporter, :user) }

View File

@ -429,7 +429,9 @@ module Ci
::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do
values = values&.slice(:version, :revision, :platform, :architecture, :ip_address, :config, :executor) || {}
values[:contacted_at] = Time.current
values[:executor_type] = EXECUTOR_NAME_TO_TYPES.fetch(values.delete(:executor), :unknown)
if values.include?(:executor)
values[:executor_type] = EXECUTOR_NAME_TO_TYPES.fetch(values.delete(:executor), :unknown)
end
cache_attributes(values)

View File

@ -531,7 +531,7 @@ class Member < ApplicationRecord
def send_request
notification_service.new_access_request(self)
todo_service.create_member_access_request(self) if source_type != 'Project'
todo_service.create_member_access_request_todos(self)
end
def post_create_hook

View File

@ -204,10 +204,18 @@ class Todo < ApplicationRecord
action == MEMBER_ACCESS_REQUESTED
end
def access_request_url
return "" unless self.target_type == 'Namespace'
def member_access_type
target.class.name.downcase
end
Gitlab::Routing.url_helpers.group_group_members_url(self.target, tab: 'access_requests')
def access_request_url(only_path: false)
if target.instance_of? Group
Gitlab::Routing.url_helpers.group_group_members_url(self.target, tab: 'access_requests', only_path: only_path)
elsif target.instance_of? Project
Gitlab::Routing.url_helpers.project_project_members_url(self.target, tab: 'access_requests', only_path: only_path)
else
""
end
end
def done?

View File

@ -217,7 +217,7 @@ class User < ApplicationRecord
has_many :releases, dependent: :nullify, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :subscriptions, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :abuse_report, dependent: :destroy, foreign_key: :user_id # rubocop:disable Cop/ActiveRecordDependent
has_many :abuse_reports, dependent: :destroy, foreign_key: :user_id # rubocop:disable Cop/ActiveRecordDependent
has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport" # rubocop:disable Cop/ActiveRecordDependent
has_many :spam_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :builds, class_name: 'Ci::Build'

View File

@ -220,7 +220,7 @@ class TodoService
create_todos(reviewers, attributes, project.namespace, project)
end
def create_member_access_request(member)
def create_member_access_request_todos(member)
source = member.source
attributes = attributes_for_access_request_todos(source, member.user, Todo::MEMBER_ACCESS_REQUESTED)
@ -433,7 +433,12 @@ class TodoService
note: note
}
attributes[:group_id] = source.id unless source.instance_of? Project
if source.instance_of? Project
attributes[:project_id] = source.id
attributes[:group_id] = source.group.id if source.group.present?
else
attributes[:group_id] = source.id
end
attributes
end

View File

@ -7,7 +7,7 @@
= _("A member of the abuse team will review your report as soon as possible.")
%hr
= gitlab_ui_form_for @abuse_report, html: { class: 'js-quick-submit js-requires-input'} do |f|
= form_errors(@abuse_report)
= form_errors(@abuse_report, custom_message: [:user_id])
= f.hidden_field :user_id
= f.hidden_field :category
@ -27,3 +27,4 @@
.form-actions
= f.submit _("Send report"), pajamas_button: true
= link_to _('Cancel'), @origin_url, class: "gl-button btn btn-default btn-cancel"

View File

@ -3,7 +3,7 @@
%li.todo.gl-hover-border-blue-200.gl-hover-bg-blue-50.gl-hover-cursor-pointer.gl-relative{ class: "todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo) }
.gl-display-flex.gl-flex-direction-column.gl-sm-flex-direction-row.gl-sm-align-items-center
.todo-item.gl-overflow-hidden.gl-overflow-x-auto.gl-align-self-center.gl-w-full{ data: { qa_selector: "todo_item_container" } }
.todo-title.gl-pt-2.gl-pb-3.gl-px-2.gl-md-mb-1.gl-font-sm.gl-text-gray-500
.todo-title.gl-pt-2.gl-pb-3.gl-px-2.gl-md-mb-1.gl-font-sm.gl-text-secondary
= todo_target_state_pill(todo)
@ -18,7 +18,7 @@
%span.todo-label
- if todo.target
= link_to todo_target_name(todo), todo_target_path(todo), class: 'todo-target-link gl-text-gray-500! gl-text-decoration-none!', :'aria-describedby' => dom_id(todo) + "_describer", :'aria-label' => todo_target_aria_label(todo)
= link_to todo_target_name(todo), todo_target_path(todo), class: 'todo-target-link gl-text-secondary! gl-text-decoration-none!', :'aria-describedby' => dom_id(todo) + "_describer", :'aria-label' => todo_target_aria_label(todo)
- else
= _("(removed)")
@ -58,7 +58,7 @@
= first_line_in_markdown(todo, :body, max_chars, is_todo: true, project: todo.project, group: todo.group)
.todo-timestamp.gl-white-space-nowrap.gl-sm-ml-3.gl-mt-2.gl-mb-2.gl-sm-my-0.gl-px-2.gl-sm-px-0
%span.todo-timestamp.gl-font-sm.gl-text-gray-500
%span.todo-timestamp.gl-font-sm.gl-text-secondary
= todo_due_date(todo)
#{time_ago_with_tooltip(todo.created_at)}

View File

@ -7,7 +7,7 @@
;(function(p,l,o,w,i,n,g){if(!p[i]){p.GlobalSnowplowNamespace=p.GlobalSnowplowNamespace||[];
p.GlobalSnowplowNamespace.push(i);p[i]=function(){(p[i].q=p[i].q||[]).push(arguments)
};p[i].q=p[i].q||[];n=l.createElement(o);g=l.getElementsByTagName(o)[0];n.async=1;
n.src=w;g.parentNode.insertBefore(n,g)}}(window,document,"script","#{asset_url('snowplow/sp.js')}","snowplow"));
n.src=w;g.parentNode.insertBefore(n,g)}}(window,document,"script","#{escaped_url(asset_url('snowplow/sp.js'))}","snowplow"));
window.snowplowOptions = #{Gitlab::Tracking.options(@group).to_json}

View File

@ -1 +1,5 @@
.js-work-item-links-root{ data: { issuable_id: @issue.id, iid: @issue.iid, project_path: @project.full_path, wi: work_items_index_data(@project) } }
.js-work-item-links-root{ data: { issuable_id: @issue.id, iid: @issue.iid,
project_path: @project.full_path,
wi: work_items_index_data(@project),
register_path: new_user_registration_path(redirect_to_referer: 'yes'),
sign_in_path: new_session_path(:user, redirect_to_referer: 'yes') } }

View File

@ -3,7 +3,11 @@
.issue-details.issuable-details.js-issue-details
.detail-page-description.content-block.js-detail-page-description.gl-pb-0.gl-border-none
#js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json, issuable_id: issuable.id, full_path: @project.full_path } }
#js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json,
issuable_id: issuable.id,
full_path: @project.full_path,
register_path: new_user_registration_path(redirect_to_referer: 'yes'),
sign_in_path: new_session_path(:user, redirect_to_referer: 'yes') } }
.title-container
%h1.title.page-title.gl-font-size-h-display= markdown_field(issuable, :title)
- if issuable.description.present?

View File

@ -18,12 +18,7 @@
icon: 'pencil',
button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile', data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }})
- elsif current_user
- if @user.abuse_report
= render Pajamas::ButtonComponent.new(variant: :danger,
icon: 'error',
button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Already reported for abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }})
- else
#js-report-abuse{ data: { form_submit_path: add_category_abuse_reports_path, user_id: @user.id, reported_from_url: user_url(@user) } }
#js-report-abuse{ data: { form_submit_path: add_category_abuse_reports_path, user_id: @user.id, reported_from_url: request.referer || user_url(@user) } }
- verified_gpg_keys = @user.gpg_keys.select(&:verified?)
- if verified_gpg_keys.any?
= render Pajamas::ButtonComponent.new(href: user_gpg_keys_path,

View File

@ -9,7 +9,7 @@ product_category: package registry
value_type: number
status: active
time_frame: all
data_source: redis
data_source: database
distribution:
- ee
- ce

View File

@ -126,6 +126,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
namespace :observability do
get 'dashboards'
get 'explore'
get 'datasources'
get 'manage'
end

View File

@ -31,6 +31,12 @@ Rails experience is useful but not required.
sudo gitlab-rails console
```
**For Docker installations**
```shell
docker exec -it <container-id> gitlab-rails console
```
**For installations from source**
```shell

View File

@ -324,6 +324,14 @@ Read more about [roles](https://docs.gitlab.com/omnibus/roles/).
sudo touch /etc/gitlab/skip-auto-reconfigure
```
1. Only the primary GitLab application server should handle migrations. To
prevent database migrations from running on upgrade, add the following
configuration to your `/etc/gitlab/gitlab.rb` file:
```ruby
gitlab_rails['auto_migrate'] = false
```
1. [Reconfigure Omnibus GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
1. Go through the steps again for all the other replica nodes.

View File

@ -22940,7 +22940,7 @@ Values for sorting timelogs.
| <a id="todoactionenumbuild_failed"></a>`build_failed` | Build triggered by the user failed. |
| <a id="todoactionenumdirectly_addressed"></a>`directly_addressed` | User was directly addressed. |
| <a id="todoactionenummarked"></a>`marked` | User added a to-do item. |
| <a id="todoactionenummember_access_requested"></a>`member_access_requested` | Group access requested from the user. |
| <a id="todoactionenummember_access_requested"></a>`member_access_requested` | Group or project access requested from the user. |
| <a id="todoactionenummentioned"></a>`mentioned` | User was mentioned. |
| <a id="todoactionenummerge_train_removed"></a>`merge_train_removed` | Merge request authored by the user was removed from the merge train. |
| <a id="todoactionenumreview_requested"></a>`review_requested` | Review was requested from the user. |

View File

@ -27,7 +27,7 @@ Parameters:
| `project_id` | integer | no | The ID of a project |
| `group_id` | integer | no | The ID of a group |
| `state` | string | no | The state of the to-do item. Can be either `pending` or `done` |
| `type` | string | no | The type of to-do item. Can be either `Issue`, `MergeRequest`, `Commit`, `Epic`, `DesignManagement::Design`, `AlertManagement::Alert` or `Namespace` |
| `type` | string | no | The type of to-do item. Can be either `Issue`, `MergeRequest`, `Commit`, `Epic`, `DesignManagement::Design` or `AlertManagement::Alert` |
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/todos"

View File

@ -66,7 +66,7 @@ All Work Item types share the same pool of predefined widgets and are customized
| start and due date | |
| status\* | |
| weight | |
| [notes](https://gitlab.com/gitlab-org/gitlab/-/issues/378949) | work_items_mvc_2 |
| [notes](https://gitlab.com/gitlab-org/gitlab/-/issues/378949) | work_items_mvc |
\* status is not currently a widget, but a part of the root work item, similar to title

View File

@ -113,6 +113,23 @@ NOTE:
To protect, update, or unprotect an environment, you must have at least the
Maintainer role.
### Optional settings
#### Allow self-approval
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/381418) in GitLab 15.8.
By default, a user who triggered a deployment pipeline can't self-approve the deployment jobs.
You can allow the self-approval by the following settings:
1. On the top bar, select **Main menu > Projects** and find your project.
1. On the left sidebar, select **Settings > CI/CD**.
1. Expand **Protected environments**.
1. From the **Approval options**, check **Allow pipeline triggerer to approve deployment**.
By enabling this, when a pipeline runs, deployment jobs will automatically be approved in the pipeline
if the triggerer is allowed to approve, otherwise nothing happens.
## Approve or reject a deployment
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/342180/) in GitLab 14.9

View File

@ -21,7 +21,7 @@ If you do not use both of these environments, use the [Jira DVCS Connector](dvcs
We recommend the GitLab.com for Jira Cloud app, because data is
synchronized in real time. The DVCS connector updates data only once per hour.
The user configuring the GitLab.com for Jira Cloud app must have
To configure the GitLab.com for Jira Cloud app, you must have
at least the Maintainer role in the GitLab.com namespace.
This integration method supports [Smart Commits](dvcs.md#smart-commits).
@ -41,14 +41,15 @@ To install the GitLab.com for Jira Cloud app:
This page is always available under **Jira Settings > Apps > Manage apps**.
![Start GitLab.com app configuration on Jira Cloud](img/jira_dev_panel_setup_com_2.png)
1. If not already signed in to GitLab.com, you must sign in as a user with
the Maintainer role to add namespaces.
1. To add namespaces, ensure you're signed in to GitLab.com
as a user with at least the Maintainer role.
![Sign in to GitLab.com in GitLab.com for Jira Cloud app](img/jira_dev_panel_setup_com_3_v13_9.png)
1. To open the list of available namespaces, select **Add namespace**.
1. Identify the namespace you want to link, and select **Link**. Only Jira site
administrators are permitted to add or remove namespaces for an installation.
1. Identify the namespace you want to link, and select **Link**.
- You must have at least the Maintainer role for the namespace.
- Only Jira site administrators can add or remove namespaces for an installation.
![Link namespace in GitLab.com for Jira Cloud app](img/jira_dev_panel_setup_com_4_v13_9.png)

View File

@ -4,7 +4,7 @@ module Gitlab
module Database
module AsyncIndexes
class IndexCreator
include ExclusiveLeaseGuard
include IndexingExclusiveLeaseGuard
TIMEOUT_PER_ACTION = 1.day
STATEMENT_TIMEOUT = 20.hours
@ -47,10 +47,6 @@ module Gitlab
TIMEOUT_PER_ACTION
end
def lease_key
[super, async_index.connection_db_config.name].join('/')
end
def set_statement_timeout
connection.execute("SET statement_timeout TO '%ds'" % STATEMENT_TIMEOUT)
yield

View File

@ -4,7 +4,7 @@ module Gitlab
module Database
module AsyncIndexes
class IndexDestructor
include ExclusiveLeaseGuard
include IndexingExclusiveLeaseGuard
TIMEOUT_PER_ACTION = 1.day
@ -53,10 +53,6 @@ module Gitlab
TIMEOUT_PER_ACTION
end
def lease_key
[super, async_index.connection_db_config.name].join('/')
end
def log_index_info(message)
Gitlab::AppLogger.info(message: message, table_name: async_index.table_name, index_name: async_index.name)
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
module Gitlab
module Database
module IndexingExclusiveLeaseGuard
extend ActiveSupport::Concern
include ExclusiveLeaseGuard
def lease_key
@lease_key ||= "gitlab/database/indexing/actions/#{database_config_name}"
end
def database_config_name
Gitlab::Database.db_config_name(connection)
end
end
end
end

View File

@ -4,7 +4,7 @@ module Gitlab
module Database
module Reindexing
class Coordinator
include ExclusiveLeaseGuard
include IndexingExclusiveLeaseGuard
# Maximum lease time for the global Redis lease
# This should be higher than the maximum time for any
@ -36,22 +36,22 @@ module Gitlab
Gitlab::AppLogger.info("Removing index #{index.identifier} which is a leftover, temporary index from previous reindexing activity")
retries = Gitlab::Database::WithLockRetriesOutsideTransaction.new(
connection: index.connection,
connection: connection,
timing_configuration: REMOVE_INDEX_RETRY_CONFIG,
klass: self.class,
logger: Gitlab::AppLogger
)
retries.run(raise_on_exhaustion: false) do
index.connection.tap do |conn|
conn.execute("DROP INDEX CONCURRENTLY IF EXISTS #{conn.quote_table_name(index.schema)}.#{conn.quote_table_name(index.name)}")
end
connection.execute("DROP INDEX CONCURRENTLY IF EXISTS #{full_index_name}")
end
end
end
private
delegate :connection, to: :index
def with_notifications(action)
notifier.notify_start(action)
yield
@ -73,8 +73,11 @@ module Gitlab
TIMEOUT_PER_ACTION
end
def lease_key
[super, index.connection_db_config.name].join('/')
def full_index_name
[
connection.quote_table_name(index.schema),
connection.quote_table_name(index.name)
].join('.')
end
end
end

View File

@ -215,12 +215,6 @@ module Gitlab
consume_list_refs_response(response)
end
def pack_refs
request = Gitaly::PackRefsRequest.new(repository: @gitaly_repo)
gitaly_client_call(@storage, :ref_service, :pack_refs, request, timeout: GitalyClient.long_timeout)
end
def find_refs_by_oid(oid:, limit:, ref_patterns: nil)
request = Gitaly::FindRefsByOIDRequest.new(repository: @gitaly_repo, sort_field: :refname, oid: oid, limit: limit, ref_patterns: ref_patterns)

View File

@ -34,21 +34,6 @@ module Gitlab
gitaly_client_call(@storage, :repository_service, :prune_unreachable_objects, request, timeout: GitalyClient.long_timeout)
end
def garbage_collect(create_bitmap, prune:)
request = Gitaly::GarbageCollectRequest.new(repository: @gitaly_repo, create_bitmap: create_bitmap, prune: prune)
gitaly_client_call(@storage, :repository_service, :garbage_collect, request, timeout: GitalyClient.long_timeout)
end
def repack_full(create_bitmap)
request = Gitaly::RepackFullRequest.new(repository: @gitaly_repo, create_bitmap: create_bitmap)
gitaly_client_call(@storage, :repository_service, :repack_full, request, timeout: GitalyClient.long_timeout)
end
def repack_incremental
request = Gitaly::RepackIncrementalRequest.new(repository: @gitaly_repo)
gitaly_client_call(@storage, :repository_service, :repack_incremental, request, timeout: GitalyClient.long_timeout)
end
def repository_size
request = Gitaly::RepositorySizeRequest.new(repository: @gitaly_repo)
response = gitaly_client_call(@storage, :repository_service, :repository_size, request, timeout: GitalyClient.long_timeout)

View File

@ -11,8 +11,6 @@ module Gitlab
# [transformed_scope, true] # true indicates that the new scope was successfully built
# [orginal_scope, false] # false indicates that the order values are not supported in this class
class SimpleOrderBuilder
NULLS_ORDER_REGEX = /(?<column_name>.*) (?<direction>\bASC\b|\bDESC\b) (?<nullable>\bNULLS LAST\b|\bNULLS FIRST\b)/.freeze
def self.build(scope)
new(scope: scope).build
end
@ -90,32 +88,6 @@ module Gitlab
end
end
# This method converts the first order value to a corresponding arel expression
# if the order value uses either NULLS LAST or NULLS FIRST ordering in raw SQL.
#
# TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/356644
# We should stop matching raw literals once we switch to using the Arel methods.
def convert_raw_nulls_order!
order_value = order_values.first
return unless order_value.is_a?(Arel::Nodes::SqlLiteral)
# Detect NULLS LAST or NULLS FIRST ordering by looking at the raw SQL string.
if matches = order_value.match(NULLS_ORDER_REGEX)
return unless table_column?(matches[:column_name])
column_attribute = arel_table[matches[:column_name]]
direction = matches[:direction].downcase.to_sym
nullable = matches[:nullable].downcase.parameterize(separator: '_').to_sym
# Build an arel order expression for NULLS ordering.
order = direction == :desc ? column_attribute.desc : column_attribute.asc
arel_order_expression = nullable == :nulls_first ? order.nulls_first : order.nulls_last
order_values[0] = arel_order_expression
end
end
def nullability(order_value, attribute_name)
nullable = model_class.columns.find { |column| column.name == attribute_name }.null
@ -206,16 +178,12 @@ module Gitlab
def ordered_by_other_column?
return unless order_values.one?
convert_raw_nulls_order!
supported_column?(order_values.first)
end
def ordered_by_other_column_with_tie_breaker?
return unless order_values.size == 2
convert_raw_nulls_order!
return unless supported_column?(order_values.first)
tie_breaker_attribute = order_values.second.try(:expr)

View File

@ -8,6 +8,7 @@ module Sidebars
def configure_menu_items
add_item(dashboards_menu_item)
add_item(explore_menu_item)
add_item(datasources_menu_item)
add_item(manage_menu_item)
end
@ -30,7 +31,7 @@ module Sidebars
def dashboards_menu_item
::Sidebars::MenuItem.new(
title: _('Dashboards'),
title: s_('Observability|Dashboards'),
link: group_observability_dashboards_path(context.group),
active_routes: { path: 'groups/observability#dashboards' },
item_id: :dashboards
@ -39,16 +40,25 @@ module Sidebars
def explore_menu_item
::Sidebars::MenuItem.new(
title: _('Explore'),
title: s_('Observability|Explore'),
link: group_observability_explore_path(context.group),
active_routes: { path: 'groups/observability#explore' },
item_id: :explore
)
end
def datasources_menu_item
::Sidebars::MenuItem.new(
title: s_('Observability|Data sources'),
link: group_observability_datasources_path(context.group),
active_routes: { path: 'groups/observability#datasources' },
item_id: :datasources
)
end
def manage_menu_item
::Sidebars::MenuItem.new(
title: _('Manage Dashboards'),
title: s_('Observability|Manage dashboards'),
link: group_observability_manage_path(context.group),
active_routes: { path: 'groups/observability#manage' },
item_id: :manage

View File

@ -2209,6 +2209,9 @@ msgstr ""
msgid "Add a collapsible section"
msgstr ""
msgid "Add a comment"
msgstr ""
msgid "Add a comment to this line"
msgstr ""
@ -4953,6 +4956,9 @@ msgstr ""
msgid "Applying suggestions..."
msgstr ""
msgid "Approval options"
msgstr ""
msgid "Approval rules"
msgstr ""
@ -13821,6 +13827,9 @@ msgstr ""
msgid "Deployment frequency"
msgstr ""
msgid "DeploymentApprovals|Allow pipeline triggerer to approve deployment."
msgstr ""
msgid "DeploymentApprovals|Approvals"
msgstr ""
@ -13836,6 +13845,12 @@ msgstr ""
msgid "DeploymentApprovals|Maintainers"
msgstr ""
msgid "DeploymentApprovals|Set how approval rules are applied to deployments in protected environments."
msgstr ""
msgid "DeploymentApprovals|When a pipeline runs, deployment jobs will automatically be approved in the pipeline if the triggerer is allowed to approve."
msgstr ""
msgid "DeploymentApproval| Current approvals: %{current}"
msgstr ""
@ -25298,9 +25313,6 @@ msgstr ""
msgid "Manage %{workspace} labels"
msgstr ""
msgid "Manage Dashboards"
msgstr ""
msgid "Manage Web IDE features."
msgstr ""
@ -27228,24 +27240,36 @@ msgid_plural "NamespaceStorageSize|%{namespace_name} contains %{locked_project_c
msgstr[0] ""
msgstr[1] ""
msgid "NamespaceStorageSize|%{namespace_name} is now read-only. You cannot: %{base_message}"
msgid "NamespaceStorageSize|%{namespace_name} is now read-only. Projects under this namespace are locked and actions are restricted."
msgstr ""
msgid "NamespaceStorageSize|If you reach 100%% storage capacity, you will not be able to: %{base_message}"
msgid "NamespaceStorageSize|If %{namespace_name} exceeds the storage quota, all projects in the namespace will be locked and actions will be restricted."
msgstr ""
msgid "NamespaceStorageSize|Please purchase additional storage to unlock your projects over the free %{free_size_limit} project limit. You can't %{base_message}"
msgid "NamespaceStorageSize|If you reach 100%% storage capacity, you will not be able to: %{repository_limits_description}"
msgstr ""
msgid "NamespaceStorageSize|You have consumed all of your additional storage, please purchase more to unlock your projects over the free %{free_size_limit} limit. You can't %{base_message}"
msgid "NamespaceStorageSize|Manage your storage usage or, if you are a namespace Owner, purchase additional storage."
msgstr ""
msgid "NamespaceStorageSize|You have reached %{usage_in_percent} of %{namespace_name}'s storage capacity (%{used_storage} of %{storage_limit})"
msgid "NamespaceStorageSize|Please purchase additional storage to unlock your projects over the free %{free_size_limit} project limit. You can't %{repository_limits_description}"
msgstr ""
msgid "NamespaceStorageSize|Which actions are restricted?"
msgstr ""
msgid "NamespaceStorageSize|Which actions become restricted?"
msgstr ""
msgid "NamespaceStorageSize|You have consumed all of your additional storage, please purchase more to unlock your projects over the free %{free_size_limit} limit. You can't %{repository_limits_description}"
msgstr ""
msgid "NamespaceStorageSize|You have reached the free storage limit of %{free_size_limit} on one or more projects."
msgstr ""
msgid "NamespaceStorageSize|You have used %{usage_in_percent} of the storage quota for %{namespace_name} (%{used_storage} of %{storage_limit})"
msgstr ""
msgid "NamespaceStorageSize|push to your repository, create pipelines, create issues or add comments. To reduce storage capacity, delete unused repositories, artifacts, wikis, issues, and pipelines."
msgstr ""
@ -28581,6 +28605,18 @@ msgstr ""
msgid "Observability"
msgstr ""
msgid "Observability|Dashboards"
msgstr ""
msgid "Observability|Data sources"
msgstr ""
msgid "Observability|Explore"
msgstr ""
msgid "Observability|Manage dashboards"
msgstr ""
msgid "Oct"
msgstr ""
@ -43836,7 +43872,7 @@ msgstr ""
msgid "Todos|added a to-do item"
msgstr ""
msgid "Todos|has requested access to group %{which}"
msgid "Todos|has requested access to %{what} %{which}"
msgstr ""
msgid "Todos|mentioned %{who}"
@ -45469,9 +45505,6 @@ msgstr ""
msgid "UserProfile|Activity"
msgstr ""
msgid "UserProfile|Already reported for abuse"
msgstr ""
msgid "UserProfile|Blocked user"
msgstr ""
@ -47961,7 +47994,7 @@ msgstr ""
msgid "You cannot add any more epics. This epic already has maximum number of child epics."
msgstr ""
msgid "You cannot approve your own deployment."
msgid "You cannot approve your own deployment. This configuration can be adjusted in the protected environment settings."
msgstr ""
msgid "You cannot combine replace_ids with add_ids or remove_ids"
@ -48083,6 +48116,9 @@ msgid_plural "You have %{pendingMembersCount} pending members that need approval
msgstr[0] ""
msgstr[1] ""
msgid "You have already reported this user"
msgstr ""
msgid "You have been granted %{access_level} access to the %{source_link} %{source_type}."
msgstr ""

View File

@ -53,6 +53,9 @@ module RuboCop
# Don't flag methods with parameters.
return if send_node.each_ancestor(:def).first&.arguments&.any?
# Don't flag singleton methods.
return if send_node.each_ancestor(:defs).any?
corrector = autocorrect_pure_definitions(node.parent, body) if node.parent.def_type?
add_offense(send_node, &corrector)

View File

@ -54,7 +54,7 @@ module Glfm
# Ensure that the diff output is flushed and output before we raise and exit.
$stderr.flush
raise('ERROR: The generated files are not up to date.')
raise('ERROR: The generated files are not up to date. The specification or examples may need to be updated. See https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#workflows')
end
end
end

View File

@ -99,7 +99,7 @@ gitlab:
cpu: 746m
memory: 2809Mi
limits:
cpu: 1300m
cpu: 1400m
memory: 4214Mi
minReplicas: 1
maxReplicas: 1

View File

@ -3,16 +3,24 @@
require 'spec_helper'
RSpec.describe 'Abuse reports', feature_category: :insider_threat do
let_it_be(:another_user) { create(:user) }
let_it_be(:abusive_user) { create(:user, username: 'abuser_mcabusive') }
let_it_be(:reporter1) { create(:user, username: 'reporter_mcreporty') }
let_it_be(:reporter2) { create(:user) }
let_it_be(:project) { create(:project, :public) }
let_it_be(:issue) { create(:issue, project: project, author: another_user) }
let_it_be(:issue) { create(:issue, project: project, author: abusive_user) }
before do
sign_in(create(:user))
let!(:group) do
create(:group).tap do |g|
g.add_owner(reporter1)
g.add_developer(abusive_user)
end
end
it 'report abuse from an issue', :js do
before do
sign_in(reporter1)
end
it 'allows a user to be reported for abuse from an issue', :js do
visit project_issue_path(project, issue)
click_button 'Issue actions'
@ -20,18 +28,13 @@ RSpec.describe 'Abuse reports', feature_category: :insider_threat do
wait_for_requests
fill_in 'abuse_report_message', with: 'This user sends spam'
click_button 'Send report'
fill_and_submit_form
expect(page).to have_content 'Thank you for your report'
visit user_path(another_user)
expect(page).to have_button('Already reported for abuse')
end
it 'report abuse from profile', :js do
visit user_path(another_user)
it 'allows a user to be reported for abuse from their profile', :js do
visit user_path(abusive_user)
click_button 'Report abuse to administrator'
@ -40,13 +43,96 @@ RSpec.describe 'Abuse reports', feature_category: :insider_threat do
wait_for_requests
fill_in 'abuse_report_message', with: 'This user sends spam'
click_button 'Send report'
fill_and_submit_form
expect(page).to have_content 'Thank you for your report'
visit user_path(another_user)
visit user_path(abusive_user)
expect(page).to have_button('Already reported for abuse')
click_button 'Report abuse to administrator'
choose "They're posting spam."
click_button 'Next'
fill_and_submit_form
expect(page).to have_content 'You have already reported this user'
end
it 'allows multiple users to report a user', :js do
visit user_path(abusive_user)
click_button 'Report abuse to administrator'
choose "They're posting spam."
click_button 'Next'
wait_for_requests
fill_and_submit_form
expect(page).to have_content 'Thank you for your report'
sign_out(reporter1)
sign_in(reporter2)
visit user_path(abusive_user)
click_button 'Report abuse to administrator'
choose "They're posting spam."
click_button 'Next'
wait_for_requests
fill_and_submit_form
expect(page).to have_content 'Thank you for your report'
end
describe 'Cancel', :js do
context 'when ref_url is not present (e.g. visit user page then click on report abuse)' do
it 'links the user back to where abuse report was triggered' do
origin_url = user_path(abusive_user)
visit origin_url
click_button 'Report abuse to administrator'
choose "They're posting spam."
click_button 'Next'
wait_for_requests
click_link 'Cancel'
expect(page).to have_current_path(origin_url)
end
end
context 'when ref_url is present (e.g. user is reported from one of their MRs)' do
it 'links the user back to ref_url' do
ref_url = group_group_members_path(group)
visit ref_url
# visit abusive user's profile page
page.first('.js-user-link').click
click_button 'Report abuse to administrator'
choose "They're posting spam."
click_button 'Next'
click_link 'Cancel'
expect(page).to have_current_path(ref_url)
end
end
end
private
def fill_and_submit_form
fill_in 'abuse_report_message', with: 'This user sends spam'
click_button 'Send report'
end
end

View File

@ -474,27 +474,30 @@ RSpec.describe 'Dashboard Todos', feature_category: :team_planning do
end
end
context 'User has a todo for an access requested raised for group membership' do
let_it_be(:group) { create(:group, :public) }
context 'User requested access' do
shared_examples 'has todo present with access request content' do
specify do
create(:todo, :member_access_requested,
user: user,
target: target,
author: author
)
target.add_owner(user)
let_it_be(:todo) do
create(:todo, :member_access_requested,
user: user,
target: group,
author: author,
group: group)
sign_in(user)
visit dashboard_todos_path
expect(page).to have_selector('.todos-list .todo', count: 1)
expect(page).to have_content "#{author.name} has requested access to #{target.class.name.downcase} #{target.name}"
end
end
before do
group.add_owner(user)
sign_in(user)
visit dashboard_todos_path
end
it 'has todo present with access request content' do
expect(page).to have_selector('.todos-list .todo', count: 1)
expect(page).to have_content "#{author.name} has requested access to group #{group.name}"
context 'when user requests access to project or group' do
%i[project group].each do |target_type|
it_behaves_like 'has todo present with access request content' do
let_it_be(:target) { create(target_type, :public) }
end
end
end
end
end

View File

@ -62,3 +62,11 @@ export const userDeletionObstacles = [
{ name: 'schedule1', type: OBSTACLE_TYPES.oncallSchedules },
{ name: 'policy1', type: OBSTACLE_TYPES.escalationPolicies },
];
export const userStatus = {
emoji: 'basketball',
message: 'test',
availability: 'busy',
message_html: 'test',
clear_status_at: '2023-01-04T10:00:00.000Z',
};

View File

@ -1,8 +1,13 @@
import MockAdapter from 'axios-mock-adapter';
import { followUser, unfollowUser, associationsCount } from '~/api/user_api';
import { followUser, unfollowUser, associationsCount, updateUserStatus } from '~/api/user_api';
import axios from '~/lib/utils/axios_utils';
import { associationsCount as associationsCountData } from 'jest/admin/users/mock_data';
import {
associationsCount as associationsCountData,
userStatus as mockUserStatus,
} from 'jest/admin/users/mock_data';
import { AVAILABILITY_STATUS } from '~/set_status_modal/constants';
import { timeRanges } from '~/vue_shared/constants';
describe('~/api/user_api', () => {
let axiosMock;
@ -62,4 +67,30 @@ describe('~/api/user_api', () => {
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
});
});
describe('updateUserStatus', () => {
it('calls correct URL and returns expected response', async () => {
const expectedUrl = '/api/v4/user/status';
const expectedData = {
emoji: 'basketball',
message: 'test',
availability: AVAILABILITY_STATUS.BUSY,
clear_status_after: timeRanges[0].shortcut,
};
const expectedResponse = { data: mockUserStatus };
axiosMock.onPatch(expectedUrl).replyOnce(200, expectedResponse);
await expect(
updateUserStatus({
emoji: 'basketball',
message: 'test',
availability: AVAILABILITY_STATUS.BUSY,
clearStatusAfter: timeRanges[0].shortcut,
}),
).resolves.toEqual(expect.objectContaining({ data: expectedResponse }));
expect(axiosMock.history.patch[0].url).toBe(expectedUrl);
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual(expectedData);
});
});
});

View File

@ -3,7 +3,9 @@ import {
newDateAsLocaleTime,
nSecondsAfter,
nSecondsBefore,
isToday,
} from '~/lib/utils/datetime/date_calculation_utility';
import { useFakeDate } from 'helpers/fake_date';
describe('newDateAsLocaleTime', () => {
it.each`
@ -66,3 +68,19 @@ describe('nSecondsBefore', () => {
expect(nSecondsBefore(date, seconds)).toEqual(expected);
});
});
describe('isToday', () => {
useFakeDate(2022, 11, 5);
describe('when date is today', () => {
it('returns `true`', () => {
expect(isToday(new Date(2022, 11, 5))).toBe(true);
});
});
describe('when date is not today', () => {
it('returns `false`', () => {
expect(isToday(new Date(2022, 11, 6))).toBe(false);
});
});
});

View File

@ -1,12 +1,15 @@
import $ from 'jquery';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { useFakeDate } from 'helpers/fake_date';
import SetStatusForm from '~/set_status_modal/set_status_form.vue';
import { NEVER_TIME_RANGE } from '~/set_status_modal/constants';
import EmojiPicker from '~/emoji/components/picker.vue';
import { timeRanges } from '~/vue_shared/constants';
import { sprintf } from '~/locale';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
const [thirtyMinutes, , , oneDay] = timeRanges;
describe('SetStatusForm', () => {
let wrapper;
@ -73,17 +76,71 @@ describe('SetStatusForm', () => {
});
});
describe('when clear status after is set', () => {
it('displays value in dropdown toggle button', async () => {
const clearStatusAfter = timeRanges[0];
describe('clear status after dropdown toggle button text', () => {
useFakeDate(2022, 11, 5);
await createComponent({
propsData: {
clearStatusAfter,
},
describe('when clear status after has previously been set', () => {
describe('when date is today', () => {
it('displays time that status will clear', async () => {
await createComponent({
propsData: {
currentClearStatusAfter: '2022-12-05 11:00:00 UTC',
},
});
expect(wrapper.findByRole('button', { name: '11:00am' }).exists()).toBe(true);
});
});
expect(wrapper.findByRole('button', { name: clearStatusAfter.label }).exists()).toBe(true);
describe('when date is not today', () => {
it('displays date and time that status will clear', async () => {
await createComponent({
propsData: {
currentClearStatusAfter: '2022-12-06 11:00:00 UTC',
},
});
expect(wrapper.findByRole('button', { name: 'Dec 6, 2022 11:00am' }).exists()).toBe(true);
});
});
describe('when a new option is choose from the dropdown', () => {
describe('when chosen option is today', () => {
it('displays chosen option as time', async () => {
await createComponent({
propsData: {
clearStatusAfter: thirtyMinutes,
currentClearStatusAfter: '2022-12-05 11:00:00 UTC',
},
});
expect(wrapper.findByRole('button', { name: '12:30am' }).exists()).toBe(true);
});
});
describe('when chosen option is not today', () => {
it('displays chosen option as date and time', async () => {
await createComponent({
propsData: {
clearStatusAfter: oneDay,
currentClearStatusAfter: '2022-12-06 11:00:00 UTC',
},
});
expect(wrapper.findByRole('button', { name: 'Dec 6, 2022 12:00am' }).exists()).toBe(
true,
);
});
});
});
});
describe('when clear status after has not been set', () => {
it('displays `Never`', async () => {
await createComponent();
expect(wrapper.findByRole('button', { name: NEVER_TIME_RANGE.label }).exists()).toBe(true);
});
});
});
@ -131,7 +188,7 @@ describe('SetStatusForm', () => {
await wrapper.findByTestId('thirtyMinutes').trigger('click');
expect(wrapper.emitted('clear-status-after-click')).toEqual([[timeRanges[0]]]);
expect(wrapper.emitted('clear-status-after-click')).toEqual([[thirtyMinutes]]);
});
});
@ -150,20 +207,4 @@ describe('SetStatusForm', () => {
expect(wrapper.findByTestId('no-emoji-placeholder').exists()).toBe(true);
});
});
describe('when `currentClearStatusAfter` prop is set', () => {
it('displays clear status message', async () => {
const date = '2022-08-25 21:14:48 UTC';
await createComponent({
propsData: {
currentClearStatusAfter: date,
},
});
expect(
wrapper.findByText(sprintf(SetStatusForm.i18n.clearStatusAfterMessage, { date })).exists(),
).toBe(true);
});
});
});

View File

@ -1,6 +1,7 @@
import { GlModal, GlFormCheckbox } from '@gitlab/ui';
import { nextTick } from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { useFakeDate } from 'helpers/fake_date';
import { initEmojiMock, clearEmojiMock } from 'helpers/emoji';
import * as UserApi from '~/api/user_api';
import EmojiPicker from '~/emoji/components/picker.vue';
@ -56,7 +57,6 @@ describe('SetStatusModalWrapper', () => {
wrapper.findByPlaceholderText(SetStatusForm.i18n.statusMessagePlaceholder);
const findClearStatusButton = () => wrapper.find('.js-clear-user-status-button');
const findAvailabilityCheckbox = () => wrapper.findComponent(GlFormCheckbox);
const findClearStatusAtMessage = () => wrapper.find('[data-testid="clear-status-at-message"]');
const getEmojiPicker = () => wrapper.findComponent(EmojiPickerStub);
const initModal = async ({ mockOnUpdateSuccess = true, mockOnUpdateFailure = true } = {}) => {
@ -103,10 +103,6 @@ describe('SetStatusModalWrapper', () => {
expect(wrapper.find('[data-testid="clear-status-at-dropdown"]').exists()).toBe(true);
});
it('does not display the clear status at message', () => {
expect(findClearStatusAtMessage().exists()).toBe(false);
});
it('renders emoji picker dropdown with custom positioning', () => {
expect(getEmojiPicker().props()).toMatchObject({
right: false,
@ -138,17 +134,16 @@ describe('SetStatusModalWrapper', () => {
});
describe('with currentClearStatusAfter set', () => {
useFakeDate(2022, 11, 5);
beforeEach(async () => {
await initEmojiMock();
wrapper = createComponent({ currentClearStatusAfter: '2021-01-01 00:00:00 UTC' });
wrapper = createComponent({ currentClearStatusAfter: '2022-12-06 11:00:00 UTC' });
return initModal();
});
it('displays the clear status at message', () => {
const clearStatusAtMessage = findClearStatusAtMessage();
expect(clearStatusAtMessage.exists()).toBe(true);
expect(clearStatusAtMessage.text()).toBe('Your status resets on 2021-01-01 00:00:00 UTC.');
it('displays date and time that status will expire in dropdown toggle button', () => {
expect(wrapper.findByRole('button', { name: 'Dec 6, 2022 11:00am' }).exists()).toBe(true);
});
});
@ -170,33 +165,33 @@ describe('SetStatusModalWrapper', () => {
});
it('clicking "setStatus" submits the user status', async () => {
findModal().vm.$emit('primary');
await nextTick();
// set the availability status
findAvailabilityCheckbox().vm.$emit('input', true);
// set the currentClearStatusAfter to 30 minutes
wrapper.find('[data-testid="thirtyMinutes"]').trigger('click');
await wrapper.find('[data-testid="thirtyMinutes"]').trigger('click');
findModal().vm.$emit('primary');
await nextTick();
const commonParams = {
emoji: defaultEmoji,
message: defaultMessage,
};
expect(UserApi.updateUserStatus).toHaveBeenCalledTimes(2);
expect(UserApi.updateUserStatus).toHaveBeenNthCalledWith(1, {
availability: AVAILABILITY_STATUS.NOT_SET,
clearStatusAfter: null,
...commonParams,
});
expect(UserApi.updateUserStatus).toHaveBeenNthCalledWith(2, {
expect(UserApi.updateUserStatus).toHaveBeenCalledWith({
availability: AVAILABILITY_STATUS.BUSY,
clearStatusAfter: '30_minutes',
...commonParams,
emoji: defaultEmoji,
message: defaultMessage,
});
});
describe('when `Clear status after` field has not been set', () => {
it('does not include `clearStatusAfter` in API request', async () => {
findModal().vm.$emit('primary');
await nextTick();
expect(UserApi.updateUserStatus).toHaveBeenCalledWith({
availability: AVAILABILITY_STATUS.NOT_SET,
emoji: defaultEmoji,
message: defaultMessage,
});
});
});

View File

@ -1,8 +1,6 @@
import { nextTick } from 'vue';
import { cloneDeep } from 'lodash';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { resetHTMLFixture } from 'helpers/fixtures';
import { useFakeDate } from 'helpers/fake_date';
import UserProfileSetStatusWrapper from '~/set_status_modal/user_profile_set_status_wrapper.vue';
import SetStatusForm from '~/set_status_modal/set_status_form.vue';
import { TIME_RANGES_WITH_NEVER, NEVER_TIME_RANGE } from '~/set_status_modal/constants';
@ -51,7 +49,7 @@ describe('UserProfileSetStatusWrapper', () => {
emoji: defaultProvide.fields.emoji.value,
message: defaultProvide.fields.message.value,
availability: true,
clearStatusAfter: NEVER_TIME_RANGE,
clearStatusAfter: null,
currentClearStatusAfter: defaultProvide.fields.clearStatusAfter.value,
});
});
@ -69,27 +67,41 @@ describe('UserProfileSetStatusWrapper', () => {
);
});
describe('when clear status after dropdown is set to `Never`', () => {
it('renders hidden clear status after input with value unset', () => {
createComponent();
describe('when clear status after has previously been set', () => {
describe('when clear status after dropdown is not set', () => {
it('does not render hidden clear status after input', () => {
createComponent();
expect(
findInput(defaultProvide.fields.clearStatusAfter.name).attributes('value'),
).toBeUndefined();
expect(findInput(defaultProvide.fields.clearStatusAfter.name).exists()).toBe(false);
});
});
});
describe('when clear status after dropdown has a value selected', () => {
it('renders hidden clear status after input with value set', async () => {
createComponent();
describe('when clear status after dropdown is set to `Never`', () => {
it('renders hidden clear status after input with value unset', async () => {
createComponent();
findSetStatusForm().vm.$emit('clear-status-after-click', TIME_RANGES_WITH_NEVER[1]);
findSetStatusForm().vm.$emit('clear-status-after-click', NEVER_TIME_RANGE);
await nextTick();
await nextTick();
expect(findInput(defaultProvide.fields.clearStatusAfter.name).attributes('value')).toBe(
TIME_RANGES_WITH_NEVER[1].shortcut,
);
expect(
findInput(defaultProvide.fields.clearStatusAfter.name).attributes('value'),
).toBeUndefined();
});
});
describe('when clear status after dropdown is set to a time range', () => {
it('renders hidden clear status after input with value set', async () => {
createComponent();
findSetStatusForm().vm.$emit('clear-status-after-click', TIME_RANGES_WITH_NEVER[1]);
await nextTick();
expect(findInput(defaultProvide.fields.clearStatusAfter.name).attributes('value')).toBe(
TIME_RANGES_WITH_NEVER[1].shortcut,
);
});
});
});
@ -120,37 +132,4 @@ describe('UserProfileSetStatusWrapper', () => {
expect(findInput(defaultProvide.fields.message.name).attributes('value')).toBe(newMessage);
});
});
describe('when form is successfully submitted', () => {
// 2022-09-02 00:00:00 UTC
useFakeDate(2022, 8, 2);
const form = document.createElement('form');
form.classList.add('js-edit-user');
beforeEach(async () => {
document.body.appendChild(form);
createComponent();
const oneDay = TIME_RANGES_WITH_NEVER[4];
findSetStatusForm().vm.$emit('clear-status-after-click', oneDay);
await nextTick();
form.dispatchEvent(new Event('ajax:success'));
});
afterEach(() => {
resetHTMLFixture();
});
it('updates clear status after dropdown to `Never`', () => {
expect(findSetStatusForm().props('clearStatusAfter')).toBe(NEVER_TIME_RANGE);
});
it('updates `currentClearStatusAfter` prop', () => {
expect(findSetStatusForm().props('currentClearStatusAfter')).toBe('2022-09-03 00:00:00 UTC');
});
});
});

View File

@ -1,5 +1,8 @@
import { isUserBusy } from '~/set_status_modal/utils';
import { AVAILABILITY_STATUS } from '~/set_status_modal/constants';
import { isUserBusy, computedClearStatusAfterValue } from '~/set_status_modal/utils';
import { AVAILABILITY_STATUS, NEVER_TIME_RANGE } from '~/set_status_modal/constants';
import { timeRanges } from '~/vue_shared/constants';
const [thirtyMinutes] = timeRanges;
describe('Set status modal utils', () => {
describe('isUserBusy', () => {
@ -13,4 +16,15 @@ describe('Set status modal utils', () => {
expect(isUserBusy(value)).toBe(result);
});
});
describe('computedClearStatusAfterValue', () => {
it.each`
value | expected
${null} | ${null}
${NEVER_TIME_RANGE} | ${null}
${thirtyMinutes} | ${thirtyMinutes.shortcut}
`('with $value returns $expected', ({ value, expected }) => {
expect(computedClearStatusAfterValue(value)).toBe(expected);
});
});
});

View File

@ -0,0 +1,205 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockTracking } from 'helpers/tracking_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { updateDraft } from '~/lib/utils/autosave';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import WorkItemCommentForm from '~/work_items/components/work_item_comment_form.vue';
import WorkItemCommentLocked from '~/work_items/components/work_item_comment_locked.vue';
import createNoteMutation from '~/work_items/graphql/create_work_item_note.mutation.graphql';
import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import {
workItemResponseFactory,
workItemQueryResponse,
projectWorkItemResponse,
createWorkItemNoteResponse,
} from '../mock_data';
jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
jest.mock('~/lib/utils/autosave');
const workItemId = workItemQueryResponse.data.workItem.id;
describe('WorkItemCommentForm', () => {
let wrapper;
Vue.use(VueApollo);
const mutationSuccessHandler = jest.fn().mockResolvedValue(createWorkItemNoteResponse);
const workItemByIidResponseHandler = jest.fn().mockResolvedValue(projectWorkItemResponse);
let workItemResponseHandler;
const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor);
const setText = (newText) => {
return findMarkdownEditor().vm.$emit('input', newText);
};
const clickSave = () =>
wrapper
.findAllComponents(GlButton)
.filter((button) => button.text().startsWith('Comment'))
.at(0)
.vm.$emit('click', {});
const createComponent = async ({
mutationHandler = mutationSuccessHandler,
canUpdate = true,
workItemResponse = workItemResponseFactory({ canUpdate }),
queryVariables = { id: workItemId },
fetchByIid = false,
signedIn = true,
isEditing = true,
} = {}) => {
workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse);
if (signedIn) {
window.gon.current_user_id = '1';
window.gon.current_user_avatar_url = 'avatar.png';
}
const { id } = workItemQueryResponse.data.workItem;
wrapper = shallowMount(WorkItemCommentForm, {
apolloProvider: createMockApollo([
[workItemQuery, workItemResponseHandler],
[createNoteMutation, mutationHandler],
[workItemByIidQuery, workItemByIidResponseHandler],
]),
propsData: {
workItemId: id,
fullPath: 'test-project-path',
queryVariables,
fetchByIid,
},
stubs: {
MarkdownField,
WorkItemCommentLocked,
},
});
await waitForPromises();
if (isEditing) {
wrapper.findComponent(GlButton).vm.$emit('click');
}
};
describe('adding a comment', () => {
it('calls update widgets mutation', async () => {
const noteText = 'updated desc';
await createComponent({
isEditing: true,
signedIn: true,
});
setText(noteText);
clickSave();
await waitForPromises();
expect(mutationSuccessHandler).toHaveBeenCalledWith({
input: {
noteableId: workItemId,
body: noteText,
},
});
});
it('tracks adding comment', async () => {
await createComponent();
const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
setText('test');
clickSave();
await waitForPromises();
expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'add_work_item_comment', {
category: TRACKING_CATEGORY_SHOW,
label: 'item_comment',
property: 'type_Task',
});
});
it('emits error when mutation returns error', async () => {
const error = 'eror';
await createComponent({
isEditing: true,
mutationHandler: jest.fn().mockResolvedValue({
data: {
createNote: {
note: null,
errors: [error],
},
},
}),
});
setText('updated desc');
clickSave();
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[error]]);
});
it('emits error when mutation fails', async () => {
const error = 'eror';
await createComponent({
isEditing: true,
mutationHandler: jest.fn().mockRejectedValue(new Error(error)),
});
setText('updated desc');
clickSave();
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[error]]);
});
it('autosaves', async () => {
await createComponent({
isEditing: true,
});
setText('updated');
expect(updateDraft).toHaveBeenCalled();
});
});
it('calls the global ID work item query when `fetchByIid` prop is false', async () => {
createComponent({ fetchByIid: false });
await waitForPromises();
expect(workItemResponseHandler).toHaveBeenCalled();
expect(workItemByIidResponseHandler).not.toHaveBeenCalled();
});
it('calls the IID work item query when when `fetchByIid` prop is true', async () => {
await createComponent({ fetchByIid: true, isEditing: false });
expect(workItemResponseHandler).not.toHaveBeenCalled();
expect(workItemByIidResponseHandler).toHaveBeenCalled();
});
it('skips calling the handlers when missing the needed queryVariables', async () => {
await createComponent({ queryVariables: {}, fetchByIid: false, isEditing: false });
expect(workItemResponseHandler).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,41 @@
import { GlLink, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import WorkItemCommentLocked from '~/work_items/components/work_item_comment_locked.vue';
const createComponent = ({ workItemType = 'Task', isProjectArchived = false } = {}) =>
shallowMount(WorkItemCommentLocked, {
propsData: {
workItemType,
isProjectArchived,
},
});
describe('WorkItemCommentLocked', () => {
let wrapper;
const findLockedIcon = () => wrapper.findComponent(GlIcon);
const findLearnMoreLink = () => wrapper.findComponent(GlLink);
it('renders the locked icon', () => {
wrapper = createComponent();
expect(findLockedIcon().props('name')).toBe('lock');
});
it('has the learn more link', () => {
wrapper = createComponent();
expect(findLearnMoreLink().attributes('href')).toBe(
WorkItemCommentLocked.constantOptions.lockedIssueDocsPath,
);
});
describe('when the project is archived', () => {
beforeEach(() => {
wrapper = createComponent({ isProjectArchived: true });
});
it('learn more link is directed to archived project docs path', () => {
expect(findLearnMoreLink().attributes('href')).toBe(
WorkItemCommentLocked.constantOptions.archivedProjectDocsPath,
);
});
});
});

View File

@ -22,6 +22,7 @@ import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue';
import WorkItemLabels from '~/work_items/components/work_item_labels.vue';
import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue';
import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue';
import WorkItemNotes from '~/work_items/components/work_item_notes.vue';
import { i18n } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
@ -81,6 +82,7 @@ describe('WorkItemDetail component', () => {
const findCloseButton = () => wrapper.find('[data-testid="work-item-close"]');
const findWorkItemType = () => wrapper.find('[data-testid="work-item-type"]');
const findHierarchyTree = () => wrapper.findComponent(WorkItemTree);
const findNotesWidget = () => wrapper.findComponent(WorkItemNotes);
const createComponent = ({
isModal = false,
@ -668,4 +670,24 @@ describe('WorkItemDetail component', () => {
});
});
});
describe('notes widget', () => {
it('does not render notes by default', async () => {
createComponent();
await waitForPromises();
expect(findNotesWidget().exists()).toBe(false);
});
it('renders notes when the work_items_mvc flag is on', async () => {
const notesWorkItem = workItemResponseFactory({
notesWidgetPresent: true,
});
const handler = jest.fn().mockResolvedValue(notesWorkItem);
createComponent({ workItemsMvcEnabled: true, handler });
await waitForPromises();
expect(findNotesWidget().exists()).toBe(true);
});
});
});

View File

@ -62,6 +62,7 @@ export const workItemQueryResponse = {
__typename: 'Project',
id: '1',
fullPath: 'test-project-path',
archived: false,
},
workItemType: {
__typename: 'WorkItemType',
@ -156,6 +157,7 @@ export const updateWorkItemMutationResponse = {
__typename: 'Project',
id: '1',
fullPath: 'test-project-path',
archived: false,
},
workItemType: {
__typename: 'WorkItemType',
@ -268,6 +270,7 @@ export const workItemResponseFactory = ({
milestoneWidgetPresent = true,
iterationWidgetPresent = true,
healthStatusWidgetPresent = true,
notesWidgetPresent = true,
confidential = false,
canInviteMembers = false,
allowsScopedLabels = false,
@ -292,6 +295,7 @@ export const workItemResponseFactory = ({
__typename: 'Project',
id: '1',
fullPath: 'test-project-path',
archived: false,
},
workItemType,
userPermissions: {
@ -380,6 +384,23 @@ export const workItemResponseFactory = ({
healthStatus: 'onTrack',
}
: { type: 'MOCK TYPE' },
notesWidgetPresent
? {
__typename: 'WorkItemWidgetNotes',
type: 'NOTES',
discussions: {
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
startCursor: null,
endCursor:
'eyJjcmVhdGVkX2F0IjoiMjAyMi0xMS0xNCAwNDoxOTowMC4wOTkxMTcwMDAgKzAwMDAiLCJpZCI6IjQyNyIsIl9rZCI6Im4ifQ==',
__typename: 'PageInfo',
},
nodes: [],
},
}
: { type: 'MOCK TYPE' },
{
__typename: 'WorkItemWidgetHierarchy',
type: 'HIERARCHY',
@ -409,6 +430,12 @@ export const workItemResponseFactory = ({
},
parent,
},
notesWidgetPresent
? {
__typename: 'WorkItemWidgetNotes',
type: 'NOTES',
}
: { type: 'MOCK TYPE' },
],
},
},
@ -448,6 +475,7 @@ export const createWorkItemMutationResponse = {
__typename: 'Project',
id: '1',
fullPath: 'test-project-path',
archived: false,
},
workItemType: {
__typename: 'WorkItemType',
@ -485,6 +513,7 @@ export const createWorkItemFromTaskMutationResponse = {
__typename: 'Project',
id: '1',
fullPath: 'test-project-path',
archived: false,
},
workItemType: {
__typename: 'WorkItemType',
@ -524,6 +553,7 @@ export const createWorkItemFromTaskMutationResponse = {
__typename: 'Project',
id: '1',
fullPath: 'test-project-path',
archived: false,
},
workItemType: {
__typename: 'WorkItemType',
@ -734,6 +764,7 @@ export const workItemHierarchyEmptyResponse = {
__typename: 'Project',
id: '1',
fullPath: 'test-project-path',
archived: false,
},
userPermissions: {
deleteWorkItem: false,
@ -780,6 +811,7 @@ export const workItemHierarchyNoUpdatePermissionResponse = {
__typename: 'Project',
id: '1',
fullPath: 'test-project-path',
archived: false,
},
confidential: false,
widgets: [
@ -920,6 +952,7 @@ export const workItemHierarchyResponse = {
__typename: 'Project',
id: '1',
fullPath: 'test-project-path',
archived: false,
},
widgets: [
{
@ -992,6 +1025,7 @@ export const workItemObjectiveWithChild = {
__typename: 'Project',
id: '1',
fullPath: 'test-project-path',
archived: false,
},
userPermissions: {
deleteWorkItem: true,
@ -1064,6 +1098,7 @@ export const workItemHierarchyTreeResponse = {
__typename: 'Project',
id: '1',
fullPath: 'test-project-path',
archived: false,
},
widgets: [
{
@ -1146,6 +1181,7 @@ export const changeWorkItemParentMutationResponse = {
__typename: 'Project',
id: '1',
fullPath: 'test-project-path',
archived: false,
},
widgets: [
{
@ -1922,3 +1958,12 @@ export const mockMoreWorkItemNotesResponse = {
},
},
};
export const createWorkItemNoteResponse = {
data: {
createNote: {
errors: [],
__typename: 'CreateNotePayload',
},
},
};

View File

@ -162,6 +162,24 @@ RSpec.describe FormHelper do
end
end
it 'renders custom messages without the attribute name prefix' do
model = double(errors: errors_stub('Error 1'))
model.errors.add(:name, 'is already taken')
model.errors.add(:code_name, 'This code name is not allowed')
allow(model.class).to receive(:human_attribute_name) do |attribute|
attribute.to_s.capitalize
end
errors = helper.form_errors(model, custom_message: [:code_name])
aggregate_failures do
expect(errors).to include('<li>Error 1</li>')
expect(errors).to include('<li>Name is already taken</li>')
expect(errors).to include('<li>This code name is not allowed</li>')
end
end
it 'renders help page links' do
stubbed_errors = ActiveModel::Errors.new(double).tap do |errors|
errors.add(:base, 'No text.', help_page_url: 'http://localhost/doc/user/index.html')

View File

@ -22,6 +22,11 @@ RSpec.describe Groups::ObservabilityHelper do
allow(helper).to receive(:params).and_return({ action: 'explore' })
expect(helper.observability_iframe_src(group)).to eq("#{observability_url}/-/#{group.id}/explore")
end
it 'returns the iframe src for action: datasources' do
allow(helper).to receive(:params).and_return({ action: 'datasources' })
expect(helper.observability_iframe_src(group)).to eq("#{observability_url}/-/#{group.id}/datasources")
end
end
context 'if observability_path exists in params' do
@ -65,6 +70,11 @@ RSpec.describe Groups::ObservabilityHelper do
allow(helper).to receive(:params).and_return({ action: 'explore' })
expect(helper.observability_iframe_src(group)).to eq("#{observability_url}/explore")
end
it 'returns the iframe src without group.id for action: datasources' do
allow(helper).to receive(:params).and_return({ action: 'datasources' })
expect(helper.observability_iframe_src(group)).to eq("#{observability_url}/datasources")
end
end
end
@ -76,7 +86,7 @@ RSpec.describe Groups::ObservabilityHelper do
it 'returns the title for action: manage' do
allow(helper).to receive(:params).and_return({ action: 'manage' })
expect(helper.observability_page_title).to eq("Manage Dashboards")
expect(helper.observability_page_title).to eq("Manage dashboards")
end
it 'returns the title for action: explore' do
@ -84,6 +94,11 @@ RSpec.describe Groups::ObservabilityHelper do
expect(helper.observability_page_title).to eq("Explore")
end
it 'returns the title for action: datasources' do
allow(helper).to receive(:params).and_return({ action: 'datasources' })
expect(helper.observability_page_title).to eq("Data sources")
end
it 'returns the default title for unknown action' do
allow(helper).to receive(:params).and_return({ action: 'unknown' })
expect(helper.observability_page_title).to eq("Dashboards")

View File

@ -43,6 +43,10 @@ RSpec.describe TodosHelper do
create(:todo, target: group)
end
let_it_be(:project_access_request_todo) do
create(:todo, target: project, action: Todo::MEMBER_ACCESS_REQUESTED)
end
describe '#todos_count_format' do
it 'shows fuzzy count for 100 or more items' do
expect(helper.todos_count_format(100)).to eq '99+'
@ -172,7 +176,17 @@ RSpec.describe TodosHelper do
it 'responds with access requests tab' do
path = helper.todo_target_path(group_access_request_todo)
access_request_path = Gitlab::Routing.url_helpers.group_group_members_url(group, tab: 'access_requests')
access_request_path = Gitlab::Routing.url_helpers.group_group_members_path(group, tab: 'access_requests')
expect(path).to eq(access_request_path)
end
end
context 'when a user requests access to project' do
it 'responds with access requests tab' do
path = helper.todo_target_path(project_access_request_todo)
access_request_path = Gitlab::Routing.url_helpers.project_project_members_path(project, tab: 'access_requests')
expect(path).to eq(access_request_path)
end
@ -374,7 +388,7 @@ RSpec.describe TodosHelper do
end
context 'member access requested' do
context 'when source is group' do
context 'when target is group' do
it 'returns group access message' do
group_todo.action = Todo::MEMBER_ACCESS_REQUESTED
@ -383,6 +397,14 @@ RSpec.describe TodosHelper do
)
end
end
context 'when target is project' do
it 'returns project access message' do
expect(helper.todo_action_name(project_access_request_todo)).to eq(
format(s_("Todos|has requested access to project %{which}"), which: _(project.name))
)
end
end
end
end

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe UrlHelper do
describe '#escaped_url' do
it 'escapes url' do
expect(helper.escaped_url('https://example.com?param=test value')).to eq('https://example.com?param=test%20value')
end
it 'escapes XSS injection' do
expect(helper.escaped_url('https://example.com/asset.js"+eval(alert(1))));</script>'))
.to eq('https://example.com/asset.js%22+eval(alert(1))));%3C/script%3E')
end
it 'returns nil if url is nil' do
expect(helper.escaped_url(nil)).to be_nil
end
end
end

View File

@ -16,7 +16,7 @@ RSpec.describe Gitlab::Database::AsyncIndexes::IndexCreator do
let(:connection) { model.connection }
let!(:lease) { stub_exclusive_lease(lease_key, :uuid, timeout: lease_timeout) }
let(:lease_key) { "gitlab/database/async_indexes/index_creator/#{Gitlab::Database::PRIMARY_DATABASE_NAME}" }
let(:lease_key) { "gitlab/database/indexing/actions/#{Gitlab::Database::PRIMARY_DATABASE_NAME}" }
let(:lease_timeout) { described_class::TIMEOUT_PER_ACTION }
around do |example|

View File

@ -16,7 +16,7 @@ RSpec.describe Gitlab::Database::AsyncIndexes::IndexDestructor do
let(:connection) { model.connection }
let!(:lease) { stub_exclusive_lease(lease_key, :uuid, timeout: lease_timeout) }
let(:lease_key) { "gitlab/database/async_indexes/index_destructor/#{Gitlab::Database::PRIMARY_DATABASE_NAME}" }
let(:lease_key) { "gitlab/database/indexing/actions/#{Gitlab::Database::PRIMARY_DATABASE_NAME}" }
let(:lease_timeout) { described_class::TIMEOUT_PER_ACTION }
before do

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Database::IndexingExclusiveLeaseGuard, feature_category: :database do
let(:helper_class) do
Class.new do
include Gitlab::Database::IndexingExclusiveLeaseGuard
attr_reader :connection
def initialize(connection)
@connection = connection
end
end
end
describe '#lease_key' do
let(:helper) { helper_class.new(connection) }
let(:lease_key) { "gitlab/database/indexing/actions/#{database_name}" }
context 'with CI database connection' do
let(:connection) { Ci::ApplicationRecord.connection }
let(:database_name) { Gitlab::Database::CI_DATABASE_NAME }
before do
skip_if_multiple_databases_not_setup
end
it { expect(helper.lease_key).to eq(lease_key) }
end
context 'with MAIN database connection' do
let(:connection) { ApplicationRecord.connection }
let(:database_name) { Gitlab::Database::MAIN_DATABASE_NAME }
it { expect(helper.lease_key).to eq(lease_key) }
end
end
end

View File

@ -13,7 +13,7 @@ RSpec.describe Gitlab::Database::Reindexing::Coordinator, feature_category: :dat
end
let!(:lease) { stub_exclusive_lease(lease_key, uuid, timeout: lease_timeout) }
let(:lease_key) { "gitlab/database/reindexing/coordinator/#{Gitlab::Database::PRIMARY_DATABASE_NAME}" }
let(:lease_key) { "gitlab/database/indexing/actions/#{Gitlab::Database::PRIMARY_DATABASE_NAME}" }
let(:lease_timeout) { 1.day }
let(:uuid) { 'uuid' }

View File

@ -409,17 +409,6 @@ RSpec.describe Gitlab::GitalyClient::RefService do
end
end
describe '#pack_refs' do
it 'sends a pack_refs message' do
expect_any_instance_of(Gitaly::RefService::Stub)
.to receive(:pack_refs)
.with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
.and_return(double(:pack_refs_response))
client.pack_refs
end
end
describe '#find_refs_by_oid' do
let(:oid) { project.repository.commit.id }

View File

@ -21,39 +21,6 @@ RSpec.describe Gitlab::GitalyClient::RepositoryService do
end
end
describe '#garbage_collect' do
it 'sends a garbage_collect message' do
expect_any_instance_of(Gitaly::RepositoryService::Stub)
.to receive(:garbage_collect)
.with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
.and_return(double(:garbage_collect_response))
client.garbage_collect(true, prune: true)
end
end
describe '#repack_full' do
it 'sends a repack_full message' do
expect_any_instance_of(Gitaly::RepositoryService::Stub)
.to receive(:repack_full)
.with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
.and_return(double(:repack_full_response))
client.repack_full(true)
end
end
describe '#repack_incremental' do
it 'sends a repack_incremental message' do
expect_any_instance_of(Gitaly::RepositoryService::Stub)
.to receive(:repack_incremental)
.with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
.and_return(double(:repack_incremental_response))
client.repack_incremental
end
end
describe '#optimize_repository' do
it 'sends a optimize_repository message' do
expect_any_instance_of(Gitaly::RepositoryService::Stub)

View File

@ -92,34 +92,6 @@ RSpec.describe Gitlab::Pagination::Keyset::SimpleOrderBuilder do
end
end
context "NULLS order given as as an Arel literal" do
context 'when NULLS LAST order is given without a tie-breaker' do
let(:scope) { Project.order(Project.arel_table[:created_at].asc.nulls_last) }
it 'sets the column definition for created_at appropriately' do
expect(column_definition.attribute_name).to eq('created_at')
end
it 'orders by primary key' do
expect(sql_with_order)
.to end_with('ORDER BY "projects"."created_at" ASC NULLS LAST, "projects"."id" DESC')
end
end
context 'when NULLS FIRST order is given with a tie-breaker' do
let(:scope) { Issue.order(Issue.arel_table[:relative_position].desc.nulls_first).order(id: :asc) }
it 'sets the column definition for created_at appropriately' do
expect(column_definition.attribute_name).to eq('relative_position')
end
it 'orders by the given primary key' do
expect(sql_with_order)
.to end_with('ORDER BY "issues"."relative_position" DESC NULLS FIRST, "issues"."id" ASC')
end
end
end
context "NULLS order given as as an Arel node" do
context 'when NULLS LAST order is given without a tie-breaker' do
let(:scope) { Project.order(Project.arel_table[:created_at].asc.nulls_last) }

View File

@ -23,8 +23,13 @@ RSpec.describe AbuseReport, feature_category: :insider_threat do
it { is_expected.to validate_presence_of(:reporter) }
it { is_expected.to validate_presence_of(:user) }
it { is_expected.to validate_presence_of(:message) }
it { is_expected.to validate_uniqueness_of(:user_id).with_message('has already been reported') }
it { is_expected.to validate_presence_of(:category) }
it do
is_expected.to validate_uniqueness_of(:user_id)
.scoped_to(:reporter_id)
.with_message('You have already reported this user')
end
end
describe '#remove_user' do

View File

@ -1097,6 +1097,23 @@ RSpec.describe Ci::Runner, feature_category: :runner do
expect(runner.runner_version).to be_nil
end
context 'with only ip_address specified', :freeze_time do
subject(:heartbeat) do
runner.heartbeat(ip_address: '1.1.1.1')
end
it 'updates only ip_address' do
attrs = Gitlab::Json.dump(ip_address: '1.1.1.1', contacted_at: Time.current)
Gitlab::Redis::Cache.with do |redis|
redis_key = runner.send(:cache_attribute_key)
expect(redis).to receive(:set).with(redis_key, attrs, any_args)
end
heartbeat
end
end
end
context 'when database was not updated recently' do

View File

@ -806,35 +806,29 @@ RSpec.describe Member do
end
describe '#request?' do
context 'when request for project' do
subject { create(:project_member, requested_at: Time.current.utc) }
shared_examples 'calls notification service and todo service' do
subject { create(source_type, requested_at: Time.current.utc) }
it 'calls notification service but not todo service' do
expect_next_instance_of(NotificationService) do |instance|
expect(instance).to receive(:new_access_request)
end
expect(TodoService).not_to receive(:new)
is_expected.to be_request
end
end
context 'when request for group' do
subject { create(:group_member, requested_at: Time.current.utc) }
it 'calls notification and todo service' do
specify do
expect_next_instance_of(NotificationService) do |instance|
expect(instance).to receive(:new_access_request)
end
expect_next_instance_of(TodoService) do |instance|
expect(instance).to receive(:create_member_access_request)
expect(instance).to receive(:create_member_access_request_todos)
end
is_expected.to be_request
end
end
context 'when requests for project and group are raised' do
%i[project_member group_member].each do |source_type|
it_behaves_like 'calls notification service and todo service' do
let_it_be(:source_type) { source_type }
end
end
end
end
describe '#pending?' do

View File

@ -175,6 +175,15 @@ RSpec.describe Todo do
end
describe '#target_reference' do
shared_examples 'returns full_path' do
specify do
subject.target = target
subject.action = Todo::MEMBER_ACCESS_REQUESTED
expect(subject.target_reference).to eq target.full_path
end
end
it 'returns commit full reference with short id' do
project = create(:project, :repository)
commit = project.commit
@ -193,13 +202,10 @@ RSpec.describe Todo do
end
context 'when target is member access requested' do
it 'returns group full path' do
group = create(:group)
subject.target = group
subject.action = Todo::MEMBER_ACCESS_REQUESTED
expect(subject.target_reference).to eq group.full_path
%i[project group].each do |target_type|
it_behaves_like 'returns full_path' do
let(:target) { create(target_type, :public) }
end
end
end
end
@ -525,4 +531,46 @@ RSpec.describe Todo do
expect(described_class.for_internal_notes).to contain_exactly(todo)
end
end
describe '#access_request_url' do
shared_examples 'returns member access requests tab url/path' do
it 'returns group access requests tab url/path if target is group' do
group = create(:group)
subject.target = group
expect(subject.access_request_url(only_path: only_path)).to eq(Gitlab::Routing.url_helpers.group_group_members_url(group, tab: 'access_requests', only_path: only_path))
end
it 'returns project access requests tab url/path if target is project' do
project = create(:project)
subject.target = project
expect(subject.access_request_url(only_path: only_path)).to eq(Gitlab::Routing.url_helpers.project_project_members_url(project, tab: 'access_requests', only_path: only_path))
end
it 'returns empty string if target is neither group nor project' do
subject.target = issue
expect(subject.access_request_url(only_path: only_path)).to eq("")
end
end
context 'when only_path param is false' do
it_behaves_like 'returns member access requests tab url/path' do
let_it_be(:only_path) { false }
end
end
context 'when only_path param is nil' do
it_behaves_like 'returns member access requests tab url/path' do
let_it_be(:only_path) { nil }
end
end
context 'when only_path param is true' do
it_behaves_like 'returns member access requests tab url/path' do
let_it_be(:only_path) { true }
end
end
end
end

View File

@ -251,22 +251,22 @@ RSpec.describe User do
end
end
describe '#abuse_report' do
describe '#abuse_reports' do
let(:current_user) { create(:user) }
let(:other_user) { create(:user) }
it { is_expected.to have_one(:abuse_report) }
it { is_expected.to have_many(:abuse_reports) }
it 'refers to the abuse report whose user_id is the current user' do
abuse_report = create(:abuse_report, reporter: other_user, user: current_user)
expect(current_user.abuse_report).to eq(abuse_report)
expect(current_user.abuse_reports.last).to eq(abuse_report)
end
it 'does not refer to the abuse report whose reporter_id is the current user' do
create(:abuse_report, reporter: current_user, user: other_user)
expect(current_user.abuse_report).to be_nil
expect(current_user.abuse_reports.last).to be_nil
end
it 'does not update the user_id of an abuse report when the user is updated' do

View File

@ -6,6 +6,7 @@ RSpec.describe API::Todos, feature_category: :source_code_management do
include DesignManagementTestHelpers
let_it_be(:group) { create(:group) }
let_it_be(:group_2) { create(:group) }
let_it_be(:project_1) { create(:project, :repository, group: group) }
let_it_be(:project_2) { create(:project) }
let_it_be(:author_1) { create(:user) }
@ -15,7 +16,8 @@ RSpec.describe API::Todos, feature_category: :source_code_management do
let_it_be(:work_item) { create(:work_item, :task, project: project_1) }
let_it_be(:merge_request) { create(:merge_request, source_project: project_1) }
let_it_be(:alert) { create(:alert_management_alert, project: project_1) }
let_it_be(:group_request_todo) { create(:todo, author: author_1, user: john_doe, target: group, action: Todo::MEMBER_ACCESS_REQUESTED) }
let_it_be(:project_request_todo) { create(:todo, author: author_1, user: john_doe, target: project_2, action: Todo::MEMBER_ACCESS_REQUESTED) }
let_it_be(:group_request_todo) { create(:todo, author: author_1, user: john_doe, target: group_2, action: Todo::MEMBER_ACCESS_REQUESTED) }
let_it_be(:alert_todo) { create(:todo, project: project_1, author: john_doe, user: john_doe, target: alert) }
let_it_be(:merge_request_todo) { create(:todo, project: project_1, author: author_2, user: john_doe, target: merge_request) }
let_it_be(:pending_1) { create(:todo, :mentioned, project: project_1, author: author_1, user: john_doe, target: issue) }
@ -72,7 +74,7 @@ RSpec.describe API::Todos, feature_category: :source_code_management do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(7)
expect(json_response.length).to eq(8)
expect(json_response[0]).to include(
'id' => pending_5.id,
@ -133,11 +135,23 @@ RSpec.describe API::Todos, feature_category: :source_code_management do
'target_type' => 'Namespace',
'action_name' => 'member_access_requested',
'target' => hash_including(
'id' => group.id,
'name' => group.name,
'full_path' => group.full_path
'id' => group_2.id,
'name' => group_2.name,
'full_path' => group_2.full_path
),
'target_url' => Gitlab::Routing.url_helpers.group_group_members_url(group, tab: 'access_requests')
'target_url' => Gitlab::Routing.url_helpers.group_group_members_url(group_2, tab: 'access_requests')
)
expect(json_response[7]).to include(
'target_type' => 'Project',
'action_name' => 'member_access_requested',
'target' => hash_including(
'id' => project_2.id,
'name' => project_2.name,
'path' => project_2.path
),
'target_url' => Gitlab::Routing.url_helpers.project_project_members_url(project_2, tab: 'access_requests'),
'body' => project_2.full_path
)
end
@ -149,7 +163,7 @@ RSpec.describe API::Todos, feature_category: :source_code_management do
get api('/todos', john_doe)
expect(json_response.count).to eq(7)
expect(json_response.count).to eq(8)
expect(json_response.map { |t| t['id'] }).not_to include(no_access_todo.id, pending_4.id)
end
end
@ -242,8 +256,10 @@ RSpec.describe API::Todos, feature_category: :source_code_management do
merge_request_3 = create(:merge_request, :jira_branch, source_project: new_todo.project)
create(:on_commit_todo, project: new_todo.project, author: author_1, user: john_doe, target: merge_request_3)
create(:todo, project: new_todo.project, author: author_2, user: john_doe, target: merge_request_3)
create(:todo, author: author_2, user: john_doe, target: project_2, action: Todo::MEMBER_ACCESS_REQUESTED)
create(:todo, author: author_2, user: john_doe, target: group_2, action: Todo::MEMBER_ACCESS_REQUESTED)
expect { get api('/todos', john_doe) }.not_to exceed_query_limit(control1).with_threshold(6)
expect { get api('/todos', john_doe) }.not_to exceed_query_limit(control1).with_threshold(5)
control2 = ActiveRecord::QueryRecorder.new { get api('/todos', john_doe) }
create_issue_todo_for(john_doe)

View File

@ -91,4 +91,11 @@ RSpec.describe Groups::ObservabilityController, feature_category: :tracing do
it_behaves_like 'observability route request'
end
describe 'GET #datasources' do
let(:path) { group_observability_datasources_path(group) }
let(:expected_observability_path) { "#{observability_url}/-/#{group.id}/datasources" }
it_behaves_like 'observability route request'
end
end

View File

@ -80,6 +80,10 @@ RSpec.shared_examples 'groups routing' do
expect(get("groups/#{group_path}/-/observability/explore")).to route_to('groups/observability#explore', group_id: group_path)
end
it 'routes to the observability controller datasources method' do
expect(get("groups/#{group_path}/-/observability/datasources")).to route_to('groups/observability#datasources', group_id: group_path)
end
it 'routes to the observability controller manage method' do
expect(get("groups/#{group_path}/-/observability/manage")).to route_to('groups/observability#manage', group_id: group_path)
end

View File

@ -86,4 +86,18 @@ RSpec.describe RuboCop::Cop::Gitlab::StrongMemoizeAttr do
RUBY
end
end
context 'when strong_memoize() is used in a singleton method' do
it 'does not register an offense' do
expect_no_offenses(<<~RUBY)
class Foo
def self.memoized_method
strong_memoize(:memoized_method) do
'this is a memoized method'
end
end
end
RUBY
end
end
end

View File

@ -1259,92 +1259,85 @@ RSpec.describe TodoService do
end
end
describe '#create_member_access_request' do
context 'snowplow event tracking' do
it 'does not track snowplow event when todos are for access request for project', :snowplow do
user = create(:user)
project = create(:project)
requester = create(:project_member, project: project, user: assignee)
project.add_owner(user)
describe '#create_member_access_request_todos' do
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, group: group) }
expect_no_snowplow_event
shared_examples 'member access request is raised' do
context 'when the source has more than 10 owners' do
it 'creates todos for 10 recently active source owners' do
users = create_list(:user, 12, :with_sign_ins)
users.each do |user|
source.add_owner(user)
end
ten_most_recently_active_source_owners = users.sort_by(&:last_sign_in_at).last(10)
excluded_source_owners = users - ten_most_recently_active_source_owners
service.create_member_access_request(requester)
service.create_member_access_request_todos(requester1)
ten_most_recently_active_source_owners.each do |owner|
expect(Todo.where(user: owner, target: source, action: Todo::MEMBER_ACCESS_REQUESTED, author: requester1.user).count).to eq 1
end
excluded_source_owners.each do |owner|
expect(Todo.where(user: owner, target: source, action: Todo::MEMBER_ACCESS_REQUESTED, author: requester1.user).count).to eq 0
end
end
end
end
context 'when the group has more than 10 owners' do
it 'creates todos for 10 recently active group owners' do
group = create(:group, :public)
context 'when total owners are less than 10' do
it 'creates todos for all source owners' do
users = create_list(:user, 4, :with_sign_ins)
users.map do |user|
source.add_owner(user)
end
users = create_list(:user, 12, :with_sign_ins)
users.each do |user|
group.add_owner(user)
service.create_member_access_request_todos(requester1)
users.each do |owner|
expect(Todo.where(user: owner, target: source, action: Todo::MEMBER_ACCESS_REQUESTED, author: requester1.user).count).to eq 1
end
end
ten_most_recently_active_group_owners = users.sort_by(&:last_sign_in_at).last(10)
excluded_group_owners = users - ten_most_recently_active_group_owners
end
requester = create(:group_member, group: group, user: assignee)
context 'when multiple access requests are raised' do
it 'creates todos for 10 recently active source owners for multiple requests' do
users = create_list(:user, 12, :with_sign_ins)
users.each do |user|
source.add_owner(user)
end
ten_most_recently_active_source_owners = users.sort_by(&:last_sign_in_at).last(10)
excluded_source_owners = users - ten_most_recently_active_source_owners
service.create_member_access_request(requester)
service.create_member_access_request_todos(requester1)
service.create_member_access_request_todos(requester2)
ten_most_recently_active_group_owners.each do |owner|
expect(Todo.where(user: owner, target: group, action: Todo::MEMBER_ACCESS_REQUESTED, author: assignee).count).to eq 1
end
ten_most_recently_active_source_owners.each do |owner|
expect(Todo.where(user: owner, target: source, action: Todo::MEMBER_ACCESS_REQUESTED, author: requester1.user).count).to eq 1
expect(Todo.where(user: owner, target: source, action: Todo::MEMBER_ACCESS_REQUESTED, author: requester2.user).count).to eq 1
end
excluded_group_owners.each do |owner|
expect(Todo.where(user: owner, target: group, action: Todo::MEMBER_ACCESS_REQUESTED, author: assignee).count).to eq 0
excluded_source_owners.each do |owner|
expect(Todo.where(user: owner, target: source, action: Todo::MEMBER_ACCESS_REQUESTED, author: requester1.user).count).to eq 0
expect(Todo.where(user: owner, target: source, action: Todo::MEMBER_ACCESS_REQUESTED, author: requester2.user).count).to eq 0
end
end
end
end
context 'when total owners are less than 10' do
it 'creates todos for all group owners' do
group = create(:group, :public)
users = create_list(:user, 4, :with_sign_ins)
users.map do |user|
group.add_owner(user)
end
requester = create(:group_member, user: assignee, group: group)
requester.requested_at = Time.now.utc
requester.save!
service.create_member_access_request(requester)
users.each do |owner|
expect(Todo.where(user: owner, target: group, action: Todo::MEMBER_ACCESS_REQUESTED, author: assignee).count).to eq 1
end
context 'when request is raised for group' do
it_behaves_like 'member access request is raised' do
let_it_be(:source) { create(:group, :public) }
let_it_be(:requester1) { create(:group_member, :access_request, group: source, user: assignee) }
let_it_be(:requester2) { create(:group_member, :access_request, group: source, user: non_member) }
end
end
context 'when multiple access requests are raised' do
it 'creates todos for 10 recently active group owners for multiple requests' do
group = create(:group, :public)
users = create_list(:user, 12, :with_sign_ins)
users.each do |user|
group.add_owner(user)
end
ten_most_recently_active_group_owners = users.sort_by(&:last_sign_in_at).last(10)
excluded_group_owners = users - ten_most_recently_active_group_owners
requester1 = create(:group_member, group: group, user: assignee)
requester2 = create(:group_member, group: group, user: non_member)
service.create_member_access_request(requester1)
service.create_member_access_request(requester2)
ten_most_recently_active_group_owners.each do |owner|
expect(Todo.where(user: owner, target: group, action: Todo::MEMBER_ACCESS_REQUESTED, author: assignee).count).to eq 1
expect(Todo.where(user: owner, target: group, action: Todo::MEMBER_ACCESS_REQUESTED, author: non_member).count).to eq 1
end
excluded_group_owners.each do |owner|
expect(Todo.where(user: owner, target: group, action: Todo::MEMBER_ACCESS_REQUESTED, author: assignee).count).to eq 0
expect(Todo.where(user: owner, target: group, action: Todo::MEMBER_ACCESS_REQUESTED, author: non_member).count).to eq 0
end
context 'when request is raised for project' do
it_behaves_like 'member access request is raised' do
let_it_be(:source) { create(:project, :public) }
let_it_be(:requester1) { create(:project_member, :access_request, project: source, user: assignee) }
let_it_be(:requester2) { create(:project_member, :access_request, project: source, user: non_member) }
end
end
end

View File

@ -93,7 +93,8 @@ module NavbarStructureHelper
nav_sub_items: [
_('Dashboards'),
_('Explore'),
_('Manage Dashboards')
_('Data sources'),
_('Manage dashboards')
]
}
)

View File

@ -210,6 +210,7 @@ RSpec.describe 'Every Sidekiq worker' do
'Deployments::LinkMergeRequestWorker' => 3,
'Deployments::SuccessWorker' => 3,
'Deployments::UpdateEnvironmentWorker' => 3,
'Deployments::ApprovalWorker' => 3,
'DesignManagement::CopyDesignCollectionWorker' => 3,
'DesignManagement::NewVersionWorker' => 3,
'DestroyPagesDeploymentsWorker' => 3,