diff --git a/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue index 4650390a7ff170269da1bbc80a0b6cf76aa9c71e..337a7f3656ac02ea73d74f3b49797e3507c3ee7c 100644 --- a/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue +++ b/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue @@ -1,20 +1,48 @@ <script> +import epicEmptyStateSvg from '@gitlab/svgs/dist/illustrations/empty-state/empty-epic-md.svg'; +import issuesEmptyStateSvg from '@gitlab/svgs/dist/illustrations/empty-state/empty-issues-md.svg'; import { GlButton, GlEmptyState } from '@gitlab/ui'; +import { __ } from '~/locale'; export default { components: { GlButton, GlEmptyState, }, - inject: ['emptyStateSvgPath', 'newIssuePath', 'showNewIssueLink'], + inject: { + newIssuePath: { + default: false, + }, + showNewIssueLink: { + default: false, + }, + }, props: { hasSearch: { type: Boolean, - required: true, + required: false, + default: false, + }, + isEpic: { + type: Boolean, + required: false, + default: false, }, isOpenTab: { type: Boolean, - required: true, + required: false, + default: true, + }, + }, + computed: { + closedTabTitle() { + return this.isEpic ? __('There are no closed epics') : __('There are no closed issues'); + }, + openTabTitle() { + return this.isEpic ? __('There are no open epics') : __('There are no open issues'); + }, + svgPath() { + return this.isEpic ? epicEmptyStateSvg : issuesEmptyStateSvg; }, }, }; @@ -25,37 +53,37 @@ export default { v-if="hasSearch" :description="__('To widen your search, change or remove filters above')" :title="__('Sorry, your filter produced no results')" - :svg-path="emptyStateSvgPath" - :svg-height="150" + :svg-path="svgPath" data-testid="issuable-empty-state" > <template #actions> - <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> - {{ __('New issue') }} - </gl-button> + <slot name="new-issue-button"> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ __('New issue') }} + </gl-button> + </slot> </template> </gl-empty-state> <gl-empty-state v-else-if="isOpenTab" - :description="__('To keep this project going, create a new issue')" - :title="__('There are no open issues')" - :svg-path="emptyStateSvgPath" - :svg-height="null" + :title="openTabTitle" + :svg-path="svgPath" data-testid="issuable-empty-state" > <template #actions> - <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> - {{ __('New issue') }} - </gl-button> + <slot name="new-issue-button"> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ __('New issue') }} + </gl-button> + </slot> </template> </gl-empty-state> <gl-empty-state v-else - :title="__('There are no closed issues')" - :svg-path="emptyStateSvgPath" - :svg-height="150" + :title="closedTabTitle" + :svg-path="svgPath" data-testid="issuable-empty-state" /> </template> diff --git a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue index 3841ba98d654db5feee3354ead46e4faf4379813..6a75e2bdf1e981e00fb3d6d73fa6b91ba46131ab 100644 --- a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue +++ b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue @@ -1,4 +1,5 @@ <script> +import emptyStateSvg from '@gitlab/svgs/dist/illustrations/empty-state/empty-issues-md.svg'; import { GlButton, GlDisclosureDropdown, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; @@ -14,7 +15,9 @@ export default { 'JiraService|%{jiraDocsLinkStart}Enable the Jira integration%{jiraDocsLinkEnd} to view your Jira issues in GitLab.', ), }, + emptyStateSvg, issuesHelpPagePath: helpPagePath('user/project/issues/index'), + jiraIntegrationPath: helpPagePath('integration/jira/issues', { anchor: 'view-jira-issues' }), components: { CsvImportExportButtons, GlButton, @@ -29,9 +32,7 @@ export default { mixins: [hasNewIssueDropdown()], inject: [ 'canCreateProjects', - 'emptyStateSvgPath', 'isSignedIn', - 'jiraIntegrationPath', 'newIssuePath', 'newProjectPath', 'showNewIssueLink', @@ -89,7 +90,7 @@ export default { <div> <gl-empty-state :title="__('Use issues to collaborate on ideas, solve problems, and plan work')" - :svg-path="emptyStateSvgPath" + :svg-path="$options.emptyStateSvg" :svg-height="150" data-testid="issuable-empty-state" > @@ -164,7 +165,7 @@ export default { <gl-sprintf :message="$options.i18n.jiraIntegrationMessage"> <template #jiraDocsLink="{ content }"> <gl-link - :href="jiraIntegrationPath" + :href="$options.jiraIntegrationPath" :data-track-action="isProject && 'click_jira_int_project_issues_empty_list_page'" :data-track-label="isProject && 'jira_int_project_issues_empty_list'" :data-track-experiment="isProject && 'issues_mrs_empty_state'" @@ -185,7 +186,7 @@ export default { <gl-empty-state v-else :title="__('Use issues to collaborate on ideas, solve problems, and plan work')" - :svg-path="emptyStateSvgPath" + :svg-path="$options.emptyStateSvg" :svg-height="null" :primary-button-text="__('Register / Sign In')" :primary-button-link="signInPath" diff --git a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues_experiment.vue b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues_experiment.vue index 41715b6876dea77b47a122d4c009f29452c0eeb1..786d790e195e35966f0a4e65dd9c06849b2dc6b9 100644 --- a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues_experiment.vue +++ b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues_experiment.vue @@ -8,6 +8,7 @@ import GlCardEmptyStateExperiment from './gl_card_empty_state_experiment.vue'; export default { issuesHelpPagePath: helpPagePath('user/project/issues/index'), + jiraIntegrationPath: helpPagePath('integration/jira/issues', { anchor: 'view-jira-issues' }), components: { GlCardEmptyStateExperiment, GlButton, @@ -19,9 +20,6 @@ export default { GlModal: GlModalDirective, }, inject: { - jiraIntegrationPath: { - default: null, - }, newIssuePath: { default: null, }, @@ -176,7 +174,7 @@ export default { > <a class="gl-text-decoration-none!" - :href="jiraIntegrationPath" + :href="$options.jiraIntegrationPath" data-testid="empty-state-jira-int-link" data-track-action="click_jira_int_project_issues_empty_list_page" data-track-label="jira_int_project_issues_empty_list" diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index 901b965aafce0fbc1d0289cdbd23ce4668849137..ed380cc9862bc60de384dbc93ec3eb5abe1a8e2a 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -68,7 +68,6 @@ export async function mountIssuesListApp() { canReadCrmOrganization, email, emailsHelpPagePath, - emptyStateSvgPath, exportCsvPath, fullPath, groupPath, @@ -89,7 +88,6 @@ export async function mountIssuesListApp() { isProject, isPublicVisibilityRestricted, isSignedIn, - jiraIntegrationPath, markdownHelpPath, maxAttachmentSize, newIssuePath, @@ -126,7 +124,6 @@ export async function mountIssuesListApp() { canCreateProjects: parseBoolean(canCreateProjects), canReadCrmContact: parseBoolean(canReadCrmContact), canReadCrmOrganization: parseBoolean(canReadCrmOrganization), - emptyStateSvgPath, fullPath, projectPath: fullPath, groupPath, @@ -148,7 +145,6 @@ export async function mountIssuesListApp() { isProject: parseBoolean(isProject), isPublicVisibilityRestricted: parseBoolean(isPublicVisibilityRestricted), isSignedIn: parseBoolean(isSignedIn), - jiraIntegrationPath, newIssuePath, newProjectPath, releasesPath, diff --git a/app/assets/javascripts/work_items/list/components/work_items_list_app.vue b/app/assets/javascripts/work_items/list/components/work_items_list_app.vue index a2f11a728e4b6896327b30463551b7077405fa8c..b8a8ba56932828b928b9ecae3265fed07581a1c0 100644 --- a/app/assets/javascripts/work_items/list/components/work_items_list_app.vue +++ b/app/assets/javascripts/work_items/list/components/work_items_list_app.vue @@ -1,5 +1,5 @@ <script> -import { GlFilteredSearchToken } from '@gitlab/ui'; +import { GlFilteredSearchToken, GlLoadingIcon } from '@gitlab/ui'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue'; import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; @@ -34,15 +34,25 @@ export default { issuableListTabs, sortOptions, components: { + GlLoadingIcon, IssuableList, IssueCardStatistics, IssueCardTimeInfo, }, inject: ['fullPath', 'initialSort', 'isSignedIn', 'workItemType'], + props: { + eeCreatedWorkItemsCount: { + type: Number, + required: false, + default: 0, + }, + }, data() { return { error: undefined, filterTokens: [], + hasAnyIssues: false, + isInitialAllCountSet: false, pageInfo: {}, pageParams: getInitialPageParams(), sortKey: deriveSortKey({ sort: this.initialSort, sortMap: urlSortParams }), @@ -77,6 +87,11 @@ export default { [STATUS_CLOSED]: closed, [STATUS_ALL]: all, }; + + if (!this.isInitialAllCountSet) { + this.hasAnyIssues = Boolean(all); + this.isInitialAllCountSet = true; + } }, error(error) { this.error = s__( @@ -90,6 +105,12 @@ export default { apiFilterParams() { return convertToApiParams(this.filterTokens); }, + hasSearch() { + return Boolean(this.searchQuery); + }, + isOpenTab() { + return this.state === STATUS_OPEN; + }, searchQuery() { return convertToSearchQuery(this.filterTokens); }, @@ -137,6 +158,15 @@ export default { return this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage; }, }, + watch: { + eeCreatedWorkItemsCount() { + // Only reset isInitialAllCountSet when there's no issues to minimize unmounting IssuableList + if (!this.hasAnyIssues) { + this.isInitialAllCountSet = false; + } + this.$apollo.queries.workItems.refetch(); + }, + }, methods: { getStatus(issue) { return issue.state === STATE_CLOSED ? __('Closed') : undefined; @@ -199,7 +229,10 @@ export default { </script> <template> + <gl-loading-icon v-if="!isInitialAllCountSet && !error" class="gl-mt-5" size="lg" /> + <issuable-list + v-else-if="hasAnyIssues || error" :current-tab="state" :error="error" :has-next-page="pageInfo.hasNextPage" @@ -239,8 +272,16 @@ export default { <issue-card-statistics :issue="issuable" /> </template> + <template #empty-state> + <slot name="list-empty-state" :has-search="hasSearch" :is-open-tab="isOpenTab"></slot> + </template> + <template #list-body> <slot name="list-body"></slot> </template> </issuable-list> + + <div v-else> + <slot name="page-empty-state"></slot> + </div> </template> diff --git a/app/assets/javascripts/work_items/list/index.js b/app/assets/javascripts/work_items/list/index.js index 886400835cc3253dd31769b70654c497ea4124a8..447145601b6e6455bde074c745c120dca271d554 100644 --- a/app/assets/javascripts/work_items/list/index.js +++ b/app/assets/javascripts/work_items/list/index.js @@ -20,6 +20,7 @@ export const mountWorkItemsListApp = () => { hasIssueWeightsFeature, initialSort, isSignedIn, + showNewIssueLink, workItemType, } = el.dataset; @@ -37,6 +38,7 @@ export const mountWorkItemsListApp = () => { initialSort, isSignedIn: parseBoolean(isSignedIn), isGroup: true, + showNewIssueLink: parseBoolean(showNewIssueLink), workItemType, }, render: (createComponent) => createComponent(WorkItemsListApp), diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 9e9c0789a9ecb33d107b0e56fad6ced6b6f46e69..b1efa39fba10e1813d6e663b51715a95a9fdf460 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -135,14 +135,12 @@ def common_issues_list_data(namespace, current_user) { autocomplete_award_emojis_path: autocomplete_award_emojis_path, calendar_path: url_for(safe_params.merge(calendar_url_options)), - empty_state_svg_path: image_path('illustrations/empty-state/empty-service-desk-md.svg'), full_path: namespace.full_path, initial_sort: current_user&.user_preference&.issues_sort, is_issue_repositioning_disabled: issue_repositioning_disabled?.to_s, is_public_visibility_restricted: Gitlab::CurrentSettings.restricted_visibility_levels&.include?(Gitlab::VisibilityLevel::PUBLIC).to_s, is_signed_in: current_user.present?.to_s, - jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'), rss_path: url_for(safe_params.merge(rss_url_options)), sign_in_path: new_user_session_path, has_issue_date_filter_feature: has_issue_date_filter_feature?(namespace, current_user).to_s diff --git a/app/helpers/work_items_helper.rb b/app/helpers/work_items_helper.rb index a1364c312f9c07caf2858dd37800a9ec5c73d785..7b997597d7a7a03cbe06f6e30bc4a258a7099417 100644 --- a/app/helpers/work_items_helper.rb +++ b/app/helpers/work_items_helper.rb @@ -20,7 +20,8 @@ def work_items_list_data(group, current_user) { full_path: group.full_path, initial_sort: current_user&.user_preference&.issues_sort, - is_signed_in: current_user.present?.to_s + is_signed_in: current_user.present?.to_s, + show_new_issue_link: can?(current_user, :create_work_item, group).to_s } end end diff --git a/ee/app/assets/javascripts/work_items/list/components/work_items_list_app.vue b/ee/app/assets/javascripts/work_items/list/components/work_items_list_app.vue index b2d1321f94efbcf72ba38867341519150e298ede..50c9f5f708b838c7ad9b02902563d7dce556a48b 100644 --- a/ee/app/assets/javascripts/work_items/list/components/work_items_list_app.vue +++ b/ee/app/assets/javascripts/work_items/list/components/work_items_list_app.vue @@ -1,39 +1,74 @@ <script> +import emptyStateSvg from '@gitlab/svgs/dist/illustrations/empty-state/empty-epic-md.svg'; +import { GlEmptyState } from '@gitlab/ui'; +import EmptyStateWithAnyIssues from '~/issues/list/components/empty_state_with_any_issues.vue'; import { WORK_ITEM_TYPE_ENUM_EPIC } from '~/work_items/constants'; import WorkItemsListApp from '~/work_items/list/components/work_items_list_app.vue'; import CreateWorkItemModal from '~/work_items/components/create_work_item_modal.vue'; export default { + emptyStateSvg, WORK_ITEM_TYPE_ENUM_EPIC, components: { CreateWorkItemModal, + EmptyStateWithAnyIssues, + GlEmptyState, WorkItemsListApp, }, - inject: ['hasEpicsFeature'], + inject: ['hasEpicsFeature', 'showNewIssueLink'], data() { return { - showEpicCreationForm: false, + createdWorkItemsCount: 0, }; }, methods: { - handleCreated({ workItem }) { - if (workItem.id) { - // Refresh results on list - this.showEpicCreationForm = false; - this.$refs.workItemsListApp.$apollo.queries.workItems.refetch(); - } + handleCreated() { + this.createdWorkItemsCount += 1; }, }, }; </script> <template> - <work-items-list-app ref="workItemsListApp"> - <template v-if="hasEpicsFeature" #nav-actions> + <work-items-list-app :ee-created-work-items-count="createdWorkItemsCount"> + <template v-if="hasEpicsFeature && showNewIssueLink" #nav-actions> <create-work-item-modal class="gl-flex-grow-1" :work-item-type-name="$options.WORK_ITEM_TYPE_ENUM_EPIC" + @workItemCreated="handleCreated" /> </template> + <template v-if="hasEpicsFeature" #list-empty-state="{ hasSearch, isOpenTab }"> + <empty-state-with-any-issues :has-search="hasSearch" is-epic :is-open-tab="isOpenTab"> + <template v-if="showNewIssueLink" #new-issue-button> + <create-work-item-modal + class="gl-flex-grow-1" + :work-item-type-name="$options.WORK_ITEM_TYPE_ENUM_EPIC" + @workItemCreated="handleCreated" + /> + </template> + </empty-state-with-any-issues> + </template> + <template v-if="hasEpicsFeature" #page-empty-state> + <gl-empty-state + :description=" + __('Track groups of issues that share a theme, across projects and milestones') + " + :svg-path="$options.emptyStateSvg" + :title=" + __( + 'Epics let you manage your portfolio of projects more efficiently and with less effort', + ) + " + > + <template v-if="showNewIssueLink" #actions> + <create-work-item-modal + class="gl-flex-grow-1" + :work-item-type-name="$options.WORK_ITEM_TYPE_ENUM_EPIC" + @workItemCreated="handleCreated" + /> + </template> + </gl-empty-state> + </template> </work-items-list-app> </template> diff --git a/ee/spec/frontend/issues/list/components/issues_list_app_spec.js b/ee/spec/frontend/issues/list/components/issues_list_app_spec.js index 04e01d4fa7060fc63767146b7a55c5f4398d18f6..447f9315ac888616700913347648a921cf83ee81 100644 --- a/ee/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/ee/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -62,7 +62,6 @@ describe('EE IssuesListApp component', () => { canCreateProjects: false, canReadCrmContact: false, canReadCrmOrganization: false, - emptyStateSvgPath: 'empty-state.svg', exportCsvPath: 'export/csv/path', fullPath: 'path/to/project', groupPath: 'group/path', @@ -82,7 +81,6 @@ describe('EE IssuesListApp component', () => { isProject: true, isPublicVisibilityRestricted: false, isSignedIn: true, - jiraIntegrationPath: 'jira/integration/path', newIssuePath: 'new/issue/path', newProjectPath: 'new/project/path', releasesPath: 'releases/path', diff --git a/ee/spec/frontend/work_items/list/components/work_items_list_app_spec.js b/ee/spec/frontend/work_items/list/components/work_items_list_app_spec.js index 833722619594c821418c43adfa1872fb993b2c03..17e1b819faeeb1dd299a8038027bd35f7175a346 100644 --- a/ee/spec/frontend/work_items/list/components/work_items_list_app_spec.js +++ b/ee/spec/frontend/work_items/list/components/work_items_list_app_spec.js @@ -1,32 +1,96 @@ +import { GlEmptyState } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import EmptyStateWithAnyIssues from '~/issues/list/components/empty_state_with_any_issues.vue'; import CreateWorkItemModal from '~/work_items/components/create_work_item_modal.vue'; +import WorkItemsListApp from '~/work_items/list/components/work_items_list_app.vue'; import EEWorkItemsListApp from 'ee/work_items/list/components/work_items_list_app.vue'; describe('WorkItemsListApp EE component', () => { + /** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */ let wrapper; const findCreateWorkItemModal = () => wrapper.findComponent(CreateWorkItemModal); + const findListEmptyState = () => wrapper.findComponent(EmptyStateWithAnyIssues); + const findPageEmptyState = () => wrapper.findComponent(GlEmptyState); + const findWorkItemsListApp = () => wrapper.findComponent(WorkItemsListApp); - const mountComponent = ({ hasEpicsFeature = false } = {}) => { + const mountComponent = ({ hasEpicsFeature = true, showNewIssueLink = true } = {}) => { wrapper = shallowMount(EEWorkItemsListApp, { provide: { hasEpicsFeature, + showNewIssueLink, }, }); }; - it('renders create work item modal when epics feature available', () => { - mountComponent({ hasEpicsFeature: true }); + describe('create-work-item modal', () => { + describe.each` + hasEpicsFeature | showNewIssueLink | exists + ${false} | ${false} | ${false} + ${true} | ${false} | ${false} + ${false} | ${true} | ${false} + ${true} | ${true} | ${true} + `( + 'when hasEpicsFeature=$hasEpicsFeature and showNewIssueLink=$showNewIssueLink', + ({ hasEpicsFeature, showNewIssueLink, exists }) => { + it(`${exists ? 'renders' : 'does not render'}`, () => { + mountComponent({ hasEpicsFeature, showNewIssueLink }); - expect(findCreateWorkItemModal().props()).toEqual({ - workItemTypeName: 'EPIC', - asDropdownItem: false, + expect(findCreateWorkItemModal().exists()).toBe(exists); + }); + }, + ); + + describe('when "workItemCreated" event is emitted', () => { + it('increments `eeCreatedWorkItemsCount` prop on WorkItemsListApp', async () => { + mountComponent(); + + expect(findWorkItemsListApp().props('eeCreatedWorkItemsCount')).toBe(0); + + findCreateWorkItemModal().vm.$emit('workItemCreated'); + await nextTick(); + + expect(findWorkItemsListApp().props('eeCreatedWorkItemsCount')).toBe(1); + }); }); }); - it('does not render modal when epics feature not available', () => { - mountComponent({ hasEpicsFeature: false }); + describe('empty states', () => { + describe('when hasEpicsFeature=true', () => { + beforeEach(() => { + mountComponent({ hasEpicsFeature: true }); + }); + + it('renders list empty state', () => { + expect(findListEmptyState().props()).toEqual({ + hasSearch: false, + isEpic: true, + isOpenTab: true, + }); + }); - expect(findCreateWorkItemModal().exists()).toBe(false); + it('renders page empty state', () => { + expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ + description: 'Track groups of issues that share a theme, across projects and milestones', + title: + 'Epics let you manage your portfolio of projects more efficiently and with less effort', + }); + }); + }); + + describe('when hasEpicsFeature=false', () => { + beforeEach(() => { + mountComponent({ hasEpicsFeature: false }); + }); + + it('does not render list empty state', () => { + expect(findListEmptyState().exists()).toBe(false); + }); + + it('does not render page empty state', () => { + expect(findPageEmptyState().exists()).toBe(false); + }); + }); }); }); diff --git a/spec/frontend/issues/list/components/empty_state_with_any_issues_spec.js b/spec/frontend/issues/list/components/empty_state_with_any_issues_spec.js index 8f632ed12c55619e358f36d5776a918aef371e35..78f0cb53e97faa735efaeb3fac02fe57413c36a5 100644 --- a/spec/frontend/issues/list/components/empty_state_with_any_issues_spec.js +++ b/spec/frontend/issues/list/components/empty_state_with_any_issues_spec.js @@ -11,11 +11,11 @@ describe('EmptyStateWithAnyIssues component', () => { wrapper = shallowMount(EmptyStateWithAnyIssues, { propsData: { hasSearch: true, + isEpic: false, isOpenTab: true, ...props, }, provide: { - emptyStateSvgPath: 'empty/state/svg/path', newIssuePath: 'new/issue/path', showNewIssueLink: false, }, @@ -23,42 +23,46 @@ describe('EmptyStateWithAnyIssues component', () => { }; describe('when there is a search (with no results)', () => { - beforeEach(() => { + it('shows empty state', () => { mountComponent({ hasSearch: true }); - }); - it('shows empty state', () => { expect(findGlEmptyState().props()).toMatchObject({ description: 'To widen your search, change or remove filters above', title: 'Sorry, your filter produced no results', - svgPath: 'empty/state/svg/path', }); }); }); describe('when "Open" tab is active', () => { - beforeEach(() => { + it('shows empty state', () => { mountComponent({ hasSearch: false, isOpenTab: true }); - }); - it('shows empty state', () => { - expect(findGlEmptyState().props()).toMatchObject({ - description: 'To keep this project going, create a new issue', - title: 'There are no open issues', - svgPath: 'empty/state/svg/path', - }); + expect(findGlEmptyState().props('title')).toBe('There are no open issues'); }); }); describe('when "Closed" tab is active', () => { - beforeEach(() => { + it('shows empty state', () => { mountComponent({ hasSearch: false, isOpenTab: false }); + + expect(findGlEmptyState().props('title')).toBe('There are no closed issues'); }); + }); - it('shows empty state', () => { - expect(findGlEmptyState().props()).toMatchObject({ - title: 'There are no closed issues', - svgPath: 'empty/state/svg/path', + describe('when epic', () => { + describe('when "Open" tab is active', () => { + it('shows empty state', () => { + mountComponent({ hasSearch: false, isEpic: true, isOpenTab: true }); + + expect(findGlEmptyState().props('title')).toBe('There are no open epics'); + }); + }); + + describe('when "Closed" tab is active', () => { + it('shows empty state', () => { + mountComponent({ hasSearch: false, isEpic: true, isOpenTab: false }); + + expect(findGlEmptyState().props('title')).toBe('There are no closed epics'); }); }); }); diff --git a/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js b/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js index 956fd485d28b3720a545db7de2d934f65c4fa2f8..f0790d83f694c875e785f851558c1ae6b1b8c1c2 100644 --- a/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js +++ b/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js @@ -17,10 +17,8 @@ describe('EmptyStateWithoutAnyIssues component', () => { const defaultProvide = { canCreateProjects: false, - emptyStateSvgPath: 'empty/state/svg/path', fullPath: 'full/path', isSignedIn: true, - jiraIntegrationPath: 'jira/integration/path', newIssuePath: 'new/issue/path', newProjectPath: 'new/project/path', showNewIssueLink: false, @@ -64,10 +62,9 @@ describe('EmptyStateWithoutAnyIssues component', () => { it('renders empty state', () => { mountComponent(); - expect(findGlEmptyState().props()).toMatchObject({ - title: 'Use issues to collaborate on ideas, solve problems, and plan work', - svgPath: defaultProvide.emptyStateSvgPath, - }); + expect(findGlEmptyState().props('title')).toBe( + 'Use issues to collaborate on ideas, solve problems, and plan work', + ); }); describe('description', () => { @@ -280,7 +277,9 @@ describe('EmptyStateWithoutAnyIssues component', () => { }); it('renders Jira integration docs link', () => { - expect(findJiraDocsLink().attributes('href')).toBe(defaultProvide.jiraIntegrationPath); + expect(findJiraDocsLink().attributes('href')).toBe( + '/help/integration/jira/issues#view-jira-issues', + ); }); }); @@ -308,7 +307,6 @@ describe('EmptyStateWithoutAnyIssues component', () => { it('renders empty state', () => { expect(findGlEmptyState().props()).toMatchObject({ title: 'Use issues to collaborate on ideas, solve problems, and plan work', - svgPath: defaultProvide.emptyStateSvgPath, primaryButtonText: 'Register / Sign In', primaryButtonLink: defaultProvide.signInPath, }); diff --git a/spec/frontend/issues/list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js index 4f8285537de9f5259aa17eede6423b7b6cc2f04d..c4e86e3e711aaa70b26aa07dfba109f30ab63834 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -112,7 +112,6 @@ describe('CE IssuesListApp component', () => { canCreateProjects: false, canReadCrmContact: false, canReadCrmOrganization: false, - emptyStateSvgPath: 'empty-state.svg', exportCsvPath: 'export/csv/path', fullPath: 'path/to/project', hasAnyIssues: true, @@ -129,7 +128,6 @@ describe('CE IssuesListApp component', () => { isProject: true, isPublicVisibilityRestricted: false, isSignedIn: true, - jiraIntegrationPath: 'jira/integration/path', newIssuePath: 'new/issue/path', newProjectPath: 'new/project/path', releasesPath: 'releases/path', @@ -610,6 +608,7 @@ describe('CE IssuesListApp component', () => { it('shows EmptyStateWithAnyIssues empty state', () => { expect(wrapper.findComponent(EmptyStateWithAnyIssues).props()).toEqual({ hasSearch: false, + isEpic: false, isOpenTab: true, }); }); diff --git a/spec/frontend/work_items/list/components/work_items_list_app_spec.js b/spec/frontend/work_items/list/components/work_items_list_app_spec.js index dcb532c7f770c220846444f96d80283bbca93aee..89644df305da7696ed84669d267916394d9c2ed2 100644 --- a/spec/frontend/work_items/list/components/work_items_list_app_spec.js +++ b/spec/frontend/work_items/list/components/work_items_list_app_spec.js @@ -1,3 +1,4 @@ +import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { cloneDeep } from 'lodash'; import Vue, { nextTick } from 'vue'; @@ -33,6 +34,7 @@ jest.mock('~/lib/utils/scroll_utils', () => ({ scrollUp: jest.fn() })); jest.mock('~/sentry/sentry_browser_wrapper'); describe('WorkItemsListApp component', () => { + /** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */ let wrapper; Vue.use(VueApollo); @@ -63,44 +65,62 @@ describe('WorkItemsListApp component', () => { }); }; - it('renders IssuableList component', () => { + it('renders loading icon when initially fetching work items', () => { mountComponent(); - expect(findIssuableList().props()).toMatchObject({ - currentTab: STATUS_OPEN, - error: '', - initialSortBy: CREATED_DESC, - issuables: [], - issuablesLoading: true, - namespace: 'work-items', - recentSearchesStorageKey: 'issues', - showWorkItemTypeIcon: true, - sortOptions, - tabs: WorkItemsListApp.issuableListTabs, - }); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); - it('renders tab counts', async () => { - mountComponent(); - await waitForPromises(); + describe('when work items are fetched', () => { + beforeEach(async () => { + mountComponent(); + await waitForPromises(); + }); - expect(cloneDeep(findIssuableList().props('tabCounts'))).toEqual({ - all: 3, - closed: 1, - opened: 2, + it('renders IssuableList component', () => { + expect(findIssuableList().props()).toMatchObject({ + currentTab: STATUS_OPEN, + error: '', + initialSortBy: CREATED_DESC, + namespace: 'work-items', + recentSearchesStorageKey: 'issues', + showWorkItemTypeIcon: true, + sortOptions, + tabs: WorkItemsListApp.issuableListTabs, + }); }); - }); - it('renders IssueCardStatistics component', () => { - mountComponent(); + it('renders tab counts', () => { + expect(cloneDeep(findIssuableList().props('tabCounts'))).toEqual({ + all: 3, + closed: 1, + opened: 2, + }); + }); - expect(findIssueCardStatistics().exists()).toBe(true); - }); + it('renders IssueCardStatistics component', () => { + expect(findIssueCardStatistics().exists()).toBe(true); + }); - it('renders IssueCardTimeInfo component', () => { - mountComponent(); + it('renders IssueCardTimeInfo component', () => { + expect(findIssueCardTimeInfo().exists()).toBe(true); + }); + + it('renders work items', () => { + expect(findIssuableList().props('issuables')).toEqual( + groupWorkItemsQueryResponse.data.group.workItems.nodes, + ); + }); - expect(findIssueCardTimeInfo().exists()).toBe(true); + it('calls query to fetch work items', () => { + expect(defaultQueryHandler).toHaveBeenCalledWith({ + fullPath: 'full/path', + sort: CREATED_DESC, + state: STATUS_OPEN, + firstPageSize: 20, + types: [null], + }); + }); }); describe('pagination controls', () => { @@ -122,53 +142,30 @@ describe('WorkItemsListApp component', () => { }); }); - it('renders work items', async () => { - mountComponent(); - await waitForPromises(); - - expect(findIssuableList().props('issuables')).toEqual( - groupWorkItemsQueryResponse.data.group.workItems.nodes, - ); - }); + describe('when workItemType is provided', () => { + it('filters work items by workItemType', () => { + const type = 'EPIC'; + mountComponent({ provide: { workItemType: type } }); - it('fetches work items', () => { - mountComponent(); - - expect(defaultQueryHandler).toHaveBeenCalledWith({ - fullPath: 'full/path', - sort: CREATED_DESC, - state: STATUS_OPEN, - firstPageSize: 20, - types: [null], - }); - }); - - it('filters work items by workItemType', () => { - const type = 'EPIC'; - mountComponent({ - provide: { - workItemType: type, - }, - }); - - expect(defaultQueryHandler).toHaveBeenCalledWith({ - fullPath: 'full/path', - sort: CREATED_DESC, - state: STATUS_OPEN, - firstPageSize: 20, - types: [type], + expect(defaultQueryHandler).toHaveBeenCalledWith({ + fullPath: 'full/path', + sort: CREATED_DESC, + state: STATUS_OPEN, + firstPageSize: 20, + types: [type], + }); }); }); describe('when there is an error fetching work items', () => { + const message = 'Something went wrong when fetching work items. Please try again.'; + beforeEach(async () => { mountComponent({ queryHandler: jest.fn().mockRejectedValue(new Error('ERROR')) }); await waitForPromises(); }); it('renders an error message', () => { - const message = 'Something went wrong when fetching work items. Please try again.'; - expect(findIssuableList().props('error')).toBe(message); expect(Sentry.captureException).toHaveBeenCalledWith(new Error('ERROR')); }); @@ -177,7 +174,22 @@ describe('WorkItemsListApp component', () => { findIssuableList().vm.$emit('dismiss-alert'); await nextTick(); - expect(findIssuableList().props('error')).toBe(''); + expect(wrapper.text()).not.toContain(message); + }); + }); + + describe('watcher', () => { + describe('when eeCreatedWorkItemsCount is updated', () => { + it('refetches work items', async () => { + mountComponent(); + await waitForPromises(); + + expect(defaultQueryHandler).toHaveBeenCalledTimes(1); + + await wrapper.setProps({ eeCreatedWorkItemsCount: 1 }); + + expect(defaultQueryHandler).toHaveBeenCalledTimes(2); + }); }); }); @@ -189,7 +201,7 @@ describe('WorkItemsListApp component', () => { avatar_url: 'avatar/url', }; - beforeEach(() => { + beforeEach(async () => { window.gon = { current_user_id: mockCurrentUser.id, current_user_fullname: mockCurrentUser.name, @@ -197,6 +209,7 @@ describe('WorkItemsListApp component', () => { current_user_avatar_url: mockCurrentUser.avatar_url, }; mountComponent(); + await waitForPromises(); }); it('renders all tokens', () => { @@ -228,6 +241,7 @@ describe('WorkItemsListApp component', () => { describe('when "filter" event is emitted by IssuableList', () => { it('fetches filtered work items', async () => { mountComponent(); + await waitForPromises(); findIssuableList().vm.$emit('filter', [ { type: FILTERED_SEARCH_TERM, value: { data: 'find issues', operator: 'undefined' } }, @@ -280,6 +294,7 @@ describe('WorkItemsListApp component', () => { } else { mountComponent(); } + await waitForPromises(); findIssuableList().vm.$emit('sort', sortKey); await waitForPromises(); @@ -291,9 +306,10 @@ describe('WorkItemsListApp component', () => { ); describe('when user is signed in', () => { - it('calls mutation to save sort preference', () => { + it('calls mutation to save sort preference', async () => { const mutationMock = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse); mountComponent({ sortPreferenceMutationResponse: mutationMock }); + await waitForPromises(); findIssuableList().vm.$emit('sort', UPDATED_DESC); @@ -305,6 +321,7 @@ describe('WorkItemsListApp component', () => { .fn() .mockResolvedValue(setSortPreferenceMutationResponseWithErrors); mountComponent({ sortPreferenceMutationResponse: mutationMock }); + await waitForPromises(); findIssuableList().vm.$emit('sort', UPDATED_DESC); await waitForPromises(); @@ -314,12 +331,13 @@ describe('WorkItemsListApp component', () => { }); describe('when user is signed out', () => { - it('does not call mutation to save sort preference', () => { + it('does not call mutation to save sort preference', async () => { const mutationMock = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse); mountComponent({ provide: { isSignedIn: false }, sortPreferenceMutationResponse: mutationMock, }); + await waitForPromises(); findIssuableList().vm.$emit('sort', CREATED_DESC); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index aad2369ca6ec0b301395718b32125d546bde5476..c0feb5796c5df86a4a8fd48332a1be18839c639f 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -3970,6 +3970,29 @@ export const groupWorkItemsQueryResponse = { }, }; +export const emptyGroupWorkItemsQueryResponse = { + data: { + group: { + id: 'gid://gitlab/Group/3', + workItemStateCounts: { + all: 0, + closed: 0, + opened: 0, + }, + workItems: { + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'startCursor', + endCursor: 'endCursor', + __typename: 'PageInfo', + }, + nodes: [], + }, + }, + }, +}; + export const updateWorkItemMutationResponseFactory = (options) => { const response = workItemResponseFactory(options); return { diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index f26cac05490008e401ef6979bb1eefe6b7130a0c..0a0d659f8e2c2b9f882e3078b806a88136ee2580 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -213,7 +213,6 @@ can_import_issues: 'true', email: current_user&.notification_email_or_default, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), - empty_state_svg_path: '#', export_csv_path: export_csv_project_issues_path(project), full_path: project.full_path, has_any_issues: project_issues(project).exists?.to_s, @@ -224,7 +223,6 @@ is_project: 'true', is_public_visibility_restricted: Gitlab::CurrentSettings.restricted_visibility_levels ? 'false' : '', is_signed_in: current_user.present?.to_s, - jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'), markdown_help_path: help_page_path('user/markdown'), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), new_issue_path: new_project_issue_path(project), @@ -281,12 +279,10 @@ autocomplete_award_emojis_path: autocomplete_award_emojis_path, calendar_path: '#', can_create_projects: 'true', - empty_state_svg_path: '#', full_path: group.full_path, has_any_issues: false.to_s, has_any_projects: true.to_s, is_signed_in: current_user.present?.to_s, - jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'), new_project_path: new_project_path(namespace_id: group.id), rss_path: '#', sign_in_path: new_user_session_path, diff --git a/spec/helpers/work_items_helper_spec.rb b/spec/helpers/work_items_helper_spec.rb index 587faa8dfca57677e1fc7bd61465998e8cbb6c75..80039f4cc9ca682b28ce43fc7eb2ee3ddab96cdf 100644 --- a/spec/helpers/work_items_helper_spec.rb +++ b/spec/helpers/work_items_helper_spec.rb @@ -32,12 +32,14 @@ it 'returns expected data' do allow(helper).to receive(:current_user).and_return(current_user) + allow(helper).to receive(:can?).and_return(true) expect(work_items_list_data).to include( { full_path: group.full_path, initial_sort: current_user&.user_preference&.issues_sort, - is_signed_in: current_user.present?.to_s + is_signed_in: current_user.present?.to_s, + show_new_issue_link: 'true' } ) end