Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-12-16 12:32:07 +00:00
parent 0a367a4a1b
commit 53cb6812f4
88 changed files with 1937 additions and 1198 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +1 @@
9aea54d16acab5377a7e0488411f6fb066788570
555d6e8568136f22dfe61dd3bdc58979e1097a3c

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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`**:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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