Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-04-26 21:09:38 +00:00
parent fa69a57b46
commit 90e793301a
24 changed files with 457 additions and 151 deletions

View File

@ -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)

View File

@ -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');

View File

@ -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 });
}
});
},

View File

@ -1,6 +1,5 @@
query getAllJobs($after: String, $first: Int = 50, $statuses: [CiJobStatus!]) {
jobs(after: $after, first: $first, statuses: $statuses) {
count
pageInfo {
endCursor
hasNextPage

View File

@ -0,0 +1,5 @@
query getAllJobsCount($statuses: [CiJobStatus!]) {
jobs(statuses: $statuses) {
count
}
}

View File

@ -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'),

View File

@ -11,6 +11,7 @@ export default {
* id: number | string;
* name: string;
* webUrl: string;
* topics: string[];
* forksCount?: number;
* avatarUrl: string | null;
* starCount: number;

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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) |

View File

@ -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

View File

@ -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**.

View File

@ -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,15 +43734,15 @@ msgstr ""
msgid "TanukiBot|For example, %{linkStart}what is a fork?%{linkEnd}"
msgstr ""
msgid "TanukiBot|GitLab Chat"
msgstr ""
msgid "TanukiBot|Give feedback"
msgstr ""
msgid "TanukiBot|Sources"
msgstr ""
msgid "TanukiBot|Tanuki Bot"
msgstr ""
msgid "TanukiBot|There was an error communicating with Tanuki Bot. Please reach out to GitLab support for more assistance or try again later."
msgstr ""
@ -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 ""

View File

@ -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'

View File

@ -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));

View File

@ -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);

View File

@ -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

View File

@ -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();
});

View File

@ -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 = [

View File

@ -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';
@ -16,12 +16,20 @@ import { createAlert } from '~/alert';
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',
};

View File

@ -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', () => {
describe('create label link', () => {
it('calls actions `receiveLabelsSuccess` with empty array and `toggleDropdownContentsCreateView`', async () => {
jest.spyOn(store, 'dispatch');
await findCreateLabelLink().vm.$emit('click');
expect(store.dispatch).toHaveBeenCalledWith('receiveLabelsSuccess', []);
expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContentsCreateView');
});
});
describe('keyboard navigation', () => {
const fakePreventDefault = jest.fn();
describe('handleComponentAppear', () => {
it('calls `focusInput` on searchInput field', async () => {
findIntersectionObserver().vm.$emit('appear');
beforeEach(() => {
createComponent(undefined, mountExtended);
store.dispatch('receiveLabelsSuccess', mockLabels);
});
await nextTick();
describe('when the "down" key is pressed', () => {
it('highlights the item', async () => {
expect(findLabelItems().at(0).classes()).not.toContain('is-focused');
expect(focusInputMock).toHaveBeenCalled();
await findLabelsList().trigger('keydown.down');
expect(findLabelItems().at(0).classes()).toContain('is-focused');
});
});
describe('handleComponentDisappear', () => {
it('calls action `receiveLabelsSuccess` with empty array', async () => {
jest.spyOn(store, 'dispatch');
await findIntersectionObserver().vm.$emit('disappear');
expect(store.dispatch).toHaveBeenCalledWith(expect.anything(), []);
});
});
describe('handleCreateLabelClick', () => {
it('calls actions `receiveLabelsSuccess` with empty array and `toggleDropdownContentsCreateView`', async () => {
jest.spyOn(store, 'dispatch');
await findCreateLabelLink().vm.$emit('click');
expect(store.dispatch).toHaveBeenCalledWith('receiveLabelsSuccess', []);
expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContentsCreateView');
});
});
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', () => {

View File

@ -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', () => {

View File

@ -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);
});
});
});
});