......@@ -4,13 +4,14 @@ type: reference
# Issues Analytics **(PREMIUM)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/7478) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.5.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/7478) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.5.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/196561) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.9 at the project level.
Issues Analytics is a bar graph which illustrates the number of issues created each month.
The default timespan is 13 months, which includes the current month, and the 12 months
prior.
To access the chart, navigate to a group's sidebar and select **Analytics > Issues Analytics**.
To access the chart, navigate to your group or project sidebar and select **{chart}** **Analytics > Issues Analytics**.
Hover over each bar to see the total number of issues.
......
......
# frozen_string_literal: true
# rubocop:disable Style/Documentation
module Gitlab
module BackgroundMigration
class CleanupOptimisticLockingNulls
QUERY_ITEM_SIZE = 1_000
# table - The name of the table the migration is performed for.
# start_id - The ID of the object to start at
# stop_id - The ID of the object to end at
def perform(start_id, stop_id, table)
model = define_model_for(table)
# After analysis done, a batch size of 1,000 items per query was found to be
# the most optimal. Discussion in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/18418#note_282285336
(start_id..stop_id).each_slice(QUERY_ITEM_SIZE).each do |range|
model
.where(lock_version: nil)
.where(id: range)
.update_all(lock_version: 0)
end
end
def define_model_for(table)
Class.new(ActiveRecord::Base) do
self.table_name = table
end
end
end
end
end
......@@ -1042,6 +1042,7 @@ into similar problems in the future (e.g. when new tables are created).
# job_class_name - The background migration job class as a string
# delay_interval - The duration between each job's scheduled time (must respond to `to_f`)
# batch_size - The maximum number of rows per job
# other_arguments - Other arguments to send to the job
#
# Example:
#
......@@ -1059,7 +1060,7 @@ into similar problems in the future (e.g. when new tables are created).
# # do something
# end
# end
def queue_background_migration_jobs_by_range_at_intervals(model_class, job_class_name, delay_interval, batch_size: BACKGROUND_MIGRATION_BATCH_SIZE)
def queue_background_migration_jobs_by_range_at_intervals(model_class, job_class_name, delay_interval, batch_size: BACKGROUND_MIGRATION_BATCH_SIZE, other_arguments: [])
raise "#{model_class} does not have an ID to use for batch ranges" unless model_class.column_names.include?('id')
# To not overload the worker too much we enforce a minimum interval both
......@@ -1074,7 +1075,7 @@ into similar problems in the future (e.g. when new tables are created).
# `BackgroundMigrationWorker.bulk_perform_in` schedules all jobs for
# the same time, which is not helpful in most cases where we wish to
# spread the work over time.
migrate_in(delay_interval * index, job_class_name, [start_id, end_id])
migrate_in(delay_interval * index, job_class_name, [start_id, end_id] + other_arguments)
end
end
......
......
......@@ -3,10 +3,21 @@
require 'spec_helper'
describe 'Project navbar' do
it_behaves_like 'verified navigation bar' do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:analytics_nav_item) do
{
nav_item: _('Analytics'),
nav_sub_items: [
_('CI / CD Analytics'),
(_('Code Review') if Gitlab.ee?),
_('Repository Analytics'),
_('Value Stream Analytics')
]
}
end
let(:structure) do
[
{
......@@ -62,15 +73,7 @@ describe 'Project navbar' do
_('Kubernetes')
]
},
{
nav_item: _('Analytics'),
nav_sub_items: [
_('CI / CD Analytics'),
(_('Code Review') if Gitlab.ee?),
_('Repository Analytics'),
_('Value Stream Analytics')
]
},
analytics_nav_item,
{
nav_item: _('Wiki'),
nav_sub_items: []
......@@ -97,8 +100,26 @@ describe 'Project navbar' do
before do
project.add_maintainer(user)
sign_in(user)
end
it_behaves_like 'verified navigation bar' do
before do
visit project_path(project)
end
end
if Gitlab.ee?
context 'when issues analytics is available' do
before do
stub_licensed_features(issues_analytics: true)
analytics_nav_item[:nav_sub_items] << _('Issues Analytics')
analytics_nav_item[:nav_sub_items].sort!
visit project_path(project)
end
it_behaves_like 'verified navigation bar'
end
end
end
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { trimText } from 'spec/helpers/text_helper';
import { shallowMount } from '@vue/test-utils';
import { trimText } from 'helpers/text_helper';
import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue';
import { mockProject } from '../mock_data'; // can also use 'mockGroup', but not useful to test here
import mockData from '../mock_data'; // can also use 'mockGroup', but not useful to test here
const localVue = createLocalVue();
const mockProject = mockData();
describe('FrequentItemsListItemComponent', () => {
let wrapper;
const findTitle = () => wrapper.find({ ref: 'frequentItemsItemTitle' });
const findAvatar = () => wrapper.find({ ref: 'frequentItemsItemAvatar' });
const findAllTitles = () => wrapper.findAll({ ref: 'frequentItemsItemTitle' });
const findNamespace = () => wrapper.find({ ref: 'frequentItemsItemNamespace' });
const findAllAnchors = () => wrapper.findAll('a');
const findAllNamespace = () => wrapper.findAll({ ref: 'frequentItemsItemNamespace' });
const findAvatarContainer = () => wrapper.findAll({ ref: 'frequentItemsItemAvatarContainer' });
const findAllMetadataContainers = () =>
wrapper.findAll({ ref: 'frequentItemsItemMetadataContainer' });
const createComponent = (props = {}) => {
wrapper = shallowMount(localVue.extend(frequentItemsListItemComponent), {
wrapper = shallowMount(frequentItemsListItemComponent, {
propsData: {
itemId: mockProject.id,
itemName: mockProject.name,
......@@ -18,7 +28,6 @@ describe('FrequentItemsListItemComponent', () => {
avatarUrl: mockProject.avatarUrl,
...props,
},
localVue,
});
};
......@@ -28,35 +37,17 @@ describe('FrequentItemsListItemComponent', () => {
});
describe('computed', () => {
describe('hasAvatar', () => {
it('should return `true` or `false` if whether avatar is present or not', () => {
createComponent({ avatarUrl: 'path/to/avatar.png' });
expect(wrapper.vm.hasAvatar).toBe(true);
});
it('should return `false` if avatar is not present', () => {
createComponent({ avatarUrl: null });
expect(wrapper.vm.hasAvatar).toBe(false);
});
});
describe('highlightedItemName', () => {
it('should enclose part of project name in <b> & </b> which matches with `matcher` prop', () => {
createComponent({ matcher: 'lab' });
expect(wrapper.find('.js-frequent-items-item-title').html()).toContain(
'<b>L</b><b>a</b><b>b</b>',
);
expect(findTitle().element.innerHTML).toContain('<b>L</b><b>a</b><b>b</b>');
});
it('should return project name as it is if `matcher` is not available', () => {
createComponent({ matcher: null });
expect(trimText(wrapper.find('.js-frequent-items-item-title').text())).toBe(
mockProject.name,
);
expect(trimText(findTitle().text())).toBe(mockProject.name);
});
});
......@@ -64,7 +55,7 @@ describe('FrequentItemsListItemComponent', () => {
it('should truncate project name from namespace string', () => {
createComponent({ namespace: 'platform / nokia-3310' });
expect(trimText(wrapper.find('.js-frequent-items-item-namespace').text())).toBe('platform');
expect(trimText(findNamespace().text())).toBe('platform');
});
it('should truncate namespace string from the middle if it includes more than two groups in path', () => {
......@@ -72,23 +63,41 @@ describe('FrequentItemsListItemComponent', () => {
namespace: 'platform / hardware / broadcom / Wifi Group / Mobile Chipset / nokia-3310',
});
expect(trimText(wrapper.find('.js-frequent-items-item-namespace').text())).toBe(
'platform / ... / Mobile Chipset',
);
expect(trimText(findNamespace().text())).toBe('platform / ... / Mobile Chipset');
});
});
});
describe('template', () => {
it('should render component element', () => {
beforeEach(() => {
createComponent();
});
it('should render avatar if avatarUrl is present', () => {
wrapper.setProps({ avatarUrl: 'path/to/avatar.png' });
return wrapper.vm.$nextTick(() => {
expect(findAvatar().exists()).toBe(true);
});
});
it('should not render avatar if avatarUrl is not present', () => {
expect(findAvatar().exists()).toBe(false);
});
it('renders root element with the right classes', () => {
expect(wrapper.classes('frequent-items-list-item-container')).toBe(true);
});
expect(wrapper.classes()).toContain('frequent-items-list-item-container');
expect(wrapper.findAll('a').length).toBe(1);
expect(wrapper.findAll('.frequent-items-item-avatar-container').length).toBe(1);
expect(wrapper.findAll('.frequent-items-item-metadata-container').length).toBe(1);
expect(wrapper.findAll('.frequent-items-item-title').length).toBe(1);
expect(wrapper.findAll('.frequent-items-item-namespace').length).toBe(1);
it.each`
name | selector | expected
${'anchor'} | ${findAllAnchors} | ${1}
${'avatar container'} | ${findAvatarContainer} | ${1}
${'metadata container'} | ${findAllMetadataContainers} | ${1}
${'title'} | ${findAllTitles} | ${1}
${'namespace'} | ${findAllNamespace} | ${1}
`('should render $expected $name', ({ selector, expected }) => {
expect(selector()).toHaveLength(expected);
});
});
});
import { TEST_HOST } from 'helpers/test_constants';
export default () => ({
id: 1,
name: 'GitLab Community Edition',
namespace: 'gitlab-org / gitlab-ce',
webUrl: `${TEST_HOST}/gitlab-org/gitlab-foss`,
avatarUrl: null,
});
......@@ -1332,6 +1332,15 @@ describe Gitlab::Database::MigrationHelpers do
end
end
end
context 'with other_arguments option' do
it 'queues jobs correctly' do
model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes, other_arguments: [1, 2])
expect(BackgroundMigrationWorker.jobs[0]['args']).to eq(['FooJob', [id1, id3, 1, 2]])
expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(10.minutes.from_now.to_f)
end
end
end
context "when the model doesn't have an ID column" do
......
......
# frozen_string_literal: true
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20200128210353_cleanup_optimistic_locking_nulls')
describe CleanupOptimisticLockingNulls, :migration do
TABLES = %w(epics merge_requests issues).freeze
TABLES.each do |table|
let(table.to_sym) { table(table.to_sym) }
end
let(:tables) { TABLES.map { |t| method(t.to_sym).call } }
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:users) { table(:users)}
before do
namespaces.create!(id: 123, name: 'gitlab1', path: 'gitlab1')
projects.create!(id: 123, name: 'gitlab1', path: 'gitlab1', namespace_id: 123)
users.create!(id: 123, username: 'author', projects_limit: 1000)
# Create necessary rows
epics.create!(iid: 123, group_id: 123, author_id: 123, title: 'a', title_html: 'a')
merge_requests.create!(iid: 123, target_project_id: 123, source_project_id: 123, target_branch: 'master', source_branch: 'hmm', title: 'a', title_html: 'a')
issues.create!(iid: 123, project_id: 123, title: 'a', title_html: 'a')
# Nullify `lock_version` column for all rows
# Needs to be done with a SQL fragment, otherwise Rails will coerce it to 0
tables.each do |table|
table.update_all('lock_version = NULL')
end
end
it 'correctly migrates nullified lock_version column', :sidekiq_inline do
tables.each do |table|
expect(table.where(lock_version: nil).count).to eq(1)
end
tables.each do |table|
expect(table.where(lock_version: 0).count).to eq(0)
end
migrate!
tables.each do |table|
expect(table.where(lock_version: nil).count).to eq(0)
end
tables.each do |table|
expect(table.where(lock_version: 0).count).to eq(1)
end
end
end