diff options
Diffstat (limited to 'app/assets/javascripts/packages')
44 files changed, 2865 insertions, 0 deletions
diff --git a/app/assets/javascripts/packages/details/components/additional_metadata.vue b/app/assets/javascripts/packages/details/components/additional_metadata.vue new file mode 100644 index 00000000000..a3de6dd46c7 --- /dev/null +++ b/app/assets/javascripts/packages/details/components/additional_metadata.vue @@ -0,0 +1,98 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import DetailsRow from '~/registry/shared/components/details_row.vue'; +import { generateConanRecipe } from '../utils'; +import { PackageType } from '../../shared/constants'; + +export default { + i18n: { + sourceText: s__('PackageRegistry|Source project located at %{link}'), + licenseText: s__('PackageRegistry|License information located at %{link}'), + recipeText: s__('PackageRegistry|Recipe: %{recipe}'), + appGroup: s__('PackageRegistry|App group: %{group}'), + appName: s__('PackageRegistry|App name: %{name}'), + }, + components: { + DetailsRow, + GlLink, + GlSprintf, + }, + props: { + packageEntity: { + type: Object, + required: true, + }, + }, + computed: { + conanRecipe() { + return generateConanRecipe(this.packageEntity); + }, + showMetadata() { + const visibilityConditions = { + [PackageType.NUGET]: this.packageEntity.nuget_metadatum, + [PackageType.CONAN]: this.packageEntity.conan_metadatum, + [PackageType.MAVEN]: this.packageEntity.maven_metadatum, + }; + return visibilityConditions[this.packageEntity.package_type]; + }, + }, +}; +</script> + +<template> + <div v-if="showMetadata"> + <h3 class="gl-font-lg" data-testid="title">{{ __('Additional Metadata') }}</h3> + + <div class="gl-bg-gray-50 gl-inset-border-1-gray-100 gl-rounded-base" data-testid="main"> + <template v-if="packageEntity.nuget_metadatum"> + <details-row icon="project" padding="gl-p-4" dashed data-testid="nuget-source"> + <gl-sprintf :message="$options.i18n.sourceText"> + <template #link> + <gl-link :href="packageEntity.nuget_metadatum.project_url" target="_blank">{{ + packageEntity.nuget_metadatum.project_url + }}</gl-link> + </template> + </gl-sprintf> + </details-row> + <details-row icon="license" padding="gl-p-4" data-testid="nuget-license"> + <gl-sprintf :message="$options.i18n.licenseText"> + <template #link> + <gl-link :href="packageEntity.nuget_metadatum.license_url" target="_blank">{{ + packageEntity.nuget_metadatum.license_url + }}</gl-link> + </template> + </gl-sprintf> + </details-row> + </template> + + <details-row + v-else-if="packageEntity.conan_metadatum" + icon="information-o" + padding="gl-p-4" + data-testid="conan-recipe" + > + <gl-sprintf :message="$options.i18n.recipeText"> + <template #recipe>{{ conanRecipe }}</template> + </gl-sprintf> + </details-row> + + <template v-else-if="packageEntity.maven_metadatum"> + <details-row icon="information-o" padding="gl-p-4" dashed data-testid="maven-app"> + <gl-sprintf :message="$options.i18n.appName"> + <template #name> + <strong>{{ packageEntity.maven_metadatum.app_name }}</strong> + </template> + </gl-sprintf> + </details-row> + <details-row icon="information-o" padding="gl-p-4" data-testid="maven-group"> + <gl-sprintf :message="$options.i18n.appGroup"> + <template #group> + <strong>{{ packageEntity.maven_metadatum.app_group }}</strong> + </template> + </gl-sprintf> + </details-row> + </template> + </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..dbb5f7be0a0 --- /dev/null +++ b/app/assets/javascripts/packages/details/components/app.vue @@ -0,0 +1,289 @@ +<script> +import { + GlBadge, + GlButton, + GlModal, + GlModalDirective, + GlTooltipDirective, + GlLink, + GlEmptyState, + GlTab, + GlTabs, + GlTable, + GlSprintf, +} from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import Tracking from '~/tracking'; +import PackageHistory from './package_history.vue'; +import PackageTitle from './package_title.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 AdditionalMetadata from './additional_metadata.vue'; +import InstallationCommands from './installation_commands.vue'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import { __, s__ } from '~/locale'; +import { PackageType, TrackingActions } from '../../shared/constants'; +import { packageTypeToTrackCategory } from '../../shared/utils'; + +export default { + name: 'PackagesApp', + components: { + GlBadge, + GlButton, + GlEmptyState, + GlLink, + GlModal, + GlTab, + GlTabs, + GlTable, + FileIcon, + GlSprintf, + PackageTitle, + PackagesListLoader, + PackageListRow, + DependencyRow, + PackageHistory, + AdditionalMetadata, + InstallationCommands, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, + }, + mixins: [timeagoMixin, Tracking.mixin()], + trackingActions: { ...TrackingActions }, + computed: { + ...mapState([ + 'projectName', + 'packageEntity', + 'packageFiles', + 'isLoading', + 'canDelete', + 'destroyPath', + 'svgPath', + 'npmPath', + 'npmHelpPath', + ]), + isValidPackage() { + return Boolean(this.packageEntity.name); + }, + canDeletePackage() { + return this.canDelete && this.destroyPath; + }, + 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; + }, + showFiles() { + return this.packageEntity?.package_type !== PackageType.COMPOSER; + }, + }, + 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-button + v-if="canDeletePackage" + v-gl-modal="'delete-modal'" + class="js-delete-button" + variant="danger" + category="primary" + data-qa-selector="delete_button" + > + {{ __('Delete') }} + </gl-button> + </div> + </div> + + <gl-tabs> + <gl-tab :title="__('Detail')"> + <div data-qa-selector="package_information_content"> + <package-history :package-entity="packageEntity" :project-name="projectName" /> + + <installation-commands + :package-entity="packageEntity" + :npm-path="npmPath" + :npm-help-path="npmHelpPath" + /> + + <additional-metadata :package-entity="packageEntity" /> + </div> + + <template v-if="showFiles"> + <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3> + <gl-table + :fields="$options.filesTableHeaderFields" + :items="filesTableRows" + tbody-tr-class="js-file-row" + > + <template #cell(name)="{ item }"> + <gl-link + :href="item.downloadPath" + class="js-file-download gl-relative" + @click="track($options.trackingActions.PULL_PACKAGE)" + > + <file-icon + :file-name="item.name" + css-classes="gl-relative file-icon" + class="gl-mr-1 gl-relative" + /> + <span class="gl-relative">{{ item.name }}</span> + </gl-link> + </template> + + <template #cell(created)="{ item }"> + <span v-gl-tooltip :title="tooltipTitle(item.created)">{{ + timeFormatted(item.created) + }}</span> + </template> + </gl-table> + </template> + </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="__('Other 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-button @click="cancelDelete()">{{ __('Cancel') }}</gl-button> + <gl-button + ref="modal-delete-button" + data-method="delete" + :to="destroyPath" + variant="danger" + category="primary" + data-qa-selector="delete_modal_button" + @click="track($options.trackingActions.DELETE_PACKAGE)" + > + {{ __('Delete') }} + </gl-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..0719ddfcd2b --- /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 gl-mb-3"> + <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/composer_installation.vue b/app/assets/javascripts/packages/details/components/composer_installation.vue new file mode 100644 index 00000000000..1934da149ce --- /dev/null +++ b/app/assets/javascripts/packages/details/components/composer_installation.vue @@ -0,0 +1,60 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { mapGetters, mapState } from 'vuex'; +import { s__ } from '~/locale'; +import CodeInstruction from './code_instruction.vue'; +import { TrackingActions } from '../constants'; + +export default { + name: 'ComposerInstallation', + components: { + CodeInstruction, + GlLink, + GlSprintf, + }, + computed: { + ...mapState(['composerHelpPath']), + ...mapGetters(['composerRegistryInclude', 'composerPackageInclude']), + }, + i18n: { + registryInclude: s__('PackageRegistry|composer.json registry include'), + copyRegistryInclude: s__('PackageRegistry|Copy registry include'), + packageInclude: s__('PackageRegistry|composer.json require package include'), + copyPackageInclude: s__('PackageRegistry|Copy require package include'), + infoLine: s__( + 'PackageRegistry|For more information on Composer packages in GitLab, %{linkStart}see the documentation.%{linkEnd}', + ), + }, + trackingActions: { ...TrackingActions }, +}; +</script> + +<template> + <div> + <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + <h4 class="gl-font-base" data-testid="registry-include-title"> + {{ $options.i18n.registryInclude }} + </h4> + + <code-instruction + :instruction="composerRegistryInclude" + :copy-text="$options.i18n.copyRegistryInclude" + :tracking-action="$options.trackingActions.COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND" + /> + <h4 class="gl-font-base" data-testid="package-include-title"> + {{ $options.i18n.packageInclude }} + </h4> + <code-instruction + :instruction="composerPackageInclude" + :copy-text="$options.i18n.copyPackageInclude" + :tracking-action="$options.trackingActions.COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND" + /> + <span data-testid="help-text"> + <gl-sprintf :message="$options.i18n.infoLine"> + <template #link="{ content }"> + <gl-link :href="composerHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + </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..cff7d73f1e8 --- /dev/null +++ b/app/assets/javascripts/packages/details/components/conan_installation.vue @@ -0,0 +1,56 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { mapGetters, mapState } from 'vuex'; +import { s__ } from '~/locale'; +import CodeInstruction from './code_instruction.vue'; +import { TrackingActions } from '../constants'; + +export default { + name: 'ConanInstallation', + components: { + CodeInstruction, + GlLink, + GlSprintf, + }, + computed: { + ...mapState(['conanHelpPath']), + ...mapGetters(['conanInstallationCommand', 'conanSetupCommand']), + }, + i18n: { + helpText: s__( + 'PackageRegistry|For more information on the Conan registry, %{linkStart}see the documentation%{linkEnd}.', + ), + }, + trackingActions: { ...TrackingActions }, +}; +</script> + +<template> + <div> + <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + <h4 class="gl-font-base"> + {{ s__('PackageRegistry|Conan Command') }} + </h4> + + <code-instruction + :instruction="conanInstallationCommand" + :copy-text="s__('PackageRegistry|Copy Conan Command')" + :tracking-action="$options.trackingActions.COPY_CONAN_COMMAND" + /> + + <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> + <h4 class="gl-font-base"> + {{ s__('PackageRegistry|Add Conan Remote') }} + </h4> + <code-instruction + :instruction="conanSetupCommand" + :copy-text="s__('PackageRegistry|Copy Conan Setup Command')" + :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> + </div> +</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..788673d2881 --- /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-body">{{ 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-body">{{ dependency.version_pattern }}</span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/packages/details/components/history_element.vue b/app/assets/javascripts/packages/details/components/history_element.vue new file mode 100644 index 00000000000..8a51c1528cf --- /dev/null +++ b/app/assets/javascripts/packages/details/components/history_element.vue @@ -0,0 +1,35 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; + +export default { + name: 'HistoryElement', + components: { + GlIcon, + TimelineEntryItem, + }, + + props: { + icon: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <timeline-entry-item class="system-note note-wrapper gl-mb-6!"> + <div class="timeline-icon"> + <gl-icon :name="icon" /> + </div> + <div class="timeline-content"> + <div class="note-header"> + <span> + <slot></slot> + </span> + </div> + <div class="note-body"></div> + </div> + </timeline-entry-item> +</template> diff --git a/app/assets/javascripts/packages/details/components/installation_commands.vue b/app/assets/javascripts/packages/details/components/installation_commands.vue new file mode 100644 index 00000000000..138103020a7 --- /dev/null +++ b/app/assets/javascripts/packages/details/components/installation_commands.vue @@ -0,0 +1,53 @@ +<script> +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 ComposerInstallation from './composer_installation.vue'; +import { PackageType } from '../../shared/constants'; + +export default { + name: 'InstallationCommands', + components: { + [PackageType.CONAN]: ConanInstallation, + [PackageType.MAVEN]: MavenInstallation, + [PackageType.NPM]: NpmInstallation, + [PackageType.NUGET]: NugetInstallation, + [PackageType.PYPI]: PypiInstallation, + [PackageType.COMPOSER]: ComposerInstallation, + }, + props: { + packageEntity: { + type: Object, + required: true, + }, + npmPath: { + type: String, + required: false, + default: '', + }, + npmHelpPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + installationComponent() { + return this.$options.components[this.packageEntity.package_type]; + }, + }, +}; +</script> + +<template> + <div v-if="installationComponent"> + <component + :is="installationComponent" + :name="packageEntity.name" + :registry-url="npmPath" + :help-url="npmHelpPath" + /> + </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..d6641c886a0 --- /dev/null +++ b/app/assets/javascripts/packages/details/components/maven_installation.vue @@ -0,0 +1,84 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { mapGetters, mapState } from 'vuex'; +import { s__ } from '~/locale'; +import CodeInstruction from './code_instruction.vue'; +import { TrackingActions } from '../constants'; + +export default { + name: 'MavenInstallation', + components: { + CodeInstruction, + GlLink, + GlSprintf, + }, + 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 }, +}; +</script> + +<template> + <div> + <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + + <h4 class="gl-font-base"> + {{ s__('PackageRegistry|Maven XML') }} + </h4> + <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')" + multiline + :tracking-action="$options.trackingActions.COPY_MAVEN_XML" + /> + + <h4 class="gl-font-base"> + {{ s__('PackageRegistry|Maven Command') }} + </h4> + <code-instruction + :instruction="mavenInstallationCommand" + :copy-text="s__('PackageRegistry|Copy Maven command')" + :tracking-action="$options.trackingActions.COPY_MAVEN_COMMAND" + /> + + <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> + <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')" + 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> + </div> +</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..d7ff8428370 --- /dev/null +++ b/app/assets/javascripts/packages/details/components/npm_installation.vue @@ -0,0 +1,80 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { mapGetters, mapState } from 'vuex'; +import { s__ } from '~/locale'; +import CodeInstruction from './code_instruction.vue'; +import { NpmManager, TrackingActions } from '../constants'; + +export default { + name: 'NpmInstallation', + components: { + CodeInstruction, + GlLink, + GlSprintf, + }, + 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 }, +}; +</script> + +<template> + <div> + <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + <h4 class="gl-font-base">{{ s__('PackageRegistry|npm command') }}</h4> + + <code-instruction + :instruction="npmCommand" + :copy-text="s__('PackageRegistry|Copy npm command')" + :tracking-action="$options.trackingActions.COPY_NPM_INSTALL_COMMAND" + /> + + <h4 class="gl-font-base">{{ s__('PackageRegistry|yarn command') }}</h4> + <code-instruction + :instruction="yarnCommand" + :copy-text="s__('PackageRegistry|Copy yarn command')" + :tracking-action="$options.trackingActions.COPY_YARN_INSTALL_COMMAND" + /> + + <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> + + <h4 class="gl-font-base">{{ s__('PackageRegistry|npm command') }}</h4> + <code-instruction + :instruction="npmSetup" + :copy-text="s__('PackageRegistry|Copy npm setup command')" + :tracking-action="$options.trackingActions.COPY_NPM_SETUP_COMMAND" + /> + + <h4 class="gl-font-base">{{ s__('PackageRegistry|yarn command') }}</h4> + <code-instruction + :instruction="yarnSetupCommand" + :copy-text="s__('PackageRegistry|Copy yarn setup command')" + :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> + </div> +</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..150b6e3ab0f --- /dev/null +++ b/app/assets/javascripts/packages/details/components/nuget_installation.vue @@ -0,0 +1,55 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { mapGetters, mapState } from 'vuex'; +import { s__ } from '~/locale'; +import CodeInstruction from './code_instruction.vue'; +import { TrackingActions } from '../constants'; + +export default { + name: 'NugetInstallation', + components: { + CodeInstruction, + GlLink, + GlSprintf, + }, + computed: { + ...mapState(['nugetHelpPath']), + ...mapGetters(['nugetInstallationCommand', 'nugetSetupCommand']), + }, + i18n: { + helpText: s__( + 'PackageRegistry|For more information on the NuGet registry, %{linkStart}see the documentation%{linkEnd}.', + ), + }, + trackingActions: { ...TrackingActions }, +}; +</script> + +<template> + <div> + <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + <h4 class="gl-font-base"> + {{ s__('PackageRegistry|NuGet Command') }} + </h4> + <code-instruction + :instruction="nugetInstallationCommand" + :copy-text="s__('PackageRegistry|Copy NuGet Command')" + :tracking-action="$options.trackingActions.COPY_NUGET_INSTALL_COMMAND" + /> + <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> + <h4 class="gl-font-base"> + {{ s__('PackageRegistry|Add NuGet Source') }} + </h4> + + <code-instruction + :instruction="nugetSetupCommand" + :copy-text="s__('PackageRegistry|Copy NuGet Setup Command')" + :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> + </div> +</template> diff --git a/app/assets/javascripts/packages/details/components/package_history.vue b/app/assets/javascripts/packages/details/components/package_history.vue new file mode 100644 index 00000000000..96ce106884d --- /dev/null +++ b/app/assets/javascripts/packages/details/components/package_history.vue @@ -0,0 +1,114 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import HistoryElement from './history_element.vue'; + +export default { + name: 'PackageHistory', + i18n: { + createdOn: s__('PackageRegistry|%{name} version %{version} was created %{datetime}'), + updatedAtText: s__('PackageRegistry|%{name} version %{version} was updated %{datetime}'), + commitText: s__('PackageRegistry|Commit %{link} on branch %{branch}'), + pipelineText: s__('PackageRegistry|Pipeline %{link} triggered %{datetime} by %{author}'), + publishText: s__('PackageRegistry|Published to the %{project} Package Registry %{datetime}'), + }, + components: { + GlLink, + GlSprintf, + HistoryElement, + TimeAgoTooltip, + }, + props: { + packageEntity: { + type: Object, + required: true, + }, + projectName: { + type: String, + required: true, + }, + }, + data() { + return { + showDescription: false, + }; + }, + computed: { + packagePipeline() { + return this.packageEntity.pipeline?.id ? this.packageEntity.pipeline : null; + }, + }, +}; +</script> + +<template> + <div class="issuable-discussion"> + <h3 class="gl-font-lg" data-testid="title">{{ __('History') }}</h3> + <ul class="timeline main-notes-list notes gl-mb-4" data-testid="timeline"> + <history-element icon="clock" data-testid="created-on"> + <gl-sprintf :message="$options.i18n.createdOn"> + <template #name> + <strong>{{ packageEntity.name }}</strong> + </template> + <template #version> + <strong>{{ packageEntity.version }}</strong> + </template> + <template #datetime> + <time-ago-tooltip :time="packageEntity.created_at" /> + </template> + </gl-sprintf> + </history-element> + <history-element icon="pencil" data-testid="updated-at"> + <gl-sprintf :message="$options.i18n.updatedAtText"> + <template #name> + <strong>{{ packageEntity.name }}</strong> + </template> + <template #version> + <strong>{{ packageEntity.version }}</strong> + </template> + <template #datetime> + <time-ago-tooltip :time="packageEntity.updated_at" /> + </template> + </gl-sprintf> + </history-element> + <template v-if="packagePipeline"> + <history-element icon="commit" data-testid="commit"> + <gl-sprintf :message="$options.i18n.commitText"> + <template #link> + <gl-link :href="packagePipeline.project.commit_url">{{ + packagePipeline.sha + }}</gl-link> + </template> + <template #branch> + <strong>{{ packagePipeline.ref }}</strong> + </template> + </gl-sprintf> + </history-element> + <history-element icon="pipeline" data-testid="pipeline"> + <gl-sprintf :message="$options.i18n.pipelineText"> + <template #link> + <gl-link :href="packagePipeline.project.pipeline_url" + >#{{ packagePipeline.id }}</gl-link + > + </template> + <template #datetime> + <time-ago-tooltip :time="packagePipeline.created_at" /> + </template> + <template #author>{{ packagePipeline.user.name }}</template> + </gl-sprintf> + </history-element> + </template> + <history-element icon="package" data-testid="published"> + <gl-sprintf :message="$options.i18n.publishText"> + <template #project> + <strong>{{ projectName }}</strong> + </template> + <template #datetime> + <time-ago-tooltip :time="packageEntity.created_at" /> + </template> + </gl-sprintf> + </history-element> + </ul> + </div> +</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..d07883e3e7a --- /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-500"> + <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-500 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-500 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-500 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-500 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..f1c619fd6d3 --- /dev/null +++ b/app/assets/javascripts/packages/details/components/pypi_installation.vue @@ -0,0 +1,68 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { mapGetters, mapState } from 'vuex'; +import { s__ } from '~/locale'; +import CodeInstruction from './code_instruction.vue'; +import { TrackingActions } from '../constants'; + +export default { + name: 'PyPiInstallation', + components: { + CodeInstruction, + GlLink, + GlSprintf, + }, + 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 }, +}; +</script> + +<template> + <div> + <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + + <h4 class="gl-font-base"> + {{ s__('PackageRegistry|Pip Command') }} + </h4> + + <code-instruction + :instruction="pypiPipCommand" + :copy-text="s__('PackageRegistry|Copy Pip command')" + data-testid="pip-command" + :tracking-action="$options.trackingActions.COPY_PIP_INSTALL_COMMAND" + /> + + <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> + <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> + </div> +</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..c6e1b388132 --- /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', + COMPOSER_INSTALLATION: 'composer_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', + + COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND: 'copy_composer_registry_include_command', + COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND: 'copy_composer_package_include_command', +}; + +export const NpmManager = { + NPM: 'npm', + YARN: 'yarn', +}; + +export const FETCH_PACKAGE_VERSIONS_ERROR = s__( + 'PackageRegistry|Unable to fetch package version information.', +); 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..cda80056e19 --- /dev/null +++ b/app/assets/javascripts/packages/details/store/actions.js @@ -0,0 +1,23 @@ +import Api from '~/api'; +import { deprecatedCreateFlash as 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..d1814d506ad --- /dev/null +++ b/app/assets/javascripts/packages/details/store/getters.js @@ -0,0 +1,115 @@ +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>`; + +export const composerRegistryInclude = ({ composerPath }) => { + const base = { type: 'composer', url: composerPath }; + return JSON.stringify(base); +}; +export const composerPackageInclude = ({ packageEntity }) => { + const base = { [packageEntity.name]: packageEntity.version }; + return JSON.stringify(base); +}; 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..454c83c9ccd --- /dev/null +++ b/app/assets/javascripts/packages/details/utils.js @@ -0,0 +1,23 @@ +import { TrackingActions } from './constants'; + +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}`; +} 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..766402d3619 --- /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 { ApolloQuery } from 'vue-apollo'; +import Tracking from '~/tracking'; +import { TrackingActions } from '../../shared/constants'; +import { s__ } from '~/locale'; +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-400"> + <gl-icon name="issues" class="gl-mr-2" /> + <gl-link + data-testid="issue-id-link" + :href="issue.webUrl" + class="gl-text-gray-400 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..ef242ea5f75 --- /dev/null +++ b/app/assets/javascripts/packages/list/components/packages_list_app.vue @@ -0,0 +1,111 @@ +<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, + selectedType: state => state.selectedType, + }), + 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(index) { + const selected = PACKAGE_REGISTRY_TABS[index]; + + if (selected !== this.selectedType) { + this.setSelectedType(selected); + 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..fa8f4f39d54 --- /dev/null +++ b/app/assets/javascripts/packages/list/components/packages_sort.vue @@ -0,0 +1,60 @@ +<script> +import { GlSorting, GlSortingItem } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { ASCENDING_ODER, DESCENDING_ORDER } from '../constants'; +import getTableHeaders from '../utils'; + +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..510d04965cb --- /dev/null +++ b/app/assets/javascripts/packages/list/constants.js @@ -0,0 +1,101 @@ +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|Composer'), + type: PackageType.COMPOSER, + }, + { + 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..2f240cff143 --- /dev/null +++ b/app/assets/javascripts/packages/list/packages_list_app_bundle.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import Translate from '~/vue_shared/translate'; +import { createStore } from './stores'; +import PackagesListApp from './components/packages_list_app.vue'; +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..0ed24aee2c5 --- /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 { deprecatedCreateFlash as 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..18ab2390b87 --- /dev/null +++ b/app/assets/javascripts/packages/list/stores/state.js @@ -0,0 +1,57 @@ +import { PACKAGE_REGISTRY_TABS } from '../constants'; + +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: '', + /** + * The selected TAB of the package types tabs + */ + selectedType: PACKAGE_REGISTRY_TABS[0], +}); 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..e000279b794 --- /dev/null +++ b/app/assets/javascripts/packages/shared/components/package_list_row.vue @@ -0,0 +1,139 @@ +<script> +import { GlButton, GlIcon, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import PackageTags from './package_tags.vue'; +import PublishMethod from './publish_method.vue'; +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); + }, + }, +}; +</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="disableDelete ? '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="!disableDelete" 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..391f53c225b --- /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-500 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..1e18562a421 --- /dev/null +++ b/app/assets/javascripts/packages/shared/components/publish_method.vue @@ -0,0 +1,61 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +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..279c2959fa9 --- /dev/null +++ b/app/assets/javascripts/packages/shared/constants.js @@ -0,0 +1,24 @@ +export const PackageType = { + CONAN: 'conan', + MAVEN: 'maven', + NPM: 'npm', + NUGET: 'nuget', + PYPI: 'pypi', + COMPOSER: 'composer', +}; + +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..a0c7389651d --- /dev/null +++ b/app/assets/javascripts/packages/shared/utils.js @@ -0,0 +1,36 @@ +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'); + case PackageType.COMPOSER: + return s__('PackageType|Composer'); + + default: + return null; + } +}; + +export const getCommitLink = ({ project_path: projectPath, pipeline = {} }, isGroup = false) => { + if (isGroup) { + return `/${projectPath}/commit/${pipeline.sha}`; + } + + return `../commit/${pipeline.sha}`; +}; |