diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index a0bbd6fa9a0..94189769bf5 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -854,10 +854,6 @@ Rails/SaveBang: Exclude: - 'ee/spec/controllers/projects/merge_requests_controller_spec.rb' - 'ee/spec/controllers/subscriptions_controller_spec.rb' - - 'ee/spec/factories/ci/job_artifacts.rb' - - 'ee/spec/factories/epics.rb' - - 'ee/spec/factories/licenses.rb' - - 'ee/spec/factories/merge_requests.rb' - 'ee/spec/features/admin/admin_users_spec.rb' - 'ee/spec/features/admin/geo/admin_geo_nodes_spec.rb' - 'ee/spec/features/admin/licenses/admin_views_license_spec.rb' @@ -1099,19 +1095,6 @@ Rails/SaveBang: - 'spec/controllers/sent_notifications_controller_spec.rb' - 'spec/controllers/sessions_controller_spec.rb' - 'spec/controllers/users_controller_spec.rb' - - 'spec/factories/alert_management/alerts.rb' - - 'spec/factories/boards.rb' - - 'spec/factories/ci/pipelines.rb' - - 'spec/factories/design_management/designs.rb' - - 'spec/factories/design_management/versions.rb' - - 'spec/factories/emails.rb' - - 'spec/factories/issues.rb' - - 'spec/factories/labels.rb' - - 'spec/factories/merge_requests.rb' - - 'spec/factories/plans.rb' - - 'spec/factories/projects.rb' - - 'spec/factories/services.rb' - - 'spec/factories/wiki_pages.rb' - 'spec/factories_spec.rb' - 'spec/features/admin/admin_appearance_spec.rb' - 'spec/features/admin/admin_labels_spec.rb' diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 94df809a50b..6b957dae42b 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -521bb978da8780aca690136e78a3ad388726c8ad +d3caef18a88838486d64a427b00c40ac70f5c378 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 ` + ${appGroup} + ${appName} + ${appVersion} +`; +}; + +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 }) => ` + + gitlab-maven + ${mavenPath} + + + + + + gitlab-maven + ${mavenPath} + + + + gitlab-maven + ${mavenPath} + +`; + +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 -Password `; + +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 = `; 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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__:@') + 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 = ''.html_safe % { url: requirements_link_url } +- link_end = ''.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 = ''.html_safe % { url: requirements_link_url } - - support_link_start = ''.html_safe % { url: support_url } - - link_end = ''.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 = ''.html_safe % { url: requirements_link_url } - - support_link_start = ''.html_safe % { url: support_url } - - link_end = ''.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 = ''.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: ''.html_safe } diff --git a/changelogs/unreleased/217580-improve-performance-of-blob.yml b/changelogs/unreleased/217580-improve-performance-of-blob.yml new file mode 100644 index 00000000000..1c2b4d92e1e --- /dev/null +++ b/changelogs/unreleased/217580-improve-performance-of-blob.yml @@ -0,0 +1,5 @@ +--- +title: Reduce 'cached' query calls for Banzai +merge_request: 36735 +author: +type: performance diff --git a/changelogs/unreleased/220325-target_details_refactor.yml b/changelogs/unreleased/220325-target_details_refactor.yml new file mode 100644 index 00000000000..c9ed6a8e2c0 --- /dev/null +++ b/changelogs/unreleased/220325-target_details_refactor.yml @@ -0,0 +1,5 @@ +--- +title: Add target_details column to AuditEvent table +merge_request: 37430 +author: +type: changed diff --git a/changelogs/unreleased/221259-move-packages-to-core-ui.yml b/changelogs/unreleased/221259-move-packages-to-core-ui.yml new file mode 100644 index 00000000000..c66a3f28906 --- /dev/null +++ b/changelogs/unreleased/221259-move-packages-to-core-ui.yml @@ -0,0 +1,5 @@ +--- +title: Package feature moved to core +merge_request: 36667 +author: +type: changed diff --git a/changelogs/unreleased/225579-reset-approvals-closed-mrs.yml b/changelogs/unreleased/225579-reset-approvals-closed-mrs.yml new file mode 100644 index 00000000000..d44892a6646 --- /dev/null +++ b/changelogs/unreleased/225579-reset-approvals-closed-mrs.yml @@ -0,0 +1,5 @@ +--- +title: Update closed MRs on push +merge_request: 37414 +author: +type: fixed diff --git a/changelogs/unreleased/design-management-shorter-lfs-msg.yml b/changelogs/unreleased/design-management-shorter-lfs-msg.yml new file mode 100644 index 00000000000..8f052426a35 --- /dev/null +++ b/changelogs/unreleased/design-management-shorter-lfs-msg.yml @@ -0,0 +1,5 @@ +--- +title: Shorten 'enable LFS' manage for design management +merge_request: 37385 +author: +type: changed diff --git a/changelogs/unreleased/rails-save-bang-4.yml b/changelogs/unreleased/rails-save-bang-4.yml new file mode 100644 index 00000000000..51b8b0a754c --- /dev/null +++ b/changelogs/unreleased/rails-save-bang-4.yml @@ -0,0 +1,5 @@ +--- +title: Refactor all factories to fix SaveBang Cop +merge_request: 37268 +author: Rajendra Kadam +type: fixed diff --git a/config/routes/group.rb b/config/routes/group.rb index 408c57eaa94..cf3e2069ef4 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -55,6 +55,8 @@ constraints(::Constraints::GroupUrlConstrainer.new) do post :toggle_subscription, on: :member end + resources :packages, only: [:index] + resources :milestones, constraints: { id: %r{[^/]+} } do member do get :merge_requests diff --git a/config/routes/project.rb b/config/routes/project.rb index dd94e6155a0..7605d63fdbd 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -30,6 +30,13 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do resources :artifacts, only: [:index, :destroy] + resources :packages, only: [:index, :show, :destroy], module: :packages + resources :package_files, only: [], module: :packages do + member do + get :download + end + end + resources :jobs, only: [:index, :show], constraints: { id: /\d+/ } do collection do resources :artifacts, only: [] do diff --git a/db/migrate/20200715124210_add_target_details_to_audit_event.rb b/db/migrate/20200715124210_add_target_details_to_audit_event.rb new file mode 100644 index 00000000000..65efe24a1e8 --- /dev/null +++ b/db/migrate/20200715124210_add_target_details_to_audit_event.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class AddTargetDetailsToAuditEvent < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + with_lock_retries do + # rubocop:disable Migration/AddLimitToTextColumns + add_column(:audit_events, :target_details, :text) + # rubocop:enable Migration/AddLimitToTextColumns + end + end + + def down + with_lock_retries do + remove_column(:audit_events, :target_details) + end + end +end diff --git a/db/migrate/20200716145156_add_text_limit_to_audit_event_target_details.rb b/db/migrate/20200716145156_add_text_limit_to_audit_event_target_details.rb new file mode 100644 index 00000000000..43ee98b69e0 --- /dev/null +++ b/db/migrate/20200716145156_add_text_limit_to_audit_event_target_details.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddTextLimitToAuditEventTargetDetails < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_text_limit :audit_events, :target_details, 5_500 + end + + def down + remove_text_limit :audit_events, :target_details + end +end diff --git a/db/structure.sql b/db/structure.sql index da5cf98b7fc..d3dd08f3374 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -9400,8 +9400,10 @@ CREATE TABLE public.audit_events ( ip_address inet, author_name text, entity_path text, + target_details text, CONSTRAINT check_492aaa021d CHECK ((char_length(entity_path) <= 5500)), - CONSTRAINT check_83ff8406e2 CHECK ((char_length(author_name) <= 255)) + CONSTRAINT check_83ff8406e2 CHECK ((char_length(author_name) <= 255)), + CONSTRAINT check_d493ec90b5 CHECK ((char_length(target_details) <= 5500)) ); CREATE SEQUENCE public.audit_events_id_seq @@ -23987,10 +23989,12 @@ COPY "schema_migrations" (version) FROM STDIN; 20200713071042 20200713141854 20200713152443 +20200715124210 20200715135130 20200715202659 20200716044023 20200716120419 +20200716145156 20200718040100 20200718040200 20200718040300 diff --git a/lib/api/entities/package.rb b/lib/api/entities/package.rb index 73473f16da9..670965b225c 100644 --- a/lib/api/entities/package.rb +++ b/lib/api/entities/package.rb @@ -13,9 +13,7 @@ module API expose :_links do expose :web_path do |package| - if ::Gitlab.ee? - ::Gitlab::Routing.url_helpers.project_package_path(package.project, package) - end + ::Gitlab::Routing.url_helpers.project_package_path(package.project, package) end expose :delete_api_path, if: can_destroy(:package, &:project) do |package| diff --git a/lib/api/entities/project.rb b/lib/api/entities/project.rb index e3c5177cd0b..fb599d68d72 100644 --- a/lib/api/entities/project.rb +++ b/lib/api/entities/project.rb @@ -35,6 +35,7 @@ module API end end + expose :packages_enabled expose :empty_repo?, as: :empty_repo expose :archived?, as: :archived expose :visibility diff --git a/lib/api/helpers/projects_helpers.rb b/lib/api/helpers/projects_helpers.rb index 76e5bb95c4d..8c20f5b8fc2 100644 --- a/lib/api/helpers/projects_helpers.rb +++ b/lib/api/helpers/projects_helpers.rb @@ -61,6 +61,7 @@ module API optional :auto_devops_deploy_strategy, type: String, values: %w(continuous manual timed_incremental), desc: 'Auto Deploy strategy' optional :autoclose_referenced_issues, type: Boolean, desc: 'Flag indication if referenced issues auto-closing is enabled' optional :repository_storage, type: String, desc: 'Which storage shard the repository is on. Available only to admins' + optional :packages_enabled, type: Boolean, desc: 'Enable project packages feature' end params :optional_project_params_ee do @@ -137,6 +138,7 @@ module API :suggestion_commit_message, :repository_storage, :compliance_framework_setting, + :packages_enabled, :service_desk_enabled, # TODO: remove in API v5, replaced by *_access_level diff --git a/lib/banzai/filter/label_reference_filter.rb b/lib/banzai/filter/label_reference_filter.rb index 7cda4699ae6..b7801de6ed9 100644 --- a/lib/banzai/filter/label_reference_filter.rb +++ b/lib/banzai/filter/label_reference_filter.rb @@ -19,7 +19,7 @@ module Banzai unescaped_html = unescape_html_entities(text).gsub(pattern) do |match| namespace, project = $~[:namespace], $~[:project] project_path = full_project_path(namespace, project) - label = find_label(project_path, $~[:label_id], $~[:label_name]) + label = find_label_cached(project_path, $~[:label_id], $~[:label_name]) if label labels[label.id] = yield match, label.id, project, namespace, $~ @@ -34,6 +34,12 @@ module Banzai escape_with_placeholders(unescaped_html, labels) end + def find_label_cached(parent_ref, label_id, label_name) + cached_call(:banzai_find_label_cached, label_name&.tr('"', '') || label_id, path: [object_class, parent_ref]) do + find_label(parent_ref, label_id, label_name) + end + end + def find_label(parent_ref, label_id, label_name) parent = parent_from_ref(parent_ref) return unless parent diff --git a/lib/banzai/reference_parser/base_parser.rb b/lib/banzai/reference_parser/base_parser.rb index 9ecbc3ecec2..c4d7e40b46c 100644 --- a/lib/banzai/reference_parser/base_parser.rb +++ b/lib/banzai/reference_parser/base_parser.rb @@ -169,7 +169,8 @@ module Banzai # been queried the object is returned from the cache. def collection_objects_for_ids(collection, ids) if Gitlab::SafeRequestStore.active? - ids = ids.map(&:to_i) + ids = ids.map(&:to_i).uniq + cache = collection_cache[collection_cache_key(collection)] to_query = ids - cache.keys diff --git a/lib/banzai/reference_parser/snippet_parser.rb b/lib/banzai/reference_parser/snippet_parser.rb index b86c259efbd..bd42f6e6ed4 100644 --- a/lib/banzai/reference_parser/snippet_parser.rb +++ b/lib/banzai/reference_parser/snippet_parser.rb @@ -9,10 +9,23 @@ module Banzai Snippet end + # Returns all the nodes that are visible to the given user. + def nodes_visible_to_user(user, nodes) + snippets = lazy { grouped_objects_for_nodes(nodes, references_relation, self.class.data_attribute) } + + nodes.select do |node| + if node.has_attribute?(self.class.data_attribute) + can_read_reference?(user, snippets[node]) + else + true + end + end + end + private - def can_read_reference?(user, ref_project, node) - can?(user, :read_snippet, referenced_by([node]).first) + def can_read_reference?(user, snippet) + can?(user, :read_snippet, snippet) end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index e3854d5ba38..d2f0a00298b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8195,7 +8195,7 @@ msgstr "" msgid "DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again." msgstr "" -msgid "DesignManagement|To enable design management, you'll need to %{requirements_link_start}meet the requirements%{requirements_link_end}. If you need help, reach out to our %{support_link_start}support team%{support_link_end} for assistance." +msgid "DesignManagement|To upload designs, you'll need to %{requirements_link_start}enable LFS%{requirements_link_end}." msgstr "" msgid "DesignManagement|Unresolve thread" diff --git a/qa/qa.rb b/qa/qa.rb index 66f1bd5eb35..c31fe56b9e3 100644 --- a/qa/qa.rb +++ b/qa/qa.rb @@ -277,6 +277,11 @@ module QA autoload :Show, 'qa/page/project/job/show' end + module Packages + autoload :Index, 'qa/page/project/packages/index' + autoload :Show, 'qa/page/project/packages/show' + end + module Settings autoload :Advanced, 'qa/page/project/settings/advanced' autoload :Main, 'qa/page/project/settings/main' @@ -315,6 +320,7 @@ module QA autoload :Repository, 'qa/page/project/sub_menus/repository' autoload :Settings, 'qa/page/project/sub_menus/settings' autoload :Project, 'qa/page/project/sub_menus/project' + autoload :Packages, 'qa/page/project/sub_menus/packages' end module Issue diff --git a/qa/qa/page/base.rb b/qa/qa/page/base.rb index f0d4ae45ef8..caf90dc137d 100644 --- a/qa/qa/page/base.rb +++ b/qa/qa/page/base.rb @@ -169,7 +169,7 @@ module QA end def has_element?(name, **kwargs) - wait_for_requests + wait_for_requests(skip_finished_loading_check: !!kwargs.delete(:skip_finished_loading_check)) disabled = kwargs.delete(:disabled) @@ -209,15 +209,6 @@ module QA has_text?(text.gsub(/\s+/, " "), wait: wait) end - def finished_loading? - wait_for_requests - - # The number of selectors should be able to be reduced after - # migration to the new spinner is complete. - # https://gitlab.com/groups/gitlab-org/-/epics/956 - has_no_css?('.gl-spinner, .fa-spinner, .spinner', wait: QA::Support::Repeater::DEFAULT_MAX_WAIT_TIME) - end - def finished_loading_block? wait_for_requests diff --git a/qa/qa/page/component/new_snippet.rb b/qa/qa/page/component/new_snippet.rb index 18f2e237097..3e5ae29177a 100644 --- a/qa/qa/page/component/new_snippet.rb +++ b/qa/qa/page/component/new_snippet.rb @@ -55,12 +55,10 @@ module QA end def fill_file_name(name) - finished_loading? fill_element :file_name_field, name end def fill_file_content(content) - finished_loading? text_area.set content end diff --git a/qa/qa/page/component/snippet.rb b/qa/qa/page/component/snippet.rb index 4ff19c01f1f..443ec3c34d5 100644 --- a/qa/qa/page/component/snippet.rb +++ b/qa/qa/page/component/snippet.rb @@ -100,19 +100,16 @@ module QA end def has_file_content?(file_content) - finished_loading? within_element(:file_content) do has_text?(file_content) end end def click_edit_button - finished_loading? click_element(:snippet_action_button, action: 'Edit') end def click_delete_button - finished_loading? click_element(:snippet_action_button, action: 'Delete') click_element(:delete_snippet_button) # wait for the page to reload after deletion @@ -123,32 +120,27 @@ module QA end def get_repository_uri_http - finished_loading? click_element(:clone_button) Git::Location.new(find_element(:copy_http_url_button)['data-clipboard-text']).uri.to_s end def get_repository_uri_ssh - finished_loading? click_element(:clone_button) Git::Location.new(find_element(:copy_ssh_url_button)['data-clipboard-text']).uri.to_s end def add_comment(comment) - finished_loading? fill_element(:note_field, comment) click_element(:comment_button) end def has_comment_author?(author_username) - finished_loading? within_element(:note_author_content) do has_text?('@' + author_username) end end def has_comment_content?(comment_content) - finished_loading? within_element(:note_content) do has_text?(comment_content) end @@ -161,14 +153,12 @@ module QA end def edit_comment(comment) - finished_loading? click_element(:edit_comment_button) fill_element(:edit_note_field, comment) click_element(:save_comment_button) end def delete_comment(comment) - finished_loading? click_element(:more_actions_dropdown) accept_alert do click_element(:delete_comment_button) diff --git a/qa/qa/page/merge_request/show.rb b/qa/qa/page/merge_request/show.rb index b9a2bf4ee69..365818054f6 100644 --- a/qa/qa/page/merge_request/show.rb +++ b/qa/qa/page/merge_request/show.rb @@ -275,7 +275,7 @@ module QA end def wait_for_loading - finished_loading? && has_no_element?(:skeleton_note) + has_no_element?(:skeleton_note) end def click_open_in_web_ide diff --git a/qa/qa/page/project/menu.rb b/qa/qa/page/project/menu.rb index 9faf1bd5f8f..16c66ea5761 100644 --- a/qa/qa/page/project/menu.rb +++ b/qa/qa/page/project/menu.rb @@ -11,6 +11,7 @@ module QA include SubMenus::Operations include SubMenus::Repository include SubMenus::Settings + include SubMenus::Packages view 'app/views/layouts/nav/sidebar/_project.html.haml' do element :activity_link diff --git a/qa/qa/page/project/operations/kubernetes/show.rb b/qa/qa/page/project/operations/kubernetes/show.rb index e1612718883..11a5c106bfc 100644 --- a/qa/qa/page/project/operations/kubernetes/show.rb +++ b/qa/qa/page/project/operations/kubernetes/show.rb @@ -56,7 +56,7 @@ module QA def await_installed(application_name) within_element(application_name) do - has_element?(:uninstall_button, application: application_name, wait: 300) + has_element?(:uninstall_button, application: application_name, wait: 300, skip_finished_loading_check: true) end end diff --git a/qa/qa/page/project/packages/index.rb b/qa/qa/page/project/packages/index.rb new file mode 100644 index 00000000000..3f8cc6035bc --- /dev/null +++ b/qa/qa/page/project/packages/index.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module QA + module Page + module Project + module Packages + class Index < QA::Page::Base + view 'app/views/projects/packages/packages/_legacy_package_list.html.haml' do + element :package_row + element :package_link + end + + def click_package(name) + click_element(:package_link, text: name) + end + + def has_package?(name) + has_element?(:package_link, text: name) + end + + def has_no_package?(name) + has_no_element?(:package_link, text: name) + end + end + end + end + end +end diff --git a/qa/qa/page/project/packages/show.rb b/qa/qa/page/project/packages/show.rb new file mode 100644 index 00000000000..59e9a3752c7 --- /dev/null +++ b/qa/qa/page/project/packages/show.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module QA + module Page + module Project + module Packages + class Show < QA::Page::Base + view 'app/assets/javascripts/packages/details/components/app.vue' do + element :delete_button + element :delete_modal_button + element :package_information_content + end + + def has_package_info?(name, version) + has_element?(:package_information_content, text: /#{name}.*#{version}/) + end + + def click_delete + click_element(:delete_button) + wait_for_animated_element(:delete_modal_button) + click_element(:delete_modal_button) + end + end + end + end + end +end diff --git a/qa/qa/page/project/snippet/new.rb b/qa/qa/page/project/snippet/new.rb index 7431d6c1bf8..47200ba5fda 100644 --- a/qa/qa/page/project/snippet/new.rb +++ b/qa/qa/page/project/snippet/new.rb @@ -14,6 +14,7 @@ module QA def click_create_first_snippet finished_loading? + # The svg takes a fraction of a second to load after which the # "New snippet" button shifts up a bit. This can cause # webdriver to miss the hit so we wait for the svg to load before diff --git a/qa/qa/page/project/sub_menus/packages.rb b/qa/qa/page/project/sub_menus/packages.rb new file mode 100644 index 00000000000..9ea045a99f5 --- /dev/null +++ b/qa/qa/page/project/sub_menus/packages.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module QA + module Page + module Project + module SubMenus + module Packages + extend QA::Page::PageConcern + + def self.included(base) + super + + base.class_eval do + view 'app/views/layouts/nav/sidebar/_project_packages_link.html.haml' do + element :packages_link + end + end + end + + def click_packages_link + within_sidebar do + click_element :packages_link + end + end + end + end + end + end +end diff --git a/qa/qa/page/project/web_ide/edit.rb b/qa/qa/page/project/web_ide/edit.rb index b46d2d32f1f..faa3f2cf595 100644 --- a/qa/qa/page/project/web_ide/edit.rb +++ b/qa/qa/page/project/web_ide/edit.rb @@ -136,7 +136,6 @@ module QA end def create_first_file(file_name) - finished_loading? click_element(:first_file_button, Page::Component::WebIDE::Modal::CreateNewFile) fill_element(:file_name_field, file_name) click_button('Create file') diff --git a/qa/qa/page/settings/common.rb b/qa/qa/page/settings/common.rb index 6989e8125d3..f5d13cbbabe 100644 --- a/qa/qa/page/settings/common.rb +++ b/qa/qa/page/settings/common.rb @@ -14,7 +14,6 @@ module QA click_button 'Expand' unless has_css?('button', text: 'Collapse', wait: 1) has_content?('Collapse') - finished_loading? end yield if block_given? diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb index e41024e5d14..91fd2579fcd 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb @@ -19,7 +19,7 @@ module QA end end - it 'closes an issue' do + it 'closes an issue', quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/225303', type: :bug } do closed_issue.visit! Page::Project::Issue::Show.perform do |issue_page| diff --git a/qa/qa/specs/features/browser_ui/5_package/maven_repository_spec.rb b/qa/qa/specs/features/browser_ui/5_package/maven_repository_spec.rb new file mode 100644 index 00000000000..19003614f1a --- /dev/null +++ b/qa/qa/specs/features/browser_ui/5_package/maven_repository_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module QA + RSpec.describe 'Package', :docker, :orchestrated, :packages do + describe 'Maven Repository' do + include Runtime::Fixtures + + let(:group_id) { 'com.gitlab.qa' } + let(:artifact_id) { 'maven' } + let(:package_name) { "#{group_id}/#{artifact_id}".tr('.', '/') } + let(:auth_token) do + unless Page::Main::Menu.perform(&:signed_in?) + Flow::Login.sign_in + end + + Resource::PersonalAccessToken.fabricate!.access_token + end + let(:project) do + Resource::Project.fabricate_via_api! do |project| + project.name = 'maven-package-project' + end + end + + it 'publishes a maven package and deletes it' do + uri = URI.parse(Runtime::Scenario.gitlab_address) + gitlab_address_with_port = "#{uri.scheme}://#{uri.host}:#{uri.port}" + pom_xml = { + file_path: 'pom.xml', + content: <<~XML + + #{group_id} + #{artifact_id} + 1.0 + 4.0.0 + + + #{project.name} + #{gitlab_address_with_port}/api/v4/projects/#{project.id}/packages/maven + + + + + #{project.name} + #{gitlab_address_with_port}/api/v4/projects/#{project.id}/packages/maven + + + #{project.name} + #{gitlab_address_with_port}/api/v4/projects/#{project.id}/packages/maven + + + + XML + } + settings_xml = { + file_path: 'settings.xml', + content: <<~XML + + + + #{project.name} + + + + Private-Token + #{auth_token} + + + + + + + XML + } + + # Use a maven docker container to deploy the package + with_fixtures([pom_xml, settings_xml]) do |dir| + Service::DockerRun::Maven.new(dir).publish! + end + + project.visit! + Page::Project::Menu.perform(&:click_packages_link) + + Page::Project::Packages::Index.perform do |index| + expect(index).to have_package(package_name) + + index.click_package(package_name) + end + + Page::Project::Packages::Show.perform do |show| + expect(show).to have_package_info(package_name, "1.0") + + show.click_delete + end + + Page::Project::Packages::Index.perform do |index| + expect(index).to have_content("Package was removed") + expect(index).to have_no_package(package_name) + end + end + end + end +end diff --git a/qa/qa/specs/features/browser_ui/5_package/npm_registry_spec.rb b/qa/qa/specs/features/browser_ui/5_package/npm_registry_spec.rb new file mode 100644 index 00000000000..1118ab02fe8 --- /dev/null +++ b/qa/qa/specs/features/browser_ui/5_package/npm_registry_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module QA + RSpec.describe 'Package', :docker, :orchestrated, :packages do + describe 'NPM registry' do + include Runtime::Fixtures + + let(:registry_scope) { project.group.sandbox.path } + let(:package_name) { "@#{registry_scope}/#{project.name}" } + let(:auth_token) do + unless Page::Main::Menu.perform(&:signed_in?) + Flow::Login.sign_in + end + + Resource::PersonalAccessToken.fabricate!.access_token + end + let(:project) do + Resource::Project.fabricate_via_api! do |project| + project.name = 'npm-registry-project' + end + end + + it 'publishes an npm package and then deletes it' do + uri = URI.parse(Runtime::Scenario.gitlab_address) + gitlab_host_with_port = "#{uri.host}:#{uri.port}" + gitlab_address_with_port = "#{uri.scheme}://#{uri.host}:#{uri.port}" + package_json = { + file_path: 'package.json', + content: <<~JSON + { + "name": "#{package_name}", + "version": "1.0.0", + "description": "Example package for GitLab NPM registry", + "publishConfig": { + "@#{registry_scope}:registry": "#{gitlab_address_with_port}/api/v4/projects/#{project.id}/packages/npm/" + } + } + JSON + } + npmrc = { + file_path: '.npmrc', + content: <<~NPMRC + //#{gitlab_host_with_port}/api/v4/projects/#{project.id}/packages/npm/:_authToken=#{auth_token} + //#{gitlab_host_with_port}/api/v4/packages/npm/:_authToken=#{auth_token} + @#{registry_scope}:registry=#{gitlab_address_with_port}/api/v4/packages/npm/ + NPMRC + } + + # Use a node docker container to publish the package + with_fixtures([npmrc, package_json]) do |dir| + Service::DockerRun::NodeJs.new(dir).publish! + end + + project.visit! + Page::Project::Menu.perform(&:click_packages_link) + + Page::Project::Packages::Index.perform do |index| + expect(index).to have_package(package_name) + + index.click_package(package_name) + end + + Page::Project::Packages::Show.perform do |show| + expect(show).to have_package_info(package_name, "1.0.0") + + show.click_delete + end + + Page::Project::Packages::Index.perform do |index| + expect(index).to have_content("Package was removed") + expect(index).to have_no_package(package_name) + end + end + end + end +end diff --git a/qa/qa/support/page/logging.rb b/qa/qa/support/page/logging.rb index 281e1b85cc3..36056029abe 100644 --- a/qa/qa/support/page/logging.rb +++ b/qa/qa/support/page/logging.rb @@ -120,7 +120,7 @@ module QA found end - def finished_loading? + def finished_loading?(wait: QA::Support::Repeater::DEFAULT_MAX_WAIT_TIME) log('waiting for loading to complete...') now = Time.now diff --git a/qa/qa/support/wait_for_requests.rb b/qa/qa/support/wait_for_requests.rb index c58882a11ea..d2451b6c6e5 100644 --- a/qa/qa/support/wait_for_requests.rb +++ b/qa/qa/support/wait_for_requests.rb @@ -5,9 +5,11 @@ module QA module WaitForRequests module_function - def wait_for_requests + DEFAULT_MAX_WAIT_TIME = 60 + + def wait_for_requests(skip_finished_loading_check: false) Waiter.wait_until(log: false) do - finished_all_ajax_requests? && finished_all_axios_requests? + finished_all_ajax_requests? && finished_all_axios_requests? && (!skip_finished_loading_check ? finished_loading?(wait: 1) : true) end end @@ -20,6 +22,13 @@ module QA Capybara.page.evaluate_script('jQuery.active').zero? end + + def finished_loading?(wait: DEFAULT_MAX_WAIT_TIME) + # The number of selectors should be able to be reduced after + # migration to the new spinner is complete. + # https://gitlab.com/groups/gitlab-org/-/epics/956 + Capybara.page.has_no_css?('.gl-spinner, .fa-spinner, .spinner', wait: wait) + end end end end diff --git a/qa/spec/support/wait_for_requests_spec.rb b/qa/spec/support/wait_for_requests_spec.rb new file mode 100644 index 00000000000..79ee3eb5099 --- /dev/null +++ b/qa/spec/support/wait_for_requests_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +describe QA::Support::WaitForRequests do + describe '.wait_for_requests' do + before do + allow(subject).to receive(:finished_all_axios_requests?).and_return(true) + allow(subject).to receive(:finished_all_ajax_requests?).and_return(true) + allow(subject).to receive(:finished_loading?).and_return(true) + end + + context 'when skip_finished_loading_check is defaulted to false' do + it 'calls finished_loading?' do + expect(subject).to receive(:finished_loading?).with(hash_including(wait: 1)) + + subject.wait_for_requests + end + end + + context 'when skip_finished_loading_check is true' do + it 'does not call finished_loading?' do + expect(subject).not_to receive(:finished_loading?) + + subject.wait_for_requests(skip_finished_loading_check: true) + end + end + end +end diff --git a/spec/factories/alert_management/alerts.rb b/spec/factories/alert_management/alerts.rb index ef511aa54b8..0f05d62b889 100644 --- a/spec/factories/alert_management/alerts.rb +++ b/spec/factories/alert_management/alerts.rb @@ -23,7 +23,7 @@ FactoryBot.define do trait :with_assignee do |alert| after(:create) do |alert| - alert.alert_assignees.create(assignee: create(:user)) + alert.alert_assignees.create!(assignee: create(:user)) end end diff --git a/spec/factories/boards.rb b/spec/factories/boards.rb index a201ca94380..cef7ec37f07 100644 --- a/spec/factories/boards.rb +++ b/spec/factories/boards.rb @@ -28,7 +28,7 @@ FactoryBot.define do end after(:create) do |board| - board.lists.create(list_type: :closed) + board.lists.create!(list_type: :closed) end end end diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index 5bd5ab7d67a..2790be8b70d 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -17,7 +17,7 @@ FactoryBot.define do after(:create) do |pipeline, evaluator| merge_request = evaluator.head_pipeline_of - merge_request&.update(head_pipeline: pipeline) + merge_request&.update!(head_pipeline: pipeline) end factory :ci_pipeline do diff --git a/spec/factories/design_management/designs.rb b/spec/factories/design_management/designs.rb index 6d1229063d8..ee672a083d1 100644 --- a/spec/factories/design_management/designs.rb +++ b/spec/factories/design_management/designs.rb @@ -34,7 +34,7 @@ FactoryBot.define do run_action = ->(action) do sha = commit_version[action] version = DesignManagement::Version.new(sha: sha, issue: issue, author: evaluator.author) - version.save(validate: false) # We need it to have an ID, validate later + version.save!(validate: false) # We need it to have an ID, validate later Gitlab::Database.bulk_insert(dv_table_name, [action.row_attrs(version)]) # rubocop:disable Gitlab/BulkInsert end diff --git a/spec/factories/design_management/versions.rb b/spec/factories/design_management/versions.rb index e6d17ba691c..6a751ac4eda 100644 --- a/spec/factories/design_management/versions.rb +++ b/spec/factories/design_management/versions.rb @@ -135,7 +135,7 @@ FactoryBot.define do actions: version_actions ) - version.update(sha: sha) + version.update!(sha: sha) end end end diff --git a/spec/factories/emails.rb b/spec/factories/emails.rb index 284ba631c37..b30fa8a5896 100644 --- a/spec/factories/emails.rb +++ b/spec/factories/emails.rb @@ -6,6 +6,6 @@ FactoryBot.define do email { generate(:email_alias) } trait(:confirmed) { confirmed_at { Time.now } } - trait(:skip_validate) { to_create {|instance| instance.save(validate: false) } } + trait(:skip_validate) { to_create {|instance| instance.save!(validate: false) } } end end diff --git a/spec/factories/issues.rb b/spec/factories/issues.rb index 4d0924a9412..f2a18434cbb 100644 --- a/spec/factories/issues.rb +++ b/spec/factories/issues.rb @@ -38,7 +38,7 @@ FactoryBot.define do end after(:create) do |issue, evaluator| - issue.update(labels: evaluator.labels) + issue.update!(labels: evaluator.labels) end end end diff --git a/spec/factories/labels.rb b/spec/factories/labels.rb index 2e783adcc94..6725b571f19 100644 --- a/spec/factories/labels.rb +++ b/spec/factories/labels.rb @@ -27,7 +27,7 @@ FactoryBot.define do after(:create) do |label, evaluator| if evaluator.priority - label.priorities.create(project: label.project, priority: evaluator.priority) + label.priorities.create!(project: label.project, priority: evaluator.priority) end end end diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb index 2a06690f894..6fe5c9e0ff9 100644 --- a/spec/factories/merge_requests.rb +++ b/spec/factories/merge_requests.rb @@ -268,7 +268,7 @@ FactoryBot.define do end after(:create) do |merge_request, evaluator| - merge_request.update(labels: evaluator.labels) + merge_request.update!(labels: evaluator.labels) end end end diff --git a/spec/factories/plans.rb b/spec/factories/plans.rb index 81506edcf16..903c176ec2a 100644 --- a/spec/factories/plans.rb +++ b/spec/factories/plans.rb @@ -6,7 +6,7 @@ FactoryBot.define do factory :"#{plan}_plan" do name { plan } title { name.titleize } - initialize_with { Plan.find_or_create_by(name: plan) } + initialize_with { Plan.find_or_create_by!(name: plan) } end end end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index e4b53186ea8..b06ccb6f65c 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -61,7 +61,7 @@ FactoryBot.define do hash.store("pages_access_level", evaluator.pages_access_level) end - project.project_feature.update(hash) + project.project_feature.update!(hash) # Normally the class Projects::CreateService is used for creating # projects, and this class takes care of making sure the owner and current @@ -82,7 +82,7 @@ FactoryBot.define do import_state.jid = evaluator.import_jid import_state.correlation_id_value = evaluator.import_correlation_id import_state.last_error = evaluator.import_last_error - import_state.save + import_state.save! end end diff --git a/spec/factories/services.rb b/spec/factories/services.rb index 9a521336fee..3dafc3404b2 100644 --- a/spec/factories/services.rb +++ b/spec/factories/services.rb @@ -196,7 +196,7 @@ FactoryBot.define do IssueTrackerService.skip_callback(:validation, :before, :handle_properties) end - to_create { |instance| instance.save(validate: false) } + to_create { |instance| instance.save!(validate: false) } after(:create) do IssueTrackerService.set_callback(:validation, :before, :handle_properties) diff --git a/spec/factories/wiki_pages.rb b/spec/factories/wiki_pages.rb index e7fcc19bbfe..cc866d336a4 100644 --- a/spec/factories/wiki_pages.rb +++ b/spec/factories/wiki_pages.rb @@ -31,7 +31,8 @@ FactoryBot.define do end to_create do |page, evaluator| - page.create(message: evaluator.message) + # WikiPages is ActiveModel which doesn't support `create!`. + page.create(message: evaluator.message) # rubocop:disable Rails/SaveBang end end diff --git a/spec/features/groups/navbar_spec.rb b/spec/features/groups/navbar_spec.rb index 06ff33ff0eb..6803b3a5785 100644 --- a/spec/features/groups/navbar_spec.rb +++ b/spec/features/groups/navbar_spec.rb @@ -45,6 +45,8 @@ RSpec.describe 'Group navbar' do end before do + insert_package_nav(_('Kubernetes')) + stub_feature_flags(group_push_rules: false) stub_feature_flags(group_iterations: false) stub_feature_flags(group_wiki: false) @@ -62,13 +64,8 @@ RSpec.describe 'Group navbar' do before do stub_config(registry: { enabled: true }) - insert_after_nav_item( - _('Kubernetes'), - new_nav_item: { - nav_item: _('Packages & Registries'), - nav_sub_items: [_('Container Registry')] - } - ) + insert_container_nav(_('Kubernetes')) + visit group_path(group) end diff --git a/spec/features/groups/packages_spec.rb b/spec/features/groups/packages_spec.rb new file mode 100644 index 00000000000..d81e4aa70cf --- /dev/null +++ b/spec/features/groups/packages_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Group Packages' do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + + before do + sign_in(user) + group.add_maintainer(user) + end + + context 'when feature is not available' do + context 'packages feature is disabled by config' do + before do + allow(Gitlab.config.packages).to receive(:enabled).and_return(false) + end + + it 'gives 404' do + visit_group_packages + + expect(page).to have_gitlab_http_status(:not_found) + end + end + end + + context 'when feature is available', :js do + before do + visit_group_packages + end + + it 'sidebar menu is open' do + sidebar = find('.nav-sidebar') + expect(sidebar).to have_link _('Package Registry') + end + + context 'when there are packages' do + let_it_be(:second_project) { create(:project, name: 'second-project', group: group) } + let_it_be(:conan_package) { create(:conan_package, project: project, name: 'zzz', created_at: 1.day.ago, version: '1.0.0') } + let_it_be(:maven_package) { create(:maven_package, project: second_project, name: 'aaa', created_at: 2.days.ago, version: '2.0.0') } + let_it_be(:packages) { [conan_package, maven_package] } + + it_behaves_like 'packages list', check_project_name: true + + it_behaves_like 'package details link' + + it 'allows you to navigate to the project page' do + page.within('[data-qa-selector="packages-table"]') do + click_link project.name + end + + expect(page).to have_current_path(project_path(project)) + expect(page).to have_content(project.name) + end + + context 'sorting' do + it_behaves_like 'shared package sorting' do + let_it_be(:package_one) { maven_package } + let_it_be(:package_two) { conan_package } + end + + it_behaves_like 'correctly sorted packages list', 'Project' do + let(:packages) { [maven_package, conan_package] } + end + + it_behaves_like 'correctly sorted packages list', 'Project', ascending: true do + let(:packages) { [conan_package, maven_package] } + end + end + end + + it_behaves_like 'when there are no packages' + end + + def visit_group_packages + visit group_packages_path(group) + end +end diff --git a/spec/features/projects/features_visibility_spec.rb b/spec/features/projects/features_visibility_spec.rb index d84c39de8d8..8d3ca9d9fd1 100644 --- a/spec/features/projects/features_visibility_spec.rb +++ b/spec/features/projects/features_visibility_spec.rb @@ -186,7 +186,7 @@ RSpec.describe 'Edit Project Settings' do click_button "Save changes" end - expect(find(".sharing-permissions")).to have_selector(".project-feature-toggle.is-disabled", count: 3) + expect(find(".sharing-permissions")).to have_selector(".project-feature-toggle.is-disabled", count: 4) end it "shows empty features project homepage" do diff --git a/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb b/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb index 2381e00972f..8070fee5804 100644 --- a/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb +++ b/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb @@ -46,7 +46,7 @@ RSpec.describe 'User uploads new design', :js do let(:feature_enabled) { false } it 'shows the message about requirements' do - expect(page).to have_content("To enable design management, you'll need to meet the requirements.") + expect(page).to have_content("To upload designs, you'll need to enable LFS.") end end end @@ -80,7 +80,7 @@ RSpec.describe 'User uploads new design', :js do let(:feature_enabled) { false } it 'shows the message about requirements' do - expect(page).to have_content("To enable design management, you'll need to meet the requirements.") + expect(page).to have_content("To upload designs, you'll need to enable LFS.") end end end diff --git a/spec/features/projects/navbar_spec.rb b/spec/features/projects/navbar_spec.rb index 22cd832ff06..4ff3827b240 100644 --- a/spec/features/projects/navbar_spec.rb +++ b/spec/features/projects/navbar_spec.rb @@ -12,6 +12,8 @@ RSpec.describe 'Project navbar' do let_it_be(:project) { create(:project, :repository) } before do + insert_package_nav(_('Operations')) + project.add_maintainer(user) sign_in(user) end @@ -58,13 +60,8 @@ RSpec.describe 'Project navbar' do before do stub_config(registry: { enabled: true }) - insert_after_nav_item( - _('Operations'), - new_nav_item: { - nav_item: _('Packages & Registries'), - nav_sub_items: [_('Container Registry')] - } - ) + insert_container_nav(_('Operations')) + visit project_path(project) end diff --git a/spec/features/projects/package_files_spec.rb b/spec/features/projects/package_files_spec.rb new file mode 100644 index 00000000000..bea9a9929b9 --- /dev/null +++ b/spec/features/projects/package_files_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'PackageFiles' do + let(:user) { create(:user) } + let(:project) { create(:project) } + let!(:package) { create(:maven_package, project: project) } + let!(:package_file) { package.package_files.first } + + before do + sign_in(user) + end + + context 'user with master role' do + before do + project.add_maintainer(user) + end + + it 'allows direct download by url' do + visit download_project_package_file_path(project, package_file) + + expect(status_code).to eq(200) + end + + it 'renders the download link with the correct url', :js do + visit project_package_path(project, package) + + download_url = download_project_package_file_path(project, package_file) + + expect(page).to have_link(package_file.file_name, href: download_url) + end + + it 'does not allow download of package belonging to different project' do + another_package = create(:maven_package) + another_file = another_package.package_files.first + + visit download_project_package_file_path(project, another_file) + + expect(status_code).to eq(404) + end + end + + it 'does not allow direct download when no access to the project' do + visit download_project_package_file_path(project, package_file) + + expect(status_code).to eq(404) + end + + it 'gives 404 when no package file exist' do + visit download_project_package_file_path(project, non_existing_record_id) + + expect(status_code).to eq(404) + end +end diff --git a/spec/features/projects/packages_spec.rb b/spec/features/projects/packages_spec.rb new file mode 100644 index 00000000000..e5c684bdff5 --- /dev/null +++ b/spec/features/projects/packages_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Packages' do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + + before do + sign_in(user) + project.add_maintainer(user) + end + + context 'when feature is not available' do + context 'packages feature is disabled by config' do + before do + allow(Gitlab.config.packages).to receive(:enabled).and_return(false) + end + + it 'gives 404' do + visit_project_packages + + expect(status_code).to eq(404) + end + end + end + + context 'when feature is available', :js do + before do + visit_project_packages + end + + context 'when there are packages' do + let_it_be(:conan_package) { create(:conan_package, project: project, name: 'zzz', created_at: 1.day.ago, version: '1.0.0') } + let_it_be(:maven_package) { create(:maven_package, project: project, name: 'aaa', created_at: 2.days.ago, version: '2.0.0') } + let_it_be(:packages) { [conan_package, maven_package] } + + it_behaves_like 'packages list' + + it_behaves_like 'package details link' + + context 'deleting a package' do + let_it_be(:project) { create(:project) } + let_it_be(:package) { create(:package, project: project) } + + it 'allows you to delete a package' do + first('[title="Remove package"]').click + click_button('Delete package') + + expect(page).to have_content 'Package deleted successfully' + expect(page).not_to have_content(package.name) + end + end + + it_behaves_like 'shared package sorting' do + let_it_be(:package_one) { maven_package } + let_it_be(:package_two) { conan_package } + end + end + + it_behaves_like 'when there are no packages' + end + + def visit_project_packages + visit project_packages_path(project) + end +end diff --git a/spec/features/projects/settings/packages_settings_spec.rb b/spec/features/projects/settings/packages_settings_spec.rb new file mode 100644 index 00000000000..416e797f3bb --- /dev/null +++ b/spec/features/projects/settings/packages_settings_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Projects > Settings > Packages', :js do + let(:project) { create(:project) } + let(:user) { create(:user) } + + before do + sign_in(user) + project.add_maintainer(user) + end + + context 'Packages enabled in config' do + before do + allow(Gitlab.config.packages).to receive(:enabled).and_return(true) + end + + context 'without the need for a license' do + it 'displays the packages toggle button' do + visit edit_project_path(project) + + expect(page).to have_content('Packages') + expect(page).to have_selector('input[name="project[packages_enabled]"] + button', visible: true) + end + end + end + + context 'Packages disabled in config' do + before do + allow(Gitlab.config.packages).to receive(:enabled).and_return(false) + end + + it 'does not show up in UI' do + visit edit_project_path(project) + + expect(page).not_to have_content('Packages') + end + end +end diff --git a/spec/finders/alert_management/alerts_finder_spec.rb b/spec/finders/alert_management/alerts_finder_spec.rb index 7bf9047704b..926446b31d5 100644 --- a/spec/finders/alert_management/alerts_finder_spec.rb +++ b/spec/finders/alert_management/alerts_finder_spec.rb @@ -5,9 +5,9 @@ require 'spec_helper' RSpec.describe AlertManagement::AlertsFinder, '#execute' do let_it_be(:current_user) { create(:user) } let_it_be(:project) { create(:project) } - let_it_be(:alert_1) { create(:alert_management_alert, :all_fields, :resolved, project: project, ended_at: 1.year.ago, events: 2, severity: :high) } - let_it_be(:alert_2) { create(:alert_management_alert, :all_fields, :ignored, project: project, events: 1, severity: :critical) } - let_it_be(:alert_3) { create(:alert_management_alert, :all_fields) } + let_it_be(:resolved_alert) { create(:alert_management_alert, :all_fields, :resolved, project: project, ended_at: 1.year.ago, events: 2, severity: :high) } + let_it_be(:ignored_alert) { create(:alert_management_alert, :all_fields, :ignored, project: project, events: 1, severity: :critical) } + let_it_be(:triggered_alert) { create(:alert_management_alert, :all_fields) } let(:params) { {} } describe '#execute' do @@ -23,13 +23,13 @@ RSpec.describe AlertManagement::AlertsFinder, '#execute' do end context 'empty params' do - it { is_expected.to contain_exactly(alert_1, alert_2) } + it { is_expected.to contain_exactly(resolved_alert, ignored_alert) } end context 'iid given' do - let(:params) { { iid: alert_1.iid } } + let(:params) { { iid: resolved_alert.iid } } - it { is_expected.to match_array(alert_1) } + it { is_expected.to match_array(resolved_alert) } context 'unknown iid' do let(:params) { { iid: 'unknown' } } @@ -41,13 +41,13 @@ RSpec.describe AlertManagement::AlertsFinder, '#execute' do context 'status given' do let(:params) { { status: AlertManagement::Alert::STATUSES[:resolved] } } - it { is_expected.to match_array(alert_1) } + it { is_expected.to match_array(resolved_alert) } context 'with an array of statuses' do - let(:alert_3) { create(:alert_management_alert) } + let(:triggered_alert) { create(:alert_management_alert) } let(:params) { { status: [AlertManagement::Alert::STATUSES[:resolved]] } } - it { is_expected.to match_array(alert_1) } + it { is_expected.to match_array(resolved_alert) } end context 'with no alerts of status' do @@ -59,13 +59,13 @@ RSpec.describe AlertManagement::AlertsFinder, '#execute' do context 'with an empty status array' do let(:params) { { status: [] } } - it { is_expected.to match_array([alert_1, alert_2]) } + it { is_expected.to match_array([resolved_alert, ignored_alert]) } end context 'with an nil status' do let(:params) { { status: nil } } - it { is_expected.to match_array([alert_1, alert_2]) } + it { is_expected.to match_array([resolved_alert, ignored_alert]) } end end @@ -74,13 +74,13 @@ RSpec.describe AlertManagement::AlertsFinder, '#execute' do context 'sorts alerts ascending' do let(:params) { { sort: 'created_asc' } } - it { is_expected.to eq [alert_1, alert_2] } + it { is_expected.to eq [resolved_alert, ignored_alert] } end context 'sorts alerts descending' do let(:params) { { sort: 'created_desc' } } - it { is_expected.to eq [alert_2, alert_1] } + it { is_expected.to eq [ignored_alert, resolved_alert] } end end @@ -88,13 +88,13 @@ RSpec.describe AlertManagement::AlertsFinder, '#execute' do context 'sorts alerts ascending' do let(:params) { { sort: 'updated_asc' } } - it { is_expected.to eq [alert_1, alert_2] } + it { is_expected.to eq [resolved_alert, ignored_alert] } end context 'sorts alerts descending' do let(:params) { { sort: 'updated_desc' } } - it { is_expected.to eq [alert_2, alert_1] } + it { is_expected.to eq [ignored_alert, resolved_alert] } end end @@ -102,13 +102,13 @@ RSpec.describe AlertManagement::AlertsFinder, '#execute' do context 'sorts alerts ascending' do let(:params) { { sort: 'started_at_asc' } } - it { is_expected.to eq [alert_1, alert_2] } + it { is_expected.to eq [resolved_alert, ignored_alert] } end context 'sorts alerts descending' do let(:params) { { sort: 'started_at_desc' } } - it { is_expected.to eq [alert_2, alert_1] } + it { is_expected.to eq [ignored_alert, resolved_alert] } end end @@ -116,13 +116,13 @@ RSpec.describe AlertManagement::AlertsFinder, '#execute' do context 'sorts alerts ascending' do let(:params) { { sort: 'ended_at_asc' } } - it { is_expected.to eq [alert_1, alert_2] } + it { is_expected.to eq [resolved_alert, ignored_alert] } end context 'sorts alerts descending' do let(:params) { { sort: 'ended_at_desc' } } - it { is_expected.to eq [alert_2, alert_1] } + it { is_expected.to eq [ignored_alert, resolved_alert] } end end @@ -133,13 +133,13 @@ RSpec.describe AlertManagement::AlertsFinder, '#execute' do context 'sorts alerts ascending' do let(:params) { { sort: 'event_count_asc' } } - it { is_expected.to eq [alert_2, alert_1, alert_count_3, alert_count_6] } + it { is_expected.to eq [ignored_alert, resolved_alert, alert_count_3, alert_count_6] } end context 'sorts alerts descending' do let(:params) { { sort: 'event_count_desc' } } - it { is_expected.to eq [alert_count_6, alert_count_3, alert_1, alert_2] } + it { is_expected.to eq [alert_count_6, alert_count_3, resolved_alert, ignored_alert] } end end diff --git a/spec/frontend/packages/details/components/__snapshots__/activity_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/activity_spec.js.snap new file mode 100644 index 00000000000..8590fd242b1 --- /dev/null +++ b/spec/frontend/packages/details/components/__snapshots__/activity_spec.js.snap @@ -0,0 +1,114 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PackageActivity render to match the default snapshot when no pipeline 1`] = ` +
+

