Commit 9e05007c authored by Paul Slaughter's avatar Paul Slaughter

Merge branch...

Merge branch '198524-add-frequently-requested-coming-soon-package-manager-formats-to-the-package-registry-ui' into 'master'

Add coming soon tab to Package Registry list

See merge request gitlab-org/gitlab!29491
parents fe5f4fa4 da96bc7f
/**
* Context:
* https://gitlab.com/gitlab-org/gitlab/-/issues/198524
* https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29491
*
*/
/**
* Constants
*
* LABEL_NAMES - an array of labels to filter issues in the GraphQL query
* WORKFLOW_PREFIX - the prefix for workflow labels
* ACCEPTING_CONTRIBUTIONS_TITLE - the accepting contributions label
*/
export const LABEL_NAMES = ['Package::Coming soon'];
const WORKFLOW_PREFIX = 'workflow::';
const ACCEPTING_CONTRIBUTIONS_TITLE = 'accepting merge requests';
const setScoped = (label, scoped) => (label ? { ...label, scoped } : label);
/**
* Finds workflow:: scoped labels and returns the first or null.
* @param {Object[]} labels Labels from the issue
*/
export const findWorkflowLabel = (labels = []) =>
labels.find(l => l.title.toLowerCase().includes(WORKFLOW_PREFIX.toLowerCase()));
/**
* Determines if an issue is accepting community contributions by checking if
* the "Accepting merge requests" label is present.
* @param {Object[]} labels
*/
export const findAcceptingContributionsLabel = (labels = []) =>
labels.find(l => l.title.toLowerCase() === ACCEPTING_CONTRIBUTIONS_TITLE.toLowerCase());
/**
* Formats the GraphQL response into the format that the view template expects.
* @param {Object} data GraphQL response
*/
export const toViewModel = data => {
// This just flatterns the issues -> nodes and labels -> nodes hierarchy
// into an array of objects.
const issues = (data.project?.issues?.nodes || []).map(i => ({
...i,
labels: (i.labels?.nodes || []).map(node => node),
}));
return issues.map(x => ({
...x,
labels: [
setScoped(findWorkflowLabel(x.labels), true),
setScoped(findAcceptingContributionsLabel(x.labels), false),
].filter(Boolean),
}));
};
<script>
import {
GlAlert,
GlEmptyState,
GlIcon,
GlLabel,
GlLink,
GlSkeletonLoader,
GlSprintf,
} from '@gitlab/ui';
import Tracking from '~/tracking';
import { TrackingActions } from '../../shared/constants';
import { s__ } from '~/locale';
import { ApolloQuery } from 'vue-apollo';
import comingSoonIssuesQuery from './queries/issues.graphql';
import { toViewModel, LABEL_NAMES } from './helpers';
export default {
name: 'ComingSoon',
components: {
GlAlert,
GlEmptyState,
GlIcon,
GlLabel,
GlLink,
GlSkeletonLoader,
GlSprintf,
ApolloQuery,
},
mixins: [Tracking.mixin()],
props: {
illustration: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
suggestedContributionsPath: {
type: String,
required: true,
},
},
computed: {
variables() {
return {
projectPath: this.projectPath,
labelNames: LABEL_NAMES,
};
},
},
mounted() {
this.track(TrackingActions.COMING_SOON_REQUESTED);
},
methods: {
onIssueLinkClick(issueIid, label) {
this.track(TrackingActions.COMING_SOON_LIST, {
label,
value: issueIid,
});
},
onDocsLinkClick() {
this.track(TrackingActions.COMING_SOON_HELP);
},
},
loadingRows: 5,
i18n: {
alertTitle: s__('PackageRegistry|Upcoming package managers'),
alertIntro: s__(
"PackageRegistry|Is your favorite package manager missing? We'd love your help in building first-class support for it into GitLab! %{contributionLinkStart}Visit the contribution documentation%{contributionLinkEnd} to learn more about how to build support for new package managers into GitLab. Below is a list of package managers that are on our radar.",
),
emptyStateTitle: s__('PackageRegistry|No upcoming issues'),
emptyStateDescription: s__('PackageRegistry|There are no upcoming issues to display.'),
},
comingSoonIssuesQuery,
toViewModel,
};
</script>
<template>
<apollo-query
:query="$options.comingSoonIssuesQuery"
:variables="variables"
:update="$options.toViewModel"
>
<template #default="{ result: { data }, isLoading }">
<div>
<gl-alert :title="$options.i18n.alertTitle" :dismissible="false" variant="tip">
<gl-sprintf :message="$options.i18n.alertIntro">
<template #contributionLink="{ content }">
<gl-link
:href="suggestedContributionsPath"
target="_blank"
@click="onDocsLinkClick"
>{{ content }}</gl-link
>
</template>
</gl-sprintf>
</gl-alert>
</div>
<div v-if="isLoading" class="gl-display-flex gl-flex-direction-column">
<gl-skeleton-loader
v-for="index in $options.loadingRows"
:key="index"
:width="1000"
:height="80"
preserve-aspect-ratio="xMinYMax meet"
>
<rect width="700" height="10" x="0" y="16" rx="4" />
<rect width="60" height="10" x="0" y="45" rx="4" />
<rect width="60" height="10" x="70" y="45" rx="4" />
</gl-skeleton-loader>
</div>
<template v-else-if="data && data.length">
<div
v-for="issue in data"
:key="issue.iid"
data-testid="issue-row"
class="gl-responsive-table-row"
>
<div
class="table-section section-100 align-items-md-center gl-flex-wrap gl-white-space-normal"
>
<gl-link
data-testid="issue-title-link"
:href="issue.webUrl"
class="gl-text-gray-900 gl-font-weight-bold"
@click="onIssueLinkClick(issue.iid, issue.title)"
>
{{ issue.title }}
</gl-link>
<div class="gl-display-flex gl-text-gray-600 gl-mt-5">
<gl-icon name="issues" class="gl-mr-2" />
<gl-link
data-testid="issue-id-link"
:href="issue.webUrl"
class="gl-text-gray-600 gl-mr-5"
@click="onIssueLinkClick(issue.iid, issue.title)"
>#{{ issue.iid }}</gl-link
>
<div v-if="issue.milestone" class="gl-display-flex gl-align-items-center gl-mr-5">
<gl-icon name="clock" class="gl-mr-2" />
<span data-testid="milestone">{{ issue.milestone.title }}</span>
</div>
<gl-label
v-for="label in issue.labels"
:key="label.title"
class="gl-mr-3"
size="sm"
:background-color="label.color"
:title="label.title"
:scoped="Boolean(label.scoped)"
/>
</div>
</div>
</div>
</template>
<gl-empty-state v-else :title="$options.i18n.emptyStateTitle" :svg-path="illustration">
<template #description>
<p>{{ $options.i18n.emptyStateDescription }}</p>
</template>
</gl-empty-state>
</template>
</apollo-query>
</template>
query getComingSoonIssues($projectPath:ID!, $labelNames:[String]) {
project(fullPath:$projectPath) {
issues(state:opened, labelName:$labelNames) {
nodes {
iid,
title,
webUrl,
labels {
nodes {
title,
color
}
},
milestone {
title
}
}
}
}
}
......@@ -6,6 +6,7 @@ import PackageFilter from './packages_filter.vue';
import PackageList from './packages_list.vue';
import PackageSort from './packages_sort.vue';
import { PACKAGE_REGISTRY_TABS } from '../constants';
import PackagesComingSoon from '../coming_soon/packages_coming_soon.vue';
export default {
components: {
......@@ -15,11 +16,13 @@ export default {
PackageFilter,
PackageList,
PackageSort,
PackagesComingSoon,
},
computed: {
...mapState({
emptyListIllustration: state => state.config.emptyListIllustration,
emptyListHelpUrl: state => state.config.emptyListHelpUrl,
comingSoon: state => state.config.comingSoon,
filterQuery: state => state.filterQuery,
}),
emptyListText() {
......@@ -56,8 +59,10 @@ export default {
tabChanged(e) {
const selectedType = PACKAGE_REGISTRY_TABS[e];
this.setSelectedType(selectedType);
this.requestPackagesList();
if (selectedType) {
this.setSelectedType(selectedType);
this.requestPackagesList();
}
},
emptyStateTitle({ title, type }) {
if (this.filterQuery) {
......@@ -96,5 +101,13 @@ export default {
</template>
</package-list>
</gl-tab>
<gl-tab v-if="comingSoon" :title="__('Coming soon')" lazy>
<packages-coming-soon
:illustration="emptyListIllustration"
:project-path="comingSoon.projectPath"
:suggested-contributions-path="comingSoon.suggestedContributions"
/>
</gl-tab>
</gl-tabs>
</template>
......@@ -2,7 +2,10 @@ import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import { createStore } from './stores';
import PackagesListApp from './components/packages_list_app.vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
Vue.use(Translate);
export default () => {
......@@ -10,9 +13,14 @@ export default () => {
const store = createStore();
store.dispatch('setInitialState', el.dataset);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
store,
apolloProvider,
components: {
PackagesListApp,
},
......
import * as types from './mutation_types';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import {
parseIntPagination,
normalizeHeaders,
convertObjectPropsToCamelCase,
} from '~/lib/utils/common_utils';
import { GROUP_PAGE_TYPE } from '../constants';
export default {
[types.SET_INITIAL_STATE](state, config) {
const { comingSoonJson, ...rest } = config;
const comingSoonObj = JSON.parse(comingSoonJson);
state.config = {
...config,
...rest,
comingSoon: comingSoonObj && convertObjectPropsToCamelCase(comingSoonObj),
isGroupPage: config.pageType === GROUP_PAGE_TYPE,
};
},
......
......@@ -9,7 +9,8 @@ export default () => ({
* resourceId: String,
* pageType: String,
* emptyListIllustration: String,
* emptyListHelpUrl: String
* emptyListHelpUrl: String,
* comingSoon: { projectPath: String, suggestedContributions : String } | null;
* }
*/
config: {},
......
......@@ -11,6 +11,9 @@ export const TrackingActions = {
REQUEST_DELETE_PACKAGE: 'request_delete_package',
CANCEL_DELETE_PACKAGE: 'cancel_delete_package',
PULL_PACKAGE: 'pull_package',
COMING_SOON_REQUESTED: 'activate_coming_soon_requested',
COMING_SOON_LIST: 'click_coming_soon_issue_link',
COMING_SOON_HELP: 'click_coming_soon_documentation_link',
};
export const TrackingCategories = {
......
......@@ -30,5 +30,28 @@ module EE
full_url = expose_url(api_v4_projects_packages_pypi_simple_package_name_path({ id: project_id, package_name: '' }, true))
full_url.sub!('://', '://__token__:<your_personal_token>@')
end
def packages_coming_soon_enabled?(resource)
::Feature.enabled?(:packages_coming_soon, resource) && ::Gitlab.dev_env_or_com?
end
def packages_coming_soon_data(resource)
return unless packages_coming_soon_enabled?(resource)
{
project_path: ::Gitlab.com? ? 'gitlab-org/gitlab' : 'gitlab-org/gitlab-test',
suggested_contributions: help_page_path('user/packages/index', anchor: 'suggested-contributions')
}
end
def packages_list_data(type, resource)
{
resource_id: resource.id,
page_type: type,
empty_list_help_url: help_page_path('administration/packages/index'),
empty_list_illustration: image_path('illustrations/no-packages.svg'),
coming_soon_json: packages_coming_soon_data(resource).to_json
}
end
end
end
......@@ -2,7 +2,4 @@
.row
.col-12
#js-vue-packages-list{ data: { resource_id: @group.id,
page_type: 'groups',
empty_list_help_url: help_page_path('administration/packages/index'),
empty_list_illustration: image_path('illustrations/no-packages.svg') } }
#js-vue-packages-list{ data: packages_list_data('groups', @group) }
......@@ -2,7 +2,4 @@
.row
.col-12
#js-vue-packages-list{ data: { resource_id: @project.id,
page_type: 'projects',
empty_list_help_url: help_page_path('administration/packages/index'),
empty_list_illustration: image_path('illustrations/no-packages.svg') } }
#js-vue-packages-list{ data: packages_list_data('projects', @project) }
import * as comingSoon from 'ee/packages/list/coming_soon/helpers';
import { fakeIssues, asGraphQLResponse, asViewModel } from './mock_data';
jest.mock('ee/api.js');
describe('Coming Soon Helpers', () => {
const [noLabels, acceptingMergeRequestLabel, workflowLabel] = fakeIssues;
describe('toViewModel', () => {
it('formats a GraphQL response correctly', () => {
expect(comingSoon.toViewModel(asGraphQLResponse)).toEqual(asViewModel);
});
});
describe('findWorkflowLabel', () => {
it('finds a workflow label', () => {
expect(comingSoon.findWorkflowLabel(workflowLabel.labels)).toEqual(workflowLabel.labels[0]);
});
it("returns undefined when there isn't one", () => {
expect(comingSoon.findWorkflowLabel(noLabels.labels)).toBeUndefined();
});
});
describe('findAcceptingContributionsLabel', () => {
it('finds the correct label when it exists', () => {
expect(comingSoon.findAcceptingContributionsLabel(acceptingMergeRequestLabel.labels)).toEqual(
acceptingMergeRequestLabel.labels[0],
);
});
it("returns undefined when there isn't one", () => {
expect(comingSoon.findAcceptingContributionsLabel(noLabels.labels)).toBeUndefined();
});
});
});
export const fakeIssues = [
{
id: 1,
iid: 1,
title: 'issue one',
webUrl: 'foo',
},
{
id: 2,
iid: 2,
title: 'issue two',
labels: [{ title: 'Accepting merge requests', color: '#69d100' }],
milestone: {
title: '12.10',
},
webUrl: 'foo',
},
{
id: 3,
iid: 3,
title: 'issue three',
labels: [{ title: 'workflow::In dev', color: '#428bca' }],
webUrl: 'foo',
},
{
id: 4,
iid: 4,
title: 'issue four',
labels: [
{ title: 'Accepting merge requests', color: '#69d100' },
{ title: 'workflow::In dev', color: '#428bca' },
],
webUrl: 'foo',
},
];
export const asGraphQLResponse = {
project: {
issues: {
nodes: fakeIssues.map(x => ({
...x,
labels: {
nodes: x.labels,
},
})),
},
},
};
export const asViewModel = [
{
...fakeIssues[0],
labels: [],
},
{
...fakeIssues[1],
labels: [
{
title: 'Accepting merge requests',
color: '#69d100',
scoped: false,
},
],
},
{
...fakeIssues[2],
labels: [
{
title: 'workflow::In dev',
color: '#428bca',
scoped: true,
},
],
},
{
...fakeIssues[3],
labels: [
{
title: 'workflow::In dev',
color: '#428bca',
scoped: true,
},
{
title: 'Accepting merge requests',
color: '#69d100',
scoped: false,
},
],
},
];
import { GlEmptyState, GlSkeletonLoader, GlLabel } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import ComingSoon from 'ee/packages/list/coming_soon/packages_coming_soon.vue';
import { TrackingActions } from 'ee/packages/shared/constants';
import { asViewModel } from './mock_data';
import Tracking from '~/tracking';
import VueApollo, { ApolloQuery } from 'vue-apollo';
jest.mock('ee/packages/list/coming_soon/helpers.js');
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('packages_coming_soon', () => {
let wrapper;
const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
const findAllIssues = () => wrapper.findAll('[data-testid="issue-row"]');
const findIssuesData = () =>
findAllIssues().wrappers.map(x => {
const titleLink = x.find('[data-testid="issue-title-link"]');
const milestone = x.find('[data-testid="milestone"]');
const issueIdLink = x.find('[data-testid="issue-id-link"]');
const labels = x.findAll(GlLabel);
const issueId = Number(issueIdLink.text().substr(1));
return {
id: issueId,
iid: issueId,
title: titleLink.text(),
webUrl: titleLink.attributes('href'),
labels: labels.wrappers.map(label => ({
color: label.props('backgroundColor'),
title: label.props('title'),
scoped: label.props('scoped'),
})),
...(milestone.exists() ? { milestone: { title: milestone.text() } } : {}),
};
});
const findIssueTitleLink = () => wrapper.find('[data-testid="issue-title-link"]');
const findIssueIdLink = () => wrapper.find('[data-testid="issue-id-link"]');
const findEmptyState = () => wrapper.find(GlEmptyState);
const mountComponent = (testParams = {}) => {
const $apolloData = {
loading: testParams.isLoading || false,
};
wrapper = mount(ComingSoon, {
localVue,
propsData: {
illustration: 'foo',
projectPath: 'foo',
suggestedContributionsPath: 'foo',
},
stubs: {
ApolloQuery,
},
mocks: {
$apolloData,
},
});
// Mock the GraphQL query result
wrapper.find(ApolloQuery).setData({
result: {
data: testParams.issues || asViewModel,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when loading', () => {
beforeEach(() => mountComponent({ isLoading: true }));
it('renders the skeleton loader', () => {
expect(findSkeletonLoader().exists()).toBe(true);
});
});
describe('when there are no issues', () => {
beforeEach(() => mountComponent({ issues: [] }));
it('renders the empty state', () => {
expect(findEmptyState().exists()).toBe(true);
});
});
describe('when there are issues', () => {
beforeEach(() => mountComponent());
it('renders each issue', () => {
expect(findIssuesData()).toEqual(asViewModel);
});
});
describe('tracking', () => {
const firstIssue = asViewModel[0];
let eventSpy;
beforeEach(() => {
eventSpy = jest.spyOn(Tracking, 'event');
mountComponent();
});
it('tracks when mounted', () => {
expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_REQUESTED, {});
});
it('tracks when an issue title link is clicked', () => {
eventSpy.mockClear();
findIssueTitleLink().trigger('click');
expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_LIST, {
label: firstIssue.title,
value: firstIssue.iid,
});
});
it('tracks when an issue id link is clicked', () => {
eventSpy.mockClear();
findIssueIdLink().trigger('click');
expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_LIST, {
label: firstIssue.title,
value: firstIssue.iid,
});
});
});
});
......@@ -414,6 +414,8 @@ exports[`packages_list_app renders 1`] = `
</div>
</template>
</b-tab-stub>
<!---->
</template>
<template>
<div
......
......@@ -18,6 +18,7 @@ describe('Mutations Registry Store', () => {
userCanDelete: '',
emptyListIllustration: 'foo',
emptyListHelpUrl: 'baz',
comingSoonJson: '{ "project_path": "gitlab-org/gitlab-test" }',
};
const expectedState = {
......
......@@ -4,6 +4,7 @@ require 'spec_helper'
describe EE::PackagesHelper do
let_it_be(:base_url) { "#{Gitlab.config.gitlab.url}/api/v4/" }
let_it_be(:project) { create(:project) }
describe 'package_registry_instance_url' do
it 'returns conant instance url when registry_type is conant' do
......@@ -42,4 +43,38 @@ describe EE::PackagesHelper do
expect(url).to eq("#{base_url_with_token}projects/1/packages/pypi/simple")
end
end
describe 'packages_coming_soon_enabled?' do
it 'returns false when the feature flag is disabled' do
stub_feature_flags(packages_coming_soon: false)
expect(helper.packages_coming_soon_enabled?(project)).to eq(false)
end
it 'returns false when not on dev or gitlab.com' do
expect(helper.packages_coming_soon_enabled?(project)).to eq(false)
end
end
describe 'packages_coming_soon_data' do
let_it_be(:group) { create(:group) }
before do
allow(Gitlab).to receive(:dev_env_or_com?) { true }
end
it 'returns the gitlab project on gitlab.com' do
allow(Gitlab).to receive(:com?) { true }
expect(helper.packages_coming_soon_data(project)).to include({ project_path: 'gitlab-org/gitlab' })
end
it 'returns the test project when not on gitlab.com' do
expect(helper.packages_coming_soon_data(project)).to include({ project_path: 'gitlab-org/gitlab-test' })
end
it 'works correctly with a group' do
expect(helper.packages_coming_soon_data(group)).to include({ project_path: 'gitlab-org/gitlab-test' })
end
end
end
......@@ -5375,6 +5375,9 @@ msgstr ""
msgid "ComboSearch is not defined"
msgstr ""
msgid "Coming soon"
msgstr ""
msgid "Command"
msgstr ""
......@@ -14866,6 +14869,9 @@ msgstr ""
msgid "PackageRegistry|Installation"
msgstr ""
msgid "PackageRegistry|Is your favorite package manager missing? We'd love your help in building first-class support for it into GitLab! %{contributionLinkStart}Visit the contribution documentation%{contributionLinkEnd} to learn more about how to build support for new package managers into GitLab. Below is a list of package managers that are on our radar."
msgstr ""
msgid "PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab."
msgstr ""
......@@ -14884,6 +14890,9 @@ msgstr ""
msgid "PackageRegistry|NPM"
msgstr ""
msgid "PackageRegistry|No upcoming issues"
msgstr ""
msgid "PackageRegistry|NuGet"
msgstr ""
......@@ -14917,6 +14926,9 @@ msgstr ""
msgid "PackageRegistry|There are no packages yet"
msgstr ""
msgid "PackageRegistry|There are no upcoming issues to display."
msgstr ""
msgid "PackageRegistry|There was a problem fetching the details for this package."
msgstr ""
......@@ -14926,6 +14938,9 @@ msgstr ""
msgid "PackageRegistry|Unable to load package"
msgstr ""
msgid "PackageRegistry|Upcoming package managers"
msgstr ""
msgid "PackageRegistry|You are about to delete <b>%{packageName}</b>, this operation is irreversible, are you sure?"
msgstr ""
......
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