diff --git a/app/assets/javascripts/design_management/utils/tracking.js b/app/assets/javascripts/design_management/utils/tracking.js index 39c20376271..b3ecc1453a6 100644 --- a/app/assets/javascripts/design_management/utils/tracking.js +++ b/app/assets/javascripts/design_management/utils/tracking.js @@ -1,18 +1,9 @@ import Tracking from '~/tracking'; -function assembleDesignPayload(payloadArr) { - return { - value: { - 'internal-object-refrerer': payloadArr[0], - 'design-collection-owner': payloadArr[1], - 'design-version-number': payloadArr[2], - 'design-is-current-version': payloadArr[3], - }, - }; -} - // Tracking Constants +const DESIGN_TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0'; const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design'; +const DESIGN_TRACKING_EVENT_NAME = 'view_design'; // eslint-disable-next-line import/prefer-default-export export function trackDesignDetailView( @@ -21,8 +12,16 @@ export function trackDesignDetailView( designVersion = 1, latestVersion = false, ) { - Tracking.event(DESIGN_TRACKING_PAGE_NAME, 'design_viewed', { - label: 'design_viewed', - ...assembleDesignPayload([referer, owner, designVersion, latestVersion]), + Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENT_NAME, { + label: DESIGN_TRACKING_EVENT_NAME, + context: { + schema: DESIGN_TRACKING_CONTEXT_SCHEMA, + data: { + 'design-version-number': designVersion, + 'design-is-current-version': latestVersion, + 'internal-object-referrer': referer, + 'design-collection-owner': owner, + }, + }, }); } diff --git a/app/assets/javascripts/design_management_new/utils/tracking.js b/app/assets/javascripts/design_management_new/utils/tracking.js index 39c20376271..b3ecc1453a6 100644 --- a/app/assets/javascripts/design_management_new/utils/tracking.js +++ b/app/assets/javascripts/design_management_new/utils/tracking.js @@ -1,18 +1,9 @@ import Tracking from '~/tracking'; -function assembleDesignPayload(payloadArr) { - return { - value: { - 'internal-object-refrerer': payloadArr[0], - 'design-collection-owner': payloadArr[1], - 'design-version-number': payloadArr[2], - 'design-is-current-version': payloadArr[3], - }, - }; -} - // Tracking Constants +const DESIGN_TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0'; const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design'; +const DESIGN_TRACKING_EVENT_NAME = 'view_design'; // eslint-disable-next-line import/prefer-default-export export function trackDesignDetailView( @@ -21,8 +12,16 @@ export function trackDesignDetailView( designVersion = 1, latestVersion = false, ) { - Tracking.event(DESIGN_TRACKING_PAGE_NAME, 'design_viewed', { - label: 'design_viewed', - ...assembleDesignPayload([referer, owner, designVersion, latestVersion]), + Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENT_NAME, { + label: DESIGN_TRACKING_EVENT_NAME, + context: { + schema: DESIGN_TRACKING_CONTEXT_SCHEMA, + data: { + 'design-version-number': designVersion, + 'design-is-current-version': latestVersion, + 'internal-object-referrer': referer, + 'design-collection-owner': owner, + }, + }, }); } diff --git a/app/assets/javascripts/ref/components/ref_results_section.vue b/app/assets/javascripts/ref/components/ref_results_section.vue new file mode 100644 index 00000000000..32e916052c4 --- /dev/null +++ b/app/assets/javascripts/ref/components/ref_results_section.vue @@ -0,0 +1,124 @@ + + + diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue new file mode 100644 index 00000000000..012a391a3da --- /dev/null +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -0,0 +1,186 @@ + + + diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js index 524ff2380c0..ca82b951377 100644 --- a/app/assets/javascripts/ref/constants.js +++ b/app/assets/javascripts/ref/constants.js @@ -1,4 +1,19 @@ -// This eslint-disable can be removed once a second -// value is added to this file. -/* eslint-disable import/prefer-default-export */ +import { __ } from '~/locale'; + export const X_TOTAL_HEADER = 'x-total'; + +export const SEARCH_DEBOUNCE_MS = 250; + +export const DEFAULT_I18N = Object.freeze({ + dropdownHeader: __('Select Git revision'), + searchPlaceholder: __('Search by Git revision'), + noResultsWithQuery: __('No matching results for "%{query}"'), + noResults: __('No matching results'), + branchesErrorMessage: __('An error occurred while fetching branches. Retry the search.'), + tagsErrorMessage: __('An error occurred while fetching tags. Retry the search.'), + commitsErrorMessage: __('An error occurred while fetching commits. Retry the search.'), + branches: __('Branches'), + tags: __('Tags'), + commits: __('Commits'), + noRefSelected: __('No ref selected'), +}); diff --git a/app/assets/stylesheets/components/ref_selector.scss b/app/assets/stylesheets/components/ref_selector.scss new file mode 100644 index 00000000000..970a7b967ee --- /dev/null +++ b/app/assets/stylesheets/components/ref_selector.scss @@ -0,0 +1,17 @@ +.ref-selector { + & &-dropdown-content { + // Setting a max height is necessary to allow the dropdown's content + // to control where and how scrollbars appear. + // This content is limited to the max-height of the dropdown + // ($dropdown-max-height-lg) minus the additional padding + // on the top and bottom (2 * $gl-padding-8) + max-height: $dropdown-max-height-lg - 2 * $gl-padding-8; + } + + .dropdown-menu.show { + // Make the dropdown a little wider and longer than usual + // since it contains quite a bit of content. + width: 20rem; + max-height: $dropdown-max-height-lg; + } +} diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 24bb1df6d22..101d782db3a 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -17,6 +17,9 @@ module Clusters default_value_for :version, VERSION + scope :preload_cluster_platform, -> { preload(cluster: [:platform_kubernetes]) } + scope :with_clusters_with_cilium, -> { joins(:cluster).merge(Clusters::Cluster.with_available_cilium) } + attr_encrypted :alert_manager_token, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_truncated, diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 8172e55d75c..7641b6d2a4b 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -133,6 +133,7 @@ module Clusters scope :with_enabled_modsecurity, -> { joins(:application_ingress).merge(::Clusters::Applications::Ingress.modsecurity_enabled) } scope :with_available_elasticstack, -> { joins(:application_elastic_stack).merge(::Clusters::Applications::ElasticStack.available) } + scope :with_available_cilium, -> { joins(:application_cilium).merge(::Clusters::Applications::Cilium.available) } scope :distinct_with_deployed_environments, -> { joins(:environments).merge(::Deployment.success).distinct } scope :preload_elasticstack, -> { preload(:application_elastic_stack) } scope :preload_environments, -> { preload(:environments) } diff --git a/app/models/namespace/traversal_hierarchy.rb b/app/models/namespace/traversal_hierarchy.rb new file mode 100644 index 00000000000..cfb6cfdde74 --- /dev/null +++ b/app/models/namespace/traversal_hierarchy.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true +# +# A Namespace::TraversalHierarchy is the collection of namespaces that descend +# from a root Namespace as defined by the Namespace#traversal_ids attributes. +# +# This class provides operations to be performed on the hierarchy itself, +# rather than individual namespaces. +# +# This includes methods for synchronizing traversal_ids attributes to a correct +# state. We use recursive methods to determine the correct state so we don't +# have to depend on the integrity of the traversal_ids attribute values +# themselves. +# +class Namespace + class TraversalHierarchy + attr_accessor :root + + def self.for_namespace(namespace) + new(recursive_root_ancestor(namespace)) + end + + def initialize(root) + raise StandardError.new('Must specify a root node') if root.parent_id + + @root = root + end + + # Update all traversal_ids in the current namespace hierarchy. + def sync_traversal_ids! + # An issue in Rails since 2013 prevents this kind of join based update in + # ActiveRecord. https://github.com/rails/rails/issues/13496 + # Ideally it would be: + # `incorrect_traversal_ids.update_all('traversal_ids = cte.traversal_ids')` + sql = """ + UPDATE namespaces + SET traversal_ids = cte.traversal_ids + FROM (#{recursive_traversal_ids}) as cte + WHERE namespaces.id = cte.id + AND namespaces.traversal_ids <> cte.traversal_ids + """ + Namespace.connection.exec_query(sql) + end + + # Identify all incorrect traversal_ids in the current namespace hierarchy. + def incorrect_traversal_ids + Namespace + .joins("INNER JOIN (#{recursive_traversal_ids}) as cte ON namespaces.id = cte.id") + .where('namespaces.traversal_ids <> cte.traversal_ids') + end + + private + + # Determine traversal_ids for the namespace hierarchy using recursive methods. + # Generate a collection of [id, traversal_ids] rows. + # + # Note that the traversal_ids represent a calculated traversal path for the + # namespace and not the value stored within the traversal_ids attribute. + def recursive_traversal_ids + root_id = Integer(@root.id) + + """ + WITH RECURSIVE cte(id, traversal_ids, cycle) AS ( + VALUES(#{root_id}, ARRAY[#{root_id}], false) + UNION ALL + SELECT n.id, cte.traversal_ids || n.id, n.id = ANY(cte.traversal_ids) + FROM namespaces n, cte + WHERE n.parent_id = cte.id AND NOT cycle + ) + SELECT id, traversal_ids FROM cte + """ + end + + # This is essentially Namespace#root_ancestor which will soon be rewritten + # to use traversal_ids. We replicate here as a reliable way to find the + # root using recursive methods. + def self.recursive_root_ancestor(namespace) + Gitlab::ObjectHierarchy + .new(Namespace.where(id: namespace)) + .base_and_ancestors + .reorder(nil) + .find_by(parent_id: nil) + end + end +end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 44a41969b1c..7528a02ee62 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -28,6 +28,9 @@ class PrometheusService < MonitoringService after_create_commit :create_default_alerts + scope :preload_project, -> { preload(:project) } + scope :with_clusters_with_cilium, -> { joins(project: [:clusters]).merge(Clusters::Cluster.with_available_cilium) } + def initialize_properties if properties.nil? self.properties = {} diff --git a/changelogs/unreleased/traversal-hierarchy.yml b/changelogs/unreleased/traversal-hierarchy.yml new file mode 100644 index 00000000000..3c8afee2cdc --- /dev/null +++ b/changelogs/unreleased/traversal-hierarchy.yml @@ -0,0 +1,5 @@ +--- +title: Define a namespace traversal cache +merge_request: 35713 +author: +type: performance diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index a04b1b60814..597b5626435 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -574,6 +574,12 @@ Gitlab.ee do Settings.cron_jobs['web_application_firewall_metrics_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['web_application_firewall_metrics_worker']['cron'] ||= '0 1 * * 0' Settings.cron_jobs['web_application_firewall_metrics_worker']['job_class'] = 'IngressModsecurityCounterMetricsWorker' + Settings.cron_jobs['users_create_statistics_worker'] ||= Settingslogic.new({}) + Settings.cron_jobs['users_create_statistics_worker']['cron'] ||= '2 15 * * *' + Settings.cron_jobs['users_create_statistics_worker']['job_class'] = 'Users::CreateStatisticsWorker' + Settings.cron_jobs['network_policy_metrics_worker'] ||= Settingslogic.new({}) + Settings.cron_jobs['network_policy_metrics_worker']['cron'] ||= '0 3 * * 0' + Settings.cron_jobs['network_policy_metrics_worker']['job_class'] = 'NetworkPolicyMetricsWorker' end # diff --git a/danger/roulette/Dangerfile b/danger/roulette/Dangerfile index 0ada46f8463..6c192c3a311 100644 --- a/danger/roulette/Dangerfile +++ b/danger/roulette/Dangerfile @@ -81,7 +81,7 @@ if changes.any? roulette_spins = roulette.spin(project, categories, branch_name, timezone_experiment: TIMEZONE_EXPERIMENT) rows = roulette_spins.map do |spin| # MR includes QA changes, but also other changes, and author isn't an SET - if spin.category == :qa && categories.size > 1 && !mr_author.reviewer?(project, spin.category, []) + if spin.category == :qa && categories.size > 1 && mr_author && !mr_author.reviewer?(project, spin.category, []) spin.optional_role = :maintainer end diff --git a/db/migrate/20200609012539_add_traversal_ids_to_namespaces.rb b/db/migrate/20200609012539_add_traversal_ids_to_namespaces.rb new file mode 100644 index 00000000000..d7f282b69f8 --- /dev/null +++ b/db/migrate/20200609012539_add_traversal_ids_to_namespaces.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddTraversalIdsToNamespaces < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + with_lock_retries do + add_column :namespaces, :traversal_ids, :integer, array: true, default: [], null: false + end + end + + def down + with_lock_retries do + remove_column :namespaces, :traversal_ids + end + end +end diff --git a/db/structure.sql b/db/structure.sql index 752fc2b1502..47fd702a68d 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -13134,7 +13134,8 @@ CREATE TABLE public.namespaces ( max_personal_access_token_lifetime integer, push_rule_id bigint, shared_runners_enabled boolean DEFAULT true NOT NULL, - allow_descendants_override_disabled_shared_runners boolean DEFAULT false NOT NULL + allow_descendants_override_disabled_shared_runners boolean DEFAULT false NOT NULL, + traversal_ids integer[] DEFAULT '{}'::integer[] NOT NULL ); CREATE SEQUENCE public.namespaces_id_seq @@ -23658,6 +23659,7 @@ COPY "schema_migrations" (version) FROM STDIN; 20200608075553 20200608214008 20200609002841 +20200609012539 20200609142506 20200609142507 20200609142508 diff --git a/doc/administration/auth/smartcard.md b/doc/administration/auth/smartcard.md index 9ad1e0641f6..80d2efbad84 100644 --- a/doc/administration/auth/smartcard.md +++ b/doc/administration/auth/smartcard.md @@ -208,7 +208,7 @@ attribute. As a prerequisite, you must use an LDAP server that: client_certificate_required_port: 3443 ``` - NOTE: **Note** + NOTE: **Note:** Assign a value to at least one of the following variables: `client_certificate_required_host` or `client_certificate_required_port`. diff --git a/doc/administration/geo/disaster_recovery/index.md b/doc/administration/geo/disaster_recovery/index.md index 4b42d739d7c..2d837ebb369 100644 --- a/doc/administration/geo/disaster_recovery/index.md +++ b/doc/administration/geo/disaster_recovery/index.md @@ -274,7 +274,7 @@ secondary domain, like changing Git remotes and API URLs. external_url 'https://' ``` - NOTE: **Note** + NOTE: **Note:** Changing `external_url` won't prevent access via the old secondary URL, as long as the secondary DNS records are still intact. diff --git a/doc/administration/geo/replication/location_aware_git_url.md b/doc/administration/geo/replication/location_aware_git_url.md index 49c83ee1718..8b086e3ff5f 100644 --- a/doc/administration/geo/replication/location_aware_git_url.md +++ b/doc/administration/geo/replication/location_aware_git_url.md @@ -18,7 +18,7 @@ Though these instructions use [AWS Route53](https://aws.amazon.com/route53/), other services such as [Cloudflare](https://www.cloudflare.com/) could be used as well. -NOTE: **Note** +NOTE: **Note:** You can also use a load balancer to distribute web UI or API traffic to [multiple Geo **secondary** nodes](../../../user/admin_area/geo_nodes.md#multiple-secondary-nodes-behind-a-load-balancer). Importantly, the **primary** node cannot yet be included. See the feature request diff --git a/doc/administration/gitaly/praefect.md b/doc/administration/gitaly/praefect.md index a718de85b54..7aed1695017 100644 --- a/doc/administration/gitaly/praefect.md +++ b/doc/administration/gitaly/praefect.md @@ -966,7 +966,7 @@ Virtual storage: default Currently `dataloss` only considers a repository up to date if it has been directly replicated to from the previous write-enabled primary. While reconciling from an up to date secondary can recover the data, this is not visible in the data loss report. This is due for improvement via [Gitaly#2866](https://gitlab.com/gitlab-org/gitaly/-/issues/2866). -NOTE: **NOTE** `dataloss` is still in beta and the output format is subject to change. +NOTE: **Note:** `dataloss` is still in beta and the output format is subject to change. ### Checking repository checksums diff --git a/doc/administration/job_artifacts.md b/doc/administration/job_artifacts.md index d85e43641c4..23fd424841e 100644 --- a/doc/administration/job_artifacts.md +++ b/doc/administration/job_artifacts.md @@ -106,7 +106,7 @@ If you configure GitLab to store CI logs and artifacts on object storage, you mu #### Object Storage Settings -NOTE: **Note** In GitLab 13.2 and later, we recommend using the +NOTE: **Note:** In GitLab 13.2 and later, we recommend using the [consolidated object storage settings](object_storage.md#consolidated-object-storage-configuration). This section describes the earlier configuration format. diff --git a/doc/administration/lfs/index.md b/doc/administration/lfs/index.md index f694be6aef5..460a1e1a5c7 100644 --- a/doc/administration/lfs/index.md +++ b/doc/administration/lfs/index.md @@ -63,7 +63,7 @@ GitLab provides two different options for the uploading mechanism: "Direct uploa [Read more about using object storage with GitLab](../object_storage.md). -NOTE: **Note** In GitLab 13.2 and later, we recommend using the +NOTE: **Note:** In GitLab 13.2 and later, we recommend using the [consolidated object storage settings](../object_storage.md#consolidated-object-storage-configuration). This section describes the earlier configuration format. diff --git a/doc/administration/merge_request_diffs.md b/doc/administration/merge_request_diffs.md index 3c0311018d1..3c4e239d137 100644 --- a/doc/administration/merge_request_diffs.md +++ b/doc/administration/merge_request_diffs.md @@ -72,7 +72,7 @@ be configured already. ## Object Storage Settings -NOTE: **Note** In GitLab 13.2 and later, we recommend using the +NOTE: **Note:** In GitLab 13.2 and later, we recommend using the [consolidated object storage settings](object_storage.md#consolidated-object-storage-configuration). This section describes the earlier configuration format. diff --git a/doc/administration/object_storage.md b/doc/administration/object_storage.md index 0345d188180..0ce2d0b3719 100644 --- a/doc/administration/object_storage.md +++ b/doc/administration/object_storage.md @@ -253,7 +253,7 @@ gitlab_rails['object_store']['connection'] = { #### OpenStack-compatible connection settings -NOTE: **Note** This is not compatible with the consolidated object storage form. +NOTE: **Note:** This is not compatible with the consolidated object storage form. OpenStack Swift is only supported with the storage-specific form. See the [S3 settings](#s3-compatible-connection-settings) if you want to use the consolidated form. @@ -274,7 +274,7 @@ Here are the valid connection settings below for the Swift API, provided by #### Rackspace Cloud Files -NOTE: **Note** This is not compatible with the consolidated object +NOTE: **Note:** This is not compatible with the consolidated object storage form. Rackspace Cloud is only supported with the storage-specific form. Here are the valid connection parameters for Rackspace Cloud, provided by @@ -408,7 +408,7 @@ additional complexity and unnecessary redundancy. Since both GitLab Rails and Workhorse components need access to object storage, the consolidated form avoids excessive duplication of credentials. -NOTE: **Note** The consolidated object storage configuration is **only** used if all +NOTE: **Note:** The consolidated object storage configuration is **only** used if all lines from the original form is omitted. To move to the consolidated form, remove the original configuration (for example, `artifacts_object_store_enabled`, `uploads_object_store_connection`, and so on.) ## Storage-specific configuration diff --git a/doc/administration/packages/dependency_proxy.md b/doc/administration/packages/dependency_proxy.md index 1f7112704df..dc76f4f7869 100644 --- a/doc/administration/packages/dependency_proxy.md +++ b/doc/administration/packages/dependency_proxy.md @@ -87,7 +87,7 @@ store the blobs of the dependency proxy. [Read more about using object storage with GitLab](../object_storage.md). -NOTE: **Note** In GitLab 13.2 and later, we recommend using the +NOTE: **Note:** In GitLab 13.2 and later, we recommend using the [consolidated object storage settings](../object_storage.md#consolidated-object-storage-configuration). This section describes the earlier configuration format. diff --git a/doc/administration/packages/index.md b/doc/administration/packages/index.md index 86444c7b73f..2428e5b90e2 100644 --- a/doc/administration/packages/index.md +++ b/doc/administration/packages/index.md @@ -99,7 +99,7 @@ store packages. [Read more about using object storage with GitLab](../object_storage.md). -NOTE: **Note** We recommend using the [consolidated object storage settings](../object_storage.md#consolidated-object-storage-configuration). The following instructions apply to the original config format. +NOTE: **Note:** We recommend using the [consolidated object storage settings](../object_storage.md#consolidated-object-storage-configuration). The following instructions apply to the original config format. **Omnibus GitLab installations** diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index f3659f9269f..b7c5b631cee 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -5099,6 +5099,11 @@ type Group { """ iid: ID + """ + Whether to include ancestor Iterations. Defaults to true + """ + includeAncestors: Boolean + """ Returns the last _n_ elements from the list. """ diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 96097e3f5ac..131c387c767 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -14110,6 +14110,16 @@ }, "defaultValue": null }, + { + "name": "includeAncestors", + "description": "Whether to include ancestor Iterations. Defaults to true", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null + }, { "name": "after", "description": "Returns the elements in the list that come after the specified cursor.", diff --git a/doc/development/telemetry/usage_ping.md b/doc/development/telemetry/usage_ping.md index d0a8ec3614e..ad2d4ca07f2 100644 --- a/doc/development/telemetry/usage_ping.md +++ b/doc/development/telemetry/usage_ping.md @@ -730,6 +730,8 @@ appear to be associated to any of the services running, since they all appear to | `process_memory_uss` | `topology > nodes > node_services` | `enablement` | | | The average Unique Set Size of a service process | | `process_memory_pss` | `topology > nodes > node_services` | `enablement` | | | The average Proportional Set Size of a service process | | `server` | `topology > nodes > node_services` | `enablement` | | | The type of web server used (Unicorn or Puma) | +| `network_policy_forwards` | `counts` | `defend` | | EE | Cumulative count of forwarded packets by Container Network | +| `network_policy_drops` | `counts` | `defend` | | EE | Cumulative count of dropped packets by Container Network | ## Example Usage Ping payload diff --git a/doc/integration/elasticsearch.md b/doc/integration/elasticsearch.md index bd7230c2f68..48f4472868d 100644 --- a/doc/integration/elasticsearch.md +++ b/doc/integration/elasticsearch.md @@ -170,7 +170,7 @@ You can filter the selection dropdown by writing part of the namespace or projec ![limit namespace filter](img/limit_namespace_filter.png) -NOTE: **Note**: +NOTE: **Note:** If no namespaces or projects are selected, no Elasticsearch indexing will take place. CAUTION: **Warning**: diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9b9a1c8a0c5..b479388b545 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2480,6 +2480,12 @@ msgstr "" msgid "An error occurred while enabling Service Desk." msgstr "" +msgid "An error occurred while fetching branches. Retry the search." +msgstr "" + +msgid "An error occurred while fetching commits. Retry the search." +msgstr "" + msgid "An error occurred while fetching coverage reports." msgstr "" @@ -2510,6 +2516,9 @@ msgstr "" msgid "An error occurred while fetching sidebar data" msgstr "" +msgid "An error occurred while fetching tags. Retry the search." +msgstr "" + msgid "An error occurred while fetching terraform reports." msgstr "" @@ -7458,6 +7467,9 @@ msgstr "" msgid "Default: Map a FogBugz account ID to a full name" msgstr "" +msgid "DefaultBranchLabel|default" +msgstr "" + msgid "Define a custom pattern with cron syntax" msgstr "" @@ -13628,6 +13640,11 @@ msgstr "" msgid "LicenseCompliance|License Approvals" msgstr "" +msgid "LicenseCompliance|License Compliance detected %d license and policy violation for the source branch only" +msgid_plural "LicenseCompliance|License Compliance detected %d licenses and policy violations for the source branch only" +msgstr[0] "" +msgstr[1] "" + msgid "LicenseCompliance|License Compliance detected %d license and policy violation for the source branch only; approval required" msgid_plural "LicenseCompliance|License Compliance detected %d licenses and policy violations for the source branch only; approval required" msgstr[0] "" @@ -13643,6 +13660,11 @@ msgid_plural "LicenseCompliance|License Compliance detected %d new licenses" msgstr[0] "" msgstr[1] "" +msgid "LicenseCompliance|License Compliance detected %d new license and policy violation" +msgid_plural "LicenseCompliance|License Compliance detected %d new licenses and policy violations" +msgstr[0] "" +msgstr[1] "" + msgid "LicenseCompliance|License Compliance detected %d new license and policy violation; approval required" msgid_plural "LicenseCompliance|License Compliance detected %d new licenses and policy violations; approval required" msgstr[0] "" @@ -15618,6 +15640,9 @@ msgstr "" msgid "No matching results" msgstr "" +msgid "No matching results for \"%{query}\"" +msgstr "" + msgid "No merge requests found" msgstr "" @@ -15654,6 +15679,9 @@ msgstr "" msgid "No public groups" msgstr "" +msgid "No ref selected" +msgstr "" + msgid "No related merge requests found." msgstr "" @@ -20270,6 +20298,9 @@ msgstr "" msgid "Search branches and tags" msgstr "" +msgid "Search by Git revision" +msgstr "" + msgid "Search by author" msgstr "" @@ -24652,6 +24683,9 @@ msgstr "" msgid "Total: %{total}" msgstr "" +msgid "TotalRefCountIndicator|1000+" +msgstr "" + msgid "Trace" msgstr "" @@ -28149,6 +28183,9 @@ msgstr "" msgid "mrWidget|You can merge this merge request manually using the" msgstr "" +msgid "mrWidget|You can only merge once the denied license is removed" +msgstr "" + msgid "mrWidget|Your password" msgstr "" diff --git a/spec/factories/namespaces.rb b/spec/factories/namespaces.rb index 09dbe16ef9e..f4d5848e878 100644 --- a/spec/factories/namespaces.rb +++ b/spec/factories/namespaces.rb @@ -29,5 +29,35 @@ FactoryBot.define do trait :with_root_storage_statistics do association :root_storage_statistics, factory: :namespace_root_storage_statistics end + + # Construct a hierarchy underneath the namespace. + # Each namespace will have `children` amount of children, + # and `depth` levels of descendants. + trait :with_hierarchy do + transient do + children { 4 } + depth { 4 } + end + + after(:create) do |namespace, evaluator| + def create_graph(parent: nil, children: 4, depth: 4) + return unless depth > 1 + + children.times do + factory_name = parent.model_name.singular + child = FactoryBot.create(factory_name, parent: parent) + create_graph(parent: child, children: children, depth: depth - 1) + end + + parent + end + + create_graph( + parent: namespace, + children: evaluator.children, + depth: evaluator.depth + ) + end + end end end diff --git a/spec/frontend/design_management/utils/tracking_spec.js b/spec/frontend/design_management/utils/tracking_spec.js index 9fa5eae55b3..0549fb44956 100644 --- a/spec/frontend/design_management/utils/tracking_spec.js +++ b/spec/frontend/design_management/utils/tracking_spec.js @@ -8,7 +8,7 @@ function getTrackingSpy(key) { describe('Tracking Events', () => { describe('trackDesignDetailView', () => { const eventKey = 'projects:issues:design'; - const eventName = 'design_viewed'; + const eventName = 'view_design'; it('trackDesignDetailView fires a tracking event when called', () => { const trackingSpy = getTrackingSpy(eventKey); @@ -20,11 +20,14 @@ describe('Tracking Events', () => { eventName, expect.objectContaining({ label: eventName, - value: { - 'internal-object-refrerer': '', - 'design-collection-owner': '', - 'design-version-number': 1, - 'design-is-current-version': false, + context: { + schema: expect.any(String), + data: { + 'design-version-number': 1, + 'design-is-current-version': false, + 'internal-object-referrer': '', + 'design-collection-owner': '', + }, }, }), ); @@ -40,11 +43,14 @@ describe('Tracking Events', () => { eventName, expect.objectContaining({ label: eventName, - value: { - 'internal-object-refrerer': 'from-a-test', - 'design-collection-owner': 'test', - 'design-version-number': 100, - 'design-is-current-version': true, + context: { + schema: expect.any(String), + data: { + 'design-version-number': 100, + 'design-is-current-version': true, + 'internal-object-referrer': 'from-a-test', + 'design-collection-owner': 'test', + }, }, }), ); diff --git a/spec/frontend/design_management_new/utils/tracking_spec.js b/spec/frontend/design_management_new/utils/tracking_spec.js index 073cc0df255..ac7267642cb 100644 --- a/spec/frontend/design_management_new/utils/tracking_spec.js +++ b/spec/frontend/design_management_new/utils/tracking_spec.js @@ -8,7 +8,7 @@ function getTrackingSpy(key) { describe('Tracking Events', () => { describe('trackDesignDetailView', () => { const eventKey = 'projects:issues:design'; - const eventName = 'design_viewed'; + const eventName = 'view_design'; it('trackDesignDetailView fires a tracking event when called', () => { const trackingSpy = getTrackingSpy(eventKey); @@ -20,11 +20,14 @@ describe('Tracking Events', () => { eventName, expect.objectContaining({ label: eventName, - value: { - 'internal-object-refrerer': '', - 'design-collection-owner': '', - 'design-version-number': 1, - 'design-is-current-version': false, + context: { + schema: expect.any(String), + data: { + 'design-version-number': 1, + 'design-is-current-version': false, + 'internal-object-referrer': '', + 'design-collection-owner': '', + }, }, }), ); @@ -40,11 +43,14 @@ describe('Tracking Events', () => { eventName, expect.objectContaining({ label: eventName, - value: { - 'internal-object-refrerer': 'from-a-test', - 'design-collection-owner': 'test', - 'design-version-number': 100, - 'design-is-current-version': true, + context: { + schema: expect.any(String), + data: { + 'design-version-number': 100, + 'design-is-current-version': true, + 'internal-object-referrer': 'from-a-test', + 'design-collection-owner': 'test', + }, }, }), ); diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js new file mode 100644 index 00000000000..2688e4b3428 --- /dev/null +++ b/spec/frontend/ref/components/ref_selector_spec.js @@ -0,0 +1,532 @@ +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { GlLoadingIcon, GlSearchBoxByType, GlNewDropdownItem, GlIcon } from '@gitlab/ui'; +import { trimText } from 'helpers/text_helper'; +import { sprintf } from '~/locale'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import { X_TOTAL_HEADER, DEFAULT_I18N } from '~/ref/constants'; +import createStore from '~/ref/stores/'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Ref selector component', () => { + const fixtures = { + branches: getJSONFixture('api/branches/branches.json'), + tags: getJSONFixture('api/tags/tags.json'), + commit: getJSONFixture('api/commits/commit.json'), + }; + + const projectId = '8'; + + let wrapper; + let branchesApiCallSpy; + let tagsApiCallSpy; + let commitApiCallSpy; + + const createComponent = () => { + wrapper = mount(RefSelector, { + propsData: { + projectId, + value: '', + }, + listeners: { + // simulate a parent component v-model binding + input: selectedRef => { + wrapper.setProps({ value: selectedRef }); + }, + }, + stubs: { + GlSearchBoxByType: true, + }, + localVue, + store: createStore(), + }); + }; + + beforeEach(() => { + const mock = new MockAdapter(axios); + gon.api_version = 'v4'; + + branchesApiCallSpy = jest + .fn() + .mockReturnValue([200, fixtures.branches, { [X_TOTAL_HEADER]: '123' }]); + tagsApiCallSpy = jest.fn().mockReturnValue([200, fixtures.tags, { [X_TOTAL_HEADER]: '456' }]); + commitApiCallSpy = jest.fn().mockReturnValue([200, fixtures.commit]); + + mock + .onGet(`/api/v4/projects/${projectId}/repository/branches`) + .reply(config => branchesApiCallSpy(config)); + mock + .onGet(`/api/v4/projects/${projectId}/repository/tags`) + .reply(config => tagsApiCallSpy(config)); + mock + .onGet(new RegExp(`/api/v4/projects/${projectId}/repository/commits/.*`)) + .reply(config => commitApiCallSpy(config)); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + // + // Finders + // + const findButtonContent = () => wrapper.find('[data-testid="button-content"]'); + + const findNoResults = () => wrapper.find('[data-testid="no-results"]'); + + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + + const findBranchesSection = () => wrapper.find('[data-testid="branches-section"]'); + const findBranchDropdownItems = () => findBranchesSection().findAll(GlNewDropdownItem); + const findFirstBranchDropdownItem = () => findBranchDropdownItems().at(0); + + const findTagsSection = () => wrapper.find('[data-testid="tags-section"]'); + const findTagDropdownItems = () => findTagsSection().findAll(GlNewDropdownItem); + const findFirstTagDropdownItem = () => findTagDropdownItems().at(0); + + const findCommitsSection = () => wrapper.find('[data-testid="commits-section"]'); + const findCommitDropdownItems = () => findCommitsSection().findAll(GlNewDropdownItem); + const findFirstCommitDropdownItem = () => findCommitDropdownItems().at(0); + + // + // Expecters + // + const branchesSectionContainsErrorMessage = () => { + const branchesSection = findBranchesSection(); + + return branchesSection.text().includes(DEFAULT_I18N.branchesErrorMessage); + }; + + const tagsSectionContainsErrorMessage = () => { + const tagsSection = findTagsSection(); + + return tagsSection.text().includes(DEFAULT_I18N.tagsErrorMessage); + }; + + const commitsSectionContainsErrorMessage = () => { + const commitsSection = findCommitsSection(); + + return commitsSection.text().includes(DEFAULT_I18N.commitsErrorMessage); + }; + + // + // Convenience methods + // + const updateQuery = newQuery => { + wrapper.find(GlSearchBoxByType).vm.$emit('input', newQuery); + }; + + const selectFirstBranch = () => { + findFirstBranchDropdownItem().vm.$emit('click'); + }; + + const selectFirstTag = () => { + findFirstTagDropdownItem().vm.$emit('click'); + }; + + const selectFirstCommit = () => { + findFirstCommitDropdownItem().vm.$emit('click'); + }; + + const waitForRequests = ({ andClearMocks } = { andClearMocks: false }) => + axios.waitForAll().then(() => { + if (andClearMocks) { + branchesApiCallSpy.mockClear(); + tagsApiCallSpy.mockClear(); + commitApiCallSpy.mockClear(); + } + }); + + describe('initialization behavior', () => { + beforeEach(createComponent); + + it('initializes the dropdown with branches and tags when mounted', () => { + return waitForRequests().then(() => { + expect(branchesApiCallSpy).toHaveBeenCalledTimes(1); + expect(tagsApiCallSpy).toHaveBeenCalledTimes(1); + expect(commitApiCallSpy).not.toHaveBeenCalled(); + }); + }); + + it('shows a spinner while network requests are in progress', () => { + expect(findLoadingIcon().exists()).toBe(true); + + return waitForRequests().then(() => { + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + }); + + describe('post-initialization behavior', () => { + describe('when the search query is updated', () => { + beforeEach(() => { + createComponent(); + + return waitForRequests({ andClearMocks: true }); + }); + + it('requeries the endpoints when the search query is updated', () => { + updateQuery('v1.2.3'); + + return waitForRequests().then(() => { + expect(branchesApiCallSpy).toHaveBeenCalledTimes(1); + expect(tagsApiCallSpy).toHaveBeenCalledTimes(1); + }); + }); + + it("does not make a call to the commit endpoint if the query doesn't look like a SHA", () => { + updateQuery('not a sha'); + + return waitForRequests().then(() => { + expect(commitApiCallSpy).not.toHaveBeenCalled(); + }); + }); + + it('searches for a commit if the query could potentially be a SHA', () => { + updateQuery('abcdef'); + + return waitForRequests().then(() => { + expect(commitApiCallSpy).toHaveBeenCalled(); + }); + }); + }); + + describe('when no results are found', () => { + beforeEach(() => { + branchesApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]); + tagsApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]); + commitApiCallSpy = jest.fn().mockReturnValue([404]); + + createComponent(); + + return waitForRequests(); + }); + + describe('when the search query is empty', () => { + it('renders a "no results" message', () => { + expect(findNoResults().text()).toBe(DEFAULT_I18N.noResults); + }); + }); + + describe('when the search query is not empty', () => { + const query = 'hello'; + + beforeEach(() => { + updateQuery(query); + + return waitForRequests(); + }); + + it('renders a "no results" message that includes the search query', () => { + expect(findNoResults().text()).toBe(sprintf(DEFAULT_I18N.noResultsWithQuery, { query })); + }); + }); + }); + + describe('branches', () => { + describe('when the branches search returns results', () => { + beforeEach(() => { + createComponent(); + + return waitForRequests(); + }); + + it('renders the branches section in the dropdown', () => { + expect(findBranchesSection().exists()).toBe(true); + }); + + it('renders the "Branches" heading with a total number indicator', () => { + expect( + findBranchesSection() + .find('[data-testid="section-header"]') + .text(), + ).toBe('Branches 123'); + }); + + it("does not render an error message in the branches section's body", () => { + expect(branchesSectionContainsErrorMessage()).toBe(false); + }); + + it('renders each non-default branch as a selectable item', () => { + const dropdownItems = findBranchDropdownItems(); + + fixtures.branches.forEach((b, i) => { + if (!b.default) { + expect(dropdownItems.at(i).text()).toBe(b.name); + } + }); + }); + + it('renders the default branch as a selectable item with a "default" badge', () => { + const dropdownItems = findBranchDropdownItems(); + + const defaultBranch = fixtures.branches.find(b => b.default); + const defaultBranchIndex = fixtures.branches.indexOf(defaultBranch); + + expect(trimText(dropdownItems.at(defaultBranchIndex).text())).toBe( + `${defaultBranch.name} default`, + ); + }); + }); + + describe('when the branches search returns no results', () => { + beforeEach(() => { + branchesApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]); + + createComponent(); + + return waitForRequests(); + }); + + it('does not render the branches section in the dropdown', () => { + expect(findBranchesSection().exists()).toBe(false); + }); + }); + + describe('when the branches search returns an error', () => { + beforeEach(() => { + branchesApiCallSpy = jest.fn().mockReturnValue([500]); + + createComponent(); + + return waitForRequests(); + }); + + it('renders the branches section in the dropdown', () => { + expect(findBranchesSection().exists()).toBe(true); + }); + + it("renders an error message in the branches section's body", () => { + expect(branchesSectionContainsErrorMessage()).toBe(true); + }); + }); + }); + + describe('tags', () => { + describe('when the tags search returns results', () => { + beforeEach(() => { + createComponent(); + + return waitForRequests(); + }); + + it('renders the tags section in the dropdown', () => { + expect(findTagsSection().exists()).toBe(true); + }); + + it('renders the "Tags" heading with a total number indicator', () => { + expect( + findTagsSection() + .find('[data-testid="section-header"]') + .text(), + ).toBe('Tags 456'); + }); + + it("does not render an error message in the tags section's body", () => { + expect(tagsSectionContainsErrorMessage()).toBe(false); + }); + + it('renders each tag as a selectable item', () => { + const dropdownItems = findTagDropdownItems(); + + fixtures.tags.forEach((t, i) => { + expect(dropdownItems.at(i).text()).toBe(t.name); + }); + }); + }); + + describe('when the tags search returns no results', () => { + beforeEach(() => { + tagsApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]); + + createComponent(); + + return waitForRequests(); + }); + + it('does not render the tags section in the dropdown', () => { + expect(findTagsSection().exists()).toBe(false); + }); + }); + + describe('when the tags search returns an error', () => { + beforeEach(() => { + tagsApiCallSpy = jest.fn().mockReturnValue([500]); + + createComponent(); + + return waitForRequests(); + }); + + it('renders the tags section in the dropdown', () => { + expect(findTagsSection().exists()).toBe(true); + }); + + it("renders an error message in the tags section's body", () => { + expect(tagsSectionContainsErrorMessage()).toBe(true); + }); + }); + }); + + describe('commits', () => { + describe('when the commit search returns results', () => { + beforeEach(() => { + createComponent(); + + updateQuery('abcd1234'); + + return waitForRequests(); + }); + + it('renders the commit section in the dropdown', () => { + expect(findCommitsSection().exists()).toBe(true); + }); + + it('renders the "Commits" heading with a total number indicator', () => { + expect( + findCommitsSection() + .find('[data-testid="section-header"]') + .text(), + ).toBe('Commits 1'); + }); + + it("does not render an error message in the comits section's body", () => { + expect(commitsSectionContainsErrorMessage()).toBe(false); + }); + + it('renders each commit as a selectable item with the short SHA and commit title', () => { + const dropdownItems = findCommitDropdownItems(); + + const { commit } = fixtures; + + expect(dropdownItems.at(0).text()).toBe(`${commit.short_id} ${commit.title}`); + }); + }); + + describe('when the commit search returns no results (i.e. a 404)', () => { + beforeEach(() => { + commitApiCallSpy = jest.fn().mockReturnValue([404]); + + createComponent(); + + updateQuery('abcd1234'); + + return waitForRequests(); + }); + + it('does not render the commits section in the dropdown', () => { + expect(findCommitsSection().exists()).toBe(false); + }); + }); + + describe('when the commit search returns an error (other than a 404)', () => { + beforeEach(() => { + commitApiCallSpy = jest.fn().mockReturnValue([500]); + + createComponent(); + + updateQuery('abcd1234'); + + return waitForRequests(); + }); + + it('renders the commits section in the dropdown', () => { + expect(findCommitsSection().exists()).toBe(true); + }); + + it("renders an error message in the commits section's body", () => { + expect(commitsSectionContainsErrorMessage()).toBe(true); + }); + }); + }); + + describe('selection', () => { + beforeEach(() => { + createComponent(); + + updateQuery(fixtures.commit.short_id); + + return waitForRequests(); + }); + + it('renders a checkmark by the selected item', () => { + expect(findFirstBranchDropdownItem().find(GlIcon).element).toHaveClass( + 'gl-visibility-hidden', + ); + + selectFirstBranch(); + + return localVue.nextTick().then(() => { + expect(findFirstBranchDropdownItem().find(GlIcon).element).not.toHaveClass( + 'gl-visibility-hidden', + ); + }); + }); + + describe('when a branch is seleceted', () => { + it("displays the branch name in the dropdown's button", () => { + expect(findButtonContent().text()).toBe(DEFAULT_I18N.noRefSelected); + + selectFirstBranch(); + + return localVue.nextTick().then(() => { + expect(findButtonContent().text()).toBe(fixtures.branches[0].name); + }); + }); + + it("updates the v-model binding with the branch's name", () => { + expect(wrapper.vm.value).toEqual(''); + + selectFirstBranch(); + + expect(wrapper.vm.value).toEqual(fixtures.branches[0].name); + }); + }); + + describe('when a tag is seleceted', () => { + it("displays the tag name in the dropdown's button", () => { + expect(findButtonContent().text()).toBe(DEFAULT_I18N.noRefSelected); + + selectFirstTag(); + + return localVue.nextTick().then(() => { + expect(findButtonContent().text()).toBe(fixtures.tags[0].name); + }); + }); + + it("updates the v-model binding with the tag's name", () => { + expect(wrapper.vm.value).toEqual(''); + + selectFirstTag(); + + expect(wrapper.vm.value).toEqual(fixtures.tags[0].name); + }); + }); + + describe('when a commit is selected', () => { + it("displays the full SHA in the dropdown's button", () => { + expect(findButtonContent().text()).toBe(DEFAULT_I18N.noRefSelected); + + selectFirstCommit(); + + return localVue.nextTick().then(() => { + expect(findButtonContent().text()).toBe(fixtures.commit.id); + }); + }); + + it("updates the v-model binding with the commit's full SHA", () => { + expect(wrapper.vm.value).toEqual(''); + + selectFirstCommit(); + + expect(wrapper.vm.value).toEqual(fixtures.commit.id); + }); + }); + }); + }); +}); diff --git a/spec/models/namespace/traversal_hierarchy_spec.rb b/spec/models/namespace/traversal_hierarchy_spec.rb new file mode 100644 index 00000000000..71b0e974106 --- /dev/null +++ b/spec/models/namespace/traversal_hierarchy_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Namespace::TraversalHierarchy, type: :model do + let_it_be(:root, reload: true) { create(:namespace, :with_hierarchy) } + + describe '.for_namespace' do + let(:hierarchy) { described_class.for_namespace(namespace) } + + context 'with root group' do + let(:namespace) { root } + + it { expect(hierarchy.root).to eq root } + end + + context 'with child group' do + let(:namespace) { root.children.first.children.first } + + it { expect(hierarchy.root).to eq root } + end + + context 'with group outside of hierarchy' do + let(:namespace) { create(:namespace) } + + it { expect(hierarchy.root).not_to eq root } + end + end + + describe '.new' do + let(:hierarchy) { described_class.new(namespace) } + + context 'with root group' do + let(:namespace) { root } + + it { expect(hierarchy.root).to eq root } + end + + context 'with child group' do + let(:namespace) { root.children.first } + + it { expect { hierarchy }.to raise_error(StandardError, 'Must specify a root node') } + end + end + + describe '#incorrect_traversal_ids' do + subject { described_class.new(root).incorrect_traversal_ids } + + it { is_expected.to match_array Namespace.all } + end + + describe '#sync_traversal_ids!' do + let(:hierarchy) { described_class.new(root) } + + before do + hierarchy.sync_traversal_ids! + root.reload + end + + it_behaves_like 'hierarchy with traversal_ids' + it { expect(hierarchy.incorrect_traversal_ids).to be_empty } + end +end diff --git a/spec/support/shared_examples/namespaces/hierarchy_examples.rb b/spec/support/shared_examples/namespaces/hierarchy_examples.rb new file mode 100644 index 00000000000..d5754f47be2 --- /dev/null +++ b/spec/support/shared_examples/namespaces/hierarchy_examples.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'hierarchy with traversal_ids' do + # A convenient null node to represent the parent of root. + let(:null_node) { double(traversal_ids: []) } + + # Walk the tree to assert that the current_node's traversal_id is always + # present and equal to it's parent's traversal_ids plus it's own ID. + def validate_traversal_ids(current_node, parent = null_node) + expect(current_node.traversal_ids).to be_present + expect(current_node.traversal_ids).to eq parent.traversal_ids + [current_node.id] + + current_node.children.each do |child| + validate_traversal_ids(child, current_node) + end + end + + it 'will be valid' do + validate_traversal_ids(root) + end +end