Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-11-06 06:08:51 +00:00
parent 74d35955aa
commit eefbee4451
26 changed files with 643 additions and 57 deletions

View file

@ -1,11 +1,17 @@
import { queryToObject } from '~/lib/utils/url_utility';
import createStore from './store';
import initDropdownFilters from './dropdown_filter';
import { initSidebar } from './sidebar';
import initGroupFilter from './group_filter';
export default () => {
const store = createStore({ query: queryToObject(window.location.search) });
initDropdownFilters(store);
if (gon.features.searchFacets) {
initSidebar(store);
} else {
initDropdownFilters(store);
}
initGroupFilter(store);
};

View file

@ -0,0 +1,25 @@
<script>
import { mapState } from 'vuex';
import { confidentialFilterData } from '../constants/confidential_filter_data';
import RadioFilter from './radio_filter.vue';
export default {
name: 'ConfidentialityFilter',
components: {
RadioFilter,
},
computed: {
...mapState(['query']),
showDropdown() {
return Object.values(confidentialFilterData.scopes).includes(this.query.scope);
},
},
confidentialFilterData,
};
</script>
<template>
<div v-if="showDropdown">
<radio-filter :filter-data="$options.confidentialFilterData" />
</div>
</template>

View file

@ -0,0 +1,68 @@
<script>
import { mapState, mapActions } from 'vuex';
import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
export default {
name: 'RadioFilter',
components: {
GlFormRadioGroup,
GlFormRadio,
},
props: {
filterData: {
type: Object,
required: true,
},
},
computed: {
...mapState(['query']),
ANY() {
return this.filterData.filters.ANY;
},
scope() {
return this.query.scope;
},
initialFilter() {
return this.query[this.filterData.filterParam];
},
filter() {
return this.initialFilter || this.ANY.value;
},
filtersArray() {
return this.filterData.filterByScope[this.scope];
},
selectedFilter: {
get() {
if (this.filtersArray.some(({ value }) => value === this.filter)) {
return this.filter;
}
return this.ANY.value;
},
set(value) {
this.setQuery({ key: this.filterData.filterParam, value });
},
},
},
methods: {
...mapActions(['setQuery']),
radioLabel(filter) {
return filter.value === this.ANY.value
? sprintf(s__('Any %{header}'), { header: this.filterData.header.toLowerCase() })
: filter.label;
},
},
};
</script>
<template>
<div>
<h5 class="gl-mt-0">{{ filterData.header }}</h5>
<gl-form-radio-group v-model="selectedFilter">
<gl-form-radio v-for="f in filtersArray" :key="f.value" :value="f.value">
{{ radioLabel(f) }}
</gl-form-radio>
</gl-form-radio-group>
</div>
</template>

View file

@ -0,0 +1,25 @@
<script>
import { mapState } from 'vuex';
import { stateFilterData } from '../constants/state_filter_data';
import RadioFilter from './radio_filter.vue';
export default {
name: 'StatusFilter',
components: {
RadioFilter,
},
computed: {
...mapState(['query']),
showDropdown() {
return Object.values(stateFilterData.scopes).includes(this.query.scope);
},
},
stateFilterData,
};
</script>
<template>
<div v-if="showDropdown">
<radio-filter :filter-data="$options.stateFilterData" />
</div>
</template>

View file

@ -0,0 +1,36 @@
import { __ } from '~/locale';
const header = __('Confidentiality');
const filters = {
ANY: {
label: __('Any'),
value: null,
},
CONFIDENTIAL: {
label: __('Confidential'),
value: 'yes',
},
NOT_CONFIDENTIAL: {
label: __('Not confidential'),
value: 'no',
},
};
const scopes = {
ISSUES: 'issues',
};
const filterByScope = {
[scopes.ISSUES]: [filters.ANY, filters.CONFIDENTIAL, filters.NOT_CONFIDENTIAL],
};
const filterParam = 'confidential';
export const confidentialFilterData = {
header,
filters,
scopes,
filterByScope,
filterParam,
};

View file

@ -0,0 +1,42 @@
import { __ } from '~/locale';
const header = __('Status');
const filters = {
ANY: {
label: __('Any'),
value: 'all',
},
OPEN: {
label: __('Open'),
value: 'opened',
},
CLOSED: {
label: __('Closed'),
value: 'closed',
},
MERGED: {
label: __('Merged'),
value: 'merged',
},
};
const scopes = {
ISSUES: 'issues',
MERGE_REQUESTS: 'merge_requests',
};
const filterByScope = {
[scopes.ISSUES]: [filters.ANY, filters.OPEN, filters.CLOSED],
[scopes.MERGE_REQUESTS]: [filters.ANY, filters.OPEN, filters.MERGED, filters.CLOSED],
};
const filterParam = 'state';
export const stateFilterData = {
header,
filters,
scopes,
filterByScope,
filterParam,
};

View file

@ -0,0 +1,34 @@
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import StatusFilter from './components/status_filter.vue';
import ConfidentialityFilter from './components/confidentiality_filter.vue';
Vue.use(Translate);
const mountRadioFilters = (store, { id, component }) => {
const el = document.getElementById(id);
if (!el) return false;
return new Vue({
el,
store,
render(createElement) {
return createElement(component);
},
});
};
const radioFilters = [
{
id: 'js-search-filter-by-state',
component: StatusFilter,
},
{
id: 'js-search-filter-by-confidential',
component: ConfidentialityFilter,
},
];
export const initSidebar = store =>
[...radioFilters].map(filter => mountRadioFilters(store, filter));

View file

@ -14,3 +14,7 @@ export const fetchGroups = ({ commit }, search) => {
commit(types.RECEIVE_GROUPS_ERROR);
});
};
export const setQuery = ({ commit }, { key, value }) => {
commit(types.SET_QUERY, { key, value });
};

View file

@ -1,3 +1,5 @@
export const REQUEST_GROUPS = 'REQUEST_GROUPS';
export const RECEIVE_GROUPS_SUCCESS = 'RECEIVE_GROUPS_SUCCESS';
export const RECEIVE_GROUPS_ERROR = 'RECEIVE_GROUPS_ERROR';
export const SET_QUERY = 'SET_QUERY';

View file

@ -12,4 +12,7 @@ export default {
state.fetchingGroups = false;
state.groups = [];
},
[types.SET_QUERY](state, { key, value }) {
state.query[key] = value;
},
};

View file

@ -13,6 +13,6 @@ class Admin::InstanceStatisticsController < Admin::ApplicationController
end
def check_feature_flag
render_404 unless Feature.enabled?(:instance_statistics)
render_404 unless Feature.enabled?(:instance_statistics, default_enabled: true)
end
end

View file

@ -23,6 +23,10 @@ class SearchController < ApplicationController
search_term_present && !params[:project_id].present?
end
before_action do
push_frontend_feature_flag(:search_facets)
end
layout 'search'
feature_category :global_search

View file

@ -69,7 +69,7 @@
= link_to admin_cohorts_path, title: _('Cohorts') do
%span
= _('Cohorts')
- if Feature.enabled?(:instance_statistics)
- if Feature.enabled?(:instance_statistics, default_enabled: true)
= nav_link(controller: :instance_statistics) do
= link_to admin_instance_statistics_path, title: _('Instance Statistics') do
%span

View file

@ -0,0 +1,5 @@
---
title: Enable 'instance_statistics' feature flag by default
merge_request: 46962
author:
type: changed

View file

@ -4,4 +4,4 @@ introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40583
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/241711
group: group::analytics
type: development
default_enabled: false
default_enabled: true

View file

@ -0,0 +1,7 @@
---
name: search_facets
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46809
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46595
group: group::global search
type: development
default_enabled: false

View file

