Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-07-21 12:09:30 +00:00
parent 5bd4297fd7
commit af28a89d5e
183 changed files with 8837 additions and 169 deletions

View File

@ -854,10 +854,6 @@ Rails/SaveBang:
Exclude:
- 'ee/spec/controllers/projects/merge_requests_controller_spec.rb'
- 'ee/spec/controllers/subscriptions_controller_spec.rb'
- 'ee/spec/factories/ci/job_artifacts.rb'
- 'ee/spec/factories/epics.rb'
- 'ee/spec/factories/licenses.rb'
- 'ee/spec/factories/merge_requests.rb'
- 'ee/spec/features/admin/admin_users_spec.rb'
- 'ee/spec/features/admin/geo/admin_geo_nodes_spec.rb'
- 'ee/spec/features/admin/licenses/admin_views_license_spec.rb'
@ -1099,19 +1095,6 @@ Rails/SaveBang:
- 'spec/controllers/sent_notifications_controller_spec.rb'
- 'spec/controllers/sessions_controller_spec.rb'
- 'spec/controllers/users_controller_spec.rb'
- 'spec/factories/alert_management/alerts.rb'
- 'spec/factories/boards.rb'
- 'spec/factories/ci/pipelines.rb'
- 'spec/factories/design_management/designs.rb'
- 'spec/factories/design_management/versions.rb'
- 'spec/factories/emails.rb'
- 'spec/factories/issues.rb'
- 'spec/factories/labels.rb'
- 'spec/factories/merge_requests.rb'
- 'spec/factories/plans.rb'
- 'spec/factories/projects.rb'
- 'spec/factories/services.rb'
- 'spec/factories/wiki_pages.rb'
- 'spec/factories_spec.rb'
- 'spec/features/admin/admin_appearance_spec.rb'
- 'spec/features/admin/admin_labels_spec.rb'

View File

@ -1 +1 @@
521bb978da8780aca690136e78a3ad388726c8ad
d3caef18a88838486d64a427b00c40ac70f5c378

View File

@ -0,0 +1,128 @@
<script>
import { GlAvatar, GlTooltipDirective, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { __, s__ } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { formatDate } from '~/lib/utils/datetime_utility';
export default {
name: 'PackageActivity',
components: {
ClipboardButton,
GlAvatar,
GlIcon,
GlLink,
GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
data() {
return {
showDescription: false,
};
},
computed: {
...mapState(['packageEntity']),
...mapGetters(['packagePipeline']),
publishedDate() {
return formatDate(this.packageEntity.created_at, 'HH:MM yyyy-mm-dd');
},
},
methods: {
toggleShowDescription() {
this.showDescription = !this.showDescription;
},
},
i18n: {
showCommit: __('Show commit description'),
pipelineText: s__(
'PackageRegistry|Pipeline %{linkStart}%{linkEnd} triggered %{timestamp} by %{author}',
),
publishText: s__('PackageRegistry|Published to the repository at %{timestamp}'),
},
};
</script>
<template>
<div class="mb-3">
<h3 class="gl-font-lg">{{ __('Activity') }}</h3>
<div ref="commit-info" class="info-well">
<div v-if="packagePipeline" class="well-segment">
<div class="d-flex align-items-center">
<gl-icon name="commit" class="d-none d-sm-block" />
<button
v-if="packagePipeline.git_commit_message"
ref="commit-message-toggle"
v-gl-tooltip
:title="$options.i18n.showCommit"
:aria-label="$options.i18n.showCommit"
class="text-expander mr-2 d-none d-sm-flex"
type="button"
@click="toggleShowDescription"
>
<gl-icon name="ellipsis_h" :size="12" />
</button>
<gl-link :href="`../../commit/${packagePipeline.sha}`">{{ packagePipeline.sha }}</gl-link>
<clipboard-button
:text="packagePipeline.sha"
:title="__('Copy commit SHA')"
css-class="border-0 text-secondary py-0"
/>
</div>
<div v-if="showDescription" ref="commit-message" class="mt-2 d-none d-sm-block">
<pre class="commit-row-description mb-0 pl-2">{{
packagePipeline.git_commit_message
}}</pre>
</div>
</div>
<div v-if="packagePipeline" ref="pipeline-info" class="well-segment">
<div class="d-flex align-items-center">
<gl-icon name="pipeline" class="mr-2 d-none d-sm-block" />
<gl-sprintf :message="$options.i18n.pipelineText">
<template #link>
&nbsp;
<gl-link :href="`../../pipelines/${packagePipeline.id}`"
>#{{ packagePipeline.id }}</gl-link
>
&nbsp;
</template>
<template #timestamp>
<span v-gl-tooltip :title="tooltipTitle(packagePipeline.created_at)">
&nbsp;{{ timeFormatted(packagePipeline.created_at) }}&nbsp;
</span>
</template>
<template #author
>{{ packagePipeline.user.name }}
<gl-avatar
class="ml-2 d-none d-sm-block"
:src="packagePipeline.user.avatar_url"
:size="24"
/></template>
</gl-sprintf>
</div>
</div>
<div class="well-segment d-flex align-items-center">
<gl-icon name="clock" class="mr-2 d-none d-sm-block" />
<gl-sprintf :message="$options.i18n.publishText">
<template #timestamp>
{{ publishedDate }}
</template>
</gl-sprintf>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,343 @@
<script>
import {
GlBadge,
GlDeprecatedButton,
GlIcon,
GlModal,
GlModalDirective,
GlTooltipDirective,
GlLink,
GlEmptyState,
GlTab,
GlTabs,
GlTable,
GlSprintf,
} from '@gitlab/ui';
import Tracking from '~/tracking';
import PackageActivity from './activity.vue';
import PackageInformation from './information.vue';
import PackageTitle from './package_title.vue';
import ConanInstallation from './conan_installation.vue';
import MavenInstallation from './maven_installation.vue';
import NpmInstallation from './npm_installation.vue';
import NugetInstallation from './nuget_installation.vue';
import PypiInstallation from './pypi_installation.vue';
import PackagesListLoader from '../../shared/components/packages_list_loader.vue';
import PackageListRow from '../../shared/components/package_list_row.vue';
import DependencyRow from './dependency_row.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { generatePackageInfo } from '../utils';
import { __, s__ } from '~/locale';
import { PackageType, TrackingActions } from '../../shared/constants';
import { packageTypeToTrackCategory } from '../../shared/utils';
import { mapActions, mapState } from 'vuex';
export default {
name: 'PackagesApp',
components: {
GlBadge,
GlDeprecatedButton,
GlEmptyState,
GlLink,
GlModal,
GlTab,
GlTabs,
GlTable,
GlIcon,
GlSprintf,
PackageActivity,
PackageInformation,
PackageTitle,
ConanInstallation,
MavenInstallation,
NpmInstallation,
NugetInstallation,
PypiInstallation,
PackagesListLoader,
PackageListRow,
DependencyRow,
},
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
mixins: [timeagoMixin, Tracking.mixin()],
trackingActions: { ...TrackingActions },
computed: {
...mapState([
'packageEntity',
'packageFiles',
'isLoading',
'canDelete',
'destroyPath',
'svgPath',
'npmPath',
'npmHelpPath',
]),
installationComponent() {
switch (this.packageEntity.package_type) {
case PackageType.CONAN:
return ConanInstallation;
case PackageType.MAVEN:
return MavenInstallation;
case PackageType.NPM:
return NpmInstallation;
case PackageType.NUGET:
return NugetInstallation;
case PackageType.PYPI:
return PypiInstallation;
default:
return null;
}
},
isValidPackage() {
return Boolean(this.packageEntity.name);
},
canDeletePackage() {
return this.canDelete && this.destroyPath;
},
packageInformation() {
return generatePackageInfo(this.packageEntity);
},
packageMetadataTitle() {
switch (this.packageEntity.package_type) {
case PackageType.MAVEN:
return s__('Maven Metadata');
default:
return s__('Package information');
}
},
packageMetadata() {
switch (this.packageEntity.package_type) {
case PackageType.MAVEN:
return [
{
label: s__('Group ID'),
value: this.packageEntity.maven_metadatum.app_group,
},
{
label: s__('Artifact ID'),
value: this.packageEntity.maven_metadatum.app_name,
},
{
label: s__('Version'),
value: this.packageEntity.maven_metadatum.app_version,
},
];
default:
return null;
}
},
filesTableRows() {
return this.packageFiles.map(x => ({
name: x.file_name,
downloadPath: x.download_path,
size: this.formatSize(x.size),
created: x.created_at,
}));
},
tracking() {
return {
category: packageTypeToTrackCategory(this.packageEntity.package_type),
};
},
hasVersions() {
return this.packageEntity.versions?.length > 0;
},
packageDependencies() {
return this.packageEntity.dependency_links || [];
},
showDependencies() {
return this.packageEntity.package_type === PackageType.NUGET;
},
},
methods: {
...mapActions(['fetchPackageVersions']),
formatSize(size) {
return numberToHumanSize(size);
},
cancelDelete() {
this.$refs.deleteModal.hide();
},
getPackageVersions() {
if (!this.packageEntity.versions) {
this.fetchPackageVersions();
}
},
},
i18n: {
deleteModalTitle: s__(`PackageRegistry|Delete Package Version`),
deleteModalContent: s__(
`PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`,
),
},
filesTableHeaderFields: [
{
key: 'name',
label: __('Name'),
tdClass: 'd-flex align-items-center',
},
{
key: 'size',
label: __('Size'),
},
{
key: 'created',
label: __('Created'),
class: 'text-right',
},
],
};
</script>
<template>
<gl-empty-state
v-if="!isValidPackage"
:title="s__('PackageRegistry|Unable to load package')"
:description="s__('PackageRegistry|There was a problem fetching the details for this package.')"
:svg-path="svgPath"
/>
<div v-else class="packages-app">
<div class="detail-page-header d-flex justify-content-between flex-column flex-sm-row">
<package-title />
<div class="mt-sm-2">
<gl-deprecated-button
v-if="canDeletePackage"
v-gl-modal="'delete-modal'"
class="js-delete-button"
variant="danger"
data-qa-selector="delete_button"
>{{ __('Delete') }}</gl-deprecated-button
>
</div>
</div>
<gl-tabs>
<gl-tab :title="__('Detail')">
<div class="row" data-qa-selector="package_information_content">
<div class="col-sm-6">
<package-information :information="packageInformation" />
<package-information
v-if="packageMetadata"
:heading="packageMetadataTitle"
:information="packageMetadata"
:show-copy="true"
/>
</div>
<div class="col-sm-6">
<component
:is="installationComponent"
v-if="installationComponent"
:name="packageEntity.name"
:registry-url="npmPath"
:help-url="npmHelpPath"
/>
</div>
</div>
<package-activity />
<gl-table
:fields="$options.filesTableHeaderFields"
:items="filesTableRows"
tbody-tr-class="js-file-row"
>
<template #cell(name)="items">
<gl-icon name="doc-code" class="space-right" />
<gl-link
:href="items.item.downloadPath"
class="js-file-download"
@click="track($options.trackingActions.PULL_PACKAGE)"
>
{{ items.item.name }}
</gl-link>
</template>
<template #cell(created)="items">
<span v-gl-tooltip :title="tooltipTitle(items.item.created)">{{
timeFormatted(items.item.created)
}}</span>
</template>
</gl-table>
</gl-tab>
<gl-tab v-if="showDependencies" title-item-class="js-dependencies-tab">
<template #title>
<span>{{ __('Dependencies') }}</span>
<gl-badge size="sm" data-testid="dependencies-badge">{{
packageDependencies.length
}}</gl-badge>
</template>
<template v-if="packageDependencies.length > 0">
<dependency-row
v-for="(dep, index) in packageDependencies"
:key="index"
:dependency="dep"
/>
</template>
<p v-else class="gl-mt-3" data-testid="no-dependencies-message">
{{ s__('PackageRegistry|This NuGet package has no dependencies.') }}
</p>
</gl-tab>
<gl-tab
:title="__('Versions')"
title-item-class="js-versions-tab"
@click="getPackageVersions"
>
<template v-if="isLoading && !hasVersions">
<packages-list-loader />
</template>
<template v-else-if="hasVersions">
<package-list-row
v-for="v in packageEntity.versions"
:key="v.id"
:package-entity="{ name: packageEntity.name, ...v }"
:package-link="v.id.toString()"
:disable-delete="true"
:show-package-type="false"
/>
</template>
<p v-else class="gl-mt-3" data-testid="no-versions-message">
{{ s__('PackageRegistry|There are no other versions of this package.') }}
</p>
</gl-tab>
</gl-tabs>
<gl-modal ref="deleteModal" class="js-delete-modal" modal-id="delete-modal">
<template #modal-title>{{ $options.i18n.deleteModalTitle }}</template>
<gl-sprintf :message="$options.i18n.deleteModalContent">
<template #version>
<strong>{{ packageEntity.version }}</strong>
</template>
<template #name>
<strong>{{ packageEntity.name }}</strong>
</template>
</gl-sprintf>
<div slot="modal-footer" class="w-100">
<div class="float-right">
<gl-deprecated-button @click="cancelDelete()">{{ __('Cancel') }}</gl-deprecated-button>
<gl-deprecated-button
ref="modal-delete-button"
data-method="delete"
:to="destroyPath"
variant="danger"
data-qa-selector="delete_modal_button"
@click="track($options.trackingActions.DELETE_PACKAGE)"
>{{ __('Delete') }}</gl-deprecated-button
>
</div>
</div>
</gl-modal>
</div>
</template>

View File

@ -0,0 +1,63 @@
<script>
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Tracking from '~/tracking';
import { TrackingLabels } from '../constants';
export default {
name: 'CodeInstruction',
components: {
ClipboardButton,
},
mixins: [
Tracking.mixin({
label: TrackingLabels.CODE_INSTRUCTION,
}),
],
props: {
instruction: {
type: String,
required: true,
},
copyText: {
type: String,
required: true,
},
multiline: {
type: Boolean,
required: false,
default: false,
},
trackingAction: {
type: String,
required: false,
default: '',
},
},
methods: {
trackCopy() {
if (this.trackingAction) {
this.track(this.trackingAction);
}
},
},
};
</script>
<template>
<div v-if="!multiline" class="input-group append-bottom-10">
<input
:value="instruction"
type="text"
class="form-control monospace js-instruction-input"
readonly
@copy="trackCopy"
/>
<span class="input-group-append js-instruction-button" @click="trackCopy">
<clipboard-button :text="instruction" :title="copyText" class="input-group-text" />
</span>
</div>
<div v-else>
<pre class="js-instruction-pre" @copy="trackCopy">{{ instruction }}</pre>
</div>
</template>

View File

@ -0,0 +1,60 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import CodeInstruction from './code_instruction.vue';
import { TrackingActions, TrackingLabels } from '../constants';
import { mapGetters, mapState } from 'vuex';
import InstallationTabs from './installation_tabs.vue';
export default {
name: 'ConanInstallation',
components: {
CodeInstruction,
GlLink,
GlSprintf,
InstallationTabs,
},
computed: {
...mapState(['conanHelpPath']),
...mapGetters(['conanInstallationCommand', 'conanSetupCommand']),
},
i18n: {
helpText: s__(
'PackageRegistry|For more information on the Conan registry, %{linkStart}see the documentation%{linkEnd}.',
),
},
trackingActions: { ...TrackingActions },
trackingLabel: TrackingLabels.CONAN_INSTALLATION,
};
</script>
<template>
<installation-tabs :tracking-label="$options.trackingLabel">
<template #installation>
<p class="gl-mt-3 font-weight-bold">{{ s__('PackageRegistry|Conan Command') }}</p>
<code-instruction
:instruction="conanInstallationCommand"
:copy-text="s__('PackageRegistry|Copy Conan Command')"
class="js-conan-command"
:tracking-action="$options.trackingActions.COPY_CONAN_COMMAND"
/>
</template>
<template #setup>
<p class="gl-mt-3 font-weight-bold">
{{ s__('PackageRegistry|Add Conan Remote') }}
</p>
<code-instruction
:instruction="conanSetupCommand"
:copy-text="s__('PackageRegistry|Copy Conan Setup Command')"
class="js-conan-setup"
:tracking-action="$options.trackingActions.COPY_CONAN_SETUP_COMMAND"
/>
<gl-sprintf :message="$options.i18n.helpText">
<template #link="{ content }">
<gl-link :href="conanHelpPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</template>
</installation-tabs>
</template>

View File

@ -0,0 +1,35 @@
<script>
export default {
name: 'DependencyRow',
props: {
dependency: {
type: Object,
required: true,
},
},
computed: {
showVersion() {
return Boolean(this.dependency.version_pattern);
},
},
};
</script>
<template>
<div class="gl-responsive-table-row">
<div class="table-section section-50">
<strong class="gl-text-black-normal">{{ dependency.name }}</strong>
<span v-if="dependency.target_framework" data-testid="target-framework"
>({{ dependency.target_framework }})</span
>
</div>
<div
v-if="showVersion"
class="table-section section-50 gl-display-flex justify-content-md-end"
data-testid="version-pattern"
>
<span class="gl-text-black-normal">{{ dependency.version_pattern }}</span>
</div>
</div>
</template>

View File

@ -0,0 +1,64 @@
<script>
import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { GlLink } from '@gitlab/ui';
import { InformationType } from '../constants';
export default {
name: 'PackageInformation',
components: {
ClipboardButton,
GlLink,
},
props: {
heading: {
type: String,
default: s__('Package information'),
required: false,
},
information: {
type: Array,
default: () => [],
required: true,
},
showCopy: {
type: Boolean,
required: false,
default: false,
},
},
informationType: InformationType,
};
</script>
<template>
<div class="card">
<div class="card-header">
<strong>{{ heading }}</strong>
</div>
<ul class="content-list">
<li v-for="(item, index) in information" :key="index">
<span class="text-secondary">{{ item.label }}</span>
<div class="pull-right w-75 gl-text-right">
<gl-link
v-if="item.type === $options.informationType.LINK"
:href="item.value"
target="_blank"
>
{{ item.value }}
</gl-link>
<span v-else>{{ item.value }}</span>
<clipboard-button
v-if="showCopy"
:text="item.value"
:title="sprintf(__('Copy %{field}'), { field: item.label })"
css-class="border-0 text-secondary py-0"
/>
</div>
</li>
</ul>
</div>
</template>

View File

@ -0,0 +1,37 @@
<script>
import Tracking from '~/tracking';
import { trackInstallationTabChange } from '../utils';
import { GlTab, GlTabs } from '@gitlab/ui';
export default {
name: 'TabWrapper',
components: {
GlTab,
GlTabs,
},
mixins: [Tracking.mixin(), trackInstallationTabChange],
props: {
trackingLabel: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="gl-mb-3">
<gl-tabs @input="trackInstallationTabChange">
<gl-tab :title="s__('PackageRegistry|Installation')" title-item-class="js-installation-tab">
<div class="gl-ml-3 gl-mr-3">
<slot name="installation"></slot>
</div>
</gl-tab>
<gl-tab :title="s__('PackageRegistry|Registry Setup')" title-item-class="js-setup-tab">
<div class="gl-ml-3 gl-mr-3">
<slot name="setup"></slot>
</div>
</gl-tab>
</gl-tabs>
</div>
</template>

View File

@ -0,0 +1,89 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import CodeInstruction from './code_instruction.vue';
import { TrackingActions, TrackingLabels } from '../constants';
import { mapGetters, mapState } from 'vuex';
import InstallationTabs from './installation_tabs.vue';
export default {
name: 'MavenInstallation',
components: {
CodeInstruction,
GlLink,
GlSprintf,
InstallationTabs,
},
computed: {
...mapState(['mavenHelpPath']),
...mapGetters(['mavenInstallationXml', 'mavenInstallationCommand', 'mavenSetupXml']),
},
i18n: {
xmlText: s__(
`PackageRegistry|Copy and paste this inside your %{codeStart}pom.xml%{codeEnd} %{codeStart}dependencies%{codeEnd} block.`,
),
setupText: s__(
`PackageRegistry|If you haven't already done so, you will need to add the below to your %{codeStart}pom.xml%{codeEnd} file.`,
),
helpText: s__(
'PackageRegistry|For more information on the Maven registry, %{linkStart}see the documentation%{linkEnd}.',
),
},
trackingActions: { ...TrackingActions },
trackingLabel: TrackingLabels.MAVEN_INSTALLATION,
};
</script>
<template>
<installation-tabs :tracking-label="$options.trackingLabel">
<template #installation>
<p class="gl-mt-3 font-weight-bold">{{ s__('PackageRegistry|Maven XML') }}</p>
<p>
<gl-sprintf :message="$options.i18n.xmlText">
<template #code="{ content }">
<code>{{ content }}</code>
</template>
</gl-sprintf>
</p>
<code-instruction
:instruction="mavenInstallationXml"
:copy-text="s__('PackageRegistry|Copy Maven XML')"
class="js-maven-xml"
multiline
:tracking-action="$options.trackingActions.COPY_MAVEN_XML"
/>
<p class="gl-mt-3 font-weight-bold">
{{ s__('PackageRegistry|Maven Command') }}
</p>
<code-instruction
:instruction="mavenInstallationCommand"
:copy-text="s__('PackageRegistry|Copy Maven command')"
class="js-maven-command"
:tracking-action="$options.trackingActions.COPY_MAVEN_COMMAND"
/>
</template>
<template #setup>
<p>
<gl-sprintf :message="$options.i18n.setupText">
<template #code="{ content }">
<code>{{ content }}</code>
</template>
</gl-sprintf>
</p>
<code-instruction
:instruction="mavenSetupXml"
:copy-text="s__('PackageRegistry|Copy Maven registry XML')"
class="js-maven-setup-xml"
multiline
:tracking-action="$options.trackingActions.COPY_MAVEN_SETUP"
/>
<gl-sprintf :message="$options.i18n.helpText">
<template #link="{ content }">
<gl-link :href="mavenHelpPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</template>
</installation-tabs>
</template>

View File

@ -0,0 +1,87 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import CodeInstruction from './code_instruction.vue';
import { NpmManager, TrackingActions, TrackingLabels } from '../constants';
import { mapGetters, mapState } from 'vuex';
import InstallationTabs from './installation_tabs.vue';
export default {
name: 'NpmInstallation',
components: {
CodeInstruction,
GlLink,
GlSprintf,
InstallationTabs,
},
computed: {
...mapState(['npmHelpPath']),
...mapGetters(['npmInstallationCommand', 'npmSetupCommand']),
npmCommand() {
return this.npmInstallationCommand(NpmManager.NPM);
},
npmSetup() {
return this.npmSetupCommand(NpmManager.NPM);
},
yarnCommand() {
return this.npmInstallationCommand(NpmManager.YARN);
},
yarnSetupCommand() {
return this.npmSetupCommand(NpmManager.YARN);
},
},
i18n: {
helpText: s__(
'PackageRegistry|You may also need to setup authentication using an auth token. %{linkStart}See the documentation%{linkEnd} to find out more.',
),
},
trackingActions: { ...TrackingActions },
trackingLabel: TrackingLabels.NPM_INSTALLATION,
};
</script>
<template>
<installation-tabs :tracking-label="$options.trackingLabel">
<template #installation>
<p class="gl-mt-3 font-weight-bold">{{ s__('PackageRegistry|npm') }}</p>
<code-instruction
:instruction="npmCommand"
:copy-text="s__('PackageRegistry|Copy npm command')"
class="js-npm-install"
:tracking-action="$options.trackingActions.COPY_NPM_INSTALL_COMMAND"
/>
<p class="gl-mt-3 font-weight-bold">{{ s__('PackageRegistry|yarn') }}</p>
<code-instruction
:instruction="yarnCommand"
:copy-text="s__('PackageRegistry|Copy yarn command')"
class="js-yarn-install"
:tracking-action="$options.trackingActions.COPY_YARN_INSTALL_COMMAND"
/>
</template>
<template #setup>
<p class="gl-mt-3 font-weight-bold">{{ s__('PackageRegistry|npm') }}</p>
<code-instruction
:instruction="npmSetup"
:copy-text="s__('PackageRegistry|Copy npm setup command')"
class="js-npm-setup"
:tracking-action="$options.trackingActions.COPY_NPM_SETUP_COMMAND"
/>
<p class="gl-mt-3 font-weight-bold">{{ s__('PackageRegistry|yarn') }}</p>
<code-instruction
:instruction="yarnSetupCommand"
:copy-text="s__('PackageRegistry|Copy yarn setup command')"
class="js-yarn-setup"
:tracking-action="$options.trackingActions.COPY_YARN_SETUP_COMMAND"
/>
<gl-sprintf :message="$options.i18n.helpText">
<template #link="{ content }">
<gl-link :href="npmHelpPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</template>
</installation-tabs>
</template>

View File

@ -0,0 +1,60 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import CodeInstruction from './code_instruction.vue';
import { TrackingActions, TrackingLabels } from '../constants';
import { mapGetters, mapState } from 'vuex';
import InstallationTabs from './installation_tabs.vue';
export default {
name: 'NugetInstallation',
components: {
CodeInstruction,
GlLink,
GlSprintf,
InstallationTabs,
},
computed: {
...mapState(['nugetHelpPath']),
...mapGetters(['nugetInstallationCommand', 'nugetSetupCommand']),
},
i18n: {
helpText: s__(
'PackageRegistry|For more information on the NuGet registry, %{linkStart}see the documentation%{linkEnd}.',
),
},
trackingActions: { ...TrackingActions },
trackingLabel: TrackingLabels.NUGET_INSTALLATION,
};
</script>
<template>
<installation-tabs :tracking-label="$options.trackingLabel">
<template #installation>
<p class="gl-mt-3 font-weight-bold">{{ s__('PackageRegistry|NuGet Command') }}</p>
<code-instruction
:instruction="nugetInstallationCommand"
:copy-text="s__('PackageRegistry|Copy NuGet Command')"
class="js-nuget-command"
:tracking-action="$options.trackingActions.COPY_NUGET_INSTALL_COMMAND"
/>
</template>
<template #setup>
<p class="gl-mt-3 font-weight-bold">
{{ s__('PackageRegistry|Add NuGet Source') }}
</p>
<code-instruction
:instruction="nugetSetupCommand"
:copy-text="s__('PackageRegistry|Copy NuGet Setup Command')"
class="js-nuget-setup"
:tracking-action="$options.trackingActions.COPY_NUGET_SETUP_COMMAND"
/>
<gl-sprintf :message="$options.i18n.helpText">
<template #link="{ content }">
<gl-link :href="nugetHelpPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</template>
</installation-tabs>
</template>

View File

@ -0,0 +1,112 @@
<script>
import { mapState, mapGetters } from 'vuex';
import { GlAvatar, GlIcon, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import PackageTags from '../../shared/components/package_tags.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { __ } from '~/locale';
export default {
name: 'PackageTitle',
components: {
GlAvatar,
GlIcon,
GlLink,
GlSprintf,
PackageTags,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
computed: {
...mapState(['packageEntity', 'packageFiles']),
...mapGetters(['packageTypeDisplay', 'packagePipeline', 'packageIcon']),
hasTagsToDisplay() {
return Boolean(this.packageEntity.tags && this.packageEntity.tags.length);
},
totalSize() {
return numberToHumanSize(this.packageFiles.reduce((acc, p) => acc + p.size, 0));
},
},
i18n: {
packageInfo: __('v%{version} published %{timeAgo}'),
},
};
</script>
<template>
<div class="gl-flex-direction-column">
<div class="gl-display-flex">
<gl-avatar
v-if="packageIcon"
:src="packageIcon"
shape="rect"
class="gl-align-self-center gl-mr-4"
data-testid="package-icon"
/>
<div class="gl-display-flex gl-flex-direction-column">
<h1 class="gl-font-size-h1 gl-mt-3 gl-mb-2">
{{ packageEntity.name }}
</h1>
<div class="gl-display-flex gl-align-items-center gl-text-gray-700">
<gl-icon name="eye" class="gl-mr-3" />
<gl-sprintf :message="$options.i18n.packageInfo">
<template #version>
{{ packageEntity.version }}
</template>
<template #timeAgo>
<span v-gl-tooltip :title="tooltipTitle(packageEntity.created_at)">
&nbsp;{{ timeFormatted(packageEntity.created_at) }}
</span>
</template>
</gl-sprintf>
</div>
</div>
</div>
<div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mb-3">
<div v-if="packageTypeDisplay" class="gl-display-flex gl-align-items-center gl-mr-5">
<gl-icon name="package" class="gl-text-gray-700 gl-mr-3" />
<span data-testid="package-type" class="gl-font-weight-bold">{{ packageTypeDisplay }}</span>
</div>
<div v-if="hasTagsToDisplay" class="gl-display-flex gl-align-items-center gl-mr-5">
<package-tags :tag-display-limit="1" :tags="packageEntity.tags" />
</div>
<div v-if="packagePipeline" class="gl-display-flex gl-align-items-center gl-mr-5">
<gl-icon name="review-list" class="gl-text-gray-700 gl-mr-3" />
<gl-link
data-testid="pipeline-project"
:href="packagePipeline.project.web_url"
class="gl-font-weight-bold text-truncate"
>
{{ packagePipeline.project.name }}
</gl-link>
</div>
<div
v-if="packagePipeline"
data-testid="package-ref"
class="gl-display-flex gl-align-items-center gl-mr-5"
>
<gl-icon name="branch" class="gl-text-gray-700 gl-mr-3" />
<span
v-gl-tooltip
class="gl-font-weight-bold text-truncate mw-xs"
:title="packagePipeline.ref"
>{{ packagePipeline.ref }}</span
>
</div>
<div class="gl-display-flex gl-align-items-center gl-mr-5">
<gl-icon name="disk" class="gl-text-gray-700 gl-mr-3" />
<span data-testid="package-size" class="gl-font-weight-bold">{{ totalSize }}</span>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,70 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import CodeInstruction from './code_instruction.vue';
import { TrackingActions, TrackingLabels } from '../constants';
import { mapGetters, mapState } from 'vuex';
import InstallationTabs from './installation_tabs.vue';
export default {
name: 'PyPiInstallation',
components: {
CodeInstruction,
GlLink,
GlSprintf,
InstallationTabs,
},
computed: {
...mapState(['pypiHelpPath']),
...mapGetters(['pypiPipCommand', 'pypiSetupCommand']),
},
i18n: {
setupText: s__(
`PackageRegistry|If you haven't already done so, you will need to add the below to your %{codeStart}.pypirc%{codeEnd} file.`,
),
helpText: s__(
'PackageRegistry|For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}.',
),
},
trackingActions: { ...TrackingActions },
trackingLabel: TrackingLabels.PYPI_INSTALLATION,
};
</script>
<template>
<installation-tabs :tracking-label="$options.trackingLabel">
<template #installation>
<p class="gl-mt-3 font-weight-bold">
{{ s__('PackageRegistry|Pip Command') }}
</p>
<code-instruction
:instruction="pypiPipCommand"
:copy-text="s__('PackageRegistry|Copy Pip command')"
data-testid="pip-command"
:tracking-action="$options.trackingActions.COPY_PIP_INSTALL_COMMAND"
/>
</template>
<template #setup>
<p>
<gl-sprintf :message="$options.i18n.setupText">
<template #code="{ content }">
<code>{{ content }}</code>
</template>
</gl-sprintf>
</p>
<code-instruction
:instruction="pypiSetupCommand"
:copy-text="s__('PackageRegistry|Copy .pypirc content')"
data-testid="pypi-setup-content"
multiline
:tracking-action="$options.trackingActions.COPY_PYPI_SETUP_COMMAND"
/>
<gl-sprintf :message="$options.i18n.helpText">
<template #link="{ content }">
<gl-link :href="pypiHelpPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</template>
</installation-tabs>
</template>

View File

@ -0,0 +1,47 @@
import { s__ } from '~/locale';
export const TrackingLabels = {
CODE_INSTRUCTION: 'code_instruction',
CONAN_INSTALLATION: 'conan_installation',
MAVEN_INSTALLATION: 'maven_installation',
NPM_INSTALLATION: 'npm_installation',
NUGET_INSTALLATION: 'nuget_installation',
PYPI_INSTALLATION: 'pypi_installation',
};
export const TrackingActions = {
INSTALLATION: 'installation',
REGISTRY_SETUP: 'registry_setup',
COPY_CONAN_COMMAND: 'copy_conan_command',
COPY_CONAN_SETUP_COMMAND: 'copy_conan_setup_command',
COPY_MAVEN_XML: 'copy_maven_xml',
COPY_MAVEN_COMMAND: 'copy_maven_command',
COPY_MAVEN_SETUP: 'copy_maven_setup_xml',
COPY_NPM_INSTALL_COMMAND: 'copy_npm_install_command',
COPY_NPM_SETUP_COMMAND: 'copy_npm_setup_command',
COPY_YARN_INSTALL_COMMAND: 'copy_yarn_install_command',
COPY_YARN_SETUP_COMMAND: 'copy_yarn_setup_command',
COPY_NUGET_INSTALL_COMMAND: 'copy_nuget_install_command',
COPY_NUGET_SETUP_COMMAND: 'copy_nuget_setup_command',
COPY_PIP_INSTALL_COMMAND: 'copy_pip_install_command',
COPY_PYPI_SETUP_COMMAND: 'copy_pypi_setup_command',
};
export const NpmManager = {
NPM: 'npm',
YARN: 'yarn',
};
export const FETCH_PACKAGE_VERSIONS_ERROR = s__(
'PackageRegistry|Unable to fetch package version information.',
);
export const InformationType = {
LINK: 'link',
};

View File

@ -0,0 +1,32 @@
import Vue from 'vue';
import PackagesApp from './components/app.vue';
import Translate from '~/vue_shared/translate';
import createStore from './store';
Vue.use(Translate);
export default () => {
const el = document.querySelector('#js-vue-packages-detail');
const { package: packageJson, canDelete: canDeleteStr, ...rest } = el.dataset;
const packageEntity = JSON.parse(packageJson);
const canDelete = canDeleteStr === 'true';
const store = createStore({
packageEntity,
packageFiles: packageEntity.package_files,
canDelete,
...rest,
});
// eslint-disable-next-line no-new
new Vue({
el,
components: {
PackagesApp,
},
store,
render(createElement) {
return createElement('packages-app');
},
});
};

View File

@ -0,0 +1,23 @@
import Api from '~/api';
import createFlash from '~/flash';
import { FETCH_PACKAGE_VERSIONS_ERROR } from '../constants';
import * as types from './mutation_types';
export default ({ commit, state }) => {
commit(types.SET_LOADING, true);
const { project_id, id } = state.packageEntity;
return Api.projectPackage(project_id, id)
.then(({ data }) => {
if (data.versions) {
commit(types.SET_PACKAGE_VERSIONS, data.versions.reverse());
}
})
.catch(() => {
createFlash(FETCH_PACKAGE_VERSIONS_ERROR);
})
.finally(() => {
commit(types.SET_LOADING, false);
});
};

View File

@ -0,0 +1,106 @@
import { generateConanRecipe } from '../utils';
import { PackageType } from '../../shared/constants';
import { getPackageTypeLabel } from '../../shared/utils';
import { NpmManager } from '../constants';
export const packagePipeline = ({ packageEntity }) => {
return packageEntity?.pipeline || null;
};
export const packageTypeDisplay = ({ packageEntity }) => {
return getPackageTypeLabel(packageEntity.package_type);
};
export const packageIcon = ({ packageEntity }) => {
if (packageEntity.package_type === PackageType.NUGET) {
return packageEntity.nuget_metadatum?.icon_url || null;
}
return null;
};
export const conanInstallationCommand = ({ packageEntity }) => {
const recipe = generateConanRecipe(packageEntity);
// eslint-disable-next-line @gitlab/require-i18n-strings
return `conan install ${recipe} --remote=gitlab`;
};
export const conanSetupCommand = ({ conanPath }) =>
// eslint-disable-next-line @gitlab/require-i18n-strings
`conan remote add gitlab ${conanPath}`;
export const mavenInstallationXml = ({ packageEntity = {} }) => {
const {
app_group: appGroup = '',
app_name: appName = '',
app_version: appVersion = '',
} = packageEntity.maven_metadatum;
return `<dependency>
<groupId>${appGroup}</groupId>
<artifactId>${appName}</artifactId>
<version>${appVersion}</version>
</dependency>`;
};
export const mavenInstallationCommand = ({ packageEntity = {} }) => {
const {
app_group: group = '',
app_name: name = '',
app_version: version = '',
} = packageEntity.maven_metadatum;
return `mvn dependency:get -Dartifact=${group}:${name}:${version}`;
};
export const mavenSetupXml = ({ mavenPath }) => `<repositories>
<repository>
<id>gitlab-maven</id>
<url>${mavenPath}</url>
</repository>
</repositories>
<distributionManagement>
<repository>
<id>gitlab-maven</id>
<url>${mavenPath}</url>
</repository>
<snapshotRepository>
<id>gitlab-maven</id>
<url>${mavenPath}</url>
</snapshotRepository>
</distributionManagement>`;
export const npmInstallationCommand = ({ packageEntity }) => (type = NpmManager.NPM) => {
// eslint-disable-next-line @gitlab/require-i18n-strings
const instruction = type === NpmManager.NPM ? 'npm i' : 'yarn add';
return `${instruction} ${packageEntity.name}`;
};
export const npmSetupCommand = ({ packageEntity, npmPath }) => (type = NpmManager.NPM) => {
const scope = packageEntity.name.substring(0, packageEntity.name.indexOf('/'));
if (type === NpmManager.NPM) {
return `echo ${scope}:registry=${npmPath} >> .npmrc`;
}
return `echo \\"${scope}:registry\\" \\"${npmPath}\\" >> .yarnrc`;
};
export const nugetInstallationCommand = ({ packageEntity }) =>
`nuget install ${packageEntity.name} -Source "GitLab"`;
export const nugetSetupCommand = ({ nugetPath }) =>
`nuget source Add -Name "GitLab" -Source "${nugetPath}" -UserName <your_username> -Password <your_token>`;
export const pypiPipCommand = ({ pypiPath, packageEntity }) =>
// eslint-disable-next-line @gitlab/require-i18n-strings
`pip install ${packageEntity.name} --index-url ${pypiPath}`;
export const pypiSetupCommand = ({ pypiSetupPath }) => `[gitlab]
repository = ${pypiSetupPath}
username = __token__
password = <your personal access token>`;

View File

@ -0,0 +1,20 @@
import Vue from 'vue';
import Vuex from 'vuex';
import fetchPackageVersions from './actions';
import * as getters from './getters';
import mutations from './mutations';
Vue.use(Vuex);
export default (initialState = {}) =>
new Vuex.Store({
actions: {
fetchPackageVersions,
},
getters,
mutations,
state: {
isLoading: false,
...initialState,
},
});

View File

@ -0,0 +1,2 @@
export const SET_LOADING = 'SET_LOADING';
export const SET_PACKAGE_VERSIONS = 'SET_PACKAGE_VERSIONS';

View File

@ -0,0 +1,14 @@
import * as types from './mutation_types';
export default {
[types.SET_LOADING](state, isLoading) {
state.isLoading = isLoading;
},
[types.SET_PACKAGE_VERSIONS](state, versions) {
state.packageEntity = {
...state.packageEntity,
versions,
};
},
};

View File

@ -0,0 +1,91 @@
import { __ } from '~/locale';
import { formatDate } from '~/lib/utils/datetime_utility';
import { TrackingActions, InformationType } from './constants';
import { PackageType } from '../shared/constants';
import { orderBy } from 'lodash';
export const trackInstallationTabChange = {
methods: {
trackInstallationTabChange(tabIndex) {
const action = tabIndex === 0 ? TrackingActions.INSTALLATION : TrackingActions.REGISTRY_SETUP;
this.track(action, { label: this.trackingLabel });
},
},
};
export function generateConanRecipe(packageEntity = {}) {
const {
name = '',
version = '',
conan_metadatum: {
package_username: packageUsername = '',
package_channel: packageChannel = '',
} = {},
} = packageEntity;
return `${name}/${version}@${packageUsername}/${packageChannel}`;
}
export function generatePackageInfo(packageEntity = {}) {
const information = [];
if (packageEntity.package_type === PackageType.CONAN) {
information.push({
order: 1,
label: __('Recipe'),
value: generateConanRecipe(packageEntity),
});
} else {
information.push({
order: 1,
label: __('Name'),
value: packageEntity.name || '',
});
}
if (packageEntity.package_type === PackageType.NUGET) {
const {
nuget_metadatum: { project_url: projectUrl, license_url: licenseUrl } = {},
} = packageEntity;
if (projectUrl) {
information.push({
order: 3,
label: __('Project URL'),
value: projectUrl,
type: InformationType.LINK,
});
}
if (licenseUrl) {
information.push({
order: 4,
label: __('License URL'),
value: licenseUrl,
type: InformationType.LINK,
});
}
}
return orderBy(
[
...information,
{
order: 2,
label: __('Version'),
value: packageEntity.version || '',
},
{
order: 5,
label: __('Created on'),
value: formatDate(packageEntity.created_at),
},
{
order: 6,
label: __('Updated at'),
value: formatDate(packageEntity.updated_at),
},
],
['order'],
);
}

View File

@ -0,0 +1,55 @@
/**
* 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),
}));
};

View File

@ -0,0 +1,172 @@
<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 gl-flex-direction-column gl-align-items-baseline"
>
<div class="table-section section-100 gl-white-space-normal text-truncate">
<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>
<div class="table-section section-100 gl-white-space-normal mt-md-3">
<div class="gl-display-flex gl-text-gray-600">
<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>

View File

@ -0,0 +1,20 @@
query getComingSoonIssues($projectPath: ID!, $labelNames: [String]) {
project(fullPath: $projectPath) {
issues(state: opened, labelName: $labelNames) {
nodes {
iid
title
webUrl
labels {
nodes {
title
color
}
}
milestone {
title
}
}
}
}
}

View File

@ -0,0 +1,21 @@
<script>
import { GlSearchBoxByClick } from '@gitlab/ui';
import { mapActions } from 'vuex';
export default {
components: {
GlSearchBoxByClick,
},
methods: {
...mapActions(['setFilter']),
},
};
</script>
<template>
<gl-search-box-by-click
:placeholder="s__('PackageRegistry|Filter by name')"
@submit="$emit('filter')"
@input="setFilter"
/>
</template>

View File

@ -0,0 +1,129 @@
<script>
import { mapState, mapGetters } from 'vuex';
import { GlPagination, GlModal, GlSprintf } from '@gitlab/ui';
import Tracking from '~/tracking';
import { s__ } from '~/locale';
import { TrackingActions } from '../../shared/constants';
import { packageTypeToTrackCategory } from '../../shared/utils';
import PackagesListLoader from '../../shared/components/packages_list_loader.vue';
import PackagesListRow from '../../shared/components/package_list_row.vue';
export default {
components: {
GlPagination,
GlModal,
GlSprintf,
PackagesListLoader,
PackagesListRow,
},
mixins: [Tracking.mixin()],
data() {
return {
itemToBeDeleted: null,
};
},
computed: {
...mapState({
perPage: state => state.pagination.perPage,
totalItems: state => state.pagination.total,
page: state => state.pagination.page,
isGroupPage: state => state.config.isGroupPage,
isLoading: 'isLoading',
}),
...mapGetters({ list: 'getList' }),
currentPage: {
get() {
return this.page;
},
set(value) {
this.$emit('page:changed', value);
},
},
isListEmpty() {
return !this.list || this.list.length === 0;
},
modalAction() {
return s__('PackageRegistry|Delete package');
},
deletePackageName() {
return this.itemToBeDeleted?.name ?? '';
},
tracking() {
const category = this.itemToBeDeleted
? packageTypeToTrackCategory(this.itemToBeDeleted.package_type)
: undefined;
return {
category,
};
},
},
methods: {
setItemToBeDeleted(item) {
this.itemToBeDeleted = { ...item };
this.track(TrackingActions.REQUEST_DELETE_PACKAGE);
this.$refs.packageListDeleteModal.show();
},
deleteItemConfirmation() {
this.$emit('package:delete', this.itemToBeDeleted);
this.track(TrackingActions.DELETE_PACKAGE);
this.itemToBeDeleted = null;
},
deleteItemCanceled() {
this.track(TrackingActions.CANCEL_DELETE_PACKAGE);
this.itemToBeDeleted = null;
},
},
i18n: {
deleteModalContent: s__(
'PackageRegistry|You are about to delete %{name}, this operation is irreversible, are you sure?',
),
},
};
</script>
<template>
<div class="d-flex flex-column">
<slot v-if="isListEmpty && !isLoading" name="empty-state"></slot>
<div v-else-if="isLoading">
<packages-list-loader :is-group="isGroupPage" />
</div>
<template v-else>
<div data-qa-selector="packages-table">
<packages-list-row
v-for="packageEntity in list"
:key="packageEntity.id"
:package-entity="packageEntity"
:package-link="packageEntity._links.web_path"
:is-group="isGroupPage"
@packageToDelete="setItemToBeDeleted"
/>
</div>
<gl-pagination
v-model="currentPage"
:per-page="perPage"
:total-items="totalItems"
align="center"
class="w-100 mt-2"
/>
<gl-modal
ref="packageListDeleteModal"
modal-id="confirm-delete-pacakge"
ok-variant="danger"
@ok="deleteItemConfirmation"
@cancel="deleteItemCanceled"
>
<template #modal-title>{{ modalAction }}</template>
<template #modal-ok>{{ modalAction }}</template>
<gl-sprintf :message="$options.i18n.deleteModalContent">
<template #name>
<strong>{{ deletePackageName }}</strong>
</template>
</gl-sprintf>
</gl-modal>
</template>
</div>
</template>

View File

@ -0,0 +1,110 @@
<script>
import { mapActions, mapState } from 'vuex';
import { GlEmptyState, GlTab, GlTabs, GlLink, GlSprintf } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
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: {
GlEmptyState,
GlTab,
GlTabs,
GlLink,
GlSprintf,
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,
}),
tabsToRender() {
return PACKAGE_REGISTRY_TABS;
},
},
mounted() {
this.requestPackagesList();
},
methods: {
...mapActions(['requestPackagesList', 'requestDeletePackage', 'setSelectedType']),
onPageChanged(page) {
return this.requestPackagesList({ page });
},
onPackageDeleteRequest(item) {
return this.requestDeletePackage(item);
},
tabChanged(e) {
const selectedType = PACKAGE_REGISTRY_TABS[e];
if (selectedType) {
this.setSelectedType(selectedType);
this.requestPackagesList();
}
},
emptyStateTitle({ title, type }) {
if (this.filterQuery) {
return s__('PackageRegistry|Sorry, your filter produced no results');
}
if (type) {
return sprintf(s__('PackageRegistry|There are no %{packageType} packages yet'), {
packageType: title,
});
}
return s__('PackageRegistry|There are no packages yet');
},
},
i18n: {
widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'),
noResults: s__(
'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
),
},
};
</script>
<template>
<gl-tabs @input="tabChanged">
<template #tabs-end>
<div class="d-flex align-self-center ml-md-auto py-1 py-md-0">
<package-filter class="mr-1" @filter="requestPackagesList" />
<package-sort @sort:changed="requestPackagesList" />
</div>
</template>
<gl-tab v-for="(tab, index) in tabsToRender" :key="index" :title="tab.title">
<package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
<template #empty-state>
<gl-empty-state :title="emptyStateTitle(tab)" :svg-path="emptyListIllustration">
<template #description>
<gl-sprintf v-if="filterQuery" :message="$options.i18n.widenFilters" />
<gl-sprintf v-else :message="$options.i18n.noResults">
<template #noPackagesLink="{content}">
<gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</template>
</gl-empty-state>
</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>

View File

@ -0,0 +1,60 @@
<script>
import { GlSorting, GlSortingItem } from '@gitlab/ui';
import { ASCENDING_ODER, DESCENDING_ORDER } from '../constants';
import getTableHeaders from '../utils';
import { mapState, mapActions } from 'vuex';
export default {
name: 'PackageSort',
components: {
GlSorting,
GlSortingItem,
},
computed: {
...mapState({
isGroupPage: state => state.config.isGroupPage,
orderBy: state => state.sorting.orderBy,
sort: state => state.sorting.sort,
}),
sortText() {
const field = this.sortableFields.find(s => s.orderBy === this.orderBy);
return field ? field.label : '';
},
sortableFields() {
return getTableHeaders(this.isGroupPage);
},
isSortAscending() {
return this.sort === ASCENDING_ODER;
},
},
methods: {
...mapActions(['setSorting']),
onDirectionChange() {
const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ODER;
this.setSorting({ sort });
this.$emit('sort:changed');
},
onSortItemClick(item) {
this.setSorting({ orderBy: item });
this.$emit('sort:changed');
},
},
};
</script>
<template>
<gl-sorting
:text="sortText"
:is-ascending="isSortAscending"
@sortDirectionChange="onDirectionChange"
>
<gl-sorting-item
v-for="item in sortableFields"
ref="packageListSortItem"
:key="item.key"
@click="onSortItemClick(item.orderBy)"
>
{{ item.label }}
</gl-sorting-item>
</gl-sorting>
</template>

View File

@ -0,0 +1,96 @@
import { __, s__ } from '~/locale';
import { PackageType } from '../shared/constants';
export const FETCH_PACKAGES_LIST_ERROR_MESSAGE = __(
'Something went wrong while fetching the packages list.',
);
export const FETCH_PACKAGE_ERROR_MESSAGE = __('Something went wrong while fetching the package.');
export const DELETE_PACKAGE_ERROR_MESSAGE = __('Something went wrong while deleting the package.');
export const DELETE_PACKAGE_SUCCESS_MESSAGE = __('Package deleted successfully');
export const DEFAULT_PAGE = 1;
export const DEFAULT_PAGE_SIZE = 20;
export const GROUP_PAGE_TYPE = 'groups';
export const LIST_KEY_NAME = 'name';
export const LIST_KEY_PROJECT = 'project_path';
export const LIST_KEY_VERSION = 'version';
export const LIST_KEY_PACKAGE_TYPE = 'package_type';
export const LIST_KEY_CREATED_AT = 'created_at';
export const LIST_KEY_ACTIONS = 'actions';
export const LIST_LABEL_NAME = __('Name');
export const LIST_LABEL_PROJECT = __('Project');
export const LIST_LABEL_VERSION = __('Version');
export const LIST_LABEL_PACKAGE_TYPE = __('Type');
export const LIST_LABEL_CREATED_AT = __('Created');
export const LIST_LABEL_ACTIONS = '';
export const LIST_ORDER_BY_PACKAGE_TYPE = 'type';
export const ASCENDING_ODER = 'asc';
export const DESCENDING_ORDER = 'desc';
// The following is not translated because it is used to build a JavaScript exception error message
export const MISSING_DELETE_PATH_ERROR = 'Missing delete_api_path link';
export const TABLE_HEADER_FIELDS = [
{
key: LIST_KEY_NAME,
label: LIST_LABEL_NAME,
orderBy: LIST_KEY_NAME,
class: ['text-left'],
},
{
key: LIST_KEY_PROJECT,
label: LIST_LABEL_PROJECT,
orderBy: LIST_KEY_PROJECT,
class: ['text-left'],
},
{
key: LIST_KEY_VERSION,
label: LIST_LABEL_VERSION,
orderBy: LIST_KEY_VERSION,
class: ['text-center'],
},
{
key: LIST_KEY_PACKAGE_TYPE,
label: LIST_LABEL_PACKAGE_TYPE,
orderBy: LIST_ORDER_BY_PACKAGE_TYPE,
class: ['text-center'],
},
{
key: LIST_KEY_CREATED_AT,
label: LIST_LABEL_CREATED_AT,
orderBy: LIST_KEY_CREATED_AT,
class: ['text-center'],
},
];
export const PACKAGE_REGISTRY_TABS = [
{
title: __('All'),
type: null,
},
{
title: s__('PackageRegistry|Conan'),
type: PackageType.CONAN,
},
{
title: s__('PackageRegistry|Maven'),
type: PackageType.MAVEN,
},
{
title: s__('PackageRegistry|NPM'),
type: PackageType.NPM,
},
{
title: s__('PackageRegistry|NuGet'),
type: PackageType.NUGET,
},
{
title: s__('PackageRegistry|PyPi'),
type: PackageType.PYPI,
},
];

View File

@ -0,0 +1,31 @@
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 () => {
const el = document.getElementById('js-vue-packages-list');
const store = createStore();
store.dispatch('setInitialState', el.dataset);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
store,
apolloProvider,
components: {
PackagesListApp,
},
render(createElement) {
return createElement('packages-list-app');
},
});
};

View File

@ -0,0 +1,73 @@
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import * as types from './mutation_types';
import {
FETCH_PACKAGES_LIST_ERROR_MESSAGE,
DELETE_PACKAGE_ERROR_MESSAGE,
DELETE_PACKAGE_SUCCESS_MESSAGE,
DEFAULT_PAGE,
DEFAULT_PAGE_SIZE,
MISSING_DELETE_PATH_ERROR,
} from '../constants';
import { getNewPaginationPage } from '../utils';
export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
export const setLoading = ({ commit }, data) => commit(types.SET_MAIN_LOADING, data);
export const setSorting = ({ commit }, data) => commit(types.SET_SORTING, data);
export const setSelectedType = ({ commit }, data) => commit(types.SET_SELECTED_TYPE, data);
export const setFilter = ({ commit }, data) => commit(types.SET_FILTER, data);
export const receivePackagesListSuccess = ({ commit }, { data, headers }) => {
commit(types.SET_PACKAGE_LIST_SUCCESS, data);
commit(types.SET_PAGINATION, headers);
};
export const requestPackagesList = ({ dispatch, state }, params = {}) => {
dispatch('setLoading', true);
const { page = DEFAULT_PAGE, per_page = DEFAULT_PAGE_SIZE } = params;
const { sort, orderBy } = state.sorting;
const type = state.selectedType?.type?.toLowerCase();
const nameFilter = state.filterQuery?.toLowerCase();
const packageFilters = { package_type: type, package_name: nameFilter };
const apiMethod = state.config.isGroupPage ? 'groupPackages' : 'projectPackages';
return Api[apiMethod](state.config.resourceId, {
params: { page, per_page, sort, order_by: orderBy, ...packageFilters },
})
.then(({ data, headers }) => {
dispatch('receivePackagesListSuccess', { data, headers });
})
.catch(() => {
createFlash(FETCH_PACKAGES_LIST_ERROR_MESSAGE);
})
.finally(() => {
dispatch('setLoading', false);
});
};
export const requestDeletePackage = ({ dispatch, state }, { _links }) => {
if (!_links || !_links.delete_api_path) {
createFlash(DELETE_PACKAGE_ERROR_MESSAGE);
const error = new Error(MISSING_DELETE_PATH_ERROR);
return Promise.reject(error);
}
dispatch('setLoading', true);
return axios
.delete(_links.delete_api_path)
.then(() => {
const { page: currentPage, perPage, total } = state.pagination;
const page = getNewPaginationPage(currentPage, perPage, total - 1);
dispatch('requestPackagesList', { page });
createFlash(DELETE_PACKAGE_SUCCESS_MESSAGE, 'success');
})
.catch(() => {
dispatch('setLoading', false);
createFlash(DELETE_PACKAGE_ERROR_MESSAGE);
});
};

View File

@ -0,0 +1,5 @@
import { LIST_KEY_PROJECT } from '../constants';
import { beautifyPath } from '../../shared/utils';
export default state =>
state.packages.map(p => ({ ...p, projectPathName: beautifyPath(p[LIST_KEY_PROJECT]) }));

View File

@ -0,0 +1,20 @@
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import getList from './getters';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
state,
getters: {
getList,
},
actions,
mutations,
});
export default createStore();

View File

@ -0,0 +1,8 @@
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
export const SET_PACKAGE_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS';
export const SET_PAGINATION = 'SET_PAGINATION';
export const SET_MAIN_LOADING = 'SET_MAIN_LOADING';
export const SET_SORTING = 'SET_SORTING';
export const SET_SELECTED_TYPE = 'SET_SELECTED_TYPE';
export const SET_FILTER = 'SET_FILTER';

View File

@ -0,0 +1,45 @@
import * as types from './mutation_types';
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 = {
...rest,
comingSoon: comingSoonObj && convertObjectPropsToCamelCase(comingSoonObj),
isGroupPage: config.pageType === GROUP_PAGE_TYPE,
};
},
[types.SET_PACKAGE_LIST_SUCCESS](state, packages) {
state.packages = packages;
},
[types.SET_MAIN_LOADING](state, isLoading) {
state.isLoading = isLoading;
},
[types.SET_PAGINATION](state, headers) {
const normalizedHeaders = normalizeHeaders(headers);
state.pagination = parseIntPagination(normalizedHeaders);
},
[types.SET_SORTING](state, sorting) {
state.sorting = { ...state.sorting, ...sorting };
},
[types.SET_SELECTED_TYPE](state, type) {
state.selectedType = type;
},
[types.SET_FILTER](state, query) {
state.filterQuery = query;
},
};

View File

@ -0,0 +1,51 @@
export default () => ({
/**
* Determine if the component is loading data from the API
*/
isLoading: false,
/**
* configuration object, set once at store creation with the following structure
* {
* resourceId: String,
* pageType: String,
* emptyListIllustration: String,
* emptyListHelpUrl: String,
* comingSoon: { projectPath: String, suggestedContributions : String } | null;
* }
*/
config: {},
/**
* Each object in `packages` has the following structure:
* {
* id: String
* name: String,
* version: String,
* package_type: String // endpoint to request the list
* }
*/
packages: [],
/**
* Pagination object has the following structure:
* {
* perPage: Number,
* page: Number
* total: Number
* }
*/
pagination: {},
/**
* Sorting object has the following structure:
* {
* sort: String,
* orderBy: String
* }
*/
sorting: {
sort: 'desc',
orderBy: 'created_at',
},
/**
* The search query that is used to filter packages by name
*/
filterQuery: '',
});

View File

@ -0,0 +1,25 @@
import { LIST_KEY_PROJECT, TABLE_HEADER_FIELDS } from './constants';
export default isGroupPage =>
TABLE_HEADER_FIELDS.filter(f => f.key !== LIST_KEY_PROJECT || isGroupPage);
/**
* A small util function that works out if the delete action has deleted the
* last item on the current paginated page and if so, returns the previous
* page. This ensures the user won't end up on an empty paginated page.
*
* @param {number} currentPage The current page the user is on
* @param {number} perPage Number of items to display per page
* @param {number} totalPackages The total number of items
*/
export const getNewPaginationPage = (currentPage, perPage, totalItems) => {
if (totalItems <= perPage) {
return 1;
}
if (currentPage > 1 && (currentPage - 1) * perPage >= totalItems) {
return currentPage - 1;
}
return currentPage;
};

View File

@ -0,0 +1,142 @@
<script>
import PackageTags from './package_tags.vue';
import PublishMethod from './publish_method.vue';
import { GlButton, GlIcon, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { getPackageTypeLabel } from '../utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
name: 'PackageListRow',
components: {
GlButton,
GlIcon,
GlLink,
GlSprintf,
PackageTags,
PublishMethod,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
props: {
packageEntity: {
type: Object,
required: true,
},
packageLink: {
type: String,
required: true,
},
disableDelete: {
type: Boolean,
default: false,
required: false,
},
isGroup: {
type: Boolean,
default: false,
required: false,
},
showPackageType: {
type: Boolean,
default: true,
required: false,
},
},
computed: {
packageType() {
return getPackageTypeLabel(this.packageEntity.package_type);
},
hasPipeline() {
return Boolean(this.packageEntity.pipeline);
},
hasProjectLink() {
return Boolean(this.packageEntity.project_path);
},
deleteAvailable() {
return !this.disableDelete && !this.isGroup;
},
},
};
</script>
<template>
<div class="gl-responsive-table-row" data-qa-selector="packages-row">
<div class="table-section section-50 d-flex flex-md-column justify-content-between flex-wrap">
<div class="d-flex align-items-center mr-2">
<gl-link
:href="packageLink"
data-qa-selector="package_link"
class="text-dark font-weight-bold mb-md-1"
>
{{ packageEntity.name }}
</gl-link>
<package-tags
v-if="packageEntity.tags && packageEntity.tags.length"
class="gl-ml-3"
:tags="packageEntity.tags"
hide-label
:tag-display-limit="1"
/>
</div>
<div class="d-flex text-secondary text-truncate mt-md-2">
<span>{{ packageEntity.version }}</span>
<div v-if="hasPipeline" class="d-none d-md-inline-block ml-1">
<gl-sprintf :message="s__('PackageRegistry|published by %{author}')">
<template #author>{{ packageEntity.pipeline.user.name }}</template>
</gl-sprintf>
</div>
<div v-if="hasProjectLink" class="d-flex align-items-center">
<gl-icon name="review-list" class="text-secondary ml-2 mr-1" />
<gl-link
data-testid="packages-row-project"
:href="`/${packageEntity.project_path}`"
class="text-secondary"
>{{ packageEntity.projectPathName }}</gl-link
>
</div>
<div v-if="showPackageType" class="d-flex align-items-center" data-testid="package-type">
<gl-icon name="package" class="text-secondary ml-2 mr-1" />
<span>{{ packageType }}</span>
</div>
</div>
</div>
<div
class="table-section d-flex flex-md-column justify-content-between align-items-md-end"
:class="!deleteAvailable ? 'section-50' : 'section-40'"
>
<publish-method :package-entity="packageEntity" :is-group="isGroup" />
<div class="text-secondary order-0 order-md-1 mt-md-2">
<gl-sprintf :message="__('Created %{timestamp}')">
<template #timestamp>
<span v-gl-tooltip :title="tooltipTitle(packageEntity.created_at)">
{{ timeFormatted(packageEntity.created_at) }}
</span>
</template>
</gl-sprintf>
</div>
</div>
<div v-if="deleteAvailable" class="table-section section-10 d-flex justify-content-end">
<gl-button
data-testid="action-delete"
icon="remove"
category="primary"
variant="danger"
:title="s__('PackageRegistry|Remove package')"
:aria-label="s__('PackageRegistry|Remove package')"
:disabled="!packageEntity._links.delete_api_path"
@click="$emit('packageToDelete', packageEntity)"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,108 @@
<script>
import { GlBadge, GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { n__ } from '~/locale';
export default {
name: 'PackageTags',
components: {
GlBadge,
GlIcon,
GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
tagDisplayLimit: {
type: Number,
required: false,
default: 2,
},
tags: {
type: Array,
required: true,
default: () => [],
},
hideLabel: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
tagCount() {
return this.tags.length;
},
tagsToRender() {
return this.tags.slice(0, this.tagDisplayLimit);
},
moreTagsDisplay() {
return Math.max(0, this.tags.length - this.tagDisplayLimit);
},
moreTagsTooltip() {
if (this.moreTagsDisplay) {
return this.tags
.slice(this.tagDisplayLimit)
.map(x => x.name)
.join(', ');
}
return '';
},
tagsDisplay() {
return n__('%d tag', '%d tags', this.tagCount);
},
},
methods: {
tagBadgeClass(index) {
return {
'gl-display-none': true,
'gl-display-flex': this.tagCount === 1,
'd-md-flex': this.tagCount > 1,
'gl-mr-2': index !== this.tagsToRender.length - 1,
'gl-ml-3': !this.hideLabel && index === 0,
};
},
},
};
</script>
<template>
<div class="gl-display-flex gl-align-items-center">
<div v-if="!hideLabel" data-testid="tagLabel" class="gl-display-flex gl-align-items-center">
<gl-icon name="labels" class="gl-text-gray-700 gl-mr-3" />
<span class="gl-font-weight-bold">{{ tagsDisplay }}</span>
</div>
<gl-badge
v-for="(tag, index) in tagsToRender"
:key="index"
data-testid="tagBadge"
:class="tagBadgeClass(index)"
variant="info"
>{{ tag.name }}</gl-badge
>
<gl-badge
v-if="moreTagsDisplay"
v-gl-tooltip
data-testid="moreBadge"
variant="muted"
:title="moreTagsTooltip"
class="gl-display-none d-md-flex gl-ml-2"
><gl-sprintf :message="__('+%{tags} more')">
<template #tags>
{{ moreTagsDisplay }}
</template>
</gl-sprintf></gl-badge
>
<gl-badge
v-if="moreTagsDisplay && hideLabel"
data-testid="moreBadge"
variant="muted"
class="d-md-none gl-ml-2"
>{{ tagsDisplay }}</gl-badge
>
</div>
</template>

View File

@ -0,0 +1,86 @@
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
export default {
components: {
GlSkeletonLoader,
},
props: {
isGroup: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
desktopShapes() {
return this.isGroup ? this.$options.shapes.groups : this.$options.shapes.projects;
},
desktopHeight() {
return this.isGroup ? 38 : 54;
},
mobileHeight() {
return this.isGroup ? 160 : 170;
},
},
shapes: {
groups: [
{ type: 'rect', width: '100', height: '10', x: '0', y: '15' },
{ type: 'rect', width: '100', height: '10', x: '195', y: '15' },
{ type: 'rect', width: '60', height: '10', x: '475', y: '15' },
{ type: 'rect', width: '60', height: '10', x: '675', y: '15' },
{ type: 'rect', width: '100', height: '10', x: '900', y: '15' },
],
projects: [
{ type: 'rect', width: '220', height: '10', x: '0', y: '20' },
{ type: 'rect', width: '60', height: '10', x: '305', y: '20' },
{ type: 'rect', width: '60', height: '10', x: '535', y: '20' },
{ type: 'rect', width: '100', height: '10', x: '760', y: '20' },
{ type: 'rect', width: '30', height: '30', x: '970', y: '10', ref: 'button-loader' },
],
},
rowsToRender: {
mobile: 5,
desktop: 20,
},
};
</script>
<template>
<div>
<div class="d-xs-flex flex-column d-md-none">
<gl-skeleton-loader
v-for="index in $options.rowsToRender.mobile"
:key="index"
:width="500"
:height="mobileHeight"
preserve-aspect-ratio="xMinYMax meet"
>
<rect width="500" height="10" x="0" y="15" rx="4" />
<rect width="500" height="10" x="0" y="45" rx="4" />
<rect width="500" height="10" x="0" y="75" rx="4" />
<rect width="500" height="10" x="0" y="105" rx="4" />
<rect v-if="isGroup" width="500" height="10" x="0" y="135" rx="4" />
<rect v-else width="30" height="30" x="470" y="135" rx="4" />
</gl-skeleton-loader>
</div>
<div class="d-none d-md-flex flex-column">
<gl-skeleton-loader
v-for="index in $options.rowsToRender.desktop"
:key="index"
:width="1000"
:height="desktopHeight"
preserve-aspect-ratio="xMinYMax meet"
>
<component
:is="r.type"
v-for="(r, rIndex) in desktopShapes"
:key="rIndex"
rx="4"
v-bind="r"
/>
</gl-skeleton-loader>
</div>
</div>
</template>

View File

@ -0,0 +1,61 @@
<script>
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { GlIcon, GlLink } from '@gitlab/ui';
import { getCommitLink } from '../utils';
export default {
name: 'PublishMethod',
components: {
ClipboardButton,
GlIcon,
GlLink,
},
props: {
packageEntity: {
type: Object,
required: true,
},
isGroup: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
hasPipeline() {
return Boolean(this.packageEntity.pipeline);
},
packageShaShort() {
return this.packageEntity.pipeline?.sha.substring(0, 8);
},
linkToCommit() {
return getCommitLink(this.packageEntity, this.isGroup);
},
},
};
</script>
<template>
<div class="d-flex align-items-center text-secondary order-1 order-md-0 mb-md-1">
<template v-if="hasPipeline">
<gl-icon name="git-merge" class="mr-1" />
<strong ref="pipeline-ref" class="mr-1 text-dark">{{ packageEntity.pipeline.ref }}</strong>
<gl-icon name="commit" class="mr-1" />
<gl-link ref="pipeline-sha" :href="linkToCommit" class="mr-1">{{ packageShaShort }}</gl-link>
<clipboard-button
:text="packageEntity.pipeline.sha"
:title="__('Copy commit SHA')"
css-class="border-0 text-secondary py-0 px-1"
/>
</template>
<template v-else>
<gl-icon name="upload" class="mr-1" />
<strong ref="manual-ref" class="text-dark">{{
s__('PackageRegistry|Manually Published')
}}</strong>
</template>
</div>
</template>

View File

@ -0,0 +1,23 @@
export const PackageType = {
CONAN: 'conan',
MAVEN: 'maven',
NPM: 'npm',
NUGET: 'nuget',
PYPI: 'pypi',
};
export const TrackingActions = {
DELETE_PACKAGE: 'delete_package',
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 = {
[PackageType.MAVEN]: 'MavenPackages',
[PackageType.NPM]: 'NpmPackages',
[PackageType.CONAN]: 'ConanPackages',
};

View File

@ -0,0 +1,34 @@
import { s__ } from '~/locale';
import { PackageType, TrackingCategories } from './constants';
export const packageTypeToTrackCategory = type =>
// eslint-disable-next-line @gitlab/require-i18n-strings
`UI::${TrackingCategories[type]}`;
export const beautifyPath = path => (path ? path.split('/').join(' / ') : '');
export const getPackageTypeLabel = packageType => {
switch (packageType) {
case PackageType.CONAN:
return s__('PackageType|Conan');
case PackageType.MAVEN:
return s__('PackageType|Maven');
case PackageType.NPM:
return s__('PackageType|NPM');
case PackageType.NUGET:
return s__('PackageType|NuGet');
case PackageType.PYPI:
return s__('PackageType|PyPi');
default:
return null;
}
};
export const getCommitLink = ({ project_path: projectPath, pipeline = {} }, isGroup = false) => {
if (isGroup) {
return `/${projectPath}/commit/${pipeline.sha}`;
}
return `../commit/${pipeline.sha}`;
};

View File

@ -0,0 +1,7 @@
import initPackageList from '~/packages/list/packages_list_app_bundle';
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('js-vue-packages-list')) {
initPackageList();
}
});

View File

@ -0,0 +1,7 @@
import initPackageList from '~/packages/list/packages_list_app_bundle';
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('js-vue-packages-list')) {
initPackageList();
}
});

View File

@ -0,0 +1,3 @@
import initPackageDetail from '~/packages/details/';
document.addEventListener('DOMContentLoaded', initPackageDetail);

View File

@ -0,0 +1,11 @@
.commit-row-description {
border: 0;
border-left: 3px solid $white-dark;
}
.package-list-table[aria-busy='true'] {
td {
padding-bottom: 0;
padding-top: 0;
}
}

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
module PackagesAccess
extend ActiveSupport::Concern
included do
before_action :verify_packages_enabled!
before_action :verify_read_package!
end
private
def verify_packages_enabled!
render_404 unless Gitlab.config.packages.enabled
end
def verify_read_package!
authorize_read_package!(project)
end
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
module Groups
class PackagesController < Groups::ApplicationController
before_action :verify_packages_enabled!
private
def verify_packages_enabled!
render_404 unless group.packages_feature_enabled?
end
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
module Projects
module Packages
class PackageFilesController < ApplicationController
include PackagesAccess
include SendFileUpload
def download
package_file = project.package_files.find(params[:id])
send_upload(package_file.file, attachment: package_file.file_name)
end
end
end
end

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
module Projects
module Packages
class PackagesController < Projects::ApplicationController
include PackagesAccess
before_action :authorize_destroy_package!, only: [:destroy]
def show
@package = project.packages.find(params[:id])
@package_files = @package.package_files.recent
@maven_metadatum = @package.maven_metadatum
end
def destroy
@package = project.packages.find(params[:id])
@package.destroy
redirect_to project_packages_path(@project), status: :found, notice: _('Package was removed')
end
end
end
end

View File

@ -392,6 +392,7 @@ class ProjectsController < Projects::ApplicationController
:initialize_with_readme,
:autoclose_referenced_issues,
:suggestion_commit_message,
:packages_enabled,
:service_desk_enabled,
project_feature_attributes: %i[

View File

@ -48,24 +48,40 @@ module BlobHelper
return unless blob = readable_blob(options, path, project, ref)
common_classes = "btn btn-primary js-edit-blob ml-2 #{options[:extra_class]}"
data = { track_event: 'click_edit', track_label: 'Edit' }
if Feature.enabled?(:web_ide_primary_edit, project.group)
common_classes += " btn-inverted"
data[:track_property] = 'secondary'
end
edit_button_tag(blob,
common_classes,
_('Edit'),
edit_blob_path(project, ref, path, options),
project,
ref)
ref,
data)
end
def ide_edit_button(project = @project, ref = @ref, path = @path, blob:)
return unless blob
common_classes = 'btn btn-primary ide-edit-button ml-2'
data = { track_event: 'click_edit_ide', track_label: 'Web IDE' }
unless Feature.enabled?(:web_ide_primary_edit, project.group)
common_classes += " btn-inverted"
data[:track_property] = 'secondary'
end
edit_button_tag(blob,
'btn btn-inverted btn-primary ide-edit-button ml-2',
common_classes,
_('Web IDE'),
ide_edit_path(project, ref, path),
project,
ref)
ref,
data)
end
def modify_file_button(project = @project, ref = @ref, path = @path, blob:, label:, action:, btn_class:, modal_type:)
@ -325,16 +341,16 @@ module BlobHelper
button_tag(button_text, class: "#{common_classes} disabled has-tooltip", title: _('You can only edit files when you are on a branch'), data: { container: 'body' })
end
def edit_link_tag(link_text, edit_path, common_classes)
link_to link_text, edit_path, class: "#{common_classes} btn-sm"
def edit_link_tag(link_text, edit_path, common_classes, data)
link_to link_text, edit_path, class: "#{common_classes} btn-sm", data: data
end
def edit_button_tag(blob, common_classes, text, edit_path, project, ref)
def edit_button_tag(blob, common_classes, text, edit_path, project, ref, data)
if !on_top_of_branch?(project, ref)
edit_disabled_button_tag(text, common_classes)
# This condition only applies to users who are logged in
elsif !current_user || (current_user && can_modify_blob?(blob, project, ref))
edit_link_tag(text, edit_path, common_classes)
edit_link_tag(text, edit_path, common_classes, data)
elsif can?(current_user, :fork_project, project) && can?(current_user, :create_merge_request_in, project)
edit_fork_button_tag(common_classes, project, text, edit_blob_fork_params(edit_path))
end

View File

@ -28,6 +28,7 @@ module GroupsHelper
def group_packages_nav_link_paths
%w[
groups/packages#index
groups/container_registries#index
]
end
@ -157,6 +158,15 @@ module GroupsHelper
groups.to_json
end
def group_packages_nav?
group_packages_list_nav? ||
group_container_registry_nav?
end
def group_packages_list_nav?
@group.packages_feature_enabled?
end
private
def get_group_sidebar_links

View File

@ -0,0 +1,55 @@
# frozen_string_literal: true
module PackagesHelper
def package_sort_path(options = {})
"#{request.path}?#{options.to_param}"
end
def nuget_package_registry_url(project_id)
expose_url(api_v4_projects_packages_nuget_index_path(id: project_id, format: '.json'))
end
def package_registry_instance_url(registry_type)
expose_url("api/#{::API::API.version}/packages/#{registry_type}")
end
def package_registry_project_url(project_id, registry_type = :maven)
project_api_path = expose_path(api_v4_projects_path(id: project_id))
package_registry_project_path = "#{project_api_path}/packages/#{registry_type}"
expose_url(package_registry_project_path)
end
def package_from_presenter(package)
presenter = ::Packages::Detail::PackagePresenter.new(package)
presenter.detail_view.to_json
end
def pypi_registry_url(project_id)
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

View File

@ -429,9 +429,19 @@ module ProjectsHelper
apply_external_nav_tabs(nav_tabs, project)
nav_tabs += package_nav_tabs(project, current_user)
nav_tabs
end
def package_nav_tabs(project, current_user)
[].tap do |tabs|
if ::Gitlab.config.packages.enabled && can?(current_user, :read_package, project)
tabs << :packages
end
end
end
def apply_external_nav_tabs(nav_tabs, project)
nav_tabs << :external_issue_tracker if project.external_issue_tracker
nav_tabs << :external_wiki if project.external_wiki
@ -584,6 +594,7 @@ module ProjectsHelper
def project_permissions_settings(project)
feature = project.project_feature
{
packagesEnabled: !!project.packages_enabled,
visibilityLevel: project.visibility_level,
requestAccessEnabled: !!project.request_access_enabled,
issuesAccessLevel: feature.issues_access_level,
@ -604,6 +615,8 @@ module ProjectsHelper
def project_permissions_panel_data(project)
{
packagesAvailable: ::Gitlab.config.packages.enabled,
packagesHelpPath: help_page_path('user/packages/index'),
currentSettings: project_permissions_settings(project),
canDisableEmails: can_disable_emails?(project, current_user),
canChangeVisibilityLevel: can_change_visibility_level?(project, current_user),

View File

@ -581,6 +581,47 @@ module SortingHelper
def sort_value_expire_date
'expired_asc'
end
def packages_sort_options_hash
{
sort_value_recently_created => sort_title_created_date,
sort_value_oldest_created => sort_title_created_date,
sort_value_name => sort_title_name,
sort_value_name_desc => sort_title_name,
sort_value_version_desc => sort_title_version,
sort_value_version_asc => sort_title_version,
sort_value_type_desc => sort_title_type,
sort_value_type_asc => sort_title_type,
sort_value_project_name_desc => sort_title_project_name,
sort_value_project_name_asc => sort_title_project_name
}
end
def packages_reverse_sort_order_hash
{
sort_value_recently_created => sort_value_oldest_created,
sort_value_oldest_created => sort_value_recently_created,
sort_value_name => sort_value_name_desc,
sort_value_name_desc => sort_value_name,
sort_value_version_desc => sort_value_version_asc,
sort_value_version_asc => sort_value_version_desc,
sort_value_type_desc => sort_value_type_asc,
sort_value_type_asc => sort_value_type_desc,
sort_value_project_name_desc => sort_value_project_name_asc,
sort_value_project_name_asc => sort_value_project_name_desc
}
end
def packages_sort_option_title(sort_value)
packages_sort_options_hash[sort_value] || sort_title_created_date
end
def packages_sort_direction_button(sort_value)
reverse_sort = packages_reverse_sort_order_hash[sort_value]
url = package_sort_path(sort: reverse_sort)
sort_direction_button(url, reverse_sort, sort_value)
end
end
SortingHelper.prepend_if_ee('::EE::SortingHelper')

View File

@ -45,7 +45,7 @@ class Packages::PackageFile < ApplicationRecord
end
def download_path
Gitlab::Routing.url_helpers.download_project_package_file_path(project, self) if ::Gitlab.ee?
Gitlab::Routing.url_helpers.download_project_package_file_path(project, self)
end
def local?

View File

@ -9,7 +9,7 @@ module MergeRequests
def execute
return [] if branch_names.blank?
source_branches = project.source_of_merge_requests.opened
source_branches = project.source_of_merge_requests.open_and_closed
.from_source_branches(branch_names).pluck(:source_branch)
target_branches = project.merge_requests.opened

View File

@ -0,0 +1,59 @@
- sort_value = @sort
- sort_title = packages_sort_option_title(sort_value)
- if @packages.any?
.d-flex.justify-content-end
.dropdown.inline.gl-mt-3.gl-mb-3.package-sort-dropdown
.btn-group{ role: 'group' }
.btn-group{ role: 'group' }
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static', 'qa-selector': 'sort-dropdown-button' }, class: 'btn btn-default' }
= sort_title
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
%li
= sortable_item(sort_title_created_date, package_sort_path(sort: sort_value_recently_created), sort_title)
= sortable_item(sort_title_name, package_sort_path(sort: sort_value_name_desc), sort_title)
= sortable_item(sort_title_project_name, package_sort_path(sort: sort_value_project_name_desc), sort_title)
= sortable_item(sort_title_version, package_sort_path(sort: sort_value_version_desc), sort_title)
= sortable_item(sort_title_type, package_sort_path(sort: sort_value_type_desc), sort_title)
= packages_sort_direction_button(sort_value)
.table-holder
.gl-responsive-table-row.table-row-header.bg-secondary-50.px-2.border-top{ role: 'row' }
.table-section.section-30{ role: 'rowheader' }
= _('Name')
.table-section.section-20{ role: 'rowheader' }
= _('Project')
.table-section.section-20{ role: 'rowheader' }
= _('Version')
.table-section.section-10{ role: 'rowheader' }
= _('Type')
.table-section.section-20{ role: 'rowheader' }
= _('Created')
- @packages.each do |package|
.gl-responsive-table-row{ data: { 'qa-selector': 'package-row' } }
.table-section.section-30
.table-mobile-header{ role: "rowheader" }= _("Name")
.table-mobile-content.flex-truncate-parent
= link_to package.name, project_package_path(package.project, package), class: 'flex-truncate-child'
.table-section.section-20
.table-mobile-header{ role: "rowheader" }= _("Project")
.table-mobile-content
= link_to_project(package.project)
.table-section.section-20
.table-mobile-header{ role: "rowheader" }= _("Version")
.table-mobile-content
= package.version
.table-section.section-10
.table-mobile-header{ role: "rowheader" }= _("Type")
.table-mobile-content
= package.package_type
.table-section.section-20
.table-mobile-header{ role: "rowheader" }= _("Created")
.table-mobile-content
= time_ago_with_tooltip(package.created_at)
= paginate @packages, theme: "gitlab"
- else
.row.empty-state
.col-12
= render 'shared/packages/no_packages'

View File

@ -0,0 +1,5 @@
- page_title _("Packages")
.row
.col-12
#js-vue-packages-list{ data: packages_list_data('groups', @group) }

View File

@ -1,16 +1,23 @@
- if group_container_registry_nav?
= nav_link(controller: 'groups/registry/repositories') do
= link_to group_container_registries_path(@group), title: _('Container Registry') do
- packages_link = group_packages_list_nav? ? group_packages_path(@group) : group_container_registries_path(@group)
- if group_packages_nav?
= nav_link(controller: ['groups/packages', 'groups/registry/repositories']) do
= link_to packages_link, title: _('Packages') do
.nav-icon-container
= sprite_icon('package')
%span.nav-item-name
= _('Packages & Registries')
%ul.sidebar-sub-level-items
= nav_link(controller: 'groups/registry/repositories', html_options: { class: "fly-out-top-item" } ) do
= link_to group_container_registries_path(@group), title: _('Container Registry') do
= nav_link(controller: [:packages, :repositories], html_options: { class: "fly-out-top-item" } ) do
= link_to packages_link, title: _('Packages & Registries') do
%strong.fly-out-top-item-name
= _('Packages & Registries')
%li.divider.fly-out-top-item
= nav_link(controller: 'groups/registry/repositories') do
= link_to group_container_registries_path(@group), title: _('Container Registry') do
%span= _('Container Registry')
- if group_packages_list_nav?
= nav_link(controller: 'groups/packages') do
= link_to group_packages_path(@group), title: _('Packages') do
%span= _('Package Registry')
- if group_container_registry_nav?
= nav_link(controller: 'groups/registry/repositories') do
= link_to group_container_registries_path(@group), title: _('Container Registry') do
%span= _('Container Registry')

View File

@ -1,16 +1,23 @@
- if project_nav_tab? :container_registry
= nav_link controller: :repositories do
= link_to project_container_registry_index_path(@project) do
- packages_link = project_nav_tab?(:packages) ? project_packages_path(@project) : project_container_registry_index_path(@project)
- if (project_nav_tab?(:packages) || project_nav_tab?(:container_registry))
= nav_link controller: [:packages, :repositories] do
= link_to packages_link, data: { qa_selector: 'packages_link' } do
.nav-icon-container
= sprite_icon('package')
%span.nav-item-name
= _('Packages & Registries')
%ul.sidebar-sub-level-items
= nav_link(controller: :repositories, html_options: { class: "fly-out-top-item" } ) do
= link_to project_container_registry_index_path(@project) do
= nav_link(controller: [:packages, :repositories], html_options: { class: "fly-out-top-item" } ) do
= link_to packages_link do
%strong.fly-out-top-item-name
= _('Packages & Registries')
%li.divider.fly-out-top-item
= nav_link controller: :repositories do
= link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry', title: _('Container Registry') do
%span= _('Container Registry')
- if project_nav_tab? :packages
= nav_link controller: :packages do
= link_to project_packages_path(@project), title: _('Package Registry') do
%span= _('Package Registry')
- if project_nav_tab? :container_registry
= nav_link controller: :repositories do
= link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry', title: _('Container Registry') do
%span= _('Container Registry')

View File

@ -1,3 +1,8 @@
- requirements_link_url = help_page_path('user/project/issues/design_management', anchor: 'requirements')
- requirements_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: requirements_link_url }
- link_end = '</a>'.html_safe
- enable_lfs_message = s_("DesignManagement|To upload designs, you'll need to %{requirements_link_start}enable LFS%{requirements_link_end}.").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end }
- if @project.design_management_enabled?
- if Feature.enabled?(:design_management_moved, @project, default_enabled: true)
.js-design-management-new{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } }
@ -5,13 +10,8 @@
.js-design-management{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } }
- else
- if Feature.enabled?(:design_management_moved, @project, default_enabled: true)
.row.empty-state.design-dropzone-border.gl-mt-5
.text-content.center.gl-font-weight-bold
- requirements_link_url = help_page_path('user/project/issues/design_management', anchor: 'requirements')
- requirements_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: requirements_link_url }
- support_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: support_url }
- link_end = '</a>'.html_safe
= s_("DesignManagement|To enable design management, you'll need to %{requirements_link_start}meet the requirements%{requirements_link_end}. If you need help, reach out to our %{support_link_start}support team%{support_link_end} for assistance.").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end, support_link_start: support_link_start, support_link_end: link_end }
.gl-border-solid.gl-border-1.gl-border-gray-100.gl-rounded-base.gl-mt-5.gl-p-3.gl-text-center
= enable_lfs_message
- else
.mt-4
.row.empty-state
@ -20,8 +20,4 @@
%h4.center
= _('The one place for your designs')
%p.center
- requirements_link_url = help_page_path('user/project/issues/design_management', anchor: 'requirements')
- requirements_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: requirements_link_url }
- support_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: support_url }
- link_end = '</a>'.html_safe
= s_("DesignManagement|To enable design management, you'll need to %{requirements_link_start}meet the requirements%{requirements_link_end}. If you need help, reach out to our %{support_link_start}support team%{support_link_end} for assistance.").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end, support_link_start: support_link_start, support_link_end: link_end }
= enable_lfs_message

View File

@ -0,0 +1,60 @@
- sort_value = @sort
- sort_title = packages_sort_option_title(sort_value)
- if @packages.any?
.d-flex.justify-content-end
.dropdown.inline.gl-mt-3.gl-mb-3.package-sort-dropdown
.btn-group{ role: 'group' }
.btn-group{ role: 'group' }
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' }
= sort_title
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
%li
= sortable_item(sort_title_created_date, package_sort_path(sort: sort_value_recently_created), sort_title)
= sortable_item(sort_title_name, package_sort_path(sort: sort_value_name_desc), sort_title)
= sortable_item(sort_title_version, package_sort_path(sort: sort_value_version_desc), sort_title)
= sortable_item(sort_title_type, package_sort_path(sort: sort_value_type_desc), sort_title)
= packages_sort_direction_button(sort_value)
.table-holder
.gl-responsive-table-row.table-row-header.bg-secondary-50.px-2.border-top{ role: 'row' }
.table-section.section-30{ role: 'rowheader' }
= _('Name')
.table-section.section-20{ role: 'rowheader' }
= _('Version')
.table-section.section-20{ role: 'rowheader' }
= _('Type')
.table-section.section-20{ role: 'rowheader' }
= _('Created')
.table-section.section-10{ role: 'rowheader' }
- @packages.each do |package|
.gl-responsive-table-row.package-row.px-2{ data: { qa_selector: "package_row" } }
.table-section.section-30
.table-mobile-header{ role: "rowheader" }= _("Name")
.table-mobile-content.flex-truncate-parent
= link_to package.name, project_package_path(@project, package), class: 'flex-truncate-child', data: { qa_selector: "package_link" }
.table-section.section-20
.table-mobile-header{ role: "rowheader" }= _("Version")
.table-mobile-content
= package.version
.table-section.section-20
.table-mobile-header{ role: "rowheader" }= _("Type")
.table-mobile-content
= package.package_type
.table-section.section-20
.table-mobile-header{ role: "rowheader" }= _("Created")
.table-mobile-content
= time_ago_with_tooltip(package.created_at)
.table-section.section-10
.table-mobile-header{ role: "rowheader" }
.table-mobile-content
- if can_destroy_package
.pull-right
= link_to project_package_path(@project, package), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-remove", title: _('Delete Package') do
= icon('trash')
= paginate @packages, theme: "gitlab"
- else
.row.empty-state
.col-12
= render 'shared/packages/no_packages'

