Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
54b2cc7dfc
commit
9db4bab965
|
|
@ -45,59 +45,17 @@ db:rollback single-db:
|
|||
- .single-db
|
||||
- .rails:rules:single-db
|
||||
|
||||
db:migrate:multi-version-upgrade-1:
|
||||
stage: test
|
||||
image: ${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images/${BUILD_OS}-${OS_VERSION}-ruby-${RUBY_VERSION}:bundler-${BUNDLER_VERSION}-docker-${DOCKER_VERSION}
|
||||
extends:
|
||||
- .db-job-base
|
||||
- .use-docker-in-docker
|
||||
variables:
|
||||
UPGRADE_STOP: 16.3.6-ee
|
||||
UPGRADE_STOP_IMAGE: gitlab/gitlab-ee:${UPGRADE_STOP}.0
|
||||
UPGRADE_STOP_TAG: v${UPGRADE_STOP}
|
||||
before_script:
|
||||
# pull, seed, and export data from previous Upgrade Stop
|
||||
- docker pull "${UPGRADE_STOP_IMAGE}"
|
||||
- |
|
||||
docker run \
|
||||
-d \
|
||||
-v ./scripts/data_seeder:/opt/gitlab/embedded/service/gitlab-rails/scripts/data_seeder \
|
||||
-v ./ee/db/seeds/data_seeder:/opt/gitlab/embedded/service/gitlab-rails/ee/db/seeds/data_seeder \
|
||||
-v ./ee/lib/tasks/gitlab/seed:/opt/gitlab/embedded/service/gitlab-rails/ee/lib/tasks/gitlab/seed \
|
||||
--name gitlab \
|
||||
"${UPGRADE_STOP_IMAGE}"
|
||||
- docker exec gitlab bash -c "cd /opt/gitlab/embedded/service/gitlab-rails; REF='${UPGRADE_STOP_TAG}' . scripts/data_seeder/test_resources.sh"
|
||||
- |
|
||||
docker exec gitlab bash -c "cd /opt/gitlab/embedded/service/gitlab-rails; echo \"gem 'gitlab-rspec', path: 'gems/gitlab-rspec'\" >> Gemfile"
|
||||
- docker exec gitlab bash -c "cd /opt/gitlab/embedded/service/gitlab-rails; ruby scripts/data_seeder/globalize_gems.rb; bundle install"
|
||||
- docker exec gitlab bash -c "gitlab-ctl reconfigure"
|
||||
- docker exec gitlab gitlab-rake "ee:gitlab:seed:data_seeder[bulk_data.rb]"
|
||||
|
||||
# dump
|
||||
- docker exec gitlab bash -c "mkdir /tmp/xfer; chown gitlab-psql /tmp/xfer"
|
||||
- |
|
||||
docker exec gitlab bash -c " \
|
||||
runuser -l gitlab-psql -c \"pg_dump -U gitlab-psql -h '/var/opt/gitlab/postgresql' gitlabhq_production | gzip > /tmp/xfer/gitlabhq_production.gz\" \
|
||||
"
|
||||
script:
|
||||
- docker cp gitlab:/tmp/xfer/gitlabhq_production.gz .
|
||||
artifacts:
|
||||
paths: ["gitlabhq_production.gz"]
|
||||
expire_in: 3d
|
||||
when: manual
|
||||
allow_failure: true
|
||||
|
||||
db:migrate:multi-version-upgrade-2:
|
||||
stage: test
|
||||
db:migrate:multi-version-upgrade:
|
||||
extends:
|
||||
- .db-job-base
|
||||
script:
|
||||
- gunzip gitlabhq_production.gz
|
||||
- curl -o latest_upgrade_stop.gz https://gitlab.com/gitlab-org/quality/pg-dump-generator/-/raw/main/pg_dumps/ee/latest_upgrade_stop.gz
|
||||
- gunzip -c latest_upgrade_stop.gz > gitlabhq_production
|
||||
- bundle exec rake db:drop db:create
|
||||
- apt-get update -qq && apt-get install -y -qq postgresql
|
||||
- psql -h postgres -U postgres -d gitlabhq_test < gitlabhq_production
|
||||
- bundle exec rake gitlab:db:configure
|
||||
needs: ["db:migrate:multi-version-upgrade-1"]
|
||||
allow_failure: true
|
||||
|
||||
db:migrate:reset:
|
||||
extends: .db-job-base
|
||||
|
|
|
|||
|
|
@ -161,9 +161,6 @@
|
|||
.if-dot-com-gitlab-org-and-security-merge-request-and-qa-tests-specified: &if-dot-com-gitlab-org-and-security-merge-request-and-qa-tests-specified
|
||||
if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE =~ /^gitlab-org($|\/security$)/ && ($CI_MERGE_REQUEST_EVENT_TYPE == "merged_result" || $CI_MERGE_REQUEST_EVENT_TYPE == "detached") && $QA_TESTS'
|
||||
|
||||
.if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e: &if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
|
||||
if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE =~ /^gitlab-org($|\/security$)/ && ($CI_MERGE_REQUEST_EVENT_TYPE == "merged_result" || $CI_MERGE_REQUEST_EVENT_TYPE == "detached") && $QA_MANUAL_FF_PACKAGE_AND_QA'
|
||||
|
||||
.if-dot-com-gitlab-org-and-security-tag: &if-dot-com-gitlab-org-and-security-tag
|
||||
if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE =~ /^gitlab-org($|\/security$)/ && $CI_COMMIT_TAG'
|
||||
|
||||
|
|
@ -898,8 +895,6 @@
|
|||
- <<: *if-merge-request
|
||||
changes: *dependency-patterns
|
||||
- <<: *if-merge-request-labels-run-all-e2e
|
||||
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
|
||||
changes: *feature-flag-development-config-patterns
|
||||
- <<: *if-merge-request
|
||||
changes: *feature-flag-development-config-patterns
|
||||
- <<: *if-merge-request
|
||||
|
|
@ -985,9 +980,6 @@
|
|||
changes: *nodejs-patterns
|
||||
- <<: *if-merge-request
|
||||
changes: *ci-qa-patterns
|
||||
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
|
||||
changes: *feature-flag-development-config-patterns
|
||||
when: manual
|
||||
- <<: *if-dot-com-gitlab-org-and-security-merge-request-and-qa-tests-specified
|
||||
changes: *code-patterns
|
||||
- <<: *if-merge-request
|
||||
|
|
@ -1016,8 +1008,6 @@
|
|||
- <<: *if-merge-request
|
||||
changes: *dependency-patterns
|
||||
- <<: *if-merge-request-labels-run-all-e2e
|
||||
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
|
||||
changes: *feature-flag-development-config-patterns
|
||||
- <<: *if-merge-request
|
||||
changes: *feature-flag-development-config-patterns
|
||||
- <<: *if-merge-request
|
||||
|
|
@ -1252,8 +1242,6 @@
|
|||
# Rules to support .qa:rules:package-and-test
|
||||
- <<: *if-default-branch-schedule-nightly
|
||||
- <<: *if-merge-request-labels-run-all-e2e
|
||||
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
|
||||
changes: *feature-flag-development-config-patterns
|
||||
- <<: *if-merge-request
|
||||
changes: *dependency-patterns
|
||||
- <<: *if-force-ci
|
||||
|
|
@ -1533,9 +1521,6 @@
|
|||
allow_failure: true
|
||||
- <<: *if-merge-request-labels-run-all-e2e
|
||||
allow_failure: true
|
||||
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
|
||||
changes: *feature-flag-development-config-patterns
|
||||
allow_failure: true
|
||||
- <<: *if-merge-request
|
||||
changes: *feature-flag-development-config-patterns
|
||||
allow_failure: true
|
||||
|
|
@ -1561,8 +1546,6 @@
|
|||
variables:
|
||||
OMNIBUS_GITLAB_BUILD_ON_ALL_OS: 'true'
|
||||
- <<: *if-merge-request-labels-run-all-e2e
|
||||
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
|
||||
changes: *feature-flag-development-config-patterns
|
||||
- <<: *if-merge-request
|
||||
changes: *feature-flag-development-config-patterns
|
||||
- <<: *if-merge-request
|
||||
|
|
@ -1605,10 +1588,6 @@
|
|||
OMNIBUS_GITLAB_BUILD_ON_ALL_OS: 'true'
|
||||
- <<: *if-merge-request-labels-run-all-e2e
|
||||
allow_failure: true
|
||||
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
|
||||
changes: *feature-flag-development-config-patterns
|
||||
when: manual
|
||||
allow_failure: true
|
||||
- <<: *if-merge-request
|
||||
changes: *feature-flag-development-config-patterns
|
||||
allow_failure: true
|
||||
|
|
@ -1773,43 +1752,6 @@
|
|||
- <<: *if-merge-request-and-specific-devops-stage
|
||||
when: never
|
||||
- !reference [".qa:rules:code-merge-request-manual", rules]
|
||||
- <<: *if-dot-com-gitlab-org-schedule
|
||||
when: never
|
||||
- <<: *if-merge-request-targeting-stable-branch
|
||||
changes: *setup-test-env-patterns
|
||||
when: never
|
||||
- <<: *if-ruby-branch
|
||||
when: never
|
||||
- <<: *if-merge-request
|
||||
changes: *dependency-patterns
|
||||
when: never
|
||||
- <<: *if-merge-request-labels-run-all-e2e
|
||||
when: never
|
||||
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
|
||||
changes: *feature-flag-development-config-patterns
|
||||
when: manual
|
||||
allow_failure: true
|
||||
- <<: *if-merge-request
|
||||
changes: *feature-flag-development-config-patterns
|
||||
when: never
|
||||
- <<: *if-merge-request
|
||||
changes: *initializers-patterns
|
||||
when: never
|
||||
- <<: *if-merge-request
|
||||
changes: *nodejs-patterns
|
||||
when: never
|
||||
- <<: *if-merge-request
|
||||
changes: *ci-qa-patterns
|
||||
when: never
|
||||
- <<: *if-merge-request
|
||||
changes: *qa-patterns
|
||||
when: never
|
||||
- <<: *if-dot-com-gitlab-org-and-security-merge-request-and-qa-tests-specified
|
||||
changes: *code-patterns
|
||||
when: never
|
||||
- <<: *if-force-ci
|
||||
when: manual
|
||||
allow_failure: true
|
||||
|
||||
# These are based on `.qa:rules:manual-omnibus-and-follow-up-e2e` but with manual jobs changed to automatic
|
||||
.qa:rules:follow-up-e2e:
|
||||
|
|
@ -1820,41 +1762,6 @@
|
|||
- <<: *if-merge-request
|
||||
changes: *code-patterns
|
||||
allow_failure: true
|
||||
- <<: *if-dot-com-gitlab-org-schedule
|
||||
when: never
|
||||
- <<: *if-merge-request-targeting-stable-branch
|
||||
changes: *setup-test-env-patterns
|
||||
when: never
|
||||
- <<: *if-ruby-branch
|
||||
when: never
|
||||
- <<: *if-merge-request
|
||||
changes: *dependency-patterns
|
||||
when: never
|
||||
- <<: *if-merge-request-labels-run-all-e2e
|
||||
when: never
|
||||
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
|
||||
changes: *feature-flag-development-config-patterns
|
||||
allow_failure: true
|
||||
- <<: *if-merge-request
|
||||
changes: *feature-flag-development-config-patterns
|
||||
when: never
|
||||
- <<: *if-merge-request
|
||||
changes: *initializers-patterns
|
||||
when: never
|
||||
- <<: *if-merge-request
|
||||
changes: *nodejs-patterns
|
||||
when: never
|
||||
- <<: *if-merge-request
|
||||
changes: *ci-qa-patterns
|
||||
when: never
|
||||
- <<: *if-merge-request
|
||||
changes: *qa-patterns
|
||||
when: never
|
||||
- <<: *if-dot-com-gitlab-org-and-security-merge-request-and-qa-tests-specified
|
||||
changes: *code-patterns
|
||||
when: never
|
||||
- <<: *if-force-ci
|
||||
allow_failure: true
|
||||
|
||||
# These are based on `qa:rules:package-and-test-ee` but with when:never in all except for code-patterns in merge requests
|
||||
.qa:rules:post-run-e2e-message:
|
||||
|
|
@ -1871,9 +1778,6 @@
|
|||
when: never
|
||||
- <<: *if-merge-request-labels-run-all-e2e
|
||||
when: never
|
||||
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
|
||||
changes: *feature-flag-development-config-patterns
|
||||
when: never
|
||||
- <<: *if-merge-request
|
||||
changes: *feature-flag-development-config-patterns
|
||||
when: never
|
||||
|
|
@ -3026,8 +2930,6 @@
|
|||
changes: *code-backstage-qa-patterns
|
||||
- <<: *if-security-merge-request
|
||||
changes: *db-patterns
|
||||
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
|
||||
changes: *feature-flag-development-config-patterns
|
||||
|
||||
.as-if-foss:rules:start-as-if-foss:allow-failure:manual:
|
||||
rules:
|
||||
|
|
@ -3126,10 +3028,6 @@
|
|||
changes: *db-patterns
|
||||
allow_failure: true
|
||||
when: manual
|
||||
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
|
||||
changes: *feature-flag-development-config-patterns
|
||||
allow_failure: true
|
||||
when: manual
|
||||
|
||||
.as-if-foss:rules:start-as-if-foss:allow-failure:
|
||||
rules:
|
||||
|
|
@ -3205,9 +3103,6 @@
|
|||
- <<: *if-security-merge-request
|
||||
changes: *db-patterns
|
||||
allow_failure: true
|
||||
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
|
||||
changes: *feature-flag-development-config-patterns
|
||||
allow_failure: true
|
||||
|
||||
##################
|
||||
# as-if-jh rules #
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import { totalArtifactsSizeForJob, mapArchivesToJobNodes, mapBooleansToJobNodes
|
|||
import bulkDestroyJobArtifactsMutation from '../graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql';
|
||||
import { removeArtifactFromStore } from '../graphql/cache_update';
|
||||
import {
|
||||
STATUS_BADGE_VARIANTS,
|
||||
I18N_DOWNLOAD,
|
||||
I18N_BROWSE,
|
||||
I18N_DELETE,
|
||||
|
|
@ -351,7 +350,6 @@ export default {
|
|||
tdClass: 'gl-text-right',
|
||||
},
|
||||
],
|
||||
STATUS_BADGE_VARIANTS,
|
||||
i18n: {
|
||||
download: I18N_DOWNLOAD,
|
||||
browse: I18N_BROWSE,
|
||||
|
|
|
|||
|
|
@ -4,29 +4,6 @@ export const PAGE_TITLE = s__('Artifacts|Artifacts');
|
|||
export const TOTAL_ARTIFACTS_SIZE = s__('Artifacts|Total artifacts size');
|
||||
export const SIZE_UNKNOWN = __('Unknown');
|
||||
|
||||
export const JOB_STATUS_GROUP_SUCCESS = 'success';
|
||||
|
||||
export const STATUS_BADGE_VARIANTS = {
|
||||
success: 'success',
|
||||
passed: 'success',
|
||||
error: 'danger',
|
||||
failed: 'danger',
|
||||
pending: 'warning',
|
||||
'waiting-for-resource': 'warning',
|
||||
'failed-with-warnings': 'warning',
|
||||
'success-with-warnings': 'warning',
|
||||
running: 'info',
|
||||
canceled: 'neutral',
|
||||
disabled: 'neutral',
|
||||
scheduled: 'neutral',
|
||||
manual: 'neutral',
|
||||
notification: 'muted',
|
||||
preparing: 'muted',
|
||||
created: 'muted',
|
||||
skipped: 'muted',
|
||||
notfound: 'muted',
|
||||
};
|
||||
|
||||
export const I18N_DOWNLOAD = __('Download');
|
||||
export const I18N_BROWSE = s__('Artifacts|Browse');
|
||||
export const I18N_DELETE = __('Delete');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { numberToHumanSize } from '~/lib/utils/number_utils';
|
||||
import { ARCHIVE_FILE_TYPE, METADATA_FILE_TYPE, JOB_STATUS_GROUP_SUCCESS } from './constants';
|
||||
import { ARCHIVE_FILE_TYPE, METADATA_FILE_TYPE } from './constants';
|
||||
|
||||
export const totalArtifactsSizeForJob = (job) =>
|
||||
numberToHumanSize(
|
||||
|
|
@ -19,7 +19,6 @@ export const mapArchivesToJobNodes = (jobNode) => {
|
|||
|
||||
export const mapBooleansToJobNodes = (jobNode) => {
|
||||
return {
|
||||
succeeded: jobNode.detailedStatus.group === JOB_STATUS_GROUP_SUCCESS,
|
||||
hasArtifacts: jobNode.artifacts.nodes.length > 0,
|
||||
hasMetadata: jobNode.artifacts.nodes.some(
|
||||
(artifact) => artifact.fileType === METADATA_FILE_TYPE,
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export default {
|
|||
</script>
|
||||
<template>
|
||||
<node-view-wrapper
|
||||
v-if="!isStaleUploadedImage"
|
||||
v-show="!isStaleUploadedImage"
|
||||
as="span"
|
||||
class="gl-relative gl-display-inline-block"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export default {
|
|||
</script>
|
||||
<template>
|
||||
<node-view-wrapper
|
||||
v-if="!isStaleUploadedMedia"
|
||||
v-show="!isStaleUploadedMedia"
|
||||
as="span"
|
||||
:class="`media-container ${node.type.name}-container`"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ export default {
|
|||
};
|
||||
},
|
||||
sort() {
|
||||
return `${this.sortName}_${this.sortDirection}`.toUpperCase();
|
||||
return `${this.sortName}_${this.sortDirection}`;
|
||||
},
|
||||
isLoading() {
|
||||
return this.$apollo.queries.groups.loading;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
query getOrganizationGroups(
|
||||
$id: OrganizationsOrganizationID!
|
||||
$search: String
|
||||
$sort: OrganizationGroupSort
|
||||
$sort: String
|
||||
$first: Int
|
||||
$last: Int
|
||||
$before: String
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export default {
|
|||
<template>
|
||||
<div>
|
||||
<gl-popover v-for="(popover, index) in popovers" :key="index" v-bind="popover">
|
||||
<template #title>
|
||||
<template v-if="popover.title" #title>
|
||||
<span v-if="popover.html" v-safe-html:[$options.safeHtmlConfig]="popover.title"></span>
|
||||
<span v-else>{{ popover.title }}</span>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
Please see [the Widget Extensions documentation](development/merge_request_concepts/widget_extensions.md) for necessary information regarding development of new MR Widgets.
|
||||
|
|
@ -1,417 +0,0 @@
|
|||
<!-- eslint-disable vue/multi-word-component-names -->
|
||||
<script>
|
||||
import { GlButton, GlLoadingIcon, GlTooltipDirective, GlIntersectionObserver } from '@gitlab/ui';
|
||||
import * as Sentry from '~/sentry/sentry_browser_wrapper';
|
||||
import SafeHtml from '~/vue_shared/directives/safe_html';
|
||||
import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller';
|
||||
import { sprintf, s__, __ } from '~/locale';
|
||||
import Poll from '~/lib/utils/poll';
|
||||
import { normalizeHeaders } from '~/lib/utils/common_utils';
|
||||
import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants';
|
||||
import Actions from '../action_buttons.vue';
|
||||
import StateContainer from '../state_container.vue';
|
||||
import { generateText } from '../widget/utils';
|
||||
import { createTelemetryHub } from '../widget/telemetry';
|
||||
import StatusIcon from './status_icon.vue';
|
||||
import ChildContent from './child_content.vue';
|
||||
|
||||
export const LOADING_STATES = {
|
||||
collapsedLoading: 'collapsedLoading',
|
||||
collapsedError: 'collapsedError',
|
||||
expandedLoading: 'expandedLoading',
|
||||
expandedError: 'expandedError',
|
||||
};
|
||||
|
||||
export default {
|
||||
telemetry: true,
|
||||
components: {
|
||||
GlButton,
|
||||
GlLoadingIcon,
|
||||
GlIntersectionObserver,
|
||||
StatusIcon,
|
||||
Actions,
|
||||
ChildContent,
|
||||
DynamicScroller,
|
||||
DynamicScrollerItem,
|
||||
StateContainer,
|
||||
},
|
||||
directives: {
|
||||
SafeHtml,
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loadingState: LOADING_STATES.collapsedLoading,
|
||||
collapsedData: {},
|
||||
fullData: [],
|
||||
isCollapsed: true,
|
||||
showFade: false,
|
||||
modalData: undefined,
|
||||
modalName: undefined,
|
||||
telemetry: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
widgetLabel() {
|
||||
return this.$options.i18n?.label || this.$options.name;
|
||||
},
|
||||
widgetLoadingText() {
|
||||
return this.$options.i18n?.loading || __('Loading...');
|
||||
},
|
||||
widgetErrorText() {
|
||||
return this.$options.i18n?.error || __('Failed to load');
|
||||
},
|
||||
isLoadingSummary() {
|
||||
return this.loadingState === LOADING_STATES.collapsedLoading;
|
||||
},
|
||||
isLoadingExpanded() {
|
||||
return this.loadingState === LOADING_STATES.expandedLoading;
|
||||
},
|
||||
isCollapsible() {
|
||||
if (!this.isLoadingSummary && this.loadingState !== LOADING_STATES.collapsedError) {
|
||||
if ('shouldCollapse' in this) {
|
||||
return this.shouldCollapse(this.collapsedData);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
hasFullData() {
|
||||
return this.fullData.length > 0;
|
||||
},
|
||||
hasFetchError() {
|
||||
return (
|
||||
this.loadingState === LOADING_STATES.collapsedError ||
|
||||
this.loadingState === LOADING_STATES.expandedError
|
||||
);
|
||||
},
|
||||
collapseButtonLabel() {
|
||||
return sprintf(
|
||||
this.isCollapsed
|
||||
? s__('mrWidget|Show %{widget} details')
|
||||
: s__('mrWidget|Hide %{widget} details'),
|
||||
{ widget: this.widgetLabel },
|
||||
);
|
||||
},
|
||||
statusIconName() {
|
||||
if (this.hasFetchError) return EXTENSION_ICONS.failed;
|
||||
if (this.isLoadingSummary) return null;
|
||||
|
||||
return this.statusIcon(this.collapsedData);
|
||||
},
|
||||
tertiaryActionsButtons() {
|
||||
return 'tertiaryButtons' in this ? this.tertiaryButtons() : undefined;
|
||||
},
|
||||
hydratedSummary() {
|
||||
const structuredOutput = this.summary(this.collapsedData);
|
||||
const summary = {
|
||||
subject: generateText(
|
||||
typeof structuredOutput === 'string' ? structuredOutput : structuredOutput.subject,
|
||||
),
|
||||
};
|
||||
|
||||
if (structuredOutput.meta) {
|
||||
summary.meta = generateText(structuredOutput.meta);
|
||||
}
|
||||
|
||||
return summary;
|
||||
},
|
||||
modalId() {
|
||||
return this.modalName || `modal${this.$options.name}`;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
isCollapsed(newVal) {
|
||||
if (!newVal) {
|
||||
this.loadAllData();
|
||||
} else {
|
||||
this.loadingState = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
created() {
|
||||
if (this.$options.telemetry) {
|
||||
this.telemetry = createTelemetryHub(this.$options.name);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadCollapsedData();
|
||||
|
||||
this.telemetry?.viewed();
|
||||
},
|
||||
methods: {
|
||||
toggleCollapsed(e) {
|
||||
if (this.isCollapsible && !e?.target?.closest('.btn:not(.btn-icon),a')) {
|
||||
if (this.isCollapsed) {
|
||||
this.telemetry?.expanded({ type: this.statusIconName });
|
||||
}
|
||||
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
}
|
||||
},
|
||||
initExtensionMultiPolling() {
|
||||
const allData = [];
|
||||
const requests = this.fetchMultiData();
|
||||
|
||||
requests.forEach((request) => {
|
||||
const poll = new Poll({
|
||||
resource: {
|
||||
fetchData: () => request(this),
|
||||
},
|
||||
method: 'fetchData',
|
||||
successCallback: (response) => {
|
||||
this.headerCheck(response, (data) => allData.push(data));
|
||||
|
||||
if (allData.length === requests.length) {
|
||||
this.setCollapsedData(allData);
|
||||
}
|
||||
},
|
||||
errorCallback: (e) => {
|
||||
this.setCollapsedError(e);
|
||||
},
|
||||
});
|
||||
|
||||
poll.makeRequest();
|
||||
});
|
||||
},
|
||||
initExtensionPolling() {
|
||||
const poll = new Poll({
|
||||
resource: {
|
||||
fetchData: () => this.fetchCollapsedData(this),
|
||||
},
|
||||
method: 'fetchData',
|
||||
successCallback: (response) => {
|
||||
this.headerCheck(response, (data) => this.setCollapsedData(data));
|
||||
},
|
||||
errorCallback: (e) => {
|
||||
this.setCollapsedError(e);
|
||||
},
|
||||
});
|
||||
|
||||
poll.makeRequest();
|
||||
},
|
||||
initExtensionFullDataPolling() {
|
||||
const poll = new Poll({
|
||||
resource: {
|
||||
fetchData: () => this.fetchFullData(this),
|
||||
},
|
||||
method: 'fetchData',
|
||||
successCallback: (response) => {
|
||||
this.headerCheck(response, (data) => {
|
||||
this.setFullData(data);
|
||||
});
|
||||
},
|
||||
errorCallback: (e) => {
|
||||
this.setExpandedError(e);
|
||||
},
|
||||
});
|
||||
|
||||
poll.makeRequest();
|
||||
},
|
||||
headerCheck(response, callback) {
|
||||
const headers = normalizeHeaders(response.headers);
|
||||
|
||||
if (!headers['POLL-INTERVAL']) {
|
||||
callback(response.data);
|
||||
}
|
||||
},
|
||||
loadCollapsedData() {
|
||||
this.loadingState = LOADING_STATES.collapsedLoading;
|
||||
|
||||
if (this.$options.enablePolling) {
|
||||
if (this.fetchMultiData) {
|
||||
this.initExtensionMultiPolling();
|
||||
} else {
|
||||
this.initExtensionPolling();
|
||||
}
|
||||
} else {
|
||||
this.fetchCollapsedData(this)
|
||||
.then((data) => {
|
||||
this.setCollapsedData(data);
|
||||
})
|
||||
.catch((e) => {
|
||||
this.setCollapsedError(e);
|
||||
});
|
||||
}
|
||||
},
|
||||
setFullData(data) {
|
||||
this.loadingState = null;
|
||||
this.fullData = data.map((x, i) => ({ id: i, ...x }));
|
||||
},
|
||||
setCollapsedData(data) {
|
||||
this.collapsedData = data;
|
||||
this.loadingState = null;
|
||||
},
|
||||
setCollapsedError(e) {
|
||||
this.loadingState = LOADING_STATES.collapsedError;
|
||||
|
||||
Sentry.captureException(e);
|
||||
},
|
||||
setExpandedError(e) {
|
||||
this.loadingState = LOADING_STATES.expandedError;
|
||||
Sentry.captureException(e);
|
||||
},
|
||||
loadAllData() {
|
||||
if (this.hasFullData) return;
|
||||
|
||||
this.loadingState = LOADING_STATES.expandedLoading;
|
||||
|
||||
if (this.$options.enableExpandedPolling) {
|
||||
this.initExtensionFullDataPolling();
|
||||
} else {
|
||||
this.fetchFullData(this)
|
||||
.then((data) => {
|
||||
this.setFullData(data);
|
||||
})
|
||||
.catch((e) => {
|
||||
this.setExpandedError(e);
|
||||
});
|
||||
}
|
||||
},
|
||||
appear(index) {
|
||||
if (index === this.fullData.length - 1) {
|
||||
this.showFade = false;
|
||||
}
|
||||
},
|
||||
disappear(index) {
|
||||
if (index === this.fullData.length - 1) {
|
||||
this.showFade = true;
|
||||
}
|
||||
},
|
||||
onRowMouseDown() {
|
||||
this.down = Number(new Date());
|
||||
},
|
||||
onRowMouseUp(e) {
|
||||
const up = Number(new Date());
|
||||
|
||||
// To allow for text to be selected we check if the the user is clicking
|
||||
// or selecting, if they are selecting the time difference should be
|
||||
// more than 200ms
|
||||
if (up - this.down < 200 && !e?.target?.closest('.btn-icon')) {
|
||||
this.toggleCollapsed(e);
|
||||
}
|
||||
},
|
||||
onClickedAction(action) {
|
||||
if (action.trackFullReportClicked) {
|
||||
this.telemetry?.fullReportClicked();
|
||||
}
|
||||
},
|
||||
generateText,
|
||||
},
|
||||
EXTENSION_ICON_CLASS,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="media-section" data-testid="widget-extension">
|
||||
<state-container
|
||||
:status="statusIconName"
|
||||
:is-loading="isLoadingSummary"
|
||||
:class="{ 'gl-cursor-pointer': isCollapsible }"
|
||||
class="gl-pl-5 gl-pr-4 gl-py-4"
|
||||
@mousedown="onRowMouseDown"
|
||||
@mouseup="onRowMouseUp"
|
||||
>
|
||||
<div
|
||||
:class="{ 'gl-h-full': isLoadingSummary }"
|
||||
class="media-body gl-display-flex gl-flex-direction-row! gl-w-full"
|
||||
data-testid="widget-extension-top-level"
|
||||
>
|
||||
<div
|
||||
class="gl-flex-grow-1 gl-display-flex gl-align-items-center gl-flex-wrap"
|
||||
data-testid="widget-extension-top-level-summary"
|
||||
>
|
||||
<div v-if="isLoadingSummary" class="gl-w-full gl-line-height-normal">
|
||||
{{ widgetLoadingText }}
|
||||
</div>
|
||||
<div v-else-if="hasFetchError" class="gl-w-full gl-line-height-normal">
|
||||
{{ widgetErrorText }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-safe-html="hydratedSummary.subject"
|
||||
class="gl-w-full gl-line-height-normal"
|
||||
></div>
|
||||
<template v-if="hydratedSummary.meta">
|
||||
<div
|
||||
v-safe-html="hydratedSummary.meta"
|
||||
class="gl-w-full gl-font-sm gl-line-height-normal"
|
||||
></div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<actions :tertiary-buttons="tertiaryActionsButtons" @clickedAction="onClickedAction" />
|
||||
<div
|
||||
v-if="isCollapsible"
|
||||
class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6"
|
||||
>
|
||||
<gl-button
|
||||
v-gl-tooltip
|
||||
:title="collapseButtonLabel"
|
||||
:aria-expanded="`${!isCollapsed}`"
|
||||
:aria-label="collapseButtonLabel"
|
||||
:icon="isCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
|
||||
category="tertiary"
|
||||
data-testid="toggle-button"
|
||||
size="small"
|
||||
@click="toggleCollapsed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</state-container>
|
||||
<div
|
||||
v-if="!isCollapsed"
|
||||
class="mr-widget-grouped-section gl-relative"
|
||||
data-testid="widget-extension-collapsed-section"
|
||||
>
|
||||
<div v-if="isLoadingExpanded" class="report-block-container">
|
||||
<gl-loading-icon size="sm" inline /> {{ __('Loading...') }}
|
||||
</div>
|
||||
<dynamic-scroller
|
||||
v-else-if="hasFullData"
|
||||
:items="fullData"
|
||||
:min-item-size="32"
|
||||
class="report-block-container gl-p-0"
|
||||
>
|
||||
<template #default="{ item, index, active }">
|
||||
<dynamic-scroller-item :item="item" :active="active" :class="{ active }">
|
||||
<div
|
||||
:class="{
|
||||
'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1,
|
||||
}"
|
||||
class="gl-py-3 gl-pl-9"
|
||||
data-testid="extension-list-item"
|
||||
>
|
||||
<gl-intersection-observer
|
||||
:options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
|
||||
rootMargin: '100px',
|
||||
thresholds: 0.1,
|
||||
} /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
|
||||
class="gl-w-full"
|
||||
@appear="appear(index)"
|
||||
@disappear="disappear(index)"
|
||||
>
|
||||
<child-content
|
||||
:data="item"
|
||||
:widget-label="widgetLabel"
|
||||
:modal-id="modalId"
|
||||
:level="2"
|
||||
@clickedAction="onClickedAction"
|
||||
/>
|
||||
</gl-intersection-observer>
|
||||
</div>
|
||||
</dynamic-scroller-item>
|
||||
</template>
|
||||
</dynamic-scroller>
|
||||
<div
|
||||
:class="{ show: showFade }"
|
||||
class="fade mr-extenson-scrim gl-absolute gl-left-0 gl-bottom-0 gl-w-full gl-h-7 gl-pointer-events-none"
|
||||
></div>
|
||||
</div>
|
||||
<div v-if="$options.modalComponent && modalData">
|
||||
<component :is="$options.modalComponent" :modal-id="modalId" v-bind="modalData" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
<script>
|
||||
import { GlBadge, GlLink, GlModalDirective } from '@gitlab/ui';
|
||||
import { isArray } from 'lodash';
|
||||
import SafeHtml from '~/vue_shared/directives/safe_html';
|
||||
import Actions from '../action_buttons.vue';
|
||||
import { generateText } from '../widget/utils';
|
||||
import StatusIcon from './status_icon.vue';
|
||||
|
||||
export default {
|
||||
name: 'ChildContent',
|
||||
components: {
|
||||
GlBadge,
|
||||
GlLink,
|
||||
StatusIcon,
|
||||
Actions,
|
||||
},
|
||||
directives: {
|
||||
SafeHtml,
|
||||
GlModal: GlModalDirective,
|
||||
},
|
||||
props: {
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
widgetLabel: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
modalId: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
level: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
subtext() {
|
||||
const { subtext } = this.data;
|
||||
if (subtext) {
|
||||
if (isArray(subtext)) {
|
||||
return subtext.map((t) => generateText(t)).join('<br />');
|
||||
}
|
||||
|
||||
return generateText(subtext);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isArray(arr) {
|
||||
return Array.isArray(arr);
|
||||
},
|
||||
onClickedAction(action) {
|
||||
this.$emit('clickedAction', action);
|
||||
},
|
||||
generateText,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ 'gl-pl-6': level === 3 }" class="gl-w-full">
|
||||
<div v-if="data.header" class="gl-mb-2">
|
||||
<template v-if="isArray(data.header)">
|
||||
<component
|
||||
:is="headerI === 0 ? 'strong' : 'span'"
|
||||
v-for="(header, headerI) in data.header"
|
||||
:key="headerI"
|
||||
v-safe-html="generateText(header)"
|
||||
class="gl-display-block"
|
||||
/>
|
||||
</template>
|
||||
<strong v-else v-safe-html="generateText(data.header)"></strong>
|
||||
</div>
|
||||
<div class="gl-display-flex">
|
||||
<div v-if="data.icon" class="report-block-child-icon gl-display-flex">
|
||||
<status-icon :icon-name="data.icon.name" :size="12" class="gl-m-auto" />
|
||||
</div>
|
||||
<div class="gl-w-full">
|
||||
<div class="gl-display-flex gl-flex-nowrap">
|
||||
<div class="gl-flex-wrap gl-display-flex gl-w-full">
|
||||
<div class="gl-display-flex gl-align-items-center">
|
||||
<p v-safe-html="generateText(data.text)" class="gl-m-0"></p>
|
||||
</div>
|
||||
<div v-if="data.link" class="gl-pr-2">
|
||||
<gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
|
||||
</div>
|
||||
<div v-if="data.modal" class="gl-pr-2">
|
||||
<gl-link v-gl-modal="modalId" data-testid="modal-link" @click="data.modal.onClick">
|
||||
{{ data.modal.text }}
|
||||
</gl-link>
|
||||
</div>
|
||||
<div v-if="data.supportingText">
|
||||
<p v-safe-html="generateText(data.supportingText)" class="gl-m-0"></p>
|
||||
</div>
|
||||
<gl-badge
|
||||
v-if="data.badge"
|
||||
:variant="data.badge.variant || 'info'"
|
||||
size="sm"
|
||||
class="gl-ml-2"
|
||||
>
|
||||
{{ data.badge.text }}
|
||||
</gl-badge>
|
||||
</div>
|
||||
<actions
|
||||
:tertiary-buttons="data.actions"
|
||||
class="gl-ml-auto gl-pl-3"
|
||||
@clickedAction="onClickedAction"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="subtext" v-safe-html="subtext" class="gl-m-0 gl-font-sm"></p>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="data.children && level === 2">
|
||||
<ul class="gl-m-0 gl-p-0 gl-list-style-none">
|
||||
<li>
|
||||
<child-content
|
||||
v-for="childData in data.children"
|
||||
:key="childData.id"
|
||||
:data="childData"
|
||||
:widget-label="widgetLabel"
|
||||
:modal-id="modalId"
|
||||
:level="3"
|
||||
data-testid="child-content"
|
||||
@clickedAction="onClickedAction"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import { __ } from '~/locale';
|
||||
import { registeredExtensions } from './index';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
mr: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
render(h) {
|
||||
const { extensions } = registeredExtensions;
|
||||
|
||||
return h(
|
||||
'section',
|
||||
{
|
||||
attrs: {
|
||||
role: 'region',
|
||||
'aria-label': __('Merge request reports'),
|
||||
},
|
||||
},
|
||||
[
|
||||
h('div', { attrs: { class: 'mr-widget-section' } }, [
|
||||
h(
|
||||
'ul',
|
||||
{
|
||||
class: 'gl-p-0 gl-m-0 gl-list-style-none',
|
||||
},
|
||||
[
|
||||
...extensions.map((extension, index) =>
|
||||
h('li', { attrs: { class: index > 0 && 'mr-widget-border-top' } }, [
|
||||
h(
|
||||
{ ...extension },
|
||||
{
|
||||
props: {
|
||||
mr: this.mr,
|
||||
},
|
||||
},
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
import { markRaw } from '~/lib/utils/vue3compat/mark_raw';
|
||||
import ExtensionBase from './base.vue';
|
||||
|
||||
// Holds all the currently registered extensions
|
||||
export const registeredExtensions = Vue.observable({ extensions: [] });
|
||||
|
||||
const createCustomOptionsWithFallback = (extension) => (options) => {
|
||||
return options.reduce((acc, option) => {
|
||||
acc[option] = extension[option] ?? ExtensionBase[option];
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const registerExtension = (extension) => {
|
||||
const customOptions = createCustomOptionsWithFallback(extension);
|
||||
registeredExtensions.extensions.push(
|
||||
markRaw({
|
||||
extends: ExtensionBase,
|
||||
name: extension.name,
|
||||
props: {
|
||||
mr: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
// Vue 3 doesn't copy custom component options with Vue.extend
|
||||
// We have to explicitly fallback to the base component if an option is missing
|
||||
...customOptions([
|
||||
'telemetry',
|
||||
'i18n',
|
||||
'expandEvent',
|
||||
'enablePolling',
|
||||
'enableExpandedPolling',
|
||||
'modalComponent',
|
||||
]),
|
||||
computed: {
|
||||
...extension.props.reduce(
|
||||
(acc, propKey) => ({
|
||||
...acc,
|
||||
[propKey]() {
|
||||
return this.mr[propKey];
|
||||
},
|
||||
}),
|
||||
{},
|
||||
),
|
||||
...Object.keys(extension.computed).reduce(
|
||||
(acc, computedKey) => ({
|
||||
...acc,
|
||||
// Making the computed property a method allows us to pass in arguments
|
||||
// this allows for each computed property to receive some data
|
||||
[computedKey]() {
|
||||
return extension.computed[computedKey];
|
||||
},
|
||||
}),
|
||||
{},
|
||||
),
|
||||
},
|
||||
methods: {
|
||||
...extension.methods,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
<script>
|
||||
import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
|
||||
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
|
||||
import { EXTENSION_ICON_CLASS, EXTENSION_ICON_NAMES } from '../../constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlLoadingIcon,
|
||||
GlIcon,
|
||||
},
|
||||
props: {
|
||||
level: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 0,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
iconName: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 12,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
iconAriaLabel() {
|
||||
return `${capitalizeFirstCharacter(this.iconName)} ${this.name}`;
|
||||
},
|
||||
},
|
||||
EXTENSION_ICON_NAMES,
|
||||
EXTENSION_ICON_CLASS,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
$options.EXTENSION_ICON_CLASS[iconName],
|
||||
{ 'gl-w-6': !isLoading && level === 1 },
|
||||
{ 'gl-p-2': isLoading || level === 1 },
|
||||
]"
|
||||
class="gl-mr-3 gl-p-2"
|
||||
>
|
||||
<div
|
||||
class="gl-rounded-full gl-relative gl-display-flex"
|
||||
:class="{ 'mr-widget-extension-icon': !isLoading && level === 1 }"
|
||||
>
|
||||
<div class="gl-absolute gl-top-half gl-left-50p gl-translate-x-n50 gl-display-flex gl-m-auto">
|
||||
<div class="gl-display-flex gl-m-auto gl-translate-y-n50">
|
||||
<gl-loading-icon v-if="isLoading" size="sm" inline />
|
||||
<gl-icon
|
||||
v-else
|
||||
:name="$options.EXTENSION_ICON_NAMES[iconName]"
|
||||
:size="size"
|
||||
:aria-label="iconAriaLabel"
|
||||
:data-testid="`status-${iconName}-icon`"
|
||||
class="gl-display-block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { GlIcon } from '@gitlab/ui';
|
||||
import { STATUS_CLOSED, STATUS_MERGED, STATUS_EMPTY } from '~/issues/constants';
|
||||
import StatusIcon from './extensions/status_icon.vue';
|
||||
import StatusIcon from './widget/status_icon.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
// This is here because ee_else_ce requires both ce and ee versions of the
|
||||
// file to be present.
|
||||
// TODO: implement me
|
||||
export default {
|
||||
name: 'WidgetSecurityReportsCE',
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
props: ['securityReportPaths'],
|
||||
};
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
<script>
|
||||
import { isEmpty, clamp } from 'lodash';
|
||||
import { registeredExtensions } from '~/vue_merge_request_widget/components/extensions';
|
||||
import SafeHtml from '~/vue_shared/directives/safe_html';
|
||||
import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue';
|
||||
import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
|
||||
|
|
@ -39,7 +38,6 @@ import ReadyToMergeState from './components/states/ready_to_merge.vue';
|
|||
import ShaMismatch from './components/states/sha_mismatch.vue';
|
||||
import UnresolvedDiscussionsState from './components/states/unresolved_discussions.vue';
|
||||
import WorkInProgressState from './components/states/work_in_progress.vue';
|
||||
import ExtensionsContainer from './components/extensions/container';
|
||||
import WidgetContainer from './components/widget/app.vue';
|
||||
import {
|
||||
STATE_MACHINE,
|
||||
|
|
@ -65,7 +63,6 @@ export default {
|
|||
},
|
||||
components: {
|
||||
Loading,
|
||||
ExtensionsContainer,
|
||||
WidgetContainer,
|
||||
MrWidgetSuggestPipeline: WidgetSuggestPipeline,
|
||||
MrWidgetPipelineContainer,
|
||||
|
|
@ -247,9 +244,6 @@ export default {
|
|||
|
||||
return !this.mr.mergeDetailsCollapsed;
|
||||
},
|
||||
hasExtensions() {
|
||||
return registeredExtensions.extensions.length;
|
||||
},
|
||||
mergeBlockedComponentEnabled() {
|
||||
return (
|
||||
window.gon?.features?.mergeBlockedComponent &&
|
||||
|
|
@ -544,7 +538,6 @@ export default {
|
|||
/>
|
||||
<mr-widget-approvals v-if="shouldRenderApprovals" :mr="mr" :service="service" />
|
||||
<report-widget-container>
|
||||
<extensions-container v-if="hasExtensions" :mr="mr" />
|
||||
<widget-container :mr="mr" />
|
||||
</report-widget-container>
|
||||
<div class="mr-section-container mr-widget-workflow">
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ class GroupsFinder < UnionFinder
|
|||
def execute
|
||||
# filtered_groups can contain an array of scopes, so these
|
||||
# are combined into a single query using UNION.
|
||||
find_union(filtered_groups, Group).with_route.order_id_desc
|
||||
groups = find_union(filtered_groups, Group)
|
||||
sort(groups).with_route
|
||||
end
|
||||
|
||||
private
|
||||
|
|
@ -147,6 +148,12 @@ class GroupsFinder < UnionFinder
|
|||
groups.search(params[:search], include_parents: params[:parent].blank?)
|
||||
end
|
||||
|
||||
def sort(groups)
|
||||
return groups.order_id_desc unless params[:sort]
|
||||
|
||||
groups.sort_by_attribute(params[:sort])
|
||||
end
|
||||
|
||||
def min_access_level?
|
||||
current_user && params[:min_access_level].present?
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,15 +10,22 @@ module Resolvers
|
|||
required: false,
|
||||
description: 'Search query for group name or group full path.'
|
||||
|
||||
argument :sort, GraphQL::Types::String,
|
||||
required: false,
|
||||
description: "Sort order of results. Format: `<field_name>_<sort_direction>`, " \
|
||||
"for example: `id_desc` or `name_asc`",
|
||||
default_value: 'name_asc'
|
||||
|
||||
private
|
||||
|
||||
# rubocop: disable CodeReuse/ActiveRecord
|
||||
def resolve_groups(**args)
|
||||
GroupsFinder
|
||||
.new(context[:current_user], args)
|
||||
.new(context[:current_user], finder_params(args))
|
||||
.execute
|
||||
.reorder(name: :asc)
|
||||
end
|
||||
# rubocop: enable CodeReuse/ActiveRecord
|
||||
|
||||
def finder_params(args)
|
||||
args
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,27 +2,13 @@
|
|||
|
||||
module Resolvers
|
||||
module Organizations
|
||||
class GroupsResolver < BaseResolver
|
||||
class GroupsResolver < Resolvers::GroupsResolver
|
||||
include Gitlab::Graphql::Authorize::AuthorizeResource
|
||||
include ResolvesGroups
|
||||
|
||||
type Types::GroupType.connection_type, null: true
|
||||
|
||||
authorize :read_group
|
||||
|
||||
argument :search,
|
||||
GraphQL::Types::String,
|
||||
required: false,
|
||||
description: 'Search query for group name or full path.',
|
||||
alpha: { milestone: '16.4' }
|
||||
|
||||
argument :sort,
|
||||
Types::Organizations::GroupSortEnum,
|
||||
description: 'Criteria to sort organization groups by.',
|
||||
required: false,
|
||||
default_value: { field: 'name', direction: :asc },
|
||||
alpha: { milestone: '16.4' }
|
||||
|
||||
private
|
||||
|
||||
alias_method :organization, :object
|
||||
|
|
@ -30,13 +16,13 @@ module Resolvers
|
|||
def resolve_groups(**args)
|
||||
return Group.none if Feature.disabled?(:resolve_organization_groups, current_user)
|
||||
|
||||
extra_args = { organization: organization, include_ancestors: false, all_available: false }
|
||||
groups = GroupsFinder.new(current_user, args.merge(extra_args)).execute
|
||||
super
|
||||
end
|
||||
|
||||
args[:sort] ||= { field: 'name', direction: :asc }
|
||||
field = args[:sort][:field]
|
||||
direction = args[:sort][:direction]
|
||||
groups.sort_by_attribute("#{field}_#{direction}")
|
||||
def finder_params(args)
|
||||
extra_args = { organization: organization, include_ancestors: false, all_available: false }
|
||||
|
||||
super.merge(extra_args)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,30 +2,24 @@
|
|||
|
||||
module Resolvers
|
||||
module Organizations
|
||||
class ProjectsResolver < BaseResolver
|
||||
class ProjectsResolver < Resolvers::ProjectsResolver
|
||||
include Gitlab::Graphql::Authorize::AuthorizeResource
|
||||
|
||||
type Types::ProjectType, null: true
|
||||
type Types::ProjectType.connection_type, null: true
|
||||
|
||||
authorize :read_project
|
||||
|
||||
private
|
||||
|
||||
alias_method :organization, :object
|
||||
|
||||
argument :sort, GraphQL::Types::String,
|
||||
required: false,
|
||||
description: "Sort order of results. Format: `<field_name>_<sort_direction>`, " \
|
||||
"for example: `id_desc` or `name_asc`",
|
||||
alpha: { milestone: '16.9' }
|
||||
|
||||
def resolve(**args)
|
||||
project_finder_params = args.merge(organization: organization)
|
||||
|
||||
if %w[path_asc path_desc].include?(project_finder_params[:sort]) &&
|
||||
def finder_params(args)
|
||||
if %w[path_asc path_desc].include?(args[:sort]) &&
|
||||
Feature.disabled?(:project_path_sort, current_user, type: :gitlab_com_derisk)
|
||||
project_finder_params.delete(:sort)
|
||||
args.delete(:sort)
|
||||
end
|
||||
|
||||
::ProjectsFinder.new(current_user: current_user, params: project_finder_params).execute
|
||||
super.merge(organization: organization)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ module Types
|
|||
field :environment_type, GraphQL::Types::String,
|
||||
description: 'Folder name of the environment.'
|
||||
|
||||
field :deployments_display_count, GraphQL::Types::String, null: true,
|
||||
description: 'Number of deployments in the environment for display. '\
|
||||
'Returns the precise number up to 999, and "999+" for counts exceeding this limit.'
|
||||
|
||||
field :latest_opened_most_severe_alert,
|
||||
Types::AlertManagement::AlertType,
|
||||
null: true,
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Types
|
||||
module Organizations
|
||||
class GroupSortEnum < BaseEnum
|
||||
graphql_name 'OrganizationGroupSort'
|
||||
description 'Values for sorting organization groups'
|
||||
|
||||
sortable_fields = ['ID', 'Name', 'Path', 'Updated at', 'Created at']
|
||||
|
||||
sortable_fields.each do |field|
|
||||
value "#{field.upcase.tr(' ', '_')}_ASC",
|
||||
value: { field: field.downcase.tr(' ', '_'), direction: :asc },
|
||||
description: "#{field} in ascending order.",
|
||||
alpha: { milestone: '16.4' }
|
||||
|
||||
value "#{field.upcase.tr(' ', '_')}_DESC",
|
||||
value: { field: field.downcase.tr(' ', '_'), direction: :desc },
|
||||
description: "#{field} in descending order.",
|
||||
alpha: { milestone: '16.4' }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -9,6 +9,7 @@ module Ci
|
|||
include Presentable
|
||||
|
||||
self.primary_key = :id
|
||||
self.sequence_name = :ci_job_stages_id_seq
|
||||
|
||||
partitionable scope: :pipeline
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ class SystemNoteMetadata < ApplicationRecord
|
|||
include Importable
|
||||
include IgnorableColumns
|
||||
|
||||
# TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/442307
|
||||
ignore_column :id_convert_to_bigint, remove_with: '16.11', remove_after: '2024-03-15'
|
||||
|
||||
# These notes's action text might contain a reference that is external.
|
||||
|
|
|
|||
|
|
@ -24,3 +24,5 @@ class WorkItemPolicy < IssuePolicy
|
|||
|
||||
rule { is_member & can?(:read_work_item) }.enable :admin_work_item_link
|
||||
end
|
||||
|
||||
WorkItemPolicy.prepend_mod
|
||||
|
|
|
|||
|
|
@ -3,7 +3,15 @@
|
|||
class EnvironmentPresenter < Gitlab::View::Presenter::Delegated
|
||||
presents ::Environment, as: :environment
|
||||
|
||||
MAX_DEPLOYMENTS_COUNT = 1000
|
||||
MAX_DISPLAY_COUNT = '999+'
|
||||
|
||||
def path
|
||||
project_environment_path(project, self)
|
||||
end
|
||||
|
||||
def deployments_display_count
|
||||
count = deployments.limit(MAX_DEPLOYMENTS_COUNT).count
|
||||
count >= MAX_DEPLOYMENTS_COUNT ? MAX_DISPLAY_COUNT : count.to_s
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ class IssuableBaseService < ::BaseContainerService
|
|||
can?(current_user, ability_name, issuable)
|
||||
end
|
||||
|
||||
def can_set_confidentiality?(issuable)
|
||||
can?(current_user, :set_confidentiality, issuable)
|
||||
end
|
||||
|
||||
def filter_params(issuable)
|
||||
unless can_set_issuable_metadata?(issuable)
|
||||
params.delete(:labels)
|
||||
|
|
@ -78,7 +82,7 @@ class IssuableBaseService < ::BaseContainerService
|
|||
|
||||
# confidential attribute is a special type of metadata and needs to be allowed to be set
|
||||
# by non-members on issues in public projects so that security issues can be reported as confidential.
|
||||
params.delete(:confidential) unless can?(current_user, :set_confidentiality, issuable)
|
||||
params.delete(:confidential) unless can_set_confidentiality?(issuable)
|
||||
filter_contact_params(issuable)
|
||||
filter_assignees(issuable)
|
||||
filter_labels
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
table_name: p_ci_stages
|
||||
classes:
|
||||
- Ci::Stage
|
||||
feature_categories:
|
||||
- continuous_integration
|
||||
description: Routing table for ci_stages
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/145217
|
||||
milestone: '16.10'
|
||||
gitlab_schema: gitlab_ci
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class PartitionCiStagesAddFkToCiPipelines < Gitlab::Database::Migration[2.2]
|
||||
include Gitlab::Database::PartitioningMigrationHelpers
|
||||
|
||||
milestone '16.10'
|
||||
disable_ddl_transaction!
|
||||
|
||||
TABLE_NAME = :ci_stages
|
||||
PARENT_TABLE_NAME = :p_ci_stages
|
||||
FIRST_PARTITION = [100, 101]
|
||||
PARTITION_COLUMN = :partition_id
|
||||
BUILDS_TABLE = :p_ci_builds
|
||||
|
||||
def up
|
||||
convert_table_to_first_list_partition(
|
||||
table_name: TABLE_NAME,
|
||||
partitioning_column: PARTITION_COLUMN,
|
||||
parent_table_name: PARENT_TABLE_NAME,
|
||||
initial_partitioning_value: FIRST_PARTITION
|
||||
)
|
||||
end
|
||||
|
||||
def down
|
||||
# rubocop:disable Migration/WithLockRetriesDisallowedMethod -- we're calling methods defined here
|
||||
with_lock_retries(raise_on_exhaustion: true) do
|
||||
drop_foreign_key
|
||||
|
||||
execute(<<~SQL)
|
||||
ALTER TABLE #{PARENT_TABLE_NAME} DETACH PARTITION #{TABLE_NAME};
|
||||
ALTER SEQUENCE ci_stages_id_seq OWNED BY #{TABLE_NAME}.id;
|
||||
SQL
|
||||
|
||||
drop_table(PARENT_TABLE_NAME)
|
||||
end
|
||||
# rubocop:enable Migration/WithLockRetriesDisallowedMethod
|
||||
|
||||
add_routing_table_fk
|
||||
|
||||
prepare_constraint_for_list_partitioning(
|
||||
table_name: TABLE_NAME,
|
||||
partitioning_column: PARTITION_COLUMN,
|
||||
parent_table_name: PARENT_TABLE_NAME,
|
||||
initial_partitioning_value: FIRST_PARTITION
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def foreign_key
|
||||
@foreign_key ||= Gitlab::Database::PostgresForeignKey
|
||||
.by_constrained_table_name(BUILDS_TABLE)
|
||||
.by_referenced_table_name(TABLE_NAME)
|
||||
.first
|
||||
end
|
||||
|
||||
def drop_foreign_key
|
||||
raise "Expected to find a foreign key between #{BUILDS_TABLE} and #{PARENT_TABLE_NAME}" unless foreign_key.present?
|
||||
|
||||
remove_foreign_key_if_exists(BUILDS_TABLE, name: foreign_key.name)
|
||||
end
|
||||
|
||||
def add_routing_table_fk
|
||||
add_concurrent_partitioned_foreign_key(
|
||||
BUILDS_TABLE,
|
||||
TABLE_NAME,
|
||||
column: [:partition_id, :stage_id],
|
||||
target_column: [:partition_id, :id],
|
||||
reverse_lock_order: true,
|
||||
on_update: :cascade,
|
||||
on_delete: :cascade,
|
||||
validate: true,
|
||||
name: foreign_key.name,
|
||||
allow_partitioned: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CleanupBigintConversionForSystemNoteMetadata < Gitlab::Database::Migration[2.2]
|
||||
enable_lock_retries!
|
||||
milestone '16.10'
|
||||
|
||||
TABLE = :system_note_metadata
|
||||
|
||||
def up
|
||||
cleanup_conversion_of_integer_to_bigint(TABLE, :id)
|
||||
end
|
||||
|
||||
def down
|
||||
restore_conversion_of_integer_to_bigint(TABLE, :id)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1 @@
|
|||
ac3a54a2e761f48354d65cc33e7bc448d48d39dad9da29fc65bdd0594e1d7677
|
||||
|
|
@ -0,0 +1 @@
|
|||
da99fbef61922e9bf9813913717c5b2b0ca1e368096f97723e72e7b48f44ab4f
|
||||
|
|
@ -611,15 +611,6 @@ BEGIN
|
|||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION trigger_eaec934fe6b2() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW."id_convert_to_bigint" := NEW."id";
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION trigger_ff16c1fd43ea() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
|
|
@ -6700,7 +6691,7 @@ CREATE SEQUENCE ci_sources_projects_id_seq
|
|||
|
||||
ALTER SEQUENCE ci_sources_projects_id_seq OWNED BY ci_sources_projects.id;
|
||||
|
||||
CREATE TABLE ci_stages (
|
||||
CREATE TABLE p_ci_stages (
|
||||
project_id integer,
|
||||
created_at timestamp without time zone,
|
||||
updated_at timestamp without time zone,
|
||||
|
|
@ -6711,9 +6702,9 @@ CREATE TABLE ci_stages (
|
|||
id bigint NOT NULL,
|
||||
partition_id bigint NOT NULL,
|
||||
pipeline_id bigint,
|
||||
CONSTRAINT check_81b431e49b CHECK ((lock_version IS NOT NULL)),
|
||||
CONSTRAINT partitioning_constraint CHECK ((partition_id = ANY (ARRAY[(100)::bigint, (101)::bigint])))
|
||||
);
|
||||
CONSTRAINT check_81b431e49b CHECK ((lock_version IS NOT NULL))
|
||||
)
|
||||
PARTITION BY LIST (partition_id);
|
||||
|
||||
CREATE SEQUENCE ci_stages_id_seq
|
||||
START WITH 1
|
||||
|
|
@ -6722,7 +6713,21 @@ CREATE SEQUENCE ci_stages_id_seq
|
|||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
ALTER SEQUENCE ci_stages_id_seq OWNED BY ci_stages.id;
|
||||
ALTER SEQUENCE ci_stages_id_seq OWNED BY p_ci_stages.id;
|
||||
|
||||
CREATE TABLE ci_stages (
|
||||
project_id integer,
|
||||
created_at timestamp without time zone,
|
||||
updated_at timestamp without time zone,
|
||||
name character varying,
|
||||
status integer,
|
||||
lock_version integer DEFAULT 0,
|
||||
"position" integer,
|
||||
id bigint DEFAULT nextval('ci_stages_id_seq'::regclass) NOT NULL,
|
||||
partition_id bigint NOT NULL,
|
||||
pipeline_id bigint,
|
||||
CONSTRAINT check_81b431e49b CHECK ((lock_version IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE TABLE ci_subscriptions_projects (
|
||||
id bigint NOT NULL,
|
||||
|
|
@ -16129,7 +16134,6 @@ CREATE SEQUENCE system_access_microsoft_graph_access_tokens_id_seq
|
|||
ALTER SEQUENCE system_access_microsoft_graph_access_tokens_id_seq OWNED BY system_access_microsoft_graph_access_tokens.id;
|
||||
|
||||
CREATE TABLE system_note_metadata (
|
||||
id_convert_to_bigint integer DEFAULT 0 NOT NULL,
|
||||
commit_count integer,
|
||||
action character varying,
|
||||
created_at timestamp without time zone NOT NULL,
|
||||
|
|
@ -18357,6 +18361,8 @@ ALTER TABLE ONLY p_ci_job_artifacts ATTACH PARTITION ci_job_artifacts FOR VALUES
|
|||
|
||||
ALTER TABLE ONLY p_ci_pipeline_variables ATTACH PARTITION ci_pipeline_variables FOR VALUES IN ('100', '101');
|
||||
|
||||
ALTER TABLE ONLY p_ci_stages ATTACH PARTITION ci_stages FOR VALUES IN ('100', '101');
|
||||
|
||||
ALTER TABLE ONLY abuse_events ALTER COLUMN id SET DEFAULT nextval('abuse_events_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY abuse_report_assignees ALTER COLUMN id SET DEFAULT nextval('abuse_report_assignees_id_seq'::regclass);
|
||||
|
|
@ -18629,8 +18635,6 @@ ALTER TABLE ONLY ci_sources_pipelines ALTER COLUMN id SET DEFAULT nextval('ci_so
|
|||
|
||||
ALTER TABLE ONLY ci_sources_projects ALTER COLUMN id SET DEFAULT nextval('ci_sources_projects_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY ci_stages ALTER COLUMN id SET DEFAULT nextval('ci_stages_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY ci_subscriptions_projects ALTER COLUMN id SET DEFAULT nextval('ci_subscriptions_projects_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY ci_trigger_requests ALTER COLUMN id SET DEFAULT nextval('ci_trigger_requests_id_seq'::regclass);
|
||||
|
|
@ -19103,6 +19107,8 @@ ALTER TABLE ONLY p_ci_builds_metadata ALTER COLUMN id SET DEFAULT nextval('ci_bu
|
|||
|
||||
ALTER TABLE ONLY p_ci_job_annotations ALTER COLUMN id SET DEFAULT nextval('p_ci_job_annotations_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY p_ci_stages ALTER COLUMN id SET DEFAULT nextval('ci_stages_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY packages_build_infos ALTER COLUMN id SET DEFAULT nextval('packages_build_infos_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY packages_composer_cache_files ALTER COLUMN id SET DEFAULT nextval('packages_composer_cache_files_id_seq'::regclass);
|
||||
|
|
@ -20570,6 +20576,9 @@ ALTER TABLE ONLY ci_sources_pipelines
|
|||
ALTER TABLE ONLY ci_sources_projects
|
||||
ADD CONSTRAINT ci_sources_projects_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY p_ci_stages
|
||||
ADD CONSTRAINT p_ci_stages_pkey PRIMARY KEY (id, partition_id);
|
||||
|
||||
ALTER TABLE ONLY ci_stages
|
||||
ADD CONSTRAINT ci_stages_pkey PRIMARY KEY (id, partition_id);
|
||||
|
||||
|
|
@ -24506,14 +24515,24 @@ CREATE INDEX index_ci_sources_projects_on_pipeline_id ON ci_sources_projects USI
|
|||
|
||||
CREATE UNIQUE INDEX index_ci_sources_projects_on_source_project_id_and_pipeline_id ON ci_sources_projects USING btree (source_project_id, pipeline_id);
|
||||
|
||||
CREATE INDEX p_ci_stages_pipeline_id_idx ON ONLY p_ci_stages USING btree (pipeline_id);
|
||||
|
||||
CREATE INDEX index_ci_stages_on_pipeline_id ON ci_stages USING btree (pipeline_id);
|
||||
|
||||
CREATE INDEX p_ci_stages_pipeline_id_id_idx ON ONLY p_ci_stages USING btree (pipeline_id, id) WHERE (status = ANY (ARRAY[0, 1, 2, 8, 9, 10]));
|
||||
|
||||
CREATE INDEX index_ci_stages_on_pipeline_id_and_id ON ci_stages USING btree (pipeline_id, id) WHERE (status = ANY (ARRAY[0, 1, 2, 8, 9, 10]));
|
||||
|
||||
CREATE INDEX p_ci_stages_pipeline_id_position_idx ON ONLY p_ci_stages USING btree (pipeline_id, "position");
|
||||
|
||||
CREATE INDEX index_ci_stages_on_pipeline_id_and_position ON ci_stages USING btree (pipeline_id, "position");
|
||||
|
||||
CREATE UNIQUE INDEX p_ci_stages_pipeline_id_name_partition_id_idx ON ONLY p_ci_stages USING btree (pipeline_id, name, partition_id);
|
||||
|
||||
CREATE UNIQUE INDEX index_ci_stages_on_pipeline_id_name_partition_id_unique ON ci_stages USING btree (pipeline_id, name, partition_id);
|
||||
|
||||
CREATE INDEX p_ci_stages_project_id_idx ON ONLY p_ci_stages USING btree (project_id);
|
||||
|
||||
CREATE INDEX index_ci_stages_on_project_id ON ci_stages USING btree (project_id);
|
||||
|
||||
CREATE INDEX index_ci_subscriptions_projects_author_id ON ci_subscriptions_projects USING btree (author_id);
|
||||
|
|
@ -29054,6 +29073,8 @@ ALTER INDEX p_ci_job_artifacts_pkey ATTACH PARTITION ci_job_artifacts_pkey;
|
|||
|
||||
ALTER INDEX p_ci_pipeline_variables_pkey ATTACH PARTITION ci_pipeline_variables_pkey;
|
||||
|
||||
ALTER INDEX p_ci_stages_pkey ATTACH PARTITION ci_stages_pkey;
|
||||
|
||||
ALTER INDEX p_ci_job_artifacts_job_id_file_type_partition_id_idx ATTACH PARTITION idx_ci_job_artifacts_on_job_id_file_type_and_partition_id_uniq;
|
||||
|
||||
ALTER INDEX p_ci_builds_commit_id_bigint_artifacts_expire_at_id_idx ATTACH PARTITION index_357cc39ca4;
|
||||
|
|
@ -29136,6 +29157,16 @@ ALTER INDEX p_ci_job_artifacts_project_id_id_idx1 ATTACH PARTITION index_ci_job_
|
|||
|
||||
ALTER INDEX p_ci_job_artifacts_project_id_idx1 ATTACH PARTITION index_ci_job_artifacts_on_project_id_for_security_reports;
|
||||
|
||||
ALTER INDEX p_ci_stages_pipeline_id_idx ATTACH PARTITION index_ci_stages_on_pipeline_id;
|
||||
|
||||
ALTER INDEX p_ci_stages_pipeline_id_id_idx ATTACH PARTITION index_ci_stages_on_pipeline_id_and_id;
|
||||
|
||||
ALTER INDEX p_ci_stages_pipeline_id_position_idx ATTACH PARTITION index_ci_stages_on_pipeline_id_and_position;
|
||||
|
||||
ALTER INDEX p_ci_stages_pipeline_id_name_partition_id_idx ATTACH PARTITION index_ci_stages_on_pipeline_id_name_partition_id_unique;
|
||||
|
||||
ALTER INDEX p_ci_stages_project_id_idx ATTACH PARTITION index_ci_stages_on_project_id;
|
||||
|
||||
ALTER INDEX p_ci_builds_commit_id_bigint_stage_idx_created_at_idx ATTACH PARTITION index_d46de3aa4f;
|
||||
|
||||
ALTER INDEX p_ci_builds_commit_id_bigint_type_ref_idx ATTACH PARTITION index_fc42f73fa6;
|
||||
|
|
@ -29204,8 +29235,6 @@ CREATE TRIGGER trigger_catalog_resource_sync_event_on_project_update AFTER UPDAT
|
|||
|
||||
CREATE TRIGGER trigger_delete_project_namespace_on_project_delete AFTER DELETE ON projects FOR EACH ROW WHEN ((old.project_namespace_id IS NOT NULL)) EXECUTE FUNCTION delete_associated_project_namespace();
|
||||
|
||||
CREATE TRIGGER trigger_eaec934fe6b2 BEFORE INSERT OR UPDATE ON system_note_metadata FOR EACH ROW EXECUTE FUNCTION trigger_eaec934fe6b2();
|
||||
|
||||
CREATE TRIGGER trigger_ff16c1fd43ea BEFORE INSERT OR UPDATE ON geo_event_log FOR EACH ROW EXECUTE FUNCTION trigger_ff16c1fd43ea();
|
||||
|
||||
CREATE TRIGGER trigger_has_external_issue_tracker_on_delete AFTER DELETE ON integrations FOR EACH ROW WHEN ((((old.category)::text = 'issue_tracker'::text) AND (old.active = true) AND (old.project_id IS NOT NULL))) EXECUTE FUNCTION set_has_external_issue_tracker();
|
||||
|
|
@ -30415,7 +30444,7 @@ ALTER TABLE ONLY protected_tag_create_access_levels
|
|||
ALTER TABLE ONLY application_settings
|
||||
ADD CONSTRAINT fk_f9867b3540 FOREIGN KEY (web_ide_oauth_application_id) REFERENCES oauth_applications(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ONLY ci_stages
|
||||
ALTER TABLE p_ci_stages
|
||||
ADD CONSTRAINT fk_fb57e6cc56 FOREIGN KEY (pipeline_id) REFERENCES ci_pipelines(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY agent_group_authorizations
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@ There is an [issue where support is being discussed](https://gitlab.com/gitlab-o
|
|||
sudo -i
|
||||
```
|
||||
|
||||
1. Stop application server and Sidekiq
|
||||
1. Stop application server and Sidekiq:
|
||||
|
||||
```shell
|
||||
gitlab-ctl stop puma
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ To disable writes to the `authorized_keys` file:
|
|||
Again, confirm that SSH is working by removing your user's SSH key in the UI,
|
||||
adding a new one, and attempting to pull a repository.
|
||||
|
||||
Then you can backup and delete your `authorized_keys` file for best performance.
|
||||
Then you can back up and delete your `authorized_keys` file for best performance.
|
||||
The current users' keys are already present in the database, so there is no need for migration
|
||||
or for users to re-add their keys.
|
||||
|
||||
|
|
|
|||
|
|
@ -413,6 +413,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| <a id="querygroupssearch"></a>`search` | [`String`](#string) | Search query for group name or group full path. |
|
||||
| <a id="querygroupssort"></a>`sort` | [`String`](#string) | Sort order of results. Format: `<field_name>_<sort_direction>`, for example: `id_desc` or `name_asc`. |
|
||||
|
||||
### `Query.instanceExternalAuditEventDestinations`
|
||||
|
||||
|
|
@ -18740,6 +18741,7 @@ Describes where code is deployed for a project.
|
|||
| <a id="environmentclusteragent"></a>`clusterAgent` | [`ClusterAgent`](#clusteragent) | Cluster agent of the environment. |
|
||||
| <a id="environmentcreatedat"></a>`createdAt` | [`Time`](#time) | When the environment was created. |
|
||||
| <a id="environmentdeployfreezes"></a>`deployFreezes` | [`[CiFreezePeriod!]`](#cifreezeperiod) | Deployment freeze periods of the environment. |
|
||||
| <a id="environmentdeploymentsdisplaycount"></a>`deploymentsDisplayCount` | [`String`](#string) | Number of deployments in the environment for display. Returns the precise number up to 999, and "999+" for counts exceeding this limit. |
|
||||
| <a id="environmentenvironmenttype"></a>`environmentType` | [`String`](#string) | Folder name of the environment. |
|
||||
| <a id="environmentexternalurl"></a>`externalUrl` | [`String`](#string) | External URL of the environment. |
|
||||
| <a id="environmentfluxresourcepath"></a>`fluxResourcePath` | [`String`](#string) | Flux resource path of the environment. |
|
||||
|
|
@ -24218,8 +24220,8 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| <a id="organizationgroupssearch"></a>`search` **{warning-solid}** | [`String`](#string) | **Introduced** in 16.4. **Status**: Experiment. Search query for group name or full path. |
|
||||
| <a id="organizationgroupssort"></a>`sort` **{warning-solid}** | [`OrganizationGroupSort`](#organizationgroupsort) | **Introduced** in 16.4. **Status**: Experiment. Criteria to sort organization groups by. |
|
||||
| <a id="organizationgroupssearch"></a>`search` | [`String`](#string) | Search query for group name or group full path. |
|
||||
| <a id="organizationgroupssort"></a>`sort` | [`String`](#string) | Sort order of results. Format: `<field_name>_<sort_direction>`, for example: `id_desc` or `name_asc`. |
|
||||
|
||||
##### `Organization.projects`
|
||||
|
||||
|
|
@ -24239,7 +24241,15 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| <a id="organizationprojectssort"></a>`sort` **{warning-solid}** | [`String`](#string) | **Introduced** in 16.9. **Status**: Experiment. Sort order of results. Format: `<field_name>_<sort_direction>`, for example: `id_desc` or `name_asc`. |
|
||||
| <a id="organizationprojectsfullpaths"></a>`fullPaths` | [`[String!]`](#string) | Filter projects by full paths. You cannot provide more than 50 full paths. |
|
||||
| <a id="organizationprojectsids"></a>`ids` | [`[ID!]`](#id) | Filter projects by IDs. |
|
||||
| <a id="organizationprojectsmembership"></a>`membership` | [`Boolean`](#boolean) | Return only projects that the current user is a member of. |
|
||||
| <a id="organizationprojectssearch"></a>`search` | [`String`](#string) | Search query, which can be for the project name, a path, or a description. |
|
||||
| <a id="organizationprojectssearchnamespaces"></a>`searchNamespaces` | [`Boolean`](#boolean) | Include namespace in project search. |
|
||||
| <a id="organizationprojectssort"></a>`sort` | [`String`](#string) | Sort order of results. Format: `<field_name>_<sort_direction>`, for example: `id_desc` or `name_asc`. |
|
||||
| <a id="organizationprojectstopics"></a>`topics` | [`[String!]`](#string) | Filter projects by topics. |
|
||||
| <a id="organizationprojectswithissuesenabled"></a>`withIssuesEnabled` | [`Boolean`](#boolean) | Return only projects with issues enabled. |
|
||||
| <a id="organizationprojectswithmergerequestsenabled"></a>`withMergeRequestsEnabled` | [`Boolean`](#boolean) | Return only projects with merge requests enabled. |
|
||||
|
||||
### `OrganizationStateCounts`
|
||||
|
||||
|
|
@ -32130,23 +32140,6 @@ Rotation length unit of an on-call rotation.
|
|||
| <a id="oncallrotationunitenumhours"></a>`HOURS` | Hours. |
|
||||
| <a id="oncallrotationunitenumweeks"></a>`WEEKS` | Weeks. |
|
||||
|
||||
### `OrganizationGroupSort`
|
||||
|
||||
Values for sorting organization groups.
|
||||
|
||||
| Value | Description |
|
||||
| ----- | ----------- |
|
||||
| <a id="organizationgroupsortcreated_at_asc"></a>`CREATED_AT_ASC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. Created at in ascending order. |
|
||||
| <a id="organizationgroupsortcreated_at_desc"></a>`CREATED_AT_DESC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. Created at in descending order. |
|
||||
| <a id="organizationgroupsortid_asc"></a>`ID_ASC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. ID in ascending order. |
|
||||
| <a id="organizationgroupsortid_desc"></a>`ID_DESC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. ID in descending order. |
|
||||
| <a id="organizationgroupsortname_asc"></a>`NAME_ASC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. Name in ascending order. |
|
||||
| <a id="organizationgroupsortname_desc"></a>`NAME_DESC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. Name in descending order. |
|
||||
| <a id="organizationgroupsortpath_asc"></a>`PATH_ASC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. Path in ascending order. |
|
||||
| <a id="organizationgroupsortpath_desc"></a>`PATH_DESC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. Path in descending order. |
|
||||
| <a id="organizationgroupsortupdated_at_asc"></a>`UPDATED_AT_ASC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. Updated at in ascending order. |
|
||||
| <a id="organizationgroupsortupdated_at_desc"></a>`UPDATED_AT_DESC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. Updated at in descending order. |
|
||||
|
||||
### `OrganizationSort`
|
||||
|
||||
Values for sorting organizations.
|
||||
|
|
|
|||
|
|
@ -117,9 +117,11 @@ into the partitioned copy.
|
|||
Continuing the above example, the migration would look like:
|
||||
|
||||
```ruby
|
||||
class BackfillPartitionMergeRequestDiffCommits < Gitlab::Database::Migration[2.1]
|
||||
class BackfillPartitionMergeRequestDiffCommits < Gitlab::Database::Migration[2.2]
|
||||
include Gitlab::Database::PartitioningMigrationHelpers
|
||||
|
||||
milestone '16.10'
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
restrict_gitlab_migration gitlab_schema: :gitlab_main
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
---
|
||||
stage: Create
|
||||
group: Source Code
|
||||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
|
||||
---
|
||||
|
||||
# Workhorse handlers
|
||||
|
||||
Long HTTP requests are hard to handle efficiently in Rails.
|
||||
The requests are either memory-inefficient (file uploads) or impossible at all due to shorter timeouts
|
||||
(for example, Puma server has 60-second timeout).
|
||||
Workhorse can efficiently handle a large number of long HTTP requests.
|
||||
Workhorse acts as a proxy that intercepts all HTTP requests and either propagates them without
|
||||
changing or handles them itself by performing additional logic.
|
||||
|
||||
## Injectors
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Workhorse
|
||||
participant Rails
|
||||
|
||||
Client->>+Workhorse: Request
|
||||
Workhorse->>+Rails: Propagate the request as-is
|
||||
Rails-->>-Workhorse: Respond with a special header that contains instructions for proceeding with the request
|
||||
Workhorse-->>Client: Response
|
||||
```
|
||||
|
||||
### Example: Send a Git blob
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Workhorse
|
||||
participant Rails
|
||||
participant Gitaly
|
||||
|
||||
Client->>+Workhorse: HTTP Request for a blob
|
||||
Workhorse->>+Rails: Propagate the request as-is
|
||||
Rails-->>-Workhorse: Respond with a git-blob:{encoded_data} header
|
||||
Workhorse->>+Gitaly: BlobService.GetBlob gRPC request
|
||||
Gitaly-->>-Workhorse: BlobService.GetBlob gRPC request
|
||||
Workhorse-->>Client: Stream the data
|
||||
```
|
||||
|
||||
### How GitLab Rails processes the request
|
||||
|
||||
- [`send_git_blob`](https://gitlab.com/gitlab-org/gitlab/blob/8ba71b1f2feec64aeec52ccac4a1e585ba8052d9/lib/api/files.rb#L161)
|
||||
- [Send a header with a particular information](https://gitlab.com/gitlab-org/gitlab/blob/8ba71b1f2feec64aeec52ccac4a1e585ba8052d9/lib/gitlab/workhorse.rb#L49-63)
|
||||
|
||||
### How Workhorse processes the header
|
||||
|
||||
- [Specify a list of injectors](https://gitlab.com/gitlab-org/gitlab/blob/8ba71b1f2feec64aeec52ccac4a1e585ba8052d9/workhorse/internal/upstream/routes.go#L179)
|
||||
- [Iterate over injectors to find a match](https://gitlab.com/gitlab-org/gitlab/blob/8ba71b1f2feec64aeec52ccac4a1e585ba8052d9/workhorse/internal/senddata/senddata.go#L88)
|
||||
- [Process a particular request](https://gitlab.com/gitlab-org/gitlab/blob/8ba71b1f2feec64aeec52ccac4a1e585ba8052d9/workhorse/internal/git/blob.go#L23)
|
||||
|
||||
#### Example: Send a file
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Workhorse
|
||||
participant Rails
|
||||
participant Object Storage
|
||||
|
||||
Client->>+Workhorse: HTTP Request for a file
|
||||
Workhorse->>+Rails: Propagate the request as-is
|
||||
Rails-->>-Workhorse: Respond with a send-url:{encoded_data} header
|
||||
Workhorse->>+Object Storage: Request for a file
|
||||
Object Storage-->>-Workhorse: Stream the data
|
||||
Workhorse-->>Client: Stream the data
|
||||
```
|
||||
|
||||
## Pre-authorized requests
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Workhorse
|
||||
participant Rails
|
||||
participant Object Storage
|
||||
|
||||
Client->>+Workhorse: PUT /artifacts/uploads
|
||||
Note right of Rails: Append `/authorize` to the original URL and call Rails for an Auth check
|
||||
Workhorse->>+Rails: GET /artifacts/uploads/authorize
|
||||
Rails-->>-Workhorse: Authorized successfully
|
||||
|
||||
Client->>+Workhorse: Stream the file content
|
||||
Workhorse->>+Object Storage: Upload the file
|
||||
Object Storage-->>-Workhorse: Success
|
||||
|
||||
Workhorse->>+Rails: Finalize the request
|
||||
Note right of Rails: Workhorse calls the original URL to create a database record
|
||||
Rails-->>-Workhorse: Finalized successfully
|
||||
Workhorse-->>Client: Uploaded successfully
|
||||
```
|
||||
|
||||
## Git over HTTP(S)
|
||||
|
||||
Workhorse accelerates Git over HTTP(S) by handling [Git HTTP protocol](https://www.git-scm.com/docs/http-protocol) requests. For example, Git push/pull may require serving large amounts of data and in order to avoid transferring it through GitLab Rails, Workhorse only performs authorization checks against GitLab Rails, then performs Gitaly gRPC request directly and streams the data from Gitaly to the Git client.
|
||||
|
||||
### Git pull
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Git on client
|
||||
participant Workhorse
|
||||
participant Rails
|
||||
participant Gitaly
|
||||
|
||||
Note left of Git on client: git clone/fetch
|
||||
Git on client->>+Workhorse: GET /foo/bar.git/info/refs/?service=git-upload-pack
|
||||
Workhorse->>+Rails: GET Repositories::GitHttpController#info_refs
|
||||
Note right of Rails: Access check/Log activity
|
||||
Rails-->>Workhorse: 200 OK, Gitlab::Workhorse.git_http_ok
|
||||
Workhorse->>+Gitaly: SmartHTTPService.InfoRefsUploadPack gRPC request
|
||||
Gitaly -->>-Workhorse: SmartHTTPService.InfoRefsUploadPack gRPC response
|
||||
Workhorse-->>-Git on client: send info-refs response
|
||||
Git on client->>+Workhorse: GET /foo/bar.git/info/refs/?service=git-upload-pack
|
||||
Workhorse->>+Rails: GET Repositories::GitHttpController#git_receive_pack
|
||||
Note right of Rails: Access check/Update statistics
|
||||
Rails-->>Workhorse: 200 OK, Gitlab::Workhorse.git_http_ok
|
||||
Workhorse->>+Gitaly: SmartHTTPService.PostUploadPackWithSidechannel gRPC request
|
||||
Gitaly -->>-Workhorse: SmartHTTPService.PostUploadPackWithSidechannel gRPC response
|
||||
Workhorse-->>-Git on client: send response
|
||||
```
|
||||
|
||||
### Git push
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Git on client
|
||||
participant Workhorse
|
||||
participant Rails
|
||||
participant Gitaly
|
||||
|
||||
Note left of Git on client: git push
|
||||
Git on client->>+Workhorse: GET /foo/bar.git/info/refs/?service=git-receive-pack
|
||||
Workhorse->>+Rails: GET Repositories::GitHttpController#info_refs
|
||||
Note right of Rails: Access check/Log activity
|
||||
Rails-->>Workhorse: 200 OK, Gitlab::Workhorse.git_http_ok
|
||||
Workhorse->>+Gitaly: SmartHTTPService.InfoRefsReceivePack gRPC request
|
||||
Gitaly -->>-Workhorse: SmartHTTPService.InfoRefsReceivePack gRPC response
|
||||
Workhorse-->>-Git on client: send info-refs response
|
||||
Git on client->>+Workhorse: GET /foo/bar.git/info/refs/?service=git-receive-pack
|
||||
Workhorse->>+Rails: GET Repositories::GitHttpController#git_receive_pack
|
||||
Note right of Rails: Access check/Update statistics
|
||||
Rails-->>Workhorse: 200 OK, Gitlab::Workhorse.git_http_ok
|
||||
Workhorse->>+Gitaly: SmartHTTPService.PostReceivePackWithSidechannel gRPC request
|
||||
Gitaly -->>-Workhorse: SmartHTTPService.PostReceivePackWithSidechannel gRPC response
|
||||
Workhorse-->>-Git on client: send response
|
||||
```
|
||||
|
|
@ -217,6 +217,10 @@ To rectify the following error, specify the deprecated DSN in **Sentry.io > Proj
|
|||
ERROR: Sentry failure builds=0 error=raven: dsn missing private key
|
||||
```
|
||||
|
||||
## Data retention
|
||||
|
||||
GitLab has a retention limit of 90 days for all errors.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
When working with Error Tracking, you might encounter the following issues.
|
||||
|
|
@ -228,7 +232,3 @@ you might see an error when you try to [enable Sentry integration for a project]
|
|||
The resulting request to `/project/path/-/error_tracking/projects.json?api_host=https:%2F%2Fsentry.example.com%2F&token=<token>` returns a 404 status.
|
||||
|
||||
To fix this issue, enable the Monitor feature for the project.
|
||||
|
||||
## Data Retention
|
||||
|
||||
GitLab has a retention limit of 90 days for all errors.
|
||||
|
|
|
|||
|
|
@ -94,6 +94,6 @@ After the limit is exceeded, a `429 Too Many Requests` response is returned.
|
|||
|
||||
To request a limit increase to 104,8576 bytes per minute, contact GitLab support.
|
||||
|
||||
## Data Retention
|
||||
## Data retention
|
||||
|
||||
GitLab has a retention limit of 30 days for all traces.
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ Some features are still in development. View details about [support for each sta
|
|||
| Helps you understand code by explaining it in English language. <br><br><i class="fa fa-youtube-play youtube" aria-hidden="true"></i> [Watch overview](https://www.youtube.com/watch?v=1izKaLmmaCA) | [Code explanation](#explain-code-in-the-web-ui-with-code-explanation) | **Tier:** Ultimate <br>**Offering:** SaaS <br>**Status:** Experiment |
|
||||
| Assists you in determining the root cause for a pipeline failure and failed CI/CD build. | [Root cause analysis](#root-cause-analysis) | **Tier:** Ultimate <br>**Offering:** SaaS <br>**Status:** Experiment |
|
||||
| Assists you with predicting productivity metrics and identifying anomalies across your software development lifecycle. | [Value stream forecasting](#forecast-deployment-frequency-with-value-stream-forecasting) | **Tier:** Ultimate <br>**Offering:** SaaS, self-managed <br>**Status:** Experiment |
|
||||
| Processes and responds to your questions about your application's usage data. | [Product Analytics](product_analytics/index.md) | **Tier:** Ultimate <br>**Offering:** SaaS <br>**Status:** Experiment |
|
||||
|
||||
## Enable AI/ML features
|
||||
|
||||
|
|
|
|||
|
|
@ -1933,6 +1933,18 @@ msgstr ""
|
|||
msgid "AIAgent|AI Agent: %{agentId}"
|
||||
msgstr ""
|
||||
|
||||
msgid "AIAgent|Agent"
|
||||
msgstr ""
|
||||
|
||||
msgid "AIAgent|Ask your agent"
|
||||
msgstr ""
|
||||
|
||||
msgid "AIAgent|Try out your agent"
|
||||
msgstr ""
|
||||
|
||||
msgid "AIAgent|Your agent's system prompt will be applied to the chat input."
|
||||
msgstr ""
|
||||
|
||||
msgid "AIPoweredSM|AI-powered features"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -49007,7 +49019,7 @@ msgstr ""
|
|||
msgid "Sync LDAP"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sync now"
|
||||
msgid "Sync changes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Synced"
|
||||
|
|
@ -58523,12 +58535,6 @@ msgstr ""
|
|||
msgid "ciReport|%{scanner} detected %{number} new potential %{vulnStr}"
|
||||
msgstr ""
|
||||
|
||||
msgid "ciReport|%{scanner} detected %{strong_start}%{number}%{strong_end} new potential %{vulnStr}"
|
||||
msgstr ""
|
||||
|
||||
msgid "ciReport|%{scanner} detected no new %{vulnStr}"
|
||||
msgstr ""
|
||||
|
||||
msgid "ciReport|%{scanner} detected no new potential vulnerabilities"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -58758,24 +58764,9 @@ msgstr ""
|
|||
msgid "ciReport|TTFB P95"
|
||||
msgstr ""
|
||||
|
||||
msgid "ciReport|There was an error creating the issue. Please try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "ciReport|There was an error creating the merge request. Please try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "ciReport|There was an error dismissing the vulnerability. Please try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "ciReport|There was an error dismissing the vulnerability: %{error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "ciReport|There was an error reverting the dismissal. Please try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "ciReport|There was an error reverting the dismissal: %{error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "ciReport|This report contains all Code Quality issues in the source branch."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -59617,9 +59608,6 @@ msgstr ""
|
|||
msgid "mrWidget|Go to first unresolved thread"
|
||||
msgstr ""
|
||||
|
||||
msgid "mrWidget|Hide %{widget} details"
|
||||
msgstr ""
|
||||
|
||||
msgid "mrWidget|If the %{type} branch exists in your local repository, you can merge this merge request manually using the command line."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -59706,9 +59694,6 @@ msgstr ""
|
|||
msgid "mrWidget|Set by %{merge_author} to start a merge train when the pipeline succeeds"
|
||||
msgstr ""
|
||||
|
||||
msgid "mrWidget|Show %{widget} details"
|
||||
msgstr ""
|
||||
|
||||
msgid "mrWidget|The %{type} branch %{codeStart}%{name}%{codeEnd} does not exist."
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ RSpec.describe 'Database schema', feature_category: :database do
|
|||
p_ci_finished_build_ch_sync_events: %w[build_id],
|
||||
p_ci_job_artifacts: %w[partition_id project_id job_id],
|
||||
p_ci_pipeline_variables: %w[partition_id],
|
||||
p_ci_stages: %w[partition_id project_id pipeline_id],
|
||||
project_build_artifacts_size_refreshes: %w[last_job_artifact_id],
|
||||
project_data_transfers: %w[project_id namespace_id],
|
||||
project_error_tracking_settings: %w[sentry_project_id],
|
||||
|
|
|
|||
|
|
@ -4,13 +4,12 @@ require 'spec_helper'
|
|||
|
||||
RSpec.describe GroupsFinder, feature_category: :groups_and_projects do
|
||||
include AdminModeHelper
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
describe '#execute' do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe 'root level groups' do
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
where(:user_type, :params, :results) do
|
||||
nil | { all_available: true } | %i[public_group user_public_group]
|
||||
nil | { all_available: false } | %i[public_group user_public_group]
|
||||
|
|
@ -407,5 +406,27 @@ RSpec.describe GroupsFinder, feature_category: :groups_and_projects do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'group sorting' do
|
||||
let_it_be(:all_groups) { create_list(:group, 3, :public) }
|
||||
|
||||
subject(:result) { described_class.new(nil, params).execute.to_a }
|
||||
|
||||
where(:field, :direction, :sorted_groups) do
|
||||
'id' | 'asc' | lazy { all_groups.sort_by(&:id) }
|
||||
'id' | 'desc' | lazy { all_groups.sort_by(&:id).reverse }
|
||||
'name' | 'asc' | lazy { all_groups.sort_by(&:name) }
|
||||
'name' | 'desc' | lazy { all_groups.sort_by(&:name).reverse }
|
||||
'path' | 'asc' | lazy { all_groups.sort_by(&:path) }
|
||||
'path' | 'desc' | lazy { all_groups.sort_by(&:path).reverse }
|
||||
end
|
||||
|
||||
with_them do
|
||||
let(:sort) { "#{field}_#{direction}" }
|
||||
let(:params) { { sort: sort } }
|
||||
|
||||
it { is_expected.to eq(sorted_groups) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@
|
|||
<div class="form-group">
|
||||
<textarea required title="Textarea is required">Textarea</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="text" title="xss:<script>alert(0)</script>"></input>
|
||||
</div>
|
||||
<div class="form-group"></div>
|
||||
<input class="submit" type="submit">Submit</input>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ describe('GL Style Field Errors', () => {
|
|||
expect(testContext.fieldErrors).toBeDefined();
|
||||
const { inputs } = testContext.fieldErrors.state;
|
||||
|
||||
expect(inputs.length).toBe(5);
|
||||
expect(inputs.length).toBe(6);
|
||||
});
|
||||
|
||||
it('should ignore elements with custom error handling', () => {
|
||||
|
|
@ -125,4 +125,15 @@ describe('GL Style Field Errors', () => {
|
|||
expect(noTitleErrorElem.text()).toBe('This field is required.');
|
||||
expect(hasTitleErrorElem.text()).toBe('Please provide a valid email address.');
|
||||
});
|
||||
|
||||
it('sanitizes error messages before appending them to DOM', () => {
|
||||
testContext.$form.submit();
|
||||
|
||||
const trackedInputs = testContext.fieldErrors.state.inputs;
|
||||
const xssInput = trackedInputs[5];
|
||||
|
||||
const xssErrorElem = xssInput.inputElement.siblings('.gl-field-error');
|
||||
|
||||
expect(xssErrorElem.html()).toBe('xss:');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import { GlAlert } from '@gitlab/ui';
|
||||
import { mount, shallowMount } from '@vue/test-utils';
|
||||
import Autosize from 'autosize';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import Vue, { nextTick } from 'vue';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import Vuex from 'vuex';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
|
||||
import {
|
||||
extendedWrapper,
|
||||
mountExtended,
|
||||
shallowMountExtended,
|
||||
} from 'helpers/vue_test_utils_helper';
|
||||
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
|
||||
import batchComments from '~/batch_comments/stores/modules/batch_comments';
|
||||
import { fetchUserCounts } from '~/super_sidebar/user_counts_fetch';
|
||||
|
|
@ -48,7 +51,7 @@ describe('issue_comment_form component', () => {
|
|||
const findCommentButton = () => findCommentTypeDropdown().find('button');
|
||||
const findErrorAlerts = () => wrapper.findAllComponents(GlAlert).wrappers;
|
||||
|
||||
const createStore = ({ actions = {}, state = {} } = {}) => {
|
||||
const createStore = ({ actions = { saveNote: jest.fn() }, state = {} } = {}) => {
|
||||
const baseModule = notesModule();
|
||||
|
||||
return new Vuex.Store({
|
||||
|
|
@ -94,38 +97,27 @@ describe('issue_comment_form component', () => {
|
|||
notesData = notesDataMock,
|
||||
userData = userDataMock,
|
||||
features = {},
|
||||
mountFunction = shallowMount,
|
||||
mountFunction = shallowMountExtended,
|
||||
store = createStore(),
|
||||
} = {}) => {
|
||||
store.dispatch('setNoteableData', noteableData);
|
||||
store.dispatch('setNotesData', notesData);
|
||||
store.dispatch('setUserData', userData);
|
||||
|
||||
wrapper = extendedWrapper(
|
||||
mountFunction(CommentForm, {
|
||||
propsData: {
|
||||
noteableType,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
...initialData,
|
||||
};
|
||||
},
|
||||
store,
|
||||
provide: {
|
||||
glFeatures: features,
|
||||
},
|
||||
mocks: {
|
||||
$apollo: {
|
||||
queries: {
|
||||
currentUser: {
|
||||
loading: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
wrapper = mountFunction(CommentForm, {
|
||||
propsData: {
|
||||
noteableType,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
...initialData,
|
||||
};
|
||||
},
|
||||
store,
|
||||
provide: {
|
||||
glFeatures: features,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -142,28 +134,20 @@ describe('issue_comment_form component', () => {
|
|||
const note = 'hello world';
|
||||
|
||||
it('should request to save note when note is entered', async () => {
|
||||
const saveNoteSpy = jest.fn();
|
||||
const store = createStore({
|
||||
actions: {
|
||||
saveNote: saveNoteSpy,
|
||||
},
|
||||
});
|
||||
mountComponent({ mountFunction: mount, initialData: { note }, store });
|
||||
const store = createStore();
|
||||
jest.spyOn(store, 'dispatch');
|
||||
mountComponent({ mountFunction: mountExtended, initialData: { note }, store });
|
||||
expect(findCloseReopenButton().props('disabled')).toBe(false);
|
||||
expect(findMarkdownEditor().props('value')).toBe(note);
|
||||
await findCloseReopenButton().trigger('click');
|
||||
expect(findCloseReopenButton().props('disabled')).toBe(true);
|
||||
expect(findMarkdownEditor().props('value')).toBe('');
|
||||
expect(saveNoteSpy).toHaveBeenCalled();
|
||||
expect(store.dispatch).toHaveBeenLastCalledWith('saveNote', expect.objectContaining({}));
|
||||
});
|
||||
|
||||
it('tracks event', async () => {
|
||||
const store = createStore({
|
||||
actions: {
|
||||
saveNote: jest.fn().mockResolvedValue(),
|
||||
},
|
||||
});
|
||||
mountComponent({ mountFunction: mount, initialData: { note }, store });
|
||||
const store = createStore();
|
||||
mountComponent({ mountFunction: mountExtended, initialData: { note }, store });
|
||||
await findCloseReopenButton().trigger('click');
|
||||
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'save_markdown', {
|
||||
label: 'markdown_editor',
|
||||
|
|
@ -172,12 +156,12 @@ describe('issue_comment_form component', () => {
|
|||
});
|
||||
|
||||
it('does not report errors in the UI when the save succeeds', async () => {
|
||||
const store = createStore({
|
||||
actions: {
|
||||
saveNote: jest.fn().mockResolvedValue(),
|
||||
},
|
||||
const store = createStore();
|
||||
mountComponent({
|
||||
mountFunction: mountExtended,
|
||||
initialData: { note: '/label ~sdfghj' },
|
||||
store,
|
||||
});
|
||||
mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' }, store });
|
||||
await findCommentButton().trigger('click');
|
||||
// findErrorAlerts().exists returns false if *any* wrapper is empty,
|
||||
// not necessarily that there aren't any at all.
|
||||
|
|
@ -202,7 +186,11 @@ describe('issue_comment_form component', () => {
|
|||
}),
|
||||
},
|
||||
});
|
||||
mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' }, store });
|
||||
mountComponent({
|
||||
mountFunction: mountExtended,
|
||||
initialData: { note: '/label ~sdfghj' },
|
||||
store,
|
||||
});
|
||||
await findCommentButton().trigger('click');
|
||||
await waitForPromises();
|
||||
const errorAlerts = findErrorAlerts();
|
||||
|
|
@ -228,7 +216,11 @@ describe('issue_comment_form component', () => {
|
|||
},
|
||||
});
|
||||
|
||||
mountComponent({ mountFunction: mount, initialData: { note: 'invalid note' }, store });
|
||||
mountComponent({
|
||||
mountFunction: mountExtended,
|
||||
initialData: { note: 'invalid note' },
|
||||
store,
|
||||
});
|
||||
|
||||
findCommentButton().trigger('click');
|
||||
});
|
||||
|
|
@ -256,7 +248,11 @@ describe('issue_comment_form component', () => {
|
|||
}),
|
||||
},
|
||||
});
|
||||
mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' }, store });
|
||||
mountComponent({
|
||||
mountFunction: mountExtended,
|
||||
initialData: { note: '/label ~sdfghj' },
|
||||
store,
|
||||
});
|
||||
await findCommentButton().trigger('click');
|
||||
await waitForPromises();
|
||||
|
||||
|
|
@ -278,7 +274,7 @@ describe('issue_comment_form component', () => {
|
|||
});
|
||||
|
||||
it('should toggle issue state when no note', async () => {
|
||||
mountComponent({ mountFunction: mount });
|
||||
mountComponent({ mountFunction: mountExtended });
|
||||
jest.spyOn(eventHub, '$emit');
|
||||
expect(eventHub.$emit).not.toHaveBeenCalledWith('toggle.issuable.state');
|
||||
await findCloseReopenButton().trigger('click');
|
||||
|
|
@ -286,28 +282,27 @@ describe('issue_comment_form component', () => {
|
|||
});
|
||||
|
||||
it('should disable action button while submitting', async () => {
|
||||
mountComponent({ mountFunction: mount, initialData: { note: 'hello world' } });
|
||||
|
||||
const saveNotePromise = Promise.resolve();
|
||||
|
||||
jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(saveNotePromise);
|
||||
|
||||
const store = createStore({
|
||||
actions: {
|
||||
saveNote: jest.fn().mockReturnValue(),
|
||||
},
|
||||
});
|
||||
mountComponent({
|
||||
mountFunction: mountExtended,
|
||||
initialData: { note: 'hello world' },
|
||||
store,
|
||||
});
|
||||
const actionButton = findCloseReopenButton();
|
||||
|
||||
await actionButton.trigger('click');
|
||||
|
||||
expect(actionButton.props('disabled')).toBe(true);
|
||||
|
||||
await saveNotePromise;
|
||||
|
||||
await waitForPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(actionButton.props('disabled')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows content editor switcher', () => {
|
||||
mountComponent({ mountFunction: mount });
|
||||
mountComponent({ mountFunction: mountExtended });
|
||||
expect(wrapper.text()).toContain('Switch to rich text editing');
|
||||
});
|
||||
|
||||
|
|
@ -327,26 +322,21 @@ describe('issue_comment_form component', () => {
|
|||
);
|
||||
|
||||
it('should make textarea disabled while requesting', async () => {
|
||||
mountComponent({ mountFunction: mount });
|
||||
|
||||
jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue();
|
||||
|
||||
mountComponent({ mountFunction: mountExtended });
|
||||
findMarkdownEditor().vm.$emit('input', 'hello world');
|
||||
await nextTick();
|
||||
|
||||
await findCommentButton().trigger('click');
|
||||
|
||||
expect(findMarkdownEditor().find('textarea').attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should support quick actions', () => {
|
||||
mountComponent({ mountFunction: mount });
|
||||
mountComponent({ mountFunction: mountExtended });
|
||||
|
||||
expect(findMarkdownEditor().props('supportsQuickActions')).toBe(true);
|
||||
});
|
||||
|
||||
it('should link to markdown docs', () => {
|
||||
mountComponent({ mountFunction: mount });
|
||||
mountComponent({ mountFunction: mountExtended });
|
||||
|
||||
const { markdownDocsPath } = notesDataMock;
|
||||
|
||||
|
|
@ -357,7 +347,11 @@ describe('issue_comment_form component', () => {
|
|||
const store = createStore();
|
||||
store.registerModule('batchComments', batchComments());
|
||||
store.state.batchComments.drafts = [{ note: 'A' }];
|
||||
await mountComponent({ mountFunction: mount, initialData: { note: 'foo' }, store });
|
||||
await mountComponent({
|
||||
mountFunction: mountExtended,
|
||||
initialData: { note: 'foo' },
|
||||
store,
|
||||
});
|
||||
await findAddCommentNowButton().trigger('click');
|
||||
await waitForPromises();
|
||||
expect(Autosize.update).toHaveBeenCalled();
|
||||
|
|
@ -365,33 +359,51 @@ describe('issue_comment_form component', () => {
|
|||
});
|
||||
|
||||
describe('edit mode', () => {
|
||||
it('should enter edit mode when arrow up is pressed', () => {
|
||||
mountComponent({ mountFunction: mount });
|
||||
jest.spyOn(wrapper.vm, 'editCurrentUserLastNote');
|
||||
|
||||
findMarkdownEditorTextarea().trigger('keydown.up');
|
||||
|
||||
expect(wrapper.vm.editCurrentUserLastNote).toHaveBeenCalled();
|
||||
it('should enter edit mode when arrow up is pressed', async () => {
|
||||
const noteId = 2;
|
||||
const store = createStore({
|
||||
state: {
|
||||
discussions: [{ notes: [{ id: noteId, author: userDataMock }] }],
|
||||
},
|
||||
});
|
||||
mountComponent({ mountFunction: mountExtended, store });
|
||||
jest.spyOn(eventHub, '$emit');
|
||||
await findMarkdownEditorTextarea().trigger('keydown.up');
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('enterEditMode', { noteId });
|
||||
});
|
||||
|
||||
describe('event enter', () => {
|
||||
describe('when no draft exists', () => {
|
||||
it('should save note when cmd+enter is pressed', () => {
|
||||
mountComponent({ mountFunction: mount });
|
||||
jest.spyOn(wrapper.vm, 'handleSave');
|
||||
const store = createStore({ actions: {} });
|
||||
|
||||
findMarkdownEditorTextarea().trigger('keydown.enter', { metaKey: true });
|
||||
|
||||
expect(wrapper.vm.handleSave).toHaveBeenCalledWith();
|
||||
it('should save note when cmd+enter is pressed', async () => {
|
||||
mountComponent({ mountFunction: mountExtended, initialData: { note: 'a' }, store });
|
||||
jest.spyOn(axios, 'post');
|
||||
await findMarkdownEditorTextarea().trigger('keydown.enter', { metaKey: true });
|
||||
expect(axios.post).toHaveBeenCalledWith(noteableDataMock.create_note_path, {
|
||||
merge_request_diff_head_sha: undefined,
|
||||
note: {
|
||||
internal: false,
|
||||
note: 'a',
|
||||
noteable_id: noteableDataMock.id,
|
||||
noteable_type: 'Issue',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should save note when ctrl+enter is pressed', () => {
|
||||
mountComponent({ mountFunction: mount });
|
||||
jest.spyOn(wrapper.vm, 'handleSave');
|
||||
|
||||
findMarkdownEditorTextarea().trigger('keydown.enter', { ctrlKey: true });
|
||||
|
||||
expect(wrapper.vm.handleSave).toHaveBeenCalledWith();
|
||||
it('should save note when ctrl+enter is pressed', async () => {
|
||||
mountComponent({ mountFunction: mountExtended, initialData: { note: 'a' }, store });
|
||||
jest.spyOn(axios, 'post');
|
||||
await findMarkdownEditorTextarea().trigger('keydown.enter', { ctrlKey: true });
|
||||
expect(axios.post).toHaveBeenCalledWith(noteableDataMock.create_note_path, {
|
||||
merge_request_diff_head_sha: undefined,
|
||||
note: {
|
||||
internal: false,
|
||||
note: 'a',
|
||||
noteable_id: noteableDataMock.id,
|
||||
noteable_type: 'Issue',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -412,7 +424,7 @@ describe('issue_comment_form component', () => {
|
|||
const note = 'some note text which enables actually adding a draft note';
|
||||
|
||||
jest.spyOn(eventHub, '$emit');
|
||||
mountComponent({ mountFunction: mount, initialData: { note }, store });
|
||||
mountComponent({ mountFunction: mountExtended, initialData: { note }, store });
|
||||
|
||||
findAddToReviewButton().trigger('click');
|
||||
|
||||
|
|
@ -422,17 +434,41 @@ describe('issue_comment_form component', () => {
|
|||
});
|
||||
|
||||
it('should save note draft when cmd+enter is pressed', async () => {
|
||||
mountComponent({ mountFunction: mount, store });
|
||||
jest.spyOn(wrapper.vm, 'handleSaveDraft');
|
||||
mountComponent({ mountFunction: mountExtended, initialData: { note: 'a' }, store });
|
||||
jest.spyOn(store, 'dispatch').mockResolvedValue();
|
||||
await findMarkdownEditorTextarea().trigger('keydown.enter', { metaKey: true });
|
||||
expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith();
|
||||
expect(store.dispatch).toHaveBeenCalledWith('saveNote', {
|
||||
data: {
|
||||
merge_request_diff_head_sha: undefined,
|
||||
note: {
|
||||
internal: false,
|
||||
note: 'a',
|
||||
noteable_id: noteableDataMock.id,
|
||||
noteable_type: 'Issue',
|
||||
},
|
||||
},
|
||||
endpoint: notesDataMock.draftsPath,
|
||||
isDraft: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should save note draft when ctrl+enter is pressed', async () => {
|
||||
mountComponent({ mountFunction: mount, store });
|
||||
jest.spyOn(wrapper.vm, 'handleSaveDraft');
|
||||
mountComponent({ mountFunction: mountExtended, initialData: { note: 'a' }, store });
|
||||
jest.spyOn(store, 'dispatch').mockResolvedValue();
|
||||
await findMarkdownEditorTextarea().trigger('keydown.enter', { ctrlKey: true });
|
||||
expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith();
|
||||
expect(store.dispatch).toHaveBeenCalledWith('saveNote', {
|
||||
data: {
|
||||
merge_request_diff_head_sha: undefined,
|
||||
note: {
|
||||
internal: false,
|
||||
note: 'a',
|
||||
noteable_id: noteableDataMock.id,
|
||||
noteable_type: 'Issue',
|
||||
},
|
||||
},
|
||||
endpoint: notesDataMock.draftsPath,
|
||||
isDraft: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -452,7 +488,7 @@ describe('issue_comment_form component', () => {
|
|||
${true} | ${'Add internal note'}
|
||||
`('renders comment button with text "$buttonText"', ({ noteIsInternal, buttonText }) => {
|
||||
mountComponent({
|
||||
mountFunction: mount,
|
||||
mountFunction: mountExtended,
|
||||
noteableData: createNotableDataMock({ confidential: noteIsInternal }),
|
||||
initialData: { noteIsInternal },
|
||||
});
|
||||
|
|
@ -491,7 +527,7 @@ describe('issue_comment_form component', () => {
|
|||
it('should show a loading spinner', async () => {
|
||||
mountComponent({
|
||||
noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE,
|
||||
mountFunction: mount,
|
||||
mountFunction: mountExtended,
|
||||
});
|
||||
|
||||
await findCloseReopenButton().trigger('click');
|
||||
|
|
@ -503,7 +539,7 @@ describe('issue_comment_form component', () => {
|
|||
describe('when toggling state', () => {
|
||||
describe('when issue', () => {
|
||||
it('emits event to toggle state', () => {
|
||||
mountComponent({ mountFunction: mount });
|
||||
mountComponent({ mountFunction: mountExtended });
|
||||
|
||||
jest.spyOn(eventHub, '$emit');
|
||||
|
||||
|
|
@ -524,7 +560,7 @@ describe('issue_comment_form component', () => {
|
|||
mountComponent({
|
||||
noteableType,
|
||||
noteableData: { ...noteableDataMock, state: STATUS_OPEN },
|
||||
mountFunction: mount,
|
||||
mountFunction: mountExtended,
|
||||
});
|
||||
expect(axios.put).not.toHaveBeenCalledWith();
|
||||
findCloseReopenButton().trigger('click');
|
||||
|
|
@ -536,7 +572,7 @@ describe('issue_comment_form component', () => {
|
|||
mountComponent({
|
||||
noteableType,
|
||||
noteableData: { ...noteableDataMock, state: STATUS_OPEN },
|
||||
mountFunction: mount,
|
||||
mountFunction: mountExtended,
|
||||
});
|
||||
await findCloseReopenButton().trigger('click');
|
||||
await nextTick();
|
||||
|
|
@ -553,7 +589,7 @@ describe('issue_comment_form component', () => {
|
|||
mountComponent({
|
||||
noteableType,
|
||||
noteableData: { ...noteableDataMock, state: STATUS_CLOSED },
|
||||
mountFunction: mount,
|
||||
mountFunction: mountExtended,
|
||||
});
|
||||
|
||||
expect(findCloseReopenButton().text()).toBe(`Reopen ${type}`);
|
||||
|
|
@ -568,7 +604,7 @@ describe('issue_comment_form component', () => {
|
|||
mountComponent({
|
||||
noteableType,
|
||||
noteableData: { ...noteableDataMock, state: STATUS_CLOSED },
|
||||
mountFunction: mount,
|
||||
mountFunction: mountExtended,
|
||||
});
|
||||
await findCloseReopenButton().trigger('click');
|
||||
await nextTick();
|
||||
|
|
@ -584,7 +620,7 @@ describe('issue_comment_form component', () => {
|
|||
jest.spyOn(axios, 'put').mockResolvedValue({ data: {} });
|
||||
mountComponent({
|
||||
noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE,
|
||||
mountFunction: mount,
|
||||
mountFunction: mountExtended,
|
||||
});
|
||||
await findCloseReopenButton().trigger('click');
|
||||
await waitForPromises();
|
||||
|
|
@ -598,7 +634,7 @@ describe('issue_comment_form component', () => {
|
|||
describe('confidential notes checkbox', () => {
|
||||
it('should render checkbox as unchecked by default', () => {
|
||||
mountComponent({
|
||||
mountFunction: mount,
|
||||
mountFunction: mountExtended,
|
||||
initialData: { note: 'confidential note' },
|
||||
noteableData: { ...notableDataMockCanUpdateIssuable },
|
||||
});
|
||||
|
|
@ -610,7 +646,7 @@ describe('issue_comment_form component', () => {
|
|||
|
||||
it('should not render checkbox if user is not at least a reporter', () => {
|
||||
mountComponent({
|
||||
mountFunction: mount,
|
||||
mountFunction: mountExtended,
|
||||
initialData: { note: 'confidential note' },
|
||||
noteableData: { ...notableDataMockCannotCreateConfidentialNote },
|
||||
});
|
||||
|
|
@ -628,7 +664,7 @@ describe('issue_comment_form component', () => {
|
|||
'should $message checkbox when noteableType is $noteableType',
|
||||
({ noteableType, rendered }) => {
|
||||
mountComponent({
|
||||
mountFunction: mount,
|
||||
mountFunction: mountExtended,
|
||||
noteableType,
|
||||
initialData: { note: 'internal note' },
|
||||
noteableData: { ...notableDataMockCanUpdateIssuable, noteableType },
|
||||
|
|
@ -644,14 +680,16 @@ describe('issue_comment_form component', () => {
|
|||
${false}
|
||||
`('when checkbox value is `$shouldCheckboxBeChecked`', ({ shouldCheckboxBeChecked }) => {
|
||||
it(`sets \`internal\` to \`${shouldCheckboxBeChecked}\``, async () => {
|
||||
const store = createStore();
|
||||
const note = 'internal note';
|
||||
mountComponent({
|
||||
mountFunction: mount,
|
||||
initialData: { note: 'internal note' },
|
||||
mountFunction: mountExtended,
|
||||
initialData: { note },
|
||||
noteableData: { ...notableDataMockCanUpdateIssuable },
|
||||
store,
|
||||
});
|
||||
|
||||
jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue({});
|
||||
|
||||
jest.spyOn(store, 'dispatch');
|
||||
const checkbox = findConfidentialNoteCheckbox();
|
||||
|
||||
// check checkbox
|
||||
|
|
@ -662,15 +700,26 @@ describe('issue_comment_form component', () => {
|
|||
// submit comment
|
||||
findCommentButton().trigger('click');
|
||||
|
||||
const [providedData] = wrapper.vm.saveNote.mock.calls[0];
|
||||
expect(providedData.data.note.internal).toBe(shouldCheckboxBeChecked);
|
||||
expect(store.dispatch).toHaveBeenCalledWith('saveNote', {
|
||||
data: {
|
||||
merge_request_diff_head_sha: undefined,
|
||||
note: {
|
||||
internal: shouldCheckboxBeChecked,
|
||||
note,
|
||||
noteable_id: noteableDataMock.id,
|
||||
noteable_type: 'Issue',
|
||||
},
|
||||
},
|
||||
endpoint: noteableDataMock.create_note_path,
|
||||
isDraft: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when user cannot update issuable', () => {
|
||||
it('should not render checkbox', () => {
|
||||
mountComponent({
|
||||
mountFunction: mount,
|
||||
mountFunction: mountExtended,
|
||||
noteableData: { ...notableDataMockCannotUpdateIssuable },
|
||||
});
|
||||
|
||||
|
|
@ -683,37 +732,38 @@ describe('issue_comment_form component', () => {
|
|||
describe('check sensitive tokens', () => {
|
||||
const sensitiveMessage = 'token: glpat-1234567890abcdefghij';
|
||||
const nonSensitiveMessage = 'text';
|
||||
const store = createStore();
|
||||
|
||||
it('should not save note when it contains sensitive token', () => {
|
||||
mountComponent({
|
||||
mountFunction: mount,
|
||||
mountFunction: mountExtended,
|
||||
initialData: { note: sensitiveMessage },
|
||||
store,
|
||||
});
|
||||
|
||||
jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue();
|
||||
|
||||
jest.spyOn(store, 'dispatch');
|
||||
findCommentButton().trigger('click');
|
||||
|
||||
expect(wrapper.vm.saveNote).not.toHaveBeenCalled();
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save note it does not contain sensitive token', () => {
|
||||
it('should save note it does not contain sensitive token', async () => {
|
||||
mountComponent({
|
||||
mountFunction: mount,
|
||||
mountFunction: mountExtended,
|
||||
initialData: { note: nonSensitiveMessage },
|
||||
store,
|
||||
});
|
||||
|
||||
jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue();
|
||||
|
||||
findCommentButton().trigger('click');
|
||||
|
||||
expect(wrapper.vm.saveNote).toHaveBeenCalled();
|
||||
jest.spyOn(store, 'dispatch');
|
||||
await findCommentButton().trigger('click');
|
||||
expect(store.dispatch).toHaveBeenCalledWith('saveNote', expect.objectContaining({}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('user is not logged in', () => {
|
||||
beforeEach(() => {
|
||||
mountComponent({ userData: null, noteableData: loggedOutnoteableData, mountFunction: mount });
|
||||
mountComponent({
|
||||
userData: null,
|
||||
noteableData: loggedOutnoteableData,
|
||||
mountFunction: mountExtended,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render signed out widget', () => {
|
||||
|
|
@ -754,7 +804,11 @@ describe('issue_comment_form component', () => {
|
|||
});
|
||||
|
||||
it('clicking `add to review`, should call draft endpoint, set `isDraft` true', async () => {
|
||||
mountComponent({ mountFunction: mount, initialData: { note: 'a draft note' }, store });
|
||||
mountComponent({
|
||||
mountFunction: mountExtended,
|
||||
initialData: { note: 'a draft note' },
|
||||
store,
|
||||
});
|
||||
jest.spyOn(store, 'dispatch').mockResolvedValue();
|
||||
await findAddToReviewButton().trigger('click');
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
|
|
@ -767,7 +821,11 @@ describe('issue_comment_form component', () => {
|
|||
});
|
||||
|
||||
it('clicking `add comment now`, should call note endpoint, set `isDraft` false', async () => {
|
||||
await mountComponent({ mountFunction: mount, initialData: { note: 'a comment' }, store });
|
||||
await mountComponent({
|
||||
mountFunction: mountExtended,
|
||||
initialData: { note: 'a comment' },
|
||||
store,
|
||||
});
|
||||
jest.spyOn(store, 'dispatch').mockResolvedValue();
|
||||
await findAddCommentNowButton().trigger('click');
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ describe('GroupsView', () => {
|
|||
expect(successHandler).toHaveBeenCalledWith({
|
||||
id: defaultProvide.organizationGid,
|
||||
search: defaultPropsData.search,
|
||||
sort: 'NAME_ASC',
|
||||
sort: 'name_asc',
|
||||
last: null,
|
||||
first: DEFAULT_PER_PAGE,
|
||||
before: null,
|
||||
|
|
@ -233,7 +233,7 @@ describe('GroupsView', () => {
|
|||
id: defaultProvide.organizationGid,
|
||||
last: null,
|
||||
search: defaultPropsData.search,
|
||||
sort: 'NAME_ASC',
|
||||
sort: 'name_asc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -296,7 +296,7 @@ describe('GroupsView', () => {
|
|||
id: defaultProvide.organizationGid,
|
||||
last: DEFAULT_PER_PAGE,
|
||||
search: defaultPropsData.search,
|
||||
sort: 'NAME_ASC',
|
||||
sort: 'name_asc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,6 +50,14 @@ describe('popovers/components/popovers.vue', () => {
|
|||
expect(wrapper.findAllComponents(GlPopover)).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('title', () => {
|
||||
it('does not render an empty header when there is no title', async () => {
|
||||
const target = createPopoverTarget({ title: '' });
|
||||
await buildWrapper(target);
|
||||
expect(wrapper.find('.popover-header').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supports HTML content', () => {
|
||||
const svgIcon = '<svg><use xlink:href="icons.svg#test"></use></svg>';
|
||||
const escapedSvgIcon = '<svg><use xlink:href="icons.svg#test"></use></svg>';
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import ChildContent from '~/vue_merge_request_widget/components/extensions/child_content.vue';
|
||||
|
||||
let wrapper;
|
||||
const mockData = () => ({
|
||||
header: 'Test header',
|
||||
text: 'Test content',
|
||||
icon: {
|
||||
name: 'error',
|
||||
},
|
||||
});
|
||||
|
||||
function factory(propsData) {
|
||||
wrapper = shallowMount(ChildContent, {
|
||||
propsData: {
|
||||
...propsData,
|
||||
widgetLabel: 'Test',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('MR widget extension child content', () => {
|
||||
it('renders child components', () => {
|
||||
factory({
|
||||
data: {
|
||||
...mockData(),
|
||||
children: [mockData()],
|
||||
},
|
||||
level: 2,
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-testid="child-content"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="child-content"]').props('level')).toBe(3);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import {
|
||||
registerExtension,
|
||||
registeredExtensions,
|
||||
} from '~/vue_merge_request_widget/components/extensions';
|
||||
import ExtensionBase from '~/vue_merge_request_widget/components/extensions/base.vue';
|
||||
|
||||
describe('MR widget extension registering', () => {
|
||||
it('registers a extension', () => {
|
||||
registerExtension({
|
||||
name: 'Test',
|
||||
props: ['helloWorld'],
|
||||
computed: {
|
||||
test() {},
|
||||
},
|
||||
methods: {
|
||||
test() {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registeredExtensions.extensions[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
extends: ExtensionBase,
|
||||
name: 'Test',
|
||||
computed: {
|
||||
helloWorld: expect.any(Function),
|
||||
test: expect.any(Function),
|
||||
},
|
||||
methods: {
|
||||
test: expect.any(Function),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue';
|
||||
|
||||
let wrapper;
|
||||
|
||||
function factory(propsData = {}) {
|
||||
wrapper = shallowMount(StatusIcon, {
|
||||
propsData,
|
||||
});
|
||||
}
|
||||
|
||||
describe('MR widget extensions status icon', () => {
|
||||
it('renders loading icon', () => {
|
||||
factory({ name: 'test', isLoading: true, iconName: 'failed' });
|
||||
|
||||
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders status icon', () => {
|
||||
factory({ name: 'test', isLoading: false, iconName: 'failed' });
|
||||
|
||||
expect(wrapper.findComponent(GlIcon).exists()).toBe(true);
|
||||
expect(wrapper.findComponent(GlIcon).props('name')).toBe('status-failed');
|
||||
});
|
||||
|
||||
it('sets aria-label for status icon', () => {
|
||||
factory({ name: 'test', isLoading: false, iconName: 'failed' });
|
||||
|
||||
expect(wrapper.findComponent(GlIcon).props('ariaLabel')).toBe('Failed test');
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
|
|||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import MergeChecksComponent from '~/vue_merge_request_widget/components/merge_checks.vue';
|
||||
import mergeChecksQuery from '~/vue_merge_request_widget/queries/merge_checks.query.graphql';
|
||||
import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue';
|
||||
import StatusIcon from '~/vue_merge_request_widget/components/widget/status_icon.vue';
|
||||
import StateContainer from '~/vue_merge_request_widget/components/state_container.vue';
|
||||
import { COMPONENTS } from '~/vue_merge_request_widget/components/checks/constants';
|
||||
import conflictsStateQuery from '~/vue_merge_request_widget/queries/states/conflicts.query.graphql';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { GlIcon } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import mrStatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
|
||||
import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue';
|
||||
import StatusIcon from '~/vue_merge_request_widget/components/widget/status_icon.vue';
|
||||
|
||||
describe('MR widget status icon component', () => {
|
||||
let wrapper;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { GlBadge, GlLink, GlIcon, GlButton, GlDropdown } from '@gitlab/ui';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import Vue, { nextTick } from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
|
|
@ -9,18 +8,11 @@ import readyToMergeResponse from 'test_fixtures/graphql/merge_requests/states/re
|
|||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import * as Sentry from '~/sentry/sentry_browser_wrapper';
|
||||
import api from '~/api';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { HTTP_STATUS_OK, HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status';
|
||||
import Poll from '~/lib/utils/poll';
|
||||
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
|
||||
import { setFaviconOverlay } from '~/lib/utils/favicon';
|
||||
import notify from '~/lib/utils/notify';
|
||||
import SmartInterval from '~/smart_interval';
|
||||
import {
|
||||
registerExtension,
|
||||
registeredExtensions,
|
||||
} from '~/vue_merge_request_widget/components/extensions';
|
||||
import { STATUS_CLOSED, STATUS_OPEN, STATUS_MERGED } from '~/issues/constants';
|
||||
import { STATE_QUERY_POLLING_INTERVAL_BACKOFF } from '~/vue_merge_request_widget/constants';
|
||||
import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants';
|
||||
|
|
@ -34,7 +26,6 @@ import MergedState from '~/vue_merge_request_widget/components/states/mr_widget_
|
|||
import WidgetContainer from '~/vue_merge_request_widget/components/widget/app.vue';
|
||||
import WidgetSuggestPipeline from '~/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue';
|
||||
import MrWidgetAlertMessage from '~/vue_merge_request_widget/components/mr_widget_alert_message.vue';
|
||||
import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue';
|
||||
import getStateQuery from '~/vue_merge_request_widget/queries/get_state.query.graphql';
|
||||
import getStateSubscription from '~/vue_merge_request_widget/queries/get_state.subscription.graphql';
|
||||
import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql';
|
||||
|
|
@ -45,21 +36,9 @@ import approvedBySubscription from 'ee_else_ce/vue_merge_request_widget/componen
|
|||
import userPermissionsQuery from '~/vue_merge_request_widget/queries/permissions.query.graphql';
|
||||
import conflictsStateQuery from '~/vue_merge_request_widget/queries/states/conflicts.query.graphql';
|
||||
import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store';
|
||||
import ExtensionsContainer from '~/vue_merge_request_widget/components/extensions/container';
|
||||
|
||||
import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data';
|
||||
import mockData, { mockDeployment, mockMergePipeline, mockPostMergeDeployments } from './mock_data';
|
||||
import {
|
||||
workingExtension,
|
||||
collapsedDataErrorExtension,
|
||||
fullDataErrorExtension,
|
||||
fullReportExtension,
|
||||
noTelemetryExtension,
|
||||
pollingExtension,
|
||||
pollingFullDataExtension,
|
||||
pollingErrorExtension,
|
||||
multiPollingExtension,
|
||||
} from './test_extensions';
|
||||
|
||||
jest.mock('~/api.js');
|
||||
|
||||
|
|
@ -160,10 +139,6 @@ describe('MrWidgetOptions', () => {
|
|||
const findPipelineContainer = () => wrapper.findByTestId('pipeline-container');
|
||||
const findAlertMessage = () => wrapper.findComponent(MrWidgetAlertMessage);
|
||||
const findMergePipelineForkAlert = () => wrapper.findByTestId('merge-pipeline-fork-warning');
|
||||
const findExtensionToggleButton = () =>
|
||||
wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]');
|
||||
const findExtensionLink = (linkHref) =>
|
||||
wrapper.find(`[data-testid="widget-extension"] [href="${linkHref}"]`);
|
||||
const findSuggestPipeline = () => wrapper.findComponent(WidgetSuggestPipeline);
|
||||
const findWidgetContainer = () => wrapper.findComponent(WidgetContainer);
|
||||
|
||||
|
|
@ -718,397 +693,6 @@ describe('MrWidgetOptions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('mock extension', () => {
|
||||
beforeEach(() => {
|
||||
registerExtension(workingExtension());
|
||||
|
||||
createComponent({ mountFn: mountExtended });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
registeredExtensions.extensions = [];
|
||||
});
|
||||
|
||||
it('renders collapsed data', async () => {
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('Test extension summary count: 1');
|
||||
});
|
||||
|
||||
it('renders full data', async () => {
|
||||
await waitForPromises();
|
||||
|
||||
findExtensionToggleButton().trigger('click');
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-testid="widget-extension-top-level"]')
|
||||
.findComponent(GlDropdown)
|
||||
.exists(),
|
||||
).toBe(false);
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
const collapsedSection = wrapper.find('[data-testid="widget-extension-collapsed-section"]');
|
||||
expect(collapsedSection.exists()).toBe(true);
|
||||
expect(collapsedSection.text()).toContain('Hello world');
|
||||
|
||||
// Renders icon in the row
|
||||
expect(collapsedSection.findComponent(GlIcon).exists()).toBe(true);
|
||||
expect(collapsedSection.findComponent(GlIcon).props('name')).toBe('status-failed');
|
||||
|
||||
// Renders badge in the row
|
||||
expect(collapsedSection.findComponent(GlBadge).exists()).toBe(true);
|
||||
expect(collapsedSection.findComponent(GlBadge).text()).toBe('Closed');
|
||||
|
||||
// Renders a link in the row
|
||||
expect(collapsedSection.findComponent(GlLink).exists()).toBe(true);
|
||||
expect(collapsedSection.findComponent(GlLink).text()).toBe('GitLab.com');
|
||||
|
||||
expect(collapsedSection.findComponent(GlButton).exists()).toBe(true);
|
||||
expect(collapsedSection.findComponent(GlButton).text()).toBe('Full report');
|
||||
});
|
||||
});
|
||||
|
||||
describe('expansion', () => {
|
||||
it('hides collapse button', async () => {
|
||||
registerExtension(workingExtension(false));
|
||||
await createComponent();
|
||||
|
||||
expect(findExtensionToggleButton().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows collapse button', async () => {
|
||||
registerExtension(workingExtension(true));
|
||||
await createComponent({ mountFn: mountExtended });
|
||||
|
||||
expect(findExtensionToggleButton().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mock polling extension', () => {
|
||||
let pollRequest;
|
||||
|
||||
const findWidgetTestExtension = () => wrapper.find('[data-testid="widget-extension"]');
|
||||
|
||||
beforeEach(() => {
|
||||
pollRequest = jest.spyOn(Poll.prototype, 'makeRequest');
|
||||
|
||||
registeredExtensions.extensions = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
registeredExtensions.extensions = [];
|
||||
});
|
||||
|
||||
describe('success - multi polling', () => {
|
||||
it('sets data when polling is complete', async () => {
|
||||
registerExtension(
|
||||
multiPollingExtension([
|
||||
() =>
|
||||
Promise.resolve({
|
||||
headers: { 'poll-interval': 0 },
|
||||
status: HTTP_STATUS_OK,
|
||||
data: { reports: 'parsed' },
|
||||
}),
|
||||
() =>
|
||||
Promise.resolve({
|
||||
status: HTTP_STATUS_OK,
|
||||
data: { reports: 'parsed' },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
await createComponent({ mountFn: mountExtended });
|
||||
expect(findWidgetTestExtension().html()).toContain(
|
||||
'Multi polling test extension reports: parsed, count: 2',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows loading state until polling is complete', async () => {
|
||||
registerExtension(
|
||||
multiPollingExtension([
|
||||
() =>
|
||||
Promise.resolve({
|
||||
headers: { 'poll-interval': 1 },
|
||||
status: HTTP_STATUS_NO_CONTENT,
|
||||
}),
|
||||
() =>
|
||||
Promise.resolve({
|
||||
status: HTTP_STATUS_OK,
|
||||
data: { reports: 'parsed' },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
await createComponent({ mountFn: mountExtended });
|
||||
expect(findWidgetTestExtension().html()).toContain('Test extension loading...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('success', () => {
|
||||
it('does not make additional requests after poll is successful', async () => {
|
||||
registerExtension(pollingExtension);
|
||||
|
||||
await createComponent({ mountFn: mountExtended });
|
||||
|
||||
expect(pollRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('success - full data polling', () => {
|
||||
it('sets data when polling is complete', async () => {
|
||||
registerExtension(pollingFullDataExtension);
|
||||
|
||||
await createComponent({ mountFn: mountExtended });
|
||||
|
||||
api.trackRedisHllUserEvent.mockClear();
|
||||
api.trackRedisCounterEvent.mockClear();
|
||||
|
||||
findExtensionToggleButton().trigger('click');
|
||||
|
||||
// The default working extension is a "warning" type, which generates a second - more specific - telemetry event for expansions
|
||||
expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(2);
|
||||
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_expand',
|
||||
);
|
||||
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_expand_warning',
|
||||
);
|
||||
expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(2);
|
||||
expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_count_expand',
|
||||
);
|
||||
expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_count_expand_warning',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error', () => {
|
||||
it('does not make additional requests after poll has failed', async () => {
|
||||
registerExtension(pollingErrorExtension);
|
||||
await createComponent({ mountFn: mountExtended });
|
||||
|
||||
expect(pollRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('captures sentry error and displays error when poll has failed', async () => {
|
||||
registerExtension(pollingErrorExtension);
|
||||
await createComponent({ mountFn: mountExtended });
|
||||
|
||||
expect(Sentry.captureException).toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error'));
|
||||
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mock extension errors', () => {
|
||||
afterEach(() => {
|
||||
registeredExtensions.extensions = [];
|
||||
});
|
||||
|
||||
it('handles collapsed data fetch errors', async () => {
|
||||
registerExtension(collapsedDataErrorExtension);
|
||||
await createComponent({ mountFn: mountExtended });
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]').exists(),
|
||||
).toBe(false);
|
||||
expect(Sentry.captureException).toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error'));
|
||||
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed');
|
||||
});
|
||||
|
||||
it('handles full data fetch errors', async () => {
|
||||
registerExtension(fullDataErrorExtension);
|
||||
await createComponent({ mountFn: mountExtended });
|
||||
|
||||
expect(wrapper.findComponent(StatusIcon).props('iconName')).not.toBe('error');
|
||||
wrapper
|
||||
.find('[data-testid="widget-extension"] [data-testid="toggle-button"]')
|
||||
.trigger('click');
|
||||
|
||||
await nextTick();
|
||||
await waitForPromises();
|
||||
|
||||
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error'));
|
||||
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('telemetry', () => {
|
||||
afterEach(() => {
|
||||
registeredExtensions.extensions = [];
|
||||
});
|
||||
|
||||
describe('component name tier suffixes', () => {
|
||||
let extension;
|
||||
|
||||
beforeEach(() => {
|
||||
extension = workingExtension();
|
||||
});
|
||||
|
||||
it('reports events without a CE suffix', async () => {
|
||||
extension.name = `${extension.name}CE`;
|
||||
|
||||
registerExtension(extension);
|
||||
await createComponent({
|
||||
mountFn: mountExtended,
|
||||
options: { stubs: { ExtensionsContainer } },
|
||||
});
|
||||
|
||||
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_view',
|
||||
);
|
||||
expect(api.trackRedisHllUserEvent).not.toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_c_e_view',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports events without a EE suffix', async () => {
|
||||
extension.name = `${extension.name}EE`;
|
||||
|
||||
registerExtension(extension);
|
||||
await createComponent({
|
||||
mountFn: mountExtended,
|
||||
options: { stubs: { ExtensionsContainer } },
|
||||
});
|
||||
|
||||
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_view',
|
||||
);
|
||||
expect(api.trackRedisHllUserEvent).not.toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_e_e_view',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves non-CE & non-EE all caps suffixes intact', async () => {
|
||||
extension.name = `${extension.name}HI`;
|
||||
|
||||
registerExtension(extension);
|
||||
await createComponent({
|
||||
mountFn: mountExtended,
|
||||
options: { stubs: { ExtensionsContainer } },
|
||||
});
|
||||
|
||||
expect(api.trackRedisHllUserEvent).not.toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_view',
|
||||
);
|
||||
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_h_i_view',
|
||||
);
|
||||
});
|
||||
|
||||
it("doesn't remove CE or EE from the middle of a widget name", async () => {
|
||||
extension.name = 'TestCEExtensionEETest';
|
||||
|
||||
registerExtension(extension);
|
||||
await createComponent({
|
||||
mountFn: mountExtended,
|
||||
options: { stubs: { ExtensionsContainer } },
|
||||
});
|
||||
|
||||
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_c_e_extension_e_e_test_view',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers view events when mounted', async () => {
|
||||
registerExtension(workingExtension());
|
||||
await createComponent({
|
||||
mountFn: mountExtended,
|
||||
options: { stubs: { ExtensionsContainer } },
|
||||
});
|
||||
|
||||
expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1);
|
||||
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_view',
|
||||
);
|
||||
expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(1);
|
||||
expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_count_view',
|
||||
);
|
||||
});
|
||||
|
||||
describe('expand button', () => {
|
||||
it('triggers expand events when clicked', async () => {
|
||||
registerExtension(workingExtension());
|
||||
createComponent({ mountFn: mountExtended });
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
api.trackRedisHllUserEvent.mockClear();
|
||||
api.trackRedisCounterEvent.mockClear();
|
||||
|
||||
findExtensionToggleButton().trigger('click');
|
||||
|
||||
// The default working extension is a "warning" type, which generates a second - more specific - telemetry event for expansions
|
||||
expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(2);
|
||||
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_expand',
|
||||
);
|
||||
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_expand_warning',
|
||||
);
|
||||
expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(2);
|
||||
expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_count_expand',
|
||||
);
|
||||
expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_count_expand_warning',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers the "full report clicked" events when the appropriate button is clicked', async () => {
|
||||
registerExtension(fullReportExtension);
|
||||
|
||||
await createComponent({
|
||||
mountFn: mountExtended,
|
||||
options: { stubs: { ExtensionsContainer } },
|
||||
});
|
||||
|
||||
api.trackRedisHllUserEvent.mockClear();
|
||||
api.trackRedisCounterEvent.mockClear();
|
||||
|
||||
findExtensionLink('testref').trigger('click');
|
||||
|
||||
expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1);
|
||||
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_click_full_report',
|
||||
);
|
||||
expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(1);
|
||||
expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
|
||||
'i_code_review_merge_request_widget_test_extension_count_click_full_report',
|
||||
);
|
||||
});
|
||||
|
||||
describe('when disabled', () => {
|
||||
afterEach(() => {
|
||||
registeredExtensions.extensions = [];
|
||||
});
|
||||
|
||||
it("doesn't emit any telemetry events", async () => {
|
||||
registerExtension(noTelemetryExtension);
|
||||
createComponent({ mountFn: mountExtended });
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
findExtensionToggleButton().trigger('click');
|
||||
findExtensionLink('testref').trigger('click'); // The "full report" link
|
||||
|
||||
expect(api.trackRedisHllUserEvent).not.toHaveBeenCalled();
|
||||
expect(api.trackRedisCounterEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('widget container', () => {
|
||||
it('renders the widget container when there is MR data', async () => {
|
||||
await createComponent(mockData);
|
||||
|
|
|
|||
|
|
@ -1,193 +0,0 @@
|
|||
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
|
||||
import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
|
||||
|
||||
export const workingExtension = (shouldCollapse = true) => ({
|
||||
name: 'WidgetTestExtension',
|
||||
props: ['targetProjectFullPath'],
|
||||
expandEvent: 'test_expand_event',
|
||||
i18n: {
|
||||
loading: 'Test extension loading...',
|
||||
},
|
||||
computed: {
|
||||
summary({ count, targetProjectFullPath } = {}) {
|
||||
return `Test extension summary count: ${count} & ${targetProjectFullPath}`;
|
||||
},
|
||||
statusIcon({ count } = {}) {
|
||||
return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
|
||||
},
|
||||
shouldCollapse() {
|
||||
return shouldCollapse;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchCollapsedData({ targetProjectFullPath }) {
|
||||
return Promise.resolve({ targetProjectFullPath, count: 1 });
|
||||
},
|
||||
fetchFullData() {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 1,
|
||||
text: 'Hello world',
|
||||
icon: {
|
||||
name: EXTENSION_ICONS.failed,
|
||||
},
|
||||
badge: {
|
||||
text: 'Closed',
|
||||
},
|
||||
link: {
|
||||
href: 'https://gitlab.com',
|
||||
text: 'GitLab.com',
|
||||
},
|
||||
actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }],
|
||||
},
|
||||
]);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const collapsedDataErrorExtension = {
|
||||
name: 'WidgetTestCollapsedErrorExtension',
|
||||
props: ['targetProjectFullPath'],
|
||||
expandEvent: 'test_expand_event',
|
||||
computed: {
|
||||
summary({ count, targetProjectFullPath }) {
|
||||
return `Test extension summary count: ${count} & ${targetProjectFullPath}`;
|
||||
},
|
||||
statusIcon({ count }) {
|
||||
return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchCollapsedData() {
|
||||
return Promise.reject(new Error('Fetch error'));
|
||||
},
|
||||
fetchFullData() {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 1,
|
||||
text: 'Hello world',
|
||||
icon: {
|
||||
name: EXTENSION_ICONS.failed,
|
||||
},
|
||||
badge: {
|
||||
text: 'Closed',
|
||||
},
|
||||
link: {
|
||||
href: 'https://gitlab.com',
|
||||
text: 'GitLab.com',
|
||||
},
|
||||
actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }],
|
||||
},
|
||||
]);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const fullDataErrorExtension = {
|
||||
name: 'WidgetTestCollapsedErrorExtension',
|
||||
props: ['targetProjectFullPath'],
|
||||
expandEvent: 'test_expand_event',
|
||||
computed: {
|
||||
summary({ count, targetProjectFullPath }) {
|
||||
return `Test extension summary count: ${count} & ${targetProjectFullPath}`;
|
||||
},
|
||||
statusIcon({ count }) {
|
||||
return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchCollapsedData({ targetProjectFullPath }) {
|
||||
return Promise.resolve({ targetProjectFullPath, count: 1 });
|
||||
},
|
||||
fetchFullData() {
|
||||
return Promise.reject(new Error('Fetch error'));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const pollingExtension = {
|
||||
...workingExtension(),
|
||||
enablePolling: true,
|
||||
};
|
||||
|
||||
export const pollingFullDataExtension = {
|
||||
...workingExtension(),
|
||||
enableExpandedPolling: true,
|
||||
methods: {
|
||||
fetchCollapsedData({ targetProjectFullPath }) {
|
||||
return Promise.resolve({ targetProjectFullPath, count: 1 });
|
||||
},
|
||||
fetchFullData() {
|
||||
return Promise.resolve([
|
||||
{
|
||||
headers: { 'poll-interval': 0 },
|
||||
status: HTTP_STATUS_OK,
|
||||
data: {
|
||||
id: 1,
|
||||
text: 'Hello world',
|
||||
icon: {
|
||||
name: EXTENSION_ICONS.failed,
|
||||
},
|
||||
badge: {
|
||||
text: 'Closed',
|
||||
},
|
||||
link: {
|
||||
href: 'https://gitlab.com',
|
||||
text: 'GitLab.com',
|
||||
},
|
||||
actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const fullReportExtension = {
|
||||
...workingExtension(),
|
||||
computed: {
|
||||
...workingExtension().computed,
|
||||
tertiaryButtons() {
|
||||
return [
|
||||
{
|
||||
text: 'test',
|
||||
href: `testref`,
|
||||
target: '_blank',
|
||||
trackFullReportClicked: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const noTelemetryExtension = {
|
||||
...fullReportExtension,
|
||||
telemetry: false,
|
||||
};
|
||||
|
||||
export const multiPollingExtension = (endpointsToBePolled) => ({
|
||||
name: 'WidgetTestMultiPollingExtension',
|
||||
props: [],
|
||||
i18n: {
|
||||
loading: 'Test extension loading...',
|
||||
},
|
||||
computed: {
|
||||
summary(data) {
|
||||
return `Multi polling test extension reports: ${data?.[0]?.reports}, count: ${data.length}`;
|
||||
},
|
||||
statusIcon(data) {
|
||||
return data?.[0]?.reports === 'parsed' ? EXTENSION_ICONS.success : EXTENSION_ICONS.warning;
|
||||
},
|
||||
},
|
||||
enablePolling: true,
|
||||
methods: {
|
||||
fetchMultiData() {
|
||||
return endpointsToBePolled;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const pollingErrorExtension = {
|
||||
...collapsedDataErrorExtension,
|
||||
enablePolling: true,
|
||||
};
|
||||
|
|
@ -10,7 +10,7 @@ RSpec.describe GitlabSchema.types['Environment'] do
|
|||
expected_fields = %w[
|
||||
name id state latest_opened_most_severe_alert path external_url deployments
|
||||
slug createdAt updatedAt autoStopAt autoDeleteAt tier environmentType lastDeployment deployFreezes
|
||||
clusterAgent
|
||||
clusterAgent deploymentsDisplayCount
|
||||
]
|
||||
|
||||
expect(described_class).to include_graphql_fields(*expected_fields)
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe GitlabSchema.types['OrganizationGroupSort'], feature_category: :cell do
|
||||
let(:sort_values) do
|
||||
%w[
|
||||
ID_ASC
|
||||
ID_DESC
|
||||
NAME_ASC
|
||||
NAME_DESC
|
||||
PATH_ASC
|
||||
PATH_DESC
|
||||
UPDATED_AT_ASC
|
||||
UPDATED_AT_DESC
|
||||
CREATED_AT_ASC
|
||||
CREATED_AT_DESC
|
||||
]
|
||||
end
|
||||
|
||||
specify { expect(described_class.graphql_name).to eq('OrganizationGroupSort') }
|
||||
|
||||
it 'exposes all the organization groups sort values' do
|
||||
expect(described_class.values.keys).to include(*sort_values)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe EnvironmentPresenter, feature_category: :continuous_delivery do
|
||||
subject(:presenter) { described_class.new(environment) }
|
||||
|
||||
let_it_be(:environment) { build(:environment) }
|
||||
|
||||
describe '#deployments_display_count' do
|
||||
subject { presenter.deployments_display_count }
|
||||
|
||||
before do
|
||||
stub_const("#{described_class}::MAX_DEPLOYMENTS_COUNT", 5)
|
||||
allow(environment).to receive_message_chain(:deployments,
|
||||
:limit).with(described_class::MAX_DEPLOYMENTS_COUNT).and_return(deployments_list)
|
||||
end
|
||||
|
||||
context 'with less than the maximum deployments' do
|
||||
let_it_be(:deployments_list) { build_stubbed_list(:deployment, 3) }
|
||||
|
||||
it 'returns the actual deployments count' do
|
||||
is_expected.to eq('3')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with more than the maximum deployments' do
|
||||
let_it_be(:deployments_list) { build_stubbed_list(:deployment, 6) }
|
||||
|
||||
it 'returns MAX_DISPLAY_COUNT value' do
|
||||
is_expected.to eq(described_class::MAX_DISPLAY_COUNT)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -4,10 +4,12 @@ require 'spec_helper'
|
|||
|
||||
RSpec.describe 'searching groups', :with_license, feature_category: :groups_and_projects do
|
||||
include GraphqlHelpers
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:public_group) { create(:group, :public) }
|
||||
let_it_be(:private_group) { create(:group, :private) }
|
||||
let(:current_user) { user }
|
||||
|
||||
let(:fields) do
|
||||
<<~FIELDS
|
||||
|
|
@ -73,4 +75,35 @@ RSpec.describe 'searching groups', :with_license, feature_category: :groups_and_
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'group sorting' do
|
||||
let_it_be(:public_group2) { create(:group, :public) }
|
||||
let_it_be(:public_group3) { create(:group, :public) }
|
||||
let_it_be(:all_groups) { [public_group, public_group2, public_group3] }
|
||||
let_it_be(:first_param) { 2 }
|
||||
let_it_be(:data_path) { [:groups] }
|
||||
|
||||
where(:field, :direction, :sorted_groups) do
|
||||
'id' | 'asc' | lazy { all_groups.sort_by(&:id) }
|
||||
'id' | 'desc' | lazy { all_groups.sort_by(&:id).reverse }
|
||||
'name' | 'asc' | lazy { all_groups.sort_by(&:name) }
|
||||
'name' | 'desc' | lazy { all_groups.sort_by(&:name).reverse }
|
||||
'path' | 'asc' | lazy { all_groups.sort_by(&:path) }
|
||||
'path' | 'desc' | lazy { all_groups.sort_by(&:path).reverse }
|
||||
end
|
||||
|
||||
with_them do
|
||||
it_behaves_like 'sorted paginated query' do
|
||||
let(:sort_param) { "#{field}_#{direction}" }
|
||||
let(:all_records) { sorted_groups.map { |p| global_id_of(p).to_s } }
|
||||
end
|
||||
end
|
||||
|
||||
def pagination_query(params)
|
||||
graphql_query_for(
|
||||
'', {},
|
||||
query_nodes(:groups, :id, include_pagination_info: true, args: params)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -154,8 +154,10 @@ RSpec.describe 'getting organization information', feature_category: :cell do
|
|||
end
|
||||
end
|
||||
|
||||
context 'with `sort` argument' do
|
||||
let(:authorized_groups) { [public_group, private_group, other_group] }
|
||||
describe 'group sorting' do
|
||||
let_it_be(:authorized_groups) { [public_group, private_group, other_group] }
|
||||
let_it_be(:first_param) { 2 }
|
||||
let_it_be(:data_path) { [:organization, :groups] }
|
||||
|
||||
where(:field, :direction, :sorted_groups) do
|
||||
'id' | 'asc' | lazy { authorized_groups.sort_by(&:id) }
|
||||
|
|
@ -167,24 +169,17 @@ RSpec.describe 'getting organization information', feature_category: :cell do
|
|||
end
|
||||
|
||||
with_them do
|
||||
let(:sort) { "#{field}_#{direction}".upcase }
|
||||
let(:organization_fields) do
|
||||
<<~FIELDS
|
||||
id
|
||||
path
|
||||
groups(sort: #{sort}) {
|
||||
nodes {
|
||||
id
|
||||
}
|
||||
}
|
||||
FIELDS
|
||||
it_behaves_like 'sorted paginated query' do
|
||||
let(:sort_param) { "#{field}_#{direction}" }
|
||||
let(:all_records) { sorted_groups.map { |p| global_id_of(p).to_s } }
|
||||
end
|
||||
end
|
||||
|
||||
it 'sorts the groups' do
|
||||
request_organization
|
||||
|
||||
expect(groups.pluck('id')).to eq(sorted_groups.map(&:to_global_id).map(&:to_s))
|
||||
end
|
||||
def pagination_query(params)
|
||||
graphql_query_for(
|
||||
:organization, { id: organization.to_global_id },
|
||||
query_nodes(:groups, :id, include_pagination_info: true, args: params)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -215,6 +210,34 @@ RSpec.describe 'getting organization information', feature_category: :cell do
|
|||
expect(projects).to contain_exactly(a_graphql_entity_for(project))
|
||||
end
|
||||
|
||||
describe 'project searching' do
|
||||
let_it_be(:other_project) do
|
||||
create(:project, name: 'other-project', organization: organization) { |p| p.add_developer(user) }
|
||||
end
|
||||
|
||||
let_it_be(:non_member_project) { create(:project, :public, organization: organization) }
|
||||
|
||||
context 'with `search` argument' do
|
||||
let(:search) { 'other' }
|
||||
let(:organization_fields) do
|
||||
<<~FIELDS
|
||||
projects(search: "#{search}") {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
FIELDS
|
||||
end
|
||||
|
||||
it 'filters projects by name' do
|
||||
request_organization
|
||||
|
||||
expect(projects).to contain_exactly(a_graphql_entity_for(other_project))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'project sorting' do
|
||||
let_it_be(:another_project) { create(:project, organization: organization) { |p| p.add_developer(user) } }
|
||||
let_it_be(:another_project2) { create(:project, organization: organization) { |p| p.add_developer(user) } }
|
||||
|
|
|
|||
|
|
@ -106,6 +106,13 @@ p_ci_job_artifacts:
|
|||
- p_ci_job_artifacts_project_id_idx
|
||||
p_ci_job_artifacts_project_id_id_idx1:
|
||||
- p_ci_job_artifacts_project_id_idx
|
||||
p_ci_stages:
|
||||
p_ci_stages_pipeline_id_name_partition_id_idx:
|
||||
- p_ci_stages_pipeline_id_idx
|
||||
p_ci_stages_pipeline_id_idx:
|
||||
- p_ci_stages_pipeline_id_idx
|
||||
p_ci_stages_pipeline_id_position_idx:
|
||||
- p_ci_stages_pipeline_id_idx
|
||||
pages_domains:
|
||||
index_pages_domains_on_project_id_and_enabled_until:
|
||||
- index_pages_domains_on_project_id
|
||||
|
|
|
|||
Loading…
Reference in New Issue