From 2976ee88d1d3d7a7ee9a38c7c6d3cc20cba05f9a Mon Sep 17 00:00:00 2001 From: Zack Cuddy <zcuddy@gitlab.com> Date: Wed, 7 Feb 2024 03:10:54 +0000 Subject: [PATCH] Organizations - Create Project and Group links This change adds new UI elements to the Groups and Projects page of Organizations. These links allow users with correct permissions to see the buttons and navigate to the correct url. Additionally there is logic that disables the project button if no groups exist. --- .../groups_and_projects/components/app.vue | 30 +++++-- .../groups_and_projects/index.js | 6 ++ .../shared/components/groups_view.vue | 22 ++--- .../shared/components/new_group_button.vue | 32 +++++++ .../shared/components/new_project_button.vue | 44 ++++++++++ .../shared/components/projects_view.vue | 20 ++--- .../javascripts/organizations/show/index.js | 6 ++ .../organizations/organization_helper.rb | 9 +- locale/gitlab.pot | 3 + .../components/app_spec.js | 25 ++++++ .../shared/components/groups_view_spec.js | 62 +++++++------- .../components/new_group_button_spec.js | 80 +++++++++++++++++ .../components/new_project_button_spec.js | 77 +++++++++++++++++ .../shared/components/projects_view_spec.js | 62 +++++++------- .../organizations/organization_helper_spec.rb | 85 ++++++++++++++++++- 15 files changed, 459 insertions(+), 104 deletions(-) create mode 100644 app/assets/javascripts/organizations/shared/components/new_group_button.vue create mode 100644 app/assets/javascripts/organizations/shared/components/new_project_button.vue create mode 100644 spec/frontend/organizations/shared/components/new_group_button_spec.js create mode 100644 spec/frontend/organizations/shared/components/new_project_button_spec.js 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 f837845ab636c..3dcfe1c8bb953 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 8f6ae7f04cb7e..ead747c341539 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 11dd8b10193d0..9822c7ee56b2f 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 0000000000000..8254735192a5b --- /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 0000000000000..c7d39e8e31fac --- /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 20faa6e82d53e..f6d0d70e24ed1 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 0d927eeea8abf..940161ef0916b 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 e18cd409d73dc..ba386bca2a05e 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 184e342f66385..a3c82eb0e380d 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 ebc84c9efb0e3..1f6a153a9e1bc 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 55d2c36f625c0..b97dcf59aac4c 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 0000000000000..0ca628478a578 --- /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 0000000000000..31f6933d0c73c --- /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 c605b777afce9..83376a34df6ad 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 1ec95e4503ad1..4404ebbc9276f 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, -- GitLab