View File

@ -0,0 +1,5 @@
- page_title _("Packages")
.row
.col-12
#js-vue-packages-list{ data: packages_list_data('projects', @project) }

View File

@ -0,0 +1,22 @@
- add_to_breadcrumbs _("Packages"), project_packages_path(@project)
- add_to_breadcrumbs @package.name, project_packages_path(@project)
- breadcrumb_title @package.version
- page_title _("Packages")
.row
.col-12
#js-vue-packages-detail{ data: { package: package_from_presenter(@package),
can_delete: can?(current_user, :destroy_package, @project).to_s,
destroy_path: project_package_path(@project, @package),
svg_path: image_path('illustrations/no-packages.svg'),
npm_path: package_registry_instance_url(:npm),
npm_help_path: help_page_path('user/packages/npm_registry/index'),
maven_path: package_registry_project_url(@project.id, :maven),
maven_help_path: help_page_path('user/packages/maven_repository/index'),
conan_path: package_registry_instance_url(:conan),
conan_help_path: help_page_path('user/packages/conan_repository/index'),
nuget_path: nuget_package_registry_url(@project.id),
nuget_help_path: help_page_path('user/packages/nuget_repository/index'),
pypi_path: pypi_registry_url(@project.id),
pypi_setup_path: package_registry_project_url(@project.id, :pypi),
pypi_help_path: help_page_path('user/packages/pypi_repository/index') } }

View File

@ -1,5 +1,5 @@
- test_reports_enabled = Feature.enabled?(:junit_pipeline_view, @project)
- dag_pipeline_tab_enabled = Feature.enabled?(:dag_pipeline_tab, @project, default_enabled: false)
- dag_pipeline_tab_enabled = Feature.enabled?(:dag_pipeline_tab, @project, default_enabled: true)
.tabs-holder
%ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator.nav.nav-tabs

View File

@ -0,0 +1,7 @@
.svg-content= image_tag 'illustrations/no-packages.svg'
.text-content
%h4.text-center= _('There are no packages yet')
%p
- no_packages_url = help_page_path('administration/packages/index')
- no_packages_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: no_packages_url }
= _('Learn how to %{no_packages_link_start}publish and share your packages%{no_packages_link_end} with GitLab.').html_safe % { no_packages_link_start: no_packages_link_start, no_packages_link_end: '</a>'.html_safe }

View File

@ -0,0 +1,5 @@
---
title: Reduce 'cached' query calls for Banzai
merge_request: 36735
author:
type: performance

View File

@ -0,0 +1,5 @@
---
title: Add target_details column to AuditEvent table
merge_request: 37430
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Package feature moved to core
merge_request: 36667
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Update closed MRs on push
merge_request: 37414
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Shorten 'enable LFS' manage for design management
merge_request: 37385
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Refactor all factories to fix SaveBang Cop
merge_request: 37268
author: Rajendra Kadam
type: fixed

