Commit abc4156e authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents b7edea3f cbd41c03
<script> <script>
import { GlFormGroup, GlFormRadio, GlFormText } from '@gitlab/ui'; import { GlFormGroup, GlFormRadio, GlFormText } from '@gitlab/ui';
import ProjectsTokenSelector from './projects_token_selector.vue';
export default { export default {
name: 'ProjectsField', name: 'ProjectsField',
ALL_PROJECTS: 'ALL_PROJECTS', ALL_PROJECTS: 'ALL_PROJECTS',
SELECTED_PROJECTS: 'SELECTED_PROJECTS', SELECTED_PROJECTS: 'SELECTED_PROJECTS',
components: { GlFormGroup, GlFormRadio, GlFormText }, components: { GlFormGroup, GlFormRadio, GlFormText, ProjectsTokenSelector },
props: { props: {
inputAttrs: { inputAttrs: {
type: Object, type: Object,
...@@ -15,8 +16,24 @@ export default { ...@@ -15,8 +16,24 @@ export default {
data() { data() {
return { return {
selectedRadio: this.$options.ALL_PROJECTS, selectedRadio: this.$options.ALL_PROJECTS,
selectedProjects: [],
}; };
}, },
computed: {
allProjectsRadioSelected() {
return this.selectedRadio === this.$options.ALL_PROJECTS;
},
hiddenInputValue() {
return this.allProjectsRadioSelected
? null
: this.selectedProjects.map((project) => project.id).join(',');
},
},
methods: {
handleTokenSelectorFocus() {
this.selectedRadio = this.$options.SELECTED_PROJECTS;
},
},
}; };
</script> </script>
...@@ -32,7 +49,8 @@ export default { ...@@ -32,7 +49,8 @@ export default {
<gl-form-radio v-model="selectedRadio" :value="$options.SELECTED_PROJECTS">{{ <gl-form-radio v-model="selectedRadio" :value="$options.SELECTED_PROJECTS">{{
__('Selected projects') __('Selected projects')
}}</gl-form-radio> }}</gl-form-radio>
<input :id="inputAttrs.id" type="hidden" :name="inputAttrs.name" /> <input :id="inputAttrs.id" type="hidden" :name="inputAttrs.name" :value="hiddenInputValue" />
<projects-token-selector v-model="selectedProjects" @focus="handleTokenSelectorFocus" />
</gl-form-group> </gl-form-group>
</div> </div>
</template> </template>
<script>
import {
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
GlIntersectionObserver,
GlLoadingIcon,
} from '@gitlab/ui';
import produce from 'immer';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import getProjectsQuery from '../graphql/queries/get_projects.query.graphql';
const DEBOUNCE_DELAY = 250;
const PROJECTS_PER_PAGE = 20;
export default {
name: 'ProjectsTokenSelector',
components: {
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
GlIntersectionObserver,
GlLoadingIcon,
},
model: {
prop: 'selectedProjects',
},
props: {
selectedProjects: {
type: Array,
required: true,
},
},
apollo: {
projects: {
query: getProjectsQuery,
debounce: DEBOUNCE_DELAY,
variables() {
return {
search: this.searchQuery,
after: null,
first: PROJECTS_PER_PAGE,
};
},
update({ projects }) {
return {
list: projects.nodes.map((project) => ({
...project,
id: getIdFromGraphQLId(project.id),
})),
pageInfo: projects.pageInfo,
};
},
result() {
this.isLoadingMoreProjects = false;
this.isSearching = false;
},
},
},
data() {
return {
projects: {
list: [],
pageInfo: {},
},
searchQuery: '',
isLoadingMoreProjects: false,
isSearching: false,
};
},
methods: {
handleSearch(query) {
this.isSearching = true;
this.searchQuery = query;
},
loadMoreProjects() {
this.isLoadingMoreProjects = true;
this.$apollo.queries.projects.fetchMore({
variables: {
after: this.projects.pageInfo.endCursor,
first: PROJECTS_PER_PAGE,
},
updateQuery(previousResult, { fetchMoreResult: { projects: newProjects } }) {
const { projects: previousProjects } = previousResult;
return produce(previousResult, (draftData) => {
/* eslint-disable no-param-reassign */
draftData.projects.nodes = [...previousProjects.nodes, ...newProjects.nodes];
draftData.projects.pageInfo = newProjects.pageInfo;
/* eslint-enable no-param-reassign */
});
},
});
},
},
};
</script>
<template>
<div class="gl-relative">
<gl-token-selector
:selected-tokens="selectedProjects"
:dropdown-items="projects.list"
:loading="isSearching"
:placeholder="__('Select projects')"
menu-class="gl-w-full! gl-max-w-full!"
@input="$emit('input', $event)"
@focus="$emit('focus', $event)"
@text-input="handleSearch"
@keydown.enter.prevent
>
<template #token-content="{ token: project }">
<gl-avatar
:entity-id="project.id"
:entity-name="project.name"
:src="project.avatarUrl"
:size="16"
/>
{{ project.nameWithNamespace }}
</template>
<template #dropdown-item-content="{ dropdownItem: project }">
<gl-avatar-labeled
:entity-id="project.id"
:entity-name="project.name"
:size="32"
:src="project.avatarUrl"
:label="project.name"
:sub-label="project.nameWithNamespace"
/>
</template>
<template #dropdown-footer>
<gl-intersection-observer v-if="projects.pageInfo.hasNextPage" @appear="loadMoreProjects">
<gl-loading-icon v-if="isLoadingMoreProjects" size="md" />
</gl-intersection-observer>
</template>
</gl-token-selector>
</div>
</template>
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getProjects($search: String!, $after: String = "", $first: Int!) {
projects(
search: $search
after: $after
first: $first
membership: true
searchNamespaces: true
sort: "UPDATED_ASC"
) {
nodes {
id
name
nameWithNamespace
avatarUrl
}
pageInfo {
...PageInfo
}
}
}
import Vue from 'vue'; import Vue from 'vue';
import createFlash from '~/flash';
import { __ } from '~/locale';
import ExpiresAtField from './components/expires_at_field.vue'; import ExpiresAtField from './components/expires_at_field.vue';
...@@ -43,10 +45,28 @@ export const initProjectsField = () => { ...@@ -43,10 +45,28 @@ export const initProjectsField = () => {
const inputAttrs = getInputAttrs(el); const inputAttrs = getInputAttrs(el);
if (window.gon.features.personalAccessTokensScopedToProjects) { if (window.gon.features.personalAccessTokensScopedToProjects) {
const ProjectsField = () => import('./components/projects_field.vue'); return new Promise((resolve) => {
Promise.all([
import('./components/projects_field.vue'),
import('vue-apollo'),
import('~/lib/graphql'),
])
.then(
([
{ default: ProjectsField },
{ default: VueApollo },
{ default: createDefaultClient },
]) => {
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({ Vue.use(VueApollo);
resolve(
new Vue({
el, el,
apolloProvider,
render(h) { render(h) {
return h(ProjectsField, { return h(ProjectsField, {
props: { props: {
...@@ -54,6 +74,17 @@ export const initProjectsField = () => { ...@@ -54,6 +74,17 @@ export const initProjectsField = () => {
}, },
}); });
}, },
}),
);
},
)
.catch(() => {
createFlash({
message: __(
'An error occurred while loading the access tokens form, please try again.',
),
});
});
}); });
} }
......
...@@ -90,21 +90,13 @@ class ProjectsController < Projects::ApplicationController ...@@ -90,21 +90,13 @@ class ProjectsController < Projects::ApplicationController
# Refresh the repo in case anything changed # Refresh the repo in case anything changed
@repository = @project.repository @repository = @project.repository
respond_to do |format|
if result[:status] == :success if result[:status] == :success
flash[:notice] = _("Project '%{project_name}' was successfully updated.") % { project_name: @project.name } flash[:notice] = _("Project '%{project_name}' was successfully updated.") % { project_name: @project.name }
format.html do
redirect_to(edit_project_path(@project, anchor: 'js-general-project-settings')) redirect_to(edit_project_path(@project, anchor: 'js-general-project-settings'))
end
else else
flash[:alert] = result[:message] flash[:alert] = result[:message]
@project.reset @project.reset
render 'edit'
format.html { render_edit }
end
format.js
end end
end end
......
...@@ -191,8 +191,12 @@ class MergeRequest < ApplicationRecord ...@@ -191,8 +191,12 @@ class MergeRequest < ApplicationRecord
end end
state_machine :merge_status, initial: :unchecked do state_machine :merge_status, initial: :unchecked do
event :mark_as_preparing do
transition unchecked: :preparing
end
event :mark_as_unchecked do event :mark_as_unchecked do
transition [:can_be_merged, :checking, :unchecked] => :unchecked transition [:preparing, :can_be_merged, :checking, :unchecked] => :unchecked
transition [:cannot_be_merged, :cannot_be_merged_rechecking, :cannot_be_merged_recheck] => :cannot_be_merged_recheck transition [:cannot_be_merged, :cannot_be_merged_rechecking, :cannot_be_merged_recheck] => :cannot_be_merged_recheck
end end
...@@ -237,7 +241,7 @@ class MergeRequest < ApplicationRecord ...@@ -237,7 +241,7 @@ class MergeRequest < ApplicationRecord
# Returns current merge_status except it returns `cannot_be_merged_rechecking` as `checking` # Returns current merge_status except it returns `cannot_be_merged_rechecking` as `checking`
# to avoid exposing unnecessary internal state # to avoid exposing unnecessary internal state
def public_merge_status def public_merge_status
cannot_be_merged_rechecking? ? 'checking' : merge_status cannot_be_merged_rechecking? || preparing? ? 'checking' : merge_status
end end
validates :source_project, presence: true, unless: [:allow_broken, :importing?, :closed_or_merged_without_fork?] validates :source_project, presence: true, unless: [:allow_broken, :importing?, :closed_or_merged_without_fork?]
...@@ -1054,6 +1058,8 @@ class MergeRequest < ApplicationRecord ...@@ -1054,6 +1058,8 @@ class MergeRequest < ApplicationRecord
end end
def mergeable?(skip_ci_check: false, skip_discussions_check: false) def mergeable?(skip_ci_check: false, skip_discussions_check: false)
return false if preparing?
return false unless mergeable_state?(skip_ci_check: skip_ci_check, return false unless mergeable_state?(skip_ci_check: skip_ci_check,
skip_discussions_check: skip_discussions_check) skip_discussions_check: skip_discussions_check)
......
...@@ -3,6 +3,13 @@ ...@@ -3,6 +3,13 @@
module MergeRequests module MergeRequests
class AfterCreateService < MergeRequests::BaseService class AfterCreateService < MergeRequests::BaseService
def execute(merge_request) def execute(merge_request)
prepare_merge_request(merge_request)
merge_request.mark_as_unchecked! if merge_request.preparing?
end
private
def prepare_merge_request(merge_request)
event_service.open_mr(merge_request, current_user) event_service.open_mr(merge_request, current_user)
merge_request_activity_counter.track_create_mr_action(user: current_user) merge_request_activity_counter.track_create_mr_action(user: current_user)
notification_service.new_merge_request(merge_request, current_user) notification_service.new_merge_request(merge_request, current_user)
......
...@@ -14,6 +14,8 @@ module MergeRequests ...@@ -14,6 +14,8 @@ module MergeRequests
end end
def after_create(issuable) def after_create(issuable)
issuable.mark_as_preparing
# Add new items to MergeRequests::AfterCreateService if they can # Add new items to MergeRequests::AfterCreateService if they can
# be performed in Sidekiq # be performed in Sidekiq
NewMergeRequestWorker.perform_async(issuable.id, current_user.id) NewMergeRequestWorker.perform_async(issuable.id, current_user.id)
......
...@@ -19,7 +19,7 @@ ...@@ -19,7 +19,7 @@
%p= _('Choose visibility level, enable/disable project features and their permissions, disable email notifications, and show default award emoji.') %p= _('Choose visibility level, enable/disable project features and their permissions, disable email notifications, and show default award emoji.')
.settings-content .settings-content
= form_for @project, remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f| = form_for @project, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f|
%input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' } %input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' }
%template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data_json(@project) %template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data_json(@project)
.js-project-permissions-form .js-project-permissions-form
...@@ -36,7 +36,7 @@ ...@@ -36,7 +36,7 @@
.settings-content .settings-content
= render_if_exists 'shared/promotions/promote_mr_features' = render_if_exists 'shared/promotions/promote_mr_features'
= form_for @project, remote: true, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f| = form_for @project, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f|
%input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' } %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' }
= render 'projects/merge_request_settings', form: f = render 'projects/merge_request_settings', form: f
= f.submit _('Save changes'), class: "btn gl-button btn-success rspec-save-merge-request-changes", data: { qa_selector: 'save_merge_request_changes_button' } = f.submit _('Save changes'), class: "btn gl-button btn-success rspec-save-merge-request-changes", data: { qa_selector: 'save_merge_request_changes_button' }
......
= form_for [@project], remote: true, html: { multipart: true, class: "edit-project js-general-settings-form" }, authenticity_token: true do |f| = form_for [@project], html: { multipart: true, class: "edit-project js-general-settings-form" }, authenticity_token: true do |f|
%input{ name: 'update_section', type: 'hidden', value: 'js-general-settings' } %input{ name: 'update_section', type: 'hidden', value: 'js-general-settings' }
= form_errors(@project)
%fieldset %fieldset
.row .row
......
- if @project.valid?
:plain
location.href = "#{edit_project_path(@project, anchor: params[:update_section])}";
location.reload();
- else
:plain
$(".flash-container").html("#{escape_javascript(render('errors'))}");
$('.save-project-loader').hide();
$('.project-edit-container').show();
$('.edit-project .js-btn-success-general-project-settings').enable();
---
title: Implement new preparing internal merge_status
merge_request: 54900
author:
type: other
title: Bump auto-deploy-image tag in Deploy.latest.gitlab-ci.yml to v2.6.0, which includes changes to ciliumnetworkpolicies.
merge_request: 54983
author:
type: added
...@@ -986,6 +986,7 @@ GitLab uses [factory_bot](https://github.com/thoughtbot/factory_bot) as a test f ...@@ -986,6 +986,7 @@ GitLab uses [factory_bot](https://github.com/thoughtbot/factory_bot) as a test f
See [issue #262624](https://gitlab.com/gitlab-org/gitlab/-/issues/262624) for further context. See [issue #262624](https://gitlab.com/gitlab-org/gitlab/-/issues/262624) for further context.
- Factories don't have to be limited to `ActiveRecord` objects. - Factories don't have to be limited to `ActiveRecord` objects.
[See example](https://gitlab.com/gitlab-org/gitlab-foss/commit/0b8cefd3b2385a21cfed779bd659978c0402766d). [See example](https://gitlab.com/gitlab-org/gitlab-foss/commit/0b8cefd3b2385a21cfed779bd659978c0402766d).
- Factories and their traits should produce valid objects that are [verified by specs](https://gitlab.com/gitlab-org/gitlab/-/blob/master/spec/factories_spec.rb).
### Fixtures ### Fixtures
......
...@@ -219,7 +219,7 @@ export default { ...@@ -219,7 +219,7 @@ export default {
class="gl-px-3" class="gl-px-3"
> >
<gl-dropdown-item <gl-dropdown-item
v-for="time in $options.HOURS_IN_DAY" v-for="(_, time) in $options.HOURS_IN_DAY"
:key="time" :key="time"
:is-checked="form.startsAt.time === time" :is-checked="form.startsAt.time === time"
is-check-item is-check-item
...@@ -278,7 +278,7 @@ export default { ...@@ -278,7 +278,7 @@ export default {
class="gl-px-3" class="gl-px-3"
> >
<gl-dropdown-item <gl-dropdown-item
v-for="time in $options.HOURS_IN_DAY" v-for="(_, time) in $options.HOURS_IN_DAY"
:key="time" :key="time"
:is-checked="form.endsAt.time === time" :is-checked="form.endsAt.time === time"
is-check-item is-check-item
......
...@@ -5,8 +5,8 @@ module EE ...@@ -5,8 +5,8 @@ module EE
module AfterCreateService module AfterCreateService
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
override :execute override :prepare_merge_request
def execute(merge_request) def prepare_merge_request(merge_request)
super super
schedule_sync_for(merge_request.head_pipeline_id) schedule_sync_for(merge_request.head_pipeline_id)
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
= link_to _("Learn more."), help_page_path("user/project/merge_requests/merge_request_approvals"), target: '_blank' = link_to _("Learn more."), help_page_path("user/project/merge_requests/merge_request_approvals"), target: '_blank'
.settings-content .settings-content
= form_for @project, remote: true, html: { class: "merge-request-approval-settings-form js-mr-approvals-form" }, authenticity_token: true do |f| = form_for @project, html: { class: "merge-request-approval-settings-form js-mr-approvals-form" }, authenticity_token: true do |f|
%input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-approval-settings' } %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-approval-settings' }
= render 'projects/merge_request_approvals_settings_form', form: f, project: @project = render 'projects/merge_request_approvals_settings_form', form: f, project: @project
= f.submit _("Save changes"), class: "btn gl-button btn-success gl-mt-4", data: { qa_selector: 'save_merge_request_approval_settings_button' } = f.submit _("Save changes"), class: "btn gl-button btn-success gl-mt-4", data: { qa_selector: 'save_merge_request_approval_settings_button' }
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
%p#issue-settings-default-template-label= _('Set a default description template to be used for new issues. %{link_start}What are description templates?%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } %p#issue-settings-default-template-label= _('Set a default description template to be used for new issues. %{link_start}What are description templates?%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
.settings-content .settings-content
= form_for @project, remote: true, html: { multipart: true, class: "issue-settings-form" }, authenticity_token: true do |f| = form_for @project, html: { multipart: true, class: "issue-settings-form" }, authenticity_token: true do |f|
%input{ type: 'hidden', name: 'update_section', value: 'js-issue-settings' } %input{ type: 'hidden', name: 'update_section', value: 'js-issue-settings' }
.row .row
.form-group.col-md-9 .form-group.col-md-9
......
...@@ -112,11 +112,12 @@ describe('AddEditRotationForm', () => { ...@@ -112,11 +112,12 @@ describe('AddEditRotationForm', () => {
}); });
it('should emit an event with selected value on time selection', async () => { it('should emit an event with selected value on time selection', async () => {
findStartsOnTimeOptions().at(3).vm.$emit('click'); const option = 3;
findStartsOnTimeOptions().at(option).vm.$emit('click');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
const emittedEvent = wrapper.emitted('update-rotation-form'); const emittedEvent = wrapper.emitted('update-rotation-form');
expect(emittedEvent).toHaveLength(1); expect(emittedEvent).toHaveLength(1);
expect(emittedEvent[0][0]).toEqual({ type: 'startsAt.time', value: 4 }); expect(emittedEvent[0][0]).toEqual({ type: 'startsAt.time', value: option });
}); });
it('should add a checkmark to a selected start time', async () => { it('should add a checkmark to a selected start time', async () => {
...@@ -133,11 +134,7 @@ describe('AddEditRotationForm', () => { ...@@ -133,11 +134,7 @@ describe('AddEditRotationForm', () => {
}, },
}); });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect( expect(findStartsOnTimeOptions().at(time).props('isChecked')).toBe(true);
findStartsOnTimeOptions()
.at(time - 1)
.props('isChecked'),
).toBe(true);
}); });
}); });
...@@ -160,7 +157,7 @@ describe('AddEditRotationForm', () => { ...@@ -160,7 +157,7 @@ describe('AddEditRotationForm', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
const emittedEvent = wrapper.emitted('update-rotation-form'); const emittedEvent = wrapper.emitted('update-rotation-form');
expect(emittedEvent).toHaveLength(1); expect(emittedEvent).toHaveLength(1);
expect(emittedEvent[0][0]).toEqual({ type: 'endsAt.time', value: option + 1 }); expect(emittedEvent[0][0]).toEqual({ type: 'endsAt.time', value: option });
}); });
it('should add a checkmark to a selected end time', async () => { it('should add a checkmark to a selected end time', async () => {
...@@ -181,11 +178,7 @@ describe('AddEditRotationForm', () => { ...@@ -181,11 +178,7 @@ describe('AddEditRotationForm', () => {
}, },
}); });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect( expect(findEndsOnTimeOptions().at(time).props('isChecked')).toBe(true);
findEndsOnTimeOptions()
.at(time - 1)
.props('isChecked'),
).toBe(true);
}); });
}); });
......
...@@ -11,13 +11,5 @@ module Bitbucket ...@@ -11,13 +11,5 @@ module Bitbucket
lazy lazy
end end
def method_missing(method, *args)
return super unless self.respond_to?(method)
self.__send__(method, *args) do |item| # rubocop:disable GitlabSecurity/PublicSend
block_given? ? yield(item) : item
end
end
end end
end end
...@@ -35,13 +35,5 @@ module BitbucketServer ...@@ -35,13 +35,5 @@ module BitbucketServer
current_page + 1 current_page + 1
end end
def method_missing(method, *args)
return super unless self.respond_to?(method)
self.__send__(method, *args) do |item| # rubocop:disable GitlabSecurity/PublicSend
block_given? ? yield(item) : item
end
end
end end
end end
.auto-deploy: .auto-deploy:
image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v2.0.0" image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v2.6.0"
dependencies: [] dependencies: []
review: review:
......
...@@ -3467,6 +3467,9 @@ msgstr "" ...@@ -3467,6 +3467,9 @@ msgstr ""
msgid "An error occurred while loading project creation UI" msgid "An error occurred while loading project creation UI"
msgstr "" msgstr ""
msgid "An error occurred while loading the access tokens form, please try again."
msgstr ""
msgid "An error occurred while loading the data. Please try again." msgid "An error occurred while loading the data. Please try again."
msgstr "" msgstr ""
......
...@@ -5,6 +5,10 @@ require 'spec_helper' ...@@ -5,6 +5,10 @@ require 'spec_helper'
RSpec.describe 'factories' do RSpec.describe 'factories' do
include Database::DatabaseHelpers include Database::DatabaseHelpers
# https://gitlab.com/groups/gitlab-org/-/epics/5464 tracks the remaining
# skipped traits.
#
# Consider adding a code comment if a trait cannot produce a valid object.
def skipped_traits def skipped_traits
[ [
[:audit_event, :unauthenticated], [:audit_event, :unauthenticated],
......
import { within } from '@testing-library/dom'; import { within, fireEvent } from '@testing-library/dom';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import ProjectsField from '~/access_tokens/components/projects_field.vue'; import ProjectsField from '~/access_tokens/components/projects_field.vue';
import ProjectsTokenSelector from '~/access_tokens/components/projects_token_selector.vue';
describe('ProjectsField', () => { describe('ProjectsField', () => {
let wrapper; let wrapper;
...@@ -18,6 +19,10 @@ describe('ProjectsField', () => { ...@@ -18,6 +19,10 @@ describe('ProjectsField', () => {
const queryByLabelText = (text) => within(wrapper.element).queryByLabelText(text); const queryByLabelText = (text) => within(wrapper.element).queryByLabelText(text);
const queryByText = (text) => within(wrapper.element).queryByText(text); const queryByText = (text) => within(wrapper.element).queryByText(text);
const findAllProjectsRadio = () => queryByLabelText('All projects');
const findSelectedProjectsRadio = () => queryByLabelText('Selected projects');
const findProjectsTokenSelector = () => wrapper.findComponent(ProjectsTokenSelector);
const findHiddenInput = () => wrapper.find('input[type="hidden"]');
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
...@@ -34,25 +39,66 @@ describe('ProjectsField', () => { ...@@ -34,25 +39,66 @@ describe('ProjectsField', () => {
}); });
it('renders "All projects" radio selected by default', () => { it('renders "All projects" radio selected by default', () => {
const allProjectsRadio = queryByLabelText('All projects'); const allProjectsRadio = findAllProjectsRadio();
expect(allProjectsRadio).not.toBe(null); expect(allProjectsRadio).not.toBe(null);
expect(allProjectsRadio.checked).toBe(true); expect(allProjectsRadio.checked).toBe(true);
}); });
it('renders "Selected projects" radio unchecked by default', () => { it('renders "Selected projects" radio unchecked by default', () => {
const selectedProjectsRadio = queryByLabelText('Selected projects'); const selectedProjectsRadio = findSelectedProjectsRadio();
expect(selectedProjectsRadio).not.toBe(null); expect(selectedProjectsRadio).not.toBe(null);
expect(selectedProjectsRadio.checked).toBe(false); expect(selectedProjectsRadio.checked).toBe(false);
}); });
it('renders `projects-token-selector` component', () => {
expect(findProjectsTokenSelector().exists()).toBe(true);
});
it('renders hidden input with correct `name` and `id` attributes', () => { it('renders hidden input with correct `name` and `id` attributes', () => {
expect(wrapper.find('input[type="hidden"]').attributes()).toEqual( expect(findHiddenInput().attributes()).toEqual(
expect.objectContaining({ expect.objectContaining({
id: 'projects', id: 'projects',
name: 'projects', name: 'projects',
}), }),
); );
}); });
describe('when `projects-token-selector` is focused', () => {
beforeEach(() => {
findProjectsTokenSelector().vm.$emit('focus');
});
it('auto selects the "Selected projects" radio', () => {
expect(findSelectedProjectsRadio().checked).toBe(true);
});
describe('when `projects-token-selector` is changed', () => {
beforeEach(() => {
findProjectsTokenSelector().vm.$emit('input', [
{
id: 1,
},
{
id: 2,
},
]);
});
it('updates the hidden input value to a comma separated list of project IDs', () => {
expect(findHiddenInput().attributes('value')).toBe('1,2');
});
describe('when radio is changed back to "All projects"', () => {
beforeEach(() => {
fireEvent.click(findAllProjectsRadio());
});
it('removes the hidden input value', () => {
expect(findHiddenInput().attributes('value')).toBe('');
});
});
});
});
}); });
import {
GlAvatar,
GlAvatarLabeled,
GlIntersectionObserver,
GlToken,
GlTokenSelector,
GlLoadingIcon,
} from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { getJSONFixture } from 'helpers/fixtures';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ProjectsTokenSelector from '~/access_tokens/components/projects_token_selector.vue';
import getProjectsQuery from '~/access_tokens/graphql/queries/get_projects.query.graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
describe('ProjectsTokenSelector', () => {
const getProjectsQueryResponse = getJSONFixture(
'graphql/projects/access_tokens/get_projects.query.graphql.json',
);
const getProjectsQueryResponsePage2 = produce(
getProjectsQueryResponse,
(getProjectsQueryResponseDraft) => {
/* eslint-disable no-param-reassign */
getProjectsQueryResponseDraft.data.projects.pageInfo.hasNextPage = false;
getProjectsQueryResponseDraft.data.projects.pageInfo.endCursor = null;
getProjectsQueryResponseDraft.data.projects.nodes.splice(1, 1);
getProjectsQueryResponseDraft.data.projects.nodes[0].id = 'gid://gitlab/Project/100';
/* eslint-enable no-param-reassign */
},
);
const runDebounce = () => jest.runAllTimers();
const { pageInfo, nodes: projects } = getProjectsQueryResponse.data.projects;
const project1 = projects[0];
const project2 = projects[1];
let wrapper;
let resolveGetProjectsQuery;
const getProjectsQueryRequestHandler = jest.fn(
() =>
new Promise((resolve) => {
resolveGetProjectsQuery = resolve;
}),
);
const createComponent = ({
propsData = {},
apolloProvider = createMockApollo([[getProjectsQuery, getProjectsQueryRequestHandler]]),
resolveQueries = true,
} = {}) => {
Vue.use(VueApollo);
wrapper = extendedWrapper(
mount(ProjectsTokenSelector, {
apolloProvider,
propsData: {
selectedProjects: [],
...propsData,
},
stubs: ['gl-intersection-observer'],
}),
);
runDebounce();
if (resolveQueries) {
resolveGetProjectsQuery(getProjectsQueryResponse);
return waitForPromises();
}
return Promise.resolve();
};
const findTokenSelector = () => wrapper.findComponent(GlTokenSelector);
const findTokenSelectorInput = () => findTokenSelector().find('input[type="text"]');
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
it('renders dropdown items with project avatars', async () => {
await createComponent();
wrapper.findAllComponents(GlAvatarLabeled).wrappers.forEach((avatarLabeledWrapper, index) => {
const project = projects[index];
expect(avatarLabeledWrapper.attributes()).toEqual(
expect.objectContaining({
'entity-id': `${getIdFromGraphQLId(project.id)}`,
'entity-name': project.name,
...(project.avatarUrl && { src: project.avatarUrl }),
}),
);
expect(avatarLabeledWrapper.props()).toEqual(
expect.objectContaining({
label: project.name,
subLabel: project.nameWithNamespace,
}),
);
});
});
it('renders tokens with project avatars', () => {
createComponent({
propsData: {
selectedProjects: [{ ...project2, id: getIdFromGraphQLId(project2.id) }],
},
});
const token = wrapper.findComponent(GlToken);
const avatar = token.findComponent(GlAvatar);
expect(token.text()).toContain(project2.nameWithNamespace);
expect(avatar.attributes('src')).toBe(project2.avatarUrl);
expect(avatar.props()).toEqual(
expect.objectContaining({
entityId: getIdFromGraphQLId(project2.id),
entityName: project2.name,
}),
);
});
describe('when `enter` key is pressed', () => {
it('calls `preventDefault` so form is not submitted when user selects a project from the dropdown', () => {
createComponent();
const event = {
preventDefault: jest.fn(),
};
findTokenSelectorInput().trigger('keydown.enter', event);
expect(event.preventDefault).toHaveBeenCalled();
});
});
describe('when text input is typed in', () => {
const searchTerm = 'foo bar';
beforeEach(async () => {
await createComponent();
await findTokenSelectorInput().setValue(searchTerm);
runDebounce();
});
it('makes GraphQL request with `search` variable set', async () => {
expect(getProjectsQueryRequestHandler).toHaveBeenLastCalledWith({
search: searchTerm,
after: null,
first: 20,
});
});
it('sets loading state while waiting for GraphQL request to resolve', async () => {
expect(findTokenSelector().props('loading')).toBe(true);
resolveGetProjectsQuery(getProjectsQueryResponse);
await waitForPromises();
expect(findTokenSelector().props('loading')).toBe(false);
});
});
describe('when there is a next page of projects and user scrolls to the bottom of the dropdown', () => {
beforeEach(async () => {
await createComponent();
findIntersectionObserver().vm.$emit('appear');
});
it('makes GraphQL request with `after` variable set', async () => {
expect(getProjectsQueryRequestHandler).toHaveBeenLastCalledWith({
after: pageInfo.endCursor,
first: 20,
search: '',
});
});
it('displays loading icon while waiting for GraphQL request to resolve', async () => {
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
resolveGetProjectsQuery(getProjectsQueryResponsePage2);
await waitForPromises();
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false);
});
});
describe('when there is not a next page of projects', () => {
it('does not render `GlIntersectionObserver`', async () => {
createComponent({ resolveQueries: false });
resolveGetProjectsQuery(getProjectsQueryResponsePage2);
await waitForPromises();
expect(findIntersectionObserver().exists()).toBe(false);
});
});
describe('when `GlTokenSelector` emits `input` event', () => {
it('emits `input` event used by `v-model`', () => {
findTokenSelector().vm.$emit('input', project1);
expect(wrapper.emitted('input')[0]).toEqual([project1]);
});
});
describe('when `GlTokenSelector` emits `focus` event', () => {
it('emits `focus` event', () => {
const event = { fakeEvent: 'foo' };
findTokenSelector().vm.$emit('focus', event);
expect(wrapper.emitted('focus')[0]).toEqual([event]);
});
});
});
import { createWrapper } from '@vue/test-utils'; import { createWrapper } from '@vue/test-utils';
import Vue from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
import { initExpiresAtField, initProjectsField } from '~/access_tokens'; import { initExpiresAtField, initProjectsField } from '~/access_tokens';
import ExpiresAtField from '~/access_tokens/components/expires_at_field.vue'; import * as ExpiresAtField from '~/access_tokens/components/expires_at_field.vue';
import ProjectsField from '~/access_tokens/components/projects_field.vue'; import * as ProjectsField from '~/access_tokens/components/projects_field.vue';
describe('access tokens', () => { describe('access tokens', () => {
const FakeComponent = Vue.component('FakeComponent', {
props: {
inputAttrs: {
type: Object,
required: true,
},
},
render: () => null,
});
beforeEach(() => { beforeEach(() => {
window.gon = { features: { personalAccessTokensScopedToProjects: true } }; window.gon = { features: { personalAccessTokensScopedToProjects: true } };
}); });
afterEach(() => { afterEach(() => {
document.body.innerHTML = ''; document.body.innerHTML = '';
window.gon = {};
}); });
describe.each` describe.each`
...@@ -34,15 +42,17 @@ describe('access tokens', () => { ...@@ -34,15 +42,17 @@ describe('access tokens', () => {
mountEl.appendChild(input); mountEl.appendChild(input);
document.body.appendChild(mountEl); document.body.appendChild(mountEl);
});
it(`mounts component and sets \`inputAttrs\` prop`, async () => { // Mock component so we don't have to deal with mocking Apollo
const wrapper = createWrapper(initFunction()); // eslint-disable-next-line no-param-reassign
expectedComponent.default = FakeComponent;
});
// Wait for dynamic imports to resolve it('mounts component and sets `inputAttrs` prop', async () => {
await waitForPromises(); const vueInstance = await initFunction();
const component = wrapper.findComponent(expectedComponent); const wrapper = createWrapper(vueInstance);
const component = wrapper.findComponent(FakeComponent);
expect(component.exists()).toBe(true); expect(component.exists()).toBe(true);
expect(component.props('inputAttrs')).toEqual({ expect(component.props('inputAttrs')).toEqual({
......
...@@ -3,13 +3,14 @@ ...@@ -3,13 +3,14 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
include ApiHelpers
include JavaScriptFixturesHelpers include JavaScriptFixturesHelpers
runners_token = 'runnerstoken:intabulasreferre' runners_token = 'runnerstoken:intabulasreferre'
let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
let(:project) { create(:project, namespace: namespace, path: 'builds-project', runners_token: runners_token) } let(:project) { create(:project, namespace: namespace, path: 'builds-project', runners_token: runners_token, avatar: fixture_file_upload('spec/fixtures/dk.png', 'image/png')) }
let(:project_with_repo) { create(:project, :repository, description: 'Code and stuff') } let(:project_with_repo) { create(:project, :repository, description: 'Code and stuff', avatar: fixture_file_upload('spec/fixtures/dk.png', 'image/png')) }
let(:project_variable_populated) { create(:project, namespace: namespace, path: 'builds-project2', runners_token: runners_token) } let(:project_variable_populated) { create(:project, namespace: namespace, path: 'builds-project2', runners_token: runners_token) }
let(:user) { project.owner } let(:user) { project.owner }
...@@ -22,7 +23,6 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do ...@@ -22,7 +23,6 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
before do before do
project_with_repo.add_maintainer(user) project_with_repo.add_maintainer(user)
sign_in(user) sign_in(user)
allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon')
end end
after do after do
...@@ -48,4 +48,31 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do ...@@ -48,4 +48,31 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
expect(response).to be_successful expect(response).to be_successful
end end
end end
describe GraphQL::Query, type: :request do
include GraphqlHelpers
context 'access token projects query' do
before do
project_variable_populated.add_maintainer(user)
end
before(:all) do
clean_frontend_fixtures('graphql/projects/access_tokens')
end
fragment_paths = ['graphql_shared/fragments/pageInfo.fragment.graphql']
base_input_path = 'access_tokens/graphql/queries/'
base_output_path = 'graphql/projects/access_tokens/'
query_name = 'get_projects.query.graphql'
it "#{base_output_path}#{query_name}.json" do
query = get_graphql_query_as_string("#{base_input_path}#{query_name}", fragment_paths)
post_graphql(query, current_user: user, variables: { search: '', first: 2 })
expect_graphql_errors_to_be_empty
end
end
end
end end
...@@ -2885,6 +2885,11 @@ RSpec.describe MergeRequest, factory_default: :keep do ...@@ -2885,6 +2885,11 @@ RSpec.describe MergeRequest, factory_default: :keep do
describe '#mergeable?' do describe '#mergeable?' do
subject { build_stubbed(:merge_request) } subject { build_stubbed(:merge_request) }
it 'returns false if still preparing' do
expect(subject).to receive(:preparing?) { true }
expect(subject.mergeable?).to be_falsey
end
it 'returns false if #mergeable_state? is false' do it 'returns false if #mergeable_state? is false' do
expect(subject).to receive(:mergeable_state?) { false } expect(subject).to receive(:mergeable_state?) { false }
...@@ -3075,6 +3080,7 @@ RSpec.describe MergeRequest, factory_default: :keep do ...@@ -3075,6 +3080,7 @@ RSpec.describe MergeRequest, factory_default: :keep do
subject { build(:merge_request, merge_status: status) } subject { build(:merge_request, merge_status: status) }
where(:status, :public_status) do where(:status, :public_status) do
'preparing' | 'checking'
'cannot_be_merged_rechecking' | 'checking' 'cannot_be_merged_rechecking' | 'checking'
'checking' | 'checking' 'checking' | 'checking'
'cannot_be_merged' | 'cannot_be_merged' 'cannot_be_merged' | 'cannot_be_merged'
......
...@@ -67,5 +67,27 @@ RSpec.describe MergeRequests::AfterCreateService do ...@@ -67,5 +67,27 @@ RSpec.describe MergeRequests::AfterCreateService do
it_behaves_like 'records an onboarding progress action', :merge_request_created do it_behaves_like 'records an onboarding progress action', :merge_request_created do
let(:namespace) { merge_request.target_project.namespace } let(:namespace) { merge_request.target_project.namespace }
end end
context 'when merge request is in unchecked state' do
before do
merge_request.mark_as_unchecked!
execute_service
end
it 'does not change its state' do
expect(merge_request.reload).to be_unchecked
end
end
context 'when merge request is in preparing state' do
before do
merge_request.mark_as_preparing!
execute_service
end
it 'marks the merge request as unchecked' do
expect(merge_request.reload).to be_unchecked
end
end
end end
end end
...@@ -67,6 +67,10 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do ...@@ -67,6 +67,10 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
expect(Event.where(attributes).count).to eq(1) expect(Event.where(attributes).count).to eq(1)
end end
it 'sets the merge_status to preparing' do
expect(merge_request.reload).to be_preparing
end
describe 'when marked with /wip' do describe 'when marked with /wip' do
context 'in title and in description' do context 'in title and in description' do
let(:opts) do let(:opts) do
......
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