Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-05-22 06:07:38 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-05-22 06:07:38 +0300
commit598b0e97df747c943cff27e2bdd426c959073e07 (patch)
treed43e696885c1831625a088be1a551ec7c0240b9d
parent0a692e1e299b3b5c3440c7f6eb0767b33300e34e (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue183
-rw-r--r--app/graphql/mutations/environments/create.rb51
-rw-r--r--app/graphql/types/mutation_type.rb1
-rw-r--r--app/services/environments/create_service.rb25
-rw-r--r--app/views/layouts/fullscreen.html.haml2
-rw-r--r--db/post_migrate/20230426030342_index_system_note_metadata_on_id_for_relate_and_unrelate_actions.rb19
-rw-r--r--db/schema_migrations/202304260303421
-rw-r--r--db/structure.sql2
-rw-r--r--doc/api/graphql/reference/index.md24
-rw-r--r--locale/gitlab.pot12
-rw-r--r--spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js217
-rw-r--r--spec/graphql/mutations/environments/create_spec.rb51
-rw-r--r--spec/requests/api/graphql/mutations/environments/create_spec.rb60
-rw-r--r--spec/services/environments/create_service_spec.rb64
14 files changed, 509 insertions, 203 deletions
diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
index 98193de4a12..fc37e413961 100644
--- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
+++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
@@ -1,14 +1,5 @@
<script>
-import {
- GlIcon,
- GlLoadingIcon,
- GlAvatar,
- GlDropdown,
- GlDropdownSectionHeader,
- GlDropdownItem,
- GlSearchBoxByType,
- GlTruncate,
-} from '@gitlab/ui';
+import { GlButton, GlIcon, GlAvatar, GlCollapsibleListbox, GlTruncate } from '@gitlab/ui';
import { debounce } from 'lodash';
import { filterBySearchTerm } from '~/analytics/shared/utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -18,17 +9,15 @@ import { n__, s__, __ } from '~/locale';
import getProjects from '../graphql/projects.query.graphql';
const sortByProjectName = (projects = []) => projects.sort((a, b) => a.name.localeCompare(b.name));
+const mapItemToListboxFormat = (item) => ({ ...item, value: item.id, text: item.name });
export default {
name: 'ProjectsDropdownFilter',
components: {
+ GlButton,
GlIcon,
- GlLoadingIcon,
GlAvatar,
- GlDropdown,
- GlDropdownSectionHeader,
- GlDropdownItem,
- GlSearchBoxByType,
+ GlCollapsibleListbox,
GlTruncate,
},
props: {
@@ -94,6 +83,9 @@ export default {
selectedProjectIds() {
return this.selectedProjects.map((p) => p.id);
},
+ selectedListBoxItems() {
+ return this.multiSelect ? this.selectedProjectIds : this.selectedProjectIds[0];
+ },
hasSelectedProjects() {
return Boolean(this.selectedProjects.length);
},
@@ -110,6 +102,28 @@ export default {
unselectedItems() {
return this.availableProjects.filter(({ id }) => !this.selectedProjectIds.includes(id));
},
+ selectedGroupOptions() {
+ return this.selectedItems.map(mapItemToListboxFormat);
+ },
+ unSelectedGroupOptions() {
+ return this.unselectedItems.map(mapItemToListboxFormat);
+ },
+ listBoxItems() {
+ if (this.selectedGroupOptions.length === 0) {
+ return this.unSelectedGroupOptions;
+ }
+
+ return [
+ {
+ text: __('Selected'),
+ options: this.selectedGroupOptions,
+ },
+ {
+ text: __('Unselected'),
+ options: this.unSelectedGroupOptions,
+ },
+ ];
+ },
},
watch: {
searchTerm() {
@@ -129,32 +143,29 @@ export default {
search: debounce(function debouncedSearch() {
this.fetchData();
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
- getSelectedProjects(selectedProject, isSelected) {
- return isSelected
- ? this.selectedProjects.concat([selectedProject])
- : this.selectedProjects.filter((project) => project.id !== selectedProject.id);
- },
singleSelectedProject(selectedObj, isMarking) {
return isMarking ? [selectedObj] : [];
},
- setSelectedProjects(project) {
+ setSelectedProjects(payload) {
this.selectedProjects = this.multiSelect
- ? this.getSelectedProjects(project, !this.isProjectSelected(project))
- : this.singleSelectedProject(project, !this.isProjectSelected(project));
+ ? payload
+ : this.singleSelectedProject(payload, !this.isProjectSelected(payload));
},
- onClick(project) {
+ onClick(projectId) {
+ const project = this.availableProjects.find(({ id }) => id === projectId);
this.setSelectedProjects(project);
this.handleUpdatedSelectedProjects();
},
- onMultiSelectClick(project) {
- this.setSelectedProjects(project);
+ onMultiSelectClick(projectIds) {
+ const projects = this.availableProjects.filter(({ id }) => projectIds.includes(id));
+ this.setSelectedProjects(projects);
this.isDirty = true;
},
- onSelected(project) {
+ onSelected(payload) {
if (this.multiSelect) {
- this.onMultiSelectClick(project);
+ this.onMultiSelectClick(payload);
} else {
- this.onClick(project);
+ this.onClick(payload);
}
},
onHide() {
@@ -201,97 +212,65 @@ export default {
getEntityId(project) {
return getIdFromGraphQLId(project.id);
},
+ setSearchTerm(val) {
+ this.searchTerm = val;
+ },
},
AVATAR_SHAPE_OPTION_RECT,
};
</script>
<template>
- <gl-dropdown
+ <gl-collapsible-listbox
ref="projectsDropdown"
- class="dropdown dropdown-projects"
toggle-class="gl-shadow-none gl-mb-0"
+ :header-text="__('Projects')"
+ :items="listBoxItems"
+ :reset-button-label="__('Clear All')"
:loading="loadingDefaultProjects"
- :show-clear-all="hasSelectedProjects"
- show-highlighted-items-title
- highlighted-items-title-class="gl-p-3"
- block
- @clear-all.stop="onClearAll"
- @hide="onHide"
+ :multiple="multiSelect"
+ :no-results-text="__('No matching results')"
+ :selected="selectedListBoxItems"
+ :searching="loading"
+ searchable
+ @hidden="onHide"
+ @reset="onClearAll"
+ @search="setSearchTerm"
+ @select="onSelected"
>
- <template #button-content>
- <gl-loading-icon v-if="loadingDefaultProjects" class="gl-mr-2 gl-flex-shrink-0" />
- <gl-avatar
- v-if="isOnlyOneProjectSelected"
- :src="selectedProjects[0].avatarUrl"
- :entity-id="getEntityId(selectedProjects[0])"
- :entity-name="selectedProjects[0].name"
- :size="16"
- :shape="$options.AVATAR_SHAPE_OPTION_RECT"
- :alt="selectedProjects[0].name"
- class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2 gl-flex-shrink-0"
- />
- <gl-truncate :text="selectedProjectsLabel" class="gl-min-w-0 gl-flex-grow-1" />
- <gl-icon class="gl-ml-2 gl-flex-shrink-0" name="chevron-down" />
- </template>
- <template #header>
- <gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header>
- <gl-search-box-by-type v-model.trim="searchTerm" :placeholder="__('Search')" />
- </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="$options.AVATAR_SHAPE_OPTION_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 #toggle>
+ <gl-button class="dropdown-projects">
+ <gl-avatar
+ v-if="isOnlyOneProjectSelected"
+ :src="selectedProjects[0].avatarUrl"
+ :entity-id="getEntityId(selectedProjects[0])"
+ :entity-name="selectedProjects[0].name"
+ :size="16"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ :alt="selectedProjects[0].name"
+ class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2 gl-flex-shrink-0"
+ />
+ <gl-truncate :text="selectedProjectsLabel" class="gl-min-w-0 gl-flex-grow-1" />
+ <gl-icon class="gl-ml-2 gl-flex-shrink-0" name="chevron-down" />
+ </gl-button>
</template>
- <gl-dropdown-item
- v-for="project in unselectedItems"
- :key="project.id"
- @click.native.capture.stop="onSelected(project)"
- >
+ <template #list-item="{ item }">
<div class="gl-display-flex">
<gl-avatar
- class="gl-mr-2 vertical-align-middle"
- :alt="project.name"
+ class="gl-mr-2 gl-vertical-align-middle"
+ :alt="item.name"
:size="16"
- :entity-id="getEntityId(project)"
- :entity-name="project.name"
- :src="project.avatarUrl"
+ :entity-id="getEntityId(item)"
+ :entity-name="item.name"
+ :src="item.avatarUrl"
:shape="$options.AVATAR_SHAPE_OPTION_RECT"
/>
<div>
- <div data-testid="project-name" data-qa-selector="project_name">{{ project.name }}</div>
+ <div data-testid="project-name" data-qa-selector="project_name">{{ item.name }}</div>
<div class="gl-text-gray-500" data-testid="project-full-path">
- {{ project.fullPath }}
+ {{ item.fullPath }}
</div>
</div>
</div>
- </gl-dropdown-item>
- <gl-dropdown-item v-show="noResultsAvailable" class="gl-pointer-events-none text-secondary">{{
- __('No matching results')
- }}</gl-dropdown-item>
- <gl-dropdown-item v-if="loading">
- <gl-loading-icon size="lg" />
- </gl-dropdown-item>
- </gl-dropdown>
+ </template>
+ </gl-collapsible-listbox>
</template>
diff --git a/app/graphql/mutations/environments/create.rb b/app/graphql/mutations/environments/create.rb
new file mode 100644
index 00000000000..a6386e3c0b1
--- /dev/null
+++ b/app/graphql/mutations/environments/create.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Environments
+ class Create < ::Mutations::BaseMutation
+ graphql_name 'EnvironmentCreate'
+ description 'Create an environment.'
+
+ include FindsProject
+
+ authorize :create_environment
+
+ argument :project_path,
+ GraphQL::Types::ID,
+ required: true,
+ description: 'Full path of the project.'
+
+ argument :name,
+ GraphQL::Types::String,
+ required: true,
+ description: 'Name of the environment.'
+
+ argument :external_url,
+ GraphQL::Types::String,
+ required: false,
+ description: 'External URL of the environment.'
+
+ argument :tier,
+ Types::DeploymentTierEnum,
+ required: false,
+ description: 'Tier of the environment.'
+
+ field :environment,
+ Types::EnvironmentType,
+ null: true,
+ description: 'Created environment.'
+
+ def resolve(project_path:, **kwargs)
+ project = authorized_find!(project_path)
+
+ response = ::Environments::CreateService.new(project, current_user, kwargs).execute
+
+ if response.success?
+ { environment: response.payload[:environment], errors: [] }
+ else
+ { environment: response.payload[:environment], errors: response.errors }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index da956953e00..4564c0b0c0d 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -52,6 +52,7 @@ module Types
mount_mutation Mutations::DependencyProxy::ImageTtlGroupPolicy::Update
mount_mutation Mutations::DependencyProxy::GroupSettings::Update
mount_mutation Mutations::Environments::CanaryIngress::Update
+ mount_mutation Mutations::Environments::Create
mount_mutation Mutations::Environments::Delete
mount_mutation Mutations::Environments::Stop
mount_mutation Mutations::Environments::Update
diff --git a/app/services/environments/create_service.rb b/app/services/environments/create_service.rb
new file mode 100644
index 00000000000..81fedf4b6d8
--- /dev/null
+++ b/app/services/environments/create_service.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Environments
+ class CreateService < BaseService
+ def execute
+ unless can?(current_user, :create_environment, project)
+ return ServiceResponse.error(
+ message: _('Unauthorized to create an environment'),
+ payload: { environment: nil }
+ )
+ end
+
+ environment = project.environments.create(**params)
+
+ if environment.persisted?
+ ServiceResponse.success(payload: { environment: environment })
+ else
+ ServiceResponse.error(
+ message: environment.errors.full_messages,
+ payload: { environment: nil }
+ )
+ end
+ end
+ end
+end
diff --git a/app/views/layouts/fullscreen.html.haml b/app/views/layouts/fullscreen.html.haml
index f4f9f39c20e..da192822902 100644
--- a/app/views/layouts/fullscreen.html.haml
+++ b/app/views/layouts/fullscreen.html.haml
@@ -17,7 +17,7 @@
= render "layouts/broadcast"
= yield :flash_message
= render "layouts/flash", flash_container_no_margin: true
- .content-wrapper{ id: "content-body", class: "d-flex flex-column align-items-stretch" }
+ .content-wrapper{ id: "content-body", class: "d-flex flex-column align-items-stretch gl-p-0" }
= yield
- unless minimal
= render "layouts/nav/top_nav_responsive", class: "gl-flex-grow-1 gl-overflow-y-auto"
diff --git a/db/post_migrate/20230426030342_index_system_note_metadata_on_id_for_relate_and_unrelate_actions.rb b/db/post_migrate/20230426030342_index_system_note_metadata_on_id_for_relate_and_unrelate_actions.rb
new file mode 100644
index 00000000000..2b21a25311f
--- /dev/null
+++ b/db/post_migrate/20230426030342_index_system_note_metadata_on_id_for_relate_and_unrelate_actions.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class IndexSystemNoteMetadataOnIdForRelateAndUnrelateActions < Gitlab::Database::Migration[2.1]
+ INDEX_NAME = 'tmp_index_for_backfilling_resource_link_events'
+ CONDITION = "action='relate_to_parent' OR action='unrelate_from_parent'"
+
+ disable_ddl_transaction!
+
+ def up
+ # Temporary index to be removed in https://gitlab.com/gitlab-org/gitlab/-/issues/408797
+ add_concurrent_index :system_note_metadata, :id,
+ where: CONDITION,
+ name: INDEX_NAME
+ end
+
+ def down
+ remove_concurrent_index_by_name :system_note_metadata, INDEX_NAME
+ end
+end
diff --git a/db/schema_migrations/20230426030342 b/db/schema_migrations/20230426030342
new file mode 100644
index 00000000000..08f6ed3d81a
--- /dev/null
+++ b/db/schema_migrations/20230426030342
@@ -0,0 +1 @@
+54267e34c6978456efca6c784c4e4ea7541dac98a704095a9766809725021bb8 \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index c14dd28fe31..d1132f0066f 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -33164,6 +33164,8 @@ CREATE INDEX tmp_index_container_repos_on_non_migrated ON container_repositories
CREATE INDEX tmp_index_container_repositories_on_id_migration_state ON container_repositories USING btree (id, migration_state);
+CREATE INDEX tmp_index_for_backfilling_resource_link_events ON system_note_metadata USING btree (id) WHERE (((action)::text = 'relate_to_parent'::text) OR ((action)::text = 'unrelate_from_parent'::text));
+
CREATE INDEX tmp_index_for_null_member_namespace_id ON members USING btree (member_namespace_id) WHERE (member_namespace_id IS NULL);
CREATE INDEX tmp_index_for_project_namespace_id_migration_on_routes ON routes USING btree (id) WHERE ((namespace_id IS NULL) AND ((source_type)::text = 'Project'::text));
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 6cf95675fb4..23d914eee12 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -3064,6 +3064,30 @@ Input type: `EnableDevopsAdoptionNamespaceInput`
| <a id="mutationenabledevopsadoptionnamespaceenablednamespace"></a>`enabledNamespace` | [`DevopsAdoptionEnabledNamespace`](#devopsadoptionenablednamespace) | Enabled namespace after mutation. |
| <a id="mutationenabledevopsadoptionnamespaceerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+### `Mutation.environmentCreate`
+
+Create an environment.
+
+Input type: `EnvironmentCreateInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationenvironmentcreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationenvironmentcreateexternalurl"></a>`externalUrl` | [`String`](#string) | External URL of the environment. |
+| <a id="mutationenvironmentcreatename"></a>`name` | [`String!`](#string) | Name of the environment. |
+| <a id="mutationenvironmentcreateprojectpath"></a>`projectPath` | [`ID!`](#id) | Full path of the project. |
+| <a id="mutationenvironmentcreatetier"></a>`tier` | [`DeploymentTier`](#deploymenttier) | Tier of the environment. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationenvironmentcreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationenvironmentcreateenvironment"></a>`environment` | [`Environment`](#environment) | Created environment. |
+| <a id="mutationenvironmentcreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+
### `Mutation.environmentDelete`
Delete an environment.
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 91a5ecff377..03d60628305 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -9684,6 +9684,9 @@ msgid_plural "Clear %{count} images from cache?"
msgstr[0] ""
msgstr[1] ""
+msgid "Clear All"
+msgstr ""
+
msgid "Clear all repository checks"
msgstr ""
@@ -41512,6 +41515,9 @@ msgstr ""
msgid "Select type"
msgstr ""
+msgid "Selected"
+msgstr ""
+
msgid "Selected commits"
msgstr ""
@@ -47922,6 +47928,9 @@ msgstr ""
msgid "Unauthenticated web rate limit period in seconds"
msgstr ""
+msgid "Unauthorized to create an environment"
+msgstr ""
+
msgid "Unauthorized to update the environment"
msgstr ""
@@ -48063,6 +48072,9 @@ msgstr ""
msgid "Unselect all"
msgstr ""
+msgid "Unselected"
+msgstr ""
+
msgid "Unstar"
msgstr ""
diff --git a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js
index 33801fb8552..364f0a2e372 100644
--- a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js
+++ b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js
@@ -1,7 +1,6 @@
-import { GlDropdown, GlDropdownItem, GlTruncate, GlSearchBoxByType } from '@gitlab/ui';
+import { GlButton, GlTruncate, GlCollapsibleListbox, GlListboxItem, GlAvatar } from '@gitlab/ui';
import { nextTick } from 'vue';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import { stubComponent } from 'helpers/stub_component';
+import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
@@ -28,18 +27,6 @@ const projects = [
},
];
-const MockGlDropdown = stubComponent(GlDropdown, {
- template: `
- <div>
- <slot name="header"></slot>
- <div data-testid="vsa-highlighted-items">
- <slot name="highlighted-items"></slot>
- </div>
- <div data-testid="vsa-default-items"><slot></slot></div>
- </div>
- `,
-});
-
const defaultMocks = {
$apollo: {
query: jest.fn().mockResolvedValue({
@@ -53,42 +40,36 @@ let spyQuery;
describe('ProjectsDropdownFilter component', () => {
let wrapper;
- const createComponent = (props = {}, stubs = {}) => {
+ const createComponent = ({ mountFn = shallowMountExtended, props = {}, stubs = {} } = {}) => {
spyQuery = defaultMocks.$apollo.query;
- wrapper = mountExtended(ProjectsDropdownFilter, {
+ wrapper = mountFn(ProjectsDropdownFilter, {
mocks: { ...defaultMocks },
propsData: {
groupId: 1,
groupNamespace: 'gitlab-org',
...props,
},
- stubs,
+ stubs: {
+ GlButton,
+ GlCollapsibleListbox,
+ ...stubs,
+ },
});
};
- const createWithMockDropdown = (props) => {
- createComponent(props, { GlDropdown: MockGlDropdown });
- return waitForPromises();
- };
-
- const findHighlightedItems = () => wrapper.findByTestId('vsa-highlighted-items');
- const findUnhighlightedItems = () => wrapper.findByTestId('vsa-default-items');
- const findClearAllButton = () => wrapper.findByText('Clear all');
+ const findClearAllButton = () => wrapper.findByTestId('listbox-reset-button');
const findSelectedProjectsLabel = () => wrapper.findComponent(GlTruncate);
- const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox);
- const findDropdownItems = () =>
- findDropdown()
- .findAllComponents(GlDropdownItem)
- .filter((w) => w.text() !== 'No matching results');
+ const findDropdownItems = () => findDropdown().findAllComponents(GlListboxItem);
const findDropdownAtIndex = (index) => findDropdownItems().at(index);
- const findDropdownButton = () => findDropdown().find('.dropdown-toggle');
+ const findDropdownButton = () => findDropdown().findComponent(GlButton);
const findDropdownButtonAvatar = () => findDropdown().find('.gl-avatar');
const findDropdownButtonAvatarAtIndex = (index) =>
- findDropdownAtIndex(index).find('img.gl-avatar');
+ findDropdownAtIndex(index).findComponent(GlAvatar);
const findDropdownButtonIdentIconAtIndex = (index) =>
findDropdownAtIndex(index).find('div.gl-avatar-identicon');
@@ -97,13 +78,15 @@ describe('ProjectsDropdownFilter component', () => {
const findDropdownFullPathAtIndex = (index) =>
findDropdownAtIndex(index).find('[data-testid="project-full-path"]');
- const selectDropdownItemAtIndex = async (index) => {
- findDropdownAtIndex(index).find('button').trigger('click');
+ const selectDropdownItemAtIndex = async (indexes, multi = true) => {
+ const payload = indexes.map((index) => projects[index]?.id).filter(Boolean);
+ findDropdown().vm.$emit('select', multi ? payload : payload[0]);
await nextTick();
};
// NOTE: Selected items are now visually separated from unselected items
- const findSelectedDropdownItems = () => findHighlightedItems().findAllComponents(GlDropdownItem);
+ const findSelectedDropdownItems = () =>
+ findDropdownItems().filter((component) => component.props('isSelected') === true);
const findSelectedDropdownAtIndex = (index) => findSelectedDropdownItems().at(index);
const findSelectedButtonIdentIconAtIndex = (index) =>
@@ -111,22 +94,20 @@ describe('ProjectsDropdownFilter component', () => {
const findSelectedButtonAvatarItemAtIndex = (index) =>
findSelectedDropdownAtIndex(index).find('img.gl-avatar');
- const selectedIds = () => wrapper.vm.selectedProjects.map(({ id }) => id);
-
- const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType);
-
describe('queryParams are applied when fetching data', () => {
beforeEach(() => {
createComponent({
- queryParams: {
- first: 50,
- includeSubgroups: true,
+ props: {
+ queryParams: {
+ first: 50,
+ includeSubgroups: true,
+ },
},
});
});
it('applies the correct queryParams when making an api call', async () => {
- findSearchBoxByType().vm.$emit('input', 'gitlab');
+ findDropdown().vm.$emit('search', 'gitlab');
expect(spyQuery).toHaveBeenCalledTimes(1);
@@ -147,17 +128,19 @@ describe('ProjectsDropdownFilter component', () => {
const blockDefaultProps = { multiSelect: true };
beforeEach(() => {
- createComponent(blockDefaultProps);
+ createComponent({
+ props: 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', () => {
+ expect(findSelectedDropdownItems()).toHaveLength(0);
});
it('renders the default project label text', () => {
+ createComponent({ mountFn: mountExtended, props: blockDefaultProps });
+
expect(findSelectedProjectsLabel().text()).toBe('Select projects');
});
@@ -167,18 +150,21 @@ describe('ProjectsDropdownFilter component', () => {
});
describe('with a selected project', () => {
- beforeEach(async () => {
- await selectDropdownItemAtIndex(0);
+ beforeEach(() => {
+ createComponent({
+ mountFn: mountExtended,
+ });
});
it('renders the highlighted items', async () => {
- await createWithMockDropdown(blockDefaultProps);
- await selectDropdownItemAtIndex(0);
+ await selectDropdownItemAtIndex([0], false);
- expect(findSelectedDropdownItems().length).toBe(1);
+ expect(findSelectedDropdownItems()).toHaveLength(1);
});
- it('renders the highlighted items title', () => {
+ it('renders the highlighted items title', async () => {
+ await selectDropdownItemAtIndex([0], false);
+
expect(findSelectedProjectsLabel().text()).toBe(projects[0].name);
});
@@ -187,11 +173,17 @@ describe('ProjectsDropdownFilter component', () => {
});
it('clears all selected items when the clear all button is clicked', async () => {
- await selectDropdownItemAtIndex(1);
+ createComponent({
+ mountFn: mountExtended,
+ props: { multiSelect: true },
+ });
+ await waitForPromises();
+
+ await selectDropdownItemAtIndex([0, 1]);
expect(findSelectedProjectsLabel().text()).toBe('2 projects selected');
- await findClearAllButton().trigger('click');
+ await findClearAllButton().vm.$emit('click');
expect(findSelectedProjectsLabel().text()).toBe('Select projects');
});
@@ -200,27 +192,35 @@ describe('ProjectsDropdownFilter component', () => {
describe('with a selected project and search term', () => {
beforeEach(async () => {
- await createWithMockDropdown({ multiSelect: true });
+ createComponent({
+ props: { multiSelect: true },
+ });
+ await waitForPromises();
- selectDropdownItemAtIndex(0);
- findSearchBoxByType().vm.$emit('input', 'this is a very long search string');
+ await selectDropdownItemAtIndex([0]);
+
+ findDropdown().vm.$emit('search', 'this is a very long search string');
});
it('renders the highlighted items', () => {
- expect(findUnhighlightedItems().findAll('li').length).toBe(1);
+ expect(findSelectedDropdownItems()).toHaveLength(1);
});
it('hides the unhighlighted items that do not match the string', () => {
- expect(findUnhighlightedItems().findAll('li').length).toBe(1);
- expect(findUnhighlightedItems().text()).toContain('No matching results');
+ expect(wrapper.find(`[name="Selected"]`).findAllComponents(GlListboxItem).length).toBe(1);
+ expect(wrapper.find(`[name="Unselected"]`).findAllComponents(GlListboxItem).length).toBe(0);
});
});
describe('when passed an array of defaultProject as prop', () => {
- beforeEach(() => {
+ beforeEach(async () => {
createComponent({
- defaultProjects: [projects[0]],
+ mountFn: mountExtended,
+ props: {
+ defaultProjects: [projects[0]],
+ },
});
+ await waitForPromises();
});
it("displays the defaultProject's name", () => {
@@ -232,14 +232,18 @@ describe('ProjectsDropdownFilter component', () => {
});
it('marks the defaultProject as selected', () => {
- expect(findDropdownAtIndex(0).props('isChecked')).toBe(true);
+ expect(
+ wrapper.findAll('[role="group"]').at(0).findAllComponents(GlListboxItem).at(0).text(),
+ ).toContain(projects[0].name);
});
});
describe('when multiSelect is false', () => {
const blockDefaultProps = { multiSelect: false };
beforeEach(() => {
- createComponent(blockDefaultProps);
+ createComponent({
+ props: blockDefaultProps,
+ });
});
describe('displays the correct information', () => {
@@ -248,13 +252,12 @@ describe('ProjectsDropdownFilter component', () => {
});
it('renders an avatar when the project has an avatarUrl', () => {
- expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true);
+ expect(findDropdownButtonAvatarAtIndex(0).props('src')).toBe(projects[0].avatarUrl);
expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false);
});
- it("renders an identicon when the project doesn't have an avatarUrl", () => {
- expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false);
- expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true);
+ it("does not render an avatar when the project doesn't have an avatarUrl", () => {
+ expect(findDropdownButtonAvatarAtIndex(1).props('src')).toEqual(null);
});
it('renders the project name', () => {
@@ -271,37 +274,46 @@ describe('ProjectsDropdownFilter component', () => {
});
describe('on project click', () => {
- it('should emit the "selected" event with the selected project', () => {
- selectDropdownItemAtIndex(0);
+ it('should emit the "selected" event with the selected project', async () => {
+ await selectDropdownItemAtIndex([0], false);
- expect(wrapper.emitted().selected).toEqual([[[projects[0]]]]);
+ expect(wrapper.emitted('selected')).toEqual([[[projects[0]]]]);
});
it('should change selection when new project is clicked', () => {
- selectDropdownItemAtIndex(1);
+ selectDropdownItemAtIndex([1], false);
- expect(wrapper.emitted().selected).toEqual([[[projects[1]]]]);
+ expect(wrapper.emitted('selected')).toEqual([[[projects[1]]]]);
});
- it('selection should be emptied when a project is deselected', () => {
- selectDropdownItemAtIndex(0); // Select the item
- selectDropdownItemAtIndex(0); // deselect it
+ it('selection should be emptied when a project is deselected', async () => {
+ await selectDropdownItemAtIndex([0], false); // Select the item
+ await selectDropdownItemAtIndex([0], false);
- expect(wrapper.emitted().selected).toEqual([[[projects[0]]], [[]]]);
+ expect(wrapper.emitted('selected')).toEqual([[[projects[0]]], [[]]]);
});
it('renders an avatar in the dropdown button when the project has an avatarUrl', async () => {
- await createWithMockDropdown(blockDefaultProps);
- await selectDropdownItemAtIndex(0);
+ createComponent({
+ mountFn: mountExtended,
+ props: blockDefaultProps,
+ });
+ await waitForPromises();
+
+ await selectDropdownItemAtIndex([0], false);
expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(true);
expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(false);
});
it("renders an identicon in the dropdown button when the project doesn't have an avatarUrl", async () => {
- await createWithMockDropdown(blockDefaultProps);
- await selectDropdownItemAtIndex(1);
+ createComponent({
+ mountFn: mountExtended,
+ props: blockDefaultProps,
+ });
+ await waitForPromises();
+ await selectDropdownItemAtIndex([1], false);
expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(false);
expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(true);
});
@@ -310,7 +322,9 @@ describe('ProjectsDropdownFilter component', () => {
describe('when multiSelect is true', () => {
beforeEach(() => {
- createComponent({ multiSelect: true });
+ createComponent({
+ props: { multiSelect: true },
+ });
});
describe('displays the correct information', () => {
@@ -319,13 +333,12 @@ describe('ProjectsDropdownFilter component', () => {
});
it('renders an avatar when the project has an avatarUrl', () => {
- expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true);
+ expect(findDropdownButtonAvatarAtIndex(0).props('src')).toBe(projects[0].avatarUrl);
expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false);
});
it("renders an identicon when the project doesn't have an avatarUrl", () => {
- expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false);
- expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true);
+ expect(findDropdownButtonAvatarAtIndex(1).props('src')).toEqual(null);
});
it('renders the project name', () => {
@@ -342,27 +355,31 @@ describe('ProjectsDropdownFilter component', () => {
});
describe('on project click', () => {
- it('should add to selection when new project is clicked', () => {
- selectDropdownItemAtIndex(0);
- selectDropdownItemAtIndex(1);
+ it('should add to selection when new project is clicked', async () => {
+ await selectDropdownItemAtIndex([0, 1]);
- expect(selectedIds()).toEqual([projects[0].id, projects[1].id]);
+ expect(findSelectedDropdownItems().at(0).text()).toContain(projects[1].name);
+ expect(findSelectedDropdownItems().at(1).text()).toContain(projects[0].name);
});
- it('should remove from selection when clicked again', () => {
- selectDropdownItemAtIndex(0);
+ it('should remove from selection when clicked again', async () => {
+ await selectDropdownItemAtIndex([0]);
- expect(selectedIds()).toEqual([projects[0].id]);
+ expect(findSelectedDropdownItems().at(0).text()).toContain(projects[0].name);
- selectDropdownItemAtIndex(0);
+ await selectDropdownItemAtIndex([]);
- expect(selectedIds()).toEqual([]);
+ expect(findSelectedDropdownItems()).toHaveLength(0);
});
it('renders the correct placeholder text when multiple projects are selected', async () => {
- selectDropdownItemAtIndex(0);
- selectDropdownItemAtIndex(1);
- await nextTick();
+ createComponent({
+ props: { multiSelect: true },
+ mountFn: mountExtended,
+ });
+ await waitForPromises();
+
+ await selectDropdownItemAtIndex([0, 1]);
expect(findDropdownButton().text()).toBe('2 projects selected');
});
diff --git a/spec/graphql/mutations/environments/create_spec.rb b/spec/graphql/mutations/environments/create_spec.rb
new file mode 100644
index 00000000000..8b40ecd6c81
--- /dev/null
+++ b/spec/graphql/mutations/environments/create_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Environments::Create, feature_category: :environment_management do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:maintainer) { create(:user).tap { |u| project.add_maintainer(u) } }
+ let_it_be(:reporter) { create(:user).tap { |u| project.add_reporter(u) } }
+
+ let(:user) { maintainer }
+
+ subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
+
+ describe '#resolve' do
+ subject { mutation.resolve(project_path: project.full_path, name: name, external_url: external_url) }
+
+ let(:name) { 'production' }
+ let(:external_url) { 'https://gitlab.com/' }
+
+ context 'when service execution succeeded' do
+ it 'returns no errors' do
+ expect(subject[:errors]).to be_empty
+ end
+
+ it 'creates the environment' do
+ expect(subject[:environment][:name]).to eq(name)
+ expect(subject[:environment][:external_url]).to eq(external_url)
+ end
+ end
+
+ context 'when service cannot create the attribute' do
+ let(:external_url) { 'http://${URL}' }
+
+ it 'returns an error' do
+ expect(subject)
+ .to eq({
+ environment: nil,
+ errors: ['External url URI is invalid']
+ })
+ end
+ end
+
+ context 'when user is reporter who does not have permission to access the environment' do
+ let(:user) { reporter }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/environments/create_spec.rb b/spec/requests/api/graphql/mutations/environments/create_spec.rb
new file mode 100644
index 00000000000..8a67f86dc4b
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/environments/create_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Create Environment', feature_category: :environment_management do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:developer) { create(:user).tap { |u| project.add_maintainer(u) } }
+ let_it_be(:reporter) { create(:user).tap { |u| project.add_reporter(u) } }
+
+ let(:current_user) { developer }
+
+ let(:mutation) do
+ graphql_mutation(:environment_create, input)
+ end
+
+ context 'when creating an environment' do
+ let(:input) do
+ {
+ project_path: project.full_path,
+ name: 'production',
+ external_url: 'https://gitlab.com/'
+ }
+ end
+
+ it 'creates successfully' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(graphql_mutation_response(:environment_create)['environment']['name']).to eq('production')
+ expect(graphql_mutation_response(:environment_create)['environment']['externalUrl']).to eq('https://gitlab.com/')
+ expect(graphql_mutation_response(:environment_create)['errors']).to be_empty
+ end
+
+ context 'when current user is reporter' do
+ let(:current_user) { reporter }
+
+ it 'returns error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(graphql_errors.to_s)
+ .to include("The resource that you are attempting to access does not exist or you don't have permission")
+ end
+ end
+ end
+
+ context 'when name is missing' do
+ let(:input) do
+ {
+ project_path: project.full_path
+ }
+ end
+
+ it 'returns error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(graphql_errors.to_s).to include("Expected value to not be null")
+ end
+ end
+end
diff --git a/spec/services/environments/create_service_spec.rb b/spec/services/environments/create_service_spec.rb
new file mode 100644
index 00000000000..e834e66dd9c
--- /dev/null
+++ b/spec/services/environments/create_service_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Environments::CreateService, feature_category: :environment_management do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
+ let_it_be(:reporter) { create(:user).tap { |u| project.add_reporter(u) } }
+
+ let(:service) { described_class.new(project, current_user, params) }
+ let(:current_user) { developer }
+ let(:params) { {} }
+
+ describe '#execute' do
+ subject { service.execute }
+
+ let(:params) { { name: 'production', external_url: 'https://gitlab.com', tier: :production } }
+
+ it 'creates an environment' do
+ expect { subject }.to change { ::Environment.count }.by(1)
+ end
+
+ it 'returns successful response' do
+ response = subject
+
+ expect(response).to be_success
+ expect(response.payload[:environment].name).to eq('production')
+ expect(response.payload[:environment].external_url).to eq('https://gitlab.com')
+ expect(response.payload[:environment].tier).to eq('production')
+ end
+
+ context 'when params contain invalid value' do
+ let(:params) { { name: 'production', external_url: 'http://${URL}' } }
+
+ it 'does not create an environment' do
+ expect { subject }.not_to change { ::Environment.count }
+ end
+
+ it 'returns an error' do
+ response = subject
+
+ expect(response).to be_error
+ expect(response.message).to match_array("External url URI is invalid")
+ expect(response.payload[:environment]).to be_nil
+ end
+ end
+
+ context 'when user is reporter' do
+ let(:current_user) { reporter }
+
+ it 'does not create an environment' do
+ expect { subject }.not_to change { ::Environment.count }
+ end
+
+ it 'returns an error' do
+ response = subject
+
+ expect(response).to be_error
+ expect(response.message).to eq('Unauthorized to create an environment')
+ expect(response.payload[:environment]).to be_nil
+ end
+ end
+ end
+end