View File

@ -55,6 +55,8 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
post :toggle_subscription, on: :member
end
resources :packages, only: [:index]
resources :milestones, constraints: { id: %r{[^/]+} } do
member do
get :merge_requests

View File

@ -30,6 +30,13 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :artifacts, only: [:index, :destroy]
resources :packages, only: [:index, :show, :destroy], module: :packages
resources :package_files, only: [], module: :packages do
member do
get :download
end
end
resources :jobs, only: [:index, :show], constraints: { id: /\d+/ } do
collection do
resources :artifacts, only: [] do

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class AddTargetDetailsToAuditEvent < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
with_lock_retries do
# rubocop:disable Migration/AddLimitToTextColumns
add_column(:audit_events, :target_details, :text)
# rubocop:enable Migration/AddLimitToTextColumns
end
end
def down
with_lock_retries do
remove_column(:audit_events, :target_details)
end
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class AddTextLimitToAuditEventTargetDetails < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_text_limit :audit_events, :target_details, 5_500
end
def down
remove_text_limit :audit_events, :target_details
end
end

View File

@ -9400,8 +9400,10 @@ CREATE TABLE public.audit_events (
ip_address inet,
author_name text,
entity_path text,
target_details text,
CONSTRAINT check_492aaa021d CHECK ((char_length(entity_path) <= 5500)),
CONSTRAINT check_83ff8406e2 CHECK ((char_length(author_name) <= 255))
CONSTRAINT check_83ff8406e2 CHECK ((char_length(author_name) <= 255)),
CONSTRAINT check_d493ec90b5 CHECK ((char_length(target_details) <= 5500))
);
CREATE SEQUENCE public.audit_events_id_seq
@ -23987,10 +23989,12 @@ COPY "schema_migrations" (version) FROM STDIN;
20200713071042
20200713141854
20200713152443
20200715124210
20200715135130
20200715202659
20200716044023
20200716120419
20200716145156
20200718040100
20200718040200
20200718040300

View File

@ -13,9 +13,7 @@ module API
expose :_links do
expose :web_path do |package|
if ::Gitlab.ee?
::Gitlab::Routing.url_helpers.project_package_path(package.project, package)
end
::Gitlab::Routing.url_helpers.project_package_path(package.project, package)
end
expose :delete_api_path, if: can_destroy(:package, &:project) do |package|

View File

@ -35,6 +35,7 @@ module API
end
end
expose :packages_enabled
expose :empty_repo?, as: :empty_repo
expose :archived?, as: :archived
expose :visibility

View File

@ -61,6 +61,7 @@ module API
optional :auto_devops_deploy_strategy, type: String, values: %w(continuous manual timed_incremental), desc: 'Auto Deploy strategy'
optional :autoclose_referenced_issues, type: Boolean, desc: 'Flag indication if referenced issues auto-closing is enabled'
optional :repository_storage, type: String, desc: 'Which storage shard the repository is on. Available only to admins'
optional :packages_enabled, type: Boolean, desc: 'Enable project packages feature'
end
params :optional_project_params_ee do
@ -137,6 +138,7 @@ module API
:suggestion_commit_message,
:repository_storage,
:compliance_framework_setting,
:packages_enabled,
:service_desk_enabled,
# TODO: remove in API v5, replaced by *_access_level

View File

@ -19,7 +19,7 @@ module Banzai
unescaped_html = unescape_html_entities(text).gsub(pattern) do |match|
namespace, project = $~[:namespace], $~[:project]
project_path = full_project_path(namespace, project)
label = find_label(project_path, $~[:label_id], $~[:label_name])
label = find_label_cached(project_path, $~[:label_id], $~[:label_name])
if label
labels[label.id] = yield match, label.id, project, namespace, $~
@ -34,6 +34,12 @@ module Banzai
escape_with_placeholders(unescaped_html, labels)
end
def find_label_cached(parent_ref, label_id, label_name)
cached_call(:banzai_find_label_cached, label_name&.tr('"', '') || label_id, path: [object_class, parent_ref]) do
find_label(parent_ref, label_id, label_name)
end
end
def find_label(parent_ref, label_id, label_name)
parent = parent_from_ref(parent_ref)
return unless parent

View File

@ -169,7 +169,8 @@ module Banzai
# been queried the object is returned from the cache.
def collection_objects_for_ids(collection, ids)
if Gitlab::SafeRequestStore.active?
ids = ids.map(&:to_i)
ids = ids.map(&:to_i).uniq
cache = collection_cache[collection_cache_key(collection)]
to_query = ids - cache.keys

View File

@ -9,10 +9,23 @@ module Banzai
Snippet
end
# Returns all the nodes that are visible to the given user.
def nodes_visible_to_user(user, nodes)
snippets = lazy { grouped_objects_for_nodes(nodes, references_relation, self.class.data_attribute) }
nodes.select do |node|
if node.has_attribute?(self.class.data_attribute)
can_read_reference?(user, snippets[node])
else
true
end
end
end
private
def can_read_reference?(user, ref_project, node)
can?(user, :read_snippet, referenced_by([node]).first)
def can_read_reference?(user, snippet)
can?(user, :read_snippet, snippet)
end
end
end

View File

@ -8195,7 +8195,7 @@ msgstr ""
msgid "DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again."
msgstr ""
msgid "DesignManagement|To enable design management, you'll need to %{requirements_link_start}meet the requirements%{requirements_link_end}. If you need help, reach out to our %{support_link_start}support team%{support_link_end} for assistance."
msgid "DesignManagement|To upload designs, you'll need to %{requirements_link_start}enable LFS%{requirements_link_end}."
msgstr ""
msgid "DesignManagement|Unresolve thread"

View File

@ -277,6 +277,11 @@ module QA
autoload :Show, 'qa/page/project/job/show'
end
module Packages
autoload :Index, 'qa/page/project/packages/index'
autoload :Show, 'qa/page/project/packages/show'
end
module Settings
autoload :Advanced, 'qa/page/project/settings/advanced'
autoload :Main, 'qa/page/project/settings/main'
@ -315,6 +320,7 @@ module QA
autoload :Repository, 'qa/page/project/sub_menus/repository'
autoload :Settings, 'qa/page/project/sub_menus/settings'
autoload :Project, 'qa/page/project/sub_menus/project'
autoload :Packages, 'qa/page/project/sub_menus/packages'
end
module Issue

View File

@ -169,7 +169,7 @@ module QA
end
def has_element?(name, **kwargs)
wait_for_requests
wait_for_requests(skip_finished_loading_check: !!kwargs.delete(:skip_finished_loading_check))
disabled = kwargs.delete(:disabled)
@ -209,15 +209,6 @@ module QA
has_text?(text.gsub(/\s+/, " "), wait: wait)
end
def finished_loading?
wait_for_requests
# The number of selectors should be able to be reduced after
# migration to the new spinner is complete.
# https://gitlab.com/groups/gitlab-org/-/epics/956
has_no_css?('.gl-spinner, .fa-spinner, .spinner', wait: QA::Support::Repeater::DEFAULT_MAX_WAIT_TIME)
end
def finished_loading_block?
wait_for_requests

View File

@ -55,12 +55,10 @@ module QA
end
def fill_file_name(name)
finished_loading?
fill_element :file_name_field, name
end
def fill_file_content(content)
finished_loading?
text_area.set content
end

View File

@ -100,19 +100,16 @@ module QA
end
def has_file_content?(file_content)
finished_loading?
within_element(:file_content) do
has_text?(file_content)
end
end
def click_edit_button
finished_loading?
click_element(:snippet_action_button, action: 'Edit')
end
def click_delete_button
finished_loading?
click_element(:snippet_action_button, action: 'Delete')
click_element(:delete_snippet_button)
# wait for the page to reload after deletion
@ -123,32 +120,27 @@ module QA
end
def get_repository_uri_http
finished_loading?
click_element(:clone_button)
Git::Location.new(find_element(:copy_http_url_button)['data-clipboard-text']).uri.to_s
end
def get_repository_uri_ssh
finished_loading?
click_element(:clone_button)
Git::Location.new(find_element(:copy_ssh_url_button)['data-clipboard-text']).uri.to_s
end
def add_comment(comment)
finished_loading?
fill_element(:note_field, comment)
click_element(:comment_button)
end
def has_comment_author?(author_username)
finished_loading?
within_element(:note_author_content) do
has_text?('@' + author_username)
end
end
def has_comment_content?(comment_content)
finished_loading?
within_element(:note_content) do
has_text?(comment_content)
end
@ -161,14 +153,12 @@ module QA
end
def edit_comment(comment)
finished_loading?
click_element(:edit_comment_button)
fill_element(:edit_note_field, comment)
click_element(:save_comment_button)
end
def delete_comment(comment)
finished_loading?
click_element(:more_actions_dropdown)
accept_alert do
click_element(:delete_comment_button)

View File

@ -275,7 +275,7 @@ module QA
end
def wait_for_loading
finished_loading? && has_no_element?(:skeleton_note)
has_no_element?(:skeleton_note)
end
def click_open_in_web_ide

View File

@ -11,6 +11,7 @@ module QA
include SubMenus::Operations
include SubMenus::Repository
include SubMenus::Settings
include SubMenus::Packages
view 'app/views/layouts/nav/sidebar/_project.html.haml' do
element :activity_link

View File

@ -56,7 +56,7 @@ module QA
def await_installed(application_name)
within_element(application_name) do
has_element?(:uninstall_button, application: application_name, wait: 300)
has_element?(:uninstall_button, application: application_name, wait: 300, skip_finished_loading_check: true)
end
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
module QA
module Page
module Project
module Packages
class Index < QA::Page::Base
view 'app/views/projects/packages/packages/_legacy_package_list.html.haml' do
element :package_row
element :package_link
end
def click_package(name)
click_element(:package_link, text: name)
end
def has_package?(name)
has_element?(:package_link, text: name)
end
def has_no_package?(name)
has_no_element?(:package_link, text: name)
end
end
end
end
end
end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
module QA
module Page
module Project
module Packages
class Show < QA::Page::Base
view 'app/assets/javascripts/packages/details/components/app.vue' do
element :delete_button
element :delete_modal_button
element :package_information_content
end
def has_package_info?(name, version)
has_element?(:package_information_content, text: /#{name}.*#{version}/)
end
def click_delete
click_element(:delete_button)
wait_for_animated_element(:delete_modal_button)
click_element(:delete_modal_button)
end
end
end
end
end
end

View File

@ -14,6 +14,7 @@ module QA
def click_create_first_snippet
finished_loading?
# The svg takes a fraction of a second to load after which the
# "New snippet" button shifts up a bit. This can cause
# webdriver to miss the hit so we wait for the svg to load before

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
module QA
module Page
module Project
module SubMenus
module Packages
extend QA::Page::PageConcern
def self.included(base)
super
base.class_eval do
view 'app/views/layouts/nav/sidebar/_project_packages_link.html.haml' do
element :packages_link
end
end
end
def click_packages_link
within_sidebar do
click_element :packages_link
end
end
end
end
end
end
end

View File

@ -136,7 +136,6 @@ module QA
end
def create_first_file(file_name)
finished_loading?
click_element(:first_file_button, Page::Component::WebIDE::Modal::CreateNewFile)
fill_element(:file_name_field, file_name)
click_button('Create file')

Some files were not shown because too many files have changed in this diff Show More