Commit c63e566f authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch 'xanf-introduce-fork-page-components' into 'master'

Introduce fork page components

See merge request gitlab-org/gitlab!35589
parents 48652311 d703290a
<script>
import { GlTabs, GlTab, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import createFlash from '~/flash';
import ForkGroupsListItem from './fork_groups_list_item.vue';
export default {
components: {
GlTabs,
GlTab,
GlLoadingIcon,
GlSearchBoxByType,
ForkGroupsListItem,
},
props: {
hasReachedProjectLimit: {
type: Boolean,
required: true,
},
endpoint: {
type: String,
required: true,
},
},
data() {
return {
namespaces: null,
filter: '',
};
},
computed: {
filteredNamespaces() {
return this.namespaces.filter(n => n.name.toLowerCase().includes(this.filter.toLowerCase()));
},
},
mounted() {
this.loadGroups();
},
methods: {
loadGroups() {
axios
.get(this.endpoint)
.then(response => {
this.namespaces = response.data.namespaces;
})
.catch(() => createFlash(__('There was a problem fetching groups.')));
},
},
i18n: {
searchPlaceholder: __('Search by name'),
},
};
</script>
<template>
<gl-tabs class="fork-groups">
<gl-tab :title="__('Groups and subgroups')">
<gl-loading-icon v-if="!namespaces" size="md" class="gl-mt-3" />
<template v-else-if="namespaces.length === 0">
<div class="gl-text-center">
<div class="h5">{{ __('No available groups to fork the project.') }}</div>
<p class="gl-mt-5">
{{ __('You must have permission to create a project in a group before forking.') }}
</p>
</div>
</template>
<div v-else-if="filteredNamespaces.length === 0" class="gl-text-center gl-mt-3">
{{ s__('GroupsTree|No groups matched your search') }}
</div>
<ul v-else class="groups-list group-list-tree">
<fork-groups-list-item
v-for="(namespace, index) in filteredNamespaces"
:key="index"
:group="namespace"
:has-reached-project-limit="hasReachedProjectLimit"
/>
</ul>
</gl-tab>
<template #tabs-end>
<gl-search-box-by-type
v-if="namespaces && namespaces.length"
v-model="filter"
:placeholder="$options.i18n.searchPlaceholder"
class="gl-align-self-center gl-ml-auto fork-filtered-search"
/>
</template>
</gl-tabs>
</template>
<script>
import {
GlLink,
GlButton,
GlIcon,
GlAvatar,
GlTooltipDirective,
GlTooltip,
GlBadge,
} from '@gitlab/ui';
import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/groups/constants';
import { __ } from '~/locale';
import csrf from '~/lib/utils/csrf';
export default {
components: {
GlIcon,
GlAvatar,
GlBadge,
GlButton,
GlTooltip,
GlLink,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
group: {
type: Object,
required: true,
},
hasReachedProjectLimit: {
type: Boolean,
required: true,
},
},
data() {
return { namespaces: null };
},
computed: {
rowClass() {
return {
'has-description': this.group.description,
'being-removed': this.isGroupPendingRemoval,
};
},
isGroupPendingRemoval() {
return this.group.marked_for_deletion;
},
hasForkedProject() {
return Boolean(this.group.forked_project_path);
},
visibilityIcon() {
return VISIBILITY_TYPE_ICON[this.group.visibility];
},
visibilityTooltip() {
return GROUP_VISIBILITY_TYPE[this.group.visibility];
},
isSelectButtonDisabled() {
return this.hasReachedProjectLimit || !this.group.can_create_project;
},
selectButtonDisabledTooltip() {
return this.hasReachedProjectLimit
? this.$options.i18n.hasReachedProjectLimitMessage
: this.$options.i18n.insufficientPermissionsMessage;
},
},
i18n: {
hasReachedProjectLimitMessage: __('You have reached your project limit'),
insufficientPermissionsMessage: __(
'You must have permission to create a project in a namespace before forking.',
),
},
csrf,
};
</script>
<template>
<li :class="rowClass" class="group-row">
<div class="group-row-contents gl-display-flex gl-align-items-center gl-py-3 gl-pr-5">
<div class="folder-toggle-wrap gl-mr-2 gl-display-flex gl-align-items-center">
<gl-icon name="folder-o" />
</div>
<gl-link
:href="group.relative_path"
class="gl-display-none gl-flex-shrink-0 gl-display-sm-flex gl-mr-3"
>
<gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatarUrl" />
</gl-link>
<div class="gl-min-w-0 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1 gl-align-items-center">
<div class="gl-min-w-0 gl-flex-grow-1 flex-shrink-1">
<div class="title gl-display-flex gl-align-items-center gl-flex-wrap gl-mr-3">
<gl-link :href="group.relative_path" class="gl-mt-3 gl-mr-3 gl-text-gray-900!">{{
group.full_name
}}</gl-link>
<gl-icon
v-gl-tooltip.hover.bottom
class="gl-mr-0 gl-inline-flex gl-mt-3 text-secondary"
:name="visibilityIcon"
:title="visibilityTooltip"
/>
<gl-badge
v-if="isGroupPendingRemoval"
variant="warning"
class="gl-display-none gl-display-sm-flex gl-mt-3 gl-mr-1"
>{{ __('pending removal') }}</gl-badge
>
<span v-if="group.permission" class="user-access-role gl-mt-3">
{{ group.permission }}
</span>
</div>
<div v-if="group.description" class="description">
<span v-html="group.markdown_description"> </span>
</div>
</div>
<div class="gl-display-flex gl-flex-shrink-0">
<gl-button
v-if="hasForkedProject"
class="gl-h-7 gl-text-decoration-none!"
:href="group.forked_project_path"
>{{ __('Go to fork') }}</gl-button
>
<template v-else>
<div ref="selectButtonWrapper">
<form method="POST" :action="group.fork_path">
<input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
<gl-button
type="submit"
class="gl-h-7 gl-text-decoration-none!"
:data-qa-name="group.full_name"
variant="success"
:disabled="isSelectButtonDisabled"
>{{ __('Select') }}</gl-button
>
</form>
</div>
<gl-tooltip v-if="isSelectButtonDisabled" :target="() => $refs.selectButtonWrapper">
{{ selectButtonDisabledTooltip }}
</gl-tooltip>
</template>
</div>
</div>
</div>
</li>
</template>
......@@ -10964,6 +10964,9 @@ msgstr ""
msgid "Go to find file"
msgstr ""
msgid "Go to fork"
msgstr ""
msgid "Go to issue boards"
msgstr ""
......@@ -11531,6 +11534,9 @@ msgstr ""
msgid "Groups and projects"
msgstr ""
msgid "Groups and subgroups"
msgstr ""
msgid "Groups can also be nested by creating %{subgroup_docs_link_start}subgroups%{subgroup_docs_link_end}."
msgstr ""
......@@ -15131,6 +15137,9 @@ msgstr ""
msgid "No authentication methods configured."
msgstr ""
msgid "No available groups to fork the project."
msgstr ""
msgid "No available namespaces to fork the project."
msgstr ""
......@@ -19831,6 +19840,9 @@ msgstr ""
msgid "Search by author"
msgstr ""
msgid "Search by name"
msgstr ""
msgid "Search files"
msgstr ""
......@@ -22979,6 +22991,9 @@ msgstr ""
msgid "There was a problem communicating with your device."
msgstr ""
msgid "There was a problem fetching groups."
msgstr ""
msgid "There was a problem fetching project branches."
msgstr ""
......@@ -26256,6 +26271,9 @@ msgstr ""
msgid "You must have maintainer access to force delete a lock"
msgstr ""
msgid "You must have permission to create a project in a group before forking."
msgstr ""
msgid "You must have permission to create a project in a namespace before forking."
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import { GlBadge, GlButton, GlLink } from '@gitlab/ui';
import ForkGroupsListItem from '~/pages/projects/forks/new/components/fork_groups_list_item.vue';
describe('Fork groups list item component', () => {
let wrapper;
const DEFAULT_PROPS = {
hasReachedProjectLimit: false,
};
const DEFAULT_GROUP_DATA = {
id: 22,
name: 'Gitlab Org',
description: 'Ad et ipsam earum id aut nobis.',
visibility: 'public',
full_name: 'Gitlab Org',
created_at: '2020-06-22T03:32:05.664Z',
updated_at: '2020-06-22T03:32:05.664Z',
avatar_url: null,
fork_path: '/twitter/typeahead-js/-/forks?namespace_key=22',
forked_project_path: null,
permission: 'Owner',
relative_path: '/gitlab-org',
markdown_description:
'<p data-sourcepos="1:1-1:31" dir="auto">Ad et ipsam earum id aut nobis.</p>',
can_create_project: true,
marked_for_deletion: false,
};
const DUMMY_PATH = '/dummy/path';
const createWrapper = propsData => {
wrapper = shallowMount(ForkGroupsListItem, {
propsData: {
...DEFAULT_PROPS,
...propsData,
},
});
};
it('renders pending removal badge if applicable', () => {
createWrapper({ group: { ...DEFAULT_GROUP_DATA, marked_for_deletion: true } });
expect(wrapper.find(GlBadge).text()).toBe('pending removal');
});
it('renders go to fork button if has forked project', () => {
createWrapper({ group: { ...DEFAULT_GROUP_DATA, forked_project_path: DUMMY_PATH } });
expect(wrapper.find(GlButton).text()).toBe('Go to fork');
expect(wrapper.find(GlButton).attributes().href).toBe(DUMMY_PATH);
});
it('renders select button if has no forked project', () => {
createWrapper({
group: { ...DEFAULT_GROUP_DATA, forked_project_path: null, fork_path: DUMMY_PATH },
});
expect(wrapper.find(GlButton).text()).toBe('Select');
expect(wrapper.find('form').attributes().action).toBe(DUMMY_PATH);
});
it('renders link to current group', () => {
const DUMMY_FULL_NAME = 'dummy';
createWrapper({
group: { ...DEFAULT_GROUP_DATA, relative_path: DUMMY_PATH, full_name: DUMMY_FULL_NAME },
});
expect(
wrapper
.findAll(GlLink)
.filter(w => w.text() === DUMMY_FULL_NAME)
.at(0)
.attributes().href,
).toBe(DUMMY_PATH);
});
});
import AxiosMockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import { nextTick } from 'vue';
import createFlash from '~/flash';
import ForkGroupsList from '~/pages/projects/forks/new/components/fork_groups_list.vue';
import ForkGroupsListItem from '~/pages/projects/forks/new/components/fork_groups_list_item.vue';
import waitForPromises from 'helpers/wait_for_promises';
jest.mock('~/flash', () => jest.fn());
describe('Fork groups list component', () => {
let wrapper;
let axiosMock;
const DEFAULT_PROPS = {
endpoint: '/dummy',
hasReachedProjectLimit: false,
};
const replyWith = (...args) => axiosMock.onGet(DEFAULT_PROPS.endpoint).reply(...args);
const createWrapper = propsData => {
wrapper = shallowMount(ForkGroupsList, {
propsData: {
...DEFAULT_PROPS,
...propsData,
},
stubs: {
GlTabs: {
template: '<div><slot></slot><slot name="tabs-end"></slot></div>',
},
},
});
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
});
afterEach(() => {
axiosMock.reset();
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
it('fires load groups request on mount', async () => {
replyWith(200, { namespaces: [] });
createWrapper();
await waitForPromises();
expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROPS.endpoint);
});
it('displays flash if loading groups fails', async () => {
replyWith(500);
createWrapper();
await waitForPromises();
expect(createFlash).toHaveBeenCalled();
});
it('displays loading indicator while loading groups', () => {
replyWith(() => new Promise(() => {}));
createWrapper();
expect(wrapper.contains(GlLoadingIcon)).toBe(true);
});
it('displays empty text if no groups are available', async () => {
const EMPTY_TEXT = 'No available groups to fork the project.';
replyWith(200, { namespaces: [] });
createWrapper();
await waitForPromises();
expect(wrapper.text()).toContain(EMPTY_TEXT);
});
it('displays filter field when groups are available', async () => {
replyWith(200, { namespaces: [{ name: 'dummy1' }, { name: 'dummy2' }] });
createWrapper();
await waitForPromises();
expect(wrapper.contains(GlSearchBoxByType)).toBe(true);
});
it('renders list items for each available group', async () => {
const namespaces = [{ name: 'dummy1' }, { name: 'dummy2' }, { name: 'otherdummy' }];
const hasReachedProjectLimit = true;
replyWith(200, { namespaces });
createWrapper({ hasReachedProjectLimit });
await waitForPromises();
expect(wrapper.findAll(ForkGroupsListItem)).toHaveLength(namespaces.length);
namespaces.forEach((namespace, idx) => {
expect(
wrapper
.findAll(ForkGroupsListItem)
.at(idx)
.props(),
).toStrictEqual({ group: namespace, hasReachedProjectLimit });
});
});
it('filters repositories on the fly', async () => {
replyWith(200, {
namespaces: [{ name: 'dummy1' }, { name: 'dummy2' }, { name: 'otherdummy' }],
});
createWrapper();
await waitForPromises();
wrapper.find(GlSearchBoxByType).vm.$emit('input', 'other');
await nextTick();
expect(wrapper.findAll(ForkGroupsListItem)).toHaveLength(1);
expect(
wrapper
.findAll(ForkGroupsListItem)
.at(0)
.props().group.name,
).toBe('otherdummy');
});
});
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