diff options
25 files changed, 433 insertions, 84 deletions
diff --git a/.gitlab/merge_request_templates/New Version of gitlab-styles.md b/.gitlab/merge_request_templates/New Version of gitlab-styles.md index 5e7f2319650..85603f6a944 100644 --- a/.gitlab/merge_request_templates/New Version of gitlab-styles.md +++ b/.gitlab/merge_request_templates/New Version of gitlab-styles.md @@ -39,6 +39,11 @@ This MR can be reused to upgrade `gitlab-styles` in this project after a new ver This checklist encourages us to confirm any changes have been analyzed to reduce risks in quality, performance, reliability, security, and maintainability. -* [ ] I have evaluated the [MR acceptance checklist](https://docs.gitlab.com/ee/development/code_review.html#acceptance-checklist) for this MR. +- [ ] I have evaluated the [MR acceptance checklist](https://docs.gitlab.com/ee/development/code_review.html#acceptance-checklist) for this MR. + +## After merge + +- [ ] Notify team members of the upgrade by creating an announcement in relevant Slack channels (`#backend` and `#development`) +and Engineering Week In Review (EWIR). /label ~"type::maintenance" ~"maintenance::dependency" ~backend ~"Engineering Productivity" ~"static code analysis" diff --git a/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue b/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue index 20a62d0eeb2..64229f54904 100644 --- a/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue +++ b/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue @@ -6,7 +6,7 @@ import { CATALOG_FEEDBACK_DISMISSED_KEY } from '../../constants'; const defaultTitle = __('CI/CD Catalog'); const defaultDescription = s__( - 'CiCatalog|Discover CI configuration resources for a seamless CI/CD experience.', + 'CiCatalog|Discover CI/CD components that can improve your pipeline with additional functionality.', ); export default { @@ -45,7 +45,7 @@ export default { }; </script> <template> - <div> + <div class="page-title-holder"> <gl-banner v-if="!isFeedbackBannerDismissed" class="gl-mt-5" @@ -58,7 +58,7 @@ export default { {{ $options.i18n.banner.description }} </p> </gl-banner> - <h1 class="gl-font-size-h-display">{{ pageTitle }}</h1> + <h1 class="page-title gl-font-size-h-display">{{ pageTitle }}</h1> <p> <span data-testid="page-description">{{ pageDescription }}</span> <gl-link :href="$options.learnMorePath" target="_blank">{{ diff --git a/app/assets/javascripts/organizations/index/components/app.vue b/app/assets/javascripts/organizations/index/components/app.vue index c47f4ed52c5..4935e0122e3 100644 --- a/app/assets/javascripts/organizations/index/components/app.vue +++ b/app/assets/javascripts/organizations/index/components/app.vue @@ -2,6 +2,7 @@ import { GlButton } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { createAlert } from '~/alert'; +import { DEFAULT_PER_PAGE } from '~/api'; import organizationsQuery from '../graphql/organizations.query.graphql'; import OrganizationsView from './organizations_view.vue'; @@ -21,14 +22,23 @@ export default { inject: ['newOrganizationUrl'], data() { return { - organizations: [], + organizations: {}, + pagination: { + first: DEFAULT_PER_PAGE, + after: null, + last: null, + before: null, + }, }; }, apollo: { organizations: { query: organizationsQuery, + variables() { + return this.pagination; + }, update(data) { - return data.currentUser.organizations.nodes; + return data.currentUser.organizations; }, error(error) { createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); @@ -37,12 +47,30 @@ export default { }, computed: { showHeader() { - return this.loading || this.organizations.length; + return this.loading || this.organizations.nodes?.length; }, loading() { return this.$apollo.queries.organizations.loading; }, }, + methods: { + onNext(endCursor) { + this.pagination = { + first: DEFAULT_PER_PAGE, + after: endCursor, + last: null, + before: null, + }; + }, + onPrev(startCursor) { + this.pagination = { + first: null, + after: null, + last: DEFAULT_PER_PAGE, + before: startCursor, + }; + }, + }, }; </script> @@ -56,6 +84,11 @@ export default { }}</gl-button> </div> </div> - <organizations-view :organizations="organizations" :loading="loading" /> + <organizations-view + :organizations="organizations" + :loading="loading" + @next="onNext" + @prev="onPrev" + /> </section> </template> diff --git a/app/assets/javascripts/organizations/index/components/organizations_list.vue b/app/assets/javascripts/organizations/index/components/organizations_list.vue index 539a4fcfe29..971d4710be2 100644 --- a/app/assets/javascripts/organizations/index/components/organizations_list.vue +++ b/app/assets/javascripts/organizations/index/components/organizations_list.vue @@ -1,26 +1,52 @@ <script> +import { GlKeysetPagination } from '@gitlab/ui'; +import { __ } from '~/locale'; import OrganizationsListItem from './organizations_list_item.vue'; export default { name: 'OrganizationsList', components: { OrganizationsListItem, + GlKeysetPagination, + }, + i18n: { + prev: __('Prev'), + next: __('Next'), }, props: { organizations: { - type: Array, + type: Object, required: true, }, }, + computed: { + nodes() { + return this.organizations.nodes || []; + }, + pageInfo() { + return this.organizations.pageInfo || {}; + }, + }, }; </script> <template> - <ul class="gl-p-0 gl-list-style-none"> - <organizations-list-item - v-for="organization in organizations" - :key="organization.id" - :organization="organization" - /> - </ul> + <div> + <ul class="gl-p-0 gl-list-style-none"> + <organizations-list-item + v-for="organization in nodes" + :key="organization.id" + :organization="organization" + /> + </ul> + <div v-if="pageInfo.hasNextPage || pageInfo.hasPreviousPage" class="gl-text-center gl-mt-5"> + <gl-keyset-pagination + v-bind="pageInfo" + :prev-text="$options.i18n.prev" + :next-text="$options.i18n.next" + @prev="$emit('prev', $event)" + @next="$emit('next', $event)" + /> + </div> + </div> </template> diff --git a/app/assets/javascripts/organizations/index/components/organizations_view.vue b/app/assets/javascripts/organizations/index/components/organizations_view.vue index 9720646bca3..59e94670826 100644 --- a/app/assets/javascripts/organizations/index/components/organizations_view.vue +++ b/app/assets/javascripts/organizations/index/components/organizations_view.vue @@ -20,9 +20,9 @@ export default { inject: ['newOrganizationUrl', 'organizationsEmptyStateSvgPath'], props: { organizations: { - type: Array, + type: Object, required: false, - default: () => [], + default: () => {}, }, loading: { type: Boolean, @@ -30,15 +30,22 @@ export default { default: false, }, }, + computed: { + nodes() { + return this.organizations.nodes || []; + }, + }, }; </script> <template> <gl-loading-icon v-if="loading" class="gl-mt-5" size="md" /> <organizations-list - v-else-if="organizations.length" + v-else-if="nodes.length" :organizations="organizations" class="gl-border-t" + @prev="$emit('prev', $event)" + @next="$emit('next', $event)" /> <gl-empty-state v-else diff --git a/app/assets/javascripts/organizations/index/graphql/organizations.query.graphql b/app/assets/javascripts/organizations/index/graphql/organizations.query.graphql index 6090e2ec789..0673acc5014 100644 --- a/app/assets/javascripts/organizations/index/graphql/organizations.query.graphql +++ b/app/assets/javascripts/organizations/index/graphql/organizations.query.graphql @@ -1,7 +1,7 @@ -query getCurrentUserOrganizations { +query getCurrentUserOrganizations($first: Int, $last: Int, $before: String, $after: String) { currentUser { id - organizations @client { + organizations(first: $first, last: $last, before: $before, after: $after) { nodes { id name @@ -9,6 +9,12 @@ query getCurrentUserOrganizations { avatarUrl webUrl } + pageInfo { + endCursor + hasNextPage + hasPreviousPage + startCursor + } } } } diff --git a/app/assets/javascripts/organizations/index/graphql/organizations_client.query.graphql b/app/assets/javascripts/organizations/index/graphql/organizations_client.query.graphql new file mode 100644 index 00000000000..66f7a82380c --- /dev/null +++ b/app/assets/javascripts/organizations/index/graphql/organizations_client.query.graphql @@ -0,0 +1,14 @@ +query getCurrentUserOrganizationsClient { + currentUser { + id + organizations @client { + nodes { + id + name + descriptionHtml + avatarUrl + webUrl + } + } + } +} diff --git a/app/assets/javascripts/organizations/index/index.js b/app/assets/javascripts/organizations/index/index.js index 7cbb9c9165d..df9ed2a4cce 100644 --- a/app/assets/javascripts/organizations/index/index.js +++ b/app/assets/javascripts/organizations/index/index.js @@ -2,7 +2,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import resolvers from '../shared/graphql/resolvers'; import OrganizationsIndexApp from './components/app.vue'; export const initOrganizationsIndex = () => { @@ -11,7 +10,7 @@ export const initOrganizationsIndex = () => { if (!el) return false; const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(resolvers), + defaultClient: createDefaultClient(), }); const { newOrganizationUrl, organizationsEmptyStateSvgPath } = convertObjectPropsToCamelCase( diff --git a/app/assets/javascripts/organizations/mock_data.js b/app/assets/javascripts/organizations/mock_data.js index 725b6ac1ad8..7080170958b 100644 --- a/app/assets/javascripts/organizations/mock_data.js +++ b/app/assets/javascripts/organizations/mock_data.js @@ -309,3 +309,24 @@ export const updateOrganizationResponse = { }, errors: [], }; + +export const pageInfo = { + endCursor: 'eyJpZCI6IjEwNTMifQ', + hasNextPage: true, + hasPreviousPage: true, + startCursor: 'eyJpZCI6IjEwNzIifQ', +}; + +export const pageInfoOnePage = { + endCursor: 'eyJpZCI6IjEwNTMifQ', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjEwNzIifQ', +}; + +export const pageInfoEmpty = { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, +}; diff --git a/app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue index d068d86d95b..ba89de36595 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue @@ -1,7 +1,7 @@ <script> import { GlAlert } from '@gitlab/ui'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; -import getCurrentUserOrganizationsQuery from '~/organizations/index/graphql/organizations.query.graphql'; +import getCurrentUserOrganizationsClientQuery from '~/organizations/index/graphql/organizations_client.query.graphql'; import getOrganizationQuery from '~/organizations/shared/graphql/queries/organization.query.graphql'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPENAME_ORGANIZATION } from '~/graphql_shared/constants'; @@ -74,7 +74,7 @@ export default { }, }, } = await this.$apollo.query({ - query: getCurrentUserOrganizationsQuery, + query: getCurrentUserOrganizationsClientQuery, // TODO: implement search support - https://gitlab.com/gitlab-org/gitlab/-/issues/429999. }); diff --git a/app/graphql/types/organizations/organization_type.rb b/app/graphql/types/organizations/organization_type.rb index e7ba8de527c..01d5bd7b635 100644 --- a/app/graphql/types/organizations/organization_type.rb +++ b/app/graphql/types/organizations/organization_type.rb @@ -7,6 +7,20 @@ module Types authorize :read_organization + field :avatar_url, + type: GraphQL::Types::String, + null: true, + description: + 'Avatar URL of the organization. `null` until ' \ + '[#422418](https://gitlab.com/gitlab-org/gitlab/-/issues/422418) is complete.', + alpha: { milestone: '16.7' } + field :description, + GraphQL::Types::String, + null: true, + description: + 'Description of the organization. `null` until ' \ + '[#422078](https://gitlab.com/gitlab-org/gitlab/-/issues/422078) is complete.', + alpha: { milestone: '16.7' } field :groups, Types::GroupType.connection_type, null: false, @@ -37,6 +51,15 @@ module Types null: false, description: 'Web URL of the organization.', alpha: { milestone: '16.6' } + + # Returns empty string until https://gitlab.com/gitlab-org/gitlab/-/issues/422078 is complete. + markdown_field :description_html, + null: true + + # TODO - update to return real avatar url when https://gitlab.com/gitlab-org/gitlab/-/issues/422418 is complete. + def avatar_url + nil + end end end end diff --git a/app/models/organizations/organization.rb b/app/models/organizations/organization.rb index 157b851e009..4244f51bb2e 100644 --- a/app/models/organizations/organization.rb +++ b/app/models/organizations/organization.rb @@ -46,6 +46,11 @@ module Organizations Gitlab::UrlBuilder.build(self, only_path: only_path) end + # TODO - update to return real description when https://gitlab.com/gitlab-org/gitlab/-/issues/422078 is complete. + def description + nil + end + private def check_if_default_organization diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 07818cefe4e..516dc713953 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -22437,6 +22437,9 @@ Active period time range for on-call rotation. | Name | Type | Description | | ---- | ---- | ----------- | +| <a id="organizationavatarurl"></a>`avatarUrl` **{warning-solid}** | [`String`](#string) | **Introduced** in 16.7. This feature is an Experiment. It can be changed or removed at any time. Avatar URL of the organization. `null` until [#422418](https://gitlab.com/gitlab-org/gitlab/-/issues/422418) is complete. | +| <a id="organizationdescription"></a>`description` **{warning-solid}** | [`String`](#string) | **Introduced** in 16.7. This feature is an Experiment. It can be changed or removed at any time. Description of the organization. `null` until [#422078](https://gitlab.com/gitlab-org/gitlab/-/issues/422078) is complete. | +| <a id="organizationdescriptionhtml"></a>`descriptionHtml` | [`String`](#string) | GitLab Flavored Markdown rendering of `description`. | | <a id="organizationid"></a>`id` **{warning-solid}** | [`ID!`](#id) | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. ID of the organization. | | <a id="organizationname"></a>`name` **{warning-solid}** | [`String!`](#string) | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Name of the organization. | | <a id="organizationorganizationusers"></a>`organizationUsers` **{warning-solid}** | [`OrganizationUserConnection!`](#organizationuserconnection) | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Users with access to the organization. | diff --git a/doc/user/search/exact_code_search.md b/doc/user/search/exact_code_search.md index 48445ccfc3c..ff8460de2c5 100644 --- a/doc/user/search/exact_code_search.md +++ b/doc/user/search/exact_code_search.md @@ -33,6 +33,7 @@ This table shows some example queries for exact code search. | Query | Description | | -------------------- |-------------------------------------------------------------------------------------- | | `foo` | Returns files that contain `foo` | +| `foo file:^doc/` | Returns files that contain `foo` in directories that start with `doc/` | | `"class foo"` | Returns files that contain the exact string `class foo` | | `class foo` | Returns files that contain both `class` and `foo` | | `foo or bar` | Returns files that contain either `foo` or `bar` | @@ -42,5 +43,5 @@ This table shows some example queries for exact code search. | `foo file:js` | Searches for `foo` in files with names that contain `js` | | `foo -file:test` | Searches for `foo` in files with names that do not contain `test` | | `foo lang:ruby` | Searches for `foo` in Ruby source code | -| `foo f:\.js$` | Searches for `foo` in files with names that end with `.js` | +| `foo file:\.js$` | Searches for `foo` in files with names that end with `.js` | | `foo.*bar` | Searches for strings that match the regular expression `foo.*bar` | diff --git a/doc/user/workspace/configuration.md b/doc/user/workspace/configuration.md index cda2315d067..793f93865bb 100644 --- a/doc/user/workspace/configuration.md +++ b/doc/user/workspace/configuration.md @@ -10,10 +10,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w > - [Enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/391543) in GitLab 16.0. > - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136744) in GitLab 16.7. Feature flag `remote_development_feature_flag` removed. -WARNING: -This feature is in [Beta](../../policy/experiment-beta-support.md#beta) and subject to change without notice. -To leave feedback, see the [feedback issue](https://gitlab.com/gitlab-org/gitlab/-/issues/410031). - You can use [workspaces](index.md) to create and manage isolated development environments for your GitLab projects. Each workspace includes its own set of dependencies, libraries, and tools, which you can customize to meet the specific needs of each project. diff --git a/doc/user/workspace/create_image.md b/doc/user/workspace/create_image.md index d44d72ec634..60998f03848 100644 --- a/doc/user/workspace/create_image.md +++ b/doc/user/workspace/create_image.md @@ -10,9 +10,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w > - [Enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/391543) in GitLab 16.0. > - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136744) in GitLab 16.7. Feature flag `remote_development_feature_flag` removed. -WARNING: -This feature is in [Beta](../../policy/experiment-beta-support.md#beta) and subject to change without notice. To leave feedback, see the [feedback issue](https://gitlab.com/gitlab-org/gitlab/-/issues/410031). - In this tutorial, you'll learn how to create a custom workspace image that supports arbitrary user IDs. You can then use this custom image with any [workspace](index.md) you create in GitLab. diff --git a/doc/user/workspace/index.md b/doc/user/workspace/index.md index 11c4ed963d1..98896628711 100644 --- a/doc/user/workspace/index.md +++ b/doc/user/workspace/index.md @@ -10,10 +10,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w > - [Enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/391543) in GitLab 16.0. > - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136744) in GitLab 16.7. Feature flag `remote_development_feature_flag` removed. -WARNING: -This feature is in [Beta](../../policy/experiment-beta-support.md#beta) and subject to change without notice. -To leave feedback, see the [feedback issue](https://gitlab.com/gitlab-org/gitlab/-/issues/410031). - A workspace is a virtual sandbox environment for your code in GitLab. You can use workspaces to create and manage isolated development environments for your GitLab projects. These environments ensure that different projects don't interfere with each other. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b029a67c1c6..e4d59856e8c 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -10322,7 +10322,7 @@ msgstr "" msgid "CiCatalog|Create a pipeline component repository and make reusing pipeline configurations faster and easier." msgstr "" -msgid "CiCatalog|Discover CI configuration resources for a seamless CI/CD experience." +msgid "CiCatalog|Discover CI/CD components that can improve your pipeline with additional functionality." msgstr "" msgid "CiCatalog|Edit your search and try again. Or %{linkStart}learn to create a component repository%{linkEnd}." diff --git a/spec/features/explore/catalog_spec.rb b/spec/features/explore/catalog_spec.rb index bb0a848e604..a6f86722f28 100644 --- a/spec/features/explore/catalog_spec.rb +++ b/spec/features/explore/catalog_spec.rb @@ -40,7 +40,9 @@ RSpec.describe 'Global Catalog', :js, feature_category: :pipeline_composition do it 'shows CI Catalog title and description', :aggregate_failures do expect(page).to have_content('CI/CD Catalog') - expect(page).to have_content('Discover CI configuration resources for a seamless CI/CD experience.') + expect(page).to have_content( + 'Discover CI/CD components that can improve your pipeline with additional functionality' + ) end it 'renders CI Catalog resources list' do diff --git a/spec/frontend/ci/catalog/components/list/catalog_header_spec.js b/spec/frontend/ci/catalog/components/list/catalog_header_spec.js index deda0128977..294b5302ec0 100644 --- a/spec/frontend/ci/catalog/components/list/catalog_header_spec.js +++ b/spec/frontend/ci/catalog/components/list/catalog_header_spec.js @@ -42,7 +42,7 @@ describe('CatalogHeader', () => { it('renders the default values', () => { expect(findTitle().text()).toBe('CI/CD Catalog'); expect(findDescription().text()).toBe( - 'Discover CI configuration resources for a seamless CI/CD experience.', + 'Discover CI/CD components that can improve your pipeline with additional functionality.', ); }); }); diff --git a/spec/frontend/organizations/index/components/app_spec.js b/spec/frontend/organizations/index/components/app_spec.js index 175b1e1c552..e73364b2bbc 100644 --- a/spec/frontend/organizations/index/components/app_spec.js +++ b/spec/frontend/organizations/index/components/app_spec.js @@ -5,8 +5,8 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; -import { organizations } from '~/organizations/mock_data'; -import resolvers from '~/organizations/shared/graphql/resolvers'; +import { DEFAULT_PER_PAGE } from '~/api'; +import { organizations as nodes, pageInfo, pageInfoEmpty } from '~/organizations/mock_data'; import organizationsQuery from '~/organizations/index/graphql/organizations.query.graphql'; import OrganizationsIndexApp from '~/organizations/index/components/app.vue'; import OrganizationsView from '~/organizations/index/components/organizations_view.vue'; @@ -20,8 +20,27 @@ describe('OrganizationsIndexApp', () => { let wrapper; let mockApollo; - const createComponent = (mockResolvers = resolvers) => { - mockApollo = createMockApollo([[organizationsQuery, mockResolvers]]); + const organizations = { + nodes, + pageInfo, + }; + + const organizationEmpty = { + nodes: [], + pageInfo: pageInfoEmpty, + }; + + const successHandler = jest.fn().mockResolvedValue({ + data: { + currentUser: { + id: 'gid://gitlab/User/1', + organizations, + }, + }, + }); + + const createComponent = (handler = successHandler) => { + mockApollo = createMockApollo([[organizationsQuery, handler]]); wrapper = shallowMountExtended(OrganizationsIndexApp, { apolloProvider: mockApollo, @@ -35,53 +54,168 @@ describe('OrganizationsIndexApp', () => { mockApollo = null; }); + // Finders const findOrganizationHeaderText = () => wrapper.findByText('Organizations'); const findNewOrganizationButton = () => wrapper.findComponent(GlButton); const findOrganizationsView = () => wrapper.findComponent(OrganizationsView); - const loadingResolver = jest.fn().mockReturnValue(new Promise(() => {})); - const successfulResolver = (nodes) => - jest.fn().mockResolvedValue({ - data: { currentUser: { id: 1, organizations: { nodes } } }, + // Assertions + const itRendersHeaderText = () => { + it('renders the header text', () => { + expect(findOrganizationHeaderText().exists()).toBe(true); + }); + }; + + const itRendersNewOrganizationButton = () => { + it('render new organization button with correct link', () => { + expect(findNewOrganizationButton().attributes('href')).toBe(MOCK_NEW_ORG_URL); + }); + }; + + const itDoesNotRenderErrorMessage = () => { + it('does not render an error message', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + }; + + const itDoesNotRenderHeaderText = () => { + it('does not render the header text', () => { + expect(findOrganizationHeaderText().exists()).toBe(false); + }); + }; + + const itDoesNotRenderNewOrganizationButton = () => { + it('does not render new organization button', () => { + expect(findNewOrganizationButton().exists()).toBe(false); + }); + }; + + describe('when API call is loading', () => { + beforeEach(() => { + createComponent(jest.fn().mockReturnValue(new Promise(() => {}))); + }); + + itRendersHeaderText(); + itRendersNewOrganizationButton(); + itDoesNotRenderErrorMessage(); + + it('renders the organizations view with loading prop set to true', () => { + expect(findOrganizationsView().props('loading')).toBe(true); + }); + }); + + describe('when API call is successful', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + itRendersHeaderText(); + itRendersNewOrganizationButton(); + itDoesNotRenderErrorMessage(); + + it('passes organizations to view component', () => { + expect(findOrganizationsView().props()).toMatchObject({ + loading: false, + organizations, + }); }); - const errorResolver = jest.fn().mockRejectedValue('error'); + }); - describe.each` - description | mockResolver | headerText | newOrgLink | loading | orgsData | error - ${'when API call is loading'} | ${loadingResolver} | ${true} | ${MOCK_NEW_ORG_URL} | ${true} | ${[]} | ${false} - ${'when API returns successful with results'} | ${successfulResolver(organizations)} | ${true} | ${MOCK_NEW_ORG_URL} | ${false} | ${organizations} | ${false} - ${'when API returns successful without results'} | ${successfulResolver([])} | ${false} | ${false} | ${false} | ${[]} | ${false} - ${'when API returns error'} | ${errorResolver} | ${false} | ${false} | ${false} | ${[]} | ${true} - `('$description', ({ mockResolver, headerText, newOrgLink, loading, orgsData, error }) => { + describe('when API call is successful and returns no organizations', () => { beforeEach(async () => { - createComponent(mockResolver); + createComponent( + jest.fn().mockResolvedValue({ + data: { + currentUser: { + id: 'gid://gitlab/User/1', + organizations: organizationEmpty, + }, + }, + }), + ); await waitForPromises(); }); - it(`does ${headerText ? '' : 'not '}render the header text`, () => { - expect(findOrganizationHeaderText().exists()).toBe(headerText); + itDoesNotRenderHeaderText(); + itDoesNotRenderNewOrganizationButton(); + itDoesNotRenderErrorMessage(); + + it('renders view component with correct organizations and loading props', () => { + expect(findOrganizationsView().props()).toMatchObject({ + loading: false, + organizations: organizationEmpty, + }); }); + }); + + describe('when API call is not successful', () => { + const error = new Error(); - it(`does ${newOrgLink ? '' : 'not '}render new organization button with correct link`, () => { - expect( - findNewOrganizationButton().exists() && findNewOrganizationButton().attributes('href'), - ).toBe(newOrgLink); + beforeEach(async () => { + createComponent(jest.fn().mockRejectedValue(error)); + await waitForPromises(); }); - it(`renders the organizations view with ${loading} loading prop`, () => { - expect(findOrganizationsView().props('loading')).toBe(loading); + itDoesNotRenderHeaderText(); + itDoesNotRenderNewOrganizationButton(); + + it('renders view component with correct organizations and loading props', () => { + expect(findOrganizationsView().props()).toMatchObject({ + loading: false, + organizations: {}, + }); }); - it(`renders the organizations view with ${ - orgsData ? 'correct' : 'empty' - } organizations array prop`, () => { - expect(findOrganizationsView().props('organizations')).toStrictEqual(orgsData); + it('renders error message', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: + 'An error occurred loading user organizations. Please refresh the page to try again.', + error, + captureError: true, + }); }); + }); + + describe('when view component emits `next` event', () => { + const endCursor = 'mockEndCursor'; + + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('calls GraphQL query with correct pageInfo variables', async () => { + findOrganizationsView().vm.$emit('next', endCursor); + await waitForPromises(); + + expect(successHandler).toHaveBeenCalledWith({ + first: DEFAULT_PER_PAGE, + after: endCursor, + last: null, + before: null, + }); + }); + }); + + describe('when view component emits `prev` event', () => { + const startCursor = 'mockStartCursor'; + + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('calls GraphQL query with correct pageInfo variables', async () => { + findOrganizationsView().vm.$emit('prev', startCursor); + await waitForPromises(); - it(`does ${error ? '' : 'not '}render an error message`, () => { - return error - ? expect(createAlert).toHaveBeenCalled() - : expect(createAlert).not.toHaveBeenCalled(); + expect(successHandler).toHaveBeenCalledWith({ + first: null, + after: null, + last: DEFAULT_PER_PAGE, + before: startCursor, + }); }); }); }); diff --git a/spec/frontend/organizations/index/components/organizations_list_spec.js b/spec/frontend/organizations/index/components/organizations_list_spec.js index 0b59c212314..105723f4705 100644 --- a/spec/frontend/organizations/index/components/organizations_list_spec.js +++ b/spec/frontend/organizations/index/components/organizations_list_spec.js @@ -1,28 +1,83 @@ +import { GlKeysetPagination } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import OrganizationsList from '~/organizations/index/components/organizations_list.vue'; import OrganizationsListItem from '~/organizations/index/components/organizations_list_item.vue'; -import { organizations } from '~/organizations/mock_data'; +import { organizations as nodes, pageInfo, pageInfoOnePage } from '~/organizations/mock_data'; describe('OrganizationsList', () => { let wrapper; - const createComponent = () => { + const createComponent = ({ propsData = {} } = {}) => { wrapper = shallowMount(OrganizationsList, { propsData: { - organizations, + organizations: { + nodes, + pageInfo, + }, + ...propsData, }, }); }; const findAllOrganizationsListItem = () => wrapper.findAllComponents(OrganizationsListItem); + const findPagination = () => wrapper.findComponent(GlKeysetPagination); describe('template', () => { - beforeEach(() => { + it('renders a list item for each organization', () => { createComponent(); + + expect(findAllOrganizationsListItem()).toHaveLength(nodes.length); }); - it('renders a list item for each organization', () => { - expect(findAllOrganizationsListItem()).toHaveLength(organizations.length); + describe('when there is one page of organizations', () => { + beforeEach(() => { + createComponent({ + propsData: { + organizations: { + nodes, + pageInfo: pageInfoOnePage, + }, + }, + }); + }); + + it('does not render pagination', () => { + expect(findPagination().exists()).toBe(false); + }); + }); + + describe('when there are multiple pages of organizations', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders pagination', () => { + expect(findPagination().props()).toMatchObject(pageInfo); + }); + + describe('when `GlKeysetPagination` emits `next` event', () => { + const endCursor = 'mockEndCursor'; + + beforeEach(() => { + findPagination().vm.$emit('next', endCursor); + }); + + it('emits `next` event', () => { + expect(wrapper.emitted('next')).toEqual([[endCursor]]); + }); + }); + + describe('when `GlKeysetPagination` emits `prev` event', () => { + const startCursor = 'startEndCursor'; + + beforeEach(() => { + findPagination().vm.$emit('prev', startCursor); + }); + + it('emits `prev` event', () => { + expect(wrapper.emitted('prev')).toEqual([[startCursor]]); + }); + }); }); }); }); diff --git a/spec/frontend/organizations/index/components/organizations_view_spec.js b/spec/frontend/organizations/index/components/organizations_view_spec.js index 85a1c11a2b1..fe167a1418f 100644 --- a/spec/frontend/organizations/index/components/organizations_view_spec.js +++ b/spec/frontend/organizations/index/components/organizations_view_spec.js @@ -31,7 +31,7 @@ describe('OrganizationsView', () => { ${'when not loading and has no organizations'} | ${false} | ${[]} | ${MOCK_ORG_EMPTY_STATE_SVG} | ${MOCK_NEW_ORG_URL} `('$description', ({ loading, orgsData, emptyStateSvg, emptyStateUrl }) => { beforeEach(() => { - createComponent({ loading, organizations: orgsData }); + createComponent({ loading, organizations: { nodes: orgsData, pageInfo: {} } }); }); it(`does ${loading ? '' : 'not '}render loading icon`, () => { @@ -54,4 +54,30 @@ describe('OrganizationsView', () => { ).toBe(emptyStateUrl); }); }); + + describe('when `OrganizationsList` emits `next` event', () => { + const endCursor = 'mockEndCursor'; + + beforeEach(() => { + createComponent({ loading: false, organizations: { nodes: organizations, pageInfo: {} } }); + findOrganizationsList().vm.$emit('next', endCursor); + }); + + it('emits `next` event', () => { + expect(wrapper.emitted('next')).toEqual([[endCursor]]); + }); + }); + + describe('when `OrganizationsList` emits `prev` event', () => { + const startCursor = 'mockStartCursor'; + + beforeEach(() => { + createComponent({ loading: false, organizations: { nodes: organizations, pageInfo: {} } }); + findOrganizationsList().vm.$emit('prev', startCursor); + }); + + it('emits `next` event', () => { + expect(wrapper.emitted('prev')).toEqual([[startCursor]]); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/entity_select/organization_select_spec.js b/spec/frontend/vue_shared/components/entity_select/organization_select_spec.js index ea029ba4f27..676a887754d 100644 --- a/spec/frontend/vue_shared/components/entity_select/organization_select_spec.js +++ b/spec/frontend/vue_shared/components/entity_select/organization_select_spec.js @@ -11,7 +11,7 @@ import { FETCH_ORGANIZATION_ERROR, } from '~/vue_shared/components/entity_select/constants'; import resolvers from '~/organizations/shared/graphql/resolvers'; -import organizationsQuery from '~/organizations/index/graphql/organizations.query.graphql'; +import organizationsClientQuery from '~/organizations/index/graphql/organizations_client.query.graphql'; import { organizations as organizationsMock } from '~/organizations/mock_data'; import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -51,7 +51,7 @@ describe('OrganizationSelect', () => { mockApollo = createMockApollo( handlers || [ [ - organizationsQuery, + organizationsClientQuery, jest.fn().mockResolvedValueOnce({ data: { currentUser: { id: 1, organizations: { nodes: organizationsMock } } }, }), @@ -154,7 +154,7 @@ describe('OrganizationSelect', () => { it('shows an error when fetching organizations fails', async () => { createComponent({ - handlers: [[organizationsQuery, jest.fn().mockRejectedValueOnce(new Error())]], + handlers: [[organizationsClientQuery, jest.fn().mockRejectedValueOnce(new Error())]], }); await nextTick(); jest.runAllTimers(); diff --git a/spec/graphql/types/organizations/organization_type_spec.rb b/spec/graphql/types/organizations/organization_type_spec.rb index 62787ad220d..6bc4bac6ba2 100644 --- a/spec/graphql/types/organizations/organization_type_spec.rb +++ b/spec/graphql/types/organizations/organization_type_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['Organization'], feature_category: :cell do - let(:expected_fields) { %w[groups id name organization_users path web_url] } + let(:expected_fields) { %w[avatar_url description description_html groups id name organization_users path web_url] } specify { expect(described_class.graphql_name).to eq('Organization') } specify { expect(described_class).to require_graphql_authorizations(:read_organization) } |