Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
0a367a4a1b
commit
53cb6812f4
|
|
@ -1,18 +1,12 @@
|
|||
#
|
||||
# This list of browsers is a conservative definition, based on
|
||||
# https://docs.gitlab.com/ee/install/requirements.html#supported-web-browsers. We
|
||||
# also use the following reasoning to choose the target versions:
|
||||
#
|
||||
# - Actual Browser usage on gitlab.com
|
||||
# - Support second latest version of Firefox ESR
|
||||
# - Support Chrome / Edge versions about the same age as the Firefox ESR version chosen
|
||||
#
|
||||
# If need be we raise versions closer to the actual supported web browsers.
|
||||
#
|
||||
# See also this epic: https://gitlab.com/groups/gitlab-org/-/epics/3957
|
||||
#
|
||||
chrome >= 103
|
||||
edge >= 103
|
||||
firefox >= 102
|
||||
safari >= 15.6
|
||||
# https://docs.gitlab.com/ee/install/requirements.html#supported-web-browsers.
|
||||
#
|
||||
# To see what browsers this targets, go here:
|
||||
# https://browsersl.ist/#q=%3E+0.5%25%2C+last+2+versions%2C+Firefox+ESR%2C+not+dead
|
||||
#
|
||||
|
||||
> 0.5%
|
||||
last 2 versions
|
||||
Firefox ESR
|
||||
not dead
|
||||
|
|
@ -143,7 +143,6 @@ Gitlab/FeatureFlagWithoutActor:
|
|||
- 'ee/lib/search/zoekt/circuit_breaker.rb'
|
||||
- 'ee/spec/lib/gitlab/product_analytics/developments/setup_spec.rb'
|
||||
- 'ee/spec/models/gitlab_subscriptions/features_spec.rb'
|
||||
- 'lib/api/features.rb'
|
||||
- 'lib/api/helpers/packages/dependency_proxy_helpers.rb'
|
||||
- 'lib/api/integrations.rb'
|
||||
- 'lib/api/internal/base.rb'
|
||||
|
|
|
|||
|
|
@ -162,7 +162,6 @@ Layout/LineEndStringConcatenationIndentation:
|
|||
- 'ee/lib/api/iterations.rb'
|
||||
- 'ee/lib/api/protected_environments.rb'
|
||||
- 'ee/lib/api/vulnerability_findings.rb'
|
||||
- 'ee/lib/ee/api/features.rb'
|
||||
- 'ee/lib/ee/api/helpers/groups_helpers.rb'
|
||||
- 'ee/lib/ee/gitlab/auth/ldap/access.rb'
|
||||
- 'ee/lib/ee/gitlab/auth/o_auth/user.rb'
|
||||
|
|
@ -344,7 +343,6 @@ Layout/LineEndStringConcatenationIndentation:
|
|||
- 'lib/api/ci/runners.rb'
|
||||
- 'lib/api/error_tracking/project_settings.rb'
|
||||
- 'lib/api/feature_flags_user_lists.rb'
|
||||
- 'lib/api/features.rb'
|
||||
- 'lib/api/freeze_periods.rb'
|
||||
- 'lib/api/groups.rb'
|
||||
- 'lib/api/issue_links.rb'
|
||||
|
|
|
|||
|
|
@ -307,7 +307,6 @@ Style/GuardClause:
|
|||
- 'ee/db/geo/migrate/20180314175612_add_partial_index_to_project_registy_verification_failure_columns.rb'
|
||||
- 'ee/db/geo/migrate/20180315222132_add_partial_index_to_project_registy_checksum_columns.rb'
|
||||
- 'ee/db/geo/migrate/20180412213305_add_index_to_artifact_id_on_job_artifact_registry.rb'
|
||||
- 'ee/lib/ee/api/features.rb'
|
||||
- 'ee/lib/ee/api/helpers/projects_helpers.rb'
|
||||
- 'ee/lib/ee/api/projects.rb'
|
||||
- 'ee/lib/ee/gitlab/auth/ldap/access.rb'
|
||||
|
|
|
|||
|
|
@ -1705,7 +1705,6 @@ Style/InlineDisableAnnotation:
|
|||
- 'lib/api/entities/project_details.rb'
|
||||
- 'lib/api/entities/project_integration.rb'
|
||||
- 'lib/api/entities/project_with_access.rb'
|
||||
- 'lib/api/features.rb'
|
||||
- 'lib/api/group_boards.rb'
|
||||
- 'lib/api/groups.rb'
|
||||
- 'lib/api/helm_packages.rb'
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
9aea54d16acab5377a7e0488411f6fb066788570
|
||||
555d6e8568136f22dfe61dd3bdc58979e1097a3c
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ const i18n = {
|
|||
|
||||
export default {
|
||||
i18n,
|
||||
formElementClasses: 'gl-mr-3 gl-mb-3 gl-basis-1/4 gl-shrink-0 gl-flex-grow-0',
|
||||
formElementClasses: 'gl-basis-1/4 gl-shrink-0 gl-flex-grow-0',
|
||||
// this height value is used inline on the textarea to match the input field height
|
||||
// it's used to prevent the overwrite if 'gl-h-7' or '!gl-h-7' were used
|
||||
textAreaStyle: { height: '32px' },
|
||||
|
|
@ -462,16 +462,16 @@ export default {
|
|||
/>
|
||||
</gl-form-group>
|
||||
|
||||
<gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" />
|
||||
<gl-loading-icon v-if="isLoading" class="gl-mb-5" size="md" />
|
||||
|
||||
<gl-form-group v-else class="gl-mb-3" :label="s__('Pipeline|Variables')">
|
||||
<gl-form-group v-else :label="s__('Pipeline|Variables')">
|
||||
<div
|
||||
v-for="(variable, index) in variables"
|
||||
:key="variable.uniqueId"
|
||||
class="gl-mb-3 gl-pb-2"
|
||||
class="gl-mb-4"
|
||||
data-testid="ci-variable-row-container"
|
||||
>
|
||||
<div class="gl-flex gl-flex-col gl-items-stretch md:gl-flex-row">
|
||||
<div class="gl-flex gl-flex-col gl-items-stretch gl-gap-4 md:gl-flex-row">
|
||||
<gl-collapsible-listbox
|
||||
:items="variableTypeListboxItems"
|
||||
:selected="variable.variable_type"
|
||||
|
|
@ -501,7 +501,6 @@ export default {
|
|||
v-else
|
||||
v-model="variable.value"
|
||||
:placeholder="s__('CiVariables|Input variable value')"
|
||||
class="gl-mb-3"
|
||||
:style="$options.textAreaStyle"
|
||||
:no-resize="false"
|
||||
data-testid="pipeline-form-ci-variable-value-field"
|
||||
|
|
@ -510,24 +509,25 @@ export default {
|
|||
<template v-if="variables.length > 1">
|
||||
<gl-button
|
||||
v-if="canRemove(index)"
|
||||
class="gl-mb-3 md:gl-ml-3"
|
||||
size="small"
|
||||
class="gl-shrink-0"
|
||||
data-testid="remove-ci-variable-row"
|
||||
:category="removeButtonCategory"
|
||||
:aria-label="$options.i18n.removeVariableLabel"
|
||||
@click="removeVariable(index)"
|
||||
>
|
||||
<gl-icon class="!gl-mr-0" name="remove" />
|
||||
<span class="gl-ml-2 md:gl-hidden">{{ $options.i18n.removeVariableLabel }}</span>
|
||||
<span class="md:gl-hidden">{{ $options.i18n.removeVariableLabel }}</span>
|
||||
</gl-button>
|
||||
<gl-button
|
||||
v-else
|
||||
class="gl-invisible gl-mb-3 gl-hidden md:gl-ml-3 md:gl-block"
|
||||
class="gl-invisible gl-hidden gl-shrink-0 md:gl-block"
|
||||
icon="remove"
|
||||
:aria-label="$options.i18n.removeVariableLabel"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="descriptions[variable.key]" class="gl-mb-3 gl-text-subtle">
|
||||
<div v-if="descriptions[variable.key]" class="gl-text-subtle">
|
||||
{{ descriptions[variable.key] }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,18 +8,12 @@
|
|||
* - Button Actions.
|
||||
* [Mockup](https://gitlab.com/gitlab-org/gitlab-foss/uploads/2f655655c0eadf655d0ae7467b53002a/environments__deploy-graphic.png)
|
||||
*/
|
||||
import {
|
||||
GlIcon,
|
||||
GlLoadingIcon,
|
||||
GlLink,
|
||||
GlTooltip,
|
||||
GlTooltipDirective,
|
||||
GlSprintf,
|
||||
} from '@gitlab/ui';
|
||||
import { GlLoadingIcon, GlLink, GlTooltip, GlTooltipDirective, GlSprintf } from '@gitlab/ui';
|
||||
import { isEmpty } from 'lodash';
|
||||
import SafeHtml from '~/vue_shared/directives/safe_html';
|
||||
import { s__, n__ } from '~/locale';
|
||||
import InstanceComponent from '~/vue_shared/components/deployment_instance.vue';
|
||||
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
|
||||
import { STATUS_MAP, CANARY_STATUS } from '../constants';
|
||||
import CanaryIngress from './canary_ingress.vue';
|
||||
|
||||
|
|
@ -27,11 +21,11 @@ export default {
|
|||
components: {
|
||||
InstanceComponent,
|
||||
CanaryIngress,
|
||||
GlIcon,
|
||||
GlLoadingIcon,
|
||||
GlLink,
|
||||
GlSprintf,
|
||||
GlTooltip,
|
||||
HelpIcon,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
|
|
@ -154,7 +148,7 @@ export default {
|
|||
>{{ instanceTitle }} ({{ instanceCount }})</span
|
||||
>
|
||||
<span ref="legend-icon" data-testid="legend-tooltip-target">
|
||||
<gl-icon class="gl-ml-2" name="question-o" variant="info" />
|
||||
<help-icon class="gl-ml-2" />
|
||||
</span>
|
||||
<gl-tooltip :target="() => $refs['legend-icon']" boundary="#content-body">
|
||||
<div class="deploy-board-legend gl-flex gl-flex-col">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { GlAlert, GlButton, GlFormSelect, GlFormGroup, GlIcon, GlLink, GlToken } from '@gitlab/ui';
|
||||
import { isNumber } from 'lodash';
|
||||
import { s__, __ } from '~/locale';
|
||||
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
|
||||
import {
|
||||
EMPTY_PARAMETERS,
|
||||
STRATEGY_SELECTIONS,
|
||||
|
|
@ -23,6 +24,7 @@ export default {
|
|||
GlToken,
|
||||
NewEnvironmentsDropdown,
|
||||
StrategyParameters,
|
||||
HelpIcon,
|
||||
},
|
||||
inject: {
|
||||
strategyTypeDocsPagePath: {
|
||||
|
|
@ -139,7 +141,7 @@ export default {
|
|||
<template #description>
|
||||
{{ $options.i18n.strategyTypeDescription }}
|
||||
<gl-link :href="strategyTypeDocsPagePath" target="_blank">
|
||||
<gl-icon name="question-o" />
|
||||
<help-icon />
|
||||
</gl-link>
|
||||
</template>
|
||||
<gl-form-select
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
<script>
|
||||
import { GlIcon, GlPopover } from '@gitlab/ui';
|
||||
import { GlPopover } from '@gitlab/ui';
|
||||
import { __, sprintf } from '~/locale';
|
||||
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
|
||||
import { MAX_TITLE_LENGTH, MAX_BODY_LENGTH } from '../../constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlIcon,
|
||||
GlPopover,
|
||||
HelpIcon,
|
||||
},
|
||||
props: {
|
||||
text: {
|
||||
|
|
@ -81,8 +82,8 @@ export default {
|
|||
<li>
|
||||
{{ __('Commit Message') }}
|
||||
<div id="ide-commit-message-popover-container">
|
||||
<span id="ide-commit-message-question" class="form-text gl-ml-3 gl-text-subtle">
|
||||
<gl-icon name="question-o" />
|
||||
<span id="ide-commit-message-question" class="form-text gl-ml-3">
|
||||
<help-icon />
|
||||
</span>
|
||||
<gl-popover
|
||||
target="ide-commit-message-question"
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ import {
|
|||
GlSprintf,
|
||||
GlLink,
|
||||
GlFormInputGroup,
|
||||
GlIcon,
|
||||
} from '@gitlab/ui';
|
||||
import { TYPE_ISSUE } from '~/issues/constants';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { sprintf, __ } from '~/locale';
|
||||
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
|
||||
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
|
||||
|
||||
export default {
|
||||
i18n: {
|
||||
|
|
@ -25,7 +25,7 @@ export default {
|
|||
GlSprintf,
|
||||
GlLink,
|
||||
GlFormInputGroup,
|
||||
GlIcon,
|
||||
HelpIcon,
|
||||
ModalCopyButton,
|
||||
},
|
||||
directives: {
|
||||
|
|
@ -150,7 +150,7 @@ export default {
|
|||
>
|
||||
<template #helpIcon>
|
||||
<gl-link :href="emailsHelpPagePath" target="_blank">
|
||||
<gl-icon name="question-o" variant="info" />
|
||||
<help-icon />
|
||||
</gl-link>
|
||||
</template>
|
||||
<template #resetLink="{ content }">
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
<script>
|
||||
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
|
||||
import { GlTooltipDirective } from '@gitlab/ui';
|
||||
import { __ } from '~/locale';
|
||||
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
|
||||
import query from '../queries/issues.query.graphql';
|
||||
import TitleSuggestionsItem from './title_suggestions_item.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlIcon,
|
||||
HelpIcon,
|
||||
TitleSuggestionsItem,
|
||||
},
|
||||
directives: {
|
||||
|
|
@ -69,14 +70,7 @@ export default {
|
|||
<div v-show="showSuggestions" class="form-group">
|
||||
<div v-once class="gl-pb-3">
|
||||
{{ __('Similar issues') }}
|
||||
<gl-icon
|
||||
v-gl-tooltip.bottom
|
||||
:title="$options.helpText"
|
||||
:aria-label="$options.helpText"
|
||||
name="question-o"
|
||||
class="gl-cursor-help"
|
||||
variant="subtle"
|
||||
/>
|
||||
<help-icon v-gl-tooltip.bottom :title="$options.helpText" :aria-label="$options.helpText" />
|
||||
</div>
|
||||
<ul class="gl-m-0 gl-list-none gl-p-0">
|
||||
<li
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script>
|
||||
import { GlIcon, GlPopover } from '@gitlab/ui';
|
||||
import { GlPopover } from '@gitlab/ui';
|
||||
import { __ } from '~/locale';
|
||||
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
|
||||
|
||||
export default {
|
||||
i18n: {
|
||||
|
|
@ -11,7 +12,7 @@ export default {
|
|||
incidentHelpText: __('For investigating IT service disruptions or outages'),
|
||||
},
|
||||
components: {
|
||||
GlIcon,
|
||||
HelpIcon,
|
||||
GlPopover,
|
||||
},
|
||||
};
|
||||
|
|
@ -19,7 +20,7 @@ export default {
|
|||
|
||||
<template>
|
||||
<span id="popovercontainer" class="gl-ml-2">
|
||||
<gl-icon id="issue-type-info" name="question-o" variant="info" />
|
||||
<help-icon id="issue-type-info" />
|
||||
|
||||
<gl-popover
|
||||
target="issue-type-info"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
<script>
|
||||
import { GlIcon, GlPopover, GlLink } from '@gitlab/ui';
|
||||
import { GlPopover, GlLink } from '@gitlab/ui';
|
||||
import { helpPagePath } from '~/helpers/help_page_helper';
|
||||
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
|
||||
import { timelineEventTagsPopover } from './constants';
|
||||
|
||||
export default {
|
||||
name: 'TimelineEventsTagsPopover',
|
||||
components: {
|
||||
GlIcon,
|
||||
GlPopover,
|
||||
GlLink,
|
||||
HelpIcon,
|
||||
},
|
||||
i18n: timelineEventTagsPopover,
|
||||
learnMoreLink: helpPagePath('operations/incident_management/incident_timeline_events', {
|
||||
|
|
@ -19,7 +20,7 @@ export default {
|
|||
|
||||
<template>
|
||||
<span>
|
||||
<gl-icon id="timeline-events-tag-question" name="question-o" variant="info" />
|
||||
<help-icon id="timeline-events-tag-question" />
|
||||
|
||||
<gl-popover
|
||||
target="timeline-events-tag-question"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { GlAlert, GlButton, GlIcon, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
|
||||
import { GlAlert, GlButton, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
|
||||
import $ from 'jquery';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { mapActions, mapGetters, mapState } from 'vuex';
|
||||
|
|
@ -16,6 +16,7 @@ import { InternalEvents } from '~/tracking';
|
|||
import { badgeState } from '~/merge_requests/components/merge_request_header.vue';
|
||||
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
|
||||
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
|
||||
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
|
||||
import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking';
|
||||
import { fetchUserCounts } from '~/super_sidebar/user_counts_fetch';
|
||||
|
||||
|
|
@ -40,7 +41,7 @@ export default {
|
|||
GlAlert,
|
||||
GlButton,
|
||||
TimelineEntryItem,
|
||||
GlIcon,
|
||||
HelpIcon,
|
||||
CommentFieldLayout,
|
||||
CommentTypeDropdown,
|
||||
GlFormCheckbox,
|
||||
|
|
@ -398,12 +399,9 @@ export default {
|
|||
data-testid="internal-note-checkbox"
|
||||
>
|
||||
{{ $options.i18n.internal }}
|
||||
<gl-icon
|
||||
<help-icon
|
||||
v-gl-tooltip:tooltipcontainer.bottom
|
||||
name="question-o"
|
||||
:size="16"
|
||||
:title="$options.i18n.internalVisibility"
|
||||
class="gl-text-blue-500"
|
||||
/>
|
||||
</gl-form-checkbox>
|
||||
<template v-if="hasDrafts">
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from '~/packages_and_registries/settings/project/constants';
|
||||
import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql';
|
||||
import SettingsSection from '~/vue_shared/components/settings/settings_section.vue';
|
||||
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
|
@ -24,6 +25,7 @@ export default {
|
|||
GlCard,
|
||||
GlButton,
|
||||
},
|
||||
mixins: [glFeatureFlagsMixin()],
|
||||
inject: [
|
||||
'projectPath',
|
||||
'isAdmin',
|
||||
|
|
@ -66,12 +68,18 @@ export default {
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
featureFlagEnabled() {
|
||||
return this.glFeatures.reorganizeProjectLevelRegistrySettings;
|
||||
},
|
||||
isCleanupEnabled() {
|
||||
return this.containerTagsExpirationPolicy?.enabled ?? false;
|
||||
},
|
||||
isEnabled() {
|
||||
return this.containerTagsExpirationPolicy || this.enableHistoricEntries;
|
||||
},
|
||||
isLoading() {
|
||||
return this.$apollo.queries.containerTagsExpirationPolicy.loading;
|
||||
},
|
||||
showDisabledFormMessage() {
|
||||
return !this.isEnabled && !this.fetchSettingsError;
|
||||
},
|
||||
|
|
@ -88,7 +96,65 @@ export default {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<gl-card v-if="featureFlagEnabled" data-testid="container-expiration-policy-project-settings">
|
||||
<template #header>
|
||||
<header class="gl-flex gl-flex-wrap gl-justify-between">
|
||||
<h2
|
||||
class="gl-m-0 gl-inline-flex gl-items-center gl-text-base gl-font-bold gl-leading-normal"
|
||||
>
|
||||
{{ $options.i18n.CONTAINER_CLEANUP_POLICY_TITLE }}
|
||||
</h2>
|
||||
<gl-button
|
||||
v-if="isEnabled"
|
||||
data-testid="rules-button"
|
||||
:href="cleanupSettingsPath"
|
||||
:loading="isLoading"
|
||||
category="secondary"
|
||||
size="small"
|
||||
variant="confirm"
|
||||
>
|
||||
{{ cleanupRulesButtonText }}
|
||||
</gl-button>
|
||||
</header>
|
||||
</template>
|
||||
<template v-if="isEnabled" #default>
|
||||
<p class="gl-text-subtle" data-testid="description">
|
||||
<gl-sprintf :message="$options.i18n.CONTAINER_CLEANUP_POLICY_DESCRIPTION">
|
||||
<template #link="{ content }">
|
||||
<gl-link :href="helpPagePath">{{ content }}</gl-link>
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
</p>
|
||||
<p v-if="!isCleanupEnabled" data-testid="empty-cleanup-policy" class="gl-mb-0 gl-text-subtle">
|
||||
{{
|
||||
s__(
|
||||
'ContainerRegistry|Registry cleanup disabled. Either no cleanup policies enabled, or this project has no container images.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="!isLoading" #default>
|
||||
<gl-alert
|
||||
v-if="showDisabledFormMessage"
|
||||
:dismissible="false"
|
||||
:title="$options.i18n.UNAVAILABLE_FEATURE_TITLE"
|
||||
variant="tip"
|
||||
>
|
||||
{{ $options.i18n.UNAVAILABLE_FEATURE_INTRO_TEXT }}
|
||||
|
||||
<gl-sprintf :message="unavailableFeatureMessage">
|
||||
<template #link="{ content }">
|
||||
<gl-link :href="adminSettingsPath">{{ content }}</gl-link>
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
</gl-alert>
|
||||
<gl-alert v-else-if="fetchSettingsError" variant="warning" :dismissible="false">
|
||||
<gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" />
|
||||
</gl-alert>
|
||||
</template>
|
||||
</gl-card>
|
||||
<settings-section
|
||||
v-else
|
||||
:heading="$options.i18n.CONTAINER_CLEANUP_POLICY_TITLE"
|
||||
data-testid="container-expiration-policy-project-settings"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import SettingsSection from '~/vue_shared/components/settings/settings_section.v
|
|||
import ContainerProtectionRuleForm from '~/packages_and_registries/settings/project/components/container_protection_rule_form.vue';
|
||||
import deleteContainerProtectionRuleMutation from '~/packages_and_registries/settings/project/graphql/mutations/delete_container_protection_rule.mutation.graphql';
|
||||
import updateContainerRegistryProtectionRuleMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_container_registry_protection_rule.mutation.graphql';
|
||||
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import { s__, __ } from '~/locale';
|
||||
|
||||
const PAGINATION_DEFAULT_PER_PAGE = 10;
|
||||
|
|
@ -41,6 +42,7 @@ export default {
|
|||
GlModal: GlModalDirective,
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
mixins: [glFeatureFlagsMixin()],
|
||||
inject: ['projectPath'],
|
||||
i18n: {
|
||||
settingBlockTitle: s__('ContainerRegistry|Protected container repositories'),
|
||||
|
|
@ -89,6 +91,9 @@ export default {
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
containsTableItems() {
|
||||
return this.protectionRulesQueryResult.length > 0;
|
||||
},
|
||||
tableItems() {
|
||||
return this.protectionRulesQueryResult.map((protectionRule) => {
|
||||
return {
|
||||
|
|
@ -98,6 +103,9 @@ export default {
|
|||
};
|
||||
});
|
||||
},
|
||||
featureFlagEnabled() {
|
||||
return this.glFeatures.reorganizeProjectLevelRegistrySettings;
|
||||
},
|
||||
protectionRulesQueryPageInfo() {
|
||||
return this.protectionRulesQueryPayload.pageInfo;
|
||||
},
|
||||
|
|
@ -113,25 +121,8 @@ export default {
|
|||
this.protectionRulesQueryPageInfo.hasNextPage
|
||||
);
|
||||
},
|
||||
modalActionPrimary() {
|
||||
return {
|
||||
text: s__('ContainerRegistry|Delete container protection rule'),
|
||||
attributes: {
|
||||
variant: 'danger',
|
||||
},
|
||||
};
|
||||
},
|
||||
modalActionCancel() {
|
||||
return {
|
||||
text: __('Cancel'),
|
||||
};
|
||||
},
|
||||
minimumAccessLevelOptions() {
|
||||
return [
|
||||
{ value: 'MAINTAINER', text: __('Maintainer') },
|
||||
{ value: 'OWNER', text: __('Owner') },
|
||||
{ value: 'ADMIN', text: __('Admin') },
|
||||
];
|
||||
showTopLevelLoadingIcon() {
|
||||
return this.isLoadingprotectionRules && !this.containsTableItems;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -258,12 +249,136 @@ export default {
|
|||
tdClass: '!gl-align-middle gl-text-right',
|
||||
},
|
||||
],
|
||||
minimumAccessLevelOptions: [
|
||||
{ value: 'MAINTAINER', text: __('Maintainer') },
|
||||
{ value: 'OWNER', text: __('Owner') },
|
||||
{ value: 'ADMIN', text: __('Admin') },
|
||||
],
|
||||
modal: { id: 'delete-protection-rule-confirmation-modal' },
|
||||
modalActionPrimary: {
|
||||
text: s__('ContainerRegistry|Delete container protection rule'),
|
||||
attributes: {
|
||||
variant: 'danger',
|
||||
},
|
||||
},
|
||||
modalActionCancel: {
|
||||
text: __('Cancel'),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="featureFlagEnabled"
|
||||
data-testid="project-container-repository-protection-rules-settings"
|
||||
>
|
||||
<crud-component
|
||||
ref="containerProtectionCrud"
|
||||
:title="$options.i18n.settingBlockTitle"
|
||||
:toggle-text="s__('ContainerRegistry|Add protection rule')"
|
||||
>
|
||||
<template #form>
|
||||
<container-protection-rule-form
|
||||
@cancel="hideProtectionRuleForm"
|
||||
@submit="refetchProtectionRules"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<p
|
||||
class="gl-pb-0 gl-text-subtle"
|
||||
:class="{ 'gl-px-5 gl-pt-4': containsTableItems }"
|
||||
data-testid="description"
|
||||
>
|
||||
{{ $options.i18n.settingBlockDescription }}
|
||||
</p>
|
||||
|
||||
<gl-alert
|
||||
v-if="alertErrorMessage"
|
||||
class="gl-mb-5"
|
||||
variant="danger"
|
||||
@dismiss="clearAlertMessage"
|
||||
>
|
||||
{{ alertErrorMessage }}
|
||||
</gl-alert>
|
||||
|
||||
<gl-loading-icon v-if="showTopLevelLoadingIcon" size="sm" class="gl-my-5" />
|
||||
<gl-table
|
||||
v-else-if="containsTableItems"
|
||||
:items="tableItems"
|
||||
:fields="$options.fields"
|
||||
show-empty
|
||||
stacked="md"
|
||||
:aria-label="$options.i18n.settingBlockTitle"
|
||||
:busy="isLoadingprotectionRules"
|
||||
>
|
||||
<template #table-busy>
|
||||
<gl-loading-icon size="sm" class="gl-my-5" />
|
||||
</template>
|
||||
|
||||
<template #cell(minimumAccessLevelForPush)="{ item }">
|
||||
<gl-form-select
|
||||
v-model="item.minimumAccessLevelForPush"
|
||||
class="gl-max-w-34"
|
||||
required
|
||||
:aria-label="$options.i18n.minimumAccessLevelForPush"
|
||||
:options="$options.minimumAccessLevelOptions"
|
||||
:disabled="isProtectionRuleMinimumAccessLevelForPushFormSelectDisabled(item)"
|
||||
data-testid="push-access-select"
|
||||
@change="updateProtectionRuleMinimumAccessLevelForPush(item)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell(rowActions)="{ item }">
|
||||
<gl-button
|
||||
v-gl-tooltip
|
||||
v-gl-modal="$options.modal.id"
|
||||
category="tertiary"
|
||||
icon="remove"
|
||||
:title="__('Delete')"
|
||||
:aria-label="__('Delete')"
|
||||
:disabled="isProtectionRuleDeleteButtonDisabled(item)"
|
||||
data-testid="delete-btn"
|
||||
@click="showProtectionRuleDeletionConfirmModal(item)"
|
||||
/>
|
||||
</template>
|
||||
</gl-table>
|
||||
<p v-else class="gl-text-subtle">
|
||||
{{ s__('ContainerRegistry|No container repositories are protected yet.') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-if="shouldShowPagination" #pagination>
|
||||
<gl-keyset-pagination
|
||||
v-bind="protectionRulesQueryPageInfo"
|
||||
class="gl-mb-3"
|
||||
@prev="onPrevPage"
|
||||
@next="onNextPage"
|
||||
/>
|
||||
</template>
|
||||
</crud-component>
|
||||
|
||||
<gl-modal
|
||||
v-if="protectionRuleMutationItem"
|
||||
:modal-id="$options.modal.id"
|
||||
size="sm"
|
||||
:title="$options.i18n.protectionRuleDeletionConfirmModal.title"
|
||||
:action-primary="$options.modalActionPrimary"
|
||||
:action-cancel="$options.modalActionCancel"
|
||||
@primary="deleteProtectionRule(protectionRuleMutationItem)"
|
||||
>
|
||||
<p>
|
||||
<gl-sprintf :message="$options.i18n.protectionRuleDeletionConfirmModal.descriptionWarning">
|
||||
<template #repositoryPathPattern>
|
||||
<strong>{{ protectionRuleMutationItem.repositoryPathPattern }}</strong>
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
</p>
|
||||
<p>{{ $options.i18n.protectionRuleDeletionConfirmModal.descriptionConsequence }}</p>
|
||||
</gl-modal>
|
||||
</div>
|
||||
<settings-section
|
||||
v-else
|
||||
:heading="$options.i18n.settingBlockTitle"
|
||||
:description="$options.i18n.settingBlockDescription"
|
||||
data-testid="project-container-repository-protection-rules-settings"
|
||||
|
|
@ -308,7 +423,7 @@ export default {
|
|||
class="gl-max-w-34"
|
||||
required
|
||||
:aria-label="$options.i18n.minimumAccessLevelForPush"
|
||||
:options="minimumAccessLevelOptions"
|
||||
:options="$options.minimumAccessLevelOptions"
|
||||
:disabled="isProtectionRuleMinimumAccessLevelForPushFormSelectDisabled(item)"
|
||||
data-testid="push-access-select"
|
||||
@change="updateProtectionRuleMinimumAccessLevelForPush(item)"
|
||||
|
|
@ -345,8 +460,8 @@ export default {
|
|||
:modal-id="$options.modal.id"
|
||||
size="sm"
|
||||
:title="$options.i18n.protectionRuleDeletionConfirmModal.title"
|
||||
:action-primary="modalActionPrimary"
|
||||
:action-cancel="modalActionCancel"
|
||||
:action-primary="$options.modalActionPrimary"
|
||||
:action-cancel="$options.modalActionCancel"
|
||||
@primary="deleteProtectionRule(protectionRuleMutationItem)"
|
||||
>
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -43,8 +43,10 @@ export default {
|
|||
</gl-sprintf>
|
||||
</template>
|
||||
<template #default>
|
||||
<container-protection-rules v-if="showProtectedContainersSettings" />
|
||||
<container-expiration-policy />
|
||||
<div class="gl-flex gl-flex-col gl-gap-5">
|
||||
<container-protection-rules v-if="showProtectedContainersSettings" />
|
||||
<container-expiration-policy />
|
||||
</div>
|
||||
</template>
|
||||
</settings-block>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
<script>
|
||||
import { GlIcon, GlLink, GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
|
||||
import { GlLink, GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
|
||||
import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
|
||||
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
|
||||
|
||||
const toCheckboxValue = (bool) => (bool ? '1' : false);
|
||||
|
||||
export default {
|
||||
name: 'IntegrationView',
|
||||
components: {
|
||||
GlIcon,
|
||||
GlLink,
|
||||
GlFormGroup,
|
||||
GlFormCheckbox,
|
||||
IntegrationHelpText,
|
||||
HelpIcon,
|
||||
},
|
||||
props: {
|
||||
helpLink: {
|
||||
|
|
@ -72,7 +73,7 @@ export default {
|
|||
<template #label>
|
||||
{{ title || config.title }}
|
||||
<gl-link class="has-tooltip" title="More information" :href="helpLink">
|
||||
<gl-icon name="question-o" class="vertical-align-middle" />
|
||||
<help-icon class="vertical-align-middle" />
|
||||
</gl-link>
|
||||
</template>
|
||||
<!-- Necessary for Rails to receive the value when not checked -->
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
import { GlTooltipDirective, GlLink, GlButton, GlCollapse, GlIcon, GlBadge } from '@gitlab/ui';
|
||||
import { difference, get } from 'lodash';
|
||||
import { __, s__, sprintf } from '~/locale';
|
||||
import { ASSET_LINK_TYPE } from '../constants';
|
||||
import { InternalEvents } from '~/tracking';
|
||||
import { ASSET_LINK_TYPE, CLICK_EXPAND_ASSETS_ON_RELEASE_PAGE } from '../constants';
|
||||
|
||||
export default {
|
||||
name: 'ReleaseBlockAssets',
|
||||
|
|
@ -16,6 +17,7 @@ export default {
|
|||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
mixins: [InternalEvents.mixin()],
|
||||
props: {
|
||||
assets: {
|
||||
type: Object,
|
||||
|
|
@ -84,6 +86,10 @@ export default {
|
|||
methods: {
|
||||
toggleAssetsExpansion() {
|
||||
this.isAssetsExpanded = !this.isAssetsExpanded;
|
||||
|
||||
if (this.isAssetsExpanded) {
|
||||
this.trackEvent(CLICK_EXPAND_ASSETS_ON_RELEASE_PAGE);
|
||||
}
|
||||
},
|
||||
linksForType(type) {
|
||||
return this.assets.links.filter((l) => l.linkType === type);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
|||
import DeploymentStatusLink from '~/environments/components/deployment_status_link.vue';
|
||||
import DeploymentTriggerer from '~/environments/environment_details/components/deployment_triggerer.vue';
|
||||
import { __ } from '~/locale';
|
||||
import { InternalEvents } from '~/tracking';
|
||||
import {
|
||||
CLICK_EXPAND_DEPLOYMENTS_ON_RELEASE_PAGE,
|
||||
CLICK_ENVIRONMENT_LINK_ON_RELEASE_PAGE,
|
||||
CLICK_DEPLOYMENT_LINK_ON_RELEASE_PAGE,
|
||||
} from '../constants';
|
||||
|
||||
export default {
|
||||
name: 'ReleaseBlockDeployments',
|
||||
|
|
@ -20,6 +26,7 @@ export default {
|
|||
DeploymentStatusLink,
|
||||
DeploymentTriggerer,
|
||||
},
|
||||
mixins: [InternalEvents.mixin()],
|
||||
props: {
|
||||
deployments: {
|
||||
type: Array,
|
||||
|
|
@ -34,6 +41,16 @@ export default {
|
|||
methods: {
|
||||
toggleDeploymentsExpansion() {
|
||||
this.isDeploymentsExpanded = !this.isDeploymentsExpanded;
|
||||
|
||||
if (this.isDeploymentsExpanded) {
|
||||
this.trackEvent(CLICK_EXPAND_DEPLOYMENTS_ON_RELEASE_PAGE);
|
||||
}
|
||||
},
|
||||
trackEnvironmentLinkClick() {
|
||||
this.trackEvent(CLICK_ENVIRONMENT_LINK_ON_RELEASE_PAGE);
|
||||
},
|
||||
trackDeploymentLinkClick() {
|
||||
this.trackEvent(CLICK_DEPLOYMENT_LINK_ON_RELEASE_PAGE);
|
||||
},
|
||||
},
|
||||
tableFields: [
|
||||
|
|
@ -98,16 +115,24 @@ export default {
|
|||
<div class="gl-pl-6 gl-pt-3">
|
||||
<gl-table-lite :items="deployments" :fields="$options.tableFields" stacked="lg">
|
||||
<template #cell(environment)="{ item }">
|
||||
<gl-link :href="item.environment.url" data-testid="environment-name"
|
||||
>{{ item.environment.name }}
|
||||
<gl-link
|
||||
:href="item.environment.url"
|
||||
data-testid="environment-name"
|
||||
@click="trackEnvironmentLinkClick"
|
||||
>
|
||||
{{ item.environment.name }}
|
||||
</gl-link>
|
||||
</template>
|
||||
<template #cell(status)="{ item }">
|
||||
<deployment-status-link :deployment="item" :status="item.status" />
|
||||
</template>
|
||||
<template #cell(deploymentId)="{ item }">
|
||||
<gl-link :href="item.deployment.url" data-testid="deployment-url"
|
||||
>{{ item.deployment.id }}
|
||||
<gl-link
|
||||
:href="item.deployment.url"
|
||||
data-testid="deployment-url"
|
||||
@click="trackDeploymentLinkClick"
|
||||
>
|
||||
{{ item.deployment.id }}
|
||||
</gl-link>
|
||||
</template>
|
||||
<template #cell(triggerer)="{ item }">
|
||||
|
|
|
|||
|
|
@ -66,3 +66,8 @@ export const i18n = {
|
|||
tagNameIsRequiredMessage: __('Tag name is required.'),
|
||||
tagIsAlredyInUseMessage: __('Selected tag is already in use. Choose another option.'),
|
||||
};
|
||||
|
||||
export const CLICK_EXPAND_DEPLOYMENTS_ON_RELEASE_PAGE = 'click_expand_deployments_on_release_page';
|
||||
export const CLICK_EXPAND_ASSETS_ON_RELEASE_PAGE = 'click_expand_assets_on_release_page';
|
||||
export const CLICK_ENVIRONMENT_LINK_ON_RELEASE_PAGE = 'click_environment_link_on_release_page';
|
||||
export const CLICK_DEPLOYMENT_LINK_ON_RELEASE_PAGE = 'click_deployment_link_on_release_page';
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab/ui';
|
||||
import { __, n__, sprintf } from '~/locale';
|
||||
import { createAlert } from '~/alert';
|
||||
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
|
||||
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
|
||||
import { TYPENAME_ISSUE } from '~/graphql_shared/constants';
|
||||
import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility';
|
||||
|
|
@ -14,6 +15,7 @@ export default {
|
|||
GlIcon,
|
||||
GlLink,
|
||||
GlPopover,
|
||||
HelpIcon,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
|
|
@ -106,9 +108,7 @@ export default {
|
|||
<span> {{ contactCount }} </span>
|
||||
</div>
|
||||
<div class="hide-collapsed help-button gl-float-right">
|
||||
<gl-link :href="$options.crmDocsLink" target="_blank"
|
||||
><gl-icon name="question-o" variant="info"
|
||||
/></gl-link>
|
||||
<gl-link :href="$options.crmDocsLink" target="_blank"><help-icon /></gl-link>
|
||||
</div>
|
||||
<div class="hide-collapsed gl-font-bold gl-leading-20">
|
||||
{{ contactsLabel }}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script>
|
||||
import { GlIcon, GlDatepicker, GlTooltipDirective, GlLink, GlPopover } from '@gitlab/ui';
|
||||
import { createAlert } from '~/alert';
|
||||
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
|
||||
import { TYPE_ISSUE } from '~/issues/constants';
|
||||
import { localeDateFormat, newDate, toISODateFormat } from '~/lib/utils/datetime_utility';
|
||||
import { __, sprintf } from '~/locale';
|
||||
|
|
@ -30,6 +31,7 @@ export default {
|
|||
SidebarEditableItem,
|
||||
SidebarFormattedDate,
|
||||
SidebarInheritDate,
|
||||
HelpIcon,
|
||||
},
|
||||
inject: ['canUpdate'],
|
||||
props: {
|
||||
|
|
@ -283,10 +285,9 @@ export default {
|
|||
@open="openDatePicker"
|
||||
>
|
||||
<template v-if="canInherit" #title-extra>
|
||||
<gl-icon
|
||||
<help-icon
|
||||
ref="epicDatePopover"
|
||||
name="question-o"
|
||||
class="hide-collapsed gl-ml-3 gl-cursor-pointer gl-text-blue-600"
|
||||
class="hide-collapsed gl-ml-3 gl-cursor-pointer"
|
||||
tabindex="0"
|
||||
:aria-label="$options.i18n.help"
|
||||
data-testid="inherit-date-popover"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script>
|
||||
import { GlIcon, GlFormGroup, GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui';
|
||||
import { SNIPPET_LEVELS_RESTRICTED, SNIPPET_LEVELS_DISABLED } from '~/snippets/constants';
|
||||
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
|
||||
import { defaultSnippetVisibilityLevels } from '../utils/blob';
|
||||
|
||||
export default {
|
||||
|
|
@ -10,6 +11,7 @@ export default {
|
|||
GlFormRadio,
|
||||
GlFormRadioGroup,
|
||||
GlLink,
|
||||
HelpIcon,
|
||||
},
|
||||
inject: ['visibilityLevels', 'multipleLevelsRestricted'],
|
||||
props: {
|
||||
|
|
@ -41,9 +43,7 @@ export default {
|
|||
<div class="form-group">
|
||||
<label>
|
||||
{{ __('Visibility level') }}
|
||||
<gl-link v-if="helpLink" :href="helpLink" target="_blank"
|
||||
><gl-icon :size="12" name="question-o"
|
||||
/></gl-link>
|
||||
<gl-link v-if="helpLink" :href="helpLink" target="_blank"><help-icon size="small" /></gl-link>
|
||||
</label>
|
||||
<gl-form-group id="visibility-level-setting" class="gl-mb-0">
|
||||
<gl-form-radio-group :checked="value" stacked v-bind="$attrs" v-on="$listeners">
|
||||
|
|
|
|||
|
|
@ -350,10 +350,8 @@ export default {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="vue-filtered-search-bar-container gl-flex gl-min-w-0 gl-flex-col sm:gl-flex-row sm:gl-gap-3"
|
||||
>
|
||||
<div class="flex-grow-1 gl-flex gl-gap-3">
|
||||
<div class="vue-filtered-search-bar-container gl-flex gl-flex-col sm:gl-flex-row sm:gl-gap-3">
|
||||
<div class="flex-grow-1 gl-flex gl-min-w-0 gl-gap-3">
|
||||
<gl-form-checkbox
|
||||
v-if="showCheckbox"
|
||||
class="gl-min-h-0 gl-self-center"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { GlButton, GlFormCheckbox, GlIcon, GlTooltipDirective } from '@gitlab/ui';
|
||||
import { GlButton, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
|
||||
import { helpPagePath } from '~/helpers/help_page_helper';
|
||||
import { s__, __ } from '~/locale';
|
||||
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
|
||||
|
|
@ -13,6 +13,7 @@ import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
|
|||
import { findWidget } from '~/issues/list/utils';
|
||||
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
|
||||
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
|
||||
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
|
||||
import WorkItemStateToggle from '~/work_items/components/work_item_state_toggle.vue';
|
||||
import CommentFieldLayout from '~/notes/components/comment_field_layout.vue';
|
||||
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
|
||||
|
|
@ -48,7 +49,7 @@ export default {
|
|||
GlButton,
|
||||
MarkdownEditor,
|
||||
GlFormCheckbox,
|
||||
GlIcon,
|
||||
HelpIcon,
|
||||
WorkItemStateToggle,
|
||||
},
|
||||
directives: {
|
||||
|
|
@ -346,12 +347,9 @@ export default {
|
|||
data-testid="internal-note-checkbox"
|
||||
>
|
||||
{{ $options.i18n.internal }}
|
||||
<gl-icon
|
||||
<help-icon
|
||||
v-gl-tooltip:tooltipcontainer.bottom
|
||||
name="question-o"
|
||||
:size="16"
|
||||
:title="$options.i18n.internalVisibility"
|
||||
class="gl-text-blue-500"
|
||||
/>
|
||||
</gl-form-checkbox>
|
||||
<div class="gl-flex gl-gap-3">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
|
|||
|
||||
before_action :admin_project_google_cloud!
|
||||
before_action :google_oauth2_enabled!
|
||||
before_action :feature_flag_enabled!
|
||||
|
||||
private
|
||||
|
||||
|
|
@ -25,17 +24,6 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
def feature_flag_enabled!
|
||||
enabled_for_user = Feature.enabled?(:incubation_5mp_google_cloud, current_user)
|
||||
enabled_for_group = Feature.enabled?(:incubation_5mp_google_cloud, project.group)
|
||||
enabled_for_project = Feature.enabled?(:incubation_5mp_google_cloud, project)
|
||||
feature_is_enabled = enabled_for_user || enabled_for_group || enabled_for_project
|
||||
unless feature_is_enabled
|
||||
track_event(:error_feature_flag_not_enabled)
|
||||
access_denied!
|
||||
end
|
||||
end
|
||||
|
||||
def validate_gcp_token!
|
||||
is_token_valid = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
|
||||
.validate_token(expires_at_in_session)
|
||||
|
|
|
|||
|
|
@ -86,7 +86,6 @@ module NamespacesHelper
|
|||
def pipeline_usage_app_data(namespace)
|
||||
{
|
||||
namespace_actual_plan_name: namespace.actual_plan_name,
|
||||
namespace_path: namespace.full_path,
|
||||
namespace_id: namespace.id,
|
||||
user_namespace: namespace.user_namespace?.to_s,
|
||||
page_size: page_size
|
||||
|
|
|
|||
|
|
@ -502,6 +502,12 @@ class Group < Namespace
|
|||
full_name
|
||||
end
|
||||
|
||||
def to_human_reference(from = nil)
|
||||
return unless cross_namespace_reference?(from)
|
||||
|
||||
human_name
|
||||
end
|
||||
|
||||
def visibility_level_allowed_by_parent?(level = self.visibility_level)
|
||||
return true unless parent_id && parent_id.nonzero?
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ module ApplicationSettings
|
|||
private
|
||||
|
||||
def update_settings
|
||||
validate_classification_label(application_setting, :external_authorization_service_default_label) unless bypass_external_auth?
|
||||
validate_classification_label_param!(application_setting, :external_authorization_service_default_label) unless bypass_external_auth?
|
||||
|
||||
if application_setting.errors.any?
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ValidatesClassificationLabel
|
||||
def validate_classification_label(record, attribute_name)
|
||||
def validate_classification_label_param!(record, attribute_name)
|
||||
return unless ::Gitlab::ExternalAuthorization.enabled?
|
||||
return unless classification_label_change?(record, attribute_name)
|
||||
|
||||
|
|
@ -14,6 +14,8 @@ module ValidatesClassificationLabel
|
|||
message = s_('ClassificationLabelUnavailable|is unavailable: %{reason}') % { reason: reason }
|
||||
record.errors.add(attribute_name, message)
|
||||
end
|
||||
|
||||
params[attribute_name] = new_label
|
||||
end
|
||||
|
||||
def rejection_reason_for_label(label)
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ module Projects
|
|||
@relations_block&.call(@project)
|
||||
yield(@project) if block_given?
|
||||
|
||||
validate_classification_label(@project, :external_authorization_classification_label)
|
||||
validate_classification_label_param!(@project, :external_authorization_classification_label)
|
||||
|
||||
# If the block added errors, don't try to save the project
|
||||
return @project if @project.errors.any?
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ module Projects
|
|||
|
||||
yield if block_given?
|
||||
|
||||
validate_classification_label(project, :external_authorization_classification_label)
|
||||
validate_classification_label_param!(project, :external_authorization_classification_label)
|
||||
|
||||
# If the block added errors, don't try to save the project
|
||||
return update_failed! if project.errors.any?
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
- add_to_breadcrumbs s_('Pipeline|Pipelines'), project_pipelines_path(@project)
|
||||
- page_title s_('Pipeline|New pipeline')
|
||||
|
||||
%h1.page-title.gl-text-size-h-display
|
||||
= s_('Pipeline|Run new pipeline')
|
||||
= render ::Layouts::PageHeadingComponent.new(s_('Pipeline|Run new pipeline'))
|
||||
|
||||
#js-new-pipeline{ data: new_pipeline_data(@project) }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
description: User clicks on the deployment link on the release page.
|
||||
internal_events: true
|
||||
action: click_deployment_link_on_release_page
|
||||
identifiers:
|
||||
- project
|
||||
- namespace
|
||||
- user
|
||||
product_group: environments
|
||||
product_categories:
|
||||
- deployment_management
|
||||
- environment_management
|
||||
milestone: '17.8'
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/175594
|
||||
tiers:
|
||||
- free
|
||||
- premium
|
||||
- ultimate
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
description: User clicks on the environment link on the release page.
|
||||
internal_events: true
|
||||
action: click_environment_link_on_release_page
|
||||
identifiers:
|
||||
- project
|
||||
- namespace
|
||||
- user
|
||||
product_group: environments
|
||||
product_categories:
|
||||
- deployment_management
|
||||
- environment_management
|
||||
milestone: '17.8'
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/175594
|
||||
tiers:
|
||||
- free
|
||||
- premium
|
||||
- ultimate
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
description: User opens the assets section on the release page.
|
||||
internal_events: true
|
||||
action: click_expand_assets_on_release_page
|
||||
identifiers:
|
||||
- project
|
||||
- namespace
|
||||
- user
|
||||
product_group: environments
|
||||
product_categories:
|
||||
- deployment_management
|
||||
- environment_management
|
||||
milestone: '17.8'
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/175594
|
||||
tiers:
|
||||
- free
|
||||
- premium
|
||||
- ultimate
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
description: User opens the deployments section on the release page.
|
||||
internal_events: true
|
||||
action: click_expand_deployments_on_release_page
|
||||
identifiers:
|
||||
- project
|
||||
- namespace
|
||||
- user
|
||||
product_group: environments
|
||||
product_categories:
|
||||
- deployment_management
|
||||
- environment_management
|
||||
milestone: '17.8'
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/175594
|
||||
tiers:
|
||||
- free
|
||||
- premium
|
||||
- ultimate
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
---
|
||||
name: incubation_5mp_google_cloud
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70715
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/371332
|
||||
milestone: '14.3'
|
||||
type: development
|
||||
group: group::incubation
|
||||
default_enabled: true
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
---
|
||||
name: set_feature_flag_service
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87028
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/373176
|
||||
milestone: '15.4'
|
||||
type: development
|
||||
group: group::import and integrate
|
||||
default_enabled: false
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
key_path: redis_hll_counters.count_distinct_user_id_from_click_deployment_link_on_release_page
|
||||
description: Count of unique users clicks on the deployment link on the release page.
|
||||
product_group: environments
|
||||
product_categories:
|
||||
- deployment_management
|
||||
- environment_management
|
||||
performance_indicator_type: []
|
||||
value_type: number
|
||||
status: active
|
||||
milestone: '17.8'
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/175594
|
||||
time_frame:
|
||||
- 28d
|
||||
- 7d
|
||||
data_source: internal_events
|
||||
data_category: optional
|
||||
tiers:
|
||||
- free
|
||||
- premium
|
||||
- ultimate
|
||||
events:
|
||||
- name: click_deployment_link_on_release_page
|
||||
unique: user.id
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
key_path: redis_hll_counters.count_distinct_user_id_from_click_environment_link_on_release_page
|
||||
description: Count of unique users clicks on the environment link on the release page.
|
||||
product_group: environments
|
||||
product_categories:
|
||||
- deployment_management
|
||||
- environment_management
|
||||
performance_indicator_type: []
|
||||
value_type: number
|
||||
status: active
|
||||
milestone: '17.8'
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/175594
|
||||
time_frame:
|
||||
- 28d
|
||||
- 7d
|
||||
data_source: internal_events
|
||||
data_category: optional
|
||||
tiers:
|
||||
- free
|
||||
- premium
|
||||
- ultimate
|
||||
events:
|
||||
- name: click_environment_link_on_release_page
|
||||
unique: user.id
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
key_path: redis_hll_counters.count_distinct_user_id_from_click_expand_assets_on_release_page
|
||||
description: Count of unique users opens the assets section on the release page.
|
||||
product_group: environments
|
||||
product_categories:
|
||||
- deployment_management
|
||||
- environment_management
|
||||
performance_indicator_type: []
|
||||
value_type: number
|
||||
status: active
|
||||
milestone: '17.8'
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/175594
|
||||
time_frame:
|
||||
- 28d
|
||||
- 7d
|
||||
data_source: internal_events
|
||||
data_category: optional
|
||||
tiers:
|
||||
- free
|
||||
- premium
|
||||
- ultimate
|
||||
events:
|
||||
- name: click_expand_assets_on_release_page
|
||||
unique: user.id
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
key_path: redis_hll_counters.count_distinct_user_id_from_click_expand_deployments_on_release_page
|
||||
description: Count of unique users opens the deployments section on the release page.
|
||||
product_group: environments
|
||||
product_categories:
|
||||
- deployment_management
|
||||
- environment_management
|
||||
performance_indicator_type: []
|
||||
value_type: number
|
||||
status: active
|
||||
milestone: '17.8'
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/175594
|
||||
time_frame:
|
||||
- 28d
|
||||
- 7d
|
||||
data_source: internal_events
|
||||
data_category: optional
|
||||
tiers:
|
||||
- free
|
||||
- premium
|
||||
- ultimate
|
||||
events:
|
||||
- name: click_expand_deployments_on_release_page
|
||||
unique: user.id
|
||||
|
|
@ -7,6 +7,5 @@ feature_categories:
|
|||
description: Stores data about issuer of X.509 certificate
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/17773
|
||||
milestone: '12.8'
|
||||
gitlab_schema: gitlab_main
|
||||
sharding_key_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/490496
|
||||
gitlab_schema: gitlab_main_clusterwide
|
||||
table_size: small
|
||||
|
|
|
|||
|
|
@ -4368,6 +4368,7 @@ relative to `refs/heads/branch1` and the pipeline source is a merge request even
|
|||
#### `rules:exists`
|
||||
|
||||
> - CI/CD variable support [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/283881) in GitLab 15.6.
|
||||
> - Maximum number of checks against `exists` patterns or file paths [increased](https://gitlab.com/gitlab-org/gitlab/-/issues/227632) from 10,000 to 50,000 in GitLab 17.7.
|
||||
|
||||
Use `exists` to run a job when certain files exist in the repository.
|
||||
|
||||
|
|
@ -4407,13 +4408,13 @@ In this example:
|
|||
- Glob patterns are interpreted with Ruby's [`File.fnmatch`](https://docs.ruby-lang.org/en/master/File.html#method-c-fnmatch)
|
||||
with the [flags](https://docs.ruby-lang.org/en/master/File/Constants.html#module-File::Constants-label-Filename+Globbing+Constants+-28File-3A-3AFNM_-2A-29)
|
||||
`File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB`.
|
||||
- For performance reasons, GitLab performs a maximum of 10,000 checks against
|
||||
`exists` patterns or file paths. After the 10,000th check, rules with patterned
|
||||
- For performance reasons, GitLab performs a maximum of 50,000 checks against
|
||||
`exists` patterns or file paths. After the 50,000th check, rules with patterned
|
||||
globs always match. In other words, the `exists` rule always assumes a match in
|
||||
projects with more than 10,000 files, or if there are fewer than 10,000 files but
|
||||
the `exists` rules are checked more than 10,000 times.
|
||||
- If there are multiple patterned globs, the limit is 10,000 divided by the number
|
||||
of globs. For example, a rule with 4 patterned globs has file limit of 2500.
|
||||
projects with more than 50,000 files, or if there are fewer than 50,000 files but
|
||||
the `exists` rules are checked more than 50,000 times.
|
||||
- If there are multiple patterned globs, the limit is 50,000 divided by the number
|
||||
of globs. For example, a rule with 5 patterned globs has file limit of 10,000.
|
||||
- A maximum of 50 patterns or file paths can be defined per `rules:exists` section.
|
||||
- `exists` resolves to `true` if any of the listed files are found (an `OR` operation).
|
||||
- With job-level `rules:exists`, GitLab searches for the files in the project and
|
||||
|
|
@ -5235,7 +5236,7 @@ be assigned every tag listed in the job.
|
|||
|
||||
**Supported values**:
|
||||
|
||||
- An array of tag names.
|
||||
- An array of tag names, which are case sensitive.
|
||||
- CI/CD variables [are supported](../variables/where_variables_can_be_used.md#gitlab-ciyml-file).
|
||||
|
||||
**Example of `tags`**:
|
||||
|
|
|
|||
|
|
@ -149,6 +149,12 @@ Combine entries if they happened in the same release:
|
|||
> - [Enabled on GitLab.com, self-managed, and GitLab Dedicated](https://issue-link) in GitLab 14.3.
|
||||
```
|
||||
|
||||
If the feature flag is introduced and enabled in the same release, combine the entries:
|
||||
|
||||
```markdown
|
||||
> - [Introduced](https://issue-link) in GitLab 13.7 [with a flag](../../administration/feature_flags.md) named `forti_token_cloud`. Enabled by default.
|
||||
```
|
||||
|
||||
Delete `Enabled on GitLab.com` entries only when the feature is enabled by default for all offerings and the flag is removed:
|
||||
|
||||
- Before:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ DETAILS:
|
|||
**Tier:** Ultimate
|
||||
**Offering:** GitLab.com, Self-managed, GitLab Dedicated
|
||||
|
||||
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/5708) in GitLab 17.7 [with a flag](../../../administration/feature_flags.md) named `vulnerability_management_policy_type`. Disabled by default.
|
||||
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/5708) in GitLab 17.7 [with a flag](../../../administration/feature_flags.md) named `vulnerability_management_policy_type`. Enabled by default.
|
||||
> - [Enabled on GitLab.com, self-managed, and GitLab Dedicated](https://gitlab.com/gitlab-org/gitlab/-/issues/467259) in GitLab 17.7.
|
||||
|
||||
FLAG:
|
||||
The availability of this feature is controlled by a feature flag.
|
||||
|
|
|
|||
|
|
@ -273,6 +273,7 @@ and do not use a seat.
|
|||
## Assign a custom role to an invited group
|
||||
|
||||
> - Support for custom roles for invited groups [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/443369) in GitLab 17.4 behind a feature flag named `assign_custom_roles_to_group_links_sm`. Disabled by default.
|
||||
> - [Enabled on self-managed and GitLab Dedicated](https://gitlab.com/gitlab-org/gitlab/-/issues/471999) in GitLab 17.4.
|
||||
|
||||
FLAG:
|
||||
The availability of this feature is controlled by a feature flag. For more information, see the history.
|
||||
|
|
|
|||
|
|
@ -322,6 +322,7 @@ These features are still accessible, but not writable.
|
|||
- Issues
|
||||
- Merge requests
|
||||
- Feature flags
|
||||
- Pull mirroring
|
||||
- All other project features
|
||||
|
||||
Active pipeline schedules of archived projects don't become read-only.
|
||||
|
|
@ -371,6 +372,8 @@ Prerequisites:
|
|||
|
||||
The deployed Pages are not restored and you must rerun the pipeline.
|
||||
|
||||
When a project is unarchived, its pull mirroring process will automatically resume.
|
||||
|
||||
## View project activity
|
||||
|
||||
To view the activity of a project:
|
||||
|
|
|
|||
|
|
@ -9,46 +9,6 @@ module API
|
|||
feature_category :feature_flags
|
||||
urgency :low
|
||||
|
||||
BadValueError = Class.new(StandardError)
|
||||
|
||||
# TODO: remove these helpers with feature flag set_feature_flag_service
|
||||
helpers do
|
||||
def gate_value(params)
|
||||
case params[:value]
|
||||
when 'true'
|
||||
true
|
||||
when '0', 'false'
|
||||
false
|
||||
else
|
||||
raise BadValueError unless params[:value].match?(/^\d+(\.\d+)?$/)
|
||||
|
||||
# https://github.com/jnunemaker/flipper/blob/master/lib/flipper/typecast.rb#L47
|
||||
if params[:value].to_s.include?('.')
|
||||
params[:value].to_f
|
||||
else
|
||||
params[:value].to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def gate_key(params)
|
||||
case params[:key]
|
||||
when 'percentage_of_actors'
|
||||
:percentage_of_actors
|
||||
else
|
||||
:percentage_of_time
|
||||
end
|
||||
end
|
||||
|
||||
def gate_targets(params)
|
||||
Feature::Target.new(params).targets
|
||||
end
|
||||
|
||||
def gate_specified?(params)
|
||||
Feature::Target.new(params).gate_specified?
|
||||
end
|
||||
end
|
||||
|
||||
resource :features do
|
||||
desc 'List all features' do
|
||||
detail 'Get a list of all persisted features, with its gate values.'
|
||||
|
|
@ -76,10 +36,12 @@ module API
|
|||
|
||||
desc 'Set or create a feature' do
|
||||
detail "Set a feature's gate value. If a feature with the given name doesn't exist yet, it's created. " \
|
||||
"The value can be a boolean, or an integer to indicate percentage of time."
|
||||
"The value can be a boolean, or an integer to indicate percentage of time."
|
||||
success Entities::Feature
|
||||
failure [
|
||||
{ code: 400, message: 'Bad request' }
|
||||
{ code: 400, message: 'Bad request' },
|
||||
{ code: 401, message: 'Unauthorized' },
|
||||
{ code: 403, message: 'Forbidden' }
|
||||
]
|
||||
tags features_tags
|
||||
end
|
||||
|
|
@ -96,14 +58,14 @@ module API
|
|||
optional :namespace,
|
||||
type: String,
|
||||
desc: "A GitLab group or user namespace's path, for example `john-doe`, or comma-separated " \
|
||||
"multiple namespace paths. Introduced in GitLab 15.0."
|
||||
"multiple namespace paths. Introduced in GitLab 15.0."
|
||||
optional :project,
|
||||
type: String,
|
||||
desc: "A projects path, for example `gitlab-org/gitlab-foss`, or comma-separated multiple project paths"
|
||||
optional :repository,
|
||||
type: String,
|
||||
desc: "A repository path, for example `gitlab-org/gitlab-test.git`, `gitlab-org/gitlab-test.wiki.git`, " \
|
||||
"`snippets/21.git`, to name a few. Use comma to separate multiple repository paths"
|
||||
"`snippets/21.git`, to name a few. Use comma to separate multiple repository paths"
|
||||
optional :force, type: Boolean, desc: 'Skip feature flag validation checks, such as a YAML definition'
|
||||
|
||||
mutually_exclusive :key, :feature_group
|
||||
|
|
@ -114,53 +76,17 @@ module API
|
|||
mutually_exclusive :key, :repository
|
||||
end
|
||||
post ':name' do
|
||||
if Feature.enabled?(:set_feature_flag_service)
|
||||
flag_params = declared_params(include_missing: false)
|
||||
response = ::Admin::SetFeatureFlagService
|
||||
.new(feature_flag_name: params[:name], params: flag_params)
|
||||
.execute
|
||||
flag_params = declared_params(include_missing: false)
|
||||
response = ::Admin::SetFeatureFlagService
|
||||
.new(feature_flag_name: params[:name], params: flag_params)
|
||||
.execute
|
||||
|
||||
if response.success?
|
||||
present response.payload[:feature_flag],
|
||||
with: Entities::Feature, current_user: current_user
|
||||
else
|
||||
bad_request!(response.message)
|
||||
end
|
||||
else
|
||||
validate_feature_flag_name!(params[:name]) unless params[:force]
|
||||
|
||||
targets = gate_targets(params)
|
||||
value = gate_value(params)
|
||||
key = gate_key(params)
|
||||
|
||||
case value
|
||||
when true
|
||||
if gate_specified?(params)
|
||||
targets.each { |target| Feature.enable(params[:name], target) }
|
||||
else
|
||||
Feature.enable(params[:name])
|
||||
end
|
||||
when false
|
||||
if gate_specified?(params)
|
||||
targets.each { |target| Feature.disable(params[:name], target) }
|
||||
else
|
||||
Feature.disable(params[:name])
|
||||
end
|
||||
else
|
||||
if key == :percentage_of_actors
|
||||
Feature.enable_percentage_of_actors(params[:name], value)
|
||||
else
|
||||
Feature.enable_percentage_of_time(params[:name], value)
|
||||
end
|
||||
end
|
||||
|
||||
present Feature.get(params[:name]), # rubocop:disable Gitlab/AvoidFeatureGet
|
||||
if response.success?
|
||||
present response.payload[:feature_flag],
|
||||
with: Entities::Feature, current_user: current_user
|
||||
else
|
||||
bad_request!(response.message)
|
||||
end
|
||||
rescue BadValueError
|
||||
bad_request!("Value must be boolean or numeric, got #{params[:value]}")
|
||||
rescue Feature::Target::UnknownTargetError => e
|
||||
bad_request!(e.message)
|
||||
end
|
||||
|
||||
desc 'Delete a feature' do
|
||||
|
|
@ -173,13 +99,6 @@ module API
|
|||
no_content!
|
||||
end
|
||||
end
|
||||
|
||||
# TODO: remove this helper with feature flag set_feature_flag_service
|
||||
helpers do
|
||||
def validate_feature_flag_name!(name)
|
||||
# no-op
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ module Banzai
|
|||
|
||||
url.chomp!(matches[:format]) if matches.names.include?("format")
|
||||
|
||||
content = link_content || object_link_text(object, matches)
|
||||
content = context[:link_text] || link_content || object_link_text(object, matches)
|
||||
|
||||
link = %(<a href="#{url}" #{data}
|
||||
title="#{escape_once(title)}"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ module Banzai
|
|||
end
|
||||
|
||||
def parent_records(parent, ids)
|
||||
# we are treating all group level issues as work items so those would be handled
|
||||
# by the WorkItemReferenceFilter
|
||||
return Issue.none if parent.is_a?(Group)
|
||||
|
||||
parent.issues.where(iid: ids.to_a)
|
||||
.includes(:project, :namespace, ::Gitlab::Issues::TypeAssociationGetter.call)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -158,6 +158,15 @@ module Banzai
|
|||
def requires_unescaping?
|
||||
true
|
||||
end
|
||||
|
||||
def data_attributes_for(text, parent, object, link_content: false, link_reference: false)
|
||||
object_parent = object.resource_parent
|
||||
|
||||
return super unless object_parent.is_a?(Group)
|
||||
return super if object_parent.id == parent.id
|
||||
|
||||
super.merge({ group: object_parent.id, namespace: object_parent.id, project: nil })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -195,29 +195,7 @@ module Banzai
|
|||
def objects_for_paths(paths, absolute_path)
|
||||
search_paths = absolute_path ? paths.pluck(1..-1) : paths
|
||||
|
||||
klass = parent_type.to_s.camelize.constantize
|
||||
result = if parent_type == :namespace
|
||||
klass.id_in(Route.by_paths(search_paths).select(:namespace_id))
|
||||
else
|
||||
klass.where_full_path_in(search_paths)
|
||||
end
|
||||
|
||||
return result if parent_type == :group || parent_type == :namespace
|
||||
return unless parent_type == :project
|
||||
|
||||
projects = result.includes(namespace: :route)
|
||||
.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046")
|
||||
|
||||
return projects unless absolute_path
|
||||
|
||||
# If we make it to here, then we're handling absolute path(s).
|
||||
# Which means we need to also search groups as well as projects.
|
||||
# Possible future optimization might be to use Route along the lines of:
|
||||
# Routable.where_full_path_in(paths).includes(:source)
|
||||
# See `routable.rb`
|
||||
groups = Group.where_full_path_in(search_paths)
|
||||
|
||||
projects.to_a + groups.to_a
|
||||
Route.by_paths(search_paths).preload(source: [:route, { namespace: :route }]).map(&:source)
|
||||
end
|
||||
|
||||
def refs_cache
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ module Gitlab
|
|||
end
|
||||
|
||||
def unfold_reference(reference, match, target_parent)
|
||||
format = match[:format].to_s
|
||||
before = @text[0...match.begin(0)]
|
||||
after = @text[match.end(0)..]
|
||||
|
||||
|
|
@ -85,22 +86,26 @@ module Gitlab
|
|||
raise RewriteError, "Unspecified reference detected for #{referable.class.name}"
|
||||
end
|
||||
|
||||
cross_reference += format
|
||||
new_text = before + cross_reference + after
|
||||
substitution_valid?(new_text) ? cross_reference : reference
|
||||
end
|
||||
|
||||
def find_referable(reference)
|
||||
extractor = Gitlab::ReferenceExtractor.new(@source_parent, @current_user)
|
||||
extractor.analyze(reference)
|
||||
extractor = Gitlab::ReferenceExtractor.new(source_parent_param[:project], @current_user)
|
||||
extractor.analyze(reference, **source_parent_param)
|
||||
extractor.all.first
|
||||
end
|
||||
|
||||
def build_cross_reference(referable, target_parent)
|
||||
if referable.respond_to?(:project)
|
||||
referable.to_reference(target_parent)
|
||||
else
|
||||
referable.to_reference(@source_parent, target_container: target_parent)
|
||||
end
|
||||
class_name = referable.class.base_class.name
|
||||
|
||||
return referable.to_reference(target_parent) unless %w[Label Milestone].include?(class_name)
|
||||
return referable.to_reference(@source_parent, target_container: target_parent) if referable.is_a?(GroupLabel)
|
||||
return referable.to_reference(target_parent, full: true, absolute_path: true) if referable.is_a?(Milestone)
|
||||
|
||||
full = @source_parent.is_a?(Group) ? true : false
|
||||
referable.to_reference(target_parent, full: full)
|
||||
end
|
||||
|
||||
def substitution_valid?(substituted)
|
||||
|
|
@ -108,8 +113,20 @@ module Gitlab
|
|||
end
|
||||
|
||||
def markdown(text)
|
||||
Banzai.render(text, project: @source_parent, no_original_data: true, no_sourcepos: true)
|
||||
Banzai.render(text, **source_parent_param, no_original_data: true, no_sourcepos: true, link_text: 'placeholder')
|
||||
end
|
||||
|
||||
def source_parent_param
|
||||
case @source_parent
|
||||
when Project
|
||||
{ project: @source_parent }
|
||||
when Group
|
||||
{ group: @source_parent, project: nil }
|
||||
when Namespaces::ProjectNamespace
|
||||
{ project: @source_parent.project }
|
||||
end
|
||||
end
|
||||
strong_memoize_attr :source_parent_param
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -94,15 +94,10 @@ module Sidebars
|
|||
end
|
||||
|
||||
def google_cloud_menu_item
|
||||
enabled_for_user = Feature.enabled?(:incubation_5mp_google_cloud, context.current_user)
|
||||
enabled_for_group = Feature.enabled?(:incubation_5mp_google_cloud, context.project.group)
|
||||
enabled_for_project = Feature.enabled?(:incubation_5mp_google_cloud, context.project)
|
||||
feature_is_enabled = enabled_for_user || enabled_for_group || enabled_for_project
|
||||
user_has_permissions = can?(context.current_user, :admin_project_google_cloud, context.project)
|
||||
|
||||
google_oauth2_configured = google_oauth2_configured?
|
||||
|
||||
unless feature_is_enabled && user_has_permissions && google_oauth2_configured
|
||||
unless user_has_permissions && google_oauth2_configured
|
||||
return ::Sidebars::NilMenuItem.new(item_id: :incubation_5mp_google_cloud)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -15319,6 +15319,9 @@ msgstr ""
|
|||
msgid "ContainerRegistry|Next cleanup scheduled to run on:"
|
||||
msgstr ""
|
||||
|
||||
msgid "ContainerRegistry|No container repositories are protected yet."
|
||||
msgstr ""
|
||||
|
||||
msgid "ContainerRegistry|Not yet scheduled"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -15346,6 +15349,9 @@ msgstr ""
|
|||
msgid "ContainerRegistry|Push an image"
|
||||
msgstr ""
|
||||
|
||||
msgid "ContainerRegistry|Registry cleanup disabled. Either no cleanup policies enabled, or this project has no container images."
|
||||
msgstr ""
|
||||
|
||||
msgid "ContainerRegistry|Regular expression without the \\A and \\z anchors."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -45333,6 +45339,9 @@ msgstr ""
|
|||
msgid "Pull"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull mirroring is disabled because the repository is set to read-only."
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull mirroring updated %{time}."
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,6 @@ spec/frontend/boards/components/board_content_spec.js
|
|||
spec/frontend/boards/components/board_form_spec.js
|
||||
spec/frontend/boards/components/board_options_spec.js
|
||||
spec/frontend/boards/components/board_settings_sidebar_spec.js
|
||||
spec/frontend/boards/components/board_top_bar_spec.js
|
||||
spec/frontend/boards/components/toggle_focus_spec.js
|
||||
spec/frontend/boards/project_select_spec.js
|
||||
spec/frontend/branches/components/delete_branch_modal_spec.js
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import waitForPromises from 'helpers/wait_for_promises';
|
|||
|
||||
import { formType } from '~/boards/constants';
|
||||
import BoardTopBar from '~/boards/components/board_top_bar.vue';
|
||||
import BoardsSelector from '~/boards/components/boards_selector.vue';
|
||||
import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue';
|
||||
import ConfigToggle from '~/boards/components/config_toggle.vue';
|
||||
import IssueBoardFilteredSearch from '~/boards/components/issue_board_filtered_search.vue';
|
||||
import IssueBoardFilteredSearch from 'ee_else_ce/boards/components/issue_board_filtered_search.vue';
|
||||
import ToggleFocus from '~/boards/components/toggle_focus.vue';
|
||||
import * as cacheUpdates from '~/boards/graphql/cache_updates';
|
||||
import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
|
||||
|
|
@ -57,6 +57,9 @@ describe('BoardTopBar', () => {
|
|||
isIssueBoard: true,
|
||||
isEpicBoard: false,
|
||||
isGroupBoard: true,
|
||||
epicFeatureAvailable: false,
|
||||
iterationFeatureAvailable: false,
|
||||
healthStatusFeatureAvailable: false,
|
||||
...provide,
|
||||
},
|
||||
stubs: { IssueBoardFilteredSearch },
|
||||
|
|
|
|||
|
|
@ -5,11 +5,9 @@ exports[`Issue type info popover renders 1`] = `
|
|||
class="gl-ml-2"
|
||||
id="reference-0"
|
||||
>
|
||||
<gl-icon-stub
|
||||
<help-icon-stub
|
||||
id="reference-1"
|
||||
name="question-o"
|
||||
size="16"
|
||||
variant="info"
|
||||
size="default"
|
||||
/>
|
||||
<gl-popover-stub
|
||||
container="popovercontainer"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { nextTick } from 'vue';
|
||||
import { GlIcon, GlPopover, GlLink } from '@gitlab/ui';
|
||||
import { GlPopover, GlLink } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { helpPagePath } from '~/helpers/help_page_helper';
|
||||
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
|
||||
import TimelineEventsTagsPopover from '~/issues/show/components/incidents/timeline_events_tags_popover.vue';
|
||||
|
||||
describe('TimelineEventsTagsPopover component', () => {
|
||||
|
|
@ -19,7 +20,7 @@ describe('TimelineEventsTagsPopover component', () => {
|
|||
mountComponent();
|
||||
});
|
||||
|
||||
const findQuestionIcon = () => wrapper.findComponent(GlIcon);
|
||||
const findQuestionIcon = () => wrapper.findComponent(HelpIcon);
|
||||
const findPopover = () => wrapper.findComponent(GlPopover);
|
||||
const findDocumentationLink = () => findPopover().findComponent(GlLink);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
|||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import component from '~/packages_and_registries/settings/project/components/container_expiration_policy.vue';
|
||||
import {
|
||||
CONTAINER_CLEANUP_POLICY_TITLE,
|
||||
CONTAINER_CLEANUP_POLICY_EDIT_RULES,
|
||||
CONTAINER_CLEANUP_POLICY_SET_RULES,
|
||||
CONTAINER_CLEANUP_POLICY_DESCRIPTION,
|
||||
CONTAINER_CLEANUP_POLICY_RULES_DESCRIPTION,
|
||||
FETCH_SETTINGS_ERROR_MESSAGE,
|
||||
UNAVAILABLE_FEATURE_INTRO_TEXT,
|
||||
|
|
@ -26,15 +28,20 @@ describe('Container expiration policy project settings', () => {
|
|||
let wrapper;
|
||||
let fakeApollo;
|
||||
|
||||
const defaultProvidedValues = {
|
||||
let defaultProvidedValues = {
|
||||
projectPath: 'path',
|
||||
isAdmin: false,
|
||||
adminSettingsPath: 'settingsPath',
|
||||
cleanupSettingsPath: 'cleanupSettingsPath',
|
||||
enableHistoricEntries: false,
|
||||
helpPagePath: 'helpPagePath',
|
||||
glFeatures: {
|
||||
reorganizeProjectLevelRegistrySettings: false,
|
||||
},
|
||||
};
|
||||
|
||||
const findCard = () => wrapper.findComponent(GlCard);
|
||||
const findHeader = () => findCard().find('h2');
|
||||
const findFormComponent = () => wrapper.findComponent(GlCard);
|
||||
const findDescription = () => wrapper.findByTestId('description');
|
||||
const findButton = () => wrapper.findByTestId('rules-button');
|
||||
|
|
@ -163,4 +170,127 @@ describe('Container expiration policy project settings', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('when "reorganizeProjectLevelRegistrySettings" feature flag is enabled', () => {
|
||||
beforeEach(() => {
|
||||
defaultProvidedValues = {
|
||||
...defaultProvidedValues,
|
||||
glFeatures: {
|
||||
reorganizeProjectLevelRegistrySettings: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('renders the setting form', async () => {
|
||||
mountComponentWithApollo({
|
||||
resolver: jest.fn().mockResolvedValue(expirationPolicyPayload()),
|
||||
});
|
||||
await waitForPromises();
|
||||
|
||||
expect(findHeader().text()).toBe(CONTAINER_CLEANUP_POLICY_TITLE);
|
||||
expect(findDescription().text()).toMatchInterpolatedText(
|
||||
CONTAINER_CLEANUP_POLICY_DESCRIPTION,
|
||||
);
|
||||
expect(findButton().text()).toMatchInterpolatedText(CONTAINER_CLEANUP_POLICY_EDIT_RULES);
|
||||
expect(findButton().attributes('href')).toBe(defaultProvidedValues.cleanupSettingsPath);
|
||||
});
|
||||
|
||||
it('when loading does not render alert components', () => {
|
||||
mountComponentWithApollo({
|
||||
resolver: jest.fn().mockResolvedValue(),
|
||||
});
|
||||
|
||||
expect(findCard().exists()).toBe(true);
|
||||
expect(findAlert().exists()).toBe(false);
|
||||
expect(findButton().exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe('when API returns `null`', () => {
|
||||
it('the button is hidden', async () => {
|
||||
mountComponentWithApollo({
|
||||
resolver: jest.fn().mockResolvedValue(nullExpirationPolicyPayload()),
|
||||
});
|
||||
await waitForPromises();
|
||||
|
||||
expect(findButton().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows an alert', async () => {
|
||||
mountComponentWithApollo({
|
||||
resolver: jest.fn().mockResolvedValue(nullExpirationPolicyPayload()),
|
||||
});
|
||||
await waitForPromises();
|
||||
|
||||
const text = findAlert().text();
|
||||
expect(text).toContain(UNAVAILABLE_FEATURE_INTRO_TEXT);
|
||||
expect(text).toContain(UNAVAILABLE_USER_FEATURE_TEXT);
|
||||
});
|
||||
|
||||
describe('an admin is visiting the page', () => {
|
||||
it('shows the admin part of the alert', async () => {
|
||||
mountComponentWithApollo({
|
||||
provide: { ...defaultProvidedValues, isAdmin: true },
|
||||
resolver: jest.fn().mockResolvedValue(nullExpirationPolicyPayload()),
|
||||
});
|
||||
await waitForPromises();
|
||||
|
||||
const sprintf = findAlert().findComponent(GlSprintf);
|
||||
expect(sprintf.text()).toBe('administration settings');
|
||||
expect(sprintf.findComponent(GlLink).attributes('href')).toBe(
|
||||
defaultProvidedValues.adminSettingsPath,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchSettingsError', () => {
|
||||
beforeEach(async () => {
|
||||
mountComponentWithApollo({
|
||||
resolver: jest.fn().mockRejectedValue(new Error('GraphQL error')),
|
||||
});
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('show the card', () => {
|
||||
expect(findCard().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('the button is hidden', () => {
|
||||
expect(findButton().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows an alert', () => {
|
||||
expect(findAlert().html()).toContain(FETCH_SETTINGS_ERROR_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty API response', () => {
|
||||
it.each`
|
||||
enableHistoricEntries | isShown
|
||||
${true} | ${true}
|
||||
${false} | ${false}
|
||||
`('is $isShown that the policy is shown', async ({ enableHistoricEntries, isShown }) => {
|
||||
mountComponentWithApollo({
|
||||
provide: {
|
||||
...defaultProvidedValues,
|
||||
enableHistoricEntries,
|
||||
},
|
||||
resolver: jest.fn().mockResolvedValue(emptyExpirationPolicyPayload()),
|
||||
});
|
||||
await waitForPromises();
|
||||
|
||||
expect(findCard().exists()).toBe(true);
|
||||
if (isShown) {
|
||||
expect(findButton().text()).toMatchInterpolatedText(CONTAINER_CLEANUP_POLICY_SET_RULES);
|
||||
expect(findButton().attributes('href')).toBe(defaultProvidedValues.cleanupSettingsPath);
|
||||
expect(wrapper.findByTestId('empty-cleanup-policy').text()).toBe(
|
||||
'Registry cleanup disabled. Either no cleanup policies enabled, or this project has no container images.',
|
||||
);
|
||||
} else {
|
||||
expect(findButton().exists()).toBe(false);
|
||||
expect(findAlert().html()).toContain(FETCH_SETTINGS_ERROR_MESSAGE);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,13 +25,18 @@ describe('Container protection rules project settings', () => {
|
|||
let wrapper;
|
||||
let fakeApollo;
|
||||
|
||||
const defaultProvidedValues = {
|
||||
let defaultProvidedValues = {
|
||||
projectPath: 'path',
|
||||
glFeatures: {
|
||||
reorganizeProjectLevelRegistrySettings: false,
|
||||
},
|
||||
};
|
||||
|
||||
const $toast = { show: jest.fn() };
|
||||
|
||||
const findCrudComponent = () => wrapper.findComponent(CrudComponent);
|
||||
const findSettingsBlock = () => wrapper.findComponent(SettingsSection);
|
||||
const findEmptyText = () => wrapper.findByText('No container repositories are protected yet.');
|
||||
const findTable = () =>
|
||||
extendedWrapper(wrapper.findByRole('table', { name: /protected container repositories/i }));
|
||||
const findTableBody = () => extendedWrapper(findTable().findAllByRole('rowgroup').at(1));
|
||||
|
|
@ -668,4 +673,629 @@ describe('Container protection rules project settings', () => {
|
|||
expect(findAlert().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when "reorganizeProjectLevelRegistrySettings" feature flag is enabled', () => {
|
||||
beforeEach(() => {
|
||||
defaultProvidedValues = {
|
||||
...defaultProvidedValues,
|
||||
glFeatures: {
|
||||
reorganizeProjectLevelRegistrySettings: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('renders the setting block with table', async () => {
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findCrudComponent().props()).toMatchObject({
|
||||
title: 'Protected container repositories',
|
||||
toggleText: 'Add protection rule',
|
||||
});
|
||||
expect(findTable().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('hides table when no protection rules exist', async () => {
|
||||
createComponent({
|
||||
containerProtectionRuleQueryResolver: jest.fn().mockResolvedValue(
|
||||
containerProtectionRuleQueryPayload({
|
||||
nodes: [],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
await waitForPromises();
|
||||
|
||||
expect(findTable().exists()).toBe(false);
|
||||
expect(findEmptyText().exists()).toBe(true);
|
||||
});
|
||||
|
||||
describe('table "container protection rules"', () => {
|
||||
const findTableRowCell = (i, j) =>
|
||||
extendedWrapper(findTableRow(i).findAllByRole('cell').at(j));
|
||||
const findTableRowCellCombobox = (i, j) => findTableRowCell(i, j).findByRole('combobox');
|
||||
const findTableRowCellComboboxSelectedOption = (i, j) =>
|
||||
findTableRowCellCombobox(i, j).element.selectedOptions.item(0);
|
||||
|
||||
it('renders table with container protection rules', async () => {
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findTable().exists()).toBe(true);
|
||||
|
||||
containerProtectionRuleQueryPayload().data.project.containerProtectionRepositoryRules.nodes.forEach(
|
||||
(protectionRule, i) => {
|
||||
expect(findTableRowCell(i, 0).text()).toBe(protectionRule.repositoryPathPattern);
|
||||
expect(findTableRowCellComboboxSelectedOption(i, 1).text).toBe('Maintainer');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('shows loading icon', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findTableLoadingIcon().exists()).toBe(true);
|
||||
expect(findTableLoadingIcon().attributes('aria-label')).toBe('Loading');
|
||||
});
|
||||
|
||||
it('calls graphql api query', () => {
|
||||
const containerProtectionRuleQueryResolver = jest
|
||||
.fn()
|
||||
.mockResolvedValue(containerProtectionRuleQueryPayload());
|
||||
createComponent({ containerProtectionRuleQueryResolver });
|
||||
|
||||
expect(containerProtectionRuleQueryResolver).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ projectPath: defaultProvidedValues.projectPath }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows alert when graphql api query failed', async () => {
|
||||
const graphqlErrorMessage = 'Error when requesting graphql api';
|
||||
const containerProtectionRuleQueryResolver = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error(graphqlErrorMessage));
|
||||
createComponent({ containerProtectionRuleQueryResolver });
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findAlert().isVisible()).toBe(true);
|
||||
expect(findAlert().text()).toBe(graphqlErrorMessage);
|
||||
});
|
||||
|
||||
describe('table pagination', () => {
|
||||
const findPagination = () => wrapper.findComponent(GlKeysetPagination);
|
||||
|
||||
it('renders pagination', async () => {
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findPagination().exists()).toBe(true);
|
||||
expect(findPagination().props()).toMatchObject({
|
||||
endCursor: '10',
|
||||
startCursor: '0',
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls initial graphql api query with pagination information', () => {
|
||||
const containerProtectionRuleQueryResolver = jest
|
||||
.fn()
|
||||
.mockResolvedValue(containerProtectionRuleQueryPayload());
|
||||
createComponent({ containerProtectionRuleQueryResolver });
|
||||
|
||||
expect(containerProtectionRuleQueryResolver).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectPath: defaultProvidedValues.projectPath,
|
||||
first: 10,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('show alert when grapqhl fails', () => {
|
||||
const containerProtectionRuleQueryResolver = jest
|
||||
.fn()
|
||||
.mockResolvedValue(containerProtectionRuleQueryPayload());
|
||||
createComponent({ containerProtectionRuleQueryResolver });
|
||||
|
||||
expect(containerProtectionRuleQueryResolver).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectPath: defaultProvidedValues.projectPath,
|
||||
first: 10,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('when button "Previous" is clicked', () => {
|
||||
const containerProtectionRuleQueryResolver = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
containerProtectionRuleQueryPayload({
|
||||
nodes: containerProtectionRulesData.slice(10),
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: true,
|
||||
startCursor: '10',
|
||||
endCursor: '16',
|
||||
},
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(containerProtectionRuleQueryPayload());
|
||||
|
||||
const findPaginationButtonPrev = () =>
|
||||
extendedWrapper(findPagination()).findByRole('button', { name: /previous/i });
|
||||
|
||||
beforeEach(async () => {
|
||||
createComponent({ containerProtectionRuleQueryResolver });
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
findPaginationButtonPrev().trigger('click');
|
||||
});
|
||||
|
||||
it('sends a second graphql api query with new pagination params', () => {
|
||||
expect(containerProtectionRuleQueryResolver).toHaveBeenCalledTimes(2);
|
||||
expect(containerProtectionRuleQueryResolver).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
before: '10',
|
||||
last: 10,
|
||||
projectPath: 'path',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when button "Next" is clicked', () => {
|
||||
const containerProtectionRuleQueryResolver = jest
|
||||
.fn()
|
||||
.mockResolvedValue(containerProtectionRuleQueryPayload())
|
||||
.mockResolvedValueOnce(containerProtectionRuleQueryPayload())
|
||||
.mockResolvedValueOnce(
|
||||
containerProtectionRuleQueryPayload({
|
||||
nodes: containerProtectionRulesData.slice(10),
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
startCursor: '1',
|
||||
endCursor: '10',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const findPaginationButtonNext = () =>
|
||||
extendedWrapper(findPagination()).findByRole('button', { name: /next/i });
|
||||
|
||||
beforeEach(async () => {
|
||||
createComponent({ containerProtectionRuleQueryResolver });
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
findPaginationButtonNext().trigger('click');
|
||||
});
|
||||
|
||||
it('sends a second graphql api query with new pagination params', () => {
|
||||
expect(containerProtectionRuleQueryResolver).toHaveBeenCalledTimes(2);
|
||||
expect(containerProtectionRuleQueryResolver).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
after: '10',
|
||||
first: 10,
|
||||
projectPath: 'path',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('displays table in busy state and shows loading icon inside table', async () => {
|
||||
expect(findTableLoadingIcon().exists()).toBe(true);
|
||||
expect(findTableLoadingIcon().attributes('aria-label')).toBe('Loading');
|
||||
|
||||
expect(findTable().attributes('aria-busy')).toBe('true');
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findTableLoadingIcon().exists()).toBe(false);
|
||||
expect(findTable().attributes('aria-busy')).toBe('false');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
comboboxName | minimumAccessLevelAttribute
|
||||
${'push-access-select'} | ${'minimumAccessLevelForPush'}
|
||||
`(
|
||||
'column "$comboboxName" with selectbox (combobox)',
|
||||
({ comboboxName, minimumAccessLevelAttribute }) => {
|
||||
const findComboboxInTableRow = (i) =>
|
||||
extendedWrapper(wrapper.findAllByTestId(comboboxName).at(i));
|
||||
|
||||
it('contains correct access level as options', async () => {
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findComboboxInTableRow(0).isVisible()).toBe(true);
|
||||
expect(findComboboxInTableRow(0).attributes('disabled')).toBeUndefined();
|
||||
expect(findComboboxInTableRow(0).element.value).toBe(
|
||||
containerProtectionRulesData[0][minimumAccessLevelAttribute],
|
||||
);
|
||||
|
||||
const accessLevelOptions = findComboboxInTableRow(0)
|
||||
.findAllComponents('option')
|
||||
.wrappers.map((w) => w.text());
|
||||
|
||||
expect(accessLevelOptions).toEqual(['Maintainer', 'Owner', 'Admin']);
|
||||
});
|
||||
|
||||
describe('when value changes', () => {
|
||||
const accessLevelValueOwner = 'OWNER';
|
||||
const accessLevelValueMaintainer = 'MAINTAINER';
|
||||
|
||||
it('only changes the value of the selectbox in the same row', async () => {
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findComboboxInTableRow(0).props('value')).toBe(accessLevelValueMaintainer);
|
||||
expect(findComboboxInTableRow(1).props('value')).toBe(accessLevelValueMaintainer);
|
||||
|
||||
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
|
||||
|
||||
expect(findComboboxInTableRow(0).props('value')).toBe(accessLevelValueOwner);
|
||||
expect(findComboboxInTableRow(1).props('value')).toBe(accessLevelValueMaintainer);
|
||||
});
|
||||
|
||||
it('sends graphql mutation', async () => {
|
||||
const updateContainerProtectionRuleMutationResolver = jest
|
||||
.fn()
|
||||
.mockResolvedValue(updateContainerProtectionRuleMutationPayload());
|
||||
|
||||
createComponent({ updateContainerProtectionRuleMutationResolver });
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
|
||||
|
||||
expect(updateContainerProtectionRuleMutationResolver).toHaveBeenCalledTimes(1);
|
||||
expect(updateContainerProtectionRuleMutationResolver).toHaveBeenCalledWith({
|
||||
input: {
|
||||
id: containerProtectionRulesData[0].id,
|
||||
[minimumAccessLevelAttribute]: accessLevelValueOwner,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('disables all fields in relevant row when graphql mutation is in progress', async () => {
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
|
||||
|
||||
expect(findComboboxInTableRow(0).props('disabled')).toBe(true);
|
||||
expect(findTableRowButtonDelete(0).attributes('disabled')).toBe('disabled');
|
||||
|
||||
expect(findComboboxInTableRow(1).props('disabled')).toBe(false);
|
||||
expect(findTableRowButtonDelete(1).attributes('disabled')).toBeUndefined();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findComboboxInTableRow(0).props('disabled')).toBe(false);
|
||||
expect(findTableRowButtonDelete(0).attributes('disabled')).toBeUndefined();
|
||||
|
||||
expect(findComboboxInTableRow(1).props('disabled')).toBe(false);
|
||||
expect(findTableRowButtonDelete(1).attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles erroneous graphql mutation', async () => {
|
||||
const updateContainerProtectionRuleMutationResolver = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('error'));
|
||||
|
||||
createComponent({ updateContainerProtectionRuleMutationResolver });
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findAlert().isVisible()).toBe(true);
|
||||
expect(findAlert().text()).toBe('error');
|
||||
});
|
||||
|
||||
it('handles graphql mutation with error response', async () => {
|
||||
const serverErrorMessage = 'Server error message';
|
||||
const updateContainerProtectionRuleMutationResolver = jest.fn().mockResolvedValue(
|
||||
updateContainerProtectionRuleMutationPayload({
|
||||
containerRegistryProtectionRule: null,
|
||||
errors: [serverErrorMessage],
|
||||
}),
|
||||
);
|
||||
|
||||
createComponent({ updateContainerProtectionRuleMutationResolver });
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findAlert().isVisible()).toBe(true);
|
||||
expect(findAlert().text()).toBe(serverErrorMessage);
|
||||
});
|
||||
|
||||
it('shows a toast with success message', async () => {
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect($toast.show).toHaveBeenCalledWith('Container protection rule updated.');
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe('column "rowActions"', () => {
|
||||
describe('button "Delete"', () => {
|
||||
it('exists in table', async () => {
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findTableRowButtonDelete(0).exists()).toBe(true);
|
||||
});
|
||||
|
||||
describe('when button is clicked', () => {
|
||||
it('renders the "delete container protection rule" confirmation modal', async () => {
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
await findTableRowButtonDelete(0).trigger('click');
|
||||
|
||||
const modalId = getBinding(findTableRowButtonDelete(0).element, 'gl-modal');
|
||||
|
||||
expect(findModal().props('modal-id')).toBe(modalId);
|
||||
expect(findModal().props('title')).toBe(
|
||||
'Delete container repository protection rule?',
|
||||
);
|
||||
expect(findModal().text()).toContain(
|
||||
'Users with at least the Developer role for this project will be able to push and delete container images to this repository path.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('modal "confirmation for delete action"', () => {
|
||||
const createComponentAndClickButtonDeleteInTableRow = async ({
|
||||
tableRowIndex = 0,
|
||||
deleteContainerProtectionRuleMutationResolver = jest
|
||||
.fn()
|
||||
.mockResolvedValue(deleteContainerProtectionRuleMutationPayload()),
|
||||
} = {}) => {
|
||||
createComponent({ deleteContainerProtectionRuleMutationResolver });
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
findTableRowButtonDelete(tableRowIndex).trigger('click');
|
||||
};
|
||||
|
||||
describe('when modal button "primary" clicked', () => {
|
||||
const clickOnModalPrimaryBtn = () => findModal().vm.$emit('primary');
|
||||
|
||||
it('disables the button when graphql mutation is executed', async () => {
|
||||
await createComponentAndClickButtonDeleteInTableRow();
|
||||
|
||||
await clickOnModalPrimaryBtn();
|
||||
|
||||
expect(findTableRowButtonDelete(0).attributes('disabled')).toBe('disabled');
|
||||
|
||||
expect(findTableRowButtonDelete(1).attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends graphql mutation', async () => {
|
||||
const deleteContainerProtectionRuleMutationResolver = jest
|
||||
.fn()
|
||||
.mockResolvedValue(deleteContainerProtectionRuleMutationPayload());
|
||||
|
||||
await createComponentAndClickButtonDeleteInTableRow({
|
||||
deleteContainerProtectionRuleMutationResolver,
|
||||
});
|
||||
|
||||
await clickOnModalPrimaryBtn();
|
||||
|
||||
expect(deleteContainerProtectionRuleMutationResolver).toHaveBeenCalledTimes(1);
|
||||
expect(deleteContainerProtectionRuleMutationResolver).toHaveBeenCalledWith({
|
||||
input: { id: containerProtectionRulesData[0].id },
|
||||
});
|
||||
});
|
||||
|
||||
it('handles erroneous graphql mutation', async () => {
|
||||
const alertErrorMessage = 'Client error message';
|
||||
const deleteContainerProtectionRuleMutationResolver = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error(alertErrorMessage));
|
||||
|
||||
await createComponentAndClickButtonDeleteInTableRow({
|
||||
deleteContainerProtectionRuleMutationResolver,
|
||||
});
|
||||
|
||||
await clickOnModalPrimaryBtn();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findAlert().isVisible()).toBe(true);
|
||||
expect(findAlert().text()).toBe(alertErrorMessage);
|
||||
});
|
||||
|
||||
it('handles graphql mutation with error response', async () => {
|
||||
const alertErrorMessage = 'Server error message';
|
||||
const deleteContainerProtectionRuleMutationResolver = jest.fn().mockResolvedValue(
|
||||
deleteContainerProtectionRuleMutationPayload({
|
||||
containerRegistryProtectionRule: null,
|
||||
errors: [alertErrorMessage],
|
||||
}),
|
||||
);
|
||||
|
||||
await createComponentAndClickButtonDeleteInTableRow({
|
||||
deleteContainerProtectionRuleMutationResolver,
|
||||
});
|
||||
|
||||
await clickOnModalPrimaryBtn();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findAlert().isVisible()).toBe(true);
|
||||
expect(findAlert().text()).toBe(alertErrorMessage);
|
||||
});
|
||||
|
||||
it('refetches package protection rules after successful graphql mutation', async () => {
|
||||
const deleteContainerProtectionRuleMutationResolver = jest
|
||||
.fn()
|
||||
.mockResolvedValue(deleteContainerProtectionRuleMutationPayload());
|
||||
|
||||
const containerProtectionRuleQueryResolver = jest
|
||||
.fn()
|
||||
.mockResolvedValue(containerProtectionRuleQueryPayload());
|
||||
|
||||
createComponent({
|
||||
containerProtectionRuleQueryResolver,
|
||||
deleteContainerProtectionRuleMutationResolver,
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(containerProtectionRuleQueryResolver).toHaveBeenCalledTimes(1);
|
||||
|
||||
await findTableRowButtonDelete(0).trigger('click');
|
||||
|
||||
await clickOnModalPrimaryBtn();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(containerProtectionRuleQueryResolver).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows a toast with success message', async () => {
|
||||
await createComponentAndClickButtonDeleteInTableRow();
|
||||
|
||||
await clickOnModalPrimaryBtn();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect($toast.show).toHaveBeenCalledWith('Container protection rule deleted.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('button "Add protection rule"', () => {
|
||||
it('button exists', async () => {
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findAddProtectionRuleFormSubmitButton().isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not initially render form "add protection rule"', async () => {
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findAddProtectionRuleFormSubmitButton().isVisible()).toBe(true);
|
||||
expect(findAddProtectionRuleForm().exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe('when button is clicked', () => {
|
||||
beforeEach(async () => {
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
await findAddProtectionRuleFormSubmitButton().trigger('click');
|
||||
});
|
||||
|
||||
it('renders form "add protection rule"', () => {
|
||||
expect(findAddProtectionRuleForm().isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the button "add protection rule"', () => {
|
||||
expect(findAddProtectionRuleFormSubmitButton().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('form "add protection rule"', () => {
|
||||
let containerProtectionRuleQueryResolver;
|
||||
|
||||
beforeEach(async () => {
|
||||
containerProtectionRuleQueryResolver = jest
|
||||
.fn()
|
||||
.mockResolvedValue(containerProtectionRuleQueryPayload());
|
||||
|
||||
createComponent({ containerProtectionRuleQueryResolver });
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
await findAddProtectionRuleFormSubmitButton().trigger('click');
|
||||
});
|
||||
|
||||
it('handles event "submit"', async () => {
|
||||
await findAddProtectionRuleForm().vm.$emit('submit');
|
||||
|
||||
expect(containerProtectionRuleQueryResolver).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(findAddProtectionRuleForm().exists()).toBe(false);
|
||||
expect(findAddProtectionRuleFormSubmitButton().attributes('disabled')).not.toBeDefined();
|
||||
});
|
||||
|
||||
it('handles event "cancel"', async () => {
|
||||
await findAddProtectionRuleForm().vm.$emit('cancel');
|
||||
|
||||
expect(containerProtectionRuleQueryResolver).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(findAddProtectionRuleForm().exists()).toBe(false);
|
||||
expect(findAddProtectionRuleFormSubmitButton().attributes()).not.toHaveProperty('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('alert "errorMessage"', () => {
|
||||
const findAlertButtonDismiss = () => wrapper.findByRole('button', { name: /dismiss/i });
|
||||
|
||||
it('renders alert and dismisses it correctly', async () => {
|
||||
const alertErrorMessage = 'Error message';
|
||||
createComponent({
|
||||
config: {
|
||||
data() {
|
||||
return {
|
||||
alertErrorMessage,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findAlert().isVisible()).toBe(true);
|
||||
expect(findAlert().text()).toBe(alertErrorMessage);
|
||||
|
||||
await findAlertButtonDismiss().trigger('click');
|
||||
|
||||
expect(findAlert().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { assets } from 'test_fixtures/api/releases/release.json';
|
|||
import { trimText } from 'helpers/text_helper';
|
||||
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
|
||||
import ReleaseBlockAssets from '~/releases/components/release_block_assets.vue';
|
||||
import { ASSET_LINK_TYPE } from '~/releases/constants';
|
||||
import { ASSET_LINK_TYPE, CLICK_EXPAND_ASSETS_ON_RELEASE_PAGE } from '~/releases/constants';
|
||||
import { useMockInternalEventsTracking } from 'helpers/tracking_internal_events_helper';
|
||||
|
||||
describe('Release block assets', () => {
|
||||
let wrapper;
|
||||
|
|
@ -26,6 +27,7 @@ describe('Release block assets', () => {
|
|||
|
||||
const findSectionHeading = (type) =>
|
||||
wrapper.findAll('h5').filter((h5) => h5.text() === sections[type]);
|
||||
const findAccordionButton = () => wrapper.find('[data-testid="accordion-button"]');
|
||||
|
||||
beforeEach(() => {
|
||||
defaultProps = { assets: convertObjectPropsToCamelCase(assets, { deep: true }) };
|
||||
|
|
@ -34,8 +36,6 @@ describe('Release block assets', () => {
|
|||
describe('with default props', () => {
|
||||
beforeEach(() => createComponent());
|
||||
|
||||
const findAccordionButton = () => wrapper.find('[data-testid="accordion-button"]');
|
||||
|
||||
it('renders an "Assets" accordion with the asset count', () => {
|
||||
const accordionButton = findAccordionButton();
|
||||
|
||||
|
|
@ -143,4 +143,23 @@ describe('Release block assets', () => {
|
|||
expect(findAllExternalIcons()).toHaveLength(defaultProps.assets.count);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sends tracking event data', () => {
|
||||
const { bindInternalEventDocument } = useMockInternalEventsTracking();
|
||||
|
||||
beforeEach(() => createComponent({ ...defaultProps, expanded: false }));
|
||||
|
||||
it('on expand', async () => {
|
||||
const { trackEventSpy } = bindInternalEventDocument(wrapper.element);
|
||||
|
||||
await findAccordionButton().trigger('click');
|
||||
|
||||
expect(trackEventSpy).toHaveBeenCalledTimes(1);
|
||||
expect(trackEventSpy).toHaveBeenCalledWith(
|
||||
CLICK_EXPAND_ASSETS_ON_RELEASE_PAGE,
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ import { mountExtended } from 'helpers/vue_test_utils_helper';
|
|||
import DeploymentStatusLink from '~/environments/components/deployment_status_link.vue';
|
||||
import DeploymentTriggerer from '~/environments/environment_details/components/deployment_triggerer.vue';
|
||||
import Commit from '~/vue_shared/components/commit.vue';
|
||||
import {
|
||||
CLICK_EXPAND_DEPLOYMENTS_ON_RELEASE_PAGE,
|
||||
CLICK_ENVIRONMENT_LINK_ON_RELEASE_PAGE,
|
||||
CLICK_DEPLOYMENT_LINK_ON_RELEASE_PAGE,
|
||||
} from '~/releases/constants';
|
||||
import { useMockInternalEventsTracking } from 'helpers/tracking_internal_events_helper';
|
||||
import { mockDeployment } from '../mock_data';
|
||||
|
||||
const expectedTableHeaders = [
|
||||
|
|
@ -19,11 +25,11 @@ const expectedTableHeaders = [
|
|||
describe('Release block deployments', () => {
|
||||
let wrapper;
|
||||
|
||||
const createComponent = (propsData = {}) => {
|
||||
const createComponent = (props = {}) => {
|
||||
wrapper = mountExtended(ReleaseBlockDeployments, {
|
||||
propsData: {
|
||||
deployments: [mockDeployment],
|
||||
...propsData,
|
||||
...props,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -167,4 +173,49 @@ describe('Release block deployments', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sends tracking event data', () => {
|
||||
const { bindInternalEventDocument } = useMockInternalEventsTracking();
|
||||
|
||||
it('on expand', async () => {
|
||||
const { trackEventSpy } = bindInternalEventDocument(wrapper.element);
|
||||
|
||||
const button = findAccordionButton();
|
||||
await button.trigger('click');
|
||||
await button.trigger('click');
|
||||
|
||||
expect(trackEventSpy).toHaveBeenCalledTimes(1);
|
||||
expect(trackEventSpy).toHaveBeenCalledWith(
|
||||
CLICK_EXPAND_DEPLOYMENTS_ON_RELEASE_PAGE,
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('on environment link click', async () => {
|
||||
const { trackEventSpy } = bindInternalEventDocument(wrapper.element);
|
||||
|
||||
await findEnvironmentName().vm.$emit('click');
|
||||
|
||||
expect(trackEventSpy).toHaveBeenCalledTimes(1);
|
||||
expect(trackEventSpy).toHaveBeenCalledWith(
|
||||
CLICK_ENVIRONMENT_LINK_ON_RELEASE_PAGE,
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('on deployment link click', async () => {
|
||||
const { trackEventSpy } = bindInternalEventDocument(wrapper.element);
|
||||
|
||||
await findDeploymentUrl().vm.$emit('click');
|
||||
|
||||
expect(trackEventSpy).toHaveBeenCalledTimes(1);
|
||||
expect(trackEventSpy).toHaveBeenCalledWith(
|
||||
CLICK_DEPLOYMENT_LINK_ON_RELEASE_PAGE,
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,10 +10,8 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] =
|
|||
href="/foo/bar"
|
||||
target="_blank"
|
||||
>
|
||||
<gl-icon-stub
|
||||
name="question-o"
|
||||
size="12"
|
||||
variant="current"
|
||||
<help-icon-stub
|
||||
size="small"
|
||||
/>
|
||||
</gl-link-stub>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { GlFormCheckbox, GlIcon } from '@gitlab/ui';
|
||||
import { GlFormCheckbox } from '@gitlab/ui';
|
||||
import Vue, { nextTick } from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
|
|
@ -9,6 +9,7 @@ import * as autosave from '~/lib/utils/autosave';
|
|||
import { ESC_KEY, ENTER_KEY } from '~/lib/utils/keys';
|
||||
import { STATE_OPEN, i18n } from '~/work_items/constants';
|
||||
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
|
||||
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
|
||||
import workItemEmailParticipantsByIidQuery from '~/work_items/graphql/notes/work_item_email_participants_by_iid.query.graphql';
|
||||
import * as confirmViaGlModal from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
|
||||
import CommentFieldLayout from '~/notes/components/comment_field_layout.vue';
|
||||
|
|
@ -56,7 +57,7 @@ describe('Work item comment form component', () => {
|
|||
const findCancelButton = () => wrapper.findByTestId('cancel-button');
|
||||
const findConfirmButton = () => wrapper.findByTestId('confirm-button');
|
||||
const findInternalNoteCheckbox = () => wrapper.findComponent(GlFormCheckbox);
|
||||
const findInternalNoteTooltipIcon = () => wrapper.findComponent(GlIcon);
|
||||
const findInternalNoteTooltipIcon = () => wrapper.findComponent(HelpIcon);
|
||||
const findWorkItemToggleStateButton = () => wrapper.findComponent(WorkItemStateToggle);
|
||||
const findToggleResolveCheckbox = () => wrapper.findByTestId('toggle-resolve-checkbox');
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ RSpec.describe Admin::ComponentsHelper, feature_category: :database do
|
|||
main[:ci] = { adapter_name: 'PostgreSQL', version: expected_version } if Gitlab::Database.has_config?(:ci)
|
||||
main[:geo] = { adapter_name: 'PostgreSQL', version: expected_version } if Gitlab::Database.has_config?(:geo)
|
||||
main[:jh] = { adapter_name: 'PostgreSQL', version: expected_version } if Gitlab::Database.has_config?(:jh)
|
||||
main[:sec] = { adapter_name: 'PostgreSQL', version: expected_version } if Gitlab::Database.has_config?(:sec)
|
||||
|
||||
main
|
||||
end
|
||||
|
|
|
|||
|
|
@ -258,7 +258,6 @@ RSpec.describe NamespacesHelper, feature_category: :groups_and_projects do
|
|||
it 'returns a hash with necessary data for the frontend' do
|
||||
expect(helper.pipeline_usage_app_data(user_group)).to eql({
|
||||
namespace_actual_plan_name: user_group.actual_plan_name,
|
||||
namespace_path: user_group.full_path,
|
||||
namespace_id: user_group.id,
|
||||
user_namespace: user_group.user_namespace?.to_s,
|
||||
page_size: Kaminari.config.default_per_page
|
||||
|
|
|
|||
|
|
@ -793,14 +793,34 @@ RSpec.describe Banzai::Filter::References::LabelReferenceFilter, feature_categor
|
|||
let_it_be(:context) { { project: nil, group: another_group } }
|
||||
|
||||
it 'can not find the label' do
|
||||
reference = "#{group.full_path}~#{group_label.name}"
|
||||
reference = "#{another_group.full_path}~#{group_label.name}"
|
||||
result = reference_filter("See #{reference}", context)
|
||||
|
||||
expect(result.to_html).to include "See #{reference}"
|
||||
end
|
||||
|
||||
it_behaves_like 'absolute group reference' do
|
||||
let_it_be(:reference) { "#{group.full_path}~#{group_label.name}" }
|
||||
it 'finds the label with relative reference' do
|
||||
label_name = group_label.name
|
||||
reference = "#{group.full_path}~#{label_name}"
|
||||
result = reference_filter("See #{reference}", context)
|
||||
|
||||
if context[:label_url_method] == :group_url
|
||||
expect(result.css('a').first.attr('href')).to eq(urls.group_url(group, label_name: label_name))
|
||||
else
|
||||
expect(result.css('a').first.attr('href')).to eq(urls.issues_group_url(group, label_name: label_name))
|
||||
end
|
||||
end
|
||||
|
||||
it 'finds label in ancestors' do
|
||||
label_name = parent_group_label.name
|
||||
reference = "#{group.full_path}~#{label_name}"
|
||||
result = reference_filter("See #{reference}", context)
|
||||
|
||||
if context[:label_url_method] == :group_url
|
||||
expect(result.css('a').first.attr('href')).to eq(urls.group_url(group, label_name: label_name))
|
||||
else
|
||||
expect(result.css('a').first.attr('href')).to eq(urls.issues_group_url(group, label_name: label_name))
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not find label in ancestors' do
|
||||
|
|
@ -809,6 +829,10 @@ RSpec.describe Banzai::Filter::References::LabelReferenceFilter, feature_categor
|
|||
|
||||
expect(result.to_html).to include "See #{reference}"
|
||||
end
|
||||
|
||||
it_behaves_like 'absolute group reference' do
|
||||
let_it_be(:reference) { "#{group.full_path}~#{group_label.name}" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -33,8 +33,13 @@ RSpec.describe Banzai::Filter::References::MilestoneReferenceFilter, feature_cat
|
|||
doc = reference_filter("Milestone #{reference}")
|
||||
link = doc.css('a').first
|
||||
|
||||
expect(link).to have_attribute('data-project')
|
||||
expect(link.attr('data-project')).to eq project.id.to_s
|
||||
if milestone.project.present?
|
||||
expect(link).to have_attribute('data-project')
|
||||
expect(link.attr('data-project')).to eq project.id.to_s
|
||||
elsif milestone.group.present?
|
||||
expect(link).to have_attribute('data-group')
|
||||
expect(link.attr('data-group')).to eq milestone.group.id.to_s
|
||||
end
|
||||
end
|
||||
|
||||
it 'includes a data-milestone attribute' do
|
||||
|
|
@ -153,8 +158,13 @@ RSpec.describe Banzai::Filter::References::MilestoneReferenceFilter, feature_cat
|
|||
doc = reference_filter("Milestone #{link_reference}")
|
||||
link = doc.css('a').first
|
||||
|
||||
expect(link).to have_attribute('data-project')
|
||||
expect(link.attr('data-project')).to eq project.id.to_s
|
||||
if milestone.project.present?
|
||||
expect(link).to have_attribute('data-project')
|
||||
expect(link.attr('data-project')).to eq project.id.to_s
|
||||
elsif milestone.group.present?
|
||||
expect(link).to have_attribute('data-group')
|
||||
expect(link.attr('data-group')).to eq milestone.group.id.to_s
|
||||
end
|
||||
end
|
||||
|
||||
it 'includes a data-milestone attribute' do
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Gfm::ReferenceRewriter do
|
||||
RSpec.describe Gitlab::Gfm::ReferenceRewriter, feature_category: :team_planning do
|
||||
let_it_be(:group) { create(:group) }
|
||||
let_it_be(:user) { create(:user) }
|
||||
|
||||
|
|
@ -26,14 +26,6 @@ RSpec.describe Gitlab::Gfm::ReferenceRewriter do
|
|||
let!(:issue_second) { create(:issue, project: old_project) }
|
||||
let!(:merge_request) { create(:merge_request, source_project: old_project) }
|
||||
|
||||
context 'plain text description' do
|
||||
let(:text) { 'Description that references #1, #2 and !1' }
|
||||
|
||||
it { is_expected.to include issue_first.to_reference(new_project) }
|
||||
it { is_expected.to include issue_second.to_reference(new_project) }
|
||||
it { is_expected.to include merge_request.to_reference(new_project) }
|
||||
end
|
||||
|
||||
context 'description with ignored elements' do
|
||||
let(:text) do
|
||||
"Hi. This references #1, but not `#2`\n" \
|
||||
|
|
@ -71,60 +63,9 @@ RSpec.describe Gitlab::Gfm::ReferenceRewriter do
|
|||
|
||||
it { is_expected.to eq "#{ref}, `#1`, #{ref}, `#1`" }
|
||||
end
|
||||
|
||||
context 'description with project labels' do
|
||||
let!(:label) { create(:label, id: 123, name: 'test', project: old_project) }
|
||||
|
||||
context 'label referenced by id' do
|
||||
let(:text) { '#1 and ~123' }
|
||||
|
||||
it { is_expected.to eq %(#{old_project_ref}#1 and #{old_project_ref}~123) }
|
||||
end
|
||||
|
||||
context 'label referenced by text' do
|
||||
let(:text) { '#1 and ~"test"' }
|
||||
|
||||
it { is_expected.to eq %(#{old_project_ref}#1 and #{old_project_ref}~123) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'description with group labels' do
|
||||
let(:old_group) { create(:group) }
|
||||
let!(:group_label) { create(:group_label, id: 321, name: 'group label', group: old_group) }
|
||||
|
||||
before do
|
||||
old_project.update!(namespace: old_group)
|
||||
end
|
||||
|
||||
context 'label referenced by id' do
|
||||
let(:text) { '#1 and ~321' }
|
||||
|
||||
it { is_expected.to eq %(#{old_project_ref}#1 and #{old_project_ref}~321) }
|
||||
end
|
||||
|
||||
context 'label referenced by text' do
|
||||
let(:text) { '#1 and ~"group label"' }
|
||||
|
||||
it { is_expected.to eq %(#{old_project_ref}#1 and #{old_project_ref}~321) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when description contains a local reference' do
|
||||
let(:local_issue) { create(:issue, project: old_project) }
|
||||
let(:text) { "See ##{local_issue.iid}" }
|
||||
|
||||
it { is_expected.to eq("See #{old_project.path}##{local_issue.iid}") }
|
||||
end
|
||||
|
||||
context 'when description contains a cross reference' do
|
||||
let(:merge_request) { create(:merge_request) }
|
||||
let(:text) { "See #{merge_request.project.full_path}!#{merge_request.iid}" }
|
||||
|
||||
it { is_expected.to eq(text) }
|
||||
end
|
||||
|
||||
context 'with a commit' do
|
||||
let(:old_project) { create(:project, :repository, name: 'old-project', group: group) }
|
||||
let(:commit) { old_project.commit }
|
||||
|
|
@ -142,26 +83,6 @@ RSpec.describe Gitlab::Gfm::ReferenceRewriter do
|
|||
end
|
||||
end
|
||||
|
||||
context 'reference contains project milestone' do
|
||||
let!(:milestone) do
|
||||
create(:milestone, title: '9.0', project: old_project)
|
||||
end
|
||||
|
||||
let(:text) { 'milestone: %"9.0"' }
|
||||
|
||||
it { is_expected.to eq %(milestone: #{old_project_ref}%"9.0") }
|
||||
end
|
||||
|
||||
context 'when referring to group milestone' do
|
||||
let!(:milestone) do
|
||||
create(:milestone, title: '10.0', group: group)
|
||||
end
|
||||
|
||||
let(:text) { 'milestone %"10.0"' }
|
||||
|
||||
it { is_expected.to eq text }
|
||||
end
|
||||
|
||||
context 'when referring to a group' do
|
||||
let(:text) { "group @#{group.full_path}" }
|
||||
|
||||
|
|
@ -178,9 +99,7 @@ RSpec.describe Gitlab::Gfm::ReferenceRewriter do
|
|||
before do
|
||||
create(:milestone, title: '9.0', project: old_project)
|
||||
|
||||
allow_any_instance_of(Milestone)
|
||||
.to receive(:to_reference)
|
||||
.and_return(nil)
|
||||
allow_any_instance_of(Milestone).to receive(:to_reference).and_return(nil)
|
||||
end
|
||||
|
||||
let(:text) { 'milestone: %"9.0"' }
|
||||
|
|
@ -193,4 +112,153 @@ RSpec.describe Gitlab::Gfm::ReferenceRewriter do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#rewrite with table syntax' do
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
let_it_be(:parent_group1) { create(:group, path: "parent-group-one") }
|
||||
let_it_be(:parent_group2) { create(:group, path: "parent-group-two") }
|
||||
let_it_be(:user) { create(:user) }
|
||||
|
||||
let_it_be(:source_project) { create(:project, path: 'old-project', group: parent_group1) }
|
||||
let_it_be(:target_project1) { create(:project, path: 'new-project', group: parent_group1) }
|
||||
let_it_be(:target_project2) { create(:project, path: 'new-project', group: parent_group2) }
|
||||
let_it_be(:target_group1) { create(:group, path: 'new-group', parent: parent_group1) }
|
||||
let_it_be(:target_group2) { create(:group, path: 'new-group', parent: parent_group2) }
|
||||
|
||||
let_it_be(:work_item_project_first) { create(:issue, project: source_project) }
|
||||
|
||||
let_it_be(:merge_request) { create(:merge_request, source_project: source_project) }
|
||||
|
||||
let_it_be(:project_label) { create(:label, id: 123, name: 'pr label1', project: source_project) }
|
||||
let_it_be(:parent_group_label) { create(:group_label, id: 321, name: 'gr label1', group: parent_group1) }
|
||||
|
||||
let_it_be(:project_milestone) { create(:milestone, title: 'project milestone', project: source_project) }
|
||||
let_it_be(:parent_group_milestone) { create(:milestone, title: 'group milestone', group: parent_group1) }
|
||||
|
||||
before_all do
|
||||
parent_group1.add_reporter(user)
|
||||
parent_group2.add_reporter(user)
|
||||
end
|
||||
|
||||
context 'with source as Project and target as Project within same parent group' do
|
||||
let_it_be(:source_parent) { source_project } # 'parent-group-one/old-project'
|
||||
let_it_be(:target_parent) { target_project1 } # 'parent-group-one/new-project'
|
||||
|
||||
where(:source_text, :destination_text) do
|
||||
# project level work item reference
|
||||
'ref #1' | 'ref old-project#1'
|
||||
'ref #1+' | 'ref old-project#1+'
|
||||
'ref #1+s' | 'ref old-project#1+s'
|
||||
# merge request reference
|
||||
'ref !1' | 'ref old-project!1'
|
||||
'ref !1+' | 'ref old-project!1+'
|
||||
'ref !1+s' | 'ref old-project!1+s'
|
||||
# project label reference
|
||||
'ref ~123' | 'ref old-project~123'
|
||||
'ref ~"pr label1"' | 'ref old-project~123'
|
||||
# group level label reference
|
||||
'ref ~321' | 'ref old-project~321'
|
||||
'ref ~"gr label1"' | 'ref old-project~321'
|
||||
# project level milestone reference
|
||||
'ref %"project milestone"' | 'ref /parent-group-one/old-project%"project milestone"'
|
||||
# group level milestone reference
|
||||
'ref %"group milestone"' | 'ref /parent-group-one%"group milestone"'
|
||||
end
|
||||
|
||||
with_them do
|
||||
it_behaves_like 'rewrites references correctly'
|
||||
end
|
||||
end
|
||||
|
||||
context 'with source as Project and target as Project within different parent groups' do
|
||||
let_it_be(:source_parent) { source_project } # 'parent-group-one/old-project'
|
||||
let_it_be(:target_parent) { target_project2 } # 'parent-group-two/new-project'
|
||||
|
||||
where(:source_text, :destination_text) do
|
||||
# project level work item reference
|
||||
'ref #1' | 'ref parent-group-one/old-project#1'
|
||||
'ref #1+' | 'ref parent-group-one/old-project#1+'
|
||||
'ref #1+s' | 'ref parent-group-one/old-project#1+s'
|
||||
# merge request reference
|
||||
'ref !1' | 'ref parent-group-one/old-project!1'
|
||||
'ref !1+' | 'ref parent-group-one/old-project!1+'
|
||||
'ref !1+s' | 'ref parent-group-one/old-project!1+s'
|
||||
# project label reference
|
||||
'ref ~123' | 'ref parent-group-one/old-project~123'
|
||||
'ref ~"pr label1"' | 'ref parent-group-one/old-project~123'
|
||||
# group level label reference
|
||||
'ref ~321' | 'ref parent-group-one/old-project~321'
|
||||
'ref ~"gr label1"' | 'ref parent-group-one/old-project~321'
|
||||
# project level milestone reference
|
||||
'ref %"project milestone"' | 'ref /parent-group-one/old-project%"project milestone"'
|
||||
# group level milestone reference
|
||||
'ref %"group milestone"' | 'ref /parent-group-one%"group milestone"'
|
||||
end
|
||||
|
||||
with_them do
|
||||
it_behaves_like 'rewrites references correctly'
|
||||
end
|
||||
end
|
||||
|
||||
context 'with source as Project and target as Group within same parent group' do
|
||||
let_it_be(:source_parent) { source_project } # 'parent-group-one/old-project'
|
||||
let_it_be(:target_parent) { target_group1 } # 'parent-group-one/new-group'
|
||||
|
||||
where(:source_text, :destination_text) do
|
||||
# project level work item reference
|
||||
'ref #1' | 'ref parent-group-one/old-project#1'
|
||||
'ref #1+' | 'ref parent-group-one/old-project#1+'
|
||||
'ref #1+s' | 'ref parent-group-one/old-project#1+s'
|
||||
# merge request reference
|
||||
'ref !1' | 'ref parent-group-one/old-project!1'
|
||||
'ref !1+' | 'ref parent-group-one/old-project!1+'
|
||||
'ref !1+s' | 'ref parent-group-one/old-project!1+s'
|
||||
# project label reference
|
||||
'ref ~123' | 'ref parent-group-one/old-project~123'
|
||||
'ref ~"pr label1"' | 'ref parent-group-one/old-project~123'
|
||||
# group level label reference
|
||||
'ref ~321' | 'ref parent-group-one/old-project~321'
|
||||
'ref ~"gr label1"' | 'ref parent-group-one/old-project~321'
|
||||
# project level milestone reference
|
||||
'ref %"project milestone"' | 'ref /parent-group-one/old-project%"project milestone"'
|
||||
# group level milestone reference
|
||||
'ref %"group milestone"' | 'ref /parent-group-one%"group milestone"'
|
||||
end
|
||||
|
||||
with_them do
|
||||
it_behaves_like 'rewrites references correctly'
|
||||
end
|
||||
end
|
||||
|
||||
context 'with source as Project and target as Group within different parent groups' do
|
||||
let_it_be(:source_parent) { source_project } # 'parent-group-one/old-project'
|
||||
let_it_be(:target_parent) { target_group2 } # 'parent-group-two/new-group'
|
||||
|
||||
where(:source_text, :destination_text) do
|
||||
# project level work item reference
|
||||
'ref #1' | 'ref parent-group-one/old-project#1'
|
||||
'ref #1+' | 'ref parent-group-one/old-project#1+'
|
||||
'ref #1+s' | 'ref parent-group-one/old-project#1+s'
|
||||
# merge request reference
|
||||
'ref !1' | 'ref parent-group-one/old-project!1'
|
||||
'ref !1+' | 'ref parent-group-one/old-project!1+'
|
||||
'ref !1+s' | 'ref parent-group-one/old-project!1+s'
|
||||
# project label reference
|
||||
'ref ~123' | 'ref parent-group-one/old-project~123'
|
||||
'ref ~"pr label1"' | 'ref parent-group-one/old-project~123'
|
||||
# group level label reference
|
||||
'ref ~321' | 'ref parent-group-one/old-project~321'
|
||||
'ref ~"gr label1"' | 'ref parent-group-one/old-project~321'
|
||||
# project level milestone reference
|
||||
'ref %"project milestone"' | 'ref /parent-group-one/old-project%"project milestone"'
|
||||
# group level milestone reference
|
||||
'ref %"group milestone"' | 'ref /parent-group-one%"group milestone"'
|
||||
end
|
||||
|
||||
with_them do
|
||||
it_behaves_like 'rewrites references correctly'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -130,41 +130,8 @@ RSpec.describe Sidebars::Projects::Menus::InfrastructureMenu, feature_category:
|
|||
|
||||
it_behaves_like 'access rights checks'
|
||||
|
||||
context 'when feature flag is turned off globally' do
|
||||
before do
|
||||
stub_feature_flags(incubation_5mp_google_cloud: false)
|
||||
end
|
||||
|
||||
it { is_expected.to be_nil }
|
||||
|
||||
context 'when feature flag is enabled for specific project' do
|
||||
before do
|
||||
stub_feature_flags(incubation_5mp_google_cloud: project)
|
||||
end
|
||||
|
||||
it_behaves_like 'access rights checks'
|
||||
end
|
||||
|
||||
context 'when feature flag is enabled for specific group' do
|
||||
before do
|
||||
stub_feature_flags(incubation_5mp_google_cloud: project.group)
|
||||
end
|
||||
|
||||
it_behaves_like 'access rights checks'
|
||||
end
|
||||
|
||||
context 'when feature flag is enabled for specific project' do
|
||||
before do
|
||||
stub_feature_flags(incubation_5mp_google_cloud: user)
|
||||
end
|
||||
|
||||
it_behaves_like 'access rights checks'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when instance is not configured for Google OAuth2' do
|
||||
before do
|
||||
stub_feature_flags(incubation_5mp_google_cloud: true)
|
||||
unconfigured_google_oauth2 = Struct.new(:app_id, :app_secret).new('', '')
|
||||
allow(Gitlab::Auth::OAuth::Provider).to receive(:config_for)
|
||||
.with('google_oauth2')
|
||||
|
|
|
|||
|
|
@ -1554,6 +1554,13 @@ RSpec.describe Group, feature_category: :groups_and_projects do
|
|||
it { expect(group.human_name).to eq(group.name) }
|
||||
end
|
||||
|
||||
describe '#to_human_reference' do
|
||||
let_it_be(:new_group) { create(:group) }
|
||||
|
||||
it { expect(group.to_human_reference).to be_nil }
|
||||
it { expect(group.to_human_reference(new_group)).to eq(group.full_name) }
|
||||
end
|
||||
|
||||
describe '#add_user' do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe LabelNote do
|
||||
RSpec.describe LabelNote, feature_category: :team_planning do
|
||||
include Gitlab::Routing.url_helpers
|
||||
|
||||
let_it_be(:project) { create(:project, :repository) }
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:label) { create(:label, project: project) }
|
||||
let_it_be(:label2) { create(:label, project: project) }
|
||||
let_it_be(:label) { create(:label, project: project, title: 'label-1') }
|
||||
let_it_be(:label2) { create(:label, project: project, title: 'label-2') }
|
||||
|
||||
let(:resource_parent) { project }
|
||||
|
||||
|
|
|
|||
|
|
@ -78,13 +78,13 @@ RSpec.describe API::Features, :clean_gitlab_redis_feature_flag, stub_feature_fla
|
|||
it_behaves_like 'GET request permissions for admin mode'
|
||||
|
||||
it 'returns a 401 for anonymous users' do
|
||||
get api('/features')
|
||||
get api(path)
|
||||
|
||||
expect(response).to have_gitlab_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns the feature list for admins' do
|
||||
get api('/features', admin, admin_mode: true)
|
||||
get api(path, admin, admin_mode: true)
|
||||
|
||||
expect(json_response).to match_array(expected_features)
|
||||
end
|
||||
|
|
@ -98,614 +98,123 @@ RSpec.describe API::Features, :clean_gitlab_redis_feature_flag, stub_feature_fla
|
|||
let(:params) { { value: 'true' } }
|
||||
end
|
||||
|
||||
# TODO: remove this shared examples block when set_feature_flag_service feature flag
|
||||
# is removed. Then remove also any duplicate specs covered by the service class.
|
||||
shared_examples 'sets the feature flag status' do
|
||||
context 'when the feature does not exist' do
|
||||
it 'returns a 401 for anonymous users' do
|
||||
post api("/features/#{feature_name}")
|
||||
it 'returns a 401 for anonymous users' do
|
||||
post api(path)
|
||||
|
||||
expect(response).to have_gitlab_http_status(:unauthorized)
|
||||
end
|
||||
expect(response).to have_gitlab_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns a 403 for users' do
|
||||
post api("/features/#{feature_name}", user)
|
||||
|
||||
expect(response).to have_gitlab_http_status(:forbidden)
|
||||
end
|
||||
|
||||
context 'when passed value=true' do
|
||||
it 'creates an enabled feature' do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: 'true' }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to match(
|
||||
'name' => feature_name,
|
||||
'state' => 'on',
|
||||
'gates' => [{ 'key' => 'boolean', 'value' => true }],
|
||||
'definition' => known_feature_flag_definition_hash
|
||||
)
|
||||
end
|
||||
|
||||
it 'logs the event' do
|
||||
expect(Feature.logger).to receive(:info).once
|
||||
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: 'true' }
|
||||
end
|
||||
|
||||
it 'creates an enabled feature for the given Flipper group when passed feature_group=perf_team' do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: 'true', feature_group: 'perf_team' }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to match(
|
||||
'name' => feature_name,
|
||||
'state' => 'conditional',
|
||||
'gates' => [
|
||||
{ 'key' => 'boolean', 'value' => false },
|
||||
{ 'key' => 'groups', 'value' => ['perf_team'] }
|
||||
],
|
||||
'definition' => known_feature_flag_definition_hash
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates an enabled feature for the given user when passed user=username' do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: 'true', user: user.username }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to match(
|
||||
'name' => feature_name,
|
||||
'state' => 'conditional',
|
||||
'gates' => [
|
||||
{ 'key' => 'boolean', 'value' => false },
|
||||
{ 'key' => 'actors', 'value' => ["User:#{user.id}"] }
|
||||
],
|
||||
'definition' => known_feature_flag_definition_hash
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates an enabled feature for the given user and feature group when passed user=username and feature_group=perf_team' do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: 'true', user: user.username, feature_group: 'perf_team' }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response['name']).to eq(feature_name)
|
||||
expect(json_response['state']).to eq('conditional')
|
||||
expect(json_response['gates']).to contain_exactly(
|
||||
{ 'key' => 'boolean', 'value' => false },
|
||||
{ 'key' => 'groups', 'value' => ['perf_team'] },
|
||||
{ 'key' => 'actors', 'value' => ["User:#{user.id}"] }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'does not enable the flag' do |actor_type|
|
||||
let(:actor_path) { raise NotImplementedError }
|
||||
let(:expected_inexistent_path) { actor_path }
|
||||
|
||||
it 'returns the current state of the flag without changes' do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: 'true', actor_type => actor_path }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:bad_request)
|
||||
expect(json_response['message']).to eq("400 Bad request - #{expected_inexistent_path} is not found!")
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'enables the flag for the actor' do |actor_type|
|
||||
it 'sets the feature gate' do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: 'true', actor_type => actor.full_path }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to match(
|
||||
'name' => feature_name,
|
||||
'state' => 'conditional',
|
||||
'gates' => [
|
||||
{ 'key' => 'boolean', 'value' => false },
|
||||
{ 'key' => 'actors', 'value' => [actor.flipper_id] }
|
||||
],
|
||||
'definition' => known_feature_flag_definition_hash
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'creates an enabled feature for the specified entries' do
|
||||
it do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: 'true', **gate_params }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response['name']).to eq(feature_name)
|
||||
expect(json_response['gates']).to contain_exactly(
|
||||
{ 'key' => 'boolean', 'value' => false },
|
||||
{ 'key' => 'actors', 'value' => array_including(expected_gate_params) }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when enabling for a project by path' do
|
||||
context 'when the project exists' do
|
||||
it_behaves_like 'enables the flag for the actor', :project do
|
||||
let(:actor) { create(:project) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the project does not exist' do
|
||||
it_behaves_like 'does not enable the flag', :project do
|
||||
let(:actor_path) { 'mep/to/the/mep/mep' }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when enabling for a group by path' do
|
||||
context 'when the group exists' do
|
||||
it_behaves_like 'enables the flag for the actor', :group do
|
||||
let(:actor) { create(:group) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the group does not exist' do
|
||||
it_behaves_like 'does not enable the flag', :group do
|
||||
let(:actor_path) { 'not/a/group' }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when enabling for a namespace by path' do
|
||||
context 'when the user namespace exists' do
|
||||
it_behaves_like 'enables the flag for the actor', :namespace do
|
||||
let(:actor) { create(:namespace) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the group namespace exists' do
|
||||
it_behaves_like 'enables the flag for the actor', :namespace do
|
||||
let(:actor) { create(:group) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user namespace does not exist' do
|
||||
it_behaves_like 'does not enable the flag', :namespace do
|
||||
let(:actor_path) { 'not/a/group' }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a project namespace exists' do
|
||||
let(:project_namespace) { create(:project_namespace) }
|
||||
|
||||
it_behaves_like 'does not enable the flag', :namespace do
|
||||
let(:actor_path) { project_namespace.full_path }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when enabling for a repository by path' do
|
||||
context 'when the repository exists' do
|
||||
it_behaves_like 'enables the flag for the actor', :repository do
|
||||
let_it_be(:actor) { create(:project).repository }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the repository does not exist' do
|
||||
it_behaves_like 'does not enable the flag', :repository do
|
||||
let(:actor_path) { 'not/a/repository' }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple users' do
|
||||
let_it_be(:users) { create_list(:user, 3) }
|
||||
|
||||
it_behaves_like 'creates an enabled feature for the specified entries' do
|
||||
let(:gate_params) { { user: users.map(&:username).join(',') } }
|
||||
let(:expected_gate_params) { users.map(&:flipper_id) }
|
||||
end
|
||||
|
||||
context 'when empty value exists between comma' do
|
||||
it_behaves_like 'creates an enabled feature for the specified entries' do
|
||||
let(:gate_params) { { user: "#{users.first.username},,,," } }
|
||||
let(:expected_gate_params) { users.first.flipper_id }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when one of the users does not exist' do
|
||||
it_behaves_like 'does not enable the flag', :user do
|
||||
let(:actor_path) { "#{users.first.username},inexistent-entry" }
|
||||
let(:expected_inexistent_path) { "inexistent-entry" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple projects' do
|
||||
let_it_be(:projects) { create_list(:project, 3) }
|
||||
|
||||
it_behaves_like 'creates an enabled feature for the specified entries' do
|
||||
let(:gate_params) { { project: projects.map(&:full_path).join(',') } }
|
||||
let(:expected_gate_params) { projects.map(&:flipper_id) }
|
||||
end
|
||||
|
||||
context 'when empty value exists between comma' do
|
||||
it_behaves_like 'creates an enabled feature for the specified entries' do
|
||||
let(:gate_params) { { project: "#{projects.first.full_path},,,," } }
|
||||
let(:expected_gate_params) { projects.first.flipper_id }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when one of the projects does not exist' do
|
||||
it_behaves_like 'does not enable the flag', :project do
|
||||
let(:actor_path) { "#{projects.first.full_path},inexistent-entry" }
|
||||
let(:expected_inexistent_path) { "inexistent-entry" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple groups' do
|
||||
let_it_be(:groups) { create_list(:group, 3) }
|
||||
|
||||
it_behaves_like 'creates an enabled feature for the specified entries' do
|
||||
let(:gate_params) { { group: groups.map(&:full_path).join(',') } }
|
||||
let(:expected_gate_params) { groups.map(&:flipper_id) }
|
||||
end
|
||||
|
||||
context 'when empty value exists between comma' do
|
||||
it_behaves_like 'creates an enabled feature for the specified entries' do
|
||||
let(:gate_params) { { group: "#{groups.first.full_path},,,," } }
|
||||
let(:expected_gate_params) { groups.first.flipper_id }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when one of the groups does not exist' do
|
||||
it_behaves_like 'does not enable the flag', :group do
|
||||
let(:actor_path) { "#{groups.first.full_path},inexistent-entry" }
|
||||
let(:expected_inexistent_path) { "inexistent-entry" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple namespaces' do
|
||||
let_it_be(:namespaces) { create_list(:namespace, 3) }
|
||||
|
||||
it_behaves_like 'creates an enabled feature for the specified entries' do
|
||||
let(:gate_params) { { namespace: namespaces.map(&:full_path).join(',') } }
|
||||
let(:expected_gate_params) { namespaces.map(&:flipper_id) }
|
||||
end
|
||||
|
||||
context 'when empty value exists between comma' do
|
||||
it_behaves_like 'creates an enabled feature for the specified entries' do
|
||||
let(:gate_params) { { namespace: "#{namespaces.first.full_path},,,," } }
|
||||
let(:expected_gate_params) { namespaces.first.flipper_id }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when one of the namespaces does not exist' do
|
||||
it_behaves_like 'does not enable the flag', :namespace do
|
||||
let(:actor_path) { "#{namespaces.first.full_path},inexistent-entry" }
|
||||
let(:expected_inexistent_path) { "inexistent-entry" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple repository' do
|
||||
let_it_be(:projects) { create_list(:project, 3) }
|
||||
|
||||
it_behaves_like 'creates an enabled feature for the specified entries' do
|
||||
let(:gate_params) { { repository: projects.map { |p| p.repository.full_path }.join(',') } }
|
||||
let(:expected_gate_params) { projects.map { |p| p.repository.flipper_id } }
|
||||
end
|
||||
|
||||
context 'when empty value exists between comma' do
|
||||
it_behaves_like 'creates an enabled feature for the specified entries' do
|
||||
let(:gate_params) { { repository: "#{projects.first.repository.full_path},,,," } }
|
||||
let(:expected_gate_params) { projects.first.repository.flipper_id }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when one of the projects does not exist' do
|
||||
it_behaves_like 'does not enable the flag', :project do
|
||||
let(:actor_path) { "#{projects.first.repository.full_path},inexistent-entry" }
|
||||
let(:expected_inexistent_path) { "inexistent-entry" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates a feature with the given percentage of time if passed an integer' do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: '50' }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to match(
|
||||
'name' => feature_name,
|
||||
'state' => 'conditional',
|
||||
'gates' => [
|
||||
{ 'key' => 'boolean', 'value' => false },
|
||||
{ 'key' => 'percentage_of_time', 'value' => 50 }
|
||||
],
|
||||
'definition' => known_feature_flag_definition_hash
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates a feature with the given percentage of time if passed a float' do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: '0.01' }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to match(
|
||||
'name' => feature_name,
|
||||
'state' => 'conditional',
|
||||
'gates' => [
|
||||
{ 'key' => 'boolean', 'value' => false },
|
||||
{ 'key' => 'percentage_of_time', 'value' => 0.01 }
|
||||
],
|
||||
'definition' => known_feature_flag_definition_hash
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates a feature with the given percentage of actors if passed an integer' do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: '50', key: 'percentage_of_actors' }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to match(
|
||||
'name' => feature_name,
|
||||
'state' => 'conditional',
|
||||
'gates' => [
|
||||
{ 'key' => 'boolean', 'value' => false },
|
||||
{ 'key' => 'percentage_of_actors', 'value' => 50 }
|
||||
],
|
||||
'definition' => known_feature_flag_definition_hash
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates a feature with the given percentage of actors if passed a float' do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: '0.01', key: 'percentage_of_actors' }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to match(
|
||||
'name' => feature_name,
|
||||
'state' => 'conditional',
|
||||
'gates' => [
|
||||
{ 'key' => 'boolean', 'value' => false },
|
||||
{ 'key' => 'percentage_of_actors', 'value' => 0.01 }
|
||||
],
|
||||
'definition' => known_feature_flag_definition_hash
|
||||
)
|
||||
end
|
||||
|
||||
describe 'mutually exclusive parameters' do
|
||||
shared_examples 'fails to set the feature flag' do
|
||||
it 'returns an error' do
|
||||
expect(response).to have_gitlab_http_status(:bad_request)
|
||||
expect(json_response['error']).to match(/key, \w+ are mutually exclusive/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when key and feature_group are provided' do
|
||||
before do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: '0.01', key: 'percentage_of_actors', feature_group: 'some-value' }
|
||||
end
|
||||
|
||||
it_behaves_like 'fails to set the feature flag'
|
||||
end
|
||||
|
||||
context 'when key and user are provided' do
|
||||
before do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: '0.01', key: 'percentage_of_actors', user: 'some-user' }
|
||||
end
|
||||
|
||||
it_behaves_like 'fails to set the feature flag'
|
||||
end
|
||||
|
||||
context 'when key and group are provided' do
|
||||
before do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: '0.01', key: 'percentage_of_actors', group: 'somepath' }
|
||||
end
|
||||
|
||||
it_behaves_like 'fails to set the feature flag'
|
||||
end
|
||||
|
||||
context 'when key and namespace are provided' do
|
||||
before do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: '0.01', key: 'percentage_of_actors', namespace: 'somepath' }
|
||||
end
|
||||
|
||||
it_behaves_like 'fails to set the feature flag'
|
||||
end
|
||||
|
||||
context 'when key and project are provided' do
|
||||
before do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: '0.01', key: 'percentage_of_actors', project: 'somepath' }
|
||||
end
|
||||
|
||||
it_behaves_like 'fails to set the feature flag'
|
||||
end
|
||||
context 'when the service responds with any error' do
|
||||
before do
|
||||
allow_next_instance_of(Admin::SetFeatureFlagService) do |service|
|
||||
allow(service).to receive(:execute).and_return(ServiceResponse.error(message: 'error'))
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the feature exists' do
|
||||
it 'returns a 400 with the error message' do
|
||||
post api(path, admin, admin_mode: true), params: { value: 'true' }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:bad_request)
|
||||
expect(json_response).to eq({ 'message' => '400 Bad request - error' })
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'enables the flag for the actor' do |actor_type|
|
||||
it 'sets the feature gate' do
|
||||
post api(path, admin, admin_mode: true), params: { value: 'true', actor_type => actor.full_path }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to match(
|
||||
'name' => feature_name,
|
||||
'state' => 'conditional',
|
||||
'gates' => [
|
||||
{ 'key' => 'boolean', 'value' => false },
|
||||
{ 'key' => 'actors', 'value' => [actor.flipper_id] }
|
||||
],
|
||||
'definition' => known_feature_flag_definition_hash
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when enabling for a project by path' do
|
||||
it_behaves_like 'enables the flag for the actor', :project do
|
||||
let(:actor) { create(:project) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when enabling for a group by path' do
|
||||
it_behaves_like 'enables the flag for the actor', :group do
|
||||
let(:actor) { create(:group) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when enabling for a namespace by path' do
|
||||
it_behaves_like 'enables the flag for the actor', :namespace do
|
||||
let(:actor) { create(:namespace) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when enabling for a repository by path' do
|
||||
it_behaves_like 'enables the flag for the actor', :repository do
|
||||
let_it_be(:actor) { create(:project).repository }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the value argument is missing' do
|
||||
it 'returns a 400' do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true)
|
||||
|
||||
expect(response).to have_gitlab_http_status(:bad_request)
|
||||
expect(json_response).to eq('error' => 'value is missing')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'mutually exclusive parameters' do
|
||||
shared_examples 'fails to set the feature flag' do
|
||||
it 'returns an error' do
|
||||
expect(response).to have_gitlab_http_status(:bad_request)
|
||||
expect(json_response['error']).to match(/key, \w+ are mutually exclusive/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when key and feature_group are provided' do
|
||||
before do
|
||||
Feature.disable(feature_name) # This also persists the feature on the DB
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: '0.01', key: 'percentage_of_actors', feature_group: 'some-value' }
|
||||
end
|
||||
|
||||
context 'when passed value=true' do
|
||||
it 'enables the feature' do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: 'true' }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to match(
|
||||
'name' => feature_name,
|
||||
'state' => 'on',
|
||||
'gates' => [{ 'key' => 'boolean', 'value' => true }],
|
||||
'definition' => known_feature_flag_definition_hash
|
||||
)
|
||||
end
|
||||
|
||||
it 'enables the feature for the given Flipper group when passed feature_group=perf_team' do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: 'true', feature_group: 'perf_team' }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to match(
|
||||
'name' => feature_name,
|
||||
'state' => 'conditional',
|
||||
'gates' => [
|
||||
{ 'key' => 'boolean', 'value' => false },
|
||||
{ 'key' => 'groups', 'value' => ['perf_team'] }
|
||||
],
|
||||
'definition' => known_feature_flag_definition_hash
|
||||
)
|
||||
end
|
||||
|
||||
it 'enables the feature for the given user when passed user=username' do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: 'true', user: user.username }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to match(
|
||||
'name' => feature_name,
|
||||
'state' => 'conditional',
|
||||
'gates' => [
|
||||
{ 'key' => 'boolean', 'value' => false },
|
||||
{ 'key' => 'actors', 'value' => ["User:#{user.id}"] }
|
||||
],
|
||||
'definition' => known_feature_flag_definition_hash
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when feature is enabled and value=false is passed' do
|
||||
it 'disables the feature' do
|
||||
Feature.enable(feature_name)
|
||||
expect(Feature.enabled?(feature_name)).to eq(true)
|
||||
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: 'false' }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to match(
|
||||
'name' => feature_name,
|
||||
'state' => 'off',
|
||||
'gates' => [{ 'key' => 'boolean', 'value' => false }],
|
||||
'definition' => known_feature_flag_definition_hash
|
||||
)
|
||||
end
|
||||
|
||||
it 'disables the feature for the given Flipper group when passed feature_group=perf_team' do
|
||||
Feature.enable(feature_name, Feature.group(:perf_team))
|
||||
expect(Feature.enabled?(feature_name, admin)).to be_truthy
|
||||
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: 'false', feature_group: 'perf_team' }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to match(
|
||||
'name' => feature_name,
|
||||
'state' => 'off',
|
||||
'gates' => [{ 'key' => 'boolean', 'value' => false }],
|
||||
'definition' => known_feature_flag_definition_hash
|
||||
)
|
||||
end
|
||||
|
||||
it 'disables the feature for the given user when passed user=username' do
|
||||
Feature.enable(feature_name, user)
|
||||
expect(Feature.enabled?(feature_name, user)).to be_truthy
|
||||
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: 'false', user: user.username }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to match(
|
||||
'name' => feature_name,
|
||||
'state' => 'off',
|
||||
'gates' => [{ 'key' => 'boolean', 'value' => false }],
|
||||
'definition' => known_feature_flag_definition_hash
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a pre-existing percentage of time value' do
|
||||
before do
|
||||
Feature.enable_percentage_of_time(feature_name, 50)
|
||||
end
|
||||
|
||||
it 'updates the percentage of time if passed an integer' do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: '30' }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to match(
|
||||
'name' => feature_name,
|
||||
'state' => 'conditional',
|
||||
'gates' => [
|
||||
{ 'key' => 'boolean', 'value' => false },
|
||||
{ 'key' => 'percentage_of_time', 'value' => 30 }
|
||||
],
|
||||
'definition' => known_feature_flag_definition_hash
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a pre-existing percentage of actors value' do
|
||||
before do
|
||||
Feature.enable_percentage_of_actors(feature_name, 42)
|
||||
end
|
||||
|
||||
it 'updates the percentage of actors if passed an integer' do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: '74', key: 'percentage_of_actors' }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to match(
|
||||
'name' => feature_name,
|
||||
'state' => 'conditional',
|
||||
'gates' => [
|
||||
{ 'key' => 'boolean', 'value' => false },
|
||||
{ 'key' => 'percentage_of_actors', 'value' => 74 }
|
||||
],
|
||||
'definition' => known_feature_flag_definition_hash
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
stub_feature_flags(set_feature_flag_service: true)
|
||||
end
|
||||
|
||||
it_behaves_like 'sets the feature flag status'
|
||||
|
||||
it 'opts given actors out' do
|
||||
Feature.enable(feature_name)
|
||||
expect(Feature.enabled?(feature_name, user)).to be_truthy
|
||||
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: 'opt_out', user: user.username }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(json_response).to include(
|
||||
'name' => feature_name,
|
||||
'state' => 'on',
|
||||
'gates' => [
|
||||
{ 'key' => 'boolean', 'value' => true },
|
||||
{ 'key' => 'actors', 'value' => ["#{user.flipper_id}:opt_out"] }
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
context 'when the actor has opted-out' do
|
||||
before do
|
||||
Feature.enable(feature_name)
|
||||
Feature.opt_out(feature_name, user)
|
||||
it_behaves_like 'fails to set the feature flag'
|
||||
end
|
||||
|
||||
it 'refuses to enable the feature' do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: 'true', user: user.username }
|
||||
context 'when key and user are provided' do
|
||||
before do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: '0.01', key: 'percentage_of_actors', user: 'some-user' }
|
||||
end
|
||||
|
||||
expect(Feature).not_to be_enabled(feature_name, user)
|
||||
|
||||
expect(response).to have_gitlab_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when feature flag set_feature_flag_service is disabled' do
|
||||
before do
|
||||
stub_feature_flags(set_feature_flag_service: false)
|
||||
it_behaves_like 'fails to set the feature flag'
|
||||
end
|
||||
|
||||
it_behaves_like 'sets the feature flag status'
|
||||
context 'when key and group are provided' do
|
||||
before do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: '0.01', key: 'percentage_of_actors', group: 'somepath' }
|
||||
end
|
||||
|
||||
it 'rejects opt_out requests' do
|
||||
Feature.enable(feature_name)
|
||||
expect(Feature).to be_enabled(feature_name, user)
|
||||
it_behaves_like 'fails to set the feature flag'
|
||||
end
|
||||
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: 'opt_out', user: user.username }
|
||||
context 'when key and namespace are provided' do
|
||||
before do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: '0.01', key: 'percentage_of_actors', namespace: 'somepath' }
|
||||
end
|
||||
|
||||
expect(response).to have_gitlab_http_status(:bad_request)
|
||||
it_behaves_like 'fails to set the feature flag'
|
||||
end
|
||||
|
||||
context 'when key and project are provided' do
|
||||
before do
|
||||
post api("/features/#{feature_name}", admin, admin_mode: true), params: { value: '0.01', key: 'percentage_of_actors', project: 'somepath' }
|
||||
end
|
||||
|
||||
it_behaves_like 'fails to set the feature flag'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -718,16 +227,10 @@ RSpec.describe API::Features, :clean_gitlab_redis_feature_flag, stub_feature_fla
|
|||
|
||||
context 'when the user has no access' do
|
||||
it 'returns a 401 for anonymous users' do
|
||||
delete api("/features/#{feature_name}")
|
||||
delete api(path)
|
||||
|
||||
expect(response).to have_gitlab_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns a 403 for users' do
|
||||
delete api("/features/#{feature_name}", user)
|
||||
|
||||
expect(response).to have_gitlab_http_status(:forbidden)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user has access' do
|
||||
|
|
|
|||
|
|
@ -68,29 +68,6 @@ RSpec.describe Projects::GoogleCloud::ConfigurationController, feature_category:
|
|||
end
|
||||
end
|
||||
|
||||
context 'but feature flag is disabled' do
|
||||
before do
|
||||
stub_feature_flags(incubation_5mp_google_cloud: false)
|
||||
end
|
||||
|
||||
it 'returns not found' do
|
||||
authorized_members.each do |authorized_member|
|
||||
sign_in(authorized_member)
|
||||
|
||||
get url
|
||||
|
||||
expect(response).to have_gitlab_http_status(:not_found)
|
||||
expect_snowplow_event(
|
||||
category: 'Projects::GoogleCloud::ConfigurationController',
|
||||
action: 'error_feature_flag_not_enabled',
|
||||
label: nil,
|
||||
project: project,
|
||||
user: authorized_member
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'but google oauth2 token is not valid' do
|
||||
it 'does not return revoke oauth url' do
|
||||
allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |client|
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ RSpec.describe Projects::GoogleCloud::DatabasesController, :snowplow, feature_ca
|
|||
shared_examples 'shared examples for database controller endpoints' do
|
||||
include_examples 'requires `admin_project_google_cloud` role'
|
||||
|
||||
include_examples 'requires feature flag `incubation_5mp_google_cloud` enabled'
|
||||
|
||||
include_examples 'requires valid Google OAuth2 configuration'
|
||||
|
||||
include_examples 'requires valid Google Oauth2 token' do
|
||||
|
|
|
|||
|
|
@ -35,19 +35,6 @@ RSpec.describe Projects::GoogleCloud::GcpRegionsController, feature_category: :d
|
|||
end
|
||||
end
|
||||
|
||||
RSpec.shared_examples "should track feature_flag_disabled event" do |user|
|
||||
it "tracks event" do
|
||||
is_expected.to be(404)
|
||||
expect_snowplow_event(
|
||||
category: 'Projects::GoogleCloud::GcpRegionsController',
|
||||
action: 'error_feature_flag_not_enabled',
|
||||
label: nil,
|
||||
project: project,
|
||||
user: user_maintainer
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.shared_examples "should track gcp_error event" do |config|
|
||||
it "tracks event" do
|
||||
is_expected.to be(403)
|
||||
|
|
@ -116,15 +103,6 @@ RSpec.describe Projects::GoogleCloud::GcpRegionsController, feature_category: :d
|
|||
it_behaves_like "should be forbidden"
|
||||
it_behaves_like "should track gcp_error event", unconfigured_google_oauth2
|
||||
end
|
||||
|
||||
context 'but feature flag is disabled' do
|
||||
before do
|
||||
stub_feature_flags(incubation_5mp_google_cloud: false)
|
||||
end
|
||||
|
||||
it_behaves_like "should be not found"
|
||||
it_behaves_like "should track feature_flag_disabled event"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -250,22 +250,6 @@ RSpec.describe Projects::GoogleCloud::ServiceAccountsController, feature_categor
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'but feature flag is disabled' do
|
||||
before do
|
||||
stub_feature_flags(incubation_5mp_google_cloud: false)
|
||||
end
|
||||
|
||||
it 'returns not found' do
|
||||
authorized_members.each do |authorized_member|
|
||||
sign_in(authorized_member)
|
||||
|
||||
get url
|
||||
|
||||
expect(response).to have_gitlab_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -276,6 +276,17 @@ RSpec.describe Admin::SetFeatureFlagService, feature_category: :feature_flags do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when enabling for a project namespace' do
|
||||
let(:project_namespace) { create(:project_namespace) }
|
||||
let(:params) { { value: 'true', namespace: project_namespace.full_path } }
|
||||
|
||||
it 'returns an error' do
|
||||
expect(Feature).not_to receive(:disable)
|
||||
expect(subject).to be_error
|
||||
expect(subject.reason).to eq(:actor_not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when enabling for a user namespace' do
|
||||
let(:namespace) { user.namespace }
|
||||
let(:params) { { value: 'true', namespace: namespace.full_path } }
|
||||
|
|
@ -325,6 +336,41 @@ RSpec.describe Admin::SetFeatureFlagService, feature_category: :feature_flags do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when enabling for multiple actors' do
|
||||
let_it_be(:actor1) { group }
|
||||
let_it_be(:actor2) { create(:group) }
|
||||
|
||||
context 'when passed as comma separated string' do
|
||||
let(:params) { { value: 'true', group: "#{actor1.full_path},#{actor2.full_path}" } }
|
||||
|
||||
it 'enables the feature flag for all actors' do
|
||||
expect(Feature).to receive(:enable).with(feature_name, actor1)
|
||||
expect(Feature).to receive(:enable).with(feature_name, actor2)
|
||||
expect(subject).to be_success
|
||||
end
|
||||
end
|
||||
|
||||
context 'when empty value exists between comma' do
|
||||
let(:params) { { value: 'true', group: "#{actor1.full_path},#{actor2.full_path},,," } }
|
||||
|
||||
it 'enables the feature flag for all actors' do
|
||||
expect(Feature).to receive(:enable).with(feature_name, actor1)
|
||||
expect(Feature).to receive(:enable).with(feature_name, actor2)
|
||||
expect(subject).to be_success
|
||||
end
|
||||
end
|
||||
|
||||
context 'when one of the actors does not exist' do
|
||||
let(:params) { { value: 'true', group: "#{actor1.full_path},nonexistent-actor" } }
|
||||
|
||||
it 'does not enable the feature flags' do
|
||||
expect(Feature).not_to receive(:enable)
|
||||
expect(subject).to be_error
|
||||
expect(subject.message).to eq('nonexistent-actor is not found!')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when enabling given a percentage of time' do
|
||||
let(:params) { { value: '50' } }
|
||||
|
||||
|
|
@ -439,6 +485,16 @@ RSpec.describe Admin::SetFeatureFlagService, feature_category: :feature_flags do
|
|||
expect(Feature).to receive(:disable).with(feature_name, project)
|
||||
expect(subject).to be_success
|
||||
end
|
||||
|
||||
context 'when project does not exist' do
|
||||
let(:params) { { value: 'false', project: 'unknown-project' } }
|
||||
|
||||
it 'returns an error' do
|
||||
expect(Feature).not_to receive(:disable)
|
||||
expect(subject).to be_error
|
||||
expect(subject.reason).to eq(:actor_not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when disabling for a group' do
|
||||
|
|
|
|||
|
|
@ -255,19 +255,19 @@ RSpec.describe ApplicationSettings::UpdateService, feature_category: :shared do
|
|||
end
|
||||
|
||||
it 'does not validate labels if external authorization gets disabled' do
|
||||
expect_any_instance_of(described_class).not_to receive(:validate_classification_label)
|
||||
expect_any_instance_of(described_class).not_to receive(:validate_classification_label_param!)
|
||||
|
||||
described_class.new(application_settings, admin, { external_authorization_service_enabled: false }).execute
|
||||
end
|
||||
|
||||
it 'does validate labels if external authorization gets enabled' do
|
||||
expect_any_instance_of(described_class).to receive(:validate_classification_label)
|
||||
expect_any_instance_of(described_class).to receive(:validate_classification_label_param!)
|
||||
|
||||
described_class.new(application_settings, admin, { external_authorization_service_enabled: true }).execute
|
||||
end
|
||||
|
||||
it 'does validate labels if external authorization is left unchanged' do
|
||||
expect_any_instance_of(described_class).to receive(:validate_classification_label)
|
||||
expect_any_instance_of(described_class).to receive(:validate_classification_label_param!)
|
||||
|
||||
described_class.new(application_settings, admin, { external_authorization_service_default_label: 'new-label' }).execute
|
||||
end
|
||||
|
|
|
|||
|
|
@ -788,6 +788,8 @@ RSpec.describe Projects::UpdateService, feature_category: :groups_and_projects d
|
|||
.to receive(:access_allowed?).with(user, 'default_label') { true }
|
||||
|
||||
update_project(project, user, { external_authorization_classification_label: '' })
|
||||
|
||||
expect(project.reload.external_authorization_classification_label).to eq('default_label')
|
||||
end
|
||||
|
||||
it 'does not check the label when it does not change' do
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.shared_examples 'rewrites references correctly' do
|
||||
let(:noteable) { source_parent.work_items.first }
|
||||
|
||||
let(:note_params) do
|
||||
case source_parent
|
||||
when ::Group
|
||||
{ namespace: source_parent, project: nil }
|
||||
when ::Project
|
||||
{ project: source_parent }
|
||||
when ::Namespaces::ProjectNamespace
|
||||
{ project: source_parent.project }
|
||||
end
|
||||
end
|
||||
|
||||
let(:note) { create(:note, note: source_text, noteable: noteable, **note_params) }
|
||||
|
||||
it 'checks source and target markdown text', :aggregate_failures do
|
||||
new_text = described_class.new(note.note, note.note_html, source_parent, user).rewrite(target_parent)
|
||||
source_text_html = generate_html_from_markdown(source_text, source_parent)
|
||||
target_text_html = generate_html_from_markdown(destination_text, target_parent)
|
||||
source_referable = find_referable(source_text, source_parent, user)
|
||||
target_referable = find_referable(new_text, source_parent, user)
|
||||
|
||||
expect(new_text).to eq(destination_text)
|
||||
|
||||
# this checks that the rendered html actually did render, in contrary the result html would look smth like:
|
||||
# <p dir="auto">ref #1</p> without an actual link to the referenced object
|
||||
expect(source_text_html).to include("href=")
|
||||
expect(target_text_html).to include("href=")
|
||||
expect(referable_href(source_text_html)).to eq(referable_href(target_text_html))
|
||||
|
||||
# validate that expected referable can be extracted from source and destination texts
|
||||
expect(source_referable.id).to eq(target_referable.id)
|
||||
|
||||
# test rewriter with target as project namespace
|
||||
if target_parent.is_a?(Project)
|
||||
project_namespace = target_parent.project_namespace
|
||||
new_text = described_class.new(note.note, note.note_html, source_parent, user).rewrite(project_namespace)
|
||||
|
||||
expect(new_text).to eq(destination_text)
|
||||
end
|
||||
|
||||
# test rewriter with source as project namespace
|
||||
if source_parent.is_a?(Project)
|
||||
project_namespace = source_parent.project_namespace
|
||||
new_text = described_class.new(note.note, note.note_html, project_namespace, user).rewrite(target_parent)
|
||||
|
||||
expect(new_text).to eq(destination_text)
|
||||
end
|
||||
|
||||
# test rewriter with source and target as project namespace
|
||||
if target_parent.is_a?(Project) && source_parent.is_a?(Project)
|
||||
target_namespace = target_parent.project_namespace
|
||||
source_namespace = source_parent.project_namespace
|
||||
new_text = described_class.new(note.note, note.note_html, source_namespace, user).rewrite(target_namespace)
|
||||
|
||||
expect(new_text).to eq(destination_text)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_html_from_markdown(text, parent)
|
||||
Banzai.render(text, **parent_argument(parent), no_original_data: true, no_sourcepos: true)
|
||||
end
|
||||
|
||||
def parent_argument(parent)
|
||||
case parent
|
||||
when Project
|
||||
{ project: parent }
|
||||
when Group
|
||||
{ group: parent, project: nil }
|
||||
when Namespaces::ProjectNamespace
|
||||
{ project: parent.project }
|
||||
end
|
||||
end
|
||||
|
||||
def find_referable(reference, parent, user)
|
||||
extractor = Gitlab::ReferenceExtractor.new(parent_argument(parent)[:project], user)
|
||||
extractor.analyze(reference, **parent_argument(parent))
|
||||
extractor.all.first
|
||||
end
|
||||
|
||||
def referable_href(text_html)
|
||||
css = 'a'
|
||||
xpath = Gitlab::Utils::Nokogiri.css_to_xpath(css)
|
||||
Nokogiri::HTML::DocumentFragment.parse(text_html).xpath(xpath).first.attribute('href').value
|
||||
end
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.shared_examples 'requires feature flag `incubation_5mp_google_cloud` enabled' do
|
||||
context 'when feature flag is disabled' do
|
||||
before do
|
||||
project.add_maintainer(user)
|
||||
stub_feature_flags(incubation_5mp_google_cloud: false)
|
||||
end
|
||||
|
||||
it 'renders not found' do
|
||||
sign_in(user)
|
||||
|
||||
subject
|
||||
|
||||
expect(response).to have_gitlab_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -62,8 +62,6 @@ RSpec.shared_examples 'cloneable and moveable work item' do
|
|||
end
|
||||
|
||||
RSpec.shared_examples 'cloneable and moveable widget data' do
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
def work_item_assignees(work_item)
|
||||
work_item.reload.assignees
|
||||
end
|
||||
|
|
@ -175,79 +173,60 @@ RSpec.shared_examples 'cloneable and moveable widget data' do
|
|||
timelogs.pluck(:user_id, :time_spent)
|
||||
end
|
||||
|
||||
where(:widget_name, :eval_value, :expected_data, :operations) do
|
||||
:assignees | :work_item_assignees | ref(:assignees) | [ref(:move), ref(:clone)]
|
||||
:award_emoji | :work_item_award_emoji | ref(:award_emojis) | [ref(:move)]
|
||||
:email_participants | :work_item_emails | ref(:emails) | [ref(:move)]
|
||||
:milestone | :work_item_milestone | ref(:milestone) | [ref(:move), ref(:clone)]
|
||||
:subscriptions | :work_item_subscriptions | ref(:subscriptions) | [ref(:move)]
|
||||
:sent_notifications | :work_item_sent_notifications | ref(:notifications) | [ref(:move)]
|
||||
:timelogs | :work_item_timelogs | ref(:timelogs) | [ref(:move)]
|
||||
:customer_relations_contacts | :work_item_crm_contacts | ref(:crm_contacts) | [ref(:move), ref(:clone)]
|
||||
let_it_be(:move) { WorkItems::DataSync::MoveService }
|
||||
let_it_be(:clone) { WorkItems::DataSync::CloneService }
|
||||
|
||||
# rubocop: disable Layout/LineLength -- improved readability with one line per widget
|
||||
let_it_be(:widgets) do
|
||||
[
|
||||
{ widget_name: :assignees, eval_value: :work_item_assignees, expected_data: assignees, operations: [move, clone] },
|
||||
{ widget_name: :award_emoji, eval_value: :work_item_award_emoji, expected_data: award_emojis, operations: [move] },
|
||||
{ widget_name: :email_participants, eval_value: :work_item_emails, expected_data: emails, operations: [move] },
|
||||
{ widget_name: :milestone, eval_value: :work_item_milestone, expected_data: milestone, operations: [move, clone] },
|
||||
{ widget_name: :subscriptions, eval_value: :work_item_subscriptions, expected_data: subscriptions, operations: [move] },
|
||||
{ widget_name: :sent_notifications, eval_value: :work_item_sent_notifications, expected_data: notifications, operations: [move] },
|
||||
{ widget_name: :timelogs, eval_value: :work_item_timelogs, expected_data: timelogs, operations: [move] },
|
||||
{ widget_name: :customer_relations_contacts, eval_value: :work_item_crm_contacts, expected_data: crm_contacts, operations: [move, clone] }
|
||||
]
|
||||
end
|
||||
# rubocop: enable Layout/LineLength
|
||||
|
||||
with_them do
|
||||
context "with widget" do
|
||||
before do
|
||||
allow(original_work_item).to receive(:from_service_desk?).and_return(true)
|
||||
allow(WorkItems::CopyTimelogsWorker).to receive(:perform_async) do |*args|
|
||||
WorkItems::CopyTimelogsWorker.perform_inline(*args)
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'for clone and move services'
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.shared_examples 'cloneable and moveable for ee widget data' do
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
def work_item_weights_source(work_item)
|
||||
work_item.reload.weights_source&.slice(:rolled_up_weight, :rolled_up_completed_weight)
|
||||
end
|
||||
|
||||
let_it_be(:weights_source) do
|
||||
weights_source = create(:work_item_weights_source, work_item: original_work_item, rolled_up_weight: 20,
|
||||
rolled_up_completed_weight: 50)
|
||||
weights_source&.slice(:rolled_up_weight, :rolled_up_completed_weight)
|
||||
end
|
||||
|
||||
where(:widget_name, :eval_value, :expected_data, :operations) do
|
||||
:weights_source | :work_item_weights_source | ref(:weights_source) | [ref(:move), ref(:clone)]
|
||||
end
|
||||
|
||||
with_them do
|
||||
context "with widget" do
|
||||
it_behaves_like 'for clone and move services'
|
||||
context "with widget" do
|
||||
before do
|
||||
allow(original_work_item).to receive(:from_service_desk?).and_return(true)
|
||||
allow(WorkItems::CopyTimelogsWorker).to receive(:perform_async) do |*args|
|
||||
WorkItems::CopyTimelogsWorker.perform_inline(*args)
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'for clone and move services'
|
||||
end
|
||||
end
|
||||
|
||||
# this shared context is only to be used for sharing the code beetween the shared examples for cloneable and movable
|
||||
# this shared example is only to be used for sharing the code between the shared examples for cloneable and movable
|
||||
# widget data (and for EE widget data)
|
||||
RSpec.shared_context 'for clone and move services' do
|
||||
let(:move) { WorkItems::DataSync::MoveService }
|
||||
let(:clone) { WorkItems::DataSync::CloneService }
|
||||
|
||||
it 'clones and moves the data', :aggregate_failures do
|
||||
RSpec.shared_examples 'for clone and move services' do
|
||||
it 'clones and moves the data', :aggregate_failures, :sidekiq_inline do
|
||||
new_work_item = service.execute[:work_item]
|
||||
widget_value = send(eval_value, new_work_item)
|
||||
|
||||
if operations.include?(described_class)
|
||||
expect(widget_value).not_to be_blank
|
||||
# trick to compare single values and arrays with a single statement
|
||||
expect([widget_value].flatten).to match_array([expected_data].flatten)
|
||||
else
|
||||
expect(widget_value).to be_blank
|
||||
end
|
||||
widgets.each do |widget|
|
||||
widget_value = send(widget[:eval_value], new_work_item)
|
||||
|
||||
cleanup_data = Feature.enabled?(:cleanup_data_source_work_item_data, original_work_item.resource_parent)
|
||||
if cleanup_data && described_class == move
|
||||
expect(original_work_item.reload.public_send(widget_name)).to be_blank
|
||||
elsif widget_name != :sent_notifications
|
||||
# sent notifications are moved from original work item to new work item rather than deleted afterwards.
|
||||
expect(original_work_item.reload.public_send(widget_name)).not_to be_blank
|
||||
if widget[:operations].include?(described_class)
|
||||
expect(widget_value).not_to be_blank
|
||||
# trick to compare single values and arrays with a single statement
|
||||
expect([widget_value].flatten).to match_array([widget[:expected_data]].flatten)
|
||||
else
|
||||
expect(widget_value).to be_blank
|
||||
end
|
||||
|
||||
cleanup_data = Feature.enabled?(:cleanup_data_source_work_item_data, original_work_item.resource_parent)
|
||||
if cleanup_data && described_class == move
|
||||
expect(original_work_item.reload.public_send(widget[:widget_name])).to be_blank
|
||||
elsif widget[:widget_name] != :sent_notifications
|
||||
# sent notifications are moved from original work item to new work item rather than deleted afterwards.
|
||||
expect(original_work_item.reload.public_send(widget[:widget_name])).not_to be_blank
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in New Issue