Commit d362c451 authored by Frédéric Caplette's avatar Frédéric Caplette Committed by Etienne Baqué

Disable multi-project viz for free users

There was a bug where we would allow free tier
users to see multi project pipelines but this is
a premium feature. We now disable the expand
button for free tier users and show a popover
explaining why this is disabled and adding tests
to ensure it works as expected and no regression
occurs again.

Changelog: changed
parent 594ff713
......@@ -92,6 +92,9 @@ export default {
hasUpstreamPipelines() {
return Boolean(this.pipeline?.upstream?.length > 0);
},
isMultiProjectVizAvailable() {
return Boolean(this.pipeline?.user?.namespace?.crossProjectPipelineAvailable);
},
isStageView() {
return this.viewType === STAGE_VIEW;
},
......@@ -178,6 +181,7 @@ export default {
<linked-pipelines-column
v-if="showUpstreamPipelines"
:config-paths="configPaths"
:is-multi-project-viz-available="isMultiProjectVizAvailable"
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:show-links="showJobLinks"
......@@ -226,6 +230,7 @@ export default {
v-if="showDownstreamPipelines"
class="gl-mr-6"
:config-paths="configPaths"
:is-multi-project-viz-available="isMultiProjectVizAvailable"
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:show-links="showJobLinks"
......
<script>
import { GlBadge, GlButton, GlLink, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import {
GlBadge,
GlButton,
GlLink,
GlLoadingIcon,
GlPopover,
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import TierBadge from '~/vue_shared/components/tier_badge.vue';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { __, sprintf } from '~/locale';
import { __, s__, sprintf } from '~/locale';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
import { reportToSentry } from '../../utils';
import { DOWNSTREAM, UPSTREAM } from './constants';
export default {
i18n: {
popover: {
title: s__('Pipelines|Multi-project pipeline graphs'),
description: s__(
'Pipelines|Gitlab Premium users have access to the multi-project pipeline graph to improve the visualization of these pipelines. %{linkStart}Learn More%{linkEnd}',
),
},
},
directives: {
GlTooltip: GlTooltipDirective,
},
......@@ -16,7 +33,11 @@ export default {
GlButton,
GlLink,
GlLoadingIcon,
GlPopover,
GlSprintf,
TierBadge,
},
inject: ['multiProjectHelpPath'],
props: {
columnTitle: {
type: String,
......@@ -26,6 +47,10 @@ export default {
type: Boolean,
required: true,
},
isMultiProjectVizAvailable: {
type: Boolean,
required: true,
},
isLoading: {
type: Boolean,
required: true,
......@@ -90,6 +115,9 @@ export default {
pipelineStatus() {
return this.pipeline.status;
},
popoverContainerId() {
return `popoverContainer-${this.pipeline.id}`;
},
projectName() {
return this.pipeline.project.name;
},
......@@ -128,16 +156,21 @@ export default {
<template>
<div
ref="linkedPipeline"
v-gl-tooltip
class="gl-h-full gl-display-flex! gl-border-solid gl-border-gray-100 gl-border-1"
:class="flexDirection"
:title="tooltipText"
data-qa-selector="child_pipeline"
data-testid="linkedPipeline"
@mouseover="onDownstreamHovered"
@mouseleave="onDownstreamHoverLeave"
>
<div class="gl-w-full gl-bg-white gl-p-3" :class="cardSpacingClass">
<div
v-gl-tooltip
class="gl-w-full gl-bg-white gl-p-3"
:class="cardSpacingClass"
data-testid="linkedPipelineBody"
data-qa-selector="linked_pipeline_body"
:title="tooltipText"
>
<div class="gl-display-flex gl-pr-3">
<ci-status
v-if="!pipelineIsLoading"
......@@ -163,17 +196,38 @@ export default {
</gl-badge>
</div>
</div>
<div class="gl-display-flex">
<div :id="popoverContainerId" class="gl-display-flex">
<gl-button
:id="buttonId"
class="gl-shadow-none! gl-rounded-0!"
:class="`js-pipeline-expand-${pipeline.id} ${buttonBorderClass}`"
:icon="expandedIcon"
:aria-label="__('Expand pipeline')"
:disabled="!isMultiProjectVizAvailable"
data-testid="expand-pipeline-button"
data-qa-selector="expand_pipeline_button"
@click="onClickLinkedPipeline"
/>
<gl-popover
v-if="!isMultiProjectVizAvailable"
placement="top"
:target="popoverContainerId"
triggers="hover"
>
<template #title>
<b>{{ $options.i18n.popover.title }}</b>
<tier-badge class="gl-mt-3" tier="premium"
/></template>
<p class="gl-my-0">
<gl-sprintf :message="$options.i18n.popover.description">
<template #link="{ content }"
><gl-link :href="multiProjectHelpPath" class="gl-font-sm" target="_blank">{{
content
}}</gl-link>
</template>
</gl-sprintf>
</p>
</gl-popover>
</div>
</div>
</template>
......@@ -28,6 +28,10 @@ export default {
required: true,
validator: validateConfigPaths,
},
isMultiProjectVizAvailable: {
type: Boolean,
required: true,
},
linkedPipelines: {
type: Array,
required: true,
......@@ -208,6 +212,7 @@ export default {
<linked-pipeline
class="gl-display-inline-block"
:is-loading="isLoadingPipeline(pipeline.id)"
:is-multi-project-viz-available="isMultiProjectVizAvailable"
:pipeline="pipeline"
:column-title="columnTitle"
:type="type"
......
......@@ -8,7 +8,7 @@ Vue.use(VueApollo);
const createPipelinesDetailApp = (
selector,
apolloProvider,
{ pipelineProjectPath, pipelineIid, metricsPath, graphqlResourceEtag } = {},
{ pipelineProjectPath, pipelineIid, metricsPath, graphqlResourceEtag, multiProjectHelpPath } = {},
) => {
// eslint-disable-next-line no-new
new Vue({
......@@ -22,6 +22,7 @@ const createPipelinesDetailApp = (
pipelineProjectPath,
pipelineIid,
graphqlResourceEtag,
multiProjectHelpPath,
},
errorCaptured(err, _vm, info) {
reportToSentry('pipeline_details_graph', `error: ${err}, info: ${info}`);
......
<script>
import { GlBadge, GlIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
const gitlabTiers = {
free: s__('GitlabTiers|Free'),
premium: s__('GitlabTiers|Premium'),
ultimate: s__('GitlabTiers|Ultimate'),
};
export default {
components: {
GlBadge,
GlIcon,
},
props: {
tier: {
type: String,
required: true,
validator: (value) => Object.keys(gitlabTiers).includes(value),
},
size: {
type: String,
required: false,
default: 'md',
},
},
computed: {
tierName() {
return gitlabTiers[this.tier];
},
},
};
</script>
<template>
<gl-badge :size="size" class="gl-text-purple-600! gl-font-weight-bold gl-bg-purple-50!">
<gl-icon name="license" /> {{ tierName }}
</gl-badge>
</template>
......@@ -47,6 +47,15 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) {
id
iid
complete
user {
__typename
id
namespace {
__typename
id
crossProjectPipelineAvailable
}
}
usesNeeds
userPermissions {
updatePipeline
......
# frozen_string_literal: true
module Projects
module PipelineHelper
def js_pipeline_details_data(project, pipeline)
{
graphql_resource_etag: graphql_etag_pipeline_path(pipeline),
metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: project.namespace, project_id: project, format: :json),
multi_project_help_path: help_page_path('ci/pipelines/multi_project_pipelines.md', anchor: 'multi-project-pipeline-visualization'),
pipeline_iid: pipeline.iid,
pipeline_project_path: project.full_path
}
end
end
end
......@@ -29,4 +29,4 @@
#js-pipeline-notification{ data: { deprecated_keywords_doc_path: help_page_path('ci/yaml/index.md', anchor: 'deprecated-keywords'), full_path: @project.full_path, pipeline_iid: @pipeline.iid } }
= render "projects/pipelines/with_tabs", pipeline: @pipeline, stages: @stages, pipeline_has_errors: pipeline_has_errors
.js-pipeline-details-vue{ data: { metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: @project.namespace, project_id: @project, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline) } }
.js-pipeline-details-vue{ data: js_pipeline_details_data(@project, @pipeline) }
......@@ -30,7 +30,7 @@ RSpec.describe 'Pipeline', :js do
create_link(pipeline, downstream_pipeline)
end
context 'expands the upstream pipeline on click' do
context 'upstream pipelines' do
it 'renders upstream pipeline' do
subject
......@@ -38,59 +38,90 @@ RSpec.describe 'Pipeline', :js do
expect(page).to have_content(upstream_pipeline.project.name)
end
it 'expands the upstream on click' do
subject
context 'without license' do
it 'cannot expand the upstream pipeline' do
subject
page.find(".js-pipeline-expand-#{upstream_pipeline.id}").click
wait_for_requests
expect(page).to have_selector("#pipeline-links-container-#{upstream_pipeline.id}")
page.find(".js-pipeline-expand-#{upstream_pipeline.id}").click
wait_for_requests
expect(page).not_to have_selector("#pipeline-links-container-#{upstream_pipeline.id}")
end
end
it 'closes the expanded upstream on click' do
subject
context 'with license' do
before do
stub_licensed_features(cross_project_pipelines: true)
end
# open
page.find(".js-pipeline-expand-#{upstream_pipeline.id}").click
wait_for_requests
it 'expands the upstream on click' do
subject
# close
page.find(".js-pipeline-expand-#{upstream_pipeline.id}").click
page.find(".js-pipeline-expand-#{upstream_pipeline.id}").click
wait_for_requests
expect(page).to have_selector("#pipeline-links-container-#{upstream_pipeline.id}")
end
expect(page).not_to have_selector("#pipeline-links-container-#{upstream_pipeline.id}")
end
end
it 'closes the expanded upstream on click' do
subject
# open
page.find(".js-pipeline-expand-#{upstream_pipeline.id}").click
wait_for_requests
it 'renders downstream pipeline' do
subject
# close
page.find(".js-pipeline-expand-#{upstream_pipeline.id}").click
expect(page).to have_content(downstream_pipeline.id)
expect(page).to have_content(downstream_pipeline.project.name)
expect(page).not_to have_selector("#pipeline-links-container-#{upstream_pipeline.id}")
end
end
end
context 'expands the downstream pipeline on click' do
it 'expands the downstream on click' do
context 'downstream pipelines' do
it 'renders downstream pipeline' do
subject
page.find(".js-pipeline-expand-#{downstream_pipeline.id}").click
wait_for_requests
expect(page).to have_selector("#pipeline-links-container-#{downstream_pipeline.id}")
expect(page).to have_content(downstream_pipeline.id)
expect(page).to have_content(downstream_pipeline.project.name)
end
it 'closes the expanded downstream on click' do
subject
context 'without license' do
it 'cannot expand the downstream pipeline' do
subject
page.find(".js-pipeline-expand-#{downstream_pipeline.id}").click
wait_for_requests
expect(page).not_to have_selector("#pipeline-links-container-#{downstream_pipeline.id}")
end
end
# open
page.find(".js-pipeline-expand-#{downstream_pipeline.id}").click
wait_for_requests
context 'with license' do
before do
stub_licensed_features(cross_project_pipelines: true)
end
# close
page.find(".js-pipeline-expand-#{downstream_pipeline.id}").click
it 'expands the downstream on click' do
subject
expect(page).not_to have_selector("#pipeline-links-container-#{downstream_pipeline.id}")
page.find(".js-pipeline-expand-#{downstream_pipeline.id}").click
wait_for_requests
expect(page).to have_selector("#pipeline-links-container-#{downstream_pipeline.id}")
end
it 'closes the expanded downstream on click' do
subject
# open
page.find(".js-pipeline-expand-#{downstream_pipeline.id}").click
wait_for_requests
# close
page.find(".js-pipeline-expand-#{downstream_pipeline.id}").click
expect(page).not_to have_selector("#pipeline-links-container-#{downstream_pipeline.id}")
end
end
end
end
context 'when :ci_require_credit_card_on_free_plan flag is on' do
before do
allow(::Gitlab).to receive(:com?).and_return(true)
......
......@@ -16771,6 +16771,15 @@ msgstr ""
msgid "GithubIntegration|This requires mirroring your GitHub repository to this project. %{docs_link}"
msgstr ""
msgid "GitlabTiers|Free"
msgstr ""
msgid "GitlabTiers|Premium"
msgstr ""
msgid "GitlabTiers|Ultimate"
msgstr ""
msgid "Gitpod"
msgstr ""
......@@ -26984,6 +26993,9 @@ msgstr ""
msgid "Pipelines|GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. There are active runners available to run your jobs right now. If you prefer, you can %{settingsLinkStart}configure your runners%{settingsLinkEnd} or %{docsLinkStart}learn more%{docsLinkEnd} about runners."
msgstr ""
msgid "Pipelines|Gitlab Premium users have access to the multi-project pipeline graph to improve the visualization of these pipelines. %{linkStart}Learn More%{linkEnd}"
msgstr ""
msgid "Pipelines|If you are unsure, please ask a project maintainer to review it for you."
msgstr ""
......@@ -27023,6 +27035,9 @@ msgstr ""
msgid "Pipelines|More Information"
msgstr ""
msgid "Pipelines|Multi-project pipeline graphs"
msgstr ""
msgid "Pipelines|No runners detected"
msgstr ""
......
......@@ -24,6 +24,7 @@ module QA
view 'app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue' do
element :expand_pipeline_button
element :child_pipeline
element :linked_pipeline_body
end
view 'app/assets/javascripts/reports/components/report_section.vue' do
......@@ -93,7 +94,7 @@ module QA
end
def find_child_pipeline_by_title(title)
child_pipelines.find { |pipeline| pipeline[:title].include?(title) }
find_element(:child_pipeline, text: title)
end
def expand_child_pipeline(title: nil)
......
......@@ -42,7 +42,7 @@ module QA
[upstream_project, downstream_project].each(&:remove_via_api!)
end
it 'runs the pipeline with composed config', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/348088' do
it 'runs the pipeline with composed config', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/353964' do
Page::Project::Pipeline::Show.perform do |parent_pipeline|
Support::Waiter.wait_until { parent_pipeline.has_child_pipeline? }
parent_pipeline.expand_child_pipeline
......
......@@ -66,7 +66,6 @@ describe('graph component', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('with data', () => {
......
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { GlButton, GlLoadingIcon, GlPopover } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { UPSTREAM, DOWNSTREAM } from '~/pipelines/components/graph/constants';
import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue';
......@@ -9,15 +9,20 @@ import mockPipeline from './linked_pipelines_mock_data';
describe('Linked pipeline', () => {
let wrapper;
const defaultProps = {
pipeline: mockPipeline,
columnTitle: 'Downstream',
type: DOWNSTREAM,
expanded: false,
isLoading: false,
isMultiProjectVizAvailable: true,
};
const downstreamProps = {
pipeline: {
...mockPipeline,
multiproject: false,
},
columnTitle: 'Downstream',
type: DOWNSTREAM,
expanded: false,
isLoading: false,
};
const upstreamProps = {
......@@ -27,21 +32,29 @@ describe('Linked pipeline', () => {
};
const findButton = () => wrapper.find(GlButton);
const findDownstreamPipelineTitle = () => wrapper.find('[data-testid="downstream-title"]');
const findPipelineLabel = () => wrapper.find('[data-testid="downstream-pipeline-label"]');
const findLinkedPipeline = () => wrapper.find({ ref: 'linkedPipeline' });
const findDownstreamPipelineTitle = () => wrapper.findByTestId('downstream-title');
const findPipelineLabel = () => wrapper.findByTestId('downstream-pipeline-label');
const findLinkedPipeline = () => wrapper.findByTestId('linkedPipeline');
const findLinkedPipelineBody = () => wrapper.findByTestId('linkedPipelineBody');
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findPipelineLink = () => wrapper.find('[data-testid="pipelineLink"]');
const findExpandButton = () => wrapper.find('[data-testid="expand-pipeline-button"]');
const findPipelineLink = () => wrapper.findByTestId('pipelineLink');
const findExpandButton = () => wrapper.findByTestId('expand-pipeline-button');
const findPopover = () => wrapper.find(GlPopover);
const createWrapper = (propsData, data = []) => {
wrapper = mount(LinkedPipelineComponent, {
propsData,
wrapper = mountExtended(LinkedPipelineComponent, {
propsData: {
...defaultProps,
...propsData,
},
data() {
return {
...data,
};
},
provide: {
multiProjectHelpPath: '/ci/pipelines/multi-project-pipelines',
},
});
};
......@@ -92,7 +105,7 @@ describe('Linked pipeline', () => {
});
it('should render the tooltip text as the title attribute', () => {
const titleAttr = findLinkedPipeline().attributes('title');
const titleAttr = findLinkedPipelineBody().attributes('title');
expect(titleAttr).toContain(mockPipeline.project.name);
expect(titleAttr).toContain(mockPipeline.status.label);
......@@ -168,10 +181,6 @@ describe('Linked pipeline', () => {
describe('when isLoading is true', () => {
const props = {
pipeline: mockPipeline,
columnTitle: 'Downstream',
type: DOWNSTREAM,
expanded: false,
isLoading: true,
};
......@@ -184,19 +193,43 @@ describe('Linked pipeline', () => {
});
});
describe('on click/hover', () => {
const props = {
pipeline: mockPipeline,
columnTitle: 'Downstream',
type: DOWNSTREAM,
expanded: false,
isLoading: false,
};
describe('when the user does not have access to the multi-project pipeline viz feature', () => {
beforeEach(() => {
const props = { isMultiProjectVizAvailable: false };
createWrapper(props);
});
it('the multi-project expand button is disabled', () => {
expect(findExpandButton().props('disabled')).toBe(true);
});
it('it adds the popover text inside the DOM', () => {
expect(findPopover().exists()).toBe(true);
expect(findPopover().text()).toContain(
'Gitlab Premium users have access to the multi-project pipeline graph to improve the visualization of these pipelines.',
);
});
});
describe('when the user has access to the multi-project pipeline viz feature', () => {
beforeEach(() => {
createWrapper();
});
it('the multi-project expand button is enabled', () => {
expect(findExpandButton().props('disabled')).toBe(false);
});
it('does not add the popover text inside the DOM', () => {
expect(findPopover().exists()).toBe(false);
});
});
describe('on click/hover', () => {
beforeEach(() => {
createWrapper();
});
it('emits `pipelineClicked` event', () => {
jest.spyOn(wrapper.vm, '$emit');
findButton().trigger('click');
......
......@@ -26,6 +26,7 @@ const processedPipeline = pipelineWithUpstreamDownstream(mockPipelineResponse);
describe('Linked Pipelines Column', () => {
const defaultProps = {
columnTitle: 'Downstream',
isMultiProjectVizAvailable: true,
linkedPipelines: processedPipeline.downstream,
showLinks: false,
type: DOWNSTREAM,
......@@ -51,6 +52,9 @@ describe('Linked Pipelines Column', () => {
...defaultProps,
...props,
},
provide: {
multiProjectHelpPath: 'ci/pipelines/multi-project-pipeline',
},
});
};
......@@ -67,7 +71,6 @@ describe('Linked Pipelines Column', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('it renders correctly', () => {
......
......@@ -13,6 +13,15 @@ export const mockPipelineResponse = {
usesNeeds: true,
downstream: null,
upstream: null,
user: {
__typename: 'UserCore',
id: 'gid://gitlab/User/1',
namespace: {
__typename: 'Namespace',
id: 'gid://gitlab/Namespaces::UserNamespace/1',
crossProjectPipelineAvailable: true,
},
},
userPermissions: {
__typename: 'PipelinePermissions',
updatePipeline: true,
......@@ -780,6 +789,15 @@ export const wrappedPipelineReturn = {
id: 'gid://gitlab/Ci::Pipeline/175',
iid: '38',
complete: true,
user: {
__typename: 'UserCore',
id: 'gid://gitlab/User/1',
namespace: {
__typename: 'Namespace',
id: 'gid://gitlab/Namespaces::UserNamespace/1',
crossProjectPipelineAvailable: true,
},
},
usesNeeds: true,
userPermissions: {
__typename: 'PipelinePermissions',
......
import { shallowMount } from '@vue/test-utils';
import { GlBadge, GlIcon } from '@gitlab/ui';
import TierBadge from '~/vue_shared/components/tier_badge.vue';
describe('Tier badge component', () => {
let wrapper;
const createComponent = (props) =>
shallowMount(TierBadge, {
propsData: {
...props,
},
});
const findBadge = () => wrapper.findComponent(GlBadge);
const findTierText = () => findBadge().text();
const findIcon = () => wrapper.findComponent(GlIcon);
afterEach(() => {
wrapper.destroy();
});
describe('tiers name', () => {
it.each`
tier | tierText
${'free'} | ${'Free'}
${'premium'} | ${'Premium'}
${'ultimate'} | ${'Ultimate'}
`(
'shows $tierText text in the badge and the license icon when $tier prop is passed',
({ tier, tierText }) => {
wrapper = createComponent({ tier });
expect(findTierText()).toBe(tierText);
expect(findIcon().exists()).toBe(true);
expect(findIcon().props().name).toBe('license');
},
);
});
describe('badge size', () => {
const newSize = 'lg';
beforeEach(() => {
wrapper = createComponent({ tier: 'free', size: newSize });
});
it('passes down the size prop to the GlBadge component', () => {
expect(findBadge().props().size).toBe(newSize);
});
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::PipelineHelper do
describe '#js_pipeline_details_data' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
subject(:pipeline_details_data) { helper.js_pipeline_details_data(project, pipeline) }
it 'returns pipeline details data' do
expect(pipeline_details_data).to eq({
graphql_resource_etag: graphql_etag_pipeline_path(pipeline),
metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: project.namespace, project_id: project, format: :json),
multi_project_help_path: help_page_path('ci/pipelines/multi_project_pipelines.md', anchor: 'multi-project-pipeline-visualization'),
pipeline_iid: pipeline.iid,
pipeline_project_path: project.full_path
})
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment