Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-03-26 12:09:15 +00:00
parent 26bba9525d
commit c0dd450008
62 changed files with 515 additions and 129 deletions

View File

@ -21,6 +21,8 @@ import {
import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql';
export const i18n = {
deleteIntegration: s__('AlertSettings|Delete integration'),
editIntegration: s__('AlertSettings|Edit integration'),
title: s__('AlertsIntegrations|Current integrations'),
emptyState: s__('AlertsIntegrations|No integrations have been added yet'),
status: {
@ -174,11 +176,16 @@ export default {
<template #cell(actions)="{ item }">
<gl-button-group class="gl-ml-3">
<gl-button icon="settings" @click="editIntegration(item)" />
<gl-button
icon="settings"
:aria-label="$options.i18n.editIntegration"
@click="editIntegration(item)"
/>
<gl-button
v-gl-modal.deleteIntegration
:disabled="item.type === $options.typeSet.prometheus"
icon="remove"
:aria-label="$options.i18n.deleteIntegration"
@click="setIntegrationToDelete(item)"
/>
</gl-button-group>
@ -198,8 +205,8 @@ export default {
</gl-table>
<gl-modal
modal-id="deleteIntegration"
:title="s__('AlertSettings|Delete integration')"
:ok-title="s__('AlertSettings|Delete integration')"
:title="$options.i18n.deleteIntegration"
:ok-title="$options.i18n.deleteIntegration"
ok-variant="danger"
@ok="deleteIntegration"
>

View File

@ -44,7 +44,7 @@ const Api = {
projectMilestonesPath: '/api/:version/projects/:id/milestones',
projectIssuePath: '/api/:version/projects/:id/issues/:issue_iid',
mergeRequestsPath: '/api/:version/merge_requests',
groupLabelsPath: '/groups/:namespace_path/-/labels',
groupLabelsPath: '/api/:version/groups/:namespace_path/labels',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
issuableTemplatesPath: '/:namespace_path/:project_path/templates/:type',
projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key',
@ -402,18 +402,29 @@ const Api = {
newLabel(namespacePath, projectPath, data, callback) {
let url;
let payload;
if (projectPath) {
url = Api.buildUrl(Api.projectLabelsPath)
.replace(':namespace_path', namespacePath)
.replace(':project_path', projectPath);
payload = {
label: data,
};
} else {
url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespacePath);
// groupLabelsPath uses public API which accepts
// `name` and `color` props.
payload = {
name: data.title,
color: data.color,
};
}
return axios
.post(url, {
label: data,
...payload,
})
.then((res) => callback(res.data))
.catch((e) => callback(e.response.data));

View File

@ -1,7 +1,11 @@
<script>
import { GlLoadingIcon, GlTooltipDirective, GlIcon, GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
i18n: {
buttonLabel: s__('Badges|Reload badge image'),
},
// name: 'Badge' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'Badge',
@ -94,7 +98,8 @@ export default {
<gl-button
v-show="hasError"
v-gl-tooltip.hover
:title="s__('Badges|Reload badge image')"
:title="$options.i18n.buttonLabel"
:aria-label="$options.i18n.buttonLabel"
category="tertiary"
variant="confirm"
type="button"

View File

@ -163,6 +163,9 @@ export default {
currentMutation() {
return this.board.id ? updateBoardMutation : createBoardMutation;
},
deleteMutation() {
return destroyBoardMutation;
},
baseMutationVariables() {
const { board } = this;
const variables = {
@ -244,17 +247,20 @@ export default {
return this.boardUpdateResponse(response.data);
},
async deleteBoard() {
await this.$apollo.mutate({
mutation: this.deleteMutation,
variables: {
id: fullBoardId(this.board.id),
},
});
},
async submit() {
if (this.board.name.length === 0) return;
this.isLoading = true;
if (this.isDeleteForm) {
try {
await this.$apollo.mutate({
mutation: destroyBoardMutation,
variables: {
id: fullBoardId(this.board.id),
},
});
await this.deleteBoard();
visitUrl(this.rootPath);
} catch {
Flash(this.$options.i18n.deleteErrorMessage);

View File

@ -47,6 +47,7 @@ export default {
class="js-focus-mode-btn"
data-qa-selector="focus_mode_button"
:title="$options.i18n.toggleFocusMode"
:aria-label="$options.i18n.toggleFocusMode"
@click="toggleFocusMode"
/>
</div>

View File

@ -7,6 +7,10 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
i18n: {
editButton: s__('Pipelines|Edit'),
revokeButton: s__('Pipelines|Revoke'),
},
components: {
GlTable,
GlButton,
@ -108,13 +112,15 @@ export default {
</template>
<template #cell(actions)="{ item }">
<gl-button
:title="s__('Pipelines|Edit')"
:title="$options.i18n.editButton"
:aria-label="$options.i18n.editButton"
icon="pencil"
data-testid="edit-btn"
:href="item.editProjectTriggerPath"
/>
<gl-button
:title="s__('Pipelines|Revoke')"
:title="$options.i18n.revokeButton"
:aria-label="$options.i18n.revokeButton"
icon="remove"
variant="warning"
:data-confirm="

View File

@ -2,6 +2,7 @@
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import $ from 'jquery';
import { debounce } from 'lodash';
import { isObject } from '~/lib/utils/type_utility';
const BLUR_KEYCODES = [27, 40];
@ -11,13 +12,21 @@ const HAS_VALUE_CLASS = 'has-value';
export class GitLabDropdownFilter {
constructor(input, options) {
let ref;
let timeout;
this.input = input;
this.options = options;
// eslint-disable-next-line no-cond-assign
this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true;
const $inputContainer = this.input.parent();
const $clearButton = $inputContainer.find('.js-dropdown-input-clear');
const filterRemoteDebounced = debounce(() => {
$inputContainer.parent().addClass('is-loading');
return this.options.query(this.input.val(), (data) => {
$inputContainer.parent().removeClass('is-loading');
return this.options.callback(data);
});
}, 500);
$clearButton.on('click', (e) => {
// Clear click
e.preventDefault();
@ -25,7 +34,6 @@ export class GitLabDropdownFilter {
return this.input.val('').trigger('input').focus();
});
// Key events
timeout = '';
this.input
.on('keydown', (e) => {
const keyCode = e.which;
@ -41,16 +49,7 @@ export class GitLabDropdownFilter {
}
// Only filter asynchronously only if option remote is set
if (this.options.remote) {
clearTimeout(timeout);
// eslint-disable-next-line no-return-assign
return (timeout = setTimeout(() => {
$inputContainer.parent().addClass('is-loading');
return this.options.query(this.input.val(), (data) => {
$inputContainer.parent().removeClass('is-loading');
return this.options.callback(data);
});
}, 250));
return filterRemoteDebounced();
}
return this.filter(this.input.val());
});

View File

@ -12,6 +12,10 @@ import allDesignsMixin from '../../mixins/all_designs';
import { DESIGN_ROUTE_NAME } from '../../router/constants';
export default {
i18n: {
nextButton: s__('DesignManagement|Go to next design'),
previousButton: s__('DesignManagement|Go to previous design'),
},
components: {
GlButton,
GlButtonGroup,
@ -81,7 +85,8 @@ export default {
<gl-button
v-gl-tooltip.bottom
:disabled="!previousDesign"
:title="s__('DesignManagement|Go to previous design')"
:title="$options.i18n.previousButton"
:aria-label="$options.i18n.previousButton"
icon="angle-left"
class="js-previous-design"
@click="navigateToDesign(previousDesign)"
@ -89,7 +94,8 @@ export default {
<gl-button
v-gl-tooltip.bottom
:disabled="!nextDesign"
:title="s__('DesignManagement|Go to next design')"
:title="$options.i18n.nextButton"
:aria-label="$options.i18n.nextButton"
icon="angle-right"
class="js-next-design"
@click="navigateToDesign(nextDesign)"

View File

@ -84,6 +84,7 @@ export default {
icon="file-tree"
class="gl-mr-3 js-toggle-tree-list"
:title="toggleFileBrowserTitle"
:aria-label="toggleFileBrowserTitle"
:selected="showTreeList"
@click="setShowTreeList({ showTreeList: !showTreeList })"
/>

View File

@ -71,6 +71,7 @@ export default {
class="gl-display-none gl-md-display-block text-secondary"
:loading="isLoading"
:title="title"
:aria-label="title"
:icon="isLastDeployment ? 'repeat' : 'redo'"
@click="onClick"
/>

View File

@ -14,6 +14,9 @@ import { sprintf, __ } from '~/locale';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
export default {
i18n: {
sendEmail: __('Send email'),
},
name: 'IssuableByEmail',
components: {
GlButton,
@ -116,7 +119,8 @@ export default {
<gl-button
v-gl-tooltip.hover
:href="mailToLink"
:title="__('Send email')"
:title="$options.i18n.sendEmail"
:aria-label="$options.i18n.sendEmail"
icon="mail"
data-testid="mail-to-btn"
/>

View File

@ -6,8 +6,12 @@ import {
GlTooltipDirective,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import { __ } from '~/locale';
export default {
i18n: {
editTitleAndDescription: __('Edit title and description'),
},
components: {
GlIcon,
GlButton,
@ -58,7 +62,8 @@ export default {
<gl-button
v-if="enableEdit"
v-gl-tooltip.bottom
:title="__('Edit title and description')"
:title="$options.i18n.editTitleAndDescription"
:aria-label="$options.i18n.editTitleAndDescription"
icon="pencil"
class="btn-edit js-issuable-edit qa-edit-button"
@click="$emit('edit-issuable', $event)"

View File

@ -1,9 +1,13 @@
<script>
import { GlButton, GlTooltipDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { __ } from '~/locale';
import eventHub from '../event_hub';
import animateMixin from '../mixins/animate';
export default {
i18n: {
editTitleAndDescription: __('Edit title and description'),
},
components: {
GlButton,
},
@ -78,7 +82,8 @@ export default {
v-gl-tooltip.bottom
icon="pencil"
class="btn-edit js-issuable-edit qa-edit-button"
title="Edit title and description"
:title="$options.i18n.editTitleAndDescription"
:aria-label="$options.i18n.editTitleAndDescription"
@click="edit"
/>
</div>

View File

@ -14,6 +14,7 @@ import { debounce } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import invalidUrl from '~/lib/utils/invalid_url';
import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import { timeRanges } from '~/vue_shared/constants';
@ -24,6 +25,9 @@ import DashboardsDropdown from './dashboards_dropdown.vue';
import RefreshButton from './refresh_button.vue';
export default {
i18n: {
metricsSettings: s__('Metrics|Metrics Settings'),
},
components: {
GlIcon,
GlButton,
@ -282,7 +286,8 @@ export default {
data-testid="metrics-settings-button"
icon="settings"
:href="operationsSettingsPath"
:title="s__('Metrics|Metrics Settings')"
:title="$options.i18n.metricsSettings"
:aria-label="$options.i18n.metricsSettings"
/>
</div>
</template>

View File

@ -9,7 +9,7 @@ import {
} from '@gitlab/ui';
import Visibility from 'visibilityjs';
import { mapActions } from 'vuex';
import { n__, __ } from '~/locale';
import { n__, __, s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@ -45,6 +45,9 @@ const makeInterval = (length = 0, unit = 's') => {
};
export default {
i18n: {
refreshDashboard: s__('Metrics|Refresh dashboard'),
},
components: {
GlButtonGroup,
GlButton,
@ -148,7 +151,8 @@ export default {
v-gl-tooltip
class="gl-flex-grow-1"
variant="default"
:title="s__('Metrics|Refresh dashboard')"
:title="$options.i18n.refreshDashboard"
:aria-label="$options.i18n.refreshDashboard"
icon="retry"
@click="refresh"
/>

View File

@ -1,7 +1,11 @@
<script>
import { GlTooltipDirective, GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
i18n: {
buttonLabel: s__('MergeRequests|Resolve this thread in a new issue'),
},
name: 'ResolveWithIssueButton',
components: {
GlButton,
@ -23,7 +27,8 @@ export default {
<gl-button
v-gl-tooltip
:href="url"
:title="s__('MergeRequests|Resolve this thread in a new issue')"
:title="$options.i18n.buttonLabel"
:aria-label="$options.i18n.buttonLabel"
class="new-issue-for-discussion discussion-create-issue-btn"
icon="issue-new"
/>

View File

@ -49,18 +49,17 @@ export default {
</script>
<template>
<div class="gl-mr-3 gl-display-inline-block gl-vertical-align-bottom full-width-mobile">
<div
data-testid="sort-discussion-filter"
class="gl-mr-3 gl-display-inline-block gl-vertical-align-bottom full-width-mobile"
>
<local-storage-sync
:value="sortDirection"
:storage-key="storageKey"
:persist="persistSortOrder"
@input="setDiscussionSortDirection({ direction: $event })"
/>
<gl-dropdown
:text="dropdownText"
data-testid="sort-discussion-filter"
class="js-dropdown-text full-width-mobile"
>
<gl-dropdown :text="dropdownText" class="js-dropdown-text full-width-mobile">
<gl-dropdown-item
v-for="{ text, key, cls } in $options.SORT_OPTIONS"
:key="key"

View File

@ -110,12 +110,12 @@ export default {
mutationVariables() {
return {
projectPath: this.projectPath,
enabled: this.value.enabled,
cadence: this.value.cadence,
olderThan: this.value.olderThan,
keepN: this.value.keepN,
nameRegex: this.value.nameRegex,
nameRegexKeep: this.value.nameRegexKeep,
enabled: this.prefilledForm.enabled,
cadence: this.prefilledForm.cadence,
olderThan: this.prefilledForm.olderThan,
keepN: this.prefilledForm.keepN,
nameRegex: this.prefilledForm.nameRegex,
nameRegexKeep: this.prefilledForm.nameRegexKeep,
};
},
},
@ -291,8 +291,8 @@ export default {
type="submit"
:disabled="isSubmitButtonDisabled"
:loading="showLoadingIcon"
variant="success"
category="primary"
variant="confirm"
class="js-no-auto-disable gl-mr-4"
>
{{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }}

View File

@ -1,9 +1,13 @@
<script>
import { GlTooltipDirective, GlLink, GlBadge, GlButton, GlIcon } from '@gitlab/ui';
import { setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import { BACK_URL_PARAM } from '~/releases/constants';
export default {
i18n: {
editButton: __('Edit this release'),
},
name: 'ReleaseBlockHeader',
components: {
GlLink,
@ -69,7 +73,8 @@ export default {
variant="default"
icon="pencil"
class="gl-mr-3 js-edit-button ml-2 pb-2"
:title="__('Edit this release')"
:title="$options.i18n.editButton"
:aria-label="$options.i18n.editButton"
:href="editLink"
/>
</div>

View File

@ -96,6 +96,7 @@ export default {
v-gl-tooltip
:disabled="!selectedSortOption.sortable"
:title="sortDirectionData.tooltip"
:aria-label="sortDirectionData.tooltip"
:icon="sortDirectionData.icon"
@click="handleSortDirectionChange"
/>

View File

@ -1,12 +1,15 @@
<script>
import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import { __, sprintf, s__ } from '~/locale';
import ReviewerAvatarLink from './reviewer_avatar_link.vue';
const LOADING_STATE = 'loading';
const SUCCESS_STATE = 'success';
export default {
i18n: {
reRequestReview: __('Re-request review'),
},
components: {
GlButton,
GlIcon,
@ -109,7 +112,8 @@ export default {
<gl-button
v-else-if="user.can_update_merge_request && user.reviewed"
v-gl-tooltip.left
:title="__('Re-request review')"
:title="$options.i18n.reRequestReview"
:aria-label="$options.i18n.reRequestReview"
:loading="loadingStates[user.id] === $options.LOADING_STATE"
class="float-right gl-text-gray-500!"
size="small"

View File

@ -65,6 +65,7 @@ export default {
<gl-button
v-gl-tooltip.hover
:title="$options.MSG_COPY"
:aria-label="$options.MSG_COPY"
:data-clipboard-text="value"
icon="copy-to-clipboard"
data-qa-selector="copy_button"

View File

@ -1,6 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import appDataQuery from './queries/app_data.query.graphql';
import fileResolver from './resolvers/file';
import hasSubmittedChangesResolver from './resolvers/has_submitted_changes';
import submitContentChangesResolver from './resolvers/submit_content_changes';
@ -28,7 +29,8 @@ const createApolloProvider = (appData) => {
// eslint-disable-next-line @gitlab/require-i18n-strings
const mounts = appData.mounts.map((mount) => ({ __typename: 'Mount', ...mount }));
defaultClient.cache.writeData({
defaultClient.cache.writeQuery({
query: appDataQuery,
data: {
appData: {
__typename: 'AppData',

View File

@ -56,6 +56,7 @@ export default {
<gl-button
v-gl-tooltip.hover
:title="$options.copyURLTooltip"
:aria-label="$options.copyURLTooltip"
:data-clipboard-text="sshLink"
data-qa-selector="copy_ssh_url_button"
icon="copy-to-clipboard"
@ -75,6 +76,7 @@ export default {
<gl-button
v-gl-tooltip.hover
:title="$options.copyURLTooltip"
:aria-label="$options.copyURLTooltip"
:data-clipboard-text="httpLink"
data-qa-selector="copy_http_url_button"
icon="copy-to-clipboard"

View File

@ -363,6 +363,7 @@ export default {
<gl-button
v-gl-tooltip
:title="sortDirectionTooltip"
:aria-label="sortDirectionTooltip"
:icon="sortDirectionIcon"
class="flex-shrink-1"
@click="handleSortDirectionClick"

View File

@ -46,7 +46,7 @@ export default {
},
activeLabel() {
return this.labels.find(
(label) => label.title.toLowerCase() === stripQuotes(this.currentValue),
(label) => this.getLabelName(label).toLowerCase() === stripQuotes(this.currentValue),
);
},
containerStyle() {
@ -69,6 +69,21 @@ export default {
},
},
methods: {
/**
* There's an inconsistency between private and public API
* for labels where label name is included in a different
* property;
*
* Private API => `label.title`
* Public API => `label.name`
*
* This method allows compatibility as there may be instances
* where `config.fetchLabels` provided externally may still be
* using either of the two APIs.
*/
getLabelName(label) {
return label.name || label.title;
},
fetchLabelBySearchTerm(searchTerm) {
this.loading = true;
this.config
@ -85,7 +100,7 @@ export default {
});
},
searchLabels: debounce(function debouncedSearch({ data }) {
this.fetchLabelBySearchTerm(data);
if (!this.loading) this.fetchLabelBySearchTerm(data);
}, DEBOUNCE_DELAY),
},
};
@ -100,7 +115,7 @@ export default {
>
<template #view-token="{ inputValue, cssClasses, listeners }">
<gl-token variant="search-value" :class="cssClasses" :style="containerStyle" v-on="listeners"
>~{{ activeLabel ? activeLabel.title : inputValue }}</gl-token
>~{{ activeLabel ? getLabelName(activeLabel) : inputValue }}</gl-token
>
</template>
<template #suggestions>
@ -114,13 +129,17 @@ export default {
<gl-dropdown-divider v-if="defaultLabels.length" />
<gl-loading-icon v-if="loading" />
<template v-else>
<gl-filtered-search-suggestion v-for="label in labels" :key="label.id" :value="label.title">
<div class="gl-display-flex">
<gl-filtered-search-suggestion
v-for="label in labels"
:key="label.id"
:value="getLabelName(label)"
>
<div class="gl-display-flex gl-align-items-center">
<span
:style="{ backgroundColor: label.color }"
class="gl-display-inline-block mr-2 p-2"
></span>
<div>{{ label.title }}</div>
<div>{{ getLabelName(label) }}</div>
</div>
</gl-filtered-search-suggestion>
</template>

View File

@ -101,6 +101,7 @@ export default {
:data-clipboard-target="target"
:data-clipboard-text="text"
:title="title"
:aria-label="title"
:category="category"
icon="copy-to-clipboard"
/>

View File

@ -316,6 +316,7 @@ class MergeRequest < ApplicationRecord
}
scope :with_csv_entity_associations, -> { preload(:assignees, :approved_by_users, :author, :milestone, metrics: [:merged_by]) }
scope :with_jira_integration_associations, -> { preload(:metrics, :assignees, :author, :target_project, :source_project) }
scope :by_target_branch_wildcard, ->(wildcard_branch_name) do
where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%'))

View File

@ -343,6 +343,10 @@ class Namespace < ApplicationRecord
Plan.default
end
def paid?
root? && actual_plan.paid?
end
def actual_limits
# We default to PlanLimits.new otherwise a lot of specs would fail
# On production each plan should already have associated limits record

View File

@ -41,7 +41,6 @@ class PipelineSerializer < BaseSerializer
def preloaded_relations
[
:cancelable_statuses,
:latest_statuses_ordered_by_stage,
:retryable_builds,
:stages,
:latest_statuses,

View File

@ -5,4 +5,5 @@
= _('Removing this group also removes all child projects, including archived projects, and their resources.')
%br
%strong= _('Removed group can not be restored!')
= button_to _('Remove group'), '#', class: 'btn gl-button btn-danger js-confirm-danger', data: { 'confirm-danger-message' => remove_group_message(group) }
= render 'groups/settings/remove_button', group: group

View File

@ -0,0 +1,7 @@
- if group.paid?
.gl-alert.gl-alert-info.gl-mb-5{ data: { testid: 'group-has-linked-subscription-alert' } }
= sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
.gl-alert-body
= html_escape(_("This group can't be removed because it is linked to a subscription. To remove this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe }
= button_to _('Remove group'), '#', class: ['btn gl-button btn-danger js-confirm-danger', ('disabled' if group.paid?)], data: { 'confirm-danger-message' => remove_group_message(group), 'testid' => 'remove-group-button' }

View File

@ -9,7 +9,7 @@
= button_tag class: 'toggle-mobile-nav', type: 'button' do
%span.sr-only= _("Open sidebar")
= sprite_icon('hamburger', size: 18)
.breadcrumbs-links.js-title-container{ data: { qa_selector: 'breadcrumb_links_content' } }
.breadcrumbs-links{ data: { testid: 'breadcrumb-links', qa_selector: 'breadcrumb_links_content' } }
%ul.list-unstyled.breadcrumbs-list.js-breadcrumbs-list
- unless hide_top_links
= header_title

View File

@ -0,0 +1,5 @@
---
title: Drop unused preload from PipelineSerializer
merge_request: 56988
author:
type: performance

View File

@ -0,0 +1,5 @@
---
title: Always save default on empty values in Exp Policies
merge_request: 57470
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Update the Package settings to use the blue primary button
merge_request: 57468
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Add aria labels to icon buttons
merge_request: 57261
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Resolve N + 1 for JIRA pulls
merge_request: 57482
author:
type: performance

View File

@ -3,6 +3,6 @@ name: usage_data_api
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41301
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/267114
milestone: '13.4'
type: development
group: group::product analytics
type: ops
group: group::product intelligence
default_enabled: true

View File

@ -6,7 +6,7 @@ type: howto
---
# Geo Glossary
# Geo Glossary **(PREMIUM SELF)**
NOTE:
We are updating the Geo documentation, user interface and commands to reflect these changes. Not all pages comply with

View File

@ -5,7 +5,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
type: howto
---
# Version-specific update instructions
# Version-specific update instructions **(PREMIUM SELF)**
Review this page for update instructions for your version. These steps
accompany the [general steps](updating_the_geo_nodes.md#general-update-steps)

View File

@ -5,7 +5,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
type: howto
---
# Setting up Geo
# Setting up Geo **(PREMIUM SELF)**
These instructions assume you have a working instance of GitLab. They guide you through:

View File

@ -4101,6 +4101,7 @@ finishes.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/19298) in GitLab 13.2.
Use `release` to create a [release](../../user/project/releases/index.md).
Requires the `release-cli` to be available in your GitLab Runner Docker or shell executor.
These keywords are supported:
@ -4122,6 +4123,69 @@ You must specify the Docker image to use for the `release-cli`:
image: registry.gitlab.com/gitlab-org/release-cli:latest
```
#### `release-cli` for shell executors
> [Introduced](https://gitlab.com/gitlab-org/release-cli/-/issues/21) in GitLab 13.8.
For GitLab Runner shell executors, you can download and install the `release-cli` manually for your [supported OS and architecture](https://release-cli-downloads.s3.amazonaws.com/latest/index.html).
Once installed, the `release` keyword should be available to you.
**Install on Unix/Linux**
1. Download the binary for your system, in the following example for amd64 systems:
```shell
curl --location --output /usr/local/bin/release-cli "https://release-cli-downloads.s3.amazonaws.com/latest/release-cli-linux-amd64"
```
1. Give it permissions to execute:
```shell
sudo chmod +x /usr/local/bin/release-cli
```
1. Verify `release-cli` is available:
```shell
$ release-cli -v
release-cli version 0.6.0
```
**Install on Windows PowerShell**
1. Create a folder somewhere in your system, for example `C:\GitLab\Release-CLI\bin`
```shell
New-Item -Path 'C:\GitLab\Release-CLI\bin' -ItemType Directory
```
1. Download the executable file:
```shell
PS C:\> Invoke-WebRequest -Uri "https://release-cli-downloads.s3.amazonaws.com/latest/release-cli-windows-amd64.exe" -OutFile "C:\GitLab\Release-CLI\bin\release-cli.exe"
Directory: C:\GitLab\Release-CLI
Mode LastWriteTime Length Name
---- ------------- ------ ----
d----- 3/16/2021 4:17 AM bin
```
1. Add the directory to your `$env:PATH`:
```shell
$env:PATH += ";C:\GitLab\Release-CLI\bin"
```
1. Verify `release-cli` is available:
```shell
PS C:\> release-cli -v
release-cli version 0.6.0
```
#### `script`
All jobs except [trigger](#trigger) jobs must have the `script` keyword. A `release`

View File

@ -9956,6 +9956,54 @@ Status: `implemented`
Tiers: `premium`, `ultimate`
### `redis_hll_counters.epics_usage.g_project_management_users_setting_epic_due_date_as_fixed_monthly`
Counts of MAU setting epic due date as inherited
[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_28d/20210325060507_g_project_management_users_setting_epic_due_date_as_fixed_monthly.yml)
Group: `group::product planning`
Status: `implemented`
Tiers: `premium`, `ultimate`
### `redis_hll_counters.epics_usage.g_project_management_users_setting_epic_due_date_as_fixed_weekly`
Counts of WAU setting epic due date as fixed
[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_7d/20210325060623_g_project_management_users_setting_epic_due_date_as_fixed_weekly.yml)
Group: `group::product planning`
Status: `implemented`
Tiers: `premium`, `ultimate`
### `redis_hll_counters.epics_usage.g_project_management_users_setting_epic_due_date_as_inherited_monthly`
Counts of MAU setting epic due date as inherited
[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_28d/20210325060315_g_project_management_users_setting_epic_due_date_as_inherited_monthly.yml)
Group: `group::product planning`
Status: `implemented`
Tiers: `premium`, `ultimate`
### `redis_hll_counters.epics_usage.g_project_management_users_setting_epic_due_date_as_inherited_weekly`
Counts of WAU setting epic due date as inherited
[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_7d/20210325060903_g_project_management_users_setting_epic_due_date_as_inherited_weekly.yml)
Group: `group::product planning`
Status: `implemented`
Tiers: `premium`, `ultimate`
### `redis_hll_counters.epics_usage.g_project_management_users_setting_epic_start_date_as_fixed_monthly`
Counts of MAU setting epic start date as fixed

View File

@ -326,10 +326,11 @@ To enable it, you need to enable [ActionCable in-app mode](https://docs.gitlab.c
## Cached issue count **(FREE SELF)**
> - [Introduced]([link-to-issue](https://gitlab.com/gitlab-org/gitlab/-/issues/243753)) in GitLab 13.9.
> - It's [deployed behind a feature flag](../../feature_flags.md), disabled by default.
> - It's disabled on GitLab.com.
> - It's not recommended for production use.
> - To use this feature in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-cached-issue-count) **(FREE SELF)**
> - It was [deployed behind a feature flag](../../feature_flags.md), disabled by default.
> - [Became enabled by default](https://gitlab.com/gitlab-org/gitlab/-/issues/323493) in GitLab 13.10.
> - It's enabled on GitLab.com.
> - It's recommended for production use.
> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#enable-or-disable-cached-issue-count) **(FREE SELF)**
WARNING:
This feature might not be available to you. Check the **version history** note above for details.
@ -373,7 +374,7 @@ You can then see issue statuses in the issues list and the
## Enable or disable cached issue count **(FREE SELF)**
Cached issue count in the left sidebar is under development and not ready for production use. It is
Cached issue count in the left sidebar is under development but ready for production use. It is
deployed behind a feature flag that is **enabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
can disable it.

View File

@ -8,7 +8,7 @@ module API
namespace 'usage_data' do
before do
not_found! unless Feature.enabled?(:usage_data_api, default_enabled: true)
not_found! unless Feature.enabled?(:usage_data_api, default_enabled: :yaml, type: :ops)
forbidden!('Invalid CSRF token is provided') unless verified_request?
end

View File

@ -75,11 +75,14 @@ module API
# rubocop: enable CodeReuse/ActiveRecord
def authorized_merge_requests
MergeRequestsFinder.new(current_user, authorized_only: !current_user.admin?).execute
MergeRequestsFinder.new(current_user, authorized_only: !current_user.admin?)
.execute.with_jira_integration_associations
end
def authorized_merge_requests_for_project(project)
MergeRequestsFinder.new(current_user, authorized_only: !current_user.admin?, project_id: project.id).execute
MergeRequestsFinder
.new(current_user, authorized_only: !current_user.admin?, project_id: project.id)
.execute.with_jira_integration_associations
end
# rubocop: disable CodeReuse/ActiveRecord

View File

@ -45,7 +45,7 @@ module Gitlab
# Initialize gon.features with any flags that should be
# made globally available to the frontend
push_frontend_feature_flag(:snippets_binary_blob, default_enabled: false)
push_frontend_feature_flag(:usage_data_api, default_enabled: true)
push_frontend_feature_flag(:usage_data_api, type: :ops, default_enabled: :yaml)
push_frontend_feature_flag(:security_auto_fix, default_enabled: false)
end

View File

@ -51,6 +51,18 @@
aggregation: daily
feature_flag: track_epics_activity
- name: g_project_management_users_setting_epic_due_date_as_fixed
category: epics_usage
redis_slot: project_management
aggregation: daily
feature_flag: track_epics_activity
- name: g_project_management_users_setting_epic_due_date_as_inherited
category: epics_usage
redis_slot: project_management
aggregation: daily
feature_flag: track_epics_activity
- name: g_project_management_epic_issue_added
category: epics_usage
redis_slot: project_management

View File

@ -2917,6 +2917,9 @@ msgstr ""
msgid "AlertSettings|Delete integration"
msgstr ""
msgid "AlertSettings|Edit integration"
msgstr ""
msgid "AlertSettings|Edit payload"
msgstr ""
@ -31061,6 +31064,9 @@ msgstr ""
msgid "This group"
msgstr ""
msgid "This group can't be removed because it is linked to a subscription. To remove this group, %{linkStart}link the subscription%{linkEnd} with a different group."
msgstr ""
msgid "This group cannot be invited to a project inside a group with enforced SSO"
msgstr ""
@ -31070,6 +31076,9 @@ msgstr ""
msgid "This group has been scheduled for permanent removal on %{date}"
msgstr ""
msgid "This group is linked to a subscription"
msgstr ""
msgid "This group, including all subgroups, projects and git repositories, will be reachable from only the specified IP address ranges."
msgstr ""

View File

@ -39,7 +39,7 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p
select('7 days', from: 'Remove tags older than:')
fill_in('Remove tags matching:', with: '.*-production')
submit_button = find('.btn.gl-button.btn-success')
submit_button = find('[data-testid="save-button"')
expect(submit_button).not_to be_disabled
submit_button.click
end
@ -53,7 +53,7 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p
within '#js-registry-policies' do
fill_in('Remove tags matching:', with: '*-production')
submit_button = find('.btn.gl-button.btn-success')
submit_button = find('[data-testid="save-button"')
expect(submit_button).not_to be_disabled
submit_button.click
end

View File

@ -16,18 +16,18 @@ RSpec.describe 'Subgroup Issuables', :js do
it 'shows the full subgroup title when issues index page is empty' do
visit project_issues_path(project)
expect_to_have_full_subgroup_title
expect_to_have_breadcrumb_links
end
it 'shows the full subgroup title when merge requests index page is empty' do
visit project_merge_requests_path(project)
expect_to_have_full_subgroup_title
expect_to_have_breadcrumb_links
end
def expect_to_have_full_subgroup_title
title = find('.breadcrumbs-links')
def expect_to_have_breadcrumb_links
links = find('[data-testid="breadcrumb-links"]')
expect(title).to have_content 'group subgroup project'
expect(links).to have_content 'group subgroup project'
end
end

View File

@ -264,18 +264,18 @@ describe('Api', () => {
it('fetches group labels', (done) => {
const options = { params: { search: 'foo' } };
const expectedGroup = 'gitlab-org';
const expectedUrl = `${dummyUrlRoot}/groups/${expectedGroup}/-/labels`;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${expectedGroup}/labels`;
mock.onGet(expectedUrl).reply(httpStatus.OK, [
{
id: 1,
title: 'Foo Label',
name: 'Foo Label',
},
]);
Api.groupLabels(expectedGroup, options)
.then((res) => {
expect(res.length).toBe(1);
expect(res[0].title).toBe('Foo Label');
expect(res[0].name).toBe('Foo Label');
})
.then(done)
.catch(done.fail);
@ -593,7 +593,7 @@ describe('Api', () => {
});
describe('newLabel', () => {
it('creates a new label', (done) => {
it('creates a new project label', (done) => {
const namespace = 'some namespace';
const project = 'some project';
const labelData = { some: 'data' };
@ -618,26 +618,23 @@ describe('Api', () => {
});
});
it('creates a group label', (done) => {
it('creates a new group label', (done) => {
const namespace = 'group/subgroup';
const labelData = { some: 'data' };
const labelData = { name: 'Foo', color: '#000000' };
const expectedUrl = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespace);
const expectedData = {
label: labelData,
};
mock.onPost(expectedUrl).reply((config) => {
expect(config.data).toBe(JSON.stringify(expectedData));
expect(config.data).toBe(JSON.stringify({ color: labelData.color }));
return [
httpStatus.OK,
{
name: 'test',
...labelData,
},
];
});
Api.newLabel(namespace, undefined, labelData, (response) => {
expect(response.name).toBe('test');
expect(response.name).toBe('Foo');
done();
});
});

View File

@ -13,6 +13,7 @@ exports[`Design management pagination component renders navigation buttons 1`] =
class="gl-mx-5"
>
<gl-button-stub
aria-label="Go to previous design"
buttontextclasses=""
category="primary"
class="js-previous-design"
@ -24,6 +25,7 @@ exports[`Design management pagination component renders navigation buttons 1`] =
/>
<gl-button-stub
aria-label="Go to next design"
buttontextclasses=""
category="primary"
class="js-next-design"

View File

@ -77,33 +77,47 @@ describe('Settings Form', () => {
});
};
const mountComponentWithApollo = ({ provide = defaultProvidedValues, resolver } = {}) => {
const mountComponentWithApollo = ({
provide = defaultProvidedValues,
mutationResolver,
queryPayload = expirationPolicyPayload(),
} = {}) => {
localVue.use(VueApollo);
const requestHandlers = [
[updateContainerExpirationPolicyMutation, resolver],
[expirationPolicyQuery, jest.fn().mockResolvedValue(expirationPolicyPayload())],
[updateContainerExpirationPolicyMutation, mutationResolver],
[expirationPolicyQuery, jest.fn().mockResolvedValue(queryPayload)],
];
fakeApollo = createMockApollo(requestHandlers);
// This component does not do the query directly, but we need a proper cache to update
fakeApollo.defaultClient.cache.writeQuery({
query: expirationPolicyQuery,
variables: {
projectPath: provide.projectPath,
},
...expirationPolicyPayload(),
...queryPayload,
});
// we keep in sync what prop we pass to the component with the cache
const {
data: {
project: { containerExpirationPolicy: value },
},
} = queryPayload;
mountComponent({
provide,
props: {
...defaultProps,
value,
},
config: {
localVue,
apolloProvider: fakeApollo,
},
});
return requestHandlers.map((resolvers) => resolvers[1]);
};
beforeEach(() => {
@ -253,19 +267,44 @@ describe('Settings Form', () => {
expect(findSaveButton().attributes('type')).toBe('submit');
});
it('dispatches the correct apollo mutation', async () => {
const [expirationPolicyMutationResolver] = mountComponentWithApollo({
resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()),
it('dispatches the correct apollo mutation', () => {
const mutationResolver = jest.fn().mockResolvedValue(expirationPolicyMutationPayload());
mountComponentWithApollo({
mutationResolver,
});
findForm().trigger('submit');
await expirationPolicyMutationResolver();
expect(expirationPolicyMutationResolver).toHaveBeenCalled();
expect(mutationResolver).toHaveBeenCalled();
});
it('saves the default values when a value is missing did not change the default options', async () => {
const mutationResolver = jest.fn().mockResolvedValue(expirationPolicyMutationPayload());
mountComponentWithApollo({
mutationResolver,
queryPayload: expirationPolicyPayload({ keepN: null, cadence: null, olderThan: null }),
});
await waitForPromises();
findForm().trigger('submit');
expect(mutationResolver).toHaveBeenCalledWith({
input: {
cadence: 'EVERY_DAY',
enabled: true,
keepN: 'TEN_TAGS',
nameRegex: 'asdasdssssdfdf',
nameRegexKeep: 'sss',
olderThan: 'NINETY_DAYS',
projectPath: 'path',
},
});
});
it('tracks the submit event', () => {
mountComponentWithApollo({
resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()),
mutationResolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()),
});
findForm().trigger('submit');
@ -274,12 +313,12 @@ describe('Settings Form', () => {
});
it('show a success toast when submit succeed', async () => {
const handlers = mountComponentWithApollo({
resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()),
mountComponentWithApollo({
mutationResolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()),
});
findForm().trigger('submit');
await Promise.all(handlers);
await waitForPromises();
await wrapper.vm.$nextTick();
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE, {
@ -290,14 +329,14 @@ describe('Settings Form', () => {
describe('when submit fails', () => {
describe('user recoverable errors', () => {
it('when there is an error is shown in a toast', async () => {
const handlers = mountComponentWithApollo({
resolver: jest
mountComponentWithApollo({
mutationResolver: jest
.fn()
.mockResolvedValue(expirationPolicyMutationPayload({ errors: ['foo'] })),
});
findForm().trigger('submit');
await Promise.all(handlers);
await waitForPromises();
await wrapper.vm.$nextTick();
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('foo', {
@ -308,13 +347,12 @@ describe('Settings Form', () => {
describe('global errors', () => {
it('shows an error', async () => {
const handlers = mountComponentWithApollo({
resolver: jest.fn().mockRejectedValue(expirationPolicyMutationPayload()),
mountComponentWithApollo({
mutationResolver: jest.fn().mockRejectedValue(expirationPolicyMutationPayload()),
});
findForm().trigger('submit');
await Promise.all(handlers);
await wrapper.vm.$nextTick();
await waitForPromises();
await wrapper.vm.$nextTick();
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE, {

View File

@ -40,6 +40,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
tag="div"
>
<gl-button-stub
aria-label="Copy URL"
buttontextclasses=""
category="primary"
class="d-inline-flex"
@ -82,6 +83,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
tag="div"
>
<gl-button-stub
aria-label="Copy URL"
buttontextclasses=""
category="primary"
class="d-inline-flex"

View File

@ -118,6 +118,22 @@ describe('LabelToken', () => {
wrapper = createComponent();
});
describe('getLabelName', () => {
it('returns value of `name` or `title` property present in provided label param', () => {
let mockLabel = {
title: 'foo',
};
expect(wrapper.vm.getLabelName(mockLabel)).toBe(mockLabel.title);
mockLabel = {
name: 'foo',
};
expect(wrapper.vm.getLabelName(mockLabel)).toBe(mockLabel.name);
});
});
describe('fetchLabelBySearchTerm', () => {
it('calls `config.fetchLabels` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchLabels');

View File

@ -1415,6 +1415,12 @@ RSpec.describe Namespace do
end
end
describe '#paid?' do
it 'returns false for a root namespace with a free plan' do
expect(namespace.paid?).to eq(false)
end
end
describe '#shared_runners_setting' do
using RSpec::Parameterized::TableSyntax

View File

@ -3,10 +3,10 @@
require 'spec_helper'
RSpec.describe API::V3::Github do
let(:user) { create(:user) }
let(:unauthorized_user) { create(:user) }
let(:admin) { create(:user, :admin) }
let(:project) { create(:project, :repository, creator: user) }
let_it_be(:user) { create(:user) }
let_it_be(:unauthorized_user) { create(:user) }
let_it_be(:admin) { create(:user, :admin) }
let_it_be(:project) { create(:project, :repository, creator: user) }
before do
project.add_maintainer(user)
@ -210,14 +210,14 @@ RSpec.describe API::V3::Github do
end
describe 'repo pulls' do
let(:project2) { create(:project, :repository, creator: user) }
let(:assignee) { create(:user) }
let(:assignee2) { create(:user) }
let!(:merge_request) do
let_it_be(:project2) { create(:project, :repository, creator: user) }
let_it_be(:assignee) { create(:user) }
let_it_be(:assignee2) { create(:user) }
let_it_be(:merge_request) do
create(:merge_request, source_project: project, target_project: project, author: user, assignees: [assignee])
end
let!(:merge_request_2) do
let_it_be(:merge_request_2) do
create(:merge_request, source_project: project2, target_project: project2, author: user, assignees: [assignee, assignee2])
end
@ -225,26 +225,54 @@ RSpec.describe API::V3::Github do
project2.add_maintainer(user)
end
def perform_request
jira_get v3_api(route, user)
end
describe 'GET /-/jira/pulls' do
let(:route) { '/repos/-/jira/pulls' }
it 'returns an array of merge requests with github format' do
jira_get v3_api('/repos/-/jira/pulls', user)
perform_request
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an(Array)
expect(json_response.size).to eq(2)
expect(response).to match_response_schema('entities/github/pull_requests')
end
it 'returns multiple merge requests without N + 1' do
perform_request
control_count = ActiveRecord::QueryRecorder.new { perform_request }.count
create(:merge_request, source_project: project, source_branch: 'fix')
expect { perform_request }.not_to exceed_query_limit(control_count)
end
end
describe 'GET /repos/:namespace/:project/pulls' do
let(:route) { "/repos/#{project.namespace.path}/#{project.path}/pulls" }
it 'returns an array of merge requests for the proper project in github format' do
jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/pulls", user)
perform_request
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an(Array)
expect(json_response.size).to eq(1)
expect(response).to match_response_schema('entities/github/pull_requests')
end
it 'returns multiple merge requests without N + 1' do
perform_request
control_count = ActiveRecord::QueryRecorder.new { perform_request }.count
create(:merge_request, source_project: project, source_branch: 'fix')
expect { perform_request }.not_to exceed_query_limit(control_count)
end
end
describe 'GET /repos/:namespace/:project/pulls/:id' do

View File

@ -202,7 +202,7 @@ RSpec.describe PipelineSerializer do
# Existing numbers are high and require performance optimization
# Ongoing issue:
# https://gitlab.com/gitlab-org/gitlab/-/issues/225156
expected_queries = Gitlab.ee? ? 85 : 76
expected_queries = Gitlab.ee? ? 82 : 76
expect(recorded.count).to be_within(2).of(expected_queries)
expect(recorded.cached_count).to eq(0)

View File

@ -2,7 +2,7 @@
RSpec.shared_examples 'error tracking index page' do
it 'renders the error index page', quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/217810' } do
within('div.js-title-container') do
within('[data-testid="breadcrumb-links"]') do
expect(page).to have_content(project.namespace.name)
expect(page).to have_content(project.name)
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'groups/settings/_remove.html.haml' do
describe 'render' do
it 'enables the Remove group button for a group' do
group = build(:group)
render 'groups/settings/remove', group: group
expect(rendered).to have_selector '[data-testid="remove-group-button"]'
expect(rendered).not_to have_selector '[data-testid="remove-group-button"].disabled'
expect(rendered).not_to have_selector '[data-testid="group-has-linked-subscription-alert"]'
end
end
end