@ -36,22 +36,3 @@ in the categories shown in [Total counts](#total-counts).
These charts help you visualize how rapidly these records are being created on your instance.
![Instance Activity Pipelines chart](img/instance_activity_pipelines_chart_v13_6.png)
### Enable or disable Instance Statistics
Instance Statistics is under development and not ready for production use. It is
deployed behind a feature flag that is **disabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
can enable it.
To enable it:
```ruby
Feature.enable(:instance_statistics)
```
To disable it:
```ruby
Feature.disable(:instance_statistics)
```

View file

@ -43,38 +43,70 @@ module Gitlab
end
def sql_to_replace_table
@sql_to_replace_table ||= [
drop_default_sql(original_table, primary_key_column),
set_default_sql(replacement_table, primary_key_column, "nextval('#{quote_table_name(sequence)}'::regclass)"),
change_sequence_owner_sql(sequence, replacement_table, primary_key_column),
rename_table_sql(original_table, replaced_table),
rename_constraint_sql(replaced_table, original_primary_key, replaced_primary_key),
rename_table_sql(replacement_table, original_table),
rename_constraint_sql(original_table, replacement_primary_key, original_primary_key)
].join(DELIMITER)
@sql_to_replace_table ||= combined_sql_statements.map(&:chomp).join(DELIMITER)
end
def drop_default_sql(table, column)
"ALTER TABLE #{quote_table_name(table)} ALTER COLUMN #{quote_column_name(column)} DROP DEFAULT"
def combined_sql_statements
statements = []
statements << alter_column_default(original_table, primary_key_column, expression: nil)
statements << alter_column_default(replacement_table, primary_key_column,
expression: "nextval('#{quote_table_name(sequence)}'::regclass)")
statements << alter_sequence_owned_by(sequence, replacement_table, primary_key_column)
rename_table_objects(statements, original_table, replaced_table, original_primary_key, replaced_primary_key)
rename_table_objects(statements, replacement_table, original_table, replacement_primary_key, original_primary_key)
statements
end
def set_default_sql(table, column, expression)
"ALTER TABLE #{quote_table_name(table)} ALTER COLUMN #{quote_column_name(column)} SET DEFAULT #{expression}"
def rename_table_objects(statements, old_table, new_table, old_primary_key, new_primary_key)
statements << rename_table(old_table, new_table)
statements << rename_constraint(new_table, old_primary_key, new_primary_key)
rename_partitions(statements, old_table, new_table)
end
def change_sequence_owner_sql(sequence, table, column)
"ALTER SEQUENCE #{quote_table_name(sequence)} OWNED BY #{quote_table_name(table)}.#{quote_column_name(column)}"
def rename_partitions(statements, old_table_name, new_table_name)
Gitlab::Database::PostgresPartition.for_parent_table(old_table_name).each do |partition|
new_partition_name = partition.name.sub(/#{old_table_name}/, new_table_name)
old_primary_key = default_primary_key(partition.name)
new_primary_key = default_primary_key(new_partition_name)
statements << rename_constraint(partition.identifier, old_primary_key, new_primary_key)
statements << rename_table(partition.identifier, new_partition_name)
end
end
def rename_table_sql(old_name, new_name)
"ALTER TABLE #{quote_table_name(old_name)} RENAME TO #{quote_table_name(new_name)}"
def alter_column_default(table_name, column_name, expression:)
default_clause = expression.nil? ? 'DROP DEFAULT' : "SET DEFAULT #{expression}"
<<~SQL
ALTER TABLE #{quote_table_name(table_name)}
ALTER COLUMN #{quote_column_name(column_name)} #{default_clause}
SQL
end
def rename_constraint_sql(table, old_name, new_name)
"ALTER TABLE #{quote_table_name(table)} RENAME CONSTRAINT #{quote_column_name(old_name)} TO #{quote_column_name(new_name)}"
def alter_sequence_owned_by(sequence_name, table_name, column_name)
<<~SQL
ALTER SEQUENCE #{quote_table_name(sequence_name)}
OWNED BY #{quote_table_name(table_name)}.#{quote_column_name(column_name)}
SQL
end
def rename_table(old_name, new_name)
<<~SQL
ALTER TABLE #{quote_table_name(old_name)}
RENAME TO #{quote_table_name(new_name)}
SQL
end
def rename_constraint(table_name, old_name, new_name)
<<~SQL
ALTER TABLE #{quote_table_name(table_name)}
RENAME CONSTRAINT #{quote_column_name(old_name)} TO #{quote_column_name(new_name)}
SQL
end
end
end

View file

@ -179,7 +179,8 @@ module Gitlab
# Replaces a non-partitioned table with its partitioned copy. This is the final step in a partitioning
# migration, which makes the partitioned table ready for use by the application. The partitioned copy should be
# replaced with the original table in such a way that it appears seamless to any database clients. The replaced
# table will be renamed to "#{replaced_table}_archived"
# table will be renamed to "#{replaced_table}_archived". Partitions and primary key constraints will also be
# renamed to match the naming scheme of the parent table.
#
# **NOTE** This method should only be used after all other migration steps have completed successfully.
# There are several limitations to this method that MUST be handled before, or during, the swap migration:
@ -415,7 +416,7 @@ module Gitlab
end
def replace_table(original_table_name, replacement_table_name, replaced_table_name, primary_key_name)
replace_table = Gitlab::Database::Partitioning::ReplaceTable.new(original_table_name,
replace_table = Gitlab::Database::Partitioning::ReplaceTable.new(original_table_name.to_s,
replacement_table_name, replaced_table_name, primary_key_name)
with_lock_retries do

View file

@ -0,0 +1,65 @@
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { MOCK_QUERY } from 'jest/search/mock_data';
import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter.vue';
import RadioFilter from '~/search/sidebar/components/radio_filter.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('ConfidentialityFilter', () => {
let wrapper;
const actionSpies = {
applyQuery: jest.fn(),
resetQuery: jest.fn(),
};
const createComponent = initialState => {
const store = new Vuex.Store({
state: {
query: MOCK_QUERY,
...initialState,
},
actions: actionSpies,
});
wrapper = shallowMount(ConfidentialityFilter, {
localVue,
store,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findRadioFilter = () => wrapper.find(RadioFilter);
describe('template', () => {
beforeEach(() => {
createComponent();
});
describe.each`
scope | showFilter
${'issues'} | ${true}
${'merge_requests'} | ${false}
${'projects'} | ${false}
${'milestones'} | ${false}
${'users'} | ${false}
${'notes'} | ${false}
${'wiki_blobs'} | ${false}
${'blobs'} | ${false}
`(`dropdown`, ({ scope, showFilter }) => {
beforeEach(() => {
createComponent({ query: { scope } });
});
it(`does${showFilter ? '' : ' not'} render when scope is ${scope}`, () => {
expect(findRadioFilter().exists()).toBe(showFilter);
});
});
});
});

View file

@ -0,0 +1,111 @@
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui';
import { MOCK_QUERY } from 'jest/search/mock_data';
import RadioFilter from '~/search/sidebar/components/radio_filter.vue';
import { stateFilterData } from '~/search/sidebar/constants/state_filter_data';
import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('RadioFilter', () => {
let wrapper;
const actionSpies = {
setQuery: jest.fn(),
};
const defaultProps = {
filterData: stateFilterData,
};
const createComponent = (initialState, props = {}) => {
const store = new Vuex.Store({
state: {
query: MOCK_QUERY,
...initialState,
},
actions: actionSpies,
});
wrapper = shallowMount(RadioFilter, {
localVue,
store,
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findGlRadioButtonGroup = () => wrapper.find(GlFormRadioGroup);
const findGlRadioButtons = () => findGlRadioButtonGroup().findAll(GlFormRadio);
const findGlRadioButtonsText = () => findGlRadioButtons().wrappers.map(w => w.text());
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders GlRadioButtonGroup always', () => {
expect(findGlRadioButtonGroup().exists()).toBe(true);
});
describe('Radio Buttons', () => {
describe('Status Filter', () => {
it('renders a radio button for each filterOption', () => {
expect(findGlRadioButtonsText()).toStrictEqual(
stateFilterData.filterByScope[stateFilterData.scopes.ISSUES].map(f => {
return f.value === stateFilterData.filters.ANY.value
? `Any ${stateFilterData.header.toLowerCase()}`
: f.label;
}),
);
});
it('clicking a radio button item calls setQuery', () => {
const filter = stateFilterData.filters[Object.keys(stateFilterData.filters)[0]].value;
findGlRadioButtonGroup().vm.$emit('input', filter);
expect(actionSpies.setQuery).toHaveBeenCalledWith(expect.any(Object), {
key: stateFilterData.filterParam,
value: filter,
});
});
});
describe('Confidentiality Filter', () => {
beforeEach(() => {
createComponent({}, { filterData: confidentialFilterData });
});
it('renders a radio button for each filterOption', () => {
expect(findGlRadioButtonsText()).toStrictEqual(
confidentialFilterData.filterByScope[confidentialFilterData.scopes.ISSUES].map(f => {
return f.value === confidentialFilterData.filters.ANY.value
? `Any ${confidentialFilterData.header.toLowerCase()}`
: f.label;
}),
);
});
it('clicking a radio button item calls setQuery', () => {
const filter =
confidentialFilterData.filters[Object.keys(confidentialFilterData.filters)[0]].value;
findGlRadioButtonGroup().vm.$emit('input', filter);
expect(actionSpies.setQuery).toHaveBeenCalledWith(expect.any(Object), {
key: confidentialFilterData.filterParam,
value: filter,
});
});
});
});
});
});

View file

@ -0,0 +1,65 @@
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { MOCK_QUERY } from 'jest/search/mock_data';
import StatusFilter from '~/search/sidebar/components/status_filter.vue';
import RadioFilter from '~/search/sidebar/components/radio_filter.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('StatusFilter', () => {
let wrapper;
const actionSpies = {
applyQuery: jest.fn(),
resetQuery: jest.fn(),
};
const createComponent = initialState => {
const store = new Vuex.Store({
state: {
query: MOCK_QUERY,
...initialState,
},
actions: actionSpies,
});
wrapper = shallowMount(StatusFilter, {
localVue,
store,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findRadioFilter = () => wrapper.find(RadioFilter);
describe('template', () => {
beforeEach(() => {
createComponent();
});
describe.each`
scope | showFilter
${'issues'} | ${true}
${'merge_requests'} | ${true}
${'projects'} | ${false}
${'milestones'} | ${false}
${'users'} | ${false}
${'notes'} | ${false}
${'wiki_blobs'} | ${false}
${'blobs'} | ${false}
`(`dropdown`, ({ scope, showFilter }) => {
beforeEach(() => {
createComponent({ query: { scope } });
});
it(`does${showFilter ? '' : ' not'} render when scope is ${scope}`, () => {
expect(findRadioFilter().exists()).toBe(showFilter);
});
});
});
});

View file

@ -43,3 +43,11 @@ describe('Global Search Store Actions', () => {
});
});
});
describe('setQuery', () => {
const payload = { key: 'key1', value: 'value1' };
it('calls the SET_QUERY mutation', done => {
testAction(actions.setQuery, payload, state, [{ type: types.SET_QUERY, payload }], [], done);
});
});

