Commit 7e9cac46 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 8d81e45f a04a853c
......@@ -82,6 +82,7 @@ import searchLabelsQuery from '../queries/search_labels.query.graphql';
import searchMilestonesQuery from '../queries/search_milestones.query.graphql';
import searchUsersQuery from '../queries/search_users.query.graphql';
import IssueCardTimeInfo from './issue_card_time_info.vue';
import NewIssueDropdown from './new_issue_dropdown.vue';
export default {
i18n,
......@@ -96,6 +97,7 @@ export default {
IssuableByEmail,
IssuableList,
IssueCardTimeInfo,
NewIssueDropdown,
BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'),
},
directives: {
......@@ -126,6 +128,9 @@ export default {
hasAnyIssues: {
default: false,
},
hasAnyProjects: {
default: false,
},
hasBlockedIssuesFeature: {
default: false,
},
......@@ -253,6 +258,9 @@ export default {
showCsvButtons() {
return this.isProject && this.isSignedIn;
},
showNewIssueDropdown() {
return !this.isProject && this.hasAnyProjects;
},
apiFilterParams() {
return convertToApiParams(this.filterTokens);
},
......@@ -662,6 +670,7 @@ export default {
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
<new-issue-dropdown v-if="showNewIssueDropdown" />
</template>
<template #timeframe="{ issuable = {} }">
......@@ -765,6 +774,7 @@ export default {
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="currentTabCount"
/>
<new-issue-dropdown v-if="showNewIssueDropdown" />
</template>
</gl-empty-state>
<hr />
......
<script>
import {
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlLoadingIcon,
GlSearchBoxByType,
} from '@gitlab/ui';
import createFlash from '~/flash';
import searchProjectsQuery from '~/issues_list/queries/search_projects.query.graphql';
import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
export default {
i18n: {
defaultDropdownText: __('Select project to create issue'),
noMatchesFound: __('No matches found'),
toggleButtonLabel: __('Toggle project select'),
},
components: {
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlLoadingIcon,
GlSearchBoxByType,
},
inject: ['fullPath'],
data() {
return {
projects: [],
search: '',
selectedProject: {},
shouldSkipQuery: true,
};
},
apollo: {
projects: {
query: searchProjectsQuery,
variables() {
return {
fullPath: this.fullPath,
search: this.search,
};
},
update: ({ group }) => group.projects.nodes ?? [],
error(error) {
createFlash({
message: __('An error occurred while loading projects.'),
captureError: true,
error,
});
},
skip() {
return this.shouldSkipQuery;
},
debounce: DEBOUNCE_DELAY,
},
},
computed: {
dropdownHref() {
return this.hasSelectedProject
? joinPaths(this.selectedProject.webUrl, DASH_SCOPE, 'issues/new')
: undefined;
},
dropdownText() {
return this.hasSelectedProject
? sprintf(__('New issue in %{project}'), { project: this.selectedProject.name })
: this.$options.i18n.defaultDropdownText;
},
hasSelectedProject() {
return this.selectedProject.id;
},
showNoSearchResultsText() {
return !this.projects.length && this.search;
},
},
methods: {
handleDropdownClick() {
if (!this.dropdownHref) {
this.$refs.dropdown.show();
}
},
handleDropdownShown() {
if (this.shouldSkipQuery) {
this.shouldSkipQuery = false;
}
this.$refs.search.focusInput();
},
selectProject(project) {
this.selectedProject = project;
},
},
};
</script>
<template>
<gl-dropdown
ref="dropdown"
right
split
:split-href="dropdownHref"
:text="dropdownText"
:toggle-text="$options.i18n.toggleButtonLabel"
variant="confirm"
@click="handleDropdownClick"
@shown="handleDropdownShown"
>
<gl-search-box-by-type ref="search" v-model.trim="search" />
<gl-loading-icon v-if="$apollo.queries.projects.loading" />
<template v-else>
<gl-dropdown-item
v-for="project of projects"
:key="project.id"
@click="selectProject(project)"
>
{{ project.nameWithNamespace }}
</gl-dropdown-item>
<gl-dropdown-text v-if="showNoSearchResultsText">
{{ $options.i18n.noMatchesFound }}
</gl-dropdown-text>
</template>
</gl-dropdown>
</template>
......@@ -121,6 +121,7 @@ export function mountIssuesListApp() {
fullPath,
groupEpicsPath,
hasAnyIssues,
hasAnyProjects,
hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature,
hasIssueWeightsFeature,
......@@ -153,6 +154,7 @@ export function mountIssuesListApp() {
fullPath,
groupEpicsPath,
hasAnyIssues: parseBoolean(hasAnyIssues),
hasAnyProjects: parseBoolean(hasAnyProjects),
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
......
query searchProjects($fullPath: ID!, $search: String) {
group(fullPath: $fullPath) {
projects(search: $search, includeSubgroups: true) {
nodes {
id
name
nameWithNamespace
webUrl
}
}
}
}
export const DASH_SCOPE = '-';
const PATH_SEPARATOR = '/';
const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`);
const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`);
......
......@@ -322,7 +322,7 @@
display: inline-block;
}
.btn {
.btn:not(.split-content-button):not(.dropdown-toggle-split) {
margin: $gl-padding-8 $gl-padding-4;
@include media-breakpoint-down(xs) {
......
......@@ -315,6 +315,9 @@ h1 {
padding-left: 0.6em;
border-radius: 10rem;
}
.bg-transparent {
background-color: transparent !important;
}
.rounded-circle {
border-radius: 50% !important;
}
......@@ -359,6 +362,20 @@ h1 {
.m-auto {
margin: auto !important;
}
.gl-badge {
display: inline-flex;
align-items: center;
font-size: 0.75rem;
font-weight: 400;
line-height: 1rem;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.gl-button .gl-badge {
top: 0;
}
.gl-form-input,
.gl-form-input.form-control {
background-color: #333;
......@@ -856,6 +873,12 @@ input {
.navbar-nav .badge.badge-pill:not(.merge-request-badge).todos-count {
background-color: var(--blue-400, #1f75cb);
}
.title-container .canary-badge .badge,
.navbar-nav .canary-badge .badge {
font-size: 12px;
line-height: 16px;
padding: 0 0.5rem;
}
@media (max-width: 575.98px) {
.navbar-gitlab .container-fluid {
font-size: 18px;
......@@ -1822,9 +1845,18 @@ body.gl-dark {
white-space: nowrap;
width: 1px;
}
.gl-bg-green-500 {
background-color: #2da160;
}
.gl-border-none\! {
border-style: none !important;
}
.gl-rounded-pill {
border-radius: 0.75rem;
}
.gl-text-white {
color: #333;
}
.gl-display-none {
display: none;
}
......@@ -1843,6 +1875,10 @@ body.gl-dark {
.gl-pr-2 {
padding-right: 0.25rem;
}
.gl-py-1 {
padding-top: 0.125rem;
padding-bottom: 0.125rem;
}
.gl-ml-3 {
margin-left: 0.5rem;
}
......
......@@ -295,6 +295,9 @@ h1 {
padding-left: 0.6em;
border-radius: 10rem;
}
.bg-transparent {
background-color: transparent !important;
}
.rounded-circle {
border-radius: 50% !important;
}
......@@ -339,6 +342,20 @@ h1 {
.m-auto {
margin: auto !important;
}
.gl-badge {
display: inline-flex;
align-items: center;
font-size: 0.75rem;
font-weight: 400;
line-height: 1rem;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.gl-button .gl-badge {
top: 0;
}
.gl-form-input,
.gl-form-input.form-control {
background-color: #fff;
......@@ -836,6 +853,12 @@ input {
.navbar-nav .badge.badge-pill:not(.merge-request-badge).todos-count {
background-color: var(--blue-400, #428fdc);
}
.title-container .canary-badge .badge,
.navbar-nav .canary-badge .badge {
font-size: 12px;
line-height: 16px;
padding: 0 0.5rem;
}
@media (max-width: 575.98px) {
.navbar-gitlab .container-fluid {
font-size: 18px;
......@@ -1591,9 +1614,18 @@ svg.s16 {
white-space: nowrap;
width: 1px;
}
.gl-bg-green-500 {
background-color: #108548;
}
.gl-border-none\! {
border-style: none !important;
}
.gl-rounded-pill {
border-radius: 0.75rem;
}
.gl-text-white {
color: #fff;
}
.gl-display-none {
display: none;
}
......@@ -1612,6 +1644,10 @@ svg.s16 {
.gl-pr-2 {
padding-right: 0.25rem;
}
.gl-py-1 {
padding-top: 0.125rem;
padding-bottom: 0.125rem;
}
.gl-ml-3 {
margin-left: 0.5rem;
}
......
......@@ -238,9 +238,10 @@ module IssuesHelper
)
end
def group_issues_list_data(group, current_user, issues)
def group_issues_list_data(group, current_user, issues, projects)
common_issues_list_data(group, current_user).merge(
has_any_issues: issues.to_a.any?.to_s
has_any_issues: issues.to_a.any?.to_s,
has_any_projects: any_projects?(projects).to_s
)
end
......
......@@ -12,6 +12,13 @@ module Ci
if Gitlab::Database.has_config?(:ci)
connects_to database: { writing: :ci, reading: :ci }
# TODO: Load Balancing messes with `CiDatabaseRecord`
# returning wrong connection. To be removed once merged:
# https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67773
def self.connection
retrieve_connection
end
end
end
end
......@@ -6,7 +6,7 @@
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues")
- if Feature.enabled?(:vue_issues_list, @group, default_enabled: :yaml)
.js-issues-list{ data: group_issues_list_data(@group, current_user, @issues) }
.js-issues-list{ data: group_issues_list_data(@group, current_user, @issues, @projects) }
- if @can_bulk_update
= render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
- else
......
......@@ -12,8 +12,7 @@ When a pipeline job is about to run, GitLab generates a unique token and injects
You can use a GitLab CI/CD job token to authenticate with specific API endpoints:
- Packages:
- [Package Registry](../../user/packages/package_registry/index.md#use-gitlab-cicd-to-build-packages). To push to the
Package Registry, you can use [deploy tokens](../../user/project/deploy_tokens/index.md).
- [Package Registry](../../user/packages/package_registry/index.md#use-gitlab-cicd-to-build-packages).
- [Container Registry](../../user/packages/container_registry/index.md#build-and-push-by-using-gitlab-cicd)
(the `$CI_REGISTRY_PASSWORD` is `$CI_JOB_TOKEN`).
- [Container Registry API](../../api/container_registry.md)
......
......@@ -12,14 +12,14 @@ View and resolve abuse reports from GitLab users.
GitLab administrators can view and [resolve](#resolving-abuse-reports) abuse
reports in the Admin Area.
## Receiving notifications of abuse reports
## Receive notification of abuse reports by email
To receive notifications of new abuse reports by email, follow these steps:
To receive notifications of new abuse reports by email:
1. On the top bar, select **Menu > Admin**.
1. On the left sidebar, select **Settings > Reporting**.
1. Expand the **Abuse reports** section.
1. Provide an email address.
1. Provide an email address and select **Save changes**.
The notification email address can also be set and retrieved
[using the API](../../api/settings.md#list-of-settings-that-can-be-accessed-via-api-calls).
......
......@@ -315,6 +315,9 @@ h1 {
padding-left: 0.6em;
border-radius: 10rem;
}
.bg-transparent {
background-color: transparent !important;
}
.rounded-circle {
border-radius: 50% !important;
}
......@@ -359,6 +362,20 @@ h1 {
.m-auto {
margin: auto !important;
}
.gl-badge {
display: inline-flex;
align-items: center;
font-size: 0.75rem;
font-weight: 400;
line-height: 1rem;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.gl-button .gl-badge {
top: 0;
}
.gl-form-input,
.gl-form-input.form-control {
background-color: #333;
......@@ -856,6 +873,12 @@ input {
.navbar-nav .badge.badge-pill:not(.merge-request-badge).todos-count {
background-color: var(--blue-400, #1f75cb);
}
.title-container .canary-badge .badge,
.navbar-nav .canary-badge .badge {
font-size: 12px;
line-height: 16px;
padding: 0 0.5rem;
}
@media (max-width: 575.98px) {
.navbar-gitlab .container-fluid {
font-size: 18px;
......@@ -1822,9 +1845,18 @@ body.gl-dark {
white-space: nowrap;
width: 1px;
}
.gl-bg-green-500 {
background-color: #2da160;
}
.gl-border-none\! {
border-style: none !important;
}
.gl-rounded-pill {
border-radius: 0.75rem;
}
.gl-text-white {
color: #333;
}
.gl-display-none {
display: none;
}
......@@ -1843,6 +1875,10 @@ body.gl-dark {
.gl-pr-2 {
padding-right: 0.25rem;
}
.gl-py-1 {
padding-top: 0.125rem;
padding-bottom: 0.125rem;
}
.gl-ml-3 {
margin-left: 0.5rem;
}
......
......@@ -295,6 +295,9 @@ h1 {
padding-left: 0.6em;
border-radius: 10rem;
}
.bg-transparent {
background-color: transparent !important;
}
.rounded-circle {
border-radius: 50% !important;
}
......@@ -339,6 +342,20 @@ h1 {
.m-auto {
margin: auto !important;
}
.gl-badge {
display: inline-flex;
align-items: center;
font-size: 0.75rem;
font-weight: 400;
line-height: 1rem;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.gl-button .gl-badge {
top: 0;
}
.gl-form-input,
.gl-form-input.form-control {
background-color: #fff;
......@@ -836,6 +853,12 @@ input {
.navbar-nav .badge.badge-pill:not(.merge-request-badge).todos-count {
background-color: var(--blue-400, #428fdc);
}
.title-container .canary-badge .badge,
.navbar-nav .canary-badge .badge {
font-size: 12px;
line-height: 16px;
padding: 0 0.5rem;
}
@media (max-width: 575.98px) {
.navbar-gitlab .container-fluid {
font-size: 18px;
......@@ -1591,9 +1614,18 @@ svg.s16 {
white-space: nowrap;
width: 1px;
}
.gl-bg-green-500 {
background-color: #108548;
}
.gl-border-none\! {
border-style: none !important;
}
.gl-rounded-pill {
border-radius: 0.75rem;
}
.gl-text-white {
color: #fff;
}
.gl-display-none {
display: none;
}
......@@ -1612,6 +1644,10 @@ svg.s16 {
.gl-pr-2 {
padding-right: 0.25rem;
}
.gl-py-1 {
padding-top: 0.125rem;
padding-bottom: 0.125rem;
}
.gl-ml-3 {
margin-left: 0.5rem;
}
......
......@@ -63,7 +63,7 @@ module EE
end
override :group_issues_list_data
def group_issues_list_data(group, current_user, issues)
def group_issues_list_data(group, current_user, issues, projects)
super.tap do |data|
data[:can_bulk_update] = (can?(current_user, :admin_issue, group) && group.feature_available?(:group_bulk_edit)).to_s
......
......@@ -187,6 +187,7 @@ RSpec.describe EE::IssuesHelper do
describe '#group_issues_list_data' do
let(:current_user) { double.as_null_object }
let(:issues) { [] }
let(:projects) { [] }
before do
allow(helper).to receive(:current_user).and_return(current_user)
......@@ -210,7 +211,7 @@ RSpec.describe EE::IssuesHelper do
group_epics_path: group_epics_path(project.group, format: :json)
}
expect(helper.group_issues_list_data(group, current_user, issues)).to include(expected)
expect(helper.group_issues_list_data(group, current_user, issues, projects)).to include(expected)
end
end
......@@ -229,7 +230,7 @@ RSpec.describe EE::IssuesHelper do
has_multiple_issue_assignees_feature: 'false'
}
result = helper.group_issues_list_data(group, current_user, issues)
result = helper.group_issues_list_data(group, current_user, issues, projects)
expect(result).to include(expected)
expect(result).not_to include(:group_epics_path)
......
......@@ -3710,6 +3710,9 @@ msgstr ""
msgid "An error occurred while loading merge requests."
msgstr ""
msgid "An error occurred while loading projects."
msgstr ""
msgid "An error occurred while loading the Needs tab."
msgstr ""
......@@ -22708,6 +22711,9 @@ msgstr ""
msgid "New issue"
msgstr ""
msgid "New issue in %{project}"
msgstr ""
msgid "New issue title"
msgstr ""
......@@ -30556,6 +30562,9 @@ msgstr ""
msgid "Select project to choose zone"
msgstr ""
msgid "Select project to create issue"
msgstr ""
msgid "Select projects"
msgstr ""
......
......@@ -18,6 +18,10 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do
let(:project) { create(:project, :public, :repository, description: 'Code and stuff', creator: user) }
before do
# We want vNext badge to be included and com/canary don't remove/hide any other elements.
# This is why we're turning com and canary on by default for now.
allow(Gitlab).to receive(:com?).and_return(true)
allow(Gitlab).to receive(:canary?).and_return(true)
sign_in(user)
end
......
......@@ -24,6 +24,7 @@ import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
import NewIssueDropdown from '~/issues_list/components/new_issue_dropdown.vue';
import {
CREATED_DESC,
DUE_DATE_OVERDUE,
......@@ -65,6 +66,7 @@ describe('IssuesListApp component', () => {
exportCsvPath: 'export/csv/path',
fullPath: 'path/to/project',
hasAnyIssues: true,
hasAnyProjects: true,
hasBlockedIssuesFeature: true,
hasIssueWeightsFeature: true,
hasIterationsFeature: true,
......@@ -93,6 +95,7 @@ describe('IssuesListApp component', () => {
const findGlEmptyState = () => wrapper.findComponent(GlEmptyState);
const findGlLink = () => wrapper.findComponent(GlLink);
const findIssuableList = () => wrapper.findComponent(IssuableList);
const findNewIssueDropdown = () => wrapper.findComponent(NewIssueDropdown);
const mountComponent = ({
provide = {},
......@@ -190,10 +193,7 @@ describe('IssuesListApp component', () => {
beforeEach(() => {
setWindowLocation(search);
wrapper = mountComponent({
provide: { isSignedIn: true },
mountFn: mount,
});
wrapper = mountComponent({ provide: { isSignedIn: true }, mountFn: mount });
jest.runOnlyPendingTimers();
});
......@@ -208,7 +208,7 @@ describe('IssuesListApp component', () => {
describe('when user is not signed in', () => {
it('does not render', () => {
wrapper = mountComponent({ provide: { isSignedIn: false } });
wrapper = mountComponent({ provide: { isSignedIn: false }, mountFn: mount });
expect(findCsvImportExportButtons().exists()).toBe(false);
});
......@@ -216,7 +216,7 @@ describe('IssuesListApp component', () => {
describe('when in a group context', () => {
it('does not render', () => {
wrapper = mountComponent({ provide: { isProject: false } });
wrapper = mountComponent({ provide: { isProject: false }, mountFn: mount });
expect(findCsvImportExportButtons().exists()).toBe(false);
});
......@@ -231,7 +231,7 @@ describe('IssuesListApp component', () => {
});
it('does not render when user does not have permissions', () => {
wrapper = mountComponent({ provide: { canBulkUpdate: false } });
wrapper = mountComponent({ provide: { canBulkUpdate: false }, mountFn: mount });
expect(findGlButtons().filter((button) => button.text() === 'Edit issues')).toHaveLength(0);
});
......@@ -258,11 +258,25 @@ describe('IssuesListApp component', () => {
});
it('does not render when user does not have permissions', () => {
wrapper = mountComponent({ provide: { showNewIssueLink: false } });
wrapper = mountComponent({ provide: { showNewIssueLink: false }, mountFn: mount });
expect(findGlButtons().filter((button) => button.text() === 'New issue')).toHaveLength(0);
});
});
describe('new issue split dropdown', () => {
it('does not render in a project context', () => {
wrapper = mountComponent({ provide: { isProject: true }, mountFn: mount });
expect(findNewIssueDropdown().exists()).toBe(false);
});
it('renders in a group context', () => {
wrapper = mountComponent({ provide: { isProject: false }, mountFn: mount });
expect(findNewIssueDropdown().exists()).toBe(true);
});
});
});
describe('initial url params', () => {
......
import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import NewIssueDropdown from '~/issues_list/components/new_issue_dropdown.vue';
import searchProjectsQuery from '~/issues_list/queries/search_projects.query.graphql';
import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility';
import {
emptySearchProjectsQueryResponse,
project1,
project2,
searchProjectsQueryResponse,
} from '../mock_data';
describe('NewIssueDropdown component', () => {
let wrapper;
const localVue = createLocalVue();
localVue.use(VueApollo);
const mountComponent = ({
search = '',
queryResponse = searchProjectsQueryResponse,
mountFn = shallowMount,
} = {}) => {
const requestHandlers = [[searchProjectsQuery, jest.fn().mockResolvedValue(queryResponse)]];
const apolloProvider = createMockApollo(requestHandlers);
return mountFn(NewIssueDropdown, {
localVue,
apolloProvider,
provide: {
fullPath: 'mushroom-kingdom',
},
data() {
return { search };
},
});
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findInput = () => wrapper.findComponent(GlSearchBoxByType);
const showDropdown = async () => {
findDropdown().vm.$emit('shown');
await wrapper.vm.$apollo.queries.projects.refetch();
jest.runOnlyPendingTimers();
};
afterEach(() => {
wrapper.destroy();
});
it('renders a split dropdown', () => {
wrapper = mountComponent();
expect(findDropdown().props('split')).toBe(true);
});
it('renders a label for the dropdown toggle button', () => {
wrapper = mountComponent();
expect(findDropdown().attributes('toggle-text')).toBe(NewIssueDropdown.i18n.toggleButtonLabel);
});
it('focuses on input when dropdown is shown', async () => {
wrapper = mountComponent({ mountFn: mount });
const inputSpy = jest.spyOn(findInput().vm, 'focusInput');
await showDropdown();
expect(inputSpy).toHaveBeenCalledTimes(1);
});
it('renders expected dropdown items', async () => {
wrapper = mountComponent({ mountFn: mount });
await showDropdown();
const listItems = wrapper.findAll('li');
expect(listItems.at(0).text()).toBe(project1.nameWithNamespace);
expect(listItems.at(1).text()).toBe(project2.nameWithNamespace);
});
it('renders `No matches found` when there are no matches', async () => {
wrapper = mountComponent({
search: 'no matches',
queryResponse: emptySearchProjectsQueryResponse,
mountFn: mount,
});
await showDropdown();
expect(wrapper.find('li').text()).toBe(NewIssueDropdown.i18n.noMatchesFound);
});
describe('when no project is selected', () => {
beforeEach(() => {
wrapper = mountComponent();
});
it('dropdown button is not a link', () => {
expect(findDropdown().attributes('split-href')).toBeUndefined();
});
it('displays default text on the dropdown button', () => {
expect(findDropdown().props('text')).toBe(NewIssueDropdown.i18n.defaultDropdownText);
});
});
describe('when a project is selected', () => {
beforeEach(async () => {
wrapper = mountComponent({ mountFn: mount });
await showDropdown();
wrapper.findComponent(GlDropdownItem).vm.$emit('click', project1);
});
it('dropdown button is a link', () => {
const href = joinPaths(project1.webUrl, DASH_SCOPE, 'issues/new');
expect(findDropdown().attributes('split-href')).toBe(href);
});
it('displays project name on the dropdown button', () => {
expect(findDropdown().props('text')).toBe(`New issue in ${project1.name}`);
});
});
});
......@@ -221,3 +221,37 @@ export const urlParamsWithSpecialValues = {
epic_id: 'None',
weight: 'None',
};
export const project1 = {
id: 'gid://gitlab/Group/26',
name: 'Super Mario Project',
nameWithNamespace: 'Mushroom Kingdom / Super Mario Project',
webUrl: 'https://127.0.0.1:3000/mushroom-kingdom/super-mario-project',
};
export const project2 = {
id: 'gid://gitlab/Group/59',
name: 'Mario Kart Project',
nameWithNamespace: 'Mushroom Kingdom / Mario Kart Project',
webUrl: 'https://127.0.0.1:3000/mushroom-kingdom/mario-kart-project',
};
export const searchProjectsQueryResponse = {
data: {
group: {
projects: {
nodes: [project1, project2],
},
},
},
};
export const emptySearchProjectsQueryResponse = {
data: {
group: {
projects: {
nodes: [],
},
},
},
};
......@@ -354,6 +354,7 @@ RSpec.describe IssuesHelper do
let(:group) { create(:group) }
let(:current_user) { double.as_null_object }
let(:issues) { [] }
let(:projects) { [] }
it 'returns expected result' do
allow(helper).to receive(:current_user).and_return(current_user)
......@@ -367,13 +368,14 @@ RSpec.describe IssuesHelper do
empty_state_svg_path: '#',
full_path: group.full_path,
has_any_issues: issues.to_a.any?.to_s,
has_any_projects: any_projects?(projects).to_s,
is_signed_in: current_user.present?.to_s,
jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'),
rss_path: '#',
sign_in_path: new_user_session_path
}
expect(helper.group_issues_list_data(group, current_user, issues)).to include(expected)
expect(helper.group_issues_list_data(group, current_user, issues, projects)).to include(expected)
end
end
......
......@@ -24,16 +24,6 @@ RSpec.describe Gitlab::Database::SchemaMigrations::Context do
end
context 'multiple databases', :reestablished_active_record_base do
let(:connection_class) do
Class.new(::ApplicationRecord) do
self.abstract_class = true
def self.name
'Gitlab::Database::SchemaMigrations::Context::TestConnection'
end
end
end
before do
connection_class.establish_connection(
ActiveRecord::Base
......@@ -44,10 +34,6 @@ RSpec.describe Gitlab::Database::SchemaMigrations::Context do
)
end
after do
connection_class.remove_connection
end
context 'when `schema_migrations_path` is configured as string' do
let(:configuration_overrides) do
{ "schema_migrations_path" => "db/ci_schema_migrations" }
......
......@@ -17,32 +17,9 @@ RSpec.configure do |config|
delete_from_all_tables!(except: ['work_item_types'])
# Postgres maximum number of columns in a table is 1600 (https://github.com/postgres/postgres/blob/de41869b64d57160f58852eab20a27f248188135/src/include/access/htup_details.h#L23-L47).
# And since:
# "The DROP COLUMN form does not physically remove the column, but simply makes
# it invisible to SQL operations. Subsequent insert and update operations in the
# table will store a null value for the column. Thus, dropping a column is quick
# but it will not immediately reduce the on-disk size of your table, as the space
# occupied by the dropped column is not reclaimed.
# The space will be reclaimed over time as existing rows are updated."
# according to https://www.postgresql.org/docs/current/sql-altertable.html.
# We drop and recreate the database if any table has more than 1200 columns, just to be safe.
max_allowed_columns = 1200
tables_with_more_than_allowed_columns =
ApplicationRecord.connection.execute("SELECT attrelid::regclass::text AS table, COUNT(*) AS column_count FROM pg_attribute GROUP BY attrelid HAVING COUNT(*) > #{max_allowed_columns}")
if tables_with_more_than_allowed_columns.any?
tables_with_more_than_allowed_columns.each do |result|
puts "The #{result['table']} table has #{result['column_count']} columns."
end
puts "Recreating the database"
start = Gitlab::Metrics::System.monotonic_time
ActiveRecord::Tasks::DatabaseTasks.drop_current
ActiveRecord::Tasks::DatabaseTasks.create_current
ActiveRecord::Tasks::DatabaseTasks.load_schema_current
ActiveRecord::Tasks::DatabaseTasks.migrate
puts "Database re-creation done in #{Gitlab::Metrics::System.monotonic_time - start}"
if any_connection_class_with_more_than_allowed_columns?
recreate_all_databases!
end
end
......
......@@ -2,7 +2,7 @@
module DbCleaner
def all_connection_classes
::ActiveRecord::Base.connection_handler.connection_pool_names.map(&:constantize)
::BeforeAllAdapter.all_connection_classes
end
def delete_from_all_tables!(except: [])
......@@ -20,6 +20,79 @@ module DbCleaner
DatabaseCleaner[:active_record, { connection: connection_class }]
end
end
def any_connection_class_with_more_than_allowed_columns?
all_connection_classes.any? do |connection_class|
more_than_allowed_columns?(connection_class)
end
end
def more_than_allowed_columns?(connection_class)
# Postgres maximum number of columns in a table is 1600 (https://github.com/postgres/postgres/blob/de41869b64d57160f58852eab20a27f248188135/src/include/access/htup_details.h#L23-L47).
# And since:
# "The DROP COLUMN form does not physically remove the column, but simply makes
# it invisible to SQL operations. Subsequent insert and update operations in the
# table will store a null value for the column. Thus, dropping a column is quick
# but it will not immediately reduce the on-disk size of your table, as the space
# occupied by the dropped column is not reclaimed.
# The space will be reclaimed over time as existing rows are updated."
# according to https://www.postgresql.org/docs/current/sql-altertable.html.
# We drop and recreate the database if any table has more than 1200 columns, just to be safe.
max_allowed_columns = 1200
tables_with_more_than_allowed_columns = connection_class.connection.execute(<<-SQL)
SELECT attrelid::regclass::text AS table, COUNT(*) AS column_count
FROM pg_attribute
GROUP BY attrelid
HAVING COUNT(*) > #{max_allowed_columns}
SQL
tables_with_more_than_allowed_columns.each do |result|
puts "The #{result['table']} (#{connection_class.connection_db_config.name}) table has #{result['column_count']} columns."
end
tables_with_more_than_allowed_columns.any?
end
def recreate_all_databases!
start = Gitlab::Metrics::System.monotonic_time
puts "Recreating the database"
force_disconnect_all_connections!
ActiveRecord::Tasks::DatabaseTasks.drop_current
ActiveRecord::Tasks::DatabaseTasks.create_current
ActiveRecord::Tasks::DatabaseTasks.load_schema_current
# Migrate each database individually
with_reestablished_active_record_base do
all_connection_classes.each do |connection_class|
ActiveRecord::Base.establish_connection(connection_class.connection_db_config)
ActiveRecord::Tasks::DatabaseTasks.migrate
end
end
puts "Databases re-creation done in #{Gitlab::Metrics::System.monotonic_time - start}"
end
def force_disconnect_all_connections!
all_connection_classes.each do |connection_class|
# We use `connection_pool` to avoid going through
# Load Balancer since it does retry ops
pool = connection_class.connection_pool
# Force disconnect https://www.cybertec-postgresql.com/en/terminating-database-connections-in-postgresql/
pool.connection.execute(<<-SQL)
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = #{pool.connection.quote(pool.db_config.database)}
AND pid != pg_backend_pid();
SQL
connection_class.connection_pool.disconnect!
end
end
end
DbCleaner.prepend_mod_with('DbCleaner')
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment