Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-03-16 12:11:31 +00:00
parent 67cdffe4de
commit e2999d09ec
39 changed files with 598 additions and 276 deletions

View File

@ -1 +1 @@
0fc3e28a00fe119679257707dadfb1b9e3354b28
0cc0f3d488f96261608d7c06261be8a0cce0d668

View File

@ -1,6 +1,6 @@
<script>
import { GlIntersectionObserver } from '@gitlab/ui';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { humanize } from '~/lib/utils/text_utility';
import EmojiGroup from './emoji_group.vue';
export default {
@ -25,7 +25,7 @@ export default {
},
computed: {
categoryTitle() {
return capitalizeFirstCharacter(this.category);
return humanize(this.category);
},
},
methods: {
@ -33,9 +33,6 @@ export default {
this.renderGroup = true;
this.$emit('appear', this.category);
},
categoryDissappeared() {
this.renderGroup = false;
},
},
};
</script>

View File

@ -1,11 +1,12 @@
<script>
import { GlIcon, GlDropdown, GlSearchBoxByType } from '@gitlab/ui';
import { findLastIndex } from 'lodash';
import VirtualList from 'vue-virtual-scroll-list';
import { CATEGORY_NAMES } from '~/emoji';
import { CATEGORY_ICON_MAP } from '../constants';
import { CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from '../constants';
import Category from './category.vue';
import EmojiList from './emoji_list.vue';
import { getEmojiCategories } from './utils';
import { addToFrequentlyUsed, getEmojiCategories, hasFrequentlyUsedEmojis } from './utils';
export default {
components: {
@ -25,13 +26,16 @@ export default {
},
data() {
return {
currentCategory: null,
currentCategory: 0,
searchValue: '',
};
},
computed: {
categoryNames() {
return CATEGORY_NAMES.map((category) => ({
return CATEGORY_NAMES.filter((c) => {
if (c === FREQUENTLY_USED_KEY) return hasFrequentlyUsedEmojis();
return true;
}).map((category) => ({
name: category,
icon: CATEGORY_ICON_MAP[category],
}));
@ -50,6 +54,7 @@ export default {
selectEmoji(name) {
this.$emit('click', name);
this.$refs.dropdown.hide();
addToFrequentlyUsed(name);
},
getBoundaryElement() {
return document.querySelector('.content-wrapper') || 'scrollParent';
@ -58,6 +63,11 @@ export default {
this.$refs.virtualScoller.setScrollTop(0);
this.$refs.virtualScoller.forceRender();
},
async onScroll(event, { offset }) {
const categories = await getEmojiCategories();
this.currentCategory = findLastIndex(Object.values(categories), ({ top }) => offset >= top);
},
},
};
</script>
@ -86,10 +96,10 @@ export default {
class="gl-display-flex gl-mx-5 gl-border-b-solid gl-border-gray-100 gl-border-b-1"
>
<button
v-for="category in categoryNames"
v-for="(category, index) in categoryNames"
:key="category.name"
:class="{
'gl-text-black-normal! emoji-picker-category-active': category.name === currentCategory,
'gl-text-black-normal! emoji-picker-category-active': index === currentCategory,
}"
type="button"
class="gl-border-0 gl-border-b-2 gl-border-b-solid gl-flex-fill-1 gl-text-gray-300 gl-pt-3 gl-pb-3 gl-bg-transparent emoji-picker-category-tab"
@ -100,18 +110,20 @@ export default {
</div>
<emoji-list :search-value="searchValue">
<template #default="{ filteredCategories }">
<virtual-list ref="virtualScoller" :size="258" :remain="1" :bench="2" variable>
<virtual-list
ref="virtualScoller"
:size="258"
:remain="1"
:bench="2"
variable
:onscroll="onScroll"
>
<div
v-for="(category, categoryKey) in filteredCategories"
:key="categoryKey"
:style="{ height: category.height + 'px' }"
>
<category
:category="categoryKey"
:emojis="category.emojis"
@appear="categoryAppeared"
@click="selectEmoji"
/>
<category :category="categoryKey" :emojis="category.emojis" @click="selectEmoji" />
</div>
</virtual-list>
</template>

View File

@ -1,27 +1,68 @@
import { chunk, memoize } from 'lodash';
import Cookies from 'js-cookie';
import { chunk, memoize, uniq } from 'lodash';
import { initEmojiMap, getEmojiCategoryMap } from '~/emoji';
import { EMOJIS_PER_ROW, EMOJI_ROW_HEIGHT, CATEGORY_ROW_HEIGHT } from '../constants';
import {
EMOJIS_PER_ROW,
EMOJI_ROW_HEIGHT,
CATEGORY_ROW_HEIGHT,
FREQUENTLY_USED_KEY,
FREQUENTLY_USED_COOKIE_KEY,
} from '../constants';
export const generateCategoryHeight = (emojisLength) =>
emojisLength * EMOJI_ROW_HEIGHT + CATEGORY_ROW_HEIGHT;
export const getFrequentlyUsedEmojis = () => {
const savedEmojis = Cookies.get(FREQUENTLY_USED_COOKIE_KEY);
if (!savedEmojis) return null;
const emojis = chunk(uniq(savedEmojis.split(',')), 9);
return {
frequently_used: {
emojis,
top: 0,
height: generateCategoryHeight(emojis.length),
},
};
};
export const addToFrequentlyUsed = (emoji) => {
const frequentlyUsedEmojis = uniq(
(Cookies.get(FREQUENTLY_USED_COOKIE_KEY) || '')
.split(',')
.filter((e) => e)
.concat(emoji),
);
Cookies.set(FREQUENTLY_USED_COOKIE_KEY, frequentlyUsedEmojis.join(','), { expires: 365 });
};
export const hasFrequentlyUsedEmojis = () => getFrequentlyUsedEmojis() !== null;
export const getEmojiCategories = memoize(async () => {
await initEmojiMap();
const categories = await getEmojiCategoryMap();
let top = 0;
const frequentlyUsedEmojis = getFrequentlyUsedEmojis();
let top = frequentlyUsedEmojis
? frequentlyUsedEmojis.frequently_used.top + frequentlyUsedEmojis.frequently_used.height
: 0;
return Object.freeze(
Object.keys(categories).reduce((acc, category) => {
const emojis = chunk(categories[category], EMOJIS_PER_ROW);
const height = generateCategoryHeight(emojis.length);
const newAcc = {
...acc,
[category]: { emojis, height, top },
};
top += height;
Object.keys(categories)
.filter((c) => c !== FREQUENTLY_USED_KEY)
.reduce((acc, category) => {
const emojis = chunk(categories[category], EMOJIS_PER_ROW);
const height = generateCategoryHeight(emojis.length);
const newAcc = {
...acc,
[category]: { emojis, height, top },
};
top += height;
return newAcc;
}, {}),
return newAcc;
}, frequentlyUsedEmojis || {}),
);
});

View File

@ -1,4 +1,8 @@
export const FREQUENTLY_USED_KEY = 'frequently_used';
export const FREQUENTLY_USED_COOKIE_KEY = 'frequently_used_emojis';
export const CATEGORY_ICON_MAP = {
[FREQUENTLY_USED_KEY]: 'history',
activity: 'dumbbell',
people: 'smiley',
nature: 'nature',

View File

@ -5,6 +5,7 @@ import {
GlDropdownSectionHeader,
GlDropdownItem,
GlIcon,
GlModalDirective,
} from '@gitlab/ui';
import permissionsQuery from 'shared_queries/repository/permissions.query.graphql';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
@ -12,12 +13,15 @@ import { __ } from '../../locale';
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
import projectShortPathQuery from '../queries/project_short_path.query.graphql';
import UploadBlobModal from './upload_blob_modal.vue';
const ROW_TYPES = {
header: 'header',
divider: 'divider',
};
const UPLOAD_BLOB_MODAL_ID = 'modal-upload-blob';
export default {
components: {
GlDropdown,
@ -25,6 +29,7 @@ export default {
GlDropdownSectionHeader,
GlDropdownItem,
GlIcon,
UploadBlobModal,
},
apollo: {
projectShortPath: {
@ -46,6 +51,9 @@ export default {
},
},
},
directives: {
GlModal: GlModalDirective,
},
mixins: [getRefMixin],
props: {
currentPath: {
@ -63,6 +71,21 @@ export default {
required: false,
default: false,
},
canPushCode: {
type: Boolean,
required: false,
default: false,
},
selectedBranch: {
type: String,
required: false,
default: '',
},
originalBranch: {
type: String,
required: false,
default: '',
},
newBranchPath: {
type: String,
required: false,
@ -93,7 +116,13 @@ export default {
required: false,
default: null,
},
uploadPath: {
type: String,
required: false,
default: '',
},
},
uploadBlobModalId: UPLOAD_BLOB_MODAL_ID,
data() {
return {
projectShortPath: '',
@ -126,7 +155,10 @@ export default {
);
},
canCreateMrFromFork() {
return this.userPermissions.forkProject && this.userPermissions.createMergeRequestIn;
return this.userPermissions?.forkProject && this.userPermissions?.createMergeRequestIn;
},
showUploadModal() {
return this.canEditTree && !this.$apollo.queries.userPermissions.loading;
},
dropdownItems() {
const items = [];
@ -149,10 +181,9 @@ export default {
{
attrs: {
href: '#modal-upload-blob',
'data-target': '#modal-upload-blob',
'data-toggle': 'modal',
},
text: __('Upload file'),
modalId: UPLOAD_BLOB_MODAL_ID,
},
{
attrs: {
@ -253,12 +284,26 @@ export default {
<gl-icon name="chevron-down" :size="16" class="float-left" />
</template>
<template v-for="(item, i) in dropdownItems">
<component :is="getComponent(item.type)" :key="i" v-bind="item.attrs">
<component
:is="getComponent(item.type)"
:key="i"
v-bind="item.attrs"
v-gl-modal="item.modalId || null"
>
{{ item.text }}
</component>
</template>
</gl-dropdown>
</li>
</ol>
<upload-blob-modal
v-if="showUploadModal"
:modal-id="$options.uploadBlobModalId"
:commit-message="__('Upload New File')"
:target-branch="selectedBranch"
:original-branch="originalBranch"
:can-push-code="canPushCode"
:path="uploadPath"
/>
</nav>
</template>

View File

@ -1,9 +1,9 @@
import { GlButton } from '@gitlab/ui';
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import { escapeFileUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import { parseBoolean } from '../lib/utils/common_utils';
import { escapeFileUrl } from '../lib/utils/url_utility';
import { __ } from '../locale';
import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
@ -55,6 +55,8 @@ export default function setupVueRepositoryList() {
const {
canCollaborate,
canEditTree,
canPushCode,
selectedBranch,
newBranchPath,
newTagPath,
newBlobPath,
@ -65,8 +67,7 @@ export default function setupVueRepositoryList() {
newDirPath,
} = breadcrumbEl.dataset;
router.afterEach(({ params: { path = '/' } }) => {
updateFormAction('.js-upload-blob-form', uploadPath, path);
router.afterEach(({ params: { path } }) => {
updateFormAction('.js-create-dir-form', newDirPath, path);
});
@ -81,12 +82,16 @@ export default function setupVueRepositoryList() {
currentPath: this.$route.params.path,
canCollaborate: parseBoolean(canCollaborate),
canEditTree: parseBoolean(canEditTree),
canPushCode: parseBoolean(canPushCode),
originalBranch: ref,
selectedBranch,
newBranchPath,
newTagPath,
newBlobPath,
forkNewBlobPath,
forkNewDirectoryPath,
forkUploadBlobPath,
uploadPath,
},
});
},

View File

@ -172,9 +172,11 @@ export default {
after: this.handleVuexActionDispatch,
});
document.addEventListener('mousedown', this.handleDocumentMousedown);
document.addEventListener('click', this.handleDocumentClick);
},
beforeDestroy() {
document.removeEventListener('mousedown', this.handleDocumentMousedown);
document.removeEventListener('click', this.handleDocumentClick);
},
methods: {
@ -196,12 +198,37 @@ export default {
this.handleDropdownClose(state.labels.filter(filterFn));
}
},
/**
* This method stores a mousedown event's target.
* Required by the click listener because the click
* event itself has no reference to this element.
*/
handleDocumentMousedown({ target }) {
this.mousedownTarget = target;
},
/**
* This method listens for document-wide click event
* and toggle dropdown if user clicks anywhere outside
* the dropdown while dropdown is visible.
*/
handleDocumentClick({ target }) {
// We also perform the toggle exception check for the
// last mousedown event's target to avoid hiding the
// box when the mousedown happened inside the box and
// only the mouseup did not.
if (
this.showDropdownContents &&
!this.preventDropdownToggleOnClick(target) &&
!this.preventDropdownToggleOnClick(this.mousedownTarget)
) {
this.toggleDropdownContents();
}
},
/**
* This method checks whether a given click target
* should prevent the dropdown from being toggled.
*/
preventDropdownToggleOnClick(target) {
// This approach of element detection is needed
// as the dropdown wrapper is not using `GlDropdown` as
// it will also require us to use `BDropdownForm`
@ -216,19 +243,20 @@ export default {
target?.parentElement?.classList.contains(className),
);
const hadExceptionParent = ['.js-btn-back', '.js-labels-list'].some(
const hasExceptionParent = ['.js-btn-back', '.js-labels-list'].some(
(className) => $(target).parents(className).length,
);
if (
this.showDropdownContents &&
!hadExceptionParent &&
!hasExceptionClass &&
!this.$refs.dropdownButtonCollapsed?.$el.contains(target) &&
!this.$refs.dropdownContents?.$el.contains(target)
) {
this.toggleDropdownContents();
}
const isInDropdownButtonCollapsed = this.$refs.dropdownButtonCollapsed?.$el.contains(target);
const isInDropdownContents = this.$refs.dropdownContents?.$el.contains(target);
return (
hasExceptionClass ||
hasExceptionParent ||
isInDropdownButtonCollapsed ||
isInDropdownContents
);
},
handleDropdownClose(labels) {
// Only emit label updates if there are any labels to update

View File

@ -16,7 +16,6 @@ class ApplicationController < ActionController::Base
include SessionlessAuthentication
include SessionsHelper
include ConfirmEmailWarning
include Gitlab::Tracking::ControllerConcern
include Gitlab::Experimentation::ControllerConcern
include InitializesCurrentUserMode
include Impersonation

View File

@ -2,7 +2,6 @@
class Groups::EmailCampaignsController < Groups::ApplicationController
include InProductMarketingHelper
include Gitlab::Tracking::ControllerConcern
EMAIL_CAMPAIGNS_SCHEMA_URL = 'iglu:com.gitlab/email_campaigns/jsonschema/1-0-0'
@ -25,7 +24,7 @@ class Groups::EmailCampaignsController < Groups::ApplicationController
subject_line: subject_line(@track, @series)
}
track_self_describing_event(EMAIL_CAMPAIGNS_SCHEMA_URL, data: data)
::Gitlab::Tracking.self_describing_event(EMAIL_CAMPAIGNS_SCHEMA_URL, data: data)
end
def redirect_link

View File

@ -10,7 +10,7 @@ module MergedAtFilter
mr_metrics_scope = mr_metrics_scope.merged_after(merged_after) if merged_after.present?
mr_metrics_scope = mr_metrics_scope.merged_before(merged_before) if merged_before.present?
items.join_metrics.merge(mr_metrics_scope)
join_metrics(items, mr_metrics_scope)
end
def merged_after
@ -20,4 +20,22 @@ module MergedAtFilter
def merged_before
params[:merged_before]
end
# rubocop: disable CodeReuse/ActiveRecord
#
# This join optimizes merged_at queries when the finder is invoked for a project by moving
# the target_project_id condition from merge_requests table to merge_request_metrics table.
def join_metrics(items, mr_metrics_scope)
scope = if project_id = items.where_values_hash["target_project_id"]
# removing the original merge_requests.target_project_id condition
items = items.unscope(where: :target_project_id)
# adding the target_project_id condition to merge_request_metrics
items.join_metrics(project_id)
else
items.join_metrics
end
scope.merge(mr_metrics_scope)
end
# rubocop: enable CodeReuse/ActiveRecord
end

View File

@ -50,6 +50,7 @@ module PackagesHelper
def track_package_event(event_name, scope, **args)
::Packages::CreateEventService.new(nil, current_user, event_name: event_name, scope: scope).execute
track_event(event_name, **args)
category = args.delete(:category) || self.class.name
::Gitlab::Tracking.event(category, event_name.to_s, **args)
end
end

View File

@ -131,6 +131,8 @@ module TreeHelper
def breadcrumb_data_attributes
attrs = {
selected_branch: selected_branch,
can_push_code: can?(current_user, :push_code, @project).to_s,
can_collaborate: can_collaborate_with_project?(@project).to_s,
new_blob_path: project_new_blob_path(@project, @ref),
upload_path: project_create_blob_path(@project, @ref),

View File

@ -289,10 +289,19 @@ class MergeRequest < ApplicationRecord
joins(:notes).where(notes: { commit_id: sha })
end
scope :join_project, -> { joins(:target_project) }
scope :join_metrics, -> do
scope :join_metrics, -> (target_project_id = nil) do
# Do not join the relation twice
return self if self.arel.join_sources.any? { |join| join.left.try(:name).eql?(MergeRequest::Metrics.table_name) }
query = joins(:metrics)
query = query.where(MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id]))
query
project_condition = if target_project_id
MergeRequest::Metrics.arel_table[:target_project_id].eq(target_project_id)
else
MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id])
end
query.where(project_condition)
end
scope :references_project, -> { references(:target_project) }
scope :with_api_entity_associations, -> {

View File

@ -21,5 +21,4 @@
#js-tree-list{ data: vue_file_list_data(project, ref) }
- if can_edit_tree?
= render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post
= render 'projects/blob/new_dir'

View File

@ -1781,9 +1781,9 @@
:idempotent: true
:tags: []
- :name: mailers
:feature_category:
:feature_category: :issue_tracking
:has_external_dependencies:
:urgency:
:urgency: low
:resource_boundary:
:weight: 2
:idempotent:

View File

@ -0,0 +1,5 @@
---
title: Migrate bootstrap modal to GlModal for repo single file uploads
merge_request: 55587
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Don't close issue label select box on click if only mouseup outside
merge_request: 56721
author: Simon Stieger @sim0
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Fix bug in wiki link rewriter filter
merge_request: 56636
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Improve the performance of Merge Request Analytics table
merge_request: 56380
author:
type: performance

View File

@ -36,7 +36,8 @@ with any type of [executor](https://docs.gitlab.com/runner/executors/)
`~/.ssh/authorized_keys`) or add it as a [deploy key](../../user/project/deploy_keys/index.md)
if you are accessing a private GitLab repository.
The private key is displayed in the job log, unless you enable
In the following example, the `ssh-add -` command does not display the value of
`$SSH_PRIVATE_KEY` in the job log, though it could be exposed if you enable
[debug logging](../variables/README.md#debug-logging). You might also want to
check the [visibility of your pipelines](../pipelines/settings.md#visibility-of-pipelines).

View File

@ -767,6 +767,66 @@ export default {
};
```
#### Polling and Performance
While the Apollo client has support for simple polling, for performance reasons, our [Etag-based caching](../polling.md) is preferred to hitting the database each time.
Once the backend is set up, there are a few changes to make on the frontend.
First, get your resource Etag path from the backend. In the example of the pipelines graph, this is called the `graphql_resource_etag`. This will be used to create new headers to add to the Apollo context:
```javascript
/* pipelines/components/graph/utils.js */
/* eslint-disable @gitlab/require-i18n-strings */
const getQueryHeaders = (etagResource) => {
return {
fetchOptions: {
method: 'GET',
},
headers: {
/* This will depend on your feature */
'X-GITLAB-GRAPHQL-FEATURE-CORRELATION': 'verify/ci/pipeline-graph',
'X-GITLAB-GRAPHQL-RESOURCE-ETAG': etagResource,
'X-REQUESTED-WITH': 'XMLHttpRequest',
},
};
};
/* eslint-enable @gitlab/require-i18n-strings */
/* component.vue */
apollo: {
pipeline: {
context() {
return getQueryHeaders(this.graphqlResourceEtag);
},
query: getPipelineDetails,
pollInterval: 10000,
..
},
},
```
Then, becasue Etags depend on the request being a `GET` instead of GraphQL's usual `POST`, but our default link library does not support `GET` we need to let our defaut Apollo client know to use a different library.
```javascript
/* componentMountIndex.js */
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
useGet: true,
},
),
});
```
Keep in mind, this means your app will not batch queries.
Once subscriptions are mature, this process can be replaced by using them and we can remove the separate link library and return to batching queries.
### Testing
#### Generating the GraphQL schema

View File

@ -6,7 +6,7 @@ module Banzai
class Rewriter
def initialize(link_string, wiki:, slug:)
@uri = Addressable::URI.parse(link_string)
@wiki_base_path = wiki && wiki.wiki_base_path
@wiki_base_path = wiki&.wiki_base_path
@slug = slug
end
@ -41,7 +41,8 @@ module Banzai
# Any link _not_ of the form `http://example.com/`
def apply_relative_link_rules!
if @uri.relative? && @uri.path.present?
link = ::File.join(@wiki_base_path, @uri.path)
link = @uri.path
link = ::File.join(@wiki_base_path, link) unless link.starts_with?(@wiki_base_path)
link = "#{link}##{@uri.fragment}" if @uri.fragment
@uri = Addressable::URI.parse(link)
end

View File

@ -13,10 +13,17 @@ module Gitlab
(EE_QUEUE_CONFIG_PATH if Gitlab.ee?)
].compact.freeze
DEFAULT_WORKERS = [
DummyWorker.new('default', weight: 1, tags: []),
DummyWorker.new('mailers', weight: 2, tags: [])
].map { |worker| Gitlab::SidekiqConfig::Worker.new(worker, ee: false) }.freeze
# This maps workers not in our application code to queues. We need
# these queues in our YAML files to ensure we don't accidentally
# miss jobs from these queues.
#
# The default queue should be unused, which is why it maps to an
# invalid class name. We keep it in the YAML file for safety, just
# in case anything does get scheduled to run there.
DEFAULT_WORKERS = {
'_' => DummyWorker.new('default', weight: 1, tags: []),
'ActionMailer::MailDeliveryJob' => DummyWorker.new('mailers', feature_category: :issue_tracking, urgency: 'low', weight: 2, tags: [])
}.transform_values { |worker| Gitlab::SidekiqConfig::Worker.new(worker, ee: false) }.freeze
class << self
include Gitlab::SidekiqConfig::CliMethods
@ -40,7 +47,7 @@ module Gitlab
def workers
@workers ||= begin
result = []
result.concat(DEFAULT_WORKERS)
result.concat(DEFAULT_WORKERS.values)
result.concat(find_workers(Rails.root.join('app', 'workers'), ee: false))
if Gitlab.ee?

View File

@ -10,6 +10,7 @@ module Gitlab
def create_labels(worker_class, queue, job)
worker_name = (job['wrapped'].presence || worker_class).to_s
worker = find_worker(worker_name, worker_class)
labels = { queue: queue.to_s,
worker: worker_name,
@ -18,15 +19,15 @@ module Gitlab
feature_category: "",
boundary: "" }
return labels unless worker_class && worker_class.include?(WorkerAttributes)
return labels unless worker.respond_to?(:get_urgency)
labels[:urgency] = worker_class.get_urgency.to_s
labels[:external_dependencies] = bool_as_label(worker_class.worker_has_external_dependencies?)
labels[:urgency] = worker.get_urgency.to_s
labels[:external_dependencies] = bool_as_label(worker.worker_has_external_dependencies?)
feature_category = worker_class.get_feature_category
feature_category = worker.get_feature_category
labels[:feature_category] = feature_category.to_s
resource_boundary = worker_class.get_worker_resource_boundary
resource_boundary = worker.get_worker_resource_boundary
labels[:boundary] = resource_boundary == :unknown ? "" : resource_boundary.to_s
labels
@ -35,6 +36,10 @@ module Gitlab
def bool_as_label(value)
value ? TRUE_LABEL : FALSE_LABEL
end
def find_worker(worker_name, worker_class)
Gitlab::SidekiqConfig::DEFAULT_WORKERS.fetch(worker_name, worker_class)
end
end
end
end

View File

@ -4,21 +4,6 @@ module Gitlab
module Tracking
SNOWPLOW_NAMESPACE = 'gl'
module ControllerConcern
extend ActiveSupport::Concern
protected
def track_event(action = action_name, **args)
category = args.delete(:category) || self.class.name
Gitlab::Tracking.event(category, action.to_s, **args)
end
def track_self_describing_event(schema_url, data:, **args)
Gitlab::Tracking.self_describing_event(schema_url, data: data, **args)
end
end
class << self
def enabled?
Gitlab::CurrentSettings.snowplow_enabled?

View File

@ -56,8 +56,6 @@ RSpec.describe ApplicationController do
end
end
it_behaves_like 'a Trackable Controller'
describe '#add_gon_variables' do
before do
Gon.clear

View File

@ -42,7 +42,7 @@ RSpec.describe Projects::Registry::TagsController do
it 'tracks the event', :snowplow do
get_tags
expect_snowplow_event(category: anything, action: 'list_tags')
expect_snowplow_event(category: 'Projects::Registry::TagsController', action: 'list_tags')
end
end
@ -107,11 +107,12 @@ RSpec.describe Projects::Registry::TagsController do
destroy_tag('test.')
end
it 'tracks the event' do
it 'tracks the event', :snowplow do
expect_delete_tags(%w[test.])
expect(controller).to receive(:track_event).with(:delete_tag)
destroy_tag('test.')
expect_snowplow_event(category: 'Projects::Registry::TagsController', action: 'delete_tag')
end
end
end

View File

@ -3,8 +3,6 @@
require 'spec_helper'
RSpec.describe 'Projects > Files > User uploads files' do
include DropzoneHelper
let(:user) { create(:user) }
let(:project) { create(:project, :repository, name: 'Shop', creator: user) }
let(:project2) { create(:project, :repository, name: 'Another Project', path: 'another-project') }
@ -17,36 +15,15 @@ RSpec.describe 'Projects > Files > User uploads files' do
context 'when a user has write access' do
before do
visit(project_tree_path(project))
wait_for_requests
end
include_examples 'it uploads and commit a new text file'
include_examples 'it uploads and commit a new image file'
it 'uploads a file to a sub-directory', :js do
click_link 'files'
page.within('.repo-breadcrumb') do
expect(page).to have_content('files')
end
find('.add-to-tree').click
click_link('Upload file')
drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'))
page.within('#modal-upload-blob') do
fill_in(:commit_message, with: 'New commit message')
end
click_button('Upload file')
expect(page).to have_content('New commit message')
page.within('.repo-breadcrumb') do
expect(page).to have_content('files')
expect(page).to have_content('doc_sample.txt')
end
end
include_examples 'it uploads a file to a sub-directory'
end
context 'when a user does not have write access' do

View File

@ -17,11 +17,15 @@ RSpec.describe 'Projects > Show > User uploads files' do
context 'when a user has write access' do
before do
visit(project_path(project))
wait_for_requests
end
include_examples 'it uploads and commit a new text file'
include_examples 'it uploads and commit a new image file'
include_examples 'it uploads a file to a sub-directory'
end
context 'when a user does not have write access' do

View File

@ -156,6 +156,18 @@ RSpec.describe MergeRequestsFinder do
it { is_expected.to eq([merge_request2]) }
end
context 'when project_id is given' do
subject(:query) { described_class.new(user, merged_after: 15.days.ago, merged_before: 6.days.ago, project_id: merge_request2.project).execute }
it { is_expected.to eq([merge_request2]) }
it 'queries merge_request_metrics.target_project_id table' do
expect(query.to_sql).to include(%{"merge_request_metrics"."target_project_id" = #{merge_request2.target_project_id}})
expect(query.to_sql).not_to include(%{"merge_requests"."target_project_id"})
end
end
end
context 'filtering by group' do

View File

@ -0,0 +1,56 @@
import Cookies from 'js-cookie';
import { getFrequentlyUsedEmojis, addToFrequentlyUsed } from '~/emoji/components/utils';
jest.mock('js-cookie');
describe('getFrequentlyUsedEmojis', () => {
it('it returns null when no saved emojis set', () => {
jest.spyOn(Cookies, 'get').mockReturnValue(null);
expect(getFrequentlyUsedEmojis()).toBe(null);
});
it('it returns frequently used emojis object', () => {
jest.spyOn(Cookies, 'get').mockReturnValue('thumbsup,thumbsdown');
expect(getFrequentlyUsedEmojis()).toEqual({
frequently_used: {
emojis: [['thumbsup', 'thumbsdown']],
top: 0,
height: 71,
},
});
});
});
describe('addToFrequentlyUsed', () => {
it('sets cookie value', () => {
jest.spyOn(Cookies, 'get').mockReturnValue(null);
addToFrequentlyUsed('thumbsup');
expect(Cookies.set).toHaveBeenCalledWith('frequently_used_emojis', 'thumbsup', {
expires: 365,
});
});
it('sets cookie value to include previously set cookie value', () => {
jest.spyOn(Cookies, 'get').mockReturnValue('thumbsdown');
addToFrequentlyUsed('thumbsup');
expect(Cookies.set).toHaveBeenCalledWith('frequently_used_emojis', 'thumbsdown,thumbsup', {
expires: 365,
});
});
it('sets cookie value with uniq values', () => {
jest.spyOn(Cookies, 'get').mockReturnValue('thumbsup');
addToFrequentlyUsed('thumbsup');
expect(Cookies.set).toHaveBeenCalledWith('frequently_used_emojis', 'thumbsup', {
expires: 365,
});
});
});

View File

@ -1,24 +1,36 @@
import { GlDropdown } from '@gitlab/ui';
import { shallowMount, RouterLinkStub } from '@vue/test-utils';
import Breadcrumbs from '~/repository/components/breadcrumbs.vue';
let vm;
function factory(currentPath, extraProps = {}) {
vm = shallowMount(Breadcrumbs, {
propsData: {
currentPath,
...extraProps,
},
stubs: {
RouterLink: RouterLinkStub,
},
});
}
import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
describe('Repository breadcrumbs component', () => {
let wrapper;
const factory = (currentPath, extraProps = {}) => {
const $apollo = {
queries: {
userPermissions: {
loading: true,
},
},
};
wrapper = shallowMount(Breadcrumbs, {
propsData: {
currentPath,
...extraProps,
},
stubs: {
RouterLink: RouterLinkStub,
},
mocks: { $apollo },
});
};
const findUploadBlobModal = () => wrapper.find(UploadBlobModal);
afterEach(() => {
vm.destroy();
wrapper.destroy();
});
it.each`
@ -30,13 +42,13 @@ describe('Repository breadcrumbs component', () => {
`('renders $linkCount links for path $path', ({ path, linkCount }) => {
factory(path);
expect(vm.findAll(RouterLinkStub).length).toEqual(linkCount);
expect(wrapper.findAll(RouterLinkStub).length).toEqual(linkCount);
});
it('escapes hash in directory path', () => {
factory('app/assets/javascripts#');
expect(vm.findAll(RouterLinkStub).at(3).props('to')).toEqual(
expect(wrapper.findAll(RouterLinkStub).at(3).props('to')).toEqual(
'/-/tree/app/assets/javascripts%23',
);
});
@ -44,26 +56,44 @@ describe('Repository breadcrumbs component', () => {
it('renders last link as active', () => {
factory('app/assets');
expect(vm.findAll(RouterLinkStub).at(2).attributes('aria-current')).toEqual('page');
expect(wrapper.findAll(RouterLinkStub).at(2).attributes('aria-current')).toEqual('page');
});
it('does not render add to tree dropdown when permissions are false', () => {
it('does not render add to tree dropdown when permissions are false', async () => {
factory('/', { canCollaborate: false });
vm.setData({ userPermissions: { forkProject: false, createMergeRequestIn: false } });
wrapper.setData({ userPermissions: { forkProject: false, createMergeRequestIn: false } });
return vm.vm.$nextTick(() => {
expect(vm.find(GlDropdown).exists()).toBe(false);
});
await wrapper.vm.$nextTick();
expect(wrapper.find(GlDropdown).exists()).toBe(false);
});
it('renders add to tree dropdown when permissions are true', () => {
it('renders add to tree dropdown when permissions are true', async () => {
factory('/', { canCollaborate: true });
vm.setData({ userPermissions: { forkProject: true, createMergeRequestIn: true } });
wrapper.setData({ userPermissions: { forkProject: true, createMergeRequestIn: true } });
return vm.vm.$nextTick(() => {
expect(vm.find(GlDropdown).exists()).toBe(true);
await wrapper.vm.$nextTick();
expect(wrapper.find(GlDropdown).exists()).toBe(true);
});
describe('renders the upload blob modal', () => {
beforeEach(() => {
factory('/', { canEditTree: true });
});
it('does not render the modal while loading', () => {
expect(findUploadBlobModal().exists()).toBe(false);
});
it('renders the modal once loaded', async () => {
wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } });
await wrapper.vm.$nextTick();
expect(findUploadBlobModal().exists()).toBe(true);
});
});
});

View File

@ -1,69 +1,67 @@
import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import failedToMergeComponent from '~/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
import MrWidgetFailedToMerge from '~/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue';
import eventHub from '~/vue_merge_request_widget/event_hub';
describe('MRWidgetFailedToMerge', () => {
const dummyIntervalId = 1337;
let Component;
let mr;
let vm;
let wrapper;
const createComponent = (props = {}, data = {}) => {
wrapper = shallowMount(MrWidgetFailedToMerge, {
propsData: {
mr: {
mergeError: 'Merge error happened',
},
...props,
},
data() {
return data;
},
});
};
beforeEach(() => {
Component = Vue.extend(failedToMergeComponent);
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
jest.spyOn(window, 'setInterval').mockReturnValue(dummyIntervalId);
jest.spyOn(window, 'clearInterval').mockImplementation();
mr = {
mergeError: 'Merge error happened',
};
vm = mountComponent(Component, {
mr,
});
});
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
it('sets interval to refresh', () => {
expect(window.setInterval).toHaveBeenCalledWith(vm.updateTimer, 1000);
expect(vm.intervalId).toBe(dummyIntervalId);
});
describe('interval', () => {
it('sets interval to refresh', () => {
createComponent();
it('clears interval when destroying ', () => {
vm.$destroy();
expect(window.clearInterval).toHaveBeenCalledWith(dummyIntervalId);
});
describe('computed', () => {
describe('timerText', () => {
it('should return correct timer text', () => {
expect(vm.timerText).toEqual('Refreshing in 10 seconds to show the updated status...');
vm.timer = 1;
expect(vm.timerText).toEqual('Refreshing in a second to show the updated status...');
});
expect(window.setInterval).toHaveBeenCalledWith(wrapper.vm.updateTimer, 1000);
expect(wrapper.vm.intervalId).toBe(dummyIntervalId);
});
describe('mergeError', () => {
it('removes forced line breaks', (done) => {
mr.mergeError = 'contains<br />line breaks<br />';
it('clears interval when destroying ', () => {
createComponent();
wrapper.destroy();
Vue.nextTick()
.then(() => {
expect(vm.mergeError).toBe('contains line breaks.');
})
.then(done)
.catch(done.fail);
});
expect(window.clearInterval).toHaveBeenCalledWith(dummyIntervalId);
});
});
describe('mergeError', () => {
it('removes forced line breaks', async () => {
createComponent({ mr: { mergeError: 'contains<br />line breaks<br />' } });
await nextTick();
expect(wrapper.vm.mergeError).toBe('contains line breaks.');
});
});
describe('created', () => {
it('should disable polling', () => {
createComponent();
expect(eventHub.$emit).toHaveBeenCalledWith('DisablePolling');
});
});
@ -71,11 +69,13 @@ describe('MRWidgetFailedToMerge', () => {
describe('methods', () => {
describe('refresh', () => {
it('should emit event to request component refresh', () => {
expect(vm.isRefreshing).toEqual(false);
createComponent();
vm.refresh();
expect(wrapper.vm.isRefreshing).toBe(false);
expect(vm.isRefreshing).toEqual(true);
wrapper.vm.refresh();
expect(wrapper.vm.isRefreshing).toBe(true);
expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
expect(eventHub.$emit).toHaveBeenCalledWith('EnablePolling');
});
@ -83,78 +83,76 @@ describe('MRWidgetFailedToMerge', () => {
describe('updateTimer', () => {
it('should update timer and emit event when timer end', () => {
jest.spyOn(vm, 'refresh').mockImplementation(() => {});
createComponent();
expect(vm.timer).toEqual(10);
jest.spyOn(wrapper.vm, 'refresh').mockImplementation(() => {});
expect(wrapper.vm.timer).toEqual(10);
for (let i = 0; i < 10; i += 1) {
expect(vm.timer).toEqual(10 - i);
vm.updateTimer();
expect(wrapper.vm.timer).toEqual(10 - i);
wrapper.vm.updateTimer();
}
expect(vm.refresh).toHaveBeenCalled();
expect(wrapper.vm.refresh).toHaveBeenCalled();
});
});
});
describe('while it is refreshing', () => {
it('renders Refresing now', (done) => {
vm.isRefreshing = true;
it('renders Refresing now', async () => {
createComponent({}, { isRefreshing: true });
Vue.nextTick(() => {
expect(vm.$el.querySelector('.js-refresh-label').textContent.trim()).toEqual(
'Refreshing now',
);
done();
});
await nextTick();
expect(wrapper.find('.js-refresh-label').text().trim()).toBe('Refreshing now');
});
});
describe('while it is not regresing', () => {
beforeEach(() => {
createComponent();
});
it('renders warning icon and disabled merge button', () => {
expect(vm.$el.querySelector('.js-ci-status-icon-warning')).not.toBeNull();
expect(
vm.$el.querySelector('[data-testid="disabled-merge-button"]').getAttribute('disabled'),
).toEqual('disabled');
expect(wrapper.find('.js-ci-status-icon-warning')).not.toBeNull();
expect(wrapper.find(StatusIcon).props('showDisabledButton')).toBe(true);
});
it('renders given error', () => {
expect(vm.$el.querySelector('.has-error-message').textContent.trim()).toEqual(
'Merge error happened.',
);
expect(wrapper.find('.has-error-message').text().trim()).toBe('Merge error happened.');
});
it('renders refresh button', () => {
expect(
vm.$el
.querySelector('[data-testid="merge-request-failed-refresh-button"]')
.textContent.trim(),
).toEqual('Refresh now');
wrapper.find('[data-testid="merge-request-failed-refresh-button"]').text().trim(),
).toBe('Refresh now');
});
it('renders remaining time', () => {
expect(vm.$el.querySelector('.has-custom-error').textContent.trim()).toEqual(
expect(wrapper.find('.has-custom-error').text().trim()).toBe(
'Refreshing in 10 seconds to show the updated status...',
);
});
});
it('should just generic merge failed message if merge_error is not available', (done) => {
vm.mr.mergeError = null;
it('should just generic merge failed message if merge_error is not available', async () => {
createComponent({ mr: { mergeError: null } });
Vue.nextTick(() => {
expect(vm.$el.innerText).toContain('Merge failed.');
expect(vm.$el.innerText).not.toContain('Merge error happened.');
done();
});
await nextTick();
expect(wrapper.text().trim()).toContain('Merge failed.');
expect(wrapper.text().trim()).not.toContain('Merge error happened.');
});
it('should show refresh label when refresh requested', (done) => {
vm.refresh();
Vue.nextTick(() => {
expect(vm.$el.innerText).not.toContain('Merge failed. Refreshing');
expect(vm.$el.innerText).toContain('Refreshing now');
done();
});
it('should show refresh label when refresh requested', async () => {
createComponent();
wrapper.vm.refresh();
await nextTick();
expect(wrapper.text().trim()).not.toContain('Merge failed. Refreshing');
expect(wrapper.text().trim()).toContain('Refreshing now');
});
});

View File

@ -22,6 +22,15 @@ RSpec.describe Banzai::Filter::WikiLinkFilter do
expect(filtered_link.attribute('href').value).to eq('/uploads/a.test')
end
describe 'when links point to the relative wiki path' do
it 'does not rewrite links' do
path = "#{wiki.wiki_base_path}/#{repository_upload_folder}/a.jpg"
filtered_link = filter("<a href='#{path}'>Link</a>", wiki: wiki, page_slug: 'home').children[0]
expect(filtered_link.attribute('href').value).to eq(path)
end
end
describe "when links point to the #{Wikis::CreateAttachmentService::ATTACHMENT_PATH} folder" do
context 'with an "a" html tag' do
it 'rewrites links' do

View File

@ -229,6 +229,15 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do
it_behaves_like "a metrics middleware"
end
context 'for ActionMailer::MailDeliveryJob' do
let(:job) { { 'class' => ActionMailer::MailDeliveryJob } }
let(:worker) { ActionMailer::MailDeliveryJob.new }
let(:worker_class) { ActionMailer::MailDeliveryJob }
let(:labels) { default_labels.merge(feature_category: 'issue_tracking') }
it_behaves_like 'a metrics middleware'
end
context "when workers are attributed" do
def create_attributed_worker_class(urgency, external_dependencies, resource_boundary, category)
Class.new do

View File

@ -1,39 +0,0 @@
# frozen_string_literal: true
RSpec.shared_examples 'a Trackable Controller' do
describe '#track_event', :snowplow do
before do
sign_in user
end
context 'with no params' do
controller(described_class) do
def index
track_event
head :ok
end
end
it 'tracks the action name', :snowplow do
get :index
expect_snowplow_event(category: 'AnonymousController', action: 'index')
end
end
context 'with params' do
controller(described_class) do
def index
track_event('some_event', category: 'SomeCategory', label: 'errorlabel')
head :ok
end
end
it 'tracks with the specified param' do
get :index
expect_snowplow_event(category: 'SomeCategory', action: 'some_event', label: 'errorlabel')
end
end
end
end

View File

@ -10,7 +10,7 @@ RSpec.shared_examples 'it uploads and commit a new text file' do
wait_for_requests
end
drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'))
attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true)
page.within('#modal-upload-blob') do
fill_in(:commit_message, with: 'New commit message')
@ -42,7 +42,7 @@ RSpec.shared_examples 'it uploads and commit a new image file' do
wait_for_requests
end
drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'logo_sample.svg'))
attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'logo_sample.svg'), make_visible: true)
page.within('#modal-upload-blob') do
fill_in(:commit_message, with: 'New commit message')
@ -70,9 +70,11 @@ RSpec.shared_examples 'it uploads and commit a new file to a forked project' do
expect(page).to have_content(fork_message)
wait_for_all_requests
find('.add-to-tree').click
click_link('Upload file')
drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'))
attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true)
page.within('#modal-upload-blob') do
fill_in(:commit_message, with: 'New commit message')
@ -95,6 +97,33 @@ RSpec.shared_examples 'it uploads and commit a new file to a forked project' do
end
end
RSpec.shared_examples 'it uploads a file to a sub-directory' do
it 'uploads a file to a sub-directory', :js do
click_link 'files'
page.within('.repo-breadcrumb') do
expect(page).to have_content('files')
end
find('.add-to-tree').click
click_link('Upload file')
attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true)
page.within('#modal-upload-blob') do
fill_in(:commit_message, with: 'New commit message')
end
click_button('Upload file')
expect(page).to have_content('New commit message')
page.within('.repo-breadcrumb') do
expect(page).to have_content('files')
expect(page).to have_content('doc_sample.txt')
end
end
end
RSpec.shared_examples 'uploads and commits a new text file via "upload file" button' do
it 'uploads and commits a new text file via "upload file" button', :js do
find('[data-testid="upload-file-button"]').click

View File

@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'Every Sidekiq worker' do
let(:workers_without_defaults) do
Gitlab::SidekiqConfig.workers - Gitlab::SidekiqConfig::DEFAULT_WORKERS
Gitlab::SidekiqConfig.workers - Gitlab::SidekiqConfig::DEFAULT_WORKERS.values
end
it 'does not use the default queue' do