Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
a352bc8e72
commit
1935f3e81b
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
import ObservabilityApp from '~/observability';
|
||||
|
||||
ObservabilityApp();
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
mutation createWorkItemNote($input: CreateNoteInput!) {
|
||||
createNote(input: $input) {
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ fragment WorkItem on WorkItem {
|
|||
project {
|
||||
id
|
||||
fullPath
|
||||
archived
|
||||
}
|
||||
workItemType {
|
||||
id
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -495,7 +495,7 @@
|
|||
|
||||
.protected-branches-list,
|
||||
.protected-tags-list {
|
||||
margin-bottom: 30px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.settings-message {
|
||||
margin: 0;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ module Groups
|
|||
render_observability
|
||||
end
|
||||
|
||||
def datasources
|
||||
render_observability
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_observability
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module UrlHelper
|
||||
def escaped_url(url)
|
||||
Addressable::URI.escape(url)
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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') } }
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
|
|||
namespace :observability do
|
||||
get 'dashboards'
|
||||
get 'explore'
|
||||
get 'datasources'
|
||||
get 'manage'
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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**.
|
||||
|
||||

|
||||
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.
|
||||
|
||||

|
||||
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.
|
||||
|
||||

|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ gitlab:
|
|||
cpu: 746m
|
||||
memory: 2809Mi
|
||||
limits:
|
||||
cpu: 1300m
|
||||
cpu: 1400m
|
||||
memory: 4214Mi
|
||||
minReplicas: 1
|
||||
maxReplicas: 1
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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|
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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' }
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -93,7 +93,8 @@ module NavbarStructureHelper
|
|||
nav_sub_items: [
|
||||
_('Dashboards'),
|
||||
_('Explore'),
|
||||
_('Manage Dashboards')
|
||||
_('Data sources'),
|
||||
_('Manage dashboards')
|
||||
]
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue