Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-07-21 06:10:13 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-07-21 06:10:13 +0300
commit3c0d15f2f194a4e08b2700b0a75c305d89dd7816 (patch)
tree9e987dcacb631d6159ba44909933d5f31103ed0e
parenta558e386749c579a70cca6463926092926627388 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.projections.json.example42
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue3
-rw-r--r--app/assets/javascripts/ide/utils.js13
-rw-r--r--app/assets/javascripts/lib/utils/file_utility.js12
-rw-r--r--app/assets/javascripts/profile/edit/components/profile_edit_app.vue107
-rw-r--r--app/assets/javascripts/profile/edit/components/user_avatar.vue177
-rw-r--r--app/assets/javascripts/profile/edit/constants.js27
-rw-r--r--app/assets/javascripts/profile/edit/index.js16
-rw-r--r--app/assets/javascripts/profile/gl_crop.js8
-rw-r--r--app/helpers/profiles_helper.rb14
-rw-r--r--app/views/profiles/show.html.haml2
-rw-r--r--db/docs/batched_background_migrations/backfill_dismissal_reason_in_vulnerability_reads.yml6
-rw-r--r--db/post_migrate/20230612232000_queue_backfill_dismissal_reason_in_vulnerability_reads.rb25
-rw-r--r--db/schema_migrations/202306122320001
-rw-r--r--doc/api/member_roles.md6
-rw-r--r--doc/architecture/blueprints/organization/index.md12
-rw-r--r--doc/ci/resource_groups/index.md2
-rw-r--r--doc/user/application_security/vulnerability_report/index.md3
-rw-r--r--doc/user/group/import/index.md65
-rw-r--r--doc/user/project/repository/branches/default.md12
-rw-r--r--lefthook.yml3
-rw-r--r--lib/gitlab/background_migration/backfill_dismissal_reason_in_vulnerability_reads.rb19
-rw-r--r--locale/gitlab.pot9
-rw-r--r--spec/features/uploads/user_uploads_avatar_to_profile_spec.rb69
-rw-r--r--spec/frontend/ide/utils_spec.js11
-rw-r--r--spec/frontend/lib/utils/file_utility_spec.js13
-rw-r--r--spec/frontend/profile/edit/components/profile_edit_app_spec.js111
-rw-r--r--spec/frontend/profile/edit/components/user_avatar_spec.js139
-rw-r--r--spec/helpers/profiles_helper_spec.rb22
-rw-r--r--spec/migrations/20230612232000_queue_backfill_dismissal_reason_in_vulnerability_reads_spec.rb26
30 files changed, 903 insertions, 72 deletions
diff --git a/.projections.json.example b/.projections.json.example
index 5a5c87704c1..eb62d2ed36e 100644
--- a/.projections.json.example
+++ b/.projections.json.example
@@ -83,9 +83,14 @@
"related": "ee/app/validators/ee/{}.rb",
"type": "source"
},
- "app/views/*.rb": {
- "alternate": "spec/app/views/{}_spec.rb",
- "related": "ee/app/views/ee/{}.rb",
+ "app/views/*.erb": {
+ "alternate": "spec/views/{}.erb_spec.rb",
+ "related": "ee/app/views/ee/{}.erb",
+ "type": "source"
+ },
+ "app/views/*.haml": {
+ "alternate": "spec/views/{}.haml_spec.rb",
+ "related": "ee/app/views/ee/{}.haml",
"type": "source"
},
"app/workers/*.rb": {
@@ -113,6 +118,16 @@
"alternate": "lib/api/{}.rb",
"type": "test"
},
+ "spec/views/*.erb_spec.rb": {
+ "alternate": "app/views/{}.erb",
+ "related": "ee/app/views/ee/{}.erb",
+ "type": "test"
+ },
+ "spec/views/*.haml_spec.rb": {
+ "alternate": "app/views/{}.haml",
+ "related": "ee/app/views/ee/{}.haml",
+ "type": "test"
+ },
"rubocop/cop/*.rb": {
"alternate": "spec/rubocop/cop/{}_spec.rb",
"type": "source"
@@ -189,9 +204,14 @@
"related": "app/validators/{}.rb",
"type": "source"
},
- "ee/app/views/ee/*.rb": {
- "alternate": "spec/app/views/{}_spec.rb",
- "related": "app/views/{}.rb",
+ "ee/app/views/ee/*.erb": {
+ "alternate": "ee/spec/views/ee/{}.erb_spec.rb",
+ "related": "app/views/{}.erb",
+ "type": "source"
+ },
+ "ee/app/views/ee/*.haml": {
+ "alternate": "ee/spec/views/ee/{}.haml_spec.rb",
+ "related": "app/views/{}.haml",
"type": "source"
},
"ee/app/workers/ee/*.rb": {
@@ -247,6 +267,16 @@
"alternate": ["ee/app/assets/javascripts/{}.vue", "ee/app/assets/javascripts/{}.js"],
"type": "test"
},
+ "ee/spec/views/ee/*.erb_spec.rb": {
+ "alternate": "ee/app/views/ee/{}.erb",
+ "related": "spec/views/{}.erb_spec.rb",
+ "type": "test"
+ },
+ "ee/spec/views/ee/*.haml_spec.rb": {
+ "alternate": "ee/app/views/ee/{}.haml",
+ "related": "spec/views/{}.haml_spec.rb",
+ "type": "test"
+ },
"*.rb": { "dispatch": "bundle exec rubocop {file}" },
"*_spec.rb": { "dispatch": "bundle exec rspec {file}" }
}
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 9e29cd94a20..e0deefd73ab 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -28,6 +28,7 @@ import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer
import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import { markRaw } from '~/lib/utils/vue3compat/mark_raw';
+import { readFileAsDataURL } from '~/lib/utils/file_utility';
import {
leftSidebarViews,
@@ -40,7 +41,7 @@ import { getRulesWithTraversal } from '../lib/editorconfig/parser';
import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
import { getFileEditorOrDefault } from '../stores/modules/editor/utils';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
-import { getPathParent, readFileAsDataURL, registerSchema, isTextFile } from '../utils';
+import { getPathParent, registerSchema, isTextFile } from '../utils';
import FileAlert from './file_alert.vue';
import FileTemplatesBar from './file_templates/bar.vue';
diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js
index 83a3d7f2ac3..3a42d7b3027 100644
--- a/app/assets/javascripts/ide/utils.js
+++ b/app/assets/javascripts/ide/utils.js
@@ -123,19 +123,6 @@ export function getPathParent(path) {
return getPathParents(path, 1)[0];
}
-/**
- * Takes a file object and returns a data uri of its contents.
- *
- * @param {File} file
- */
-export function readFileAsDataURL(file) {
- return new Promise((resolve) => {
- const reader = new FileReader();
- reader.addEventListener('load', (e) => resolve(e.target.result), { once: true });
- reader.readAsDataURL(file);
- });
-}
-
export function getFileEOL(content = '') {
return content.includes('\r\n') ? 'CRLF' : 'LF';
}
diff --git a/app/assets/javascripts/lib/utils/file_utility.js b/app/assets/javascripts/lib/utils/file_utility.js
new file mode 100644
index 00000000000..e5a41f3b042
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/file_utility.js
@@ -0,0 +1,12 @@
+/**
+ * Takes a file object and returns a data uri of its contents.
+ *
+ * @param {File} file
+ */
+export function readFileAsDataURL(file) {
+ return new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.addEventListener('load', (e) => resolve(e.target.result), { once: true });
+ reader.readAsDataURL(file);
+ });
+}
diff --git a/app/assets/javascripts/profile/edit/components/profile_edit_app.vue b/app/assets/javascripts/profile/edit/components/profile_edit_app.vue
index ab29d94c41c..df7f9a5be81 100644
--- a/app/assets/javascripts/profile/edit/components/profile_edit_app.vue
+++ b/app/assets/javascripts/profile/edit/components/profile_edit_app.vue
@@ -1,10 +1,107 @@
<script>
-export default {};
+import { nextTick } from 'vue';
+import { GlForm, GlButton } from '@gitlab/ui';
+import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/alert';
+import axios from '~/lib/utils/axios_utils';
+import { readFileAsDataURL } from '~/lib/utils/file_utility';
+
+import { i18n } from '../constants';
+import UserAvatar from './user_avatar.vue';
+
+export default {
+ components: {
+ UserAvatar,
+ GlForm,
+ GlButton,
+ },
+ props: {
+ profilePath: {
+ type: String,
+ required: true,
+ },
+ userPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ uploadingProfile: false,
+ avatarBlob: null,
+ };
+ },
+ methods: {
+ async onSubmit() {
+ // TODO: Do validation before organizing data.
+ this.uploadingProfile = true;
+ const formData = new FormData();
+
+ if (this.avatarBlob) {
+ formData.append('user[avatar]', this.avatarBlob, 'avatar.png');
+ }
+
+ try {
+ const { data } = await axios.put(this.profilePath, formData);
+
+ if (this.avatarBlob) {
+ this.syncHeaderAvatars();
+ }
+
+ createAlert({
+ message: data.message,
+ variant: data.status === 'error' ? VARIANT_DANGER : VARIANT_INFO,
+ });
+
+ nextTick(() => {
+ window.scrollTo(0, 0);
+ this.uploadingProfile = false;
+ });
+ } catch (e) {
+ createAlert({
+ message: e.message,
+ variant: VARIANT_DANGER,
+ });
+ this.updateProfileSettings = false;
+ }
+ },
+ async syncHeaderAvatars() {
+ const dataURL = await readFileAsDataURL(this.avatarBlob);
+
+ // TODO: implement sync for super sidebar
+ ['.header-user-avatar', '.js-sidebar-user-avatar'].forEach((selector) => {
+ const node = document.querySelector(selector);
+ if (!node) return;
+
+ node.setAttribute('src', dataURL);
+ node.setAttribute('srcset', dataURL);
+ });
+ },
+ onBlobChange(blob) {
+ this.avatarBlob = blob;
+ },
+ },
+ i18n,
+};
</script>
<template>
- <!-- This is left empty intensionally -->
- <!-- It will be implemented in the upcoming MRs -->
- <!-- Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/389918 -->
- <div></div>
+ <gl-form @submit.prevent="onSubmit">
+ <user-avatar @blob-change="onBlobChange" />
+ <!-- TODO: to implement profile editing form fields -->
+ <!-- It will be implemented in the upcoming MRs -->
+ <!-- Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/389918 -->
+ <div class="js-hide-when-nothing-matches-search gl-border-t gl-py-6">
+ <gl-button
+ variant="confirm"
+ type="submit"
+ class="gl-mr-3 js-password-prompt-btn"
+ :disabled="uploadingProfile"
+ >
+ {{ $options.i18n.updateProfileSettings }}
+ </gl-button>
+ <gl-button :href="userPath" data-testid="cancel-edit-button">
+ {{ $options.i18n.cancel }}
+ </gl-button>
+ </div>
+ </gl-form>
</template>
diff --git a/app/assets/javascripts/profile/edit/components/user_avatar.vue b/app/assets/javascripts/profile/edit/components/user_avatar.vue
new file mode 100644
index 00000000000..41178a76bb0
--- /dev/null
+++ b/app/assets/javascripts/profile/edit/components/user_avatar.vue
@@ -0,0 +1,177 @@
+<script>
+import $ from 'jquery';
+import { GlAvatar, GlAvatarLink, GlButton, GlLink, GlSprintf } from '@gitlab/ui';
+import { loadCSSFile } from '~/lib/utils/css_utils';
+import SafeHtmlDirective from '~/vue_shared/directives/safe_html';
+
+import { avatarI18n } from '../constants';
+
+export default {
+ name: 'EditProfileUserAvatar',
+ components: {
+ GlAvatar,
+ GlAvatarLink,
+ GlButton,
+ GlLink,
+ GlSprintf,
+ },
+ directives: {
+ SafeHtml: SafeHtmlDirective,
+ },
+ inject: [
+ 'avatarUrl',
+ 'brandProfileImageGuidelines',
+ 'cropperCssPath',
+ 'hasAvatar',
+ 'gravatarEnabled',
+ 'gravatarLink',
+ 'profileAvatarPath',
+ ],
+ computed: {
+ avatarHelpText() {
+ const { changeOrRemoveAvatar, changeAvatar, uploadOrChangeAvatar, uploadAvatar } = avatarI18n;
+ if (this.hasAvatar) {
+ return this.gravatarEnabled ? changeOrRemoveAvatar : changeAvatar;
+ }
+ return this.gravatarEnabled ? uploadOrChangeAvatar : uploadAvatar;
+ },
+ },
+
+ mounted() {
+ this.initializeCropper();
+ loadCSSFile(this.cropperCssPath);
+ },
+
+ methods: {
+ initializeCropper() {
+ const cropOpts = {
+ filename: '.js-avatar-filename',
+ previewImage: '.avatar-image .gl-avatar',
+ modalCrop: '.modal-profile-crop',
+ pickImageEl: '.js-choose-user-avatar-button',
+ uploadImageBtn: '.js-upload-user-avatar',
+ modalCropImg: '.modal-profile-crop-image',
+ onBlobChange: this.onBlobChange,
+ };
+ // This has to be used with jQuery, considering migrate that from jQuery to Vue in the future.
+ $('.js-user-avatar-input').glCrop(cropOpts).data('glcrop');
+ },
+ onBlobChange(blob) {
+ this.$emit('blob-change', blob);
+ },
+ },
+ i18n: avatarI18n,
+};
+</script>
+
+<template>
+ <div class="js-search-settings-section gl-pb-6">
+ <div class="profile-settings-sidebar">
+ <h4 class="gl-my-0">
+ {{ $options.i18n.publicAvatar }}
+ </h4>
+ <p class="gl-text-secondary">
+ <gl-sprintf :message="avatarHelpText">
+ <template #gravatar_link>
+ <gl-link :href="gravatarLink.url" target="__blank">
+ {{ gravatarLink.hostname }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <div
+ v-if="brandProfileImageGuidelines"
+ v-safe-html="brandProfileImageGuidelines"
+ class="md gl-mb-5"
+ data-testid="brand-profile-image-guidelines"
+ ></div>
+ </div>
+ <div class="gl-display-flex">
+ <div class="avatar-image">
+ <gl-avatar-link :href="avatarUrl" target="blank">
+ <gl-avatar class="gl-mr-5" :src="avatarUrl" :size="96" shape="circle" />
+ </gl-avatar-link>
+ </div>
+ <div class="gl-flex-grow-1">
+ <h5 class="gl-mt-0">
+ {{ $options.i18n.uploadNewAvatar }}
+ </h5>
+ <div class="gl-display-flex gl-align-items-center gl-my-3">
+ <gl-button
+ class="js-choose-user-avatar-button"
+ data-testid="select-avatar-trigger-button"
+ >
+ {{ $options.i18n.chooseFile }}
+ </gl-button>
+ <span class="gl-ml-3 js-avatar-filename">{{ $options.i18n.noFileChosen }}</span>
+ <input
+ id="user_avatar"
+ class="js-user-avatar-input hidden"
+ accept="image/*"
+ type="file"
+ name="user[avatar]"
+ />
+ </div>
+ <p class="gl-mb-0 gl-text-gray-500">{{ $options.i18n.maximumFileSize }}</p>
+ <gl-button
+ v-if="hasAvatar"
+ class="gl-mt-3"
+ category="secondary"
+ variant="danger"
+ data-method="delete"
+ rel="nofollow"
+ data-testid="remove-avatar-button"
+ :data-confirm="$options.i18n.removeAvatarConfirmation"
+ :href="profileAvatarPath"
+ >
+ {{ $options.i18n.removeAvatar }}
+ </gl-button>
+ </div>
+ </div>
+ <!-- For bs.modal to take over -->
+ <div class="modal modal-profile-crop" :data-cropper-css-path="cropperCssPath">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h4 class="modal-title">
+ {{ $options.i18n.cropAvatarTitle }}
+ </h4>
+ <gl-button
+ category="tertiary"
+ icon="close"
+ class="close"
+ data-dismiss="modal"
+ :aria-label="__('Close')"
+ />
+ </div>
+ <div class="modal-body">
+ <div class="profile-crop-image-container">
+ <img :alt="$options.i18n.cropAvatarImageAltText" class="modal-profile-crop-image" />
+ </div>
+ <div class="gl-text-center gl-mt-4">
+ <div class="btn-group">
+ <gl-button
+ :aria-label="__('Zoom out')"
+ icon="search-minus"
+ data-method="zoom"
+ data-option="-0.1"
+ />
+ <gl-button
+ :aria-label="__('Zoom in')"
+ icon="search-plus"
+ data-method="zoom"
+ data-option="0.1"
+ />
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <gl-button class="js-upload-user-avatar" variant="confirm">{{
+ $options.i18n.cropAvatarSetAsNewAvatar
+ }}</gl-button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/profile/edit/constants.js b/app/assets/javascripts/profile/edit/constants.js
new file mode 100644
index 00000000000..ecd6274e39b
--- /dev/null
+++ b/app/assets/javascripts/profile/edit/constants.js
@@ -0,0 +1,27 @@
+import { s__, __ } from '~/locale';
+
+export const avatarI18n = {
+ publicAvatar: s__('Profiles|Public avatar'),
+ changeOrRemoveAvatar: s__(
+ 'Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}',
+ ),
+ changeAvatar: s__('Profiles|You can change your avatar here'),
+ uploadOrChangeAvatar: s__(
+ 'Profiles|You can upload your avatar here or change it at %{gravatar_link}',
+ ),
+ uploadAvatar: s__('Profiles|You can upload your avatar here'),
+ uploadNewAvatar: s__('Profiles|Upload new avatar'),
+ chooseFile: s__('Profiles|Choose file...'),
+ noFileChosen: s__('Profiles|No file chosen.'),
+ maximumFileSize: s__('Profiles|The maximum file size allowed is 200KB.'),
+ removeAvatar: s__('Profiles|Remove avatar'),
+ removeAvatarConfirmation: s__('Profiles|Avatar will be removed. Are you sure?'),
+ cropAvatarTitle: s__('Profiles|Position and size your new avatar'),
+ cropAvatarImageAltText: s__('Profiles|Avatar cropper'),
+ cropAvatarSetAsNewAvatar: s__('Profiles|Set new profile picture'),
+};
+
+export const i18n = {
+ updateProfileSettings: s__('Profiles|Update profile settings'),
+ cancel: __('Cancel'),
+};
diff --git a/app/assets/javascripts/profile/edit/index.js b/app/assets/javascripts/profile/edit/index.js
index b46a395d6f5..1466eede864 100644
--- a/app/assets/javascripts/profile/edit/index.js
+++ b/app/assets/javascripts/profile/edit/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
import ProfileEditApp from './components/profile_edit_app.vue';
export const initProfileEdit = () => {
@@ -6,11 +7,24 @@ export const initProfileEdit = () => {
if (!mountEl) return false;
+ const { profilePath, userPath, ...provides } = mountEl.dataset;
+
return new Vue({
el: mountEl,
name: 'ProfileEditRoot',
+ provide: {
+ ...provides,
+ hasAvatar: parseBoolean(provides.hasAvatar),
+ gravatarEnabled: parseBoolean(provides.gravatarEnabled),
+ gravatarLink: JSON.parse(provides.gravatarLink),
+ },
render(createElement) {
- return createElement(ProfileEditApp);
+ return createElement(ProfileEditApp, {
+ props: {
+ profilePath,
+ userPath,
+ },
+ });
},
});
};
diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js
index 107bfd159dd..ea1a5199ece 100644
--- a/app/assets/javascripts/profile/gl_crop.js
+++ b/app/assets/javascripts/profile/gl_crop.js
@@ -25,6 +25,7 @@ import { loadCSSFile } from '../lib/utils/css_utils';
exportHeight = 200,
cropBoxWidth = 200,
cropBoxHeight = 200,
+ onBlobChange = () => {},
} = {},
) {
this.onUploadImageBtnClick = this.onUploadImageBtnClick.bind(this);
@@ -54,6 +55,7 @@ import { loadCSSFile } from '../lib/utils/css_utils';
this.uploadImageBtn = isString(uploadImageBtn) ? $(uploadImageBtn) : uploadImageBtn;
this.modalCropImg = isString(modalCropImg) ? $(modalCropImg) : modalCropImg;
this.cropActionsBtn = this.modalCrop.find('[data-method]');
+ this.onBlobChange = onBlobChange;
this.bindEvents();
}
@@ -75,6 +77,7 @@ import { loadCSSFile } from '../lib/utils/css_utils';
const btn = this;
return _this.onActionBtnClick(btn);
});
+ this.onBlobChange(null);
return (this.croppedImageBlob = null);
}
@@ -187,7 +190,10 @@ import { loadCSSFile } from '../lib/utils/css_utils';
height: 200,
})
.toDataURL('image/png');
- return (this.croppedImageBlob = this.dataURLtoBlob(this.dataURL));
+
+ this.croppedImageBlob = this.dataURLtoBlob(this.dataURL);
+ this.onBlobChange(this.croppedImageBlob);
+ return this.croppedImageBlob;
}
getBlob() {
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index 26463003f8d..1dd41bf8e27 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -73,6 +73,20 @@ module ProfilesHelper
def prevent_delete_account?
false
end
+
+ def user_profile_data(user)
+ {
+ profile_path: profile_path,
+ profile_avatar_path: profile_avatar_path,
+ avatar_url: avatar_icon_for_user(user, current_user: current_user),
+ has_avatar: user.avatar?.to_s,
+ gravatar_enabled: gravatar_enabled?.to_s,
+ gravatar_link: { hostname: Gitlab.config.gravatar.host, url: "https://#{Gitlab.config.gravatar.host}" }.to_json,
+ brand_profile_image_guidelines: current_appearance&.profile_image_guidelines? ? brand_profile_image_guidelines : '',
+ cropper_css_path: ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css'),
+ user_path: user_path(current_user)
+ }
+ end
end
ProfilesHelper.prepend_mod
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index ebdea5786f5..4da48771ba3 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -5,7 +5,7 @@
- @force_desktop_expanded_sidebar = true
- if Feature.enabled?(:edit_user_profile_vue, current_user)
- .js-user-profile
+ .js-user-profile{ data: user_profile_data(@user) }
- else
= gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f|
.settings-section.js-search-settings-section
diff --git a/db/docs/batched_background_migrations/backfill_dismissal_reason_in_vulnerability_reads.yml b/db/docs/batched_background_migrations/backfill_dismissal_reason_in_vulnerability_reads.yml
new file mode 100644
index 00000000000..a446300af0a
--- /dev/null
+++ b/db/docs/batched_background_migrations/backfill_dismissal_reason_in_vulnerability_reads.yml
@@ -0,0 +1,6 @@
+---
+migration_job_name: BackfillDismissalReasonInVulnerabilityReads
+description: Backfill `dismissal_reason` for rows with `state` of `dismissed` in `vulnerability_reads` table
+feature_category: vulnerability_management
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/412667
+milestone: 16.1
diff --git a/db/post_migrate/20230612232000_queue_backfill_dismissal_reason_in_vulnerability_reads.rb b/db/post_migrate/20230612232000_queue_backfill_dismissal_reason_in_vulnerability_reads.rb
new file mode 100644
index 00000000000..2f7c670795a
--- /dev/null
+++ b/db/post_migrate/20230612232000_queue_backfill_dismissal_reason_in_vulnerability_reads.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class QueueBackfillDismissalReasonInVulnerabilityReads < Gitlab::Database::Migration[2.1]
+ MIGRATION = "BackfillDismissalReasonInVulnerabilityReads"
+ DELAY_INTERVAL = 2.minutes
+ BATCH_SIZE = 5000
+ SUB_BATCH_SIZE = 500
+
+ restrict_gitlab_migration gitlab_schema: :gitlab_main
+
+ def up
+ queue_batched_background_migration(
+ MIGRATION,
+ :vulnerability_reads,
+ :id,
+ job_interval: DELAY_INTERVAL,
+ batch_size: BATCH_SIZE,
+ sub_batch_size: SUB_BATCH_SIZE
+ )
+ end
+
+ def down
+ delete_batched_background_migration(MIGRATION, :vulnerability_reads, :id, [])
+ end
+end
diff --git a/db/schema_migrations/20230612232000 b/db/schema_migrations/20230612232000
new file mode 100644
index 00000000000..248552bbb52
--- /dev/null
+++ b/db/schema_migrations/20230612232000
@@ -0,0 +1 @@
+cb34e35ebabd6e7f1c5ac0796ab92d1323c88d222ae5a5b38686383b365dca46 \ No newline at end of file
diff --git a/doc/api/member_roles.md b/doc/api/member_roles.md
index 3ef6e287418..f3d2d7330b4 100644
--- a/doc/api/member_roles.md
+++ b/doc/api/member_roles.md
@@ -29,6 +29,7 @@ If successful, returns [`200`](rest/index.md#status-codes) and the following res
| `[].group_id` | integer | The ID of the group that the member role belongs to. |
| `[].base_access_level` | integer | Base access level for member role. |
| `[].read_code` | boolean | Permission to read code. |
+| `[].read_dependency` | boolean | Permission to read project dependencies. |
Example request:
@@ -70,6 +71,7 @@ To add a member role to a group, the group must be at root-level (have no parent
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](rest/index.md#namespaced-path-encoding) owned by the authenticated user. |
| `base_access_level` | integer | yes | Base access level for configured role. |
| `read_code` | boolean | no | Permission to read code. |
+| `read_dependency` | boolean | no | Permission to read project dependencies. |
If successful, returns [`201`](rest/index.md#status-codes) and the following attributes:
@@ -79,6 +81,7 @@ If successful, returns [`201`](rest/index.md#status-codes) and the following att
| `group_id` | integer | The ID of the group that the member role belongs to. |
| `base_access_level` | integer | Base access level for member role. |
| `read_code` | boolean | Permission to read code. |
+| `read_dependency` | boolean | Permission to read project dependencies. |
Example request:
@@ -93,7 +96,8 @@ Example response:
"id": 3,
"group_id": 84,
"base_access_level": 10,
- "read_code": true
+ "read_code": true,
+ "read_dependency": false
}
```
diff --git a/doc/architecture/blueprints/organization/index.md b/doc/architecture/blueprints/organization/index.md
index 6fabed33302..9676a4c90f3 100644
--- a/doc/architecture/blueprints/organization/index.md
+++ b/doc/architecture/blueprints/organization/index.md
@@ -234,7 +234,7 @@ Non-Users are external to the Organization and can only access the public resour
Organizations will have an Owner role. Compared to Users, they can perform the following actions:
-| Action | Owner | User |
+| Action | Owner | User |
| ------ | ------ | ----- |
| View Organization settings | :white_check_mark: | :x: |
| Edit Organization settings | :white_check_mark: | :x: |
@@ -266,10 +266,11 @@ The following iteration plan outlines how we intend to arrive at the Organizatio
### Iteration 1: Organization Prototype (FY24Q2)
-In iteration 1, we introduce the concept of an Organization as a way to Group top-level Groups together. Support for Organizations does not require any [Cells](../cells/index.md) work, but having them will make all subsequent iterations of Cells simpler. The goal of iteration 1 will be to generate a prototype that can be used by GitLab teams to test moving functionality to the Organization. It contains everything that is necessary to move an Organization to a Cell:
+In iteration 1, we introduce the concept of an Organization as a way to group top-level Groups together. Support for Organizations does not require any [Cells](../cells/index.md) work, but having them will make all subsequent iterations of Cells simpler. The goal of iteration 1 will be to generate a prototype that can be used by GitLab teams to test moving functionality to the Organization. It contains everything that is necessary to move an Organization to a Cell:
- The Organization can be named, has an ID and an avatar.
-- Only a Non-Enterprise User can be part of an Organization.
+- Both Enterprise and Non-Enterprise Users can be part of an Organization.
+- Enterprise Users are still managed by top-level Groups.
- A User can be part of multiple Organizations.
- A single Organization Owner can be assigned.
- Groups can be created in an Organization. Groups are listed in the Groups overview.
@@ -288,7 +289,8 @@ In iteration 2, an Organization MVC Experiment will be released. We will test th
In iteration 3, the Organization MVC Beta will be released.
- Multiple Organization Owners can be assigned.
-- Organization Owners can change the visibility of an organization between `public` and `private`. A Non-User of a specific Organization will not see private Organizations in the explore section.
+- Organization Owners can create, edit and delete Groups from the Groups overview.
+- Organization Owners can create, edit and delete Projects from the Projects overview.
### Iteration 4: Organization MVC GA (FY25Q1)
@@ -320,7 +322,7 @@ We propose the following steps to successfully roll out Organizations:
- Phase 1: Rollout
- Organizations will be rolled out using the concept of a `default Organization`. All existing top-level groups on GitLab.com are already part of this `default Organization`. The Organization UI is feature flagged and can be enabled for a specific set of users initially, and the global user pool at the end of this phase. This way, users will already become familiar with the concept of an Organization and the Organization UI. No features would be impacted by enabling the `default Organization`. See issue [#418225](https://gitlab.com/gitlab-org/gitlab/-/issues/418225) for more details.
-- Phase 2: Migrations
+- Phase 2: Migrations
- GitLab, the organization, will be the first one to bud off into a separate Organization. We move all top-level groups that belong to GitLab into the new GitLab Organization, including the `gitLab-org` and `gitLab-com` top-level Groups. See issue [#418228](https://gitlab.com/gitlab-org/gitlab/-/issues/418228) for more details.
- Existing customers can create their own Organization. Creation of an Organization remains optional.
- Phase 3: Onboarding changes
diff --git a/doc/ci/resource_groups/index.md b/doc/ci/resource_groups/index.md
index 74e3e493adb..6d7049ff23b 100644
--- a/doc/ci/resource_groups/index.md
+++ b/doc/ci/resource_groups/index.md
@@ -314,4 +314,4 @@ To get job information from the GraphQL API:
}
```
- If the status is not `running` or `pending`, open a new issue.
+ If the status is not `running` or `pending`, [open a new issue](https://gitlab.com/gitlab-org/gitlab/-/issues/new) and [contact support](https://about.gitlab.com/support/#contact-support) so they can apply the correct labels to the issue.
diff --git a/doc/user/application_security/vulnerability_report/index.md b/doc/user/application_security/vulnerability_report/index.md
index 78f8bbdb0c3..2f439588e2b 100644
--- a/doc/user/application_security/vulnerability_report/index.md
+++ b/doc/user/application_security/vulnerability_report/index.md
@@ -48,6 +48,9 @@ At the project level, the Vulnerability Report also contains:
- The number of failures that occurred in the most recent pipeline. Select the failure
notification to view the **Failed jobs** tab of the pipeline's page.
+When vulnerabilities originate from a multi-project pipeline setup,
+this page displays the vulnerabilities that originate from the selected project.
+
### View the project-level vulnerability report
To view the project-level vulnerability report:
diff --git a/doc/user/group/import/index.md b/doc/user/group/import/index.md
index f5cb530337e..0ef7e8da416 100644
--- a/doc/user/group/import/index.md
+++ b/doc/user/group/import/index.md
@@ -64,6 +64,53 @@ groups are in the same GitLab instance. Transferring groups is a faster and more
See [epic 6629](https://gitlab.com/groups/gitlab-org/-/epics/6629) for a list of known issues for migrating by direct
transfer.
+### Estimating migration duration
+
+Estimating the duration of migration by direct transfer is difficult. The following factors affect migration duration:
+
+- Hardware and database resources available on the source and destination GitLab instances. More resources on the source and destination instances can result in
+ shorter migration duration because:
+ - The source instance receives API requests, and extracts and serializes the entities to export.
+ - The destination instance runs the jobs and creates the entities in its database.
+- Complexity and size of data to be exported. For example, imagine you want to migrate two different projects with 1000 merge requests each. The two projects can take
+ very different amounts of time to migrate if one of the projects has a lot more attachments, comments, and other items on the merge requests. Therefore, the number
+ of merge requests on a project is a poor predictor of how long a project will take to migrate.
+
+There’s no exact formula to reliably estimate a migration. However, the average durations of each pipeline worker importing a project relation can help you to get an idea of how long importing your projects might take:
+
+| Project resource type | Average time (in seconds) to import a record |
+|:----------------------------|:---------------------------------------------|
+| Empty Project | 2.4 |
+| Repository | 20 |
+| Project Attributes | 1.5 |
+| Members | 0.2 |
+| Labels | 0.1 |
+| Milestones | 0.07 |
+| Badges | 0.1 |
+| Issues | 0.1 |
+| Snippets | 0.05 |
+| Snippet Repositories | 0.5 |
+| Boards | 0.1 |
+| Merge Requests | 1 |
+| External Pull Requests | 0.5 |
+| Protected Branches | 0.1 |
+| Project Feature | 0.3 |
+| Container Expiration Policy | 0.3 |
+| Service Desk Setting | 0.3 |
+| Releases | 0.1 |
+| CI Pipelines | 0.2 |
+| Commit Notes | 0.05 |
+| Wiki | 10 |
+| Uploads | 0.5 |
+| LFS Objects | 0.5 |
+| Design | 0.1 |
+| Auto DevOps | 0.1 |
+| Pipeline Schedules | 0.5 |
+| References | 5 |
+| Push Rule | 0.1 |
+
+If you are migrating large projects and encounter problems with timeouts or duration of the migration, see [Reducing migration duration](#reducing-migration-duration).
+
### Limits
Hardcoded limits apply on migration by direct transfer.
@@ -428,6 +475,24 @@ You can receive other `404` errors when importing a group, for example:
This error indicates a problem transferring from the _source_ instance. To solve this, check that you have met the [prerequisites](#prerequisites) on the source
instance.
+#### Reducing migration duration
+
+A single direct transfer migration runs 5 entities (groups or projects) per import at a time, independent of the number of workers available on the destination instance.
+That said, having more workers on the destination instance speeds up migration by decreasing the time it takes to import each entity.
+
+Increasing the number of workers on the destination instance helps reduce the migration duration until the source instance hardware resources are saturated. Exporting and importing relations in batches (proposed in [epic 9036](https://gitlab.com/groups/gitlab-org/-/epics/9036)) will make having enough available workers on
+the destination instance even more useful.
+
+The number of workers on the source instance should be enough to export the 5 concurrent entities in parallel (for each running import). Otherwise, there can be
+delays and potential timeouts as the destination is waiting for exported data to become available.
+
+Distributing projects in different groups helps to avoid timeouts. If several large projects are in the same group, you can:
+
+1. Move large projects to different groups or subgroups.
+1. Start separate migrations each group and subgroup.
+
+The GitLab UI can only migrate top-level groups. Using the API, you can also migrate subgroups.
+
## Migrate groups by uploading an export file (deprecated)
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/2888) in GitLab 13.0 as an experimental feature. May change in future releases.
diff --git a/doc/user/project/repository/branches/default.md b/doc/user/project/repository/branches/default.md
index 6dc12c3f4f7..643f067e4c7 100644
--- a/doc/user/project/repository/branches/default.md
+++ b/doc/user/project/repository/branches/default.md
@@ -103,16 +103,16 @@ to apply to every repository's [default branch](#default-branch)
at the [instance level](#instance-level-default-branch-protection) and
[group level](#group-level-default-branch-protection) with one of the following options:
-- **Not protected** - Both developers and maintainers can push new commits
- and force push.
+- **Fully protected** - Default value. Developers cannot push new commits, but maintainers can.
+ No one can force push.
+- **Fully protected after initial push** - Developers can push the initial commit
+ to a repository, but none afterward. Maintainers can always push. No one can force push.
- **Protected against pushes** - Developers cannot push new commits, but are
allowed to accept merge requests to the branch. Maintainers can push to the branch.
- **Partially protected** - Both developers and maintainers can push new commits,
but cannot force push.
-- **Fully protected** - Developers cannot push new commits, but maintainers can.
- No one can force push.
-- **Fully protected after initial push** - Developers can push the initial commit
- to a repository, but none afterward. Maintainers can always push. No one can force push.
+- **Not protected** - Both developers and maintainers can push new commits
+ and force push.
### Instance-level default branch protection **(FREE SELF)**
diff --git a/lefthook.yml b/lefthook.yml
index 7a3c7e5d174..85f1f671d55 100644
--- a/lefthook.yml
+++ b/lefthook.yml
@@ -23,6 +23,7 @@ pre-push:
markdownlint:
tags: documentation style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
+ exclude: "doc/api/graphql/reference/index.md"
glob: 'doc/*.md'
run: yarn markdownlint {files}
yamllint:
@@ -58,6 +59,7 @@ pre-push:
vale: # Requires Vale: https://docs.gitlab.com/ee/development/documentation/testing.html#install-linters
tags: documentation style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
+ exclude: "doc/api/graphql/reference/index.md"
glob: 'doc/*.md'
run: 'if [ $VALE_WARNINGS ]; then minWarnings=warning; else minWarnings=error; fi; if command -v vale > /dev/null 2>&1; then if ! vale --config .vale.ini --minAlertLevel $minWarnings {files}; then echo "ERROR: Fix any linting errors and make sure you are using the latest version of Vale."; exit 1; fi; else echo "ERROR: Vale not found. For more information, see https://docs.errata.ai/vale/install."; exit 1; fi'
gettext:
@@ -73,6 +75,7 @@ pre-push:
docs-trailing_spaces: # Not enforced in CI/CD pipelines, but reduces the amount of required cleanup: https://gitlab.com/gitlab-org/technical-writing/-/blob/main/.gitlab/issue_templates/tw-monthly-tasks.md#remote-tasks
tags: documentation style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
+ exclude: "doc/api/graphql/reference/index.md"
glob: 'doc/*.md'
run: yarn markdownlint:no-trailing-spaces {files}
docs-deprecations:
diff --git a/lib/gitlab/background_migration/backfill_dismissal_reason_in_vulnerability_reads.rb b/lib/gitlab/background_migration/backfill_dismissal_reason_in_vulnerability_reads.rb
new file mode 100644
index 00000000000..d7972a6a7a9
--- /dev/null
+++ b/lib/gitlab/background_migration/backfill_dismissal_reason_in_vulnerability_reads.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module BackgroundMigration
+ # This batched background migration is EE-only,
+ # see ee/lib/ee/gitlab/background_migration/backfill_dismissal_reason_in_vulnerability_reads.rb for the actual
+ # migration code.
+ #
+ # This batched background migration will backfill `dismissal_reason` field in `vulnerability_reads` table for
+ # records with `state: 2` and `dismissal_reason: null`.
+ class BackfillDismissalReasonInVulnerabilityReads < BatchedMigrationJob
+ feature_category :vulnerability_management
+
+ def perform; end
+ end
+ end
+end
+
+Gitlab::BackgroundMigration::BackfillDismissalReasonInVulnerabilityReads.prepend_mod
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 6209ef99087..f4c1c13abf7 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -41872,6 +41872,9 @@ msgstr ""
msgid "SecurityOrchestration|Vulnerabilities are %{vulnerabilityStates}."
msgstr ""
+msgid "SecurityOrchestration|Vulnerability age requires previously existing vulnerability states (detected, confirmed, resolved, or dismissed)"
+msgstr ""
+
msgid "SecurityOrchestration|When %{scanners} %{vulnerabilitiesAllowed} %{vulnerability} in an open merge request %{targeting}%{branches}%{criteriaApply}"
msgstr ""
@@ -53862,12 +53865,18 @@ msgstr ""
msgid "ZentaoIntegration|ZenTao issues"
msgstr ""
+msgid "Zoom in"
+msgstr ""
+
msgid "Zoom meeting added"
msgstr ""
msgid "Zoom meeting removed"
msgstr ""
+msgid "Zoom out"
+msgstr ""
+
msgid "[No reason]"
msgstr ""
diff --git a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
index cc296259b80..cd181f73473 100644
--- a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
+++ b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
@@ -6,34 +6,63 @@ RSpec.describe 'User uploads avatar to profile', feature_category: :user_profile
let!(:user) { create(:user) }
let(:avatar_file_path) { Rails.root.join('spec', 'fixtures', 'dk.png') }
- before do
- stub_feature_flags(edit_user_profile_vue: false)
- sign_in user
- visit profile_path
- end
+ shared_examples 'upload avatar' do
+ it 'shows the new avatar immediately in the header and setting sidebar', :js do
+ expect(page.find('.avatar-image .gl-avatar')['src']).not_to include(
+ "/uploads/-/system/user/avatar/#{user.id}/avatar.png"
+ )
+ find('.js-user-avatar-input', visible: false).set(avatar_file_path)
+
+ click_button 'Set new profile picture'
+ click_button 'Update profile settings'
- it 'they see their new avatar on their profile' do
- attach_file('user_avatar', avatar_file_path, visible: false)
- click_button 'Update profile settings'
+ wait_for_all_requests
- visit user_path(user)
+ data_uri = find('.avatar-image .gl-avatar')['src']
+ expect(page.find('.header-user-avatar')['src']).to eq data_uri
+ expect(page.find('[data-testid="sidebar-user-avatar"]')['src']).to eq data_uri
+
+ visit profile_path
+
+ expect(page.find('.avatar-image .gl-avatar')['src']).to include(
+ "/uploads/-/system/user/avatar/#{user.id}/avatar.png"
+ )
+ end
+ end
- expect(page).to have_selector(%(img[src$="/uploads/-/system/user/avatar/#{user.id}/dk.png?width=96"]))
+ context 'with "edit_user_profile_vue" turned on' do
+ before do
+ sign_in_and_visit_profile
+ end
- # Cheating here to verify something that isn't user-facing, but is important
- expect(user.reload.avatar.file).to exist
+ it_behaves_like 'upload avatar'
end
- it 'their new avatar is immediately visible in the header and setting sidebar', :js do
- find('.js-user-avatar-input', visible: false).set(avatar_file_path)
+ context 'with "edit_user_profile_vue" turned off' do
+ before do
+ stub_feature_flags(edit_user_profile_vue: false)
+ sign_in_and_visit_profile
+ end
- click_button 'Set new profile picture'
- click_button 'Update profile settings'
+ it 'they see their new avatar on their profile' do
+ attach_file('user_avatar', avatar_file_path, visible: false)
+ click_button 'Update profile settings'
- wait_for_all_requests
+ visit user_path(user)
- data_uri = find('.avatar-image .gl-avatar')['src']
- expect(page.find('.header-user-avatar')['src']).to eq data_uri
- expect(page.find('[data-testid="sidebar-user-avatar"]')['src']).to eq data_uri
+ expect(page).to have_selector(%(img[src$="/uploads/-/system/user/avatar/#{user.id}/dk.png?width=96"]))
+
+ # Cheating here to verify something that isn't user-facing, but is important
+ expect(user.reload.avatar.file).to exist
+ end
+
+ it_behaves_like 'upload avatar'
+ end
+
+ private
+
+ def sign_in_and_visit_profile
+ sign_in user
+ visit profile_path
end
end
diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js
index 4efc0ac6028..dd3c6862ea4 100644
--- a/spec/frontend/ide/utils_spec.js
+++ b/spec/frontend/ide/utils_spec.js
@@ -8,7 +8,6 @@ import {
trimTrailingWhitespace,
getPathParents,
getPathParent,
- readFileAsDataURL,
addNumericSuffix,
} from '~/ide/utils';
@@ -267,16 +266,6 @@ describe('WebIDE utils', () => {
});
});
- describe('readFileAsDataURL', () => {
- it('reads a file and returns its output as a data url', () => {
- const file = new File(['foo'], 'foo.png', { type: 'image/png' });
-
- return readFileAsDataURL(file).then((contents) => {
- expect(contents).toBe('data:image/png;base64,Zm9v');
- });
- });
- });
-
/*
* hello-2425 -> hello-2425
* hello.md -> hello-1.md
diff --git a/spec/frontend/lib/utils/file_utility_spec.js b/spec/frontend/lib/utils/file_utility_spec.js
new file mode 100644
index 00000000000..386deafe712
--- /dev/null
+++ b/spec/frontend/lib/utils/file_utility_spec.js
@@ -0,0 +1,13 @@
+import { readFileAsDataURL } from '~/lib/utils/file_utility';
+
+describe('File utilities', () => {
+ describe('readFileAsDataURL', () => {
+ it('reads a file and returns its output as a data url', () => {
+ const file = new File(['foo'], 'foo.png', { type: 'image/png' });
+
+ return readFileAsDataURL(file).then((contents) => {
+ expect(contents).toBe('data:image/png;base64,Zm9v');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/profile/edit/components/profile_edit_app_spec.js b/spec/frontend/profile/edit/components/profile_edit_app_spec.js
new file mode 100644
index 00000000000..614b5ad1a4d
--- /dev/null
+++ b/spec/frontend/profile/edit/components/profile_edit_app_spec.js
@@ -0,0 +1,111 @@
+import { GlButton, GlForm } from '@gitlab/ui';
+import MockAdapter from 'axios-mock-adapter';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+
+import { readFileAsDataURL } from '~/lib/utils/file_utility';
+import axios from '~/lib/utils/axios_utils';
+import ProfileEditApp from '~/profile/edit/components/profile_edit_app.vue';
+import UserAvatar from '~/profile/edit/components/user_avatar.vue';
+import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/alert';
+
+jest.mock('~/alert');
+jest.mock('~/lib/utils/file_utility', () => ({
+ readFileAsDataURL: jest.fn().mockResolvedValue(),
+}));
+
+describe('Profile Edit App', () => {
+ let wrapper;
+ let mockAxios;
+
+ const mockAvatarBlob = new Blob([''], { type: 'image/png' });
+
+ const mockAvatarFile = new File([mockAvatarBlob], 'avatar.png', { type: mockAvatarBlob.type });
+
+ const stubbedProfilePath = '/profile/edit';
+ const stubbedUserPath = '/user/test';
+ const successMessage = 'Profile was successfully updated.';
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(ProfileEditApp, {
+ propsData: {
+ profilePath: stubbedProfilePath,
+ userPath: stubbedUserPath,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mockAxios = new MockAdapter(axios);
+
+ createComponent();
+ });
+
+ const findForm = () => wrapper.findComponent(GlForm);
+ const findButtons = () => wrapper.findAllComponents(GlButton);
+ const findAvatar = () => wrapper.findComponent(UserAvatar);
+ const submitForm = () => findForm().vm.$emit('submit', new Event('submit'));
+ const setAvatar = () => findAvatar().vm.$emit('blob-change', mockAvatarFile);
+
+ it('renders the form for users to interact with', () => {
+ const form = findForm();
+ const buttons = findButtons();
+
+ expect(form.exists()).toBe(true);
+ expect(buttons).toHaveLength(2);
+
+ expect(wrapper.findByTestId('cancel-edit-button').attributes('href')).toBe(stubbedUserPath);
+ });
+
+ describe('when form submit request is successful', () => {
+ it('shows success alert', async () => {
+ mockAxios.onPut(stubbedProfilePath).reply(200, {
+ message: successMessage,
+ });
+
+ submitForm();
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith({ message: successMessage, variant: VARIANT_INFO });
+ });
+
+ it('syncs header avatars', async () => {
+ mockAxios.onPut(stubbedProfilePath).reply(200, {
+ message: successMessage,
+ });
+
+ setAvatar();
+ submitForm();
+
+ await waitForPromises();
+
+ expect(readFileAsDataURL).toHaveBeenCalledWith(mockAvatarFile);
+ });
+ });
+
+ describe('when form submit request is not successful', () => {
+ it('shows error alert', async () => {
+ mockAxios.onPut(stubbedProfilePath).reply(500);
+
+ submitForm();
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith(
+ expect.objectContaining({ variant: VARIANT_DANGER }),
+ );
+ });
+ });
+
+ it('submits API request with avatar file', async () => {
+ mockAxios.onPut(stubbedProfilePath).reply(200);
+
+ setAvatar();
+ submitForm();
+
+ await waitForPromises();
+
+ const axiosRequestData = mockAxios.history.put[0].data;
+
+ expect(axiosRequestData.get('user[avatar]')).toEqual(mockAvatarFile);
+ });
+});
diff --git a/spec/frontend/profile/edit/components/user_avatar_spec.js b/spec/frontend/profile/edit/components/user_avatar_spec.js
new file mode 100644
index 00000000000..caa3356b49f
--- /dev/null
+++ b/spec/frontend/profile/edit/components/user_avatar_spec.js
@@ -0,0 +1,139 @@
+import { nextTick } from 'vue';
+import jQuery from 'jquery';
+import { GlAvatar, GlAvatarLink, GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { avatarI18n } from '~/profile/edit/constants';
+import { loadCSSFile } from '~/lib/utils/css_utils';
+
+import UserAvatar from '~/profile/edit/components/user_avatar.vue';
+
+const glCropDataMock = jest.fn().mockImplementation(() => ({
+ getBlob: jest.fn(),
+}));
+
+const jQueryMock = {
+ glCrop: jest.fn().mockReturnValue({
+ data: glCropDataMock,
+ }),
+};
+
+jest.mock(`~/lib/utils/css_utils`);
+jest.mock('jquery');
+
+describe('Edit User Avatar', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ jQuery.mockImplementation(() => jQueryMock);
+ });
+
+ const defaultProvides = {
+ avatarUrl: '/-/profile/avatarUrl',
+ brandProfileImageGuidelines: '',
+ cropperCssPath: '',
+ hasAvatar: true,
+ gravatarEnabled: true,
+ gravatarLink: {
+ hostname: 'gravatar.com',
+ url: 'gravatar.com',
+ },
+ profileAvatarPath: '/profile/avatar',
+ };
+
+ const createComponent = (provides = {}) => {
+ wrapper = shallowMountExtended(UserAvatar, {
+ provide: {
+ ...defaultProvides,
+ ...provides,
+ },
+ });
+ };
+
+ const findAvatar = () => wrapper.findComponent(GlAvatar);
+ const findAvatarLink = () => wrapper.findComponent(GlAvatarLink);
+ const findHelpText = () => wrapper.findComponent(GlSprintf).attributes('message');
+ const findRemoveAvatarButton = () => wrapper.findByTestId('remove-avatar-button');
+
+ describe('renders correctly', () => {
+ it('under default condition', async () => {
+ createComponent();
+ await nextTick();
+
+ expect(jQueryMock.glCrop).toHaveBeenCalledWith({
+ filename: '.js-avatar-filename',
+ previewImage: '.avatar-image .gl-avatar',
+ modalCrop: '.modal-profile-crop',
+ pickImageEl: '.js-choose-user-avatar-button',
+ uploadImageBtn: '.js-upload-user-avatar',
+ modalCropImg: '.modal-profile-crop-image',
+ onBlobChange: expect.any(Function),
+ });
+ expect(glCropDataMock).toHaveBeenCalledWith('glcrop');
+ expect(loadCSSFile).toHaveBeenCalledWith(defaultProvides.cropperCssPath);
+ const avatar = findAvatar();
+
+ expect(avatar.exists()).toBe(true);
+ expect(avatar.attributes('src')).toBe(defaultProvides.avatarUrl);
+ expect(findAvatarLink().attributes('href')).toBe(defaultProvides.avatarUrl);
+
+ const removeAvatarButton = findRemoveAvatarButton();
+ expect(removeAvatarButton.exists()).toBe(true);
+ expect(removeAvatarButton.attributes('href')).toBe(defaultProvides.profileAvatarPath);
+ });
+
+ describe('when user has avatar', () => {
+ describe('while gravatar is enabled', () => {
+ it('shows help text for change or remove avatar', () => {
+ createComponent({
+ gravatarEnabled: true,
+ });
+
+ expect(findHelpText()).toBe(avatarI18n.changeOrRemoveAvatar);
+ });
+ });
+ describe('while gravatar is disabled', () => {
+ it('shows help text for change avatar', () => {
+ createComponent({
+ gravatarEnabled: false,
+ });
+
+ expect(findHelpText()).toBe(avatarI18n.changeAvatar);
+ });
+ });
+ });
+
+ describe('when user does not have an avatar', () => {
+ describe('while gravatar is enabled', () => {
+ it('shows help text for upload or change avatar', () => {
+ createComponent({
+ gravatarEnabled: true,
+ hasAvatar: false,
+ });
+ expect(findHelpText()).toBe(avatarI18n.uploadOrChangeAvatar);
+ });
+ });
+
+ describe('while gravatar is disabled', () => {
+ it('shows help text for upload avatar', () => {
+ createComponent({
+ gravatarEnabled: false,
+ hasAvatar: false,
+ });
+ expect(findHelpText()).toBe(avatarI18n.uploadAvatar);
+ expect(findRemoveAvatarButton().exists()).toBe(false);
+ });
+ });
+ });
+ });
+
+ it('can render profile image guidelines', () => {
+ const brandProfileImageGuidelines = 'brandProfileImageGuidelines';
+ createComponent({
+ brandProfileImageGuidelines,
+ });
+
+ expect(wrapper.findByTestId('brand-profile-image-guidelines').text()).toBe(
+ brandProfileImageGuidelines,
+ );
+ });
+});
diff --git a/spec/helpers/profiles_helper_spec.rb b/spec/helpers/profiles_helper_spec.rb
index 4c43b1ec4cf..1f166967390 100644
--- a/spec/helpers/profiles_helper_spec.rb
+++ b/spec/helpers/profiles_helper_spec.rb
@@ -124,6 +124,28 @@ RSpec.describe ProfilesHelper do
end
end
+ describe '#user_profile_data' do
+ let(:user) { build_stubbed(:user) }
+
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ it 'returns user profile data' do
+ data = helper.user_profile_data(user)
+
+ expect(data[:profile_path]).to be_a(String)
+ expect(data[:profile_avatar_path]).to be_a(String)
+ expect(data[:avatar_url]).to be_http_url
+ expect(data[:has_avatar]).to be_a(String)
+ expect(data[:gravatar_enabled]).to be_a(String)
+ expect(Gitlab::Json.parse(data[:gravatar_link])).to match(hash_including('hostname' => Gitlab.config.gravatar.host, 'url' => a_valid_url))
+ expect(data[:brand_profile_image_guidelines]).to be_a(String)
+ expect(data[:cropper_css_path]).to eq(ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css'))
+ expect(data[:user_path]).to be_a(String)
+ end
+ end
+
def stub_auth0_omniauth_provider
provider = OpenStruct.new(
'name' => example_omniauth_provider,
diff --git a/spec/migrations/20230612232000_queue_backfill_dismissal_reason_in_vulnerability_reads_spec.rb b/spec/migrations/20230612232000_queue_backfill_dismissal_reason_in_vulnerability_reads_spec.rb
new file mode 100644
index 00000000000..56bfa50abd0
--- /dev/null
+++ b/spec/migrations/20230612232000_queue_backfill_dismissal_reason_in_vulnerability_reads_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe QueueBackfillDismissalReasonInVulnerabilityReads, feature_category: :vulnerability_management do
+ let!(:batched_migration) { described_class::MIGRATION }
+
+ it 'schedules a new batched migration' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(batched_migration).not_to have_scheduled_batched_migration
+ }
+
+ migration.after -> {
+ expect(batched_migration).to have_scheduled_batched_migration(
+ table_name: :vulnerability_reads,
+ column_name: :id,
+ interval: described_class::DELAY_INTERVAL,
+ batch_size: described_class::BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE
+ )
+ }
+ end
+ end
+end