diff --git a/app/assets/javascripts/organizations/groups_and_projects/components/app.vue b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue index f837845ab636ca33091e88e8823c281f6bf4c616..3dcfe1c8bb95363bbcc0ba6a349f5900f1de3364 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/components/app.vue +++ b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue @@ -13,17 +13,19 @@ import { FILTERED_SEARCH_TERM, TOKEN_EMPTY_SEARCH_TERM, } from '~/vue_shared/components/filtered_search_bar/constants'; -import { RESOURCE_TYPE_GROUPS, RESOURCE_TYPE_PROJECTS } from '../../constants'; -import GroupsView from '../../shared/components/groups_view.vue'; -import ProjectsView from '../../shared/components/projects_view.vue'; -import { onPageChange } from '../../shared/utils'; +import { RESOURCE_TYPE_GROUPS, RESOURCE_TYPE_PROJECTS } from '~/organizations/constants'; +import GroupsView from '~/organizations/shared/components/groups_view.vue'; +import ProjectsView from '~/organizations/shared/components/projects_view.vue'; +import NewGroupButton from '~/organizations/shared/components/new_group_button.vue'; +import NewProjectButton from '~/organizations/shared/components/new_project_button.vue'; +import { onPageChange } from '~/organizations/shared/utils'; import { QUERY_PARAM_END_CURSOR, QUERY_PARAM_START_CURSOR, SORT_DIRECTION_ASC, SORT_DIRECTION_DESC, SORT_ITEM_NAME, -} from '../../shared/constants'; +} from '~/organizations/shared/constants'; import { DISPLAY_LISTBOX_ITEMS, SORT_ITEMS, FILTERED_SEARCH_TERM_KEY } from '../constants'; export default { @@ -32,7 +34,13 @@ export default { searchInputPlaceholder: s__('Organization|Search or filter list'), displayListboxHeaderText: __('Display'), }, - components: { FilteredSearchBar, GlCollapsibleListbox, GlSorting }, + components: { + FilteredSearchBar, + GlCollapsibleListbox, + GlSorting, + NewGroupButton, + NewProjectButton, + }, filteredSearch: { tokens: [], namespace: 'organization_groups_and_projects', @@ -156,7 +164,15 @@ export default { <template> <div> - <h1 class="gl-font-size-h-display">{{ $options.i18n.pageTitle }}</h1> + <div + class="page-title-holder gl-display-flex gl-sm-flex-direction-row gl-flex-direction-column gl-sm-align-items-center" + > + <h1 class="page-title gl-font-size-h-display">{{ $options.i18n.pageTitle }}</h1> + <div class="gl-display-flex gl-column-gap-3 gl-sm-ml-auto gl-mb-4 gl-sm-mb-0"> + <new-group-button category="secondary" /> + <new-project-button /> + </div> + </div> <div class="gl-p-5 gl-bg-gray-10 gl-border-t gl-border-b"> <div class="gl-mx-n2 gl-my-n2 gl-md-display-flex"> <div class="gl-p-2 gl-flex-grow-1"> diff --git a/app/assets/javascripts/organizations/groups_and_projects/index.js b/app/assets/javascripts/organizations/groups_and_projects/index.js index 8f6ae7f04cb7ef373a77c889e1526f5096967089..ead747c341539789c214e1309df0724619058426 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/index.js +++ b/app/assets/javascripts/organizations/groups_and_projects/index.js @@ -32,6 +32,9 @@ export const initOrganizationsGroupsAndProjects = () => { groupsEmptyStateSvgPath, newGroupPath, newProjectPath, + canCreateGroup, + canCreateProject, + hasGroups, } = convertObjectPropsToCamelCase(JSON.parse(appData)); Vue.use(VueRouter); @@ -51,6 +54,9 @@ export const initOrganizationsGroupsAndProjects = () => { groupsEmptyStateSvgPath, newGroupPath, newProjectPath, + canCreateGroup, + canCreateProject, + hasGroups, }, render(createElement) { return createElement(App); diff --git a/app/assets/javascripts/organizations/shared/components/groups_view.vue b/app/assets/javascripts/organizations/shared/components/groups_view.vue index 11dd8b10193d0bcffd3e1c0a2d7115840b43e019..9822c7ee56b2fd65e3ab910fc07570f8eb8c9a35 100644 --- a/app/assets/javascripts/organizations/shared/components/groups_view.vue +++ b/app/assets/javascripts/organizations/shared/components/groups_view.vue @@ -7,6 +7,7 @@ import { DEFAULT_PER_PAGE } from '~/api'; import groupsQuery from '../graphql/queries/groups.query.graphql'; import { SORT_ITEM_NAME, SORT_DIRECTION_ASC } from '../constants'; import { formatGroups } from '../utils'; +import NewGroupButton from './new_group_button.vue'; export default { i18n: { @@ -18,19 +19,14 @@ export default { description: s__( 'Organization|A group is a collection of several projects. If you organize your projects under a group, it works like a folder.', ), - primaryButtonText: __('New group'), }, - prev: __('Prev'), next: __('Next'), }, - components: { GlLoadingIcon, GlEmptyState, GlKeysetPagination, GroupsList }, + components: { GlLoadingIcon, GlEmptyState, GlKeysetPagination, GroupsList, NewGroupButton }, inject: { organizationGid: {}, groupsEmptyStateSvgPath: {}, - newGroupPath: { - default: null, - }, }, props: { shouldShowEmptyStateButtons: { @@ -143,14 +139,6 @@ export default { description: this.$options.i18n.emptyState.description, }; - if (this.shouldShowEmptyStateButtons && this.newGroupPath) { - return { - ...baseProps, - primaryButtonLink: this.newGroupPath, - primaryButtonText: this.$options.i18n.emptyState.primaryButtonText, - }; - } - return baseProps; }, }, @@ -186,5 +174,9 @@ export default { /> </div> </div> - <gl-empty-state v-else v-bind="emptyStateProps" /> + <gl-empty-state v-else v-bind="emptyStateProps"> + <template v-if="shouldShowEmptyStateButtons" #actions> + <new-group-button /> + </template> + </gl-empty-state> </template> diff --git a/app/assets/javascripts/organizations/shared/components/new_group_button.vue b/app/assets/javascripts/organizations/shared/components/new_group_button.vue new file mode 100644 index 0000000000000000000000000000000000000000..8254735192a5b9ed1aff6b2d426e0b8e64ce4b52 --- /dev/null +++ b/app/assets/javascripts/organizations/shared/components/new_group_button.vue @@ -0,0 +1,32 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + i18n: { + newGroup: __('New group'), + }, + components: { + GlButton, + }, + inject: ['canCreateGroup', 'newGroupPath'], + props: { + category: { + type: String, + required: false, + default: 'primary', + }, + }, + computed: { + showButton() { + return this.canCreateGroup && this.newGroupPath; + }, + }, +}; +</script> + +<template> + <gl-button v-if="showButton" :href="newGroupPath" :category="category" variant="confirm">{{ + $options.i18n.newGroup + }}</gl-button> +</template> diff --git a/app/assets/javascripts/organizations/shared/components/new_project_button.vue b/app/assets/javascripts/organizations/shared/components/new_project_button.vue new file mode 100644 index 0000000000000000000000000000000000000000..c7d39e8e31fac2207a748b1603d4c4870b763293 --- /dev/null +++ b/app/assets/javascripts/organizations/shared/components/new_project_button.vue @@ -0,0 +1,44 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; + +export default { + i18n: { + newProjectButtonDisabledTooltip: s__( + 'Organization|Projects are hosted/created in groups. Before creating a project, you must create a group.', + ), + newProject: __('New project'), + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlButton, + }, + inject: ['hasGroups', 'canCreateProject', 'newProjectPath'], + computed: { + showButton() { + return this.canCreateProject && this.newProjectPath; + }, + tooltip() { + return this.hasGroups ? null : this.$options.i18n.newProjectButtonDisabledTooltip; + }, + }, +}; +</script> + +<template> + <span + v-if="showButton" + v-gl-tooltip + :title="tooltip" + data-testid="new-project-button-tooltip-container" + ><gl-button + :href="newProjectPath" + :disabled="!hasGroups" + category="primary" + variant="confirm" + >{{ $options.i18n.newProject }}</gl-button + ></span + > +</template> diff --git a/app/assets/javascripts/organizations/shared/components/projects_view.vue b/app/assets/javascripts/organizations/shared/components/projects_view.vue index 20faa6e82d53e9c5275dedccee5cd974194f0133..f6d0d70e24ed18276e586d1fb2a6855c727d97ee 100644 --- a/app/assets/javascripts/organizations/shared/components/projects_view.vue +++ b/app/assets/javascripts/organizations/shared/components/projects_view.vue @@ -7,6 +7,7 @@ import { createAlert } from '~/alert'; import { SORT_ITEM_NAME, SORT_DIRECTION_ASC } from '../constants'; import projectsQuery from '../graphql/queries/projects.query.graphql'; import { formatProjects } from '../utils'; +import NewProjectButton from './new_project_button.vue'; export default { i18n: { @@ -18,7 +19,6 @@ export default { description: s__( 'GroupsEmptyState|Projects are where you can store your code, access issues, wiki, and other features of GitLab.', ), - primaryButtonText: __('New project'), }, prev: __('Prev'), next: __('Next'), @@ -28,13 +28,11 @@ export default { GlLoadingIcon, GlEmptyState, GlKeysetPagination, + NewProjectButton, }, inject: { organizationGid: {}, projectsEmptyStateSvgPath: {}, - newProjectPath: { - default: null, - }, }, props: { shouldShowEmptyStateButtons: { @@ -149,14 +147,6 @@ export default { description: this.$options.i18n.emptyState.description, }; - if (this.shouldShowEmptyStateButtons && this.newProjectPath) { - return { - ...baseProps, - primaryButtonLink: this.newProjectPath, - primaryButtonText: this.$options.i18n.emptyState.primaryButtonText, - }; - } - return baseProps; }, }, @@ -191,5 +181,9 @@ export default { /> </div> </div> - <gl-empty-state v-else v-bind="emptyStateProps" /> + <gl-empty-state v-else v-bind="emptyStateProps"> + <template v-if="shouldShowEmptyStateButtons" #actions> + <new-project-button /> + </template> + </gl-empty-state> </template> diff --git a/app/assets/javascripts/organizations/show/index.js b/app/assets/javascripts/organizations/show/index.js index 0d927eeea8abf8494ca7042a3895f1d531e425ef..940161ef0916bf50992aaf9c77ad91bf7516c62b 100644 --- a/app/assets/javascripts/organizations/show/index.js +++ b/app/assets/javascripts/organizations/show/index.js @@ -35,6 +35,9 @@ export const initOrganizationsShow = () => { newGroupPath, newProjectPath, associationCounts, + canCreateProject, + canCreateGroup, + hasGroups, } = convertObjectPropsToCamelCase(JSON.parse(appData)); Vue.use(VueRouter); @@ -54,6 +57,9 @@ export const initOrganizationsShow = () => { groupsEmptyStateSvgPath, newGroupPath, newProjectPath, + canCreateProject, + canCreateGroup, + hasGroups, }, render(createElement) { return createElement(App, { diff --git a/app/helpers/organizations/organization_helper.rb b/app/helpers/organizations/organization_helper.rb index e18cd409d73dc71ca177992bc5c85164db589b49..ba386bca2a05e83f4c3e3d6ff4a0ee8c764a1aad 100644 --- a/app/helpers/organizations/organization_helper.rb +++ b/app/helpers/organizations/organization_helper.rb @@ -77,7 +77,10 @@ def shared_groups_and_projects_app_data(organization) projects_empty_state_svg_path: image_path('illustrations/empty-state/empty-projects-md.svg'), groups_empty_state_svg_path: image_path('illustrations/empty-state/empty-groups-md.svg'), new_group_path: new_group_path, - new_project_path: new_project_path + new_project_path: new_project_path, + can_create_group: can?(current_user, :create_group, organization), + can_create_project: current_user&.can_create_project?, + has_groups: has_groups?(organization) } end @@ -95,5 +98,9 @@ def organizations_users_paths admin_user: admin_user_path(:id) } end + + def has_groups?(organization) + organization.groups.exists? + end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 184e342f6638597ab14a0b79a220fdd28d26dc00..a3c82eb0e380d1a78ca082062db4edee54a5ebdc 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -34523,6 +34523,9 @@ msgstr "" msgid "Organization|Perform advanced options such as deleting the organization." msgstr "" +msgid "Organization|Projects are hosted/created in groups. Before creating a project, you must create a group." +msgstr "" + msgid "Organization|Public - The organization can be accessed without any authentication." msgstr "" diff --git a/spec/frontend/organizations/groups_and_projects/components/app_spec.js b/spec/frontend/organizations/groups_and_projects/components/app_spec.js index ebc84c9efb0e3973459eaf8f6ae76d96711adcbc..1f6a153a9e1bc653c442c97c35db34cce209ed66 100644 --- a/spec/frontend/organizations/groups_and_projects/components/app_spec.js +++ b/spec/frontend/organizations/groups_and_projects/components/app_spec.js @@ -2,6 +2,8 @@ import { GlCollapsibleListbox, GlSorting } from '@gitlab/ui'; import App from '~/organizations/groups_and_projects/components/app.vue'; import GroupsView from '~/organizations/shared/components/groups_view.vue'; import ProjectsView from '~/organizations/shared/components/projects_view.vue'; +import NewGroupButton from '~/organizations/shared/components/new_group_button.vue'; +import NewProjectButton from '~/organizations/shared/components/new_project_button.vue'; import { RESOURCE_TYPE_GROUPS, RESOURCE_TYPE_PROJECTS } from '~/organizations/constants'; import { SORT_ITEMS } from '~/organizations/groups_and_projects/constants'; import { @@ -35,10 +37,19 @@ describe('GroupsAndProjectsApp', () => { }); }; + const findPageTitle = () => wrapper.findByText('Groups and projects'); const findFilteredSearchBar = () => wrapper.findComponent(FilteredSearchBar); const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); const findSort = () => wrapper.findComponent(GlSorting); const findProjectsView = () => wrapper.findComponent(ProjectsView); + const findNewGroupButton = () => wrapper.findComponent(NewGroupButton); + const findNewProjectButton = () => wrapper.findComponent(NewProjectButton); + + it('renders page title as Groups and projects', () => { + createComponent(); + + expect(findPageTitle().exists()).toBe(true); + }); describe.each` display | expectedComponent | expectedDisplayListboxSelectedProp @@ -101,6 +112,20 @@ describe('GroupsAndProjectsApp', () => { }); }); + describe('actions', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders NewProjectButton', () => { + expect(findNewProjectButton().exists()).toBe(true); + }); + + it('renders NewGroupButton with correct props', () => { + expect(findNewGroupButton().props()).toStrictEqual({ category: 'secondary' }); + }); + }); + it('renders sort dropdown with sort items and correct props', () => { createComponent(); diff --git a/spec/frontend/organizations/shared/components/groups_view_spec.js b/spec/frontend/organizations/shared/components/groups_view_spec.js index 55d2c36f625c03cb0ffd7440b61db33dcd463d1e..b97dcf59aac4c5eaf455529cc89a1f4fb33b51c6 100644 --- a/spec/frontend/organizations/shared/components/groups_view_spec.js +++ b/spec/frontend/organizations/shared/components/groups_view_spec.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import { GlEmptyState, GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui'; import GroupsView from '~/organizations/shared/components/groups_view.vue'; import { SORT_DIRECTION_ASC, SORT_ITEM_NAME } from '~/organizations/shared/constants'; +import NewGroupButton from '~/organizations/shared/components/new_group_button.vue'; import { formatGroups } from '~/organizations/shared/utils'; import groupsQuery from '~/organizations/shared/graphql/queries/groups.query.graphql'; import GroupsList from '~/vue_shared/components/groups_list/groups_list.vue'; @@ -67,6 +68,7 @@ describe('GroupsView', () => { }; const findPagination = () => wrapper.findComponent(GlKeysetPagination); + const findNewGroupButton = () => wrapper.findComponent(NewGroupButton); afterEach(() => { mockApollo = null; @@ -81,51 +83,47 @@ describe('GroupsView', () => { }); describe('when API call is successful', () => { - describe('when there are no groups', () => { - const emptyHandler = jest.fn().mockResolvedValue({ - data: { - organization: { - id: defaultProvide.organizationGid, - groups: { - nodes: [], - pageInfo: pageInfoEmpty, + describe.each` + shouldShowEmptyStateButtons + ${false} + ${true} + `( + 'when there are no groups and `shouldShowEmptyStateButtons` is `$shouldShowEmptyStateButtons`', + ({ shouldShowEmptyStateButtons }) => { + const emptyHandler = jest.fn().mockResolvedValue({ + data: { + organization: { + id: defaultProvide.organizationGid, + groups: { + nodes: [], + pageInfo: pageInfoEmpty, + }, }, }, - }, - }); - - it('renders empty state without buttons by default', async () => { - createComponent({ handler: emptyHandler }); - - await waitForPromises(); - - expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ - title: "You don't have any groups yet.", - description: - 'A group is a collection of several projects. If you organize your projects under a group, it works like a folder.', - svgHeight: 144, - svgPath: defaultProvide.groupsEmptyStateSvgPath, - primaryButtonLink: null, - primaryButtonText: null, }); - }); - describe('when `shouldShowEmptyStateButtons` is `true` and `groupsEmptyStateSvgPath` is set', () => { - it('renders empty state with buttons', async () => { + it(`renders empty state ${ + shouldShowEmptyStateButtons ? 'with' : 'without' + } buttons`, async () => { createComponent({ handler: emptyHandler, - propsData: { shouldShowEmptyStateButtons: true }, + propsData: { shouldShowEmptyStateButtons }, }); await waitForPromises(); expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ - primaryButtonLink: defaultProvide.newGroupPath, - primaryButtonText: 'New group', + title: "You don't have any groups yet.", + description: + 'A group is a collection of several projects. If you organize your projects under a group, it works like a folder.', + svgHeight: 144, + svgPath: defaultProvide.groupsEmptyStateSvgPath, }); + + expect(findNewGroupButton().exists()).toBe(shouldShowEmptyStateButtons); }); - }); - }); + }, + ); describe('when there are groups', () => { beforeEach(() => { diff --git a/spec/frontend/organizations/shared/components/new_group_button_spec.js b/spec/frontend/organizations/shared/components/new_group_button_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0ca628478a578ee30bdf419478b3eb830a97a876 --- /dev/null +++ b/spec/frontend/organizations/shared/components/new_group_button_spec.js @@ -0,0 +1,80 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import NewGroupButton from '~/organizations/shared/components/new_group_button.vue'; + +describe('NewGroupButton', () => { + let wrapper; + + const defaultProvide = { + canCreateGroup: false, + newGroupPath: '', + }; + + const defaultProps = { + category: 'primary', + }; + + function createComponent({ provide = {}, props = {} } = {}) { + wrapper = shallowMount(NewGroupButton, { + provide: { + ...defaultProvide, + ...provide, + }, + propsData: { + ...defaultProps, + ...props, + }, + }); + } + + const findGlButton = () => wrapper.findComponent(GlButton); + + describe.each` + canCreateGroup | newGroupPath + ${false} | ${null} + ${false} | ${'/asdf'} + ${true} | ${null} + `( + 'when `canCreateGroup` is $canCreateGroup and `newGroupPath` is $newGroupPath', + ({ canCreateGroup, newGroupPath }) => { + beforeEach(() => { + createComponent({ provide: { canCreateGroup, newGroupPath } }); + }); + + it('renders nothing', () => { + expect(wrapper.html()).toBe(''); + }); + }, + ); + + describe('when `canCreateGroup` is true and `newGroupPath` is /asdf', () => { + const newGroupPath = '/asdf'; + + describe('with no category', () => { + beforeEach(() => { + createComponent({ + provide: { canCreateGroup: true, newGroupPath }, + props: { category: undefined }, + }); + }); + + it('renders GlButton correctly', () => { + expect(findGlButton().attributes('href')).toBe(newGroupPath); + expect(findGlButton().props('category')).toBe(defaultProps.category); + }); + }); + + describe('with set category', () => { + const category = 'secondary'; + + beforeEach(() => { + createComponent({ provide: { canCreateGroup: true, newGroupPath }, props: { category } }); + }); + + it('renders GlButton correctly', () => { + expect(findGlButton().attributes('href')).toBe(newGroupPath); + expect(findGlButton().props('category')).toBe(category); + }); + }); + }); +}); diff --git a/spec/frontend/organizations/shared/components/new_project_button_spec.js b/spec/frontend/organizations/shared/components/new_project_button_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..31f6933d0c73c7a184ba9ac09963c6321fdc6137 --- /dev/null +++ b/spec/frontend/organizations/shared/components/new_project_button_spec.js @@ -0,0 +1,77 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import NewProjectButton from '~/organizations/shared/components/new_project_button.vue'; + +describe('NewProjectButton', () => { + let wrapper; + + const defaultProvide = { + canCreateProject: false, + newProjectPath: '', + hasGroups: false, + }; + + function createComponent({ provide = {} } = {}) { + wrapper = shallowMountExtended(NewProjectButton, { + provide: { + ...defaultProvide, + ...provide, + }, + }); + } + + const findTooltipContainer = () => wrapper.findByTestId('new-project-button-tooltip-container'); + const findGlButton = () => wrapper.findComponent(GlButton); + + describe.each` + canCreateProject | newProjectPath + ${false} | ${null} + ${false} | ${'/asdf'} + ${true} | ${null} + `( + 'when `canCreateProject` is $canCreateProject and `newProjectPath` is $newProjectPath', + ({ canCreateProject, newProjectPath }) => { + beforeEach(() => { + createComponent({ provide: { canCreateProject, newProjectPath } }); + }); + + it('renders nothing', () => { + expect(wrapper.html()).toBe(''); + }); + }, + ); + + describe('when `canCreateProject` is true and `newProjectPath` is /asdf', () => { + const newProjectPath = '/asdf'; + + beforeEach(() => { + createComponent({ provide: { canCreateProject: true, newProjectPath } }); + }); + + it('renders GlButton correctly', () => { + expect(findGlButton().attributes('href')).toBe(newProjectPath); + }); + }); + + describe.each` + hasGroups | disabled | tooltip + ${false} | ${'true'} | ${'Projects are hosted/created in groups. Before creating a project, you must create a group.'} + ${true} | ${undefined} | ${undefined} + `( + 'when `canCreateProject` is true , `newProjectPath` is /asdf, and hasGroups is $hasGroups', + ({ hasGroups, disabled, tooltip }) => { + beforeEach(() => { + createComponent({ + provide: { canCreateProject: true, newProjectPath: '/asdf', hasGroups }, + }); + }); + + it(`renders GlButton as ${disabled ? 'disabled' : 'not disabled'} with ${ + tooltip ? 'tooltip' : 'no tooltip' + }`, () => { + expect(findGlButton().attributes('disabled')).toBe(disabled); + expect(findTooltipContainer().attributes('title')).toBe(tooltip); + }); + }, + ); +}); diff --git a/spec/frontend/organizations/shared/components/projects_view_spec.js b/spec/frontend/organizations/shared/components/projects_view_spec.js index c605b777afce911a16ffd9013a699cddab59751e..83376a34df6add22cc082866bb2838c2e5efa75b 100644 --- a/spec/frontend/organizations/shared/components/projects_view_spec.js +++ b/spec/frontend/organizations/shared/components/projects_view_spec.js @@ -2,6 +2,7 @@ import VueApollo from 'vue-apollo'; import Vue from 'vue'; import { GlLoadingIcon, GlEmptyState, GlKeysetPagination } from '@gitlab/ui'; import ProjectsView from '~/organizations/shared/components/projects_view.vue'; +import NewProjectButton from '~/organizations/shared/components/new_project_button.vue'; import projectsQuery from '~/organizations/shared/graphql/queries/projects.query.graphql'; import { formatProjects } from '~/organizations/shared/utils'; import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue'; @@ -66,6 +67,7 @@ describe('ProjectsView', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findProjectsList = () => wrapper.findComponent(ProjectsList); + const findNewProjectButton = () => wrapper.findComponent(NewProjectButton); afterEach(() => { mockApollo = null; @@ -80,51 +82,47 @@ describe('ProjectsView', () => { }); describe('when API call is successful', () => { - describe('when there are no projects', () => { - const emptyHandler = jest.fn().mockResolvedValue({ - data: { - organization: { - id: defaultProvide.organizationGid, - projects: { - nodes: [], - pageInfo: pageInfoEmpty, + describe.each` + shouldShowEmptyStateButtons + ${false} + ${true} + `( + 'when there are no projects and `shouldShowEmptyStateButtons` is `$shouldShowEmptyStateButtons`', + ({ shouldShowEmptyStateButtons }) => { + const emptyHandler = jest.fn().mockResolvedValue({ + data: { + organization: { + id: defaultProvide.organizationGid, + projects: { + nodes: [], + pageInfo: pageInfoEmpty, + }, }, }, - }, - }); - - it('renders empty state without buttons by default', async () => { - createComponent({ handler: emptyHandler }); - - await waitForPromises(); - - expect(findEmptyState().props()).toMatchObject({ - title: "You don't have any projects yet.", - description: - 'Projects are where you can store your code, access issues, wiki, and other features of GitLab.', - svgHeight: 144, - svgPath: defaultProvide.projectsEmptyStateSvgPath, - primaryButtonLink: null, - primaryButtonText: null, }); - }); - describe('when `shouldShowEmptyStateButtons` is `true` and `projectsEmptyStateSvgPath` is set', () => { - it('renders empty state with buttons', async () => { + it(`renders empty state ${ + shouldShowEmptyStateButtons ? 'with' : 'without' + } buttons`, async () => { createComponent({ handler: emptyHandler, - propsData: { shouldShowEmptyStateButtons: true }, + propsData: { shouldShowEmptyStateButtons }, }); await waitForPromises(); expect(findEmptyState().props()).toMatchObject({ - primaryButtonLink: defaultProvide.newProjectPath, - primaryButtonText: 'New project', + title: "You don't have any projects yet.", + description: + 'Projects are where you can store your code, access issues, wiki, and other features of GitLab.', + svgHeight: 144, + svgPath: defaultProvide.projectsEmptyStateSvgPath, }); + + expect(findNewProjectButton().exists()).toBe(shouldShowEmptyStateButtons); }); - }); - }); + }, + ); describe('when there are projects', () => { beforeEach(() => { diff --git a/spec/helpers/organizations/organization_helper_spec.rb b/spec/helpers/organizations/organization_helper_spec.rb index 1ec95e4503ad1d2c42c54bd08647a2fb4c39bc9b..4404ebbc9276f2d70507e6db74473bdf0c33eb24 100644 --- a/spec/helpers/organizations/organization_helper_spec.rb +++ b/spec/helpers/organizations/organization_helper_spec.rb @@ -3,6 +3,9 @@ require 'spec_helper' RSpec.describe Organizations::OrganizationHelper, feature_category: :cell do + include Devise::Test::ControllerHelpers + + let_it_be(:user) { build_stubbed(:user) } let_it_be(:organization_detail) { build_stubbed(:organization_detail, description_html: '<em>description</em>') } let_it_be(:organization) { organization_detail.organization } let_it_be(:organization_gid) { 'gid://gitlab/Organizations::Organization/1' } @@ -27,6 +30,31 @@ allow(helper).to receive(:image_path).with(groups_empty_state_svg_path).and_return(groups_empty_state_svg_path) allow(helper).to receive(:image_path).with(projects_empty_state_svg_path).and_return(projects_empty_state_svg_path) allow(helper).to receive(:preview_markdown_organizations_path).and_return(preview_markdown_organizations_path) + allow(helper).to receive(:current_user).and_return(user) + end + + shared_examples 'includes that the user can create a group' do |method| + it 'returns expected json' do + expect( + Gitlab::Json.parse(helper.send(method, organization)) + ).to include('can_create_group' => true) + end + end + + shared_examples 'includes that the user can create a project' do |method| + it 'returns expected json' do + expect( + Gitlab::Json.parse(helper.send(method, organization)) + ).to include('can_create_project' => true) + end + end + + shared_examples 'includes that the organization has groups' do |method| + it 'returns expected json' do + expect( + Gitlab::Json.parse(helper.send(method, organization)) + ).to include('has_groups' => true) + end end describe '#organization_layout_nav' do @@ -68,13 +96,38 @@ .and_return(groups_and_projects_organization_path) end - it 'returns expected json' do + context 'when the user can create a group' do + before do + allow(helper).to receive(:can?).with(user, :create_group, organization).and_return(true) + end + + include_examples 'includes that the user can create a group', 'organization_show_app_data' + end + + context 'when the user can create a project' do + before do + allow(user).to receive(:can_create_project?).and_return(true) + end + + include_examples 'includes that the user can create a project', 'organization_show_app_data' + end + + context 'when the organization has groups' do + before do + allow(helper).to receive(:has_groups?).and_return(true) + end + + include_examples 'includes that the organization has groups', 'organization_show_app_data' + end + + it "includes all other non-conditional data" do expect(organization).to receive(:avatar_url).with(size: 128).and_return('avatar.jpg') + expect( Gitlab::Json.parse( helper.organization_show_app_data(organization) ) - ).to eq( + ).to include( { 'organization_gid' => organization_gid, 'organization' => { @@ -99,12 +152,36 @@ end describe '#organization_groups_and_projects_app_data' do - it 'returns expected json' do + context 'when the user can create a group' do + before do + allow(helper).to receive(:can?).with(user, :create_group, organization).and_return(true) + end + + include_examples 'includes that the user can create a group', 'organization_groups_and_projects_app_data' + end + + context 'when the user can create a project' do + before do + allow(user).to receive(:can_create_project?).and_return(true) + end + + include_examples 'includes that the user can create a project', 'organization_groups_and_projects_app_data' + end + + context 'when the organization has groups' do + before do + allow(helper).to receive(:has_groups?).and_return(true) + end + + include_examples 'includes that the organization has groups', 'organization_groups_and_projects_app_data' + end + + it "includes all other non-conditional data" do expect( Gitlab::Json.parse( helper.organization_groups_and_projects_app_data(organization) ) - ).to eq( + ).to include( { 'organization_gid' => organization_gid, 'new_group_path' => new_group_path,