Commit 983e8506 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch '2647-frontend-components' into 'master'

Create branch from Jira issue MVC 1: add supporting Vue components

See merge request gitlab-org/gitlab!66034
parents 61412975 c3851700
<script>
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import { PROJECTS_PER_PAGE } from '../constants';
import getProjectsQuery from '../graphql/queries/get_projects.query.graphql';
export default {
PROJECTS_PER_PAGE,
projectQueryPageInfo: {
endCursor: '',
},
components: {
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
GlLoadingIcon,
},
props: {
selectedProject: {
type: Object,
required: false,
default: null,
},
},
data() {
return {
initialProjectsLoading: true,
projectSearchQuery: '',
};
},
apollo: {
projects: {
query: getProjectsQuery,
variables() {
return {
search: this.projectSearchQuery,
first: this.$options.PROJECTS_PER_PAGE,
after: this.$options.projectQueryPageInfo.endCursor,
searchNamespaces: true,
sort: 'similarity',
};
},
update(data) {
return data?.projects?.nodes.filter((project) => !project.repository.empty) ?? [];
},
result() {
this.initialProjectsLoading = false;
},
error() {
this.onError({ message: __('Failed to load projects') });
},
},
},
computed: {
projectsLoading() {
return Boolean(this.$apollo.queries.projects.loading);
},
projectDropdownText() {
return this.selectedProject?.nameWithNamespace || __('Select a project');
},
},
methods: {
async onProjectSelect(project) {
this.$emit('change', project);
},
onError({ message } = {}) {
this.$emit('error', { message });
},
isProjectSelected(project) {
return project.id === this.selectedProject?.id;
},
},
};
</script>
<template>
<gl-dropdown :text="projectDropdownText" :loading="initialProjectsLoading">
<template #header>
<gl-search-box-by-type v-model.trim="projectSearchQuery" :debounce="250" />
</template>
<gl-loading-icon v-show="projectsLoading" />
<template v-if="!projectsLoading">
<gl-dropdown-item
v-for="project in projects"
:key="project.id"
is-check-item
:is-checked="isProjectSelected(project)"
@click="onProjectSelect(project)"
>
{{ project.nameWithNamespace }}
</gl-dropdown-item>
</template>
</gl-dropdown>
</template>
<script>
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import { BRANCHES_PER_PAGE } from '../constants';
import getProjectQuery from '../graphql/queries/get_project.query.graphql';
export default {
BRANCHES_PER_PAGE,
components: {
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
GlLoadingIcon,
},
props: {
selectedProject: {
type: Object,
required: false,
default: null,
},
selectedBranchName: {
type: String,
required: false,
default: null,
},
},
data() {
return {
sourceBranchSearchQuery: '',
initialSourceBranchNamesLoading: false,
sourceBranchNamesLoading: false,
sourceBranchNames: [],
};
},
computed: {
hasSelectedProject() {
return Boolean(this.selectedProject);
},
hasSelectedSourceBranch() {
return Boolean(this.selectedBranchName);
},
branchDropdownText() {
return this.selectedBranchName || __('Select a branch');
},
},
watch: {
selectedProject: {
immediate: true,
async handler(selectedProject) {
if (!selectedProject) return;
this.initialSourceBranchNamesLoading = true;
await this.fetchSourceBranchNames({ projectPath: selectedProject.fullPath });
this.initialSourceBranchNamesLoading = false;
},
},
},
methods: {
onSourceBranchSelect(branchName) {
this.$emit('change', branchName);
},
onSourceBranchSearchQuery(branchSearchQuery) {
this.branchSearchQuery = branchSearchQuery;
this.fetchSourceBranchNames({
projectPath: this.selectedProject.fullPath,
searchPattern: this.branchSearchQuery,
});
},
onError({ message } = {}) {
this.$emit('error', { message });
},
async fetchSourceBranchNames({ projectPath, searchPattern } = {}) {
this.sourceBranchNamesLoading = true;
try {
const { data } = await this.$apollo.query({
query: getProjectQuery,
variables: {
projectPath,
branchNamesLimit: this.$options.BRANCHES_PER_PAGE,
branchNamesOffset: 0,
branchNamesSearchPattern: searchPattern ? `*${searchPattern}*` : '*',
},
});
const { branchNames, rootRef } = data?.project.repository || {};
this.sourceBranchNames = branchNames || [];
// Use root ref as the default selection
if (rootRef && !this.hasSelectedSourceBranch) {
this.onSourceBranchSelect(rootRef);
}
} catch (err) {
this.onError({
message: __('Something went wrong while fetching source branches.'),
});
} finally {
this.sourceBranchNamesLoading = false;
}
},
},
};
</script>
<template>
<gl-dropdown
:text="branchDropdownText"
:loading="initialSourceBranchNamesLoading"
:disabled="!hasSelectedProject"
:class="{ 'gl-font-monospace': hasSelectedSourceBranch }"
>
<template #header>
<gl-search-box-by-type
:debounce="250"
:value="sourceBranchSearchQuery"
@input="onSourceBranchSearchQuery"
/>
</template>
<gl-loading-icon v-show="sourceBranchNamesLoading" />
<template v-if="!sourceBranchNamesLoading">
<gl-dropdown-item
v-for="branchName in sourceBranchNames"
v-show="!sourceBranchNamesLoading"
:key="branchName"
:is-checked="branchName === selectedBranchName"
is-check-item
class="gl-font-monospace"
@click="onSourceBranchSelect(branchName)"
>
{{ branchName }}
</gl-dropdown-item>
</template>
</gl-dropdown>
</template>
export const BRANCHES_PER_PAGE = 20;
export const PROJECTS_PER_PAGE = 20;
query getProject(
$projectPath: ID!
$branchNamesLimit: Int!
$branchNamesOffset: Int!
$branchNamesSearchPattern: String!
) {
project(fullPath: $projectPath) {
repository {
branchNames(
limit: $branchNamesLimit
offset: $branchNamesOffset
searchPattern: $branchNamesSearchPattern
)
rootRef
}
}
}
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getProjects(
$search: String!
$after: String = ""
$first: Int!
$searchNamespaces: Boolean = false
$sort: String
$membership: Boolean = true
) {
projects(
search: $search
after: $after
first: $first
membership: $membership
searchNamespaces: $searchNamespaces
sort: $sort
) {
nodes {
id
name
nameWithNamespace
fullPath
avatarUrl
path
repository {
empty
}
}
pageInfo {
...PageInfo
}
}
}
...@@ -30603,6 +30603,9 @@ msgstr "" ...@@ -30603,6 +30603,9 @@ msgstr ""
msgid "Something went wrong while fetching requirements list." msgid "Something went wrong while fetching requirements list."
msgstr "" msgstr ""
msgid "Something went wrong while fetching source branches."
msgstr ""
msgid "Something went wrong while fetching the environments for this merge request. Please try again." msgid "Something went wrong while fetching the environments for this merge request. Please try again."
msgstr "" msgstr ""
......
import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ProjectDropdown from '~/jira_connect/branches/components/project_dropdown.vue';
import { PROJECTS_PER_PAGE } from '~/jira_connect/branches/constants';
import getProjectsQuery from '~/jira_connect/branches/graphql/queries/get_projects.query.graphql';
const localVue = createLocalVue();
const mockProjects = [
{
id: 'test',
name: 'test',
nameWithNamespace: 'test',
avatarUrl: 'https://gitlab.com',
path: 'test-path',
fullPath: 'test-path',
repository: {
empty: false,
},
},
{
id: 'gitlab',
name: 'GitLab',
nameWithNamespace: 'gitlab-org/gitlab',
avatarUrl: 'https://gitlab.com',
path: 'gitlab',
fullPath: 'gitlab-org/gitlab',
repository: {
empty: false,
},
},
];
const mockProjectsQueryResponse = {
data: {
projects: {
nodes: mockProjects,
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
endCursor: '',
},
},
},
};
const mockGetProjectsQuerySuccess = jest.fn().mockResolvedValue(mockProjectsQueryResponse);
const mockGetProjectsQueryFailed = jest.fn().mockRejectedValue(new Error('GraphQL error'));
const mockQueryLoading = jest.fn().mockReturnValue(new Promise(() => {}));
describe('ProjectDropdown', () => {
let wrapper;
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findDropdownItemByText = (text) =>
findAllDropdownItems().wrappers.find((item) => item.text() === text);
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
function createMockApolloProvider({ mockGetProjectsQuery = mockGetProjectsQuerySuccess } = {}) {
localVue.use(VueApollo);
const mockApollo = createMockApollo([[getProjectsQuery, mockGetProjectsQuery]]);
return mockApollo;
}
function createComponent({ mockApollo, props, mountFn = shallowMount } = {}) {
wrapper = mountFn(ProjectDropdown, {
localVue,
apolloProvider: mockApollo || createMockApolloProvider(),
propsData: props,
});
}
afterEach(() => {
wrapper.destroy();
});
describe('when loading projects', () => {
beforeEach(() => {
createComponent({
mockApollo: createMockApolloProvider({ mockGetProjectsQuery: mockQueryLoading }),
});
});
it('sets dropdown `loading` prop to `true`', () => {
expect(findDropdown().props('loading')).toBe(true);
});
it('renders loading icon in dropdown', () => {
expect(findLoadingIcon().isVisible()).toBe(true);
});
});
describe('when projects query succeeds', () => {
beforeEach(async () => {
createComponent();
await waitForPromises();
await wrapper.vm.$nextTick();
});
it('sets dropdown `loading` prop to `false`', () => {
expect(findDropdown().props('loading')).toBe(false);
});
it('renders dropdown items', () => {
const dropdownItems = findAllDropdownItems();
expect(dropdownItems.wrappers).toHaveLength(mockProjects.length);
expect(dropdownItems.wrappers.map((item) => item.text())).toEqual(
mockProjects.map((project) => project.nameWithNamespace),
);
});
describe('when selecting a dropdown item', () => {
it('emits `change` event with the selected project name', async () => {
const mockProject = mockProjects[0];
const itemToSelect = findDropdownItemByText(mockProject.nameWithNamespace);
await itemToSelect.vm.$emit('click');
expect(wrapper.emitted('change')[0]).toEqual([mockProject]);
});
});
describe('when `selectedProject` prop is specified', () => {
const mockProject = mockProjects[0];
beforeEach(async () => {
wrapper.setProps({
selectedProject: mockProject,
});
});
it('sets `isChecked` prop of the corresponding dropdown item to `true`', () => {
expect(findDropdownItemByText(mockProject.nameWithNamespace).props('isChecked')).toBe(true);
});
it('sets dropdown text to `selectedBranchName` value', () => {
expect(findDropdown().props('text')).toBe(mockProject.nameWithNamespace);
});
});
});
describe('when projects query fails', () => {
beforeEach(async () => {
createComponent({
mockApollo: createMockApolloProvider({ mockGetProjectsQuery: mockGetProjectsQueryFailed }),
});
await waitForPromises();
});
it('emits `error` event', () => {
expect(wrapper.emitted('error')).toBeTruthy();
});
});
describe('when searching branches', () => {
it('triggers a refetch', async () => {
createComponent({ mountFn: mount });
await waitForPromises();
jest.clearAllMocks();
const mockSearchTerm = 'gitl';
await findSearchBox().vm.$emit('input', mockSearchTerm);
expect(mockGetProjectsQuerySuccess).toHaveBeenCalledWith({
after: '',
first: PROJECTS_PER_PAGE,
membership: true,
search: mockSearchTerm,
searchNamespaces: true,
sort: 'similarity',
});
});
});
});
import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import SourceBranchDropdown from '~/jira_connect/branches/components/source_branch_dropdown.vue';
import { BRANCHES_PER_PAGE } from '~/jira_connect/branches/constants';
import getProjectQuery from '~/jira_connect/branches/graphql/queries/get_project.query.graphql';
const localVue = createLocalVue();
const mockProject = {
id: 'test',
fullPath: 'test-path',
repository: {
branchNames: ['main', 'f-test', 'release'],
rootRef: 'main',
},
};
const mockProjectQueryResponse = {
data: {
project: mockProject,
},
};
const mockGetProjectQuery = jest.fn().mockResolvedValue(mockProjectQueryResponse);
const mockQueryLoading = jest.fn().mockReturnValue(new Promise(() => {}));
describe('SourceBranchDropdown', () => {
let wrapper;
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findDropdownItemByText = (text) =>
findAllDropdownItems().wrappers.find((item) => item.text() === text);
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
const assertDropdownItems = () => {
const dropdownItems = findAllDropdownItems();
expect(dropdownItems.wrappers).toHaveLength(mockProject.repository.branchNames.length);
expect(dropdownItems.wrappers.map((item) => item.text())).toEqual(
mockProject.repository.branchNames,
);
};
function createMockApolloProvider({ getProjectQueryLoading = false } = {}) {
localVue.use(VueApollo);
const mockApollo = createMockApollo([
[getProjectQuery, getProjectQueryLoading ? mockQueryLoading : mockGetProjectQuery],
]);
return mockApollo;
}
function createComponent({ mockApollo, props, mountFn = shallowMount } = {}) {
wrapper = mountFn(SourceBranchDropdown, {
localVue,
apolloProvider: mockApollo || createMockApolloProvider(),
propsData: props,
});
}
afterEach(() => {
wrapper.destroy();
});
describe('when `selectedProject` prop is not specified', () => {
beforeEach(() => {
createComponent();
});
it('sets dropdown `disabled` prop to `true`', () => {
expect(findDropdown().props('disabled')).toBe(true);
});
describe('when `selectedProject` becomes specified', () => {
beforeEach(async () => {
wrapper.setProps({
selectedProject: mockProject,
});
await waitForPromises();
});
it('sets dropdown props correctly', () => {
expect(findDropdown().props()).toMatchObject({
loading: false,
disabled: false,
text: 'Select a branch',
});
});
it('renders available source branches as dropdown items', () => {
assertDropdownItems();
});
});
});
describe('when `selectedProject` prop is specified', () => {
describe('when branches are loading', () => {
it('renders loading icon in dropdown', () => {
createComponent({
mockApollo: createMockApolloProvider({ getProjectQueryLoading: true }),
props: { selectedProject: mockProject },
});
expect(findLoadingIcon().isVisible()).toBe(true);
});
});
describe('when branches have loaded', () => {
describe('when searching branches', () => {
it('triggers a refetch', async () => {
createComponent({ mountFn: mount, props: { selectedProject: mockProject } });
await waitForPromises();
jest.clearAllMocks();
const mockSearchTerm = 'mai';
await findSearchBox().vm.$emit('input', mockSearchTerm);
expect(mockGetProjectQuery).toHaveBeenCalledWith({
branchNamesLimit: BRANCHES_PER_PAGE,
branchNamesOffset: 0,
branchNamesSearchPattern: `*${mockSearchTerm}*`,
projectPath: 'test-path',
});
});
});
describe('template', () => {
beforeEach(async () => {
createComponent({ props: { selectedProject: mockProject } });
await waitForPromises();
});
it('sets dropdown props correctly', () => {
expect(findDropdown().props()).toMatchObject({
loading: false,
disabled: false,
text: 'Select a branch',
});
});
it('omits monospace styling from dropdown', () => {
expect(findDropdown().classes()).not.toContain('gl-font-monospace');
});
it('renders available source branches as dropdown items', () => {
assertDropdownItems();
});
it("emits `change` event with the repository's `rootRef` by default", () => {
expect(wrapper.emitted('change')[0]).toEqual([mockProject.repository.rootRef]);
});
describe('when selecting a dropdown item', () => {
it('emits `change` event with the selected branch name', async () => {
const mockBranchName = mockProject.repository.branchNames[1];
const itemToSelect = findDropdownItemByText(mockBranchName);
await itemToSelect.vm.$emit('click');
expect(wrapper.emitted('change')[1]).toEqual([mockBranchName]);
});
});
describe('when `selectedBranchName` prop is specified', () => {
const mockBranchName = mockProject.repository.branchNames[2];
beforeEach(async () => {
wrapper.setProps({
selectedBranchName: mockBranchName,
});
});
it('sets `isChecked` prop of the corresponding dropdown item to `true`', () => {
expect(findDropdownItemByText(mockBranchName).props('isChecked')).toBe(true);
});
it('sets dropdown text to `selectedBranchName` value', () => {
expect(findDropdown().props('text')).toBe(mockBranchName);
});
it('adds monospace styling to dropdown', () => {
expect(findDropdown().classes()).toContain('gl-font-monospace');
});
});
});
});
});
});
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