Commit 869b4111 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 9e5bc0e5 790071b3
...@@ -15,6 +15,8 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; ...@@ -15,6 +15,8 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { n__, s__, __ } from '~/locale'; import { n__, s__, __ } from '~/locale';
import getProjects from '../graphql/projects.query.graphql'; import getProjects from '../graphql/projects.query.graphql';
const sortByProjectName = (projects = []) => projects.sort((a, b) => a.name.localeCompare(b.name));
export default { export default {
name: 'ProjectsDropdownFilter', name: 'ProjectsDropdownFilter',
components: { components: {
...@@ -88,6 +90,9 @@ export default { ...@@ -88,6 +90,9 @@ export default {
selectedProjectIds() { selectedProjectIds() {
return this.selectedProjects.map((p) => p.id); return this.selectedProjects.map((p) => p.id);
}, },
hasSelectedProjects() {
return Boolean(this.selectedProjects.length);
},
availableProjects() { availableProjects() {
return filterBySearchTerm(this.projects, this.searchTerm); return filterBySearchTerm(this.projects, this.searchTerm);
}, },
...@@ -95,6 +100,14 @@ export default { ...@@ -95,6 +100,14 @@ export default {
const { loading, availableProjects } = this; const { loading, availableProjects } = this;
return !loading && !availableProjects.length; return !loading && !availableProjects.length;
}, },
selectedItems() {
return sortByProjectName(
this.availableProjects.filter(({ id }) => this.selectedProjectIds.includes(id)),
);
},
unselectedItems() {
return this.availableProjects.filter(({ id }) => !this.selectedProjectIds.includes(id));
},
}, },
watch: { watch: {
searchTerm() { searchTerm() {
...@@ -105,44 +118,53 @@ export default { ...@@ -105,44 +118,53 @@ export default {
this.search(); this.search();
}, },
methods: { methods: {
handleUpdatedSelectedProjects() {
this.$emit('selected', this.selectedProjects);
},
search: debounce(function debouncedSearch() { search: debounce(function debouncedSearch() {
this.fetchData(); this.fetchData();
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
getSelectedProjects(selectedProject, isMarking) { getSelectedProjects(selectedProject, isSelected) {
return isMarking return isSelected
? this.selectedProjects.concat([selectedProject]) ? this.selectedProjects.concat([selectedProject])
: this.selectedProjects.filter((project) => project.id !== selectedProject.id); : this.selectedProjects.filter((project) => project.id !== selectedProject.id);
}, },
singleSelectedProject(selectedObj, isMarking) { singleSelectedProject(selectedObj, isMarking) {
return isMarking ? [selectedObj] : []; return isMarking ? [selectedObj] : [];
}, },
setSelectedProjects(selectedObj, isMarking) { setSelectedProjects(project) {
this.selectedProjects = this.multiSelect this.selectedProjects = this.multiSelect
? this.getSelectedProjects(selectedObj, isMarking) ? this.getSelectedProjects(project, !this.isProjectSelected(project))
: this.singleSelectedProject(selectedObj, isMarking); : this.singleSelectedProject(project, !this.isProjectSelected(project));
}, },
onClick({ project, isSelected }) { onClick(project) {
this.setSelectedProjects(project, !isSelected); this.setSelectedProjects(project);
this.$emit('selected', this.selectedProjects); this.handleUpdatedSelectedProjects();
}, },
onMultiSelectClick({ project, isSelected }) { onMultiSelectClick(project) {
this.setSelectedProjects(project, !isSelected); this.setSelectedProjects(project);
this.isDirty = true; this.isDirty = true;
}, },
onSelected(ev) { onSelected(project) {
if (this.multiSelect) { if (this.multiSelect) {
this.onMultiSelectClick(ev); this.onMultiSelectClick(project);
} else { } else {
this.onClick(ev); this.onClick(project);
} }
}, },
onHide() { onHide() {
if (this.multiSelect && this.isDirty) { if (this.multiSelect && this.isDirty) {
this.$emit('selected', this.selectedProjects); this.handleUpdatedSelectedProjects();
} }
this.searchTerm = ''; this.searchTerm = '';
this.isDirty = false; this.isDirty = false;
}, },
onClearAll() {
if (this.hasSelectedProjects) {
this.isDirty = true;
}
this.selectedProjects = [];
},
fetchData() { fetchData() {
this.loading = true; this.loading = true;
...@@ -168,8 +190,8 @@ export default { ...@@ -168,8 +190,8 @@ export default {
this.projects = nodes; this.projects = nodes;
}); });
}, },
isProjectSelected(id) { isProjectSelected(project) {
return this.selectedProjects ? this.selectedProjectIds.includes(id) : false; return this.selectedProjectIds.includes(project.id);
}, },
getEntityId(project) { getEntityId(project) {
return getIdFromGraphQLId(project.id); return getIdFromGraphQLId(project.id);
...@@ -182,6 +204,10 @@ export default { ...@@ -182,6 +204,10 @@ export default {
ref="projectsDropdown" ref="projectsDropdown"
class="dropdown dropdown-projects" class="dropdown dropdown-projects"
toggle-class="gl-shadow-none" toggle-class="gl-shadow-none"
:show-clear-all="hasSelectedProjects"
show-highlighted-items-title
highlighted-items-title-class="gl-p-3"
@clear-all.stop="onClearAll"
@hide="onHide" @hide="onHide"
> >
<template #button-content> <template #button-content>
...@@ -204,14 +230,37 @@ export default { ...@@ -204,14 +230,37 @@ export default {
<gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header> <gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header>
<gl-search-box-by-type v-model.trim="searchTerm" /> <gl-search-box-by-type v-model.trim="searchTerm" />
</template> </template>
<template #highlighted-items>
<gl-dropdown-item
v-for="project in selectedItems"
:key="project.id"
is-check-item
:is-checked="isProjectSelected(project)"
@click.native.capture.stop="onSelected(project)"
>
<div class="gl-display-flex">
<gl-avatar
class="gl-mr-2 gl-vertical-align-middle"
:alt="project.name"
:size="16"
:entity-id="getEntityId(project)"
:entity-name="project.name"
:src="project.avatarUrl"
shape="rect"
/>
<div>
<div data-testid="project-name">{{ project.name }}</div>
<div class="gl-text-gray-500" data-testid="project-full-path">
{{ project.fullPath }}
</div>
</div>
</div>
</gl-dropdown-item>
</template>
<gl-dropdown-item <gl-dropdown-item
v-for="project in availableProjects" v-for="project in unselectedItems"
:key="project.id" :key="project.id"
:is-check-item="true" @click.native.capture.stop="onSelected(project)"
:is-checked="isProjectSelected(project.id)"
@click.native.capture.stop="
onSelected({ project, isSelected: isProjectSelected(project.id) })
"
> >
<div class="gl-display-flex"> <div class="gl-display-flex">
<gl-avatar <gl-avatar
......
...@@ -851,10 +851,6 @@ ...@@ -851,10 +851,6 @@
"const": "runner_system_failure", "const": "runner_system_failure",
"description": "Retry if there is a runner system failure (for example, job setup failed)." "description": "Retry if there is a runner system failure (for example, job setup failed)."
}, },
{
"const": "missing_dependency_failure",
"description": "Retry if a dependency is missing."
},
{ {
"const": "runner_unsupported", "const": "runner_unsupported",
"description": "Retry if the runner is unsupported." "description": "Retry if the runner is unsupported."
......
<script> <script>
import { GlAlert, GlFormGroup, GlFormInputGroup, GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; import { GlAlert, GlFormGroup, GlFormInputGroup, GlSkeletonLoader, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale'; import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import {
DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
DEPENDENCY_PROXY_DOCS_PATH,
} from '~/packages_and_registries/settings/group/constants';
import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql'; import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql';
...@@ -19,9 +22,6 @@ export default { ...@@ -19,9 +22,6 @@ export default {
}, },
inject: ['groupPath', 'dependencyProxyAvailable'], inject: ['groupPath', 'dependencyProxyAvailable'],
i18n: { i18n: {
subTitle: __(
'Create a local proxy for storing frequently used upstream images. %{docLinkStart}Learn more%{docLinkEnd} about dependency proxies.',
),
proxyNotAvailableText: __('Dependency proxy feature is limited to public groups for now.'), proxyNotAvailableText: __('Dependency proxy feature is limited to public groups for now.'),
proxyImagePrefix: __('Dependency proxy image prefix'), proxyImagePrefix: __('Dependency proxy image prefix'),
copyImagePrefixText: __('Copy prefix'), copyImagePrefixText: __('Copy prefix'),
...@@ -47,8 +47,8 @@ export default { ...@@ -47,8 +47,8 @@ export default {
infoMessages() { infoMessages() {
return [ return [
{ {
text: this.$options.i18n.subTitle, text: DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
link: helpPagePath('user/packages/dependency_proxy/index'), link: DEPENDENCY_PROXY_DOCS_PATH,
}, },
]; ];
}, },
......
<script> <script>
import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; import { GlAlert } from '@gitlab/ui';
import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
import { import {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
PACKAGES_DOCS_PATH,
ERROR_UPDATING_SETTINGS, ERROR_UPDATING_SETTINGS,
SUCCESS_UPDATING_SETTINGS, SUCCESS_UPDATING_SETTINGS,
} from '~/packages_and_registries/settings/group/constants'; } from '~/packages_and_registries/settings/group/constants';
import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; import PackagesSettings from '~/packages_and_registries/settings/group/components/packages_settings.vue';
import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql'; import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql';
import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update';
import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
export default { export default {
name: 'GroupSettingsApp', name: 'GroupSettingsApp',
i18n: {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
},
links: {
PACKAGES_DOCS_PATH,
},
components: { components: {
GlAlert, GlAlert,
GlSprintf, PackagesSettings,
GlLink,
SettingsBlock,
MavenSettings,
GenericSettings,
DuplicatesSettings,
}, },
inject: ['defaultExpanded', 'groupPath'], inject: ['groupPath'],
apollo: { apollo: {
packageSettings: { group: {
query: getGroupPackagesSettingsQuery, query: getGroupPackagesSettingsQuery,
variables() { variables() {
return { return {
fullPath: this.groupPath, fullPath: this.groupPath,
}; };
}, },
update(data) {
return data.group?.packageSettings;
},
}, },
}, },
data() { data() {
return { return {
packageSettings: {}, group: {},
errors: {},
alertMessage: null, alertMessage: null,
}; };
}, },
computed: { computed: {
packageSettings() {
return this.group?.packageSettings || {};
},
isLoading() { isLoading() {
return this.$apollo.queries.packageSettings.loading; return this.$apollo.queries.group.loading;
}, },
}, },
methods: { methods: {
dismissAlert() { dismissAlert() {
this.alertMessage = null; this.alertMessage = null;
}, },
updateSettings(payload) { handleSuccess() {
this.errors = {}; this.$toast.show(SUCCESS_UPDATING_SETTINGS);
return this.$apollo this.dismissAlert();
.mutate({ },
mutation: updateNamespacePackageSettings, handleError() {
variables: { this.alertMessage = ERROR_UPDATING_SETTINGS;
input: {
namespacePath: this.groupPath,
...payload,
},
},
update: updateGroupPackageSettings(this.groupPath),
optimisticResponse: updateGroupPackagesSettingsOptimisticResponse({
...this.packageSettings,
...payload,
}),
})
.then(({ data }) => {
if (data.updateNamespacePackageSettings?.errors?.length > 0) {
this.alertMessage = ERROR_UPDATING_SETTINGS;
} else {
this.dismissAlert();
this.$toast.show(SUCCESS_UPDATING_SETTINGS);
}
})
.catch((e) => {
if (e.graphQLErrors) {
e.graphQLErrors.forEach((error) => {
const [
{
path: [key],
message,
},
] = error.extensions.problems;
this.errors = { ...this.errors, [key]: message };
});
}
this.alertMessage = ERROR_UPDATING_SETTINGS;
});
}, },
}, },
}; };
...@@ -114,50 +60,11 @@ export default { ...@@ -114,50 +60,11 @@ export default {
{{ alertMessage }} {{ alertMessage }}
</gl-alert> </gl-alert>
<settings-block <packages-settings
:default-expanded="defaultExpanded" :package-settings="packageSettings"
data-qa-selector="package_registry_settings_content" :is-loading="isLoading"
> @success="handleSuccess"
<template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template> @error="handleError"
<template #description> />
<span data-testid="description">
<gl-sprintf :message="$options.i18n.PACKAGE_SETTINGS_DESCRIPTION">
<template #link="{ content }">
<gl-link :href="$options.links.PACKAGES_DOCS_PATH" target="_blank">{{
content
}}</gl-link>
</template>
</gl-sprintf>
</span>
</template>
<template #default>
<maven-settings data-testid="maven-settings">
<template #default="{ modelNames }">
<duplicates-settings
:duplicates-allowed="packageSettings.mavenDuplicatesAllowed"
:duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex"
:duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex"
:model-names="modelNames"
:loading="isLoading"
toggle-qa-selector="allow_duplicates_toggle"
label-qa-selector="allow_duplicates_label"
@update="updateSettings"
/>
</template>
</maven-settings>
<generic-settings class="gl-mt-6" data-testid="generic-settings">
<template #default="{ modelNames }">
<duplicates-settings
:duplicates-allowed="packageSettings.genericDuplicatesAllowed"
:duplicate-exception-regex="packageSettings.genericDuplicateExceptionRegex"
:duplicate-exception-regex-error="errors.genericDuplicateExceptionRegex"
:model-names="modelNames"
:loading="isLoading"
@update="updateSettings"
/>
</template>
</generic-settings>
</template>
</settings-block>
</div> </div>
</template> </template>
<script>
import { GlSprintf, GlLink } from '@gitlab/ui';
import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
import {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
PACKAGES_DOCS_PATH,
} from '~/packages_and_registries/settings/group/constants';
import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update';
import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
export default {
name: 'PackageSettings',
i18n: {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
},
links: {
PACKAGES_DOCS_PATH,
},
components: {
GlSprintf,
GlLink,
SettingsBlock,
MavenSettings,
GenericSettings,
DuplicatesSettings,
},
inject: ['defaultExpanded', 'groupPath'],
props: {
packageSettings: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
errors: {},
};
},
methods: {
async updateSettings(payload) {
this.errors = {};
try {
const { data } = await this.$apollo.mutate({
mutation: updateNamespacePackageSettings,
variables: {
input: {
namespacePath: this.groupPath,
...payload,
},
},
update: updateGroupPackageSettings(this.groupPath),
optimisticResponse: updateGroupPackagesSettingsOptimisticResponse({
...this.packageSettings,
...payload,
}),
});
if (data.updateNamespacePackageSettings?.errors?.length > 0) {
throw new Error();
} else {
this.$emit('success');
}
} catch (e) {
if (e.graphQLErrors) {
e.graphQLErrors.forEach((error) => {
const [
{
path: [key],
message,
},
] = error.extensions.problems;
this.errors = { ...this.errors, [key]: message };
});
}
this.$emit('error');
}
},
},
};
</script>
<template>
<settings-block
:default-expanded="defaultExpanded"
data-qa-selector="package_registry_settings_content"
>
<template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template>
<template #description>
<span data-testid="description">
<gl-sprintf :message="$options.i18n.PACKAGE_SETTINGS_DESCRIPTION">
<template #link="{ content }">
<gl-link :href="$options.links.PACKAGES_DOCS_PATH" target="_blank">{{
content
}}</gl-link>
</template>
</gl-sprintf>
</span>
</template>
<template #default>
<maven-settings data-testid="maven-settings">
<template #default="{ modelNames }">
<duplicates-settings
:duplicates-allowed="packageSettings.mavenDuplicatesAllowed"
:duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex"
:duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex"
:model-names="modelNames"
:loading="isLoading"
toggle-qa-selector="allow_duplicates_toggle"
label-qa-selector="allow_duplicates_label"
@update="updateSettings"
/>
</template>
</maven-settings>
<generic-settings class="gl-mt-6" data-testid="generic-settings">
<template #default="{ modelNames }">
<duplicates-settings
:duplicates-allowed="packageSettings.genericDuplicatesAllowed"
:duplicate-exception-regex="packageSettings.genericDuplicateExceptionRegex"
:duplicate-exception-regex-error="errors.genericDuplicateExceptionRegex"
:model-names="modelNames"
:loading="isLoading"
@update="updateSettings"
/>
</template>
</generic-settings>
</template>
</settings-block>
</template>
...@@ -23,8 +23,15 @@ export const ERROR_UPDATING_SETTINGS = s__( ...@@ -23,8 +23,15 @@ export const ERROR_UPDATING_SETTINGS = s__(
'PackageRegistry|An error occurred while saving the settings', 'PackageRegistry|An error occurred while saving the settings',
); );
export const DEPENDENCY_PROXY_HEADER = s__('DependencyProxy|Dependency Proxy');
export const DEPENDENCY_PROXY_SETTINGS_DESCRIPTION = s__(
'DependencyProxy|Create a local proxy for storing frequently used upstream images. %{docLinkStart}Learn more%{docLinkEnd} about dependency proxies.',
);
// Parameters // Parameters
export const PACKAGES_DOCS_PATH = helpPagePath('user/packages'); export const PACKAGES_DOCS_PATH = helpPagePath('user/packages');
export const MAVEN_DUPLICATES_ALLOWED = 'mavenDuplicatesAllowed'; export const MAVEN_DUPLICATES_ALLOWED = 'mavenDuplicatesAllowed';
export const MAVEN_DUPLICATE_EXCEPTION_REGEX = 'mavenDuplicateExceptionRegex'; export const MAVEN_DUPLICATE_EXCEPTION_REGEX = 'mavenDuplicateExceptionRegex';
export const DEPENDENCY_PROXY_DOCS_PATH = helpPagePath('user/packages/dependency_proxy/index');
...@@ -2,13 +2,7 @@ ...@@ -2,13 +2,7 @@
%h1.page-title= _('Activity') %h1.page-title= _('Activity')
.top-area .top-area
%ul.nav-links.nav.nav-tabs = gl_tabs_nav({ class: 'gl-border-b-0', data: { testid: 'dashboard-activity-tabs' } }) do
%li{ class: active_when(params[:filter].nil?) }> = gl_tab_link_to _("Your projects"), activity_dashboard_path, { item_active: params[:filter].nil? }
= link_to activity_dashboard_path, class: 'shortcuts-activity', data: {placement: 'right'} do = gl_tab_link_to _("Starred projects"), activity_dashboard_path(filter: 'starred')
= _('Your projects') = gl_tab_link_to _("Followed users"), activity_dashboard_path(filter: 'followed')
%li{ class: active_when(params[:filter] == 'starred') }>
= link_to activity_dashboard_path(filter: 'starred'), data: {placement: 'right'} do
= _('Starred projects')
%li{ class: active_when(params[:filter] == 'followed') }>
= link_to activity_dashboard_path(filter: 'followed'), data: {placement: 'right'} do
= _('Followed users')
...@@ -3405,7 +3405,6 @@ Possible values for `when` are: ...@@ -3405,7 +3405,6 @@ Possible values for `when` are:
- `api_failure`: Retry on API failure. - `api_failure`: Retry on API failure.
- `stuck_or_timeout_failure`: Retry when the job got stuck or timed out. - `stuck_or_timeout_failure`: Retry when the job got stuck or timed out.
- `runner_system_failure`: Retry if there is a runner system failure (for example, job setup failed). - `runner_system_failure`: Retry if there is a runner system failure (for example, job setup failed).
- `missing_dependency_failure`: Retry if a dependency is missing.
- `runner_unsupported`: Retry if the runner is unsupported. - `runner_unsupported`: Retry if the runner is unsupported.
- `stale_schedule`: Retry if a delayed job could not be executed. - `stale_schedule`: Retry if a delayed job could not be executed.
- `job_execution_timeout`: Retry if the script exceeded the maximum execution time set for the job. - `job_execution_timeout`: Retry if the script exceeded the maximum execution time set for the job.
......
...@@ -16,15 +16,9 @@ module SubscriptionsHelper ...@@ -16,15 +16,9 @@ module SubscriptionsHelper
} }
end end
def buy_minutes_addon_data(group) def buy_addon_data(group, anchor, purchased_product)
{ {
redirect_after_success: group_usage_quotas_path(group, anchor: 'pipelines-quota-tab', purchased_product: _('CI minutes')) redirect_after_success: group_usage_quotas_path(group, anchor: anchor, purchased_product: purchased_product)
}.merge(addon_data(group))
end
def buy_storage_addon_data(group)
{
redirect_after_success: group_usage_quotas_path(group, anchor: 'storage-quota-tab', purchased_product: _('Storage'))
}.merge(addon_data(group)) }.merge(addon_data(group))
end end
......
...@@ -404,6 +404,11 @@ module EE ...@@ -404,6 +404,11 @@ module EE
::Gitlab::CurrentSettings.invalidate_elasticsearch_indexes_cache_for_namespace!(self.id) ::Gitlab::CurrentSettings.invalidate_elasticsearch_indexes_cache_for_namespace!(self.id)
end end
def elastic_namespace_ancestry
separator = '-'
self_and_ancestor_ids(hierarchy_order: :desc).join(separator) + separator
end
def enable_temporary_storage_increase! def enable_temporary_storage_increase!
update(temporary_storage_increase_ends_on: TEMPORARY_STORAGE_INCREASE_DAYS.days.from_now) update(temporary_storage_increase_ends_on: TEMPORARY_STORAGE_INCREASE_DAYS.days.from_now)
end end
......
- page_title _('Buy CI Minutes') - page_title _('Buy CI Minutes')
#js-buy-minutes{ data: buy_minutes_addon_data(@group) } #js-buy-minutes{ data: buy_addon_data(@group, 'pipelines-quota-tab', _('CI minutes')) }
- page_title _('Buy Storage') - page_title _('Buy Storage')
#js-buy-storage{ data: buy_storage_addon_data(@group) } #js-buy-storage{ data: buy_addon_data(@group, 'storage-quota-tab', s_('Checkout|a storage subscription')) }
---
name: elasticsearch_use_group_level_optimization
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69741
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/340596
milestone: '14.4'
type: development
group: group::global search
default_enabled: false
...@@ -381,6 +381,37 @@ module Elastic ...@@ -381,6 +381,37 @@ module Elastic
authorized_project_ids.to_a authorized_project_ids.to_a
end end
def authorized_namespace_ids(current_user, options = {})
return [] unless current_user && options[:group_ids].present?
authorized_ids = current_user.authorized_groups.pluck_primary_key.to_set
authorized_ids.intersection(options[:group_ids].to_set).to_a
end
def ancestry_filter(current_user, namespace_ancestry)
return {} unless current_user
return {} if namespace_ancestry.blank?
context.name(:ancestry_filter) do
filters = namespace_ancestry.map do |namespace_ids|
{
prefix: {
namespace_ancestry_ids: {
_name: context.name(:descendants),
value: namespace_ids
}
}
}
end
{
bool: {
should: filters
}
}
end
end
end end
end end
end end
...@@ -5,8 +5,6 @@ module Elastic ...@@ -5,8 +5,6 @@ module Elastic
class ApplicationInstanceProxy < Elasticsearch::Model::Proxy::InstanceMethodsProxy class ApplicationInstanceProxy < Elasticsearch::Model::Proxy::InstanceMethodsProxy
include InstanceProxyUtil include InstanceProxyUtil
NAMESPACE_ANCESTRY_SEPARATOR = '-'
def es_parent def es_parent
"project_#{target.project_id}" unless target.is_a?(Project) || target&.project_id.nil? "project_#{target.project_id}" unless target.is_a?(Project) || target&.project_id.nil?
end end
...@@ -21,8 +19,7 @@ module Elastic ...@@ -21,8 +19,7 @@ module Elastic
def namespace_ancestry def namespace_ancestry
project = target.is_a?(Project) ? target : target.project project = target.is_a?(Project) ? target : target.project
namespace = project.namespace project.namespace.elastic_namespace_ancestry
namespace.self_and_ancestor_ids(hierarchy_order: :desc).join(NAMESPACE_ANCESTRY_SEPARATOR) + NAMESPACE_ANCESTRY_SEPARATOR
end end
private private
......
...@@ -19,7 +19,7 @@ module Elastic ...@@ -19,7 +19,7 @@ module Elastic
options[:features] = 'issues' options[:features] = 'issues'
options[:no_join_project] = true options[:no_join_project] = true
context.name(:issue) do context.name(:issue) do
query_hash = context.name(:authorized) { project_ids_filter(query_hash, options) } query_hash = context.name(:authorized) { authorization_filter(query_hash, options) }
query_hash = context.name(:confidentiality) { confidentiality_filter(query_hash, options) } query_hash = context.name(:confidentiality) { confidentiality_filter(query_hash, options) }
query_hash = context.name(:match) { state_filter(query_hash, options) } query_hash = context.name(:match) { state_filter(query_hash, options) }
end end
...@@ -56,6 +56,30 @@ module Elastic ...@@ -56,6 +56,30 @@ module Elastic
end end
end end
def should_use_project_ids_filter?(options)
options[:project_ids] == :any ||
options[:group_ids].blank? ||
Feature.disabled?(:elasticsearch_use_group_level_optimization) ||
!Elastic::DataMigrationService.migration_has_finished?(:redo_backfill_namespace_ancestry_ids_for_issues)
end
def authorization_filter(query_hash, options)
return project_ids_filter(query_hash, options) if should_use_project_ids_filter?(options)
current_user = options[:current_user]
namespace_ancestry = Namespace.find(authorized_namespace_ids(current_user, options))
.map(&:elastic_namespace_ancestry)
return project_ids_filter(query_hash, options) if namespace_ancestry.blank?
context.name(:namespace) do
query_hash[:query][:bool][:filter] ||= []
query_hash[:query][:bool][:filter] << ancestry_filter(current_user, namespace_ancestry)
end
query_hash
end
# Builds an elasticsearch query that will select documents from a # Builds an elasticsearch query that will select documents from a
# set of projects for Group and Project searches, taking user access # set of projects for Group and Project searches, taking user access
# rules for issues into account. Relies upon super for Global searches # rules for issues into account. Relies upon super for Global searches
......
...@@ -6,6 +6,8 @@ module Gitlab ...@@ -6,6 +6,8 @@ module Gitlab
# superclass inside a module, because autoloading can occur in a # superclass inside a module, because autoloading can occur in a
# different order between execution environments. # different order between execution environments.
class GroupSearchResults < Gitlab::Elastic::SearchResults class GroupSearchResults < Gitlab::Elastic::SearchResults
extend Gitlab::Utils::Override
attr_reader :group, :default_project_filter, :filters attr_reader :group, :default_project_filter, :filters
# rubocop:disable Metrics/ParameterLists # rubocop:disable Metrics/ParameterLists
...@@ -17,6 +19,16 @@ module Gitlab ...@@ -17,6 +19,16 @@ module Gitlab
super(current_user, query, limit_project_ids, public_and_internal_projects: public_and_internal_projects, order_by: order_by, sort: sort, filters: filters) super(current_user, query, limit_project_ids, public_and_internal_projects: public_and_internal_projects, order_by: order_by, sort: sort, filters: filters)
end end
# rubocop:enable Metrics/ParameterLists # rubocop:enable Metrics/ParameterLists
override :scope_options
def scope_options(scope)
case scope
when :issues
super.merge(group_ids: [group.id])
else
super
end
end
end end
end end
end end
...@@ -150,29 +150,14 @@ RSpec.describe SubscriptionsHelper do ...@@ -150,29 +150,14 @@ RSpec.describe SubscriptionsHelper do
it { is_expected.to include(group_data: %Q{[{"id":#{group.id},"name":"My Namespace","users":1,"guests":0}]}) } it { is_expected.to include(group_data: %Q{[{"id":#{group.id},"name":"My Namespace","users":1,"guests":0}]}) }
end end
describe '#buy_minutes_addon_data' do describe '#buy_addon_data' do
subject(:buy_minutes_addon_data) { helper.buy_minutes_addon_data(group) } subject(:buy_addon_data) { helper.buy_addon_data(group, anchor, purchased_product) }
let_it_be(:user) { create(:user, name: 'First Last') }
let_it_be(:group) { create(:group, name: 'My Namespace') } let_it_be(:group) { create(:group, name: 'My Namespace') }
before do
allow(helper).to receive(:current_user).and_return(user)
allow(helper).to receive(:params).and_return({ selected_group: group.id.to_s, source: 'some_source' })
group.add_owner(user)
end
it { is_expected.to include(namespace_id: group.id.to_s) }
it { is_expected.to include(source: 'some_source') }
it { is_expected.to include(group_data: %Q{[{"id":#{group.id},"name":"My Namespace","users":1,"guests":0}]}) }
it { is_expected.to include(redirect_after_success: group_usage_quotas_path(group, anchor: 'pipelines-quota-tab', purchased_product: 'CI minutes')) }
end
describe '#buy_storage_addon_data' do
subject(:buy_storage_addon_data) { helper.buy_storage_addon_data(group) }
let_it_be(:user) { create(:user, name: 'First Last') } let_it_be(:user) { create(:user, name: 'First Last') }
let_it_be(:group) { create(:group, name: 'My Namespace') }
let(:anchor) { 'pipelines-quota-tab' }
let(:purchased_product) { 'CI Minutes' }
before do before do
allow(helper).to receive(:current_user).and_return(user) allow(helper).to receive(:current_user).and_return(user)
...@@ -183,6 +168,6 @@ RSpec.describe SubscriptionsHelper do ...@@ -183,6 +168,6 @@ RSpec.describe SubscriptionsHelper do
it { is_expected.to include(namespace_id: group.id.to_s) } it { is_expected.to include(namespace_id: group.id.to_s) }
it { is_expected.to include(source: 'some_source') } it { is_expected.to include(source: 'some_source') }
it { is_expected.to include(group_data: %Q{[{"id":#{group.id},"name":"My Namespace","users":1,"guests":0}]}) } it { is_expected.to include(group_data: %Q{[{"id":#{group.id},"name":"My Namespace","users":1,"guests":0}]}) }
it { is_expected.to include(redirect_after_success: group_usage_quotas_path(group, anchor: 'storage-quota-tab', purchased_product: 'Storage')) } it { is_expected.to include(redirect_after_success: group_usage_quotas_path(group, anchor: anchor, purchased_product: purchased_product)) }
end end
end end
...@@ -206,8 +206,24 @@ RSpec.describe Search::GroupService do ...@@ -206,8 +206,24 @@ RSpec.describe Search::GroupService do
permission_table_for_guest_feature_access permission_table_for_guest_feature_access
end end
with_them do context 'elasticsearch_use_group_level_optimization is enabled' do
it_behaves_like 'search respects visibility' before do
stub_feature_flags(elasticsearch_use_group_level_optimization: true)
end
with_them do
it_behaves_like 'search respects visibility'
end
end
context 'elasticsearch_use_group_level_optimization is disabled' do
before do
stub_feature_flags(elasticsearch_use_group_level_optimization: false)
end
with_them do
it_behaves_like 'search respects visibility'
end
end end
end end
...@@ -296,13 +312,27 @@ RSpec.describe Search::GroupService do ...@@ -296,13 +312,27 @@ RSpec.describe Search::GroupService do
let!(:new_updated) { create(:issue, project: project, title: 'updated recent', updated_at: 1.day.ago) } let!(:new_updated) { create(:issue, project: project, title: 'updated recent', updated_at: 1.day.ago) }
let!(:very_old_updated) { create(:issue, project: project, title: 'updated very old', updated_at: 1.year.ago) } let!(:very_old_updated) { create(:issue, project: project, title: 'updated very old', updated_at: 1.year.ago) }
let(:results_created) { described_class.new(nil, group, search: 'sorted', sort: sort).execute }
let(:results_updated) { described_class.new(nil, group, search: 'updated', sort: sort).execute }
before do before do
ensure_elasticsearch_index! ensure_elasticsearch_index!
end end
include_examples 'search results sorted' do context 'elasticsearch_use_group_level_optimization is enabled' do
let(:results_created) { described_class.new(nil, group, search: 'sorted', sort: sort).execute } before do
let(:results_updated) { described_class.new(nil, group, search: 'updated', sort: sort).execute } stub_feature_flags(elasticsearch_use_group_level_optimization: true)
end
include_examples 'search results sorted'
end
context 'elasticsearch_use_group_level_optimization is disabled' do
before do
stub_feature_flags(elasticsearch_use_group_level_optimization: false)
end
include_examples 'search results sorted'
end end
end end
......
...@@ -22,12 +22,12 @@ end ...@@ -22,12 +22,12 @@ end
RSpec.shared_examples_for 'buy minutes addon form data' do |js_selector| RSpec.shared_examples_for 'buy minutes addon form data' do |js_selector|
before do before do
allow(view).to receive(:buy_minutes_addon_data).and_return( allow(view).to receive(:buy_addon_data).and_return(
group_data: '[{"id":"ci_minutes_plan_id","code":"ci_minutes","price_per_year":10.0}]', group_data: '[{"id":"ci_minutes_plan_id","code":"ci_minutes","price_per_year":10.0}]',
namespace_id: '1', namespace_id: '1',
plan_id: 'ci_minutes_plan_id', plan_id: 'ci_minutes_plan_id',
source: 'some_source', source: 'some_source',
redirect_after_success: '/groups/my-ci-minutes-group/-/usage_quotas' redirect_after_success: '/groups/my-ci-minutes-group/-/usage_quotas#pipelines-quota-tab'
) )
end end
...@@ -37,17 +37,17 @@ RSpec.shared_examples_for 'buy minutes addon form data' do |js_selector| ...@@ -37,17 +37,17 @@ RSpec.shared_examples_for 'buy minutes addon form data' do |js_selector|
it { is_expected.to have_selector("#{js_selector}[data-plan-id='ci_minutes_plan_id']") } it { is_expected.to have_selector("#{js_selector}[data-plan-id='ci_minutes_plan_id']") }
it { is_expected.to have_selector("#{js_selector}[data-namespace-id='1']") } it { is_expected.to have_selector("#{js_selector}[data-namespace-id='1']") }
it { is_expected.to have_selector("#{js_selector}[data-source='some_source']") } it { is_expected.to have_selector("#{js_selector}[data-source='some_source']") }
it { is_expected.to have_selector("#{js_selector}[data-redirect-after-success='/groups/my-ci-minutes-group/-/usage_quotas']") } it { is_expected.to have_selector("#{js_selector}[data-redirect-after-success='/groups/my-ci-minutes-group/-/usage_quotas#pipelines-quota-tab']") }
end end
RSpec.shared_examples_for 'buy storage addon form data' do |js_selector| RSpec.shared_examples_for 'buy storage addon form data' do |js_selector|
before do before do
allow(view).to receive(:buy_storage_addon_data).and_return( allow(view).to receive(:buy_addon_data).and_return(
group_data: '[{"id":"storage_plan_id","code":"storage","price_per_year":10.0}]', group_data: '[{"id":"storage_plan_id","code":"storage","price_per_year":10.0}]',
namespace_id: '2', namespace_id: '2',
plan_id: 'storage_plan_id', plan_id: 'storage_plan_id',
source: 'some_source', source: 'some_source',
redirect_after_success: '/groups/my-group/-/usage_quotas' redirect_after_success: '/groups/my-group/-/usage_quotas#storage-quota-tab'
) )
end end
...@@ -57,5 +57,5 @@ RSpec.shared_examples_for 'buy storage addon form data' do |js_selector| ...@@ -57,5 +57,5 @@ RSpec.shared_examples_for 'buy storage addon form data' do |js_selector|
it { is_expected.to have_selector("#{js_selector}[data-plan-id='storage_plan_id']") } it { is_expected.to have_selector("#{js_selector}[data-plan-id='storage_plan_id']") }
it { is_expected.to have_selector("#{js_selector}[data-namespace-id='2']") } it { is_expected.to have_selector("#{js_selector}[data-namespace-id='2']") }
it { is_expected.to have_selector("#{js_selector}[data-source='some_source']") } it { is_expected.to have_selector("#{js_selector}[data-source='some_source']") }
it { is_expected.to have_selector("#{js_selector}[data-redirect-after-success='/groups/my-group/-/usage_quotas']") } it { is_expected.to have_selector("#{js_selector}[data-redirect-after-success='/groups/my-group/-/usage_quotas#storage-quota-tab']") }
end end
...@@ -9,7 +9,8 @@ class Gitlab::Ci::Build::AutoRetry ...@@ -9,7 +9,8 @@ class Gitlab::Ci::Build::AutoRetry
RETRY_OVERRIDES = { RETRY_OVERRIDES = {
ci_quota_exceeded: 0, ci_quota_exceeded: 0,
no_matching_runner: 0 no_matching_runner: 0,
missing_dependency_failure: 0
}.freeze }.freeze
def initialize(build) def initialize(build)
......
...@@ -6760,6 +6760,9 @@ msgstr "" ...@@ -6760,6 +6760,9 @@ msgstr ""
msgid "Checkout|Zip code" msgid "Checkout|Zip code"
msgstr "" msgstr ""
msgid "Checkout|a storage subscription"
msgstr ""
msgid "Checkout|company or team" msgid "Checkout|company or team"
msgstr "" msgstr ""
...@@ -9510,9 +9513,6 @@ msgstr "" ...@@ -9510,9 +9513,6 @@ msgstr ""
msgid "Create a Mattermost team for this group" msgid "Create a Mattermost team for this group"
msgstr "" msgstr ""
msgid "Create a local proxy for storing frequently used upstream images. %{docLinkStart}Learn more%{docLinkEnd} about dependency proxies."
msgstr ""
msgid "Create a local proxy for storing frequently used upstream images. %{link_start}Learn more%{link_end} about dependency proxies." msgid "Create a local proxy for storing frequently used upstream images. %{link_start}Learn more%{link_end} about dependency proxies."
msgstr "" msgstr ""
...@@ -11160,6 +11160,12 @@ msgstr "" ...@@ -11160,6 +11160,12 @@ msgstr ""
msgid "Dependency proxy image prefix" msgid "Dependency proxy image prefix"
msgstr "" msgstr ""
msgid "DependencyProxy|Create a local proxy for storing frequently used upstream images. %{docLinkStart}Learn more%{docLinkEnd} about dependency proxies."
msgstr ""
msgid "DependencyProxy|Dependency Proxy"
msgstr ""
msgid "DependencyProxy|Toggle Dependency Proxy" msgid "DependencyProxy|Toggle Dependency Proxy"
msgstr "" msgstr ""
......
...@@ -7,11 +7,11 @@ module QA ...@@ -7,11 +7,11 @@ module QA
class PackageRegistries < QA::Page::Base class PackageRegistries < QA::Page::Base
include ::QA::Page::Settings::Common include ::QA::Page::Settings::Common
view 'app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue' do view 'app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue' do
element :package_registry_settings_content element :package_registry_settings_content
end end
view 'app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue' do view 'app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue' do
element :allow_duplicates_toggle element :allow_duplicates_toggle
element :allow_duplicates_label element :allow_duplicates_label
end end
......
...@@ -13,19 +13,19 @@ RSpec.describe 'Dashboard > Activity' do ...@@ -13,19 +13,19 @@ RSpec.describe 'Dashboard > Activity' do
it 'shows Your Projects' do it 'shows Your Projects' do
visit activity_dashboard_path visit activity_dashboard_path
expect(find('.top-area .nav-tabs li.active')).to have_content('Your projects') expect(find('[data-testid="dashboard-activity-tabs"] a.active')).to have_content('Your projects')
end end
it 'shows Starred Projects' do it 'shows Starred Projects' do
visit activity_dashboard_path(filter: 'starred') visit activity_dashboard_path(filter: 'starred')
expect(find('.top-area .nav-tabs li.active')).to have_content('Starred projects') expect(find('[data-testid="dashboard-activity-tabs"] a.active')).to have_content('Starred projects')
end end
it 'shows Followed Projects' do it 'shows Followed Projects' do
visit activity_dashboard_path(filter: 'followed') visit activity_dashboard_path(filter: 'followed')
expect(find('.top-area .nav-tabs li.active')).to have_content('Followed users') expect(find('[data-testid="dashboard-activity-tabs"] a.active')).to have_content('Followed users')
end end
end end
......
import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue'; import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
import getProjects from '~/analytics/shared/graphql/projects.query.graphql'; import getProjects from '~/analytics/shared/graphql/projects.query.graphql';
...@@ -25,6 +26,17 @@ const projects = [ ...@@ -25,6 +26,17 @@ const projects = [
}, },
]; ];
const MockGlDropdown = stubComponent(GlDropdown, {
template: `
<div>
<div data-testid="vsa-highlighted-items">
<slot name="highlighted-items"></slot>
</div>
<div data-testid="vsa-default-items"><slot></slot></div>
</div>
`,
});
const defaultMocks = { const defaultMocks = {
$apollo: { $apollo: {
query: jest.fn().mockResolvedValue({ query: jest.fn().mockResolvedValue({
...@@ -38,22 +50,32 @@ let spyQuery; ...@@ -38,22 +50,32 @@ let spyQuery;
describe('ProjectsDropdownFilter component', () => { describe('ProjectsDropdownFilter component', () => {
let wrapper; let wrapper;
const createComponent = (props = {}) => { const createComponent = (props = {}, stubs = {}) => {
spyQuery = defaultMocks.$apollo.query; spyQuery = defaultMocks.$apollo.query;
wrapper = mount(ProjectsDropdownFilter, { wrapper = mountExtended(ProjectsDropdownFilter, {
mocks: { ...defaultMocks }, mocks: { ...defaultMocks },
propsData: { propsData: {
groupId: 1, groupId: 1,
groupNamespace: 'gitlab-org', groupNamespace: 'gitlab-org',
...props, ...props,
}, },
stubs,
}); });
}; };
const createWithMockDropdown = (props) => {
createComponent(props, { GlDropdown: MockGlDropdown });
return wrapper.vm.$nextTick();
};
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
const findHighlightedItems = () => wrapper.findByTestId('vsa-highlighted-items');
const findHighlightedItemsTitle = () => wrapper.findByText('Selected');
const findClearAllButton = () => wrapper.findByText('Clear all');
const findDropdown = () => wrapper.find(GlDropdown); const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownItems = () => const findDropdownItems = () =>
...@@ -75,8 +97,19 @@ describe('ProjectsDropdownFilter component', () => { ...@@ -75,8 +97,19 @@ describe('ProjectsDropdownFilter component', () => {
const findDropdownFullPathAtIndex = (index) => const findDropdownFullPathAtIndex = (index) =>
findDropdownAtIndex(index).find('[data-testid="project-full-path"]'); findDropdownAtIndex(index).find('[data-testid="project-full-path"]');
const selectDropdownItemAtIndex = (index) => const selectDropdownItemAtIndex = (index) => {
findDropdownAtIndex(index).find('button').trigger('click'); findDropdownAtIndex(index).find('button').trigger('click');
return wrapper.vm.$nextTick();
};
// NOTE: Selected items are now visually separated from unselected items
const findSelectedDropdownItems = () => findHighlightedItems().findAll(GlDropdownItem);
const findSelectedDropdownAtIndex = (index) => findSelectedDropdownItems().at(index);
const findSelectedButtonIdentIconAtIndex = (index) =>
findSelectedDropdownAtIndex(index).find('div.gl-avatar-identicon');
const findSelectedButtonAvatarItemAtIndex = (index) =>
findSelectedDropdownAtIndex(index).find('img.gl-avatar');
const selectedIds = () => wrapper.vm.selectedProjects.map(({ id }) => id); const selectedIds = () => wrapper.vm.selectedProjects.map(({ id }) => id);
...@@ -109,7 +142,62 @@ describe('ProjectsDropdownFilter component', () => { ...@@ -109,7 +142,62 @@ describe('ProjectsDropdownFilter component', () => {
}); });
}); });
describe('when passed a an array of defaultProject as prop', () => { describe('highlighted items', () => {
const blockDefaultProps = { multiSelect: true };
beforeEach(() => {
createComponent(blockDefaultProps);
});
describe('with no project selected', () => {
it('does not render the highlighted items', async () => {
await createWithMockDropdown(blockDefaultProps);
expect(findSelectedDropdownItems().length).toBe(0);
});
it('does not render the highlighted items title', () => {
expect(findHighlightedItemsTitle().exists()).toBe(false);
});
it('does not render the clear all button', () => {
expect(findClearAllButton().exists()).toBe(false);
});
});
describe('with a selected project', () => {
beforeEach(async () => {
await selectDropdownItemAtIndex(0);
});
it('renders the highlighted items', async () => {
await createWithMockDropdown(blockDefaultProps);
await selectDropdownItemAtIndex(0);
expect(findSelectedDropdownItems().length).toBe(1);
});
it('renders the highlighted items title', () => {
expect(findHighlightedItemsTitle().exists()).toBe(true);
});
it('renders the clear all button', () => {
expect(findClearAllButton().exists()).toBe(true);
});
it('clears all selected items when the clear all button is clicked', async () => {
await selectDropdownItemAtIndex(1);
expect(wrapper.text()).toContain('2 projects selected');
findClearAllButton().trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.text()).not.toContain('2 projects selected');
expect(wrapper.text()).toContain('Select projects');
});
});
});
describe('when passed an array of defaultProject as prop', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
defaultProjects: [projects[0]], defaultProjects: [projects[0]],
...@@ -130,8 +218,9 @@ describe('ProjectsDropdownFilter component', () => { ...@@ -130,8 +218,9 @@ describe('ProjectsDropdownFilter component', () => {
}); });
describe('when multiSelect is false', () => { describe('when multiSelect is false', () => {
const blockDefaultProps = { multiSelect: false };
beforeEach(() => { beforeEach(() => {
createComponent({ multiSelect: false }); createComponent(blockDefaultProps);
}); });
describe('displays the correct information', () => { describe('displays the correct information', () => {
...@@ -183,21 +272,19 @@ describe('ProjectsDropdownFilter component', () => { ...@@ -183,21 +272,19 @@ describe('ProjectsDropdownFilter component', () => {
}); });
it('renders an avatar in the dropdown button when the project has an avatarUrl', async () => { it('renders an avatar in the dropdown button when the project has an avatarUrl', async () => {
selectDropdownItemAtIndex(0); await createWithMockDropdown(blockDefaultProps);
await selectDropdownItemAtIndex(0);
await wrapper.vm.$nextTick().then(() => { expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(true);
expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true); expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(false);
expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false);
});
}); });
it("renders an identicon in the dropdown button when the project doesn't have an avatarUrl", async () => { it("renders an identicon in the dropdown button when the project doesn't have an avatarUrl", async () => {
selectDropdownItemAtIndex(1); await createWithMockDropdown(blockDefaultProps);
await selectDropdownItemAtIndex(1);
await wrapper.vm.$nextTick().then(() => { expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(false);
expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false); expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(true);
expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true);
});
}); });
}); });
}); });
......
import { GlSprintf, GlLink } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
import component from '~/packages_and_registries/settings/group/components/packages_settings.vue';
import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
import {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
PACKAGES_DOCS_PATH,
} from '~/packages_and_registries/settings/group/constants';
import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql';
import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
import {
packageSettings,
groupPackageSettingsMock,
groupPackageSettingsMutationMock,
groupPackageSettingsMutationErrorMock,
} from '../mock_data';
jest.mock('~/flash');
jest.mock('~/packages_and_registries/settings/group/graphql/utils/optimistic_responses');
const localVue = createLocalVue();
describe('Packages Settings', () => {
let wrapper;
let apolloProvider;
const defaultProvide = {
defaultExpanded: false,
groupPath: 'foo_group_path',
};
const mountComponent = ({
mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()),
} = {}) => {
localVue.use(VueApollo);
const requestHandlers = [[updateNamespacePackageSettings, mutationResolver]];
apolloProvider = createMockApollo(requestHandlers);
wrapper = shallowMountExtended(component, {
localVue,
apolloProvider,
provide: defaultProvide,
propsData: {
packageSettings: packageSettings(),
},
stubs: {
GlSprintf,
SettingsBlock,
MavenSettings,
GenericSettings,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const findSettingsBlock = () => wrapper.findComponent(SettingsBlock);
const findDescription = () => wrapper.findByTestId('description');
const findLink = () => wrapper.findComponent(GlLink);
const findMavenSettings = () => wrapper.findComponent(MavenSettings);
const findMavenDuplicatedSettings = () => findMavenSettings().findComponent(DuplicatesSettings);
const findGenericSettings = () => wrapper.findComponent(GenericSettings);
const findGenericDuplicatedSettings = () =>
findGenericSettings().findComponent(DuplicatesSettings);
const fillApolloCache = () => {
apolloProvider.defaultClient.cache.writeQuery({
query: getGroupPackagesSettingsQuery,
variables: {
fullPath: defaultProvide.groupPath,
},
...groupPackageSettingsMock,
});
};
const emitMavenSettingsUpdate = (override) => {
findMavenDuplicatedSettings().vm.$emit('update', {
mavenDuplicateExceptionRegex: ')',
...override,
});
};
it('renders a settings block', () => {
mountComponent();
expect(findSettingsBlock().exists()).toBe(true);
});
it('passes the correct props to settings block', () => {
mountComponent();
expect(findSettingsBlock().props('defaultExpanded')).toBe(false);
});
it('has the correct header text', () => {
mountComponent();
expect(wrapper.text()).toContain(PACKAGE_SETTINGS_HEADER);
});
it('has the correct description text', () => {
mountComponent();
expect(findDescription().text()).toMatchInterpolatedText(PACKAGE_SETTINGS_DESCRIPTION);
});
it('has the correct link', () => {
mountComponent();
expect(findLink().attributes()).toMatchObject({
href: PACKAGES_DOCS_PATH,
target: '_blank',
});
expect(findLink().text()).toBe('Learn more.');
});
describe('maven settings', () => {
it('exists', () => {
mountComponent();
expect(findMavenSettings().exists()).toBe(true);
});
it('assigns duplication allowness and exception props', async () => {
mountComponent();
const { mavenDuplicatesAllowed, mavenDuplicateExceptionRegex } = packageSettings();
expect(findMavenDuplicatedSettings().props()).toMatchObject({
duplicatesAllowed: mavenDuplicatesAllowed,
duplicateExceptionRegex: mavenDuplicateExceptionRegex,
duplicateExceptionRegexError: '',
loading: false,
});
});
it('on update event calls the mutation', () => {
const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock());
mountComponent({ mutationResolver });
fillApolloCache();
emitMavenSettingsUpdate();
expect(mutationResolver).toHaveBeenCalledWith({
input: { mavenDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' },
});
});
});
describe('generic settings', () => {
it('exists', () => {
mountComponent();
expect(findGenericSettings().exists()).toBe(true);
});
it('assigns duplication allowness and exception props', async () => {
mountComponent();
const { genericDuplicatesAllowed, genericDuplicateExceptionRegex } = packageSettings();
expect(findGenericDuplicatedSettings().props()).toMatchObject({
duplicatesAllowed: genericDuplicatesAllowed,
duplicateExceptionRegex: genericDuplicateExceptionRegex,
duplicateExceptionRegexError: '',
loading: false,
});
});
it('on update event calls the mutation', async () => {
const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock());
mountComponent({ mutationResolver });
fillApolloCache();
findMavenDuplicatedSettings().vm.$emit('update', {
genericDuplicateExceptionRegex: ')',
});
expect(mutationResolver).toHaveBeenCalledWith({
input: { genericDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' },
});
});
});
describe('settings update', () => {
describe('success state', () => {
it('emits a success event', async () => {
mountComponent();
fillApolloCache();
emitMavenSettingsUpdate();
await waitForPromises();
expect(wrapper.emitted('success')).toEqual([[]]);
});
it('has an optimistic response', () => {
const mavenDuplicateExceptionRegex = 'latest[main]something';
mountComponent();
fillApolloCache();
expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe('');
emitMavenSettingsUpdate({ mavenDuplicateExceptionRegex });
expect(updateGroupPackagesSettingsOptimisticResponse).toHaveBeenCalledWith({
...packageSettings(),
mavenDuplicateExceptionRegex,
});
});
});
describe('errors', () => {
it('mutation payload with root level errors', async () => {
// note this is a complex test that covers all the path around errors that are shown in the form
// it's one single it case, due to the expensive preparation and execution
const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationErrorMock);
mountComponent({ mutationResolver });
fillApolloCache();
emitMavenSettingsUpdate();
await waitForPromises();
// errors are bound to the component
expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe(
groupPackageSettingsMutationErrorMock.errors[0].extensions.problems[0].message,
);
// general error message is shown
expect(wrapper.emitted('error')).toEqual([[]]);
emitMavenSettingsUpdate();
await wrapper.vm.$nextTick();
// errors are reset on mutation call
expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe('');
});
it.each`
type | mutationResolver
${'local'} | ${jest.fn().mockResolvedValue(groupPackageSettingsMutationMock({ errors: ['foo'] }))}
${'network'} | ${jest.fn().mockRejectedValue()}
`('mutation payload with $type error', async ({ mutationResolver }) => {
mountComponent({ mutationResolver });
fillApolloCache();
emitMavenSettingsUpdate();
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[]]);
});
});
});
});
export const packageSettings = () => ({
mavenDuplicatesAllowed: true,
mavenDuplicateExceptionRegex: '',
genericDuplicatesAllowed: true,
genericDuplicateExceptionRegex: '',
});
export const groupPackageSettingsMock = { export const groupPackageSettingsMock = {
data: { data: {
group: { group: {
packageSettings: { fullPath: 'foo_group_path',
mavenDuplicatesAllowed: true, packageSettings: packageSettings(),
mavenDuplicateExceptionRegex: '',
genericDuplicatesAllowed: true,
genericDuplicateExceptionRegex: '',
},
}, },
}, },
}; };
......
...@@ -24,6 +24,7 @@ RSpec.describe Gitlab::Ci::Build::AutoRetry do ...@@ -24,6 +24,7 @@ RSpec.describe Gitlab::Ci::Build::AutoRetry do
"default for scheduler failure" | 1 | {} | :scheduler_failure | true "default for scheduler failure" | 1 | {} | :scheduler_failure | true
"quota is exceeded" | 0 | { max: 2 } | :ci_quota_exceeded | false "quota is exceeded" | 0 | { max: 2 } | :ci_quota_exceeded | false
"no matching runner" | 0 | { max: 2 } | :no_matching_runner | false "no matching runner" | 0 | { max: 2 } | :no_matching_runner | false
"missing dependencies" | 0 | { max: 2 } | :missing_dependency_failure | false
end end
with_them do with_them do
......
...@@ -101,7 +101,6 @@ RSpec.describe Gitlab::Ci::Config::Entry::Retry do ...@@ -101,7 +101,6 @@ RSpec.describe Gitlab::Ci::Config::Entry::Retry do
api_failure api_failure
stuck_or_timeout_failure stuck_or_timeout_failure
runner_system_failure runner_system_failure
missing_dependency_failure
runner_unsupported runner_unsupported
stale_schedule stale_schedule
job_execution_timeout job_execution_timeout
......
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