diff --git a/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue b/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue index c534f184abc31b17560b8169815be5a6fc5727d5..4672e67d9e636b68123ffe17976afde30026fbae 100644 --- a/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue +++ b/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue @@ -1,16 +1,33 @@ <script> -import SafeHtml from '~/vue_shared/directives/safe_html'; +import { GlTab, GlTabs } from '@gitlab/ui'; +import { __ } from '~/locale'; +import CiResourceReadme from './ci_resource_readme.vue'; export default { - directives: { SafeHtml }, + components: { + CiResourceReadme, + GlTab, + GlTabs, + }, props: { - readmeHtml: { - required: true, + resourceId: { type: String, + required: true, + }, + }, + i18n: { + tabs: { + readme: __('Readme'), }, }, }; </script> + <template> - <div v-safe-html="readmeHtml"></div> + <gl-tabs> + <gl-tab :title="$options.i18n.tabs.readme" lazy> + <ci-resource-readme :resource-id="resourceId" /> + </gl-tab> + </gl-tabs> </template> +<style></style> diff --git a/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue b/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue index da4745dcdb8e847c8479f4c5c2de4051625239da..6673785ffd2b376218ad68476cc88bd1ce22e227 100644 --- a/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue +++ b/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue @@ -72,7 +72,7 @@ export default { }; </script> <template> - <div class="gl-border-b"> + <div> <ci-resource-header-skeleton-loader v-if="isLoadingSharedData" class="gl-py-5" /> <div v-else class="gl-display-flex gl-py-5"> <gl-avatar-link :href="resource.webPath"> diff --git a/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue b/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue new file mode 100644 index 0000000000000000000000000000000000000000..d473833869d517c8bc21f918c3108d7be95a537c --- /dev/null +++ b/ee/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue @@ -0,0 +1,55 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import getCiCatalogResourceReadme from '../../graphql/queries/get_ci_catalog_resource_readme.query.graphql'; + +export default { + components: { + GlLoadingIcon, + }, + directives: { SafeHtml }, + props: { + resourceId: { + type: String, + required: true, + }, + }, + data() { + return { + readmeHtml: null, + }; + }, + apollo: { + readmeHtml: { + query: getCiCatalogResourceReadme, + variables() { + return { + id: this.resourceId, + }; + }, + update(data) { + return data?.ciCatalogResource?.readmeHtml || null; + }, + error() { + createAlert({ message: this.$options.i18n.loadingError }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.readmeHtml.loading; + }, + }, + i18n: { + loadingError: __("There was a problem loading this project's readme content."), + }, +}; +</script> +<template> + <div> + <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" /> + <div v-else v-safe-html="readmeHtml"></div> + </div> +</template> diff --git a/ee/app/assets/javascripts/ci/catalog/components/pages/ci_resource_details_page.vue b/ee/app/assets/javascripts/ci/catalog/components/pages/ci_resource_details_page.vue index 40a0c9c56be040a109a77c0d4e4d030d04ed16fe..da2c73be900bc29914f1e6ebfd3151dfe3f84f53 100644 --- a/ee/app/assets/javascripts/ci/catalog/components/pages/ci_resource_details_page.vue +++ b/ee/app/assets/javascripts/ci/catalog/components/pages/ci_resource_details_page.vue @@ -1,5 +1,5 @@ <script> -import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; +import { GlEmptyState } from '@gitlab/ui'; import { s__ } from '~/locale'; import { createAlert } from '~/alert'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -14,7 +14,6 @@ export default { CiResourceDetails, CiResourceHeader, GlEmptyState, - GlLoadingIcon, }, inject: ['ciCatalogPath'], data() { @@ -104,8 +103,7 @@ export default { :pipeline-status="pipelineStatus" :resource="resourceSharedData" /> - <gl-loading-icon v-if="isLoadingDetails" size="lg" class="gl-mt-5" /> - <ci-resource-details v-else :readme-html="resourceAdditionalDetails.readmeHtml" /> + <ci-resource-details :resource-id="graphQLId" /> </div> </div> </template> diff --git a/ee/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql b/ee/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql index e0549b9feab9161d7911dcac9b39bd9f4c37428e..382d38667953ce9dea74028806dc4ecb8fd38020 100644 --- a/ee/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql +++ b/ee/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql @@ -1,7 +1,6 @@ query getCiCatalogResourceDetails($id: CiCatalogResourceID!) { ciCatalogResource(id: $id) { id - readmeHtml openIssuesCount openMergeRequestsCount versions(first: 1) { diff --git a/ee/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_readme.query.graphql b/ee/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_readme.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..6b3d0cdcfc70e1b9f19565afc1226ca0851c4d83 --- /dev/null +++ b/ee/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_readme.query.graphql @@ -0,0 +1,6 @@ +query getCiCatalogResourceReadme($id: CiCatalogResourceID!) { + ciCatalogResource(id: $id) { + id + readmeHtml + } +} diff --git a/ee/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js b/ee/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js index 13dc6896ae365756c62054056df7b45e4e0264e1..b7efc7f2a7dd367953c048472df9ca59ca5f494b 100644 --- a/ee/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js +++ b/ee/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js @@ -1,10 +1,14 @@ +import { GlTabs, GlTab } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import CiResourceDetails from 'ee/ci/catalog/components/details/ci_resource_details.vue'; +import CiResourceReadme from 'ee/ci/catalog/components/details/ci_resource_readme.vue'; describe('CiResourceDetails', () => { let wrapper; - const defaultProps = { readmeHtml: '<h1>Hello world</h1>' }; + const defaultProps = { + resourceId: 'gid://gitlab/Ci::Catalog::Resource/1', + }; const createComponent = ({ props = {} } = {}) => { wrapper = shallowMount(CiResourceDetails, { @@ -12,16 +16,37 @@ describe('CiResourceDetails', () => { ...defaultProps, ...props, }, + stubs: { + GlTabs, + }, }); }; + const findAllTabs = () => wrapper.findAllComponents(GlTab); + const findCiResourceReadme = () => wrapper.findComponent(CiResourceReadme); + + beforeEach(() => { + createComponent(); + }); + + describe('tabs', () => { + it('renders the right number of tabs', () => { + expect(findAllTabs()).toHaveLength(1); + }); + + it('renders the readme tab as default', () => { + expect(findCiResourceReadme().exists()).toBe(true); + }); - describe('when mounted', () => { - beforeEach(() => { - createComponent(); + it('passes lazy attribute to all tabs', () => { + findAllTabs().wrappers.forEach((tab) => { + expect(tab.attributes().lazy).not.toBeUndefined(); + }); }); - it('renders the received HTML', () => { - expect(wrapper.html()).toContain(defaultProps.readmeHtml); + describe('readme tab', () => { + it('passes the right props to the readme component', () => { + expect(findCiResourceReadme().props().resourceId).toBe(defaultProps.resourceId); + }); }); }); }); diff --git a/ee/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js b/ee/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..d8af6ccdd8347ad72171d18d267d8bb6df9199a7 --- /dev/null +++ b/ee/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js @@ -0,0 +1,96 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CiResourceReadme from 'ee/ci/catalog/components/details/ci_resource_readme.vue'; +import getCiCatalogResourceReadme from 'ee/ci/catalog/graphql/queries/get_ci_catalog_resource_readme.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; + +jest.mock('~/alert'); + +Vue.use(VueApollo); + +const readmeHtml = '<h1>This is a readme file</h1>'; +const resourceId = 'gid://gitlab/Ci::Catalog::Resource/1'; + +describe('CiResourceReadme', () => { + let wrapper; + let mockReadmeResponse; + + const readmeMockData = { + data: { + ciCatalogResource: { + id: resourceId, + readmeHtml, + }, + }, + }; + + const defaultProps = { resourceId }; + + const createComponent = ({ props = {} } = {}) => { + const handlers = [[getCiCatalogResourceReadme, mockReadmeResponse]]; + + wrapper = shallowMountExtended(CiResourceReadme, { + propsData: { + ...defaultProps, + ...props, + }, + apolloProvider: createMockApollo(handlers), + }); + }; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + beforeEach(() => { + mockReadmeResponse = jest.fn(); + }); + + describe('when loading', () => { + beforeEach(() => { + mockReadmeResponse.mockResolvedValue(readmeMockData); + createComponent(); + }); + + it('renders only a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(wrapper.html()).not.toContain(readmeHtml); + }); + }); + + describe('when mounted', () => { + beforeEach(async () => { + mockReadmeResponse.mockResolvedValue(readmeMockData); + + createComponent(); + await waitForPromises(); + }); + + it('renders only the received HTML', () => { + expect(findLoadingIcon().exists()).toBe(false); + expect(wrapper.html()).toContain(readmeHtml); + }); + + it('does not render an error', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + }); + + describe('when there is an error loading the readme', () => { + beforeEach(async () => { + mockReadmeResponse.mockRejectedValue({ errors: [] }); + + createComponent(); + await waitForPromises(); + }); + + it('calls the createAlert function to show an error', () => { + expect(createAlert).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ + message: "There was a problem loading this project's readme content.", + }); + }); + }); +}); diff --git a/ee/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js b/ee/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js index ef90ddbb5f865e1a5ff2224618dd866a13fd99a0..2bd5773f706568e8c55f0984a0bd0795d1f8b3e8 100644 --- a/ee/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js +++ b/ee/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js @@ -1,11 +1,11 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; -import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; +import { GlEmptyState } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { cacheConfig } from 'ee/ci/catalog/graphql/settings'; +import { CI_CATALOG_RESOURCE_TYPE, cacheConfig } from 'ee/ci/catalog/graphql/settings'; import getCiCatalogResourceSharedData from 'ee/ci/catalog/graphql/queries/get_ci_catalog_resource_shared_data.query.graphql'; import getCiCatalogResourceDetails from 'ee/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql'; @@ -17,6 +17,7 @@ import CiResourceHeaderSkeletonLoader from 'ee/ci/catalog/components/details/ci_ import { createRouter } from 'ee/ci/catalog/router/index'; import { CI_RESOURCE_DETAILS_PAGE_NAME } from 'ee/ci/catalog/router/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import { catalogSharedDataMock, catalogAdditionalDetailsMock } from '../../mock'; Vue.use(VueApollo); @@ -41,7 +42,6 @@ describe('CiResourceDetailsPage', () => { const findDetailsComponent = () => wrapper.findComponent(CiResourceDetails); const findHeaderComponent = () => wrapper.findComponent(CiResourceHeader); const findEmptyState = () => wrapper.findComponent(GlEmptyState); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findHeaderSkeletonLoader = () => wrapper.findComponent(CiResourceHeaderSkeletonLoader); const createComponent = ({ props = {} } = {}) => { @@ -89,8 +89,7 @@ describe('CiResourceDetailsPage', () => { createComponent(); }); - it('renders only the details loading state', () => { - expect(findLoadingIcon().exists()).toBe(true); + it('renders the header skeleton loader', () => { expect(findHeaderSkeletonLoader().exists()).toBe(false); }); @@ -111,11 +110,11 @@ describe('CiResourceDetailsPage', () => { createComponent(); }); - it('renders all loading states', () => { - expect(findLoadingIcon().exists()).toBe(true); + it('does not render the header skeleton', () => { + expect(findHeaderSkeletonLoader().exists()).toBe(false); }); - it('passes down the loading state to the header component', () => { + it('passes all loading state to the header component as true', () => { expect(findHeaderComponent().props()).toMatchObject({ isLoadingDetails: true, isLoadingSharedData: true, @@ -150,8 +149,8 @@ describe('CiResourceDetailsPage', () => { await waitForPromises(); }); - it('does not render a loading icon', () => { - expect(findLoadingIcon().exists()).toBe(false); + it('does not render the header skeleton loader', () => { + expect(findHeaderSkeletonLoader().exists()).toBe(false); }); describe('Catalog header', () => { @@ -179,7 +178,7 @@ describe('CiResourceDetailsPage', () => { it('passes expected props', () => { expect(findDetailsComponent().props()).toEqual({ - readmeHtml: defaultAdditionalData.readmeHtml, + resourceId: convertToGraphQLId(CI_CATALOG_RESOURCE_TYPE, defaultAdditionalData.id), }); }); }); diff --git a/ee/spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js b/ee/spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js index e720829065b7664ca9b9288102b9366800126889..e4a3d05d0985233e12704423f860f39ab41e2600 100644 --- a/ee/spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js +++ b/ee/spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js @@ -74,6 +74,7 @@ describe('CiResourcesPage', () => { await createComponent(); }); + it('renders the empty state', () => { expect(findLoadingState().exists()).toBe(false); expect(findEmptyState().exists()).toBe(true); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b7b88999dd990b45e2736a48a5d41fcb82a1ede6..80c8ab86e7184822ef656e79ae6985ff03fe2275 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -38757,6 +38757,9 @@ msgstr "" msgid "Read their documentation." msgstr "" +msgid "Readme" +msgstr "" + msgid "Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project." msgstr "" @@ -48003,6 +48006,9 @@ msgstr "" msgid "There was a problem handling the pipeline data." msgstr "" +msgid "There was a problem loading this project's readme content." +msgstr "" + msgid "There was a problem sending the confirmation email" msgstr ""