diff options
24 files changed, 452 insertions, 146 deletions
diff --git a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue index 4eceabc9fb5..ffd3a2caa7f 100644 --- a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue @@ -2,7 +2,7 @@ import { GlFormGroup, GlModal, GlSprintf } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { importProjectMembers } from '~/api/projects_api'; -import { BV_SHOW_MODAL } from '~/lib/utils/constants'; +import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { s__, __, sprintf } from '~/locale'; import eventHub from '../event_hub'; import { @@ -81,11 +81,17 @@ export default { openModal() { this.$root.$emit(BV_SHOW_MODAL, this.$options.modalId); }, + closeModal() { + this.$root.$emit(BV_HIDE_MODAL, this.$options.modalId); + }, resetFields() { this.invalidFeedbackMessage = ''; this.projectToBeImported = {}; }, - submitImport() { + submitImport(e) { + // We never want to hide when submitting + e.preventDefault(); + this.isLoading = true; return importProjectMembers(this.projectId, this.projectToBeImported.id) .then(this.onInviteSuccess) diff --git a/app/assets/javascripts/pages/admin/jobs/components/constants.js b/app/assets/javascripts/pages/admin/jobs/components/constants.js index 61d5e329fc0..57220f0727c 100644 --- a/app/assets/javascripts/pages/admin/jobs/components/constants.js +++ b/app/assets/javascripts/pages/admin/jobs/components/constants.js @@ -1,6 +1,10 @@ import { s__, __ } from '~/locale'; import { DEFAULT_FIELDS, RAW_TEXT_WARNING } from '~/jobs/components/table/constants'; +export const JOBS_COUNT_ERROR_MESSAGE = __('There was an error fetching the number of jobs.'); +export const JOBS_FETCH_ERROR_MSG = __('There was an error fetching the jobs.'); +export const LOADING_ARIA_LABEL = __('Loading'); +export const CANCELABLE_JOBS_ERROR_MSG = __('There was an error fetching the cancelable jobs.'); export const CANCEL_JOBS_MODAL_ID = 'cancel-jobs-modal'; export const CANCEL_JOBS_MODAL_TITLE = s__('AdminArea|Are you sure?'); export const CANCEL_JOBS_BUTTON_TEXT = s__('AdminArea|Cancel all jobs'); diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue b/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue index da6739aad8b..e56ec5375c2 100644 --- a/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue +++ b/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue @@ -1,6 +1,5 @@ <script> import { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; -import { __ } from '~/locale'; import { setUrlParams, updateHistory, queryToObject } from '~/lib/utils/url_utility'; import { validateQueryString } from '~/jobs/components/filtered_search/utils'; import JobsTable from '~/jobs/components/table/jobs_table.vue'; @@ -9,14 +8,24 @@ import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_ import JobsTableEmptyState from '~/jobs/components/table/jobs_table_empty_state.vue'; import { createAlert } from '~/alert'; import JobsSkeletonLoader from '../jobs_skeleton_loader.vue'; -import { DEFAULT_FIELDS_ADMIN, RAW_TEXT_WARNING_ADMIN } from '../constants'; +import { + DEFAULT_FIELDS_ADMIN, + RAW_TEXT_WARNING_ADMIN, + JOBS_COUNT_ERROR_MESSAGE, + JOBS_FETCH_ERROR_MSG, + LOADING_ARIA_LABEL, + CANCELABLE_JOBS_ERROR_MSG, +} from '../constants'; import GetAllJobs from './graphql/queries/get_all_jobs.query.graphql'; +import GetAllJobsCount from './graphql/queries/get_all_jobs_count.query.graphql'; import CancelableJobs from './graphql/queries/get_cancelable_jobs_count.query.graphql'; export default { i18n: { - jobsFetchErrorMsg: __('There was an error fetching the jobs.'), - loadingAriaLabel: __('Loading'), + jobsCountErrorMsg: JOBS_COUNT_ERROR_MESSAGE, + jobsFetchErrorMsg: JOBS_FETCH_ERROR_MSG, + loadingAriaLabel: LOADING_ARIA_LABEL, + cancelableJobsErrorMsg: CANCELABLE_JOBS_ERROR_MSG, }, filterSearchBoxStyles: 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-b gl-border-gray-100', @@ -51,22 +60,36 @@ export default { return this.variables; }, update(data) { - const { jobs: { nodes: list = [], pageInfo = {}, count } = {} } = data || {}; + const { jobs: { nodes: list = [], pageInfo = {} } = {} } = data || {}; return { list, pageInfo, - count, }; }, error() { this.error = this.$options.i18n.jobsFetchErrorMsg; }, }, + jobsCount: { + query: GetAllJobsCount, + update(data) { + return data?.jobs?.count || 0; + }, + context: { + isSingleRequest: true, + }, + error() { + this.error = this.$options.i18n.jobsCountErrorMsg; + }, + }, cancelable: { query: CancelableJobs, update(data) { this.isCancelable = data.cancelable.count !== 0; }, + error() { + this.error = this.$options.i18n.cancelableJobsErrorMsg; + }, }, }, data() { @@ -81,6 +104,7 @@ export default { filterSearchTriggered: false, DEFAULT_FIELDS_ADMIN, isCancelable: false, + jobsCount: null, }; }, computed: { @@ -109,9 +133,6 @@ export default { showFilteredSearch() { return !this.scope; }, - jobsCount() { - return this.jobs.count; - }, showLoadingSpinner() { return this.loading && this.infiniteScrollingTriggered; }, @@ -160,6 +181,7 @@ export default { }); this.$apollo.queries.jobs.refetch({ statuses: null }); + this.$apollo.queries.jobsCount.refetch({ statuses: null }); return; } @@ -183,6 +205,7 @@ export default { }); this.$apollo.queries.jobs.refetch({ statuses: filter.value.data }); + this.$apollo.queries.jobsCount.refetch({ statuses: filter.value.data }); } }); }, diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql index 374009efa15..6e63f0c81e6 100644 --- a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql +++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql @@ -1,6 +1,5 @@ query getAllJobs($after: String, $first: Int = 50, $statuses: [CiJobStatus!]) { jobs(after: $after, first: $first, statuses: $statuses) { - count pageInfo { endCursor hasNextPage diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs_count.query.graphql b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs_count.query.graphql new file mode 100644 index 00000000000..8c59230b2b8 --- /dev/null +++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs_count.query.graphql @@ -0,0 +1,5 @@ +query getAllJobsCount($statuses: [CiJobStatus!]) { + jobs(statuses: $statuses) { + count + } +} diff --git a/app/assets/javascripts/super_sidebar/components/help_center.vue b/app/assets/javascripts/super_sidebar/components/help_center.vue index 70e1780aae1..d09d3f28ba2 100644 --- a/app/assets/javascripts/super_sidebar/components/help_center.vue +++ b/app/assets/javascripts/super_sidebar/components/help_center.vue @@ -38,7 +38,7 @@ export default { shortcuts: __('Keyboard shortcuts'), version: __('Your GitLab version'), whatsnew: __("What's new"), - tanuki: __('Ask the Tanuki Bot'), + chat: __('Ask the GitLab Chat'), }, props: { sidebarData: { @@ -71,7 +71,7 @@ export default { items: [ this.sidebarData.show_tanuki_bot && { icon: 'tanuki', - text: this.$options.i18n.tanuki, + text: this.$options.i18n.chat, action: this.showTanukiBotChat, extraAttrs: { ...this.trackingAttrs('tanuki_bot_help_dropdown'), diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue index 1ace1c52a68..64ce4b66213 100644 --- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue +++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue @@ -11,6 +11,7 @@ export default { * id: number | string; * name: string; * webUrl: string; + * topics: string[]; * forksCount?: number; * avatarUrl: string | null; * starCount: number; diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue index f77fd029e93..714ffd60c25 100644 --- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue @@ -1,5 +1,14 @@ <script> -import { GlAvatarLabeled, GlIcon, GlLink, GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { + GlAvatarLabeled, + GlIcon, + GlLink, + GlBadge, + GlTooltipDirective, + GlPopover, + GlSprintf, +} from '@gitlab/ui'; +import uniqueId from 'lodash/uniqueId'; import { VISIBILITY_TYPE_ICON, PROJECT_VISIBILITY_TYPE } from '~/visibility_level/constants'; import { ACCESS_LEVEL_LABELS } from '~/access_level/constants'; @@ -7,6 +16,10 @@ import { FEATURABLE_ENABLED } from '~/featurable/constants'; import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import { __ } from '~/locale'; import { numberToMetricPrefix } from '~/lib/utils/number_utils'; +import { truncate } from '~/lib/utils/text_utility'; + +const MAX_TOPICS_TO_SHOW = 3; +const MAX_TOPIC_TITLE_LENGTH = 15; export default { i18n: { @@ -14,6 +27,9 @@ export default { forks: __('Forks'), issues: __('Issues'), archived: __('Archived'), + topics: __('Topics'), + topicsPopoverTargetText: __('+ %{count} more'), + moreTopics: __('More topics'), }, components: { GlAvatarLabeled, @@ -21,6 +37,8 @@ export default { UserAccessRoleBadge, GlLink, GlBadge, + GlPopover, + GlSprintf, }, directives: { GlTooltip: GlTooltipDirective, @@ -33,6 +51,7 @@ export default { * id: number | string; * name: string; * webUrl: string; + * topics: string[]; * forksCount?: number; * avatarUrl: string | null; * starCount: number; @@ -49,6 +68,11 @@ export default { required: true, }, }, + data() { + return { + topicsPopoverTarget: uniqueId('project-topics-popover-'), + }; + }, computed: { visibilityIcon() { return VISIBILITY_TYPE_ICON[this.project.visibility]; @@ -83,9 +107,32 @@ export default { isIssuesEnabled() { return this.project.issuesAccessLevel === FEATURABLE_ENABLED; }, + hasTopics() { + return this.project.topics.length; + }, + visibleTopics() { + return this.project.topics.slice(0, MAX_TOPICS_TO_SHOW); + }, + popoverTopics() { + return this.project.topics.slice(MAX_TOPICS_TO_SHOW); + }, }, methods: { numberToMetricPrefix, + topicPath(topic) { + return `/explore/projects/topics/${encodeURIComponent(topic)}`; + }, + topicTitle(topic) { + return truncate(topic, MAX_TOPIC_TITLE_LENGTH); + }, + topicTooltipTitle(topic) { + // Matches conditional in app/assets/javascripts/lib/utils/text_utility.js#L88 + if (topic.length - 1 > MAX_TOPIC_TITLE_LENGTH) { + return topic; + } + + return null; + }, }, }; </script> @@ -111,6 +158,43 @@ export default { accessLevelLabel }}</user-access-role-badge> </template> + <div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics"> + <div + class="gl-w-full gl-display-inline-flex gl-flex-wrap gl-font-base gl-font-weight-normal gl-align-items-center gl-mx-n2 gl-my-n2" + > + <span class="gl-p-2 gl-text-secondary">{{ $options.i18n.topics }}:</span> + <div v-for="topic in visibleTopics" :key="topic" class="gl-p-2"> + <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)"> + {{ topicTitle(topic) }} + </gl-badge> + </div> + <template v-if="popoverTopics.length"> + <div + :id="topicsPopoverTarget" + class="gl-p-2 gl-text-secondary" + role="button" + tabindex="0" + > + <gl-sprintf :message="$options.i18n.topicsPopoverTargetText"> + <template #count>{{ popoverTopics.length }}</template> + </gl-sprintf> + </div> + <gl-popover :target="topicsPopoverTarget" :title="$options.i18n.moreTopics"> + <div class="gl-font-base gl-font-weight-normal gl-mx-n2 gl-my-n2"> + <div + v-for="topic in popoverTopics" + :key="topic" + class="gl-p-2 gl-display-inline-block" + > + <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)"> + {{ topicTitle(topic) }} + </gl-badge> + </div> + </div> + </gl-popover> + </template> + </div> + </div> </gl-avatar-labeled> <div class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-mt-0" diff --git a/config/feature_flags/development/sign_and_verify_ansi2json_state.yml b/config/feature_flags/development/sign_and_verify_ansi2json_state.yml index 7d9ac48dba6..af9286dc3cd 100644 --- a/config/feature_flags/development/sign_and_verify_ansi2json_state.yml +++ b/config/feature_flags/development/sign_and_verify_ansi2json_state.yml @@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/404718 milestone: '15.11' type: development group: group::pipeline execution -default_enabled: false +default_enabled: true diff --git a/doc/development/database/required_stops.md b/doc/development/database/required_stops.md index b706babbc5e..e4f66f4424f 100644 --- a/doc/development/database/required_stops.md +++ b/doc/development/database/required_stops.md @@ -33,6 +33,32 @@ You may need to introduce a required stop for mitigation when: - **Cause:** The dependent migration may fail if the background migration is incomplete. - **Mitigation:** Ensure that all background migrations are finalized before authoring dependent migrations. +### Remove a migration + +If a migration is removed, you may need to introduce a required stop to ensure customers +don't miss the required change. + +- **Cause:** Dependent migrations may fail, or the application may not function, because a required + migration was removed. +- **Mitigation:** Ensure migrations are only removed after they've been a part of a planned + required stop. + +### A migration timestamp is very old + +If a migration timestamp is very old (> 3 weeks, or after a before the last stop), +these scenarios may cause issues: + +- If the migration depends on another migration with a newer timestamp but introduced in a + previous release _after_ a required stop, then the new migration may run sequentially sooner + than the prerequisite migration, and thus fail. +- If the migration timestamp ID is before the last, it may be inadvertently squashed when the + team squashes other migrations from the required stop. + +- **Cause:** The migration may fail if it depends on a migration with a later timestamp introduced + in an earlier version. Or, the migration may be inadvertently squashed after a required stop. +- **Mitigation:** Aim for migration timestamps to fall inside the release dates and be sure that + they are not dated prior to the last required stop. + ### Bugs in migration related tooling In a few circumstances, bugs in migration related tooling has required us to introduce stops. While we aim diff --git a/doc/development/go_guide/go_upgrade.md b/doc/development/go_guide/go_upgrade.md index f71fe7b8dac..7fc18604a3d 100644 --- a/doc/development/go_guide/go_upgrade.md +++ b/doc/development/go_guide/go_upgrade.md @@ -150,6 +150,7 @@ if you need help finding the correct person or labels: | [Alertmanager](https://github.com/prometheus/alertmanager) | [Issue Tracker](https://gitlab.com/gitlab-org/gitlab/-/issues) | | Docker Distribution Pruner | [Issue Tracker](https://gitlab.com/gitlab-org/docker-distribution-pruner) | | Gitaly | [Issue Tracker](https://gitlab.com/gitlab-org/gitaly/-/issues) | +| GitLab CLI (`glab`). | [Issue Tracker](https://gitlab.com/gitlab-org/cli/-/issues) | GitLab Compose Kit | [Issuer Tracker](https://gitlab.com/gitlab-org/gitlab-compose-kit/-/issues) | | GitLab Container Registry | [Issue Tracker](https://gitlab.com/gitlab-org/container-registry) | | GitLab Elasticsearch Indexer | [Issue Tracker](https://gitlab.com/gitlab-org/gitlab-elasticsearch-indexer/-/issues) | diff --git a/doc/user/admin_area/settings/visibility_and_access_controls.md b/doc/user/admin_area/settings/visibility_and_access_controls.md index 07067945ea6..dfb23a4017e 100644 --- a/doc/user/admin_area/settings/visibility_and_access_controls.md +++ b/doc/user/admin_area/settings/visibility_and_access_controls.md @@ -161,6 +161,10 @@ For more details on group visibility, see ## Restrict visibility levels +When restricting visibility levels, consider how these restrictions interact +with permissions for subgroups and projects that inherit their visibility from +the item you're changing. + To restrict visibility levels for groups, projects, snippets, and selected pages: 1. Sign in to GitLab as a user with Administrator access level. @@ -181,7 +185,7 @@ To restrict visibility levels for groups, projects, snippets, and selected pages 1. Select **Save changes**. For more details on project visibility, see -[Project visibility](../../public_access.md). +[Project visibility](../../public_access.md). ## Configure allowed import sources diff --git a/doc/user/project/merge_requests/creating_merge_requests.md b/doc/user/project/merge_requests/creating_merge_requests.md index 8a4a61bb80d..4ac6c6e0aa2 100644 --- a/doc/user/project/merge_requests/creating_merge_requests.md +++ b/doc/user/project/merge_requests/creating_merge_requests.md @@ -83,7 +83,7 @@ You can create a merge request when you add, edit, or upload a file to a reposit 1. [Add, edit, or upload](../repository/web_editor.md) a file to the repository. 1. In the **Commit message**, enter a reason for the commit. -1. Select the **Target branch** or create a new branch by typing the name (without spaces, capital letters, or special chars). +1. Select the **Target branch** or create a new branch by typing the name (without spaces). 1. Select the **Start a new merge request with these changes** checkbox or toggle. This checkbox or toggle is visible only if the target is not the same as the source branch, or if the source branch is protected. 1. Select **Commit changes**. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 339137cf065..68916760cb3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5910,7 +5910,7 @@ msgstr "" msgid "Ask someone with write access to resolve it." msgstr "" -msgid "Ask the Tanuki Bot" +msgid "Ask the GitLab Chat" msgstr "" msgid "Ask your group owner to set up a group runner." @@ -43734,13 +43734,13 @@ msgstr "" msgid "TanukiBot|For example, %{linkStart}what is a fork?%{linkEnd}" msgstr "" -msgid "TanukiBot|Give feedback" +msgid "TanukiBot|GitLab Chat" msgstr "" -msgid "TanukiBot|Sources" +msgid "TanukiBot|Give feedback" msgstr "" -msgid "TanukiBot|Tanuki Bot" +msgid "TanukiBot|Sources" msgstr "" msgid "TanukiBot|There was an error communicating with Tanuki Bot. Please reach out to GitLab support for more assistance or try again later." @@ -44990,6 +44990,9 @@ msgstr "" msgid "There was an error fetching the %{replicableType}" msgstr "" +msgid "There was an error fetching the cancelable jobs." +msgstr "" + msgid "There was an error fetching the deploy freezes." msgstr "" @@ -45008,6 +45011,9 @@ msgstr "" msgid "There was an error fetching the number of jobs for your project." msgstr "" +msgid "There was an error fetching the number of jobs." +msgstr "" + msgid "There was an error fetching the top labels for the selected group" msgstr "" diff --git a/qa/qa/specs/features/browser_ui/8_monitor/incident_management/recovery_alert_closes_correct_incident_spec.rb b/qa/qa/specs/features/browser_ui/8_monitor/incident_management/recovery_alert_closes_correct_incident_spec.rb index 695a73de078..191da25c57b 100644 --- a/qa/qa/specs/features/browser_ui/8_monitor/incident_management/recovery_alert_closes_correct_incident_spec.rb +++ b/qa/qa/specs/features/browser_ui/8_monitor/incident_management/recovery_alert_closes_correct_incident_spec.rb @@ -1,7 +1,10 @@ # frozen_string_literal: true module QA - RSpec.describe 'Monitor', product_group: :respond do + RSpec.describe 'Monitor', product_group: :respond, quarantine: { + type: :bug, + issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/395512' + } do describe 'Recovery alert' do shared_examples 'triggers recovery alert' do it 'only closes the correct incident', :aggregate_failures do @@ -31,12 +34,7 @@ module QA context( 'when using HTTP endpoint integration', - testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/393842', - quarantine: { - only: { pipeline: :nightly }, - type: :bug, - issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/403596' - } + testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/393842' ) do include_context 'sends and resolves test alerts' diff --git a/spec/frontend/deprecated_jquery_dropdown_spec.js b/spec/frontend/deprecated_jquery_dropdown_spec.js index 439c20e0fb5..44279ec7915 100644 --- a/spec/frontend/deprecated_jquery_dropdown_spec.js +++ b/spec/frontend/deprecated_jquery_dropdown_spec.js @@ -1,8 +1,9 @@ /* eslint-disable no-param-reassign */ import $ from 'jquery'; +import htmlDeprecatedJqueryDropdown from 'test_fixtures_static/deprecated_jquery_dropdown.html'; import mockProjects from 'test_fixtures_static/projects.json'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import '~/lib/utils/common_utils'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -65,7 +66,7 @@ describe('deprecatedJQueryDropdown', () => { } beforeEach(() => { - loadHTMLFixture('static/deprecated_jquery_dropdown.html'); + setHTMLFixture(htmlDeprecatedJqueryDropdown); test.dropdownContainerElement = $('.dropdown.inline'); test.$dropdownMenuElement = $('.dropdown-menu', test.dropdownContainerElement); test.projectsData = JSON.parse(JSON.stringify(mockProjects)); diff --git a/spec/frontend/dropzone_input_spec.js b/spec/frontend/dropzone_input_spec.js index 179ba917e7f..57debf79c7b 100644 --- a/spec/frontend/dropzone_input_spec.js +++ b/spec/frontend/dropzone_input_spec.js @@ -1,7 +1,8 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; +import htmlNewMilestone from 'test_fixtures/milestones/new-milestone.html'; import mock from 'xhr-mock'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; import PasteMarkdownTable from '~/behaviors/markdown/paste_markdown_table'; @@ -48,7 +49,7 @@ describe('dropzone_input', () => { }; beforeEach(() => { - loadHTMLFixture('milestones/new-milestone.html'); + setHTMLFixture(htmlNewMilestone); form = $('#new_milestone'); form.data('uploads-path', TEST_UPLOAD_PATH); diff --git a/spec/frontend/fixtures/jobs.rb b/spec/frontend/fixtures/jobs.rb index 15bbec9b9f2..6c0b87c5a68 100644 --- a/spec/frontend/fixtures/jobs.rb +++ b/spec/frontend/fixtures/jobs.rb @@ -48,7 +48,7 @@ RSpec.describe 'Jobs (JavaScript fixtures)' do let!(:with_artifact) { create(:ci_build, :success, name: 'with_artifact', job_artifacts: [artifact], pipeline: pipeline) } let!(:with_coverage) { create(:ci_build, :success, name: 'with_coverage', coverage: 40.0, pipeline: pipeline) } - shared_examples 'graphql queries' do |path, jobs_query| + shared_examples 'graphql queries' do |path, jobs_query, skip_non_defaults = false| let_it_be(:variables) { {} } let_it_be(:success_path) { '' } @@ -65,25 +65,27 @@ RSpec.describe 'Jobs (JavaScript fixtures)' do expect_graphql_errors_to_be_empty end - it "#{fixtures_path}#{jobs_query}.as_guest.json" do - guest = create(:user) - project.add_guest(guest) + context 'with non default fixtures', if: !skip_non_defaults do + it "#{fixtures_path}#{jobs_query}.as_guest.json" do + guest = create(:user) + project.add_guest(guest) - post_graphql(query, current_user: guest, variables: variables) + post_graphql(query, current_user: guest, variables: variables) - expect_graphql_errors_to_be_empty - end + expect_graphql_errors_to_be_empty + end - it "#{fixtures_path}#{jobs_query}.paginated.json" do - post_graphql(query, current_user: user, variables: variables.merge({ first: 2 })) + it "#{fixtures_path}#{jobs_query}.paginated.json" do + post_graphql(query, current_user: user, variables: variables.merge({ first: 2 })) - expect_graphql_errors_to_be_empty - end + expect_graphql_errors_to_be_empty + end - it "#{fixtures_path}#{jobs_query}.empty.json" do - post_graphql(query, current_user: user, variables: variables.merge({ first: 0 })) + it "#{fixtures_path}#{jobs_query}.empty.json" do + post_graphql(query, current_user: user, variables: variables.merge({ first: 0 })) - expect_graphql_errors_to_be_empty + expect_graphql_errors_to_be_empty + end end end @@ -92,37 +94,25 @@ RSpec.describe 'Jobs (JavaScript fixtures)' do let(:success_path) { %w[project jobs] } end + it_behaves_like 'graphql queries', 'jobs/components/table/graphql/queries', 'get_jobs_count.query.graphql', true do + let(:variables) { { fullPath: 'frontend-fixtures/builds-project' } } + let(:success_path) { %w[project jobs] } + end + it_behaves_like 'graphql queries', 'pages/admin/jobs/components/table/graphql/queries', 'get_all_jobs.query.graphql' do let(:user) { create(:admin) } let(:success_path) { 'jobs' } end - it_behaves_like 'graphql queries', 'pages/admin/jobs/components/table/graphql/queries', 'get_cancelable_jobs_count.query.graphql' do + it_behaves_like 'graphql queries', 'pages/admin/jobs/components/table/graphql/queries', 'get_cancelable_jobs_count.query.graphql', true do let(:variables) { { statuses: %w[PENDING RUNNING] } } let(:user) { create(:admin) } let(:success_path) { %w[cancelable count] } end - end - - describe 'get_jobs_count.query.graphql', type: :request do - let!(:build) { create(:ci_build, :success, name: 'build', pipeline: pipeline) } - let!(:cancelable) { create(:ci_build, :cancelable, name: 'cancelable', pipeline: pipeline) } - let!(:failed) { create(:ci_build, :failed, name: 'failed', pipeline: pipeline) } - - fixtures_path = 'graphql/jobs/' - get_jobs_count_query = 'get_jobs_count.query.graphql' - full_path = 'frontend-fixtures/builds-project' - - let_it_be(:query) do - get_graphql_query_as_string("jobs/components/table/graphql/queries/#{get_jobs_count_query}") - end - - it "#{fixtures_path}#{get_jobs_count_query}.json" do - post_graphql(query, current_user: user, variables: { - fullPath: full_path - }) - expect_graphql_errors_to_be_empty + it_behaves_like 'graphql queries', 'pages/admin/jobs/components/table/graphql/queries', 'get_all_jobs_count.query.graphql', true do + let(:user) { create(:admin) } + let(:success_path) { 'jobs' } end end end diff --git a/spec/frontend/invite_members/components/import_project_members_modal_spec.js b/spec/frontend/invite_members/components/import_project_members_modal_spec.js index 74cb59a9b52..73634855850 100644 --- a/spec/frontend/invite_members/components/import_project_members_modal_spec.js +++ b/spec/frontend/invite_members/components/import_project_members_modal_spec.js @@ -1,6 +1,8 @@ import { GlFormGroup, GlSprintf, GlModal } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; +import { createWrapper } from '@vue/test-utils'; +import { BV_HIDE_MODAL } from '~/lib/utils/constants'; import { stubComponent } from 'helpers/stub_component'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -107,6 +109,15 @@ describe('ImportProjectMembersModal', () => { }); describe('submitting the import', () => { + it('prevents closing', () => { + const evt = { preventDefault: jest.fn() }; + createComponent(); + + findGlModal().vm.$emit('primary', evt); + + expect(evt.preventDefault).toHaveBeenCalledTimes(1); + }); + describe('when the import is successful with reloadPageOnSubmit', () => { beforeEach(() => { createComponent({ @@ -161,6 +172,12 @@ describe('ImportProjectMembersModal', () => { ); }); + it('hides the modal', () => { + const rootWrapper = createWrapper(wrapper.vm.$root); + + expect(rootWrapper.emitted(BV_HIDE_MODAL)).toHaveLength(1); + }); + it('does not call displaySuccessfulInvitationAlert on mount', () => { expect(displaySuccessfulInvitationAlert).not.toHaveBeenCalled(); }); diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js index de0c06e001a..253e669e889 100644 --- a/spec/frontend/jobs/mock_data.js +++ b/spec/frontend/jobs/mock_data.js @@ -1,4 +1,5 @@ import mockJobsCount from 'test_fixtures/graphql/jobs/get_jobs_count.query.graphql.json'; +import mockAllJobsCount from 'test_fixtures/graphql/jobs/get_all_jobs_count.query.graphql.json'; import mockJobsEmpty from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.empty.json'; import mockAllJobsEmpty from 'test_fixtures/graphql/jobs/get_all_jobs.query.graphql.empty.json'; import mockJobsPaginated from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.paginated.json'; @@ -22,6 +23,7 @@ export const mockJobsNodes = mockJobs.data.project.jobs.nodes; export const mockAllJobsNodes = mockAllJobs.data.jobs.nodes; export const mockJobsNodesAsGuest = mockJobsAsGuest.data.project.jobs.nodes; export const mockJobsCountResponse = mockJobsCount; +export const mockAllJobsCountResponse = mockAllJobsCount; export const mockCancelableJobsCountResponse = mockCancelableJobsCount; export const stages = [ diff --git a/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js b/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js index d4b581c3fcf..44a5878e6f2 100644 --- a/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js +++ b/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js @@ -3,11 +3,11 @@ import { mount, shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; -import { s__ } from '~/locale'; import waitForPromises from 'helpers/wait_for_promises'; import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue'; import JobsSkeletonLoader from '~/pages/admin/jobs/components/jobs_skeleton_loader.vue'; import getAllJobsQuery from '~/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql'; +import getAllJobsCount from '~/pages/admin/jobs/components/table/graphql/queries/get_all_jobs_count.query.graphql'; import getCancelableJobsQuery from '~/pages/admin/jobs/components/table/graphql/queries/get_cancelable_jobs_count.query.graphql'; import AdminJobsTableApp from '~/pages/admin/jobs/components/table/admin_jobs_table_app.vue'; import CancelJobs from '~/pages/admin/jobs/components/cancel_jobs.vue'; @@ -17,11 +17,19 @@ import { TEST_HOST } from 'spec/test_constants'; import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue'; import * as urlUtils from '~/lib/utils/url_utility'; import { + JOBS_FETCH_ERROR_MSG, + CANCELABLE_JOBS_ERROR_MSG, + LOADING_ARIA_LABEL, + RAW_TEXT_WARNING_ADMIN, + JOBS_COUNT_ERROR_MESSAGE, +} from '~/pages/admin/jobs/components/constants'; +import { mockAllJobsResponsePaginated, mockCancelableJobsCountResponse, mockAllJobsResponseEmpty, statuses, mockFailedSearchToken, + mockAllJobsCountResponse, } from '../../../../../jobs/mock_data'; Vue.use(VueApollo); @@ -35,6 +43,7 @@ describe('Job table app', () => { const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); const cancelHandler = jest.fn().mockResolvedValue(mockCancelableJobsCountResponse); const emptyHandler = jest.fn().mockResolvedValue(mockAllJobsResponseEmpty); + const countSuccessHandler = jest.fn().mockResolvedValue(mockAllJobsCountResponse); const findSkeletonLoader = () => wrapper.findComponent(JobsSkeletonLoader); const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); @@ -48,10 +57,11 @@ describe('Job table app', () => { const triggerInfiniteScroll = () => wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); - const createMockApolloProvider = (handler, cancelableHandler) => { + const createMockApolloProvider = (handler, cancelableHandler, countHandler) => { const requestHandlers = [ [getAllJobsQuery, handler], [getCancelableJobsQuery, cancelableHandler], + [getAllJobsCount, countHandler], ]; return createMockApollo(requestHandlers); @@ -60,6 +70,7 @@ describe('Job table app', () => { const createComponent = ({ handler = successHandler, cancelableHandler = cancelHandler, + countHandler = countSuccessHandler, mountFn = shallowMount, data = {}, } = {}) => { @@ -72,7 +83,7 @@ describe('Job table app', () => { provide: { jobStatuses: statuses, }, - apolloProvider: createMockApolloProvider(handler, cancelableHandler), + apolloProvider: createMockApolloProvider(handler, cancelableHandler, countHandler), }); }; @@ -133,6 +144,7 @@ describe('Job table app', () => { const pageSize = 50; expect(findLoadingSpinner().exists()).toBe(true); + expect(findLoadingSpinner().attributes('aria-label')).toBe(LOADING_ARIA_LABEL); await waitForPromises(); @@ -172,9 +184,57 @@ describe('Job table app', () => { await waitForPromises(); - expect(findAlert().text()).toBe('There was an error fetching the jobs.'); + expect(findAlert().text()).toBe(JOBS_FETCH_ERROR_MSG); expect(findTable().exists()).toBe(false); }); + + it('should show an alert if there is an error fetching the jobs count data', async () => { + createComponent({ handler: successHandler, countHandler: failedHandler }); + + await waitForPromises(); + + expect(findAlert().text()).toBe(JOBS_COUNT_ERROR_MESSAGE); + }); + + it('should show an alert if there is an error fetching the cancelable jobs data', async () => { + createComponent({ handler: successHandler, cancelableHandler: failedHandler }); + + await waitForPromises(); + + expect(findAlert().text()).toBe(CANCELABLE_JOBS_ERROR_MSG); + }); + + it('jobs table should still load if count query fails', async () => { + createComponent({ handler: successHandler, countHandler: failedHandler }); + + await waitForPromises(); + + expect(findTable().exists()).toBe(true); + }); + + it('jobs table should still load if cancel query fails', async () => { + createComponent({ handler: successHandler, cancelableHandler: failedHandler }); + + await waitForPromises(); + + expect(findTable().exists()).toBe(true); + }); + + it('jobs count should be zero if count query fails', async () => { + createComponent({ handler: successHandler, countHandler: failedHandler }); + + await waitForPromises(); + + expect(findTabs().props('allJobsCount')).toBe(0); + }); + + it('cancel button should be hidden if query fails', async () => { + createComponent({ handler: successHandler, cancelableHandler: failedHandler }); + + await waitForPromises(); + + expect(findCancelJobsButton().exists()).toBe(false); + }); }); describe('cancel jobs button', () => { @@ -233,11 +293,21 @@ describe('Job table app', () => { expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1); }); + it('refetches jobs count query when filtering', async () => { + createComponent(); + + jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn()); + + expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); + + expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(1); + }); + it('shows raw text warning when user inputs raw text', async () => { const expectedWarning = { - message: s__( - 'Jobs|Raw text search is not currently supported for the jobs filtered search feature. Please use the available search tokens.', - ), + message: RAW_TEXT_WARNING_ADMIN, type: 'warning', }; diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view_spec.js index 1fa613d15d4..5c6358a94ab 100644 --- a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -81,8 +81,26 @@ describe('DropdownContentsLabelsView', () => { } }; - describe('computed', () => { - describe('visibleLabels', () => { + describe('component', () => { + it('calls `focusInput` on searchInput field when the component appears', async () => { + findIntersectionObserver().vm.$emit('appear'); + + await nextTick(); + + expect(focusInputMock).toHaveBeenCalled(); + }); + + it('removes loaded labels when the component disappears', async () => { + jest.spyOn(store, 'dispatch'); + + await findIntersectionObserver().vm.$emit('disappear'); + + expect(store.dispatch).toHaveBeenCalledWith(expect.anything(), []); + }); + }); + + describe('labels', () => { + describe('when it is visible', () => { beforeEach(() => { createComponent(undefined, mountExtended); store.dispatch('receiveLabelsSuccess', mockLabels); @@ -112,6 +130,29 @@ describe('DropdownContentsLabelsView', () => { }); }); + describe('when it is clicked', () => { + beforeEach(() => { + createComponent(undefined, mountExtended); + store.dispatch('receiveLabelsSuccess', mockLabels); + }); + + it('calls action `updateSelectedLabels` with provided `label` param', () => { + findLabelItems().at(0).findComponent(GlLink).vm.$emit('click'); + + expect(updateSelectedLabelsMock).toHaveBeenCalledWith(expect.anything(), [ + { ...mockLabels[0], indeterminate: expect.anything(), set: expect.anything() }, + ]); + }); + + it('calls action `toggleDropdownContents` when `state.allowMultiselect` is false', () => { + store.state.allowMultiselect = false; + + findLabelItems().at(0).findComponent(GlLink).vm.$emit('click'); + + expect(toggleDropdownContentsMock).toHaveBeenCalled(); + }); + }); + describe('showNoMatchingResultsMessage', () => { it.each` searchKey | labels | labelsDescription | returnValue @@ -132,47 +173,37 @@ describe('DropdownContentsLabelsView', () => { }); }); - describe('methods', () => { - const fakePreventDefault = jest.fn(); + describe('create label link', () => { + it('calls actions `receiveLabelsSuccess` with empty array and `toggleDropdownContentsCreateView`', async () => { + jest.spyOn(store, 'dispatch'); - describe('handleComponentAppear', () => { - it('calls `focusInput` on searchInput field', async () => { - findIntersectionObserver().vm.$emit('appear'); + await findCreateLabelLink().vm.$emit('click'); - await nextTick(); - - expect(focusInputMock).toHaveBeenCalled(); - }); + expect(store.dispatch).toHaveBeenCalledWith('receiveLabelsSuccess', []); + expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContentsCreateView'); }); + }); - describe('handleComponentDisappear', () => { - it('calls action `receiveLabelsSuccess` with empty array', async () => { - jest.spyOn(store, 'dispatch'); - - await findIntersectionObserver().vm.$emit('disappear'); + describe('keyboard navigation', () => { + const fakePreventDefault = jest.fn(); - expect(store.dispatch).toHaveBeenCalledWith(expect.anything(), []); - }); + beforeEach(() => { + createComponent(undefined, mountExtended); + store.dispatch('receiveLabelsSuccess', mockLabels); }); - describe('handleCreateLabelClick', () => { - it('calls actions `receiveLabelsSuccess` with empty array and `toggleDropdownContentsCreateView`', async () => { - jest.spyOn(store, 'dispatch'); + describe('when the "down" key is pressed', () => { + it('highlights the item', async () => { + expect(findLabelItems().at(0).classes()).not.toContain('is-focused'); - await findCreateLabelLink().vm.$emit('click'); + await findLabelsList().trigger('keydown.down'); - expect(store.dispatch).toHaveBeenCalledWith('receiveLabelsSuccess', []); - expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContentsCreateView'); + expect(findLabelItems().at(0).classes()).toContain('is-focused'); }); }); - describe('handleKeyDown', () => { - beforeEach(() => { - createComponent(undefined, mountExtended); - store.dispatch('receiveLabelsSuccess', mockLabels); - }); - - it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', async () => { + describe('when the "up" arrow key is pressed', () => { + it('un-highlights the item', async () => { await setCurrentHighlightItem(1); expect(findLabelItems().at(1).classes()).toContain('is-focused'); @@ -181,16 +212,10 @@ describe('DropdownContentsLabelsView', () => { expect(findLabelItems().at(1).classes()).not.toContain('is-focused'); }); + }); - it('increases `currentHighlightItem` value by 1 when Down arrow key is pressed', async () => { - expect(findLabelItems().at(0).classes()).not.toContain('is-focused'); - - await findLabelsList().trigger('keydown.down'); - - expect(findLabelItems().at(0).classes()).toContain('is-focused'); - }); - - it('resets the search text when the Enter key is pressed', async () => { + describe('when the "enter" key is pressed', () => { + it('resets the search text', async () => { await setCurrentHighlightItem(1); await findSearchBoxByType().vm.$emit('input', 'bug'); await findLabelsList().trigger('keydown.enter', { preventDefault: fakePreventDefault }); @@ -199,21 +224,23 @@ describe('DropdownContentsLabelsView', () => { expect(fakePreventDefault).toHaveBeenCalled(); }); - it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', async () => { + it('calls action `updateSelectedLabels` with currently highlighted label', async () => { await setCurrentHighlightItem(2); await findLabelsList().trigger('keydown.enter', { preventDefault: fakePreventDefault }); expect(updateSelectedLabelsMock).toHaveBeenCalledWith(expect.anything(), [mockLabels[2]]); }); + }); - it('calls action `toggleDropdownContents` when Esc key is pressed', async () => { + describe('when the "esc" key is pressed', () => { + it('calls action `toggleDropdownContents`', async () => { await setCurrentHighlightItem(1); await findLabelsList().trigger('keydown.esc'); expect(toggleDropdownContentsMock).toHaveBeenCalled(); }); - it('calls action `scrollIntoViewIfNeeded` in next tick when esc key is pressed', async () => { + it('scrolls dropdown content into view', async () => { const containerTop = 500; const labelTop = 0; @@ -227,29 +254,6 @@ describe('DropdownContentsLabelsView', () => { expect(findDropdownContent().element.scrollTop).toBe(labelTop - containerTop); }); }); - - describe('handleLabelClick', () => { - beforeEach(() => { - createComponent(undefined, mountExtended); - store.dispatch('receiveLabelsSuccess', mockLabels); - }); - - it('calls action `updateSelectedLabels` with provided `label` param', () => { - findLabelItems().at(0).findComponent(GlLink).vm.$emit('click'); - - expect(updateSelectedLabelsMock).toHaveBeenCalledWith(expect.anything(), [ - { ...mockLabels[0], indeterminate: expect.anything(), set: expect.anything() }, - ]); - }); - - it('calls action `toggleDropdownContents` when `state.allowMultiselect` is false', () => { - store.state.allowMultiselect = false; - - findLabelItems().at(0).findComponent(GlLink).vm.$emit('click'); - - expect(toggleDropdownContentsMock).toHaveBeenCalled(); - }); - }); }); describe('template', () => { diff --git a/spec/frontend/super_sidebar/components/help_center_spec.js b/spec/frontend/super_sidebar/components/help_center_spec.js index b441c5f531d..17a1d655561 100644 --- a/spec/frontend/super_sidebar/components/help_center_spec.js +++ b/spec/frontend/super_sidebar/components/help_center_spec.js @@ -103,20 +103,20 @@ describe('HelpCenter component', () => { jest.spyOn(wrapper.vm.$refs.dropdown, 'close'); }); - it('shows Ask the Tanuki Bot with the help items via Portal', () => { + it('shows Ask the GitLab Chat with the help items', () => { expect(findDropdownGroup(0).props('group').items).toEqual([ expect.objectContaining({ icon: 'tanuki', - text: HelpCenter.i18n.tanuki, + text: HelpCenter.i18n.chat, extraAttrs: trackingAttrs('tanuki_bot_help_dropdown'), }), ...DEFAULT_HELP_ITEMS, ]); }); - describe('when Ask the Tanuki Bot button is clicked', () => { + describe('when Ask the GitLab Chat button is clicked', () => { beforeEach(() => { - findButton('Ask the Tanuki Bot').click(); + findButton('Ask the GitLab Chat').click(); }); it('closes the dropdown', () => { diff --git a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js index a8e3536059e..dc67097d763 100644 --- a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js +++ b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js @@ -1,4 +1,4 @@ -import { GlAvatarLabeled, GlBadge, GlIcon } from '@gitlab/ui'; +import { GlAvatarLabeled, GlBadge, GlIcon, GlPopover } from '@gitlab/ui'; import projects from 'test_fixtures/api/users/projects/get.json'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import ProjectsListItem from '~/vue_shared/components/projects_list/projects_list_item.vue'; @@ -13,6 +13,8 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge. import { ACCESS_LEVEL_LABELS } from '~/access_level/constants'; import { FEATURABLE_DISABLED, FEATURABLE_ENABLED } from '~/featurable/constants'; +jest.mock('lodash/uniqueId', () => (prefix) => `${prefix}1`); + describe('ProjectsListItem', () => { let wrapper; @@ -32,6 +34,8 @@ describe('ProjectsListItem', () => { const findAvatarLabeled = () => wrapper.findComponent(GlAvatarLabeled); const findIssuesLink = () => wrapper.findByRole('link', { name: ProjectsListItem.i18n.issues }); const findForksLink = () => wrapper.findByRole('link', { name: ProjectsListItem.i18n.forks }); + const findProjectTopics = () => wrapper.findByTestId('project-topics'); + const findPopover = () => findProjectTopics().findComponent(GlPopover); it('renders project avatar', () => { createComponent(); @@ -166,4 +170,64 @@ describe('ProjectsListItem', () => { expect(findForksLink().exists()).toBe(false); }); }); + + describe('if project has topics', () => { + it('renders first three topics', () => { + createComponent(); + + const firstThreeTopics = project.topics.slice(0, 3); + const firstThreeBadges = findProjectTopics().findAllComponents(GlBadge).wrappers.slice(0, 3); + const firstThreeBadgesText = firstThreeBadges.map((badge) => badge.text()); + const firstThreeBadgesHref = firstThreeBadges.map((badge) => badge.attributes('href')); + + expect(firstThreeTopics).toEqual(firstThreeBadgesText); + expect(firstThreeBadgesHref).toEqual( + firstThreeTopics.map((topic) => `/explore/projects/topics/${encodeURIComponent(topic)}`), + ); + }); + + it('renders the rest of the topics in a popover', () => { + createComponent(); + + const topics = project.topics.slice(3); + const badges = findPopover().findAllComponents(GlBadge).wrappers; + const badgesText = badges.map((badge) => badge.text()); + const badgesHref = badges.map((badge) => badge.attributes('href')); + + expect(topics).toEqual(badgesText); + expect(badgesHref).toEqual( + topics.map((topic) => `/explore/projects/topics/${encodeURIComponent(topic)}`), + ); + }); + + it('renders button to open popover', () => { + createComponent(); + + const expectedButtonId = 'project-topics-popover-1'; + + expect(wrapper.findByText('+ 2 more').attributes('id')).toBe(expectedButtonId); + expect(findPopover().props('target')).toBe(expectedButtonId); + }); + + describe('when topic has a name longer than 15 characters', () => { + it('truncates name and shows tooltip with full name', () => { + const topicWithLongName = 'topic with very very very long name'; + + createComponent({ + propsData: { + project: { + ...project, + topics: [topicWithLongName, ...project.topics], + }, + }, + }); + + const firstTopicBadge = findProjectTopics().findComponent(GlBadge); + const tooltip = getBinding(firstTopicBadge.element, 'gl-tooltip'); + + expect(firstTopicBadge.text()).toBe('topic with ver…'); + expect(tooltip.value).toBe(topicWithLongName); + }); + }); + }); }); |