+ Activity +

+ +
+ + + + +
+ + + +
+
+
+`; + +exports[`PackageActivity render to match the default snapshot when there is a pipeline 1`] = ` +
+

+ Activity +

+ +
+
+
+ + + + + + xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + + + +
+ + +
+ +
+
+ + + +
+
+ +
+ + + +
+
+
+`; diff --git a/spec/frontend/packages/details/components/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/code_instruction_spec.js.snap new file mode 100644 index 00000000000..7fe5fcc3236 --- /dev/null +++ b/spec/frontend/packages/details/components/__snapshots__/code_instruction_spec.js.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Package code instruction multiline to match the snapshot 1`] = ` +
+
+    this is some
+multiline text
+  
+
+`; + +exports[`Package code instruction single line to match the default snapshot 1`] = ` +
+ + + + + +
+`; diff --git a/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap new file mode 100644 index 00000000000..100daeb7194 --- /dev/null +++ b/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DependencyRow renders full dependency 1`] = ` +
+
+ + Test.Dependency + + + + (.NETStandard2.0) + +
+ +
+ + 2.3.7 + +
+
+`; diff --git a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap new file mode 100644 index 00000000000..4b5538af9ff --- /dev/null +++ b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap @@ -0,0 +1,172 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PackageTitle renders with tags 1`] = ` +
+
+ + +
+

+ + Test package + +

+ +
+ + + +
+
+
+ +
+
+ + + + maven + +
+ +
+ +
+ + + + + +
+ + + + 300 bytes + +
+
+
+`; + +exports[`PackageTitle renders without tags 1`] = ` +
+
+ + +
+

+ + Test package + +

+ +
+ + + +
+
+
+ +
+
+ + + + maven + +
+ + + + + + + +
+ + + + 300 bytes + +
+
+
+`; diff --git a/spec/frontend/packages/details/components/activity_spec.js b/spec/frontend/packages/details/components/activity_spec.js new file mode 100644 index 00000000000..6e15b2ef030 --- /dev/null +++ b/spec/frontend/packages/details/components/activity_spec.js @@ -0,0 +1,92 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import PackageActivity from '~/packages/details/components/activity.vue'; +import { + npmPackage, + mavenPackage as packageWithoutBuildInfo, + mockPipelineInfo, +} from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('PackageActivity', () => { + let wrapper; + let store; + + function createComponent(packageEntity = packageWithoutBuildInfo, pipelineInfo = null) { + store = new Vuex.Store({ + state: { + packageEntity, + }, + getters: { + packagePipeline: () => pipelineInfo, + }, + }); + + wrapper = shallowMount(PackageActivity, { + localVue, + store, + }); + } + + const commitMessageToggle = () => wrapper.find({ ref: 'commit-message-toggle' }); + const commitMessage = () => wrapper.find({ ref: 'commit-message' }); + const commitInfo = () => wrapper.find({ ref: 'commit-info' }); + const pipelineInfo = () => wrapper.find({ ref: 'pipeline-info' }); + + afterEach(() => { + if (wrapper) wrapper.destroy(); + wrapper = null; + }); + + describe('render', () => { + it('to match the default snapshot when no pipeline', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('to match the default snapshot when there is a pipeline', () => { + createComponent(npmPackage, mockPipelineInfo); + + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('commit message toggle', () => { + it("does not display the commit message button when there isn't one", () => { + createComponent(npmPackage, mockPipelineInfo); + + expect(commitMessageToggle().exists()).toBe(false); + expect(commitMessage().exists()).toBe(false); + }); + + it('displays the commit message on toggle', () => { + const commitMessageStr = 'a message'; + createComponent(npmPackage, { + ...mockPipelineInfo, + git_commit_message: commitMessageStr, + }); + + commitMessageToggle().trigger('click'); + + return wrapper.vm.$nextTick(() => expect(commitMessage().text()).toBe(commitMessageStr)); + }); + }); + + describe('pipeline information', () => { + it('does not display pipeline information when no build info is available', () => { + createComponent(); + + expect(pipelineInfo().exists()).toBe(false); + }); + + it('displays the pipeline information if found', () => { + createComponent(npmPackage, mockPipelineInfo); + + expect(commitInfo().exists()).toBe(true); + expect(pipelineInfo().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/app_spec.js b/spec/frontend/packages/details/components/app_spec.js new file mode 100644 index 00000000000..9faea5d7e45 --- /dev/null +++ b/spec/frontend/packages/details/components/app_spec.js @@ -0,0 +1,289 @@ +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import { GlEmptyState, GlModal } from '@gitlab/ui'; +import Tracking from '~/tracking'; +import * as getters from '~/packages/details/store/getters'; +import PackagesApp from '~/packages/details/components/app.vue'; +import PackageTitle from '~/packages/details/components/package_title.vue'; +import PackageInformation from '~/packages/details/components/information.vue'; +import NpmInstallation from '~/packages/details/components/npm_installation.vue'; +import MavenInstallation from '~/packages/details/components/maven_installation.vue'; +import * as SharedUtils from '~/packages/shared/utils'; +import { TrackingActions } from '~/packages/shared/constants'; +import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; +import PackageListRow from '~/packages/shared/components/package_list_row.vue'; +import ConanInstallation from '~/packages/details/components/conan_installation.vue'; +import NugetInstallation from '~/packages/details/components/nuget_installation.vue'; +import PypiInstallation from '~/packages/details/components/pypi_installation.vue'; +import DependencyRow from '~/packages/details/components/dependency_row.vue'; +import { + conanPackage, + mavenPackage, + mavenFiles, + npmPackage, + npmFiles, + nugetPackage, + pypiPackage, +} from '../../mock_data'; +import stubChildren from 'helpers/stub_children'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('PackagesApp', () => { + let wrapper; + let store; + const fetchPackageVersions = jest.fn(); + + function createComponent({ + packageEntity = mavenPackage, + packageFiles = mavenFiles, + isLoading = false, + } = {}) { + store = new Vuex.Store({ + state: { + isLoading, + packageEntity, + packageFiles, + canDelete: true, + destroyPath: 'destroy-package-path', + emptySvgPath: 'empty-illustration', + npmPath: 'foo', + npmHelpPath: 'foo', + }, + actions: { + fetchPackageVersions, + }, + getters, + }); + + wrapper = mount(PackagesApp, { + localVue, + store, + stubs: { + ...stubChildren(PackagesApp), + GlDeprecatedButton: false, + GlModal: false, + GlTab: false, + GlTabs: false, + GlTable: false, + }, + }); + } + + const packageTitle = () => wrapper.find(PackageTitle); + const emptyState = () => wrapper.find(GlEmptyState); + const allPackageInformation = () => wrapper.findAll(PackageInformation); + const packageInformation = index => allPackageInformation().at(index); + const npmInstallation = () => wrapper.find(NpmInstallation); + const mavenInstallation = () => wrapper.find(MavenInstallation); + const conanInstallation = () => wrapper.find(ConanInstallation); + const nugetInstallation = () => wrapper.find(NugetInstallation); + const pypiInstallation = () => wrapper.find(PypiInstallation); + const allFileRows = () => wrapper.findAll('.js-file-row'); + const firstFileDownloadLink = () => wrapper.find('.js-file-download'); + const deleteButton = () => wrapper.find('.js-delete-button'); + const deleteModal = () => wrapper.find(GlModal); + const modalDeleteButton = () => wrapper.find({ ref: 'modal-delete-button' }); + const versionsTab = () => wrapper.find('.js-versions-tab > a'); + const packagesLoader = () => wrapper.find(PackagesListLoader); + const packagesVersionRows = () => wrapper.findAll(PackageListRow); + const noVersionsMessage = () => wrapper.find('[data-testid="no-versions-message"]'); + const dependenciesTab = () => wrapper.find('.js-dependencies-tab > a'); + const dependenciesCountBadge = () => wrapper.find('[data-testid="dependencies-badge"]'); + const noDependenciesMessage = () => wrapper.find('[data-testid="no-dependencies-message"]'); + const dependencyRows = () => wrapper.findAll(DependencyRow); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the app and displays the package title', () => { + createComponent(); + + expect(packageTitle()).toExist(); + }); + + it('renders an empty state component when no an invalid package is passed as a prop', () => { + createComponent({ + packageEntity: {}, + }); + + expect(emptyState()).toExist(); + }); + + it('renders package information and metadata for packages containing both information and metadata', () => { + createComponent(); + + expect(packageInformation(0)).toExist(); + expect(packageInformation(1)).toExist(); + }); + + it('does not render package metadata for npm as npm packages do not contain metadata', () => { + createComponent({ packageEntity: npmPackage, packageFiles: npmFiles }); + + expect(packageInformation(0)).toExist(); + expect(allPackageInformation()).toHaveLength(1); + }); + + describe('installation instructions', () => { + describe.each` + packageEntity | selector + ${conanPackage} | ${conanInstallation} + ${mavenPackage} | ${mavenInstallation} + ${npmPackage} | ${npmInstallation} + ${nugetPackage} | ${nugetInstallation} + ${pypiPackage} | ${pypiInstallation} + `('renders', ({ packageEntity, selector }) => { + it(`${packageEntity.package_type} instructions`, () => { + createComponent({ packageEntity }); + + expect(selector()).toExist(); + }); + }); + }); + + it('renders a single file for an npm package as they only contain one file', () => { + createComponent({ packageEntity: npmPackage, packageFiles: npmFiles }); + + expect(allFileRows()).toExist(); + expect(allFileRows()).toHaveLength(1); + }); + + it('renders multiple files for a package that contains more than one file', () => { + createComponent(); + + expect(allFileRows()).toExist(); + expect(allFileRows()).toHaveLength(2); + }); + + it('allows the user to download a package file by rendering a download link', () => { + createComponent(); + + expect(allFileRows()).toExist(); + expect(firstFileDownloadLink().vm.$attrs.href).toContain('download'); + }); + + describe('deleting packages', () => { + beforeEach(() => { + createComponent(); + deleteButton().trigger('click'); + }); + + it('shows the delete confirmation modal when delete is clicked', () => { + expect(deleteModal()).toExist(); + }); + }); + + describe('versions', () => { + describe('api call', () => { + beforeEach(() => { + createComponent(); + }); + + it('makes api request on first click of tab', () => { + versionsTab().trigger('click'); + + expect(fetchPackageVersions).toHaveBeenCalled(); + }); + }); + + it('displays the loader when state is loading', () => { + createComponent({ isLoading: true }); + + expect(packagesLoader().exists()).toBe(true); + }); + + it('displays the correct version count when the package has versions', () => { + createComponent({ packageEntity: npmPackage }); + + expect(packagesVersionRows()).toHaveLength(npmPackage.versions.length); + }); + + it('displays the no versions message when there are none', () => { + createComponent(); + + expect(noVersionsMessage().exists()).toBe(true); + }); + }); + + describe('dependency links', () => { + it('does not show the dependency links for a non nuget package', () => { + createComponent(); + + expect(dependenciesTab().exists()).toBe(false); + }); + + it('shows the dependencies tab with 0 count when a nuget package with no dependencies', () => { + createComponent({ + packageEntity: { + ...nugetPackage, + dependency_links: [], + }, + }); + + return wrapper.vm.$nextTick(() => { + const dependenciesBadge = dependenciesCountBadge(); + + expect(dependenciesTab().exists()).toBe(true); + expect(dependenciesBadge.exists()).toBe(true); + expect(dependenciesBadge.text()).toBe('0'); + expect(noDependenciesMessage().exists()).toBe(true); + }); + }); + + it('renders the correct number of dependency rows for a nuget package', () => { + createComponent({ packageEntity: nugetPackage }); + + return wrapper.vm.$nextTick(() => { + const dependenciesBadge = dependenciesCountBadge(); + + expect(dependenciesTab().exists()).toBe(true); + expect(dependenciesBadge.exists()).toBe(true); + expect(dependenciesBadge.text()).toBe(nugetPackage.dependency_links.length.toString()); + expect(dependencyRows()).toHaveLength(nugetPackage.dependency_links.length); + }); + }); + }); + + describe('tracking', () => { + let eventSpy; + let utilSpy; + const category = 'foo'; + + beforeEach(() => { + eventSpy = jest.spyOn(Tracking, 'event'); + utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category); + }); + + it('tracking category calls packageTypeToTrackCategory', () => { + createComponent({ packageEntity: conanPackage }); + expect(wrapper.vm.tracking.category).toBe(category); + expect(utilSpy).toHaveBeenCalledWith('conan'); + }); + + it(`delete button on delete modal call event with ${TrackingActions.DELETE_PACKAGE}`, () => { + createComponent({ packageEntity: conanPackage }); + deleteButton().trigger('click'); + return wrapper.vm.$nextTick().then(() => { + modalDeleteButton().trigger('click'); + expect(eventSpy).toHaveBeenCalledWith( + category, + TrackingActions.DELETE_PACKAGE, + expect.any(Object), + ); + }); + }); + + it(`file download link call event with ${TrackingActions.PULL_PACKAGE}`, () => { + createComponent({ packageEntity: conanPackage }); + + firstFileDownloadLink().vm.$emit('click'); + expect(eventSpy).toHaveBeenCalledWith( + category, + TrackingActions.PULL_PACKAGE, + expect.any(Object), + ); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/code_instruction_spec.js b/spec/frontend/packages/details/components/code_instruction_spec.js new file mode 100644 index 00000000000..724eddb9070 --- /dev/null +++ b/spec/frontend/packages/details/components/code_instruction_spec.js @@ -0,0 +1,110 @@ +import { mount } from '@vue/test-utils'; +import CodeInstruction from '~/packages/details/components/code_instruction.vue'; +import { TrackingLabels } from '~/packages/details/constants'; +import Tracking from '~/tracking'; + +describe('Package code instruction', () => { + let wrapper; + + const defaultProps = { + instruction: 'npm i @my-package', + copyText: 'Copy npm install command', + }; + + function createComponent(props = {}) { + wrapper = mount(CodeInstruction, { + propsData: { + ...defaultProps, + ...props, + }, + }); + } + + const findInstructionInput = () => wrapper.find('.js-instruction-input'); + const findInstructionPre = () => wrapper.find('.js-instruction-pre'); + const findInstructionButton = () => wrapper.find('.js-instruction-button'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('single line', () => { + beforeEach(() => createComponent()); + + it('to match the default snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('multiline', () => { + beforeEach(() => + createComponent({ + instruction: 'this is some\nmultiline text', + copyText: 'Copy the command', + multiline: true, + }), + ); + + it('to match the snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('tracking', () => { + let eventSpy; + const trackingAction = 'test_action'; + const label = TrackingLabels.CODE_INSTRUCTION; + + beforeEach(() => { + eventSpy = jest.spyOn(Tracking, 'event'); + }); + + it('should not track when no trackingAction is provided', () => { + createComponent(); + findInstructionButton().trigger('click'); + + expect(eventSpy).toHaveBeenCalledTimes(0); + }); + + describe('when trackingAction is provided for single line', () => { + beforeEach(() => + createComponent({ + trackingAction, + }), + ); + + it('should track when copying from the input', () => { + findInstructionInput().trigger('copy'); + + expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, { + label, + }); + }); + + it('should track when the copy button is pressed', () => { + findInstructionButton().trigger('click'); + + expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, { + label, + }); + }); + }); + + describe('when trackingAction is provided for multiline', () => { + beforeEach(() => + createComponent({ + trackingAction, + multiline: true, + }), + ); + + it('should track when copying from the multiline pre element', () => { + findInstructionPre().trigger('copy'); + + expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, { + label, + }); + }); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/conan_installation_spec.js b/spec/frontend/packages/details/components/conan_installation_spec.js new file mode 100644 index 00000000000..ee0212dd14f --- /dev/null +++ b/spec/frontend/packages/details/components/conan_installation_spec.js @@ -0,0 +1,64 @@ +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import ConanInstallation from '~/packages/details/components/conan_installation.vue'; +import { conanPackage as packageEntity } from '../../mock_data'; +import { registryUrl as conanPath } from '../mock_data'; +import { GlTabs } from '@gitlab/ui'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ConanInstallation', () => { + let wrapper; + + const conanInstallationCommandStr = 'foo/command'; + const conanSetupCommandStr = 'foo/setup'; + + const store = new Vuex.Store({ + state: { + packageEntity, + conanPath, + }, + getters: { + conanInstallationCommand: () => conanInstallationCommandStr, + conanSetupCommand: () => conanSetupCommandStr, + }, + }); + + const findTabs = () => wrapper.find(GlTabs); + const conanInstallationCommand = () => wrapper.find('.js-conan-command > input'); + const conanSetupCommand = () => wrapper.find('.js-conan-setup > input'); + + function createComponent() { + wrapper = mount(ConanInstallation, { + localVue, + store, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + describe('it renders', () => { + it('with GlTabs', () => { + expect(findTabs().exists()).toBe(true); + }); + }); + + describe('installation commands', () => { + it('renders the correct command', () => { + expect(conanInstallationCommand().element.value).toBe(conanInstallationCommandStr); + }); + }); + + describe('setup commands', () => { + it('renders the correct command', () => { + expect(conanSetupCommand().element.value).toBe(conanSetupCommandStr); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/dependency_row_spec.js b/spec/frontend/packages/details/components/dependency_row_spec.js new file mode 100644 index 00000000000..7d3ee92908d --- /dev/null +++ b/spec/frontend/packages/details/components/dependency_row_spec.js @@ -0,0 +1,62 @@ +import { shallowMount } from '@vue/test-utils'; +import DependencyRow from '~/packages/details/components/dependency_row.vue'; +import { dependencyLinks } from '../../mock_data'; + +describe('DependencyRow', () => { + let wrapper; + + const { withoutFramework, withoutVersion, fullLink } = dependencyLinks; + + function createComponent({ dependencyLink = fullLink } = {}) { + wrapper = shallowMount(DependencyRow, { + propsData: { + dependency: dependencyLink, + }, + }); + } + + const dependencyVersion = () => wrapper.find('[data-testid="version-pattern"]'); + const dependencyFramework = () => wrapper.find('[data-testid="target-framework"]'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('renders', () => { + it('full dependency', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('version', () => { + it('does not render any version information when not supplied', () => { + createComponent({ dependencyLink: withoutVersion }); + + expect(dependencyVersion().exists()).toBe(false); + }); + + it('does render version info when it exists', () => { + createComponent(); + + expect(dependencyVersion().exists()).toBe(true); + expect(dependencyVersion().text()).toBe(fullLink.version_pattern); + }); + }); + + describe('target framework', () => { + it('does not render any framework information when not supplied', () => { + createComponent({ dependencyLink: withoutFramework }); + + expect(dependencyFramework().exists()).toBe(false); + }); + + it('does render framework info when it exists', () => { + createComponent(); + + expect(dependencyFramework().exists()).toBe(true); + expect(dependencyFramework().text()).toBe(`(${fullLink.target_framework})`); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/information_spec.js b/spec/frontend/packages/details/components/information_spec.js new file mode 100644 index 00000000000..ba134fcc5a1 --- /dev/null +++ b/spec/frontend/packages/details/components/information_spec.js @@ -0,0 +1,110 @@ +import { shallowMount } from '@vue/test-utils'; +import PackageInformation from '~/packages/details/components/information.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { GlLink } from '@gitlab/ui'; + +describe('PackageInformation', () => { + let wrapper; + + const gitlabLink = 'https://gitlab.com'; + const testInformation = [ + { + label: 'Information one', + value: 'Information value one', + }, + { + label: 'Information two', + value: 'Information value two', + }, + { + label: 'Information three', + value: 'Information value three', + }, + ]; + + function createComponent(props = {}) { + const propsData = { + information: testInformation, + ...props, + }; + + wrapper = shallowMount(PackageInformation, { + propsData, + }); + } + + const headingSelector = () => wrapper.find('.card-header > strong'); + const copyButton = () => wrapper.findAll(ClipboardButton); + const informationSelector = () => wrapper.findAll('ul.content-list li'); + const informationRowText = index => + informationSelector() + .at(index) + .text(); + const informationLink = () => wrapper.find(GlLink); + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + it('renders the information block with default heading', () => { + createComponent(); + + expect(headingSelector()).toExist(); + expect(headingSelector().text()).toBe('Package information'); + }); + + it('renders a custom supplied heading', () => { + const heading = 'A custom heading'; + + createComponent({ + heading, + }); + + expect(headingSelector()).toExist(); + expect(headingSelector().text()).toBe(heading); + }); + + it('renders the supplied information', () => { + createComponent(); + + expect(informationSelector()).toHaveLength(testInformation.length); + expect(informationRowText(0)).toContain(testInformation[0].value); + expect(informationRowText(1)).toContain(testInformation[1].value); + expect(informationRowText(2)).toContain(testInformation[2].value); + }); + + it('renders a link when the information is of type link', () => { + createComponent({ + information: [ + { + label: 'Information link', + value: gitlabLink, + type: 'link', + }, + ], + }); + + const link = informationLink(); + + expect(link.exists()).toBe(true); + expect(link.text()).toBe(gitlabLink); + expect(link.attributes('href')).toBe(gitlabLink); + }); + + describe('copy button', () => { + it('does not render by default', () => { + createComponent(); + + expect(copyButton().exists()).toBe(false); + }); + + it('does render when the prop is set and has correct text set', () => { + createComponent({ showCopy: true }); + + expect(copyButton()).toHaveLength(testInformation.length); + expect(copyButton().at(0).vm.text).toBe(testInformation[0].value); + expect(copyButton().at(1).vm.text).toBe(testInformation[1].value); + expect(copyButton().at(2).vm.text).toBe(testInformation[2].value); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/installation_tabs_spec.js b/spec/frontend/packages/details/components/installation_tabs_spec.js new file mode 100644 index 00000000000..e9218dd985a --- /dev/null +++ b/spec/frontend/packages/details/components/installation_tabs_spec.js @@ -0,0 +1,59 @@ +import { mount } from '@vue/test-utils'; +import InstallationTabs from '~/packages/details/components/installation_tabs.vue'; +import Tracking from '~/tracking'; +import { TrackingActions } from '~/packages/details/constants'; + +describe('InstallationTabs', () => { + let wrapper; + let eventSpy; + + const trackingLabel = 'foo'; + + function createComponent() { + wrapper = mount(InstallationTabs, { + propsData: { + trackingLabel, + }, + }); + } + + const installationTab = () => wrapper.find('.js-installation-tab > a'); + const setupTab = () => wrapper.find('.js-setup-tab > a'); + + beforeEach(() => { + eventSpy = jest.spyOn(Tracking, 'event'); + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('tab change tracking', () => { + it('should track when the setup tab is clicked', () => { + setupTab().trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.REGISTRY_SETUP, { + label: trackingLabel, + }); + }); + }); + + it('should track when the installation tab is clicked', () => { + setupTab().trigger('click'); + + return wrapper.vm + .$nextTick() + .then(() => { + installationTab().trigger('click'); + }) + .then(() => { + expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.INSTALLATION, { + label: trackingLabel, + }); + }); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/maven_installation_spec.js b/spec/frontend/packages/details/components/maven_installation_spec.js new file mode 100644 index 00000000000..8dff24bd47b --- /dev/null +++ b/spec/frontend/packages/details/components/maven_installation_spec.js @@ -0,0 +1,71 @@ +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import MavenInstallation from '~/packages/details/components/maven_installation.vue'; +import { registryUrl as mavenPath } from '../mock_data'; +import { mavenPackage as packageEntity } from '../../mock_data'; +import { GlTabs } from '@gitlab/ui'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('MavenInstallation', () => { + let wrapper; + + const xmlCodeBlock = 'foo/xml'; + const mavenCommandStr = 'foo/command'; + const mavenSetupXml = 'foo/setup'; + + const store = new Vuex.Store({ + state: { + packageEntity, + mavenPath, + }, + getters: { + mavenInstallationXml: () => xmlCodeBlock, + mavenInstallationCommand: () => mavenCommandStr, + mavenSetupXml: () => mavenSetupXml, + }, + }); + + const findTabs = () => wrapper.find(GlTabs); + const xmlCode = () => wrapper.find('.js-maven-xml > pre'); + const mavenCommand = () => wrapper.find('.js-maven-command > input'); + const xmlSetup = () => wrapper.find('.js-maven-setup-xml > pre'); + + function createComponent() { + wrapper = mount(MavenInstallation, { + localVue, + store, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + describe('it renders', () => { + it('with GlTabs', () => { + expect(findTabs().exists()).toBe(true); + }); + }); + + describe('installation commands', () => { + it('renders the correct xml block', () => { + expect(xmlCode().text()).toBe(xmlCodeBlock); + }); + + it('renders the correct maven command', () => { + expect(mavenCommand().element.value).toBe(mavenCommandStr); + }); + }); + + describe('setup commands', () => { + it('renders the correct xml block', () => { + expect(xmlSetup().text()).toBe(mavenSetupXml); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/npm_installation_spec.js b/spec/frontend/packages/details/components/npm_installation_spec.js new file mode 100644 index 00000000000..38396330129 --- /dev/null +++ b/spec/frontend/packages/details/components/npm_installation_spec.js @@ -0,0 +1,80 @@ +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import NpmInstallation from '~/packages/details/components/npm_installation.vue'; +import { npmPackage as packageEntity } from '../../mock_data'; +import { registryUrl as nugetPath } from '../mock_data'; +import { GlTabs } from '@gitlab/ui'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('NpmInstallation', () => { + let wrapper; + + const npmCommandStr = 'npm install'; + const npmSetupStr = 'npm setup'; + const yarnCommandStr = 'npm install'; + const yarnSetupStr = 'npm setup'; + + const findTabs = () => wrapper.find(GlTabs); + const npmInstallationCommand = () => wrapper.find('.js-npm-install > input'); + const npmSetupCommand = () => wrapper.find('.js-npm-setup > input'); + const yarnInstallationCommand = () => wrapper.find('.js-yarn-install > input'); + const yarnSetupCommand = () => wrapper.find('.js-yarn-setup > input'); + + function createComponent(yarn = false) { + const store = new Vuex.Store({ + state: { + packageEntity, + nugetPath, + }, + getters: { + npmInstallationCommand: () => () => (yarn ? yarnCommandStr : npmCommandStr), + npmSetupCommand: () => () => (yarn ? yarnSetupStr : npmSetupStr), + }, + }); + + wrapper = mount(NpmInstallation, { + localVue, + store, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + describe('it renders', () => { + it('with GlTabs', () => { + expect(findTabs().exists()).toBe(true); + }); + }); + + describe('npm commands', () => { + it('renders the correct install command', () => { + expect(npmInstallationCommand().element.value).toBe(npmCommandStr); + }); + + it('renders the correct setup command', () => { + expect(npmSetupCommand().element.value).toBe(npmSetupStr); + }); + }); + + describe('yarn commands', () => { + beforeEach(() => { + createComponent(true); + }); + + it('renders the correct install command', () => { + expect(yarnInstallationCommand().element.value).toBe(yarnCommandStr); + }); + + it('renders the correct setup command', () => { + expect(yarnSetupCommand().element.value).toBe(yarnSetupStr); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/nuget_installation_spec.js b/spec/frontend/packages/details/components/nuget_installation_spec.js new file mode 100644 index 00000000000..21e2aa0f99d --- /dev/null +++ b/spec/frontend/packages/details/components/nuget_installation_spec.js @@ -0,0 +1,64 @@ +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import NugetInstallation from '~/packages/details/components/nuget_installation.vue'; +import { nugetPackage as packageEntity } from '../../mock_data'; +import { registryUrl as nugetPath } from '../mock_data'; +import { GlTabs } from '@gitlab/ui'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('NugetInstallation', () => { + let wrapper; + + const nugetInstallationCommandStr = 'foo/command'; + const nugetSetupCommandStr = 'foo/setup'; + + const store = new Vuex.Store({ + state: { + packageEntity, + nugetPath, + }, + getters: { + nugetInstallationCommand: () => nugetInstallationCommandStr, + nugetSetupCommand: () => nugetSetupCommandStr, + }, + }); + + const findTabs = () => wrapper.find(GlTabs); + const nugetInstallationCommand = () => wrapper.find('.js-nuget-command > input'); + const nugetSetupCommand = () => wrapper.find('.js-nuget-setup > input'); + + function createComponent() { + wrapper = mount(NugetInstallation, { + localVue, + store, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + describe('it renders', () => { + it('with GlTabs', () => { + expect(findTabs().exists()).toBe(true); + }); + }); + + describe('installation commands', () => { + it('renders the correct command', () => { + expect(nugetInstallationCommand().element.value).toBe(nugetInstallationCommandStr); + }); + }); + + describe('setup commands', () => { + it('renders the correct command', () => { + expect(nugetSetupCommand().element.value).toBe(nugetSetupCommandStr); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/package_title_spec.js b/spec/frontend/packages/details/components/package_title_spec.js new file mode 100644 index 00000000000..a30dc4b8aba --- /dev/null +++ b/spec/frontend/packages/details/components/package_title_spec.js @@ -0,0 +1,168 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import PackageTitle from '~/packages/details/components/package_title.vue'; +import PackageTags from '~/packages/shared/components/package_tags.vue'; +import { + conanPackage, + mavenFiles, + mavenPackage, + mockTags, + npmFiles, + npmPackage, + nugetPackage, +} from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('PackageTitle', () => { + let wrapper; + let store; + + function createComponent({ + packageEntity = mavenPackage, + packageFiles = mavenFiles, + icon = null, + } = {}) { + store = new Vuex.Store({ + state: { + packageEntity, + packageFiles, + }, + getters: { + packageTypeDisplay: ({ packageEntity: { package_type: type } }) => type, + packagePipeline: ({ packageEntity: { pipeline = null } }) => pipeline, + packageIcon: () => icon, + }, + }); + + wrapper = shallowMount(PackageTitle, { + localVue, + store, + }); + } + + const packageIcon = () => wrapper.find('[data-testid="package-icon"]'); + const packageType = () => wrapper.find('[data-testid="package-type"]'); + const packageSize = () => wrapper.find('[data-testid="package-size"]'); + const pipelineProject = () => wrapper.find('[data-testid="pipeline-project"]'); + const packageRef = () => wrapper.find('[data-testid="package-ref"]'); + const packageTags = () => wrapper.find(PackageTags); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('renders', () => { + it('without tags', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('with tags', () => { + createComponent({ packageEntity: { ...mavenPackage, tags: mockTags } }); + + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('package icon', () => { + const fakeSrc = 'a-fake-src'; + + it('shows an icon when provided one from vuex', () => { + createComponent({ icon: fakeSrc }); + + expect(packageIcon().exists()).toBe(true); + }); + + it('has the correct src attribute', () => { + createComponent({ icon: fakeSrc }); + + expect(packageIcon().props('src')).toBe(fakeSrc); + }); + + it('does not show an icon when not provided one', () => { + createComponent(); + + expect(packageIcon().exists()).toBe(false); + }); + }); + + describe.each` + packageEntity | expectedResult + ${conanPackage} | ${'conan'} + ${mavenPackage} | ${'maven'} + ${npmPackage} | ${'npm'} + ${nugetPackage} | ${'nuget'} + `(`package type`, ({ packageEntity, expectedResult }) => { + beforeEach(() => createComponent({ packageEntity })); + + it(`${packageEntity.package_type} should render from Vuex getters ${expectedResult}`, () => { + expect(packageType().text()).toBe(expectedResult); + }); + }); + + describe('calculates the package size', () => { + it('correctly calulates when there is only 1 file', () => { + createComponent({ packageEntity: npmPackage, packageFiles: npmFiles }); + + expect(packageSize().text()).toBe('200 bytes'); + }); + + it('correctly calulates when there are multiple files', () => { + createComponent(); + + expect(packageSize().text()).toBe('300 bytes'); + }); + }); + + describe('package tags', () => { + it('displays the package-tags component when the package has tags', () => { + createComponent({ + packageEntity: { + ...npmPackage, + tags: mockTags, + }, + }); + + expect(packageTags().exists()).toBe(true); + }); + + it('does not display the package-tags component when there are no tags', () => { + createComponent(); + + expect(packageTags().exists()).toBe(false); + }); + }); + + describe('package ref', () => { + it('does not display the ref if missing', () => { + createComponent(); + + expect(packageRef().exists()).toBe(false); + }); + + it('correctly shows the package ref if there is one', () => { + createComponent({ packageEntity: npmPackage }); + + expect(packageRef().contains('gl-icon-stub')).toBe(true); + expect(packageRef().text()).toBe(npmPackage.pipeline.ref); + }); + }); + + describe('pipeline project', () => { + it('does not display the project if missing', () => { + createComponent(); + + expect(pipelineProject().exists()).toBe(false); + }); + + it('correctly shows the pipeline project if there is one', () => { + createComponent({ packageEntity: npmPackage }); + + expect(pipelineProject().text()).toBe(npmPackage.pipeline.project.name); + expect(pipelineProject().attributes('href')).toBe(npmPackage.pipeline.project.web_url); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/pypi_installation_spec.js b/spec/frontend/packages/details/components/pypi_installation_spec.js new file mode 100644 index 00000000000..c1c1114dc5b --- /dev/null +++ b/spec/frontend/packages/details/components/pypi_installation_spec.js @@ -0,0 +1,68 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import PypiInstallation from '~/packages/details/components/pypi_installation.vue'; +import InstallationTabs from '~/packages/details/components/installation_tabs.vue'; +import { pypiPackage as packageEntity } from '../../mock_data'; +import { GlTabs } from '@gitlab/ui'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('PypiInstallation', () => { + let wrapper; + + const pipCommandStr = 'pip install'; + const pypiSetupStr = 'python setup'; + + const store = new Vuex.Store({ + state: { + packageEntity, + pypiHelpPath: 'foo', + }, + getters: { + pypiPipCommand: () => pipCommandStr, + pypiSetupCommand: () => pypiSetupStr, + }, + }); + + const findTabs = () => wrapper.find(GlTabs); + const pipCommand = () => wrapper.find('[data-testid="pip-command"]'); + const setupInstruction = () => wrapper.find('[data-testid="pypi-setup-content"]'); + + function createComponent() { + wrapper = shallowMount(PypiInstallation, { + localVue, + store, + stubs: { + InstallationTabs, + }, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('it renders', () => { + it('with GlTabs', () => { + expect(findTabs().exists()).toBe(true); + }); + }); + + describe('installation commands', () => { + it('renders the correct pip command', () => { + expect(pipCommand().props('instruction')).toBe(pipCommandStr); + }); + }); + + describe('setup commands', () => { + it('renders the correct setup block', () => { + expect(setupInstruction().props('instruction')).toBe(pypiSetupStr); + }); + }); +}); diff --git a/spec/frontend/packages/details/mock_data.js b/spec/frontend/packages/details/mock_data.js new file mode 100644 index 00000000000..0d56dbab3f8 --- /dev/null +++ b/spec/frontend/packages/details/mock_data.js @@ -0,0 +1,111 @@ +import { formatDate } from '~/lib/utils/datetime_utility'; +import { orderBy } from 'lodash'; + +export const registryUrl = 'foo/registry'; + +export const mavenMetadata = { + app_group: 'com.test.package.app', + app_name: 'test-package-app', + app_version: '1.0.0', +}; + +export const generateMavenCommand = ({ + app_group: appGroup = '', + app_name: appName = '', + app_version: appVersion = '', +}) => `mvn dependency:get -Dartifact=${appGroup}:${appName}:${appVersion}`; + +export const generateXmlCodeBlock = ({ + app_group: appGroup = '', + app_name: appName = '', + app_version: appVersion = '', +}) => ` + ${appGroup} + ${appName} + ${appVersion} +`; + +export const generateMavenSetupXml = () => ` + + gitlab-maven + ${registryUrl} + + + + + + gitlab-maven + ${registryUrl} + + + + gitlab-maven + ${registryUrl} + +`; + +const generateCommonPackageInformation = packageEntity => [ + { + label: 'Version', + value: packageEntity.version, + order: 2, + }, + { + label: 'Created on', + value: formatDate(packageEntity.created_at), + order: 5, + }, + { + label: 'Updated at', + value: formatDate(packageEntity.updated_at), + order: 6, + }, +]; + +export const generateStandardPackageInformation = packageEntity => [ + { + label: 'Name', + value: packageEntity.name, + order: 1, + }, + ...generateCommonPackageInformation(packageEntity), +]; + +export const generateConanInformation = conanPackage => [ + { + label: 'Recipe', + value: conanPackage.recipe, + order: 1, + }, + ...generateCommonPackageInformation(conanPackage), +]; + +export const generateNugetInformation = nugetPackage => + orderBy( + [ + ...generateCommonPackageInformation(nugetPackage), + { + label: 'Name', + value: nugetPackage.name, + order: 1, + }, + { + label: 'Project URL', + value: nugetPackage.nuget_metadatum.project_url, + order: 3, + type: 'link', + }, + { + label: 'License URL', + value: nugetPackage.nuget_metadatum.license_url, + order: 4, + type: 'link', + }, + ], + ['order'], + ); + +export const pypiSetupCommandStr = `[gitlab] +repository = foo +username = __token__ +password = `; diff --git a/spec/frontend/packages/details/store/actions_spec.js b/spec/frontend/packages/details/store/actions_spec.js new file mode 100644 index 00000000000..5818f573877 --- /dev/null +++ b/spec/frontend/packages/details/store/actions_spec.js @@ -0,0 +1,76 @@ +import Api from '~/api'; +import createFlash from '~/flash'; +import fetchPackageVersions from '~/packages/details/store/actions'; +import * as types from '~/packages/details/store/mutation_types'; +import { FETCH_PACKAGE_VERSIONS_ERROR } from '~/packages/details/constants'; +import testAction from 'helpers/vuex_action_helper'; +import { npmPackage as packageEntity } from '../../mock_data'; + +jest.mock('~/flash.js'); +jest.mock('~/api.js'); + +describe('Actions Package details store', () => { + describe('fetchPackageVersions', () => { + it('should fetch the package versions', done => { + Api.projectPackage = jest.fn().mockResolvedValue({ data: packageEntity }); + + testAction( + fetchPackageVersions, + undefined, + { packageEntity }, + [ + { type: types.SET_LOADING, payload: true }, + { type: types.SET_PACKAGE_VERSIONS, payload: packageEntity.versions }, + { type: types.SET_LOADING, payload: false }, + ], + [], + () => { + expect(Api.projectPackage).toHaveBeenCalledWith( + packageEntity.project_id, + packageEntity.id, + ); + done(); + }, + ); + }); + + it("does not set the versions if they don't exist", done => { + Api.projectPackage = jest.fn().mockResolvedValue({ data: { packageEntity, versions: null } }); + + testAction( + fetchPackageVersions, + undefined, + { packageEntity }, + [{ type: types.SET_LOADING, payload: true }, { type: types.SET_LOADING, payload: false }], + [], + () => { + expect(Api.projectPackage).toHaveBeenCalledWith( + packageEntity.project_id, + packageEntity.id, + ); + done(); + }, + ); + }); + + it('should create flash on API error', done => { + Api.projectPackage = jest.fn().mockRejectedValue(); + + testAction( + fetchPackageVersions, + undefined, + { packageEntity }, + [{ type: types.SET_LOADING, payload: true }, { type: types.SET_LOADING, payload: false }], + [], + () => { + expect(Api.projectPackage).toHaveBeenCalledWith( + packageEntity.project_id, + packageEntity.id, + ); + expect(createFlash).toHaveBeenCalledWith(FETCH_PACKAGE_VERSIONS_ERROR); + done(); + }, + ); + }); + }); +}); diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js new file mode 100644 index 00000000000..95eb5abd0cf --- /dev/null +++ b/spec/frontend/packages/details/store/getters_spec.js @@ -0,0 +1,217 @@ +import { + conanInstallationCommand, + conanSetupCommand, + packagePipeline, + packageTypeDisplay, + packageIcon, + mavenInstallationXml, + mavenInstallationCommand, + mavenSetupXml, + npmInstallationCommand, + npmSetupCommand, + nugetInstallationCommand, + nugetSetupCommand, + pypiPipCommand, + pypiSetupCommand, +} from '~/packages/details/store/getters'; +import { + conanPackage, + npmPackage, + nugetPackage, + mockPipelineInfo, + mavenPackage as packageWithoutBuildInfo, + pypiPackage, +} from '../../mock_data'; +import { + generateMavenCommand, + generateXmlCodeBlock, + generateMavenSetupXml, + registryUrl, + pypiSetupCommandStr, +} from '../mock_data'; +import { generateConanRecipe } from '~/packages/details/utils'; +import { NpmManager } from '~/packages/details/constants'; + +describe('Getters PackageDetails Store', () => { + let state; + + const defaultState = { + packageEntity: packageWithoutBuildInfo, + conanPath: registryUrl, + mavenPath: registryUrl, + npmPath: registryUrl, + nugetPath: registryUrl, + pypiPath: registryUrl, + }; + + const setupState = (testState = {}) => { + state = { + ...defaultState, + ...testState, + }; + }; + + const recipe = generateConanRecipe(conanPackage); + const conanInstallationCommandStr = `conan install ${recipe} --remote=gitlab`; + const conanSetupCommandStr = `conan remote add gitlab ${registryUrl}`; + + const mavenCommandStr = generateMavenCommand(packageWithoutBuildInfo.maven_metadatum); + const mavenInstallationXmlBlock = generateXmlCodeBlock(packageWithoutBuildInfo.maven_metadatum); + const mavenSetupXmlBlock = generateMavenSetupXml(); + + const npmInstallStr = `npm i ${npmPackage.name}`; + const npmSetupStr = `echo @Test:registry=${registryUrl} >> .npmrc`; + const yarnInstallStr = `yarn add ${npmPackage.name}`; + const yarnSetupStr = `echo \\"@Test:registry\\" \\"${registryUrl}\\" >> .yarnrc`; + + const nugetInstallationCommandStr = `nuget install ${nugetPackage.name} -Source "GitLab"`; + const nugetSetupCommandStr = `nuget source Add -Name "GitLab" -Source "${registryUrl}" -UserName -Password `; + + const pypiPipCommandStr = `pip install ${pypiPackage.name} --index-url ${registryUrl}`; + + describe('packagePipeline', () => { + it('should return the pipeline info when pipeline exists', () => { + setupState({ + packageEntity: { + ...npmPackage, + pipeline: mockPipelineInfo, + }, + }); + + expect(packagePipeline(state)).toEqual(mockPipelineInfo); + }); + + it('should return null when build_info does not exist', () => { + setupState(); + + expect(packagePipeline(state)).toBe(null); + }); + }); + + describe('packageTypeDisplay', () => { + describe.each` + packageEntity | expectedResult + ${conanPackage} | ${'Conan'} + ${packageWithoutBuildInfo} | ${'Maven'} + ${npmPackage} | ${'NPM'} + ${nugetPackage} | ${'NuGet'} + ${pypiPackage} | ${'PyPi'} + `(`package type`, ({ packageEntity, expectedResult }) => { + beforeEach(() => setupState({ packageEntity })); + + it(`${packageEntity.package_type} should show as ${expectedResult}`, () => { + expect(packageTypeDisplay(state)).toBe(expectedResult); + }); + }); + }); + + describe('packageIcon', () => { + describe('nuget packages', () => { + it('should return nuget package icon', () => { + setupState({ packageEntity: nugetPackage }); + + expect(packageIcon(state)).toBe(nugetPackage.nuget_metadatum.icon_url); + }); + + it('should return null when nuget package does not have an icon', () => { + setupState({ packageEntity: { ...nugetPackage, nuget_metadatum: {} } }); + + expect(packageIcon(state)).toBe(null); + }); + }); + + it('should not find icons for other package types', () => { + setupState({ packageEntity: npmPackage }); + + expect(packageIcon(state)).toBe(null); + }); + }); + + describe('conan string getters', () => { + it('gets the correct conanInstallationCommand', () => { + setupState({ packageEntity: conanPackage }); + + expect(conanInstallationCommand(state)).toBe(conanInstallationCommandStr); + }); + + it('gets the correct conanSetupCommand', () => { + setupState({ packageEntity: conanPackage }); + + expect(conanSetupCommand(state)).toBe(conanSetupCommandStr); + }); + }); + + describe('maven string getters', () => { + it('gets the correct mavenInstallationXml', () => { + setupState(); + + expect(mavenInstallationXml(state)).toBe(mavenInstallationXmlBlock); + }); + + it('gets the correct mavenInstallationCommand', () => { + setupState(); + + expect(mavenInstallationCommand(state)).toBe(mavenCommandStr); + }); + + it('gets the correct mavenSetupXml', () => { + setupState(); + + expect(mavenSetupXml(state)).toBe(mavenSetupXmlBlock); + }); + }); + + describe('npm string getters', () => { + it('gets the correct npmInstallationCommand for NPM', () => { + setupState({ packageEntity: npmPackage }); + + expect(npmInstallationCommand(state)(NpmManager.NPM)).toBe(npmInstallStr); + }); + + it('gets the correct npmSetupCommand for NPM', () => { + setupState({ packageEntity: npmPackage }); + + expect(npmSetupCommand(state)(NpmManager.NPM)).toBe(npmSetupStr); + }); + + it('gets the correct npmInstallationCommand for Yarn', () => { + setupState({ packageEntity: npmPackage }); + + expect(npmInstallationCommand(state)(NpmManager.YARN)).toBe(yarnInstallStr); + }); + + it('gets the correct npmSetupCommand for Yarn', () => { + setupState({ packageEntity: npmPackage }); + + expect(npmSetupCommand(state)(NpmManager.YARN)).toBe(yarnSetupStr); + }); + }); + + describe('nuget string getters', () => { + it('gets the correct nugetInstallationCommand', () => { + setupState({ packageEntity: nugetPackage }); + + expect(nugetInstallationCommand(state)).toBe(nugetInstallationCommandStr); + }); + + it('gets the correct nugetSetupCommand', () => { + setupState({ packageEntity: nugetPackage }); + + expect(nugetSetupCommand(state)).toBe(nugetSetupCommandStr); + }); + }); + + describe('pypi string getters', () => { + it('gets the correct pypiPipCommand', () => { + setupState({ packageEntity: pypiPackage }); + + expect(pypiPipCommand(state)).toBe(pypiPipCommandStr); + }); + + it('gets the correct pypiSetupCommand', () => { + setupState({ pypiSetupPath: 'foo' }); + + expect(pypiSetupCommand(state)).toBe(pypiSetupCommandStr); + }); + }); +}); diff --git a/spec/frontend/packages/details/store/mutations_spec.js b/spec/frontend/packages/details/store/mutations_spec.js new file mode 100644 index 00000000000..501a56dcdde --- /dev/null +++ b/spec/frontend/packages/details/store/mutations_spec.js @@ -0,0 +1,31 @@ +import mutations from '~/packages/details/store/mutations'; +import * as types from '~/packages/details/store/mutation_types'; +import { npmPackage as packageEntity } from '../../mock_data'; + +describe('Mutations package details Store', () => { + let mockState; + + beforeEach(() => { + mockState = { + packageEntity, + }; + }); + + describe('SET_LOADING', () => { + it('should set loading', () => { + mutations[types.SET_LOADING](mockState, true); + + expect(mockState.isLoading).toEqual(true); + }); + }); + + describe('SET_PACKAGE_VERSIONS', () => { + it('should set the package entity versions', () => { + const fakeVersions = [1, 2, 3]; + + mutations[types.SET_PACKAGE_VERSIONS](mockState, fakeVersions); + + expect(mockState.packageEntity.versions).toEqual(fakeVersions); + }); + }); +}); diff --git a/spec/frontend/packages/details/utils_spec.js b/spec/frontend/packages/details/utils_spec.js new file mode 100644 index 00000000000..88777796613 --- /dev/null +++ b/spec/frontend/packages/details/utils_spec.js @@ -0,0 +1,71 @@ +import { generateConanRecipe, generatePackageInfo } from '~/packages/details/utils'; +import { conanPackage, mavenPackage, npmPackage, nugetPackage } from '../mock_data'; +import { + generateConanInformation, + generateStandardPackageInformation, + generateNugetInformation, +} from './mock_data'; + +describe('Package detail utils', () => { + describe('generating information', () => { + describe('conan packages', () => { + const conanInformation = generateConanInformation(conanPackage); + + it('correctly generates the conan information', () => { + const info = generatePackageInfo(conanPackage); + + expect(info).toEqual(conanInformation); + }); + + describe('generating recipe', () => { + it('correctly generates the conan recipe', () => { + const recipe = generateConanRecipe(conanPackage); + + expect(recipe).toEqual(conanPackage.recipe); + }); + + it('returns an empty recipe when no information is supplied', () => { + const recipe = generateConanRecipe({}); + + expect(recipe).toEqual('/@/'); + }); + + it('recipe returns empty strings for missing metadata', () => { + const recipe = generateConanRecipe({ name: 'foo', version: '0.0.1' }); + + expect(recipe).toEqual('foo/0.0.1@/'); + }); + }); + }); + + describe('npm packages', () => { + const npmInformation = generateStandardPackageInformation(npmPackage); + + it('correctly generates the npm information', () => { + const info = generatePackageInfo(npmPackage); + + expect(info).toEqual(npmInformation); + }); + }); + + describe('maven packages', () => { + const mavenInformation = generateStandardPackageInformation(mavenPackage); + + it('correctly generates the maven information', () => { + const info = generatePackageInfo(mavenPackage); + + expect(info).toEqual(mavenInformation); + }); + }); + + describe('nuget packages', () => { + const nugetInformation = generateNugetInformation(nugetPackage); + + it('correctly generates the nuget information', () => { + const info = generatePackageInfo(nugetPackage); + + expect(info).toEqual(nugetInformation); + }); + }); + }); +}); diff --git a/spec/frontend/packages/list/coming_soon/helpers_spec.js b/spec/frontend/packages/list/coming_soon/helpers_spec.js new file mode 100644 index 00000000000..4a996bfad76 --- /dev/null +++ b/spec/frontend/packages/list/coming_soon/helpers_spec.js @@ -0,0 +1,36 @@ +import * as comingSoon from '~/packages/list/coming_soon/helpers'; +import { fakeIssues, asGraphQLResponse, asViewModel } from './mock_data'; + +jest.mock('~/api.js'); + +describe('Coming Soon Helpers', () => { + const [noLabels, acceptingMergeRequestLabel, workflowLabel] = fakeIssues; + + describe('toViewModel', () => { + it('formats a GraphQL response correctly', () => { + expect(comingSoon.toViewModel(asGraphQLResponse)).toEqual(asViewModel); + }); + }); + + describe('findWorkflowLabel', () => { + it('finds a workflow label', () => { + expect(comingSoon.findWorkflowLabel(workflowLabel.labels)).toEqual(workflowLabel.labels[0]); + }); + + it("returns undefined when there isn't one", () => { + expect(comingSoon.findWorkflowLabel(noLabels.labels)).toBeUndefined(); + }); + }); + + describe('findAcceptingContributionsLabel', () => { + it('finds the correct label when it exists', () => { + expect(comingSoon.findAcceptingContributionsLabel(acceptingMergeRequestLabel.labels)).toEqual( + acceptingMergeRequestLabel.labels[0], + ); + }); + + it("returns undefined when there isn't one", () => { + expect(comingSoon.findAcceptingContributionsLabel(noLabels.labels)).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/packages/list/coming_soon/mock_data.js b/spec/frontend/packages/list/coming_soon/mock_data.js new file mode 100644 index 00000000000..bb4568e4bd5 --- /dev/null +++ b/spec/frontend/packages/list/coming_soon/mock_data.js @@ -0,0 +1,90 @@ +export const fakeIssues = [ + { + id: 1, + iid: 1, + title: 'issue one', + webUrl: 'foo', + }, + { + id: 2, + iid: 2, + title: 'issue two', + labels: [{ title: 'Accepting merge requests', color: '#69d100' }], + milestone: { + title: '12.10', + }, + webUrl: 'foo', + }, + { + id: 3, + iid: 3, + title: 'issue three', + labels: [{ title: 'workflow::In dev', color: '#428bca' }], + webUrl: 'foo', + }, + { + id: 4, + iid: 4, + title: 'issue four', + labels: [ + { title: 'Accepting merge requests', color: '#69d100' }, + { title: 'workflow::In dev', color: '#428bca' }, + ], + webUrl: 'foo', + }, +]; + +export const asGraphQLResponse = { + project: { + issues: { + nodes: fakeIssues.map(x => ({ + ...x, + labels: { + nodes: x.labels, + }, + })), + }, + }, +}; + +export const asViewModel = [ + { + ...fakeIssues[0], + labels: [], + }, + { + ...fakeIssues[1], + labels: [ + { + title: 'Accepting merge requests', + color: '#69d100', + scoped: false, + }, + ], + }, + { + ...fakeIssues[2], + labels: [ + { + title: 'workflow::In dev', + color: '#428bca', + scoped: true, + }, + ], + }, + { + ...fakeIssues[3], + labels: [ + { + title: 'workflow::In dev', + color: '#428bca', + scoped: true, + }, + { + title: 'Accepting merge requests', + color: '#69d100', + scoped: false, + }, + ], + }, +]; diff --git a/spec/frontend/packages/list/coming_soon/packages_coming_soon_spec.js b/spec/frontend/packages/list/coming_soon/packages_coming_soon_spec.js new file mode 100644 index 00000000000..0336c8a8839 --- /dev/null +++ b/spec/frontend/packages/list/coming_soon/packages_coming_soon_spec.js @@ -0,0 +1,138 @@ +import { GlEmptyState, GlSkeletonLoader, GlLabel } from '@gitlab/ui'; +import { mount, createLocalVue } from '@vue/test-utils'; +import ComingSoon from '~/packages/list/coming_soon/packages_coming_soon.vue'; +import { TrackingActions } from '~/packages/shared/constants'; +import { asViewModel } from './mock_data'; +import Tracking from '~/tracking'; +import VueApollo, { ApolloQuery } from 'vue-apollo'; + +jest.mock('~/packages/list/coming_soon/helpers.js'); + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('packages_coming_soon', () => { + let wrapper; + + const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader); + const findAllIssues = () => wrapper.findAll('[data-testid="issue-row"]'); + const findIssuesData = () => + findAllIssues().wrappers.map(x => { + const titleLink = x.find('[data-testid="issue-title-link"]'); + const milestone = x.find('[data-testid="milestone"]'); + const issueIdLink = x.find('[data-testid="issue-id-link"]'); + const labels = x.findAll(GlLabel); + + const issueId = Number(issueIdLink.text().substr(1)); + + return { + id: issueId, + iid: issueId, + title: titleLink.text(), + webUrl: titleLink.attributes('href'), + labels: labels.wrappers.map(label => ({ + color: label.props('backgroundColor'), + title: label.props('title'), + scoped: label.props('scoped'), + })), + ...(milestone.exists() ? { milestone: { title: milestone.text() } } : {}), + }; + }); + const findIssueTitleLink = () => wrapper.find('[data-testid="issue-title-link"]'); + const findIssueIdLink = () => wrapper.find('[data-testid="issue-id-link"]'); + const findEmptyState = () => wrapper.find(GlEmptyState); + + const mountComponent = (testParams = {}) => { + const $apolloData = { + loading: testParams.isLoading || false, + }; + + wrapper = mount(ComingSoon, { + localVue, + propsData: { + illustration: 'foo', + projectPath: 'foo', + suggestedContributionsPath: 'foo', + }, + stubs: { + ApolloQuery, + GlLink: true, + }, + mocks: { + $apolloData, + }, + }); + + // Mock the GraphQL query result + wrapper.find(ApolloQuery).setData({ + result: { + data: testParams.issues || asViewModel, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when loading', () => { + beforeEach(() => mountComponent({ isLoading: true })); + + it('renders the skeleton loader', () => { + expect(findSkeletonLoader().exists()).toBe(true); + }); + }); + + describe('when there are no issues', () => { + beforeEach(() => mountComponent({ issues: [] })); + + it('renders the empty state', () => { + expect(findEmptyState().exists()).toBe(true); + }); + }); + + describe('when there are issues', () => { + beforeEach(() => mountComponent()); + + it('renders each issue', () => { + expect(findIssuesData()).toEqual(asViewModel); + }); + }); + + describe('tracking', () => { + const firstIssue = asViewModel[0]; + let eventSpy; + + beforeEach(() => { + eventSpy = jest.spyOn(Tracking, 'event'); + mountComponent(); + }); + + it('tracks when mounted', () => { + expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_REQUESTED, {}); + }); + + it('tracks when an issue title link is clicked', () => { + eventSpy.mockClear(); + + findIssueTitleLink().vm.$emit('click'); + + expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_LIST, { + label: firstIssue.title, + value: firstIssue.iid, + }); + }); + + it('tracks when an issue id link is clicked', () => { + eventSpy.mockClear(); + + findIssueIdLink().vm.$emit('click'); + + expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_LIST, { + label: firstIssue.title, + value: firstIssue.iid, + }); + }); + }); +}); diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_filter_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_filter_spec.js.snap new file mode 100644 index 00000000000..ed77f25916f --- /dev/null +++ b/spec/frontend/packages/list/components/__snapshots__/packages_filter_spec.js.snap @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`packages_filter renders 1`] = ` + +`; diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap new file mode 100644 index 00000000000..4aa9da3687f --- /dev/null +++ b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap @@ -0,0 +1,396 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`packages_list_app renders 1`] = ` + + + + +`; diff --git a/spec/frontend/packages/list/components/packages_filter_spec.js b/spec/frontend/packages/list/components/packages_filter_spec.js new file mode 100644 index 00000000000..b186b5f5e48 --- /dev/null +++ b/spec/frontend/packages/list/components/packages_filter_spec.js @@ -0,0 +1,50 @@ +import Vuex from 'vuex'; +import { GlSearchBoxByClick } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import PackagesFilter from '~/packages/list/components/packages_filter.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('packages_filter', () => { + let wrapper; + let store; + + const findGlSearchBox = () => wrapper.find(GlSearchBoxByClick); + + const mountComponent = () => { + store = new Vuex.Store(); + store.dispatch = jest.fn(); + + wrapper = shallowMount(PackagesFilter, { + localVue, + store, + }); + }; + + beforeEach(mountComponent); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('emits events', () => { + it('sets the filter value in the store on input', () => { + const searchString = 'foo'; + findGlSearchBox().vm.$emit('input', searchString); + + expect(store.dispatch).toHaveBeenCalledWith('setFilter', searchString); + }); + + it('emits the filter event when search box is submitted', () => { + findGlSearchBox().vm.$emit('submit'); + + expect(wrapper.emitted('filter')).toBeTruthy(); + }); + }); +}); diff --git a/spec/frontend/packages/list/components/packages_list_app_spec.js b/spec/frontend/packages/list/components/packages_list_app_spec.js new file mode 100644 index 00000000000..063205e961c --- /dev/null +++ b/spec/frontend/packages/list/components/packages_list_app_spec.js @@ -0,0 +1,129 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlEmptyState, GlTab, GlTabs, GlSprintf, GlLink } from '@gitlab/ui'; +import PackageListApp from '~/packages/list/components/packages_list_app.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('packages_list_app', () => { + let wrapper; + let store; + + const PackageList = { + name: 'package-list', + template: '
', + }; + const GlLoadingIcon = { name: 'gl-loading-icon', template: '
loading
' }; + + const emptyListHelpUrl = 'helpUrl'; + const findEmptyState = () => wrapper.find(GlEmptyState); + const findListComponent = () => wrapper.find(PackageList); + const findTabComponent = (index = 0) => wrapper.findAll(GlTab).at(index); + + const createStore = (filterQuery = '') => { + store = new Vuex.Store({ + state: { + isLoading: false, + config: { + resourceId: 'project_id', + emptyListIllustration: 'helpSvg', + emptyListHelpUrl, + }, + filterQuery, + }, + }); + store.dispatch = jest.fn(); + }; + + const mountComponent = () => { + wrapper = shallowMount(PackageListApp, { + localVue, + store, + stubs: { + GlEmptyState, + GlLoadingIcon, + PackageList, + GlTab, + GlTabs, + GlSprintf, + GlLink, + }, + }); + }; + + beforeEach(() => { + createStore(); + mountComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + mountComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('empty state', () => { + it('generate the correct empty list link', () => { + const link = findListComponent().find(GlLink); + + expect(link.attributes('href')).toBe(emptyListHelpUrl); + expect(link.text()).toBe('publish and share your packages'); + }); + + it('includes the right content on the default tab', () => { + const heading = findEmptyState().find('h1'); + + expect(heading.text()).toBe('There are no packages yet'); + }); + }); + + it('call requestPackagesList on page:changed', () => { + const list = findListComponent(); + list.vm.$emit('page:changed', 1); + expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList', { page: 1 }); + }); + + it('call requestDeletePackage on package:delete', () => { + const list = findListComponent(); + list.vm.$emit('package:delete', 'foo'); + expect(store.dispatch).toHaveBeenCalledWith('requestDeletePackage', 'foo'); + }); + + it('calls requestPackagesList on sort:changed', () => { + const list = findListComponent(); + list.vm.$emit('sort:changed'); + expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList'); + }); + + describe('tab change', () => { + it('calls requestPackagesList when all tab is clicked', () => { + findTabComponent().trigger('click'); + + expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList'); + }); + + it('calls requestPackagesList when a package type tab is clicked', () => { + findTabComponent(1).trigger('click'); + + expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList'); + }); + }); + + describe('filter without results', () => { + beforeEach(() => { + createStore('foo'); + mountComponent(); + }); + + it('should show specific empty message', () => { + expect(findEmptyState().text()).toContain('Sorry, your filter produced no results'); + expect(findEmptyState().text()).toContain( + 'To widen your search, change or remove the filters above', + ); + }); + }); +}); diff --git a/spec/frontend/packages/list/components/packages_list_spec.js b/spec/frontend/packages/list/components/packages_list_spec.js new file mode 100644 index 00000000000..cca2fab3cfd --- /dev/null +++ b/spec/frontend/packages/list/components/packages_list_spec.js @@ -0,0 +1,219 @@ +import Vuex from 'vuex'; +import { last } from 'lodash'; +import { GlTable, GlPagination, GlModal } from '@gitlab/ui'; +import Tracking from '~/tracking'; +import { mount, createLocalVue } from '@vue/test-utils'; +import PackagesList from '~/packages/list/components/packages_list.vue'; +import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; +import PackagesListRow from '~/packages/shared/components/package_list_row.vue'; +import * as SharedUtils from '~/packages/shared/utils'; +import { TrackingActions } from '~/packages/shared/constants'; +import stubChildren from 'helpers/stub_children'; +import { packageList } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('packages_list', () => { + let wrapper; + let store; + + const GlSortingItem = { name: 'sorting-item-stub', template: '
' }; + const EmptySlotStub = { name: 'empty-slot-stub', template: '
bar
' }; + + const findPackagesListLoader = () => wrapper.find(PackagesListLoader); + const findPackageListPagination = () => wrapper.find(GlPagination); + const findPackageListDeleteModal = () => wrapper.find(GlModal); + const findEmptySlot = () => wrapper.find({ name: 'empty-slot-stub' }); + const findPackagesListRow = () => wrapper.find(PackagesListRow); + + const createStore = (isGroupPage, packages, isLoading) => { + const state = { + isLoading, + packages, + pagination: { + perPage: 1, + total: 1, + page: 1, + }, + config: { + isGroupPage, + }, + sorting: { + orderBy: 'version', + sort: 'desc', + }, + }; + store = new Vuex.Store({ + state, + getters: { + getList: () => packages, + }, + }); + store.dispatch = jest.fn(); + }; + + const mountComponent = ({ + isGroupPage = false, + packages = packageList, + isLoading = false, + ...options + } = {}) => { + createStore(isGroupPage, packages, isLoading); + + wrapper = mount(PackagesList, { + localVue, + store, + stubs: { + ...stubChildren(PackagesList), + GlTable, + GlSortingItem, + GlModal, + }, + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when is loading', () => { + beforeEach(() => { + mountComponent({ + packages: [], + isLoading: true, + }); + }); + + it('shows skeleton loader when loading', () => { + expect(findPackagesListLoader().exists()).toBe(true); + }); + }); + + describe('when is not loading', () => { + beforeEach(() => { + mountComponent(); + }); + + it('does not show skeleton loader when not loading', () => { + expect(findPackagesListLoader().exists()).toBe(false); + }); + }); + + describe('layout', () => { + beforeEach(() => { + mountComponent(); + }); + + it('contains a pagination component', () => { + const sorting = findPackageListPagination(); + expect(sorting.exists()).toBe(true); + }); + + it('contains a modal component', () => { + const sorting = findPackageListDeleteModal(); + expect(sorting.exists()).toBe(true); + }); + }); + + describe('when the user can destroy the package', () => { + beforeEach(() => { + mountComponent(); + }); + + it('setItemToBeDeleted sets itemToBeDeleted and open the modal', () => { + const mockModalShow = jest.spyOn(wrapper.vm.$refs.packageListDeleteModal, 'show'); + const item = last(wrapper.vm.list); + + findPackagesListRow().vm.$emit('packageToDelete', item); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.itemToBeDeleted).toEqual(item); + expect(mockModalShow).toHaveBeenCalled(); + }); + }); + + it('deleteItemConfirmation resets itemToBeDeleted', () => { + wrapper.setData({ itemToBeDeleted: 1 }); + wrapper.vm.deleteItemConfirmation(); + expect(wrapper.vm.itemToBeDeleted).toEqual(null); + }); + + it('deleteItemConfirmation emit package:delete', () => { + const itemToBeDeleted = { id: 2 }; + wrapper.setData({ itemToBeDeleted }); + wrapper.vm.deleteItemConfirmation(); + return wrapper.vm.$nextTick(() => { + expect(wrapper.emitted('package:delete')[0]).toEqual([itemToBeDeleted]); + }); + }); + + it('deleteItemCanceled resets itemToBeDeleted', () => { + wrapper.setData({ itemToBeDeleted: 1 }); + wrapper.vm.deleteItemCanceled(); + expect(wrapper.vm.itemToBeDeleted).toEqual(null); + }); + }); + + describe('when the list is empty', () => { + beforeEach(() => { + mountComponent({ + packages: [], + slots: { + 'empty-state': EmptySlotStub, + }, + }); + }); + + it('show the empty slot', () => { + const emptySlot = findEmptySlot(); + expect(emptySlot.exists()).toBe(true); + }); + }); + + describe('pagination component', () => { + let pagination; + let modelEvent; + + beforeEach(() => { + mountComponent(); + pagination = findPackageListPagination(); + // retrieve the event used by v-model, a more sturdy approach than hardcoding it + modelEvent = pagination.vm.$options.model.event; + }); + + it('emits page:changed events when the page changes', () => { + pagination.vm.$emit(modelEvent, 2); + expect(wrapper.emitted('page:changed')).toEqual([[2]]); + }); + }); + + describe('tracking', () => { + let eventSpy; + let utilSpy; + const category = 'foo'; + + beforeEach(() => { + mountComponent(); + eventSpy = jest.spyOn(Tracking, 'event'); + utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category); + wrapper.setData({ itemToBeDeleted: { package_type: 'conan' } }); + }); + + it('tracking category calls packageTypeToTrackCategory', () => { + expect(wrapper.vm.tracking.category).toBe(category); + expect(utilSpy).toHaveBeenCalledWith('conan'); + }); + + it('deleteItemConfirmation calls event', () => { + wrapper.vm.deleteItemConfirmation(); + expect(eventSpy).toHaveBeenCalledWith( + category, + TrackingActions.DELETE_PACKAGE, + expect.any(Object), + ); + }); + }); +}); diff --git a/spec/frontend/packages/list/components/packages_sort_spec.js b/spec/frontend/packages/list/components/packages_sort_spec.js new file mode 100644 index 00000000000..47fe0164255 --- /dev/null +++ b/spec/frontend/packages/list/components/packages_sort_spec.js @@ -0,0 +1,92 @@ +import Vuex from 'vuex'; +import { GlSorting } from '@gitlab/ui'; +import { mount, createLocalVue } from '@vue/test-utils'; +import PackagesSort from '~/packages/list/components/packages_sort.vue'; +import stubChildren from 'helpers/stub_children'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('packages_sort', () => { + let wrapper; + let store; + let sorting; + let sortingItems; + + const GlSortingItem = { name: 'sorting-item-stub', template: '
' }; + + const findPackageListSorting = () => wrapper.find(GlSorting); + const findSortingItems = () => wrapper.findAll(GlSortingItem); + + const createStore = isGroupPage => { + const state = { + config: { + isGroupPage, + }, + sorting: { + orderBy: 'version', + sort: 'desc', + }, + }; + store = new Vuex.Store({ + state, + }); + store.dispatch = jest.fn(); + }; + + const mountComponent = (isGroupPage = false) => { + createStore(isGroupPage); + + wrapper = mount(PackagesSort, { + localVue, + store, + stubs: { + ...stubChildren(PackagesSort), + GlSortingItem, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when is in projects', () => { + beforeEach(() => { + mountComponent(); + sorting = findPackageListSorting(); + sortingItems = findSortingItems(); + }); + + it('has all the sortable items', () => { + expect(sortingItems).toHaveLength(wrapper.vm.sortableFields.length); + }); + + it('on sort change set sorting in vuex and emit event', () => { + sorting.vm.$emit('sortDirectionChange'); + expect(store.dispatch).toHaveBeenCalledWith('setSorting', { sort: 'asc' }); + expect(wrapper.emitted('sort:changed')).toBeTruthy(); + }); + + it('on sort item click set sorting and emit event', () => { + const item = sortingItems.at(0); + const { orderBy } = wrapper.vm.sortableFields[0]; + item.vm.$emit('click'); + expect(store.dispatch).toHaveBeenCalledWith('setSorting', { orderBy }); + expect(wrapper.emitted('sort:changed')).toBeTruthy(); + }); + }); + + describe('when is in group', () => { + beforeEach(() => { + mountComponent(true); + sorting = findPackageListSorting(); + sortingItems = findSortingItems(); + }); + + it('has all the sortable items', () => { + expect(sortingItems).toHaveLength(wrapper.vm.sortableFields.length); + }); + }); +}); diff --git a/spec/frontend/packages/list/stores/actions_spec.js b/spec/frontend/packages/list/stores/actions_spec.js new file mode 100644 index 00000000000..b406e0d1282 --- /dev/null +++ b/spec/frontend/packages/list/stores/actions_spec.js @@ -0,0 +1,240 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import Api from '~/api'; +import createFlash from '~/flash'; +import * as actions from '~/packages/list/stores/actions'; +import * as types from '~/packages/list/stores/mutation_types'; +import { MISSING_DELETE_PATH_ERROR, DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/list/constants'; +import testAction from 'helpers/vuex_action_helper'; + +jest.mock('~/flash.js'); +jest.mock('~/api.js'); + +describe('Actions Package list store', () => { + const headers = 'bar'; + let mock; + + beforeEach(() => { + Api.projectPackages = jest.fn().mockResolvedValue({ data: 'foo', headers }); + Api.groupPackages = jest.fn().mockResolvedValue({ data: 'baz', headers }); + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('requestPackagesList', () => { + const sorting = { + sort: 'asc', + orderBy: 'version', + }; + it('should fetch the project packages list when isGroupPage is false', done => { + testAction( + actions.requestPackagesList, + undefined, + { config: { isGroupPage: false, resourceId: 1 }, sorting }, + [], + [ + { type: 'setLoading', payload: true }, + { type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } }, + { type: 'setLoading', payload: false }, + ], + () => { + expect(Api.projectPackages).toHaveBeenCalledWith(1, { + params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy }, + }); + done(); + }, + ); + }); + + it('should fetch the group packages list when isGroupPage is true', done => { + testAction( + actions.requestPackagesList, + undefined, + { config: { isGroupPage: true, resourceId: 2 }, sorting }, + [], + [ + { type: 'setLoading', payload: true }, + { type: 'receivePackagesListSuccess', payload: { data: 'baz', headers } }, + { type: 'setLoading', payload: false }, + ], + () => { + expect(Api.groupPackages).toHaveBeenCalledWith(2, { + params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy }, + }); + done(); + }, + ); + }); + + it('should fetch packages of a certain type when selectedType is present', done => { + const packageType = 'maven'; + + testAction( + actions.requestPackagesList, + undefined, + { + config: { isGroupPage: false, resourceId: 1 }, + sorting, + selectedType: { type: packageType }, + }, + [], + [ + { type: 'setLoading', payload: true }, + { type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } }, + { type: 'setLoading', payload: false }, + ], + () => { + expect(Api.projectPackages).toHaveBeenCalledWith(1, { + params: { + page: 1, + per_page: 20, + sort: sorting.sort, + order_by: sorting.orderBy, + package_type: packageType, + }, + }); + done(); + }, + ); + }); + + it('should create flash on API error', done => { + Api.projectPackages = jest.fn().mockRejectedValue(); + testAction( + actions.requestPackagesList, + undefined, + { config: { isGroupPage: false, resourceId: 2 }, sorting }, + [], + [{ type: 'setLoading', payload: true }, { type: 'setLoading', payload: false }], + () => { + expect(createFlash).toHaveBeenCalled(); + done(); + }, + ); + }); + }); + + describe('receivePackagesListSuccess', () => { + it('should set received packages', done => { + const data = 'foo'; + + testAction( + actions.receivePackagesListSuccess, + { data, headers }, + null, + [ + { type: types.SET_PACKAGE_LIST_SUCCESS, payload: data }, + { type: types.SET_PAGINATION, payload: headers }, + ], + [], + done, + ); + }); + }); + + describe('setInitialState', () => { + it('should commit setInitialState', done => { + testAction( + actions.setInitialState, + '1', + null, + [{ type: types.SET_INITIAL_STATE, payload: '1' }], + [], + done, + ); + }); + }); + + describe('setLoading', () => { + it('should commit set main loading', done => { + testAction( + actions.setLoading, + true, + null, + [{ type: types.SET_MAIN_LOADING, payload: true }], + [], + done, + ); + }); + }); + + describe('requestDeletePackage', () => { + const payload = { + _links: { + delete_api_path: 'foo', + }, + }; + it('should perform a delete operation on _links.delete_api_path', done => { + mock.onDelete(payload._links.delete_api_path).replyOnce(200); + Api.projectPackages = jest.fn().mockResolvedValue({ data: 'foo' }); + + testAction( + actions.requestDeletePackage, + payload, + { pagination: { page: 1 } }, + [], + [ + { type: 'setLoading', payload: true }, + { type: 'requestPackagesList', payload: { page: 1 } }, + ], + done, + ); + }); + + it('should stop the loading and call create flash on api error', done => { + mock.onDelete(payload._links.delete_api_path).replyOnce(400); + testAction( + actions.requestDeletePackage, + payload, + null, + [], + [{ type: 'setLoading', payload: true }, { type: 'setLoading', payload: false }], + () => { + expect(createFlash).toHaveBeenCalled(); + done(); + }, + ); + }); + + it.each` + property | actionPayload + ${'_links'} | ${{}} + ${'delete_api_path'} | ${{ _links: {} }} + `('should reject and createFlash when $property is missing', ({ actionPayload }, done) => { + testAction(actions.requestDeletePackage, actionPayload, null, [], []).catch(e => { + expect(e).toEqual(new Error(MISSING_DELETE_PATH_ERROR)); + expect(createFlash).toHaveBeenCalledWith(DELETE_PACKAGE_ERROR_MESSAGE); + done(); + }); + }); + }); + + describe('setSorting', () => { + it('should commit SET_SORTING', done => { + testAction( + actions.setSorting, + 'foo', + null, + [{ type: types.SET_SORTING, payload: 'foo' }], + [], + done, + ); + }); + }); + + describe('setFilter', () => { + it('should commit SET_FILTER', done => { + testAction( + actions.setFilter, + 'foo', + null, + [{ type: types.SET_FILTER, payload: 'foo' }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/packages/list/stores/getters_spec.js b/spec/frontend/packages/list/stores/getters_spec.js new file mode 100644 index 00000000000..080bbc21d9f --- /dev/null +++ b/spec/frontend/packages/list/stores/getters_spec.js @@ -0,0 +1,36 @@ +import getList from '~/packages/list/stores/getters'; +import { packageList } from '../../mock_data'; + +describe('Getters registry list store', () => { + let state; + + const setState = ({ isGroupPage = false } = {}) => { + state = { + packages: packageList, + config: { + isGroupPage, + }, + }; + }; + + beforeEach(() => setState()); + + afterEach(() => { + state = null; + }); + + describe('getList', () => { + it('returns a list of packages', () => { + const result = getList(state); + + expect(result).toHaveLength(packageList.length); + expect(result[0].name).toBe('Test package'); + }); + + it('adds projectPathName', () => { + const result = getList(state); + + expect(result[0].projectPathName).toMatchInlineSnapshot(`"foo / bar / baz"`); + }); + }); +}); diff --git a/spec/frontend/packages/list/stores/mutations_spec.js b/spec/frontend/packages/list/stores/mutations_spec.js new file mode 100644 index 00000000000..563a3dabbb3 --- /dev/null +++ b/spec/frontend/packages/list/stores/mutations_spec.js @@ -0,0 +1,95 @@ +import mutations from '~/packages/list/stores/mutations'; +import * as types from '~/packages/list/stores/mutation_types'; +import createState from '~/packages/list/stores/state'; +import * as commonUtils from '~/lib/utils/common_utils'; +import { npmPackage, mavenPackage } from '../../mock_data'; + +describe('Mutations Registry Store', () => { + let mockState; + beforeEach(() => { + mockState = createState(); + }); + + describe('SET_INITIAL_STATE', () => { + it('should set the initial state', () => { + const config = { + resourceId: '1', + pageType: 'groups', + userCanDelete: '', + emptyListIllustration: 'foo', + emptyListHelpUrl: 'baz', + comingSoonJson: '{ "project_path": "gitlab-org/gitlab-test" }', + }; + + const expectedState = { + ...mockState, + config: { + ...config, + isGroupPage: true, + canDestroyPackage: true, + }, + }; + mutations[types.SET_INITIAL_STATE](mockState, config); + + expect(mockState.projectId).toEqual(expectedState.projectId); + }); + }); + + describe('SET_PACKAGE_LIST_SUCCESS', () => { + it('should set a packages list', () => { + const payload = [npmPackage, mavenPackage]; + const expectedState = { ...mockState, packages: payload }; + mutations[types.SET_PACKAGE_LIST_SUCCESS](mockState, payload); + + expect(mockState.packages).toEqual(expectedState.packages); + }); + }); + + describe('SET_MAIN_LOADING', () => { + it('should set main loading', () => { + mutations[types.SET_MAIN_LOADING](mockState, true); + + expect(mockState.isLoading).toEqual(true); + }); + }); + + describe('SET_PAGINATION', () => { + const mockPagination = { perPage: 10, page: 1 }; + beforeEach(() => { + commonUtils.normalizeHeaders = jest.fn().mockReturnValue('baz'); + commonUtils.parseIntPagination = jest.fn().mockReturnValue(mockPagination); + }); + it('should set a parsed pagination', () => { + mutations[types.SET_PAGINATION](mockState, 'foo'); + expect(commonUtils.normalizeHeaders).toHaveBeenCalledWith('foo'); + expect(commonUtils.parseIntPagination).toHaveBeenCalledWith('baz'); + expect(mockState.pagination).toEqual(mockPagination); + }); + }); + + describe('SET_SORTING', () => { + it('should merge the sorting object with sort value', () => { + mutations[types.SET_SORTING](mockState, { sort: 'desc' }); + expect(mockState.sorting).toEqual({ ...mockState.sorting, sort: 'desc' }); + }); + + it('should merge the sorting object with order_by value', () => { + mutations[types.SET_SORTING](mockState, { orderBy: 'foo' }); + expect(mockState.sorting).toEqual({ ...mockState.sorting, orderBy: 'foo' }); + }); + }); + + describe('SET_SELECTED_TYPE', () => { + it('should set the selected type', () => { + mutations[types.SET_SELECTED_TYPE](mockState, { type: 'maven' }); + expect(mockState.selectedType).toEqual({ type: 'maven' }); + }); + }); + + describe('SET_FILTER', () => { + it('should set the filter query', () => { + mutations[types.SET_FILTER](mockState, 'foo'); + expect(mockState.filterQuery).toEqual('foo'); + }); + }); +}); diff --git a/spec/frontend/packages/list/utils_spec.js b/spec/frontend/packages/list/utils_spec.js new file mode 100644 index 00000000000..5bcc3784752 --- /dev/null +++ b/spec/frontend/packages/list/utils_spec.js @@ -0,0 +1,39 @@ +import { getNewPaginationPage } from '~/packages/list/utils'; + +describe('Packages list utils', () => { + describe('packageTypeDisplay', () => { + it('returns the current page when total items exceeds pagniation', () => { + expect(getNewPaginationPage(2, 20, 21)).toBe(2); + }); + + it('returns the previous page when total items is lower than or equal to pagination', () => { + expect(getNewPaginationPage(2, 20, 20)).toBe(1); + }); + + it('returns the first page when totalItems is lower than or equal to perPage', () => { + expect(getNewPaginationPage(4, 20, 20)).toBe(1); + }); + + describe('works when a different perPage is used', () => { + it('returns the current page', () => { + expect(getNewPaginationPage(2, 10, 11)).toBe(2); + }); + + it('returns the previous page', () => { + expect(getNewPaginationPage(2, 10, 10)).toBe(1); + }); + }); + + describe.each` + currentPage | totalItems | expectedResult + ${1} | ${20} | ${1} + ${2} | ${20} | ${1} + ${3} | ${40} | ${2} + ${4} | ${60} | ${3} + `(`works across numerious pages`, ({ currentPage, totalItems, expectedResult }) => { + it(`when currentPage is ${currentPage} return to the previous page ${expectedResult}`, () => { + expect(getNewPaginationPage(currentPage, 20, totalItems)).toBe(expectedResult); + }); + }); + }); +}); diff --git a/spec/frontend/packages/mock_data.js b/spec/frontend/packages/mock_data.js new file mode 100644 index 00000000000..62cf73d44ac --- /dev/null +++ b/spec/frontend/packages/mock_data.js @@ -0,0 +1,155 @@ +const _links = { + web_path: 'foo', + delete_api_path: 'bar', +}; + +export const mockPipelineInfo = { + id: 1, + ref: 'branch-name', + sha: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + user: { + name: 'foo', + }, + project: { + name: 'foo-project', + web_url: 'foo-project-link', + }, +}; + +export const mavenPackage = { + created_at: '2015-12-10', + id: 1, + maven_metadatum: { + app_group: 'com.test.app', + app_name: 'test-app', + app_version: '1.0-SNAPSHOT', + }, + name: 'Test package', + package_type: 'maven', + project_path: 'foo/bar/baz', + project_id: 1, + updated_at: '2015-12-10', + version: '1.0.0', + _links, +}; + +export const mavenFiles = [ + { + created_at: '2015-12-10', + file_name: 'File one', + id: 1, + size: 100, + download_path: '/-/package_files/1/download', + }, + { + created_at: '2015-12-10', + file_name: 'File two', + id: 2, + size: 200, + download_path: '/-/package_files/2/download', + }, +]; + +export const npmPackage = { + created_at: '2015-12-10', + id: 2, + name: '@Test/package', + package_type: 'npm', + project_path: 'foo/bar/baz', + project_id: 1, + updated_at: '2015-12-10', + version: '', + versions: [], + _links, + pipeline: mockPipelineInfo, +}; + +export const npmFiles = [ + { + created_at: '2015-12-10', + file_name: '@test/test-package-1.0.0.tgz', + id: 2, + size: 200, + download_path: '/-/package_files/2/download', + }, +]; + +export const conanPackage = { + conan_metadatum: { + package_channel: 'stable', + package_username: 'conan+conan-package', + }, + created_at: '2015-12-10', + id: 3, + name: 'conan-package', + project_path: 'foo/bar/baz', + package_files: [], + package_type: 'conan', + project_id: 1, + recipe: 'conan-package/1.0.0@conan+conan-package/stable', + updated_at: '2015-12-10', + version: '1.0.0', + _links, +}; + +export const dependencyLinks = { + withoutFramework: { name: 'Moqi', version_pattern: '2.5.6' }, + withoutVersion: { name: 'Castle.Core', version_pattern: '' }, + fullLink: { + name: 'Test.Dependency', + version_pattern: '2.3.7', + target_framework: '.NETStandard2.0', + }, + anotherFullLink: { + name: 'Newtonsoft.Json', + version_pattern: '12.0.3', + target_framework: '.NETStandard2.0', + }, +}; + +export const nugetPackage = { + created_at: '2015-12-10', + id: 4, + name: 'NugetPackage1', + package_files: [], + package_type: 'nuget', + project_id: 1, + tags: [], + updated_at: '2015-12-10', + version: '1.0.0', + dependency_links: Object.values(dependencyLinks), + nuget_metadatum: { + icon_url: 'fake-icon', + project_url: 'project-foo-url', + license_url: 'license-foo-url', + }, +}; + +export const pypiPackage = { + created_at: '2015-12-10', + id: 5, + name: 'PyPiPackage', + package_files: [], + package_type: 'pypi', + project_id: 1, + tags: [], + updated_at: '2015-12-10', + version: '1.0.0', +}; + +export const mockTags = [ + { + name: 'foo-1', + }, + { + name: 'foo-2', + }, + { + name: 'foo-3', + }, + { + name: 'foo-4', + }, +]; + +export const packageList = [mavenPackage, { ...npmPackage, tags: mockTags }, conanPackage]; diff --git a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap new file mode 100644 index 00000000000..eab8d7b67cc --- /dev/null +++ b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`packages_list_row renders 1`] = ` +
+
+
+ + + Test package + + + + +
+ +
+ + 1.0.0 + + + + +
+ + + + + +
+ +
+ + + + Maven + +
+
+
+ +
+ + +
+ +
+
+ +
+ +
+
+`; diff --git a/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap new file mode 100644 index 00000000000..01a2e80173a --- /dev/null +++ b/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`publish_method renders 1`] = ` +
+ + + + branch-name + + + + + + xxxxxxxx + + + +
+`; diff --git a/spec/frontend/packages/shared/components/package_list_row_spec.js b/spec/frontend/packages/shared/components/package_list_row_spec.js new file mode 100644 index 00000000000..61a5bb16edb --- /dev/null +++ b/spec/frontend/packages/shared/components/package_list_row_spec.js @@ -0,0 +1,112 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import PackagesListRow from '~/packages/shared/components/package_list_row.vue'; +import PackageTags from '~/packages/shared/components/package_tags.vue'; +import { packageList } from '../../mock_data'; + +describe('packages_list_row', () => { + let wrapper; + let store; + + const [packageWithoutTags, packageWithTags] = packageList; + + const findPackageTags = () => wrapper.find(PackageTags); + const findProjectLink = () => wrapper.find('[data-testid="packages-row-project"]'); + const findDeleteButton = () => wrapper.find('[data-testid="action-delete"]'); + const findPackageType = () => wrapper.find('[data-testid="package-type"]'); + + const mountComponent = ({ + isGroup = false, + packageEntity = packageWithoutTags, + shallow = true, + showPackageType = true, + disableDelete = false, + } = {}) => { + const mountFunc = shallow ? shallowMount : mount; + + wrapper = mountFunc(PackagesListRow, { + store, + propsData: { + packageLink: 'foo', + packageEntity, + isGroup, + showPackageType, + disableDelete, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders', () => { + mountComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('tags', () => { + it('renders package tags when a package has tags', () => { + mountComponent({ isGroup: false, packageEntity: packageWithTags }); + + expect(findPackageTags().exists()).toBe(true); + }); + + it('does not render when there are no tags', () => { + mountComponent(); + + expect(findPackageTags().exists()).toBe(false); + }); + }); + + describe('when is is group', () => { + beforeEach(() => { + mountComponent({ isGroup: true }); + }); + + it('has project field', () => { + expect(findProjectLink().exists()).toBe(true); + }); + + it('does not show the delete button', () => { + expect(findDeleteButton().exists()).toBe(false); + }); + }); + + describe('showPackageType', () => { + it('shows the type when set', () => { + mountComponent(); + + expect(findPackageType().exists()).toBe(true); + }); + + it('does not show the type when not set', () => { + mountComponent({ showPackageType: false }); + + expect(findPackageType().exists()).toBe(false); + }); + }); + + describe('deleteAvailable', () => { + it('does not show when not set', () => { + mountComponent({ disableDelete: true }); + + expect(findDeleteButton().exists()).toBe(false); + }); + }); + + describe('delete event', () => { + beforeEach(() => + mountComponent({ isGroup: false, packageEntity: packageWithoutTags, shallow: false }), + ); + + it('emits the packageToDelete event when the delete button is clicked', () => { + findDeleteButton().trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('packageToDelete')).toBeTruthy(); + expect(wrapper.emitted('packageToDelete')[0]).toEqual([packageWithoutTags]); + }); + }); + }); +}); diff --git a/spec/frontend/packages/shared/components/package_tags_spec.js b/spec/frontend/packages/shared/components/package_tags_spec.js new file mode 100644 index 00000000000..cc49a9a9244 --- /dev/null +++ b/spec/frontend/packages/shared/components/package_tags_spec.js @@ -0,0 +1,115 @@ +import { mount } from '@vue/test-utils'; +import PackageTags from '~/packages/shared/components/package_tags.vue'; +import { mockTags } from '../../mock_data'; + +describe('PackageTags', () => { + let wrapper; + + function createComponent(tags = [], props = {}) { + const propsData = { + tags, + ...props, + }; + + wrapper = mount(PackageTags, { + propsData, + }); + } + + const tagLabel = () => wrapper.find('[data-testid="tagLabel"]'); + const tagBadges = () => wrapper.findAll('[data-testid="tagBadge"]'); + const moreBadge = () => wrapper.find('[data-testid="moreBadge"]'); + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + describe('tag label', () => { + it('shows the tag label by default', () => { + createComponent(); + + expect(tagLabel().exists()).toBe(true); + }); + + it('hides when hideLabel prop is set to true', () => { + createComponent(mockTags, { hideLabel: true }); + + expect(tagLabel().exists()).toBe(false); + }); + }); + + it('renders the correct number of tags', () => { + createComponent(mockTags.slice(0, 2)); + + expect(tagBadges()).toHaveLength(2); + expect(moreBadge().exists()).toBe(false); + }); + + it('does not render more than the configured tagDisplayLimit', () => { + createComponent(mockTags); + + expect(tagBadges()).toHaveLength(2); + }); + + it('renders the more tags badge if there are more than the configured limit', () => { + createComponent(mockTags); + + expect(tagBadges()).toHaveLength(2); + expect(moreBadge().exists()).toBe(true); + expect(moreBadge().text()).toContain('2'); + }); + + it('renders the configured tagDisplayLimit when set in props', () => { + createComponent(mockTags, { tagDisplayLimit: 1 }); + + expect(tagBadges()).toHaveLength(1); + expect(moreBadge().exists()).toBe(true); + expect(moreBadge().text()).toContain('3'); + }); + + describe('tagBadgeStyle', () => { + const defaultStyle = ['badge', 'badge-info', 'gl-display-none']; + + it('shows tag badge when there is only one', () => { + createComponent([mockTags[0]]); + + const expectedStyle = [...defaultStyle, 'gl-display-flex', 'gl-ml-3']; + + expect( + tagBadges() + .at(0) + .classes(), + ).toEqual(expect.arrayContaining(expectedStyle)); + }); + + it('shows tag badge for medium or heigher resolutions', () => { + createComponent(mockTags); + + const expectedStyle = [...defaultStyle, 'd-md-flex']; + + expect( + tagBadges() + .at(1) + .classes(), + ).toEqual(expect.arrayContaining(expectedStyle)); + }); + + it('correctly prepends left and appends right when there is more than one tag', () => { + createComponent(mockTags, { + tagDisplayLimit: 4, + }); + + const expectedStyleWithoutAppend = [...defaultStyle, 'd-md-flex']; + const expectedStyleWithAppend = [...expectedStyleWithoutAppend, 'gl-mr-2']; + + const allBadges = tagBadges(); + + expect(allBadges.at(0).classes()).toEqual( + expect.arrayContaining([...expectedStyleWithAppend, 'gl-ml-3']), + ); + expect(allBadges.at(1).classes()).toEqual(expect.arrayContaining(expectedStyleWithAppend)); + expect(allBadges.at(2).classes()).toEqual(expect.arrayContaining(expectedStyleWithAppend)); + expect(allBadges.at(3).classes()).toEqual(expect.arrayContaining(expectedStyleWithoutAppend)); + }); + }); +}); diff --git a/spec/frontend/packages/shared/components/packages_list_loader_spec.js b/spec/frontend/packages/shared/components/packages_list_loader_spec.js new file mode 100644 index 00000000000..c8c2e2a4ba4 --- /dev/null +++ b/spec/frontend/packages/shared/components/packages_list_loader_spec.js @@ -0,0 +1,42 @@ +import { mount } from '@vue/test-utils'; +import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; + +describe('PackagesListLoader', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = mount(PackagesListLoader, { + propsData: { + ...props, + }, + }); + }; + + const getShapes = () => wrapper.vm.desktopShapes; + const findSquareButton = () => wrapper.find({ ref: 'button-loader' }); + + beforeEach(createComponent); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when used for projects', () => { + it('should return 5 rects with last one being a square', () => { + expect(getShapes()).toHaveLength(5); + expect(findSquareButton().exists()).toBe(true); + }); + }); + + describe('when used for groups', () => { + beforeEach(() => { + createComponent({ isGroup: true }); + }); + + it('should return 5 rects with no square', () => { + expect(getShapes()).toHaveLength(5); + expect(findSquareButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/packages/shared/components/publish_method_spec.js b/spec/frontend/packages/shared/components/publish_method_spec.js new file mode 100644 index 00000000000..bb9287c1204 --- /dev/null +++ b/spec/frontend/packages/shared/components/publish_method_spec.js @@ -0,0 +1,50 @@ +import { shallowMount } from '@vue/test-utils'; +import PublishMethod from '~/packages/shared/components/publish_method.vue'; +import { packageList } from '../../mock_data'; + +describe('publish_method', () => { + let wrapper; + + const [packageWithoutPipeline, packageWithPipeline] = packageList; + + const findPipelineRef = () => wrapper.find({ ref: 'pipeline-ref' }); + const findPipelineSha = () => wrapper.find({ ref: 'pipeline-sha' }); + const findManualPublish = () => wrapper.find({ ref: 'manual-ref' }); + + const mountComponent = (packageEntity = {}, isGroup = false) => { + wrapper = shallowMount(PublishMethod, { + propsData: { + packageEntity, + isGroup, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders', () => { + mountComponent(packageWithPipeline); + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('pipeline information', () => { + it('displays branch and commit when pipeline info exists', () => { + mountComponent(packageWithPipeline); + + expect(findPipelineRef().exists()).toBe(true); + expect(findPipelineSha().exists()).toBe(true); + }); + + it('does not show any pipeline details when no information exists', () => { + mountComponent(packageWithoutPipeline); + + expect(findPipelineRef().exists()).toBe(false); + expect(findPipelineSha().exists()).toBe(false); + expect(findManualPublish().exists()).toBe(true); + expect(findManualPublish().text()).toBe('Manually Published'); + }); + }); +}); diff --git a/spec/frontend/packages/shared/utils_spec.js b/spec/frontend/packages/shared/utils_spec.js new file mode 100644 index 00000000000..abc873efae0 --- /dev/null +++ b/spec/frontend/packages/shared/utils_spec.js @@ -0,0 +1,65 @@ +import { + packageTypeToTrackCategory, + beautifyPath, + getPackageTypeLabel, + getCommitLink, +} from '~/packages/shared/utils'; +import { PackageType, TrackingCategories } from '~/packages/shared/constants'; +import { packageList } from '../mock_data'; + +describe('Packages shared utils', () => { + describe('packageTypeToTrackCategory', () => { + it('prepend UI to package category', () => { + expect(packageTypeToTrackCategory()).toMatchInlineSnapshot(`"UI::undefined"`); + }); + + it.each(Object.keys(PackageType))('returns a correct category string for %s', packageKey => { + const packageName = PackageType[packageKey]; + expect(packageTypeToTrackCategory(packageName)).toBe( + `UI::${TrackingCategories[packageName]}`, + ); + }); + }); + + describe('beautifyPath', () => { + it('returns a string with spaces around /', () => { + expect(beautifyPath('foo/bar')).toBe('foo / bar'); + }); + it('does not fail for empty string', () => { + expect(beautifyPath()).toBe(''); + }); + }); + + describe('getPackageTypeLabel', () => { + describe.each` + packageType | expectedResult + ${'conan'} | ${'Conan'} + ${'maven'} | ${'Maven'} + ${'npm'} | ${'NPM'} + ${'nuget'} | ${'NuGet'} + ${'pypi'} | ${'PyPi'} + ${'foo'} | ${null} + `(`package type`, ({ packageType, expectedResult }) => { + it(`${packageType} should show as ${expectedResult}`, () => { + expect(getPackageTypeLabel(packageType)).toBe(expectedResult); + }); + }); + }); + + describe('getCommitLink', () => { + it('returns a relative link when isGroup is false', () => { + const link = getCommitLink(packageList[0], false); + + expect(link).toContain('../commit'); + }); + + describe('when isGroup is true', () => { + it('returns an absolute link matching project path', () => { + const mavenPackage = packageList[0]; + const link = getCommitLink(mavenPackage, true); + + expect(link).toContain(`/${mavenPackage.project_path}/commit`); + }); + }); + }); +}); diff --git a/spec/graphql/resolvers/alert_management/alert_resolver_spec.rb b/spec/graphql/resolvers/alert_management/alert_resolver_spec.rb index 0c1ba5aab2c..42830f0024d 100644 --- a/spec/graphql/resolvers/alert_management/alert_resolver_spec.rb +++ b/spec/graphql/resolvers/alert_management/alert_resolver_spec.rb @@ -7,8 +7,8 @@ RSpec.describe Resolvers::AlertManagement::AlertResolver do let_it_be(:current_user) { create(:user) } let_it_be(:project) { create(:project) } - let_it_be(:alert_1) { create(:alert_management_alert, :resolved, project: project, ended_at: 1.year.ago, events: 2, severity: :high) } - let_it_be(:alert_2) { create(:alert_management_alert, :ignored, project: project, events: 1, severity: :critical) } + let_it_be(:resolved_alert) { create(:alert_management_alert, :resolved, project: project, ended_at: 1.year.ago, events: 2, severity: :high) } + let_it_be(:ignored_alert) { create(:alert_management_alert, :ignored, project: project, events: 1, severity: :critical) } let_it_be(:alert_other_proj) { create(:alert_management_alert) } let(:args) { {} } @@ -24,18 +24,18 @@ RSpec.describe Resolvers::AlertManagement::AlertResolver do project.add_developer(current_user) end - it { is_expected.to contain_exactly(alert_1, alert_2) } + it { is_expected.to contain_exactly(resolved_alert, ignored_alert) } context 'finding by iid' do - let(:args) { { iid: alert_1.iid } } + let(:args) { { iid: resolved_alert.iid } } - it { is_expected.to contain_exactly(alert_1) } + it { is_expected.to contain_exactly(resolved_alert) } end context 'finding by status' do let(:args) { { status: [Types::AlertManagement::StatusEnum.values['IGNORED'].value] } } - it { is_expected.to contain_exactly(alert_2) } + it { is_expected.to contain_exactly(ignored_alert) } end describe 'sorting' do @@ -45,11 +45,11 @@ RSpec.describe Resolvers::AlertManagement::AlertResolver do let_it_be(:alert_count_3) { create(:alert_management_alert, project: project, events: 3) } it 'sorts alerts ascending' do - expect(resolve_alerts(sort: :event_count_asc)).to eq [alert_2, alert_1, alert_count_3, alert_count_6] + expect(resolve_alerts(sort: :event_count_asc)).to eq [ignored_alert, resolved_alert, alert_count_3, alert_count_6] end it 'sorts alerts descending' do - expect(resolve_alerts(sort: :event_count_desc)).to eq [alert_count_6, alert_count_3, alert_1, alert_2] + expect(resolve_alerts(sort: :event_count_desc)).to eq [alert_count_6, alert_count_3, resolved_alert, ignored_alert] end end end diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index fe975aa7723..74ee7a152b3 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -30,6 +30,8 @@ RSpec.describe BlobHelper do let(:namespace) { create(:namespace, name: 'gitlab') } let(:project) { create(:project, :repository, namespace: namespace) } + subject(:link) { helper.edit_blob_button(project, 'master', 'README.md') } + before do allow(helper).to receive(:current_user).and_return(nil) allow(helper).to receive(:can?).and_return(true) @@ -53,15 +55,49 @@ RSpec.describe BlobHelper do end it 'returns a link with the proper route' do - link = helper.edit_blob_button(project, 'master', 'README.md') - expect(Capybara.string(link).find_link('Edit')[:href]).to eq("/#{project.full_path}/-/edit/master/README.md") end it 'returns a link with the passed link_opts on the expected route' do - link = helper.edit_blob_button(project, 'master', 'README.md', link_opts: { mr_id: 10 }) + link_with_mr = helper.edit_blob_button(project, 'master', 'README.md', link_opts: { mr_id: 10 }) - expect(Capybara.string(link).find_link('Edit')[:href]).to eq("/#{project.full_path}/-/edit/master/README.md?mr_id=10") + expect(Capybara.string(link_with_mr).find_link('Edit')[:href]).to eq("/#{project.full_path}/-/edit/master/README.md?mr_id=10") + end + + context 'when edit is the primary button' do + before do + stub_feature_flags(web_ide_primary_edit: false) + end + + it 'is rendered as primary' do + expect(link).not_to match(/btn-inverted/) + end + + it 'passes on primary tracking attributes' do + parsed_link = Capybara.string(link).find_link('Edit') + + expect(parsed_link[:'data-track-event']).to eq("click_edit") + expect(parsed_link[:'data-track-label']).to eq("Edit") + expect(parsed_link[:'data-track-property']).to eq(nil) + end + end + + context 'when Web IDE is the primary button' do + before do + stub_feature_flags(web_ide_primary_edit: true) + end + + it 'is rendered as inverted' do + expect(link).to match(/btn-inverted/) + end + + it 'passes on secondary tracking attributes' do + parsed_link = Capybara.string(link).find_link('Edit') + + expect(parsed_link[:'data-track-event']).to eq("click_edit") + expect(parsed_link[:'data-track-label']).to eq("Edit") + expect(parsed_link[:'data-track-property']).to eq("secondary") + end end end @@ -285,6 +321,62 @@ RSpec.describe BlobHelper do end end + describe `#ide_edit_button` do + let_it_be(:namespace) { create(:namespace, name: 'gitlab') } + let_it_be(:project) { create(:project, :repository, namespace: namespace) } + let_it_be(:current_user) { create(:user) } + let(:can_push_code) { true } + let(:blob) { project.repository.blob_at('refs/heads/master', 'README.md') } + + subject(:link) { helper.ide_edit_button(project, 'master', 'README.md', blob: blob) } + + before do + allow(helper).to receive(:current_user).and_return(current_user) + allow(helper).to receive(:can?).with(current_user, :push_code, project).and_return(can_push_code) + allow(helper).to receive(:can_collaborate_with_project?).and_return(true) + end + + it 'returns a link with a Web IDE route' do + expect(Capybara.string(link).find_link('Web IDE')[:href]).to eq("/-/ide/project/#{project.full_path}/edit/master/-/README.md") + end + + context 'when edit is the primary button' do + before do + stub_feature_flags(web_ide_primary_edit: false) + end + + it 'is rendered as inverted' do + expect(link).to match(/btn-inverted/) + end + + it 'passes on secondary tracking attributes' do + parsed_link = Capybara.string(link).find_link('Web IDE') + + expect(parsed_link[:'data-track-event']).to eq("click_edit_ide") + expect(parsed_link[:'data-track-label']).to eq("Web IDE") + expect(parsed_link[:'data-track-property']).to eq("secondary") + end + end + + context 'when Web IDE is the primary button' do + before do + stub_feature_flags(web_ide_primary_edit: true) + end + + it 'is rendered as primary' do + expect(link).not_to match(/btn-inverted/) + end + + it 'passes on primary tracking attributes' do + parsed_link = Capybara.string(link).find_link('Web IDE') + + expect(parsed_link[:'data-track-event']).to eq("click_edit_ide") + expect(parsed_link[:'data-track-label']).to eq("Web IDE") + expect(parsed_link[:'data-track-property']).to eq(nil) + end + end + end + describe '#ide_edit_path' do let(:project) { create(:project) } let(:current_user) { create(:user) } diff --git a/spec/helpers/packages_helper_spec.rb b/spec/helpers/packages_helper_spec.rb new file mode 100644 index 00000000000..6dc6f8b2299 --- /dev/null +++ b/spec/helpers/packages_helper_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe PackagesHelper do + let_it_be(:base_url) { "#{Gitlab.config.gitlab.url}/api/v4/" } + let_it_be(:project) { create(:project) } + + describe 'package_registry_instance_url' do + it 'returns conant instance url when registry_type is conant' do + url = helper.package_registry_instance_url(:conan) + + expect(url).to eq("#{base_url}packages/conan") + end + + it 'returns npm instance url when registry_type is npm' do + url = helper.package_registry_instance_url(:npm) + + expect(url).to eq("#{base_url}packages/npm") + end + end + + describe 'package_registry_project_url' do + it 'returns maven registry url when registry_type is not provided' do + url = helper.package_registry_project_url(1) + + expect(url).to eq("#{base_url}projects/1/packages/maven") + end + + it 'returns specified registry url when registry_type is provided' do + url = helper.package_registry_project_url(1, :npm) + + expect(url).to eq("#{base_url}projects/1/packages/npm") + end + end + + describe 'pypi_registry_url' do + let_it_be(:base_url_with_token) { base_url.sub('://', '://__token__:@') } + + it 'returns the pypi registry url' do + url = helper.pypi_registry_url(1) + + expect(url).to eq("#{base_url_with_token}projects/1/packages/pypi/simple") + end + end + + describe 'packages_coming_soon_enabled?' do + it 'returns false when the feature flag is disabled' do + stub_feature_flags(packages_coming_soon: false) + + expect(helper.packages_coming_soon_enabled?(project)).to eq(false) + end + + it 'returns false when not on dev or gitlab.com' do + expect(helper.packages_coming_soon_enabled?(project)).to eq(false) + end + end + + describe 'packages_coming_soon_data' do + let_it_be(:group) { create(:group) } + + before do + allow(Gitlab).to receive(:dev_env_or_com?) { true } + end + + it 'returns the gitlab project on gitlab.com' do + allow(Gitlab).to receive(:com?) { true } + + expect(helper.packages_coming_soon_data(project)).to include({ project_path: 'gitlab-org/gitlab' }) + end + + it 'returns the test project when not on gitlab.com' do + expect(helper.packages_coming_soon_data(project)).to include({ project_path: 'gitlab-org/gitlab-test' }) + end + + it 'works correctly with a group' do + expect(helper.packages_coming_soon_data(group)).to include({ project_path: 'gitlab-org/gitlab-test' }) + end + end +end diff --git a/spec/lib/banzai/filter/label_reference_filter_spec.rb b/spec/lib/banzai/filter/label_reference_filter_spec.rb index dadf98d9b76..726ef8c57ab 100644 --- a/spec/lib/banzai/filter/label_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/label_reference_filter_spec.rb @@ -31,6 +31,19 @@ RSpec.describe Banzai::Filter::LabelReferenceFilter do expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-label has-tooltip gl-link gl-label-link' end + it 'avoids N+1 cached queries', :use_sql_query_cache, :request_store do + # Run this once to establish a baseline + reference_filter("Label #{reference}") + + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do + reference_filter("Label #{reference}") + end + + labels_markdown = Array.new(10, "Label #{reference}").join('\n') + + expect { reference_filter(labels_markdown) }.not_to exceed_all_query_limit(control_count.count) + end + it 'includes a data-project attribute' do doc = reference_filter("Label #{reference}") link = doc.css('a').first diff --git a/spec/lib/banzai/reference_parser/snippet_parser_spec.rb b/spec/lib/banzai/reference_parser/snippet_parser_spec.rb index cdc660b4f4a..3459784708f 100644 --- a/spec/lib/banzai/reference_parser/snippet_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/snippet_parser_spec.rb @@ -33,6 +33,17 @@ RSpec.describe Banzai::ReferenceParser::SnippetParser do project.project_feature.update_attribute(:snippets_access_level, ProjectFeature::ENABLED) end + it 'avoids N+1 cached queries', :use_sql_query_cache do + # Run this once to establish a baseline + visible_references(:public) + + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do + subject.nodes_visible_to_user(user, [link]) + end + + expect { subject.nodes_visible_to_user(user, Array.new(10, link)) }.not_to exceed_all_query_limit(control_count.count) + end + it 'creates a reference for guest for a public snippet' do expect(visible_references(:public)).to eq([link]) end diff --git a/spec/models/alert_management/alert_spec.rb b/spec/models/alert_management/alert_spec.rb index becc5475c15..37a8092bd87 100644 --- a/spec/models/alert_management/alert_spec.rb +++ b/spec/models/alert_management/alert_spec.rb @@ -249,15 +249,15 @@ RSpec.describe AlertManagement::Alert do subject { described_class.last_prometheus_alert_by_project_id } let(:project_1) { create(:project) } - let!(:alert_1) { create(:alert_management_alert, project: project_1) } - let!(:alert_2) { create(:alert_management_alert, project: project_1) } + let!(:p1_alert_1) { create(:alert_management_alert, project: project_1) } + let!(:p1_alert_2) { create(:alert_management_alert, project: project_1) } let(:project_2) { create(:project) } - let!(:alert_3) { create(:alert_management_alert, project: project_2) } - let!(:alert_4) { create(:alert_management_alert, project: project_2) } + let!(:p2_alert_1) { create(:alert_management_alert, project: project_2) } + let!(:p2_alert_2) { create(:alert_management_alert, project: project_2) } it 'returns the latest alert for each project' do - expect(subject).to contain_exactly(alert_2, alert_4) + expect(subject).to contain_exactly(p1_alert_2, p2_alert_2) end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 8b0f862932d..e35a8727fb2 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -475,6 +475,46 @@ RSpec.describe Project do end end + describe '#has_packages?' do + let(:project) { create(:project, :public) } + + subject { project.has_packages?(package_type) } + + shared_examples 'returning true examples' do + let!(:package) { create("#{package_type}_package", project: project) } + + it { is_expected.to be true } + end + + shared_examples 'returning false examples' do + it { is_expected.to be false } + end + + context 'with maven packages' do + it_behaves_like 'returning true examples' do + let(:package_type) { :maven } + end + end + + context 'with npm packages' do + it_behaves_like 'returning true examples' do + let(:package_type) { :npm } + end + end + + context 'with conan packages' do + it_behaves_like 'returning true examples' do + let(:package_type) { :conan } + end + end + + context 'with no package type' do + it_behaves_like 'returning false examples' do + let(:package_type) { nil } + end + end + end + describe '#ci_pipelines' do let(:project) { create(:project) } diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 76b0c04e32d..52c6da80714 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -1586,6 +1586,7 @@ RSpec.describe API::Projects do expect(json_response['ci_default_git_depth']).to eq(project.ci_default_git_depth) expect(json_response['merge_method']).to eq(project.merge_method.to_s) expect(json_response['readme_url']).to eq(project.readme_url) + expect(json_response).to have_key 'packages_enabled' end it 'returns a group link with expiration date' do @@ -2339,6 +2340,22 @@ RSpec.describe API::Projects do expect(project_member).to be_persisted end + describe 'updating packages_enabled attribute' do + it 'is enabled by default' do + expect(project.packages_enabled).to be true + end + + context 'without the need for a license' do + it 'disables project packages feature' do + put(api("/projects/#{project.id}", user), params: { packages_enabled: false }) + + expect(response).to have_gitlab_http_status(:ok) + expect(project.reload.packages_enabled).to be false + expect(json_response['packages_enabled']).to eq(false) + end + end + end + it 'returns 400 when nothing sent' do project_param = {} diff --git a/spec/services/merge_requests/pushed_branches_service_spec.rb b/spec/services/merge_requests/pushed_branches_service_spec.rb index 6e9c77bd3b6..cd6af4c275e 100644 --- a/spec/services/merge_requests/pushed_branches_service_spec.rb +++ b/spec/services/merge_requests/pushed_branches_service_spec.rb @@ -8,19 +8,24 @@ RSpec.describe MergeRequests::PushedBranchesService do context 'when branches pushed' do let(:pushed_branches) do - %w(branch1 branch2 extra1 extra2 extra3).map do |branch| + %w(branch1 branch2 closed-branch1 closed-branch2 extra1 extra2).map do |branch| { ref: "refs/heads/#{branch}" } end end - it 'returns only branches which have a merge request' do + it 'returns only branches which have a open and closed merge request' do create(:merge_request, source_branch: 'branch1', source_project: project) create(:merge_request, source_branch: 'branch2', source_project: project) create(:merge_request, target_branch: 'branch2', source_project: project) - create(:merge_request, :closed, target_branch: 'extra1', source_project: project) - create(:merge_request, source_branch: 'extra2') + create(:merge_request, :closed, target_branch: 'closed-branch1', source_project: project) + create(:merge_request, :closed, source_branch: 'closed-branch2', source_project: project) + create(:merge_request, source_branch: 'extra1') - expect(service.execute).to contain_exactly('branch1', 'branch2') + expect(service.execute).to contain_exactly( + 'branch1', + 'branch2', + 'closed-branch2' + ) end end diff --git a/spec/support/helpers/navbar_structure_helper.rb b/spec/support/helpers/navbar_structure_helper.rb index cfb1b185560..c7aa2ffe536 100644 --- a/spec/support/helpers/navbar_structure_helper.rb +++ b/spec/support/helpers/navbar_structure_helper.rb @@ -18,4 +18,22 @@ module NavbarStructureHelper index = hash[:nav_sub_items].find_index(before_sub_nav_item_name) hash[:nav_sub_items].insert(index + 1, new_sub_nav_item_name) end + + def insert_package_nav(within) + insert_after_nav_item( + within, + new_nav_item: { + nav_item: _('Packages & Registries'), + nav_sub_items: [_('Package Registry')] + } + ) + end + + def insert_container_nav(within) + insert_after_sub_nav_item( + _('Package Registry'), + within: _('Packages & Registries'), + new_sub_nav_item_name: _('Container Registry') + ) + end end diff --git a/spec/support/shared_examples/features/packages_shared_examples.rb b/spec/support/shared_examples/features/packages_shared_examples.rb new file mode 100644 index 00000000000..412a5ee4881 --- /dev/null +++ b/spec/support/shared_examples/features/packages_shared_examples.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'packages list' do |check_project_name: false| + it 'shows a list of packages' do + wait_for_requests + + packages.each_with_index do |pkg, index| + package_row = package_table_row(index) + + expect(package_row).to have_content(pkg.name) + expect(package_row).to have_content(pkg.version) + expect(package_row).to have_content(pkg.project.name) if check_project_name + end + end + + def package_table_row(index) + page.all("#{packages_table_selector} > [data-qa-selector=\"packages-row\"]")[index].text + end +end + +RSpec.shared_examples 'package details link' do |property| + let(:package) { packages.first } + + it 'navigates to the correct url' do + page.within(packages_table_selector) do + click_link package.name + end + + expect(page).to have_current_path(project_package_path(package.project, package)) + + page.within('.detail-page-header') do + expect(page).to have_content(package.name) + end + + page.within('[data-qa-selector="package_information_content"]') do + expect(page).to have_content('Installation') + expect(page).to have_content('Registry Setup') + end + end +end + +RSpec.shared_examples 'when there are no packages' do + it 'displays the empty message' do + expect(page).to have_content('There are no packages yet') + end +end + +RSpec.shared_examples 'correctly sorted packages list' do |order_by, ascending: false| + context "ordered by #{order_by} and ascending #{ascending}" do + before do + click_sort_option(order_by, ascending) + end + + it_behaves_like 'packages list' + end +end + +RSpec.shared_examples 'shared package sorting' do + it_behaves_like 'correctly sorted packages list', 'Type' do + let(:packages) { [package_two, package_one] } + end + + it_behaves_like 'correctly sorted packages list', 'Type', ascending: true do + let(:packages) { [package_one, package_two] } + end + + it_behaves_like 'correctly sorted packages list', 'Name' do + let(:packages) { [package_two, package_one] } + end + + it_behaves_like 'correctly sorted packages list', 'Name', ascending: true do + let(:packages) { [package_one, package_two] } + end + + it_behaves_like 'correctly sorted packages list', 'Version' do + let(:packages) { [package_one, package_two] } + end + + it_behaves_like 'correctly sorted packages list', 'Version', ascending: true do + let(:packages) { [package_two, package_one] } + end + + it_behaves_like 'correctly sorted packages list', 'Created' do + let(:packages) { [package_two, package_one] } + end + + it_behaves_like 'correctly sorted packages list', 'Created', ascending: true do + let(:packages) { [package_one, package_two] } + end +end + +def packages_table_selector + '[data-qa-selector="packages-table"]' +end + +def click_sort_option(option, ascending) + page.within('.gl-sorting') do + # Reset the sort direction + click_button 'Sort direction' if page.has_selector?('svg[aria-label="Sorting Direction: Ascending"]', wait: 0) + + find('button.dropdown-menu-toggle').click + + page.within('.dropdown-menu') do + click_button option + end + + click_button 'Sort direction' if ascending + end +end diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb index bf0bf63e164..bf5b5785b8d 100644 --- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb +++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb @@ -47,6 +47,58 @@ RSpec.describe 'layouts/nav/sidebar/_project' do end end + describe 'Packages' do + let(:user) { create(:user) } + + let_it_be(:package_menu_name) { 'Packages & Registries' } + let_it_be(:package_entry_name) { 'Package Registry' } + + before do + project.team.add_developer(user) + sign_in(user) + stub_container_registry_config(enabled: true) + end + + context 'when packages is enabled' do + it 'packages link is visible' do + render + + expect(rendered).to have_link(package_menu_name, href: project_packages_path(project)) + end + + it 'packages list link is visible' do + render + + expect(rendered).to have_link(package_entry_name, href: project_packages_path(project)) + end + + it 'container registry link is visible' do + render + + expect(rendered).to have_link('Container Registry', href: project_container_registry_index_path(project)) + end + end + + context 'when container registry is disabled' do + before do + stub_container_registry_config(enabled: false) + end + + it 'packages top level and list link are visible' do + render + + expect(rendered).to have_link(package_menu_name, href: project_packages_path(project)) + expect(rendered).to have_link(package_entry_name, href: project_packages_path(project)) + end + + it 'container registry link is not visible' do + render + + expect(rendered).not_to have_link('Container Registry', href: project_container_registry_index_path(project)) + end + end + end + describe 'releases entry' do it 'renders releases link' do render