View file

@ -35,4 +35,14 @@ describe('Global Search Store Mutations', () => {
expect(state.groups).toEqual([]);
});
});
describe('SET_QUERY', () => {
const payload = { key: 'key1', value: 'value1' };
it('sets query key to value', () => {
mutations[types.SET_QUERY](state, payload);
expect(state.query[payload.key]).toBe(payload.value);
});
});
});

View file

@ -30,9 +30,6 @@ RSpec.describe Gitlab::Database::Partitioning::ReplaceTable, '#perform' do
created_at timestamptz NOT NULL,
PRIMARY KEY (id, created_at))
PARTITION BY RANGE (created_at);
CREATE TABLE #{replacement_table}_202001 PARTITION OF #{replacement_table}
FOR VALUES FROM ('2020-01-01') TO ('2020-02-01');
SQL
end
@ -56,13 +53,58 @@ RSpec.describe Gitlab::Database::Partitioning::ReplaceTable, '#perform' do
end
it 'renames the primary key constraints to match the new table names' do
expect(primary_key_constraint_name(original_table)).to eq(original_primary_key)
expect(primary_key_constraint_name(replacement_table)).to eq(replacement_primary_key)
expect_primary_keys_after_tables([original_table, replacement_table])
expect_table_to_be_replaced { replace_table }
expect(primary_key_constraint_name(original_table)).to eq(original_primary_key)
expect(primary_key_constraint_name(archived_table)).to eq(archived_primary_key)
expect_primary_keys_after_tables([original_table, archived_table])
end
context 'when the table has partitions' do
before do
connection.execute(<<~SQL)
CREATE TABLE gitlab_partitions_dynamic.#{replacement_table}_202001 PARTITION OF #{replacement_table}
FOR VALUES FROM ('2020-01-01') TO ('2020-02-01');
CREATE TABLE gitlab_partitions_dynamic.#{replacement_table}_202002 PARTITION OF #{replacement_table}
FOR VALUES FROM ('2020-02-01') TO ('2020-03-01');
SQL
end
it 'renames the partitions to match the new table name' do
expect(partitions_for_parent_table(original_table).count).to eq(0)
expect(partitions_for_parent_table(replacement_table).count).to eq(2)
expect_table_to_be_replaced { replace_table }
expect(partitions_for_parent_table(archived_table).count).to eq(0)
partitions = partitions_for_parent_table(original_table).all
expect(partitions.size).to eq(2)
expect(partitions[0]).to have_attributes(
identifier: "gitlab_partitions_dynamic.#{original_table}_202001",
condition: "FOR VALUES FROM ('2020-01-01 00:00:00+00') TO ('2020-02-01 00:00:00+00')")
expect(partitions[1]).to have_attributes(
identifier: "gitlab_partitions_dynamic.#{original_table}_202002",
condition: "FOR VALUES FROM ('2020-02-01 00:00:00+00') TO ('2020-03-01 00:00:00+00')")
end
it 'renames the primary key constraints to match the new partition names' do
original_partitions = ["#{replacement_table}_202001", "#{replacement_table}_202002"]
expect_primary_keys_after_tables(original_partitions, schema: 'gitlab_partitions_dynamic')
expect_table_to_be_replaced { replace_table }
renamed_partitions = ["#{original_table}_202001", "#{original_table}_202002"]
expect_primary_keys_after_tables(renamed_partitions, schema: 'gitlab_partitions_dynamic')
end
end
def partitions_for_parent_table(table)
Gitlab::Database::PostgresPartition.for_parent_table(table)
end
def expect_table_to_be_replaced(&block)

View file

@ -24,6 +24,14 @@ module TableSchemaHelpers
expect(index_exists_by_name(name, schema: schema)).to be_nil
end
def expect_primary_keys_after_tables(tables, schema: nil)
tables.each do |table|
primary_key = primary_key_constraint_name(table, schema: schema)
expect(primary_key).to eq("#{table}_pkey")
end
end
def table_oid(name)
connection.select_value(<<~SQL)
SELECT oid
@ -75,13 +83,15 @@ module TableSchemaHelpers
SQL
end
def primary_key_constraint_name(table_name)
def primary_key_constraint_name(table_name, schema: nil)
table_name = schema ? "#{schema}.#{table_name}" : table_name
connection.select_value(<<~SQL)
SELECT
conname AS constraint_name
FROM pg_catalog.pg_constraint
WHERE conrelid = '#{table_name}'::regclass
AND contype = 'p'
WHERE pg_constraint.conrelid = '#{table_name}'::regclass
AND pg_constraint.contype = 'p'
SQL
end