Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-12-16 18:10:28 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-12-16 18:10:28 +0300
commit6aaec2fc6c3e3f96f443b96fd53ae9ed5e7979af (patch)
tree17336eb6c5d10d904310218c72b3b0bf9b78a180
parenta32fd79d1e34ca4da1d5390c0aaf91d660e03fc8 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.gitlab/ci/rails.gitlab-ci.yml13
-rw-r--r--GITLAB_PAGES_VERSION2
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock6
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js2
-rw-r--r--app/assets/javascripts/crm/components/new_organization_form.vue164
-rw-r--r--app/assets/javascripts/crm/components/organizations_root.vue49
-rw-r--r--app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql10
-rw-r--r--app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql7
-rw-r--r--app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql8
-rw-r--r--app/assets/javascripts/crm/organizations_bundle.js16
-rw-r--r--app/assets/javascripts/crm/routes.js4
-rw-r--r--app/controllers/groups/crm/organizations_controller.rb4
-rw-r--r--app/finders/packages/build_infos_finder.rb68
-rw-r--r--app/graphql/resolvers/package_pipelines_resolver.rb57
-rw-r--r--app/graphql/types/packages/package_details_type.rb7
-rw-r--r--app/models/namespaces/traversal/linear_scopes.rb2
-rw-r--r--app/models/packages/build_info.rb6
-rw-r--r--app/views/groups/crm/organizations/index.html.haml2
-rw-r--r--app/views/groups/projects.html.haml2
-rw-r--r--app/views/projects/issues/_issue.html.haml2
-rw-r--r--app/views/shared/projects/_project.html.haml2
-rw-r--r--config/routes/group.rb2
-rw-r--r--db/migrate/20211202135508_add_index_on_packages_build_infos_package_id_pipeline_id.rb18
-rw-r--r--db/schema_migrations/202112021355081
-rw-r--r--db/structure.sql4
-rw-r--r--doc/api/projects.md4
-rw-r--r--doc/development/cicd/cicd_reference_documentation_guide.md2
-rw-r--r--doc/user/crm/crm_contacts_v14_6.pngbin11613 -> 19864 bytes
-rw-r--r--doc/user/crm/index.md74
-rw-r--r--lib/api/entities/ci/runner.rb2
-rw-r--r--locale/gitlab.pot18
-rw-r--r--spec/finders/packages/build_infos_finder_spec.rb64
-rw-r--r--spec/frontend/crm/mock_data.js25
-rw-r--r--spec/frontend/crm/new_organization_form_spec.js109
-rw-r--r--spec/frontend/crm/organizations_root_spec.js104
-rw-r--r--spec/graphql/resolvers/package_pipelines_resolver_spec.rb84
-rw-r--r--spec/graphql/types/packages/package_details_type_spec.rb9
-rw-r--r--spec/models/packages/build_info_spec.rb42
-rw-r--r--spec/requests/api/ci/runners_spec.rb1
-rw-r--r--spec/requests/api/graphql/packages/package_spec.rb64
-rw-r--r--spec/requests/groups/crm/organizations_controller_spec.rb91
42 files changed, 1094 insertions, 59 deletions
diff --git a/.gitlab/ci/rails.gitlab-ci.yml b/.gitlab/ci/rails.gitlab-ci.yml
index f1b5c00139d..525e1c6710b 100644
--- a/.gitlab/ci/rails.gitlab-ci.yml
+++ b/.gitlab/ci/rails.gitlab-ci.yml
@@ -540,6 +540,19 @@ rspec:coverage:
# Memory jobs
- memory-static
- memory-on-boot
+ # As-if-FOSS jobs
+ - rspec migration pg12-as-if-foss
+ - rspec migration pg12-as-if-foss minimal
+ - rspec migration pg12-as-if-foss decomposed
+ - rspec unit pg12-as-if-foss
+ - rspec unit pg12-as-if-foss minimal
+ - rspec unit pg12-as-if-foss decomposed
+ - rspec integration pg12-as-if-foss
+ - rspec integration pg12-as-if-foss minimal
+ - rspec integration pg12-as-if-foss decomposed
+ - rspec system pg12-as-if-foss
+ - rspec system pg12-as-if-foss minimal
+ - rspec system pg12-as-if-foss decomposed
script:
- run_timed_command "bundle exec scripts/merge-simplecov"
- run_timed_command "bundle exec scripts/gather-test-memory-data"
diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION
index 9db5ea12f52..7f3a46a841e 100644
--- a/GITLAB_PAGES_VERSION
+++ b/GITLAB_PAGES_VERSION
@@ -1 +1 @@
-1.48.0
+1.49.0
diff --git a/Gemfile b/Gemfile
index 899b7525319..1d88df82967 100644
--- a/Gemfile
+++ b/Gemfile
@@ -464,7 +464,7 @@ gem 'health_check', '~> 3.0'
# System information
gem 'vmstat', '~> 2.3.0'
-gem 'sys-filesystem', '~> 1.1.6'
+gem 'sys-filesystem', '~> 1.4.3'
# NTP client
gem 'net-ntp'
diff --git a/Gemfile.lock b/Gemfile.lock
index 00205c4b827..2f30df91862 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1255,8 +1255,8 @@ GEM
activesupport (>= 3)
attr_required (>= 0.0.5)
httpclient (>= 2.4)
- sys-filesystem (1.1.9)
- ffi
+ sys-filesystem (1.4.3)
+ ffi (~> 1.1)
sysexits (1.2.0)
tanuki_emoji (0.5.0)
temple (0.8.2)
@@ -1645,7 +1645,7 @@ DEPENDENCIES
sshkey (~> 2.0)
stackprof (~> 0.2.15)
state_machines-activerecord (~> 0.8.0)
- sys-filesystem (~> 1.1.6)
+ sys-filesystem (~> 1.4.3)
tanuki_emoji (~> 0.5)
terser (= 1.0.2)
test-prof (~> 1.0.7)
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
index 6154fae21df..ea51bee3ba9 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -4,6 +4,8 @@ import * as lowlight from 'lowlight';
const extractLanguage = (element) => element.getAttribute('lang');
export default CodeBlockLowlight.extend({
+ isolating: true,
+
addAttributes() {
return {
language: {
diff --git a/app/assets/javascripts/crm/components/new_organization_form.vue b/app/assets/javascripts/crm/components/new_organization_form.vue
new file mode 100644
index 00000000000..3b11edc6935
--- /dev/null
+++ b/app/assets/javascripts/crm/components/new_organization_form.vue
@@ -0,0 +1,164 @@
+<script>
+import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { produce } from 'immer';
+import { __, s__ } from '~/locale';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_GROUP } from '~/graphql_shared/constants';
+import createOrganization from './queries/create_organization.mutation.graphql';
+import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ GlDrawer,
+ GlFormGroup,
+ GlFormInput,
+ },
+ inject: ['groupFullPath', 'groupId'],
+ props: {
+ drawerOpen: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ name: '',
+ defaultRate: null,
+ description: '',
+ submitting: false,
+ errorMessages: [],
+ };
+ },
+ computed: {
+ invalid() {
+ return this.name.trim() === '';
+ },
+ },
+ methods: {
+ save() {
+ this.submitting = true;
+ return this.$apollo
+ .mutate({
+ mutation: createOrganization,
+ variables: {
+ input: {
+ groupId: convertToGraphQLId(TYPE_GROUP, this.groupId),
+ name: this.name,
+ defaultRate: this.defaultRate ? parseFloat(this.defaultRate) : null,
+ description: this.description,
+ },
+ },
+ update: this.updateCache,
+ })
+ .then(({ data }) => {
+ if (data.customerRelationsOrganizationCreate.errors.length === 0) this.close(true);
+
+ this.submitting = false;
+ })
+ .catch(() => {
+ this.errorMessages = [this.$options.i18n.somethingWentWrong];
+ this.submitting = false;
+ });
+ },
+ close(success) {
+ this.$emit('close', success);
+ },
+ updateCache(store, { data: { customerRelationsOrganizationCreate } }) {
+ if (customerRelationsOrganizationCreate.errors.length > 0) {
+ this.errorMessages = customerRelationsOrganizationCreate.errors;
+ return;
+ }
+
+ const variables = {
+ groupFullPath: this.groupFullPath,
+ };
+ const sourceData = store.readQuery({
+ query: getGroupOrganizationsQuery,
+ variables,
+ });
+
+ const data = produce(sourceData, (draftState) => {
+ draftState.group.organizations.nodes = [
+ ...sourceData.group.organizations.nodes,
+ customerRelationsOrganizationCreate.organization,
+ ];
+ });
+
+ store.writeQuery({
+ query: getGroupOrganizationsQuery,
+ variables,
+ data,
+ });
+ },
+ getDrawerHeaderHeight() {
+ const wrapperEl = document.querySelector('.content-wrapper');
+
+ if (wrapperEl) {
+ return `${wrapperEl.offsetTop}px`;
+ }
+
+ return '';
+ },
+ },
+ i18n: {
+ buttonLabel: s__('Crm|Create organization'),
+ cancel: __('Cancel'),
+ name: __('Name'),
+ defaultRate: s__('Crm|Default rate (optional)'),
+ description: __('Description (optional)'),
+ title: s__('Crm|New Organization'),
+ somethingWentWrong: __('Something went wrong. Please try again.'),
+ },
+};
+</script>
+
+<template>
+ <gl-drawer
+ class="gl-drawer-responsive"
+ :open="drawerOpen"
+ :header-height="getDrawerHeaderHeight()"
+ @close="close(false)"
+ >
+ <template #title>
+ <h4>{{ $options.i18n.title }}</h4>
+ </template>
+ <gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []">
+ <ul class="gl-mb-0! gl-ml-5">
+ <li v-for="error in errorMessages" :key="error">
+ {{ error }}
+ </li>
+ </ul>
+ </gl-alert>
+ <form @submit.prevent="save">
+ <gl-form-group :label="$options.i18n.name" label-for="organization-name">
+ <gl-form-input id="organization-name" v-model="name" />
+ </gl-form-group>
+ <gl-form-group :label="$options.i18n.defaultRate" label-for="organization-default-rate">
+ <gl-form-input
+ id="organization-default-rate"
+ v-model="defaultRate"
+ type="number"
+ step="0.01"
+ />
+ </gl-form-group>
+ <gl-form-group :label="$options.i18n.description" label-for="organization-description">
+ <gl-form-input id="organization-description" v-model="description" />
+ </gl-form-group>
+ <span class="gl-float-right">
+ <gl-button data-testid="cancel-button" @click="close(false)">
+ {{ $options.i18n.cancel }}
+ </gl-button>
+ <gl-button
+ variant="confirm"
+ :disabled="invalid"
+ :loading="submitting"
+ data-testid="create-new-organization-button"
+ type="submit"
+ >{{ $options.i18n.buttonLabel }}</gl-button
+ >
+ </span>
+ </form>
+ </gl-drawer>
+</template>
diff --git a/app/assets/javascripts/crm/components/organizations_root.vue b/app/assets/javascripts/crm/components/organizations_root.vue
index 336087e3dff..9370c6377e9 100644
--- a/app/assets/javascripts/crm/components/organizations_root.vue
+++ b/app/assets/javascripts/crm/components/organizations_root.vue
@@ -1,8 +1,11 @@
<script>
import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
+import { parseBoolean } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME } from '../constants';
import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql';
+import NewOrganizationForm from './new_organization_form.vue';
export default {
components: {
@@ -10,11 +13,12 @@ export default {
GlButton,
GlLoadingIcon,
GlTable,
+ NewOrganizationForm,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: ['groupFullPath', 'groupIssuesPath'],
+ inject: ['canAdminCrmOrganization', 'groupFullPath', 'groupIssuesPath'],
data() {
return {
error: false,
@@ -43,18 +47,31 @@ export default {
isLoading() {
return this.$apollo.queries.organizations.loading;
},
+ showNewForm() {
+ return this.$route.name === NEW_ROUTE_NAME;
+ },
+ canCreateNew() {
+ return parseBoolean(this.canAdminCrmOrganization);
+ },
},
methods: {
extractOrganizations(data) {
const organizations = data?.group?.organizations?.nodes || [];
return organizations.slice().sort((a, b) => a.name.localeCompare(b.name));
},
- dismissError() {
- this.error = false;
- },
getIssuesPath(path, value) {
return `${path}?scope=all&state=opened&crm_organization_id=${value}`;
},
+ displayNewForm() {
+ if (this.showNewForm) return;
+
+ this.$router.push({ name: NEW_ROUTE_NAME });
+ },
+ hideNewForm(success) {
+ if (success) this.$toast.show(this.$options.i18n.organizationAdded);
+
+ this.$router.replace({ name: INDEX_ROUTE_NAME });
+ },
},
fields: [
{ key: 'name', sortable: true },
@@ -71,19 +88,39 @@ export default {
i18n: {
emptyText: s__('Crm|No organizations found'),
issuesButtonLabel: __('View issues'),
+ title: s__('Crm|Customer Relations Organizations'),
+ newOrganization: s__('Crm|New organization'),
errorText: __('Something went wrong. Please try again.'),
+ organizationAdded: s__('Crm|Organization has been added'),
},
};
</script>
<template>
<div>
- <gl-alert v-if="error" variant="danger" class="gl-my-6" @dismiss="dismissError">
- <div>{{ $options.i18n.errorText }}</div>
+ <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = false">
+ {{ $options.i18n.errorText }}
</gl-alert>
+ <div
+ class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6"
+ >
+ <h2 class="gl-font-size-h2 gl-my-0">
+ {{ $options.i18n.title }}
+ </h2>
+ <div
+ v-if="canCreateNew"
+ class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end"
+ >
+ <gl-button variant="confirm" data-testid="new-organization-button" @click="displayNewForm">
+ {{ $options.i18n.newOrganization }}
+ </gl-button>
+ </div>
+ </div>
+ <new-organization-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" />
<gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" />
<gl-table
v-else
+ class="gl-mt-5"
:items="organizations"
:fields="$options.fields"
:empty-text="$options.i18n.emptyText"
diff --git a/app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql b/app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql
new file mode 100644
index 00000000000..2cc7e53ee9b
--- /dev/null
+++ b/app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql
@@ -0,0 +1,10 @@
+#import "./crm_organization_fields.fragment.graphql"
+
+mutation createOrganization($input: CustomerRelationsOrganizationCreateInput!) {
+ customerRelationsOrganizationCreate(input: $input) {
+ organization {
+ ...OrganizationFragment
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql b/app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql
new file mode 100644
index 00000000000..4adc5742d3a
--- /dev/null
+++ b/app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql
@@ -0,0 +1,7 @@
+fragment OrganizationFragment on CustomerRelationsOrganization {
+ __typename
+ id
+ name
+ defaultRate
+ description
+}
diff --git a/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql b/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql
index 7c4ec6ec585..e8d8109431e 100644
--- a/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql
+++ b/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql
@@ -1,14 +1,12 @@
+#import "./crm_organization_fields.fragment.graphql"
+
query organizations($groupFullPath: ID!) {
group(fullPath: $groupFullPath) {
__typename
id
organizations {
nodes {
- __typename
- id
- name
- defaultRate
- description
+ ...OrganizationFragment
}
}
}
diff --git a/app/assets/javascripts/crm/organizations_bundle.js b/app/assets/javascripts/crm/organizations_bundle.js
index 71b9d9698bf..828d7cd426c 100644
--- a/app/assets/javascripts/crm/organizations_bundle.js
+++ b/app/assets/javascripts/crm/organizations_bundle.js
@@ -1,9 +1,14 @@
+import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import VueRouter from 'vue-router';
import createDefaultClient from '~/lib/graphql';
import CrmOrganizationsRoot from './components/organizations_root.vue';
+import routes from './routes';
Vue.use(VueApollo);
+Vue.use(VueRouter);
+Vue.use(GlToast);
export default () => {
const el = document.getElementById('js-crm-organizations-app');
@@ -16,12 +21,19 @@ export default () => {
return false;
}
- const { groupFullPath, groupIssuesPath } = el.dataset;
+ const { basePath, canAdminCrmOrganization, groupFullPath, groupId, groupIssuesPath } = el.dataset;
+
+ const router = new VueRouter({
+ base: basePath,
+ mode: 'history',
+ routes,
+ });
return new Vue({
el,
+ router,
apolloProvider,
- provide: { groupFullPath, groupIssuesPath },
+ provide: { canAdminCrmOrganization, groupFullPath, groupId, groupIssuesPath },
render(createElement) {
return createElement(CrmOrganizationsRoot);
},
diff --git a/app/assets/javascripts/crm/routes.js b/app/assets/javascripts/crm/routes.js
index 586fd7b987d..12aa17d73b6 100644
--- a/app/assets/javascripts/crm/routes.js
+++ b/app/assets/javascripts/crm/routes.js
@@ -1,20 +1,16 @@
import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from './constants';
-import CrmContactsRoot from './components/contacts_root.vue';
export default [
{
name: INDEX_ROUTE_NAME,
path: '/',
- component: CrmContactsRoot,
},
{
name: NEW_ROUTE_NAME,
path: '/new',
- component: CrmContactsRoot,
},
{
name: EDIT_ROUTE_NAME,
path: '/:id/edit',
- component: CrmContactsRoot,
},
];
diff --git a/app/controllers/groups/crm/organizations_controller.rb b/app/controllers/groups/crm/organizations_controller.rb
index 6f285687e6b..ab720f490be 100644
--- a/app/controllers/groups/crm/organizations_controller.rb
+++ b/app/controllers/groups/crm/organizations_controller.rb
@@ -5,6 +5,10 @@ class Groups::Crm::OrganizationsController < Groups::ApplicationController
before_action :authorize_read_crm_organization!
+ def new
+ render action: "index"
+ end
+
private
def authorize_read_crm_organization!
diff --git a/app/finders/packages/build_infos_finder.rb b/app/finders/packages/build_infos_finder.rb
new file mode 100644
index 00000000000..92ad5888eb9
--- /dev/null
+++ b/app/finders/packages/build_infos_finder.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+module Packages
+ class BuildInfosFinder
+ MAX_PAGE_SIZE = 100
+
+ def initialize(package, params)
+ @package = package
+ @params = params
+ end
+
+ def execute
+ build_infos = @package.build_infos.without_empty_pipelines
+ build_infos = apply_order(build_infos)
+ build_infos = apply_limit(build_infos)
+ apply_cursor(build_infos)
+ end
+
+ private
+
+ def apply_order(build_infos)
+ order_direction = :desc
+ order_direction = :asc if last
+
+ build_infos.order_by_pipeline_id(order_direction)
+ end
+
+ def apply_limit(build_infos)
+ limit = [first, last, max_page_size, MAX_PAGE_SIZE].compact.min
+ limit += 1 if support_next_page
+ build_infos.limit(limit)
+ end
+
+ def apply_cursor(build_infos)
+ if before
+ build_infos.with_pipeline_id_greater_than(before)
+ elsif after
+ build_infos.with_pipeline_id_less_than(after)
+ else
+ build_infos
+ end
+ end
+
+ def first
+ @params[:first]
+ end
+
+ def last
+ @params[:last]
+ end
+
+ def max_page_size
+ @params[:max_page_size]
+ end
+
+ def before
+ @params[:before]
+ end
+
+ def after
+ @params[:after]
+ end
+
+ def support_next_page
+ @params[:support_next_page]
+ end
+ end
+end
diff --git a/app/graphql/resolvers/package_pipelines_resolver.rb b/app/graphql/resolvers/package_pipelines_resolver.rb
new file mode 100644
index 00000000000..59a1cd173a4
--- /dev/null
+++ b/app/graphql/resolvers/package_pipelines_resolver.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class PackagePipelinesResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::Ci::PipelineType.connection_type, null: true
+ extension Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension
+
+ authorizes_object!
+ authorize :read_pipeline
+
+ alias_method :package, :object
+
+ def resolve(first: nil, last: nil, after: nil, before: nil, lookahead:)
+ finder = ::Packages::BuildInfosFinder.new(
+ package,
+ first: first,
+ last: last,
+ after: decode_cursor(after),
+ before: decode_cursor(before),
+ max_page_size: context.schema.default_max_page_size,
+ support_next_page: lookahead.selects?(:page_info)
+ )
+
+ build_infos = finder.execute
+
+ # this .pluck_pipeline_ids can load max 101 pipelines ids
+ ::Ci::Pipeline.id_in(build_infos.pluck_pipeline_ids)
+ end
+
+ # we manage the pagination manually, so opt out of the connection field extension
+ def self.field_options
+ super.merge(
+ connection: false,
+ extras: [:lookahead]
+ )
+ end
+
+ private
+
+ def decode_cursor(encoded)
+ return unless encoded
+
+ decoded = Gitlab::Json.parse(context.schema.cursor_encoder.decode(encoded, nonce: true))
+ id_from_cursor(decoded)
+ rescue JSON::ParserError
+ raise Gitlab::Graphql::Errors::ArgumentError, "Please provide a valid cursor"
+ end
+
+ def id_from_cursor(cursor)
+ cursor&.fetch('id')
+ rescue KeyError
+ raise Gitlab::Graphql::Errors::ArgumentError, "Please provide a valid cursor"
+ end
+ end
+end
diff --git a/app/graphql/types/packages/package_details_type.rb b/app/graphql/types/packages/package_details_type.rb
index 59a4885e87e..5ac80860fe2 100644
--- a/app/graphql/types/packages/package_details_type.rb
+++ b/app/graphql/types/packages/package_details_type.rb
@@ -14,6 +14,13 @@ module Types
field :dependency_links, Types::Packages::PackageDependencyLinkType.connection_type, null: true, description: 'Dependency link.'
+ # this is an override of Types::Packages::PackageType.pipelines
+ # in order to use a custom resolver: Resolvers::PackagePipelinesResolver
+ field :pipelines,
+ resolver: Resolvers::PackagePipelinesResolver,
+ description: 'Pipelines that built the package.',
+ deprecated: { reason: 'Due to scalability concerns, this field is going to be removed', milestone: '14.6' }
+
def versions
object.versions
end
diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb
index 91fa2b03c95..0dfb7320461 100644
--- a/app/models/namespaces/traversal/linear_scopes.rb
+++ b/app/models/namespaces/traversal/linear_scopes.rb
@@ -105,7 +105,7 @@ module Namespaces
:traversal_ids,
'LEAD (namespaces.traversal_ids, 1) OVER (ORDER BY namespaces.traversal_ids ASC) next_traversal_ids'
)
- base_cte = Gitlab::SQL::CTE.new(:base_cte, base)
+ base_cte = Gitlab::SQL::CTE.new(:descendants_base_cte, base)
namespaces = Arel::Table.new(:namespaces)
diff --git a/app/models/packages/build_info.rb b/app/models/packages/build_info.rb
index 1b0f0ed8ffd..38245bef7a5 100644
--- a/app/models/packages/build_info.rb
+++ b/app/models/packages/build_info.rb
@@ -3,4 +3,10 @@
class Packages::BuildInfo < ApplicationRecord
belongs_to :package, inverse_of: :build_infos
belongs_to :pipeline, class_name: 'Ci::Pipeline'
+
+ scope :pluck_pipeline_ids, -> { pluck(:pipeline_id) }
+ scope :without_empty_pipelines, -> { where.not(pipeline_id: nil) }
+ scope :order_by_pipeline_id, -> (direction) { order(pipeline_id: direction) }
+ scope :with_pipeline_id_less_than, -> (pipeline_id) { where("pipeline_id < ?", pipeline_id) }
+ scope :with_pipeline_id_greater_than, -> (pipeline_id) { where("pipeline_id > ?", pipeline_id) }
end
diff --git a/app/views/groups/crm/organizations/index.html.haml b/app/views/groups/crm/organizations/index.html.haml
index 694d38015db..1647805b976 100644
--- a/app/views/groups/crm/organizations/index.html.haml
+++ b/app/views/groups/crm/organizations/index.html.haml
@@ -1,4 +1,4 @@
- breadcrumb_title _('Customer Relations Organizations')
- page_title _('Customer Relations Organizations')
-#js-crm-organizations-app{ data: { group_full_path: @group.full_path, group_issues_path: issues_group_path(@group) } }
+#js-crm-organizations-app{ data: { base_path: group_crm_organizations_path(@group), can_admin_crm_organization: can?(current_user, :admin_crm_organization, @group).to_s, group_full_path: @group.full_path, group_id: @group.id, group_issues_path: issues_group_path(@group) } }
diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml
index d8461d85a89..3507f4574ab 100644
--- a/app/views/groups/projects.html.haml
+++ b/app/views/groups/projects.html.haml
@@ -22,7 +22,7 @@
= render 'project_badges', project: project
.title
- = link_to(project_path(project)) do
+ = link_to project_path(project), class: 'js-prefetch-document' do
.dash-project-avatar
.avatar-container.rect-avatar.s40
= project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index 6d848a1fbbe..2dc21685057 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -13,7 +13,7 @@
%span.has-tooltip{ title: _('Confidential') }
= confidential_icon(issue)
= hidden_issue_icon(issue)
- = link_to issue.title, issue_path(issue)
+ = link_to issue.title, issue_path(issue), class: 'js-prefetch-document'
= render_if_exists 'projects/issues/subepic_flag', issue: issue
- if issue.tasks?
%span.task-status.d-none.d-sm-inline-block
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index 2136d287f53..ae264f2188f 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -31,7 +31,7 @@
.flex-wrapper
.d-flex.align-items-center.flex-wrap.project-title
%h2.d-flex.gl-mt-3
- = link_to project_path(project), class: 'text-plain' do
+ = link_to project_path(project), class: 'text-plain js-prefetch-document' do
%span.project-full-name.gl-mr-3><
%span.namespace-name
- if project.namespace && !skip_namespace
diff --git a/config/routes/group.rb b/config/routes/group.rb
index 0a73a6c0884..da205163e6d 100644
--- a/config/routes/group.rb
+++ b/config/routes/group.rb
@@ -128,7 +128,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
namespace :crm do
resources :contacts, only: [:index, :new, :edit]
- resources :organizations, only: [:index]
+ resources :organizations, only: [:index, :new]
end
end
diff --git a/db/migrate/20211202135508_add_index_on_packages_build_infos_package_id_pipeline_id.rb b/db/migrate/20211202135508_add_index_on_packages_build_infos_package_id_pipeline_id.rb
new file mode 100644
index 00000000000..47a155d40fa
--- /dev/null
+++ b/db/migrate/20211202135508_add_index_on_packages_build_infos_package_id_pipeline_id.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class AddIndexOnPackagesBuildInfosPackageIdPipelineId < Gitlab::Database::Migration[1.0]
+ disable_ddl_transaction!
+
+ INDEX_NAME = 'index_packages_build_infos_package_id_pipeline_id'
+ OLD_INDEX_NAME = 'idx_packages_build_infos_on_package_id'
+
+ def up
+ add_concurrent_index :packages_build_infos, [:package_id, :pipeline_id], name: INDEX_NAME
+ remove_concurrent_index_by_name :packages_build_infos, OLD_INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index :packages_build_infos, :package_id, name: OLD_INDEX_NAME
+ remove_concurrent_index :packages_build_infos, [:package_id, :pipeline_id], name: INDEX_NAME
+ end
+end
diff --git a/db/schema_migrations/20211202135508 b/db/schema_migrations/20211202135508
new file mode 100644
index 00000000000..6ca4701268d
--- /dev/null
+++ b/db/schema_migrations/20211202135508
@@ -0,0 +1 @@
+b565abbbb43f04ba4a6b77154ecb24b30328ac6d964f4be9fc5f9d05144606f0 \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index e49124d84aa..d1ada2a3f5d 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -25158,8 +25158,6 @@ CREATE UNIQUE INDEX idx_on_external_status_checks_project_id_external_url ON ext
CREATE UNIQUE INDEX idx_on_external_status_checks_project_id_name ON external_status_checks USING btree (project_id, name);
-CREATE INDEX idx_packages_build_infos_on_package_id ON packages_build_infos USING btree (package_id);
-
CREATE INDEX idx_packages_debian_group_component_files_on_architecture_id ON packages_debian_group_component_files USING btree (architecture_id);
CREATE INDEX idx_packages_debian_project_component_files_on_architecture_id ON packages_debian_project_component_files USING btree (architecture_id);
@@ -26972,6 +26970,8 @@ CREATE UNIQUE INDEX index_ops_strategies_user_lists_on_strategy_id_and_user_list
CREATE INDEX index_packages_build_infos_on_pipeline_id ON packages_build_infos USING btree (pipeline_id);
+CREATE INDEX index_packages_build_infos_package_id_pipeline_id ON packages_build_infos USING btree (package_id, pipeline_id);
+
CREATE UNIQUE INDEX index_packages_composer_cache_namespace_and_sha ON packages_composer_cache_files USING btree (namespace_id, file_sha256);
CREATE UNIQUE INDEX index_packages_composer_metadata_on_package_id_and_target_sha ON packages_composer_metadata USING btree (package_id, target_sha);
diff --git a/doc/api/projects.md b/doc/api/projects.md
index f2932897ef9..a87363d8ed4 100644
--- a/doc/api/projects.md
+++ b/doc/api/projects.md
@@ -1238,8 +1238,10 @@ POST /projects
| `jobs_enabled` | boolean | **{dotted-circle}** No | _(Deprecated)_ Enable jobs for this project. Use `builds_access_level` instead. |
| `lfs_enabled` | boolean | **{dotted-circle}** No | Enable LFS. |
| `merge_method` | string | **{dotted-circle}** No | Set the [merge method](#project-merge-method) used. |
+| `merge_pipelines_enabled` | boolean | **{dotted-circle}** No | Enable or disable merge pipelines. |
| `merge_requests_access_level` | string | **{dotted-circle}** No | One of `disabled`, `private`, or `enabled`. |
| `merge_requests_enabled` | boolean | **{dotted-circle}** No | _(Deprecated)_ Enable merge requests for this project. Use `merge_requests_access_level` instead. |
+| `merge_trains_enabled` | boolean | **{dotted-circle}** No | Enable or disable merge trains. |
| `mirror_trigger_builds` **(PREMIUM)** | boolean | **{dotted-circle}** No | Pull mirroring triggers builds. |
| `mirror` **(PREMIUM)** | boolean | **{dotted-circle}** No | Enables pull mirroring in a project. |
| `namespace_id` | integer | **{dotted-circle}** No | Namespace for the new project (defaults to the current user's namespace). |
@@ -1406,8 +1408,10 @@ Supported attributes:
| `merge_commit_template` | string | **{dotted-circle}** No | [Template](../user/project/merge_requests/commit_templates.md) used to create merge commit message in merge requests. _([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/20263) in GitLab 14.5.)_ |
| `squash_commit_template` | string | **{dotted-circle}** No | [Template](../user/project/merge_requests/commit_templates.md) used to create squash commit message in merge requests. _([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/345275) in GitLab 14.6.)_ |
| `merge_method` | string | **{dotted-circle}** No | Set the [merge method](#project-merge-method) used. |
+| `merge_pipelines_enabled` | boolean | **{dotted-circle}** No | Enable or disable merge pipelines. |
| `merge_requests_access_level` | string | **{dotted-circle}** No | One of `disabled`, `private`, or `enabled`. |
| `merge_requests_enabled` | boolean | **{dotted-circle}** No | _(Deprecated)_ Enable merge requests for this project. Use `merge_requests_access_level` instead. |
+| `merge_trains_enabled` | boolean | **{dotted-circle}** No | Enable or disable merge trains. |
| `mirror_overwrites_diverged_branches` **(PREMIUM)** | boolean | **{dotted-circle}** No | Pull mirror overwrites diverged branches. |
| `mirror_trigger_builds` **(PREMIUM)** | boolean | **{dotted-circle}** No | Pull mirroring triggers builds. |
| `mirror_user_id` **(PREMIUM)** | integer | **{dotted-circle}** No | User responsible for all the activity surrounding a pull mirror event. _(administrators only)_ |
diff --git a/doc/development/cicd/cicd_reference_documentation_guide.md b/doc/development/cicd/cicd_reference_documentation_guide.md
index 892c9814c7f..e937220d208 100644
--- a/doc/development/cicd/cicd_reference_documentation_guide.md
+++ b/doc/development/cicd/cicd_reference_documentation_guide.md
@@ -6,7 +6,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# CI/CD YAML reference style guide **(FREE)**
-The CI/CD YAML reference uses a standard style to make it easier to use and update.
+The [CI/CD YAML reference](../../ci/yaml/index.md) uses a standard style to make it easier to use and update.
The reference information should be kept as simple as possible, and expanded details
and examples should be documented on other pages.
diff --git a/doc/user/crm/crm_contacts_v14_6.png b/doc/user/crm/crm_contacts_v14_6.png
index fef1982db4f..37a615f3926 100644
--- a/doc/user/crm/crm_contacts_v14_6.png
+++ b/doc/user/crm/crm_contacts_v14_6.png
Binary files differ
diff --git a/doc/user/crm/index.md b/doc/user/crm/index.md
index b5907af3006..d68ce0a4f7a 100644
--- a/doc/user/crm/index.md
+++ b/doc/user/crm/index.md
@@ -22,6 +22,8 @@ To read more about what is planned for the future, see [issue 2256](https://gitl
## Contacts
+### View contacts linked to a group
+
To view a group's contacts:
1. On the top bar, select **Menu > Groups** and find your group.
@@ -29,12 +31,36 @@ To view a group's contacts:
![Contacts list](crm_contacts_v14_6.png)
-You can only [create](../../api/graphql/reference/index.md#mutationcustomerrelationscontactcreate)
-or [update](../../api/graphql/reference/index.md#mutationcustomerrelationscontactupdate)
+### Create a contact
+
+To create a contact:
+
+1. On the top bar, select **Menu > Groups** and find your group.
+1. On the left sidebar, select **Customer relations > Contacts**.
+1. Select **New contact**.
+1. Complete all required fields.
+1. Select **Create new contact**.
+
+You can also [create](../../api/graphql/reference/index.md#mutationcustomerrelationscontactcreate)
+contacts using the GraphQL API.
+
+### Edit a contact
+
+To edit an existing contact:
+
+1. On the top bar, select **Menu > Groups** and find your group.
+1. On the left sidebar, select **Customer relations > Contacts**.
+1. Next to the contact you wish to edit, select **Edit** (**{pencil}**).
+1. Edit the required fields.
+1. Select **Save changes**.
+
+You can also [edit](../../api/graphql/reference/index.md#mutationcustomerrelationscontactupdate)
contacts using the GraphQL API.
## Organizations
+### View organizations
+
To view a group's organizations:
1. On the top bar, select **Menu > Groups** and find your group.
@@ -42,13 +68,43 @@ To view a group's organizations:
![Organizations list](crm_organizations_v14_6.png)
-You can only [create](../../api/graphql/reference/index.md#mutationcustomerrelationsorganizationcreate)
-or [update](../../api/graphql/reference/index.md#mutationcustomerrelationsorganizationupdate)
+### Create an organization
+
+To create an organization:
+
+1. On the top bar, select **Menu > Groups** and find your group.
+1. On the left sidebar, select **Customer relations > Organizations**.
+1. Select **New organization**.
+1. Complete all required fields.
+1. Select **Create new organization**.
+
+You can also [create](../../api/graphql/reference/index.md#mutationcustomerrelationsorganizationcreate)
+organizations using the GraphQL API.
+
+### Edit an organization
+
+You can only [edit](../../api/graphql/reference/index.md#mutationcustomerrelationsorganizationupdate)
organizations using the GraphQL API.
## Issues
-### View issue contacts
+### View issues linked to a contact
+
+To view a contact's issues:
+
+1. On the top bar, select **Menu > Groups** and find your group.
+1. On the left sidebar, select **Customer relations > Contacts**.
+1. Next to the contact whose issues you wish to view, select **View issues** (**{issues}**).
+
+### View issues linked to an organization
+
+To view an organization's issues:
+
+1. On the top bar, select **Menu > Groups** and find your group.
+1. On the left sidebar, select **Customer relations > Organizations**.
+1. Next to the organization whose issues you wish to view, select **View issues** (**{issues}**).
+
+### View contacts linked to an issue
You can view contacts associated with an issue in the right sidebar.
@@ -66,9 +122,17 @@ Prerequisites:
- You must have at least the [Developer role](../permissions.md#project-members-permissions) for a group.
+### Add contacts to an issue
+
To add contacts to an issue use the `/add_contacts`
[quick action](../project/quick_actions.md).
+You can also add, remove, or replace issue contacts using the
+[GraphQL](../../api/graphql/reference/index.md#mutationissuesetcrmcontacts)
+API.
+
+### Remove contacts from an issue
+
To remove contacts from an issue use the `/remove_contacts`
[quick action](../project/quick_actions.md).
diff --git a/lib/api/entities/ci/runner.rb b/lib/api/entities/ci/runner.rb
index 60193fe1df4..c17ff513479 100644
--- a/lib/api/entities/ci/runner.rb
+++ b/lib/api/entities/ci/runner.rb
@@ -14,7 +14,7 @@ module API
expose :online?, as: :online
# DEPRECATED
# TODO Remove in %15.0 in favor of `status` for REST calls, see https://gitlab.com/gitlab-org/gitlab/-/issues/344648
- expose :status, as: :deprecated_rest_status
+ expose :deprecated_rest_status, as: :status
end
end
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 5776f3de804..24d6319490d 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -10315,9 +10315,18 @@ msgstr ""
msgid "Crm|Create new contact"
msgstr ""
+msgid "Crm|Create organization"
+msgstr ""
+
msgid "Crm|Customer Relations Contacts"
msgstr ""
+msgid "Crm|Customer Relations Organizations"
+msgstr ""
+
+msgid "Crm|Default rate (optional)"
+msgstr ""
+
msgid "Crm|Description (optional)"
msgstr ""
@@ -10333,15 +10342,24 @@ msgstr ""
msgid "Crm|Last name"
msgstr ""
+msgid "Crm|New Organization"
+msgstr ""
+
msgid "Crm|New contact"
msgstr ""
+msgid "Crm|New organization"
+msgstr ""
+
msgid "Crm|No contacts found"
msgstr ""
msgid "Crm|No organizations found"
msgstr ""
+msgid "Crm|Organization has been added"
+msgstr ""
+
msgid "Crm|Phone number (optional)"
msgstr ""
diff --git a/spec/finders/packages/build_infos_finder_spec.rb b/spec/finders/packages/build_infos_finder_spec.rb
new file mode 100644
index 00000000000..23425de4316
--- /dev/null
+++ b/spec/finders/packages/build_infos_finder_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Packages::BuildInfosFinder do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:package) { create(:package) }
+ let_it_be(:build_infos) { create_list(:package_build_info, 5, :with_pipeline, package: package) }
+ let_it_be(:build_info_with_empty_pipeline) { create(:package_build_info, package: package) }
+
+ let(:finder) { described_class.new(package, params) }
+ let(:params) do
+ {
+ first: first,
+ last: last,
+ after: after,
+ before: before,
+ max_page_size: max_page_size,
+ support_next_page: support_next_page
+ }
+ end
+
+ describe '#execute' do
+ subject { finder.execute }
+
+ where(:first, :last, :after_index, :before_index, :max_page_size, :support_next_page, :expected_build_infos_indexes) do
+ # F L AI BI MPS SNP
+ nil | nil | nil | nil | nil | false | [4, 3, 2, 1, 0]
+ nil | nil | nil | nil | 10 | false | [4, 3, 2, 1, 0]
+ nil | nil | nil | nil | 2 | false | [4, 3]
+ 2 | nil | nil | nil | nil | false | [4, 3]
+ 2 | nil | nil | nil | nil | true | [4, 3, 2]
+ 2 | nil | 3 | nil | nil | false | [2, 1]
+ 2 | nil | 3 | nil | nil | true | [2, 1, 0]
+ 3 | nil | 4 | nil | 2 | false | [3, 2]
+ 3 | nil | 4 | nil | 2 | true | [3, 2, 1]
+ nil | 2 | nil | nil | nil | false | [0, 1]
+ nil | 2 | nil | nil | nil | true | [0, 1, 2]
+ nil | 2 | nil | 1 | nil | false | [2, 3]
+ nil | 2 | nil | 1 | nil | true | [2, 3, 4]
+ nil | 3 | nil | 0 | 2 | false | [1, 2]
+ nil | 3 | nil | 0 | 2 | true | [1, 2, 3]
+ end
+
+ with_them do
+ let(:expected_build_infos) do
+ expected_build_infos_indexes.map do |idx|
+ build_infos[idx]
+ end
+ end
+
+ let(:after) do
+ build_infos[after_index].pipeline_id if after_index
+ end
+
+ let(:before) do
+ build_infos[before_index].pipeline_id if before_index
+ end
+
+ it { is_expected.to eq(expected_build_infos) }
+ end
+ end
+end
diff --git a/spec/frontend/crm/mock_data.js b/spec/frontend/crm/mock_data.js
index 3abbc488081..f7af2ccdb72 100644
--- a/spec/frontend/crm/mock_data.js
+++ b/spec/frontend/crm/mock_data.js
@@ -134,3 +134,28 @@ export const updateContactMutationErrorResponse = {
},
},
};
+
+export const createOrganizationMutationResponse = {
+ data: {
+ customerRelationsOrganizationCreate: {
+ __typeName: 'CustomerRelationsOrganizationCreatePayload',
+ organization: {
+ __typename: 'CustomerRelationsOrganization',
+ id: 'gid://gitlab/CustomerRelations::Organization/2',
+ name: 'A',
+ defaultRate: null,
+ description: null,
+ },
+ errors: [],
+ },
+ },
+};
+
+export const createOrganizationMutationErrorResponse = {
+ data: {
+ customerRelationsOrganizationCreate: {
+ organization: null,
+ errors: ['Name cannot be blank.'],
+ },
+ },
+};
diff --git a/spec/frontend/crm/new_organization_form_spec.js b/spec/frontend/crm/new_organization_form_spec.js
new file mode 100644
index 00000000000..976b626f35f
--- /dev/null
+++ b/spec/frontend/crm/new_organization_form_spec.js
@@ -0,0 +1,109 @@
+import { GlAlert } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import NewOrganizationForm from '~/crm/components/new_organization_form.vue';
+import createOrganizationMutation from '~/crm/components/queries/create_organization.mutation.graphql';
+import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql';
+import {
+ createOrganizationMutationErrorResponse,
+ createOrganizationMutationResponse,
+ getGroupOrganizationsQueryResponse,
+} from './mock_data';
+
+describe('Customer relations organizations root app', () => {
+ Vue.use(VueApollo);
+ let wrapper;
+ let fakeApollo;
+ let queryHandler;
+
+ const findCreateNewOrganizationButton = () =>
+ wrapper.findByTestId('create-new-organization-button');
+ const findCancelButton = () => wrapper.findByTestId('cancel-button');
+ const findForm = () => wrapper.find('form');
+ const findError = () => wrapper.findComponent(GlAlert);
+
+ const mountComponent = () => {
+ fakeApollo = createMockApollo([[createOrganizationMutation, queryHandler]]);
+ fakeApollo.clients.defaultClient.cache.writeQuery({
+ query: getGroupOrganizationsQuery,
+ variables: { groupFullPath: 'flightjs' },
+ data: getGroupOrganizationsQueryResponse.data,
+ });
+ wrapper = shallowMountExtended(NewOrganizationForm, {
+ provide: { groupId: 26, groupFullPath: 'flightjs' },
+ apolloProvider: fakeApollo,
+ propsData: { drawerOpen: true },
+ });
+ };
+
+ beforeEach(() => {
+ queryHandler = jest.fn().mockResolvedValue(createOrganizationMutationResponse);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ fakeApollo = null;
+ });
+
+ describe('Create new organization button', () => {
+ it('should be disabled by default', () => {
+ mountComponent();
+
+ expect(findCreateNewOrganizationButton().attributes('disabled')).toBeTruthy();
+ });
+
+ it('should not be disabled when first, last and email have values', async () => {
+ mountComponent();
+
+ wrapper.find('#organization-name').vm.$emit('input', 'A');
+ await waitForPromises();
+
+ expect(findCreateNewOrganizationButton().attributes('disabled')).toBeFalsy();
+ });
+ });
+
+ it("should emit 'close' when cancel button is clicked", () => {
+ mountComponent();
+
+ findCancelButton().vm.$emit('click');
+
+ expect(wrapper.emitted().close).toBeTruthy();
+ });
+
+ describe('when query is successful', () => {
+ it("should emit 'close'", async () => {
+ mountComponent();
+
+ findForm().trigger('submit');
+ await waitForPromises();
+
+ expect(wrapper.emitted().close).toBeTruthy();
+ });
+ });
+
+ describe('when query fails', () => {
+ it('should show error on reject', async () => {
+ queryHandler = jest.fn().mockRejectedValue('ERROR');
+ mountComponent();
+
+ findForm().trigger('submit');
+ await waitForPromises();
+
+ expect(findError().exists()).toBe(true);
+ });
+
+ it('should show error on error response', async () => {
+ queryHandler = jest.fn().mockResolvedValue(createOrganizationMutationErrorResponse);
+ mountComponent();
+
+ findForm().trigger('submit');
+ await waitForPromises();
+
+ expect(findError().exists()).toBe(true);
+ expect(findError().text()).toBe('Name cannot be blank.');
+ });
+ });
+});
diff --git a/spec/frontend/crm/organizations_root_spec.js b/spec/frontend/crm/organizations_root_spec.js
index cfaeb8ae7e3..aef417964f4 100644
--- a/spec/frontend/crm/organizations_root_spec.js
+++ b/spec/frontend/crm/organizations_root_spec.js
@@ -1,38 +1,59 @@
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import VueRouter from 'vue-router';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import OrganizationsRoot from '~/crm/components/organizations_root.vue';
+import NewOrganizationForm from '~/crm/components/new_organization_form.vue';
+import { NEW_ROUTE_NAME } from '~/crm/constants';
+import routes from '~/crm/routes';
import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql';
import { getGroupOrganizationsQueryResponse } from './mock_data';
describe('Customer relations organizations root app', () => {
Vue.use(VueApollo);
+ Vue.use(VueRouter);
let wrapper;
let fakeApollo;
+ let router;
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName });
const findIssuesLinks = () => wrapper.findAllByTestId('issues-link');
+ const findNewOrganizationButton = () => wrapper.findByTestId('new-organization-button');
+ const findNewOrganizationForm = () => wrapper.findComponent(NewOrganizationForm);
const findError = () => wrapper.findComponent(GlAlert);
const successQueryHandler = jest.fn().mockResolvedValue(getGroupOrganizationsQueryResponse);
+ const basePath = '/groups/flightjs/-/crm/organizations';
+
const mountComponent = ({
queryHandler = successQueryHandler,
mountFunction = shallowMountExtended,
+ canAdminCrmOrganization = true,
} = {}) => {
fakeApollo = createMockApollo([[getGroupOrganizationsQuery, queryHandler]]);
wrapper = mountFunction(OrganizationsRoot, {
- provide: { groupFullPath: 'flightjs', groupIssuesPath: '/issues' },
+ router,
+ provide: { canAdminCrmOrganization, groupFullPath: 'flightjs', groupIssuesPath: '/issues' },
apolloProvider: fakeApollo,
});
};
+ beforeEach(() => {
+ router = new VueRouter({
+ base: basePath,
+ mode: 'history',
+ routes,
+ });
+ });
+
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
+ router = null;
});
it('should render loading spinner', () => {
@@ -41,6 +62,56 @@ describe('Customer relations organizations root app', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
+ describe('new organization button', () => {
+ it('should exist when user has permission', () => {
+ mountComponent();
+
+ expect(findNewOrganizationButton().exists()).toBe(true);
+ });
+
+ it('should not exist when user has no permission', () => {
+ mountComponent({ canAdminCrmOrganization: false });
+
+ expect(findNewOrganizationButton().exists()).toBe(false);
+ });
+ });
+
+ describe('new organization form', () => {
+ it('should not exist by default', async () => {
+ mountComponent();
+ await waitForPromises();
+
+ expect(findNewOrganizationForm().exists()).toBe(false);
+ });
+
+ it('should exist when user clicks new contact button', async () => {
+ mountComponent();
+
+ findNewOrganizationButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(findNewOrganizationForm().exists()).toBe(true);
+ });
+
+ it('should exist when user navigates directly to /new', async () => {
+ router.replace({ name: NEW_ROUTE_NAME });
+ mountComponent();
+ await waitForPromises();
+
+ expect(findNewOrganizationForm().exists()).toBe(true);
+ });
+
+ it('should not exist when form emits close', async () => {
+ router.replace({ name: NEW_ROUTE_NAME });
+ mountComponent();
+
+ findNewOrganizationForm().vm.$emit('close');
+ await waitForPromises();
+
+ expect(findNewOrganizationForm().exists()).toBe(false);
+ });
+ });
+
it('should render error message on reject', async () => {
mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
await waitForPromises();
@@ -48,18 +119,27 @@ describe('Customer relations organizations root app', () => {
expect(findError().exists()).toBe(true);
});
- it('renders correct results', async () => {
- mountComponent({ mountFunction: mountExtended });
- await waitForPromises();
+ describe('on successful load', () => {
+ it('should not render error', async () => {
+ mountComponent();
+ await waitForPromises();
- expect(findRowByName(/Test Inc/i)).toHaveLength(1);
- expect(findRowByName(/VIP/i)).toHaveLength(1);
- expect(findRowByName(/120/i)).toHaveLength(1);
+ expect(findError().exists()).toBe(false);
+ });
- const issueLink = findIssuesLinks().at(0);
- expect(issueLink.exists()).toBe(true);
- expect(issueLink.attributes('href')).toBe(
- '/issues?scope=all&state=opened&crm_organization_id=2',
- );
+ it('renders correct results', async () => {
+ mountComponent({ mountFunction: mountExtended });
+ await waitForPromises();
+
+ expect(findRowByName(/Test Inc/i)).toHaveLength(1);
+ expect(findRowByName(/VIP/i)).toHaveLength(1);
+ expect(findRowByName(/120/i)).toHaveLength(1);
+
+ const issueLink = findIssuesLinks().at(0);
+ expect(issueLink.exists()).toBe(true);
+ expect(issueLink.attributes('href')).toBe(
+ '/issues?scope=all&state=opened&crm_organization_id=2',
+ );
+ });
});
});
diff --git a/spec/graphql/resolvers/package_pipelines_resolver_spec.rb b/spec/graphql/resolvers/package_pipelines_resolver_spec.rb
new file mode 100644
index 00000000000..d48d4d8ae01
--- /dev/null
+++ b/spec/graphql/resolvers/package_pipelines_resolver_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::PackagePipelinesResolver do
+ include GraphqlHelpers
+
+ let_it_be_with_reload(:package) { create(:package) }
+ let_it_be(:pipelines) { create_list(:ci_pipeline, 3, project: package.project) }
+
+ let(:user) { package.project.owner }
+ let(:args) { {} }
+
+ describe '#resolve' do
+ subject { resolve(described_class, obj: package, args: args, ctx: { current_user: user }) }
+
+ before do
+ package.pipelines = pipelines
+ package.save!
+ end
+
+ it { is_expected.to contain_exactly(*pipelines) }
+
+ context 'with invalid after' do
+ let(:args) { { first: 1, after: 'not_json_string' } }
+
+ it 'raises argument error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
+ end
+ end
+
+ context 'with invalid after key' do
+ let(:args) { { first: 1, after: encode_cursor(foo: 3) } }
+
+ it 'raises argument error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
+ end
+ end
+
+ context 'with invalid before' do
+ let(:args) { { last: 1, before: 'not_json_string' } }
+
+ it 'raises argument error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
+ end
+ end
+
+ context 'with invalid before key' do
+ let(:args) { { last: 1, before: encode_cursor(foo: 3) } }
+
+ it 'raises argument error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
+ end
+ end
+
+ context 'field options' do
+ let(:field) do
+ field_options = described_class.field_options.merge(
+ owner: resolver_parent,
+ name: 'dummy_field'
+ )
+ ::Types::BaseField.new(**field_options)
+ end
+
+ it 'sets them properly' do
+ expect(field).not_to be_connection
+ expect(field.extras).to match_array([:lookahead])
+ end
+ end
+
+ context 'with unauthorized user' do
+ let_it_be(:user) { create(:user) }
+
+ it { is_expected.to be_nil }
+ end
+
+ def encode_cursor(json)
+ GitlabSchema.cursor_encoder.encode(
+ Gitlab::Json.dump(json),
+ nonce: true
+ )
+ end
+ end
+end
diff --git a/spec/graphql/types/packages/package_details_type_spec.rb b/spec/graphql/types/packages/package_details_type_spec.rb
index 7e1103d8aa0..f0b684d6b07 100644
--- a/spec/graphql/types/packages/package_details_type_spec.rb
+++ b/spec/graphql/types/packages/package_details_type_spec.rb
@@ -10,4 +10,13 @@ RSpec.describe GitlabSchema.types['PackageDetailsType'] do
expect(described_class).to include_graphql_fields(*expected_fields)
end
+
+ it 'overrides the pipelines field' do
+ field = described_class.fields['pipelines']
+
+ expect(field).to have_graphql_type(Types::Ci::PipelineType.connection_type)
+ expect(field).to have_graphql_extension(Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension)
+ expect(field).to have_graphql_resolver(Resolvers::PackagePipelinesResolver)
+ expect(field).not_to be_connection
+ end
end
diff --git a/spec/models/packages/build_info_spec.rb b/spec/models/packages/build_info_spec.rb
index a4369c56fe2..db8ac605d72 100644
--- a/spec/models/packages/build_info_spec.rb
+++ b/spec/models/packages/build_info_spec.rb
@@ -6,4 +6,46 @@ RSpec.describe Packages::BuildInfo, type: :model do
it { is_expected.to belong_to(:package) }
it { is_expected.to belong_to(:pipeline) }
end
+
+ context 'with some build infos' do
+ let_it_be(:package) { create(:package) }
+ let_it_be(:build_infos) { create_list(:package_build_info, 3, :with_pipeline, package: package) }
+ let_it_be(:build_info_with_no_pipeline) { create(:package_build_info) }
+
+ describe '.pluck_pipeline_ids' do
+ subject { package.build_infos.pluck_pipeline_ids.sort }
+
+ it { is_expected.to eq(build_infos.map(&:pipeline_id).sort) }
+ end
+
+ describe '.without_empty_pipelines' do
+ subject { package.build_infos.without_empty_pipelines }
+
+ it { is_expected.to contain_exactly(*build_infos) }
+ end
+
+ describe '.order_by_pipeline_id asc' do
+ subject { package.build_infos.order_by_pipeline_id(:asc) }
+
+ it { is_expected.to eq(build_infos) }
+ end
+
+ describe '.order_by_pipeline_id desc' do
+ subject { package.build_infos.order_by_pipeline_id(:desc) }
+
+ it { is_expected.to eq(build_infos.reverse) }
+ end
+
+ describe '.with_pipeline_id_less_than' do
+ subject { package.build_infos.with_pipeline_id_less_than(build_infos[1].pipeline_id) }
+
+ it { is_expected.to contain_exactly(build_infos[0]) }
+ end
+
+ describe '.with_pipeline_id_greater_than' do
+ subject { package.build_infos.with_pipeline_id_greater_than(build_infos[1].pipeline_id) }
+
+ it { is_expected.to contain_exactly(build_infos[2]) }
+ end
+ end
end
diff --git a/spec/requests/api/ci/runners_spec.rb b/spec/requests/api/ci/runners_spec.rb
index 20acdd892e0..6ca380a3cb9 100644
--- a/spec/requests/api/ci/runners_spec.rb
+++ b/spec/requests/api/ci/runners_spec.rb
@@ -254,6 +254,7 @@ RSpec.describe API::Ci::Runners do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['description']).to eq(shared_runner.description)
expect(json_response['maximum_timeout']).to be_nil
+ expect(json_response['status']).to eq("not_connected")
end
end
diff --git a/spec/requests/api/graphql/packages/package_spec.rb b/spec/requests/api/graphql/packages/package_spec.rb
index c75b86f85bd..a9019a7611a 100644
--- a/spec/requests/api/graphql/packages/package_spec.rb
+++ b/spec/requests/api/graphql/packages/package_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'package details' do
include GraphqlHelpers
- let_it_be(:project) { create(:project) }
+ let_it_be_with_reload(:project) { create(:project) }
let_it_be(:composer_package) { create(:composer_package, project: project) }
let_it_be(:composer_json) { { name: 'name', type: 'type', license: 'license', version: 1 } }
let_it_be(:composer_metadatum) do
@@ -97,8 +97,23 @@ RSpec.describe 'package details' do
end
end
- context 'when loading pipelines ordered by ID DESC' do
+ context 'with unauthorized user' do
+ let_it_be(:user) { create(:user) }
+
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ it 'returns no packages' do
+ subject
+
+ expect(graphql_data_at(:package)).to be_nil
+ end
+ end
+
+ context 'pipelines field', :aggregate_failures do
let(:pipelines) { create_list(:ci_pipeline, 6, project: project) }
+ let(:pipeline_gids) { pipelines.sort_by(&:id).map(&:to_gid).map(&:to_s).reverse }
before do
composer_package.pipelines = pipelines
@@ -111,6 +126,7 @@ RSpec.describe 'package details' do
id
}
pageInfo {
+ startCursor
endCursor
}
QUERY
@@ -119,20 +135,48 @@ RSpec.describe 'package details' do
post_graphql(query, current_user: user)
end
- it 'loads the second page correctly' do
- run_query({ first: 2 })
+ it 'loads the second page with pagination first correctly' do
+ run_query(first: 2)
+ pipeline_ids = graphql_data.dig('package', 'pipelines', 'nodes').pluck('id')
+
+ expect(pipeline_ids).to eq(pipeline_gids[0..1])
+
cursor = graphql_data.dig('package', 'pipelines', 'pageInfo', 'endCursor')
- run_query({ first: 2, after: cursor })
+ run_query(first: 2, after: cursor)
- expected_pipeline_ids = pipelines
- .sort_by(&:id)
- .reverse[2..3] # second page
- .map { |pipeline| pipeline.to_gid.to_s }
+ pipeline_ids = graphql_data.dig('package', 'pipelines', 'nodes').pluck('id')
+ expect(pipeline_ids).to eq(pipeline_gids[2..3])
+ end
+
+ it 'loads the second page with pagination last correctly' do
+ run_query(last: 2)
pipeline_ids = graphql_data.dig('package', 'pipelines', 'nodes').pluck('id')
- expect(pipeline_ids).to eq(expected_pipeline_ids)
+ expect(pipeline_ids).to eq(pipeline_gids[4..5])
+
+ cursor = graphql_data.dig('package', 'pipelines', 'pageInfo', 'startCursor')
+
+ run_query(last: 2, before: cursor)
+
+ pipeline_ids = graphql_data.dig('package', 'pipelines', 'nodes').pluck('id')
+
+ expect(pipeline_ids).to eq(pipeline_gids[2..3])
+ end
+
+ context 'with unauthorized user' do
+ let_it_be(:user) { create(:user) }
+
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ it 'returns no packages' do
+ run_query(first: 2)
+
+ expect(graphql_data_at(:package)).to be_nil
+ end
end
end
end
diff --git a/spec/requests/groups/crm/organizations_controller_spec.rb b/spec/requests/groups/crm/organizations_controller_spec.rb
new file mode 100644
index 00000000000..7595950350d
--- /dev/null
+++ b/spec/requests/groups/crm/organizations_controller_spec.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Groups::Crm::OrganizationsController do
+ let_it_be(:user) { create(:user) }
+
+ shared_examples 'response with 404 status' do
+ it 'returns 404' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ shared_examples 'ok response with index template' do
+ it 'renders the index template' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:index)
+ end
+ end
+
+ shared_examples 'ok response with index template if authorized' do
+ context 'private group' do
+ let(:group) { create(:group, :private) }
+
+ context 'with authorized user' do
+ before do
+ group.add_reporter(user)
+ sign_in(user)
+ end
+
+ context 'when feature flag is enabled' do
+ it_behaves_like 'ok response with index template'
+ end
+
+ context 'when feature flag is not enabled' do
+ before do
+ stub_feature_flags(customer_relations: false)
+ end
+
+ it_behaves_like 'response with 404 status'
+ end
+ end
+
+ context 'with unauthorized user' do
+ before do
+ sign_in(user)
+ end
+
+ it_behaves_like 'response with 404 status'
+ end
+
+ context 'with anonymous user' do
+ it 'blah' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(response).to redirect_to(new_user_session_path)
+ end
+ end
+ end
+
+ context 'public group' do
+ let(:group) { create(:group, :public) }
+
+ context 'with anonymous user' do
+ it_behaves_like 'ok response with index template'
+ end
+ end
+ end
+
+ describe 'GET #index' do
+ subject do
+ get group_crm_organizations_path(group)
+ response
+ end
+
+ it_behaves_like 'ok response with index template if authorized'
+ end
+
+ describe 'GET #new' do
+ subject do
+ get new_group_crm_organization_path(group)
+ end
+
+ it_behaves_like 'ok response with index template if authorized'
+ end
+end