summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-07-21 12:09:30 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-07-21 12:09:30 +0000
commitaf28a89d5e6a62811b462ca7d3adcccf8c03e213 (patch)
treebe14cd6b4adc411fe7f179c236ec0c2d2e472025 /app
parent5bd4297fd759a14ad9ab9232cb985d28bf44ac49 (diff)
downloadgitlab-ce-af28a89d5e6a62811b462ca7d3adcccf8c03e213.tar.gz
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/packages/details/components/activity.vue128
-rw-r--r--app/assets/javascripts/packages/details/components/app.vue343
-rw-r--r--app/assets/javascripts/packages/details/components/code_instruction.vue63
-rw-r--r--app/assets/javascripts/packages/details/components/conan_installation.vue60
-rw-r--r--app/assets/javascripts/packages/details/components/dependency_row.vue35
-rw-r--r--app/assets/javascripts/packages/details/components/information.vue64
-rw-r--r--app/assets/javascripts/packages/details/components/installation_tabs.vue37
-rw-r--r--app/assets/javascripts/packages/details/components/maven_installation.vue89
-rw-r--r--app/assets/javascripts/packages/details/components/npm_installation.vue87
-rw-r--r--app/assets/javascripts/packages/details/components/nuget_installation.vue60
-rw-r--r--app/assets/javascripts/packages/details/components/package_title.vue112
-rw-r--r--app/assets/javascripts/packages/details/components/pypi_installation.vue70
-rw-r--r--app/assets/javascripts/packages/details/constants.js47
-rw-r--r--app/assets/javascripts/packages/details/index.js32
-rw-r--r--app/assets/javascripts/packages/details/store/actions.js23
-rw-r--r--app/assets/javascripts/packages/details/store/getters.js106
-rw-r--r--app/assets/javascripts/packages/details/store/index.js20
-rw-r--r--app/assets/javascripts/packages/details/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/packages/details/store/mutations.js14
-rw-r--r--app/assets/javascripts/packages/details/utils.js91
-rw-r--r--app/assets/javascripts/packages/list/coming_soon/helpers.js55
-rw-r--r--app/assets/javascripts/packages/list/coming_soon/packages_coming_soon.vue172
-rw-r--r--app/assets/javascripts/packages/list/coming_soon/queries/issues.graphql20
-rw-r--r--app/assets/javascripts/packages/list/components/packages_filter.vue21
-rw-r--r--app/assets/javascripts/packages/list/components/packages_list.vue129
-rw-r--r--app/assets/javascripts/packages/list/components/packages_list_app.vue110
-rw-r--r--app/assets/javascripts/packages/list/components/packages_sort.vue60
-rw-r--r--app/assets/javascripts/packages/list/constants.js96
-rw-r--r--app/assets/javascripts/packages/list/packages_list_app_bundle.js31
-rw-r--r--app/assets/javascripts/packages/list/stores/actions.js73
-rw-r--r--app/assets/javascripts/packages/list/stores/getters.js5
-rw-r--r--app/assets/javascripts/packages/list/stores/index.js20
-rw-r--r--app/assets/javascripts/packages/list/stores/mutation_types.js8
-rw-r--r--app/assets/javascripts/packages/list/stores/mutations.js45
-rw-r--r--app/assets/javascripts/packages/list/stores/state.js51
-rw-r--r--app/assets/javascripts/packages/list/utils.js25
-rw-r--r--app/assets/javascripts/packages/shared/components/package_list_row.vue142
-rw-r--r--app/assets/javascripts/packages/shared/components/package_tags.vue108
-rw-r--r--app/assets/javascripts/packages/shared/components/packages_list_loader.vue86
-rw-r--r--app/assets/javascripts/packages/shared/components/publish_method.vue61
-rw-r--r--app/assets/javascripts/packages/shared/constants.js23
-rw-r--r--app/assets/javascripts/packages/shared/utils.js34
-rw-r--r--app/assets/javascripts/pages/groups/packages/index/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/packages/packages/index/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/packages/packages/show/index.js3
-rw-r--r--app/assets/stylesheets/pages/packages.scss11
-rw-r--r--app/controllers/concerns/packages_access.rb20
-rw-r--r--app/controllers/groups/packages_controller.rb13
-rw-r--r--app/controllers/projects/packages/package_files_controller.rb16
-rw-r--r--app/controllers/projects/packages/packages_controller.rb24
-rw-r--r--app/controllers/projects_controller.rb1
-rw-r--r--app/helpers/blob_helper.rb30
-rw-r--r--app/helpers/groups_helper.rb10
-rw-r--r--app/helpers/packages_helper.rb55
-rw-r--r--app/helpers/projects_helper.rb13
-rw-r--r--app/helpers/sorting_helper.rb41
-rw-r--r--app/models/packages/package_file.rb2
-rw-r--r--app/services/merge_requests/pushed_branches_service.rb2
-rw-r--r--app/views/groups/packages/_legacy_package_list.haml59
-rw-r--r--app/views/groups/packages/index.html.haml5
-rw-r--r--app/views/groups/sidebar/_packages.html.haml23
-rw-r--r--app/views/layouts/nav/sidebar/_project_packages_link.html.haml23
-rw-r--r--app/views/projects/issues/_design_management.html.haml20
-rw-r--r--app/views/projects/packages/packages/_legacy_package_list.html.haml60
-rw-r--r--app/views/projects/packages/packages/index.html.haml5
-rw-r--r--app/views/projects/packages/packages/show.html.haml22
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml2
-rw-r--r--app/views/shared/packages/_no_packages.html.haml7
68 files changed, 3301 insertions, 38 deletions
diff --git a/app/assets/javascripts/packages/details/components/activity.vue b/app/assets/javascripts/packages/details/components/activity.vue
new file mode 100644
index 00000000000..4088845a2a3
--- /dev/null
+++ b/app/assets/javascripts/packages/details/components/activity.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/details/components/app.vue b/app/assets/javascripts/packages/details/components/app.vue
new file mode 100644
index 00000000000..da4429f5134
--- /dev/null
+++ b/app/assets/javascripts/packages/details/components/app.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/details/components/code_instruction.vue b/app/assets/javascripts/packages/details/components/code_instruction.vue
new file mode 100644
index 00000000000..a300a885b58
--- /dev/null
+++ b/app/assets/javascripts/packages/details/components/code_instruction.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/details/components/conan_installation.vue b/app/assets/javascripts/packages/details/components/conan_installation.vue
new file mode 100644
index 00000000000..9a66347f08d
--- /dev/null
+++ b/app/assets/javascripts/packages/details/components/conan_installation.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/details/components/dependency_row.vue b/app/assets/javascripts/packages/details/components/dependency_row.vue
new file mode 100644
index 00000000000..cc3e7330521
--- /dev/null
+++ b/app/assets/javascripts/packages/details/components/dependency_row.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/details/components/information.vue b/app/assets/javascripts/packages/details/components/information.vue
new file mode 100644
index 00000000000..60bf1d40ff0
--- /dev/null
+++ b/app/assets/javascripts/packages/details/components/information.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/details/components/installation_tabs.vue b/app/assets/javascripts/packages/details/components/installation_tabs.vue
new file mode 100644
index 00000000000..603160b4563
--- /dev/null
+++ b/app/assets/javascripts/packages/details/components/installation_tabs.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/details/components/maven_installation.vue b/app/assets/javascripts/packages/details/components/maven_installation.vue
new file mode 100644
index 00000000000..f635cdfa53b
--- /dev/null
+++ b/app/assets/javascripts/packages/details/components/maven_installation.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/details/components/npm_installation.vue b/app/assets/javascripts/packages/details/components/npm_installation.vue
new file mode 100644
index 00000000000..5dacbaf699d
--- /dev/null
+++ b/app/assets/javascripts/packages/details/components/npm_installation.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/details/components/nuget_installation.vue b/app/assets/javascripts/packages/details/components/nuget_installation.vue
new file mode 100644
index 00000000000..008fb83b34a
--- /dev/null
+++ b/app/assets/javascripts/packages/details/components/nuget_installation.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/details/components/package_title.vue b/app/assets/javascripts/packages/details/components/package_title.vue
new file mode 100644
index 00000000000..da558273bb7
--- /dev/null
+++ b/app/assets/javascripts/packages/details/components/package_title.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/details/components/pypi_installation.vue b/app/assets/javascripts/packages/details/components/pypi_installation.vue
new file mode 100644
index 00000000000..566933182d6
--- /dev/null
+++ b/app/assets/javascripts/packages/details/components/pypi_installation.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/details/constants.js b/app/assets/javascripts/packages/details/constants.js
new file mode 100644
index 00000000000..d12f93dd277
--- /dev/null
+++ b/app/assets/javascripts/packages/details/constants.js
@@ -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',
+};
diff --git a/app/assets/javascripts/packages/details/index.js b/app/assets/javascripts/packages/details/index.js
new file mode 100644
index 00000000000..233da3e4a99
--- /dev/null
+++ b/app/assets/javascripts/packages/details/index.js
@@ -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');
+ },
+ });
+};
diff --git a/app/assets/javascripts/packages/details/store/actions.js b/app/assets/javascripts/packages/details/store/actions.js
new file mode 100644
index 00000000000..420c51bb6e1
--- /dev/null
+++ b/app/assets/javascripts/packages/details/store/actions.js
@@ -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);
+ });
+};
diff --git a/app/assets/javascripts/packages/details/store/getters.js b/app/assets/javascripts/packages/details/store/getters.js
new file mode 100644
index 00000000000..bcf74713f03
--- /dev/null
+++ b/app/assets/javascripts/packages/details/store/getters.js
@@ -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>`;
diff --git a/app/assets/javascripts/packages/details/store/index.js b/app/assets/javascripts/packages/details/store/index.js
new file mode 100644
index 00000000000..9687eb98544
--- /dev/null
+++ b/app/assets/javascripts/packages/details/store/index.js
@@ -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,
+ },
+ });
diff --git a/app/assets/javascripts/packages/details/store/mutation_types.js b/app/assets/javascripts/packages/details/store/mutation_types.js
new file mode 100644
index 00000000000..340d668819c
--- /dev/null
+++ b/app/assets/javascripts/packages/details/store/mutation_types.js
@@ -0,0 +1,2 @@
+export const SET_LOADING = 'SET_LOADING';
+export const SET_PACKAGE_VERSIONS = 'SET_PACKAGE_VERSIONS';
diff --git a/app/assets/javascripts/packages/details/store/mutations.js b/app/assets/javascripts/packages/details/store/mutations.js
new file mode 100644
index 00000000000..e113638311b
--- /dev/null
+++ b/app/assets/javascripts/packages/details/store/mutations.js
@@ -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,
+ };
+ },
+};
diff --git a/app/assets/javascripts/packages/details/utils.js b/app/assets/javascripts/packages/details/utils.js
new file mode 100644
index 00000000000..dba3e1607e9
--- /dev/null
+++ b/app/assets/javascripts/packages/details/utils.js
@@ -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'],
+ );
+}
diff --git a/app/assets/javascripts/packages/list/coming_soon/helpers.js b/app/assets/javascripts/packages/list/coming_soon/helpers.js
new file mode 100644
index 00000000000..5b6a4b3aa87
--- /dev/null
+++ b/app/assets/javascripts/packages/list/coming_soon/helpers.js
@@ -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),
+ }));
+};
diff --git a/app/assets/javascripts/packages/list/coming_soon/packages_coming_soon.vue b/app/assets/javascripts/packages/list/coming_soon/packages_coming_soon.vue
new file mode 100644
index 00000000000..60d40efada4
--- /dev/null
+++ b/app/assets/javascripts/packages/list/coming_soon/packages_coming_soon.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/list/coming_soon/queries/issues.graphql b/app/assets/javascripts/packages/list/coming_soon/queries/issues.graphql
new file mode 100644
index 00000000000..36c27d9ad70
--- /dev/null
+++ b/app/assets/javascripts/packages/list/coming_soon/queries/issues.graphql
@@ -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
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/packages/list/components/packages_filter.vue b/app/assets/javascripts/packages/list/components/packages_filter.vue
new file mode 100644
index 00000000000..17398071217
--- /dev/null
+++ b/app/assets/javascripts/packages/list/components/packages_filter.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/list/components/packages_list.vue b/app/assets/javascripts/packages/list/components/packages_list.vue
new file mode 100644
index 00000000000..b26c6087e14
--- /dev/null
+++ b/app/assets/javascripts/packages/list/components/packages_list.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/list/components/packages_list_app.vue b/app/assets/javascripts/packages/list/components/packages_list_app.vue
new file mode 100644
index 00000000000..dabeb7f21f1
--- /dev/null
+++ b/app/assets/javascripts/packages/list/components/packages_list_app.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/list/components/packages_sort.vue b/app/assets/javascripts/packages/list/components/packages_sort.vue
new file mode 100644
index 00000000000..157f98d3aaa
--- /dev/null
+++ b/app/assets/javascripts/packages/list/components/packages_sort.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js
new file mode 100644
index 00000000000..5938f658295
--- /dev/null
+++ b/app/assets/javascripts/packages/list/constants.js
@@ -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,
+ },
+];
diff --git a/app/assets/javascripts/packages/list/packages_list_app_bundle.js b/app/assets/javascripts/packages/list/packages_list_app_bundle.js
new file mode 100644
index 00000000000..764da1fcd24
--- /dev/null
+++ b/app/assets/javascripts/packages/list/packages_list_app_bundle.js
@@ -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');
+ },
+ });
+};
diff --git a/app/assets/javascripts/packages/list/stores/actions.js b/app/assets/javascripts/packages/list/stores/actions.js
new file mode 100644
index 00000000000..fed0337614a
--- /dev/null
+++ b/app/assets/javascripts/packages/list/stores/actions.js
@@ -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);
+ });
+};
diff --git a/app/assets/javascripts/packages/list/stores/getters.js b/app/assets/javascripts/packages/list/stores/getters.js
new file mode 100644
index 00000000000..0af7e453f19
--- /dev/null
+++ b/app/assets/javascripts/packages/list/stores/getters.js
@@ -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]) }));
diff --git a/app/assets/javascripts/packages/list/stores/index.js b/app/assets/javascripts/packages/list/stores/index.js
new file mode 100644
index 00000000000..1d6a4bf831d
--- /dev/null
+++ b/app/assets/javascripts/packages/list/stores/index.js
@@ -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();
diff --git a/app/assets/javascripts/packages/list/stores/mutation_types.js b/app/assets/javascripts/packages/list/stores/mutation_types.js
new file mode 100644
index 00000000000..a5a584ccf1f
--- /dev/null
+++ b/app/assets/javascripts/packages/list/stores/mutation_types.js
@@ -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';
diff --git a/app/assets/javascripts/packages/list/stores/mutations.js b/app/assets/javascripts/packages/list/stores/mutations.js
new file mode 100644
index 00000000000..a47ba356c0a
--- /dev/null
+++ b/app/assets/javascripts/packages/list/stores/mutations.js
@@ -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;
+ },
+};
diff --git a/app/assets/javascripts/packages/list/stores/state.js b/app/assets/javascripts/packages/list/stores/state.js
new file mode 100644
index 00000000000..00a34bb2deb
--- /dev/null
+++ b/app/assets/javascripts/packages/list/stores/state.js
@@ -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: '',
+});
diff --git a/app/assets/javascripts/packages/list/utils.js b/app/assets/javascripts/packages/list/utils.js
new file mode 100644
index 00000000000..98d78db8706
--- /dev/null
+++ b/app/assets/javascripts/packages/list/utils.js
@@ -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;
+};
diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages/shared/components/package_list_row.vue
new file mode 100644
index 00000000000..3515ab4ef03
--- /dev/null
+++ b/app/assets/javascripts/packages/shared/components/package_list_row.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/shared/components/package_tags.vue b/app/assets/javascripts/packages/shared/components/package_tags.vue
new file mode 100644
index 00000000000..62fddef68b2
--- /dev/null
+++ b/app/assets/javascripts/packages/shared/components/package_tags.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/shared/components/packages_list_loader.vue b/app/assets/javascripts/packages/shared/components/packages_list_loader.vue
new file mode 100644
index 00000000000..cd9ef74d467
--- /dev/null
+++ b/app/assets/javascripts/packages/shared/components/packages_list_loader.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/shared/components/publish_method.vue b/app/assets/javascripts/packages/shared/components/publish_method.vue
new file mode 100644
index 00000000000..51c3b41cdd8
--- /dev/null
+++ b/app/assets/javascripts/packages/shared/components/publish_method.vue
@@ -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>
diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js
new file mode 100644
index 00000000000..c984cdfe25c
--- /dev/null
+++ b/app/assets/javascripts/packages/shared/constants.js
@@ -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',
+};
diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js
new file mode 100644
index 00000000000..ed33fd688ae
--- /dev/null
+++ b/app/assets/javascripts/packages/shared/utils.js
@@ -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}`;
+};
diff --git a/app/assets/javascripts/pages/groups/packages/index/index.js b/app/assets/javascripts/pages/groups/packages/index/index.js
new file mode 100644
index 00000000000..4836900aa28
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/packages/index/index.js
@@ -0,0 +1,7 @@
+import initPackageList from '~/packages/list/packages_list_app_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ if (document.getElementById('js-vue-packages-list')) {
+ initPackageList();
+ }
+});
diff --git a/app/assets/javascripts/pages/projects/packages/packages/index/index.js b/app/assets/javascripts/pages/projects/packages/packages/index/index.js
new file mode 100644
index 00000000000..4836900aa28
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/packages/packages/index/index.js
@@ -0,0 +1,7 @@
+import initPackageList from '~/packages/list/packages_list_app_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ if (document.getElementById('js-vue-packages-list')) {
+ initPackageList();
+ }
+});
diff --git a/app/assets/javascripts/pages/projects/packages/packages/show/index.js b/app/assets/javascripts/pages/projects/packages/packages/show/index.js
new file mode 100644
index 00000000000..1fde4ddfc1d
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/packages/packages/show/index.js
@@ -0,0 +1,3 @@
+import initPackageDetail from '~/packages/details/';
+
+document.addEventListener('DOMContentLoaded', initPackageDetail);
diff --git a/app/assets/stylesheets/pages/packages.scss b/app/assets/stylesheets/pages/packages.scss
new file mode 100644
index 00000000000..8f6eee524e5
--- /dev/null
+++ b/app/assets/stylesheets/pages/packages.scss
@@ -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;
+ }
+}
diff --git a/app/controllers/concerns/packages_access.rb b/app/controllers/concerns/packages_access.rb
new file mode 100644
index 00000000000..6df2e064bb2
--- /dev/null
+++ b/app/controllers/concerns/packages_access.rb
@@ -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
diff --git a/app/controllers/groups/packages_controller.rb b/app/controllers/groups/packages_controller.rb
new file mode 100644
index 00000000000..600acc72e67
--- /dev/null
+++ b/app/controllers/groups/packages_controller.rb
@@ -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
diff --git a/app/controllers/projects/packages/package_files_controller.rb b/app/controllers/projects/packages/package_files_controller.rb
new file mode 100644
index 00000000000..dd6d875cd1e
--- /dev/null
+++ b/app/controllers/projects/packages/package_files_controller.rb
@@ -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
diff --git a/app/controllers/projects/packages/packages_controller.rb b/app/controllers/projects/packages/packages_controller.rb
new file mode 100644
index 00000000000..fc4ef7a01dc
--- /dev/null
+++ b/app/controllers/projects/packages/packages_controller.rb
@@ -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
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index a5666cb70ac..5146a44de83 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -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[
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index f4238e7711a..0532353b141 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -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
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 61c9bd74451..5255dd27852 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -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
diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb
new file mode 100644
index 00000000000..a0434284ce6
--- /dev/null
+++ b/app/helpers/packages_helper.rb
@@ -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
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 840e3ef9daa..dd429954368 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -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),
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index ed1b35338ae..417aeb219f9 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -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')
diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb
index 567b5a14603..9b412cd6d6a 100644
--- a/app/models/packages/package_file.rb
+++ b/app/models/packages/package_file.rb
@@ -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?
diff --git a/app/services/merge_requests/pushed_branches_service.rb b/app/services/merge_requests/pushed_branches_service.rb
index afcf0f7678a..bbe75305d92 100644
--- a/app/services/merge_requests/pushed_branches_service.rb
+++ b/app/services/merge_requests/pushed_branches_service.rb
@@ -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
diff --git a/app/views/groups/packages/_legacy_package_list.haml b/app/views/groups/packages/_legacy_package_list.haml
new file mode 100644
index 00000000000..481a0dbb6e8
--- /dev/null
+++ b/app/views/groups/packages/_legacy_package_list.haml
@@ -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'
diff --git a/app/views/groups/packages/index.html.haml b/app/views/groups/packages/index.html.haml
new file mode 100644
index 00000000000..b07c08f50ca
--- /dev/null
+++ b/app/views/groups/packages/index.html.haml
@@ -0,0 +1,5 @@
+- page_title _("Packages")
+
+.row
+ .col-12
+ #js-vue-packages-list{ data: packages_list_data('groups', @group) }
diff --git a/app/views/groups/sidebar/_packages.html.haml b/app/views/groups/sidebar/_packages.html.haml
index 59061a048b3..54510d5df0c 100644
--- a/app/views/groups/sidebar/_packages.html.haml
+++ b/app/views/groups/sidebar/_packages.html.haml
@@ -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')
diff --git a/app/views/layouts/nav/sidebar/_project_packages_link.html.haml b/app/views/layouts/nav/sidebar/_project_packages_link.html.haml
index 0931ccdf637..e9989abe5a0 100644
--- a/app/views/layouts/nav/sidebar/_project_packages_link.html.haml
+++ b/app/views/layouts/nav/sidebar/_project_packages_link.html.haml
@@ -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')
diff --git a/app/views/projects/issues/_design_management.html.haml b/app/views/projects/issues/_design_management.html.haml
index 045f032e6e7..b847fdad8b5 100644
--- a/app/views/projects/issues/_design_management.html.haml
+++ b/app/views/projects/issues/_design_management.html.haml
@@ -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
diff --git a/app/views/projects/packages/packages/_legacy_package_list.html.haml b/app/views/projects/packages/packages/_legacy_package_list.html.haml
new file mode 100644
index 00000000000..afce5b6b992
--- /dev/null
+++ b/app/views/projects/packages/packages/_legacy_package_list.html.haml
@@ -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'
diff --git a/app/views/projects/packages/packages/index.html.haml b/app/views/projects/packages/packages/index.html.haml
new file mode 100644
index 00000000000..c81326f3760
--- /dev/null
+++ b/app/views/projects/packages/packages/index.html.haml
@@ -0,0 +1,5 @@
+- page_title _("Packages")
+
+.row
+ .col-12
+ #js-vue-packages-list{ data: packages_list_data('projects', @project) }
diff --git a/app/views/projects/packages/packages/show.html.haml b/app/views/projects/packages/packages/show.html.haml
new file mode 100644
index 00000000000..7a3d81c9124
--- /dev/null
+++ b/app/views/projects/packages/packages/show.html.haml
@@ -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') } }
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 590ae72a2ff..be947b42e25 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -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
diff --git a/app/views/shared/packages/_no_packages.html.haml b/app/views/shared/packages/_no_packages.html.haml
new file mode 100644
index 00000000000..ae5c2cfd378
--- /dev/null
+++ b/app/views/shared/packages/_no_packages.html.haml
@@ -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 }