diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-07-21 12:09:30 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-07-21 12:09:30 +0000 |
commit | af28a89d5e6a62811b462ca7d3adcccf8c03e213 (patch) | |
tree | be14cd6b4adc411fe7f179c236ec0c2d2e472025 /app | |
parent | 5bd4297fd759a14ad9ab9232cb985d28bf44ac49 (diff) | |
download | gitlab-ce-af28a89d5e6a62811b462ca7d3adcccf8c03e213.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
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> + + <gl-link :href="`../../pipelines/${packagePipeline.id}`" + >#{{ packagePipeline.id }}</gl-link + > + + </template> + + <template #timestamp> + <span v-gl-tooltip :title="tooltipTitle(packagePipeline.created_at)"> + {{ timeFormatted(packagePipeline.created_at) }} + </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)"> + {{ 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 } |