diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-22 06:07:38 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-22 06:07:38 +0300 |
commit | 598b0e97df747c943cff27e2bdd426c959073e07 (patch) | |
tree | d43e696885c1831625a088be1a551ec7c0240b9d | |
parent | 0a692e1e299b3b5c3440c7f6eb0767b33300e34e (diff) |
Add latest changes from gitlab-org/gitlab@master
-rw-r--r-- | app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue | 183 | ||||
-rw-r--r-- | app/graphql/mutations/environments/create.rb | 51 | ||||
-rw-r--r-- | app/graphql/types/mutation_type.rb | 1 | ||||
-rw-r--r-- | app/services/environments/create_service.rb | 25 | ||||
-rw-r--r-- | app/views/layouts/fullscreen.html.haml | 2 | ||||
-rw-r--r-- | db/post_migrate/20230426030342_index_system_note_metadata_on_id_for_relate_and_unrelate_actions.rb | 19 | ||||
-rw-r--r-- | db/schema_migrations/20230426030342 | 1 | ||||
-rw-r--r-- | db/structure.sql | 2 | ||||
-rw-r--r-- | doc/api/graphql/reference/index.md | 24 | ||||
-rw-r--r-- | locale/gitlab.pot | 12 | ||||
-rw-r--r-- | spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js | 217 | ||||
-rw-r--r-- | spec/graphql/mutations/environments/create_spec.rb | 51 | ||||
-rw-r--r-- | spec/requests/api/graphql/mutations/environments/create_spec.rb | 60 | ||||
-rw-r--r-- | spec/services/environments/create_service_spec.rb | 64 |
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 |